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
+593
-18
No files matched your search
@@ -12,13 +12,14 @@ import {
|
|||||||
Hash,
|
Hash,
|
||||||
Lock,
|
Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Document, DocumentBlock } from '../../lib/types';
|
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
import { useDocumentSync, DocSyncEvent } from '../../lib/useDocumentSync';
|
import { useDocumentSync, DocSyncEvent, RemoteCursor } from '../../lib/useDocumentSync';
|
||||||
import { OutlinerTree } from './OutlinerTree';
|
import { OutlinerTree } from './OutlinerTree';
|
||||||
|
|
||||||
export interface DocumentEditorProps {
|
export interface DocumentEditorProps {
|
||||||
docId: string;
|
docId: string;
|
||||||
|
currentUser?: User | null;
|
||||||
initialDoc?: Document | null;
|
initialDoc?: Document | null;
|
||||||
availableFolders: string[];
|
availableFolders: string[];
|
||||||
onDocMetadataChange: (updated: Document) => void;
|
onDocMetadataChange: (updated: Document) => void;
|
||||||
@@ -56,6 +57,7 @@ function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): Document
|
|||||||
|
|
||||||
export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||||
docId,
|
docId,
|
||||||
|
currentUser,
|
||||||
initialDoc,
|
initialDoc,
|
||||||
availableFolders,
|
availableFolders,
|
||||||
onDocMetadataChange,
|
onDocMetadataChange,
|
||||||
@@ -210,12 +212,14 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
status: syncStatus,
|
status: syncStatus,
|
||||||
|
remoteCursors,
|
||||||
|
sendCursorMoved,
|
||||||
sendBlockCreated,
|
sendBlockCreated,
|
||||||
sendBlockUpdated,
|
sendBlockUpdated,
|
||||||
sendBlockDeleted,
|
sendBlockDeleted,
|
||||||
sendBlockMoved,
|
sendBlockMoved,
|
||||||
sendTitleUpdated,
|
sendTitleUpdated,
|
||||||
} = useDocumentSync(docId, handleRemoteEvent, canEdit);
|
} = useDocumentSync(docId, handleRemoteEvent, canEdit, currentUser);
|
||||||
|
|
||||||
// Fetch isolated document state on mount
|
// Fetch isolated document state on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -276,6 +280,20 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onDocMetadataChange(updatedDoc);
|
onDocMetadataChange(updatedDoc);
|
||||||
}, [blocks, extractedTags, title]);
|
}, [blocks, extractedTags, title]);
|
||||||
|
|
||||||
|
// Active remote users in this room (excluding self)
|
||||||
|
const activeUsers = useMemo(() => {
|
||||||
|
const list: RemoteCursor[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const c of Object.values(remoteCursors)) {
|
||||||
|
const key = c.userId || c.clientId;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
list.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [remoteCursors]);
|
||||||
|
|
||||||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
const nextTitle = e.target.value;
|
const nextTitle = e.target.value;
|
||||||
@@ -513,6 +531,34 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
|
|
||||||
{/* Right Status & Action Buttons */}
|
{/* Right Status & Action Buttons */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Active Presence Avatars */}
|
||||||
|
{activeUsers.length > 0 && (
|
||||||
|
<div className="flex items-center -space-x-1.5 mr-1" title="Aktive Bearbeiter im Dokument">
|
||||||
|
{activeUsers.map((u) => {
|
||||||
|
const initials = (u.userName || 'A')
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n[0])
|
||||||
|
.slice(0, 2)
|
||||||
|
.join('')
|
||||||
|
.toUpperCase();
|
||||||
|
return (
|
||||||
|
<div key={u.clientId} className="relative group/avatar cursor-default">
|
||||||
|
<div
|
||||||
|
className="w-5 h-5 rounded-btn flex items-center justify-center text-[9px] font-bold text-white shadow-sm ring-1 ring-panel"
|
||||||
|
style={{ backgroundColor: u.userColor }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div className="pointer-events-none absolute bottom-full mb-1 left-1/2 -translate-x-1/2 opacity-0 group-hover/avatar:opacity-100 transition-opacity whitespace-nowrap bg-panel-elevated text-main text-[10px] px-1.5 py-0.5 rounded-btn border border-border shadow-md z-30">
|
||||||
|
{u.userName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Sync Status Badge */}
|
{/* Sync Status Badge */}
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[10px] font-mono font-medium ${
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[10px] font-mono font-medium ${
|
||||||
@@ -654,6 +700,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
docId={docId}
|
docId={docId}
|
||||||
blocks={blocks}
|
blocks={blocks}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
remoteCursors={remoteCursors}
|
||||||
|
sendCursorMoved={sendCursorMoved}
|
||||||
onUpdateBlock={handleUpdateBlock}
|
onUpdateBlock={handleUpdateBlock}
|
||||||
onCreateBlock={handleCreateBlock}
|
onCreateBlock={handleCreateBlock}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
|
import { RemoteCursor } from '../../lib/useDocumentSync';
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
type: BlockType;
|
type: BlockType;
|
||||||
@@ -43,6 +44,8 @@ export interface OutlinerBlockProps {
|
|||||||
hasChildren: boolean;
|
hasChildren: boolean;
|
||||||
isFocused: boolean;
|
isFocused: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
|
activeRemoteCursors?: RemoteCursor[];
|
||||||
|
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
|
||||||
onFocusBlock: (blockId: string) => void;
|
onFocusBlock: (blockId: string) => void;
|
||||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||||
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
|
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
|
||||||
@@ -64,6 +67,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
hasChildren,
|
hasChildren,
|
||||||
isFocused,
|
isFocused,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
activeRemoteCursors = [],
|
||||||
|
sendCursorMoved,
|
||||||
onFocusBlock,
|
onFocusBlock,
|
||||||
onUpdateBlock,
|
onUpdateBlock,
|
||||||
onCreateBlockBelow,
|
onCreateBlockBelow,
|
||||||
@@ -193,6 +198,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLocalContent(val);
|
setLocalContent(val);
|
||||||
|
sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
||||||
|
|
||||||
if (debounceTimerRef.current !== null) {
|
if (debounceTimerRef.current !== null) {
|
||||||
window.clearTimeout(debounceTimerRef.current);
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
@@ -215,6 +221,33 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}, 150);
|
}, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||||
|
onFocusBlock(block.id);
|
||||||
|
sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
if (debounceTimerRef.current !== null) {
|
||||||
|
flushDebounce(localContent);
|
||||||
|
}
|
||||||
|
sendCursorMoved?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
|
||||||
|
sendCursorMoved?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
sendCursorMoved?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
sendCursorMoved?.(block.id, target.selectionStart, target.selectionEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null;
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
|
|
||||||
@@ -321,10 +354,30 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
? 'bg-panel-elevated'
|
? 'bg-panel-elevated'
|
||||||
: 'hover:bg-panel'
|
: 'hover:bg-panel'
|
||||||
}`}
|
}`}
|
||||||
style={{ paddingLeft: `${level * 22 + 4}px` }}
|
style={{
|
||||||
|
paddingLeft: `${level * 22 + 4}px`,
|
||||||
|
...(primaryRemoteCursor
|
||||||
|
? { borderLeft: `2px solid ${primaryRemoteCursor.userColor}` }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
||||||
onDrop={() => onDropBlock?.(index)}
|
onDrop={() => onDropBlock?.(index)}
|
||||||
>
|
>
|
||||||
|
{/* Remote Cursors Floating Name Badges on Block */}
|
||||||
|
{activeRemoteCursors.length > 0 && (
|
||||||
|
<div className="absolute -top-2.5 right-2 z-20 flex items-center gap-1 select-none pointer-events-none">
|
||||||
|
{activeRemoteCursors.map((c) => (
|
||||||
|
<span
|
||||||
|
key={c.clientId}
|
||||||
|
className="text-[9px] font-bold px-1.5 py-0.5 rounded-btn text-white shadow-sm flex items-center gap-0.5"
|
||||||
|
style={{ backgroundColor: c.userColor }}
|
||||||
|
>
|
||||||
|
{c.userName}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Indentation Guide Line */}
|
{/* Indentation Guide Line */}
|
||||||
{level > 0 &&
|
{level > 0 &&
|
||||||
Array.from({ length: level }).map((_, lvlIdx) => (
|
Array.from({ length: level }).map((_, lvlIdx) => (
|
||||||
@@ -436,8 +489,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
rows={1}
|
rows={1}
|
||||||
value={localContent}
|
value={localContent}
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
onFocus={() => onFocusBlock(block.id)}
|
onFocus={handleFocus}
|
||||||
onBlur={() => debounceTimerRef.current !== null && flushDebounce(localContent)}
|
onBlur={handleBlur}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
onSelect={handleSelect}
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
|
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
|
||||||
@@ -453,14 +509,18 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
rows={1}
|
rows={1}
|
||||||
value={localContent}
|
value={localContent}
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
onFocus={() => onFocusBlock(block.id)}
|
onFocus={handleFocus}
|
||||||
onBlur={() => debounceTimerRef.current !== null && flushDebounce(localContent)}
|
onBlur={handleBlur}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
onSelect={handleSelect}
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
|
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="relative w-full">
|
||||||
<textarea
|
<textarea
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
textareaRef.current = el;
|
textareaRef.current = el;
|
||||||
@@ -469,8 +529,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
rows={1}
|
rows={1}
|
||||||
value={localContent}
|
value={localContent}
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
onFocus={() => onFocusBlock(block.id)}
|
onFocus={handleFocus}
|
||||||
onBlur={() => debounceTimerRef.current !== null && flushDebounce(localContent)}
|
onBlur={handleBlur}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
onSelect={handleSelect}
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
placeholder={
|
||||||
@@ -490,6 +553,46 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
: 'text-sm text-main'
|
: 'text-sm text-main'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Remote Caret & Floating Name Tag Overlay */}
|
||||||
|
{primaryRemoteCursor &&
|
||||||
|
primaryRemoteCursor.selectionStart !== undefined &&
|
||||||
|
primaryRemoteCursor.selectionStart !== null && (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`pointer-events-none absolute inset-0 overflow-hidden leading-relaxed whitespace-pre-wrap break-words select-none z-10 ${
|
||||||
|
block.type === 'h1'
|
||||||
|
? 'text-lg font-bold tracking-tight'
|
||||||
|
: block.type === 'h2'
|
||||||
|
? 'text-base font-semibold'
|
||||||
|
: block.type === 'h3'
|
||||||
|
? 'text-sm font-semibold'
|
||||||
|
: block.type === 'todo' && block.checked
|
||||||
|
? 'text-sm text-faint line-through'
|
||||||
|
: 'text-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="opacity-0">
|
||||||
|
{localContent.slice(
|
||||||
|
0,
|
||||||
|
Math.min(primaryRemoteCursor.selectionStart, localContent.length)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="relative inline-block align-baseline">
|
||||||
|
<span
|
||||||
|
className="inline-block w-[2px] h-[1.15em] -mb-0.5 animate-pulse"
|
||||||
|
style={{ backgroundColor: primaryRemoteCursor.userColor }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="absolute -top-3.5 left-0 text-[8px] font-bold px-1 py-0 rounded-btn text-white whitespace-nowrap shadow-sm"
|
||||||
|
style={{ backgroundColor: primaryRemoteCursor.userColor }}
|
||||||
|
>
|
||||||
|
{primaryRemoteCursor.userName}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rich Tokens Bar (Wiki-Links & #Tags) if present */}
|
{/* Rich Tokens Bar (Wiki-Links & #Tags) if present */}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { DocumentBlock } from '../../lib/types';
|
import { DocumentBlock } from '../../lib/types';
|
||||||
|
import { RemoteCursor } from '../../lib/useDocumentSync';
|
||||||
import { OutlinerBlock } from './OutlinerBlock';
|
import { OutlinerBlock } from './OutlinerBlock';
|
||||||
|
|
||||||
export interface OutlinerTreeProps {
|
export interface OutlinerTreeProps {
|
||||||
docId: string;
|
docId: string;
|
||||||
blocks: DocumentBlock[];
|
blocks: DocumentBlock[];
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
|
remoteCursors?: Record<string, RemoteCursor>;
|
||||||
|
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
|
||||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||||
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
||||||
onDeleteBlock: (blockId: string) => void;
|
onDeleteBlock: (blockId: string) => void;
|
||||||
@@ -19,6 +22,8 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
docId,
|
docId,
|
||||||
blocks,
|
blocks,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
remoteCursors,
|
||||||
|
sendCursorMoved,
|
||||||
onUpdateBlock,
|
onUpdateBlock,
|
||||||
onCreateBlock,
|
onCreateBlock,
|
||||||
onDeleteBlock,
|
onDeleteBlock,
|
||||||
@@ -255,6 +260,18 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
return () => window.removeEventListener('dragend', clearDrag);
|
return () => window.removeEventListener('dragend', clearDrag);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const cursorsByBlock = useMemo(() => {
|
||||||
|
const map: Record<string, RemoteCursor[]> = {};
|
||||||
|
if (!remoteCursors) return map;
|
||||||
|
for (const c of Object.values(remoteCursors)) {
|
||||||
|
if (c.blockId) {
|
||||||
|
if (!map[c.blockId]) map[c.blockId] = [];
|
||||||
|
map[c.blockId].push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [remoteCursors]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => (
|
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => (
|
||||||
@@ -266,6 +283,8 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
hasChildren={hasChildren}
|
hasChildren={hasChildren}
|
||||||
isFocused={focusedBlockId === block.id}
|
isFocused={focusedBlockId === block.id}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
activeRemoteCursors={cursorsByBlock[block.id] || []}
|
||||||
|
sendCursorMoved={sendCursorMoved}
|
||||||
onFocusBlock={setFocusedBlockId}
|
onFocusBlock={setFocusedBlockId}
|
||||||
onUpdateBlock={onUpdateBlock}
|
onUpdateBlock={onUpdateBlock}
|
||||||
onCreateBlockBelow={handleCreateBlockBelow}
|
onCreateBlockBelow={handleCreateBlockBelow}
|
||||||
|
|||||||
@@ -864,6 +864,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<DocumentEditor
|
<DocumentEditor
|
||||||
key={selectedDocId}
|
key={selectedDocId}
|
||||||
docId={selectedDocId}
|
docId={selectedDocId}
|
||||||
|
currentUser={currentUser}
|
||||||
initialDoc={selectedDocObj}
|
initialDoc={selectedDocObj}
|
||||||
availableFolders={allFolderPaths}
|
availableFolders={allFolderPaths}
|
||||||
onDocMetadataChange={handleDocMetadataChange}
|
onDocMetadataChange={handleDocMetadataChange}
|
||||||
|
|||||||
@@ -1,9 +1,42 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||||
import { DocumentBlock } from './types';
|
import { DocumentBlock } from './types';
|
||||||
import { getToken, api } from './api';
|
import { getToken, api } from './api';
|
||||||
|
|
||||||
export type DocSyncStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline';
|
export type DocSyncStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline';
|
||||||
|
|
||||||
|
export interface RemoteCursor {
|
||||||
|
clientId: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
userColor: string;
|
||||||
|
blockId: string | null;
|
||||||
|
selectionStart?: number;
|
||||||
|
selectionEnd?: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CURSOR_COLORS = [
|
||||||
|
'#3b82f6', // blue
|
||||||
|
'#10b981', // emerald
|
||||||
|
'#f59e0b', // amber
|
||||||
|
'#ec4899', // pink
|
||||||
|
'#8b5cf6', // purple
|
||||||
|
'#06b6d4', // cyan
|
||||||
|
'#f97316', // orange
|
||||||
|
'#14b8a6', // teal
|
||||||
|
'#6366f1', // indigo
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getUserColor(idOrName: string): string {
|
||||||
|
if (!idOrName) return CURSOR_COLORS[0];
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < idOrName.length; i++) {
|
||||||
|
hash = (hash << 5) - hash + idOrName.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocSyncEvent {
|
export interface DocSyncEvent {
|
||||||
type:
|
type:
|
||||||
| 'doc_state'
|
| 'doc_state'
|
||||||
@@ -12,10 +45,17 @@ export interface DocSyncEvent {
|
|||||||
| 'block_deleted'
|
| 'block_deleted'
|
||||||
| 'block_moved'
|
| 'block_moved'
|
||||||
| 'title_updated'
|
| 'title_updated'
|
||||||
| 'blocks_replaced';
|
| 'blocks_replaced'
|
||||||
|
| 'cursor_moved'
|
||||||
|
| 'client_left';
|
||||||
docId?: string;
|
docId?: string;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
userName?: string;
|
||||||
|
userColor?: string;
|
||||||
|
selectionStart?: number;
|
||||||
|
selectionEnd?: number;
|
||||||
|
activeCursors?: RemoteCursor[];
|
||||||
title?: string;
|
title?: string;
|
||||||
block?: DocumentBlock;
|
block?: DocumentBlock;
|
||||||
blockId?: string;
|
blockId?: string;
|
||||||
@@ -32,9 +72,11 @@ export interface DocSyncEvent {
|
|||||||
export function useDocumentSync(
|
export function useDocumentSync(
|
||||||
docId: string | null,
|
docId: string | null,
|
||||||
onRemoteEvent: (evt: DocSyncEvent) => void,
|
onRemoteEvent: (evt: DocSyncEvent) => void,
|
||||||
canEdit: boolean = true
|
canEdit: boolean = true,
|
||||||
|
currentUser?: { id: string; email?: string; displayName?: string } | null
|
||||||
) {
|
) {
|
||||||
const [status, setStatus] = useState<DocSyncStatus>('connecting');
|
const [status, setStatus] = useState<DocSyncStatus>('connecting');
|
||||||
|
const [remoteCursors, setRemoteCursors] = useState<Record<string, RemoteCursor>>({});
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const clientIdRef = useRef<string>(
|
const clientIdRef = useRef<string>(
|
||||||
`cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
|
`cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
|
||||||
@@ -45,9 +87,45 @@ export function useDocumentSync(
|
|||||||
const reconnectTimerRef = useRef<number | null>(null);
|
const reconnectTimerRef = useRef<number | null>(null);
|
||||||
const fallbackTimerRef = useRef<number | null>(null);
|
const fallbackTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const localUserName =
|
||||||
|
currentUser?.displayName || currentUser?.email?.split('@')[0] || 'Anonym';
|
||||||
|
const localUserColor = useMemo(
|
||||||
|
() => getUserColor(currentUser?.id || clientIdRef.current),
|
||||||
|
[currentUser?.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastCursorSendRef = useRef<number>(0);
|
||||||
|
const pendingCursorTimerRef = useRef<number | null>(null);
|
||||||
|
const pendingCursorPayloadRef = useRef<{
|
||||||
|
blockId: string | null;
|
||||||
|
selectionStart?: number;
|
||||||
|
selectionEnd?: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Clean up stale remote cursors (> 30s)
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
setRemoteCursors((prev) => {
|
||||||
|
let hasStale = false;
|
||||||
|
const next: Record<string, RemoteCursor> = {};
|
||||||
|
for (const [key, cursor] of Object.entries(prev)) {
|
||||||
|
if (now - cursor.updatedAt < 30000) {
|
||||||
|
next[key] = cursor;
|
||||||
|
} else {
|
||||||
|
hasStale = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasStale ? next : prev;
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!docId) {
|
if (!docId) {
|
||||||
setStatus('offline');
|
setStatus('offline');
|
||||||
|
setRemoteCursors({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +146,11 @@ export function useDocumentSync(
|
|||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(
|
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(
|
||||||
docId
|
docId
|
||||||
)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(currentClientId)}`;
|
)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(
|
||||||
|
currentClientId
|
||||||
|
)}&userName=${encodeURIComponent(localUserName)}&userColor=${encodeURIComponent(
|
||||||
|
localUserColor
|
||||||
|
)}`;
|
||||||
|
|
||||||
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting');
|
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting');
|
||||||
|
|
||||||
@@ -96,10 +178,55 @@ export function useDocumentSync(
|
|||||||
try {
|
try {
|
||||||
const parsed: DocSyncEvent = JSON.parse(event.data);
|
const parsed: DocSyncEvent = JSON.parse(event.data);
|
||||||
if (!parsed || !parsed.type) return;
|
if (!parsed || !parsed.type) return;
|
||||||
|
|
||||||
// Echo protection: ignore events originating from our own clientId (except initial doc_state)
|
// Echo protection: ignore events originating from our own clientId (except initial doc_state)
|
||||||
if (parsed.type !== 'doc_state' && parsed.clientId && parsed.clientId === currentClientId) {
|
if (parsed.type !== 'doc_state' && parsed.clientId && parsed.clientId === currentClientId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.type === 'doc_state') {
|
||||||
|
if (Array.isArray(parsed.activeCursors)) {
|
||||||
|
const initialCursors: Record<string, RemoteCursor> = {};
|
||||||
|
for (const c of parsed.activeCursors) {
|
||||||
|
if (c.clientId && c.clientId !== currentClientId) {
|
||||||
|
initialCursors[c.clientId] = {
|
||||||
|
...c,
|
||||||
|
updatedAt: c.updatedAt || Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setRemoteCursors(initialCursors);
|
||||||
|
}
|
||||||
|
} else if (parsed.type === 'cursor_moved') {
|
||||||
|
if (parsed.clientId && parsed.clientId !== currentClientId) {
|
||||||
|
setRemoteCursors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[parsed.clientId!]: {
|
||||||
|
clientId: parsed.clientId!,
|
||||||
|
userId: parsed.userId || '',
|
||||||
|
userName: parsed.userName || 'Anonym',
|
||||||
|
userColor: parsed.userColor || '#3b82f6',
|
||||||
|
blockId:
|
||||||
|
parsed.blockId !== undefined
|
||||||
|
? parsed.blockId
|
||||||
|
: prev[parsed.clientId!]?.blockId ?? null,
|
||||||
|
selectionStart: parsed.selectionStart,
|
||||||
|
selectionEnd: parsed.selectionEnd,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (parsed.type === 'client_left') {
|
||||||
|
if (parsed.clientId) {
|
||||||
|
setRemoteCursors((prev) => {
|
||||||
|
if (!prev[parsed.clientId!]) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[parsed.clientId!];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onRemoteEventRef.current(parsed);
|
onRemoteEventRef.current(parsed);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed frames
|
// Ignore malformed frames
|
||||||
@@ -132,6 +259,10 @@ export function useDocumentSync(
|
|||||||
window.clearTimeout(fallbackTimerRef.current);
|
window.clearTimeout(fallbackTimerRef.current);
|
||||||
fallbackTimerRef.current = null;
|
fallbackTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (pendingCursorTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(pendingCursorTimerRef.current);
|
||||||
|
pendingCursorTimerRef.current = null;
|
||||||
|
}
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
try {
|
try {
|
||||||
wsRef.current.onopen = null;
|
wsRef.current.onopen = null;
|
||||||
@@ -145,7 +276,72 @@ export function useDocumentSync(
|
|||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [docId]);
|
}, [docId, localUserName, localUserColor]);
|
||||||
|
|
||||||
|
const executeSendCursor = useCallback(
|
||||||
|
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
||||||
|
const ws = wsRef.current;
|
||||||
|
if (!docId || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const payload: DocSyncEvent = {
|
||||||
|
type: 'cursor_moved',
|
||||||
|
docId,
|
||||||
|
clientId: clientIdRef.current,
|
||||||
|
userId: currentUser?.id,
|
||||||
|
userName: localUserName,
|
||||||
|
userColor: localUserColor,
|
||||||
|
blockId: blockId || undefined,
|
||||||
|
selectionStart,
|
||||||
|
selectionEnd,
|
||||||
|
};
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
lastCursorSendRef.current = Date.now();
|
||||||
|
},
|
||||||
|
[docId, currentUser?.id, localUserName, localUserColor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendCursorMoved = useCallback(
|
||||||
|
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
||||||
|
if (!docId) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - lastCursorSendRef.current;
|
||||||
|
|
||||||
|
// When leaving focus / blur (blockId === null), send immediately
|
||||||
|
if (blockId === null) {
|
||||||
|
if (pendingCursorTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(pendingCursorTimerRef.current);
|
||||||
|
pendingCursorTimerRef.current = null;
|
||||||
|
}
|
||||||
|
pendingCursorPayloadRef.current = null;
|
||||||
|
executeSendCursor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max once per 100ms
|
||||||
|
if (elapsed >= 100) {
|
||||||
|
if (pendingCursorTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(pendingCursorTimerRef.current);
|
||||||
|
pendingCursorTimerRef.current = null;
|
||||||
|
}
|
||||||
|
executeSendCursor(blockId, selectionStart, selectionEnd);
|
||||||
|
} else {
|
||||||
|
pendingCursorPayloadRef.current = { blockId, selectionStart, selectionEnd };
|
||||||
|
if (pendingCursorTimerRef.current === null) {
|
||||||
|
pendingCursorTimerRef.current = window.setTimeout(() => {
|
||||||
|
pendingCursorTimerRef.current = null;
|
||||||
|
if (pendingCursorPayloadRef.current) {
|
||||||
|
executeSendCursor(
|
||||||
|
pendingCursorPayloadRef.current.blockId,
|
||||||
|
pendingCursorPayloadRef.current.selectionStart,
|
||||||
|
pendingCursorPayloadRef.current.selectionEnd
|
||||||
|
);
|
||||||
|
pendingCursorPayloadRef.current = null;
|
||||||
|
}
|
||||||
|
}, 100 - elapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[docId, executeSendCursor]
|
||||||
|
);
|
||||||
|
|
||||||
const sendRawEvent = useCallback(
|
const sendRawEvent = useCallback(
|
||||||
(evt: DocSyncEvent, fallbackFn?: () => void) => {
|
(evt: DocSyncEvent, fallbackFn?: () => void) => {
|
||||||
@@ -294,6 +490,8 @@ export function useDocumentSync(
|
|||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
clientId: clientIdRef.current,
|
clientId: clientIdRef.current,
|
||||||
|
remoteCursors,
|
||||||
|
sendCursorMoved,
|
||||||
sendBlockCreated,
|
sendBlockCreated,
|
||||||
sendBlockUpdated,
|
sendBlockUpdated,
|
||||||
sendBlockDeleted,
|
sendBlockDeleted,
|
||||||
|
|||||||
+124
-3
@@ -11,6 +11,18 @@ import (
|
|||||||
"github.com/sandrodach/mindsift/internal/domain"
|
"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.
|
// DocWSClient represents an active WebSocket connection to a specific document room.
|
||||||
type DocWSClient struct {
|
type DocWSClient struct {
|
||||||
hub *Hub
|
hub *Hub
|
||||||
@@ -28,6 +40,11 @@ type DocSyncEvent struct {
|
|||||||
DocID string `json:"docId,omitempty"`
|
DocID string `json:"docId,omitempty"`
|
||||||
ClientID string `json:"clientId,omitempty"`
|
ClientID string `json:"clientId,omitempty"`
|
||||||
UserID string `json:"userId,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"`
|
Title string `json:"title,omitempty"`
|
||||||
Block *domain.DocumentBlock `json:"block,omitempty"`
|
Block *domain.DocumentBlock `json:"block,omitempty"`
|
||||||
BlockID string `json:"blockId,omitempty"`
|
BlockID string `json:"blockId,omitempty"`
|
||||||
@@ -50,6 +67,7 @@ type DocRoom struct {
|
|||||||
blocks []domain.DocumentBlock
|
blocks []domain.DocumentBlock
|
||||||
loaded bool
|
loaded bool
|
||||||
wsClients map[*DocWSClient]bool
|
wsClients map[*DocWSClient]bool
|
||||||
|
activeCursors map[string]*CursorPresence
|
||||||
debounceTimer *time.Timer
|
debounceTimer *time.Timer
|
||||||
dirty bool
|
dirty bool
|
||||||
}
|
}
|
||||||
@@ -66,6 +84,7 @@ func (h *Hub) GetOrCreateDocRoom(docID string) *DocRoom {
|
|||||||
docID: docID,
|
docID: docID,
|
||||||
hub: h,
|
hub: h,
|
||||||
wsClients: make(map[*DocWSClient]bool),
|
wsClients: make(map[*DocWSClient]bool),
|
||||||
|
activeCursors: make(map[string]*CursorPresence),
|
||||||
}
|
}
|
||||||
h.docRooms[docID] = room
|
h.docRooms[docID] = room
|
||||||
}
|
}
|
||||||
@@ -205,6 +224,42 @@ func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) {
|
|||||||
mutated := false
|
mutated := false
|
||||||
|
|
||||||
switch evt.Type {
|
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":
|
case "title_updated":
|
||||||
if evt.Title != "" || r.title != evt.Title {
|
if evt.Title != "" || r.title != 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")
|
clientID = domain.NewID("cli")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userName := req.URL.Query().Get("userName")
|
||||||
|
userColor := req.URL.Query().Get("userColor")
|
||||||
|
|
||||||
client := &DocWSClient{
|
client := &DocWSClient{
|
||||||
hub: r.hub,
|
hub: r.hub,
|
||||||
room: r,
|
room: r,
|
||||||
@@ -460,23 +518,67 @@ func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID st
|
|||||||
|
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.wsClients[client] = true
|
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
|
title := r.title
|
||||||
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
|
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
|
||||||
copy(blocksCopy, 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()
|
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{
|
initEvt := DocSyncEvent{
|
||||||
Type: "doc_state",
|
Type: "doc_state",
|
||||||
DocID: r.docID,
|
DocID: r.docID,
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Blocks: blocksCopy,
|
Blocks: blocksCopy,
|
||||||
|
ActiveCursors: activeList,
|
||||||
}
|
}
|
||||||
if initBytes, err := json.Marshal(initEvt); err == nil {
|
if initBytes, err := json.Marshal(initEvt); err == nil {
|
||||||
client.send <- initBytes
|
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.writePump()
|
||||||
go client.readPump()
|
go client.readPump()
|
||||||
}
|
}
|
||||||
@@ -485,7 +587,26 @@ func (c *DocWSClient) readPump() {
|
|||||||
defer func() {
|
defer func() {
|
||||||
c.room.mu.Lock()
|
c.room.mu.Lock()
|
||||||
delete(c.room.wsClients, c)
|
delete(c.room.wsClients, c)
|
||||||
|
delete(c.room.activeCursors, c.clientID)
|
||||||
remaining := len(c.room.wsClients)
|
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.room.mu.Unlock()
|
||||||
|
|
||||||
_ = c.conn.Close()
|
_ = c.conn.Close()
|
||||||
@@ -511,8 +632,8 @@ func (c *DocWSClient) readPump() {
|
|||||||
|
|
||||||
var evt DocSyncEvent
|
var evt DocSyncEvent
|
||||||
if err := json.Unmarshal(message, &evt); err == nil && evt.Type != "" {
|
if err := json.Unmarshal(message, &evt); err == nil && evt.Type != "" {
|
||||||
if !c.canWrite {
|
// Read-only clients can still move their cursor and send presence updates
|
||||||
// Read-only clients cannot mutate the document
|
if !c.canWrite && evt.Type != "cursor_moved" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c.room.HandleEvent(evt, c)
|
c.room.HandleEvent(evt, c)
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDocRoomCursorPresenceAndEchoProtection(t *testing.T) {
|
||||||
|
hub := NewHub()
|
||||||
|
room := hub.GetOrCreateDocRoom("doc_test_presence")
|
||||||
|
|
||||||
|
c1 := &DocWSClient{
|
||||||
|
hub: hub,
|
||||||
|
room: room,
|
||||||
|
userID: "user_1",
|
||||||
|
clientID: "cli_1",
|
||||||
|
canWrite: true,
|
||||||
|
send: make(chan []byte, 10),
|
||||||
|
}
|
||||||
|
c2 := &DocWSClient{
|
||||||
|
hub: hub,
|
||||||
|
room: room,
|
||||||
|
userID: "user_2",
|
||||||
|
clientID: "cli_2",
|
||||||
|
canWrite: false, // read-only viewer
|
||||||
|
send: make(chan []byte, 10),
|
||||||
|
}
|
||||||
|
|
||||||
|
room.mu.Lock()
|
||||||
|
room.wsClients[c1] = true
|
||||||
|
room.wsClients[c2] = true
|
||||||
|
room.mu.Unlock()
|
||||||
|
|
||||||
|
// 1. Send cursor_moved from c1
|
||||||
|
selStart := 5
|
||||||
|
selEnd := 5
|
||||||
|
room.HandleEvent(DocSyncEvent{
|
||||||
|
Type: "cursor_moved",
|
||||||
|
DocID: "doc_test_presence",
|
||||||
|
ClientID: "cli_1",
|
||||||
|
UserID: "user_1",
|
||||||
|
UserName: "Alice",
|
||||||
|
UserColor: "#3b82f6",
|
||||||
|
BlockID: "blk_123",
|
||||||
|
SelectionStart: &selStart,
|
||||||
|
SelectionEnd: &selEnd,
|
||||||
|
}, c1)
|
||||||
|
|
||||||
|
// c1 should NOT have received its own echo
|
||||||
|
select {
|
||||||
|
case <-c1.send:
|
||||||
|
t.Fatal("c1 received echo of its own cursor_moved event!")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// c2 SHOULD receive the event
|
||||||
|
select {
|
||||||
|
case msg := <-c2.send:
|
||||||
|
if len(msg) == 0 {
|
||||||
|
t.Fatal("empty message received by c2")
|
||||||
|
}
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
t.Fatal("timed out waiting for cursor_moved event on c2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Room should have registered c1's presence
|
||||||
|
room.mu.Lock()
|
||||||
|
cp, exists := room.activeCursors["cli_1"]
|
||||||
|
room.mu.Unlock()
|
||||||
|
|
||||||
|
if !exists || cp == nil {
|
||||||
|
t.Fatal("expected active cursor for cli_1 in room")
|
||||||
|
}
|
||||||
|
if cp.UserName != "Alice" || cp.BlockID != "blk_123" {
|
||||||
|
t.Fatalf("unexpected cursor data: %+v", cp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ephemeral cursor events must NOT mark the room dirty for disk flush
|
||||||
|
room.mu.Lock()
|
||||||
|
dirty := room.dirty
|
||||||
|
room.mu.Unlock()
|
||||||
|
if dirty {
|
||||||
|
t.Fatal("cursor_moved should not mark room dirty")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user