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