feat(notes): browser sync engine with offline queue; editor saves through the operation log
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m26s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m26s
Phase 2 of the collaboration plan: - frontend/src/lib/docSync: TypeScript port of the block tree, operations, effects and order keys (kept identical to Go via shared test vectors), diff from the editor's flat list to operations, IndexedDB persistence, HTTP transport and DocSyncEngine (one request in flight, idempotent resends, backoff, 3 s/30 s polling, keepalive flush and leave on exit, block leases for focus, 20 s grace and new blocks). - Text changes carry the version the user saw when typing (first change wins when coalescing; a stale focused field keeps its old base), so a concurrent edit becomes a conflict copy instead of a silent overwrite. - DocumentEditor uses the engine instead of the 800 ms whole-document PUT; editors (not only owners) can edit, remote changes appear live. - Server: task IDs of linked blocks are delivered again, linked tasks follow ticked/deleted blocks after sync, note tags are derived, title changes notify other users; block IDs already used in another note are renamed (starter content with fixed IDs was empty for every further user). - Fix: unlinking tasks from blocks wrote to non-existent columns and never worked (error was swallowed). - Tests: Go/TS order key vectors, diff round trips, engine races (lost responses, offline + reload, edits arriving between typing and sending, stale focused field, duplicate first block), plus a Playwright test with two browsers against the real image (e2e/run.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ea0c78b41
commit
51f90eb7e4
31 files changed
+2468
-207
No files matched your search
@@ -16,7 +16,8 @@ import {
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||
import { api, getToken } from '../../lib/api';
|
||||
import { api } from '../../lib/api';
|
||||
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
|
||||
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||||
|
||||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||
@@ -119,6 +120,29 @@ function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): Document
|
||||
}));
|
||||
}
|
||||
|
||||
// True if both lists show the same blocks; avoids re-rendering when the sync engine echoes a local change.
|
||||
function sameBlocks(a: DocumentBlock[], b: DocumentBlock[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = a[i];
|
||||
const y = b[i];
|
||||
if (
|
||||
x.id !== y.id ||
|
||||
x.content !== y.content ||
|
||||
(x.level || 0) !== (y.level || 0) ||
|
||||
x.type !== y.type ||
|
||||
!!x.checked !== !!y.checked ||
|
||||
!!x.collapsed !== !!y.collapsed ||
|
||||
(x.lang || '') !== (y.lang || '') ||
|
||||
(x.taskId || '') !== (y.taskId || '') ||
|
||||
(x.conflictOf || '') !== (y.conflictOf || '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface BreadcrumbNode {
|
||||
id: string | null;
|
||||
title: string;
|
||||
@@ -224,6 +248,10 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const [emojiSearch, setEmojiSearch] = useState<string>('');
|
||||
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
||||
|
||||
// Sync engine (operation log): stores every change locally and syncs it with the server
|
||||
const engineRef = useRef<DocSyncEngine | null>(null);
|
||||
const [engineState, setEngineState] = useState<EngineState | null>(null);
|
||||
|
||||
const filteredEmojis = useMemo(() => {
|
||||
const q = emojiSearch.trim().toLowerCase();
|
||||
if (!q) return EMOJI_CATALOG;
|
||||
@@ -232,13 +260,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
);
|
||||
}, [emojiSearch]);
|
||||
|
||||
// AutoSave state (REST persistence)
|
||||
const [, setSaveStatus] = useState<DocSaveStatus>('saved');
|
||||
const [, setSaveErrorMessage] = useState<string | null>(null);
|
||||
const saveTimeoutRef = useRef<number | null>(null);
|
||||
const isSavingRef = useRef<boolean>(false);
|
||||
const lastSavedSnapshotRef = useRef<string>('');
|
||||
const isInitialLoadRef = useRef<boolean>(true);
|
||||
|
||||
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
||||
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
|
||||
@@ -249,7 +270,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
currentTitleRef.current = title;
|
||||
const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
|
||||
currentBlocksRef.current = blocks;
|
||||
const lastKnownUpdatedAtRef = useRef<string | null>(initialDoc?.updatedAt || null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
||||
@@ -283,11 +303,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId);
|
||||
}, [title, doc?.title, blocks, zoomedBlockId]);
|
||||
|
||||
// Strictly Read-Only for shared documents: only the document owner can edit!
|
||||
// Owner and editors may edit; the role comes from the server via the sync engine
|
||||
const canEdit = useMemo(() => {
|
||||
if (!doc) return true;
|
||||
return Boolean(doc.isOwner || doc.permission === 'owner');
|
||||
}, [doc]);
|
||||
if (engineState?.role) return engineState.role === 'owner' || engineState.role === 'editor';
|
||||
if (!doc) return false;
|
||||
return Boolean(doc.isOwner || doc.permission === 'owner' || doc.permission === 'write');
|
||||
}, [doc, engineState?.role]);
|
||||
|
||||
// Extract all #tags present across the current blocks
|
||||
const extractedTags = useMemo(() => {
|
||||
@@ -303,124 +324,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
return Array.from(tagSet);
|
||||
}, [blocks]);
|
||||
|
||||
const currentTagsRef = useRef<string[]>(extractedTags);
|
||||
currentTagsRef.current = extractedTags;
|
||||
|
||||
// AutoSave snapshot
|
||||
const currentSnapshot = useMemo(() => {
|
||||
return JSON.stringify({
|
||||
title,
|
||||
blocks,
|
||||
tags: extractedTags,
|
||||
});
|
||||
}, [title, blocks, extractedTags]);
|
||||
|
||||
const currentSnapshotRef = useRef<string>(currentSnapshot);
|
||||
currentSnapshotRef.current = currentSnapshot;
|
||||
|
||||
const executeAutoSave = useCallback(
|
||||
async (immediate: boolean = false) => {
|
||||
if (!docId || !canEdit) return;
|
||||
if (isSavingRef.current && !immediate) return;
|
||||
|
||||
const snapshot = currentSnapshotRef.current;
|
||||
if (snapshot === lastSavedSnapshotRef.current) {
|
||||
setSaveStatus('saved');
|
||||
onSaveStatusChange?.('saved');
|
||||
return;
|
||||
}
|
||||
|
||||
isSavingRef.current = true;
|
||||
setSaveStatus('saving');
|
||||
onSaveStatusChange?.('saving');
|
||||
|
||||
try {
|
||||
const updated = await api.updateDocument(docId, {
|
||||
title: currentTitleRef.current,
|
||||
blocks: currentBlocksRef.current,
|
||||
tags: currentTagsRef.current,
|
||||
});
|
||||
isSavingRef.current = false;
|
||||
lastSavedSnapshotRef.current = snapshot;
|
||||
lastKnownUpdatedAtRef.current = updated.updatedAt || null;
|
||||
setSaveStatus('saved');
|
||||
setSaveErrorMessage(null);
|
||||
onSaveStatusChange?.('saved');
|
||||
if (docRef.current) {
|
||||
docRef.current = updated;
|
||||
onDocMetadataChange(updated);
|
||||
}
|
||||
} catch (err: any) {
|
||||
isSavingRef.current = false;
|
||||
const msg = err?.message || 'Fehler beim Speichern';
|
||||
setSaveStatus('error');
|
||||
setSaveErrorMessage(msg);
|
||||
onSaveStatusChange?.('error', msg);
|
||||
}
|
||||
},
|
||||
[docId, canEdit, onSaveStatusChange, onDocMetadataChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialLoadRef.current) return;
|
||||
if (!canEdit) return;
|
||||
if (currentSnapshot === lastSavedSnapshotRef.current) return;
|
||||
|
||||
setSaveStatus('unsaved');
|
||||
onSaveStatusChange?.('unsaved');
|
||||
|
||||
if (saveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
saveTimeoutRef.current = window.setTimeout(() => {
|
||||
saveTimeoutRef.current = null;
|
||||
executeAutoSave();
|
||||
}, 800);
|
||||
|
||||
return () => {
|
||||
if (saveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [currentSnapshot, canEdit, executeAutoSave, onSaveStatusChange]);
|
||||
|
||||
// Flush on page unload / pagehide via keepalive fetch
|
||||
useEffect(() => {
|
||||
const handleUnload = () => {
|
||||
if (saveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
}
|
||||
const snapshot = currentSnapshotRef.current;
|
||||
if (snapshot !== lastSavedSnapshotRef.current && canEdit) {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
fetch(`/api/v1/documents/${docId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: currentTitleRef.current,
|
||||
blocks: currentBlocksRef.current,
|
||||
tags: currentTagsRef.current,
|
||||
}),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleUnload);
|
||||
window.addEventListener('pagehide', handleUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleUnload);
|
||||
window.removeEventListener('pagehide', handleUnload);
|
||||
handleUnload();
|
||||
};
|
||||
}, [docId, canEdit]);
|
||||
|
||||
// Fetch document on mount
|
||||
useEffect(() => {
|
||||
@@ -436,29 +339,19 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
if (isCancelled) return;
|
||||
const normalized = ensureNonEmptyBlocks(docId, fetched.blocks);
|
||||
setDoc(fetched);
|
||||
setTitle(fetched.title || 'Unbenannte Notiz');
|
||||
setBlocks(normalized);
|
||||
if (!engineRef.current?.getState().ready) {
|
||||
setTitle(fetched.title || 'Unbenannte Notiz');
|
||||
currentBlocksRef.current = normalized;
|
||||
setBlocks(normalized);
|
||||
}
|
||||
const links = new Map<string, string>();
|
||||
for (const b of fetched.blocks || []) {
|
||||
if (b.taskId) links.set(b.id, b.taskId);
|
||||
}
|
||||
engineRef.current?.setTaskLinks(links);
|
||||
setLoading(false);
|
||||
onDocMetadataChange(fetched);
|
||||
|
||||
const initialTags: string[] = [];
|
||||
for (const b of normalized) {
|
||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
if (matches) {
|
||||
for (const m of matches) {
|
||||
const tag = m.slice(1).toLowerCase();
|
||||
if (!initialTags.includes(tag)) initialTags.push(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
lastSavedSnapshotRef.current = JSON.stringify({
|
||||
title: fetched.title || 'Unbenannte Notiz',
|
||||
blocks: normalized,
|
||||
tags: initialTags,
|
||||
});
|
||||
isInitialLoadRef.current = false;
|
||||
setSaveStatus('saved');
|
||||
onSaveStatusChange?.('saved');
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (isCancelled) return;
|
||||
@@ -471,6 +364,60 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
};
|
||||
}, [docId]);
|
||||
|
||||
// Start the sync engine for this note: shows stored state immediately (also offline), then syncs
|
||||
useEffect(() => {
|
||||
const engine = new DocSyncEngine(docId);
|
||||
engineRef.current = engine;
|
||||
const unsubscribe = engine.subscribe((state) => {
|
||||
setEngineState(state);
|
||||
if (!state.ready) return;
|
||||
const next = ensureNonEmptyBlocks(docId, state.blocks);
|
||||
if (!sameBlocks(currentBlocksRef.current, next)) {
|
||||
currentBlocksRef.current = next;
|
||||
setBlocks(next);
|
||||
}
|
||||
if (state.title && state.title !== currentTitleRef.current) {
|
||||
setTitle(state.title);
|
||||
}
|
||||
});
|
||||
void engine.start();
|
||||
return () => {
|
||||
unsubscribe();
|
||||
engine.stop();
|
||||
if (engineRef.current === engine) engineRef.current = null;
|
||||
};
|
||||
}, [docId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!engineState) return;
|
||||
switch (engineState.status) {
|
||||
case 'saving':
|
||||
onSaveStatusChange?.('saving');
|
||||
break;
|
||||
case 'offline':
|
||||
onSaveStatusChange?.('unsaved', 'Offline – Änderungen sind lokal gesichert und werden gesendet, sobald die Verbindung zurück ist');
|
||||
break;
|
||||
case 'error':
|
||||
onSaveStatusChange?.('error', engineState.errorMessage);
|
||||
break;
|
||||
default:
|
||||
onSaveStatusChange?.('saved');
|
||||
}
|
||||
}, [engineState?.status, engineState?.errorMessage]);
|
||||
|
||||
// Every local block change goes to the engine, which turns it into operations
|
||||
const applyLocalBlocks = useCallback(
|
||||
(update: DocumentBlock[] | ((prev: DocumentBlock[]) => DocumentBlock[])) => {
|
||||
const prev = currentBlocksRef.current;
|
||||
const next = typeof update === 'function' ? update(prev) : update;
|
||||
if (next === prev) return;
|
||||
currentBlocksRef.current = next;
|
||||
setBlocks(next);
|
||||
engineRef.current?.applyFlat(next);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Keep parent sidebar metadata in sync when blocks/tags change
|
||||
useEffect(() => {
|
||||
if (!docRef.current) return;
|
||||
@@ -488,27 +435,19 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (!canEdit) return;
|
||||
setTitle(e.target.value);
|
||||
engineRef.current?.setTitle(e.target.value);
|
||||
};
|
||||
|
||||
const handleTitleBlur = () => {
|
||||
executeAutoSave();
|
||||
void engineRef.current?.syncNow();
|
||||
};
|
||||
|
||||
const handleUpdateBlock = useCallback(
|
||||
(updatedBlock: DocumentBlock, immediate: boolean = false) => {
|
||||
(updatedBlock: DocumentBlock) => {
|
||||
if (!canEdit) return;
|
||||
setBlocks((prev) => {
|
||||
const next = prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b));
|
||||
currentBlocksRef.current = next;
|
||||
return next;
|
||||
});
|
||||
if (immediate) {
|
||||
window.setTimeout(() => {
|
||||
executeAutoSave(true);
|
||||
}, 10);
|
||||
}
|
||||
applyLocalBlocks((prev) => prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b)));
|
||||
},
|
||||
[canEdit, executeAutoSave]
|
||||
[canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
const handleUpdateBlocks = useCallback(
|
||||
@@ -518,17 +457,15 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
for (const b of updatedBlocks) {
|
||||
map.set(b.id, b);
|
||||
}
|
||||
setBlocks((prev) =>
|
||||
prev.map((b) => map.get(b.id) || b)
|
||||
);
|
||||
applyLocalBlocks((prev) => prev.map((b) => map.get(b.id) || b));
|
||||
},
|
||||
[canEdit]
|
||||
[canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
const handleCreateBlock = useCallback(
|
||||
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
||||
if (!canEdit) return;
|
||||
setBlocks((prev) => {
|
||||
applyLocalBlocks((prev) => {
|
||||
const next = [...prev];
|
||||
if (afterBlockId) {
|
||||
const idx = next.findIndex((b) => b.id === afterBlockId);
|
||||
@@ -541,13 +478,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
return ensureNonEmptyBlocks(docId, next);
|
||||
});
|
||||
},
|
||||
[docId, canEdit]
|
||||
[docId, canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
const handleInsertBlocks = useCallback(
|
||||
(afterBlockId: string, updatedCurrentBlock: DocumentBlock, newBlocks: DocumentBlock[]) => {
|
||||
if (!canEdit) return;
|
||||
setBlocks((prev) => {
|
||||
applyLocalBlocks((prev) => {
|
||||
const next = [...prev];
|
||||
const idx = next.findIndex((b) => b.id === afterBlockId);
|
||||
if (idx >= 0) {
|
||||
@@ -556,32 +493,24 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
} else {
|
||||
next.push(updatedCurrentBlock, ...newBlocks);
|
||||
}
|
||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
||||
currentBlocksRef.current = normalized;
|
||||
return normalized;
|
||||
return ensureNonEmptyBlocks(docId, next);
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
executeAutoSave(true);
|
||||
}, 50);
|
||||
},
|
||||
[docId, canEdit, executeAutoSave]
|
||||
[docId, canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
const handleDeleteBlock = useCallback(
|
||||
(blockId: string) => {
|
||||
if (!canEdit) return;
|
||||
setBlocks((prev) => {
|
||||
const filtered = prev.filter((b) => b.id !== blockId);
|
||||
return ensureNonEmptyBlocks(docId, filtered);
|
||||
});
|
||||
applyLocalBlocks((prev) => ensureNonEmptyBlocks(docId, prev.filter((b) => b.id !== blockId)));
|
||||
},
|
||||
[docId, canEdit]
|
||||
[docId, canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
const handleMoveBlock = useCallback(
|
||||
(blockId: string, targetIndex: number, newLevel?: number) => {
|
||||
if (!canEdit) return;
|
||||
setBlocks((prev) => {
|
||||
applyLocalBlocks((prev) => {
|
||||
const fromIdx = prev.findIndex((b) => b.id === blockId);
|
||||
if (fromIdx === -1) return prev;
|
||||
|
||||
@@ -632,7 +561,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
return ensureNonEmptyBlocks(docId, copy);
|
||||
});
|
||||
},
|
||||
[docId, canEdit]
|
||||
[docId, canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
@@ -977,6 +906,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onTagClick={onSelectTag}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
initialFocusedBlockId={initialFocusedBlockId}
|
||||
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1113,6 +1043,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onTagClick={onSelectTag}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
initialFocusedBlockId={initialFocusedBlockId}
|
||||
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface OutlinerTreeProps {
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
initialFocusedBlockId?: string | null;
|
||||
onFocusedBlockChange?: (blockId: string | null) => void;
|
||||
}
|
||||
|
||||
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
@@ -41,8 +42,15 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
initialFocusedBlockId,
|
||||
onFocusedBlockChange,
|
||||
}, ref) => {
|
||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||
|
||||
const onFocusedBlockChangeRef = useRef(onFocusedBlockChange);
|
||||
onFocusedBlockChangeRef.current = onFocusedBlockChange;
|
||||
useEffect(() => {
|
||||
onFocusedBlockChangeRef.current?.(focusedBlockId);
|
||||
}, [focusedBlockId]);
|
||||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
||||
initialFocusedBlockId || null
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user