@beyondwork/docx-react-component 1.0.37 → 1.0.39

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 (116) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +496 -1
  3. package/src/core/commands/section-layout-commands.ts +58 -0
  4. package/src/core/commands/table-grid.ts +431 -0
  5. package/src/core/commands/table-structure-commands.ts +845 -56
  6. package/src/core/commands/text-commands.ts +122 -2
  7. package/src/io/docx-session.ts +1 -0
  8. package/src/io/export/serialize-main-document.ts +2 -11
  9. package/src/io/export/serialize-numbering.ts +43 -10
  10. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  11. package/src/io/export/serialize-run-formatting.ts +90 -0
  12. package/src/io/export/serialize-styles.ts +212 -0
  13. package/src/io/export/serialize-tables.ts +74 -0
  14. package/src/io/export/table-properties-xml.ts +139 -4
  15. package/src/io/normalize/normalize-text.ts +15 -0
  16. package/src/io/ooxml/parse-fields.ts +10 -3
  17. package/src/io/ooxml/parse-footnotes.ts +60 -0
  18. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  19. package/src/io/ooxml/parse-main-document.ts +137 -0
  20. package/src/io/ooxml/parse-numbering.ts +41 -1
  21. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  22. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  23. package/src/io/ooxml/parse-styles.ts +31 -0
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  26. package/src/io/ooxml/xml-element.ts +19 -0
  27. package/src/model/canonical-document.ts +117 -3
  28. package/src/runtime/collab/event-types.ts +165 -0
  29. package/src/runtime/collab/index.ts +22 -0
  30. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  31. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  32. package/src/runtime/document-layout.ts +4 -2
  33. package/src/runtime/document-navigation.ts +1 -1
  34. package/src/runtime/document-runtime.ts +248 -18
  35. package/src/runtime/layout/default-page-format.ts +96 -0
  36. package/src/runtime/layout/index.ts +47 -0
  37. package/src/runtime/layout/inert-layout-facet.ts +16 -0
  38. package/src/runtime/layout/layout-engine-instance.ts +100 -23
  39. package/src/runtime/layout/layout-invalidation.ts +14 -5
  40. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-graph.ts +55 -0
  43. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  44. package/src/runtime/layout/paginated-layout-engine.ts +484 -37
  45. package/src/runtime/layout/project-block-fragments.ts +225 -0
  46. package/src/runtime/layout/public-facet.ts +748 -16
  47. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  48. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  49. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  50. package/src/runtime/layout/table-render-plan.ts +249 -0
  51. package/src/runtime/numbering-prefix.ts +5 -0
  52. package/src/runtime/paragraph-style-resolver.ts +194 -0
  53. package/src/runtime/render/block-fragment-projection.ts +35 -0
  54. package/src/runtime/render/decoration-resolver.ts +189 -0
  55. package/src/runtime/render/index.ts +57 -0
  56. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  57. package/src/runtime/render/render-frame-types.ts +317 -0
  58. package/src/runtime/render/render-kernel.ts +759 -0
  59. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  60. package/src/runtime/surface-projection.ts +129 -9
  61. package/src/runtime/table-schema.ts +11 -0
  62. package/src/runtime/view-state.ts +67 -0
  63. package/src/runtime/workflow-markup.ts +1 -5
  64. package/src/runtime/workflow-rail-segments.ts +280 -0
  65. package/src/ui/WordReviewEditor.tsx +368 -19
  66. package/src/ui/editor-command-bag.ts +4 -0
  67. package/src/ui/editor-runtime-boundary.ts +16 -0
  68. package/src/ui/editor-shell-view.tsx +10 -0
  69. package/src/ui/editor-surface-controller.tsx +9 -1
  70. package/src/ui/headless/chrome-registry.ts +310 -15
  71. package/src/ui/headless/scoped-chrome-policy.ts +49 -1
  72. package/src/ui/headless/selection-tool-types.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  74. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  75. package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
  76. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  77. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
  78. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
  79. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  80. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  81. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  82. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  83. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
  84. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  85. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
  86. package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
  88. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  89. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  90. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  91. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  92. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  93. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
  94. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  95. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  96. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  97. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  98. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  99. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  100. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
  101. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  102. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  103. package/src/ui-tailwind/index.ts +29 -0
  104. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  105. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  106. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  107. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  108. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  109. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  110. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
  111. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  112. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  113. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
  114. package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
  115. package/src/runtime/collab-review-sync.ts +0 -254
  116. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -82,6 +82,7 @@ import {
82
82
  persistedSnapshotFromEditorSessionState,
83
83
  } from "../api/session-state.ts";
84
84
  import {
85
+ type CommandExecutionContext,
85
86
  executeEditorCommand,
86
87
  selectionChanged,
87
88
  type CommandOrigin,
@@ -124,11 +125,16 @@ import {
124
125
  findPageForOffset,
125
126
  } from "./document-navigation.ts";
126
127
  import {
128
+ createDocxFontLoader,
127
129
  createLayoutEngine,
128
130
  createLayoutFacet,
131
+ createMeasurementProvider,
132
+ type DocxFontLoader,
129
133
  type LayoutEngineInstance,
134
+ type LayoutMeasurementProvider,
130
135
  type WordReviewEditorLayoutFacet,
131
136
  } from "./layout/index.ts";
137
+ import { createRenderKernel, type RenderKernel } from "./render/index.ts";
132
138
  import {
133
139
  createDocumentOutlineSnapshot,
134
140
  createDocumentSectionSnapshots,
@@ -174,6 +180,8 @@ import {
174
180
  setCaretAffinity as applyCaretAffinity,
175
181
  setActivePageRegion as applyActivePageRegion,
176
182
  setActiveObjectFrame as applyActiveObjectFrame,
183
+ setEditorRole as applyEditorRole,
184
+ setChromePin as applyChromePins,
177
185
  createEditorViewStateSnapshot,
178
186
  type ViewState,
179
187
  } from "./view-state.ts";
@@ -224,6 +232,24 @@ export interface DocumentRuntime {
224
232
  replaceText(text: string, target?: EditorAnchorProjection): void;
225
233
  applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
226
234
  dispatch(command: EditorCommand): void;
235
+ /**
236
+ * Apply a command received from a remote collaborator. The command
237
+ * executes through `executeEditorCommand` exactly like a local dispatch,
238
+ * but:
239
+ * - `onCommandApplied` is NOT fired (no echo back to the network)
240
+ * - the local undo/redo history is NOT mutated (remote edits are
241
+ * not undoable by the local user)
242
+ * - local workflow/blocked-command checks are bypassed (the remote
243
+ * already performed them)
244
+ *
245
+ * Used by runtime-level collaboration sync to replay `CommandEvent`s
246
+ * from the shared event log.
247
+ */
248
+ applyRemoteCommand(
249
+ command: EditorCommand,
250
+ context: CommandExecutionContext,
251
+ meta?: Partial<CommandAppliedMeta>,
252
+ ): void;
227
253
  emitBlockedCommand(command: string, reasons: WorkflowBlockedCommandReason[]): void;
228
254
  undo(): void;
229
255
  redo(): void;
@@ -250,6 +276,8 @@ export interface DocumentRuntime {
250
276
  getProtectionSnapshot(): ProtectionSnapshot;
251
277
  setWorkspaceMode(mode: WorkspaceMode): void;
252
278
  setZoom(level: ZoomLevel): void;
279
+ setEditorRole(role: import("./view-state.ts").ViewState["editorRole"]): void;
280
+ setChromePin(surface: import("../api/public-types.ts").ChromePinSurface, pin: import("../api/public-types.ts").PinState | null): void;
253
281
  getPageLayoutSnapshot(): PageLayoutSnapshot | null;
254
282
  getDocumentNavigationSnapshot(): DocumentNavigationSnapshot;
255
283
  /**
@@ -314,6 +342,11 @@ export interface DocumentRuntime {
314
342
  ): RuntimeContextAnalyticsSnapshot | null;
315
343
  }
316
344
 
345
+ export interface CommandAppliedMeta {
346
+ preSelection: import("../core/state/editor-state.ts").SelectionSnapshot;
347
+ activeStory: EditorStoryTarget;
348
+ }
349
+
317
350
  export interface CreateDocumentRuntimeOptions {
318
351
  documentId: string;
319
352
  initialSessionState?: EditorSessionState;
@@ -333,6 +366,23 @@ export interface CreateDocumentRuntimeOptions {
333
366
  onEvent?: (event: DocumentRuntimeEvent) => void;
334
367
  onWarning?: (warning: EditorWarning) => void;
335
368
  onError?: (error: EditorError) => void;
369
+ /**
370
+ * Fired AFTER a command has been applied locally via `dispatch()` or
371
+ * `applyActiveStoryTextCommand()`. Used by collaboration sync to
372
+ * broadcast the command to remote clients. NOT fired for remote commands
373
+ * applied via `applyRemoteCommand()` — this prevents echo loops.
374
+ *
375
+ * Not fired for:
376
+ * - `history.undo` / `history.redo` (applied via `applyHistory()` which
377
+ * does not pass through `commit()`'s new hook call site)
378
+ * - Remote replays applied via `applyRemoteCommand()`
379
+ */
380
+ onCommandApplied?: (
381
+ command: EditorCommand,
382
+ transaction: EditorTransaction,
383
+ context: CommandExecutionContext,
384
+ meta: CommandAppliedMeta,
385
+ ) => void;
336
386
  initialViewState?: Partial<ViewState>;
337
387
  protectionSnapshot?: ProtectionSnapshot;
338
388
  }
@@ -405,7 +455,20 @@ export function createDocumentRuntime(
405
455
  // The engine caches graph + resolved-formatting + fragment mapper keyed on
406
456
  // (content, styles, subParts). It is the single internal source of truth
407
457
  // for page composition, story resolution, and layout invalidation.
458
+ //
459
+ // R0 measurement wiring: the engine starts with the sync empirical backend
460
+ // so the runtime is available immediately, then we kick off
461
+ // `createMeasurementProvider({ preference: "auto", fontLoader })` which
462
+ // upgrades to the canvas backend once fonts resolve. `swapMeasurementProvider`
463
+ // emits `measurement_backend_ready` so chrome consumers can re-read metrics.
408
464
  const layoutEngine: LayoutEngineInstance = createLayoutEngine();
465
+ const fontLoader: DocxFontLoader = createDocxFontLoader(
466
+ collectFontLoaderInput(state.document),
467
+ );
468
+ void upgradeMeasurementProvider(layoutEngine, fontLoader);
469
+ // `renderKernelRef` is a forward reference so the facet can reach the
470
+ // kernel after it is created below (kernel creation needs the facet).
471
+ let renderKernelRef: RenderKernel | null = null;
409
472
  const layoutFacet: WordReviewEditorLayoutFacet = createLayoutFacet({
410
473
  engine: layoutEngine,
411
474
  getQueryInput: () => ({
@@ -416,6 +479,27 @@ export function createDocumentRuntime(
416
479
  zoomLevel: viewState.zoomLevel,
417
480
  },
418
481
  }),
482
+ renderKernel: () => renderKernelRef,
483
+ getWorkflowRailInput: () => {
484
+ if (!workflowOverlay) return null;
485
+ const activeWorkItemId = workflowOverlay.activeWorkItemId ?? null;
486
+ const activeWorkItem =
487
+ activeWorkItemId !== null
488
+ ? workflowOverlay.workItems?.find(
489
+ (item) => item.workItemId === activeWorkItemId,
490
+ )
491
+ : undefined;
492
+ return {
493
+ scopes: workflowOverlay.scopes,
494
+ candidates: workflowOverlay.candidates,
495
+ activeWorkItemScopeIds: activeWorkItem?.scopeIds ?? [],
496
+ activeStory,
497
+ };
498
+ },
499
+ });
500
+ renderKernelRef = createRenderKernel({
501
+ facet: layoutFacet,
502
+ getActiveStory: () => activeStory,
419
503
  });
420
504
  let cachedSurface:
421
505
  | {
@@ -1572,12 +1656,40 @@ export function createDocumentRuntime(
1572
1656
  return;
1573
1657
  }
1574
1658
  try {
1575
- const transaction = executeEditorCommand(state, command, {
1576
- timestamp: command.origin?.timestamp ?? clock(),
1659
+ const context = {
1660
+ timestamp: normalizeCommandTimestamp(command.origin?.timestamp) ?? clock(),
1577
1661
  documentMode: getEffectiveDocumentMode(commandSelection),
1578
1662
  defaultAuthorId: defaultAuthorId ?? undefined,
1579
- });
1663
+ } as const;
1664
+ const preSelection = commandSelection;
1665
+ const preActiveStory = activeStory;
1666
+ const transaction = executeEditorCommand(state, command, context);
1580
1667
  commit(transaction);
1668
+ options.onCommandApplied?.(command, transaction, context, {
1669
+ preSelection,
1670
+ activeStory: preActiveStory,
1671
+ });
1672
+ } catch (error) {
1673
+ emitError(toRuntimeError(error));
1674
+ }
1675
+ },
1676
+ applyRemoteCommand(command, context, meta) {
1677
+ try {
1678
+ if (command.type === "history.undo" || command.type === "history.redo") {
1679
+ return;
1680
+ }
1681
+ if (meta?.activeStory && !storyTargetsEqual(meta.activeStory, activeStory)) {
1682
+ activeStory = meta.activeStory;
1683
+ storySelections.set(
1684
+ storyTargetKey(activeStory),
1685
+ meta.preSelection ?? state.selection,
1686
+ );
1687
+ }
1688
+ const replayState = meta?.preSelection
1689
+ ? { ...state, selection: meta.preSelection }
1690
+ : state;
1691
+ const transaction = executeEditorCommand(replayState, command, context);
1692
+ commitRemote(transaction);
1581
1693
  } catch (error) {
1582
1694
  emitError(toRuntimeError(error));
1583
1695
  }
@@ -1851,6 +1963,20 @@ export function createDocumentRuntime(
1851
1963
  listener();
1852
1964
  }
1853
1965
  },
1966
+ setEditorRole(role) {
1967
+ viewState = applyEditorRole(viewState, role);
1968
+ cachedRenderSnapshot = refreshRenderSnapshot();
1969
+ for (const listener of listeners) {
1970
+ listener();
1971
+ }
1972
+ },
1973
+ setChromePin(surface, pin) {
1974
+ viewState = applyChromePins(viewState, surface, pin);
1975
+ cachedRenderSnapshot = refreshRenderSnapshot();
1976
+ for (const listener of listeners) {
1977
+ listener();
1978
+ }
1979
+ },
1854
1980
  getPageLayoutSnapshot() {
1855
1981
  return getCachedPageLayoutSnapshot(state, activeStory);
1856
1982
  },
@@ -2307,13 +2433,21 @@ export function createDocumentRuntime(
2307
2433
  }
2308
2434
 
2309
2435
  function commit(transaction: EditorTransaction): void {
2310
- const previous = state;
2311
-
2312
2436
  if (transaction.historyBoundary === "push") {
2313
2437
  history.past.push(state);
2314
2438
  history.future = [];
2315
2439
  }
2316
2440
 
2441
+ applyTransactionToState(transaction);
2442
+ }
2443
+
2444
+ function commitRemote(transaction: EditorTransaction): void {
2445
+ applyTransactionToState(transaction);
2446
+ }
2447
+
2448
+ function applyTransactionToState(transaction: EditorTransaction): void {
2449
+ const previous = state;
2450
+
2317
2451
  protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
2318
2452
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
2319
2453
  storySelections.set(storyTargetKey(activeStory), state.selection);
@@ -2336,6 +2470,14 @@ export function createDocumentRuntime(
2336
2470
  }
2337
2471
  }
2338
2472
 
2473
+ // Font-loader refresh on subParts identity change — this is the
2474
+ // lightweight proxy for "a change that could affect which fonts the
2475
+ // canvas backend measures against". Typing edits don't rebuild
2476
+ // subParts; style + font + numbering imports do.
2477
+ if (previous.document.subParts !== state.document.subParts) {
2478
+ fontLoader.refresh(collectFontLoaderInput(state.document));
2479
+ }
2480
+
2339
2481
  cachedRenderSnapshot = refreshRenderSnapshot();
2340
2482
  notify(previous, state, transaction);
2341
2483
  }
@@ -2515,13 +2657,13 @@ export function createDocumentRuntime(
2515
2657
 
2516
2658
  function applyTextCommandInActiveStory(
2517
2659
  command: ActiveStoryTextCommand,
2518
- options: {
2660
+ textOptions: {
2519
2661
  selection?: EditorState["selection"];
2520
2662
  blockedCommandName?: string;
2521
2663
  } = {},
2522
2664
  ): TextCommandAck {
2523
2665
  const opId = (command.origin as { opId?: string } | undefined)?.opId;
2524
- const selection = options.selection ?? state.selection;
2666
+ const selection = textOptions.selection ?? state.selection;
2525
2667
  if (
2526
2668
  activeStory.kind !== "main" &&
2527
2669
  getEffectiveDocumentMode(selection) === "suggesting" &&
@@ -2531,7 +2673,7 @@ export function createDocumentRuntime(
2531
2673
  emit({
2532
2674
  type: "command_blocked",
2533
2675
  documentId: state.documentId,
2534
- command: options.blockedCommandName ?? command.type,
2676
+ command: textOptions.blockedCommandName ?? command.type,
2535
2677
  reasons: [{
2536
2678
  code: "suggesting_unsupported",
2537
2679
  message,
@@ -2550,7 +2692,7 @@ export function createDocumentRuntime(
2550
2692
  emit({
2551
2693
  type: "command_blocked",
2552
2694
  documentId: state.documentId,
2553
- command: options.blockedCommandName ?? command.type,
2695
+ command: textOptions.blockedCommandName ?? command.type,
2554
2696
  reasons: blockedReasons,
2555
2697
  });
2556
2698
  return {
@@ -2561,7 +2703,7 @@ export function createDocumentRuntime(
2561
2703
  };
2562
2704
  }
2563
2705
 
2564
- const timestamp = command.origin?.timestamp ?? clock();
2706
+ const timestamp = normalizeCommandTimestamp(command.origin?.timestamp) ?? clock();
2565
2707
  const context = {
2566
2708
  timestamp,
2567
2709
  documentMode: getEffectiveDocumentMode(selection),
@@ -2574,9 +2716,15 @@ export function createDocumentRuntime(
2574
2716
  selection,
2575
2717
  };
2576
2718
 
2719
+ const preSelection = selection;
2720
+ const preActiveStory = activeStory;
2577
2721
  if (activeStory.kind === "main") {
2578
2722
  const mainTransaction = executeEditorCommand(baseState, command, context);
2579
2723
  commit(mainTransaction);
2724
+ options.onCommandApplied?.(command, mainTransaction, context, {
2725
+ preSelection,
2726
+ activeStory: preActiveStory,
2727
+ });
2580
2728
  return classifyAck({
2581
2729
  command,
2582
2730
  opId,
@@ -2635,16 +2783,17 @@ export function createDocumentRuntime(
2635
2783
  activeStory,
2636
2784
  ),
2637
2785
  };
2786
+ const broadcastCommand: EditorCommand = {
2787
+ type: "document.replace",
2788
+ document: nextDocumentWithReview,
2789
+ selection: localTransaction.nextState.selection,
2790
+ mapping: createEmptyMapping(),
2791
+ protectionSelection: selection,
2792
+ origin: command.origin,
2793
+ };
2638
2794
  const fullTransaction = executeEditorCommand(
2639
2795
  baseState,
2640
- {
2641
- type: "document.replace",
2642
- document: nextDocumentWithReview,
2643
- selection: localTransaction.nextState.selection,
2644
- mapping: createEmptyMapping(),
2645
- protectionSelection: selection,
2646
- origin: command.origin,
2647
- },
2796
+ broadcastCommand,
2648
2797
  context,
2649
2798
  );
2650
2799
 
@@ -2653,6 +2802,10 @@ export function createDocumentRuntime(
2653
2802
  effects: mergeTransactionEffects(fullTransaction.effects, localTransaction.effects),
2654
2803
  };
2655
2804
  commit(mergedTransaction);
2805
+ options.onCommandApplied?.(broadcastCommand, mergedTransaction, context, {
2806
+ preSelection,
2807
+ activeStory: preActiveStory,
2808
+ });
2656
2809
  return classifyAck({
2657
2810
  command,
2658
2811
  opId,
@@ -2941,6 +3094,16 @@ function createEntityId(
2941
3094
  return nextId;
2942
3095
  }
2943
3096
 
3097
+ function normalizeCommandTimestamp(value: unknown): string | undefined {
3098
+ if (typeof value === "string" && value.length > 0) {
3099
+ return value;
3100
+ }
3101
+ if (typeof value === "number" && Number.isFinite(value)) {
3102
+ return new Date(value).toISOString();
3103
+ }
3104
+ return undefined;
3105
+ }
3106
+
2944
3107
  function finalizeState(
2945
3108
  state: EditorState,
2946
3109
  markDirty: boolean,
@@ -4390,3 +4553,70 @@ function remapProtectionSnapshot(
4390
4553
  preservedRangeCount: nextRanges.filter((range) => !range.enforced).length,
4391
4554
  };
4392
4555
  }
4556
+
4557
+ // ---------------------------------------------------------------------------
4558
+ // Measurement provider wiring (R0)
4559
+ // ---------------------------------------------------------------------------
4560
+
4561
+ /**
4562
+ * Build the initial input the `DocxFontLoader` needs: a list of font
4563
+ * families the document actively uses, plus any embedded font payloads the
4564
+ * import pipeline may have extracted.
4565
+ *
4566
+ * Walks the document content tree once per call. Embedded font extraction
4567
+ * is not yet wired into the canonical model; we pass an empty map today and
4568
+ * let the loader register system fonts it finds via
4569
+ * `document.fonts.check(...)`.
4570
+ */
4571
+ function collectFontLoaderInput(
4572
+ document: CanonicalDocumentEnvelope,
4573
+ ): { families: readonly string[] } {
4574
+ try {
4575
+ const families = new Set<string>();
4576
+ const visit = (node: unknown): void => {
4577
+ if (!node || typeof node !== "object") return;
4578
+ const record = node as Record<string, unknown>;
4579
+ const rpr = record["runProperties"] as
4580
+ | Record<string, unknown>
4581
+ | undefined;
4582
+ if (rpr && typeof rpr["fontFamily"] === "string") {
4583
+ families.add(rpr["fontFamily"] as string);
4584
+ }
4585
+ for (const value of Object.values(record)) {
4586
+ if (Array.isArray(value)) value.forEach(visit);
4587
+ else if (value && typeof value === "object") visit(value);
4588
+ }
4589
+ };
4590
+ visit(document.content);
4591
+ if (document.styles) {
4592
+ visit(document.styles);
4593
+ }
4594
+ return { families: Array.from(families) };
4595
+ } catch {
4596
+ return { families: [] };
4597
+ }
4598
+ }
4599
+
4600
+ /**
4601
+ * Asynchronously upgrade the engine's measurement backend to canvas once
4602
+ * the platform supports it and fonts have resolved. Errors are swallowed
4603
+ * so a failure in the upgrade path can never break the empirical baseline.
4604
+ */
4605
+ async function upgradeMeasurementProvider(
4606
+ engine: LayoutEngineInstance,
4607
+ fontLoader: DocxFontLoader,
4608
+ ): Promise<void> {
4609
+ try {
4610
+ const provider: LayoutMeasurementProvider = await createMeasurementProvider({
4611
+ preference: "auto",
4612
+ fontLoader,
4613
+ });
4614
+ // If the host is running in SSR or a jsdom test shell, the factory will
4615
+ // fall back to the empirical backend. In that case swapping is a no-op
4616
+ // but still emits `measurement_backend_ready` with `empirical` which is
4617
+ // informational; chrome consumers use the event to refresh metrics.
4618
+ engine.swapMeasurementProvider(provider);
4619
+ } catch {
4620
+ // fall through — the empirical backend remains in place
4621
+ }
4622
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Locale-aware default page format.
3
+ *
4
+ * Word historically defaults to US Letter for `en-US` hosts and A4 everywhere
5
+ * else. This module is the single place that decides which format a
6
+ * newly-created document uses when no section carries an explicit `w:pgSz`.
7
+ *
8
+ * It never overrides an existing section's page size on import — importers
9
+ * always preserve what the source document specified. The default is
10
+ * consulted in two places:
11
+ *
12
+ * 1. `serialize-main-document.ts` when the canonical model carries a
13
+ * section with no `pageSize` (e.g. programmatic document construction).
14
+ * 2. `DocumentRuntime` when rendering a brand-new blank document.
15
+ */
16
+
17
+ import {
18
+ getPageFormatById,
19
+ type PageFormatDefinition,
20
+ } from "./page-format-catalog.ts";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Locale resolution
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Which BCP-47 language tags should fall back to Letter.
28
+ *
29
+ * Anything else defaults to A4. This intentionally uses a small whitelist
30
+ * rather than a `startsWith("en")` check — `en-GB`, `en-AU`, `en-IN` all
31
+ * expect ISO paper sizes, not US Letter.
32
+ */
33
+ const LETTER_LOCALES = new Set<string>([
34
+ "en-us",
35
+ "en-ca",
36
+ "fr-ca",
37
+ "es-mx",
38
+ "es-cl",
39
+ "fil-ph",
40
+ ]);
41
+
42
+ export interface ResolveDefaultPageFormatOptions {
43
+ /** Explicit BCP-47 locale (e.g. "en-US", "de-DE"). */
44
+ locale?: string;
45
+ }
46
+
47
+ /**
48
+ * Return the default `PageFormatDefinition` for a given locale.
49
+ *
50
+ * When `locale` is omitted the function consults `Intl.DateTimeFormat` to
51
+ * infer the current locale. When `Intl` is unavailable (unusual in modern
52
+ * runtimes) it falls back to A4 as the safer international default.
53
+ */
54
+ export function resolveDefaultPageFormat(
55
+ options: ResolveDefaultPageFormatOptions = {},
56
+ ): PageFormatDefinition {
57
+ const raw = options.locale ?? tryResolveHostLocale();
58
+ if (!raw) {
59
+ return getPageFormatById("a4");
60
+ }
61
+ const normalized = raw.toLowerCase();
62
+ if (LETTER_LOCALES.has(normalized)) {
63
+ return getPageFormatById("letter");
64
+ }
65
+ // Match just the language-region prefix (e.g. "en-us-1234" → "en-us")
66
+ const region = normalized.split("-").slice(0, 2).join("-");
67
+ if (LETTER_LOCALES.has(region)) {
68
+ return getPageFormatById("letter");
69
+ }
70
+ return getPageFormatById("a4");
71
+ }
72
+
73
+ function tryResolveHostLocale(): string | undefined {
74
+ try {
75
+ if (typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function") {
76
+ return undefined;
77
+ }
78
+ return new Intl.DateTimeFormat().resolvedOptions().locale;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Return the default `w:pgSz` payload (width/height in twips) for a given
86
+ * locale. Convenience wrapper used by the export pipeline.
87
+ */
88
+ export function resolveDefaultPageSizeTwips(
89
+ options: ResolveDefaultPageFormatOptions = {},
90
+ ): { widthTwips: number; heightTwips: number } {
91
+ const format = resolveDefaultPageFormat(options);
92
+ return {
93
+ widthTwips: format.portraitWidthTwips,
94
+ heightTwips: format.portraitHeightTwips,
95
+ };
96
+ }
@@ -158,6 +158,8 @@ export {
158
158
  type EmbeddedFontBytes,
159
159
  } from "./docx-font-loader.ts";
160
160
 
161
+ export { createCanvasBackend } from "./measurement-backend-canvas.ts";
162
+
161
163
  // ---------------------------------------------------------------------------
162
164
  // Public facet (Phase 7)
163
165
  // ---------------------------------------------------------------------------
@@ -184,5 +186,50 @@ export {
184
186
  type PublicFieldDirtinessReport,
185
187
  type LayoutFacetEvent,
186
188
  type LayoutFacetInvalidationReason,
189
+ type RenderZoomSummary,
187
190
  type CreateLayoutFacetInput,
191
+ type PageFormatDefinition,
192
+ type ActivePageFormat,
193
+ type MarginPresetDefinition,
194
+ type ActiveMarginPreset,
188
195
  } from "./public-facet.ts";
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Page-format catalog + margin preset catalog + locale defaults (R0.5)
199
+ // ---------------------------------------------------------------------------
200
+
201
+ export {
202
+ PAGE_FORMAT_CATALOG,
203
+ matchPageFormat,
204
+ getPageFormatById,
205
+ type PageFormatId,
206
+ type PageFormatRegion,
207
+ type PageFormatLocaleDefault,
208
+ type PageFormatDisplay,
209
+ type MatchPageFormatInput,
210
+ } from "./page-format-catalog.ts";
211
+
212
+ export {
213
+ MARGIN_PRESET_CATALOG,
214
+ matchMarginPreset,
215
+ getMarginPresetById,
216
+ type MarginPresetId,
217
+ type MatchMarginPresetInput,
218
+ } from "./margin-preset-catalog.ts";
219
+
220
+ export {
221
+ resolveDefaultPageFormat,
222
+ resolveDefaultPageSizeTwips,
223
+ type ResolveDefaultPageFormatOptions,
224
+ } from "./default-page-format.ts";
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Workflow rail segments (R3a)
228
+ // ---------------------------------------------------------------------------
229
+
230
+ export {
231
+ collectScopeRailSegments,
232
+ type CollectScopeRailSegmentsInput,
233
+ type ScopeRailPosture,
234
+ type ScopeRailSegment,
235
+ } from "../workflow-rail-segments.ts";
@@ -12,6 +12,8 @@ import type {
12
12
  PublicMeasurementFidelity,
13
13
  WordReviewEditorLayoutFacet,
14
14
  } from "./public-facet.ts";
15
+ import { MARGIN_PRESET_CATALOG } from "./margin-preset-catalog.ts";
16
+ import { PAGE_FORMAT_CATALOG } from "./page-format-catalog.ts";
15
17
 
16
18
  export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
17
19
  const emptyReport: PublicFieldDirtinessReport = {
@@ -32,12 +34,26 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
32
34
  getActiveStoriesOnPage: () => null,
33
35
  getDisplayPageNumber: () => null,
34
36
  getLineBoxes: () => [],
37
+ getLineBoxesForRegion: () => [],
35
38
  getFragmentsForPage: () => [],
39
+ getPageFormatCatalog: () => PAGE_FORMAT_CATALOG,
40
+ getActivePageFormat: () => null,
41
+ getMarginPresetCatalog: () => MARGIN_PRESET_CATALOG,
42
+ getActiveMarginPreset: () => null,
43
+ getRenderFrame: () => null,
44
+ getRenderZoom: () => null,
45
+ hitTest: () => null,
46
+ getAnchorRects: () => [],
47
+ getScopeRailSegments: () => [],
48
+ getAllScopeRailSegments: () => [],
36
49
  getResolvedFormatting: () => null,
37
50
  getResolvedRunFormatting: () => null,
38
51
  getMeasurement: () => null,
39
52
  getMeasurementFidelity: () => fidelity,
40
53
  whenMeasurementReady: () => Promise.resolve(),
54
+ getFirstPageIndexForBlock: () => null,
55
+ swapMeasurementProvider: () => undefined,
56
+ getTableRenderPlan: () => null,
41
57
  getDirtyFieldFamilies: () => [],
42
58
  getFieldDirtinessReport: () => emptyReport,
43
59
  subscribe: (_listener: (event: LayoutFacetEvent) => void) => () => undefined,