@beyondwork/docx-react-component 1.0.47 → 1.0.48

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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +115 -1
  3. package/src/compare/diff-engine.ts +4 -0
  4. package/src/core/commands/add-scope.ts +257 -0
  5. package/src/core/commands/formatting-commands.ts +2 -0
  6. package/src/core/schema/text-schema.ts +95 -1
  7. package/src/core/state/text-transaction.ts +17 -5
  8. package/src/io/chart-preview-resolver.ts +27 -0
  9. package/src/io/docx-session.ts +226 -38
  10. package/src/io/export/serialize-main-document.ts +37 -0
  11. package/src/io/export/serialize-settings.ts +421 -0
  12. package/src/io/export/serialize-styles.ts +10 -0
  13. package/src/io/normalize/normalize-text.ts +1 -0
  14. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  15. package/src/io/ooxml/chart/parse-chart-space.ts +813 -0
  16. package/src/io/ooxml/chart/parse-series.ts +570 -0
  17. package/src/io/ooxml/chart/resolve-color.ts +251 -0
  18. package/src/io/ooxml/chart/types.ts +420 -0
  19. package/src/io/ooxml/parse-block-structure.ts +99 -0
  20. package/src/io/ooxml/parse-complex-content.ts +87 -2
  21. package/src/io/ooxml/parse-main-document.ts +115 -1
  22. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  23. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  24. package/src/io/ooxml/parse-settings.ts +97 -1
  25. package/src/io/ooxml/parse-styles.ts +65 -0
  26. package/src/io/ooxml/parse-theme.ts +2 -127
  27. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  28. package/src/io/ooxml/xml-parser.ts +142 -0
  29. package/src/model/canonical-document.ts +94 -0
  30. package/src/model/scope-markers.ts +144 -0
  31. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  32. package/src/runtime/collab/checkpoint-election.ts +75 -0
  33. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  34. package/src/runtime/collab/checkpoint-store.ts +115 -0
  35. package/src/runtime/collab/event-types.ts +27 -0
  36. package/src/runtime/collab/index.ts +22 -0
  37. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  38. package/src/runtime/collab/runtime-collab-sync.ts +279 -0
  39. package/src/runtime/document-runtime.ts +214 -16
  40. package/src/runtime/editor-surface/capabilities.ts +63 -50
  41. package/src/runtime/layout/layout-engine-version.ts +8 -1
  42. package/src/runtime/prerender/cache-envelope.ts +19 -7
  43. package/src/runtime/prerender/cache-key.ts +25 -14
  44. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  45. package/src/runtime/prerender/customxml-cache.ts +211 -0
  46. package/src/runtime/prerender/customxml-probe.ts +78 -0
  47. package/src/runtime/prerender/prerender-document.ts +74 -7
  48. package/src/runtime/scope-resolver.ts +148 -0
  49. package/src/runtime/scope-tag-registry.ts +10 -0
  50. package/src/runtime/surface-projection.ts +8 -1
  51. package/src/ui/WordReviewEditor.tsx +30 -0
  52. package/src/ui/editor-runtime-boundary.ts +6 -1
  53. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
@@ -16,6 +16,8 @@ import type {
16
16
  AddCommentParams,
17
17
  AddCommentReplyResult,
18
18
  AddCommentResult,
19
+ AddScopeParams,
20
+ AddScopeResult,
19
21
  CommentSidebarSnapshot,
20
22
  CommentSidebarThreadSnapshot,
21
23
  CompatibilityReport,
@@ -74,6 +76,7 @@ import type {
74
76
  WorkflowMetadataSnapshot,
75
77
  WorkflowMarkupSnapshot,
76
78
  WorkflowOverlay,
79
+ WorkflowScope,
77
80
  WorkflowScopeSnapshot,
78
81
  WorkspaceMode,
79
82
  WordReviewEditorEvent,
@@ -116,6 +119,11 @@ import {
116
119
  } from "../review/store/revision-store.ts";
117
120
  import { createSuggestionsSnapshot } from "./suggestions-snapshot.ts";
118
121
  import { validateSelectionAgainstDocument } from "./selection/post-edit-validator.ts";
122
+ import { resolveScope } from "./scope-resolver.ts";
123
+ import {
124
+ insertScopeMarkers,
125
+ removeScopeMarkers,
126
+ } from "../core/commands/add-scope.ts";
119
127
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
120
128
  import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
121
129
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
@@ -291,6 +299,9 @@ export interface DocumentRuntime {
291
299
  reopenComment(commentId: string): void;
292
300
  addCommentReply(commentId: string, body: string, authorId?: string): AddCommentReplyResult;
293
301
  editCommentBody(commentId: string, body: string): void;
302
+ addScope(params: AddScopeParams): AddScopeResult;
303
+ getScope(scopeId: string): WorkflowScope | null;
304
+ removeScope(scopeId: string): void;
294
305
  acceptChange(changeId: string): void;
295
306
  rejectChange(changeId: string): void;
296
307
  acceptAllChanges(): void;
@@ -2035,6 +2046,16 @@ export function createDocumentRuntime(
2035
2046
  }
2036
2047
  });
2037
2048
 
2049
+ // R5 scratch snapshot: single pre-allocated object reused for every
2050
+ // `applyRemoteCommand(cmd, ctx, meta)` call with a `meta.preSelection`
2051
+ // override. Avoids the per-remote-command `{ ...cachedRenderSnapshot }`
2052
+ // + `{ ...state }` allocations that CLAUDE.md rule 4 warns against.
2053
+ // Mutated in place below — callers MUST NOT hold references to it
2054
+ // across dispatches; `executeEditorCommand` + `commitRemote` consume
2055
+ // synchronously within `applyRemoteCommand`, so this is safe today.
2056
+ const r5ScratchReplayState: typeof state = { ...state };
2057
+ const r5ScratchReplaySnapshot: typeof cachedRenderSnapshot = { ...cachedRenderSnapshot };
2058
+
2038
2059
  return {
2039
2060
  subscribe(listener) {
2040
2061
  listeners.add(listener);
@@ -2147,28 +2168,56 @@ export function createDocumentRuntime(
2147
2168
  applyRuntimeStateOverlayCommand(command);
2148
2169
  return;
2149
2170
  }
2150
- if (meta?.activeStory && !storyTargetsEqual(meta.activeStory, activeStory)) {
2151
- activeStory = meta.activeStory;
2152
- storySelections.set(
2153
- storyTargetKey(activeStory),
2154
- meta.preSelection ?? state.selection,
2155
- );
2171
+ // Story-target isolation: the remote's `meta.activeStory` scopes
2172
+ // the replay (so the command lands in the intended region of the
2173
+ // shared document), but must NEVER overwrite the local user's
2174
+ // closure-level `activeStory`. Before P11 this assignment stole
2175
+ // focus every time a remote event arrived for a different story —
2176
+ // a local user authoring in a header would get yanked into the
2177
+ // main body as soon as a peer edited main.
2178
+ const replayStory = meta?.activeStory ?? activeStory;
2179
+ const crossStoryReplay = Boolean(
2180
+ meta?.activeStory && !storyTargetsEqual(meta.activeStory, activeStory),
2181
+ );
2182
+ let replayState: typeof state;
2183
+ let replaySnapshot: typeof cachedRenderSnapshot;
2184
+ if (meta?.preSelection) {
2185
+ // Refresh scratch with current `state` / `cachedRenderSnapshot` (both
2186
+ // mutate on every commit), then override only the selection fields.
2187
+ Object.assign(r5ScratchReplayState, state);
2188
+ r5ScratchReplayState.selection = meta.preSelection;
2189
+ Object.assign(r5ScratchReplaySnapshot, cachedRenderSnapshot);
2190
+ r5ScratchReplaySnapshot.selection = toPublicSelectionSnapshot(meta.preSelection, replayStory);
2191
+ replayState = r5ScratchReplayState;
2192
+ replaySnapshot = r5ScratchReplaySnapshot;
2193
+ } else {
2194
+ replayState = state;
2195
+ replaySnapshot = cachedRenderSnapshot;
2156
2196
  }
2157
- const replayState = meta?.preSelection
2158
- ? { ...state, selection: meta.preSelection }
2159
- : state;
2160
- const replaySnapshot = meta?.preSelection
2161
- ? {
2162
- ...cachedRenderSnapshot,
2163
- selection: toPublicSelectionSnapshot(meta.preSelection, activeStory),
2164
- }
2165
- : cachedRenderSnapshot;
2166
2197
  const replayContext = {
2167
2198
  ...context,
2168
2199
  renderSnapshot: replaySnapshot,
2169
2200
  };
2170
2201
  const transaction = executeEditorCommand(replayState, command, replayContext);
2171
- commitRemote(transaction);
2202
+ if (crossStoryReplay) {
2203
+ // Cross-story replay: the transaction's resulting selection is
2204
+ // in the remote story's region. Don't leak that into local
2205
+ // `state.selection` — preserve the pre-replay local selection
2206
+ // so the local caret stays where the user is focused. The
2207
+ // document delta still applies; only the selection is filtered.
2208
+ // Full position-mapping of the local cursor through the remote
2209
+ // edit is a separate P11 sub-bullet (Awareness cursor transaction
2210
+ // mapping).
2211
+ commitRemote({
2212
+ ...transaction,
2213
+ nextState: {
2214
+ ...transaction.nextState,
2215
+ selection: state.selection,
2216
+ },
2217
+ });
2218
+ } else {
2219
+ commitRemote(transaction);
2220
+ }
2172
2221
  } catch (error) {
2173
2222
  emitError(toRuntimeError(error));
2174
2223
  }
@@ -2362,6 +2411,155 @@ export function createDocumentRuntime(
2362
2411
  origin: createOrigin("api", clock()),
2363
2412
  });
2364
2413
  },
2414
+ addScope(params): AddScopeResult {
2415
+ const scopeId =
2416
+ params.scopeId ??
2417
+ `scope-${clock().replace(/[^0-9]/gu, "")}-${Math.floor(Math.random() * 1e6)}`;
2418
+ const anchor =
2419
+ params.anchor.kind === "range"
2420
+ ? { from: params.anchor.from, to: params.anchor.to }
2421
+ : null;
2422
+
2423
+ if (!anchor) {
2424
+ return {
2425
+ scopeId,
2426
+ anchor: params.anchor,
2427
+ };
2428
+ }
2429
+
2430
+ const { document: nextDocument } = insertScopeMarkers(state.document, {
2431
+ scopeId,
2432
+ from: anchor.from,
2433
+ to: anchor.to,
2434
+ });
2435
+
2436
+ if (nextDocument !== state.document) {
2437
+ this.dispatch({
2438
+ type: "document.replace",
2439
+ document: nextDocument,
2440
+ origin: createOrigin("api", clock()),
2441
+ });
2442
+ }
2443
+
2444
+ const resolved = resolveScope(state.document, scopeId);
2445
+ const publicAnchor: EditorAnchorProjection =
2446
+ resolved && resolved.kind === "range"
2447
+ ? resolved
2448
+ : {
2449
+ kind: "range",
2450
+ from: anchor.from,
2451
+ to: anchor.to,
2452
+ assoc: { start: -1, end: 1 },
2453
+ };
2454
+
2455
+ const currentOverlay: WorkflowOverlay = workflowOverlay ?? {
2456
+ overlayVersion: "workflow-overlay/1",
2457
+ scopes: [],
2458
+ };
2459
+ const existingScopes = currentOverlay.scopes.filter(
2460
+ (existing) => existing.scopeId !== scopeId,
2461
+ );
2462
+ const scope: WorkflowScope = {
2463
+ scopeId,
2464
+ mode: params.mode ?? "comment",
2465
+ anchor: publicAnchor,
2466
+ ...(params.storyTarget ? { storyTarget: params.storyTarget } : {}),
2467
+ ...(params.label ? { label: params.label } : {}),
2468
+ };
2469
+ this.dispatch({
2470
+ type: "workflow.set-overlay",
2471
+ overlay: {
2472
+ ...currentOverlay,
2473
+ scopes: [...existingScopes, scope],
2474
+ },
2475
+ origin: createOrigin("api", clock()),
2476
+ });
2477
+
2478
+ if (params.persistence && params.persistence !== "runtime-only") {
2479
+ const entry: WorkflowMetadataEntry = {
2480
+ entryId: `scope-metadata-${scopeId}`,
2481
+ metadataId: "workflow.scope",
2482
+ anchor: publicAnchor,
2483
+ scopeId,
2484
+ value:
2485
+ params.persistence === "document-metadata"
2486
+ ? { ...(params.metadata?.value ?? {}), label: params.label }
2487
+ : params.metadata?.value,
2488
+ metadataPersistence:
2489
+ params.persistence === "session" ? "external" : "internal",
2490
+ };
2491
+ this.dispatch({
2492
+ type: "workflow.set-metadata-entries",
2493
+ entries: [...(workflowMetadataEntries ?? []), entry],
2494
+ origin: createOrigin("api", clock()),
2495
+ });
2496
+ }
2497
+
2498
+ return {
2499
+ scopeId,
2500
+ anchor: publicAnchor,
2501
+ };
2502
+ },
2503
+ getScope(scopeId) {
2504
+ const resolved = resolveScope(state.document, scopeId);
2505
+ if (!resolved) {
2506
+ const stored = workflowOverlay?.scopes.find((s) => s.scopeId === scopeId);
2507
+ return stored ?? null;
2508
+ }
2509
+ const stored = workflowOverlay?.scopes.find((s) => s.scopeId === scopeId);
2510
+ if (!stored) {
2511
+ return {
2512
+ scopeId,
2513
+ mode: "comment",
2514
+ anchor: resolved,
2515
+ };
2516
+ }
2517
+ return {
2518
+ ...stored,
2519
+ anchor: resolved,
2520
+ };
2521
+ },
2522
+ removeScope(scopeId) {
2523
+ // Step 1: drop the scope from the overlay FIRST. If the scope's mode was
2524
+ // "comment" / "view" the workflow-blocked-reasons gate in `dispatch`
2525
+ // would otherwise refuse the subsequent `document.replace` with
2526
+ // `workflow_comment_only` / `workflow_view_only`. Overlay commands are
2527
+ // routed through `applyRuntimeStateOverlayCommand` and bypass that gate.
2528
+ if (workflowOverlay) {
2529
+ const nextScopes = workflowOverlay.scopes.filter(
2530
+ (scope) => scope.scopeId !== scopeId,
2531
+ );
2532
+ if (nextScopes.length !== workflowOverlay.scopes.length) {
2533
+ this.dispatch({
2534
+ type: "workflow.set-overlay",
2535
+ overlay: { ...workflowOverlay, scopes: nextScopes },
2536
+ origin: createOrigin("api", clock()),
2537
+ });
2538
+ }
2539
+ }
2540
+ // Step 2: now that the scope is gone, strip the markers from the doc.
2541
+ const nextDocument = removeScopeMarkers(state.document, scopeId);
2542
+ if (nextDocument !== state.document) {
2543
+ this.dispatch({
2544
+ type: "document.replace",
2545
+ document: nextDocument,
2546
+ origin: createOrigin("api", clock()),
2547
+ });
2548
+ }
2549
+ // Step 3: clear any customXml-persisted metadata entries.
2550
+ if (workflowMetadataEntries) {
2551
+ const nextEntries = workflowMetadataEntries.filter(
2552
+ (entry) => entry.scopeId !== scopeId,
2553
+ );
2554
+ if (nextEntries.length !== workflowMetadataEntries.length) {
2555
+ this.dispatch({
2556
+ type: "workflow.set-metadata-entries",
2557
+ entries: nextEntries,
2558
+ origin: createOrigin("api", clock()),
2559
+ });
2560
+ }
2561
+ }
2562
+ },
2365
2563
  acceptChange(changeId) {
2366
2564
  this.dispatch({
2367
2565
  type: "change.accept",
@@ -45,7 +45,8 @@ export type CapabilityCategory =
45
45
  | "tracked-changes"
46
46
  | "comments"
47
47
  | "structure"
48
- | "system";
48
+ | "system"
49
+ | "workflow";
49
50
 
50
51
  export interface CapabilityShortcut {
51
52
  /** Canonical Windows / Linux binding string (e.g. "Ctrl+B", "Shift+Tab"). */
@@ -301,87 +302,99 @@ export const EDITOR_CAPABILITIES: readonly EditorCapability[] = [
301
302
  shortcut: { winLinux: "Ctrl+0", mac: "Cmd+0" },
302
303
  hostEvent: "onZoomRequested",
303
304
  },
304
-
305
- // ---------------------------------------------------------------
306
- // Blocked — Word shortcuts the mounted editor does not implement
307
- // ---------------------------------------------------------------
308
305
  {
309
- id: "replaceText",
310
- kind: "blocked",
306
+ id: "shortcut.replace",
307
+ kind: "host-delegated",
311
308
  category: "navigation",
312
309
  label: "Find and replace",
313
310
  shortcut: { winLinux: "Ctrl+H", mac: "Ctrl+H" },
314
- blockReason: {
315
- code: UNSUPPORTED_SURFACE,
316
- message: "Replace shortcuts are not supported in the mounted editor yet.",
317
- },
311
+ hostEvent: "onReplaceRequested",
318
312
  },
319
313
  {
320
- id: "goTo",
321
- kind: "blocked",
314
+ id: "shortcut.go-to",
315
+ kind: "host-delegated",
322
316
  category: "navigation",
323
- label: "Go to",
317
+ label: "Go to page / bookmark / line",
324
318
  shortcut: { winLinux: "Ctrl+G", mac: "Cmd+Option+G" },
325
- blockReason: {
326
- code: UNSUPPORTED_SURFACE,
327
- message: "Go To shortcuts are not supported in the mounted editor yet.",
328
- },
329
- },
330
- {
331
- id: "toggleTrackChanges",
332
- kind: "blocked",
333
- category: "tracked-changes",
334
- label: "Toggle track-changes authoring mode",
335
- shortcut: { winLinux: "Ctrl+Shift+E", mac: "Cmd+Shift+E" },
336
- blockReason: {
337
- code: UNSUPPORTED_SURFACE,
338
- message: "Track changes authoring shortcuts are not supported in the mounted editor.",
339
- },
319
+ hostEvent: "onGoToRequested",
340
320
  },
341
321
  {
342
- id: "checkSpelling",
343
- kind: "blocked",
322
+ id: "shortcut.spell",
323
+ kind: "host-delegated",
344
324
  category: "system",
345
325
  label: "Check spelling",
346
326
  shortcut: { winLinux: "F7", mac: "F7" },
347
- blockReason: {
348
- code: UNSUPPORTED_SURFACE,
349
- message: "Spelling shortcuts are not supported in the mounted editor.",
350
- },
327
+ hostEvent: "onSpellRequested",
351
328
  },
352
329
  {
353
- id: "openThesaurus",
354
- kind: "blocked",
330
+ id: "shortcut.thesaurus",
331
+ kind: "host-delegated",
355
332
  category: "system",
356
333
  label: "Open thesaurus",
357
334
  shortcut: { winLinux: "Shift+F7", mac: "Shift+F7" },
358
- blockReason: {
359
- code: UNSUPPORTED_SURFACE,
360
- message: "Thesaurus shortcuts are not supported in the mounted editor.",
361
- },
335
+ hostEvent: "onThesaurusRequested",
362
336
  },
363
337
  {
364
- id: "extendSelection",
365
- kind: "blocked",
338
+ id: "shortcut.extend-selection",
339
+ kind: "host-delegated",
366
340
  category: "selection",
367
341
  label: "Extend-selection mode",
368
342
  shortcut: { winLinux: "F8", mac: "F8" },
369
- blockReason: {
370
- code: UNSUPPORTED_SURFACE,
371
- message: "Extend-selection shortcuts are not supported in the mounted editor.",
372
- },
343
+ hostEvent: "onExtendSelectionRequested",
373
344
  },
374
345
  {
375
- id: "lastEdit",
376
- kind: "blocked",
346
+ id: "shortcut.last-edit",
347
+ kind: "host-delegated",
377
348
  category: "navigation",
378
349
  label: "Return to last edit",
379
350
  shortcut: { winLinux: "Shift+F5", mac: "Shift+F5" },
351
+ hostEvent: "onLastEditRequested",
352
+ },
353
+
354
+ // ---------------------------------------------------------------
355
+ // Blocked — Word shortcuts the mounted editor does not implement
356
+ // ---------------------------------------------------------------
357
+ {
358
+ id: "toggleTrackChanges",
359
+ kind: "blocked",
360
+ category: "tracked-changes",
361
+ label: "Toggle track-changes authoring mode",
362
+ shortcut: { winLinux: "Ctrl+Shift+E", mac: "Cmd+Shift+E" },
380
363
  blockReason: {
381
364
  code: UNSUPPORTED_SURFACE,
382
- message: "Last-edit shortcuts are not supported in the mounted editor.",
365
+ message: "Track changes authoring shortcuts are not supported in the mounted editor.",
383
366
  },
384
367
  },
368
+
369
+ // ---------------------------------------------------------------
370
+ // Workflow ref-methods (supported, no shortcut)
371
+ //
372
+ // These entries document ref-method API surface that has no
373
+ // keyboard binding — `WordReviewEditorRef.addScope` / `getScope`
374
+ // / `removeScope`, shipped as S1. They appear in the capability
375
+ // table so host integrations can introspect the full contract
376
+ // without parsing multiple sources. Do not remove the `kind:
377
+ // "supported"` discipline — they are runtime-owned mutations, not
378
+ // host-delegated.
379
+ // ---------------------------------------------------------------
380
+ {
381
+ id: "scope.add",
382
+ kind: "supported",
383
+ category: "workflow",
384
+ label: "Attach a workflow scope to a selection or range (ref method)",
385
+ },
386
+ {
387
+ id: "scope.get",
388
+ kind: "supported",
389
+ category: "workflow",
390
+ label: "Resolve a scopeId to a live WorkflowScope with current anchor (ref method)",
391
+ },
392
+ {
393
+ id: "scope.remove",
394
+ kind: "supported",
395
+ category: "workflow",
396
+ label: "Remove a scope's markers + metadata record (ref method)",
397
+ },
385
398
  ];
386
399
 
387
400
  /**
@@ -55,5 +55,12 @@ export const LAYOUT_ENGINE_VERSION = 5 as const;
55
55
  * 1 — initial envelope shape: { schemaVersion, engineVersion,
56
56
  * fontFingerprint, structuralHash, graph, surface }. Ships with
57
57
  * L7 Phase 2.5 Plan A.
58
+ * 2 — L7 Phase 2.5 Plan B: adds `canonicalDocument` + `canonicalDocumentHash`
59
+ * fields so the receiving client can skip the DOCX parse entirely on
60
+ * cache hit. `canonicalDocumentHash` is also a 5th input to the cache
61
+ * key so any state mutation (styles, metadata, comments, preservation)
62
+ * correctly invalidates. v1 envelopes are rejected on load under v2 —
63
+ * no corruption path exists because schemaVersion is the top-level
64
+ * discriminator.
58
65
  */
59
- export const LAYCACHE_SCHEMA_VERSION = 1 as const;
66
+ export const LAYCACHE_SCHEMA_VERSION = 2 as const;
@@ -1,13 +1,14 @@
1
1
  import type { EditorSurfaceSnapshot } from "../../api/public-types";
2
+ import type { CanonicalDocument } from "../../model/canonical-document.ts";
2
3
  import type { RuntimePageGraph } from "../layout/page-graph.ts";
3
4
 
4
5
  /**
5
- * L7 Phase 2.5 Task 2.5.3 — prerender cache envelope shape.
6
+ * L7 Phase 2.5 — prerender cache envelope shape.
6
7
  *
7
- * The envelope is the unit written to IndexedDB (Plan A) and — after Plan B
8
- * ships — to the `laycache` customXml editor-state namespace. Two consumers
9
- * must agree on this shape: the prerender pipeline that populates it, and
10
- * the warm-path loader that rehydrates it.
8
+ * The envelope is the unit written to IndexedDB (Plan A) and — under Plan B
9
+ * (schema v2) — to the `laycache` customXml editor-state namespace. Two
10
+ * consumers must agree on this shape: the prerender pipeline that populates
11
+ * it, and the warm-path loader that rehydrates it.
11
12
  *
12
13
  * Load-time invariants checked by consumers before trusting the envelope:
13
14
  * - `schemaVersion === LAYCACHE_SCHEMA_VERSION` — bump invalidates
@@ -15,15 +16,26 @@ import type { RuntimePageGraph } from "../layout/page-graph.ts";
15
16
  * - `graph.revision === 0` — canonical marker
16
17
  *
17
18
  * The envelope MUST be structured-clone-safe because IndexedDB and Plan B's
18
- * customXml path both rely on structured-clone semantics. Keep fields as
19
- * JSON-serializable primitives, plain objects, or arrays — no class
19
+ * customXml path both rely on structured-clone / JSON semantics. Keep fields
20
+ * as JSON-serializable primitives, plain objects, or arrays — no class
20
21
  * instances, functions, or symbols.
22
+ *
23
+ * Plan B additions (schema v2):
24
+ * - `canonicalDocument` — the full parsed model, so the warm-path loader
25
+ * can skip `parseMainDocumentXml` + `createImportedCanonicalDocument` +
26
+ * `buildCompatibilityReport` + `createImportedSnapshot` on cache hit.
27
+ * Saves ~584 ms of the 976 ms cold-upload on `extra-large` CCEP.
28
+ * - `canonicalDocumentHash` — sha256 of sorted-keys JSON. Also the 5th
29
+ * input to `deriveCacheKey`, so style/metadata/comment/preservation
30
+ * mutations correctly invalidate the cache.
21
31
  */
22
32
  export interface CacheEnvelope {
23
33
  readonly schemaVersion: number;
24
34
  readonly engineVersion: number;
25
35
  readonly fontFingerprint: string;
26
36
  readonly structuralHash: string;
37
+ readonly canonicalDocumentHash: string;
27
38
  readonly graph: RuntimePageGraph;
28
39
  readonly surface: EditorSurfaceSnapshot;
40
+ readonly canonicalDocument: CanonicalDocument;
29
41
  }
@@ -1,22 +1,31 @@
1
1
  /**
2
- * L7 Phase 2.5 Task 2.5.1 — prerender cache-key derivation.
2
+ * L7 Phase 2.5 — prerender cache-key derivation.
3
3
  *
4
4
  * The cache key is the composite identity the IndexedDB (Plan A) and
5
5
  * customXml (Plan B) backends index on. It has five inputs:
6
6
  *
7
- * 1. structuralHash(blocks) — sha256 of the ordered kind:blockId list.
8
- * Stable across text-only edits; changes on
9
- * insert/delete/reorder because blockIds are
10
- * kind-counter pairs (paragraph-5 stays 5
11
- * under typing, shifts to paragraph-6 after
12
- * an insert).
13
- * 2. fontFingerprint — identifies the measurement-backend + font-
14
- * metric source. "empirical-backend" in Plan A;
15
- * a real font-derived string after Phase 8.
16
- * 3. engineVersion — LAYOUT_ENGINE_VERSION from src/runtime/
17
- * layout/layout-engine-version.ts. Bumped by
18
- * CI gate on any layout/render shape change.
19
- * 4. schemaVersion — LAYCACHE_SCHEMA_VERSION for envelope format.
7
+ * 1. structuralHash(blocks) — sha256 of the ordered kind:blockId list.
8
+ * Stable across text-only edits; changes on
9
+ * insert/delete/reorder because blockIds are
10
+ * kind-counter pairs (paragraph-5 stays 5
11
+ * under typing, shifts to paragraph-6 after
12
+ * an insert).
13
+ * 2. fontFingerprint — identifies the measurement-backend + font-
14
+ * metric source. "empirical-backend" in
15
+ * Plan A; a real font-derived string after
16
+ * Phase 8.
17
+ * 3. engineVersion — LAYOUT_ENGINE_VERSION from src/runtime/
18
+ * layout/layout-engine-version.ts. Bumped by
19
+ * CI gate on any layout/render shape change.
20
+ * 4. schemaVersion — LAYCACHE_SCHEMA_VERSION for envelope
21
+ * format.
22
+ * 5. canonicalDocumentHash — (Plan B, schema v2) sha256 of sorted-keys
23
+ * JSON of the CanonicalDocument. Catches
24
+ * non-structural mutations (styles,
25
+ * metadata, comments, preservation) that
26
+ * `structuralHash` alone misses. Computed
27
+ * via `computeCanonicalDocumentHash()` from
28
+ * `./canonical-document-hash.ts`.
20
29
  *
21
30
  * Returns a 64-char lower-case hex digest. Uses the Web Crypto API
22
31
  * (globalThis.crypto.subtle), available in Node 18+ and all target browsers —
@@ -33,6 +42,7 @@ export interface CacheKeyInputs {
33
42
  readonly fontFingerprint: string;
34
43
  readonly engineVersion: string | number;
35
44
  readonly schemaVersion: number;
45
+ readonly canonicalDocumentHash: string;
36
46
  }
37
47
 
38
48
  const BLOCK_SEPARATOR = "\u0000";
@@ -61,6 +71,7 @@ export async function deriveCacheKey(inputs: CacheKeyInputs): Promise<string> {
61
71
  inputs.fontFingerprint,
62
72
  String(inputs.engineVersion),
63
73
  String(inputs.schemaVersion),
74
+ inputs.canonicalDocumentHash,
64
75
  ].join(FIELD_SEPARATOR);
65
76
  return sha256Hex(composite);
66
77
  }
@@ -0,0 +1,63 @@
1
+ import type { CanonicalDocument } from "../../model/canonical-document.ts";
2
+ import { stableStringify } from "../../model/cds-1.0.0.ts";
3
+
4
+ /**
5
+ * L7 Phase 2.5 Plan B — deterministic hash of a CanonicalDocument.
6
+ *
7
+ * Used as the fifth input to `deriveCacheKey` so non-structural mutations
8
+ * (style edits, metadata changes, comment/revision edits, preservation
9
+ * fragment updates) correctly invalidate the cache. `structuralHash`
10
+ * alone — which keys on the block-id list — misses these because the
11
+ * block structure is unchanged by such mutations.
12
+ *
13
+ * Determinism: uses `stableStringify` from `cds-1.0.0.ts` (the same
14
+ * ordering the canonical-document model already uses for equality
15
+ * comparison), so two runs on structurally-identical documents produce
16
+ * byte-identical JSON → byte-identical SHA-256. Cross-process / cross-
17
+ * machine agreement holds as long as the CanonicalDocument shape matches.
18
+ *
19
+ * **Session-birth metadata is excluded from the hash** — `createdAt` and
20
+ * `updatedAt` are set to `new Date().toISOString()` at session load (see
21
+ * `docx-session.ts`), and `docId` derives from a runtime-allocated UUID
22
+ * when the host does not pin one. These fields are not document identity
23
+ * for cache-validity purposes: a save-and-reload should hit the cache if
24
+ * the document content is unchanged, even though `updatedAt` shifted.
25
+ *
26
+ * Cost budget: <50 ms on `extra-large` CCEP (~250 KB canonical). Dominated
27
+ * by `JSON.stringify` + Web Crypto SHA-256; the key-sort pass inside
28
+ * `stableStringify` is a single depth-first walk.
29
+ */
30
+
31
+ const textEncoder = new TextEncoder();
32
+ const NORMALIZED_SENTINEL = "__hash_normalized__";
33
+
34
+ async function sha256Hex(input: string): Promise<string> {
35
+ const digest = await crypto.subtle.digest("SHA-256", textEncoder.encode(input));
36
+ const bytes = new Uint8Array(digest);
37
+ let hex = "";
38
+ for (let i = 0; i < bytes.length; i++) {
39
+ hex += bytes[i]!.toString(16).padStart(2, "0");
40
+ }
41
+ return hex;
42
+ }
43
+
44
+ /**
45
+ * Produces a copy of `doc` with session-birth metadata replaced by fixed
46
+ * sentinel values. Keeps the hash stable across two sequential
47
+ * `prerenderDocument` calls on identical bytes (both calls set
48
+ * `createdAt`/`updatedAt` from `Date.now()` so would otherwise diverge).
49
+ */
50
+ function normalizeForHashing(doc: CanonicalDocument): CanonicalDocument {
51
+ return {
52
+ ...doc,
53
+ docId: NORMALIZED_SENTINEL as CanonicalDocument["docId"],
54
+ createdAt: NORMALIZED_SENTINEL as CanonicalDocument["createdAt"],
55
+ updatedAt: NORMALIZED_SENTINEL as CanonicalDocument["updatedAt"],
56
+ };
57
+ }
58
+
59
+ export async function computeCanonicalDocumentHash(
60
+ doc: CanonicalDocument,
61
+ ): Promise<string> {
62
+ return sha256Hex(stableStringify(normalizeForHashing(doc)));
63
+ }