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
@@ -1,5 +1,5 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { DocumentBlock } from '../../types';
|
||||
import { DocSyncEngine, formBatch } from '../engine';
|
||||
import { applyOp, cloneDoc, Doc, emptyDoc, Op, Result } from '../model';
|
||||
@@ -254,6 +254,33 @@ describe('DocSyncEngine', () => {
|
||||
expect(serverContents(server)).toEqual(['Start']);
|
||||
});
|
||||
|
||||
it('polls every 3 s right after reopening a shared note from local storage', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
let calls = 0;
|
||||
const sync = net.sync.bind(net);
|
||||
net.sync = async (...args: Parameters<typeof sync>) => {
|
||||
calls++;
|
||||
return sync(...args);
|
||||
};
|
||||
// Opened before: the note is reopened from local storage (like after a reload)
|
||||
const storage = new MemoryStorage();
|
||||
const first = new DocSyncEngine('doc', { transport: net, storage, manual: true });
|
||||
await first.start();
|
||||
first.stop();
|
||||
const engine = new DocSyncEngine('doc', { transport: net, storage });
|
||||
await engine.start();
|
||||
const afterStart = calls;
|
||||
await vi.advanceTimersByTimeAsync(3_500);
|
||||
expect(calls).toBeGreaterThan(afterStart); // not only after 30 s
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('formBatch keeps the last text per block with the base of the first', () => {
|
||||
const batch = formBatch([
|
||||
{ opId: '1', kind: 'set_content', blockId: 'a', content: 'x', baseVersion: 5 },
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { DocumentBlock } from '../../types';
|
||||
import { DocSyncEngine } from '../engine';
|
||||
import { applyOp, Doc, emptyDoc, Op, Result } from '../model';
|
||||
import { MemoryStorage } from '../persist';
|
||||
import { LogEntry, SyncRequest, Transport, TransportError } from '../transport';
|
||||
|
||||
/** Minimal stand-in server (same rules as the Go server, plus configurable leases of others). */
|
||||
class Server {
|
||||
doc: Doc = emptyDoc();
|
||||
log: LogEntry[] = [];
|
||||
results = new Map<string, Result>();
|
||||
othersLeases: { blockId: string; userId: string; name: string }[] = [];
|
||||
|
||||
constructor(blocks: [string, string][]) {
|
||||
blocks.forEach(([id, content], i) => this.apply({ opId: `seed${i}`, kind: 'insert', blockId: id, parentId: '', orderKey: `a${i}`, content }, 'seed'));
|
||||
}
|
||||
apply(op: Op, by: string): Result {
|
||||
const known = this.results.get(op.opId);
|
||||
if (known) return known;
|
||||
const { effects, result } = applyOp(this.doc, op, by);
|
||||
for (const e of effects) this.log.push({ seq: this.log.length + 1, userId: by, clientId: by, opId: op.opId, effect: e });
|
||||
if (effects.length) result.seq = this.log.length;
|
||||
this.results.set(op.opId, result);
|
||||
return result;
|
||||
}
|
||||
transport(): Transport {
|
||||
return {
|
||||
snapshot: async () => ({
|
||||
docId: 'd',
|
||||
title: this.doc.title,
|
||||
titleVersion: this.doc.titleVersion,
|
||||
headSeq: this.log.length,
|
||||
blocks: Array.from(this.doc.blocks.values()).map((b) => ({ ...b, props: { ...b.props } })),
|
||||
role: 'owner',
|
||||
shared: true,
|
||||
}),
|
||||
sync: async (_id: string, body: SyncRequest) => {
|
||||
if (!body) throw new TransportError('x', 0);
|
||||
const results = body.ops.map((op) => this.apply(op, body.clientId));
|
||||
return {
|
||||
results,
|
||||
ops: this.log.filter((e) => e.seq > body.lastSeq),
|
||||
headSeq: this.log.length,
|
||||
hasMore: false,
|
||||
role: 'owner',
|
||||
shared: true,
|
||||
sessions: 2,
|
||||
presence: [],
|
||||
leases: { granted: [], denied: [], others: this.othersLeases },
|
||||
};
|
||||
},
|
||||
leave: async () => {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let clock = 1_000_000;
|
||||
async function engineFor(server: Server) {
|
||||
const e = new DocSyncEngine('d', { transport: server.transport(), storage: new MemoryStorage(), manual: true, now: () => clock });
|
||||
await e.start();
|
||||
return e;
|
||||
}
|
||||
const flat = (e: DocSyncEngine) => e.getState().blocks;
|
||||
const texts = (e: DocSyncEngine) => flat(e).map((b) => b.content);
|
||||
function edit(e: DocSyncEngine, fn: (b: DocumentBlock[]) => DocumentBlock[]) {
|
||||
e.applyFlat(fn(flat(e).map((b) => ({ ...b }))));
|
||||
}
|
||||
|
||||
describe('undo/redo', () => {
|
||||
it('groups typing in one block and reverts it in one step', async () => {
|
||||
const server = new Server([['a', 'Hallo']]);
|
||||
const e = await engineFor(server);
|
||||
edit(e, (b) => [{ ...b[0], content: 'Hallo W' }]);
|
||||
clock += 300;
|
||||
edit(e, (b) => [{ ...b[0], content: 'Hallo Welt' }]);
|
||||
expect(e.undo().ok).toBe(true);
|
||||
expect(texts(e)).toEqual(['Hallo']);
|
||||
expect(e.getState().canUndo).toBe(false);
|
||||
expect(e.redo().ok).toBe(true);
|
||||
expect(texts(e)).toEqual(['Hallo Welt']);
|
||||
await e.syncNow();
|
||||
expect(server.doc.blocks.get('a')!.content).toBe('Hallo Welt');
|
||||
});
|
||||
|
||||
it('separates typing steps after a pause', async () => {
|
||||
const server = new Server([['a', '']]);
|
||||
const e = await engineFor(server);
|
||||
edit(e, (b) => [{ ...b[0], content: 'Eins' }]);
|
||||
clock += 2000;
|
||||
edit(e, (b) => [{ ...b[0], content: 'Eins Zwei' }]);
|
||||
e.undo();
|
||||
expect(texts(e)).toEqual(['Eins']);
|
||||
});
|
||||
|
||||
it('undoes inserting and deleting blocks', async () => {
|
||||
const server = new Server([['a', 'Erster']]);
|
||||
const e = await engineFor(server);
|
||||
edit(e, (b) => [...b, { id: 'n', type: 'bullet', content: 'Neu', level: 0 }]);
|
||||
clock += 2000;
|
||||
edit(e, (b) => b.filter((x) => x.id !== 'a'));
|
||||
expect(texts(e)).toEqual(['Neu']);
|
||||
e.undo();
|
||||
expect(texts(e)).toEqual(['Erster', 'Neu']);
|
||||
e.undo();
|
||||
expect(texts(e)).toEqual(['Erster']);
|
||||
await e.syncNow();
|
||||
expect(server.doc.blocks.get('a')!.deleted).toBe(false);
|
||||
expect(server.doc.blocks.get('n')!.deleted).toBe(true);
|
||||
});
|
||||
|
||||
it('skips undo when someone else changed the block in between', async () => {
|
||||
const server = new Server([['a', 'Start']]);
|
||||
const e = await engineFor(server);
|
||||
edit(e, (b) => [{ ...b[0], content: 'Meins' }]);
|
||||
await e.syncNow();
|
||||
server.apply({ opId: 'other', kind: 'set_content', blockId: 'a', baseVersion: 2, content: 'Fremd' }, 'other');
|
||||
await e.syncNow();
|
||||
const res = e.undo();
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.message).toMatch(/jemand anderem/);
|
||||
expect(texts(e)).toEqual(['Fremd']);
|
||||
});
|
||||
|
||||
it('skips undo on a block someone else is editing', async () => {
|
||||
const server = new Server([['a', 'Start']]);
|
||||
const e = await engineFor(server);
|
||||
edit(e, (b) => [{ ...b[0], content: 'Meins' }]);
|
||||
await e.syncNow();
|
||||
server.othersLeases = [{ blockId: 'a', userId: 'u2', name: 'Anna' }];
|
||||
await e.syncNow();
|
||||
const res = e.undo();
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.message).toMatch(/Anna/);
|
||||
expect(texts(e)).toEqual(['Meins']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conflict copies', () => {
|
||||
async function withConflict() {
|
||||
const server = new Server([['a', 'Start']]);
|
||||
const e = await engineFor(server);
|
||||
server.apply({ opId: 'first', kind: 'set_content', blockId: 'a', baseVersion: 1, content: 'Gewinner' }, 'other');
|
||||
server.apply({ opId: 'second', kind: 'set_content', blockId: 'a', baseVersion: 1, content: 'Verlierer' }, 'mine');
|
||||
await e.syncNow();
|
||||
const copy = flat(e).find((b) => b.conflictOf === 'a')!;
|
||||
return { server, e, copy };
|
||||
}
|
||||
|
||||
it('accepting replaces the original text and removes the copy', async () => {
|
||||
const { server, e, copy } = await withConflict();
|
||||
expect(e.resolveConflict(copy.id, true).ok).toBe(true);
|
||||
expect(texts(e)).toEqual(['Verlierer']);
|
||||
await e.syncNow();
|
||||
expect(server.doc.blocks.get('a')!.content).toBe('Verlierer');
|
||||
expect(server.doc.blocks.get(copy.id)!.deleted).toBe(true);
|
||||
e.undo();
|
||||
expect(texts(e)).toEqual(['Gewinner', 'Verlierer']);
|
||||
});
|
||||
|
||||
it('is not started while someone else holds the copy', async () => {
|
||||
const { server, e, copy } = await withConflict();
|
||||
server.othersLeases = [{ blockId: copy.id, userId: 'u2', name: 'Ben' }];
|
||||
await e.syncNow();
|
||||
const res = e.resolveConflict(copy.id, true);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.message).toMatch(/Ben/);
|
||||
expect(texts(e)).toEqual(['Gewinner', 'Verlierer']);
|
||||
});
|
||||
|
||||
it('discarding removes only the copy', async () => {
|
||||
const { e, copy } = await withConflict();
|
||||
e.resolveConflict(copy.id, false);
|
||||
expect(texts(e)).toEqual(['Gewinner']);
|
||||
});
|
||||
|
||||
it('a copy whose original is gone becomes a normal block', async () => {
|
||||
const { server, e, copy } = await withConflict();
|
||||
server.apply({ opId: 'del', kind: 'delete', blockId: 'a' }, 'other');
|
||||
await e.syncNow();
|
||||
e.resolveConflict(copy.id, true);
|
||||
expect(flat(e).find((b) => b.id === copy.id)?.conflictOf).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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]) {
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface PropsPatch {
|
||||
checked?: boolean;
|
||||
collapsed?: boolean;
|
||||
lang?: string;
|
||||
clearConflict?: boolean;
|
||||
}
|
||||
|
||||
export type OpKind = 'insert' | 'set_content' | 'set_props' | 'move' | 'delete' | 'restore' | 'set_title';
|
||||
@@ -345,6 +346,7 @@ export function applyOp(d: Doc, op: Op, by = '', conflictId: (opId: string) => s
|
||||
...(op.patch.checked !== undefined ? { checked: op.patch.checked } : {}),
|
||||
...(op.patch.collapsed !== undefined ? { collapsed: op.patch.collapsed } : {}),
|
||||
...(op.patch.lang !== undefined ? { lang: op.patch.lang } : {}),
|
||||
...(op.patch.clearConflict ? { conflictOf: undefined, conflictAuthor: undefined } : {}),
|
||||
});
|
||||
if (type !== b.type || !propsEqual(props, b.props)) emit({ kind: 'set_props', blockId: b.id, type, props });
|
||||
return done({ status: 'applied' });
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Undo/redo of the user's own changes. For every action the engine records the inverse operations plus a
|
||||
// check of the state the action left behind. Undo only runs if that state is still there, i.e. nobody
|
||||
// else changed the affected blocks in between; otherwise the step is skipped instead of overwriting.
|
||||
import { applyOp, cloneDoc, Doc, normProps, Op, propsEqual } from './model';
|
||||
|
||||
export type UndoCheck =
|
||||
| { kind: 'content'; blockId: string; content: string }
|
||||
| { kind: 'alive'; blockId: string; content: string }
|
||||
| { kind: 'deleted'; ids: string[] }
|
||||
| { kind: 'place'; blockId: string; parentId: string; orderKey: string; restoreParent: string }
|
||||
| { kind: 'props'; blockId: string; type: string; checked: boolean; collapsed: boolean; lang: string }
|
||||
| { kind: 'title'; title: string };
|
||||
|
||||
export interface UndoStep {
|
||||
inverse: Omit<Op, 'opId'>[]; // already in the order to apply
|
||||
checks: UndoCheck[];
|
||||
at: number;
|
||||
textKey?: string; // set for pure typing steps (block ID or '#title'), used to group keystrokes
|
||||
}
|
||||
|
||||
/** Applies ops to a copy of `before` and records how to revert them. */
|
||||
export function buildStep(before: Doc, ops: Op[], at: number): UndoStep | null {
|
||||
const v = cloneDoc(before);
|
||||
const inverse: Omit<Op, 'opId'>[] = [];
|
||||
const checks: UndoCheck[] = [];
|
||||
|
||||
for (const raw of ops) {
|
||||
const op = { ...raw };
|
||||
const pre = op.blockId ? v.blocks.get(op.blockId) : undefined;
|
||||
const preCopy = pre ? { ...pre, props: { ...pre.props } } : undefined;
|
||||
const preTitle = v.title;
|
||||
if (op.kind === 'set_content') op.baseVersion = pre?.version ?? 0;
|
||||
if (op.kind === 'set_title') op.baseVersion = v.titleVersion;
|
||||
const { effects } = applyOp(v, op);
|
||||
if (effects.length === 0) continue;
|
||||
const post = op.blockId ? v.blocks.get(op.blockId) : undefined;
|
||||
|
||||
switch (op.kind) {
|
||||
case 'insert':
|
||||
if (post && !post.deleted) {
|
||||
inverse.push({ kind: 'delete', blockId: post.id });
|
||||
checks.push({ kind: 'alive', blockId: post.id, content: post.content });
|
||||
}
|
||||
break;
|
||||
case 'set_content':
|
||||
if (preCopy && post) {
|
||||
inverse.push({ kind: 'set_content', blockId: post.id, content: preCopy.content });
|
||||
checks.push({ kind: 'content', blockId: post.id, content: post.content });
|
||||
}
|
||||
break;
|
||||
case 'set_props':
|
||||
if (preCopy && post) {
|
||||
const p = normProps(preCopy.props);
|
||||
inverse.push({
|
||||
kind: 'set_props',
|
||||
blockId: post.id,
|
||||
patch: { type: preCopy.type, checked: !!p.checked, collapsed: !!p.collapsed, lang: p.lang || '' },
|
||||
});
|
||||
const q = normProps(post.props);
|
||||
checks.push({ kind: 'props', blockId: post.id, type: post.type, checked: !!q.checked, collapsed: !!q.collapsed, lang: q.lang || '' });
|
||||
}
|
||||
break;
|
||||
case 'move':
|
||||
if (preCopy && post) {
|
||||
inverse.push({ kind: 'move', blockId: post.id, parentId: preCopy.parentId, orderKey: preCopy.orderKey });
|
||||
checks.push({ kind: 'place', blockId: post.id, parentId: post.parentId, orderKey: post.orderKey, restoreParent: preCopy.parentId });
|
||||
}
|
||||
break;
|
||||
case 'delete': {
|
||||
const ids = effects.flatMap((e) => (e.kind === 'delete' ? e.ids || [] : []));
|
||||
if (ids.length > 0) {
|
||||
inverse.push({ kind: 'restore', ids });
|
||||
checks.push({ kind: 'deleted', ids });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'restore': {
|
||||
const ids = effects.flatMap((e) => (e.kind === 'restore' ? e.ids || [] : []));
|
||||
for (const id of ids) inverse.push({ kind: 'delete', blockId: id });
|
||||
for (const id of ids) checks.push({ kind: 'alive', blockId: id, content: v.blocks.get(id)?.content || '' });
|
||||
break;
|
||||
}
|
||||
case 'set_title':
|
||||
inverse.push({ kind: 'set_title', title: preTitle });
|
||||
checks.push({ kind: 'title', title: v.title });
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (inverse.length === 0) return null;
|
||||
inverse.reverse();
|
||||
const onlyText = ops.every((o) => o.kind === 'set_content') && new Set(ops.map((o) => o.blockId)).size === 1;
|
||||
const onlyTitle = ops.every((o) => o.kind === 'set_title');
|
||||
return { inverse, checks, at, textKey: onlyText ? ops[0].blockId : onlyTitle ? '#title' : undefined };
|
||||
}
|
||||
|
||||
/** Returns the IDs of blocks the step touches whose state no longer matches, or [] if undo is safe. */
|
||||
export function failedChecks(d: Doc, step: UndoStep): string[] {
|
||||
const failed: string[] = [];
|
||||
for (const c of step.checks) {
|
||||
switch (c.kind) {
|
||||
case 'content': {
|
||||
const b = d.blocks.get(c.blockId);
|
||||
if (!b || b.deleted || b.content !== c.content) failed.push(c.blockId);
|
||||
break;
|
||||
}
|
||||
case 'alive': {
|
||||
const b = d.blocks.get(c.blockId);
|
||||
if (!b || b.deleted || b.content !== c.content) failed.push(c.blockId);
|
||||
break;
|
||||
}
|
||||
case 'deleted':
|
||||
for (const id of c.ids) if (!d.blocks.get(id)?.deleted) failed.push(id);
|
||||
break;
|
||||
case 'place': {
|
||||
const b = d.blocks.get(c.blockId);
|
||||
const parent = c.restoreParent ? d.blocks.get(c.restoreParent) : null;
|
||||
if (!b || b.deleted || b.parentId !== c.parentId || b.orderKey !== c.orderKey || (c.restoreParent && (!parent || parent.deleted))) {
|
||||
failed.push(c.blockId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'props': {
|
||||
const b = d.blocks.get(c.blockId);
|
||||
if (
|
||||
!b ||
|
||||
b.deleted ||
|
||||
b.type !== c.type ||
|
||||
!propsEqual(normProps({ checked: b.props.checked, collapsed: b.props.collapsed, lang: b.props.lang }), normProps({ checked: c.checked, collapsed: c.collapsed, lang: c.lang }))
|
||||
) {
|
||||
failed.push(c.blockId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'title':
|
||||
if (d.title !== c.title) failed.push('#title');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
/** Block IDs an inverse step would modify (for lease checks). */
|
||||
export function touchedBlocks(step: UndoStep): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const op of step.inverse) {
|
||||
if (op.blockId) ids.add(op.blockId);
|
||||
for (const id of op.ids || []) ids.add(id);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
Reference in new issue
Block a user