@beyondwork/docx-react-component 1.0.42 → 1.0.45
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/README.md +17 -0
- package/package.json +5 -4
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/public-types.ts +333 -4
- package/src/core/commands/formatting-commands.ts +7 -1
- package/src/core/commands/index.ts +60 -10
- package/src/core/commands/text-commands.ts +59 -0
- package/src/core/search/search-text.ts +15 -2
- package/src/core/selection/review-anchors.ts +131 -21
- package/src/index.ts +29 -1
- package/src/io/chart-preview-resolver.ts +281 -0
- package/src/io/docx-session.ts +692 -2
- package/src/io/export/build-app-properties-xml.ts +1 -1
- package/src/io/export/serialize-comments.ts +38 -9
- package/src/io/export/twip.ts +1 -1
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +116 -0
- package/src/io/ooxml/parse-comments.ts +0 -33
- package/src/io/ooxml/parse-complex-content.ts +14 -0
- package/src/io/ooxml/parse-main-document.ts +4 -0
- package/src/io/ooxml/workflow-payload-validator.ts +97 -1
- package/src/io/ooxml/workflow-payload.ts +172 -1
- package/src/preservation/opaque-region.ts +5 -0
- package/src/review/store/comment-remapping.ts +2 -2
- package/src/runtime/collab-session.ts +1 -1
- package/src/runtime/document-runtime.ts +661 -42
- package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
- package/src/runtime/edit-dispatch/index.ts +2 -0
- package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/editor-surface/capabilities.ts +411 -0
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +4 -0
- package/src/runtime/layout/layout-engine-instance.ts +63 -2
- package/src/runtime/layout/layout-engine-version.ts +41 -0
- package/src/runtime/layout/paginated-layout-engine.ts +211 -14
- package/src/runtime/layout/public-facet.ts +430 -1
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/prerender/cache-envelope.ts +29 -0
- package/src/runtime/prerender/cache-key.ts +66 -0
- package/src/runtime/prerender/font-fingerprint.ts +17 -0
- package/src/runtime/prerender/graph-canonicalize.ts +121 -0
- package/src/runtime/prerender/indexeddb-cache.ts +184 -0
- package/src/runtime/prerender/prerender-document.ts +145 -0
- package/src/runtime/render/block-fragment-projection.ts +2 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/selection/post-edit-validator.ts +77 -0
- package/src/runtime/surface-projection.ts +45 -7
- package/src/runtime/workflow-markup.ts +71 -16
- package/src/ui/WordReviewEditor.tsx +142 -237
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +115 -12
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui/runtime-shortcut-dispatch.ts +28 -68
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
- package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
- package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
- package/src/ui-tailwind/index.ts +5 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/theme/editor-theme.css +47 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EditorStateBlob,
|
|
3
|
+
EditorStateLocation,
|
|
4
|
+
EditorStateNamespace,
|
|
5
|
+
EditorStatePartLoadFailure,
|
|
6
|
+
EditorStatePartPersistFailure,
|
|
7
|
+
EditorStatePersister,
|
|
8
|
+
EditorStatePolicy,
|
|
9
|
+
EditorStatePolicyEntry,
|
|
10
|
+
EditorStatePolicyMigration,
|
|
11
|
+
EditorStateResolver,
|
|
12
|
+
EditorStateResolveErrorMode,
|
|
13
|
+
} from "../api/editor-state-types.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default policy entry for a namespace not explicitly configured.
|
|
17
|
+
* Locks current behavior: in-document, block-on-resolve-failure,
|
|
18
|
+
* 250ms debounce.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_POLICY_ENTRY: Readonly<Required<EditorStatePolicyEntry>> = {
|
|
21
|
+
location: "in-document",
|
|
22
|
+
key: "",
|
|
23
|
+
onResolveError: "block",
|
|
24
|
+
debounceMs: 250,
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_DEBOUNCE_MS = 250;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Internal channel event surface. Task D maps these to
|
|
31
|
+
* WordReviewEditorEvent variants. Pure runtime emission; no
|
|
32
|
+
* dependency on the editor event union.
|
|
33
|
+
*/
|
|
34
|
+
export type EditorStateChannelEvent =
|
|
35
|
+
| { kind: "load_failed"; failure: EditorStatePartLoadFailure }
|
|
36
|
+
| { kind: "persist_failed"; failure: EditorStatePartPersistFailure }
|
|
37
|
+
| { kind: "policy_migrated"; migration: EditorStatePolicyMigration }
|
|
38
|
+
| { kind: "unknown_namespace"; namespace: string };
|
|
39
|
+
|
|
40
|
+
export type EditorStateChannelListener = (event: EditorStateChannelEvent) => void;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parser-captured payload for an unknown namespace. Stored verbatim
|
|
44
|
+
* so the serializer can re-emit the original XML fragment on save,
|
|
45
|
+
* preserving forward-compat with schemas the current build doesn't know.
|
|
46
|
+
*/
|
|
47
|
+
export interface UnknownNamespaceEntry {
|
|
48
|
+
rawXml?: string;
|
|
49
|
+
inline?: EditorStateBlob;
|
|
50
|
+
storageRef?: import("../api/editor-state-types.ts").EditorStateStorageRef;
|
|
51
|
+
schemaVersion?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface EditorStateChannelOptions {
|
|
55
|
+
/** Injectable timer for tests. Defaults to setTimeout/clearTimeout. */
|
|
56
|
+
scheduler?: {
|
|
57
|
+
setTimeout: (fn: () => void, ms: number) => unknown;
|
|
58
|
+
clearTimeout: (handle: unknown) => void;
|
|
59
|
+
};
|
|
60
|
+
/** UUID factory for editor-generated keys. Defaults to crypto.randomUUID() when available. */
|
|
61
|
+
randomUuid?: () => string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface EditorStateChannel {
|
|
65
|
+
// Policy ---------------------------------------------------------
|
|
66
|
+
setPolicy(partial: EditorStatePolicy): void;
|
|
67
|
+
getPolicyEntry(ns: EditorStateNamespace): Required<EditorStatePolicyEntry>;
|
|
68
|
+
getKey(ns: EditorStateNamespace): string | undefined;
|
|
69
|
+
/** Set the effective key for a namespace (e.g. from a loaded docx). */
|
|
70
|
+
setKey(ns: EditorStateNamespace, key: string): void;
|
|
71
|
+
|
|
72
|
+
// Callbacks ------------------------------------------------------
|
|
73
|
+
setResolver(resolver: EditorStateResolver | null): void;
|
|
74
|
+
setPersister(persister: EditorStatePersister | null): void;
|
|
75
|
+
getResolver(): EditorStateResolver | null;
|
|
76
|
+
getPersister(): EditorStatePersister | null;
|
|
77
|
+
|
|
78
|
+
// Mutation flow --------------------------------------------------
|
|
79
|
+
/**
|
|
80
|
+
* Record a subsystem mutation. Schedules a debounced persist when
|
|
81
|
+
* the namespace is under keyed policy. Pure state update + timer
|
|
82
|
+
* registration; the actual persister call happens on debounce-fire.
|
|
83
|
+
*/
|
|
84
|
+
recordMutation(ns: EditorStateNamespace, blob: EditorStateBlob): void;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Flush any pending debounced persist for a namespace (or all).
|
|
88
|
+
* Returns a promise that resolves when all flushes complete or
|
|
89
|
+
* fail via the retry queue.
|
|
90
|
+
*/
|
|
91
|
+
flush(ns?: EditorStateNamespace): Promise<void>;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Re-attempt persists sitting in the retry queue.
|
|
95
|
+
*/
|
|
96
|
+
retry(ns?: EditorStateNamespace): Promise<void>;
|
|
97
|
+
|
|
98
|
+
// Load-path hooks ------------------------------------------------
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a keyed namespace's blob via the registered resolver,
|
|
101
|
+
* applying onResolveError policy. Returns the blob on success,
|
|
102
|
+
* undefined when the resolver missed or the failure mode is
|
|
103
|
+
* "empty" / "retain-last-known".
|
|
104
|
+
*
|
|
105
|
+
* Caller applies the returned blob to the subsystem store. If the
|
|
106
|
+
* failure mode is "block", throws — caller should fail the load.
|
|
107
|
+
*/
|
|
108
|
+
resolve(
|
|
109
|
+
ns: EditorStateNamespace,
|
|
110
|
+
entryKey: string,
|
|
111
|
+
): Promise<{ blob: EditorStateBlob | null; appliedFallback: boolean }>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Record a policy migration detected during load. Caller diff'd
|
|
115
|
+
* the payload-written location vs. current policy location. Emits
|
|
116
|
+
* policy_migrated event.
|
|
117
|
+
*/
|
|
118
|
+
recordPolicyMigration(migration: EditorStatePolicyMigration): void;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Record an unknown-namespace finding from the parser. Emits the
|
|
122
|
+
* warning event AND stores the raw entry so it round-trips verbatim
|
|
123
|
+
* on the next save. `entry` carries the parser-captured rawXml.
|
|
124
|
+
*/
|
|
125
|
+
recordUnknownNamespace(namespace: string, entry?: UnknownNamespaceEntry): void;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Return the preserved unknown-namespace entries captured during load,
|
|
129
|
+
* for the serializer to re-emit verbatim on save.
|
|
130
|
+
*/
|
|
131
|
+
getUnknownEntries(): ReadonlyArray<UnknownNamespaceEntry & { name: string }>;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Record a load failure detected outside the resolver path (e.g.
|
|
135
|
+
* malformed inline JSON discovered by the parser). Emits
|
|
136
|
+
* `load_failed` so hosts are notified through the same channel.
|
|
137
|
+
*/
|
|
138
|
+
recordLoadFailure(failure: EditorStatePartLoadFailure): void;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Record a blob that was successfully loaded/applied (inline apply
|
|
142
|
+
* during hydration or post-resolve apply). Updates `lastKnown` so
|
|
143
|
+
* the `retain-last-known` fallback has something to return on a
|
|
144
|
+
* subsequent resolver failure. No event, no debounce.
|
|
145
|
+
*/
|
|
146
|
+
recordLoaded(namespace: EditorStateNamespace, blob: EditorStateBlob): void;
|
|
147
|
+
|
|
148
|
+
// Subscription ---------------------------------------------------
|
|
149
|
+
addListener(listener: EditorStateChannelListener): () => void;
|
|
150
|
+
|
|
151
|
+
// Snapshot -------------------------------------------------------
|
|
152
|
+
/** Read-side snapshot used by the serializer. */
|
|
153
|
+
snapshot(): EditorStateChannelSnapshot;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface EditorStateChannelSnapshot {
|
|
157
|
+
policy: EditorStatePolicy;
|
|
158
|
+
keys: ReadonlyMap<EditorStateNamespace, string>;
|
|
159
|
+
/** Namespaces with a pending debounced persist. */
|
|
160
|
+
dirtyNamespaces: ReadonlySet<EditorStateNamespace>;
|
|
161
|
+
/** Retry queue size per namespace (0 when clean). */
|
|
162
|
+
retryCounts: ReadonlyMap<EditorStateNamespace, number>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Implementation
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
export function createEditorStateChannel(
|
|
170
|
+
options: EditorStateChannelOptions = {},
|
|
171
|
+
): EditorStateChannel {
|
|
172
|
+
const scheduler = options.scheduler ?? {
|
|
173
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
174
|
+
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
|
175
|
+
};
|
|
176
|
+
const randomUuid =
|
|
177
|
+
options.randomUuid ??
|
|
178
|
+
(() => {
|
|
179
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
180
|
+
return crypto.randomUUID();
|
|
181
|
+
}
|
|
182
|
+
// Fallback: simple UUIDv4 via Math.random — not cryptographic but ok for correlation keys.
|
|
183
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
184
|
+
const r = (Math.random() * 16) | 0;
|
|
185
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
186
|
+
return v.toString(16);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const policy: EditorStatePolicy = {};
|
|
191
|
+
const keys = new Map<EditorStateNamespace, string>();
|
|
192
|
+
let resolver: EditorStateResolver | null = null;
|
|
193
|
+
let persister: EditorStatePersister | null = null;
|
|
194
|
+
|
|
195
|
+
// Debounce queue: namespace → { blob, timer }
|
|
196
|
+
interface DebounceEntry {
|
|
197
|
+
blob: EditorStateBlob;
|
|
198
|
+
timer: unknown;
|
|
199
|
+
}
|
|
200
|
+
const debounced = new Map<EditorStateNamespace, DebounceEntry>();
|
|
201
|
+
|
|
202
|
+
// Retry queue: namespace → latest blob that failed to persist + attempt count
|
|
203
|
+
interface RetryEntry {
|
|
204
|
+
blob: EditorStateBlob;
|
|
205
|
+
attemptCount: number;
|
|
206
|
+
}
|
|
207
|
+
const retries = new Map<EditorStateNamespace, RetryEntry>();
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Last-known-good blob per namespace. Populated on successful
|
|
211
|
+
* resolve, recordMutation, and inline apply during load. Used to
|
|
212
|
+
* honor `retain-last-known` onResolveError: when the resolver fails
|
|
213
|
+
* we return this blob instead of falling through to empty.
|
|
214
|
+
*/
|
|
215
|
+
const lastKnown = new Map<EditorStateNamespace, EditorStateBlob>();
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Unknown-namespace entries captured at load; re-emitted verbatim on
|
|
219
|
+
* the next save so forward-compat namespaces survive round-trip.
|
|
220
|
+
*/
|
|
221
|
+
const unknownEntries = new Map<string, UnknownNamespaceEntry>();
|
|
222
|
+
|
|
223
|
+
const listeners = new Set<EditorStateChannelListener>();
|
|
224
|
+
|
|
225
|
+
function emit(event: EditorStateChannelEvent): void {
|
|
226
|
+
for (const listener of listeners) {
|
|
227
|
+
try {
|
|
228
|
+
listener(event);
|
|
229
|
+
} catch {
|
|
230
|
+
// Listeners are host-supplied; they must not break the channel.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function effectiveEntry(ns: EditorStateNamespace): Required<EditorStatePolicyEntry> {
|
|
236
|
+
const configured = policy[ns];
|
|
237
|
+
if (!configured) return { ...DEFAULT_POLICY_ENTRY };
|
|
238
|
+
return {
|
|
239
|
+
location: configured.location,
|
|
240
|
+
key: configured.key ?? "",
|
|
241
|
+
onResolveError: configured.onResolveError ?? DEFAULT_POLICY_ENTRY.onResolveError,
|
|
242
|
+
debounceMs: configured.debounceMs ?? DEFAULT_DEBOUNCE_MS,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function runPersist(
|
|
247
|
+
ns: EditorStateNamespace,
|
|
248
|
+
blob: EditorStateBlob,
|
|
249
|
+
priorAttempts: number,
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
const entry = effectiveEntry(ns);
|
|
252
|
+
if (entry.location === "in-document") {
|
|
253
|
+
// Nothing to persist externally — next serialize inlines.
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (!persister) {
|
|
257
|
+
retries.set(ns, { blob, attemptCount: priorAttempts + 1 });
|
|
258
|
+
emit({
|
|
259
|
+
kind: "persist_failed",
|
|
260
|
+
failure: {
|
|
261
|
+
namespace: ns,
|
|
262
|
+
entryKey: keys.get(ns) ?? entry.key,
|
|
263
|
+
error: new Error("No persister registered"),
|
|
264
|
+
attemptCount: priorAttempts + 1,
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const key = keys.get(ns) ?? entry.key;
|
|
270
|
+
if (!key) {
|
|
271
|
+
retries.set(ns, { blob, attemptCount: priorAttempts + 1 });
|
|
272
|
+
emit({
|
|
273
|
+
kind: "persist_failed",
|
|
274
|
+
failure: {
|
|
275
|
+
namespace: ns,
|
|
276
|
+
entryKey: "",
|
|
277
|
+
error: new Error("No entry key assigned"),
|
|
278
|
+
attemptCount: priorAttempts + 1,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
await persister(ns, key, blob);
|
|
285
|
+
retries.delete(ns);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
288
|
+
retries.set(ns, { blob, attemptCount: priorAttempts + 1 });
|
|
289
|
+
emit({
|
|
290
|
+
kind: "persist_failed",
|
|
291
|
+
failure: {
|
|
292
|
+
namespace: ns,
|
|
293
|
+
entryKey: key,
|
|
294
|
+
error,
|
|
295
|
+
attemptCount: priorAttempts + 1,
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function cancelDebounce(ns: EditorStateNamespace): DebounceEntry | undefined {
|
|
302
|
+
const existing = debounced.get(ns);
|
|
303
|
+
if (existing) {
|
|
304
|
+
scheduler.clearTimeout(existing.timer);
|
|
305
|
+
debounced.delete(ns);
|
|
306
|
+
}
|
|
307
|
+
return existing;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function scheduleDebounce(
|
|
311
|
+
ns: EditorStateNamespace,
|
|
312
|
+
blob: EditorStateBlob,
|
|
313
|
+
debounceMs: number,
|
|
314
|
+
): void {
|
|
315
|
+
cancelDebounce(ns);
|
|
316
|
+
const handle = scheduler.setTimeout(() => {
|
|
317
|
+
debounced.delete(ns);
|
|
318
|
+
void runPersist(ns, blob, 0);
|
|
319
|
+
}, debounceMs);
|
|
320
|
+
debounced.set(ns, { blob, timer: handle });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function validatePolicyChange(partial: EditorStatePolicy): void {
|
|
324
|
+
for (const [ns, entry] of Object.entries(partial) as Array<[
|
|
325
|
+
EditorStateNamespace,
|
|
326
|
+
EditorStatePolicyEntry,
|
|
327
|
+
]>) {
|
|
328
|
+
if (entry.location === "rowstore" || entry.location === "key-only") {
|
|
329
|
+
if (!resolver || !persister) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`Keyed policy for "${ns}" requires both a resolver and persister to be registered first`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (entry.location === "key-only" && !entry.key) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`key-only policy for "${ns}" requires an explicit key`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
setPolicy(partial) {
|
|
345
|
+
validatePolicyChange(partial);
|
|
346
|
+
for (const [ns, entry] of Object.entries(partial) as Array<[
|
|
347
|
+
EditorStateNamespace,
|
|
348
|
+
EditorStatePolicyEntry,
|
|
349
|
+
]>) {
|
|
350
|
+
const previous = policy[ns];
|
|
351
|
+
// Effective prior location (defaults to in-document when the
|
|
352
|
+
// namespace has no explicit policy yet, per DEFAULT_POLICY_ENTRY).
|
|
353
|
+
const prevLocation: EditorStateLocation = previous?.location ?? "in-document";
|
|
354
|
+
policy[ns] = entry;
|
|
355
|
+
if (entry.key) {
|
|
356
|
+
keys.set(ns, entry.key);
|
|
357
|
+
} else if (!keys.has(ns) && entry.location !== "in-document") {
|
|
358
|
+
keys.set(ns, randomUuid());
|
|
359
|
+
}
|
|
360
|
+
if (prevLocation !== entry.location) {
|
|
361
|
+
// Only emit the migrated event when we actually had a prior
|
|
362
|
+
// explicit policy — first-time configuration is not a migration.
|
|
363
|
+
if (previous) {
|
|
364
|
+
emit({
|
|
365
|
+
kind: "policy_migrated",
|
|
366
|
+
migration: {
|
|
367
|
+
namespace: ns,
|
|
368
|
+
from: previous.location,
|
|
369
|
+
to: entry.location,
|
|
370
|
+
key: keys.get(ns),
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Migrate data across the location change so the next save/flush
|
|
376
|
+
// writes the in-memory state to the new destination. The source
|
|
377
|
+
// blob is `lastKnown` (populated by recordMutation + inline apply
|
|
378
|
+
// + successful resolve) — authoritative across locations.
|
|
379
|
+
const retained = lastKnown.get(ns);
|
|
380
|
+
if (retained) {
|
|
381
|
+
if (prevLocation === "in-document" && entry.location !== "in-document") {
|
|
382
|
+
// in-document → keyed: queue for persist.
|
|
383
|
+
cancelDebounce(ns);
|
|
384
|
+
scheduleDebounce(ns, retained, entry.debounceMs ?? DEFAULT_DEBOUNCE_MS);
|
|
385
|
+
} else if (prevLocation !== "in-document" && entry.location === "in-document") {
|
|
386
|
+
// keyed → in-document: seed the inline dirty slot so the next
|
|
387
|
+
// serialize inlines the retained blob.
|
|
388
|
+
cancelDebounce(ns);
|
|
389
|
+
debounced.set(ns, { blob: retained, timer: null });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
getPolicyEntry(ns) {
|
|
397
|
+
return effectiveEntry(ns);
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
getKey(ns) {
|
|
401
|
+
return keys.get(ns);
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
setKey(ns, key) {
|
|
405
|
+
keys.set(ns, key);
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
setResolver(next) {
|
|
409
|
+
resolver = next;
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
setPersister(next) {
|
|
413
|
+
persister = next;
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
getResolver() {
|
|
417
|
+
return resolver;
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
getPersister() {
|
|
421
|
+
return persister;
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
recordMutation(ns, blob) {
|
|
425
|
+
lastKnown.set(ns, blob);
|
|
426
|
+
const entry = effectiveEntry(ns);
|
|
427
|
+
if (entry.location === "in-document") {
|
|
428
|
+
// Mark dirty for the serializer — no external debounce needed.
|
|
429
|
+
// Use null timer to distinguish from a scheduled debounce.
|
|
430
|
+
debounced.set(ns, { blob, timer: null });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
scheduleDebounce(ns, blob, entry.debounceMs);
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
async flush(ns) {
|
|
437
|
+
const targets = ns ? [ns] : Array.from(debounced.keys());
|
|
438
|
+
for (const target of targets) {
|
|
439
|
+
const pending = cancelDebounce(target);
|
|
440
|
+
if (!pending) continue;
|
|
441
|
+
await runPersist(target, pending.blob, 0);
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
async retry(ns) {
|
|
446
|
+
const targets = ns ? [ns] : Array.from(retries.keys());
|
|
447
|
+
for (const target of targets) {
|
|
448
|
+
const entry = retries.get(target);
|
|
449
|
+
if (!entry) continue;
|
|
450
|
+
retries.delete(target);
|
|
451
|
+
await runPersist(target, entry.blob, entry.attemptCount);
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
async resolve(ns, entryKey) {
|
|
456
|
+
const entry = effectiveEntry(ns);
|
|
457
|
+
const retainBlob = (): EditorStateBlob | null => {
|
|
458
|
+
if (entry.onResolveError !== "retain-last-known") return null;
|
|
459
|
+
return lastKnown.get(ns) ?? null;
|
|
460
|
+
};
|
|
461
|
+
if (!resolver) {
|
|
462
|
+
const failure: EditorStatePartLoadFailure = {
|
|
463
|
+
namespace: ns,
|
|
464
|
+
entryKey,
|
|
465
|
+
error: new Error("No resolver registered"),
|
|
466
|
+
fallback: entry.onResolveError === "block" ? "empty" : entry.onResolveError,
|
|
467
|
+
};
|
|
468
|
+
emit({ kind: "load_failed", failure });
|
|
469
|
+
if (entry.onResolveError === "block") {
|
|
470
|
+
throw failure.error;
|
|
471
|
+
}
|
|
472
|
+
return { blob: retainBlob(), appliedFallback: true };
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
const blob = await resolver(ns, entryKey);
|
|
476
|
+
if (blob === null) {
|
|
477
|
+
const failure: EditorStatePartLoadFailure = {
|
|
478
|
+
namespace: ns,
|
|
479
|
+
entryKey,
|
|
480
|
+
error: new Error(`Resolver returned null for ${ns}:${entryKey}`),
|
|
481
|
+
fallback: entry.onResolveError === "block" ? "empty" : entry.onResolveError,
|
|
482
|
+
};
|
|
483
|
+
emit({ kind: "load_failed", failure });
|
|
484
|
+
if (entry.onResolveError === "block") throw failure.error;
|
|
485
|
+
return { blob: retainBlob(), appliedFallback: true };
|
|
486
|
+
}
|
|
487
|
+
lastKnown.set(ns, blob);
|
|
488
|
+
return { blob, appliedFallback: false };
|
|
489
|
+
} catch (err) {
|
|
490
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
491
|
+
const failure: EditorStatePartLoadFailure = {
|
|
492
|
+
namespace: ns,
|
|
493
|
+
entryKey,
|
|
494
|
+
error,
|
|
495
|
+
fallback: entry.onResolveError === "block" ? "empty" : entry.onResolveError,
|
|
496
|
+
};
|
|
497
|
+
emit({ kind: "load_failed", failure });
|
|
498
|
+
if (entry.onResolveError === "block") throw error;
|
|
499
|
+
return { blob: retainBlob(), appliedFallback: true };
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
recordPolicyMigration(migration) {
|
|
504
|
+
emit({ kind: "policy_migrated", migration });
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
recordUnknownNamespace(namespace, entry) {
|
|
508
|
+
if (entry) {
|
|
509
|
+
unknownEntries.set(namespace, entry);
|
|
510
|
+
}
|
|
511
|
+
emit({ kind: "unknown_namespace", namespace });
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
getUnknownEntries() {
|
|
515
|
+
return Array.from(unknownEntries.entries()).map(([name, entry]) => ({ name, ...entry }));
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
recordLoadFailure(failure) {
|
|
519
|
+
emit({ kind: "load_failed", failure });
|
|
520
|
+
},
|
|
521
|
+
|
|
522
|
+
recordLoaded(ns, blob) {
|
|
523
|
+
lastKnown.set(ns, blob);
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
addListener(listener) {
|
|
527
|
+
listeners.add(listener);
|
|
528
|
+
return () => {
|
|
529
|
+
listeners.delete(listener);
|
|
530
|
+
};
|
|
531
|
+
},
|
|
532
|
+
|
|
533
|
+
snapshot() {
|
|
534
|
+
return {
|
|
535
|
+
policy: { ...policy },
|
|
536
|
+
keys: new Map(keys),
|
|
537
|
+
dirtyNamespaces: new Set(debounced.keys()),
|
|
538
|
+
retryCounts: new Map(
|
|
539
|
+
Array.from(retries.entries()).map(([ns, entry]) => [ns, entry.attemptCount]),
|
|
540
|
+
),
|
|
541
|
+
};
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
}
|