refactor(crdt): implement proxy-resilient Yjs dual-transport sync with indexeddb offline-first and debounced sqlite persistence
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m11s

This commit is contained in:
sandrodach committed 2026-10-26 19:54:51 +01:00
1 parent 297f3ed111
commit f3b3f07e04
10 files changed
+1248 -484

No files matched your search

+101 -358
View File
@@ -56,6 +56,7 @@ import { UserAutocomplete } from '../UserAutocomplete';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentCRDTSession, CollaboratorAwareness, getCollaboratorColor } from '../../lib/crdt';
import { YjsHybridProvider, SyncTransportStatus } from '../../lib/yjs-provider';
interface WorkspaceViewProps {
currentUser?: User | null;
@@ -226,23 +227,18 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [showFolderShareModal, setShowFolderShareModal] = useState(false);
const [folderShares, setFolderShares] = useState<FolderShare[]>([]);
// --- DOCUMENT CRDT & COLLABORATION STATE ---
// --- DOCUMENT CRDT & HYBRID TRANSPORT STATE ---
const crdtSessionRef = useRef<DocumentCRDTSession | null>(null);
const providerRef = useRef<YjsHybridProvider | null>(null);
const [syncStatus, setSyncStatus] = useState<SyncTransportStatus>('offline');
const [activePeers, setActivePeers] = useState<CollaboratorAwareness[]>([]);
// --- DOCUMENT LOCKING STATE (FALLBACK) ---
const [activeLock, setActiveLock] = useState<DocLockInfo | null>(null);
const [holdsLock, setHoldsLock] = useState(false);
const holdsLockRef = useRef(false);
const heartbeatTimerRef = useRef<any>(null);
const idleUnlockTimerRef = useRef<any>(null);
const currentDocIdRef = useRef<string | null>(null);
const isEditorFocusedRef = useRef(false);
const isDraggingBlockRef = useRef(false);
const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({});
// With CRDT, multiple users can write simultaneously without lockouts
// Collaborative CRDT: multi-user writing with zero lockouts
const isLockedByOther = false;
const isReadOnly = selectedDoc?.permission === 'read';
@@ -369,69 +365,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return mergedOrder.map((id) => blockMap.get(id)!).filter(Boolean);
};
const stopHeartbeat = () => {
if (heartbeatTimerRef.current) {
clearInterval(heartbeatTimerRef.current);
heartbeatTimerRef.current = null;
}
};
const startHeartbeat = (docId: string) => {
stopHeartbeat();
heartbeatTimerRef.current = setInterval(async () => {
if (!holdsLockRef.current) {
stopHeartbeat();
return;
}
const res = await api.heartbeatDocLock(docId);
if (!res.extended) {
holdsLockRef.current = false;
setHoldsLock(false);
stopHeartbeat();
}
}, 4000);
};
const releaseMyLock = async (docId?: string) => {
const targetDocId = docId || currentDocIdRef.current;
if (!targetDocId) return;
const wasHolding = holdsLockRef.current;
stopHeartbeat();
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
idleUnlockTimerRef.current = null;
}
holdsLockRef.current = false;
setHoldsLock(false);
if (wasHolding || docId) {
try {
await api.unlockDocument(targetDocId);
} catch {}
}
};
const ensureLock = async (force = false): Promise<boolean> => {
if (!selectedDoc || selectedDoc.permission === 'read') return false;
if (holdsLockRef.current && !force) {
return true;
}
try {
const res = await api.lockDocument(selectedDoc.id);
if (res.acquired) {
holdsLockRef.current = true;
setHoldsLock(true);
setActiveLock(null);
startHeartbeat(selectedDoc.id);
return true;
} else if (res.lock) {
setActiveLock(res.lock);
return false;
}
} catch (e) {
console.error('Failed to acquire lock', e);
}
return false;
};
const releaseMyLock = async (_docId?: string) => {};
const broadcastAwareness = (blockId: string | null) => {
if (!selectedDoc) return;
@@ -442,43 +376,28 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
color: getCollaboratorColor(currentUser?.id || ''),
lastActive: Date.now(),
};
sendWebSocketMessage({
event: 'CRDT_AWARENESS',
docId: selectedDoc.id,
payload,
});
if (providerRef.current) {
providerRef.current.sendAwareness(payload);
} else {
sendWebSocketMessage({
event: 'CRDT_AWARENESS',
docId: selectedDoc.id,
payload,
});
}
};
const handleBlockFocus = (blockIdOrEvent?: string | React.FocusEvent<any>) => {
const blockId = typeof blockIdOrEvent === 'string' ? blockIdOrEvent : undefined;
isEditorFocusedRef.current = true;
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
idleUnlockTimerRef.current = null;
}
if (blockId) {
broadcastAwareness(blockId);
}
if (!isReadOnly) {
ensureLock();
}
};
const handleBlockBlur = () => {
isEditorFocusedRef.current = false;
broadcastAwareness(null);
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
}
if (isDraggingBlockRef.current) {
return;
}
idleUnlockTimerRef.current = setTimeout(async () => {
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
await flushPendingSave();
await releaseMyLock();
}
}, 8000);
};
// Debounced auto-save refs (500ms delay)
@@ -491,29 +410,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
isSavingToBackendRef.current = true;
try {
const docId = docToSave.id;
const dirtyMap = localDirtyBlocksRef.current.get(docId);
const deletedSet = localDeletedBlocksRef.current.get(docId);
const srvBlocks = syncedBlocksRef.current.get(docId);
const srvOrder = syncedOrderRef.current.get(docId) || [];
const upsertedMap = new Map<string, DocumentBlock>();
if (dirtyMap) {
dirtyMap.forEach((b, id) => upsertedMap.set(id, b));
}
(docToSave.blocks || []).forEach((b) => {
const srv = srvBlocks?.get(b.id);
if (!srv || !blocksAreEqual(b, srv)) {
upsertedMap.set(b.id, b);
}
});
const upsertedToSend = Array.from(upsertedMap.values());
const deletedToSend = Array.from(deletedSet?.values() || []);
const currentOrder = (docToSave.blocks || []).map((b) => b.id);
const orderChanged =
currentOrder.length !== srvOrder.length ||
currentOrder.some((id, idx) => id !== srvOrder[idx]);
const srvMeta = syncedDocMetaRef.current.get(docId);
const cleanTags = (docToSave.tags || []).filter((t) => !t.startsWith('folder:'));
const cleanTagsJson = JSON.stringify(cleanTags);
@@ -524,36 +420,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
srvMeta.tagsJson !== cleanTagsJson ||
srvMeta.isPinned !== !!docToSave.isPinned;
// Clear the sent snapshot from dirty/deleted maps before awaiting network call
if (dirtyMap) {
upsertedToSend.forEach((b) => {
const cur = dirtyMap.get(b.id);
if (cur && blocksAreEqual(cur, b)) {
dirtyMap.delete(b.id);
}
});
}
if (deletedSet) {
deletedToSend.forEach((id) => deletedSet.delete(id));
}
if (upsertedToSend.length > 0 || deletedToSend.length > 0 || orderChanged) {
const patchedDoc = await api.patchDocumentBlocks(docId, {
upserted: upsertedToSend,
deletedIds: deletedToSend,
blockOrder: currentOrder,
});
// Update server block baseline with patched result
const nextSrvMap = new Map<string, DocumentBlock>();
const nextSrvOrder: string[] = [];
(patchedDoc.blocks || []).forEach((b) => {
nextSrvMap.set(b.id, { ...b });
nextSrvOrder.push(b.id);
});
syncedBlocksRef.current.set(docId, nextSrvMap);
syncedOrderRef.current.set(docId, nextSrvOrder);
}
if (metaChanged) {
await api.updateDocument(docId, {
title: docToSave.title,
@@ -576,7 +442,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
api.getTagDetails(selectedTag).then(setTagDetails).catch(() => {});
}
} catch (e: any) {
console.error('Failed to save document to backend', e);
console.error('Failed to save document metadata to backend', e);
} finally {
isSavingToBackendRef.current = false;
if (saveSucceeded) {
@@ -585,17 +451,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setSaveState('saved');
}
} else {
setSaveState('saving');
}
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
}
idleUnlockTimerRef.current = setTimeout(async () => {
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
await releaseMyLock();
}
}, 8000);
setSaveState('saved');
}
}
};
@@ -614,48 +470,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const persistDocument = async (updatedDoc: Document, immediate = false) => {
if (isReadOnly) return;
ensureLock();
lastLocalEditTimestampRef.current = Date.now();
// Record block-level delta against previous local state and server baseline
const docId = updatedDoc.id;
let dirtyMap = localDirtyBlocksRef.current.get(docId);
if (!dirtyMap) {
dirtyMap = new Map<string, DocumentBlock>();
localDirtyBlocksRef.current.set(docId, dirtyMap);
}
let deletedSet = localDeletedBlocksRef.current.get(docId);
if (!deletedSet) {
deletedSet = new Set<string>();
localDeletedBlocksRef.current.set(docId, deletedSet);
}
const srvBlocks = syncedBlocksRef.current.get(docId);
const prevLocalBlocks =
selectedDoc && selectedDoc.id === docId ? selectedDoc.blocks || [] : [];
const nextIds = new Set<string>();
(updatedDoc.blocks || []).forEach((b) => {
nextIds.add(b.id);
deletedSet!.delete(b.id);
const srv = srvBlocks?.get(b.id);
if (!srv || !blocksAreEqual(b, srv)) {
dirtyMap!.set(b.id, { ...b });
}
});
prevLocalBlocks.forEach((prevB) => {
if (!nextIds.has(prevB.id)) {
dirtyMap!.delete(prevB.id);
deletedSet!.add(prevB.id);
}
});
// Sync into active CRDT session
// 1. Sync into active CRDT session
if (crdtSessionRef.current && crdtSessionRef.current.docId === updatedDoc.id) {
crdtSessionRef.current.syncFullBlocks(updatedDoc.blocks || []);
}
// 1. Update local UI state immediately for responsive, instant typing
// 2. Update local UI state immediately for responsive, instant typing
setSelectedDoc(updatedDoc);
setDocs((prev) => prev.map((d) => (d.id === updatedDoc.id ? updatedDoc : d)));
setSaveState('saving');
@@ -668,7 +490,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
if (immediate) {
await flushPendingSave();
await executeSaveToBackend(updatedDoc);
} else {
debounceSaveTimerRef.current = setTimeout(async () => {
debounceSaveTimerRef.current = null;
@@ -677,7 +499,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
pendingSaveDocRef.current = null;
await executeSaveToBackend(doc);
}
}, 350);
}, 500);
}
};
@@ -776,13 +598,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const currentUserIdRef = useRef<string | null>(null);
currentUserIdRef.current = currentUser?.id || null;
// Handle document switch, CRDT session lifecycle, and lock status checks
// Handle document switch, Yjs CRDT session & YjsHybridProvider lifecycle
useEffect(() => {
const prevId = currentDocIdRef.current;
if (prevId && prevId !== selectedDoc?.id) {
flushPendingSave();
releaseMyLock(prevId);
sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: prevId });
if (providerRef.current) {
providerRef.current.destroy();
providerRef.current = null;
}
if (crdtSessionRef.current) {
crdtSessionRef.current.destroy();
crdtSessionRef.current = null;
@@ -795,88 +619,64 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const targetDoc = selectedDoc;
const session = new DocumentCRDTSession(targetDoc.id);
session.initFromBlocks(targetDoc.blocks || []);
session.onLocalUpdate = (b64Update) => {
sendWebSocketMessage({
event: 'CRDT_UPDATE',
docId: targetDoc.id,
payload: { update: b64Update },
});
};
session.onRemoteChange = (crdtBlocks) => {
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500;
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(targetDoc.id)?.size || 0) > 0;
if (!recentlyEdited && !hasPendingSave && !hasDirtyBlocks) {
if (crdtBlocks && crdtBlocks.length > 0) {
setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks: crdtBlocks } : prev));
setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks: crdtBlocks } : d)));
}
};
crdtSessionRef.current = session;
sendWebSocketMessage({ event: 'CRDT_JOIN', docId: targetDoc.id });
api
.getDocumentCRDT(targetDoc.id)
.then((res) => {
if (res && crdtSessionRef.current && crdtSessionRef.current.docId === targetDoc.id) {
if (res.state) {
crdtSessionRef.current.applyRemoteUpdate(res.state);
}
if (res.updates && Array.isArray(res.updates)) {
crdtSessionRef.current.applyRemoteUpdates(res.updates);
}
const blocks = crdtSessionRef.current.getBlocks();
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500;
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(targetDoc.id)?.size || 0) > 0;
if (blocks.length > 0 && !recentlyEdited && !hasPendingSave && !hasDirtyBlocks) {
setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks } : prev));
setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks } : d)));
}
const provider = new YjsHybridProvider({
docId: targetDoc.id,
ydoc: session.doc,
onStatusChange: (status) => {
setSyncStatus(status);
},
onSynced: () => {
const blocks = session.getBlocks();
if (blocks && blocks.length > 0) {
setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks } : prev));
setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks } : d)));
}
})
.catch(() => {});
api
.getDocLock(selectedDoc.id)
.then((res) => {
if (res.locked && res.lock && res.lock.userId !== currentUser?.id) {
setActiveLock(res.lock);
} else {
setActiveLock(null);
},
onAwareness: (payload) => {
if (payload && payload.userId && payload.userId !== currentUser?.id) {
setActivePeers((prev) => {
const filtered = prev.filter((p) => p.userId !== payload.userId);
if (payload.activeBlockId !== null) {
return [...filtered, { ...payload, color: getCollaboratorColor(payload.userId), lastActive: Date.now() }];
}
return filtered;
});
}
})
.catch(() => {});
} else {
setActiveLock(null);
},
});
providerRef.current = provider;
}
return () => {
flushPendingSave();
if (currentDocIdRef.current) {
sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current });
if (crdtSessionRef.current) {
crdtSessionRef.current.destroy();
crdtSessionRef.current = null;
}
releaseMyLock(currentDocIdRef.current);
if (providerRef.current) {
providerRef.current.destroy();
providerRef.current = null;
}
if (crdtSessionRef.current) {
crdtSessionRef.current.destroy();
crdtSessionRef.current = null;
}
};
}, [selectedDoc?.id]);
// Window blur / visibility change listener: release lock when user leaves tab/window
// Window blur / visibility change listener: flush any pending metadata save
useEffect(() => {
const handleWindowBlur = () => {
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
flushPendingSave();
releaseMyLock();
}
flushPendingSave();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden' && !isDraggingBlockRef.current) {
isEditorFocusedRef.current = false;
if (document.visibilityState === 'hidden') {
flushPendingSave();
releaseMyLock();
}
};
window.addEventListener('blur', handleWindowBlur);
@@ -887,28 +687,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
};
}, []);
// Flush pending save and release lock before tab closes or unmounts
// Flush pending save before tab closes or unmounts
useEffect(() => {
const handleBeforeUnload = () => {
flushPendingSave();
if (currentDocIdRef.current && holdsLockRef.current) {
try {
const token = getToken();
fetch(`/api/v1/documents/${currentDocIdRef.current}/lock`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {},
keepalive: true,
}).catch(() => {});
} catch {}
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
flushPendingSave();
if (currentDocIdRef.current && holdsLockRef.current) {
releaseMyLock(currentDocIdRef.current);
}
};
}, []);
@@ -965,19 +752,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (payload && payload.userId) {
setActivePeers((prev) => prev.filter((c) => c.userId !== payload.userId));
}
} else if (msg.event === 'DOC_LOCKED') {
const payload = msg.payload as DocLockInfo;
if (payload && payload.docId === selectedDocIdRef.current) {
if (payload.userId !== currentUserIdRef.current) {
setActiveLock(payload);
}
}
} else if (msg.event === 'DOC_UNLOCKED') {
const payload = msg.payload as { docId?: string; doc_id?: string; id?: string };
const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id;
if (unlockedDocId && unlockedDocId === selectedDocIdRef.current) {
setActiveLock(null);
}
} else if (msg.event === 'DOC_BLOCKS_PATCHED') {
const patch = msg.payload as DocBlocksPatchEvent;
if (!patch || !patch.docId) return;
@@ -1155,19 +929,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const pollInterval = setInterval(async () => {
if (!isMounted || document.visibilityState !== 'visible') return;
// 1. Poll lock status of currently selected document
if (selectedDocIdRef.current) {
try {
const res = await api.getDocLock(selectedDocIdRef.current);
if (res.locked && res.lock && res.lock.userId !== currentUserIdRef.current) {
setActiveLock(res.lock);
} else {
setActiveLock(null);
}
} catch {}
}
// 2. Poll document content if not actively typing and document has changed on server
// 1. Poll document content if not actively typing and document has changed on server
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 4000;
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(selectedDocIdRef.current || '')?.size || 0) > 0;
@@ -2148,7 +1910,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
segStart = 0
) => {
if (isReadOnly) return;
ensureLock();
if (slashBlockId === block.id) {
if (filteredSlashCommands.length === 0) {
@@ -2315,7 +2076,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const handleContentChange = (block: DocumentBlock, newContent: string) => {
if (isReadOnly) return;
ensureLock();
// Markdown shortcut: typing '---' creates a divider and inserts a new bullet below
if (newContent.trim() === '---') {
@@ -2634,13 +2394,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return;
}
isDraggingBlockRef.current = true;
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
idleUnlockTimerRef.current = null;
}
// Activate write lock immediately upon dragging (optimistic UI + backend broadcast)
setHoldsLock(true);
ensureLock(true);
e.dataTransfer.setData('application/mindsift-block-id', blockId);
e.dataTransfer.setData('text/plain', blockId);
@@ -2652,15 +2405,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
isDraggingBlockRef.current = false;
setDraggingBlockId(null);
setDragOverBlockTarget(null);
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
}
idleUnlockTimerRef.current = setTimeout(async () => {
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
await flushPendingSave();
await releaseMyLock();
}
}, 2000);
};
const handleBlockDragOver = (e: React.DragEvent, targetBlockId: string) => {
@@ -2772,16 +2516,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
persistDocument({ ...selectedDoc, blocks }, true);
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
}
idleUnlockTimerRef.current = setTimeout(async () => {
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
await flushPendingSave();
await releaseMyLock();
}
}, 2500);
};
// Export full document as portable Logseq-indented Markdown
@@ -3975,6 +3709,41 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<div className="flex items-center gap-2 text-xs text-muted">
{workspaceMode === 'editor' && selectedDoc && (
<>
{/* Transport Connection Status Indicator */}
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${
syncStatus === 'ws'
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/25'
: syncStatus === 'sse'
? 'bg-amber-500/10 text-amber-500 border-amber-500/25'
: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/25'
}`}
title={
syncStatus === 'ws'
? 'Echtzeit-Synchronisation aktiv über WebSocket'
: syncStatus === 'sse'
? 'Eingeschränkt über HTTP-Proxy-Modus (SSE & POST Fallback)'
: 'Offline: Alle Änderungen lokal in IndexedDB gesichert'
}
>
<span
className={`w-2 h-2 rounded-full ${
syncStatus === 'ws'
? 'bg-emerald-500 animate-pulse'
: syncStatus === 'sse'
? 'bg-amber-500'
: 'bg-zinc-400'
}`}
/>
<span>
{syncStatus === 'ws'
? 'Live (WebSocket)'
: syncStatus === 'sse'
? 'Eingeschränkt (HTTP-Proxy-Modus)'
: 'Offline (Lokal gesichert)'}
</span>
</span>
<span className="badge-accent">
<Check className="w-3 h-3" />
{saveState === 'saving' ? 'Speichert…' : 'Gespeichert'}
@@ -4144,7 +3913,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</button>
)}
{activePeers.length > 0 ? (
{activePeers.length > 0 && (
<span
className="px-2 py-0.5 rounded text-[10px] font-semibold border border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center gap-1.5 animate-pulse"
title={`Live-Kollaboration via CRDT aktiv: ${activePeers.map((c) => c.userName).join(', ')}`}
@@ -4153,12 +3922,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<Users className="w-2.5 h-2.5" />
<span>Live ({activePeers.length})</span>
</span>
) : holdsLock ? (
<span className="px-2 py-0.5 rounded text-[10px] font-semibold border border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
<span>CRDT Sync aktiv</span>
</span>
) : null}
)}
{selectedDoc.sharedWith && selectedDoc.sharedWith.length > 0 && selectedDoc.isOwner !== false && (
<button
@@ -4307,7 +4071,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onBlur={handleBlockBlur}
onChange={(e) => {
if (!isReadOnly) {
ensureLock();
autoResizeTextarea(e.target);
persistDocument({ ...selectedDoc, title: e.target.value });
}
@@ -4346,7 +4109,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onBlur={handleBlockBlur}
onChange={(e) => {
if (!isReadOnly) {
ensureLock();
autoResizeTextarea(e.target);
updateBlock(zoomedBlock.id, { content: e.target.value });
}
@@ -4440,15 +4202,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* Drag Handle on hover - tightened spacing to the bullet */}
<div
draggable={!isReadOnly}
onMouseDown={() => {
if (!isReadOnly) {
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
idleUnlockTimerRef.current = null;
}
ensureLock(true);
}
}}
onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)}
onDragEnd={handleBlockDragEnd}
className="opacity-0 group-hover:opacity-100 flex items-center pt-1 text-faint hover:text-accent cursor-grab active:cursor-grabbing flex-shrink-0 -ml-1 mr-0.5"
@@ -4478,15 +4231,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder! Drop on it: Nest as child!) */}
<div
draggable={!isReadOnly}
onMouseDown={() => {
if (!isReadOnly) {
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
idleUnlockTimerRef.current = null;
}
ensureLock(true);
}
}}
onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)}
onDragEnd={handleBlockDragEnd}
onDragOver={(e) => {
@@ -4728,7 +4472,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onChangeContent={(content) => handleContentChange(block, content)}
onChangeLang={(lang) => {
if (!isReadOnly) {
ensureLock();
updateBlock(block.id, { lang });
}
}}