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