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}