@beyondwork/docx-react-component 1.0.43 → 1.0.46

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 (50) hide show
  1. package/README.md +35 -1
  2. package/package.json +44 -32
  3. package/src/api/public-types.ts +156 -3
  4. package/src/core/commands/formatting-commands.ts +7 -1
  5. package/src/core/commands/index.ts +27 -2
  6. package/src/core/commands/text-commands.ts +59 -0
  7. package/src/core/selection/review-anchors.ts +131 -21
  8. package/src/index.ts +16 -1
  9. package/src/io/chart-preview-resolver.ts +281 -0
  10. package/src/io/docx-session.ts +21 -1
  11. package/src/io/export/build-app-properties-xml.ts +1 -1
  12. package/src/io/export/serialize-comments.ts +38 -9
  13. package/src/io/export/twip.ts +1 -1
  14. package/src/io/normalize/normalize-text.ts +33 -0
  15. package/src/io/ooxml/parse-comments.ts +0 -33
  16. package/src/io/ooxml/parse-complex-content.ts +14 -0
  17. package/src/io/ooxml/parse-main-document.ts +4 -0
  18. package/src/preservation/opaque-region.ts +5 -0
  19. package/src/review/store/comment-remapping.ts +2 -2
  20. package/src/runtime/document-runtime.ts +351 -25
  21. package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
  22. package/src/runtime/edit-dispatch/index.ts +2 -0
  23. package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
  24. package/src/runtime/editor-surface/capabilities.ts +411 -0
  25. package/src/runtime/event-refresh-hints.ts +1 -0
  26. package/src/runtime/layout/docx-font-loader.ts +30 -11
  27. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  28. package/src/runtime/layout/layout-engine-instance.ts +46 -0
  29. package/src/runtime/layout/layout-engine-version.ts +41 -0
  30. package/src/runtime/layout/public-facet.ts +30 -0
  31. package/src/runtime/prerender/cache-envelope.ts +29 -0
  32. package/src/runtime/prerender/cache-key.ts +66 -0
  33. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  34. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  35. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  36. package/src/runtime/prerender/prerender-document.ts +145 -0
  37. package/src/runtime/render/block-fragment-projection.ts +2 -0
  38. package/src/runtime/selection/post-edit-validator.ts +77 -0
  39. package/src/runtime/surface-projection.ts +35 -2
  40. package/src/ui/WordReviewEditor.tsx +75 -192
  41. package/src/ui/editor-runtime-boundary.ts +5 -1
  42. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  43. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  44. package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -0
  45. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +23 -0
  46. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  47. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +38 -0
  48. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  49. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  50. package/src/ui-tailwind/tw-review-workspace.tsx +131 -29
@@ -0,0 +1,77 @@
1
+ import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
2
+ import type { SelectionSnapshot } from "../../core/state/editor-state.ts";
3
+
4
+ /**
5
+ * Snap a selection to a valid position relative to the document.
6
+ *
7
+ * Pure function. O(1) on the identity (in-bounds) fast path — returns
8
+ * the SAME object reference when no change is needed. Callers should
9
+ * compare with `!==` to detect a snap (e.g. to decide whether to
10
+ * re-spread runtime state).
11
+ *
12
+ * Wired into the runtime snapshot-emit chokepoint
13
+ * (`applyTransactionToState` -> `cachedRenderSnapshot = refreshRenderSnapshot()`),
14
+ * so it runs once per transaction commit. Must NOT walk the document;
15
+ * the caller is responsible for passing a valid `maxOffset` (the
16
+ * POST-mutation `surface.storySize`, primed via
17
+ * `getCachedSurface(state.document, activeStory).storySize`).
18
+ *
19
+ * NodeAnchor invalidation is deferred until CanonicalDocumentEnvelope
20
+ * grows an O(1) node-by-id accessor. Until then, NodeAnchor selections
21
+ * are returned unchanged (identity).
22
+ *
23
+ * @param document The post-mutation canonical document. Currently
24
+ * unused except for the deferred NodeAnchor branch;
25
+ * the parameter is kept for API stability.
26
+ * @param selection The selection to validate.
27
+ * @param maxOffset The POST-mutation maximum story offset. Caller
28
+ * passes `getCachedSurface(state.document,
29
+ * activeStory).storySize` (which primes the cache
30
+ * that `refreshRenderSnapshot` reuses on its next
31
+ * call — no extra surface walk). The validator does
32
+ * NOT walk the document to compute this. Do NOT pass
33
+ * the pre-mutation snapshot's storySize: at end-of-doc
34
+ * inserts, the new selection legitimately exceeds the
35
+ * old bound and the validator would clamp the caret
36
+ * backward by one position per keystroke. Pass
37
+ * `Number.POSITIVE_INFINITY` to skip the upper-bound
38
+ * clamp.
39
+ */
40
+ export function validateSelectionAgainstDocument(
41
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- reserved for deferred NodeAnchor lookup
42
+ document: CanonicalDocumentEnvelope,
43
+ selection: SelectionSnapshot,
44
+ maxOffset: number,
45
+ ): SelectionSnapshot {
46
+ if (selection.activeRange.kind === "node") {
47
+ // Deferred: NodeAnchor invalidation requires an O(1) node-by-id
48
+ // accessor on CanonicalDocumentEnvelope. Until that lands, return
49
+ // identity so we never falsely invalidate a still-valid node anchor.
50
+ return selection;
51
+ }
52
+
53
+ const anchor = clamp(selection.anchor, 0, maxOffset);
54
+ const head = clamp(selection.head, 0, maxOffset);
55
+
56
+ if (anchor === selection.anchor && head === selection.head) {
57
+ // Identity fast path — no allocation, same reference returned.
58
+ return selection;
59
+ }
60
+
61
+ const range = { from: Math.min(anchor, head), to: Math.max(anchor, head) };
62
+ const assoc =
63
+ selection.activeRange.kind === "range"
64
+ ? selection.activeRange.assoc
65
+ : { start: 1 as const, end: 1 as const };
66
+
67
+ return {
68
+ anchor,
69
+ head,
70
+ isCollapsed: anchor === head,
71
+ activeRange: { kind: "range", range, assoc },
72
+ };
73
+ }
74
+
75
+ function clamp(n: number, lo: number, hi: number): number {
76
+ return n < lo ? lo : n > hi ? hi : n;
77
+ }
@@ -68,11 +68,17 @@ interface ParagraphAccumulator {
68
68
  segments: SurfaceInlineSegment[];
69
69
  }
70
70
 
71
+ export interface SurfaceProjectionOptions {
72
+ viewportBlockRange?: { start: number; end: number } | null;
73
+ }
74
+
71
75
  export function createEditorSurfaceSnapshot(
72
76
  document: CanonicalDocumentEnvelope,
73
77
  _selection: SelectionSnapshot,
74
78
  activeStory: EditorStoryTarget = { kind: "main" },
79
+ options: SurfaceProjectionOptions = {},
75
80
  ): EditorSurfaceSnapshot {
81
+ const viewportBlockRange = options.viewportBlockRange ?? null;
76
82
  const root = normalizeDocumentRoot({
77
83
  type: "doc",
78
84
  children: [...getStoryBlocks(document, activeStory)],
@@ -99,8 +105,34 @@ export function createEditorSurfaceSnapshot(
99
105
  numberingPrefixResolver,
100
106
  activeStory.kind !== "main",
101
107
  );
102
- blocks.push(surfaceBlock.block);
103
- lockedFragmentIds.push(...surfaceBlock.lockedFragmentIds);
108
+ const isInViewport =
109
+ viewportBlockRange === null ||
110
+ (index >= viewportBlockRange.start && index < viewportBlockRange.end);
111
+
112
+ if (isInViewport) {
113
+ blocks.push(surfaceBlock.block);
114
+ lockedFragmentIds.push(...surfaceBlock.lockedFragmentIds);
115
+ } else {
116
+ // Replace with size-preserving placeholder. from/to track the SAME
117
+ // position range as the real block, so selection and anchor stability
118
+ // outside the viewport is preserved.
119
+ const placeholderSize = surfaceBlock.nextCursor - cursor;
120
+ const placeholderBlockId = `placeholder-culled-${index}`;
121
+ blocks.push({
122
+ blockId: placeholderBlockId,
123
+ kind: "opaque_block",
124
+ from: cursor,
125
+ to: surfaceBlock.nextCursor,
126
+ fragmentId: placeholderBlockId,
127
+ warningId: placeholderBlockId,
128
+ label: "",
129
+ detail: "",
130
+ placeholderSize,
131
+ state: "placeholder-culled",
132
+ } as SurfaceBlockSnapshot);
133
+ // Do NOT push lockedFragmentIds — placeholder has no real fragment.
134
+ }
135
+
104
136
  cursor = surfaceBlock.nextCursor;
105
137
  if (index < root.children.length - 1 && root.children[index + 1]?.type === "paragraph") {
106
138
  cursor += 1;
@@ -115,6 +147,7 @@ export function createEditorSurfaceSnapshot(
115
147
  blocks,
116
148
  lockedFragmentIds,
117
149
  secondaryStories,
150
+ viewportBlockRange,
118
151
  };
119
152
  }
120
153
 
@@ -97,14 +97,15 @@ import {
97
97
  } from "../core/commands/style-commands.ts";
98
98
  import {
99
99
  continueNumbering as continueListNumbering,
100
- backspaceAtListStart,
101
- indentListItems,
102
- outdentListItems,
103
100
  restartNumbering as restartListNumbering,
104
- splitListParagraph,
105
101
  toggleBulletedList,
106
102
  toggleNumberedList,
107
103
  } from "../core/commands/list-commands.ts";
104
+ import {
105
+ dispatchTextCommand,
106
+ type DispatchContext,
107
+ type DispatchTextCommand,
108
+ } from "../runtime/edit-dispatch/index.ts";
108
109
  import {
109
110
  resolveActiveParagraphIndex,
110
111
  setActiveParagraphIndentation,
@@ -242,6 +243,12 @@ const BROWSER_SAFE_PREVIEW_TYPES = new Set([
242
243
  "image/gif",
243
244
  "image/webp",
244
245
  "image/bmp",
246
+ // SVG is served through `<img src="data:image/svg+xml;base64,...">` by
247
+ // `createImageDataUrl`. Chromium sandboxes SVGs loaded via <img> — scripts
248
+ // don't execute, external references are blocked, XSS surface matches PNG.
249
+ // Needed for docs/plans/lane-5-charts.md Stage 0B synthesized chart previews and
250
+ // any host that ships .svg inside `word/media/` as a logo or figure.
251
+ "image/svg+xml",
245
252
  ]);
246
253
 
247
254
  const ACCESSIBLE_REGION_ORDER = [
@@ -1001,16 +1008,9 @@ export function __createWordReviewEditorRefBridge(
1001
1008
 
1002
1009
  export function __applyRuntimeTextCommand(
1003
1010
  runtime: WordReviewEditorRuntime,
1004
- command:
1005
- | { type: "insert-text"; text: string }
1006
- | { type: "delete-backward" }
1007
- | { type: "delete-forward" }
1008
- | { type: "insert-tab" }
1009
- | { type: "outdent-tab" }
1010
- | { type: "insert-hard-break" }
1011
- | { type: "split-paragraph" },
1011
+ command: DispatchTextCommand,
1012
1012
  ): void {
1013
- applyRuntimeTextCommand(runtime, command);
1013
+ dispatchTextCommand(runtime, command, DISPATCH_CONTEXT);
1014
1014
  }
1015
1015
 
1016
1016
  export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditorProps>(
@@ -1039,6 +1039,9 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1039
1039
  onWarning,
1040
1040
  onReviewSidebarTrackedChanges,
1041
1041
  onReviewSidebarComments,
1042
+ onFindRequested,
1043
+ onPrintRequested,
1044
+ onZoomRequested,
1042
1045
  readOnly = false,
1043
1046
  reviewMode = "review",
1044
1047
  suggestionsEnabled = false,
@@ -2141,7 +2144,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2141
2144
 
2142
2145
  function addReviewComment(): string | null {
2143
2146
  try {
2144
- const commentId = activeRuntime.addComment({
2147
+ const { commentId } = activeRuntime.addComment({
2145
2148
  anchor: snapshot.selection.activeRange,
2146
2149
  body: "",
2147
2150
  authorId: currentUser.userId,
@@ -2570,7 +2573,46 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2570
2573
  },
2571
2574
  );
2572
2575
 
2573
- if (shortcut.kind === "none" || shortcut.kind === "delegate") {
2576
+ if (shortcut.kind === "none") {
2577
+ return;
2578
+ }
2579
+
2580
+ if (shortcut.kind === "delegate") {
2581
+ // Host-delegated shortcuts: if the host has wired a typed
2582
+ // callback we call it and suppress the browser default; if
2583
+ // not, the event falls through to the browser (Ctrl+F opens
2584
+ // Find, Ctrl+Plus zooms, etc.) — matching the legacy behavior.
2585
+ let handled = false;
2586
+ if (shortcut.shortcut === "find" && onFindRequested) {
2587
+ // selectionText is intentionally empty — hosts that need the
2588
+ // selected text already receive it via the selection_changed
2589
+ // event + canonicalDocument they have via onEvent listeners.
2590
+ // The range is the load-bearing field so host Find panels can
2591
+ // scope their search to the selection or pre-populate from it.
2592
+ onFindRequested({
2593
+ selectionText: "",
2594
+ selectionRange: snapshot.selection,
2595
+ });
2596
+ handled = true;
2597
+ } else if (shortcut.shortcut === "print" && onPrintRequested) {
2598
+ onPrintRequested();
2599
+ handled = true;
2600
+ } else if (
2601
+ (shortcut.shortcut === "zoom-in" ||
2602
+ shortcut.shortcut === "zoom-out" ||
2603
+ shortcut.shortcut === "zoom-reset") &&
2604
+ onZoomRequested
2605
+ ) {
2606
+ const direction =
2607
+ shortcut.shortcut === "zoom-in" ? "in" :
2608
+ shortcut.shortcut === "zoom-out" ? "out" : "reset";
2609
+ onZoomRequested(direction);
2610
+ handled = true;
2611
+ }
2612
+ if (handled) {
2613
+ event.preventDefault();
2614
+ event.stopPropagation();
2615
+ }
2574
2616
  return;
2575
2617
  }
2576
2618
 
@@ -2636,13 +2678,13 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2636
2678
  onFocus: handleSurfaceFocus,
2637
2679
  onBlur: handleSurfaceBlur,
2638
2680
  onSelectionChange: dispatchSelection,
2639
- onInsertText: (text: string) => applyRuntimeTextCommand(activeRuntime, { type: "insert-text", text }),
2640
- onDeleteBackward: () => applyRuntimeTextCommand(activeRuntime, { type: "delete-backward" }),
2641
- onDeleteForward: () => applyRuntimeTextCommand(activeRuntime, { type: "delete-forward" }),
2642
- onInsertTab: () => applyRuntimeTextCommand(activeRuntime, { type: "insert-tab" }),
2643
- onOutdentTab: () => applyRuntimeTextCommand(activeRuntime, { type: "outdent-tab" }),
2644
- onInsertHardBreak: () => applyRuntimeTextCommand(activeRuntime, { type: "insert-hard-break" }),
2645
- onSplitParagraph: () => applyRuntimeTextCommand(activeRuntime, { type: "split-paragraph" }),
2681
+ onInsertText: (text: string) => dispatchTextCommand(activeRuntime, { type: "insert-text", text }, DISPATCH_CONTEXT),
2682
+ onDeleteBackward: () => dispatchTextCommand(activeRuntime, { type: "delete-backward" }, DISPATCH_CONTEXT),
2683
+ onDeleteForward: () => dispatchTextCommand(activeRuntime, { type: "delete-forward" }, DISPATCH_CONTEXT),
2684
+ onInsertTab: () => dispatchTextCommand(activeRuntime, { type: "insert-tab" }, DISPATCH_CONTEXT),
2685
+ onOutdentTab: () => dispatchTextCommand(activeRuntime, { type: "outdent-tab" }, DISPATCH_CONTEXT),
2686
+ onInsertHardBreak: () => dispatchTextCommand(activeRuntime, { type: "insert-hard-break" }, DISPATCH_CONTEXT),
2687
+ onSplitParagraph: () => dispatchTextCommand(activeRuntime, { type: "split-paragraph" }, DISPATCH_CONTEXT),
2646
2688
  onUndo: () => activeRuntime.undo(),
2647
2689
  onRedo: () => activeRuntime.redo(),
2648
2690
  onBlockedInput: (command: "paste" | "drop", message: string) =>
@@ -3294,7 +3336,11 @@ function applyRuntimeListToggle(
3294
3336
  dispatchStoryMutationResult(
3295
3337
  runtime,
3296
3338
  context,
3297
- createListMutationResult(result, context.localSnapshot.selection),
3339
+ {
3340
+ changed: result.affectedParagraphIndexes.length > 0,
3341
+ document: result.document,
3342
+ selection: toRuntimeSelectionSnapshot(context.localSnapshot.selection),
3343
+ },
3298
3344
  context.timestamp,
3299
3345
  );
3300
3346
  }
@@ -4418,175 +4464,12 @@ function buildTablesFacet(
4418
4464
 
4419
4465
  export { buildTablesFacet as __buildTablesFacet };
4420
4466
 
4421
- function applyRuntimeTextCommand(
4422
- runtime: WordReviewEditorRuntime,
4423
- command:
4424
- | { type: "insert-text"; text: string }
4425
- | { type: "delete-backward" }
4426
- | { type: "delete-forward" }
4427
- | { type: "insert-tab" }
4428
- | { type: "outdent-tab" }
4429
- | { type: "insert-hard-break" }
4430
- | { type: "split-paragraph" },
4431
- ): void {
4432
- const snapshot = runtime.getRenderSnapshot();
4433
- const context = getStoryMutationContext(runtime, getMountedTextCommandName(command));
4434
- if (!context) {
4435
- return;
4436
- }
4437
-
4438
- const effectiveSelectionMode = runtime.getInteractionGuardSnapshot().effectiveMode;
4439
- const listAwareResult = applyListAwareTextCommand(context, command);
4440
- if (effectiveSelectionMode === "suggest" && listAwareResult) {
4441
- runtime.emitBlockedCommand(getMountedTextCommandName(command), [{
4442
- code: "suggesting_unsupported",
4443
- message: "List structure changes are not supported in suggesting mode.",
4444
- }]);
4445
- return;
4446
- }
4447
-
4448
- if (listAwareResult) {
4449
- dispatchStoryMutationResult(runtime, context, listAwareResult, context.timestamp);
4450
- return;
4451
- }
4452
-
4453
- switch (command.type) {
4454
- case "insert-text":
4455
- runtime.applyActiveStoryTextCommand({ type: "text.insert", text: command.text });
4456
- return;
4457
- case "delete-backward":
4458
- runtime.applyActiveStoryTextCommand({ type: "text.delete-backward" });
4459
- return;
4460
- case "delete-forward":
4461
- runtime.applyActiveStoryTextCommand({ type: "text.delete-forward" });
4462
- return;
4463
- case "insert-tab":
4464
- runtime.applyActiveStoryTextCommand({ type: "text.insert-tab" });
4465
- return;
4466
- case "outdent-tab":
4467
- return;
4468
- case "insert-hard-break":
4469
- runtime.applyActiveStoryTextCommand({ type: "text.insert-hard-break" });
4470
- return;
4471
- case "split-paragraph":
4472
- runtime.applyActiveStoryTextCommand({ type: "paragraph.split" });
4473
- return;
4474
- }
4475
- }
4476
-
4477
- function getMountedTextCommandName(
4478
- command:
4479
- | { type: "insert-text"; text: string }
4480
- | { type: "delete-backward" }
4481
- | { type: "delete-forward" }
4482
- | { type: "insert-tab" }
4483
- | { type: "outdent-tab" }
4484
- | { type: "insert-hard-break" }
4485
- | { type: "split-paragraph" },
4486
- ): string {
4487
- switch (command.type) {
4488
- case "insert-text":
4489
- return "text.insert";
4490
- case "delete-backward":
4491
- return "text.delete-backward";
4492
- case "delete-forward":
4493
- return "text.delete-forward";
4494
- case "insert-tab":
4495
- case "outdent-tab":
4496
- return "text.insert-tab";
4497
- case "insert-hard-break":
4498
- return "text.insert-hard-break";
4499
- case "split-paragraph":
4500
- return "paragraph.split";
4501
- }
4502
- }
4503
-
4504
- function applyListAwareTextCommand(
4505
- context: NonNullable<ReturnType<typeof getStoryMutationContext>>,
4506
- command:
4507
- | { type: "insert-text"; text: string }
4508
- | { type: "delete-backward" }
4509
- | { type: "delete-forward" }
4510
- | { type: "insert-tab" }
4511
- | { type: "outdent-tab" }
4512
- | { type: "insert-hard-break" }
4513
- | { type: "split-paragraph" },
4514
- ): {
4515
- changed: boolean;
4516
- document: EditorSessionState["canonicalDocument"];
4517
- selection: InternalSelectionSnapshot;
4518
- } | null {
4519
- const paragraphContext = resolveActiveParagraphContext(context.localSnapshot);
4520
- if (!paragraphContext?.paragraph.numbering) {
4521
- return null;
4522
- }
4523
-
4524
- switch (command.type) {
4525
- case "insert-tab": {
4526
- const result = indentListItems(
4527
- context.localDocument,
4528
- [paragraphContext.paragraphIndex],
4529
- { timestamp: context.timestamp },
4530
- );
4531
- return createListMutationResult(result, context.localSnapshot.selection);
4532
- }
4533
- case "outdent-tab": {
4534
- const result = outdentListItems(
4535
- context.localDocument,
4536
- [paragraphContext.paragraphIndex],
4537
- { timestamp: context.timestamp },
4538
- );
4539
- return createListMutationResult(result, context.localSnapshot.selection);
4540
- }
4541
- case "delete-backward": {
4542
- if (!paragraphContext.atParagraphStart || !context.localSnapshot.selection.isCollapsed) {
4543
- return null;
4544
- }
4545
- const result = backspaceAtListStart(
4546
- context.localDocument,
4547
- paragraphContext.paragraphIndex,
4548
- { timestamp: context.timestamp },
4549
- );
4550
- return result.handled
4551
- ? createListMutationResult(result, context.localSnapshot.selection)
4552
- : null;
4553
- }
4554
- case "split-paragraph": {
4555
- if (!context.localSnapshot.selection.isCollapsed || !paragraphContext.isEmpty) {
4556
- return null;
4557
- }
4558
- const result = splitListParagraph(
4559
- context.localDocument,
4560
- paragraphContext.paragraphIndex,
4561
- true,
4562
- { timestamp: context.timestamp },
4563
- );
4564
- return result.action === "split"
4565
- ? null
4566
- : createListMutationResult(result, context.localSnapshot.selection);
4567
- }
4568
- default:
4569
- return null;
4570
- }
4571
- }
4572
-
4573
- function createListMutationResult(
4574
- result: {
4575
- document: EditorSessionState["canonicalDocument"];
4576
- affectedParagraphIndexes: number[];
4577
- },
4578
- selection: RuntimeRenderSnapshot["selection"],
4579
- ): {
4580
- changed: boolean;
4581
- document: EditorSessionState["canonicalDocument"];
4582
- selection: InternalSelectionSnapshot;
4583
- } {
4584
- return {
4585
- changed: result.affectedParagraphIndexes.length > 0,
4586
- document: result.document,
4587
- selection: toRuntimeSelectionSnapshot(selection),
4588
- };
4589
- }
4467
+ const DISPATCH_CONTEXT: DispatchContext = {
4468
+ getStoryMutationContext,
4469
+ dispatchStoryMutationResult,
4470
+ resolveActiveParagraphContext,
4471
+ toRuntimeSelectionSnapshot,
4472
+ };
4590
4473
 
4591
4474
  function resolveActiveParagraphContext(
4592
4475
  snapshot: Pick<RuntimeRenderSnapshot, "surface" | "selection">,
@@ -896,7 +896,9 @@ function createLoadingRuntimeBridge(input: {
896
896
  openComment: () => undefined,
897
897
  resolveComment: () => undefined,
898
898
  reopenComment: () => undefined,
899
- addCommentReply: () => undefined,
899
+ addCommentReply: () => {
900
+ throw createLoadingBoundaryError(input.snapshot.documentId, "comment");
901
+ },
900
902
  editCommentBody: () => undefined,
901
903
  acceptChange: () => undefined,
902
904
  rejectChange: () => undefined,
@@ -1007,6 +1009,8 @@ function createLoadingRuntimeBridge(input: {
1007
1009
  editorStateChannel: createEditorStateChannel(),
1008
1010
  getPerfCountersSnapshot: () => ({}),
1009
1011
  resetPerfCounters: () => undefined,
1012
+ setVisibleBlockRange: () => undefined,
1013
+ requestViewportRefresh: () => undefined,
1010
1014
  };
1011
1015
  }
1012
1016
 
@@ -2,6 +2,7 @@ import type {
2
2
  StyleCatalogSnapshot,
3
3
  WorkflowBlockedCommandReason,
4
4
  } from "../api/public-types.ts";
5
+ import { CAPABILITY_BY_ID } from "../runtime/editor-surface/capabilities.ts";
5
6
 
6
7
  export interface ShortcutKeyInput {
7
8
  key: string;
@@ -113,87 +114,35 @@ export function resolveShellShortcut(
113
114
  return { kind: "delegate", shortcut: "zoom-reset" };
114
115
  }
115
116
 
116
- if (isPasteTextOnlyShortcut(input, key)) {
117
- return {
118
- kind: "block",
119
- command: "pasteTextOnly",
120
- reason: createUnsupportedShortcutReason(
121
- "Plain-text paste is not supported in the mounted editor yet.",
122
- ),
123
- };
124
- }
125
-
126
117
  if (isReplaceShortcut(input, key)) {
127
- return {
128
- kind: "block",
129
- command: "replaceText",
130
- reason: createUnsupportedShortcutReason(
131
- "Replace shortcuts are not supported in the mounted editor yet.",
132
- ),
133
- };
118
+ return resolveBlockedCapability("replaceText");
134
119
  }
135
120
 
136
121
  if (
137
122
  isGoToShortcut(input, key) ||
138
123
  (key === "f5" && !input.shiftKey)
139
124
  ) {
140
- return {
141
- kind: "block",
142
- command: "goTo",
143
- reason: createUnsupportedShortcutReason(
144
- "Go To shortcuts are not supported in the mounted editor yet.",
145
- ),
146
- };
125
+ return resolveBlockedCapability("goTo");
147
126
  }
148
127
 
149
128
  if (isModShiftShortcut(input, key, "e")) {
150
- return {
151
- kind: "block",
152
- command: "toggleTrackChanges",
153
- reason: createUnsupportedShortcutReason(
154
- "Track changes authoring shortcuts are not supported in the mounted editor.",
155
- ),
156
- };
129
+ return resolveBlockedCapability("toggleTrackChanges");
157
130
  }
158
131
 
159
132
  if (key === "f7" && !input.shiftKey) {
160
- return {
161
- kind: "block",
162
- command: "checkSpelling",
163
- reason: createUnsupportedShortcutReason(
164
- "Spelling shortcuts are not supported in the mounted editor.",
165
- ),
166
- };
133
+ return resolveBlockedCapability("checkSpelling");
167
134
  }
168
135
 
169
136
  if (key === "f7" && input.shiftKey) {
170
- return {
171
- kind: "block",
172
- command: "openThesaurus",
173
- reason: createUnsupportedShortcutReason(
174
- "Thesaurus shortcuts are not supported in the mounted editor.",
175
- ),
176
- };
137
+ return resolveBlockedCapability("openThesaurus");
177
138
  }
178
139
 
179
140
  if (key === "f8") {
180
- return {
181
- kind: "block",
182
- command: "extendSelection",
183
- reason: createUnsupportedShortcutReason(
184
- "Extend-selection shortcuts are not supported in the mounted editor.",
185
- ),
186
- };
141
+ return resolveBlockedCapability("extendSelection");
187
142
  }
188
143
 
189
144
  if (key === "f5" && input.shiftKey) {
190
- return {
191
- kind: "block",
192
- command: "lastEdit",
193
- reason: createUnsupportedShortcutReason(
194
- "Last-edit shortcuts are not supported in the mounted editor.",
195
- ),
196
- };
145
+ return resolveBlockedCapability("lastEdit");
197
146
  }
198
147
 
199
148
  return { kind: "none" };
@@ -262,10 +211,27 @@ export function resolveHeadingShortcutStyleId(
262
211
  return null;
263
212
  }
264
213
 
265
- function createUnsupportedShortcutReason(message: string): WorkflowBlockedCommandReason {
214
+ /**
215
+ * Look up a `blocked` capability by id and produce the matching
216
+ * `ShellShortcutResolution`. The capability's `blockReason` becomes
217
+ * the dispatcher's returned reason, so editing the user-facing
218
+ * message in `capabilities.ts` is enough to update every shell-layer
219
+ * block path — no dispatcher change needed. Throws if the id is not
220
+ * registered as a blocked capability; the C.1 anchor-check test
221
+ * guarantees every id this file references is present.
222
+ */
223
+ function resolveBlockedCapability(id: string): ShellShortcutResolution {
224
+ const cap = CAPABILITY_BY_ID.get(id);
225
+ if (!cap || cap.kind !== "blocked" || !cap.blockReason) {
226
+ throw new Error(
227
+ `resolveShellShortcut: capability ${id} is not registered as a blocked entry. ` +
228
+ "See src/runtime/editor-surface/capabilities.ts.",
229
+ );
230
+ }
266
231
  return {
267
- code: "unsupported_surface",
268
- message,
232
+ kind: "block",
233
+ command: id,
234
+ reason: cap.blockReason as WorkflowBlockedCommandReason,
269
235
  };
270
236
  }
271
237
 
@@ -357,9 +323,3 @@ function isZoomOutShortcut(input: ShortcutKeyInput, key: string): boolean {
357
323
  (key === "-" || key === "_" || key === "subtract");
358
324
  }
359
325
 
360
- function isPasteTextOnlyShortcut(input: ShortcutKeyInput, key: string): boolean {
361
- return key === "v" &&
362
- Boolean(input.ctrlKey || input.metaKey) &&
363
- !input.altKey &&
364
- Boolean(input.shiftKey);
365
- }