refactor(collab): remove live document collaboration and enforce strictly read-only sharing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s

This commit is contained in:
sandrodach committed 2026-10-27 17:08:38 +01:00
1 parent aea05e868a
commit 04fa774d10
16 files changed
+179 -3556

No files matched your search

+17 -4
View File
@@ -46,7 +46,9 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
const [shareType, setShareType] = useState<'team' | 'user'>('team');
const [selectedTeamId, setSelectedTeamId] = useState('');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>('editor');
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>(
allowRoleSelection ? 'editor' : 'viewer'
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -63,7 +65,8 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
setLoading(true);
try {
await onAddShare(shareType, targetId, selectedPermission);
const permToUse = allowRoleSelection ? selectedPermission : 'viewer';
await onAddShare(shareType, targetId, permToUse);
setSelectedTeamId('');
setSelectedUser(null);
} catch (err: any) {
@@ -196,7 +199,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
</option>
))}
</select>
{allowRoleSelection && (
{allowRoleSelection ? (
<select
value={selectedPermission}
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
@@ -206,6 +209,11 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
</select>
) : (
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
<Lock className="w-3 h-3 text-faint" />
Nur Lesen
</span>
)}
</div>
) : (
@@ -220,7 +228,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
placeholder="Benutzer suchen (Name oder @Benutzername)…"
/>
</div>
{allowRoleSelection && (
{allowRoleSelection ? (
<select
value={selectedPermission}
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
@@ -230,6 +238,11 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
</select>
) : (
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
<Lock className="w-3 h-3 text-faint" />
Nur Lesen
</span>
)}
</div>
)}
File diff suppressed because it is too large. Load diff
+10 -142
View File
@@ -17,10 +17,8 @@ import {
Maximize2,
Link as LinkIcon,
FileText,
Lock,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { RemoteCursor } from '../../lib/useDocumentSync';
import { LinkModal } from './LinkModal';
export interface SlashCommand {
@@ -48,11 +46,8 @@ export interface OutlinerBlockProps {
hasChildren: boolean;
isFocused: boolean;
canEdit: boolean;
activeRemoteCursors?: RemoteCursor[];
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
onFocusBlock: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
onDeleteBlock: (blockId: string) => void;
@@ -66,23 +61,17 @@ export interface OutlinerBlockProps {
isDragTarget?: boolean;
isTargeted?: boolean;
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
isLockedByOther?: boolean;
lockedByName?: string;
lockedByColor?: string;
onBlurBlock?: (blockId: string) => void;
}
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
block,
index,
maxAllowedLevel,
hasChildren,
isFocused,
canEdit,
activeRemoteCursors = [],
sendCursorMoved,
levelOffset = 0,
onZoomBlock,
onFocusBlock,
onUpdateBlock,
onCreateBlockBelow,
onDeleteBlock,
@@ -96,10 +85,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
isDragTarget,
isTargeted,
registerTextareaRef,
isLockedByOther,
lockedByName,
lockedByColor,
onBlurBlock,
}) => {
const [localContent, setLocalContent] = useState<string>(block.content || '');
const [slashSearch, setSlashSearch] = useState<string | null>(null);
@@ -116,10 +101,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
// Callback refs to avoid stale closures in setTimeout, debounce flush, and event listeners
const onUpdateBlockRef = useRef(onUpdateBlock);
onUpdateBlockRef.current = onUpdateBlock;
const onFocusBlockRef = useRef(onFocusBlock);
onFocusBlockRef.current = onFocusBlock;
const onBlurBlockRef = useRef(onBlurBlock);
onBlurBlockRef.current = onBlurBlock;
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
onCreateBlockBelowRef.current = onCreateBlockBelow;
const onDeleteBlockRef = useRef(onDeleteBlock);
@@ -128,8 +109,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onIndentBlockRef.current = onIndentBlock;
const onNavigateFocusRef = useRef(onNavigateFocus);
onNavigateFocusRef.current = onNavigateFocus;
const sendCursorMovedRef = useRef(sendCursorMoved);
sendCursorMovedRef.current = sendCursorMoved;
// Track if this input currently has actual focus in an active browser window
const isFocusedRef = useRef<boolean>(false);
@@ -177,7 +156,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
flushDebounce(localContentRef.current);
}
isFocusedRef.current = false;
sendCursorMovedRef.current?.(null);
};
const handleWindowFocus = () => {
@@ -290,7 +268,6 @@ 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);
@@ -313,16 +290,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}, 150);
};
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
if (isLockedByOther) return;
const handleFocus = () => {
isFocusedRef.current = true;
onFocusBlockRef.current(block.id);
sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd);
};
const handleBlur = () => {
isFocusedRef.current = false;
if (isLockedByOther) return;
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
@@ -330,27 +303,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} else if (localContentRef.current !== latestBlockRef.current.content) {
flushDebounce(localContentRef.current);
}
sendCursorMovedRef.current?.(null);
onBlurBlockRef.current?.(block.id);
};
const handleClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
};
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
};
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const target = e.currentTarget;
sendCursorMovedRef.current?.(block.id, target.selectionStart, target.selectionEnd);
};
const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null;
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!canEdit || isLockedByOther) return;
if (!canEdit) return;
// Slash command navigation
if (slashSearch !== null && filteredSlashCommands.length > 0) {
@@ -460,50 +416,16 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
? 'border-t-2 border-accent bg-accent/10'
: isTargeted
? 'bg-accent/15 ring-2 ring-accent'
: isLockedByOther
? 'bg-panel-elevated/80 border border-border/60 shadow-xs'
: isFocused
? 'bg-panel-elevated'
: 'hover:bg-panel'
}`}
style={{
paddingLeft: `${visualLevel * 22 + 4}px`,
...(isLockedByOther && lockedByColor
? { borderLeft: `3px solid ${lockedByColor}` }
: primaryRemoteCursor
? { borderLeft: `2px solid ${primaryRemoteCursor.userColor}` }
: {}),
}}
onDragOver={(e) => onDragOverBlock?.(e, index)}
onDrop={() => onDropBlock?.(index)}
>
{/* Right-aligned Lock Indicator (Zero vertical height impact, keeps bullet & line aligned) */}
{isLockedByOther && (
<div
className="absolute right-2 top-1.5 z-10 flex items-center gap-1 px-2 py-0.5 rounded-pill text-[10px] font-semibold text-white shadow-xs select-none pointer-events-none"
style={{ backgroundColor: lockedByColor || '#64748b' }}
title={`Wird gerade bearbeitet von ${lockedByName || 'anderer Nutzer'}`}
>
<Lock className="w-2.5 h-2.5 shrink-0" />
<span className="truncate max-w-[120px]">{lockedByName || 'Gesperrt'}</span>
</div>
)}
{/* 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 */}
{visualLevel > 0 &&
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
@@ -516,7 +438,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */}
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
{canEdit && !isLockedByOther && (
{canEdit && (
<button
type="button"
draggable
@@ -550,7 +472,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{block.type === 'todo' ? (
<button
type="button"
disabled={!canEdit || isLockedByOther}
disabled={!canEdit}
onClick={() =>
flushDebounce(localContent, { checked: !block.checked })
}
@@ -608,17 +530,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}}
rows={1}
value={localContent}
readOnly={!canEdit || isLockedByOther}
readOnly={!canEdit}
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 ${
isLockedByOther ? 'pr-28 cursor-not-allowed opacity-90' : ''
}`}
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
/>
</div>
) : block.type === 'callout' ? (
@@ -630,17 +547,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}}
rows={1}
value={localContent}
readOnly={!canEdit || isLockedByOther}
readOnly={!canEdit}
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 ${
isLockedByOther ? 'pr-28 cursor-not-allowed opacity-90' : ''
}`}
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
/>
</div>
) : (
@@ -652,12 +564,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}}
rows={1}
value={localContent}
readOnly={!canEdit || isLockedByOther}
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onClick={handleClick}
onKeyUp={handleKeyUp}
onSelect={handleSelect}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
placeholder={
@@ -666,8 +575,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
: ''
}
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${
isLockedByOther ? 'pr-28 cursor-not-allowed opacity-90' : ''
} ${
block.type === 'h1'
? 'text-lg font-bold text-main tracking-tight'
: block.type === 'h2'
@@ -679,45 +586,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
: '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>
)}
@@ -1,7 +1,6 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Plus } from 'lucide-react';
import { DocumentBlock, BlockLockInfo } from '../../lib/types';
import { RemoteCursor } from '../../lib/useDocumentSync';
import { DocumentBlock } from '../../lib/types';
import { OutlinerBlock } from './OutlinerBlock';
export interface OutlinerTreeProps {
@@ -10,8 +9,6 @@ export interface OutlinerTreeProps {
canEdit: boolean;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
remoteCursors?: Record<string, RemoteCursor>;
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
@@ -20,10 +17,6 @@ export interface OutlinerTreeProps {
onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
initialFocusedBlockId?: string | null;
activeBlockLocks?: Record<string, BlockLockInfo>;
onFocusBlock?: (blockId: string) => void;
onBlurBlock?: (blockId: string) => void;
currentUserId?: string;
}
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
@@ -32,8 +25,6 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
canEdit,
levelOffset = 0,
onZoomBlock,
remoteCursors,
sendCursorMoved,
onUpdateBlock,
onUpdateBlocks,
onCreateBlock,
@@ -42,10 +33,6 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
onTagClick,
onWikiLinkClick,
initialFocusedBlockId,
activeBlockLocks,
onFocusBlock,
onBlurBlock,
currentUserId,
}) => {
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
@@ -371,24 +358,9 @@ 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 }) => {
const lockInfo = activeBlockLocks?.[block.id];
const isLockedByOther = Boolean(lockInfo && currentUserId && lockInfo.userId !== currentUserId);
return (
<OutlinerBlock
key={block.id}
@@ -400,15 +372,6 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={onZoomBlock}
activeRemoteCursors={cursorsByBlock[block.id] || []}
sendCursorMoved={sendCursorMoved}
onFocusBlock={(bId) => {
setFocusedBlockId(bId);
onFocusBlock?.(bId);
}}
onBlurBlock={(bId) => {
onBlurBlock?.(bId);
}}
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
onDeleteBlock={handleDeleteBlock}
@@ -422,9 +385,6 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
isDragTarget={dragOverIndex === index && draggedIndex !== index}
isTargeted={highlightedBlockId === block.id}
registerTextareaRef={registerTextareaRef}
isLockedByOther={isLockedByOther}
lockedByName={lockInfo?.userName}
lockedByColor={lockInfo?.userColor}
/>
);
})}
+50 -201
View File
@@ -48,10 +48,9 @@ import {
getRealtimeTransport,
RealtimeTransport,
} from '../../lib/api';
import { DocSyncStatus } from '../../lib/useDocumentSync';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus, DocLiveState } from './DocumentEditor';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
interface WorkspaceViewProps {
currentUser?: User | null;
@@ -328,13 +327,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
// Realtime connection status for top header indicator (identical to SharedListsView)
const [realtimeConnected, setRealtimeConnected] = useState<boolean>(() => isRealtimeConnected());
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>(() => getRealtimeTransport());
const [activeDocSyncStatus, setActiveDocSyncStatus] = useState<DocSyncStatus>('connected');
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
const [docSaveError, setDocSaveError] = useState<string | null>(null);
const [docLiveState, setDocLiveState] = useState<DocLiveState>('idle');
const [docLiveInitiator, setDocLiveInitiator] = useState<string | null>(null);
const [showWsBlockedModal, setShowWsBlockedModal] = useState<boolean>(false);
const liveToggleRef = useRef<(() => void) | null>(null);
useEffect(() => {
const unsub = onRealtimeStatus((connected, transport) => {
@@ -1168,136 +1162,52 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* Right Header Section: Save Status & Live Collaboration Button */}
<div className="flex items-center gap-2">
{subView === 'editor' && selectedDocId ? (
<>
{/* Save Status Pill */}
<div
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
docSaveStatus === 'saved'
? 'bg-canvas border-border text-muted'
: docSaveStatus === 'saving'
? 'bg-panel border-accent/40 text-accent animate-pulse'
: docSaveStatus === 'unsaved'
? 'bg-status-warn/10 border-status-warn/30 text-status-warn'
: 'bg-status-err/10 border-status-err/30 text-status-err'
}`}
title={
docSaveStatus === 'saved'
? 'Alle Änderungen per HTTPS gesichert'
: docSaveStatus === 'saving'
? 'Änderungen werden per HTTPS gespeichert…'
: docSaveStatus === 'unsaved'
? 'Ungespeicherte Änderungen (Auto-Save aktiv)'
: docSaveError || 'Fehler beim Speichern'
}
>
{docSaveStatus === 'saved' && (
<>
<Check className="w-3.5 h-3.5 text-status-ok" />
<span>Gespeichert</span>
</>
)}
{docSaveStatus === 'saving' && (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
<span>Speichert…</span>
</>
)}
{docSaveStatus === 'unsaved' && (
<>
<Clock className="w-3.5 h-3.5 text-status-warn" />
<span>Ungespeichert</span>
</>
)}
{docSaveStatus === 'error' && (
<>
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
<span>Fehler</span>
</>
)}
</div>
{/* Interactive Live Collaboration Button (replaces passive radio icon) */}
{docLiveState === 'locked_by_other' ? (
<div className="inline-flex items-center gap-1.5">
<div
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-warn/15 border border-status-warn/40 text-status-warn select-none"
title={`Live-Zusammenarbeit aktiv durch ${docLiveInitiator || 'anderer Nutzer'}`}
>
<Lock className="w-3.5 h-3.5 shrink-0" />
<span className="truncate max-w-[120px]">Live: {docLiveInitiator || 'Gesperrt'}</span>
</div>
{(selectedDocObj?.isOwner || selectedDocObj?.permission === 'owner') && (
<button
type="button"
onClick={() => liveToggleRef.current?.()}
className="px-2 py-1 rounded-btn text-xs font-semibold bg-status-warn/20 border border-status-warn/40 text-status-warn hover:bg-status-warn/30 transition-colors cursor-pointer"
title="Als Notiz-Besitzer Live-Sitzung beenden und Dokument freigeben"
>
Freigeben
</button>
)}
</div>
) : docLiveState === 'blocked' ? (
<div className="inline-flex items-center gap-1.5">
<button
type="button"
onClick={() => setShowWsBlockedModal(true)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-err/15 border border-status-err/40 text-status-err hover:bg-status-err/25 transition-colors cursor-pointer"
title="WebSockets im Firmennetzwerk blockiert – Hier klicken für Infos"
>
<AlertTriangle className="w-3.5 h-3.5 text-status-err animate-pulse shrink-0" />
<span>Live blockiert</span>
</button>
<button
type="button"
onClick={() => liveToggleRef.current?.()}
className="px-2 py-1 rounded-btn text-xs font-medium bg-canvas border border-border text-muted hover:text-status-err hover:border-status-err/50 transition-colors cursor-pointer"
title="Live-Sitzung beenden"
>
Beenden
</button>
</div>
) : docLiveState === 'connecting' ? (
<div className="inline-flex items-center gap-1.5">
<button
type="button"
disabled
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-amber-500/15 border border-amber-500/40 text-amber-500 opacity-80 cursor-wait"
>
<Loader2 className="w-3.5 h-3.5 animate-spin text-amber-500 shrink-0" />
<span>Verbinde…</span>
</button>
<button
type="button"
onClick={() => liveToggleRef.current?.()}
className="px-2 py-1 rounded-btn text-xs font-medium bg-canvas border border-border text-muted hover:text-status-err hover:border-status-err/50 transition-colors cursor-pointer"
title="Verbindungsaufbau abbrechen"
>
Abbrechen
</button>
</div>
) : docLiveState === 'active' ? (
<button
type="button"
onClick={() => liveToggleRef.current?.()}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-semibold bg-emerald-500/15 border border-emerald-500/40 text-emerald-500 hover:bg-emerald-500/25 transition-colors cursor-pointer"
title="Live-Zusammenarbeit beenden (Tastendruck / WebSocket)"
>
<Radio className="w-3.5 h-3.5 text-emerald-500 animate-pulse shrink-0" />
<span>Live aktiv</span>
</button>
) : (
<button
type="button"
onClick={() => liveToggleRef.current?.()}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-canvas border border-border text-muted hover:text-main hover:border-accent transition-colors cursor-pointer"
title="Live-Zusammenarbeit aktivieren (WebSockets)"
>
<Radio className="w-3.5 h-3.5 text-muted hover:text-accent shrink-0" />
<span>Live</span>
</button>
/* Save Status Pill */
<div
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
docSaveStatus === 'saved'
? 'bg-canvas border-border text-muted'
: docSaveStatus === 'saving'
? 'bg-panel border-accent/40 text-accent animate-pulse'
: docSaveStatus === 'unsaved'
? 'bg-status-warn/10 border-status-warn/30 text-status-warn'
: 'bg-status-err/10 border-status-err/30 text-status-err'
}`}
title={
docSaveStatus === 'saved'
? 'Alle Änderungen per HTTPS gesichert'
: docSaveStatus === 'saving'
? 'Änderungen werden per HTTPS gespeichert…'
: docSaveStatus === 'unsaved'
? 'Ungespeicherte Änderungen (Auto-Save aktiv)'
: docSaveError || 'Fehler beim Speichern'
}
>
{docSaveStatus === 'saved' && (
<>
<Check className="w-3.5 h-3.5 text-status-ok" />
<span>Gespeichert</span>
</>
)}
</>
{docSaveStatus === 'saving' && (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
<span>Speichert…</span>
</>
)}
{docSaveStatus === 'unsaved' && (
<>
<Clock className="w-3.5 h-3.5 text-status-warn" />
<span>Ungespeichert</span>
</>
)}
{docSaveStatus === 'error' && (
<>
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
<span>Fehler</span>
</>
)}
</div>
) : (
/* Standalone Realtime Status Indicator for Todos / Hashtags */
(() => {
@@ -1568,18 +1478,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onBackMobile={() => setMobileShowDetail(false)}
onSyncStatusChange={setActiveDocSyncStatus}
onSaveStatusChange={(status, err) => {
setDocSaveStatus(status);
setDocSaveError(err || null);
}}
onLiveStateChange={(state, initiator) => {
setDocLiveState(state);
setDocLiveInitiator(initiator || null);
}}
registerLiveToggle={(handler) => {
liveToggleRef.current = handler;
}}
/>
) : subView === 'tags' ? (
/* =========================================================================
@@ -2535,14 +2437,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
shares={unifiedShares}
teams={teams}
collaborators={collaborators}
allowRoleSelection={!shareModalDoc}
onAddShare={async (targetType, targetId, permission) => {
const perm = permission === 'editor' ? 'write' : 'read';
const perm = (!shareModalDoc && permission === 'editor') ? 'write' : 'read';
if (shareModalDoc) {
await api.createDocShare(
shareModalDoc.id,
targetType === 'team'
? { teamId: targetId, permission: perm }
: { userId: targetId, permission: perm }
? { teamId: targetId, permission: 'read' }
: { userId: targetId, permission: 'read' }
);
const updatedShares = await api.getDocShares(shareModalDoc.id);
setActiveDocShares(updatedShares);
@@ -2569,60 +2472,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}}
/>
)}
{/* WebSocket Blocked Modal */}
{showWsBlockedModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150">
<div className="w-full max-w-md bg-canvas border border-border rounded-card shadow-modal p-6 space-y-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-status-err/15 flex items-center justify-center shrink-0">
<AlertTriangle className="w-5 h-5 text-status-err" />
</div>
<div className="flex-1">
<h3 className="text-sm font-bold text-main">
WebSockets in diesem Netzwerk blockiert
</h3>
<p className="text-xs text-muted mt-1 leading-relaxed">
Deine Firewall oder dein Firmen-Proxy blockiert WebSocket-Verbindungen (<code className="text-accent bg-panel px-1 py-0.5 rounded">ws://</code> / <code className="text-accent bg-panel px-1 py-0.5 rounded">wss://</code>).
</p>
</div>
</div>
<div className="bg-surface-subtle border border-border rounded-btn p-3 text-xs space-y-2 text-main">
<div className="flex items-center gap-2 text-status-ok font-semibold">
<Check className="w-4 h-4 shrink-0" />
<span>Kein Datenverlust: Auto-Save per HTTPS ist aktiv!</span>
</div>
<p className="text-muted leading-relaxed">
Jede getippte Zeile und alle Änderungen werden zuverlässig über Standard-HTTPS-Anfragen im Hintergrund gespeichert.
</p>
<p className="text-muted text-[11px] leading-relaxed">
Lediglich die gleichzeitige Tastendruck-Echtzeitanzeige mit anderen Nutzern im selben Moment ist ohne WebSockets eingeschränkt.
</p>
</div>
<div className="flex justify-between items-center pt-2">
<button
type="button"
onClick={() => {
liveToggleRef.current?.();
setShowWsBlockedModal(false);
}}
className="px-3 py-1.5 text-xs font-medium bg-status-err/15 border border-status-err/40 text-status-err hover:bg-status-err/25 rounded-btn transition-colors cursor-pointer"
>
Live-Sitzung beenden
</button>
<button
type="button"
onClick={() => setShowWsBlockedModal(false)}
className="btn-action px-4 py-1.5 text-xs font-semibold bg-accent text-on-accent hover:opacity-90 transition-opacity cursor-pointer"
>
Verstanden
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
-44
View File
@@ -5,10 +5,6 @@ import {
Document,
DocumentBlock,
DocShare,
DocLockInfo,
BlockLockInfo,
LiveSessionInfo,
DocLocksResponse,
KanbanColumn,
ListItem,
ListShare,
@@ -641,46 +637,6 @@ export const api = {
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
},
async getDocLocks(docId: string): Promise<DocLocksResponse> {
const res = await request<DocLocksResponse>(`/api/v1/documents/${docId}/locks`);
return {
liveSession: res.liveSession || null,
blockLocks: ensureArray(res.blockLocks),
};
},
async lockBlock(docId: string, blockId: string, userColor?: string, ttlSeconds: number = 30): Promise<BlockLockInfo> {
console.log(`[MindSift Lock] Lock anfordern für Block "${blockId}" in Notiz "${docId}"...`);
const res = await request<any>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/lock`, {
method: 'POST',
body: JSON.stringify({ userColor, ttlSeconds, ttl: ttlSeconds }),
});
console.log(`[MindSift Lock] Lock erfolgreich erhalten für "${blockId}":`, res?.lock || res);
return res?.lock || res;
},
async unlockBlock(docId: string, blockId: string): Promise<{ status: string }> {
console.log(`[MindSift Lock] Lock freigeben für Block "${blockId}" in Notiz "${docId}"...`);
const res = await request<{ status: string }>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/unlock`, {
method: 'POST',
});
console.log(`[MindSift Lock] Lock freigegeben für "${blockId}"`);
return res;
},
async startLiveSession(docId: string): Promise<LiveSessionInfo> {
const res = await request<any>(`/api/v1/documents/${docId}/live/start`, {
method: 'POST',
});
return res?.session || res;
},
async endLiveSession(docId: string): Promise<{ status: string }> {
return request<{ status: string }>(`/api/v1/documents/${docId}/live/end`, {
method: 'POST',
});
},
async moveDocument(id: string, folderPath: string): Promise<Document> {
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
method: 'PUT',
-29
View File
@@ -200,35 +200,6 @@ export interface DocShare {
createdAt: string;
}
export interface DocLockInfo {
docId: string;
userId: string;
userName: string;
expiresAt: string;
}
export interface BlockLockInfo {
docId: string;
blockId: string;
userId: string;
userName: string;
userColor?: string;
expiresAt: string;
}
export interface LiveSessionInfo {
docId: string;
initiatorId: string;
initiatorName: string;
startedAt: string;
expiresAt: string;
}
export interface DocLocksResponse {
liveSession?: LiveSessionInfo | null;
blockLocks: BlockLockInfo[];
}
export interface Document {
id: string;
userId: string;
-507
View File
@@ -1,507 +0,0 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { DocumentBlock } from './types';
import { getToken, api } from './api';
export type DocSyncStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'offline' | 'blocked';
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'
| 'block_created'
| 'block_updated'
| 'block_deleted'
| 'block_moved'
| 'title_updated'
| '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;
afterBlockId?: string;
newIndex?: number;
content?: string;
level?: number;
checked?: boolean;
collapsed?: boolean;
blockType?: string;
blocks?: DocumentBlock[];
}
export function useDocumentSync(
docId: string | null,
onRemoteEvent: (evt: DocSyncEvent) => void,
canEdit: boolean = true,
currentUser?: { id: string; email?: string; displayName?: string } | null,
enabled: boolean = false
) {
const [status, setStatus] = useState<DocSyncStatus>(enabled ? 'connecting' : 'idle');
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)}`
);
const onRemoteEventRef = useRef(onRemoteEvent);
onRemoteEventRef.current = onRemoteEvent;
const docIdRef = useRef(docId);
docIdRef.current = docId;
const canEditRef = useRef(canEdit);
canEditRef.current = canEdit;
const currentUserRef = useRef(currentUser);
currentUserRef.current = currentUser;
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 localUserNameRef = useRef(localUserName);
localUserNameRef.current = localUserName;
const localUserColorRef = useRef(localUserColor);
localUserColorRef.current = localUserColor;
const pendingMessagesQueueRef = useRef<string[]>([]);
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 || !enabled) {
setStatus('idle');
setRemoteCursors({});
pendingMessagesQueueRef.current = [];
if (wsRef.current) {
try {
wsRef.current.close();
} catch {}
wsRef.current = null;
}
return;
}
pendingMessagesQueueRef.current = [];
let isDisposed = false;
let retryCount = 0;
const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
clientIdRef.current = currentClientId;
const connect = () => {
if (isDisposed) return;
if (typeof window !== 'undefined' && sessionStorage.getItem('mindsift_ws_blocked') === 'true') {
console.info('[DocSync] WebSockets im Firmennetzwerk blockiert (Cache). Live-Transport übersprungen.');
setStatus('blocked');
return;
}
const token = getToken();
if (!token) {
setStatus('offline');
return;
}
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
)}&userName=${encodeURIComponent(localUserName)}&userColor=${encodeURIComponent(
localUserColor
)}`;
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting');
let ws: WebSocket;
try {
ws = new WebSocket(wsUrl);
} catch {
setStatus('blocked');
return;
}
wsRef.current = ws;
const handshakeTimer = window.setTimeout(() => {
if (ws.readyState !== WebSocket.OPEN) {
console.warn('[DocSync] WebSocket handshake timed out (corporate firewall/proxy block detected).');
setStatus('blocked');
try {
ws.close();
} catch {}
}
}, 4500);
ws.onopen = () => {
window.clearTimeout(handshakeTimer);
if (isDisposed) {
ws.close();
return;
}
retryCount = 0;
setStatus('connected');
// Flush any queued messages buffered while connecting or reconnecting
if (pendingMessagesQueueRef.current.length > 0) {
const queue = [...pendingMessagesQueueRef.current];
pendingMessagesQueueRef.current = [];
for (const msg of queue) {
try {
ws.send(msg);
} catch (err) {
console.warn('[DocSync] Failed to flush queued message:', err);
}
}
}
};
ws.onmessage = (event) => {
if (isDisposed) return;
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
}
};
ws.onerror = () => {
// Handled in onclose
};
ws.onclose = () => {
window.clearTimeout(handshakeTimer);
if (isDisposed) return;
wsRef.current = null;
retryCount += 1;
if (retryCount >= 2) {
setStatus('blocked');
} else {
setStatus('reconnecting');
const delay = Math.min(1000 * Math.pow(1.6, retryCount), 4000);
reconnectTimerRef.current = window.setTimeout(connect, delay);
}
};
};
connect();
return () => {
isDisposed = true;
if (reconnectTimerRef.current !== null) {
window.clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (fallbackTimerRef.current !== null) {
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;
wsRef.current.onmessage = null;
wsRef.current.onerror = null;
wsRef.current.onclose = null;
wsRef.current.close();
} catch {
// ignore
}
wsRef.current = null;
}
};
}, [docId, enabled, localUserName, localUserColor]);
const executeSendCursor = useCallback(
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
const ws = wsRef.current;
const activeDocId = docIdRef.current;
if (!activeDocId || !ws || ws.readyState !== WebSocket.OPEN) return;
const payload: DocSyncEvent = {
type: 'cursor_moved',
docId: activeDocId,
clientId: clientIdRef.current,
userId: currentUserRef.current?.id,
userName: localUserNameRef.current,
userColor: localUserColorRef.current,
blockId: blockId || undefined,
selectionStart,
selectionEnd,
};
try {
ws.send(JSON.stringify(payload));
} catch {
// Ephemeral cursor, ignore send failure
}
lastCursorSendRef.current = Date.now();
},
[]
);
const sendCursorMoved = useCallback(
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
if (!docIdRef.current) 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);
}
}
},
[executeSendCursor]
);
const sendRawEvent = useCallback(
(evt: DocSyncEvent) => {
const activeDocId = docIdRef.current;
const activeCanEdit = canEditRef.current;
if (!activeDocId || !activeCanEdit) return;
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const payload: DocSyncEvent = {
...evt,
docId: activeDocId,
clientId: clientIdRef.current,
};
try {
ws.send(JSON.stringify(payload));
} catch (err) {
console.warn('[DocSync] Failed to send WebSocket message:', err);
}
},
[]
);
const sendBlockCreated = useCallback(
(block: DocumentBlock, afterBlockId?: string, _allBlocks?: DocumentBlock[]) => {
sendRawEvent({
type: 'block_created',
block,
afterBlockId,
});
},
[sendRawEvent]
);
const sendBlockUpdated = useCallback(
(block: DocumentBlock) => {
sendRawEvent({
type: 'block_updated',
blockId: block.id,
block,
});
},
[sendRawEvent]
);
const sendBlockDeleted = useCallback(
(blockId: string, _allBlocks?: DocumentBlock[]) => {
sendRawEvent({
type: 'block_deleted',
blockId,
});
},
[sendRawEvent]
);
const sendBlockMoved = useCallback(
(blockId: string, newIndex: number, level?: number, _allBlocks?: DocumentBlock[]) => {
sendRawEvent({
type: 'block_moved',
blockId,
newIndex,
level,
});
},
[sendRawEvent]
);
const sendTitleUpdated = useCallback(
(title: string) => {
sendRawEvent({
type: 'title_updated',
title,
});
},
[sendRawEvent]
);
const sendBlocksReplaced = useCallback(
(blocks: DocumentBlock[]) => {
sendRawEvent({
type: 'blocks_replaced',
blocks,
});
},
[sendRawEvent]
);
return {
status,
clientId: clientIdRef.current,
remoteCursors,
sendCursorMoved,
sendBlockCreated,
sendBlockUpdated,
sendBlockDeleted,
sendBlockMoved,
sendTitleUpdated,
sendBlocksReplaced,
};
}