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

+121 -3
View File
@@ -7,6 +7,7 @@ import { diffFlat, toDocumentBlocks } from './diff';
import { applyEffect, applyOp, cloneDoc, Doc, docFromSnapshot, emptyDoc, Op, Result } from './model';
import { defaultStorage, SyncStorage } from './persist';
import { httpTransport, LeaseInfo, Presence, Transport, TransportError } from './transport';
import { buildStep, failedChecks, touchedBlocks, UndoStep } from './undo';
export type EngineStatus = 'loading' | 'saved' | 'saving' | 'offline' | 'error';
@@ -23,6 +24,13 @@ export interface EngineState {
presence: Presence[];
lockedByOthers: LeaseInfo[];
conflicts: Result[]; // conflicts of own operations since start
canUndo: boolean;
canRedo: boolean;
}
export interface ActionResult {
ok: boolean;
message?: string;
}
export interface EngineOptions {
@@ -39,6 +47,8 @@ const SYNC_DEBOUNCE_MS = 300;
const POLL_COLLAB_MS = 3_000;
const POLL_SOLO_MS = 30_000;
const BACKOFF_MAX_MS = 30_000;
const UNDO_LIMIT = 200;
const UNDO_GROUP_MS = 1_000; // keystrokes in the same block are one undo step until a pause this long
function randomHex(bytes: number): string {
const arr = new Uint8Array(bytes);
@@ -118,6 +128,8 @@ export class DocSyncEngine {
// Version of each block the editor last showed in agreement with the engine. The focused text field does
// not take over remote changes while typing, so its edits are based on this version, not the newest one.
private uiSeen = new Map<string, number>();
private undoStack: UndoStep[] = [];
private redoStack: UndoStep[] = [];
private syncing = false;
private syncAgain = false;
@@ -198,6 +210,8 @@ export class DocSyncEngine {
presence: this.presence,
lockedByOthers: this.lockedByOthers,
conflicts: this.conflicts,
canUndo: this.undoStack.length > 0,
canRedo: this.redoStack.length > 0,
};
}
@@ -223,12 +237,14 @@ export class DocSyncEngine {
}
const ops = diffFlat(this.local, blocks, () => this.newOpId());
if (ops.length === 0) return;
const before = this.local;
const t = this.now();
for (const op of ops) {
if ((op.kind === 'insert' || op.kind === 'set_content') && op.blockId) this.activity.set(op.blockId, t);
if (op.kind === 'set_content' && this.uiSeen.has(op.blockId!)) op.baseVersion = this.uiSeen.get(op.blockId!);
}
this.enqueue(ops);
this.record(before, ops);
// The editor now shows exactly the local state for these blocks
for (const op of ops) {
if (op.kind === 'set_content' || op.kind === 'insert') {
@@ -241,7 +257,58 @@ export class DocSyncEngine {
setTitle(title: string): void {
if (!this.ready || !this.canEdit || title === this.local.title) return;
// The title field always shows the engine's title, so the current local version is what the user saw
this.enqueue([{ opId: this.newOpId(), kind: 'set_title', title, baseVersion: this.local.titleVersion }]);
const before = this.local;
const ops: Op[] = [{ opId: this.newOpId(), kind: 'set_title', title, baseVersion: this.local.titleVersion }];
this.enqueue(ops);
this.record(before, ops);
}
/** Name of the user holding the lease on a block, if it is someone else. */
lockedBy(blockId: string): string | undefined {
return this.lockedByOthers.find((l) => l.blockId === blockId)?.name;
}
/** Reverts the user's last own change (skipped if someone else changed the blocks since). */
undo(): ActionResult {
return this.replay(this.undoStack, this.redoStack, 'Rückgängig');
}
/** Re-applies the last undone change. */
redo(): ActionResult {
return this.replay(this.redoStack, this.undoStack, 'Wiederholen');
}
/** Accepts (replaces the original with the conflict copy) or discards a conflict copy. */
resolveConflict(copyId: string, accept: boolean): ActionResult {
if (!this.ready || !this.canEdit) return { ok: false };
const copy = this.local.blocks.get(copyId);
if (!copy || copy.deleted || !copy.props.conflictOf) return { ok: false };
const target = copy.props.conflictOf;
// The author of the conflicting text holds the copy for a moment; deleting it now would fail half-way
const copyHolder = this.lockedBy(copyId);
if (copyHolder) return { ok: false, message: `${copyHolder} bearbeitet diese Version gerade – bitte kurz warten` };
const ops: Op[] = [];
if (!accept) {
ops.push({ opId: this.newOpId(), kind: 'delete', blockId: copyId });
} else if (target === 'title') {
ops.push({ opId: this.newOpId(), kind: 'set_title', title: copy.content, baseVersion: this.local.titleVersion });
ops.push({ opId: this.newOpId(), kind: 'delete', blockId: copyId });
} else {
const original = this.local.blocks.get(target);
if (original && !original.deleted) {
const holder = this.lockedBy(target);
if (holder) return { ok: false, message: `Der ursprüngliche Punkt wird gerade von ${holder} bearbeitet` };
ops.push({ opId: this.newOpId(), kind: 'set_content', blockId: target, content: copy.content, baseVersion: original.version });
ops.push({ opId: this.newOpId(), kind: 'delete', blockId: copyId });
} else {
// The original is gone: the copy simply becomes a normal block
ops.push({ opId: this.newOpId(), kind: 'set_props', blockId: copyId, patch: { clearConflict: true } });
}
}
const before = this.local;
this.enqueue(ops);
this.record(before, ops);
return { ok: true };
}
/** Tells the engine which block the cursor is in (null = none); used for block leases. */
@@ -264,11 +331,12 @@ export class DocSyncEngine {
this.timer = null;
}
this.syncing = true;
let delay = this.pollDelay();
let delay: number;
try {
await this.roundTrip();
this.backoff = 0;
if (this.syncAgain || this.pending.length > 0) delay = 0;
// Decided only now: the response tells whether the note is shared (3 s) or private (30 s)
delay = this.syncAgain || this.pending.length > 0 ? 0 : this.pollDelay();
} catch (err) {
this.handleError(err);
delay = this.status === 'error' ? this.pollDelay() : this.nextBackoff();
@@ -297,6 +365,56 @@ export class DocSyncEngine {
void this.persist().finally(() => this.schedule(SYNC_DEBOUNCE_MS));
}
private record(before: Doc, ops: Op[]): void {
const step = buildStep(before, ops, this.now());
if (!step) return;
this.redoStack = [];
const top = this.undoStack[this.undoStack.length - 1];
if (step.textKey && top && top.textKey === step.textKey && step.at - top.at < UNDO_GROUP_MS) {
// Same block, still typing: keep the original text as undo target, update the expected result
top.checks = step.checks;
top.at = step.at;
} else {
this.undoStack.push(step);
if (this.undoStack.length > UNDO_LIMIT) this.undoStack.shift();
}
this.emit();
}
private replay(from: UndoStep[], to: UndoStep[], label: string): ActionResult {
if (!this.ready || !this.canEdit) return { ok: false };
const step = from.pop();
if (!step) return { ok: false };
if (failedChecks(this.local, step).length > 0) {
this.emit();
return { ok: false, message: `${label} übersprungen: Der Punkt wurde inzwischen von jemand anderem geändert` };
}
const holder = touchedBlocks(step).map((id) => this.lockedBy(id)).find(Boolean);
if (holder) {
this.emit();
return { ok: false, message: `${label} übersprungen: ${holder} bearbeitet den Punkt gerade` };
}
const ops: Op[] = step.inverse.map((o) => {
const op: Op = { ...o, opId: this.newOpId() } as Op;
if (op.kind === 'set_content') op.baseVersion = this.local.blocks.get(op.blockId!)?.version ?? 0;
if (op.kind === 'set_title') op.baseVersion = this.local.titleVersion;
return op;
});
const before = this.local;
this.enqueue(ops);
const back = buildStep(before, ops, this.now());
if (back) {
back.textKey = undefined;
to.push(back);
}
for (const op of ops) {
const b = op.blockId ? this.local.blocks.get(op.blockId) : undefined;
if (b) this.uiSeen.set(b.id, b.version);
}
this.emit();
return { ok: true };
}
private recompute(): void {
const v = cloneDoc(this.confirmed);
for (const op of [...(this.inflight || []), ...this.pending]) {