fix(collab): fix corporate SSE fallback, realtime lock propagation and prevent stale overwrite
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s
This commit is contained in:
1 parent
996f27133c
commit
a1c7a65d84
5 files changed
+238
-63
No files matched your search
@@ -599,32 +599,148 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
}
|
||||
}, [registerLiveToggle, handleToggleLive]);
|
||||
|
||||
// Load initial locks on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.getDocLocks(docId)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.liveSession) {
|
||||
setLiveSession(res.liveSession);
|
||||
if (currentUserId && res.liveSession.initiatorId === currentUserId) {
|
||||
setIsLiveEnabled(true);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(res.blockLocks)) {
|
||||
const map: Record<string, BlockLockInfo> = {};
|
||||
for (const l of res.blockLocks) {
|
||||
map[l.blockId] = l;
|
||||
}
|
||||
setActiveBlockLocks(map);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
// Handle remote document updates (from other users saving via HTTP or realtime)
|
||||
const handleRemoteDocUpdated = useCallback(
|
||||
(remoteDoc: Document) => {
|
||||
if (!remoteDoc || remoteDoc.id !== docId || !Array.isArray(remoteDoc.blocks)) return;
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
const isTitleActivelyEditing =
|
||||
titleInputRef.current !== null &&
|
||||
document.activeElement === titleInputRef.current &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.hasFocus();
|
||||
|
||||
let targetTitle = currentTitleRef.current;
|
||||
if (!isTitleActivelyEditing && remoteDoc.title !== undefined) {
|
||||
setTitle(remoteDoc.title);
|
||||
currentTitleRef.current = remoteDoc.title;
|
||||
targetTitle = remoteDoc.title;
|
||||
}
|
||||
|
||||
// Merge remote blocks: preserve any block currently being actively typed by local user,
|
||||
// update all other blocks from the authoritative server state.
|
||||
const localFocusedId = focusedBlockIdRef.current;
|
||||
const localBlocks = currentBlocksRef.current;
|
||||
const localFocusedBlock = localFocusedId ? localBlocks.find((b) => b.id === localFocusedId) : null;
|
||||
|
||||
const mergedBlocks = remoteDoc.blocks.map((remoteB) => {
|
||||
if (localFocusedId && remoteB.id === localFocusedId && localFocusedBlock) {
|
||||
return { ...remoteB, content: localFocusedBlock.content };
|
||||
}
|
||||
return remoteB;
|
||||
});
|
||||
|
||||
// If local newly-created block isn't on server yet, retain it
|
||||
if (localFocusedId && localFocusedBlock && !mergedBlocks.some((b) => b.id === localFocusedId)) {
|
||||
mergedBlocks.push(localFocusedBlock);
|
||||
}
|
||||
|
||||
const nonEmpties = ensureNonEmptyBlocks(docId, mergedBlocks);
|
||||
setBlocks(nonEmpties);
|
||||
currentBlocksRef.current = nonEmpties;
|
||||
|
||||
const newSnapshot = JSON.stringify({
|
||||
title: targetTitle,
|
||||
blocks: nonEmpties,
|
||||
tags: remoteDoc.tags || currentTagsRef.current || [],
|
||||
});
|
||||
lastSavedSnapshotRef.current = newSnapshot;
|
||||
currentSnapshotRef.current = newSnapshot;
|
||||
|
||||
if (docRef.current) {
|
||||
docRef.current = { ...docRef.current, ...remoteDoc };
|
||||
}
|
||||
onDocMetadataChange(remoteDoc);
|
||||
},
|
||||
[docId, onDocMetadataChange]
|
||||
);
|
||||
|
||||
const handleRemoteBlocksPatched = useCallback(
|
||||
(patch: { upserted?: DocumentBlock[]; deletedIDs?: string[]; blockOrder?: string[] }) => {
|
||||
console.log('[DocumentEditor] DOC_BLOCKS_PATCHED empfangen:', patch);
|
||||
const localFocusedId = focusedBlockIdRef.current;
|
||||
const localBlocks = currentBlocksRef.current;
|
||||
const localFocusedBlock = localFocusedId ? localBlocks.find((b) => b.id === localFocusedId) : null;
|
||||
|
||||
const blockMap = new Map<string, DocumentBlock>();
|
||||
for (const b of localBlocks) {
|
||||
blockMap.set(b.id, b);
|
||||
}
|
||||
|
||||
if (Array.isArray(patch.upserted)) {
|
||||
for (const u of patch.upserted) {
|
||||
if (localFocusedId && u.id === localFocusedId && localFocusedBlock) {
|
||||
blockMap.set(u.id, { ...u, content: localFocusedBlock.content });
|
||||
} else {
|
||||
blockMap.set(u.id, u);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(patch.deletedIDs)) {
|
||||
for (const delId of patch.deletedIDs) {
|
||||
if (delId !== localFocusedId) {
|
||||
blockMap.delete(delId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reconciledBlocks: DocumentBlock[] = [];
|
||||
if (Array.isArray(patch.blockOrder) && patch.blockOrder.length > 0) {
|
||||
for (const id of patch.blockOrder) {
|
||||
const b = blockMap.get(id);
|
||||
if (b) reconciledBlocks.push(b);
|
||||
}
|
||||
blockMap.forEach((b, id) => {
|
||||
if (!patch.blockOrder!.includes(id)) {
|
||||
reconciledBlocks.push(b);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
reconciledBlocks = Array.from(blockMap.values());
|
||||
}
|
||||
|
||||
const nonEmpties = ensureNonEmptyBlocks(docId, reconciledBlocks);
|
||||
setBlocks(nonEmpties);
|
||||
currentBlocksRef.current = nonEmpties;
|
||||
|
||||
const newSnapshot = JSON.stringify({
|
||||
title: currentTitleRef.current,
|
||||
blocks: nonEmpties,
|
||||
tags: currentTagsRef.current,
|
||||
});
|
||||
lastSavedSnapshotRef.current = newSnapshot;
|
||||
currentSnapshotRef.current = newSnapshot;
|
||||
},
|
||||
[docId]
|
||||
);
|
||||
|
||||
// Periodic poll for locks & updates (guarantees synchronization even across flaky networks)
|
||||
useEffect(() => {
|
||||
if (!docId) return;
|
||||
const syncLocks = () => {
|
||||
api.getDocLocks(docId)
|
||||
.then((res) => {
|
||||
if (Array.isArray(res.blockLocks)) {
|
||||
const map: Record<string, BlockLockInfo> = {};
|
||||
for (const l of res.blockLocks) {
|
||||
map[l.blockId] = l;
|
||||
}
|
||||
setActiveBlockLocks(map);
|
||||
}
|
||||
if (res.liveSession) {
|
||||
setLiveSession(res.liveSession);
|
||||
} else {
|
||||
setLiveSession(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
}, [docId, currentUserId]);
|
||||
|
||||
syncLocks();
|
||||
const interval = window.setInterval(syncLocks, 3000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [docId]);
|
||||
|
||||
// Subscribe to realtime lock & live session events
|
||||
useEffect(() => {
|
||||
@@ -633,6 +749,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onMessage: (msg: WSMessage) => {
|
||||
if (msg.event === 'DOC_BLOCK_LOCKED') {
|
||||
const p = (msg.payload?.lock || msg.payload) as BlockLockInfo;
|
||||
console.log('[DocumentEditor] DOC_BLOCK_LOCKED empfangen:', p);
|
||||
if (p?.docId === docId && p?.blockId) {
|
||||
setActiveBlockLocks((prev) => ({
|
||||
...prev,
|
||||
@@ -641,6 +758,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
}
|
||||
} else if (msg.event === 'DOC_BLOCK_UNLOCKED') {
|
||||
const p = msg.payload;
|
||||
console.log('[DocumentEditor] DOC_BLOCK_UNLOCKED empfangen:', p);
|
||||
if (p?.docId === docId && p?.blockId) {
|
||||
setActiveBlockLocks((prev) => {
|
||||
const next = { ...prev };
|
||||
@@ -648,8 +766,21 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} else if (msg.event === 'DOC_UPDATED') {
|
||||
const p = msg.payload as Document;
|
||||
console.log('[DocumentEditor] DOC_UPDATED von anderem Benutzer empfangen:', p);
|
||||
if (p?.id === docId && Array.isArray(p?.blocks)) {
|
||||
handleRemoteDocUpdated(p);
|
||||
}
|
||||
} else if (msg.event === 'DOC_BLOCKS_PATCHED') {
|
||||
const p = msg.payload;
|
||||
console.log('[DocumentEditor] DOC_BLOCKS_PATCHED von Server empfangen:', p);
|
||||
if (p?.docId === docId) {
|
||||
handleRemoteBlocksPatched(p);
|
||||
}
|
||||
} else if (msg.event === 'DOC_LIVE_STARTED') {
|
||||
const s = (msg.payload?.session || msg.payload) as LiveSessionInfo;
|
||||
console.log('[DocumentEditor] DOC_LIVE_STARTED empfangen:', s);
|
||||
if (s?.docId === docId) {
|
||||
setLiveSession(s);
|
||||
if (currentUserId && s.initiatorId === currentUserId) {
|
||||
@@ -657,6 +788,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
}
|
||||
}
|
||||
} else if (msg.event === 'DOC_LIVE_ENDED') {
|
||||
console.log('[DocumentEditor] DOC_LIVE_ENDED empfangen');
|
||||
if (msg.payload?.docId === docId) {
|
||||
setLiveSession(null);
|
||||
setIsLiveEnabled(false);
|
||||
@@ -668,7 +800,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
return () => {
|
||||
conn.close();
|
||||
};
|
||||
}, [docId, currentUserId]);
|
||||
}, [docId, currentUserId, handleRemoteDocUpdated, handleRemoteBlocksPatched]);
|
||||
|
||||
// Block focus/blur locks
|
||||
const localUserColor = useMemo(
|
||||
@@ -682,7 +814,10 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
(blockId: string) => {
|
||||
if (!effectiveCanEdit) return;
|
||||
focusedBlockIdRef.current = blockId;
|
||||
api.lockBlock(docId, blockId, localUserColor, 30).catch(() => {});
|
||||
console.log(`[DocumentEditor] Fokus auf Block "${blockId}". Sende Lock an Server...`);
|
||||
api.lockBlock(docId, blockId, localUserColor, 30).catch((err) => {
|
||||
console.warn(`[DocumentEditor] Lock für Block "${blockId}" fehlgeschlagen:`, err);
|
||||
});
|
||||
|
||||
if (lockKeepaliveTimerRef.current !== null) {
|
||||
window.clearInterval(lockKeepaliveTimerRef.current);
|
||||
@@ -698,6 +833,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
|
||||
const handleBlurBlock = useCallback(
|
||||
(blockId: string) => {
|
||||
console.log(`[DocumentEditor] Blur von Block "${blockId}". Gebe Lock frei und sichere per AutoSave...`);
|
||||
if (lockKeepaliveTimerRef.current !== null) {
|
||||
window.clearInterval(lockKeepaliveTimerRef.current);
|
||||
lockKeepaliveTimerRef.current = null;
|
||||
|
||||
@@ -314,6 +314,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
if (isLockedByOther) return;
|
||||
isFocusedRef.current = true;
|
||||
onFocusBlockRef.current(block.id);
|
||||
sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
||||
@@ -321,6 +322,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
const handleBlur = () => {
|
||||
isFocusedRef.current = false;
|
||||
if (isLockedByOther) return;
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
|
||||
Reference in new issue
Block a user