feat(workspace): add real-time live cursor and presence tracking
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:
sandrodach committed 2026-10-26 21:57:19 +01:00
1 parent 85e4010435
commit 95977ba15a
7 files changed
+643 -68

No files matched your search

@@ -12,13 +12,14 @@ import {
Hash,
Lock,
} from 'lucide-react';
import { Document, DocumentBlock } from '../../lib/types';
import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api';
import { useDocumentSync, DocSyncEvent } from '../../lib/useDocumentSync';
import { useDocumentSync, DocSyncEvent, RemoteCursor } from '../../lib/useDocumentSync';
import { OutlinerTree } from './OutlinerTree';
export interface DocumentEditorProps {
docId: string;
currentUser?: User | null;
initialDoc?: Document | null;
availableFolders: string[];
onDocMetadataChange: (updated: Document) => void;
@@ -56,6 +57,7 @@ function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): Document
export const DocumentEditor: React.FC<DocumentEditorProps> = ({
docId,
currentUser,
initialDoc,
availableFolders,
onDocMetadataChange,
@@ -210,12 +212,14 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const {
status: syncStatus,
remoteCursors,
sendCursorMoved,
sendBlockCreated,
sendBlockUpdated,
sendBlockDeleted,
sendBlockMoved,
sendTitleUpdated,
} = useDocumentSync(docId, handleRemoteEvent, canEdit);
} = useDocumentSync(docId, handleRemoteEvent, canEdit, currentUser);
// Fetch isolated document state on mount
useEffect(() => {
@@ -276,6 +280,20 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onDocMetadataChange(updatedDoc);
}, [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>) => {
if (!canEdit) return;
const nextTitle = e.target.value;
@@ -513,6 +531,34 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Right Status & Action Buttons */}
<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 */}
<span
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}
blocks={blocks}
canEdit={canEdit}
remoteCursors={remoteCursors}
sendCursorMoved={sendCursorMoved}
onUpdateBlock={handleUpdateBlock}
onCreateBlock={handleCreateBlock}
onDeleteBlock={handleDeleteBlock}
+137 -34
View File
@@ -17,6 +17,7 @@ import {
ExternalLink,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { RemoteCursor } from '../../lib/useDocumentSync';
export interface SlashCommand {
type: BlockType;
@@ -43,6 +44,8 @@ export interface OutlinerBlockProps {
hasChildren: boolean;
isFocused: boolean;
canEdit: boolean;
activeRemoteCursors?: RemoteCursor[];
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
onFocusBlock: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
@@ -64,6 +67,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
hasChildren,
isFocused,
canEdit,
activeRemoteCursors = [],
sendCursorMoved,
onFocusBlock,
onUpdateBlock,
onCreateBlockBelow,
@@ -193,6 +198,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}
setLocalContent(val);
sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd);
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
@@ -215,6 +221,33 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}, 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>) => {
if (!canEdit) return;
@@ -321,10 +354,30 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
? 'bg-panel-elevated'
: '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)}
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 */}
{level > 0 &&
Array.from({ length: level }).map((_, lvlIdx) => (
@@ -436,8 +489,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
rows={1}
value={localContent}
readOnly={!canEdit}
onFocus={() => onFocusBlock(block.id)}
onBlur={() => debounceTimerRef.current !== null && flushDebounce(localContent)}
onFocus={handleFocus}
onBlur={handleBlur}
onClick={handleClick}
onKeyUp={handleKeyUp}
onSelect={handleSelect}
onChange={handleContentChange}
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"
@@ -453,43 +509,90 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
rows={1}
value={localContent}
readOnly={!canEdit}
onFocus={() => onFocusBlock(block.id)}
onBlur={() => debounceTimerRef.current !== null && flushDebounce(localContent)}
onFocus={handleFocus}
onBlur={handleBlur}
onClick={handleClick}
onKeyUp={handleKeyUp}
onSelect={handleSelect}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
/>
</div>
) : (
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value={localContent}
readOnly={!canEdit}
onFocus={() => onFocusBlock(block.id)}
onBlur={() => debounceTimerRef.current !== null && flushDebounce(localContent)}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
placeholder={
index === 0 && level === 0
? 'Schreibe einen Punkt… (Enter = Neu, Tab = Einrücken, Shift+Tab = Ausrücken, / für Befehle)'
: ''
}
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${
block.type === 'h1'
? 'text-lg font-bold text-main tracking-tight'
: block.type === 'h2'
? 'text-base font-semibold text-main'
: block.type === 'h3'
? 'text-sm font-semibold text-main'
: block.type === 'todo' && block.checked
? 'text-sm text-faint line-through'
: 'text-sm text-main'
}`}
/>
<div className="relative w-full">
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value={localContent}
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onClick={handleClick}
onKeyUp={handleKeyUp}
onSelect={handleSelect}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
placeholder={
index === 0 && level === 0
? 'Schreibe einen Punkt… (Enter = Neu, Tab = Einrücken, Shift+Tab = Ausrücken, / für Befehle)'
: ''
}
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${
block.type === 'h1'
? 'text-lg font-bold text-main tracking-tight'
: block.type === 'h2'
? 'text-base font-semibold text-main'
: block.type === 'h3'
? 'text-sm font-semibold text-main'
: block.type === 'todo' && block.checked
? 'text-sm text-faint line-through'
: '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 */}
@@ -1,12 +1,15 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Plus } from 'lucide-react';
import { DocumentBlock } from '../../lib/types';
import { RemoteCursor } from '../../lib/useDocumentSync';
import { OutlinerBlock } from './OutlinerBlock';
export interface OutlinerTreeProps {
docId: string;
blocks: DocumentBlock[];
canEdit: boolean;
remoteCursors?: Record<string, RemoteCursor>;
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
onDeleteBlock: (blockId: string) => void;
@@ -19,6 +22,8 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
docId,
blocks,
canEdit,
remoteCursors,
sendCursorMoved,
onUpdateBlock,
onCreateBlock,
onDeleteBlock,
@@ -255,6 +260,18 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
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 (
<div className="space-y-0.5">
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => (
@@ -266,6 +283,8 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
hasChildren={hasChildren}
isFocused={focusedBlockId === block.id}
canEdit={canEdit}
activeRemoteCursors={cursorsByBlock[block.id] || []}
sendCursorMoved={sendCursorMoved}
onFocusBlock={setFocusedBlockId}
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
@@ -864,6 +864,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<DocumentEditor
key={selectedDocId}
docId={selectedDocId}
currentUser={currentUser}
initialDoc={selectedDocObj}
availableFolders={allFolderPaths}
onDocMetadataChange={handleDocMetadataChange}
+203 -5
View File
@@ -1,9 +1,42 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { DocumentBlock } from './types';
import { getToken, api } from './api';
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 {
type:
| 'doc_state'
@@ -12,10 +45,17 @@ export interface DocSyncEvent {
| 'block_deleted'
| 'block_moved'
| 'title_updated'
| 'blocks_replaced';
| 'blocks_replaced'
| 'cursor_moved'
| 'client_left';
docId?: string;
clientId?: string;
userId?: string;
userName?: string;
userColor?: string;
selectionStart?: number;
selectionEnd?: number;
activeCursors?: RemoteCursor[];
title?: string;
block?: DocumentBlock;
blockId?: string;
@@ -32,9 +72,11 @@ export interface DocSyncEvent {
export function useDocumentSync(
docId: string | null,
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 [remoteCursors, setRemoteCursors] = useState<Record<string, RemoteCursor>>({});
const wsRef = useRef<WebSocket | null>(null);
const clientIdRef = useRef<string>(
`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 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(() => {
if (!docId) {
setStatus('offline');
setRemoteCursors({});
return;
}
@@ -68,7 +146,11 @@ export function useDocumentSync(
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(
docId
)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(currentClientId)}`;
)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(
currentClientId
)}&userName=${encodeURIComponent(localUserName)}&userColor=${encodeURIComponent(
localUserColor
)}`;
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting');
@@ -96,10 +178,55 @@ export function useDocumentSync(
try {
const parsed: DocSyncEvent = JSON.parse(event.data);
if (!parsed || !parsed.type) return;
// Echo protection: ignore events originating from our own clientId (except initial doc_state)
if (parsed.type !== 'doc_state' && parsed.clientId && parsed.clientId === currentClientId) {
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);
} catch {
// Ignore malformed frames
@@ -132,6 +259,10 @@ export function useDocumentSync(
window.clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}
if (pendingCursorTimerRef.current !== null) {
window.clearTimeout(pendingCursorTimerRef.current);
pendingCursorTimerRef.current = null;
}
if (wsRef.current) {
try {
wsRef.current.onopen = null;
@@ -145,7 +276,72 @@ export function useDocumentSync(
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(
(evt: DocSyncEvent, fallbackFn?: () => void) => {
@@ -294,6 +490,8 @@ export function useDocumentSync(
return {
status,
clientId: clientIdRef.current,
remoteCursors,
sendCursorMoved,
sendBlockCreated,
sendBlockUpdated,
sendBlockDeleted,