@beyondwork/docx-react-component 1.0.38 → 1.0.40

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 (85) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +305 -6
  3. package/src/core/commands/table-structure-commands.ts +31 -2
  4. package/src/core/commands/text-commands.ts +122 -2
  5. package/src/index.ts +9 -0
  6. package/src/io/docx-session.ts +1 -0
  7. package/src/io/export/serialize-numbering.ts +42 -8
  8. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  9. package/src/io/export/serialize-run-formatting.ts +90 -0
  10. package/src/io/export/serialize-styles.ts +212 -0
  11. package/src/io/ooxml/parse-fields.ts +10 -3
  12. package/src/io/ooxml/parse-numbering.ts +41 -1
  13. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  14. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  15. package/src/io/ooxml/parse-styles.ts +31 -0
  16. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  17. package/src/io/ooxml/xml-element.ts +19 -0
  18. package/src/model/canonical-document.ts +83 -3
  19. package/src/runtime/collab/event-types.ts +165 -0
  20. package/src/runtime/collab/index.ts +22 -0
  21. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  22. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  23. package/src/runtime/document-runtime.ts +141 -18
  24. package/src/runtime/layout/docx-font-loader.ts +30 -11
  25. package/src/runtime/layout/index.ts +2 -0
  26. package/src/runtime/layout/inert-layout-facet.ts +3 -0
  27. package/src/runtime/layout/layout-engine-instance.ts +69 -2
  28. package/src/runtime/layout/layout-invalidation.ts +14 -5
  29. package/src/runtime/layout/page-graph.ts +36 -0
  30. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  31. package/src/runtime/layout/paginated-layout-engine.ts +342 -28
  32. package/src/runtime/layout/project-block-fragments.ts +154 -20
  33. package/src/runtime/layout/public-facet.ts +81 -1
  34. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  35. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  36. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  37. package/src/runtime/layout/table-render-plan.ts +21 -1
  38. package/src/runtime/numbering-prefix.ts +5 -0
  39. package/src/runtime/paragraph-style-resolver.ts +194 -0
  40. package/src/runtime/render/render-kernel.ts +5 -1
  41. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  42. package/src/runtime/surface-projection.ts +129 -9
  43. package/src/runtime/table-schema.ts +11 -0
  44. package/src/runtime/workflow-rail-segments.ts +149 -1
  45. package/src/ui/WordReviewEditor.tsx +302 -5
  46. package/src/ui/editor-command-bag.ts +4 -0
  47. package/src/ui/editor-runtime-boundary.ts +16 -0
  48. package/src/ui/editor-shell-view.tsx +22 -0
  49. package/src/ui/editor-surface-controller.tsx +9 -1
  50. package/src/ui/headless/chrome-registry.ts +34 -5
  51. package/src/ui/headless/scoped-chrome-policy.ts +29 -0
  52. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  53. package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
  54. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +80 -0
  55. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
  56. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
  57. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  58. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  59. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
  60. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +353 -0
  61. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
  62. package/src/ui-tailwind/chrome-overlay/index.ts +2 -6
  63. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +82 -18
  64. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +133 -0
  65. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +386 -0
  66. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +140 -69
  67. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  68. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
  69. package/src/ui-tailwind/editor-surface/pm-decorations.ts +7 -2
  70. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  71. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  72. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +170 -63
  73. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  74. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  75. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
  76. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  77. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  78. package/src/ui-tailwind/index.ts +6 -5
  79. package/src/ui-tailwind/theme/editor-theme.css +108 -15
  80. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
  81. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
  82. package/src/ui-tailwind/tw-review-workspace.tsx +207 -54
  83. package/src/runtime/collab-review-sync.ts +0 -254
  84. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
  85. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -0,0 +1,273 @@
1
+ import * as Y from "yjs";
2
+
3
+ import type {
4
+ CommandExecutionContext,
5
+ EditorCommand,
6
+ EditorTransaction,
7
+ } from "../../core/commands/index.ts";
8
+ import type {
9
+ CommandAppliedMeta,
10
+ DocumentRuntime,
11
+ Unsubscribe,
12
+ } from "../document-runtime.ts";
13
+ import {
14
+ createCommandEvent,
15
+ isBroadcastCommand,
16
+ isLocalOnlyCommand,
17
+ type CommandEvent,
18
+ } from "./event-types.ts";
19
+
20
+ export type RuntimeCommandAppliedListener = (
21
+ command: EditorCommand,
22
+ transaction: EditorTransaction,
23
+ context: CommandExecutionContext,
24
+ meta: CommandAppliedMeta,
25
+ ) => void;
26
+
27
+ export interface RuntimeCommandAppliedBridge {
28
+ onCommandApplied: RuntimeCommandAppliedListener;
29
+ subscribe(listener: RuntimeCommandAppliedListener): Unsubscribe;
30
+ }
31
+
32
+ export interface RuntimeCollabSyncOptions {
33
+ ydoc: Y.Doc;
34
+ runtime: DocumentRuntime;
35
+ authorId: string;
36
+ commandAppliedBridge: RuntimeCommandAppliedBridge;
37
+ }
38
+
39
+ export interface RuntimeCollabSyncHandle {
40
+ destroy(): void;
41
+ }
42
+
43
+ export function createRuntimeCommandAppliedBridge(): RuntimeCommandAppliedBridge {
44
+ const listeners = new Set<RuntimeCommandAppliedListener>();
45
+
46
+ return {
47
+ onCommandApplied(command, transaction, context, meta) {
48
+ for (const listener of [...listeners]) {
49
+ listener(command, transaction, context, meta);
50
+ }
51
+ },
52
+ subscribe(listener) {
53
+ listeners.add(listener);
54
+ return () => {
55
+ listeners.delete(listener);
56
+ };
57
+ },
58
+ };
59
+ }
60
+
61
+ export function createRuntimeCollabSync(
62
+ options: RuntimeCollabSyncOptions,
63
+ ): RuntimeCollabSyncHandle {
64
+ const { ydoc, runtime, authorId, commandAppliedBridge } = options;
65
+ const yEvents = ydoc.getArray<CommandEvent>("commandEvents");
66
+ const appliedEventIds = new Set<string>();
67
+
68
+ const unsubscribeCommandApplied = commandAppliedBridge.subscribe((command, _transaction, context, meta) => {
69
+ if (isLocalOnlyCommand(command)) {
70
+ return;
71
+ }
72
+ if (!isBroadcastCommand(command)) {
73
+ return;
74
+ }
75
+
76
+ const event = createCommandEvent({
77
+ command,
78
+ originClientId: ydoc.clientID,
79
+ authorId,
80
+ timestamp: context.timestamp,
81
+ context: {
82
+ documentMode: context.documentMode,
83
+ defaultAuthorId: context.defaultAuthorId,
84
+ preSelection: meta.preSelection,
85
+ activeStory: meta.activeStory,
86
+ },
87
+ });
88
+
89
+ appliedEventIds.add(event.eventId);
90
+ yEvents.push([event]);
91
+ });
92
+
93
+ yEvents.observe(onYEventsChange);
94
+
95
+ for (const value of yEvents.toArray()) {
96
+ applyStartupEvent(value);
97
+ }
98
+
99
+ return {
100
+ destroy() {
101
+ unsubscribeCommandApplied();
102
+ yEvents.unobserve(onYEventsChange);
103
+ },
104
+ };
105
+
106
+ function onYEventsChange(event: Y.YArrayEvent<CommandEvent>): void {
107
+ for (const delta of event.changes.delta) {
108
+ if (!Array.isArray(delta.insert)) {
109
+ continue;
110
+ }
111
+
112
+ for (const value of delta.insert) {
113
+ applyObservedEvent(value);
114
+ }
115
+ }
116
+ }
117
+
118
+ function applyStartupEvent(value: unknown): void {
119
+ const event = asCommandEvent(value);
120
+ if (!event) {
121
+ return;
122
+ }
123
+ if (appliedEventIds.has(event.eventId)) {
124
+ return;
125
+ }
126
+ if (!isReplayableEvent(event)) {
127
+ appliedEventIds.add(event.eventId);
128
+ return;
129
+ }
130
+
131
+ appliedEventIds.add(event.eventId);
132
+ applyEventToRuntime(event);
133
+ }
134
+
135
+ function applyObservedEvent(value: unknown): void {
136
+ const event = asCommandEvent(value);
137
+ if (!event) {
138
+ return;
139
+ }
140
+ if (appliedEventIds.has(event.eventId)) {
141
+ return;
142
+ }
143
+ if (!isReplayableEvent(event)) {
144
+ appliedEventIds.add(event.eventId);
145
+ return;
146
+ }
147
+
148
+ appliedEventIds.add(event.eventId);
149
+ if (event.originClientId === ydoc.clientID) {
150
+ return;
151
+ }
152
+ applyEventToRuntime(event);
153
+ }
154
+
155
+ function isReplayableEvent(event: CommandEvent): boolean {
156
+ if (isLocalOnlyCommand(event.command)) {
157
+ return false;
158
+ }
159
+ if (!isBroadcastCommand(event.command)) {
160
+ return false;
161
+ }
162
+ return true;
163
+ }
164
+
165
+ function applyEventToRuntime(event: CommandEvent): void {
166
+ runtime.applyRemoteCommand(
167
+ event.command,
168
+ {
169
+ timestamp: event.timestamp,
170
+ documentMode: event.context.documentMode,
171
+ defaultAuthorId: event.context.defaultAuthorId ?? event.authorId,
172
+ },
173
+ {
174
+ preSelection: event.context.preSelection,
175
+ activeStory: event.context.activeStory,
176
+ },
177
+ );
178
+ }
179
+ }
180
+
181
+ function asCommandEvent(value: unknown): CommandEvent | null {
182
+ if (!isRecord(value)) {
183
+ return null;
184
+ }
185
+ if (typeof value.eventId !== "string") {
186
+ return null;
187
+ }
188
+ if (typeof value.originClientId !== "number" || !Number.isFinite(value.originClientId)) {
189
+ return null;
190
+ }
191
+ if (typeof value.authorId !== "string") {
192
+ return null;
193
+ }
194
+ const timestamp = normalizeTimestamp(value.timestamp);
195
+ if (timestamp === null) {
196
+ return null;
197
+ }
198
+
199
+ const command = asEditorCommand(value.command);
200
+ const context = asCommandEventContext(value.context);
201
+ if (!command || !context) {
202
+ return null;
203
+ }
204
+
205
+ return {
206
+ eventId: value.eventId,
207
+ originClientId: value.originClientId,
208
+ authorId: value.authorId,
209
+ timestamp,
210
+ command,
211
+ context,
212
+ };
213
+ }
214
+
215
+ function normalizeTimestamp(value: unknown): string | null {
216
+ if (typeof value === "string" && value.length > 0) {
217
+ return value;
218
+ }
219
+ if (typeof value === "number" && Number.isFinite(value)) {
220
+ return new Date(value).toISOString();
221
+ }
222
+ return null;
223
+ }
224
+
225
+ function asEditorCommand(value: unknown): EditorCommand | null {
226
+ if (!isRecord(value)) {
227
+ return null;
228
+ }
229
+ if (typeof value.type !== "string") {
230
+ return null;
231
+ }
232
+ return value as EditorCommand;
233
+ }
234
+
235
+ function asCommandEventContext(value: unknown): CommandEvent["context"] | null {
236
+ if (!isRecord(value)) {
237
+ return null;
238
+ }
239
+
240
+ return {
241
+ documentMode: isCommandDocumentMode(value.documentMode) ? value.documentMode : undefined,
242
+ defaultAuthorId: typeof value.defaultAuthorId === "string" ? value.defaultAuthorId : undefined,
243
+ preSelection: asSelectionSnapshot(value.preSelection),
244
+ activeStory: asStoryTarget(value.activeStory),
245
+ };
246
+ }
247
+
248
+ function asSelectionSnapshot(value: unknown): CommandEvent["context"]["preSelection"] {
249
+ if (!isRecord(value)) return undefined;
250
+ if (typeof value.anchor !== "number" || typeof value.head !== "number") return undefined;
251
+ if (typeof value.isCollapsed !== "boolean") return undefined;
252
+ if (!isRecord(value.activeRange)) return undefined;
253
+ return value as unknown as CommandEvent["context"]["preSelection"];
254
+ }
255
+
256
+ function asStoryTarget(value: unknown): CommandEvent["context"]["activeStory"] {
257
+ if (!isRecord(value)) return undefined;
258
+ if (typeof value.kind !== "string") return undefined;
259
+ return value as unknown as CommandEvent["context"]["activeStory"];
260
+ }
261
+
262
+ function isCommandDocumentMode(
263
+ value: unknown,
264
+ ): value is NonNullable<CommandEvent["context"]["documentMode"]> {
265
+ return value === "editing"
266
+ || value === "suggesting"
267
+ || value === "viewing"
268
+ || value === "commenting";
269
+ }
270
+
271
+ function isRecord(value: unknown): value is Record<string, unknown> {
272
+ return typeof value === "object" && value !== null;
273
+ }
@@ -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,
@@ -179,6 +180,8 @@ import {
179
180
  setCaretAffinity as applyCaretAffinity,
180
181
  setActivePageRegion as applyActivePageRegion,
181
182
  setActiveObjectFrame as applyActiveObjectFrame,
183
+ setEditorRole as applyEditorRole,
184
+ setChromePin as applyChromePins,
182
185
  createEditorViewStateSnapshot,
183
186
  type ViewState,
184
187
  } from "./view-state.ts";
@@ -229,6 +232,24 @@ export interface DocumentRuntime {
229
232
  replaceText(text: string, target?: EditorAnchorProjection): void;
230
233
  applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
231
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;
232
253
  emitBlockedCommand(command: string, reasons: WorkflowBlockedCommandReason[]): void;
233
254
  undo(): void;
234
255
  redo(): void;
@@ -255,6 +276,8 @@ export interface DocumentRuntime {
255
276
  getProtectionSnapshot(): ProtectionSnapshot;
256
277
  setWorkspaceMode(mode: WorkspaceMode): void;
257
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;
258
281
  getPageLayoutSnapshot(): PageLayoutSnapshot | null;
259
282
  getDocumentNavigationSnapshot(): DocumentNavigationSnapshot;
260
283
  /**
@@ -319,6 +342,11 @@ export interface DocumentRuntime {
319
342
  ): RuntimeContextAnalyticsSnapshot | null;
320
343
  }
321
344
 
345
+ export interface CommandAppliedMeta {
346
+ preSelection: import("../core/state/editor-state.ts").SelectionSnapshot;
347
+ activeStory: EditorStoryTarget;
348
+ }
349
+
322
350
  export interface CreateDocumentRuntimeOptions {
323
351
  documentId: string;
324
352
  initialSessionState?: EditorSessionState;
@@ -338,6 +366,23 @@ export interface CreateDocumentRuntimeOptions {
338
366
  onEvent?: (event: DocumentRuntimeEvent) => void;
339
367
  onWarning?: (warning: EditorWarning) => void;
340
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;
341
386
  initialViewState?: Partial<ViewState>;
342
387
  protectionSnapshot?: ProtectionSnapshot;
343
388
  }
@@ -451,6 +496,13 @@ export function createDocumentRuntime(
451
496
  activeStory,
452
497
  };
453
498
  },
499
+ // R2 / scope-card-overlay P1 — surface metadata markup so
500
+ // `facet.getAllScopeCardModels()` can attach `IssueMetadataValue`
501
+ // to its scope without the chrome overlay having to re-fetch a
502
+ // separate snapshot. Reads through the same cached snapshot the
503
+ // runtime already builds for comment/revision/search consumers.
504
+ getWorkflowMarkupMetadata: () =>
505
+ getCachedWorkflowMarkupSnapshot().metadata,
454
506
  });
455
507
  renderKernelRef = createRenderKernel({
456
508
  facet: layoutFacet,
@@ -1611,12 +1663,40 @@ export function createDocumentRuntime(
1611
1663
  return;
1612
1664
  }
1613
1665
  try {
1614
- const transaction = executeEditorCommand(state, command, {
1615
- timestamp: command.origin?.timestamp ?? clock(),
1666
+ const context = {
1667
+ timestamp: normalizeCommandTimestamp(command.origin?.timestamp) ?? clock(),
1616
1668
  documentMode: getEffectiveDocumentMode(commandSelection),
1617
1669
  defaultAuthorId: defaultAuthorId ?? undefined,
1618
- });
1670
+ } as const;
1671
+ const preSelection = commandSelection;
1672
+ const preActiveStory = activeStory;
1673
+ const transaction = executeEditorCommand(state, command, context);
1619
1674
  commit(transaction);
1675
+ options.onCommandApplied?.(command, transaction, context, {
1676
+ preSelection,
1677
+ activeStory: preActiveStory,
1678
+ });
1679
+ } catch (error) {
1680
+ emitError(toRuntimeError(error));
1681
+ }
1682
+ },
1683
+ applyRemoteCommand(command, context, meta) {
1684
+ try {
1685
+ if (command.type === "history.undo" || command.type === "history.redo") {
1686
+ return;
1687
+ }
1688
+ if (meta?.activeStory && !storyTargetsEqual(meta.activeStory, activeStory)) {
1689
+ activeStory = meta.activeStory;
1690
+ storySelections.set(
1691
+ storyTargetKey(activeStory),
1692
+ meta.preSelection ?? state.selection,
1693
+ );
1694
+ }
1695
+ const replayState = meta?.preSelection
1696
+ ? { ...state, selection: meta.preSelection }
1697
+ : state;
1698
+ const transaction = executeEditorCommand(replayState, command, context);
1699
+ commitRemote(transaction);
1620
1700
  } catch (error) {
1621
1701
  emitError(toRuntimeError(error));
1622
1702
  }
@@ -1890,6 +1970,20 @@ export function createDocumentRuntime(
1890
1970
  listener();
1891
1971
  }
1892
1972
  },
1973
+ setEditorRole(role) {
1974
+ viewState = applyEditorRole(viewState, role);
1975
+ cachedRenderSnapshot = refreshRenderSnapshot();
1976
+ for (const listener of listeners) {
1977
+ listener();
1978
+ }
1979
+ },
1980
+ setChromePin(surface, pin) {
1981
+ viewState = applyChromePins(viewState, surface, pin);
1982
+ cachedRenderSnapshot = refreshRenderSnapshot();
1983
+ for (const listener of listeners) {
1984
+ listener();
1985
+ }
1986
+ },
1893
1987
  getPageLayoutSnapshot() {
1894
1988
  return getCachedPageLayoutSnapshot(state, activeStory);
1895
1989
  },
@@ -2346,13 +2440,21 @@ export function createDocumentRuntime(
2346
2440
  }
2347
2441
 
2348
2442
  function commit(transaction: EditorTransaction): void {
2349
- const previous = state;
2350
-
2351
2443
  if (transaction.historyBoundary === "push") {
2352
2444
  history.past.push(state);
2353
2445
  history.future = [];
2354
2446
  }
2355
2447
 
2448
+ applyTransactionToState(transaction);
2449
+ }
2450
+
2451
+ function commitRemote(transaction: EditorTransaction): void {
2452
+ applyTransactionToState(transaction);
2453
+ }
2454
+
2455
+ function applyTransactionToState(transaction: EditorTransaction): void {
2456
+ const previous = state;
2457
+
2356
2458
  protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
2357
2459
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
2358
2460
  storySelections.set(storyTargetKey(activeStory), state.selection);
@@ -2562,13 +2664,13 @@ export function createDocumentRuntime(
2562
2664
 
2563
2665
  function applyTextCommandInActiveStory(
2564
2666
  command: ActiveStoryTextCommand,
2565
- options: {
2667
+ textOptions: {
2566
2668
  selection?: EditorState["selection"];
2567
2669
  blockedCommandName?: string;
2568
2670
  } = {},
2569
2671
  ): TextCommandAck {
2570
2672
  const opId = (command.origin as { opId?: string } | undefined)?.opId;
2571
- const selection = options.selection ?? state.selection;
2673
+ const selection = textOptions.selection ?? state.selection;
2572
2674
  if (
2573
2675
  activeStory.kind !== "main" &&
2574
2676
  getEffectiveDocumentMode(selection) === "suggesting" &&
@@ -2578,7 +2680,7 @@ export function createDocumentRuntime(
2578
2680
  emit({
2579
2681
  type: "command_blocked",
2580
2682
  documentId: state.documentId,
2581
- command: options.blockedCommandName ?? command.type,
2683
+ command: textOptions.blockedCommandName ?? command.type,
2582
2684
  reasons: [{
2583
2685
  code: "suggesting_unsupported",
2584
2686
  message,
@@ -2597,7 +2699,7 @@ export function createDocumentRuntime(
2597
2699
  emit({
2598
2700
  type: "command_blocked",
2599
2701
  documentId: state.documentId,
2600
- command: options.blockedCommandName ?? command.type,
2702
+ command: textOptions.blockedCommandName ?? command.type,
2601
2703
  reasons: blockedReasons,
2602
2704
  });
2603
2705
  return {
@@ -2608,7 +2710,7 @@ export function createDocumentRuntime(
2608
2710
  };
2609
2711
  }
2610
2712
 
2611
- const timestamp = command.origin?.timestamp ?? clock();
2713
+ const timestamp = normalizeCommandTimestamp(command.origin?.timestamp) ?? clock();
2612
2714
  const context = {
2613
2715
  timestamp,
2614
2716
  documentMode: getEffectiveDocumentMode(selection),
@@ -2621,9 +2723,15 @@ export function createDocumentRuntime(
2621
2723
  selection,
2622
2724
  };
2623
2725
 
2726
+ const preSelection = selection;
2727
+ const preActiveStory = activeStory;
2624
2728
  if (activeStory.kind === "main") {
2625
2729
  const mainTransaction = executeEditorCommand(baseState, command, context);
2626
2730
  commit(mainTransaction);
2731
+ options.onCommandApplied?.(command, mainTransaction, context, {
2732
+ preSelection,
2733
+ activeStory: preActiveStory,
2734
+ });
2627
2735
  return classifyAck({
2628
2736
  command,
2629
2737
  opId,
@@ -2682,16 +2790,17 @@ export function createDocumentRuntime(
2682
2790
  activeStory,
2683
2791
  ),
2684
2792
  };
2793
+ const broadcastCommand: EditorCommand = {
2794
+ type: "document.replace",
2795
+ document: nextDocumentWithReview,
2796
+ selection: localTransaction.nextState.selection,
2797
+ mapping: createEmptyMapping(),
2798
+ protectionSelection: selection,
2799
+ origin: command.origin,
2800
+ };
2685
2801
  const fullTransaction = executeEditorCommand(
2686
2802
  baseState,
2687
- {
2688
- type: "document.replace",
2689
- document: nextDocumentWithReview,
2690
- selection: localTransaction.nextState.selection,
2691
- mapping: createEmptyMapping(),
2692
- protectionSelection: selection,
2693
- origin: command.origin,
2694
- },
2803
+ broadcastCommand,
2695
2804
  context,
2696
2805
  );
2697
2806
 
@@ -2700,6 +2809,10 @@ export function createDocumentRuntime(
2700
2809
  effects: mergeTransactionEffects(fullTransaction.effects, localTransaction.effects),
2701
2810
  };
2702
2811
  commit(mergedTransaction);
2812
+ options.onCommandApplied?.(broadcastCommand, mergedTransaction, context, {
2813
+ preSelection,
2814
+ activeStory: preActiveStory,
2815
+ });
2703
2816
  return classifyAck({
2704
2817
  command,
2705
2818
  opId,
@@ -2988,6 +3101,16 @@ function createEntityId(
2988
3101
  return nextId;
2989
3102
  }
2990
3103
 
3104
+ function normalizeCommandTimestamp(value: unknown): string | undefined {
3105
+ if (typeof value === "string" && value.length > 0) {
3106
+ return value;
3107
+ }
3108
+ if (typeof value === "number" && Number.isFinite(value)) {
3109
+ return new Date(value).toISOString();
3110
+ }
3111
+ return undefined;
3112
+ }
3113
+
2991
3114
  function finalizeState(
2992
3115
  state: EditorState,
2993
3116
  markDirty: boolean,
@@ -45,12 +45,35 @@ export interface DocxFontLoader {
45
45
  refresh(input: FontLoaderInput): void;
46
46
  }
47
47
 
48
+ interface MinimalFontFace {
49
+ load(): Promise<MinimalFontFace>;
50
+ }
51
+
52
+ interface MinimalFontFaceDescriptors {
53
+ weight?: string;
54
+ style?: string;
55
+ }
56
+
57
+ interface MinimalFontFaceConstructor {
58
+ new (
59
+ family: string,
60
+ source: ArrayBuffer | ArrayBufferView | string,
61
+ descriptors?: MinimalFontFaceDescriptors,
62
+ ): MinimalFontFace;
63
+ }
64
+
65
+ interface MinimalFontFaceSet {
66
+ add(face: MinimalFontFace): void;
67
+ check(font: string): boolean;
68
+ ready: Promise<MinimalFontFaceSet>;
69
+ }
70
+
48
71
  export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
72
+ const globalDocument = (globalThis as { document?: { fonts?: MinimalFontFaceSet } }).document;
49
73
  const supported =
50
- typeof document !== "undefined" &&
74
+ globalDocument !== undefined &&
51
75
  typeof (globalThis as { FontFace?: unknown }).FontFace !== "undefined" &&
52
- // Guard against jsdom which exposes FontFace but not document.fonts
53
- Boolean((document as Document & { fonts?: FontFaceSet }).fonts);
76
+ Boolean(globalDocument.fonts);
54
77
 
55
78
  let current: FontLoaderInput = initial;
56
79
  let readyPromise: Promise<void>;
@@ -58,7 +81,7 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
58
81
 
59
82
  function run(input: FontLoaderInput): Promise<void> {
60
83
  if (!supported) return Promise.resolve();
61
- const fontSet = (document as Document & { fonts?: FontFaceSet }).fonts;
84
+ const fontSet = globalDocument?.fonts;
62
85
  if (!fontSet) return Promise.resolve();
63
86
 
64
87
  const pending: Array<Promise<unknown>> = [];
@@ -70,10 +93,8 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
70
93
 
71
94
  for (const [descriptor, data] of variantsOf(variants)) {
72
95
  try {
73
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
- const FontFaceCtor = (globalThis as any).FontFace as {
75
- new (family: string, source: BufferSource, descriptors?: FontFaceDescriptors): FontFace;
76
- };
96
+ const FontFaceCtor = (globalThis as { FontFace?: MinimalFontFaceConstructor }).FontFace;
97
+ if (!FontFaceCtor) continue;
77
98
  const face = new FontFaceCtor(family, data, descriptor);
78
99
  pending.push(
79
100
  face.load().then((loaded) => {
@@ -88,8 +109,6 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
88
109
  }
89
110
  }
90
111
 
91
- // Mark declared families as registered if the browser already resolves
92
- // them (e.g. system fonts like Calibri, Arial).
93
112
  for (const family of input.families) {
94
113
  try {
95
114
  const probe = `12px "${family.replace(/"/g, "'")}", serif`;
@@ -127,7 +146,7 @@ export function createDocxFontLoader(initial: FontLoaderInput): DocxFontLoader {
127
146
 
128
147
  function* variantsOf(
129
148
  variants: EmbeddedFontBytes,
130
- ): IterableIterator<[FontFaceDescriptors, ArrayBuffer]> {
149
+ ): IterableIterator<[MinimalFontFaceDescriptors, ArrayBuffer]> {
131
150
  if (variants.regular) {
132
151
  yield [{ weight: "400", style: "normal" }, variants.regular];
133
152
  }
@@ -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
  // ---------------------------------------------------------------------------
@@ -46,11 +46,14 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
46
46
  getAnchorRects: () => [],
47
47
  getScopeRailSegments: () => [],
48
48
  getAllScopeRailSegments: () => [],
49
+ getAllScopeCardModels: () => [],
49
50
  getResolvedFormatting: () => null,
50
51
  getResolvedRunFormatting: () => null,
51
52
  getMeasurement: () => null,
52
53
  getMeasurementFidelity: () => fidelity,
53
54
  whenMeasurementReady: () => Promise.resolve(),
55
+ getFirstPageIndexForBlock: () => null,
56
+ swapMeasurementProvider: () => undefined,
54
57
  getTableRenderPlan: () => null,
55
58
  getDirtyFieldFamilies: () => [],
56
59
  getFieldDirtinessReport: () => emptyReport,