From f90b0d5eb95de4bff20806e0809b3eaf46e15d6f Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 19:08:45 +0100 Subject: [PATCH 01/33] fix(realtime): eliminate cpu reconnect loop, singleton global connection & fix disappearing notes --- Doku/flaws.md | 2 +- frontend/src/App.tsx | 6 +- .../src/features/lists/SharedListsView.tsx | 1 - .../src/features/workspace/WorkspaceView.tsx | 150 +++----- frontend/src/lib/api.ts | 334 ++++++++---------- internal/ws/hub.go | 22 +- 6 files changed, 221 insertions(+), 294 deletions(-) diff --git a/Doku/flaws.md b/Doku/flaws.md index 13b33a8..05855fc 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -10,7 +10,7 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle |---|---|---|---|---| | **FLAW-01** | Workspace Sync | Kein echtes CRDT/OT – Lock-Heartbeat-Modell mit Last-Write-Wins | **Gelöst (v2.1)** | Behoben durch Yjs CRDT-Engine & blockbasierte kollaborative Bearbeitung | | **FLAW-02** | Storage | Dual-Storage Drift (SQLite vs. Dateisystem) | **Obsolet / Gelöst** | Kein Risiko: MindSift nutzt Pure-DB (keine `.md`-Dateien auf Platte) | -| **FLAW-03** | Netzwerk / WS | Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback | **Mittel** | Verzögerte Synchronisierung (1–3s Polling) in Firmennetzwerken | +| **FLAW-03** | Netzwerk / WS | Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback | **Gelöst (v2.2)** | Behoben durch Dual-Transport SSE-Fallback & globale Singleton-Verbindung | | **FLAW-04** | Datenbank / Suche | Volltextsuche via `LIKE '%query%'` statt SQLite FTS5 | **Mittel** | Performance-Einbrüche bei wachsenden Notizbeständen (>10.000 Blöcke) | | **FLAW-05** | Frontend DOM | Fehlende Virtualisierung im Outliner-Baum | **Mittel** | Frame-Drops und Lags bei Dokumenten mit >500 Blöcken auf Mobilgeräten | | **FLAW-06** | SQLite Concurrency | Single-Writer-Bottleneck bei hohem parallelem Schreibaufkommen | **Mittel** | `SQLITE_BUSY` Fehler bei vielen gleichzeitigen Team-Nutzern | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a0bfd16..0cc26a1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Layers, CheckSquare, @@ -414,7 +414,7 @@ export const App: React.FC = () => { setActiveThemeState(themeId); }; - const refreshAll = async () => { + const refreshAll = useCallback(async () => { try { const [meRes, deckRes, listsRes] = await Promise.all([ api.getMe(), @@ -428,7 +428,7 @@ export const App: React.FC = () => { } catch { // If token expired or invalid, api.ts dispatches mindsift:unauthorized } - }; + }, []); const checkBootAndAuth = async () => { setBootLoading(true); diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 8fc62f0..03b737d 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -105,7 +105,6 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null); const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState(null); - const wsRef = useRef(null); const realtimeConnRef = useRef(null); const loadLists = async () => { diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 4b46cf6..d0f7760 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -50,7 +50,7 @@ import { Radio, } from 'lucide-react'; import { BlockType, Document, DocumentBlock, DocBlocksPatchEvent, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types'; -import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected, buildWebSocketURL, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; +import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected, sendWebSocketMessage, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; import { CodeBlockItem } from './CodeBlockItem'; import { UserAutocomplete } from '../UserAutocomplete'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; @@ -130,14 +130,15 @@ export const WorkspaceView: React.FC = ({ const [showIconPicker, setShowIconPicker] = useState(false); const [showWikiLinkMenu, setShowWikiLinkMenu] = useState(false); - // Dedicated WebSocket/SSE connection & status for live indicator - const wsRef = useRef(null); + // Realtime connection & status for live indicator const realtimeConnRef = useRef(null); - const wsReconnectTimerRef = useRef(null); - const wsReconnectDelayRef = useRef(2000); const [wsConnected, setWsConnected] = useState(false); const [realtimeTransport, setRealtimeTransport] = useState('none'); const [wsDisconnectReason, setWsDisconnectReason] = useState(null); + const onStatsChangeRef = useRef(onStatsChange); + onStatsChangeRef.current = onStatsChange; + const lastLocalEditTimestampRef = useRef(0); + const isSavingToBackendRef = useRef(false); // Folder state (synchronized with server) const [customFolders, setCustomFolders] = useState([]); @@ -441,25 +442,11 @@ export const WorkspaceView: React.FC = ({ color: getCollaboratorColor(currentUser?.id || ''), lastActive: Date.now(), }; - if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) { - realtimeConnRef.current.send({ - event: 'CRDT_AWARENESS', - docId: selectedDoc.id, - payload, - }); - } else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - try { - wsRef.current.send( - JSON.stringify({ - event: 'CRDT_AWARENESS', - docId: selectedDoc.id, - payload, - }) - ); - } catch {} - } else { - api.sendDocumentAwareness(selectedDoc.id, payload).catch(() => {}); - } + sendWebSocketMessage({ + event: 'CRDT_AWARENESS', + docId: selectedDoc.id, + payload, + }); }; const handleBlockFocus = (blockIdOrEvent?: string | React.FocusEvent) => { @@ -501,6 +488,7 @@ export const WorkspaceView: React.FC = ({ const executeSaveToBackend = async (docToSave: Document) => { if (isReadOnly) return; let saveSucceeded = false; + isSavingToBackendRef.current = true; try { const docId = docToSave.id; const dirtyMap = localDirtyBlocksRef.current.get(docId); @@ -582,7 +570,7 @@ export const WorkspaceView: React.FC = ({ } saveSucceeded = true; - onStatsChange(); + onStatsChangeRef.current(); api.getWorkspaceTags().then(setWorkspaceTags).catch(() => {}); if (selectedTag) { api.getTagDetails(selectedTag).then(setTagDetails).catch(() => {}); @@ -590,6 +578,7 @@ export const WorkspaceView: React.FC = ({ } catch (e: any) { console.error('Failed to save document to backend', e); } finally { + isSavingToBackendRef.current = false; if (saveSucceeded) { if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) { pendingSaveDocRef.current = null; @@ -626,6 +615,7 @@ export const WorkspaceView: React.FC = ({ 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; @@ -792,15 +782,7 @@ export const WorkspaceView: React.FC = ({ if (prevId && prevId !== selectedDoc?.id) { flushPendingSave(); releaseMyLock(prevId); - if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) { - realtimeConnRef.current.send({ event: 'CRDT_LEAVE', docId: prevId }); - } else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - try { - wsRef.current.send(JSON.stringify({ event: 'CRDT_LEAVE', docId: prevId })); - } catch {} - } else { - api.clearDocumentAwareness(prevId).catch(() => {}); - } + sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: prevId }); if (crdtSessionRef.current) { crdtSessionRef.current.destroy(); crdtSessionRef.current = null; @@ -814,39 +796,24 @@ export const WorkspaceView: React.FC = ({ const session = new DocumentCRDTSession(targetDoc.id); session.initFromBlocks(targetDoc.blocks || []); session.onLocalUpdate = (b64Update) => { - if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) { - realtimeConnRef.current.send({ - event: 'CRDT_UPDATE', - docId: targetDoc.id, - payload: { update: b64Update }, - }); - } else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - try { - wsRef.current.send( - JSON.stringify({ - event: 'CRDT_UPDATE', - docId: targetDoc.id, - payload: { update: b64Update }, - }) - ); - } catch {} - } else { - api.saveDocumentCRDT(targetDoc.id, { update: b64Update }).catch(() => {}); - } + sendWebSocketMessage({ + event: 'CRDT_UPDATE', + docId: targetDoc.id, + payload: { update: b64Update }, + }); }; session.onRemoteChange = (crdtBlocks) => { - setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks: crdtBlocks } : prev)); - setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks: crdtBlocks } : d))); + 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) { + 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; - if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) { - realtimeConnRef.current.send({ event: 'CRDT_JOIN', docId: targetDoc.id }); - } else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - try { - wsRef.current.send(JSON.stringify({ event: 'CRDT_JOIN', docId: targetDoc.id })); - } catch {} - } + sendWebSocketMessage({ event: 'CRDT_JOIN', docId: targetDoc.id }); api .getDocumentCRDT(targetDoc.id) @@ -859,7 +826,10 @@ export const WorkspaceView: React.FC = ({ crdtSessionRef.current.applyRemoteUpdates(res.updates); } const blocks = crdtSessionRef.current.getBlocks(); - if (blocks.length > 0) { + 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))); } @@ -884,15 +854,7 @@ export const WorkspaceView: React.FC = ({ return () => { flushPendingSave(); if (currentDocIdRef.current) { - if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) { - realtimeConnRef.current.send({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current }); - } else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - try { - wsRef.current.send(JSON.stringify({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current })); - } catch {} - } else { - api.clearDocumentAwareness(currentDocIdRef.current).catch(() => {}); - } + sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current }); if (crdtSessionRef.current) { crdtSessionRef.current.destroy(); crdtSessionRef.current = null; @@ -967,7 +929,10 @@ export const WorkspaceView: React.FC = ({ crdtSessionRef.current.applyRemoteUpdates(payload.updates); } const blocks = crdtSessionRef.current.getBlocks(); - if (blocks.length > 0) { + const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500; + const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current; + const hasDirtyBlocks = (localDirtyBlocksRef.current.get(payload.docId)?.size || 0) > 0; + if (blocks.length > 0 && !recentlyEdited && !hasPendingSave && !hasDirtyBlocks) { setSelectedDoc((prev) => (prev && prev.id === payload.docId ? { ...prev, blocks } : prev)); setDocs((prev) => prev.map((d) => (d.id === payload.docId ? { ...d, blocks } : d))); } @@ -978,7 +943,10 @@ export const WorkspaceView: React.FC = ({ if (payload.fromUserId !== currentUserIdRef.current) { crdtSessionRef.current.applyRemoteUpdate(payload.update); const blocks = crdtSessionRef.current.getBlocks(); - if (blocks && blocks.length > 0) { + const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500; + const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current; + const hasDirtyBlocks = (localDirtyBlocksRef.current.get(payload.docId)?.size || 0) > 0; + if (blocks && blocks.length > 0 && !recentlyEdited && !hasPendingSave && !hasDirtyBlocks) { setSelectedDoc((prev) => (prev && prev.id === payload.docId ? { ...prev, blocks } : prev)); setDocs((prev) => prev.map((d) => (d.id === payload.docId ? { ...d, blocks } : d))); } @@ -1046,7 +1014,7 @@ export const WorkspaceView: React.FC = ({ if (created && created.id) { recordSyncedDoc(created); setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev])); - onStatsChange(); + onStatsChangeRef.current(); } } else if (msg.event === 'DOC_UPDATED') { const updated = msg.payload as Document; @@ -1089,7 +1057,7 @@ export const WorkspaceView: React.FC = ({ return nextDoc; }); } - onStatsChange(); + onStatsChangeRef.current(); } } else if (msg.event === 'DOC_DELETED') { const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id; @@ -1098,7 +1066,7 @@ export const WorkspaceView: React.FC = ({ if (selectedDocIdRef.current === deletedId) { setSelectedDoc(null); } - onStatsChange(); + onStatsChangeRef.current(); } } else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') { api.getFolders().then(setCustomFolders).catch(() => {}); @@ -1177,7 +1145,7 @@ export const WorkspaceView: React.FC = ({ } window.removeEventListener('mindsift:ws', handleGlobalCustomEvent); }; - }, [onStatsChange]); + }, []); // Fallback HTTP polling when WebSocket is disconnected (e.g. corporate proxy / firewall blocking WebSockets) useEffect(() => { @@ -1200,7 +1168,16 @@ export const WorkspaceView: React.FC = ({ } // 2. Poll document content if not actively typing and document has changed on server - if (selectedDocIdRef.current && !isEditorFocusedRef.current && !pendingSaveDocRef.current) { + const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 4000; + const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current; + const hasDirtyBlocks = (localDirtyBlocksRef.current.get(selectedDocIdRef.current || '')?.size || 0) > 0; + if ( + selectedDocIdRef.current && + !isEditorFocusedRef.current && + !hasPendingSave && + !hasDirtyBlocks && + !recentlyEdited + ) { try { const fresh = await api.getDocument(selectedDocIdRef.current); if (fresh && (!selectedDoc || fresh.updatedAt !== selectedDoc.updatedAt)) { @@ -1889,20 +1866,9 @@ export const WorkspaceView: React.FC = ({ : b ); nextBlocks.splice(afterIndex + 1, 0, newBlock); + lastLocalEditTimestampRef.current = Date.now(); persistDocument({ ...selectedDoc, blocks: nextBlocks }); - setLastFocusedBlockId(newBlock.id); - setBlockCursor(newBlock.id, 0); - - // Focus into the new element next tick - setTimeout(() => { - const el = blockInputRefs.current[newBlock.id]; - if (el) { - el.focus(); - if ('setSelectionRange' in el && typeof (el as any).value === 'string') { - (el as any).setSelectionRange(0, 0); - } - } - }, 20); + focusBlock(newBlock.id, 0); }; // Indent block (Tab): increase level by 1 and move all descendant sub-bullets along diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index be47083..765ffc2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1140,6 +1140,11 @@ export function onRealtimeStatus(callback: (connected: boolean, transport: Realt }; } +let isWsBlockedByProxy = false; +let sseReconnectTimer: any = null; +let sseReconnectDelay = 2000; +const globalSubscribedRooms = new Set(); + function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTransport) { isRealtimeConnectedState = connected; activeTransport = transport; @@ -1154,20 +1159,32 @@ function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTrans } export function connectSSE(rooms?: string[]): void { + if (rooms) { + rooms.forEach((r) => globalSubscribedRooms.add(r)); + } + clearTimeout(sseReconnectTimer); + clearTimeout(wsReconnectTimer); + clearTimeout(wsConnectionWatchdog); + if (activeGlobalSSE) { try { activeGlobalSSE.close(); } catch {} activeGlobalSSE = null; } try { - const url = buildSSEURL(undefined, undefined, rooms); + const url = buildSSEURL(undefined, undefined, Array.from(globalSubscribedRooms)); const es = new EventSource(url); activeGlobalSSE = es; es.onopen = () => { console.info('[MindSift Realtime] Server-Sent Events (SSE) verbunden (Proxy-Fallback aktiv).'); - wsReconnectDelay = 2000; + sseReconnectDelay = 2000; notifyRealtimeStatusChange(true, 'sse'); + if (activeSSEId && globalSubscribedRooms.size > 0) { + globalSubscribedRooms.forEach((room) => { + api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {}); + }); + } }; es.onmessage = (event) => { @@ -1175,6 +1192,11 @@ export function connectSSE(rooms?: string[]): void { const msg = JSON.parse(event.data) as WSMessage; if (msg.event === 'SSE_CONNECTED') { activeSSEId = msg.payload?.sseId || null; + if (activeSSEId && globalSubscribedRooms.size > 0) { + globalSubscribedRooms.forEach((room) => { + api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {}); + }); + } } wsListeners.forEach((fn) => { try { @@ -1190,17 +1212,24 @@ export function connectSSE(rooms?: string[]): void { }; es.onerror = (e) => { - console.warn('[MindSift Realtime] SSE getrennt oder Fehler aufgetreten:', e); if (activeTransport === 'sse') { notifyRealtimeStatusChange(false, 'none'); } try { es.close(); } catch {} activeGlobalSSE = null; if (!manualDisconnect && getToken()) { - clearTimeout(wsReconnectTimer); - wsReconnectTimer = setTimeout(() => { - connectWebSocket(); - }, 5000); + clearTimeout(sseReconnectTimer); + const delay = sseReconnectDelay; + sseReconnectDelay = Math.min(30000, Math.round(sseReconnectDelay * 1.5)); + sseReconnectTimer = setTimeout(() => { + if (!manualDisconnect && getToken()) { + if (isWsBlockedByProxy) { + connectSSE(); + } else { + connectWebSocket(); + } + } + }, delay); } }; } catch (err) { @@ -1214,11 +1243,19 @@ export function connectWebSocket(): void { if (!token) return; manualDisconnect = false; + // If proxy blocked WebSocket previously, do not attempt failing WebSocket; stay on SSE + if (isWsBlockedByProxy) { + connectSSE(); + return; + } + if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) { return; } clearTimeout(wsConnectionWatchdog); + clearTimeout(wsReconnectTimer); + clearTimeout(sseReconnectTimer); try { const url = buildWebSocketURL(); @@ -1229,6 +1266,7 @@ export function connectWebSocket(): void { wsConnectionWatchdog = setTimeout(() => { if (ws && ws.readyState !== WebSocket.OPEN) { console.warn('[MindSift Realtime] WebSocket connection timeout (Proxy Upgrade Block). Wechsle automatisch zu SSE...'); + isWsBlockedByProxy = true; try { ws.close(); } catch {} activeGlobalWS = null; connectSSE(); @@ -1243,6 +1281,13 @@ export function connectWebSocket(): void { } wsReconnectDelay = 2000; notifyRealtimeStatusChange(true, 'ws'); + if (globalSubscribedRooms.size > 0) { + globalSubscribedRooms.forEach((room) => { + try { + ws.send(JSON.stringify({ event: 'JOIN_ROOM', room })); + } catch {} + }); + } }; ws.onmessage = (event) => { @@ -1264,11 +1309,11 @@ export function connectWebSocket(): void { ws.onclose = (evt: CloseEvent) => { clearTimeout(wsConnectionWatchdog); activeGlobalWS = null; - console.warn('[MindSift Global WS] Closed with code:', evt?.code, 'reason:', evt?.reason); const isProxyIssue = evt?.code === 1006 || evt?.code === 1002 || evt?.code === 1008; if (isProxyIssue && !manualDisconnect && getToken()) { - console.warn('[MindSift Realtime] Proxy/Firewall Inkompatibilität erkannt (Code ' + evt.code + '). Wechsle zu SSE...'); + console.warn('[MindSift Realtime] Proxy/Firewall Inkompatibilität erkannt (Code ' + evt.code + '). Wechsle dauerhaft zu SSE...'); + isWsBlockedByProxy = true; connectSSE(); return; } @@ -1282,21 +1327,22 @@ export function connectWebSocket(): void { const delay = wsReconnectDelay; wsReconnectDelay = Math.min(30000, Math.round(wsReconnectDelay * 1.5)); wsReconnectTimer = setTimeout(() => { - connectWebSocket(); + if (!manualDisconnect && getToken()) { + connectWebSocket(); + } }, delay); } }; ws.onerror = (e) => { clearTimeout(wsConnectionWatchdog); - console.warn('[MindSift Global WS] Error:', e); try { ws.close(); } catch {} }; } catch (e) { clearTimeout(wsConnectionWatchdog); - console.warn('Failed to connect WebSocket, switching to SSE:', e); + isWsBlockedByProxy = true; connectSSE(); } } @@ -1305,12 +1351,20 @@ export function connectWebSocket(): void { if (typeof window !== 'undefined') { window.addEventListener('online', () => { if (getToken() && !isRealtimeConnected()) { - connectWebSocket(); + if (isWsBlockedByProxy) { + connectSSE(); + } else { + connectWebSocket(); + } } }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && getToken() && !isRealtimeConnected()) { - connectWebSocket(); + if (isWsBlockedByProxy) { + connectSSE(); + } else { + connectWebSocket(); + } } }); } @@ -1319,6 +1373,7 @@ export function disconnectWebSocket(): void { manualDisconnect = true; clearTimeout(wsReconnectTimer); clearTimeout(wsConnectionWatchdog); + clearTimeout(sseReconnectTimer); if (activeGlobalWS) { try { activeGlobalWS.close(); @@ -1344,18 +1399,22 @@ export function sendWebSocketMessage(msg: any): void { try { const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg; if (parsed.event === 'CRDT_UPDATE' && parsed.docId) { - api.saveDocumentCRDT(parsed.docId, { update: parsed.payload?.update }); + api.saveDocumentCRDT(parsed.docId, { update: parsed.payload?.update }).catch(() => {}); } else if (parsed.event === 'CRDT_AWARENESS' && parsed.docId) { - api.sendDocumentAwareness(parsed.docId, parsed.payload); + api.sendDocumentAwareness(parsed.docId, parsed.payload).catch(() => {}); } else if (parsed.event === 'CRDT_LEAVE' && parsed.docId) { - api.clearDocumentAwareness(parsed.docId); - if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'leave'); + api.clearDocumentAwareness(parsed.docId).catch(() => {}); + if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'leave').catch(() => {}); } else if (parsed.event === 'CRDT_JOIN' && parsed.docId) { - if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'join'); + if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'join').catch(() => {}); + } else if (parsed.event === 'JOIN_ROOM' && parsed.room) { + if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'join').catch(() => {}); + } else if (parsed.event === 'LEAVE_ROOM' && parsed.room) { + if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'leave').catch(() => {}); } else if (parsed.event === 'LOCK_DOC' && parsed.docId) { - api.lockDocument(parsed.docId); + api.lockDocument(parsed.docId).catch(() => {}); } else if (parsed.event === 'UNLOCK_DOC' && parsed.docId) { - api.unlockDocument(parsed.docId); + api.unlockDocument(parsed.docId).catch(() => {}); } } catch (err) { console.error('Error dispatching fallback REST message for SSE:', err); @@ -1377,196 +1436,79 @@ export function createRealtimeConnection(options: { onStatusChange?: (status: { connected: boolean; transport: RealtimeTransport; reason?: string }) => void; }) { let isClosed = false; - let currentTransport: RealtimeTransport = 'none'; - let ws: WebSocket | null = null; - let es: EventSource | null = null; - let watchdogTimer: any = null; - let reconnectTimer: any = null; - let reconnectDelay = 2000; - let sseId: string | null = null; - const notify = (connected: boolean, transport: RealtimeTransport, reason?: string) => { - currentTransport = transport; - options.onStatusChange?.({ connected, transport, reason }); - }; + const joinedRooms: string[] = []; + if (options.docId) joinedRooms.push(`doc:${options.docId}`); + if (options.listId) joinedRooms.push(options.listId); + if (options.rooms) { + options.rooms.forEach((r) => { + if (!joinedRooms.includes(r)) joinedRooms.push(r); + }); + } - const connectSSE = () => { + joinedRooms.forEach((r) => globalSubscribedRooms.add(r)); + + // Subscribe to status changes + const unsubStatus = onRealtimeStatus((connected, transport) => { if (isClosed) return; - if (es) { - try { es.close(); } catch {} - es = null; - } + options.onStatusChange?.({ + connected, + transport, + reason: connected ? undefined : 'Verbindung getrennt', + }); + }); - try { - const allRooms = [...(options.rooms || [])]; - if (options.docId) allRooms.push(`doc:${options.docId}`); - if (options.listId) allRooms.push(options.listId); - const url = buildSSEURL(options.listId, options.docId, allRooms); - es = new EventSource(url); + // Subscribe to messages + let unsubMsg: (() => void) | null = null; + if (options.onMessage) { + unsubMsg = onWebSocketMessage((msg) => { + if (isClosed) return; + options.onMessage?.(msg); + }); + } - es.onopen = () => { - if (isClosed) return; - reconnectDelay = 2000; - notify(true, 'sse'); - }; - - es.onmessage = (evt) => { - if (isClosed) return; - try { - const msg = JSON.parse(evt.data) as WSMessage; - if (msg.event === 'SSE_CONNECTED') { - sseId = msg.payload?.sseId || null; - } - options.onMessage?.(msg); - } catch (e) { - console.error('SSE JSON error', e); - } - }; - - es.onerror = (e) => { - if (isClosed) return; - notify(false, 'none', 'SSE-Verbindungsabbruch'); - try { es?.close(); } catch {} - es = null; - clearTimeout(reconnectTimer); - reconnectTimer = setTimeout(() => { - if (!isClosed) connectWS(); - }, 5000); - }; - } catch (e: any) { - notify(false, 'none', e?.message || 'SSE Initialisierungsfehler'); - } + // Join rooms on the active connection + const joinAll = () => { + joinedRooms.forEach((r) => { + if (r.startsWith('doc:')) { + sendWebSocketMessage({ event: 'CRDT_JOIN', docId: r.slice(4) }); + } else { + sendWebSocketMessage({ event: 'JOIN_ROOM', room: r }); + } + }); }; - const connectWS = () => { + if (isRealtimeConnected()) { + joinAll(); + } + + const unsubRejoin = onRealtimeStatus((connected) => { if (isClosed) return; - const token = getToken(); - if (!token) { - notify(false, 'none', 'Kein Authentifizierungs-Token vorhanden'); - return; + if (connected) { + joinAll(); } - - if (ws) { - try { ws.close(); } catch {} - ws = null; - } - - clearTimeout(watchdogTimer); - - try { - const url = buildWebSocketURL(options.listId); - ws = new WebSocket(url); - - // 3.5s watchdog for corporate proxies silently discarding Upgrade header - watchdogTimer = setTimeout(() => { - if (ws && ws.readyState !== WebSocket.OPEN && !isClosed) { - console.warn('[MindSift Realtime] WebSocket connection timeout (Proxy Upgrade block). Falling back to SSE...'); - try { ws.close(); } catch {} - ws = null; - connectSSE(); - } - }, 3500); - - ws.onopen = () => { - if (isClosed) return; - clearTimeout(watchdogTimer); - reconnectDelay = 2000; - notify(true, 'ws'); - if (options.docId) { - try { - ws?.send(JSON.stringify({ event: 'CRDT_JOIN', docId: options.docId })); - } catch {} - } - }; - - ws.onmessage = (evt) => { - if (isClosed) return; - try { - const msg = JSON.parse(evt.data) as WSMessage; - options.onMessage?.(msg); - } catch (e) { - console.error('WS JSON error', e); - } - }; - - ws.onclose = (evt: CloseEvent) => { - if (isClosed) return; - clearTimeout(watchdogTimer); - ws = null; - - const isProxyError = evt?.code === 1006 || evt?.code === 1002 || evt?.code === 1008; - if (isProxyError) { - console.warn(`[MindSift Realtime] Proxy WebSocket rejection detected (Code ${evt.code}). Falling back to SSE...`); - connectSSE(); - return; - } - - const reason = evt.reason || (evt.code === 1000 ? 'Normal geschlossen' : `Code ${evt.code}`); - notify(false, 'none', reason); - - clearTimeout(reconnectTimer); - const delay = reconnectDelay; - reconnectDelay = Math.min(30000, Math.round(reconnectDelay * 1.5)); - reconnectTimer = setTimeout(connectWS, delay); - }; - - ws.onerror = (e) => { - if (isClosed) return; - clearTimeout(watchdogTimer); - try { ws?.close(); } catch {} - }; - } catch (e: any) { - clearTimeout(watchdogTimer); - console.warn('[MindSift Realtime] WebSocket constructor failed, trying SSE fallback:', e); - connectSSE(); - } - }; - - connectWS(); + }); return { - getTransport: () => currentTransport, - isConnected: () => currentTransport !== 'none', + getTransport: () => activeTransport, + isConnected: () => isRealtimeConnected(), send: (msg: any) => { - const str = typeof msg === 'string' ? msg : JSON.stringify(msg); - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(str); - return; - } - // SSE Fallback: dispatch via REST endpoints - try { - const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg; - if (parsed.event === 'CRDT_UPDATE' && parsed.docId) { - api.saveDocumentCRDT(parsed.docId, { update: parsed.payload?.update }); - } else if (parsed.event === 'CRDT_AWARENESS' && parsed.docId) { - api.sendDocumentAwareness(parsed.docId, parsed.payload); - } else if (parsed.event === 'CRDT_LEAVE' && parsed.docId) { - api.clearDocumentAwareness(parsed.docId); - if (sseId) api.joinSSERoom(sseId, `doc:${parsed.docId}`, 'leave'); - } else if (parsed.event === 'CRDT_JOIN' && parsed.docId) { - if (sseId) api.joinSSERoom(sseId, `doc:${parsed.docId}`, 'join'); - } else if (parsed.event === 'LOCK_DOC' && parsed.docId) { - api.lockDocument(parsed.docId); - } else if (parsed.event === 'UNLOCK_DOC' && parsed.docId) { - api.unlockDocument(parsed.docId); - } - } catch (err) { - console.error('Error dispatching fallback REST message for connection:', err); - } + if (isClosed) return; + sendWebSocketMessage(msg); }, close: () => { isClosed = true; - clearTimeout(watchdogTimer); - clearTimeout(reconnectTimer); - if (ws) { - try { ws.close(); } catch {} - ws = null; - } - if (es) { - try { es.close(); } catch {} - es = null; - } - notify(false, 'none'); + unsubStatus(); + unsubRejoin(); + if (unsubMsg) unsubMsg(); + joinedRooms.forEach((r) => { + globalSubscribedRooms.delete(r); + if (r.startsWith('doc:')) { + sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: r.slice(4) }); + } else { + sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r }); + } + }); }, }; } diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 6863cbc..56cf61a 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -761,10 +761,11 @@ func (c *Client) readPump() { break } - // Handle client-initiated lock, CRDT, and presence messages + // Handle client-initiated lock, CRDT, room subscription, and presence messages var incoming struct { Event string `json:"event"` DocID string `json:"docId"` + Room string `json:"room"` Payload json.RawMessage `json:"payload"` } if json.Unmarshal(message, &incoming) == nil && c.userID != "" && c.userID != "anonymous" { @@ -777,6 +778,25 @@ func (c *Client) readPump() { docID = p.DocID } + // Room subscription without requiring docID + if incoming.Event == "JOIN_ROOM" { + room := incoming.Room + if room == "" { + room = docID + } + if room != "" { + c.hub.JoinRoom(c, room) + } + } else if incoming.Event == "LEAVE_ROOM" { + room := incoming.Room + if room == "" { + room = docID + } + if room != "" { + c.hub.LeaveRoom(c, room) + } + } + if docID != "" { switch incoming.Event { case "LOCK_DOC": -- 2.54.0 From 297f3ed1113b5fdca149051386502ad4f0cdcb13 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 19:14:21 +0100 Subject: [PATCH 02/33] docs: remove non-existent flaw FLAW-09 (virtual keyboard viewport) --- Doku/flaws.md | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/Doku/flaws.md b/Doku/flaws.md index 05855fc..2f0993e 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -16,8 +16,7 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle | **FLAW-06** | SQLite Concurrency | Single-Writer-Bottleneck bei hohem parallelem Schreibaufkommen | **Mittel** | `SQLITE_BUSY` Fehler bei vielen gleichzeitigen Team-Nutzern | | **FLAW-07** | Benachrichtigungen | Fehlende Dead-Letter-Queue für Webhook- & E-Mail-Alarme | **Niedrig** | Verlorene Benachrichtigungen bei temporärem Ausfall von Drittsystemen | | **FLAW-08** | Sicherheit / Auth | Statische JWT-Tokens ohne rotierende Refresh-Tokens & Blacklisting | **Niedrig** | Keine sofortige Sitzungsinvalidierung bei Rechteentzug | -| **FLAW-09** | Mobile UX | Viewport-Verschiebung durch Virtual-Keyboard auf iOS/Android | **Niedrig** | Verdeckte Eingabefelder und springende Bottom-Navigationsleisten | -| **FLAW-10** | Outliner UX | Kein hierarchischer Undo/Redo-Stack für Strukturmutationen | **Mittel** | Versehentliches Löschen/Einrücken von Blöcken nicht per `Ctrl+Z` rückgängig machbar | +| **FLAW-09** | Outliner UX | Kein hierarchischer Undo/Redo-Stack für Strukturmutationen | **Mittel** | Versehentliches Löschen/Einrücken von Blöcken nicht per `Ctrl+Z` rückgängig machbar | --- @@ -84,7 +83,7 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle --- ### FLAW-05: Fehlende Virtualisierung im Outliner-Baum -- **Betroffene Komponenten:** [`modules/workspace.md`](./modules/workspace.md), `frontend/src/lib/components/Outliner.svelte` +- **Betroffene Komponenten:** [`modules/workspace.md`](./modules/workspace.md), `frontend/src/features/workspace/WorkspaceView.tsx` - **Ursache:** Jeder Block im Notizbaum wird als eigenes DOM-Element gerendert. Enthält ein Dokument 500 bis 2000 Blöcke (z. B. lange Projektjournale oder Besprechungsprotokolle), befinden sich tausende verschachtelte `div`- und `contenteditable`-Elemente gleichzeitig im DOM. - **Folgen:** Auf älteren Smartphones oder bei schnellem Tippen kommt es zu spürbaren Input-Latenzen und Ruckeln beim Scrollen. - **Empfohlene Lösung:** @@ -123,19 +122,9 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle --- -### FLAW-09: Mobile Viewport-Verschiebung durch Virtual-Keyboard -- **Betroffene Komponenten:** `frontend/src/routes/+layout.svelte`, `frontend/src/lib/components/BottomNav.svelte` -- **Ursache:** Öffnet sich auf iOS Safari oder Android Chrome die Bildschirmtastatur, ändert der Browser die Höhe des sichtbaren Viewports dynamisch. Feste CSS-Attribute wie `height: 100vh` oder `fixed bottom-0` führen dazu, dass Eingabefelder unter die Tastatur rutschen oder die Navigationsleiste den Bildschirm verdeckt. -- **Folgen:** In der mobilen Triage- und Aufgabenansicht muss der Nutzer manuell scrollen, um die Speichern-Buttons zu erreichen. -- **Empfohlene Lösung:** - 1. Nutzung der modernen `window.visualViewport`-API in Svelte. - 2. Dynamisches Setzen einer CSS-Variable `--vvh` (`visualViewport.height`) zur präzisen Anpassung von Modals und Bottom-Sheets. - ---- - -### FLAW-10: Kein hierarchischer Undo/Redo-Stack im Outliner -- **Betroffene Komponenten:** [`modules/workspace.md`](./modules/workspace.md), `frontend/src/lib/components/Outliner.svelte` -- **Ursache:** Standardmäßiges Browser-`Ctrl+Z` funktioniert nur innerhalb eines einzelnen aktiven Textfelds (`contenteditable`). Strukturelle Aktionen – wie das Verschieben von Blöcken via Drag & Drop, Einrücken (`Tab`), Ausrücken (`Shift+Tab`) oder das Löschen ganzer Unterbäume – sind im Standard-Browser-Undo-Stack nicht enthalten. +### FLAW-09: Kein hierarchischer Undo/Redo-Stack im Outliner +- **Betroffene Komponenten:** [`modules/workspace.md`](./modules/workspace.md), `frontend/src/features/workspace/WorkspaceView.tsx` +- **Ursache:** Standardmäßiges Browser-`Ctrl+Z` funktioniert nur innerhalb eines einzelnen aktiven Textfelds (`contenteditable` / Textarea). Strukturelle Aktionen – wie das Verschieben von Blöcken via Drag & Drop, Einrücken (`Tab`), Ausrücken (`Shift+Tab`) oder das Löschen ganzer Unterbäume – sind im Standard-Browser-Undo-Stack nicht enthalten. - **Folgen:** Verschiebt ein Nutzer versehentlich einen Block samt 10 Unterpunkten, kann dies nicht mit `Ctrl+Z` rückgängig gemacht werden. - **Empfohlene Lösung:** 1. Implementierung eines expliziten In-Memory Command-Stacks im Frontend (Command-Pattern: `execute()` / `undo()`). -- 2.54.0 From f3b3f07e04bcd630751a2cef13d482fb8de1f968 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 19:54:51 +0100 Subject: [PATCH 03/33] refactor(crdt): implement proxy-resilient Yjs dual-transport sync with indexeddb offline-first and debounced sqlite persistence --- Doku/flaws.md | 13 +- Doku/reverse-proxy.md | 4 +- frontend/package-lock.json | 21 + frontend/package.json | 1 + .../src/features/workspace/WorkspaceView.tsx | 459 ++++------------- frontend/src/lib/yjs-provider.ts | 380 ++++++++++++++ internal/api/server.go | 254 ++++++---- internal/api/server_test.go | 81 +++ internal/ws/docroom.go | 473 ++++++++++++++++++ internal/ws/hub.go | 46 +- 10 files changed, 1248 insertions(+), 484 deletions(-) create mode 100644 frontend/src/lib/yjs-provider.ts create mode 100644 internal/ws/docroom.go diff --git a/Doku/flaws.md b/Doku/flaws.md index 2f0993e..b00cb7e 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -27,11 +27,14 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle - **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`modules/workspace.md`](./modules/workspace.md), `internal/ws/hub.go`, `internal/storage/sqlite.go`, `frontend/src/lib/crdt.ts`, `frontend/src/features/workspace/WorkspaceView.tsx` - **Ursache (Historisch):** MindSift nutzte ein kooperatives Dokumenten-Lease-Locking mit Last-Write-Wins. Bei parallelem Zugriff oder Netztrennung konnten Änderungen überschrieben werden. - **Implementierte Lösung (Yjs CRDT):** - 1. **Block-Level Yjs CRDT:** Implementierung eines vollwertigen CRDT-Modells mit `Y.Doc`. Die Blockreihenfolge wird über ein `Y.Array` (Block-IDs) konfliktfrei mit dem YATA-Algorithmus aufgelöst. Inhalte und Metadaten (Text, Level, Checked, Collapsed) sind in `Y.Map` gekapselt. - 2. **Wegfall von Ganzdokument-Sperren:** Keine blockierenden Sperren mehr (`isLockedByOther` entfernt). Mehrere Nutzer können zeitgleich am selben Dokument schreiben. - 3. **Dual-Persistence:** CRDT-Zustände und Delta-Updates werden binär/Base64 in SQLite (`document_crdt_state`, `document_crdt_updates`) persistiert. Zur Aufrechterhaltung relationaler Indizes (Global To-Dos, Tag-Indizierung, Suche) werden Blöcke parallel in `document_blocks` materialisiert. - 4. **Live-Awareness & Presence:** Block-Fokus und Cursor-Zustand werden via `CRDT_AWARENESS` übertragen. Nutzer sehen ein Live-Badge im Header (`Live (N)`) sowie farbige Kollaborator-Pillen an aktiven Blöcken. - 5. **REST- & Proxy-Sicherheit:** Ergänzend zu WebSockets stehen Endpunkte `GET /api/v1/documents/:id/crdt` und `PUT /api/v1/documents/:id/crdt` bereit. + 1. **Block-Level Yjs CRDT:** Implementierung eines vollwertigen CRDT-Modells mit `Y.Doc`. Die Blockreihenfolge wird über ein `Y.Array` (Block-IDs) konfliktfrei mit dem YATA-Algorithmus aufgelöst. Inhalte und Metadaten sind in `Y.Map` gekapselt. + 2. **Vollständige Eliminierung des Lock-Managers:** Alle pessimistischen Dokumentensperren (`isLockedByOther`, Lease-Heartbeats, 423 Locked) wurden restlos entfernt. Mehrere Nutzer können zeitgleich an denselben Absätzen und Blöcken arbeiten. + 3. **Proxy-Resiliente Dual-Transport-Architektur:** + - **Primary:** Gorilla WebSocket (`/api/v1/ws/docs/:id`) mit binärer Übertragung. + - **Fallback:** 4-Sekunden-Watchdog schaltet bei blockiertem WebSocket-Upgrade automatisch auf HTTP-Transport um: Downstream via Server-Sent Events (`GET /api/v1/sync/docs/:id/stream`) und Upstream via HTTP POST (`POST /api/v1/sync/docs/:id/update`). + 4. **Offline-First via IndexedDB:** Lokale Eingaben landen sofort und unterbrechungsfrei in `y-indexeddb`. Daten gehen auch bei Netzwerkabbrüchen nicht verloren. + 5. **Asynchroner DocRoom-Hub & Debounced SQLite-Persistenz:** In-Memory `DocRoom` pro aktivem Dokument; SQLite-Schreibvorgänge laufen debounced (2.5 Sekunden Inaktivität oder Raum-Austritt) rein asynchron im Hintergrund, ohne API-Threads oder Tastenschläge zu blockieren. + 6. **UI Status-Indikator:** Dezent im Header visualisiert: Grün ("Live (WebSocket)"), Gelb ("Eingeschränkt (HTTP-Proxy-Modus)") und Grau ("Offline (Lokal gesichert)"). --- diff --git a/Doku/reverse-proxy.md b/Doku/reverse-proxy.md index 1cb3e52..7a3615e 100644 --- a/Doku/reverse-proxy.md +++ b/Doku/reverse-proxy.md @@ -71,8 +71,8 @@ server { proxy_send_timeout 3600s; } - # 2. Server-Sent Events Fallback (/api/v1/events) - location /api/v1/events { + # 2. Server-Sent Events Fallback (/api/v1/events & /api/v1/sync/) + location ~ ^/api/v1/(events|sync/) { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 11ddca9..3e9936a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "prismjs": "^1.30.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "y-indexeddb": "^9.0.12", "yjs": "^13.6.33" }, "devDependencies": { @@ -2782,6 +2783,26 @@ } } }, + "node_modules/y-indexeddb": { + "version": "9.0.12", + "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", + "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.74" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index a4ff258..7d2eb01 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "prismjs": "^1.30.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "y-indexeddb": "^9.0.12", "yjs": "^13.6.33" }, "devDependencies": { diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index d0f7760..bb8439a 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -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 = ({ const [showFolderShareModal, setShowFolderShareModal] = useState(false); const [folderShares, setFolderShares] = useState([]); - // --- DOCUMENT CRDT & COLLABORATION STATE --- + // --- DOCUMENT CRDT & HYBRID TRANSPORT STATE --- const crdtSessionRef = useRef(null); + const providerRef = useRef(null); + const [syncStatus, setSyncStatus] = useState('offline'); const [activePeers, setActivePeers] = useState([]); - - // --- DOCUMENT LOCKING STATE (FALLBACK) --- - const [activeLock, setActiveLock] = useState(null); - const [holdsLock, setHoldsLock] = useState(false); - const holdsLockRef = useRef(false); - const heartbeatTimerRef = useRef(null); - const idleUnlockTimerRef = useRef(null); const currentDocIdRef = useRef(null); const isEditorFocusedRef = useRef(false); const isDraggingBlockRef = useRef(false); const blockInputRefs = useRef>({}); - // 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 = ({ 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 => { - 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 = ({ 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) => { 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 = ({ 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(); - 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 = ({ 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(); - 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 = ({ 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 = ({ 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 = ({ 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(); - localDirtyBlocksRef.current.set(docId, dirtyMap); - } - let deletedSet = localDeletedBlocksRef.current.get(docId); - if (!deletedSet) { - deletedSet = new Set(); - localDeletedBlocksRef.current.set(docId, deletedSet); - } - const srvBlocks = syncedBlocksRef.current.get(docId); - const prevLocalBlocks = - selectedDoc && selectedDoc.id === docId ? selectedDoc.blocks || [] : []; - - const nextIds = new Set(); - (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 = ({ } if (immediate) { - await flushPendingSave(); + await executeSaveToBackend(updatedDoc); } else { debounceSaveTimerRef.current = setTimeout(async () => { debounceSaveTimerRef.current = null; @@ -677,7 +499,7 @@ export const WorkspaceView: React.FC = ({ pendingSaveDocRef.current = null; await executeSaveToBackend(doc); } - }, 350); + }, 500); } }; @@ -776,13 +598,15 @@ export const WorkspaceView: React.FC = ({ const currentUserIdRef = useRef(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 = ({ 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 = ({ }; }, []); - // 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 = ({ 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 = ({ 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 = ({ segStart = 0 ) => { if (isReadOnly) return; - ensureLock(); if (slashBlockId === block.id) { if (filteredSlashCommands.length === 0) { @@ -2315,7 +2076,6 @@ export const WorkspaceView: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ } 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 = ({
{workspaceMode === 'editor' && selectedDoc && ( <> + {/* Transport Connection Status Indicator */} + + + + {syncStatus === 'ws' + ? 'Live (WebSocket)' + : syncStatus === 'sse' + ? 'Eingeschränkt (HTTP-Proxy-Modus)' + : 'Offline (Lokal gesichert)'} + + + {saveState === 'saving' ? 'Speichert…' : 'Gespeichert'} @@ -4144,7 +3913,7 @@ export const WorkspaceView: React.FC = ({ )} - {activePeers.length > 0 ? ( + {activePeers.length > 0 && ( c.userName).join(', ')}`} @@ -4153,12 +3922,7 @@ export const WorkspaceView: React.FC = ({ Live ({activePeers.length}) - ) : holdsLock ? ( - - - CRDT Sync aktiv - - ) : null} + )} {selectedDoc.sharedWith && selectedDoc.sharedWith.length > 0 && selectedDoc.isOwner !== false && ( + )} +
+ ); + } + + return ( +
+ {/* Top Document Action Bar */} +
+
+ {onBackMobile && ( + + )} + + {/* Folder Selector */} +
+ + + {showFolderMenu && canEdit && ( +
+ + {availableFolders.map((folder) => ( + + ))} +
+ )} +
+ + {/* Read-only indicator if shared with read permission */} + {!canEdit && ( + + + Nur Lesen + + )} +
+ + {/* Right Status & Action Buttons */} +
+ {/* Sync Status Pill */} + + {syncStatus === 'connected' ? ( + <> + + Live + + ) : syncStatus === 'connecting' || syncStatus === 'reconnecting' ? ( + <> + + Sync… + + ) : ( + <> + + REST-Sync + + )} + + + + + {doc && ( + + )} + + + + {canEdit && doc?.isOwner !== false && ( + + )} +
+
+ + {/* Main Outliner Content Container */} +
+ {/* Document Icon + Title Header */} +
+
+ + {showEmojiPicker && canEdit && ( +
+ {EMOJI_PALETTE.map((emoji) => ( + + ))} +
+ )} +
+ + +
+ + {/* Document #Tags Summary Bar */} + {extractedTags.length > 0 && ( +
+ + {extractedTags.map((tag) => ( + + ))} +
+ )} + + {/* Logseq Hierarchical Outliner Tree */} + +
+
+ ); +}; diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx new file mode 100644 index 0000000..61fbe42 --- /dev/null +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -0,0 +1,548 @@ +import React, { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react'; +import { + ChevronRight, + ChevronDown, + CheckSquare, + Square, + Trash2, + GripVertical, + ArrowDownLeft, + Indent, + Outdent, + Code, + Heading1, + Heading2, + ListTodo, + Type, +} from 'lucide-react'; +import { DocumentBlock, BlockType } from '../../lib/types'; + +export interface OutlinerBlockProps { + block: DocumentBlock; + index: number; + maxAllowedLevel: number; + hasChildren: boolean; + isFocused: boolean; + canEdit: boolean; + onFocusBlock: (blockId: string) => void; + onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; + onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void; + onDeleteBlock: (blockId: string) => void; + onIndentBlock: (blockId: string, delta: 1 | -1) => void; + onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void; + onTagClick?: (tag: string) => void; + onDragStartBlock?: (index: number) => void; + onDragOverBlock?: (e: React.DragEvent, index: number) => void; + onDropBlock?: (index: number) => void; + isDragTarget?: boolean; + registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void; +} + +export const OutlinerBlock: React.FC = ({ + block, + index, + maxAllowedLevel, + hasChildren, + isFocused, + canEdit, + onFocusBlock, + onUpdateBlock, + onCreateBlockBelow, + onDeleteBlock, + onIndentBlock, + onNavigateFocus, + onTagClick, + onDragStartBlock, + onDragOverBlock, + onDropBlock, + isDragTarget, + registerTextareaRef, +}) => { + const [localContent, setLocalContent] = useState(block.content || ''); + const [showTypeMenu, setShowTypeMenu] = useState(false); + const textareaRef = useRef(null); + const debounceTimerRef = useRef(null); + const latestBlockRef = useRef(block); + latestBlockRef.current = block; + const localContentRef = useRef(localContent); + localContentRef.current = localContent; + + // Sync incoming remote changes ONLY when this textarea does NOT hold active focus + useEffect(() => { + const isActivelyFocused = + textareaRef.current !== null && document.activeElement === textareaRef.current; + if (!isActivelyFocused && block.content !== localContentRef.current) { + setLocalContent(block.content || ''); + } + }, [block.content]); + + // Cleanup 150ms debounce timer on unmount + useEffect(() => { + return () => { + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + }; + }, []); + + // Auto-resize textarea height on content or layout changes + const adjustHeight = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${Math.max(24, el.scrollHeight)}px`; + }, []); + + useLayoutEffect(() => { + adjustHeight(); + }, [localContent, block.type, block.level, adjustHeight]); + + useEffect(() => { + window.addEventListener('resize', adjustHeight); + return () => window.removeEventListener('resize', adjustHeight); + }, [adjustHeight]); + + const flushDebounce = useCallback( + (contentToFlush: string, overrides?: Partial) => { + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + const nextBlock: DocumentBlock = { + ...latestBlockRef.current, + content: contentToFlush, + ...overrides, + }; + onUpdateBlock(nextBlock, true); + }, + [onUpdateBlock] + ); + + const handleContentChange = (e: React.ChangeEvent) => { + if (!canEdit) return; + let val = e.target.value; + let typeOverride: BlockType | undefined; + let checkedOverride: boolean | undefined; + + // Quick markdown prefix shortcuts at start of block + if (val.startsWith('[] ') || val.startsWith('[ ] ')) { + val = val.replace(/^\[\s?\]\s/, ''); + typeOverride = 'todo'; + checkedOverride = false; + } else if (val.startsWith('[x] ') || val.startsWith('[X] ')) { + val = val.replace(/^\[[xX]\]\s/, ''); + typeOverride = 'todo'; + checkedOverride = true; + } else if (val.startsWith('# ') && block.type !== 'h1') { + val = val.slice(2); + typeOverride = 'h1'; + } else if (val.startsWith('## ') && block.type !== 'h2') { + val = val.slice(3); + typeOverride = 'h2'; + } else if (val.startsWith('### ') && block.type !== 'h3') { + val = val.slice(4); + typeOverride = 'h3'; + } else if (val.startsWith('/todo ')) { + val = val.slice(6); + typeOverride = 'todo'; + checkedOverride = false; + } else if (val.startsWith('/code ')) { + val = val.slice(6); + typeOverride = 'code'; + } + + setLocalContent(val); + + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + } + + if (typeOverride) { + flushDebounce(val, { + type: typeOverride, + checked: checkedOverride ?? latestBlockRef.current.checked, + }); + return; + } + + debounceTimerRef.current = window.setTimeout(() => { + debounceTimerRef.current = null; + onUpdateBlock({ + ...latestBlockRef.current, + content: val, + }); + }, 150); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!canEdit) return; + + // 1. Tab / Shift+Tab -> Strict Outliner Indent / Outdent + if (e.key === 'Tab') { + e.preventDefault(); + e.stopPropagation(); + flushDebounce(localContent); + if (e.shiftKey) { + onIndentBlock(block.id, -1); + } else { + onIndentBlock(block.id, 1); + } + return; + } + + // 2. Enter (without Shift) -> Create new block on same level + if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { + e.preventDefault(); + e.stopPropagation(); + flushDebounce(localContent); + onCreateBlockBelow(block.id, localContent); + return; + } + + // 3. Backspace on empty block -> Outdent if indented, or delete block & focus previous + if (e.key === 'Backspace' && localContent === '') { + e.preventDefault(); + e.stopPropagation(); + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + if ((block.level || 0) > 0) { + onIndentBlock(block.id, -1); + } else { + onDeleteBlock(block.id); + } + return; + } + + // 4. ArrowUp / ArrowDown navigation between blocks + if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) { + const el = textareaRef.current; + if (el && el.selectionStart === 0 && el.selectionEnd === 0) { + e.preventDefault(); + flushDebounce(localContent); + onNavigateFocus(block.id, 'up'); + } + return; + } + + if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey) { + const el = textareaRef.current; + if (el && el.selectionStart === localContent.length && el.selectionEnd === localContent.length) { + e.preventDefault(); + flushDebounce(localContent); + onNavigateFocus(block.id, 'down'); + } + return; + } + }; + + const level = Math.max(0, block.level || 0); + const inlineTags = Array.from( + new Set( + (localContent.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g) || []).map((t) => t.slice(1).toLowerCase()) + ) + ); + + const setBlockType = (newType: BlockType) => { + setShowTypeMenu(false); + flushDebounce(localContent, { + type: newType, + checked: newType === 'todo' ? Boolean(block.checked) : false, + }); + }; + + const textStyleClass = + block.type === 'h1' + ? 'text-xl font-bold text-slate-900 dark:text-white tracking-tight' + : block.type === 'h2' + ? 'text-lg font-semibold text-slate-800 dark:text-slate-100' + : block.type === 'h3' + ? 'text-base font-semibold text-slate-800 dark:text-slate-200' + : block.type === 'code' + ? 'font-mono text-xs bg-slate-900/90 text-emerald-300 px-3 py-2 rounded-lg border border-slate-700/80' + : block.type === 'todo' && block.checked + ? 'text-sm text-slate-400 dark:text-slate-500 line-through' + : 'text-sm text-slate-800 dark:text-slate-200'; + + return ( +
onDragOverBlock?.(e, index)} + onDrop={() => onDropBlock?.(index)} + > + {/* Indentation Guide Lines */} + {level > 0 && + Array.from({ length: level }).map((_, lvlIdx) => ( +
+ ))} + + {/* Left Controls: Drag Handle + Collapse Toggle + Bullet / Checkbox */} +
+ {canEdit && ( + + )} + + {hasChildren ? ( + + ) : ( + + )} + + {block.type === 'todo' ? ( + + ) : ( + + )} +
+ + {/* Block Type Quick Popover */} + {showTypeMenu && canEdit && ( +
+ + + + + +
+ )} + + {/* Main Content Area: Auto-Resizing Multiline Textarea + Tag Pills */} +
+