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
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:
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}
|
||||
|
||||
Reference in new issue
Block a user