feat(workspace): add real-time live cursor and presence tracking
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
- Support ephemeral cursor_moved and client_left events in docroom Hub - Keep active in-memory cursor presence map per document room - Synchronize current presence state to newly connected WebSocket clients - Allow read-only viewers to share cursor positions without document mutation rights - Throttle frontend cursor move emissions to 100ms with instant blur cleanup - Render active collaborator presence avatar pills in document header - Display user-colored block indicator and non-destructive caret overlay with name tag
This commit is contained in:
1 parent
85e4010435
commit
95977ba15a
7 files changed
+643
-68
No files matched your search
+147
-26
@@ -11,6 +11,18 @@ import (
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// CursorPresence represents an active user's ephemeral cursor position in a document room.
|
||||
type CursorPresence struct {
|
||||
ClientID string `json:"clientId"`
|
||||
UserID string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
UserColor string `json:"userColor"`
|
||||
BlockID string `json:"blockId,omitempty"`
|
||||
SelectionStart *int `json:"selectionStart,omitempty"`
|
||||
SelectionEnd *int `json:"selectionEnd,omitempty"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// DocWSClient represents an active WebSocket connection to a specific document room.
|
||||
type DocWSClient struct {
|
||||
hub *Hub
|
||||
@@ -24,21 +36,26 @@ type DocWSClient struct {
|
||||
|
||||
// DocSyncEvent is the lightweight JSON payload exchanged over /api/v1/ws/docs/{id}.
|
||||
type DocSyncEvent struct {
|
||||
Type string `json:"type"`
|
||||
DocID string `json:"docId,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Block *domain.DocumentBlock `json:"block,omitempty"`
|
||||
BlockID string `json:"blockId,omitempty"`
|
||||
AfterBlockID string `json:"afterBlockId,omitempty"`
|
||||
NewIndex *int `json:"newIndex,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
Level *int `json:"level,omitempty"`
|
||||
Checked *bool `json:"checked,omitempty"`
|
||||
Collapsed *bool `json:"collapsed,omitempty"`
|
||||
BlockType *string `json:"blockType,omitempty"`
|
||||
Blocks []domain.DocumentBlock `json:"blocks,omitempty"`
|
||||
Type string `json:"type"`
|
||||
DocID string `json:"docId,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
UserColor string `json:"userColor,omitempty"`
|
||||
SelectionStart *int `json:"selectionStart,omitempty"`
|
||||
SelectionEnd *int `json:"selectionEnd,omitempty"`
|
||||
ActiveCursors []CursorPresence `json:"activeCursors,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Block *domain.DocumentBlock `json:"block,omitempty"`
|
||||
BlockID string `json:"blockId,omitempty"`
|
||||
AfterBlockID string `json:"afterBlockId,omitempty"`
|
||||
NewIndex *int `json:"newIndex,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
Level *int `json:"level,omitempty"`
|
||||
Checked *bool `json:"checked,omitempty"`
|
||||
Collapsed *bool `json:"collapsed,omitempty"`
|
||||
BlockType *string `json:"blockType,omitempty"`
|
||||
Blocks []domain.DocumentBlock `json:"blocks,omitempty"`
|
||||
}
|
||||
|
||||
// DocRoom coordinates in-memory real-time block state and 1.5s debounced SQLite persistence for one document.
|
||||
@@ -50,6 +67,7 @@ type DocRoom struct {
|
||||
blocks []domain.DocumentBlock
|
||||
loaded bool
|
||||
wsClients map[*DocWSClient]bool
|
||||
activeCursors map[string]*CursorPresence
|
||||
debounceTimer *time.Timer
|
||||
dirty bool
|
||||
}
|
||||
@@ -63,9 +81,10 @@ func (h *Hub) GetOrCreateDocRoom(docID string) *DocRoom {
|
||||
room, exists := h.docRooms[docID]
|
||||
if !exists {
|
||||
room = &DocRoom{
|
||||
docID: docID,
|
||||
hub: h,
|
||||
wsClients: make(map[*DocWSClient]bool),
|
||||
docID: docID,
|
||||
hub: h,
|
||||
wsClients: make(map[*DocWSClient]bool),
|
||||
activeCursors: make(map[string]*CursorPresence),
|
||||
}
|
||||
h.docRooms[docID] = room
|
||||
}
|
||||
@@ -205,6 +224,42 @@ func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) {
|
||||
mutated := false
|
||||
|
||||
switch evt.Type {
|
||||
case "cursor_moved":
|
||||
if evt.ClientID != "" {
|
||||
cp := &CursorPresence{
|
||||
ClientID: evt.ClientID,
|
||||
UserID: evt.UserID,
|
||||
UserName: evt.UserName,
|
||||
UserColor: evt.UserColor,
|
||||
BlockID: evt.BlockID,
|
||||
SelectionStart: evt.SelectionStart,
|
||||
SelectionEnd: evt.SelectionEnd,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
if r.activeCursors == nil {
|
||||
r.activeCursors = make(map[string]*CursorPresence)
|
||||
}
|
||||
r.activeCursors[evt.ClientID] = cp
|
||||
|
||||
msgBytes, err := json.Marshal(evt)
|
||||
if err == nil {
|
||||
for client := range r.wsClients {
|
||||
if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) {
|
||||
continue
|
||||
}
|
||||
if evt.ClientID != "" && client.clientID == evt.ClientID {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.send <- msgBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
return
|
||||
|
||||
case "title_updated":
|
||||
if evt.Title != "" || r.title != evt.Title {
|
||||
r.title = evt.Title
|
||||
@@ -448,6 +503,9 @@ func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID st
|
||||
clientID = domain.NewID("cli")
|
||||
}
|
||||
|
||||
userName := req.URL.Query().Get("userName")
|
||||
userColor := req.URL.Query().Get("userColor")
|
||||
|
||||
client := &DocWSClient{
|
||||
hub: r.hub,
|
||||
room: r,
|
||||
@@ -460,23 +518,67 @@ func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID st
|
||||
|
||||
r.mu.Lock()
|
||||
r.wsClients[client] = true
|
||||
if r.activeCursors == nil {
|
||||
r.activeCursors = make(map[string]*CursorPresence)
|
||||
}
|
||||
if userName != "" {
|
||||
r.activeCursors[clientID] = &CursorPresence{
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
UserName: userName,
|
||||
UserColor: userColor,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
}
|
||||
title := r.title
|
||||
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
|
||||
copy(blocksCopy, r.blocks)
|
||||
|
||||
activeList := make([]CursorPresence, 0, len(r.activeCursors))
|
||||
for _, cp := range r.activeCursors {
|
||||
if cp != nil {
|
||||
activeList = append(activeList, *cp)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
// Immediately send live in-memory state to the newly connected client
|
||||
// Immediately send live in-memory state and current cursors to the newly connected client
|
||||
initEvt := DocSyncEvent{
|
||||
Type: "doc_state",
|
||||
DocID: r.docID,
|
||||
ClientID: clientID,
|
||||
Title: title,
|
||||
Blocks: blocksCopy,
|
||||
Type: "doc_state",
|
||||
DocID: r.docID,
|
||||
ClientID: clientID,
|
||||
Title: title,
|
||||
Blocks: blocksCopy,
|
||||
ActiveCursors: activeList,
|
||||
}
|
||||
if initBytes, err := json.Marshal(initEvt); err == nil {
|
||||
client.send <- initBytes
|
||||
}
|
||||
|
||||
// If userName was provided, broadcast initial presence to existing clients in room
|
||||
if userName != "" {
|
||||
joinEvt := DocSyncEvent{
|
||||
Type: "cursor_moved",
|
||||
DocID: r.docID,
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
UserName: userName,
|
||||
UserColor: userColor,
|
||||
}
|
||||
if joinBytes, err := json.Marshal(joinEvt); err == nil {
|
||||
r.mu.Lock()
|
||||
for c := range r.wsClients {
|
||||
if c != client && c.clientID != clientID {
|
||||
select {
|
||||
case c.send <- joinBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
||||
@@ -485,7 +587,26 @@ func (c *DocWSClient) readPump() {
|
||||
defer func() {
|
||||
c.room.mu.Lock()
|
||||
delete(c.room.wsClients, c)
|
||||
delete(c.room.activeCursors, c.clientID)
|
||||
remaining := len(c.room.wsClients)
|
||||
|
||||
// Broadcast client_left to all remaining clients in room
|
||||
leftEvt := DocSyncEvent{
|
||||
Type: "client_left",
|
||||
DocID: c.room.docID,
|
||||
ClientID: c.clientID,
|
||||
UserID: c.userID,
|
||||
}
|
||||
if leftBytes, err := json.Marshal(leftEvt); err == nil {
|
||||
for client := range c.room.wsClients {
|
||||
if client != c && client.clientID != c.clientID {
|
||||
select {
|
||||
case client.send <- leftBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.room.mu.Unlock()
|
||||
|
||||
_ = c.conn.Close()
|
||||
@@ -511,8 +632,8 @@ func (c *DocWSClient) readPump() {
|
||||
|
||||
var evt DocSyncEvent
|
||||
if err := json.Unmarshal(message, &evt); err == nil && evt.Type != "" {
|
||||
if !c.canWrite {
|
||||
// Read-only clients cannot mutate the document
|
||||
// Read-only clients can still move their cursor and send presence updates
|
||||
if !c.canWrite && evt.Type != "cursor_moved" {
|
||||
continue
|
||||
}
|
||||
c.room.HandleEvent(evt, c)
|
||||
|
||||
Reference in new issue
Block a user