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
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user