feat(notes): show block locks and presence, conflict copies with accept/discard, undo/redo
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m21s

Phase 3 of the collaboration plan:
- Blocks held by someone else show a lock with the name and are read-only;
  Enter in such a block adds a new block below. Delete, merge, indent and
  drag are not started when a involved block (or one in its branch) is
  locked; a short notice explains why (fixes FLAW-25).
- Presence: the editor header lists who else has the note open.
- Conflict copies are highlighted ("Konflikt – Version von …") with
  Übernehmen (replaces the original or the title) and Verwerfen. Both check
  the locks of the original and of the copy first.
- Undo/redo of own changes (Ctrl/Cmd+Z, Ctrl+Shift+Z, Ctrl+Y, beforeinput
  history events): typing is grouped per block until a 1 s pause, up to 200
  steps per tab; a step is skipped with a notice if someone else changed or
  holds the block meanwhile. New clearConflict props patch on the server.
- New blocks get focus synchronously on mount (fixes FLAW-26).
- Fix: after reopening a note from local storage the engine polled every
  30 s instead of 3 s until the next change, because the delay was decided
  before the first response told it the note is shared.
- Tests: undo/conflict engine tests, lock/undo/conflict UI tests and
  e2e/lock.e2e.mjs (two browsers); all three browser tests pass against a
  freshly built image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 13:44:27 +01:00
1 parent 08d9b2dc77
commit aa397c18cd
20 files changed
+1060 -38

No files matched your search

@@ -24,6 +24,11 @@ export interface OutlinerTreeProps {
onWikiLinkClick?: (pageTitle: string) => void;
initialFocusedBlockId?: string | null;
onFocusedBlockChange?: (blockId: string | null) => void;
lockedBlocks?: Map<string, string>; // blockId -> name of the user editing it (someone else)
onLockedAction?: (holderName: string) => void;
onResolveConflict?: (blockId: string, accept: boolean) => void;
onUndo?: () => void;
onRedo?: () => void;
}
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
@@ -43,6 +48,11 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onWikiLinkClick,
initialFocusedBlockId,
onFocusedBlockChange,
lockedBlocks,
onLockedAction,
onResolveConflict,
onUndo,
onRedo,
}, ref) => {
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
@@ -125,21 +135,26 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
if (pendingFocusRef.current && pendingFocusRef.current.blockId === blockId) {
const target = pendingFocusRef.current;
pendingFocusRef.current = null;
requestAnimationFrame(() => {
setFocusedBlockId(blockId);
el.focus();
try {
if (target.cursor === 'select') {
el.select();
} else {
const pos = target.cursor === 'end' ? el.value.length : 0;
el.setSelectionRange(pos, pos);
}
} catch {
// ignore
// Focus right away (the element is already in the DOM): keystrokes typed directly after Enter
// must land in the new block, not in the previous one
setFocusedBlockId(blockId);
el.focus();
try {
if (target.cursor === 'select') {
el.select();
} else {
const pos =
typeof target.cursor === 'number'
? Math.min(target.cursor, el.value.length)
: target.cursor === 'end'
? el.value.length
: 0;
el.setSelectionRange(pos, pos);
}
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
} catch {
// ignore
}
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} else {
textareaMapRef.current.delete(blockId);
@@ -482,9 +497,33 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
[blocks, canEdit, docId, focusBlockInput, onCreateBlock, onInsertBlocks, onUpdateBlock]
);
// Name of the other user holding the block (or, withBranch, any block below it); structural actions on
// such blocks are not started at all, so they cannot end up half applied
const lockHolder = useCallback(
(blockId: string, withBranch = false): string | undefined => {
if (!lockedBlocks || lockedBlocks.size === 0) return undefined;
const direct = lockedBlocks.get(blockId);
if (direct || !withBranch) return direct;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx < 0) return undefined;
const level = blocks[idx].level || 0;
for (let i = idx + 1; i < blocks.length && (blocks[i].level || 0) > level; i++) {
const name = lockedBlocks.get(blocks[i].id);
if (name) return name;
}
return undefined;
},
[blocks, lockedBlocks]
);
const handleDeleteBlock = useCallback(
(blockId: string, deleteSubtree = false) => {
if (!canEdit) return;
const holder = lockHolder(blockId, true);
if (holder) {
onLockedAction?.(holder);
return;
}
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
@@ -526,7 +565,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onDeleteBlock(blockId);
}
},
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock]
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock, lockHolder, onLockedAction]
);
// Backspace at the start of a non-empty block: append its text to the previous visible block
@@ -540,6 +579,11 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
const idx = blocks.findIndex((b) => b.id === blockId);
const cur = blocks[idx];
if (!cur || notMergeable.includes(prev.type) || notMergeable.includes(cur.type)) return false;
const holder = lockHolder(cur.id, true) || lockHolder(prev.id);
if (holder) {
onLockedAction?.(holder);
return true;
}
// Children of the merged block move up so they become children of the previous block
const curLevel = Math.max(0, cur.level || 0);
@@ -563,7 +607,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
focusBlockInput(prev.id, joinPos);
return true;
},
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock, onUpdateBlocks, visibleEntries]
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock, onUpdateBlocks, visibleEntries, lockHolder, onLockedAction]
);
const handleIndentBlock = useCallback(
@@ -571,6 +615,11 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
const holder = lockHolder(blockId);
if (holder) {
onLockedAction?.(holder);
return;
}
const current = blocks[idx];
const currentLevel = Math.max(0, current.level || 0);
@@ -616,7 +665,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
}
}
},
[blocks, canEdit, levelOffset, onUpdateBlock, onUpdateBlocks]
[blocks, canEdit, levelOffset, onUpdateBlock, onUpdateBlocks, lockHolder, onLockedAction]
);
const handleNavigateFocus = useCallback(
@@ -685,13 +734,16 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
return;
}
const movingBlock = blocks[draggedIndex];
if (movingBlock) {
const holder = movingBlock ? lockHolder(movingBlock.id) : undefined;
if (holder) {
onLockedAction?.(holder);
} else if (movingBlock) {
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
}
setDraggedIndex(null);
setDragOverIndex(null);
},
[blocks, draggedIndex, onMoveBlock]
[blocks, draggedIndex, onMoveBlock, lockHolder, onLockedAction]
);
useEffect(() => {
@@ -717,7 +769,12 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
maxAllowedLevel={maxAllowedLevel}
hasChildren={hasChildren}
isFocused={focusedBlockId === block.id}
canEdit={canEdit}
canEdit={canEdit && !lockedBlocks?.has(block.id)}
lockedBy={lockedBlocks?.get(block.id)}
canAppendBelow={canEdit}
onResolveConflict={canEdit ? onResolveConflict : undefined}
onUndo={onUndo}
onRedo={onRedo}
levelOffset={levelOffset}
onZoomBlock={onZoomBlock}
onUpdateBlock={onUpdateBlock}