web-doc 0.6.2 → 0.8.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/THIRD_PARTY_NOTICES.md +33 -1
- package/dist/adapters/docx-images.d.ts +13 -4
- package/dist/adapters/docx-images.js +3 -0
- package/dist/adapters/docx-paragraphs.d.ts +65 -0
- package/dist/adapters/docx-paragraphs.js +165 -0
- package/dist/adapters/docx-prepass.d.ts +17 -0
- package/dist/adapters/docx-prepass.js +230 -0
- package/dist/adapters/office.d.ts +31 -5
- package/dist/adapters/office.js +60 -29
- package/dist/adapters/pdf.d.ts +9 -0
- package/dist/adapters/pdf.js +10 -0
- package/dist/assets/pdfium/pdfium.wasm +0 -0
- package/dist/contracts.d.ts +53 -2
- package/dist/edit/ai/outline.d.ts +47 -0
- package/dist/edit/ai/outline.js +338 -0
- package/dist/edit/ai/targets.d.ts +16 -0
- package/dist/edit/ai/targets.js +309 -0
- package/dist/edit/ai/tools.d.ts +28 -0
- package/dist/edit/ai/tools.js +605 -0
- package/dist/edit/ai/types.d.ts +175 -0
- package/dist/edit/ai/types.js +1 -0
- package/dist/edit/assets.d.ts +23 -0
- package/dist/edit/assets.js +75 -0
- package/dist/edit/docx/elements.d.ts +6 -0
- package/dist/edit/docx/elements.js +90 -0
- package/dist/edit/docx/engine.d.ts +57 -0
- package/dist/edit/docx/engine.js +547 -0
- package/dist/edit/docx/handlers.d.ts +3 -0
- package/dist/edit/docx/handlers.js +15 -0
- package/dist/edit/docx/ids.d.ts +45 -0
- package/dist/edit/docx/ids.js +101 -0
- package/dist/edit/docx/model.d.ts +94 -0
- package/dist/edit/docx/model.js +350 -0
- package/dist/edit/docx/operations.d.ts +47 -0
- package/dist/edit/docx/operations.js +3 -0
- package/dist/edit/docx/provider.d.ts +16 -0
- package/dist/edit/docx/provider.js +37 -0
- package/dist/edit/docx/schemas.d.ts +6 -0
- package/dist/edit/docx/schemas.js +192 -0
- package/dist/edit/docx/session.d.ts +48 -0
- package/dist/edit/docx/session.js +467 -0
- package/dist/edit/docx/structure-ops.d.ts +6 -0
- package/dist/edit/docx/structure-ops.js +529 -0
- package/dist/edit/docx/style.d.ts +45 -0
- package/dist/edit/docx/style.js +375 -0
- package/dist/edit/docx/table-ops.d.ts +4 -0
- package/dist/edit/docx/table-ops.js +241 -0
- package/dist/edit/docx/text-ops.d.ts +40 -0
- package/dist/edit/docx/text-ops.js +491 -0
- package/dist/edit/docx/text.d.ts +34 -0
- package/dist/edit/docx/text.js +284 -0
- package/dist/edit/docx/tracked.d.ts +52 -0
- package/dist/edit/docx/tracked.js +347 -0
- package/dist/edit/docx/types.d.ts +206 -0
- package/dist/edit/docx/types.js +1 -0
- package/dist/edit/docx/write.d.ts +64 -0
- package/dist/edit/docx/write.js +375 -0
- package/dist/edit/engine.d.ts +128 -0
- package/dist/edit/engine.js +1 -0
- package/dist/edit/history.d.ts +77 -0
- package/dist/edit/history.js +123 -0
- package/dist/edit/ooxml/names.d.ts +20 -0
- package/dist/edit/ooxml/names.js +61 -0
- package/dist/edit/ooxml/opc.d.ts +50 -0
- package/dist/edit/ooxml/opc.js +150 -0
- package/dist/edit/ooxml/package.d.ts +82 -0
- package/dist/edit/ooxml/package.js +233 -0
- package/dist/edit/ooxml/patch.d.ts +51 -0
- package/dist/edit/ooxml/patch.js +250 -0
- package/dist/edit/ooxml/transaction.d.ts +39 -0
- package/dist/edit/ooxml/transaction.js +316 -0
- package/dist/edit/ooxml/worker.d.ts +8 -0
- package/dist/edit/ooxml/worker.js +12 -0
- package/dist/edit/ooxml/writer.d.ts +21 -0
- package/dist/edit/ooxml/writer.js +187 -0
- package/dist/edit/ooxml/xml.d.ts +74 -0
- package/dist/edit/ooxml/xml.js +451 -0
- package/dist/edit/ooxml/zip.d.ts +54 -0
- package/dist/edit/ooxml/zip.js +280 -0
- package/dist/edit/operations.d.ts +19 -0
- package/dist/edit/operations.js +137 -0
- package/dist/edit/pdf/engine/compact.d.ts +6 -0
- package/dist/edit/pdf/engine/compact.js +442 -0
- package/dist/edit/pdf/engine/document.d.ts +95 -0
- package/dist/edit/pdf/engine/document.js +868 -0
- package/dist/edit/pdf/engine/elements.d.ts +45 -0
- package/dist/edit/pdf/engine/elements.js +313 -0
- package/dist/edit/pdf/engine/existing-text.d.ts +4 -0
- package/dist/edit/pdf/engine/existing-text.js +424 -0
- package/dist/edit/pdf/engine/fonts.d.ts +78 -0
- package/dist/edit/pdf/engine/fonts.js +466 -0
- package/dist/edit/pdf/engine/geometry.d.ts +36 -0
- package/dist/edit/pdf/engine/geometry.js +97 -0
- package/dist/edit/pdf/engine/handler.d.ts +21 -0
- package/dist/edit/pdf/engine/handler.js +108 -0
- package/dist/edit/pdf/engine/images.d.ts +33 -0
- package/dist/edit/pdf/engine/images.js +188 -0
- package/dist/edit/pdf/engine/layout.d.ts +23 -0
- package/dist/edit/pdf/engine/layout.js +177 -0
- package/dist/edit/pdf/engine/operations.d.ts +67 -0
- package/dist/edit/pdf/engine/operations.js +3 -0
- package/dist/edit/pdf/engine/pages.d.ts +6 -0
- package/dist/edit/pdf/engine/pages.js +98 -0
- package/dist/edit/pdf/engine/pdfium.d.ts +195 -0
- package/dist/edit/pdf/engine/pdfium.js +249 -0
- package/dist/edit/pdf/engine/shapes.d.ts +4 -0
- package/dist/edit/pdf/engine/shapes.js +183 -0
- package/dist/edit/pdf/engine/tables.d.ts +44 -0
- package/dist/edit/pdf/engine/tables.js +315 -0
- package/dist/edit/pdf/engine/text-box.d.ts +71 -0
- package/dist/edit/pdf/engine/text-box.js +317 -0
- package/dist/edit/pdf/engine/text-layout.d.ts +28 -0
- package/dist/edit/pdf/engine/text-layout.js +67 -0
- package/dist/edit/pdf/engine/transform.d.ts +5 -0
- package/dist/edit/pdf/engine/transform.js +137 -0
- package/dist/edit/pdf/provider.d.ts +35 -0
- package/dist/edit/pdf/provider.js +92 -0
- package/dist/edit/pdf/range-map.d.ts +16 -0
- package/dist/edit/pdf/range-map.js +107 -0
- package/dist/edit/pdf/schemas.d.ts +11 -0
- package/dist/edit/pdf/schemas.js +291 -0
- package/dist/edit/pdf/selection.d.ts +5 -0
- package/dist/edit/pdf/selection.js +152 -0
- package/dist/edit/pdf/session.d.ts +63 -0
- package/dist/edit/pdf/session.js +312 -0
- package/dist/edit/pdf/types.d.ts +336 -0
- package/dist/edit/pdf/types.js +1 -0
- package/dist/edit/pptx/elements.d.ts +74 -0
- package/dist/edit/pptx/elements.js +301 -0
- package/dist/edit/pptx/engine.d.ts +37 -0
- package/dist/edit/pptx/engine.js +466 -0
- package/dist/edit/pptx/geometry.d.ts +72 -0
- package/dist/edit/pptx/geometry.js +193 -0
- package/dist/edit/pptx/handler.d.ts +7 -0
- package/dist/edit/pptx/handler.js +99 -0
- package/dist/edit/pptx/handlers.d.ts +3 -0
- package/dist/edit/pptx/handlers.js +21 -0
- package/dist/edit/pptx/image-table-ops.d.ts +5 -0
- package/dist/edit/pptx/image-table-ops.js +233 -0
- package/dist/edit/pptx/model.d.ts +69 -0
- package/dist/edit/pptx/model.js +170 -0
- package/dist/edit/pptx/operations.d.ts +50 -0
- package/dist/edit/pptx/operations.js +11 -0
- package/dist/edit/pptx/provider.d.ts +17 -0
- package/dist/edit/pptx/provider.js +40 -0
- package/dist/edit/pptx/schemas.d.ts +6 -0
- package/dist/edit/pptx/schemas.js +220 -0
- package/dist/edit/pptx/session.d.ts +54 -0
- package/dist/edit/pptx/session.js +145 -0
- package/dist/edit/pptx/shape-ops.d.ts +10 -0
- package/dist/edit/pptx/shape-ops.js +486 -0
- package/dist/edit/pptx/slide-ops.d.ts +6 -0
- package/dist/edit/pptx/slide-ops.js +353 -0
- package/dist/edit/pptx/style.d.ts +31 -0
- package/dist/edit/pptx/style.js +181 -0
- package/dist/edit/pptx/text-ops.d.ts +13 -0
- package/dist/edit/pptx/text-ops.js +267 -0
- package/dist/edit/pptx/text-write.d.ts +67 -0
- package/dist/edit/pptx/text-write.js +293 -0
- package/dist/edit/pptx/text.d.ts +41 -0
- package/dist/edit/pptx/text.js +99 -0
- package/dist/edit/pptx/types.d.ts +232 -0
- package/dist/edit/pptx/types.js +1 -0
- package/dist/edit/schema.d.ts +11 -0
- package/dist/edit/schema.js +275 -0
- package/dist/edit/session.d.ts +77 -0
- package/dist/edit/session.js +943 -0
- package/dist/edit/sessions.d.ts +8 -0
- package/dist/edit/sessions.js +1 -0
- package/dist/edit/types.d.ts +290 -0
- package/dist/edit/types.js +1 -0
- package/dist/edit/worker-engine.d.ts +31 -0
- package/dist/edit/worker-engine.js +91 -0
- package/dist/fonts/THIRD_PARTY_NOTICES.md +3 -0
- package/dist/fonts/manifest.json +6 -1
- package/dist/fonts/noto-sans-latin-cyrillic.ttf +0 -0
- package/dist/fonts.d.ts +2 -0
- package/dist/fonts.js +4 -0
- package/dist/fuzzy-alignment.d.ts +11 -4
- package/dist/fuzzy-alignment.js +3 -9
- package/dist/headless.d.ts +5 -1
- package/dist/headless.js +8 -1
- package/dist/index.d.ts +10 -2
- package/dist/index.js +16 -2
- package/dist/limits.js +6 -0
- package/dist/ooxml-edit-worker.d.ts +1 -0
- package/dist/ooxml-edit-worker.js +8 -0
- package/dist/pdf-edit-worker.d.ts +1 -0
- package/dist/pdf-edit-worker.js +35 -0
- package/dist/spreadsheet-viewport.d.ts +5 -0
- package/dist/spreadsheet-viewport.js +11 -0
- package/dist/ui.js +7 -0
- package/dist/viewer.d.ts +6 -0
- package/dist/viewer.js +293 -19
- package/dist/viewport.d.ts +13 -0
- package/dist/viewport.js +126 -1
- package/dist/worker-protocol.d.ts +39 -2
- package/dist/workers/fuzzy-search-worker.js +1 -1
- package/dist/workers/ooxml-edit-worker.js +9212 -0
- package/dist/workers/pdf-edit-worker.js +10847 -0
- package/package.json +3 -3
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
import { linkedAbortController } from "../abort.js";
|
|
2
|
+
import { abortError, ViewerError } from "../errors.js";
|
|
3
|
+
import { readDescription, readOutline } from "./ai/outline.js";
|
|
4
|
+
import { resolveTargets } from "./ai/targets.js";
|
|
5
|
+
import { buildToolSet, callTool as runTool } from "./ai/tools.js";
|
|
6
|
+
import { assetIdOf, AssetStore, binaryFields, isAssetReference, } from "./assets.js";
|
|
7
|
+
import { batchOf, EditHistory, modeOf } from "./history.js";
|
|
8
|
+
import { assertBatchSize, checkOperations, freezeOperations, invalidOperationError, parseReference, } from "./operations.js";
|
|
9
|
+
/**
|
|
10
|
+
* The format-independent editing session: validation, history, revisions,
|
|
11
|
+
* saving and the viewer refresh. Format modules wrap it to add typed methods.
|
|
12
|
+
* Calls run one at a time in call order.
|
|
13
|
+
*/
|
|
14
|
+
export class EditSessionController {
|
|
15
|
+
format;
|
|
16
|
+
schemas;
|
|
17
|
+
#engine;
|
|
18
|
+
#host;
|
|
19
|
+
#history;
|
|
20
|
+
#original;
|
|
21
|
+
#originalPageCount;
|
|
22
|
+
#ending = new AbortController();
|
|
23
|
+
sessionId = randomId();
|
|
24
|
+
/** Bytes of the last committed state; what the viewer shows and what a broken session saves. */
|
|
25
|
+
#committedBytes;
|
|
26
|
+
/** Materialized bytes of some committed states, by state id, so restores replay less. */
|
|
27
|
+
#checkpoints = new Map();
|
|
28
|
+
#checkpointBytes = 0;
|
|
29
|
+
/** Named checkpoints by id, in creation order. */
|
|
30
|
+
#named = new Map();
|
|
31
|
+
/** State ids named checkpoints pin, with how many name each; never evicted. */
|
|
32
|
+
#pinned = new Map();
|
|
33
|
+
/** Binary payloads of this session's batches, by content id. */
|
|
34
|
+
#assets = new AssetStore();
|
|
35
|
+
#state;
|
|
36
|
+
#tools;
|
|
37
|
+
#queue = Promise.resolve();
|
|
38
|
+
#revision = 0;
|
|
39
|
+
#savedStateId = 0;
|
|
40
|
+
#ended = false;
|
|
41
|
+
/** Set when a rollback failed: the engine no longer matches the history. */
|
|
42
|
+
#broken = false;
|
|
43
|
+
constructor(engine, host, original, originalPageCount) {
|
|
44
|
+
this.format = host.format;
|
|
45
|
+
this.schemas = engine.schemas;
|
|
46
|
+
this.#engine = engine;
|
|
47
|
+
this.#host = host;
|
|
48
|
+
this.#original = original;
|
|
49
|
+
this.#committedBytes = original;
|
|
50
|
+
this.#originalPageCount = originalPageCount;
|
|
51
|
+
this.#history = new EditHistory(host.limits.maxEditHistory, originalPageCount);
|
|
52
|
+
this.#state = this.#snapshot();
|
|
53
|
+
}
|
|
54
|
+
get state() {
|
|
55
|
+
return this.#state;
|
|
56
|
+
}
|
|
57
|
+
get limits() {
|
|
58
|
+
return this.#host.limits;
|
|
59
|
+
}
|
|
60
|
+
applyJson(operations, options = {}) {
|
|
61
|
+
return this.apply(operations, options);
|
|
62
|
+
}
|
|
63
|
+
apply(operations, options = {}) {
|
|
64
|
+
// Copied before the call is queued, so what the engine sees is what the
|
|
65
|
+
// caller passed, whatever it does to its objects while waiting.
|
|
66
|
+
const batch = Array.isArray(operations)
|
|
67
|
+
? freezeOperations(operations)
|
|
68
|
+
: operations;
|
|
69
|
+
return this.#enqueue(options.signal, async (signal) => {
|
|
70
|
+
this.#assertRevision(options);
|
|
71
|
+
assertBatchSize(batch, this.#host.limits.maxEditOperations);
|
|
72
|
+
const modeIssues = checkChangeMode(this.format, options);
|
|
73
|
+
if (modeIssues.length > 0)
|
|
74
|
+
throw invalidOperationError(modeIssues);
|
|
75
|
+
const shapeIssues = checkOperations(batch, this.schemas);
|
|
76
|
+
if (shapeIssues.length > 0)
|
|
77
|
+
throw invalidOperationError(shapeIssues);
|
|
78
|
+
const referenceIssues = checkBatchReferences(batch);
|
|
79
|
+
if (referenceIssues.length > 0)
|
|
80
|
+
throw invalidOperationError(referenceIssues);
|
|
81
|
+
const interned = await this.#intern(batch, signal);
|
|
82
|
+
const mode = modeOf(options);
|
|
83
|
+
const engineIssues = await this.#engine.validate(interned, signal, mode);
|
|
84
|
+
throwIfAborted(signal);
|
|
85
|
+
if (engineIssues.length > 0)
|
|
86
|
+
throw invalidOperationError(engineIssues);
|
|
87
|
+
// The id the history will give this state; a dry run uses the same one,
|
|
88
|
+
// so its receipt names the ids a real apply would.
|
|
89
|
+
const engineBatch = {
|
|
90
|
+
stateId: this.#history.nextStateId,
|
|
91
|
+
operations: interned,
|
|
92
|
+
...mode,
|
|
93
|
+
};
|
|
94
|
+
if (options.dryRun) {
|
|
95
|
+
const change = await this.#transaction(signal, "apply", async () => {
|
|
96
|
+
const result = await this.#engine.apply(engineBatch, signal);
|
|
97
|
+
// Producing the bytes catches what only saving would; then the
|
|
98
|
+
// working copy goes back to the current state.
|
|
99
|
+
await this.#engine.materialize("save", {}, signal);
|
|
100
|
+
await this.#engine.restore(this.#restoreTarget(), signal);
|
|
101
|
+
return result;
|
|
102
|
+
});
|
|
103
|
+
return this.#receipt(true, batch.length, change.createdIds, {
|
|
104
|
+
...change,
|
|
105
|
+
pageCount: change.pageCount ?? this.#history.pageCount,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const before = this.#history.pageCount;
|
|
109
|
+
const { change, shown } = await this.#transaction(signal, "apply", async () => {
|
|
110
|
+
const result = await this.#engine.apply(engineBatch, signal);
|
|
111
|
+
return {
|
|
112
|
+
change: result,
|
|
113
|
+
shown: await this.#show(signal, this.#pagesOf(result.changedPages, result.reflowFrom)),
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
this.#history.push({
|
|
117
|
+
operations: interned,
|
|
118
|
+
...mode,
|
|
119
|
+
...(options.label === undefined ? {} : { label: options.label }),
|
|
120
|
+
createdIds: change.createdIds,
|
|
121
|
+
removedIds: change.removedIds,
|
|
122
|
+
changedPages: shown.changedPages,
|
|
123
|
+
...(change.reflowFrom === undefined
|
|
124
|
+
? {}
|
|
125
|
+
: { reflowFrom: change.reflowFrom }),
|
|
126
|
+
pageCountBefore: before,
|
|
127
|
+
pageCountAfter: shown.pageCount,
|
|
128
|
+
});
|
|
129
|
+
this.#commit("apply", shown.changedPages, shown);
|
|
130
|
+
return this.#receipt(false, batch.length, change.createdIds, {
|
|
131
|
+
...change,
|
|
132
|
+
changedPages: shown.changedPages,
|
|
133
|
+
pageCount: shown.pageCount,
|
|
134
|
+
warnings: [...change.warnings, ...pageCountWarning(change, shown)],
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
undo(options = {}) {
|
|
139
|
+
return this.#enqueue(options.signal, async (signal) => {
|
|
140
|
+
this.#assertRevision(options);
|
|
141
|
+
const entry = this.#history.undoEntry;
|
|
142
|
+
if (!entry)
|
|
143
|
+
return this.#noop();
|
|
144
|
+
const shown = await this.#moveTo(this.#history.position - 1, this.#pagesOf(pagesTouched(entry, entry.pageCountBefore), entry.reflowFrom), signal);
|
|
145
|
+
this.#history.undo();
|
|
146
|
+
this.#commit("undo", shown.changedPages, shown);
|
|
147
|
+
return this.#receipt(false, entry.operations.length, [], {
|
|
148
|
+
removedIds: entry.createdIds,
|
|
149
|
+
changedPages: shown.changedPages,
|
|
150
|
+
pageCount: shown.pageCount,
|
|
151
|
+
warnings: [],
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
redo(options = {}) {
|
|
156
|
+
return this.#enqueue(options.signal, async (signal) => {
|
|
157
|
+
this.#assertRevision(options);
|
|
158
|
+
const entry = this.#history.redoEntry;
|
|
159
|
+
if (!entry)
|
|
160
|
+
return this.#noop();
|
|
161
|
+
const shown = await this.#moveTo(this.#history.position + 1, this.#pagesOf(pagesTouched(entry, entry.pageCountAfter), entry.reflowFrom), signal);
|
|
162
|
+
this.#history.redo();
|
|
163
|
+
this.#commit("redo", shown.changedPages, shown);
|
|
164
|
+
return this.#receipt(false, entry.operations.length, entry.createdIds, {
|
|
165
|
+
removedIds: entry.removedIds,
|
|
166
|
+
changedPages: shown.changedPages,
|
|
167
|
+
pageCount: shown.pageCount,
|
|
168
|
+
warnings: [],
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
reset(options = {}) {
|
|
173
|
+
return this.#enqueue(options.signal, async (signal) => {
|
|
174
|
+
this.#assertRevision(options);
|
|
175
|
+
const applied = this.#history.entriesAt(this.#history.position);
|
|
176
|
+
if (this.#history.stateId === 0 && this.#history.isPristine)
|
|
177
|
+
return this.#noop();
|
|
178
|
+
const changedPages = allPages(Math.max(this.#originalPageCount, this.#history.pageCount));
|
|
179
|
+
const shown = await this.#transaction(signal, "apply", async () => {
|
|
180
|
+
await this.#engine.restore({ batches: [] }, signal);
|
|
181
|
+
return this.#show(signal, changedPages);
|
|
182
|
+
});
|
|
183
|
+
this.#history.clear();
|
|
184
|
+
this.#commit("reset", changedPages, shown);
|
|
185
|
+
return this.#receipt(false, applied.reduce((count, entry) => count + entry.operations.length, 0), [], {
|
|
186
|
+
removedIds: applied.flatMap((entry) => entry.createdIds),
|
|
187
|
+
changedPages,
|
|
188
|
+
pageCount: shown.pageCount,
|
|
189
|
+
warnings: [],
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
save(options = {}) {
|
|
194
|
+
// Format fields (the PDF save mode) travel to the engine; the signal stays.
|
|
195
|
+
const { signal: own, ...format } = options;
|
|
196
|
+
return this.#enqueue(own, async (signal) => {
|
|
197
|
+
// A session whose recovery failed still hands out what it last
|
|
198
|
+
// showed; the history state matches those bytes.
|
|
199
|
+
const { bytes, warnings } = this.#broken
|
|
200
|
+
? { bytes: this.#committedBytes.slice(), warnings: [] }
|
|
201
|
+
: this.#history.stateId === 0
|
|
202
|
+
? { bytes: this.#original.slice(), warnings: [] }
|
|
203
|
+
: await this.#materialize("save", format, signal);
|
|
204
|
+
throwIfAborted(signal);
|
|
205
|
+
return Object.freeze({
|
|
206
|
+
bytes,
|
|
207
|
+
stateToken: this.#stateToken(this.#history.stateId),
|
|
208
|
+
sessionId: this.sessionId,
|
|
209
|
+
revision: this.#revision,
|
|
210
|
+
warnings: Object.freeze([...warnings]),
|
|
211
|
+
});
|
|
212
|
+
}, { allowBroken: true });
|
|
213
|
+
}
|
|
214
|
+
/** Records that the host persisted the state a `save()` token names. */
|
|
215
|
+
markSaved(stateToken) {
|
|
216
|
+
const stateId = this.#parseStateToken(stateToken);
|
|
217
|
+
if (stateId === undefined) {
|
|
218
|
+
reportError(new ViewerError("edit-conflict", "The save token belongs to another session", { details: { stateToken, sessionId: this.sessionId } }));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const wasDirty = this.#state.dirty;
|
|
222
|
+
this.#savedStateId = stateId;
|
|
223
|
+
this.#state = this.#snapshot();
|
|
224
|
+
if (wasDirty !== this.#state.dirty)
|
|
225
|
+
this.#emitState();
|
|
226
|
+
}
|
|
227
|
+
getElements(query = {}, options = {}) {
|
|
228
|
+
return this.#enqueue(options.signal, async (signal) => this.#items(await this.#engine.getElements(query, signal)));
|
|
229
|
+
}
|
|
230
|
+
getElement(id, options = {}) {
|
|
231
|
+
return this.#enqueue(options.signal, async (signal) => {
|
|
232
|
+
const item = this.#engine.getElement
|
|
233
|
+
? await this.#engine.getElement(id, signal)
|
|
234
|
+
: (await this.#engine.getElements({}, signal)).find((element) => element.id === id);
|
|
235
|
+
return Object.freeze({
|
|
236
|
+
sessionId: this.sessionId,
|
|
237
|
+
revision: this.#revision,
|
|
238
|
+
item,
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
elementsAt(pageIndex, point, options = {}) {
|
|
243
|
+
return this.#enqueue(options.signal, async (signal) => this.#items(await this.#engine.elementsAt(pageIndex, point, signal)));
|
|
244
|
+
}
|
|
245
|
+
findText(query, options = {}) {
|
|
246
|
+
// The signal stays on this side; the rest may cross to a worker.
|
|
247
|
+
const { signal: own, ...engineOptions } = options;
|
|
248
|
+
return this.#enqueue(own, async (signal) => this.#items(await this.#engine.findText(query, engineOptions, signal)));
|
|
249
|
+
}
|
|
250
|
+
getOutline(options) {
|
|
251
|
+
return readOutline(this, this.#host.limits, options);
|
|
252
|
+
}
|
|
253
|
+
describe(options) {
|
|
254
|
+
return readDescription(this, this.#host.limits, options);
|
|
255
|
+
}
|
|
256
|
+
resolveTargets(query, options) {
|
|
257
|
+
return resolveTargets(this, query, options);
|
|
258
|
+
}
|
|
259
|
+
createCheckpoint(label) {
|
|
260
|
+
return this.#enqueue(undefined, async () => {
|
|
261
|
+
const limit = this.#host.limits.maxEditCheckpoints;
|
|
262
|
+
if (this.#named.size >= limit)
|
|
263
|
+
throw new ViewerError("resource-limit", "Too many edit checkpoints; drop one first", { details: { limit } });
|
|
264
|
+
const stateId = this.#history.stateId;
|
|
265
|
+
const checkpoint = Object.freeze({
|
|
266
|
+
id: randomId(),
|
|
267
|
+
...(label === undefined ? {} : { label }),
|
|
268
|
+
revision: this.#revision,
|
|
269
|
+
createdAt: new Date().toISOString(),
|
|
270
|
+
});
|
|
271
|
+
const entries = this.#history.entriesAt(this.#history.position);
|
|
272
|
+
this.#named.set(checkpoint.id, {
|
|
273
|
+
checkpoint,
|
|
274
|
+
stateId,
|
|
275
|
+
pageCount: this.#history.pageCount,
|
|
276
|
+
entries,
|
|
277
|
+
batches: batchesFromOriginal(entries),
|
|
278
|
+
});
|
|
279
|
+
this.#pin(stateId, this.#committedBytes);
|
|
280
|
+
return checkpoint;
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
listCheckpoints() {
|
|
284
|
+
return Object.freeze([...this.#named.values()].map((named) => named.checkpoint));
|
|
285
|
+
}
|
|
286
|
+
restoreCheckpoint(id, options = {}) {
|
|
287
|
+
return this.#enqueue(options.signal, async (signal) => {
|
|
288
|
+
this.#assertRevision(options);
|
|
289
|
+
const named = this.#named.get(id);
|
|
290
|
+
if (!named)
|
|
291
|
+
throw new ViewerError("invalid-operation", `Unknown edit checkpoint ${id}`, { details: { checkpointId: id } });
|
|
292
|
+
if (named.stateId === this.#history.stateId)
|
|
293
|
+
return this.#noop();
|
|
294
|
+
const before = this.#history.pageCount;
|
|
295
|
+
const changedPages = allPages(Math.max(before, named.pageCount));
|
|
296
|
+
const shown = await this.#transaction(signal, "apply", async () => {
|
|
297
|
+
await this.#engine.restore(this.#targetFor(named.entries), signal);
|
|
298
|
+
return this.#show(signal, changedPages);
|
|
299
|
+
});
|
|
300
|
+
const diff = entryDiff(this.#history.entriesAt(this.#history.position), named.entries);
|
|
301
|
+
this.#history.push({
|
|
302
|
+
operations: [],
|
|
303
|
+
createdIds: diff.created,
|
|
304
|
+
removedIds: diff.removed,
|
|
305
|
+
changedPages: shown.changedPages,
|
|
306
|
+
pageCountBefore: before,
|
|
307
|
+
pageCountAfter: shown.pageCount,
|
|
308
|
+
base: { stateId: named.stateId, batches: named.batches },
|
|
309
|
+
}, named.stateId);
|
|
310
|
+
this.#commit("restore", shown.changedPages, shown);
|
|
311
|
+
return this.#receipt(false, 0, diff.created, {
|
|
312
|
+
removedIds: diff.removed,
|
|
313
|
+
changedPages: shown.changedPages,
|
|
314
|
+
pageCount: shown.pageCount,
|
|
315
|
+
warnings: [],
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
dropCheckpoint(id) {
|
|
320
|
+
const named = this.#named.get(id);
|
|
321
|
+
if (!named)
|
|
322
|
+
return;
|
|
323
|
+
this.#named.delete(id);
|
|
324
|
+
this.#unpin(named.stateId);
|
|
325
|
+
}
|
|
326
|
+
get tools() {
|
|
327
|
+
return (this.#tools ??= buildToolSet(this.format, this.schemas));
|
|
328
|
+
}
|
|
329
|
+
callTool(call, options) {
|
|
330
|
+
return runTool(this, this.tools, call, options);
|
|
331
|
+
}
|
|
332
|
+
readItem(options, task) {
|
|
333
|
+
return this.#enqueue(options?.signal, async (signal) => Object.freeze({
|
|
334
|
+
sessionId: this.sessionId,
|
|
335
|
+
revision: this.#revision,
|
|
336
|
+
item: await task(this.#engine, signal),
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
readItems(options, task) {
|
|
340
|
+
return this.#enqueue(options?.signal, async (signal) => this.#items(await task(this.#engine, signal)));
|
|
341
|
+
}
|
|
342
|
+
/** Stamps a read with the state it describes: the revision at its queue position. */
|
|
343
|
+
#items(items) {
|
|
344
|
+
return Object.freeze({
|
|
345
|
+
sessionId: this.sessionId,
|
|
346
|
+
revision: this.#revision,
|
|
347
|
+
items: Object.freeze([...items]),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Ends the session: pending calls reject with `aborted`, later calls with
|
|
352
|
+
* `lifecycle-error`, the engine is disposed and `active: false` is emitted.
|
|
353
|
+
*/
|
|
354
|
+
async end() {
|
|
355
|
+
if (this.#ended)
|
|
356
|
+
return;
|
|
357
|
+
this.#ended = true;
|
|
358
|
+
this.#ending.abort(abortError());
|
|
359
|
+
await this.#queue.catch(() => undefined);
|
|
360
|
+
try {
|
|
361
|
+
await this.#engine.dispose();
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
this.#emitState(false);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
get ended() {
|
|
368
|
+
return this.#ended;
|
|
369
|
+
}
|
|
370
|
+
/** False once the session ended or a failed recovery left it inconsistent. */
|
|
371
|
+
get usable() {
|
|
372
|
+
return !this.#ended && !this.#broken;
|
|
373
|
+
}
|
|
374
|
+
/** Runs `task` after every earlier call, with a signal bounded by `maxOperationMs`. */
|
|
375
|
+
#enqueue(signal, task, options = {}) {
|
|
376
|
+
// Calls made after the session ended fail at once; calls still queued
|
|
377
|
+
// when it ends are cancelled, like any other pending work.
|
|
378
|
+
if (this.#ended)
|
|
379
|
+
return Promise.reject(new ViewerError("lifecycle-error", "The edit session has ended"));
|
|
380
|
+
const run = async () => {
|
|
381
|
+
if (this.#ending.signal.aborted)
|
|
382
|
+
throw abortError();
|
|
383
|
+
if (!options.allowBroken)
|
|
384
|
+
this.#assertAlive();
|
|
385
|
+
if (signal?.aborted)
|
|
386
|
+
throw abortError();
|
|
387
|
+
const controller = linkedAbortController(signal, this.#ending.signal);
|
|
388
|
+
let timedOut = false;
|
|
389
|
+
const timer = setTimeout(() => {
|
|
390
|
+
timedOut = true;
|
|
391
|
+
controller.abort(abortError());
|
|
392
|
+
}, this.#host.limits.maxOperationMs);
|
|
393
|
+
try {
|
|
394
|
+
return await task(controller.signal);
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
// A dead engine worker cannot be recovered; the next edit() starts anew.
|
|
398
|
+
if (error instanceof ViewerError && error.code === "worker-crashed")
|
|
399
|
+
this.#broken = true;
|
|
400
|
+
if (timedOut)
|
|
401
|
+
throw new ViewerError("resource-limit", "Edit operation exceeded maxOperationMs", { details: { timeoutMs: this.#host.limits.maxOperationMs } });
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
clearTimeout(timer);
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
const result = this.#queue.then(run, run);
|
|
409
|
+
this.#queue = result.catch(() => undefined);
|
|
410
|
+
return result;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Runs `work` against the engine and the viewer. On any failure the engine
|
|
414
|
+
* goes back to the current history state and the viewer is left untouched,
|
|
415
|
+
* so a failed call never leaks a partial change.
|
|
416
|
+
*/
|
|
417
|
+
async #transaction(signal, stage, work) {
|
|
418
|
+
let failedStage = stage;
|
|
419
|
+
try {
|
|
420
|
+
return await work();
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
if (error instanceof ViewerError && error.code === "edit-failed")
|
|
424
|
+
failedStage = error.details?.stage ?? stage;
|
|
425
|
+
await this.#rollback();
|
|
426
|
+
if (signal.aborted)
|
|
427
|
+
throw abortError();
|
|
428
|
+
if (error instanceof ViewerError &&
|
|
429
|
+
(error.code === "edit-failed" || error.code === "invalid-operation"))
|
|
430
|
+
throw error;
|
|
431
|
+
throw new ViewerError("edit-failed", `Editing failed while ${describe(failedStage)}; the document is unchanged`, { cause: error, details: { stage: failedStage } });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Materializes the working copy and shows it in the viewer in two phases:
|
|
436
|
+
* the preparation may fail or be aborted and is then discarded; the commit
|
|
437
|
+
* is synchronous and cannot fail, so once it ran the call completes
|
|
438
|
+
* whatever its signal says.
|
|
439
|
+
*/
|
|
440
|
+
/** The engine's bytes with their warnings; an engine without the richer form warns of nothing. */
|
|
441
|
+
async #materialize(purpose, options, signal) {
|
|
442
|
+
if (this.#engine.materializeDocument)
|
|
443
|
+
return this.#engine.materializeDocument(purpose, options, signal);
|
|
444
|
+
return {
|
|
445
|
+
bytes: await this.#engine.materialize(purpose, options, signal),
|
|
446
|
+
warnings: [],
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* The pages a change repaints: the engine's list, or for a reflow every
|
|
451
|
+
* page from the paragraph's first page (asked of the host before the
|
|
452
|
+
* document is replaced) to the end of the shown document.
|
|
453
|
+
*/
|
|
454
|
+
#pagesOf(changedPages, reflowFrom) {
|
|
455
|
+
if (reflowFrom === undefined)
|
|
456
|
+
return changedPages;
|
|
457
|
+
// Everything from the reflowed paragraph's page on, and the pages
|
|
458
|
+
// the batch names before it (an undo that moves a paragraph back).
|
|
459
|
+
const first = Math.min(this.#host.pageOf?.(reflowFrom) ?? 0, changedPages[0] ?? Number.POSITIVE_INFINITY);
|
|
460
|
+
return (pageCount) => Array.from({ length: Math.max(0, pageCount - first) }, (_, index) => first + index);
|
|
461
|
+
}
|
|
462
|
+
async #show(signal, changedPages) {
|
|
463
|
+
let bytes;
|
|
464
|
+
try {
|
|
465
|
+
({ bytes } = await this.#materialize("show", {}, signal));
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
throw stageError("materialize", error);
|
|
469
|
+
}
|
|
470
|
+
throwIfAborted(signal);
|
|
471
|
+
let prepared;
|
|
472
|
+
try {
|
|
473
|
+
prepared = await this.#host.prepareDocument(bytes, signal);
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
throw stageError("reopen", error);
|
|
477
|
+
}
|
|
478
|
+
if (signal.aborted) {
|
|
479
|
+
this.#host.discardDocument(prepared);
|
|
480
|
+
throw abortError();
|
|
481
|
+
}
|
|
482
|
+
const pages = Object.freeze(typeof changedPages === "function"
|
|
483
|
+
? [...changedPages(prepared.pageCount)]
|
|
484
|
+
: [...changedPages]);
|
|
485
|
+
return {
|
|
486
|
+
bytes,
|
|
487
|
+
pageCount: this.#host.commitDocument(prepared, pages),
|
|
488
|
+
changedPages: pages,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
async #moveTo(position, changedPages, signal) {
|
|
492
|
+
return this.#transaction(signal, "apply", async () => {
|
|
493
|
+
await this.#engine.restore(this.#restoreTarget(position), signal);
|
|
494
|
+
return this.#show(signal, changedPages);
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
async #rollback() {
|
|
498
|
+
// Recovery must finish even when the caller's signal is gone, so it gets
|
|
499
|
+
// a fresh budget of its own.
|
|
500
|
+
const controller = new AbortController();
|
|
501
|
+
const timer = setTimeout(() => controller.abort(abortError()), this.#host.limits.maxOperationMs);
|
|
502
|
+
try {
|
|
503
|
+
await this.#engine.restore(this.#restoreTarget(), controller.signal);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
this.#broken = true;
|
|
507
|
+
}
|
|
508
|
+
finally {
|
|
509
|
+
clearTimeout(timer);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
#commit(reason, changedPages, shown) {
|
|
513
|
+
this.#committedBytes = shown.bytes;
|
|
514
|
+
if (reason === "apply" || reason === "restore")
|
|
515
|
+
this.#keepCheckpoint(shown.bytes);
|
|
516
|
+
else if (reason === "reset")
|
|
517
|
+
this.#dropCheckpoints();
|
|
518
|
+
this.#revision += 1;
|
|
519
|
+
this.#state = this.#snapshot();
|
|
520
|
+
this.#emitState();
|
|
521
|
+
this.#emit("documentchange", {
|
|
522
|
+
sessionId: this.sessionId,
|
|
523
|
+
revision: this.#revision,
|
|
524
|
+
reason,
|
|
525
|
+
changedPages: Object.freeze([...changedPages]),
|
|
526
|
+
pageCount: shown.pageCount,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
/** Hands an event to the host; a throwing listener is reported, never propagated. */
|
|
530
|
+
#emit(type, event) {
|
|
531
|
+
try {
|
|
532
|
+
this.#host.emit(type, event);
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
reportError(error);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
addAsset(data, options = {}) {
|
|
539
|
+
return this.#enqueue(options.signal, async (signal) => {
|
|
540
|
+
if (data.byteLength > this.#host.limits.maxInputBytes)
|
|
541
|
+
throw new ViewerError("resource-limit", "Asset exceeds maxInputBytes", {
|
|
542
|
+
details: {
|
|
543
|
+
actual: data.byteLength,
|
|
544
|
+
limit: this.#host.limits.maxInputBytes,
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
return this.#register(data.slice(), signal);
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
/** Stores bytes under their content id and hands them to the engine once. */
|
|
551
|
+
async #register(bytes, signal) {
|
|
552
|
+
const id = await assetIdOf(bytes);
|
|
553
|
+
if (!this.#assets.has(id)) {
|
|
554
|
+
this.#assets.set(id, bytes);
|
|
555
|
+
await this.#engine.putAsset(id, bytes, signal);
|
|
556
|
+
}
|
|
557
|
+
return id;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Replaces inline binary payloads by asset references, so the history
|
|
561
|
+
* holds references only; unknown references are reported like any issue.
|
|
562
|
+
*/
|
|
563
|
+
async #intern(batch, signal) {
|
|
564
|
+
const issues = [];
|
|
565
|
+
const result = [];
|
|
566
|
+
for (const [operationIndex, operation] of batch.entries()) {
|
|
567
|
+
const fields = binaryFields(this.schemas.operations[operation.op]);
|
|
568
|
+
if (fields.length === 0) {
|
|
569
|
+
result.push(operation);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const patched = { ...operation };
|
|
573
|
+
for (const field of fields) {
|
|
574
|
+
const value = patched[field];
|
|
575
|
+
if (value === undefined)
|
|
576
|
+
continue;
|
|
577
|
+
if (isAssetReference(value)) {
|
|
578
|
+
if (!this.#assets.has(value))
|
|
579
|
+
issues.push({
|
|
580
|
+
operationIndex,
|
|
581
|
+
path: `/${field}`,
|
|
582
|
+
code: "unknown-asset",
|
|
583
|
+
message: `Unknown asset ${value}`,
|
|
584
|
+
});
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const bytes = value instanceof Uint8Array
|
|
588
|
+
? value
|
|
589
|
+
: Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
|
|
590
|
+
patched[field] = await this.#register(bytes, signal);
|
|
591
|
+
}
|
|
592
|
+
result.push(Object.freeze(patched));
|
|
593
|
+
}
|
|
594
|
+
if (issues.length > 0)
|
|
595
|
+
throw invalidOperationError(issues);
|
|
596
|
+
return Object.freeze(result);
|
|
597
|
+
}
|
|
598
|
+
/** Keeps every stride-th committed state's bytes within the memory budget. */
|
|
599
|
+
#keepCheckpoint(bytes) {
|
|
600
|
+
const stride = Math.max(1, Math.floor(this.#host.limits.maxEditHistory / 4));
|
|
601
|
+
const stateId = this.#history.stateId;
|
|
602
|
+
// Entries dropped by a new change after an undo can never be restored;
|
|
603
|
+
// a pinned state stays whatever the history does.
|
|
604
|
+
const reachable = this.#reachable();
|
|
605
|
+
for (const [id, kept] of this.#checkpoints)
|
|
606
|
+
if (!reachable.has(id) && !this.#pinned.has(id))
|
|
607
|
+
this.#forgetCheckpoint(id, kept);
|
|
608
|
+
if (stateId === 0 ||
|
|
609
|
+
this.#checkpoints.has(stateId) ||
|
|
610
|
+
stateId % stride !== 0)
|
|
611
|
+
return;
|
|
612
|
+
this.#retain(stateId, bytes);
|
|
613
|
+
}
|
|
614
|
+
/** Stores a state's bytes when the budget, less the pinned states, can hold them. */
|
|
615
|
+
#retain(stateId, bytes) {
|
|
616
|
+
const budget = this.#host.limits.maxEditCheckpointBytes;
|
|
617
|
+
if (bytes.byteLength > budget)
|
|
618
|
+
return;
|
|
619
|
+
const evictable = [...this.#checkpoints.keys()]
|
|
620
|
+
.filter((id) => !this.#pinned.has(id))
|
|
621
|
+
.sort((a, b) => a - b);
|
|
622
|
+
while (this.#checkpointBytes + bytes.byteLength > budget &&
|
|
623
|
+
evictable.length > 0) {
|
|
624
|
+
const id = evictable.shift();
|
|
625
|
+
this.#forgetCheckpoint(id, this.#checkpoints.get(id));
|
|
626
|
+
}
|
|
627
|
+
if (this.#checkpointBytes + bytes.byteLength > budget)
|
|
628
|
+
return;
|
|
629
|
+
this.#checkpoints.set(stateId, bytes);
|
|
630
|
+
this.#checkpointBytes += bytes.byteLength;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Pins a state for a named checkpoint: its bytes are kept while any
|
|
634
|
+
* checkpoint names it, or rebuilt by replay when the budget cannot hold
|
|
635
|
+
* them. The original (state 0) needs no bytes.
|
|
636
|
+
*/
|
|
637
|
+
#pin(stateId, bytes) {
|
|
638
|
+
if (stateId === 0)
|
|
639
|
+
return;
|
|
640
|
+
this.#pinned.set(stateId, (this.#pinned.get(stateId) ?? 0) + 1);
|
|
641
|
+
if (!this.#checkpoints.has(stateId))
|
|
642
|
+
this.#retain(stateId, bytes);
|
|
643
|
+
}
|
|
644
|
+
#unpin(stateId) {
|
|
645
|
+
const count = this.#pinned.get(stateId);
|
|
646
|
+
if (!count)
|
|
647
|
+
return;
|
|
648
|
+
if (count > 1) {
|
|
649
|
+
this.#pinned.set(stateId, count - 1);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
this.#pinned.delete(stateId);
|
|
653
|
+
const kept = this.#checkpoints.get(stateId);
|
|
654
|
+
if (kept && !this.#reachable().has(stateId))
|
|
655
|
+
this.#forgetCheckpoint(stateId, kept);
|
|
656
|
+
}
|
|
657
|
+
/** State ids a replay may start from: every entry's, and the base of every restore. */
|
|
658
|
+
#reachable() {
|
|
659
|
+
const reachable = new Set();
|
|
660
|
+
for (const entry of this.#history.allEntries) {
|
|
661
|
+
reachable.add(entry.stateId);
|
|
662
|
+
if (entry.base)
|
|
663
|
+
reachable.add(entry.base.stateId);
|
|
664
|
+
}
|
|
665
|
+
return reachable;
|
|
666
|
+
}
|
|
667
|
+
#forgetCheckpoint(id, bytes) {
|
|
668
|
+
this.#checkpoints.delete(id);
|
|
669
|
+
this.#checkpointBytes -= bytes.byteLength;
|
|
670
|
+
}
|
|
671
|
+
/** After a reset nothing in the history is reachable; pinned states stay. */
|
|
672
|
+
#dropCheckpoints() {
|
|
673
|
+
for (const [id, kept] of this.#checkpoints)
|
|
674
|
+
if (!this.#pinned.has(id))
|
|
675
|
+
this.#forgetCheckpoint(id, kept);
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* The cheapest way to rebuild the state at `position`: the newest
|
|
679
|
+
* retained bytes at or before it, plus the batches after them.
|
|
680
|
+
*/
|
|
681
|
+
#restoreTarget(position = this.#history.position) {
|
|
682
|
+
return this.#targetFor(this.#history.entriesAt(position));
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Walks `entries` from the end: the first with retained bytes is the base;
|
|
686
|
+
* a restore entry without them starts from the original plus the batches
|
|
687
|
+
* that built its checkpoint.
|
|
688
|
+
*/
|
|
689
|
+
#targetFor(entries) {
|
|
690
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
691
|
+
const entry = entries[index];
|
|
692
|
+
const after = () => batchesOf(entries.slice(index + 1));
|
|
693
|
+
const retained = this.#checkpoints.get(entry.stateId);
|
|
694
|
+
if (retained)
|
|
695
|
+
return { base: retained, batches: after() };
|
|
696
|
+
// A restore entry carries its checkpoint's state id, so its bytes
|
|
697
|
+
// were just looked up; without them the checkpoint's batches rebuild
|
|
698
|
+
// it from the original.
|
|
699
|
+
if (entry.base)
|
|
700
|
+
return { batches: [...entry.base.batches, ...after()] };
|
|
701
|
+
}
|
|
702
|
+
return { batches: batchesOf(entries) };
|
|
703
|
+
}
|
|
704
|
+
#stateToken(stateId) {
|
|
705
|
+
return `${this.sessionId}:${stateId}`;
|
|
706
|
+
}
|
|
707
|
+
#parseStateToken(token) {
|
|
708
|
+
const prefix = `${this.sessionId}:`;
|
|
709
|
+
if (!token.startsWith(prefix))
|
|
710
|
+
return undefined;
|
|
711
|
+
const stateId = Number(token.slice(prefix.length));
|
|
712
|
+
return Number.isSafeInteger(stateId) && stateId >= 0 ? stateId : undefined;
|
|
713
|
+
}
|
|
714
|
+
#snapshot() {
|
|
715
|
+
return Object.freeze({
|
|
716
|
+
sessionId: this.sessionId,
|
|
717
|
+
revision: this.#revision,
|
|
718
|
+
dirty: this.#history.stateId !== this.#savedStateId,
|
|
719
|
+
canUndo: this.#history.canUndo,
|
|
720
|
+
canRedo: this.#history.canRedo,
|
|
721
|
+
pageCount: this.#history.pageCount,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
#emitState(active = true) {
|
|
725
|
+
this.#emit("editstatechange", {
|
|
726
|
+
...this.#state,
|
|
727
|
+
active,
|
|
728
|
+
format: this.format,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
#receipt(dryRun, operationCount, createdIds, change) {
|
|
732
|
+
return Object.freeze({
|
|
733
|
+
sessionId: this.sessionId,
|
|
734
|
+
revision: this.#revision,
|
|
735
|
+
dryRun,
|
|
736
|
+
operationCount,
|
|
737
|
+
createdIds: Object.freeze([...createdIds]),
|
|
738
|
+
removedIds: Object.freeze([...(change.removedIds ?? [])]),
|
|
739
|
+
...(change.remappedIds
|
|
740
|
+
? { remappedIds: Object.freeze({ ...change.remappedIds }) }
|
|
741
|
+
: {}),
|
|
742
|
+
changedPages: Object.freeze([...change.changedPages]),
|
|
743
|
+
pageCount: change.pageCount,
|
|
744
|
+
warnings: Object.freeze(change.warnings.map((warning) => Object.freeze({ ...warning }))),
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
#noop() {
|
|
748
|
+
return this.#receipt(false, 0, [], {
|
|
749
|
+
changedPages: [],
|
|
750
|
+
pageCount: this.#history.pageCount,
|
|
751
|
+
warnings: [],
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
#assertRevision(options) {
|
|
755
|
+
const { expectedRevision, expectedSessionId } = options;
|
|
756
|
+
const staleSession = expectedSessionId !== undefined && expectedSessionId !== this.sessionId;
|
|
757
|
+
const staleRevision = expectedRevision !== undefined && expectedRevision !== this.#revision;
|
|
758
|
+
if (staleSession || staleRevision)
|
|
759
|
+
throw new ViewerError("edit-conflict", staleSession
|
|
760
|
+
? "The session the caller read from has ended"
|
|
761
|
+
: "The document changed since it was read", {
|
|
762
|
+
details: {
|
|
763
|
+
...(expectedRevision === undefined ? {} : { expectedRevision }),
|
|
764
|
+
revision: this.#revision,
|
|
765
|
+
...(expectedSessionId === undefined ? {} : { expectedSessionId }),
|
|
766
|
+
sessionId: this.sessionId,
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
#assertAlive() {
|
|
771
|
+
if (this.#ended)
|
|
772
|
+
throw new ViewerError("lifecycle-error", "The edit session has ended");
|
|
773
|
+
if (this.#broken)
|
|
774
|
+
throw new ViewerError("edit-failed", "The edit session is unusable after a failed recovery", { details: { stage: "apply", recovered: false } });
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
function stageError(stage, cause) {
|
|
778
|
+
if (cause instanceof ViewerError && cause.code === "aborted")
|
|
779
|
+
return cause;
|
|
780
|
+
return new ViewerError("edit-failed", `Editing failed while ${describe(stage)}; the document is unchanged`, { cause, details: { stage } });
|
|
781
|
+
}
|
|
782
|
+
function describe(stage) {
|
|
783
|
+
switch (stage) {
|
|
784
|
+
case "apply":
|
|
785
|
+
return "applying the change";
|
|
786
|
+
case "materialize":
|
|
787
|
+
return "producing the edited file";
|
|
788
|
+
case "reopen":
|
|
789
|
+
return "reopening the edited file";
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
function throwIfAborted(signal) {
|
|
793
|
+
if (signal.aborted)
|
|
794
|
+
throw abortError();
|
|
795
|
+
}
|
|
796
|
+
/** Pages to re-render when an entry is undone or redone. */
|
|
797
|
+
function pagesTouched(entry, pageCount) {
|
|
798
|
+
return entry.pageCountBefore === entry.pageCountAfter
|
|
799
|
+
? [...entry.changedPages]
|
|
800
|
+
: allPages(pageCount);
|
|
801
|
+
}
|
|
802
|
+
function allPages(pageCount) {
|
|
803
|
+
return Array.from({ length: pageCount }, (_, index) => index);
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Same-batch references: `"$<n>"` names the first element created by
|
|
807
|
+
* operation `n` of the batch, which must come earlier. The engine resolves
|
|
808
|
+
* them while applying; here only the form is checked.
|
|
809
|
+
*/
|
|
810
|
+
function checkBatchReferences(operations) {
|
|
811
|
+
const issues = [];
|
|
812
|
+
operations.forEach((operation, operationIndex) => {
|
|
813
|
+
const target = operation.target;
|
|
814
|
+
const reference = typeof target === "string" ? parseReference(target) : undefined;
|
|
815
|
+
if (reference === undefined)
|
|
816
|
+
return;
|
|
817
|
+
if (reference >= operationIndex)
|
|
818
|
+
issues.push({
|
|
819
|
+
operationIndex,
|
|
820
|
+
path: "/target",
|
|
821
|
+
code: "unknown-target",
|
|
822
|
+
message: `"$${reference}" must name an earlier operation of the batch`,
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
return issues;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Tracked changes exist in DOCX only and always name their author (decision
|
|
829
|
+
* 8 of the ai-edit module): the batch is refused before any engine work.
|
|
830
|
+
*/
|
|
831
|
+
function checkChangeMode(format, options) {
|
|
832
|
+
const issues = [];
|
|
833
|
+
// A timestamp is written into the file where a format records one, so
|
|
834
|
+
// it must be a date-time the file can hold whatever the mode.
|
|
835
|
+
if (options.timestamp !== undefined &&
|
|
836
|
+
(!DATE_TIME.test(options.timestamp) ||
|
|
837
|
+
Number.isNaN(Date.parse(options.timestamp))))
|
|
838
|
+
issues.push({
|
|
839
|
+
operationIndex: -1,
|
|
840
|
+
path: "/timestamp",
|
|
841
|
+
code: "invalid-value",
|
|
842
|
+
message: "The timestamp must be an ISO 8601 date-time",
|
|
843
|
+
});
|
|
844
|
+
if (options.changeMode !== "tracked")
|
|
845
|
+
return issues;
|
|
846
|
+
if (format !== "docx")
|
|
847
|
+
issues.push({
|
|
848
|
+
operationIndex: -1,
|
|
849
|
+
path: "",
|
|
850
|
+
code: "unsupported-change-mode",
|
|
851
|
+
message: `${format.toUpperCase()} has no tracked changes; apply directly and review with checkpoints`,
|
|
852
|
+
});
|
|
853
|
+
else if (!options.author || options.author.trim().length === 0)
|
|
854
|
+
issues.push({
|
|
855
|
+
operationIndex: -1,
|
|
856
|
+
path: "/author",
|
|
857
|
+
code: "required",
|
|
858
|
+
message: "Tracked changes name their author; pass ApplyOptions.author",
|
|
859
|
+
});
|
|
860
|
+
return issues;
|
|
861
|
+
}
|
|
862
|
+
/** ISO 8601 as `xsd:dateTime` takes it. */
|
|
863
|
+
const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/;
|
|
864
|
+
/** The renderer owns the page count; an engine that disagrees is reported, not trusted. */
|
|
865
|
+
function pageCountWarning(change, shown) {
|
|
866
|
+
if (change.pageCount === undefined || change.pageCount === shown.pageCount)
|
|
867
|
+
return [];
|
|
868
|
+
return [
|
|
869
|
+
{
|
|
870
|
+
code: "fidelity-degraded",
|
|
871
|
+
message: `The engine reports ${change.pageCount} pages but the renderer shows ${shown.pageCount}`,
|
|
872
|
+
details: { engine: change.pageCount, renderer: shown.pageCount },
|
|
873
|
+
},
|
|
874
|
+
];
|
|
875
|
+
}
|
|
876
|
+
/** The engine batches of entries that carry operations; restore entries carry none. */
|
|
877
|
+
function batchesOf(entries) {
|
|
878
|
+
return entries.filter((entry) => entry.operations.length > 0).map(batchOf);
|
|
879
|
+
}
|
|
880
|
+
/** The batches from the original to the state after `entries`, through the last restore. */
|
|
881
|
+
function batchesFromOriginal(entries) {
|
|
882
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
883
|
+
const base = entries[index].base;
|
|
884
|
+
if (base)
|
|
885
|
+
return [...base.batches, ...batchesOf(entries.slice(index + 1))];
|
|
886
|
+
}
|
|
887
|
+
return batchesOf(entries);
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* The ids a move from the state after `current` to the state after `target`
|
|
891
|
+
* removes and creates: the entries past the common prefix are undone in
|
|
892
|
+
* reverse, then the target's are redone, each id netted out.
|
|
893
|
+
*/
|
|
894
|
+
function entryDiff(current, target) {
|
|
895
|
+
let common = 0;
|
|
896
|
+
while (common < current.length &&
|
|
897
|
+
common < target.length &&
|
|
898
|
+
current[common].stateId === target[common].stateId)
|
|
899
|
+
common += 1;
|
|
900
|
+
const created = new Set();
|
|
901
|
+
const removed = new Set();
|
|
902
|
+
const create = (id) => {
|
|
903
|
+
if (removed.has(id))
|
|
904
|
+
removed.delete(id);
|
|
905
|
+
else
|
|
906
|
+
created.add(id);
|
|
907
|
+
};
|
|
908
|
+
const remove = (id) => {
|
|
909
|
+
if (created.has(id))
|
|
910
|
+
created.delete(id);
|
|
911
|
+
else
|
|
912
|
+
removed.add(id);
|
|
913
|
+
};
|
|
914
|
+
for (const entry of current.slice(common).reverse()) {
|
|
915
|
+
entry.createdIds.forEach(remove);
|
|
916
|
+
entry.removedIds.forEach(create);
|
|
917
|
+
}
|
|
918
|
+
for (const entry of target.slice(common)) {
|
|
919
|
+
entry.removedIds.forEach(remove);
|
|
920
|
+
entry.createdIds.forEach(create);
|
|
921
|
+
}
|
|
922
|
+
return { created: [...created], removed: [...removed] };
|
|
923
|
+
}
|
|
924
|
+
/** 128 random bits as 22 URL-safe base64 characters; unique across sessions and reloads. */
|
|
925
|
+
function randomId() {
|
|
926
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
927
|
+
let binary = "";
|
|
928
|
+
for (const byte of bytes)
|
|
929
|
+
binary += String.fromCharCode(byte);
|
|
930
|
+
return btoa(binary)
|
|
931
|
+
.replaceAll("+", "-")
|
|
932
|
+
.replaceAll("/", "_")
|
|
933
|
+
.replace(/=+$/, "");
|
|
934
|
+
}
|
|
935
|
+
/** Surfaces a listener's exception without failing the call that emitted the event. */
|
|
936
|
+
export function reportError(error) {
|
|
937
|
+
const report = globalThis
|
|
938
|
+
.reportError;
|
|
939
|
+
if (report)
|
|
940
|
+
report(error);
|
|
941
|
+
else
|
|
942
|
+
console.error(error);
|
|
943
|
+
}
|