From 41c64c4924675d0eedc2accfd5d767caa51bf1df Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 02:36:02 +0200 Subject: [PATCH] feat(workspace): add 500ms debouncing to note typing persistence --- .../src/features/workspace/WorkspaceView.tsx | 147 +++++++++++++----- 1 file changed, 104 insertions(+), 43 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index aa370c3..ec04636 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -233,6 +233,90 @@ export const WorkspaceView: React.FC = ({ return false; }; + // Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD) + const debounceSaveTimerRef = useRef(null); + const pendingSaveDocRef = useRef(null); + + const executeSaveToBackend = async (docToSave: Document) => { + if (isReadOnly) return; + try { + const saved = await api.updateDocument(docToSave.id, { + title: docToSave.title, + icon: docToSave.icon, + folderPath: docToSave.folderPath, + blocks: docToSave.blocks, + tags: docToSave.tags, + isPinned: docToSave.isPinned, + }); + setSelectedDoc((prev) => + prev && prev.id === saved.id + ? { + ...prev, + ...saved, + permission: prev.permission, + isOwner: prev.isOwner, + ownerName: prev.ownerName, + sharedWith: prev.sharedWith, + } + : prev + ); + } catch (e: any) { + if (e.message && e.message.includes('423')) { + api.getDocLock(docToSave.id).then((res) => { + if (res.lock) setActiveLock(res.lock); + }); + } + } finally { + if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) { + pendingSaveDocRef.current = null; + setSaveState('saved'); + } + } + }; + + const flushPendingSave = async () => { + if (debounceSaveTimerRef.current) { + clearTimeout(debounceSaveTimerRef.current); + debounceSaveTimerRef.current = null; + } + if (pendingSaveDocRef.current) { + const doc = pendingSaveDocRef.current; + pendingSaveDocRef.current = null; + await executeSaveToBackend(doc); + } + }; + + const persistDocument = async (updatedDoc: Document, immediate = false) => { + if (isReadOnly) return; + await ensureLock(); + + // 1. Update local UI state immediately for responsive, instant typing + setSelectedDoc(updatedDoc); + setDocs((prev) => prev.map((d) => (d.id === updatedDoc.id ? updatedDoc : d))); + setSaveState('saving'); + + pendingSaveDocRef.current = updatedDoc; + + if (debounceSaveTimerRef.current) { + clearTimeout(debounceSaveTimerRef.current); + debounceSaveTimerRef.current = null; + } + + if (immediate) { + await flushPendingSave(); + } else { + // 500 ms debounced delay after last keystroke so disk/SSD is only written after typing pauses + debounceSaveTimerRef.current = setTimeout(async () => { + debounceSaveTimerRef.current = null; + if (pendingSaveDocRef.current) { + const doc = pendingSaveDocRef.current; + pendingSaveDocRef.current = null; + await executeSaveToBackend(doc); + } + }, 500); + } + }; + const loadDocs = async (selectId?: string | null) => { const list = await api.getDocuments(); setDocs(list); @@ -259,6 +343,7 @@ export const WorkspaceView: React.FC = ({ useEffect(() => { const prevId = currentDocIdRef.current; if (prevId && prevId !== selectedDoc?.id) { + flushPendingSave(); releaseMyLock(prevId); } currentDocIdRef.current = selectedDoc?.id || null; @@ -279,12 +364,25 @@ export const WorkspaceView: React.FC = ({ } return () => { + flushPendingSave(); if (currentDocIdRef.current) { releaseMyLock(currentDocIdRef.current); } }; }, [selectedDoc?.id]); + // Flush pending save before tab closes or unmounts + useEffect(() => { + const handleBeforeUnload = () => { + flushPendingSave(); + }; + window.addEventListener('beforeunload', handleBeforeUnload); + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + flushPendingSave(); + }; + }, []); + // Real-time WebSocket listener for Document & Lock events useEffect(() => { const handleWs = (e: any) => { @@ -457,43 +555,6 @@ export const WorkspaceView: React.FC = ({ } }; - const persistDocument = async (updatedDoc: Document) => { - if (isReadOnly) return; - await ensureLock(); - setSelectedDoc(updatedDoc); - setDocs((prev) => prev.map((d) => (d.id === updatedDoc.id ? updatedDoc : d))); - setSaveState('saving'); - try { - const saved = await api.updateDocument(updatedDoc.id, { - title: updatedDoc.title, - icon: updatedDoc.icon, - folderPath: updatedDoc.folderPath, - blocks: updatedDoc.blocks, - tags: updatedDoc.tags, - isPinned: updatedDoc.isPinned, - }); - setSelectedDoc((prev) => - prev - ? { - ...prev, - ...saved, - permission: prev.permission, - isOwner: prev.isOwner, - ownerName: prev.ownerName, - sharedWith: prev.sharedWith, - } - : saved - ); - } catch (e: any) { - if (e.message && e.message.includes('423')) { - api.getDocLock(updatedDoc.id).then((res) => { - if (res.lock) setActiveLock(res.lock); - }); - } - } finally { - setSaveState('saved'); - } - }; const getDocFolder = (doc: Document): string => { if (doc.folderPath) return doc.folderPath; @@ -577,7 +638,7 @@ export const WorkspaceView: React.FC = ({ folderPath: cleanPath, tags: updatedTags, }; - await persistDocument(updatedDoc); + await persistDocument(updatedDoc, true); }; // Global drag cleanup so drag states never get stuck if dragend is lost during re-render @@ -710,7 +771,7 @@ export const WorkspaceView: React.FC = ({ folderPath: cleanPath, tags: updatedTags, }; - await persistDocument(updatedDoc); + await persistDocument(updatedDoc, true); if (cleanPath) { setExpandedFolders((prev) => new Set([...prev, cleanPath])); } @@ -1001,7 +1062,7 @@ export const WorkspaceView: React.FC = ({ moved.level = blocks[targetIdx]?.level || 0; blocks.splice(targetIdx, 0, moved); - persistDocument({ ...selectedDoc, blocks }); + persistDocument({ ...selectedDoc, blocks }, true); setDraggingBlockId(null); }; @@ -1737,7 +1798,7 @@ export const WorkspaceView: React.FC = ({ type="button" onClick={(e) => { e.stopPropagation(); - persistDocument({ ...doc, isPinned: false }); + persistDocument({ ...doc, isPinned: false }, true); }} className="opacity-0 group-hover:opacity-100 p-0.5 text-muted hover:text-accent rounded transition-opacity" title="Von Angepinnt entfernen" @@ -1819,7 +1880,7 @@ export const WorkspaceView: React.FC = ({