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

@@ -405,6 +405,50 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}
}, [engineState?.status, engineState?.errorMessage]);
// Blocks other users are editing right now (blockId -> name)
const lockedBlocks = useMemo(() => {
const m = new Map<string, string>();
for (const l of engineState?.lockedByOthers || []) m.set(l.blockId, l.name || 'Jemand');
return m;
}, [engineState?.lockedByOthers]);
const otherPeople = useMemo(
() => (engineState?.presence || []).filter((p) => p.userId !== currentUser?.id),
[engineState?.presence, currentUser?.id]
);
// Short hint at the bottom of the editor (blocked actions, skipped undo)
const [notice, setNotice] = useState<string | null>(null);
const noticeTimerRef = useRef<number | null>(null);
const showNotice = useCallback((message: string) => {
setNotice(message);
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
noticeTimerRef.current = window.setTimeout(() => setNotice(null), 3500);
}, []);
useEffect(() => () => {
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
}, []);
const handleLockedAction = useCallback(
(holder: string) => showNotice(`${holder} bearbeitet diesen Punkt gerade – bitte kurz warten`),
[showNotice]
);
const handleUndo = useCallback(() => {
const res = engineRef.current?.undo();
if (res?.message) showNotice(res.message);
}, [showNotice]);
const handleRedo = useCallback(() => {
const res = engineRef.current?.redo();
if (res?.message) showNotice(res.message);
}, [showNotice]);
const handleResolveConflict = useCallback(
(blockId: string, accept: boolean) => {
const res = engineRef.current?.resolveConflict(blockId, accept);
if (res?.message) showNotice(res.message);
},
[showNotice]
);
// Every local block change goes to the engine, which turns it into operations
const applyLocalBlocks = useCallback(
(update: DocumentBlock[] | ((prev: DocumentBlock[]) => DocumentBlock[])) => {
@@ -722,6 +766,16 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Right Action Buttons */}
<div className="flex items-center gap-1.5">
{/* Who else has this note open */}
{otherPeople.length > 0 && (
<span
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
title="Hat diese Notiz gerade geöffnet"
>
<Users className="w-3 h-3 text-accent" />
{otherPeople.map((p) => p.name).join(', ')}
</span>
)}
{/* Sharing Section */}
{doc && (() => {
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
@@ -907,6 +961,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onWikiLinkClick={onWikiLinkClick}
initialFocusedBlockId={initialFocusedBlockId}
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
lockedBlocks={lockedBlocks}
onLockedAction={handleLockedAction}
onResolveConflict={handleResolveConflict}
onUndo={handleUndo}
onRedo={handleRedo}
/>
)}
</div>
@@ -1014,6 +1073,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
e.target.style.height = `${e.target.scrollHeight}px`;
}}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && !e.altKey && ['z', 'y'].includes(e.key.toLowerCase())) {
e.preventDefault();
if (e.key.toLowerCase() === 'y' || e.shiftKey) handleRedo();
else handleUndo();
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
outlinerTreeRef.current?.focusFirstBlock();
@@ -1044,10 +1109,24 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onWikiLinkClick={onWikiLinkClick}
initialFocusedBlockId={initialFocusedBlockId}
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
lockedBlocks={lockedBlocks}
onLockedAction={handleLockedAction}
onResolveConflict={handleResolveConflict}
onUndo={handleUndo}
onRedo={handleRedo}
/>
</>
)}
</div>
{notice && (
<div
role="status"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel"
>
{notice}
</div>
)}
</div>
);
};
@@ -18,6 +18,8 @@ import {
Maximize2,
Link as LinkIcon,
FileText,
Lock,
AlertTriangle,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { LinkModal } from './LinkModal';
@@ -70,6 +72,11 @@ export interface OutlinerBlockProps {
isTargeted?: boolean;
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
lockedBy?: string; // someone else is editing this block
canAppendBelow?: boolean; // the user may edit the note, so Enter in a locked block still adds one below
onResolveConflict?: (blockId: string, accept: boolean) => void;
onUndo?: () => void;
onRedo?: () => void;
}
// Rendered markdown only drops syntax characters (**, `, [..](url) …), so its text is a subsequence of the raw
@@ -112,10 +119,17 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
isTargeted,
registerTextareaRef,
onPasteMultiLines,
lockedBy,
canAppendBelow,
onResolveConflict,
onUndo,
onRedo,
}) => {
const [localContent, setLocalContent] = useState<string>(block.content || '');
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(false);
const activeFocus = isFocused || isLocallyFocused;
// A block someone else is editing can still get the cursor (read-only), e.g. to press Enter at its end
const canFocus = canEdit || (!!lockedBy && !!canAppendBelow);
const [slashSearch, setSlashSearch] = useState<string | null>(null);
const [slashIndex, setSlashIndex] = useState(0);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
@@ -164,6 +178,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
[]
);
// Right after undo/redo the focused field must show the engine's text, not keep its own
const acceptRemoteUntilRef = useRef<number>(0);
const onUndoRef = useRef(onUndo);
onUndoRef.current = onUndo;
const onRedoRef = useRef(onRedo);
onRedoRef.current = onRedo;
// Sync incoming remote changes ONLY when this textarea is NOT actively being edited
// Condition: isFocusedRef.current === true && document.hasFocus()
useEffect(() => {
@@ -174,7 +195,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
textareaRef.current !== null &&
document.activeElement === textareaRef.current;
if (!isActivelyEditing && block.content !== localContentRef.current) {
const acceptAfterUndo = Date.now() < acceptRemoteUntilRef.current;
if ((!isActivelyEditing || acceptAfterUndo) && block.content !== localContentRef.current) {
setLocalContent(block.content || '');
localContentRef.current = block.content || '';
}
@@ -406,7 +428,44 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onPasteMultiLinesRef.current?.(block.id, textBefore, textAfter, rawLines);
};
// Ctrl/Cmd+Z undoes the user's own changes in the whole note (not the browser's per-field undo)
const runHistory = (redo: boolean) => {
if (debounceTimerRef.current !== null) {
flushDebounce(localContentRef.current);
}
acceptRemoteUntilRef.current = Date.now() + 500;
if (redo) onRedoRef.current?.();
else onUndoRef.current?.();
};
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
// Mobile keyboards and the context menu undo via beforeinput instead of key events
const onBeforeInput = (e: InputEvent) => {
if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
e.preventDefault();
runHistory(e.inputType === 'historyRedo');
}
};
el.addEventListener('beforeinput', onBeforeInput);
return () => el.removeEventListener('beforeinput', onBeforeInput);
});
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && !e.altKey && ['z', 'y'].includes(e.key.toLowerCase())) {
e.preventDefault();
e.stopPropagation();
runHistory(e.key.toLowerCase() === 'y' || e.shiftKey);
return;
}
// Enter in a block someone else is editing cannot split it (read-only), so it adds a new block below
// without touching the locked one. (Chromium does not move the caret with End in read-only fields.)
if (lockedBy && canAppendBelow && e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onCreateBlockBelowRef.current(block.id, '');
return;
}
if (!canEdit) return;
// Slash command navigation
@@ -577,7 +636,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
: isTargeted
? 'bg-panel-elevated'
: ''
}`}
} ${block.conflictOf ? 'border-l-2 border-status-warn bg-status-warn/5' : ''}`}
style={{
paddingLeft: `${visualLevel * 22 + 4}px`,
}}
@@ -585,7 +644,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onDrop={() => onDropBlock?.(index)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button, a, input, select')) return;
if (!activeFocus && canEdit && textareaRef.current) {
if (!activeFocus && canFocus && textareaRef.current) {
textareaRef.current.focus();
const len = textareaRef.current.value.length;
textareaRef.current.setSelectionRange(len, len);
@@ -728,7 +787,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
<div
onClick={(e) => {
if (!activeFocus && canEdit && textareaRef.current) {
if (!activeFocus && canFocus && textareaRef.current) {
// Keep the row's click handler from moving the caret back to the end
e.stopPropagation();
focusAtPoint(e.clientX, e.clientY);
@@ -875,6 +934,37 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</div>
)}
{/* Conflict copy: text that lost against someone else's change */}
{block.conflictOf && (
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px] text-status-warn select-none">
<AlertTriangle className="w-3 h-3 shrink-0" />
<span>
{block.conflictOf === 'title' ? 'Konflikt beim Titel' : 'Konflikt'}
{block.conflictAuthor ? ` – Version von ${block.conflictAuthor}` : ''}
</span>
{onResolveConflict && (
<>
<button
type="button"
onClick={() => onResolveConflict(block.id, true)}
className="px-1.5 py-0.5 rounded-btn border border-status-warn/40 hover:bg-status-warn/15 cursor-pointer"
title={block.conflictOf === 'title' ? 'Diesen Text als Titel übernehmen' : 'Diesen Text in den ursprünglichen Punkt übernehmen'}
>
Übernehmen
</button>
<button
type="button"
onClick={() => onResolveConflict(block.id, false)}
className="px-1.5 py-0.5 rounded-btn border border-border text-muted hover:text-main hover:bg-panel cursor-pointer"
title="Diese Version verwerfen"
>
Verwerfen
</button>
</>
)}
</div>
)}
{/* Rich Tokens Bar (Wiki-Links & #Tags) if present */}
{hasSpecialTokens && !isFocused && (
<div className="flex flex-wrap items-center gap-1 mt-0.5">
@@ -980,6 +1070,17 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
<Maximize2 className="w-3.5 h-3.5" />
</button>
{/* Someone else is editing this block */}
{lockedBy && (
<span
className="shrink-0 self-start mt-1 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-btn text-[10px] text-muted bg-panel border border-border select-none"
title={`${lockedBy} bearbeitet diesen Punkt gerade`}
>
<Lock className="w-2.5 h-2.5" />
{lockedBy}
</span>
)}
{/* Delete button on hover (Trash2 icon) */}
{canEdit && (
<button
@@ -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}
@@ -1,5 +1,5 @@
import React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { OutlinerBlock, mapRenderedOffsetToRaw } from '../OutlinerBlock';
import { DocumentBlock } from '../../../lib/types';
@@ -62,3 +62,59 @@ describe('clicking into an unfocused bullet', () => {
expect(ta.selectionStart).toBe(10); // "Hallo **We|lt** du"
});
});
function renderBlock(block: DocumentBlock, extra: Record<string, unknown> = {}) {
return render(
<OutlinerBlock
docId="d"
block={block}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused={false}
canEdit
onUpdateBlock={() => {}}
onCreateBlockBelow={() => {}}
onDeleteBlock={() => {}}
onIndentBlock={() => {}}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
{...extra}
/>
);
}
describe('undo shortcut', () => {
it('saves pending typing first, then undoes', () => {
const calls: string[] = [];
const block = { id: 'b1', type: 'bullet', content: 'Alt', level: 0 } as DocumentBlock;
const { container } = renderBlock(block, {
onUpdateBlock: (b: DocumentBlock) => calls.push(`update:${b.content}`),
onUndo: () => calls.push('undo'),
onRedo: () => calls.push('redo'),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: 'Alt neu' } });
fireEvent.keyDown(ta, { key: 'z', ctrlKey: true });
fireEvent.keyDown(ta, { key: 'z', ctrlKey: true, shiftKey: true });
fireEvent.keyDown(ta, { key: 'y', metaKey: true });
expect(calls).toEqual(['update:Alt neu', 'undo', 'redo', 'redo']);
});
});
describe('conflict copies', () => {
it('show who lost and offer accept/discard', () => {
const onResolve = vi.fn();
const block = { id: 'cf', type: 'bullet', content: 'Meine Version', level: 0, conflictOf: 'b1', conflictAuthor: 'Ben' } as DocumentBlock;
const { getByText } = renderBlock(block, { onResolveConflict: onResolve });
expect(getByText(/Version von Ben/)).toBeTruthy();
fireEvent.click(getByText('Übernehmen'));
fireEvent.click(getByText('Verwerfen'));
expect(onResolve.mock.calls).toEqual([
['cf', true],
['cf', false],
]);
});
});
@@ -7,17 +7,26 @@ import { DocumentBlock } from '../../../lib/types';
// Holds the latest block state rendered by the harness
let live: DocumentBlock[] = [];
const Harness: React.FC<{ initial: DocumentBlock[] }> = ({ initial }) => {
let lockedNotices: string[] = [];
const Harness: React.FC<{ initial: DocumentBlock[]; locked?: Map<string, string> }> = ({ initial, locked }) => {
const [blocks, setBlocks] = useState(initial);
live = blocks;
return (
<OutlinerTree
docId="d1"
blocks={blocks}
lockedBlocks={locked}
onLockedAction={(name) => lockedNotices.push(name)}
canEdit
onUpdateBlock={(b) => setBlocks((p) => p.map((x) => (x.id === b.id ? b : x)))}
onUpdateBlocks={(u) => setBlocks((p) => p.map((x) => u.find((y) => y.id === x.id) || x))}
onCreateBlock={() => {}}
onCreateBlock={(nb, after) =>
setBlocks((p) => {
const i = p.findIndex((x) => x.id === after);
return i < 0 ? [...p, nb] : [...p.slice(0, i + 1), nb, ...p.slice(i + 1)];
})
}
onDeleteBlock={(id) => setBlocks((p) => p.filter((x) => x.id !== id))}
onMoveBlock={() => {}}
/>
@@ -77,3 +86,46 @@ describe('Backspace at the start of a bullet', () => {
expect(live).toHaveLength(2);
});
});
describe('blocks locked by someone else', () => {
it('are read-only and show who edits them', () => {
render(<Harness initial={[mk('a', 'Frei'), mk('b', 'Gesperrt')]} locked={new Map([['b', 'Anna']])} />);
expect(area('Gesperrt').readOnly).toBe(true);
expect(area('Frei').readOnly).toBe(false);
expect(screen.getByTitle('Anna bearbeitet diesen Punkt gerade')).toBeTruthy();
});
it('merging into a locked bullet is not started at all', async () => {
lockedNotices = [];
render(<Harness initial={[mk('a', 'Oben'), mk('b', 'Unten')]} locked={new Map([['a', 'Anna']])} />);
await backspaceAt(area('Unten'));
expect(live.map((b) => b.content)).toEqual(['Oben', 'Unten']);
expect(lockedNotices).toEqual(['Anna']);
});
it('a branch with a locked child cannot be merged away', async () => {
lockedNotices = [];
render(<Harness initial={[mk('a', 'A'), mk('b', 'B'), mk('c', 'C', 1)]} locked={new Map([['c', 'Ben']])} />);
await backspaceAt(area('B'));
expect(live.map((b) => b.content)).toEqual(['A', 'B', 'C']);
expect(lockedNotices).toEqual(['Ben']);
});
});
describe('Enter on a locked bullet', () => {
for (const [label, pos] of [
['at the end', 8],
['in the middle', 3],
] as const) {
it(`${label} adds a new bullet below and never splits the locked one`, async () => {
render(<Harness initial={[mk('a', 'Gesperrt')]} locked={new Map([['a', 'Anna']])} />);
const ta = area('Gesperrt');
ta.focus();
ta.setSelectionRange(pos, pos);
await act(async () => {
fireEvent.keyDown(ta, { key: 'Enter' });
});
expect(live.map((b) => b.content)).toEqual(['Gesperrt', '']);
});
}
});