feat(workspace): add 500ms debouncing to note typing persistence
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 53s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 53s
This commit is contained in:
1 parent
d338c4cb8a
commit
41c64c4924
1 file changed
+104
-43
@@ -233,6 +233,90 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
return false;
|
||||
};
|
||||
|
||||
// Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD)
|
||||
const debounceSaveTimerRef = useRef<any>(null);
|
||||
const pendingSaveDocRef = useRef<Document | null>(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<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
}
|
||||
|
||||
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<WorkspaceViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
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<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
persistDocument({ ...selectedDoc, isPinned: !selectedDoc.isPinned })
|
||||
persistDocument({ ...selectedDoc, isPinned: !selectedDoc.isPinned }, true)
|
||||
}
|
||||
className={
|
||||
selectedDoc.isPinned
|
||||
@@ -1929,7 +1990,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
key={ic}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
persistDocument({ ...selectedDoc, icon: ic });
|
||||
persistDocument({ ...selectedDoc, icon: ic }, true);
|
||||
setShowIconPicker(false);
|
||||
}}
|
||||
className="w-8 h-8 hover:bg-panel-elevated text-base flex items-center justify-center rounded-btn transition-colors"
|
||||
|
||||
Reference in new issue
Block a user