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

+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 */}