Dev #2

Merged
sandrodach merged 37 commits from dev into main 2026-10-25 12:14:23 +01:00
Showing only changes of commit 41c64c4924 - Show all commits
+104 -43
View File
@@ -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"