@brass-build/client 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +124 -45
- package/CHANGELOG.md +80 -4
- package/README.md +666 -179
- package/dist/api-fetch.d.ts +8 -0
- package/dist/api-fetch.d.ts.map +1 -1
- package/dist/api-fetch.js +35 -8
- package/dist/api-fetch.js.map +1 -1
- package/dist/batched-get.d.ts +11 -0
- package/dist/batched-get.d.ts.map +1 -0
- package/dist/batched-get.js +92 -0
- package/dist/batched-get.js.map +1 -0
- package/dist/call-service.d.ts +4 -0
- package/dist/call-service.d.ts.map +1 -1
- package/dist/call-service.js +66 -3
- package/dist/call-service.js.map +1 -1
- package/dist/client.d.ts +48 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +918 -115
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +25 -2
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +258 -61
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-commands.d.ts.map +1 -1
- package/dist/document-commands.js +5 -3
- package/dist/document-commands.js.map +1 -1
- package/dist/document-contents.d.ts +10 -0
- package/dist/document-contents.d.ts.map +1 -1
- package/dist/document-contents.js +23 -1
- package/dist/document-contents.js.map +1 -1
- package/dist/document-set-subscription.d.ts +56 -0
- package/dist/document-set-subscription.d.ts.map +1 -0
- package/dist/document-set-subscription.js +240 -0
- package/dist/document-set-subscription.js.map +1 -0
- package/dist/document-signals.d.ts +6 -0
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +31 -1
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +36 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +69 -18
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +12 -2
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +103 -27
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts +92 -10
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +285 -39
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +173 -8
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +1778 -310
- package/dist/documents.js.map +1 -1
- package/dist/errors.d.ts +3 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +2 -0
- package/dist/fetch-retry.js.map +1 -1
- package/dist/file-choice.d.ts +2 -0
- package/dist/file-choice.d.ts.map +1 -0
- package/dist/file-choice.js +27 -0
- package/dist/file-choice.js.map +1 -0
- package/dist/guest-link.d.ts +18 -1
- package/dist/guest-link.d.ts.map +1 -1
- package/dist/guest-link.js +329 -1
- package/dist/guest-link.js.map +1 -1
- package/dist/handle-types.d.ts +67 -5
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +15 -19
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +231 -76
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +61 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +94 -42
- package/dist/hosted-ui.js.map +1 -1
- package/dist/import-contract.d.ts +74 -0
- package/dist/import-contract.d.ts.map +1 -0
- package/dist/import-contract.js +163 -0
- package/dist/import-contract.js.map +1 -0
- package/dist/import-guest.d.ts +9 -0
- package/dist/import-guest.d.ts.map +1 -0
- package/dist/import-guest.js +155 -0
- package/dist/import-guest.js.map +1 -0
- package/dist/import-report.d.ts +17 -0
- package/dist/import-report.d.ts.map +1 -0
- package/dist/import-report.js +7 -0
- package/dist/import-report.js.map +1 -0
- package/dist/importer-wait.d.ts +2 -0
- package/dist/importer-wait.d.ts.map +1 -0
- package/dist/importer-wait.js +5 -0
- package/dist/importer-wait.js.map +1 -0
- package/dist/importer.d.ts +18 -4
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +322 -105
- package/dist/importer.js.map +1 -1
- package/dist/in-development.d.ts +2 -0
- package/dist/in-development.d.ts.map +1 -0
- package/dist/in-development.js +10 -0
- package/dist/in-development.js.map +1 -0
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -8
- package/dist/index.js.map +1 -1
- package/dist/init-keys.d.ts +1 -1
- package/dist/init-keys.d.ts.map +1 -1
- package/dist/init-keys.js +6 -1
- package/dist/init-keys.js.map +1 -1
- package/dist/input-documents.d.ts +67 -0
- package/dist/input-documents.d.ts.map +1 -0
- package/dist/input-documents.js +693 -0
- package/dist/input-documents.js.map +1 -0
- package/dist/inputs-status.d.ts +80 -0
- package/dist/inputs-status.d.ts.map +1 -0
- package/dist/inputs-status.js +460 -0
- package/dist/inputs-status.js.map +1 -0
- package/dist/inputs.d.ts +2 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +793 -0
- package/dist/inputs.js.map +1 -0
- package/dist/internal.d.ts +6 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-content.d.ts +33 -0
- package/dist/join-content.d.ts.map +1 -0
- package/dist/join-content.js +114 -0
- package/dist/join-content.js.map +1 -0
- package/dist/join-stamp.d.ts +3 -0
- package/dist/join-stamp.d.ts.map +1 -0
- package/dist/join-stamp.js +12 -0
- package/dist/join-stamp.js.map +1 -0
- package/dist/manifest-drift.d.ts +9 -0
- package/dist/manifest-drift.d.ts.map +1 -0
- package/dist/manifest-drift.js +46 -0
- package/dist/manifest-drift.js.map +1 -0
- package/dist/open-document.d.ts +109 -2
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1822 -811
- package/dist/open-document.js.map +1 -1
- package/dist/page-restore.d.ts +12 -0
- package/dist/page-restore.d.ts.map +1 -0
- package/dist/page-restore.js +23 -0
- package/dist/page-restore.js.map +1 -0
- package/dist/placement.d.ts +13 -0
- package/dist/placement.d.ts.map +1 -0
- package/dist/placement.js +34 -0
- package/dist/placement.js.map +1 -0
- package/dist/presence.d.ts +1 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +16 -4
- package/dist/presence.js.map +1 -1
- package/dist/read-only-document.d.ts +87 -0
- package/dist/read-only-document.d.ts.map +1 -0
- package/dist/read-only-document.js +4 -0
- package/dist/read-only-document.js.map +1 -0
- package/dist/record-paging.d.ts +63 -6
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +122 -29
- package/dist/record-paging.js.map +1 -1
- package/dist/references.d.ts +40 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +135 -0
- package/dist/references.js.map +1 -0
- package/dist/request-lane.d.ts +5 -0
- package/dist/request-lane.d.ts.map +1 -0
- package/dist/request-lane.js +48 -0
- package/dist/request-lane.js.map +1 -0
- package/dist/sealed-arrow.d.ts +170 -0
- package/dist/sealed-arrow.d.ts.map +1 -0
- package/dist/sealed-arrow.js +769 -0
- package/dist/sealed-arrow.js.map +1 -0
- package/dist/sealed-cache.d.ts +21 -0
- package/dist/sealed-cache.d.ts.map +1 -0
- package/dist/sealed-cache.js +247 -0
- package/dist/sealed-cache.js.map +1 -0
- package/dist/sealed-encode.d.ts +87 -0
- package/dist/sealed-encode.d.ts.map +1 -0
- package/dist/sealed-encode.js +876 -0
- package/dist/sealed-encode.js.map +1 -0
- package/dist/sealed-handles.d.ts +45 -0
- package/dist/sealed-handles.d.ts.map +1 -0
- package/dist/sealed-handles.js +408 -0
- package/dist/sealed-handles.js.map +1 -0
- package/dist/sealed-store.d.ts +242 -0
- package/dist/sealed-store.d.ts.map +1 -0
- package/dist/sealed-store.js +1155 -0
- package/dist/sealed-store.js.map +1 -0
- package/dist/serializer.d.ts +6 -15
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-toolbar.d.ts +20 -5
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +419 -67
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts +38 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +562 -79
- package/dist/session.js.map +1 -1
- package/dist/sha256.d.ts +3 -0
- package/dist/sha256.d.ts.map +1 -0
- package/dist/sha256.js +74 -0
- package/dist/sha256.js.map +1 -0
- package/dist/stable-handles.d.ts +19 -0
- package/dist/stable-handles.d.ts.map +1 -0
- package/dist/stable-handles.js +193 -0
- package/dist/stable-handles.js.map +1 -0
- package/dist/staged-seal.d.ts +2 -0
- package/dist/staged-seal.d.ts.map +1 -0
- package/dist/staged-seal.js +76 -0
- package/dist/staged-seal.js.map +1 -0
- package/dist/subscriber-delivery.d.ts +8 -0
- package/dist/subscriber-delivery.d.ts.map +1 -0
- package/dist/subscriber-delivery.js +31 -0
- package/dist/subscriber-delivery.js.map +1 -0
- package/dist/tail-op-import.d.ts +10 -0
- package/dist/tail-op-import.d.ts.map +1 -0
- package/dist/tail-op-import.js +33 -0
- package/dist/tail-op-import.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +6 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +32 -2
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/trial-replace-dialog.d.ts +10 -0
- package/dist/trial-replace-dialog.d.ts.map +1 -0
- package/dist/trial-replace-dialog.js +153 -0
- package/dist/trial-replace-dialog.js.map +1 -0
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +60 -5
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +344 -75
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts +1 -0
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +2 -1
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/ws-client.d.ts +21 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +192 -8
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts +1 -0
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +121 -17
- package/docs/custom-chrome.md +24 -16
- package/docs/hosted-ui-advanced.md +38 -6
- package/docs/imported-documents.md +104 -51
- package/docs/presence.md +16 -4
- package/docs/schemas.md +8 -5
- package/docs/services.md +6 -6
- package/docs/streams.md +139 -150
- package/docs/text-fields.md +26 -19
- package/package.json +5 -2
- package/src/api-fetch.ts +53 -3
- package/src/auth-flow.ts +1 -1
- package/src/batched-get.ts +117 -0
- package/src/call-service.ts +78 -3
- package/src/client.ts +1022 -112
- package/src/document-checkpoints.ts +307 -65
- package/src/document-commands.ts +5 -5
- package/src/document-contents.ts +27 -1
- package/src/document-set-subscription.ts +274 -0
- package/src/document-signals.ts +47 -5
- package/src/document-streams.ts +85 -16
- package/src/document-thumbnail-capture.ts +118 -32
- package/src/documents-namespace.ts +539 -84
- package/src/documents.ts +2017 -348
- package/src/errors.ts +17 -6
- package/src/fetch-retry.ts +5 -0
- package/src/file-choice.ts +26 -0
- package/src/guest-link.ts +362 -1
- package/src/handle-types.ts +73 -47
- package/src/handles.ts +253 -83
- package/src/hosted-ui.ts +211 -50
- package/src/import-contract.ts +227 -0
- package/src/import-guest.ts +193 -0
- package/src/import-report.ts +24 -0
- package/src/importer-wait.ts +4 -0
- package/src/importer.ts +386 -131
- package/src/in-development.ts +10 -0
- package/src/index.ts +76 -16
- package/src/init-keys.ts +6 -1
- package/src/input-documents.ts +819 -0
- package/src/inputs-status.ts +598 -0
- package/src/inputs.ts +1090 -0
- package/src/internal.ts +7 -0
- package/src/join-content.ts +135 -0
- package/src/join-stamp.ts +12 -0
- package/src/manifest-drift.ts +54 -0
- package/src/open-document.ts +2346 -930
- package/src/page-restore.ts +38 -0
- package/src/placement.ts +54 -0
- package/src/presence.ts +21 -5
- package/src/read-only-document.ts +133 -0
- package/src/record-paging.ts +147 -25
- package/src/references.ts +207 -0
- package/src/request-lane.ts +59 -0
- package/src/sealed-arrow.ts +843 -0
- package/src/sealed-cache.ts +267 -0
- package/src/sealed-encode.ts +1015 -0
- package/src/sealed-handles.ts +486 -0
- package/src/sealed-store.ts +1330 -0
- package/src/serializer.ts +8 -20
- package/src/session-toolbar.ts +463 -72
- package/src/session.ts +740 -99
- package/src/sha256.ts +65 -0
- package/src/stable-handles.ts +235 -0
- package/src/staged-seal.ts +90 -0
- package/src/subscriber-delivery.ts +37 -0
- package/src/tail-op-import.ts +30 -0
- package/src/topic-lifecycle.ts +38 -2
- package/src/trial-replace-dialog.ts +163 -0
- package/src/types.ts +6 -6
- package/src/undo.ts +386 -73
- package/src/version-bar.ts +5 -1
- package/src/version.ts +1 -1
- package/src/ws-client.ts +208 -16
- package/src/ws-frame-limit.ts +1 -0
package/src/undo.ts
CHANGED
|
@@ -1,27 +1,31 @@
|
|
|
1
1
|
// Per-Document undo/redo, backed by Loro's UndoManager: undo is
|
|
2
|
-
// local-peer-scoped (reverts only this peer's ops),
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// local-peer-scoped (reverts only this peer's ops), and the controller groups
|
|
3
|
+
// Loro's per-commit stack items into the steps a reader undoes. Every stream
|
|
4
|
+
// of one document is its own LoroDoc with its own controller, and an
|
|
5
|
+
// `UndoGroup` makes them one stack: they share the step clock, so a step is
|
|
6
|
+
// every commit between two seals in whichever streams took one, and the group
|
|
7
|
+
// replays the newest step in each stream that holds a part of it.
|
|
5
8
|
import { UndoManager, type LoroDoc } from './wasm-runtime.js';
|
|
6
9
|
|
|
7
10
|
import type { UndoControllerOptions } from './types.js';
|
|
8
11
|
|
|
9
|
-
// Loro's time-based merge is permanently disabled.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// separately-sealed steps into one. That silently violates the "every
|
|
14
|
-
// checkpoint is an undo boundary" contract (one Ctrl-Z reverts more than the
|
|
15
|
-
// last operation and the rendered state jumps past where the user expected).
|
|
16
|
-
// Same-stroke collapsing is still handled, by the open group not the clock:
|
|
17
|
-
// every commit between two seals brackets into one entry. Apps tune
|
|
18
|
-
// granularity via `setAutoCheckpointIntervalMs` and `createCheckpoint`.
|
|
12
|
+
// Loro's time-based merge is permanently disabled. It merges adjacent changes
|
|
13
|
+
// by timestamp, across a `sealStep` boundary, so one Ctrl-Z would revert more
|
|
14
|
+
// than the last step. Every commit is its own Loro stack item, and the step a
|
|
15
|
+
// commit belongs to is the tag `sealStep` advances.
|
|
19
16
|
const UNDO_MERGE_INTERVAL_MS = 0;
|
|
20
17
|
|
|
21
18
|
// Cap on retained undo steps (Loro's default is 100). 200 at typical AEC
|
|
22
19
|
// entity sizes is a few hundred KB, well below anything that matters.
|
|
23
20
|
const DEFAULT_MAX_UNDO_STEPS = 200;
|
|
24
21
|
|
|
22
|
+
// Loro caps stack ITEMS, and a step is as many items as it has commits (a
|
|
23
|
+
// typed word is one per keystroke). The controller caps STEPS itself (see
|
|
24
|
+
// `_floor`), and Loro's item cap is a memory bound behind it. When that one
|
|
25
|
+
// binds, Loro drops the oldest items, and the oldest step reverts only the
|
|
26
|
+
// part of it that is left.
|
|
27
|
+
const ITEMS_PER_STEP_CAP = 64;
|
|
28
|
+
|
|
25
29
|
// Commit origin the paged marker (`meta.paged:<root>`) is stamped with, and
|
|
26
30
|
// the one origin the undo stack ignores.
|
|
27
31
|
//
|
|
@@ -33,101 +37,410 @@ const DEFAULT_MAX_UNDO_STEPS = 200;
|
|
|
33
37
|
// excluded alongside it.
|
|
34
38
|
export const PAGING_COMMIT_ORIGIN = 'brass.paging';
|
|
35
39
|
|
|
40
|
+
// Commit origin of a write the app asked to keep off the reader's Undo stack
|
|
41
|
+
// (`mutate(fn, { undoable: false })`, `markRead(..., { undoable: false })`):
|
|
42
|
+
// an automatic pass the reader did not ask for, which their Undo must not
|
|
43
|
+
// revert along with what they did.
|
|
44
|
+
export const NON_UNDOABLE_COMMIT_ORIGIN = 'brass.auto';
|
|
45
|
+
|
|
36
46
|
// Thin wrapper around Loro's UndoManager exposing only what the Document
|
|
37
47
|
// needs. Owned by the Document; not part of the public SDK surface.
|
|
48
|
+
//
|
|
49
|
+
// A step is every local commit between two `sealStep` calls, and the Document
|
|
50
|
+
// seals only at a checkpoint, so a step spans a peer's edits whenever two
|
|
51
|
+
// people work at once. Every commit is its own Loro stack item, tagged in
|
|
52
|
+
// `onPush` with the step it belongs to, and `undo` / `redo` replay every item
|
|
53
|
+
// at the top of the stack carrying the top item's tag, so a step reverts whole
|
|
54
|
+
// however many commits it took.
|
|
55
|
+
//
|
|
56
|
+
// Loro's own group cannot hold a step. A peer's edit made after seeing only
|
|
57
|
+
// some of a group's commits leaves the group impossible to undo fully (the
|
|
58
|
+
// commits the peer saw stay in place, and no later undo removes them), and
|
|
59
|
+
// commits ship as they are made, so any group of two or more commits can meet
|
|
60
|
+
// that in ordinary collaboration. `undo-concurrent-delivery.test.ts` pins the
|
|
61
|
+
// engine behaviour.
|
|
62
|
+
//
|
|
63
|
+
// One item per commit costs time on a long step: each Loro undo transforms the
|
|
64
|
+
// rest of the stack against the remote edits recorded on it, so a step's undo
|
|
65
|
+
// grows with its commit count times the remote edits interleaved with the
|
|
66
|
+
// stack (`scripts/bench/undo-long-step.mts`).
|
|
38
67
|
export class UndoController {
|
|
39
68
|
private readonly _mgr: UndoManager;
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
69
|
+
private readonly _maxSteps: number;
|
|
70
|
+
// Holds the tag of the step new local commits join, shared by every stream
|
|
71
|
+
// of the document. `sealStep` advances it.
|
|
72
|
+
private _clock: UndoClock;
|
|
73
|
+
private readonly _hooks: UndoControllerHooks;
|
|
74
|
+
// How many items at the bottom of the redo stack belong to a history a
|
|
75
|
+
// fresh commit in ANOTHER stream has since replaced. Loro clears this
|
|
76
|
+
// stack itself on a commit to this stream only.
|
|
77
|
+
private _redoRetired = 0;
|
|
78
|
+
// While `undo` / `redo` replays a step, the tag the items it pushes onto
|
|
79
|
+
// the opposite stack carry, so the step stays one step in both directions.
|
|
80
|
+
private _replaying: number | null = null;
|
|
81
|
+
// The tag of the item Loro last popped, which the item its replay pushes
|
|
82
|
+
// onto the opposite stack inherits. Loro pops before it pushes.
|
|
83
|
+
private _popped: number | null = null;
|
|
84
|
+
// Set when a replay's Loro call applied an item of another step (see
|
|
85
|
+
// `replay`), and while the controller puts that item back.
|
|
86
|
+
private _foreign: number | null = null;
|
|
87
|
+
private _restoring = false;
|
|
88
|
+
// Whether the replay popped an item of the step it replays.
|
|
89
|
+
private _poppedOwn = false;
|
|
90
|
+
// Tags of the steps on the undo stack, oldest first. Tags only grow up the
|
|
91
|
+
// stack, so a step past the cap is retired by raising `_floor` to its tag:
|
|
92
|
+
// `undo` never replays an item tagged at or below the floor.
|
|
93
|
+
private _undoSteps: number[] = [];
|
|
94
|
+
private _floor = -1;
|
|
95
|
+
// The tag of every item on each Loro stack, bottom first. Once a stack is
|
|
96
|
+
// at Loro's item cap, Loro loses the value `onPush` attaches to each item
|
|
97
|
+
// pushed from then on (loro-crdt 1.13.9 through 1.16.4), so the tag a
|
|
98
|
+
// replay reads comes from here whenever Loro's own reads back empty.
|
|
99
|
+
private _undoTags: number[] = [];
|
|
100
|
+
private _redoTags: number[] = [];
|
|
101
|
+
private readonly _itemCap: number;
|
|
102
|
+
|
|
103
|
+
constructor(
|
|
104
|
+
loroDoc: LoroDoc,
|
|
105
|
+
options?: UndoControllerOptions,
|
|
106
|
+
clock: UndoClock = new UndoGroup(),
|
|
107
|
+
hooks: UndoControllerHooks = {},
|
|
108
|
+
) {
|
|
109
|
+
this._clock = clock;
|
|
110
|
+
this._hooks = hooks;
|
|
111
|
+
const maxSteps = options?.maxUndoSteps ?? DEFAULT_MAX_UNDO_STEPS;
|
|
112
|
+
if (!Number.isInteger(maxSteps) || maxSteps < 1) {
|
|
113
|
+
throw new RangeError(`maxUndoSteps must be an integer of at least 1, got ${maxSteps}`);
|
|
114
|
+
}
|
|
115
|
+
this._maxSteps = maxSteps;
|
|
116
|
+
this._itemCap = maxSteps * ITEMS_PER_STEP_CAP;
|
|
49
117
|
this._mgr = new UndoManager(loroDoc, {
|
|
50
118
|
mergeInterval: UNDO_MERGE_INTERVAL_MS,
|
|
51
|
-
maxUndoSteps:
|
|
52
|
-
excludeOriginPrefixes: [PAGING_COMMIT_ORIGIN],
|
|
119
|
+
maxUndoSteps: this._itemCap,
|
|
120
|
+
excludeOriginPrefixes: [PAGING_COMMIT_ORIGIN, NON_UNDOABLE_COMMIT_ORIGIN],
|
|
121
|
+
onPush: (isUndo) => {
|
|
122
|
+
const tag = this._popped ?? this._replaying ?? this._clock.step;
|
|
123
|
+
this._popped = null;
|
|
124
|
+
const ownStep = this._replaying === null || tag === this._replaying;
|
|
125
|
+
if (!ownStep && !this._restoring) this._foreign = tag;
|
|
126
|
+
if (isUndo) {
|
|
127
|
+
if (ownStep && !this._restoring) this.recordUndoStep(tag);
|
|
128
|
+
// A local commit clears Loro's redo stack; a redo's own push does not.
|
|
129
|
+
if (this._replaying === null) {
|
|
130
|
+
this._redoTags = [];
|
|
131
|
+
this._redoRetired = 0;
|
|
132
|
+
this._hooks.onFreshCommit?.();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const tags = isUndo ? this._undoTags : this._redoTags;
|
|
136
|
+
tags.push(tag);
|
|
137
|
+
if (tags.length > this._itemCap) {
|
|
138
|
+
const dropped = tags.length - this._itemCap;
|
|
139
|
+
tags.splice(0, dropped);
|
|
140
|
+
if (!isUndo) this._redoRetired = Math.max(0, this._redoRetired - dropped);
|
|
141
|
+
}
|
|
142
|
+
this._hooks.onStackChange?.();
|
|
143
|
+
return { value: tag, cursors: [] };
|
|
144
|
+
},
|
|
145
|
+
onPop: (isUndo, meta) => {
|
|
146
|
+
const mirrored = (isUndo ? this._undoTags : this._redoTags).pop();
|
|
147
|
+
const tag = typeof meta.value === 'number' ? meta.value : mirrored;
|
|
148
|
+
this._popped = tag ?? null;
|
|
149
|
+
if (tag === this._replaying && !this._restoring) this._poppedOwn = true;
|
|
150
|
+
if (!isUndo && this._redoRetired > this._redoTags.length) {
|
|
151
|
+
this._redoRetired = this._redoTags.length;
|
|
152
|
+
}
|
|
153
|
+
this._hooks.onStackChange?.();
|
|
154
|
+
},
|
|
53
155
|
});
|
|
54
|
-
// Open the implicit group so commits before the first `createCheckpoint`
|
|
55
|
-
// still bracket as one undo step.
|
|
56
|
-
this._mgr.groupStart();
|
|
57
|
-
this._groupOpen = true;
|
|
58
156
|
}
|
|
59
157
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
undo(): boolean {
|
|
66
|
-
return this.outsideGroup(() => this._mgr.undo());
|
|
158
|
+
private topTag(dir: 'undo' | 'redo'): number | undefined {
|
|
159
|
+
const value = dir === 'undo' ? this._mgr.topUndoValue() : this._mgr.topRedoValue();
|
|
160
|
+
if (typeof value === 'number') return value;
|
|
161
|
+
const tags = dir === 'undo' ? this._undoTags : this._redoTags;
|
|
162
|
+
return tags[tags.length - 1];
|
|
67
163
|
}
|
|
68
164
|
|
|
69
|
-
|
|
70
|
-
|
|
165
|
+
private recordUndoStep(tag: number): void {
|
|
166
|
+
if (this._undoSteps[this._undoSteps.length - 1] === tag) return;
|
|
167
|
+
this._undoSteps.push(tag);
|
|
168
|
+
while (this._undoSteps.length > this._maxSteps) {
|
|
169
|
+
this._floor = Math.max(this._floor, this._undoSteps.shift()!);
|
|
170
|
+
}
|
|
71
171
|
}
|
|
72
172
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
173
|
+
// `beforeEach` runs before every Loro undo / redo the step takes, since each
|
|
174
|
+
// one is its own commit (the Document tags each with its commit origin).
|
|
175
|
+
undo(beforeEach?: () => void): boolean {
|
|
176
|
+
return this.replay('undo', beforeEach);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
redo(beforeEach?: () => void): boolean {
|
|
180
|
+
return this.replay('redo', beforeEach);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Replay this stream's part of step `tag`, which the group chose as the
|
|
184
|
+
// document's newest (undo) or most recently undone (redo) step.
|
|
185
|
+
replayStep(dir: 'undo' | 'redo', tag: number, beforeEach?: () => void): boolean {
|
|
186
|
+
return this.replay(dir, beforeEach, tag);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The tag of the step this stream would replay next, or `undefined` when
|
|
190
|
+
// it has none: below the floor for undo, and past the retired items for
|
|
191
|
+
// redo.
|
|
192
|
+
peek(dir: 'undo' | 'redo'): number | undefined {
|
|
193
|
+
const tag = this.topTag(dir);
|
|
194
|
+
if (typeof tag !== 'number') return undefined;
|
|
195
|
+
if (dir === 'undo') return tag > this._floor ? tag : undefined;
|
|
196
|
+
return this.hasLiveRedo() ? tag : undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Tags at or below this have left the undo stack (see `_floor`).
|
|
200
|
+
get floor(): number {
|
|
201
|
+
return this._floor;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Move to another document's step clock. Called when a stream joins its
|
|
205
|
+
// document, before it has taken any local commit.
|
|
206
|
+
useClock(clock: UndoClock): void {
|
|
207
|
+
this._clock = clock;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// A fresh commit in another stream of the document: the steps on this
|
|
211
|
+
// stream's redo stack belong to the history that commit replaced.
|
|
212
|
+
retireRedo(): void {
|
|
213
|
+
this._redoRetired = this._redoTags.length;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private hasLiveRedo(): boolean {
|
|
217
|
+
return this._redoRetired === 0 || this._redoTags.length > this._redoRetired;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Loro skips an item whose replay would change nothing visible (a peer
|
|
221
|
+
// deleted what it edited) and pops the next one in the same call, which can
|
|
222
|
+
// belong to an older step or sit below `_floor`. The controller puts such an
|
|
223
|
+
// item straight back through the opposite stack, under its own tag, so one
|
|
224
|
+
// call replays one step. A step whose every item Loro skipped has still left
|
|
225
|
+
// the stack, so it counts as replayed and the call returns `true`.
|
|
226
|
+
private replay(dir: 'undo' | 'redo', beforeEach?: () => void, chosen?: number): boolean {
|
|
227
|
+
const top = (): number | undefined => this.topTag(dir);
|
|
228
|
+
const tag = chosen ?? this.peek(dir);
|
|
229
|
+
this._poppedOwn = false;
|
|
78
230
|
try {
|
|
79
|
-
|
|
231
|
+
if (typeof tag === 'number' && top() === tag) {
|
|
232
|
+
this._replaying = tag;
|
|
233
|
+
while (top() === tag) {
|
|
234
|
+
beforeEach?.();
|
|
235
|
+
const moved = dir === 'undo' ? this._mgr.undo() : this._mgr.redo();
|
|
236
|
+
if (this._foreign !== null) {
|
|
237
|
+
this._restoring = true;
|
|
238
|
+
beforeEach?.();
|
|
239
|
+
if (dir === 'undo') this._mgr.redo();
|
|
240
|
+
else this._mgr.undo();
|
|
241
|
+
this._restoring = false;
|
|
242
|
+
this._foreign = null;
|
|
243
|
+
}
|
|
244
|
+
if (!moved) break;
|
|
245
|
+
}
|
|
246
|
+
if (dir === 'undo' && this._undoSteps[this._undoSteps.length - 1] === tag) {
|
|
247
|
+
this._undoSteps.pop();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
80
250
|
} finally {
|
|
81
|
-
this.
|
|
82
|
-
this.
|
|
251
|
+
this._replaying = null;
|
|
252
|
+
this._restoring = false;
|
|
253
|
+
this._foreign = null;
|
|
254
|
+
this._popped = null;
|
|
255
|
+
// The next local commit starts a step of its own, so it never joins the
|
|
256
|
+
// step just replayed (an edit after a redo undoes on its own).
|
|
257
|
+
this._clock.step += 1;
|
|
83
258
|
}
|
|
259
|
+
return this._poppedOwn;
|
|
84
260
|
}
|
|
85
261
|
|
|
86
262
|
canUndo(): boolean {
|
|
87
|
-
|
|
263
|
+
const tag = this.topTag('undo');
|
|
264
|
+
return tag !== undefined && tag > this._floor && this._mgr.canUndo();
|
|
88
265
|
}
|
|
89
266
|
|
|
90
267
|
canRedo(): boolean {
|
|
91
|
-
return this._mgr.canRedo();
|
|
268
|
+
return this._mgr.canRedo() && this.hasLiveRedo();
|
|
92
269
|
}
|
|
93
270
|
|
|
94
|
-
// Seal the current undo step:
|
|
95
|
-
// previous seal collapse into one entry) and open a fresh one.
|
|
271
|
+
// Seal the current undo step: commits after this one join a new step.
|
|
96
272
|
// `createCheckpoint` and the auto-checkpoint scheduler call this so every
|
|
97
|
-
// checkpoint is a Ctrl-Z boundary. Idempotent:
|
|
98
|
-
// intervening commit is safe (Loro collapses the empty group).
|
|
273
|
+
// checkpoint is a Ctrl-Z boundary. Idempotent: an empty step has no items.
|
|
99
274
|
sealStep(): void {
|
|
100
|
-
|
|
101
|
-
this._mgr.groupEnd();
|
|
102
|
-
this._groupOpen = false;
|
|
103
|
-
}
|
|
104
|
-
this._mgr.groupStart();
|
|
105
|
-
this._groupOpen = true;
|
|
275
|
+
this._clock.step += 1;
|
|
106
276
|
}
|
|
107
277
|
|
|
108
278
|
// Drop both stacks. Called by `_applySnapshot`: the prior entries reference a
|
|
109
279
|
// state the new snapshot superseded.
|
|
110
280
|
clear(): void {
|
|
111
|
-
// Close the open group first so `clear()` sees a clean state (defensive;
|
|
112
|
-
// Loro's `clear` drops both stacks regardless).
|
|
113
|
-
if (this._groupOpen) {
|
|
114
|
-
this._mgr.groupEnd();
|
|
115
|
-
this._groupOpen = false;
|
|
116
|
-
}
|
|
117
281
|
this._mgr.clear();
|
|
118
|
-
this.
|
|
119
|
-
this.
|
|
282
|
+
this._undoSteps = [];
|
|
283
|
+
this._undoTags = [];
|
|
284
|
+
this._redoTags = [];
|
|
285
|
+
this._redoRetired = 0;
|
|
286
|
+
this._clock.step += 1;
|
|
120
287
|
}
|
|
121
288
|
|
|
122
289
|
free(): void {
|
|
123
|
-
if (this._groupOpen) {
|
|
124
|
-
try {
|
|
125
|
-
this._mgr.groupEnd();
|
|
126
|
-
} catch {
|
|
127
|
-
// Best-effort; the underlying manager is about to free.
|
|
128
|
-
}
|
|
129
|
-
this._groupOpen = false;
|
|
130
|
-
}
|
|
131
290
|
this._mgr.free();
|
|
132
291
|
}
|
|
133
292
|
}
|
|
293
|
+
|
|
294
|
+
// The step clock a controller tags its commits with.
|
|
295
|
+
export interface UndoClock {
|
|
296
|
+
step: number;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export interface UndoControllerHooks {
|
|
300
|
+
// A local commit outside a replay landed on the undo stack.
|
|
301
|
+
onFreshCommit?: () => void;
|
|
302
|
+
// An item was pushed onto or popped off either stack. Runs inside Loro's
|
|
303
|
+
// callback, so it must not call back into the UndoManager.
|
|
304
|
+
onStackChange?: () => void;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// One stream's place in a document's undo group: the Document that holds the
|
|
308
|
+
// stream's state.
|
|
309
|
+
export interface UndoGroupMember {
|
|
310
|
+
/** @internal This stream's controller, or `null` when it has no CRDT. */
|
|
311
|
+
_undoController(): UndoController | null;
|
|
312
|
+
/** @internal Whether this stream may replay a step now: it takes writes
|
|
313
|
+
* and is not showing a version. */
|
|
314
|
+
_undoEligible(): boolean;
|
|
315
|
+
/** @internal True inside a `mutate` callback on this stream. */
|
|
316
|
+
_undoMutating(): boolean;
|
|
317
|
+
/** @internal Replay this stream's part of step `tag`. */
|
|
318
|
+
_replayUndoStep(dir: 'undo' | 'redo', tag: number): boolean;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface UndoGroupOwner {
|
|
322
|
+
// Whether the document refuses every replay now (closed, or showing a
|
|
323
|
+
// version), whatever each stream's own state.
|
|
324
|
+
blocked(): boolean;
|
|
325
|
+
// Re-read the document's undo state for `subscribeUndoState`.
|
|
326
|
+
notify(): void;
|
|
327
|
+
// A stream other than the owner's took a fresh undoable commit.
|
|
328
|
+
memberCommitted(): void;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// The undo stack of one document, across its streams. The step clock is
|
|
332
|
+
// shared, so tags only grow over time document-wide: the newest step is the
|
|
333
|
+
// highest tag on top of any stream's undo stack, and the most recently undone
|
|
334
|
+
// step is the lowest tag on top of any redo stack. Undo replays that tag in
|
|
335
|
+
// every stream whose top carries it, so a step spanning streams reverts whole.
|
|
336
|
+
export class UndoGroup implements UndoClock {
|
|
337
|
+
step = 0;
|
|
338
|
+
private readonly _members = new Set<UndoGroupMember>();
|
|
339
|
+
private _owner: { member: UndoGroupMember; hooks: UndoGroupOwner } | null = null;
|
|
340
|
+
private _notifyQueued = false;
|
|
341
|
+
|
|
342
|
+
setOwner(member: UndoGroupMember, hooks: UndoGroupOwner): void {
|
|
343
|
+
this._owner = { member, hooks };
|
|
344
|
+
this._members.add(member);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
isOwner(member: UndoGroupMember): boolean {
|
|
348
|
+
return this._owner?.member === member;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
add(member: UndoGroupMember): void {
|
|
352
|
+
this._members.add(member);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
delete(member: UndoGroupMember): void {
|
|
356
|
+
this._members.delete(member);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
seal(): void {
|
|
360
|
+
this.step += 1;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// A non-owner stream's stacks moved. Deferred, because the move is reported
|
|
364
|
+
// from inside the UndoManager, which the re-read calls back into.
|
|
365
|
+
changed(): void {
|
|
366
|
+
if (this._owner === null || this._notifyQueued) return;
|
|
367
|
+
this._notifyQueued = true;
|
|
368
|
+
queueMicrotask(() => {
|
|
369
|
+
this._notifyQueued = false;
|
|
370
|
+
this._owner?.hooks.notify();
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// A fresh commit replaces the history every redo stack in the document
|
|
375
|
+
// undoes back into, not only the stack of the stream it landed in.
|
|
376
|
+
freshCommit(from: UndoGroupMember): void {
|
|
377
|
+
for (const member of this._members) {
|
|
378
|
+
if (member !== from) member._undoController()?.retireRedo();
|
|
379
|
+
}
|
|
380
|
+
if (this._owner !== null && this._owner.member !== from) {
|
|
381
|
+
this._owner.hooks.memberCommitted();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private eligible(): Array<{ member: UndoGroupMember; ctl: UndoController }> {
|
|
386
|
+
const out: Array<{ member: UndoGroupMember; ctl: UndoController }> = [];
|
|
387
|
+
if (this._owner?.hooks.blocked()) return out;
|
|
388
|
+
for (const member of this._members) {
|
|
389
|
+
const ctl = member._undoController();
|
|
390
|
+
if (ctl !== null && member._undoEligible()) out.push({ member, ctl });
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Each stream retires its oldest steps past its own cap, and a step one
|
|
396
|
+
// stream has retired is retired in every stream, so no Undo reverts part of
|
|
397
|
+
// one.
|
|
398
|
+
private floor(): number {
|
|
399
|
+
let floor = -1;
|
|
400
|
+
for (const member of this._members) {
|
|
401
|
+
const ctl = member._undoController();
|
|
402
|
+
if (ctl !== null) floor = Math.max(floor, ctl.floor);
|
|
403
|
+
}
|
|
404
|
+
return floor;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private next(dir: 'undo' | 'redo'): number | undefined {
|
|
408
|
+
let next: number | undefined;
|
|
409
|
+
for (const { ctl } of this.eligible()) {
|
|
410
|
+
const tag = ctl.peek(dir);
|
|
411
|
+
if (tag === undefined) continue;
|
|
412
|
+
if (next === undefined || (dir === 'undo' ? tag > next : tag < next)) next = tag;
|
|
413
|
+
}
|
|
414
|
+
if (dir === 'undo' && next !== undefined && next <= this.floor()) return undefined;
|
|
415
|
+
return next;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
canUndo(): boolean {
|
|
419
|
+
const floor = this.floor();
|
|
420
|
+
return this.eligible().some(({ ctl }) => {
|
|
421
|
+
const tag = ctl.peek('undo');
|
|
422
|
+
return tag !== undefined && tag > floor && ctl.canUndo();
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
canRedo(): boolean {
|
|
427
|
+
return this.eligible().some(({ ctl }) => ctl.canRedo());
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// True when a step was replayed. Refuses inside any stream's `mutate`
|
|
431
|
+
// callback, whose open transaction holds writes the replay would commit
|
|
432
|
+
// outside the op it ships.
|
|
433
|
+
replay(dir: 'undo' | 'redo'): boolean {
|
|
434
|
+
for (const member of this._members) {
|
|
435
|
+
if (member._undoMutating()) return false;
|
|
436
|
+
}
|
|
437
|
+
const tag = this.next(dir);
|
|
438
|
+
if (tag === undefined) return false;
|
|
439
|
+
let replayed = false;
|
|
440
|
+
for (const { member, ctl } of this.eligible()) {
|
|
441
|
+
if (ctl.peek(dir) !== tag) continue;
|
|
442
|
+
if (member._replayUndoStep(dir, tag)) replayed = true;
|
|
443
|
+
}
|
|
444
|
+
return replayed;
|
|
445
|
+
}
|
|
446
|
+
}
|
package/src/version-bar.ts
CHANGED
|
@@ -37,6 +37,9 @@ export interface VersionBarDeps {
|
|
|
37
37
|
// The resolved dashboard/embed origin (no trailing slash); the iframe
|
|
38
38
|
// loads `${dashboardBaseUrl}/embed/version-bar`.
|
|
39
39
|
dashboardBaseUrl: string;
|
|
40
|
+
// The app this bar serves, sent as `appId` in the embed's init so the embed
|
|
41
|
+
// can check the framing page may host it. `undefined` before `Brass.init()`.
|
|
42
|
+
appId: string | undefined;
|
|
40
43
|
// Pins the embed's light/dark theme (matches the other hosted surfaces).
|
|
41
44
|
theme?: 'light' | 'dark' | 'auto';
|
|
42
45
|
// Intents from the embed, routed to the live active document. The two
|
|
@@ -141,7 +144,7 @@ export function createVersionBarFrame(
|
|
|
141
144
|
const url = new URL(`${base}/embed/version-bar`);
|
|
142
145
|
url.searchParams.set('theme', theme);
|
|
143
146
|
iframe.src = url.toString();
|
|
144
|
-
iframe.title = '
|
|
147
|
+
iframe.title = 'Version';
|
|
145
148
|
Object.assign(iframe.style, {
|
|
146
149
|
display: 'block',
|
|
147
150
|
border: '0',
|
|
@@ -235,6 +238,7 @@ export function createVersionBarFrame(
|
|
|
235
238
|
type: 'init',
|
|
236
239
|
surface: 'version-bar',
|
|
237
240
|
params: {
|
|
241
|
+
...(deps.appId !== undefined ? { appId: deps.appId } : {}),
|
|
238
242
|
docId: ctx.docId,
|
|
239
243
|
...(ctx.branch !== undefined ? { branch: ctx.branch } : {}),
|
|
240
244
|
version: ctx.version,
|
package/src/version.ts
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
// can measure the version distribution of deployed apps and time
|
|
4
4
|
// compatibility windows against it. A unit test pins this to
|
|
5
5
|
// package.json's version; the release bump touches both together.
|
|
6
|
-
export const SDK_VERSION = '0.
|
|
6
|
+
export const SDK_VERSION = '0.5.0';
|
|
7
7
|
|
|
8
8
|
export const SDK_CLIENT_ID = `sdk/${SDK_VERSION}`;
|