@beyondwork/docx-react-component 1.0.46 → 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 (56) 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/commands/index.ts +120 -1
  7. package/src/core/schema/text-schema.ts +95 -1
  8. package/src/core/state/text-transaction.ts +17 -5
  9. package/src/io/chart-preview-resolver.ts +27 -0
  10. package/src/io/docx-session.ts +219 -2
  11. package/src/io/export/serialize-main-document.ts +37 -0
  12. package/src/io/export/serialize-settings.ts +421 -0
  13. package/src/io/export/serialize-styles.ts +10 -0
  14. package/src/io/normalize/normalize-text.ts +1 -0
  15. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  16. package/src/io/ooxml/chart/parse-chart-space.ts +813 -0
  17. package/src/io/ooxml/chart/parse-series.ts +570 -0
  18. package/src/io/ooxml/chart/resolve-color.ts +251 -0
  19. package/src/io/ooxml/chart/types.ts +420 -0
  20. package/src/io/ooxml/parse-block-structure.ts +99 -0
  21. package/src/io/ooxml/parse-complex-content.ts +87 -2
  22. package/src/io/ooxml/parse-main-document.ts +115 -1
  23. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  24. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  25. package/src/io/ooxml/parse-settings.ts +97 -1
  26. package/src/io/ooxml/parse-styles.ts +65 -0
  27. package/src/io/ooxml/parse-theme.ts +2 -127
  28. package/src/io/ooxml/workflow-payload.ts +27 -0
  29. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  30. package/src/io/ooxml/xml-parser.ts +142 -0
  31. package/src/model/canonical-document.ts +94 -0
  32. package/src/model/scope-markers.ts +144 -0
  33. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  34. package/src/runtime/collab/checkpoint-election.ts +75 -0
  35. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  36. package/src/runtime/collab/checkpoint-store.ts +115 -0
  37. package/src/runtime/collab/event-types.ts +37 -5
  38. package/src/runtime/collab/index.ts +22 -0
  39. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  40. package/src/runtime/collab/runtime-collab-sync.ts +404 -1
  41. package/src/runtime/document-runtime.ts +221 -16
  42. package/src/runtime/editor-surface/capabilities.ts +63 -50
  43. package/src/runtime/layout/layout-engine-version.ts +27 -2
  44. package/src/runtime/prerender/cache-envelope.ts +19 -7
  45. package/src/runtime/prerender/cache-key.ts +25 -14
  46. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  47. package/src/runtime/prerender/customxml-cache.ts +211 -0
  48. package/src/runtime/prerender/customxml-probe.ts +78 -0
  49. package/src/runtime/prerender/prerender-document.ts +74 -7
  50. package/src/runtime/scope-resolver.ts +148 -0
  51. package/src/runtime/scope-tag-registry.ts +10 -0
  52. package/src/runtime/surface-projection.ts +8 -1
  53. package/src/runtime/text-ack-range.ts +3 -3
  54. package/src/ui/WordReviewEditor.tsx +30 -0
  55. package/src/ui/editor-runtime-boundary.ts +6 -1
  56. 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;
@@ -406,6 +417,7 @@ export interface DocumentRuntime {
406
417
  export interface CommandAppliedMeta {
407
418
  preSelection: import("../core/state/editor-state.ts").SelectionSnapshot;
408
419
  activeStory: EditorStoryTarget;
420
+ priorDocument: CanonicalDocumentEnvelope;
409
421
  }
410
422
 
411
423
  export interface CreateDocumentRuntimeOptions {
@@ -2034,6 +2046,16 @@ export function createDocumentRuntime(
2034
2046
  }
2035
2047
  });
2036
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
+
2037
2059
  return {
2038
2060
  subscribe(listener) {
2039
2061
  listeners.add(listener);
@@ -2111,6 +2133,7 @@ export function createDocumentRuntime(
2111
2133
  options.onCommandApplied?.(command, noopTransaction, context, {
2112
2134
  preSelection: state.selection,
2113
2135
  activeStory,
2136
+ priorDocument: state.document,
2114
2137
  });
2115
2138
  return;
2116
2139
  }
@@ -2124,11 +2147,13 @@ export function createDocumentRuntime(
2124
2147
  } as const;
2125
2148
  const preSelection = commandSelection;
2126
2149
  const preActiveStory = activeStory;
2150
+ const priorDocument = state.document;
2127
2151
  const transaction = executeEditorCommand(state, command, context);
2128
2152
  commit(transaction);
2129
2153
  options.onCommandApplied?.(command, transaction, context, {
2130
2154
  preSelection,
2131
2155
  activeStory: preActiveStory,
2156
+ priorDocument,
2132
2157
  });
2133
2158
  } catch (error) {
2134
2159
  emitError(toRuntimeError(error));
@@ -2143,28 +2168,56 @@ export function createDocumentRuntime(
2143
2168
  applyRuntimeStateOverlayCommand(command);
2144
2169
  return;
2145
2170
  }
2146
- if (meta?.activeStory && !storyTargetsEqual(meta.activeStory, activeStory)) {
2147
- activeStory = meta.activeStory;
2148
- storySelections.set(
2149
- storyTargetKey(activeStory),
2150
- meta.preSelection ?? state.selection,
2151
- );
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;
2152
2196
  }
2153
- const replayState = meta?.preSelection
2154
- ? { ...state, selection: meta.preSelection }
2155
- : state;
2156
- const replaySnapshot = meta?.preSelection
2157
- ? {
2158
- ...cachedRenderSnapshot,
2159
- selection: toPublicSelectionSnapshot(meta.preSelection, activeStory),
2160
- }
2161
- : cachedRenderSnapshot;
2162
2197
  const replayContext = {
2163
2198
  ...context,
2164
2199
  renderSnapshot: replaySnapshot,
2165
2200
  };
2166
2201
  const transaction = executeEditorCommand(replayState, command, replayContext);
2167
- 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
+ }
2168
2221
  } catch (error) {
2169
2222
  emitError(toRuntimeError(error));
2170
2223
  }
@@ -2358,6 +2411,155 @@ export function createDocumentRuntime(
2358
2411
  origin: createOrigin("api", clock()),
2359
2412
  });
2360
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
+ },
2361
2563
  acceptChange(changeId) {
2362
2564
  this.dispatch({
2363
2565
  type: "change.accept",
@@ -3283,12 +3485,14 @@ export function createDocumentRuntime(
3283
3485
 
3284
3486
  const preSelection = selection;
3285
3487
  const preActiveStory = activeStory;
3488
+ const priorDocument = state.document;
3286
3489
  if (activeStory.kind === "main") {
3287
3490
  const mainTransaction = executeEditorCommand(baseState, command, context);
3288
3491
  commit(mainTransaction);
3289
3492
  options.onCommandApplied?.(command, mainTransaction, context, {
3290
3493
  preSelection,
3291
3494
  activeStory: preActiveStory,
3495
+ priorDocument,
3292
3496
  });
3293
3497
  return classifyAck({
3294
3498
  command,
@@ -3370,6 +3574,7 @@ export function createDocumentRuntime(
3370
3574
  options.onCommandApplied?.(broadcastCommand, mergedTransaction, context, {
3371
3575
  preSelection,
3372
3576
  activeStory: preActiveStory,
3577
+ priorDocument,
3373
3578
  });
3374
3579
  return classifyAck({
3375
3580
  command,
@@ -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
  /**
@@ -24,8 +24,26 @@
24
24
  * internal cachedGraph + cachedKey without triggering fullRebuild.
25
25
  * Does not change geometry — but the public interface changed, so
26
26
  * persisted envelopes MUST re-derive their cacheKey under 3.
27
+ * 4 — PR #188 `fix(export)` bumped the version to satisfy the
28
+ * `src/runtime/layout/**` gate, even though the font-loader
29
+ * type-def restoration intended for that PR did not survive the
30
+ * squash-merge. Runtime and cached geometry unchanged.
31
+ * 5 — PR #187 `joakim/commentevents` restores the local `Minimal*`
32
+ * font-loader type defs under `src/runtime/layout/docx-font-loader.ts`
33
+ * (re-application of previously-shipped PRs #162/#163 which a
34
+ * subsequent merge reverted) so downstream consumers whose
35
+ * TS `lib` does not expose `FontFaceSet.add` can type-check the
36
+ * package, and adds the `comments_changed` event plumbing in
37
+ * runtime domains outside `src/runtime/layout/**`. TypeScript-
38
+ * surface-only from the layout engine's perspective: no cached
39
+ * geometry or cache-key derivation changes. The bump exists
40
+ * solely to satisfy the
41
+ * `scripts/ci-check-layout-engine-version.mjs` gate because a
42
+ * file under `src/runtime/layout/**` changed. Safe to treat
43
+ * versions 3, 4, and 5 as cache-compatible if a migration ever
44
+ * needs to collapse them.
27
45
  */
28
- export const LAYOUT_ENGINE_VERSION = 3 as const;
46
+ export const LAYOUT_ENGINE_VERSION = 5 as const;
29
47
 
30
48
  /**
31
49
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -37,5 +55,12 @@ export const LAYOUT_ENGINE_VERSION = 3 as const;
37
55
  * 1 — initial envelope shape: { schemaVersion, engineVersion,
38
56
  * fontFingerprint, structuralHash, graph, surface }. Ships with
39
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.
40
65
  */
41
- 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
  }