diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index f1f7d5e..cf67ee3 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -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 = ({ docId, + currentUser, initialDoc, availableFolders, onDocMetadataChange, @@ -210,12 +212,14 @@ export const DocumentEditor: React.FC = ({ 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 = ({ onDocMetadataChange(updatedDoc); }, [blocks, extractedTags, title]); + // Active remote users in this room (excluding self) + const activeUsers = useMemo(() => { + const list: RemoteCursor[] = []; + const seen = new Set(); + 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) => { if (!canEdit) return; const nextTitle = e.target.value; @@ -513,6 +531,34 @@ export const DocumentEditor: React.FC = ({ {/* Right Status & Action Buttons */}
+ {/* Active Presence Avatars */} + {activeUsers.length > 0 && ( +
+ {activeUsers.map((u) => { + const initials = (u.userName || 'A') + .split(' ') + .map((n) => n[0]) + .slice(0, 2) + .join('') + .toUpperCase(); + return ( +
+
+ {initials} +
+ {/* Tooltip */} +
+ {u.userName} +
+
+ ); + })} +
+ )} + {/* Sync Status Badge */} = ({ docId={docId} blocks={blocks} canEdit={canEdit} + remoteCursors={remoteCursors} + sendCursorMoved={sendCursorMoved} onUpdateBlock={handleUpdateBlock} onCreateBlock={handleCreateBlock} onDeleteBlock={handleDeleteBlock} diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index c390540..2a696fa 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -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 = ({ hasChildren, isFocused, canEdit, + activeRemoteCursors = [], + sendCursorMoved, onFocusBlock, onUpdateBlock, onCreateBlockBelow, @@ -193,6 +198,7 @@ export const OutlinerBlock: React.FC = ({ } 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 = ({ }, 150); }; + const handleFocus = (e: React.FocusEvent) => { + 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) => { + sendCursorMoved?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + sendCursorMoved?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd); + }; + + const handleSelect = (e: React.SyntheticEvent) => { + const target = e.currentTarget; + sendCursorMoved?.(block.id, target.selectionStart, target.selectionEnd); + }; + + const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null; + const handleKeyDown = (e: React.KeyboardEvent) => { if (!canEdit) return; @@ -321,10 +354,30 @@ export const OutlinerBlock: React.FC = ({ ? '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 && ( +
+ {activeRemoteCursors.map((c) => ( + + {c.userName} + + ))} +
+ )} + {/* Indentation Guide Line */} {level > 0 && Array.from({ length: level }).map((_, lvlIdx) => ( @@ -436,8 +489,11 @@ export const OutlinerBlock: React.FC = ({ 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 = ({ 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" />
) : ( -