@beyondwork/docx-react-component 1.0.58 → 1.0.60

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 (135) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -1
  3. package/src/api/awareness-identity-types.ts +4 -2
  4. package/src/api/comment-negotiation-types.ts +4 -1
  5. package/src/api/external-custody-types.ts +16 -0
  6. package/src/api/internal/build-ref-projections.ts +108 -0
  7. package/src/api/package-version.ts +1 -1
  8. package/src/api/participants-types.ts +11 -1
  9. package/src/api/public-types.ts +980 -10
  10. package/src/api/scope-metadata-resolver-types.ts +6 -0
  11. package/src/compare/diff-engine.ts +3 -0
  12. package/src/core/commands/formatting-commands.ts +1 -0
  13. package/src/core/commands/index.ts +225 -16
  14. package/src/core/commands/legacy-form-field-commands.ts +181 -0
  15. package/src/core/commands/table-structure-commands.ts +149 -31
  16. package/src/core/selection/mapping.ts +20 -0
  17. package/src/core/state/editor-state.ts +4 -1
  18. package/src/index.ts +28 -0
  19. package/src/io/docx-session.ts +22 -3
  20. package/src/io/export/export-session.ts +11 -7
  21. package/src/io/export/ooxml-namespaces.ts +47 -0
  22. package/src/io/export/reattach-preserved-parts.ts +4 -16
  23. package/src/io/export/serialize-comments.ts +3 -131
  24. package/src/io/export/serialize-ffdata.ts +89 -0
  25. package/src/io/export/serialize-headers-footers.ts +5 -0
  26. package/src/io/export/serialize-main-document.ts +224 -34
  27. package/src/io/export/serialize-numbering.ts +22 -2
  28. package/src/io/export/serialize-revisions.ts +99 -0
  29. package/src/io/export/serialize-tables.ts +9 -0
  30. package/src/io/export/split-review-boundaries.ts +1 -0
  31. package/src/io/export/table-properties-xml.ts +14 -0
  32. package/src/io/load-scheduler.ts +70 -28
  33. package/src/io/normalize/normalize-text.ts +13 -0
  34. package/src/io/ooxml/_mini-xml.ts +198 -0
  35. package/src/io/ooxml/canonicalize-payload.ts +1 -4
  36. package/src/io/ooxml/chart/chart-style-table.ts +4 -3
  37. package/src/io/ooxml/chart/parse-chart-space.ts +2 -4
  38. package/src/io/ooxml/chart/parse-series.ts +2 -1
  39. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  40. package/src/io/ooxml/chart/types.ts +6 -434
  41. package/src/io/ooxml/comment-presentation-payload.ts +6 -5
  42. package/src/io/ooxml/highlight-colors.ts +8 -5
  43. package/src/io/ooxml/parse-anchor.ts +68 -53
  44. package/src/io/ooxml/parse-comments.ts +14 -142
  45. package/src/io/ooxml/parse-complex-content.ts +3 -106
  46. package/src/io/ooxml/parse-drawing.ts +100 -195
  47. package/src/io/ooxml/parse-ffdata.ts +93 -0
  48. package/src/io/ooxml/parse-fields.ts +7 -146
  49. package/src/io/ooxml/parse-fill.ts +88 -8
  50. package/src/io/ooxml/parse-font-table.ts +5 -105
  51. package/src/io/ooxml/parse-footnotes.ts +28 -152
  52. package/src/io/ooxml/parse-headers-footers.ts +106 -212
  53. package/src/io/ooxml/parse-inline-media.ts +3 -200
  54. package/src/io/ooxml/parse-main-document.ts +180 -217
  55. package/src/io/ooxml/parse-numbering.ts +154 -335
  56. package/src/io/ooxml/parse-object.ts +147 -0
  57. package/src/io/ooxml/parse-ole-relationship.ts +82 -0
  58. package/src/io/ooxml/parse-paragraph-formatting.ts +7 -10
  59. package/src/io/ooxml/parse-picture-sdt.ts +85 -0
  60. package/src/io/ooxml/parse-picture.ts +72 -42
  61. package/src/io/ooxml/parse-revisions.ts +285 -51
  62. package/src/io/ooxml/parse-settings.ts +6 -99
  63. package/src/io/ooxml/parse-shapes.ts +25 -140
  64. package/src/io/ooxml/parse-styles.ts +3 -218
  65. package/src/io/ooxml/parse-tables.ts +76 -256
  66. package/src/io/ooxml/parse-theme.ts +1 -4
  67. package/src/io/ooxml/property-grab-bag.ts +5 -47
  68. package/src/io/ooxml/workflow-payload.ts +6 -1
  69. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  70. package/src/io/ooxml/xml-parser.ts +183 -0
  71. package/src/legal/bookmarks.ts +1 -1
  72. package/src/legal/cross-references.ts +1 -1
  73. package/src/legal/defined-terms.ts +1 -1
  74. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  75. package/src/legal/signature-blocks.ts +1 -1
  76. package/src/model/canonical-document.ts +159 -6
  77. package/src/model/chart-types.ts +439 -0
  78. package/src/model/snapshot.ts +5 -1
  79. package/src/review/store/comment-remapping.ts +24 -11
  80. package/src/review/store/revision-actions.ts +482 -2
  81. package/src/review/store/revision-store.ts +15 -0
  82. package/src/review/store/revision-types.ts +76 -0
  83. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  84. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  85. package/src/runtime/diagnostics/build-diagnostic.ts +153 -0
  86. package/src/runtime/diagnostics/code-metadata-table.ts +230 -0
  87. package/src/runtime/document-runtime.ts +821 -54
  88. package/src/runtime/document-search.ts +115 -0
  89. package/src/runtime/edit-ops/index.ts +18 -2
  90. package/src/runtime/footnote-resolver.ts +130 -0
  91. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  92. package/src/runtime/layout/layout-engine-version.ts +37 -1
  93. package/src/runtime/layout/page-graph.ts +14 -1
  94. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  95. package/src/runtime/numbering-prefix.ts +17 -0
  96. package/src/runtime/query-scopes.ts +108 -10
  97. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  98. package/src/runtime/revision-runtime.ts +27 -1
  99. package/src/runtime/selection/post-edit-validator.ts +60 -6
  100. package/src/runtime/structure-ops/index.ts +20 -4
  101. package/src/runtime/surface-projection.ts +290 -21
  102. package/src/runtime/table-schema.ts +6 -0
  103. package/src/runtime/theme-color-resolver.ts +2 -2
  104. package/src/runtime/units.ts +9 -0
  105. package/src/runtime/workflow-rail-segments.ts +4 -0
  106. package/src/ui/WordReviewEditor.tsx +187 -43
  107. package/src/ui/editor-runtime-boundary.ts +10 -0
  108. package/src/ui/editor-shell-view.tsx +4 -1
  109. package/src/ui/headless/chrome-registry.ts +53 -0
  110. package/src/ui/headless/selection-tool-resolver.ts +11 -1
  111. package/src/ui-tailwind/chrome/chrome-preset-model.ts +13 -0
  112. package/src/ui-tailwind/chrome/tw-command-palette-mount.tsx +96 -0
  113. package/src/ui-tailwind/chrome/tw-context-menu.tsx +2 -1
  114. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +5 -4
  115. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +6 -2
  116. package/src/ui-tailwind/chrome/use-container-breakpoint.ts +111 -0
  117. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +0 -9
  118. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +1 -0
  119. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +6 -7
  120. package/src/ui-tailwind/editor-surface/pm-schema.ts +87 -25
  121. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +9 -0
  122. package/src/ui-tailwind/editor-surface/shape-renderer.ts +76 -14
  123. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +18 -1
  124. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +2 -0
  125. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +18 -2
  126. package/src/ui-tailwind/index.ts +9 -0
  127. package/src/ui-tailwind/page-chrome-model.ts +77 -5
  128. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +56 -1
  129. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +2 -0
  130. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +116 -113
  131. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +2 -2
  132. package/src/ui-tailwind/theme/tokens.ts +14 -0
  133. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +5 -0
  134. package/src/ui-tailwind/tw-review-workspace.tsx +29 -87
  135. package/src/validation/diagnostics.ts +1 -0
@@ -44,6 +44,7 @@ import type {
44
44
  SectionLayoutPatch,
45
45
  SectionPageNumberingPatch,
46
46
  SearchOptions,
47
+ SetSelectionOptions,
47
48
  SearchResultSnapshot,
48
49
  SelectionSnapshot as PublicSelectionSnapshot,
49
50
  SuggestionEntrySnapshot,
@@ -76,6 +77,7 @@ import type {
76
77
  ZoomLevel,
77
78
  } from "../api/public-types";
78
79
  import { MetadataResolverMissingError } from "../api/public-types";
80
+ import { buildRefProjections } from "../api/internal/build-ref-projections.ts";
79
81
  import { readHarnessDebugPortsFlag } from "../internal/harness-debug-ports.ts";
80
82
  import type { ScopeMetadataResolver } from "../api/scope-metadata-resolver-types.ts";
81
83
  import {
@@ -189,6 +191,7 @@ import type {
189
191
  SuggestionCardModel,
190
192
  } from "./headless/selection-toolbar-model";
191
193
  import { resolveActiveSelectionTool } from "./headless/selection-tool-resolver";
194
+ import { resolveSelectionToolRegistry } from "./headless/chrome-registry";
192
195
  import { type EditorCommandBag, useCommandBag } from "./editor-command-bag.ts";
193
196
  import {
194
197
  resolveHeadingShortcutStyleId,
@@ -566,7 +569,16 @@ export function __createWordReviewEditorRefBridge(
566
569
  // Pending-conflict queue for this bridge instance. Keyed by conflictKey(...).
567
570
  const pendingConflicts = new Map<string, PendingConflict>();
568
571
 
569
- return {
572
+ // v2.0.0 Track D — purpose-grouped projections dispatch via this holder
573
+ // so they stay in lockstep with the ref even if a rebuild is triggered.
574
+ const refHolder: { current: WordReviewEditorRef | null } = { current: null };
575
+ const projections = buildRefProjections(() => {
576
+ const r = refHolder.current;
577
+ if (!r) throw new Error("ref projection used before ref initialization");
578
+ return r;
579
+ });
580
+
581
+ const refValue: WordReviewEditorRef = {
570
582
  focus: () => runtime.focus(),
571
583
  blur: () => runtime.blur(),
572
584
  undo: () => runtime.undo(),
@@ -595,6 +607,12 @@ export function __createWordReviewEditorRefBridge(
595
607
  addScope: (params) => runtime.addScope(params),
596
608
  getScope: (scopeId) => runtime.getScope(scopeId),
597
609
  removeScope: (scopeId) => runtime.removeScope(scopeId),
610
+ addInvisibleScope: (params) => runtime.addInvisibleScope(params),
611
+ setScopeVisibility: (scopeId, visibility) => runtime.setScopeVisibility(scopeId, visibility),
612
+ getScopeVisibility: (scopeId) => runtime.getScopeVisibility(scopeId),
613
+ setScopeChromeVisibility: (state) => runtime.setScopeChromeVisibility(state),
614
+ getScopeChromeVisibility: () => runtime.getScopeChromeVisibility(),
615
+ subscribeToScopeQuery: (filter, callback) => runtime.subscribeToScopeQuery(filter, callback),
598
616
  acceptChange: (changeId) => runtime.acceptChange(changeId),
599
617
  rejectChange: (changeId) => runtime.rejectChange(changeId),
600
618
  acceptAllChanges: () => runtime.acceptAllChanges(),
@@ -760,7 +778,7 @@ export function __createWordReviewEditorRefBridge(
760
778
  clearSearch: () => {
761
779
  mountedSurface?.clearSearch();
762
780
  },
763
- setSelection: (selection) => {
781
+ setSelection: (selection, _options?: SetSelectionOptions) => {
764
782
  applyRuntimeSelection(
765
783
  runtime,
766
784
  normalizeRequestedSelection(runtime.getRenderSnapshot(), selection),
@@ -937,6 +955,8 @@ export function __createWordReviewEditorRefBridge(
937
955
  applyRuntimeSelection(runtime, createSelectionFromAnchor(hits[0]!));
938
956
  return hits.length;
939
957
  },
958
+ findTextWithStyle: (query, filter, opts) => runtime.findTextWithStyle(query, filter, opts),
959
+ selectTextWithStyle: (query, filter, opts) => runtime.selectTextWithStyle(query, filter, opts),
940
960
  // P17 — metadata persistence toggle + convert methods.
941
961
  setMetadataPersistenceMode: (mode) => {
942
962
  if (mode === "external" && !(options?.resolverRef?.current ?? null)) {
@@ -1140,7 +1160,10 @@ export function __createWordReviewEditorRefBridge(
1140
1160
  replaceWorkflowMarkupText: (markupId, text) => {
1141
1161
  runtime.replaceWorkflowMarkupText(markupId, text);
1142
1162
  },
1163
+ ...projections,
1143
1164
  };
1165
+ refHolder.current = refValue;
1166
+ return refValue;
1144
1167
  }
1145
1168
 
1146
1169
  export function __applyRuntimeTextCommand(
@@ -1154,6 +1177,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1154
1177
  function WordReviewEditor(props, ref) {
1155
1178
  const {
1156
1179
  currentUser,
1180
+ shellHeader,
1157
1181
  ydoc,
1158
1182
  awareness,
1159
1183
  hostAdapter,
@@ -1191,6 +1215,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1191
1215
  suggestionsEnabled = false,
1192
1216
  showReviewPanel = true,
1193
1217
  chromeVisibility,
1218
+ density,
1219
+ customSelectionTools,
1194
1220
  } = props;
1195
1221
 
1196
1222
  const [activeRailTab, setActiveRailTab] = useState<ReviewRailTab>("comments");
@@ -1218,6 +1244,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1218
1244
  new Map<string, { choice: string; mergedValue?: Record<string, unknown> }>(),
1219
1245
  );
1220
1246
  const metadataConflictPendingRef = useRef(new Map<string, PendingConflict>());
1247
+ const suppressNextAwarenessPublishRef = useRef(false);
1221
1248
  const {
1222
1249
  runtime,
1223
1250
  loadError,
@@ -1472,6 +1499,21 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1472
1499
  activeRuntime.setDocumentMode(suggestionsEnabled ? "suggesting" : "editing");
1473
1500
  }, [activeRuntime, suggestionsEnabled]);
1474
1501
 
1502
+ // design-close-chrome Phase 2 — density contract (designsystem §4.2).
1503
+ // When the `density` prop is supplied, drive the root `data-density`
1504
+ // attribute that powers the `--space-density-multiplier` cascade in
1505
+ // `tokens.css`. When the prop is omitted, leave the attribute alone
1506
+ // so the host (or `useDensity()`) retains control — the prop is
1507
+ // strictly an opt-in override, mirroring React's controlled vs.
1508
+ // uncontrolled input pattern. On unmount the attribute is left in
1509
+ // place so a subsequent host-managed setter (e.g. the density picker
1510
+ // in the shell view menu) can persist without a flicker.
1511
+ useEffect(() => {
1512
+ if (density === undefined) return;
1513
+ if (typeof document === "undefined") return;
1514
+ document.documentElement.dataset.density = density;
1515
+ }, [density]);
1516
+
1475
1517
  const markCurrentSectionForReview = useCallback((input?: {
1476
1518
  sectionIndex?: number;
1477
1519
  label?: string;
@@ -1568,14 +1610,18 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1568
1610
  return;
1569
1611
  }
1570
1612
 
1571
- setLocalCursorState(awareness, {
1572
- userId: currentUser.userId,
1573
- displayName: currentUser.displayName,
1574
- color: getCursorColorForUser(currentUser.userId),
1575
- anchor: snapshot.selection.anchor,
1576
- head: snapshot.selection.head,
1577
- storyTarget: snapshot.activeStory,
1578
- });
1613
+ if (suppressNextAwarenessPublishRef.current) {
1614
+ suppressNextAwarenessPublishRef.current = false;
1615
+ } else {
1616
+ setLocalCursorState(awareness, {
1617
+ userId: currentUser.userId,
1618
+ displayName: currentUser.displayName,
1619
+ color: getCursorColorForUser(currentUser.userId),
1620
+ anchor: snapshot.selection.anchor,
1621
+ head: snapshot.selection.head,
1622
+ storyTarget: snapshot.activeStory,
1623
+ });
1624
+ }
1579
1625
  }, [
1580
1626
  awareness,
1581
1627
  currentUser.displayName,
@@ -1600,7 +1646,17 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1600
1646
 
1601
1647
  useImperativeHandle(
1602
1648
  ref,
1603
- () => ({
1649
+ () => {
1650
+ // v2.0.0 Track D — projections dispatch through a holder so they
1651
+ // always point at the freshly-built ref on every
1652
+ // useImperativeHandle re-run.
1653
+ const refHolder: { current: WordReviewEditorRef | null } = { current: null };
1654
+ const projections = buildRefProjections(() => {
1655
+ const r = refHolder.current;
1656
+ if (!r) throw new Error("ref projection used before ref initialization");
1657
+ return r;
1658
+ });
1659
+ const refValue: WordReviewEditorRef = ({
1604
1660
  focus: () => activeRuntime.focus(),
1605
1661
  blur: () => activeRuntime.blur(),
1606
1662
  undo: () => activeRuntime.undo(),
@@ -1635,6 +1691,12 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1635
1691
  addScope: (params) => activeRuntime.addScope(params),
1636
1692
  getScope: (scopeId) => activeRuntime.getScope(scopeId),
1637
1693
  removeScope: (scopeId) => activeRuntime.removeScope(scopeId),
1694
+ addInvisibleScope: (params) => activeRuntime.addInvisibleScope(params),
1695
+ setScopeVisibility: (scopeId, visibility) => activeRuntime.setScopeVisibility(scopeId, visibility),
1696
+ getScopeVisibility: (scopeId) => activeRuntime.getScopeVisibility(scopeId),
1697
+ setScopeChromeVisibility: (state) => activeRuntime.setScopeChromeVisibility(state),
1698
+ getScopeChromeVisibility: () => activeRuntime.getScopeChromeVisibility(),
1699
+ subscribeToScopeQuery: (filter, callback) => activeRuntime.subscribeToScopeQuery(filter, callback),
1638
1700
  acceptChange: (changeId) => activeRuntime.acceptChange(changeId),
1639
1701
  rejectChange: (changeId) => activeRuntime.rejectChange(changeId),
1640
1702
  acceptAllChanges: () => activeRuntime.acceptAllChanges(),
@@ -1849,11 +1911,22 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1849
1911
  clearSearch: () => {
1850
1912
  surfaceRef.current?.clearSearch();
1851
1913
  },
1852
- setSelection: (selection) => {
1853
- applyRuntimeSelection(
1854
- activeRuntime,
1855
- normalizeRequestedSelection(activeRuntime.getRenderSnapshot(), selection),
1856
- );
1914
+ setSelection: (selection, options?: SetSelectionOptions) => {
1915
+ const snap = activeRuntime.getRenderSnapshot();
1916
+ const normalized = normalizeRequestedSelection(snap, selection);
1917
+ if (options?.silent) {
1918
+ // Only suppress awareness publish if the selection will actually change.
1919
+ // If positions are identical the cursor-state useEffect won't re-run,
1920
+ // which would leave the flag set and silently suppress the next real
1921
+ // selection change (stale-flag bug).
1922
+ if (
1923
+ normalized.anchor !== snap.selection.anchor ||
1924
+ normalized.head !== snap.selection.head
1925
+ ) {
1926
+ suppressNextAwarenessPublishRef.current = true;
1927
+ }
1928
+ }
1929
+ applyRuntimeSelection(activeRuntime, normalized);
1857
1930
  },
1858
1931
  scrollToRevision: (revisionId: string) => {
1859
1932
  const revision = activeRuntime.getRenderSnapshot().trackedChanges.revisions.find(
@@ -2032,6 +2105,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2032
2105
  applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(hits[0]!));
2033
2106
  return hits.length;
2034
2107
  },
2108
+ findTextWithStyle: (query, filter, opts) => activeRuntime.findTextWithStyle(query, filter, opts),
2109
+ selectTextWithStyle: (query, filter, opts) => activeRuntime.selectTextWithStyle(query, filter, opts),
2035
2110
  // P17 — metadata persistence toggle + convert methods.
2036
2111
  setMetadataPersistenceMode: (mode) => {
2037
2112
  if (mode === "external" && scopeMetadataResolverRef.current === null) {
@@ -2226,7 +2301,11 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2226
2301
  replaceWorkflowMarkupText: (markupId, text) => {
2227
2302
  activeRuntime.replaceWorkflowMarkupText(markupId, text);
2228
2303
  },
2229
- }),
2304
+ ...projections,
2305
+ }) as WordReviewEditorRef;
2306
+ refHolder.current = refValue;
2307
+ return refValue;
2308
+ },
2230
2309
  [
2231
2310
  activeRuntime,
2232
2311
  clearReviewSectionMarkById,
@@ -2540,29 +2619,40 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2540
2619
  suggestionsSnapshot,
2541
2620
  viewState.activeStory,
2542
2621
  ]);
2543
- const activeSelectionTool = resolveActiveSelectionTool({
2544
- snapshot,
2545
- viewState,
2546
- capabilities,
2547
- documentNavigation,
2548
- styleCatalog,
2549
- formattingState,
2550
- workflowScopeSnapshot,
2551
- interactionGuardSnapshot,
2552
- workflowMarkupSnapshot: workflowMarkupSnapshot ?? undefined,
2553
- suggestionsSnapshot,
2554
- activeRevisionId,
2555
- activeCommentId: snapshot.comments.activeCommentId,
2556
- activeCommentThread,
2557
- activeTableContext,
2558
- activeImageContext,
2559
- activeObjectContext,
2560
- activeListContext: viewState.activeListContext,
2561
- preferListStructureContext: viewState.workspaceMode === "page",
2562
- addCommentDisabledReason,
2563
- suppressedSuggestionRevisionId,
2564
- scopedChromePolicy,
2565
- });
2622
+ // R9 host may supply custom selection-tool registry entries; merge
2623
+ // with the default before resolving. The merger is a pure function
2624
+ // returning the canonical default array by reference when `custom`
2625
+ // is empty, so the typical no-custom case is a single identity call.
2626
+ const selectionToolRegistry = useMemo(
2627
+ () => resolveSelectionToolRegistry(customSelectionTools),
2628
+ [customSelectionTools],
2629
+ );
2630
+ const activeSelectionTool = resolveActiveSelectionTool(
2631
+ {
2632
+ snapshot,
2633
+ viewState,
2634
+ capabilities,
2635
+ documentNavigation,
2636
+ styleCatalog,
2637
+ formattingState,
2638
+ workflowScopeSnapshot,
2639
+ interactionGuardSnapshot,
2640
+ workflowMarkupSnapshot: workflowMarkupSnapshot ?? undefined,
2641
+ suggestionsSnapshot,
2642
+ activeRevisionId,
2643
+ activeCommentId: snapshot.comments.activeCommentId,
2644
+ activeCommentThread,
2645
+ activeTableContext,
2646
+ activeImageContext,
2647
+ activeObjectContext,
2648
+ activeListContext: viewState.activeListContext,
2649
+ preferListStructureContext: viewState.workspaceMode === "page",
2650
+ addCommentDisabledReason,
2651
+ suppressedSuggestionRevisionId,
2652
+ scopedChromePolicy,
2653
+ },
2654
+ selectionToolRegistry,
2655
+ );
2566
2656
  const selectionToolbarSelectionKey = useMemo(
2567
2657
  () =>
2568
2658
  createSelectionToolbarSelectionKey(
@@ -3293,6 +3383,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3293
3383
  )}
3294
3384
  visuallyHiddenStyles={VISUALLY_HIDDEN_STYLES}
3295
3385
  onShellKeyDownCapture={handleShellKeyDownCapture}
3386
+ shellHeader={shellHeader}
3296
3387
  viewState={viewState}
3297
3388
  markupDisplay={liveMarkupDisplay}
3298
3389
  currentUserId={currentUser.userId}
@@ -3395,6 +3486,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3395
3486
  onScopeRejectSuggestionGroup={(payload) => {
3396
3487
  applySuggestionGroupAction(activeRuntime, payload.groupId, "reject");
3397
3488
  }}
3489
+ onDeselectObject={() => activeRuntime.deselectObject()}
3398
3490
  onScopeAskAgent={(payload) => {
3399
3491
  // Resolve the scope's anchor + story from the facet's card
3400
3492
  // model so the agent request carries the canonical range.
@@ -3449,13 +3541,28 @@ function applySuggestionGroupAction(
3449
3541
  ): void {
3450
3542
  const snapshot = runtime.getSuggestionsSnapshot();
3451
3543
  const group = snapshot.groups?.find((entry) => entry.groupId === groupId);
3452
- if (!group) return;
3544
+ const op = action === "accept" ? "acceptSuggestionGroup" : "rejectSuggestionGroup";
3545
+ if (!group) {
3546
+ runtime.emitTransientWarning({
3547
+ warningId: `suggestion-group-unknown-${groupId}-${Date.now()}`,
3548
+ code: "review_target_not_found",
3549
+ severity: "info",
3550
+ message: `${op}("${groupId}") skipped: unknown groupId.`,
3551
+ source: "review",
3552
+ details: { op, targetId: groupId, reason: "group_unknown" },
3553
+ });
3554
+ return;
3555
+ }
3453
3556
  const byId = new Map(
3454
3557
  snapshot.suggestions.map((entry) => [entry.suggestionId, entry]),
3455
3558
  );
3559
+ const skippedSuggestions: string[] = [];
3456
3560
  for (const suggestionId of group.suggestionIds) {
3457
3561
  const suggestion = byId.get(suggestionId);
3458
- if (!suggestion) continue;
3562
+ if (!suggestion) {
3563
+ skippedSuggestions.push(suggestionId);
3564
+ continue;
3565
+ }
3459
3566
  for (const changeId of suggestion.changeIds) {
3460
3567
  if (action === "accept") {
3461
3568
  runtime.acceptChange(changeId);
@@ -3464,6 +3571,21 @@ function applySuggestionGroupAction(
3464
3571
  }
3465
3572
  }
3466
3573
  }
3574
+ if (skippedSuggestions.length > 0) {
3575
+ runtime.emitTransientWarning({
3576
+ warningId: `suggestion-group-stale-${groupId}-${Date.now()}`,
3577
+ code: "review_target_not_found",
3578
+ severity: "info",
3579
+ message: `${op}("${groupId}") partially skipped: ${skippedSuggestions.length} suggestion(s) no longer in snapshot.`,
3580
+ source: "review",
3581
+ details: {
3582
+ op,
3583
+ targetId: groupId,
3584
+ reason: "suggestion_stale",
3585
+ skippedSuggestionIds: skippedSuggestions,
3586
+ },
3587
+ });
3588
+ }
3467
3589
  }
3468
3590
 
3469
3591
  function applyRuntimeFormattingOperation(
@@ -5036,12 +5158,34 @@ function applyRuntimeDeleteComment(
5036
5158
  commentId: string,
5037
5159
  ): void {
5038
5160
  const snapshot = runtime.getRenderSnapshot();
5039
- if (!snapshot.isReady || snapshot.readOnly || snapshot.fatalError) {
5161
+ // Pre-ready / fatal states stay silent: the host called too early, and
5162
+ // there is no meaningful document yet to signal against. Emitting a
5163
+ // warning here would only add noise to load-time error handling.
5164
+ if (!snapshot.isReady || snapshot.fatalError) {
5165
+ return;
5166
+ }
5167
+ if (snapshot.readOnly) {
5168
+ runtime.emitTransientWarning({
5169
+ warningId: `delete-comment-readonly-${commentId}-${Date.now()}`,
5170
+ code: "review_target_not_found",
5171
+ severity: "info",
5172
+ message: `deleteComment("${commentId}") skipped: editor is read-only.`,
5173
+ source: "review",
5174
+ details: { op: "deleteComment", targetId: commentId, reason: "read_only" },
5175
+ });
5040
5176
  return;
5041
5177
  }
5042
5178
 
5043
5179
  const sessionState = runtime.getSessionState();
5044
5180
  if (!sessionState.canonicalDocument.review.comments[commentId]) {
5181
+ runtime.emitTransientWarning({
5182
+ warningId: `delete-comment-unknown-${commentId}-${Date.now()}`,
5183
+ code: "review_target_not_found",
5184
+ severity: "info",
5185
+ message: `deleteComment("${commentId}") skipped: unknown commentId.`,
5186
+ source: "review",
5187
+ details: { op: "deleteComment", targetId: commentId, reason: "comment_unknown" },
5188
+ });
5045
5189
  return;
5046
5190
  }
5047
5191
 
@@ -1015,6 +1015,7 @@ function createLoadingRuntimeBridge(input: {
1015
1015
  subscribe: () => () => undefined,
1016
1016
  subscribeToEvents: () => () => undefined,
1017
1017
  emitBlockedCommand: () => undefined,
1018
+ emitTransientWarning: () => undefined,
1018
1019
  getRenderSnapshot: () => {
1019
1020
  const progressive = input.progressiveSurfaceRef?.current;
1020
1021
  if (progressive == null) return input.snapshot;
@@ -1186,6 +1187,15 @@ function createLoadingRuntimeBridge(input: {
1186
1187
  resetPerfCounters: () => undefined,
1187
1188
  setVisibleBlockRange: () => undefined,
1188
1189
  requestViewportRefresh: () => undefined,
1190
+ addInvisibleScope: () => ({ scopeId: "", anchor: { kind: "range", from: 0, to: 0, assoc: { start: -1, end: 1 } } }),
1191
+ setScopeVisibility: () => undefined,
1192
+ getScopeVisibility: () => "visible" as const,
1193
+ setScopeChromeVisibility: () => undefined,
1194
+ getScopeChromeVisibility: () => ({ mode: "all" as const }),
1195
+ subscribeToScopeQuery: (_filter, _callback) => () => undefined,
1196
+ findAllText: () => [],
1197
+ findTextWithStyle: () => [],
1198
+ selectTextWithStyle: () => 0,
1189
1199
  };
1190
1200
  }
1191
1201
 
@@ -82,6 +82,7 @@ export interface EditorShellViewProps {
82
82
  selectionToolAnchor?: SelectionToolAnchor | null;
83
83
  documentNavigation?: DocumentNavigationSnapshot;
84
84
  commands: EditorCommandBag;
85
+ shellHeader?: ReactNode;
85
86
  document: ReactNode;
86
87
  onAddCommentFromSelection?: () => void;
87
88
  onAddCommentFromSuggestion?: () => void;
@@ -126,6 +127,8 @@ export interface EditorShellViewProps {
126
127
  }) => void;
127
128
  /** K2 — forwarded from workspace to WordReviewEditor. */
128
129
  onScopeAskAgent?: (payload: { scopeId: string }) => void;
130
+ /** N6 — deselects the currently grabbed object; wired to runtime.deselectObject(). */
131
+ onDeselectObject?: () => void;
129
132
  }
130
133
 
131
134
  export function EditorShellView(props: EditorShellViewProps) {
@@ -154,7 +157,7 @@ export function EditorShellView(props: EditorShellViewProps) {
154
157
  aria-describedby={`${accessibilityInstructionsId} ${accessibilityStatusId}${
155
158
  diagnosticsModeMessage ? ` ${accessibilityAlertId}` : ""
156
159
  }`}
157
- className="wre-editor relative h-full"
160
+ className="wre-editor wre-editor-root relative h-full"
158
161
  onKeyDownCapture={onShellKeyDownCapture}
159
162
  >
160
163
  <p id={accessibilityInstructionsId} style={visuallyHiddenStyles}>
@@ -102,6 +102,59 @@ export const SELECTION_TOOL_REGISTRY: ReadonlyArray<SelectionToolRegistryEntry>
102
102
  { id: "blocked-explainer", surfaces: ["selection-tool"], group: "blocked", precedence: 60 },
103
103
  ];
104
104
 
105
+ /**
106
+ * R10 — options for `resolveSelectionToolRegistry`.
107
+ *
108
+ * `remove` lets a host suppress a default kind entirely (the dedicated
109
+ * builder for that kind is skipped at resolve time). Used together
110
+ * with `custom` when the host wants to both add a new precedence and
111
+ * silence an existing one.
112
+ */
113
+ export interface ResolveSelectionToolRegistryOptions {
114
+ remove?: ReadonlyArray<SelectionToolRegistryEntry["id"]>;
115
+ }
116
+
117
+ /**
118
+ * Merge host-supplied selection-tool entries with the default registry.
119
+ *
120
+ * design-close-chrome Phase 4 / designsystem §6 extensibility. Pure
121
+ * function — callers supply `custom` as a stable array (memoize in
122
+ * React), this returns a fresh, precedence-sorted copy that the
123
+ * resolver can iterate. Dedupe rule: host entries win on id collision
124
+ * (they override default precedence for that id). When `options.remove`
125
+ * lists ids, those are filtered out of the default set before merging.
126
+ *
127
+ * Note: as of this Phase, the resolver builder matrix (see
128
+ * `buildSelectionToolFromRegistryEntry` in `selection-tool-resolver.ts`)
129
+ * still only understands the built-in `SelectionToolKind` union.
130
+ * Widening that union to accept host render functions is a downstream
131
+ * change coordinated with Lane 8 API ergonomics; the merger is shipped
132
+ * first so callers can depend on the final shape while the renderer
133
+ * catches up.
134
+ */
135
+ export function resolveSelectionToolRegistry(
136
+ custom?: ReadonlyArray<SelectionToolRegistryEntry>,
137
+ options?: ResolveSelectionToolRegistryOptions,
138
+ ): ReadonlyArray<SelectionToolRegistryEntry> {
139
+ const remove = options?.remove;
140
+ const hasCustom = custom && custom.length > 0;
141
+ const hasRemove = remove && remove.length > 0;
142
+ if (!hasCustom && !hasRemove) return SELECTION_TOOL_REGISTRY;
143
+ const removed = new Set(remove ?? []);
144
+ const byId = new Map<SelectionToolRegistryEntry["id"], SelectionToolRegistryEntry>();
145
+ for (const entry of SELECTION_TOOL_REGISTRY) {
146
+ if (removed.has(entry.id)) continue;
147
+ byId.set(entry.id, entry);
148
+ }
149
+ for (const entry of custom ?? []) {
150
+ // Host entries are NOT filtered by `remove` — callers can always
151
+ // install an entry they meant to keep even when a matching default
152
+ // would otherwise have been suppressed.
153
+ byId.set(entry.id, entry);
154
+ }
155
+ return [...byId.values()].sort((a, b) => a.precedence - b.precedence);
156
+ }
157
+
105
158
  export const TOOLBAR_CHROME_REGISTRY: ReadonlyArray<ToolbarChromeRegistryEntry> = [
106
159
  {
107
160
  id: "history",
@@ -23,10 +23,20 @@ import {
23
23
  } from "./chrome-registry";
24
24
  import { shouldRenderSelectionToolKind } from "./scoped-chrome-policy";
25
25
 
26
+ /**
27
+ * Resolve the active selection tool for `input`.
28
+ *
29
+ * `registry` defaults to the canonical `SELECTION_TOOL_REGISTRY`. Hosts
30
+ * that want to reorder or shadow default precedences pass in the result
31
+ * of `resolveSelectionToolRegistry(props.customSelectionTools, options)`.
32
+ * Entries iterate in the order provided — callers are responsible for
33
+ * supplying a precedence-sorted array.
34
+ */
26
35
  export function resolveActiveSelectionTool(
27
36
  input: SelectionToolResolverInput,
37
+ registry: ReadonlyArray<SelectionToolRegistryEntry> = SELECTION_TOOL_REGISTRY,
28
38
  ): ActiveSelectionToolModel | null {
29
- for (const entry of SELECTION_TOOL_REGISTRY) {
39
+ for (const entry of registry) {
30
40
  if (!shouldRenderSelectionToolKind(input.scopedChromePolicy, entry.id)) {
31
41
  continue;
32
42
  }
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ EditorRole,
2
3
  WordReviewEditorChromeOptions,
3
4
  WordReviewEditorChromePreset,
4
5
  WordReviewEditorChromeVisibility,
@@ -21,6 +22,7 @@ export function resolveChromePreset(
21
22
  export function resolveChromePresetOptions(
22
23
  chromePreset: WordReviewEditorChromePreset,
23
24
  overrides?: Partial<WordReviewEditorChromeOptions>,
25
+ editorRole?: EditorRole,
24
26
  ): WordReviewEditorChromeOptions {
25
27
  const defaults: Record<WordReviewEditorChromePreset, WordReviewEditorChromeOptions> = {
26
28
  selection: {
@@ -73,10 +75,21 @@ export function resolveChromePresetOptions(
73
75
 
74
76
  return {
75
77
  ...defaults[chromePreset],
78
+ ...resolveRoleAdjustments(editorRole),
76
79
  ...overrides,
77
80
  };
78
81
  }
79
82
 
83
+ function resolveRoleAdjustments(role: EditorRole | undefined): Partial<WordReviewEditorChromeOptions> {
84
+ if (role === "editor") {
85
+ return { showReviewRail: false, showReviewQueueBar: false };
86
+ }
87
+ if (role === "workflow") {
88
+ return { showReviewQueueBar: false };
89
+ }
90
+ return {};
91
+ }
92
+
80
93
  export function resolveChromeVisibilityForPreset(input: {
81
94
  chromePreset: WordReviewEditorChromePreset;
82
95
  chromeOptions?: Partial<WordReviewEditorChromeOptions>;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * TwCommandPaletteMount — ready-to-use mount adapter that wires
3
+ * `TwCommandPalette` + `isCommandPaletteOpenShortcut` together so a
4
+ * host only needs to supply the command graph.
5
+ *
6
+ * design-close-chrome Phase 3 / designsystem §6.25.
7
+ *
8
+ * Why a separate mount component:
9
+ * - `TwCommandPalette` is intentionally pure — no global state, no
10
+ * key subscription. That keeps it testable and composable.
11
+ * - Most hosts want the Ctrl+K / Cmd+K binding, open-state
12
+ * management, and click-off-to-close plumbing out of the box.
13
+ * - This adapter pairs them with a single `groups` prop. Opt-out
14
+ * is trivial: use `TwCommandPalette` directly with your own open
15
+ * state + key handler.
16
+ *
17
+ * The adapter:
18
+ * - Subscribes to window keydown with `isCommandPaletteOpenShortcut`
19
+ * (only when not already open, to let the palette own Escape/Enter).
20
+ * - Calls `event.preventDefault()` so Ctrl+K does not bubble to the
21
+ * browser (avoids the URL-bar focus on macOS Chrome).
22
+ * - Honors a `disabled` flag so hosts can suppress the binding while
23
+ * another modal captures input.
24
+ */
25
+
26
+ import React, { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
27
+
28
+ import {
29
+ TwCommandPalette,
30
+ isCommandPaletteOpenShortcut,
31
+ type CommandPaletteGroup,
32
+ type TwCommandPaletteProps,
33
+ } from "./tw-command-palette";
34
+
35
+ export interface TwCommandPaletteMountProps
36
+ extends Omit<TwCommandPaletteProps, "open" | "onOpenChange"> {
37
+ /** Command graph supplied by the host. */
38
+ groups: readonly CommandPaletteGroup[];
39
+ /**
40
+ * When `true`, the global Ctrl+K / Cmd+K listener is suppressed so
41
+ * a higher-priority modal keeps keyboard focus. Defaults to `false`.
42
+ */
43
+ disabled?: boolean;
44
+ /**
45
+ * Optional: fires when the palette's open state transitions. Callers
46
+ * can use this to emit analytics or to coordinate focus with other
47
+ * chrome surfaces.
48
+ */
49
+ onOpenChange?: (open: boolean) => void;
50
+ /**
51
+ * Optional: the initial open state. Defaults to `false`. Primarily
52
+ * useful for Storybook / tests.
53
+ */
54
+ defaultOpen?: boolean;
55
+ }
56
+
57
+ export function TwCommandPaletteMount(props: TwCommandPaletteMountProps): ReactNode {
58
+ const { disabled = false, defaultOpen = false, onOpenChange, ...paletteProps } = props;
59
+ const [open, setOpen] = useState(defaultOpen);
60
+ // Ref mirrors `open` so the keydown handler can toggle without reading
61
+ // React state (which React 18 strict mode double-invokes inside
62
+ // `setState` updaters). Fire `onOpenChange` exactly once per Ctrl+K.
63
+ const openRef = useRef(defaultOpen);
64
+
65
+ const handleOpenChange = useCallback(
66
+ (next: boolean) => {
67
+ openRef.current = next;
68
+ setOpen(next);
69
+ onOpenChange?.(next);
70
+ },
71
+ [onOpenChange],
72
+ );
73
+
74
+ useEffect(() => {
75
+ if (disabled) return;
76
+ if (typeof window === "undefined") return;
77
+ const handler = (event: KeyboardEvent): void => {
78
+ if (!isCommandPaletteOpenShortcut(event)) return;
79
+ event.preventDefault();
80
+ // Toggle: Ctrl+K with palette open dismisses it. That matches
81
+ // VSCode / most IDE palettes. Side effect (onOpenChange) lives
82
+ // here — NOT inside a setState updater — so React 18 strict mode
83
+ // cannot fire it twice for a single keypress.
84
+ const next = !openRef.current;
85
+ openRef.current = next;
86
+ setOpen(next);
87
+ onOpenChange?.(next);
88
+ };
89
+ window.addEventListener("keydown", handler);
90
+ return () => {
91
+ window.removeEventListener("keydown", handler);
92
+ };
93
+ }, [disabled, onOpenChange]);
94
+
95
+ return <TwCommandPalette {...paletteProps} open={open} onOpenChange={handleOpenChange} />;
96
+ }