@beyondwork/docx-react-component 1.0.47 → 1.0.49

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 (80) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +199 -13
  4. package/src/compare/diff-engine.ts +4 -0
  5. package/src/core/commands/add-scope.ts +257 -0
  6. package/src/core/commands/formatting-commands.ts +2 -0
  7. package/src/core/commands/index.ts +9 -1
  8. package/src/core/commands/text-commands.ts +3 -1
  9. package/src/core/schema/text-schema.ts +95 -1
  10. package/src/core/selection/anchor-conversion.ts +112 -0
  11. package/src/core/selection/review-anchors.ts +108 -3
  12. package/src/core/state/text-transaction.ts +103 -7
  13. package/src/internal/harness-debug-ports.ts +168 -0
  14. package/src/io/chart-preview-resolver.ts +59 -1
  15. package/src/io/docx-session.ts +226 -38
  16. package/src/io/export/serialize-main-document.ts +46 -0
  17. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  18. package/src/io/export/serialize-run-formatting.ts +10 -1
  19. package/src/io/export/serialize-settings.ts +421 -0
  20. package/src/io/export/serialize-styles.ts +10 -0
  21. package/src/io/normalize/normalize-text.ts +1 -0
  22. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  23. package/src/io/ooxml/chart/color-palette.ts +101 -0
  24. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  25. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  26. package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
  27. package/src/io/ooxml/chart/parse-series.ts +635 -0
  28. package/src/io/ooxml/chart/resolve-color.ts +261 -0
  29. package/src/io/ooxml/chart/types.ts +439 -0
  30. package/src/io/ooxml/parse-block-structure.ts +99 -0
  31. package/src/io/ooxml/parse-complex-content.ts +90 -2
  32. package/src/io/ooxml/parse-main-document.ts +156 -1
  33. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  34. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  35. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  36. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  37. package/src/io/ooxml/parse-settings.ts +97 -1
  38. package/src/io/ooxml/parse-styles.ts +65 -0
  39. package/src/io/ooxml/parse-theme.ts +2 -127
  40. package/src/io/ooxml/property-grab-bag.ts +211 -0
  41. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  42. package/src/io/ooxml/xml-parser.ts +142 -0
  43. package/src/model/canonical-document.ts +160 -0
  44. package/src/model/scope-markers.ts +144 -0
  45. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  46. package/src/runtime/collab/checkpoint-election.ts +75 -0
  47. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  48. package/src/runtime/collab/checkpoint-store.ts +115 -0
  49. package/src/runtime/collab/event-types.ts +27 -0
  50. package/src/runtime/collab/index.ts +29 -0
  51. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  52. package/src/runtime/collab/runtime-collab-sync.ts +330 -0
  53. package/src/runtime/collab/workflow-shared.ts +247 -0
  54. package/src/runtime/document-locations.ts +1 -9
  55. package/src/runtime/document-outline.ts +1 -9
  56. package/src/runtime/document-runtime.ts +288 -65
  57. package/src/runtime/editor-surface/capabilities.ts +63 -50
  58. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  59. package/src/runtime/layout/layout-engine-version.ts +8 -1
  60. package/src/runtime/prerender/cache-envelope.ts +19 -7
  61. package/src/runtime/prerender/cache-key.ts +25 -14
  62. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  63. package/src/runtime/prerender/customxml-cache.ts +211 -0
  64. package/src/runtime/prerender/customxml-probe.ts +78 -0
  65. package/src/runtime/prerender/prerender-document.ts +74 -7
  66. package/src/runtime/scope-resolver.ts +148 -0
  67. package/src/runtime/scope-tag-registry.ts +10 -0
  68. package/src/runtime/surface-projection.ts +102 -37
  69. package/src/runtime/theme-color-resolver.ts +188 -0
  70. package/src/runtime/workflow-markup.ts +7 -18
  71. package/src/ui/WordReviewEditor.tsx +48 -2
  72. package/src/ui/editor-runtime-boundary.ts +42 -1
  73. package/src/ui/headless/selection-helpers.ts +10 -23
  74. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
  75. package/src/ui/unsupported-previews-policy.ts +23 -0
  76. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  77. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  78. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  79. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  80. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
@@ -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,
@@ -28,6 +30,7 @@ import type {
28
30
  DocumentTextToken,
29
31
  EditorSessionState,
30
32
  EditorAnchorProjection,
33
+ TextFormattingDirective,
31
34
  EditorError,
32
35
  EditorStoryTarget,
33
36
  EditorViewStateSnapshot,
@@ -74,6 +77,7 @@ import type {
74
77
  WorkflowMetadataSnapshot,
75
78
  WorkflowMarkupSnapshot,
76
79
  WorkflowOverlay,
80
+ WorkflowScope,
77
81
  WorkflowScopeSnapshot,
78
82
  WorkspaceMode,
79
83
  WordReviewEditorEvent,
@@ -101,7 +105,14 @@ import {
101
105
  storyTargetsEqual,
102
106
  type EditorAnchorProjection as InternalEditorAnchorProjection,
103
107
  } from "../core/selection/mapping.ts";
104
- import { canCreateDocxCommentAnchor } from "../core/selection/review-anchors.ts";
108
+ import {
109
+ toInternalAnchorProjection,
110
+ toPublicAnchorProjection,
111
+ } from "../core/selection/anchor-conversion.ts";
112
+ import {
113
+ commentAnchorRejectionReason,
114
+ snapCommentAnchorAwayFromTable,
115
+ } from "../core/selection/review-anchors.ts";
105
116
  import { buildBookmarkNameMap } from "../legal/bookmarks.ts";
106
117
  import {
107
118
  describeOpaqueFragment,
@@ -116,6 +127,11 @@ import {
116
127
  } from "../review/store/revision-store.ts";
117
128
  import { createSuggestionsSnapshot } from "./suggestions-snapshot.ts";
118
129
  import { validateSelectionAgainstDocument } from "./selection/post-edit-validator.ts";
130
+ import { resolveScope } from "./scope-resolver.ts";
131
+ import {
132
+ insertScopeMarkers,
133
+ removeScopeMarkers,
134
+ } from "../core/commands/add-scope.ts";
119
135
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
120
136
  import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
121
137
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
@@ -224,6 +240,7 @@ import type {
224
240
  } from "../api/editor-state-types.ts";
225
241
  import { collectEditorStateForSerialize } from "./editor-state-integration.ts";
226
242
  import type { EditorStatePayload } from "../io/ooxml/workflow-payload.ts";
243
+ import type { SharedWorkflowState } from "./collab/workflow-shared.ts";
227
244
 
228
245
  /** Internal extension of ExportDocxOptions that threads the collected
229
246
  * editorState payload from the runtime to the docx serializer. */
@@ -257,7 +274,7 @@ export interface DocumentRuntime {
257
274
  getRenderSnapshot(): RuntimeRenderSnapshot;
258
275
  getCanonicalDocument(): CanonicalDocumentEnvelope;
259
276
  getSourcePackage(): EditorSessionState["sourcePackage"] | undefined;
260
- replaceText(text: string, target?: EditorAnchorProjection): void;
277
+ replaceText(text: string, target?: EditorAnchorProjection, formatting?: TextFormattingDirective): void;
261
278
  applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
262
279
  dispatch(command: EditorCommand): void;
263
280
  /**
@@ -291,6 +308,9 @@ export interface DocumentRuntime {
291
308
  reopenComment(commentId: string): void;
292
309
  addCommentReply(commentId: string, body: string, authorId?: string): AddCommentReplyResult;
293
310
  editCommentBody(commentId: string, body: string): void;
311
+ addScope(params: AddScopeParams): AddScopeResult;
312
+ getScope(scopeId: string): WorkflowScope | null;
313
+ removeScope(scopeId: string): void;
294
314
  acceptChange(changeId: string): void;
295
315
  rejectChange(changeId: string): void;
296
316
  acceptAllChanges(): void;
@@ -348,6 +368,7 @@ export interface DocumentRuntime {
348
368
  setWorkflowOverlay(overlay: WorkflowOverlay): void;
349
369
  clearWorkflowOverlay(): void;
350
370
  getWorkflowOverlay(): WorkflowOverlay | null;
371
+ setSharedWorkflowState(state: SharedWorkflowState | null): void;
351
372
  getWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null;
352
373
  getInteractionGuardSnapshot(): InteractionGuardSnapshot;
353
374
  getWorkflowMarkupSnapshot(): WorkflowMarkupSnapshot;
@@ -618,6 +639,9 @@ export function createDocumentRuntime(
618
639
  ?? options.initialSnapshot?.workflowMetadata?.entries
619
640
  ?? [];
620
641
  let hostAnnotationOverlay: HostAnnotationOverlay | null = null;
642
+ // P13 Slice B: shared workflow state from the collab Y.Map "workflow".
643
+ // Set via setSharedWorkflowState(); observed by evaluateWorkflowBlockedReasons.
644
+ let sharedWorkflowState: SharedWorkflowState | null = null;
621
645
  const initialPersistedSnapshot = options.initialSessionState
622
646
  ? persistedSnapshotFromEditorSessionState(options.initialSessionState, {
623
647
  savedAt: options.initialSessionState.updatedAt,
@@ -862,6 +886,7 @@ export function createDocumentRuntime(
862
886
  documentMode: DocumentMode;
863
887
  protectionSnapshot: ProtectionSnapshot;
864
888
  workflowOverlay: WorkflowOverlay | null;
889
+ sharedWorkflowState: SharedWorkflowState | null;
865
890
  snapshot: InteractionGuardSnapshot;
866
891
  }
867
892
  | undefined;
@@ -1129,6 +1154,29 @@ export function createDocumentRuntime(
1129
1154
  commandType?: string,
1130
1155
  ): WorkflowBlockedCommandReason[] {
1131
1156
  const reasons: WorkflowBlockedCommandReason[] = [];
1157
+ // P13 Slice B: shared lockedMode overrides all other scope checks when
1158
+ // non-editing. Short-circuit: no other scope reason applies when the round
1159
+ // is locked (the round state supersedes scope/overlay-level gating).
1160
+ // Emit a reason code whose effectiveMode mapping matches the mode intent:
1161
+ // "commenting" → workflow_comment_only (maps to effectiveMode: "comment")
1162
+ // "viewing" → workflow_view_only (maps to effectiveMode: "view")
1163
+ // "suggesting" → workflow_round_locked (no existing mapping; stays "blocked"
1164
+ // for this slice — full suggesting-mode semantics will be a
1165
+ // future slice that hooks getEffectiveDocumentMode instead).
1166
+ if (sharedWorkflowState?.lockedMode && sharedWorkflowState.lockedMode !== "editing") {
1167
+ const lockedMode = sharedWorkflowState.lockedMode;
1168
+ const code: WorkflowBlockedCommandReason["code"] =
1169
+ lockedMode === "commenting"
1170
+ ? "workflow_comment_only"
1171
+ : lockedMode === "viewing"
1172
+ ? "workflow_view_only"
1173
+ : "workflow_round_locked";
1174
+ reasons.push({
1175
+ code,
1176
+ message: `Round is locked to ${lockedMode} mode.`,
1177
+ });
1178
+ return reasons;
1179
+ }
1132
1180
  const selectionBounds = {
1133
1181
  from: Math.min(selection.anchor, selection.head),
1134
1182
  to: Math.max(selection.anchor, selection.head),
@@ -1541,7 +1589,8 @@ export function createDocumentRuntime(
1541
1589
  cachedInteractionGuardSnapshot.readOnly === state.readOnly &&
1542
1590
  cachedInteractionGuardSnapshot.documentMode === viewState.documentMode &&
1543
1591
  cachedInteractionGuardSnapshot.protectionSnapshot === protectionSnapshot &&
1544
- cachedInteractionGuardSnapshot.workflowOverlay === workflowOverlay
1592
+ cachedInteractionGuardSnapshot.workflowOverlay === workflowOverlay &&
1593
+ cachedInteractionGuardSnapshot.sharedWorkflowState === sharedWorkflowState
1545
1594
  ) {
1546
1595
  return cachedInteractionGuardSnapshot.snapshot;
1547
1596
  }
@@ -1602,6 +1651,7 @@ export function createDocumentRuntime(
1602
1651
  documentMode: viewState.documentMode,
1603
1652
  protectionSnapshot,
1604
1653
  workflowOverlay,
1654
+ sharedWorkflowState,
1605
1655
  snapshot,
1606
1656
  };
1607
1657
  return snapshot;
@@ -2035,6 +2085,16 @@ export function createDocumentRuntime(
2035
2085
  }
2036
2086
  });
2037
2087
 
2088
+ // R5 scratch snapshot: single pre-allocated object reused for every
2089
+ // `applyRemoteCommand(cmd, ctx, meta)` call with a `meta.preSelection`
2090
+ // override. Avoids the per-remote-command `{ ...cachedRenderSnapshot }`
2091
+ // + `{ ...state }` allocations that CLAUDE.md rule 4 warns against.
2092
+ // Mutated in place below — callers MUST NOT hold references to it
2093
+ // across dispatches; `executeEditorCommand` + `commitRemote` consume
2094
+ // synchronously within `applyRemoteCommand`, so this is safe today.
2095
+ const r5ScratchReplayState: typeof state = { ...state };
2096
+ const r5ScratchReplaySnapshot: typeof cachedRenderSnapshot = { ...cachedRenderSnapshot };
2097
+
2038
2098
  return {
2039
2099
  subscribe(listener) {
2040
2100
  listeners.add(listener);
@@ -2147,28 +2207,56 @@ export function createDocumentRuntime(
2147
2207
  applyRuntimeStateOverlayCommand(command);
2148
2208
  return;
2149
2209
  }
2150
- if (meta?.activeStory && !storyTargetsEqual(meta.activeStory, activeStory)) {
2151
- activeStory = meta.activeStory;
2152
- storySelections.set(
2153
- storyTargetKey(activeStory),
2154
- meta.preSelection ?? state.selection,
2155
- );
2210
+ // Story-target isolation: the remote's `meta.activeStory` scopes
2211
+ // the replay (so the command lands in the intended region of the
2212
+ // shared document), but must NEVER overwrite the local user's
2213
+ // closure-level `activeStory`. Before P11 this assignment stole
2214
+ // focus every time a remote event arrived for a different story —
2215
+ // a local user authoring in a header would get yanked into the
2216
+ // main body as soon as a peer edited main.
2217
+ const replayStory = meta?.activeStory ?? activeStory;
2218
+ const crossStoryReplay = Boolean(
2219
+ meta?.activeStory && !storyTargetsEqual(meta.activeStory, activeStory),
2220
+ );
2221
+ let replayState: typeof state;
2222
+ let replaySnapshot: typeof cachedRenderSnapshot;
2223
+ if (meta?.preSelection) {
2224
+ // Refresh scratch with current `state` / `cachedRenderSnapshot` (both
2225
+ // mutate on every commit), then override only the selection fields.
2226
+ Object.assign(r5ScratchReplayState, state);
2227
+ r5ScratchReplayState.selection = meta.preSelection;
2228
+ Object.assign(r5ScratchReplaySnapshot, cachedRenderSnapshot);
2229
+ r5ScratchReplaySnapshot.selection = toPublicSelectionSnapshot(meta.preSelection, replayStory);
2230
+ replayState = r5ScratchReplayState;
2231
+ replaySnapshot = r5ScratchReplaySnapshot;
2232
+ } else {
2233
+ replayState = state;
2234
+ replaySnapshot = cachedRenderSnapshot;
2156
2235
  }
2157
- const replayState = meta?.preSelection
2158
- ? { ...state, selection: meta.preSelection }
2159
- : state;
2160
- const replaySnapshot = meta?.preSelection
2161
- ? {
2162
- ...cachedRenderSnapshot,
2163
- selection: toPublicSelectionSnapshot(meta.preSelection, activeStory),
2164
- }
2165
- : cachedRenderSnapshot;
2166
2236
  const replayContext = {
2167
2237
  ...context,
2168
2238
  renderSnapshot: replaySnapshot,
2169
2239
  };
2170
2240
  const transaction = executeEditorCommand(replayState, command, replayContext);
2171
- commitRemote(transaction);
2241
+ if (crossStoryReplay) {
2242
+ // Cross-story replay: the transaction's resulting selection is
2243
+ // in the remote story's region. Don't leak that into local
2244
+ // `state.selection` — preserve the pre-replay local selection
2245
+ // so the local caret stays where the user is focused. The
2246
+ // document delta still applies; only the selection is filtered.
2247
+ // Full position-mapping of the local cursor through the remote
2248
+ // edit is a separate P11 sub-bullet (Awareness cursor transaction
2249
+ // mapping).
2250
+ commitRemote({
2251
+ ...transaction,
2252
+ nextState: {
2253
+ ...transaction.nextState,
2254
+ selection: state.selection,
2255
+ },
2256
+ });
2257
+ } else {
2258
+ commitRemote(transaction);
2259
+ }
2172
2260
  } catch (error) {
2173
2261
  emitError(toRuntimeError(error));
2174
2262
  }
@@ -2207,13 +2295,14 @@ export function createDocumentRuntime(
2207
2295
  getDefaultAuthorId() {
2208
2296
  return defaultAuthorId;
2209
2297
  },
2210
- replaceText(text, target) {
2298
+ replaceText(text, target, formatting) {
2211
2299
  try {
2212
2300
  const timestamp = clock();
2213
2301
  applyTextCommandInActiveStory(
2214
2302
  {
2215
2303
  type: "text.insert",
2216
2304
  text,
2305
+ ...(formatting ? { formatting } : {}),
2217
2306
  origin: createOrigin("api", timestamp),
2218
2307
  },
2219
2308
  {
@@ -2260,22 +2349,39 @@ export function createDocumentRuntime(
2260
2349
  throw toStructuredRuntimeException(error);
2261
2350
  }
2262
2351
  const commentId = createEntityId("comment", state.document.review.comments, clock());
2263
- const anchor = params.anchor
2352
+ let anchor = params.anchor
2264
2353
  ? toInternalAnchorProjection(params.anchor)
2265
2354
  : state.selection.activeRange;
2266
- const selection = params.anchor
2355
+ let selection = params.anchor
2267
2356
  ? createSelectionFromPublicAnchor(params.anchor)
2268
2357
  : state.selection;
2269
- if (!canCreateDocxCommentAnchor(cachedRenderSnapshot.surface, anchor)) {
2358
+ if (params.snapToSafeBoundary === true) {
2359
+ const snapped = snapCommentAnchorAwayFromTable(
2360
+ cachedRenderSnapshot.surface,
2361
+ anchor,
2362
+ );
2363
+ if (snapped !== null && snapped !== anchor) {
2364
+ anchor = snapped;
2365
+ selection = createSelectionFromPublicAnchor(toPublicAnchorProjection(snapped));
2366
+ }
2367
+ }
2368
+ const rejectionReason = commentAnchorRejectionReason(
2369
+ cachedRenderSnapshot.surface,
2370
+ anchor,
2371
+ );
2372
+ if (rejectionReason !== null) {
2373
+ const message =
2374
+ rejectionReason === "comment_anchor_table_adjacent"
2375
+ ? "DOCX comments cannot currently anchor mid-run within a paragraph adjacent to a table boundary — snap the range to a paragraph or word boundary and retry."
2376
+ : "DOCX comments must use a non-empty range that stays within a single story and does not cross table or opaque-fragment boundaries.";
2270
2377
  const error: InternalEditorError = {
2271
2378
  errorId: createSessionId("comment-anchor", clock()),
2272
2379
  code: "validation_failed",
2273
2380
  isFatal: false,
2274
- message:
2275
- "DOCX comments must use a non-empty range that stays within a single story and does not cross table or opaque-fragment boundaries.",
2381
+ message,
2276
2382
  source: "runtime",
2277
2383
  details: {
2278
- reason: "invalid_comment_anchor",
2384
+ reason: rejectionReason,
2279
2385
  },
2280
2386
  };
2281
2387
  emitError(error);
@@ -2362,6 +2468,155 @@ export function createDocumentRuntime(
2362
2468
  origin: createOrigin("api", clock()),
2363
2469
  });
2364
2470
  },
2471
+ addScope(params): AddScopeResult {
2472
+ const scopeId =
2473
+ params.scopeId ??
2474
+ `scope-${clock().replace(/[^0-9]/gu, "")}-${Math.floor(Math.random() * 1e6)}`;
2475
+ const anchor =
2476
+ params.anchor.kind === "range"
2477
+ ? { from: params.anchor.from, to: params.anchor.to }
2478
+ : null;
2479
+
2480
+ if (!anchor) {
2481
+ return {
2482
+ scopeId,
2483
+ anchor: params.anchor,
2484
+ };
2485
+ }
2486
+
2487
+ const { document: nextDocument } = insertScopeMarkers(state.document, {
2488
+ scopeId,
2489
+ from: anchor.from,
2490
+ to: anchor.to,
2491
+ });
2492
+
2493
+ if (nextDocument !== state.document) {
2494
+ this.dispatch({
2495
+ type: "document.replace",
2496
+ document: nextDocument,
2497
+ origin: createOrigin("api", clock()),
2498
+ });
2499
+ }
2500
+
2501
+ const resolved = resolveScope(state.document, scopeId);
2502
+ const publicAnchor: EditorAnchorProjection =
2503
+ resolved && resolved.kind === "range"
2504
+ ? resolved
2505
+ : {
2506
+ kind: "range",
2507
+ from: anchor.from,
2508
+ to: anchor.to,
2509
+ assoc: { start: -1, end: 1 },
2510
+ };
2511
+
2512
+ const currentOverlay: WorkflowOverlay = workflowOverlay ?? {
2513
+ overlayVersion: "workflow-overlay/1",
2514
+ scopes: [],
2515
+ };
2516
+ const existingScopes = currentOverlay.scopes.filter(
2517
+ (existing) => existing.scopeId !== scopeId,
2518
+ );
2519
+ const scope: WorkflowScope = {
2520
+ scopeId,
2521
+ mode: params.mode ?? "comment",
2522
+ anchor: publicAnchor,
2523
+ ...(params.storyTarget ? { storyTarget: params.storyTarget } : {}),
2524
+ ...(params.label ? { label: params.label } : {}),
2525
+ };
2526
+ this.dispatch({
2527
+ type: "workflow.set-overlay",
2528
+ overlay: {
2529
+ ...currentOverlay,
2530
+ scopes: [...existingScopes, scope],
2531
+ },
2532
+ origin: createOrigin("api", clock()),
2533
+ });
2534
+
2535
+ if (params.persistence && params.persistence !== "runtime-only") {
2536
+ const entry: WorkflowMetadataEntry = {
2537
+ entryId: `scope-metadata-${scopeId}`,
2538
+ metadataId: "workflow.scope",
2539
+ anchor: publicAnchor,
2540
+ scopeId,
2541
+ value:
2542
+ params.persistence === "document-metadata"
2543
+ ? { ...(params.metadata?.value ?? {}), label: params.label }
2544
+ : params.metadata?.value,
2545
+ metadataPersistence:
2546
+ params.persistence === "session" ? "external" : "internal",
2547
+ };
2548
+ this.dispatch({
2549
+ type: "workflow.set-metadata-entries",
2550
+ entries: [...(workflowMetadataEntries ?? []), entry],
2551
+ origin: createOrigin("api", clock()),
2552
+ });
2553
+ }
2554
+
2555
+ return {
2556
+ scopeId,
2557
+ anchor: publicAnchor,
2558
+ };
2559
+ },
2560
+ getScope(scopeId) {
2561
+ const resolved = resolveScope(state.document, scopeId);
2562
+ if (!resolved) {
2563
+ const stored = workflowOverlay?.scopes.find((s) => s.scopeId === scopeId);
2564
+ return stored ?? null;
2565
+ }
2566
+ const stored = workflowOverlay?.scopes.find((s) => s.scopeId === scopeId);
2567
+ if (!stored) {
2568
+ return {
2569
+ scopeId,
2570
+ mode: "comment",
2571
+ anchor: resolved,
2572
+ };
2573
+ }
2574
+ return {
2575
+ ...stored,
2576
+ anchor: resolved,
2577
+ };
2578
+ },
2579
+ removeScope(scopeId) {
2580
+ // Step 1: drop the scope from the overlay FIRST. If the scope's mode was
2581
+ // "comment" / "view" the workflow-blocked-reasons gate in `dispatch`
2582
+ // would otherwise refuse the subsequent `document.replace` with
2583
+ // `workflow_comment_only` / `workflow_view_only`. Overlay commands are
2584
+ // routed through `applyRuntimeStateOverlayCommand` and bypass that gate.
2585
+ if (workflowOverlay) {
2586
+ const nextScopes = workflowOverlay.scopes.filter(
2587
+ (scope) => scope.scopeId !== scopeId,
2588
+ );
2589
+ if (nextScopes.length !== workflowOverlay.scopes.length) {
2590
+ this.dispatch({
2591
+ type: "workflow.set-overlay",
2592
+ overlay: { ...workflowOverlay, scopes: nextScopes },
2593
+ origin: createOrigin("api", clock()),
2594
+ });
2595
+ }
2596
+ }
2597
+ // Step 2: now that the scope is gone, strip the markers from the doc.
2598
+ const nextDocument = removeScopeMarkers(state.document, scopeId);
2599
+ if (nextDocument !== state.document) {
2600
+ this.dispatch({
2601
+ type: "document.replace",
2602
+ document: nextDocument,
2603
+ origin: createOrigin("api", clock()),
2604
+ });
2605
+ }
2606
+ // Step 3: clear any customXml-persisted metadata entries.
2607
+ if (workflowMetadataEntries) {
2608
+ const nextEntries = workflowMetadataEntries.filter(
2609
+ (entry) => entry.scopeId !== scopeId,
2610
+ );
2611
+ if (nextEntries.length !== workflowMetadataEntries.length) {
2612
+ this.dispatch({
2613
+ type: "workflow.set-metadata-entries",
2614
+ entries: nextEntries,
2615
+ origin: createOrigin("api", clock()),
2616
+ });
2617
+ }
2618
+ }
2619
+ },
2365
2620
  acceptChange(changeId) {
2366
2621
  this.dispatch({
2367
2622
  type: "change.accept",
@@ -2756,6 +3011,13 @@ export function createDocumentRuntime(
2756
3011
  getWorkflowOverlay() {
2757
3012
  return workflowOverlay;
2758
3013
  },
3014
+ setSharedWorkflowState(state) {
3015
+ if (state === sharedWorkflowState) return;
3016
+ sharedWorkflowState = state;
3017
+ // Invalidate guard/scope caches so next derivation reflects the new state.
3018
+ cachedInteractionGuardSnapshot = undefined;
3019
+ cachedWorkflowScopeSnapshot = undefined;
3020
+ },
2759
3021
  getWorkflowScopeSnapshot() {
2760
3022
  return getCachedWorkflowScopeSnapshot();
2761
3023
  },
@@ -3954,45 +4216,6 @@ function toPublicSelectionSnapshot(
3954
4216
  };
3955
4217
  }
3956
4218
 
3957
- function toPublicAnchorProjection(
3958
- anchor: InternalEditorAnchorProjection,
3959
- ): EditorAnchorProjection {
3960
- switch (anchor.kind) {
3961
- case "range":
3962
- return {
3963
- kind: "range",
3964
- from: anchor.range.from,
3965
- to: anchor.range.to,
3966
- assoc: anchor.assoc,
3967
- };
3968
- case "node":
3969
- return {
3970
- kind: "node",
3971
- at: anchor.at,
3972
- assoc: anchor.assoc,
3973
- };
3974
- case "detached":
3975
- return {
3976
- kind: "detached",
3977
- lastKnownRange: anchor.lastKnownRange,
3978
- reason: anchor.reason,
3979
- };
3980
- }
3981
- }
3982
-
3983
- function toInternalAnchorProjection(
3984
- anchor: EditorAnchorProjection,
3985
- ): InternalEditorAnchorProjection {
3986
- switch (anchor.kind) {
3987
- case "range":
3988
- return createRangeAnchor(anchor.from, anchor.to, anchor.assoc);
3989
- case "node":
3990
- return createNodeAnchor(anchor.at, anchor.assoc);
3991
- case "detached":
3992
- return createDetachedAnchor(anchor.lastKnownRange, anchor.reason);
3993
- }
3994
- }
3995
-
3996
4219
  function createSelectionFromPublicAnchor(
3997
4220
  anchor: EditorAnchorProjection,
3998
4221
  ): import("../core/state/editor-state.ts").SelectionSnapshot {
@@ -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
  /**