Dev #2
@@ -233,6 +233,90 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
return false;
|
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 loadDocs = async (selectId?: string | null) => {
|
||||||
const list = await api.getDocuments();
|
const list = await api.getDocuments();
|
||||||
setDocs(list);
|
setDocs(list);
|
||||||
@@ -259,6 +343,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevId = currentDocIdRef.current;
|
const prevId = currentDocIdRef.current;
|
||||||
if (prevId && prevId !== selectedDoc?.id) {
|
if (prevId && prevId !== selectedDoc?.id) {
|
||||||
|
flushPendingSave();
|
||||||
releaseMyLock(prevId);
|
releaseMyLock(prevId);
|
||||||
}
|
}
|
||||||
currentDocIdRef.current = selectedDoc?.id || null;
|
currentDocIdRef.current = selectedDoc?.id || null;
|
||||||
@@ -279,12 +364,25 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
flushPendingSave();
|
||||||
if (currentDocIdRef.current) {
|
if (currentDocIdRef.current) {
|
||||||
releaseMyLock(currentDocIdRef.current);
|
releaseMyLock(currentDocIdRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [selectedDoc?.id]);
|
}, [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
|
// Real-time WebSocket listener for Document & Lock events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleWs = (e: any) => {
|
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 => {
|
const getDocFolder = (doc: Document): string => {
|
||||||
if (doc.folderPath) return doc.folderPath;
|
if (doc.folderPath) return doc.folderPath;
|
||||||
@@ -577,7 +638,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
folderPath: cleanPath,
|
folderPath: cleanPath,
|
||||||
tags: updatedTags,
|
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
|
// 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,
|
folderPath: cleanPath,
|
||||||
tags: updatedTags,
|
tags: updatedTags,
|
||||||
};
|
};
|
||||||
await persistDocument(updatedDoc);
|
await persistDocument(updatedDoc, true);
|
||||||
if (cleanPath) {
|
if (cleanPath) {
|
||||||
setExpandedFolders((prev) => new Set([...prev, cleanPath]));
|
setExpandedFolders((prev) => new Set([...prev, cleanPath]));
|
||||||
}
|
}
|
||||||
@@ -1001,7 +1062,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
moved.level = blocks[targetIdx]?.level || 0;
|
moved.level = blocks[targetIdx]?.level || 0;
|
||||||
blocks.splice(targetIdx, 0, moved);
|
blocks.splice(targetIdx, 0, moved);
|
||||||
|
|
||||||
persistDocument({ ...selectedDoc, blocks });
|
persistDocument({ ...selectedDoc, blocks }, true);
|
||||||
setDraggingBlockId(null);
|
setDraggingBlockId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1737,7 +1798,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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"
|
className="opacity-0 group-hover:opacity-100 p-0.5 text-muted hover:text-accent rounded transition-opacity"
|
||||||
title="Von Angepinnt entfernen"
|
title="Von Angepinnt entfernen"
|
||||||
@@ -1819,7 +1880,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
persistDocument({ ...selectedDoc, isPinned: !selectedDoc.isPinned })
|
persistDocument({ ...selectedDoc, isPinned: !selectedDoc.isPinned }, true)
|
||||||
}
|
}
|
||||||
className={
|
className={
|
||||||
selectedDoc.isPinned
|
selectedDoc.isPinned
|
||||||
@@ -1929,7 +1990,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
key={ic}
|
key={ic}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
persistDocument({ ...selectedDoc, icon: ic });
|
persistDocument({ ...selectedDoc, icon: ic }, true);
|
||||||
setShowIconPicker(false);
|
setShowIconPicker(false);
|
||||||
}}
|
}}
|
||||||
className="w-8 h-8 hover:bg-panel-elevated text-base flex items-center justify-center rounded-btn transition-colors"
|
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