@beyondwork/docx-react-component 1.0.36 → 1.0.38

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 (107) hide show
  1. package/README.md +103 -13
  2. package/package.json +1 -1
  3. package/src/api/package-version.ts +13 -0
  4. package/src/api/public-types.ts +402 -1
  5. package/src/core/commands/index.ts +18 -1
  6. package/src/core/commands/section-layout-commands.ts +58 -0
  7. package/src/core/commands/table-grid.ts +431 -0
  8. package/src/core/commands/table-structure-commands.ts +815 -55
  9. package/src/core/selection/mapping.ts +6 -0
  10. package/src/io/docx-session.ts +24 -9
  11. package/src/io/export/build-app-properties-xml.ts +88 -0
  12. package/src/io/export/serialize-comments.ts +6 -1
  13. package/src/io/export/serialize-footnotes.ts +10 -9
  14. package/src/io/export/serialize-headers-footers.ts +11 -10
  15. package/src/io/export/serialize-main-document.ts +328 -50
  16. package/src/io/export/serialize-numbering.ts +114 -24
  17. package/src/io/export/serialize-tables.ts +87 -11
  18. package/src/io/export/table-properties-xml.ts +174 -20
  19. package/src/io/export/twip.ts +66 -0
  20. package/src/io/normalize/normalize-text.ts +20 -0
  21. package/src/io/ooxml/parse-footnotes.ts +62 -1
  22. package/src/io/ooxml/parse-headers-footers.ts +62 -1
  23. package/src/io/ooxml/parse-main-document.ts +158 -1
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/legal/bookmarks.ts +78 -0
  26. package/src/model/canonical-document.ts +45 -0
  27. package/src/review/store/scope-tag-diff.ts +130 -0
  28. package/src/runtime/document-layout.ts +4 -2
  29. package/src/runtime/document-navigation.ts +2 -306
  30. package/src/runtime/document-runtime.ts +287 -11
  31. package/src/runtime/layout/default-page-format.ts +96 -0
  32. package/src/runtime/layout/docx-font-loader.ts +143 -0
  33. package/src/runtime/layout/index.ts +233 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +59 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +628 -0
  36. package/src/runtime/layout/layout-invalidation.ts +257 -0
  37. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  38. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  39. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  40. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  43. package/src/runtime/layout/page-graph.ts +452 -0
  44. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  45. package/src/runtime/layout/page-story-resolver.ts +195 -0
  46. package/src/runtime/layout/paginated-layout-engine.ts +921 -0
  47. package/src/runtime/layout/project-block-fragments.ts +91 -0
  48. package/src/runtime/layout/public-facet.ts +1398 -0
  49. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  50. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  51. package/src/runtime/layout/table-render-plan.ts +229 -0
  52. package/src/runtime/render/block-fragment-projection.ts +35 -0
  53. package/src/runtime/render/decoration-resolver.ts +189 -0
  54. package/src/runtime/render/index.ts +57 -0
  55. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  56. package/src/runtime/render/render-frame-types.ts +317 -0
  57. package/src/runtime/render/render-kernel.ts +755 -0
  58. package/src/runtime/scope-tag-registry.ts +95 -0
  59. package/src/runtime/surface-projection.ts +1 -0
  60. package/src/runtime/text-ack-range.ts +49 -0
  61. package/src/runtime/view-state.ts +67 -0
  62. package/src/runtime/workflow-markup.ts +1 -5
  63. package/src/runtime/workflow-rail-segments.ts +280 -0
  64. package/src/ui/WordReviewEditor.tsx +99 -15
  65. package/src/ui/editor-runtime-boundary.ts +10 -1
  66. package/src/ui/editor-shell-view.tsx +6 -0
  67. package/src/ui/editor-surface-controller.tsx +3 -0
  68. package/src/ui/headless/chrome-registry.ts +501 -0
  69. package/src/ui/headless/scoped-chrome-policy.ts +183 -0
  70. package/src/ui/headless/selection-tool-context.ts +2 -0
  71. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  72. package/src/ui/headless/selection-tool-types.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  74. package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
  75. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  76. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
  77. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
  78. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  79. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  80. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
  81. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
  82. package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
  83. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  85. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
  86. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +337 -0
  87. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +100 -0
  88. package/src/ui-tailwind/editor-surface/perf-probe.ts +27 -1
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +20 -2
  90. package/src/ui-tailwind/editor-surface/pm-decorations.ts +93 -23
  91. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  92. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  93. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  94. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +176 -6
  95. package/src/ui-tailwind/index.ts +33 -0
  96. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  97. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  98. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  99. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  100. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  101. package/src/ui-tailwind/theme/editor-theme.css +505 -144
  102. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
  103. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  104. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  105. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  106. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +304 -166
  107. package/src/ui-tailwind/tw-review-workspace.tsx +163 -2
@@ -17,27 +17,26 @@ import type {
17
17
  SuggestionReviewSelectionToolModel,
18
18
  WorkflowTaskSelectionToolModel,
19
19
  } from "./selection-tool-types";
20
+ import {
21
+ SELECTION_TOOL_REGISTRY,
22
+ type SelectionToolRegistryEntry,
23
+ } from "./chrome-registry";
24
+ import { shouldRenderSelectionToolKind } from "./scoped-chrome-policy";
20
25
 
21
26
  export function resolveActiveSelectionTool(
22
27
  input: SelectionToolResolverInput,
23
28
  ): ActiveSelectionToolModel | null {
24
- const suggestionTool = buildSuggestionReviewSelectionToolModel(input);
25
- const commentTool = buildCommentThreadSelectionToolModel(input);
26
- const workflowTool = buildWorkflowTaskSelectionToolModel(input);
27
- const structureTool = buildStructureContextSelectionToolModel(input);
28
- // Semantic targets beat generic range formatting so structure tools do not
29
- // leak preview text when the user is acting on a table, image, or object.
30
- const formattingTool = buildFormattingInlineSelectionToolModel(input);
31
- const blockedTool = buildBlockedExplainerSelectionToolModel(input);
32
- const resolvedTool =
33
- suggestionTool ??
34
- commentTool ??
35
- workflowTool ??
36
- structureTool ??
37
- formattingTool ??
38
- blockedTool;
39
-
40
- return resolvedTool;
29
+ for (const entry of SELECTION_TOOL_REGISTRY) {
30
+ if (!shouldRenderSelectionToolKind(input.scopedChromePolicy, entry.id)) {
31
+ continue;
32
+ }
33
+ const resolvedTool = buildSelectionToolFromRegistryEntry(entry, input);
34
+ if (resolvedTool) {
35
+ return resolvedTool;
36
+ }
37
+ }
38
+
39
+ return null;
41
40
  }
42
41
 
43
42
  export function buildFormattingInlineSelectionToolModel(
@@ -273,6 +272,26 @@ function selectionMatchesSuggestion(
273
272
  );
274
273
  }
275
274
 
275
+ function buildSelectionToolFromRegistryEntry(
276
+ entry: SelectionToolRegistryEntry,
277
+ input: SelectionToolResolverInput,
278
+ ): ActiveSelectionToolModel | null {
279
+ switch (entry.id) {
280
+ case "suggestion-review":
281
+ return buildSuggestionReviewSelectionToolModel(input);
282
+ case "comment-thread":
283
+ return buildCommentThreadSelectionToolModel(input);
284
+ case "workflow-task":
285
+ return buildWorkflowTaskSelectionToolModel(input);
286
+ case "structure-context":
287
+ return buildStructureContextSelectionToolModel(input);
288
+ case "formatting-inline":
289
+ return buildFormattingInlineSelectionToolModel(input);
290
+ case "blocked-explainer":
291
+ return buildBlockedExplainerSelectionToolModel(input);
292
+ }
293
+ }
294
+
276
295
  function findSelectionMatchingScope(
277
296
  activeRange: SelectionToolResolverInput["snapshot"]["selection"]["activeRange"] | null,
278
297
  activeStory: EditorStoryTarget,
@@ -59,6 +59,16 @@ export interface FormattingInlineSelectionToolModel extends BaseSelectionToolMod
59
59
  italicActive: boolean;
60
60
  underlineActive: boolean;
61
61
  canAddComment: boolean;
62
+ /**
63
+ * One-click "apply" color for the text-color affordance. R2.5 plumbs
64
+ * the current `formattingState.textColor` (or the user's most recent
65
+ * pick) here so the mini-toolbar reflects the active color instead of
66
+ * a hardcoded blue. Consumers fall back to a fixed default when
67
+ * absent.
68
+ */
69
+ textColorDefault?: string;
70
+ /** Matching one-click highlight color. See `textColorDefault`. */
71
+ highlightColorDefault?: string;
62
72
  }
63
73
 
64
74
  export interface SuggestionReviewSelectionToolModel extends BaseSelectionToolModel {
@@ -7,12 +7,15 @@ import type {
7
7
 
8
8
  export function resolveChromePreset(
9
9
  chromePreset: WordReviewEditorProps["chromePreset"],
10
- _reviewMode: WordReviewEditorProps["reviewMode"] = "review",
10
+ reviewMode: WordReviewEditorProps["reviewMode"] = "review",
11
11
  ): WordReviewEditorChromePreset {
12
12
  if (chromePreset) {
13
13
  return chromePreset;
14
14
  }
15
- return "advanced";
15
+ // When the host does not set an explicit preset, pick one from the
16
+ // review-mode signal so review sessions get the review strip inline
17
+ // with the toolbar (rather than the density default "advanced").
18
+ return reviewMode === "review" ? "review" : "advanced";
16
19
  }
17
20
 
18
21
  export function resolveChromePresetOptions(
@@ -40,6 +43,14 @@ export function resolveChromePresetOptions(
40
43
  showSectionTagAction: true,
41
44
  showReviewRail: true,
42
45
  },
46
+ workflow: {
47
+ // Workflow role consolidates prev/next + mark-section into the top
48
+ // toolbar via the role-action region, so the separate queue bar
49
+ // strip is suppressed.
50
+ showReviewQueueBar: false,
51
+ showSectionTagAction: false,
52
+ showReviewRail: true,
53
+ },
43
54
  };
44
55
 
45
56
  return {
@@ -95,6 +106,16 @@ export function resolveChromeVisibilityForPreset(input: {
95
106
  statusBar: true,
96
107
  reviewRail: options.showReviewRail,
97
108
  },
109
+ workflow: {
110
+ toolbar: true,
111
+ alerts: true,
112
+ selectionOverlay: true,
113
+ contextToolbars: true,
114
+ contextAnalytics: true,
115
+ pageChrome: true,
116
+ statusBar: true,
117
+ reviewRail: options.showReviewRail,
118
+ },
98
119
  };
99
120
 
100
121
  return {
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Role → ordered primary-action id mapping for the top-toolbar's inline
3
+ * role-action region (spec §6.4).
4
+ *
5
+ * The registry in `chrome-registry.ts` knows which ids exist per role;
6
+ * this module decides the *rendering order* so the role region presents
7
+ * a tight, task-focused set instead of the preset-density order.
8
+ *
9
+ * Consumers of `TwRoleActionRegion` look up the array for the active
10
+ * role, iterate it in order, and let `scoped-chrome-policy` filter out
11
+ * anything currently invisible (for instance, review-role accept/reject
12
+ * buttons disappear when there are no pending revisions).
13
+ */
14
+
15
+ import type { EditorRole } from "../../api/public-types";
16
+ import type { ToolbarChromeItemId } from "../../ui/headless/chrome-registry";
17
+
18
+ /**
19
+ * Ordered role-action ids. Each array covers the *role-primary* actions
20
+ * only — the general left cluster (history, formatting, style selectors)
21
+ * and right cluster (tracked-changes, workspace mode, zoom, health,
22
+ * export) stay in the base `TwToolbar` layout regardless of role.
23
+ */
24
+ export const ROLE_ACTION_SETS: Record<
25
+ EditorRole,
26
+ ReadonlyArray<ToolbarChromeItemId>
27
+ > = {
28
+ editor: [
29
+ // Posture menu replaces the flat "Mark section" button per spec §6.4.
30
+ "editor-scope-posture-menu",
31
+ // Insert menu (tables / images / page breaks / section breaks) — only
32
+ // meaningful for authoring, so it lives in the editor role's action
33
+ // region rather than the base toolbar.
34
+ "insert-actions",
35
+ "update-actions",
36
+ "list-continuation",
37
+ ],
38
+ review: [
39
+ // Queue navigation + counts, collapsed from the old TwReviewQueueBar.
40
+ "review-queue-prev",
41
+ "review-queue-next",
42
+ "review-queue-counts",
43
+ "review-queue-active-label",
44
+ // Per-item accept/reject — the canonical review actions.
45
+ "review-accept",
46
+ "review-reject",
47
+ // Batch + markup-mode live in the overflow popover.
48
+ "review-accept-all",
49
+ "review-reject-all",
50
+ "review-markup-mode",
51
+ ],
52
+ workflow: [
53
+ // Work-item navigation (distinct from review-queue nav).
54
+ "workflow-prev",
55
+ "workflow-next",
56
+ // Primary workflow actions.
57
+ "workflow-mark-complete",
58
+ "workflow-claim",
59
+ "workflow-skip",
60
+ "workflow-mark-blocked",
61
+ "workflow-jump-to-scope",
62
+ ],
63
+ };
64
+
65
+ /**
66
+ * Reverse lookup: role → entry order with registry details merged in.
67
+ * Consumers who need to iterate role actions with presets/runtimeBehavior
68
+ * import from here to avoid re-running `Array.find` per render.
69
+ */
70
+ export function resolveRoleActionOrder(
71
+ role: EditorRole,
72
+ ): ReadonlyArray<ToolbarChromeItemId> {
73
+ return ROLE_ACTION_SETS[role];
74
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Shared detach/attach primitive for chrome surfaces that can float vs
3
+ * dock. Extracted from the hand-rolled implementation in
4
+ * `tw-selection-tool-host.tsx` so the same UX works on the topnav, the
5
+ * selection tier, and any future overlay layer that opts in.
6
+ *
7
+ * Per runtime-rendering-and-chrome-phase.md §6.3 every overlay child
8
+ * should consume this shape. State is owned by `ViewState.chromePins`
9
+ * so pin offsets survive snapshot rebuilds within one session.
10
+ */
11
+
12
+ import React, { useCallback, useEffect, useRef } from "react";
13
+ import { GripHorizontal } from "lucide-react";
14
+
15
+ import type {
16
+ ChromePinSurface,
17
+ PinState,
18
+ } from "../../api/public-types";
19
+ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
20
+
21
+ export interface TwDetachHandleProps {
22
+ /** Which chrome surface this handle controls; stored in ViewState. */
23
+ surface: ChromePinSurface;
24
+ /** Current pin state; `undefined` means docked-default. */
25
+ pin?: PinState;
26
+ /** Callback fired with the next pin state (null = clear). */
27
+ onChange: (surface: ChromePinSurface, pin: PinState | null) => void;
28
+ /** Human label rendered next to the status chip. */
29
+ label: string;
30
+ /** Optional test id override. */
31
+ "data-testid"?: string;
32
+ /** Optional className spliced onto the root. */
33
+ className?: string;
34
+ }
35
+
36
+ /**
37
+ * Compact grip + Float/Dock toggle row. Consumers mount this inline
38
+ * above the surface's content; when `pin.detached === true` the consumer
39
+ * translates the surface by `pin.offset.x / y` itself (the handle does
40
+ * not wrap the payload).
41
+ */
42
+ export function TwDetachHandle(props: TwDetachHandleProps): React.JSX.Element {
43
+ const { surface, pin, onChange, label } = props;
44
+ const isDetached = pin?.detached ?? false;
45
+ const offset = pin?.offset ?? { x: 0, y: 0 };
46
+ const dragState = useRef<
47
+ | {
48
+ startX: number;
49
+ startY: number;
50
+ originX: number;
51
+ originY: number;
52
+ }
53
+ | null
54
+ >(null);
55
+
56
+ useEffect(() => {
57
+ if (typeof window === "undefined") {
58
+ return undefined;
59
+ }
60
+
61
+ const handleMove = (event: MouseEvent) => {
62
+ if (!dragState.current) return;
63
+ const nextX =
64
+ dragState.current.originX + (event.clientX - dragState.current.startX);
65
+ const nextY =
66
+ dragState.current.originY + (event.clientY - dragState.current.startY);
67
+ onChange(surface, { detached: true, offset: { x: nextX, y: nextY } });
68
+ };
69
+
70
+ const handleUp = () => {
71
+ dragState.current = null;
72
+ };
73
+
74
+ window.addEventListener("mousemove", handleMove);
75
+ window.addEventListener("mouseup", handleUp);
76
+ return () => {
77
+ window.removeEventListener("mousemove", handleMove);
78
+ window.removeEventListener("mouseup", handleUp);
79
+ };
80
+ }, [onChange, surface]);
81
+
82
+ const beginDrag = useCallback(
83
+ (event: React.MouseEvent<HTMLButtonElement>) => {
84
+ preserveEditorSelectionMouseDown(event);
85
+ dragState.current = {
86
+ startX: event.clientX,
87
+ startY: event.clientY,
88
+ originX: isDetached ? offset.x : 0,
89
+ originY: isDetached ? offset.y : 0,
90
+ };
91
+ if (!isDetached) {
92
+ onChange(surface, { detached: true, offset: { x: 0, y: 0 } });
93
+ }
94
+ },
95
+ [isDetached, offset.x, offset.y, onChange, surface],
96
+ );
97
+
98
+ const toggle = useCallback(() => {
99
+ if (isDetached) {
100
+ onChange(surface, null);
101
+ } else {
102
+ onChange(surface, { detached: true, offset: { x: 0, y: 0 } });
103
+ }
104
+ }, [isDetached, onChange, surface]);
105
+
106
+ return (
107
+ <div
108
+ className={[
109
+ "inline-flex items-center gap-1.5 self-center rounded-lg border border-border/70 bg-canvas/94 px-1.5 py-1 shadow-[0_8px_20px_-18px_var(--color-shadow-strong)]",
110
+ props.className ?? "",
111
+ ]
112
+ .filter(Boolean)
113
+ .join(" ")}
114
+ data-testid={props["data-testid"] ?? `detach-handle-${surface}`}
115
+ data-surface={surface}
116
+ >
117
+ <button
118
+ type="button"
119
+ aria-label={isDetached ? "Drag floating menu" : "Drag to float menu"}
120
+ data-testid={`${surface}-detach-drag-handle`}
121
+ className="inline-flex h-6 items-center justify-center rounded-md border border-transparent px-1.5 text-tertiary transition-colors hover:border-border/60 hover:bg-surface hover:text-primary"
122
+ onMouseDown={beginDrag}
123
+ >
124
+ <GripHorizontal className="h-3 w-3" />
125
+ </button>
126
+ <span className="text-[9px] font-semibold uppercase tracking-[0.12em] text-tertiary">
127
+ {label}
128
+ </span>
129
+ <span className="rounded-full bg-surface px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-[0.08em] text-secondary">
130
+ {isDetached ? "Floating" : "Docked"}
131
+ </span>
132
+ <button
133
+ type="button"
134
+ aria-label={isDetached ? "Dock menu" : "Float menu"}
135
+ aria-pressed={isDetached}
136
+ data-testid={`${surface}-detach-toggle`}
137
+ className="inline-flex h-6 items-center rounded-md border border-border/60 px-2 text-[10px] font-medium text-secondary transition-colors hover:bg-surface hover:text-primary"
138
+ onMouseDown={preserveEditorSelectionMouseDown}
139
+ onClick={toggle}
140
+ >
141
+ {isDetached ? "Dock menu" : "Float menu"}
142
+ </button>
143
+ </div>
144
+ );
145
+ }
146
+
147
+ export default TwDetachHandle;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Selection anchor resolver — pure helper that turns a runtime selection
3
+ * into an overlay-space `RenderFrameRect` via the layout facet's render-
4
+ * kernel anchor index.
5
+ *
6
+ * Replaces the DOM-rect + zoomScale math that lived in
7
+ * `tw-review-workspace.tsx` (`resolveSelectionToolbarPlacement`, dropped
8
+ * in R2). Per runtime-rendering-and-chrome-phase.md §6.2, every chrome
9
+ * surface reads anchors from the kernel — not DOM rects — so selection
10
+ * chrome stays glued to canonical positions through scroll, zoom, and
11
+ * predicted-text reconciliation.
12
+ *
13
+ * The resolver is intentionally pure: same facet + selection + tool kind
14
+ * returns the same rect. Consumers compose it with the placement
15
+ * helper (`tw-selection-tool-placement.ts`) and the overlay projector.
16
+ */
17
+
18
+ import type {
19
+ RenderFrameRect,
20
+ SelectionSnapshot,
21
+ TableStructureContextSnapshot,
22
+ WordReviewEditorLayoutFacet,
23
+ } from "../../api/public-types";
24
+ import type {
25
+ ActiveImageContext,
26
+ ActiveObjectContext,
27
+ ActiveSelectionToolModel,
28
+ } from "../../ui/headless/selection-tool-types";
29
+
30
+ export interface ResolveSelectionAnchorInput {
31
+ facet: WordReviewEditorLayoutFacet;
32
+ selection: SelectionSnapshot;
33
+ tool: ActiveSelectionToolModel | null;
34
+ }
35
+
36
+ /**
37
+ * Resolve the anchor rect for the currently active selection tool.
38
+ *
39
+ * - formatting / suggestion / comment / workflow / blocked → bySelection
40
+ * over the selection's from/to offsets (collapsed selections fall
41
+ * through to byRuntimeOffset).
42
+ * - structure(image) → byBlockId when the image has a mediaId that the
43
+ * engine can map back to a block; else falls through to selection.
44
+ * - structure(object) → same as image.
45
+ * - structure(table) → (byTableCell from sibling plan P4 when shipped;
46
+ * today we fall back to bySelection against the selected cells' range).
47
+ * - structure(list) → bySelection.
48
+ *
49
+ * Returns `null` when the facet has no render kernel installed or the
50
+ * selection does not resolve to any anchor.
51
+ */
52
+ export function resolveSelectionAnchor(
53
+ input: ResolveSelectionAnchorInput,
54
+ ): RenderFrameRect | null {
55
+ const { facet, selection, tool } = input;
56
+ // No kernel, no anchor — caller falls back to DOM rects in that case.
57
+ const frame =
58
+ typeof facet.getRenderFrame === "function"
59
+ ? facet.getRenderFrame() ?? null
60
+ : null;
61
+ if (!frame) return null;
62
+
63
+ if (!tool) {
64
+ return anchorForSelection(frame.anchorIndex, selection);
65
+ }
66
+
67
+ switch (tool.kind) {
68
+ case "structure-context": {
69
+ const structural = resolveStructuralAnchor(frame, tool);
70
+ if (structural) return structural;
71
+ return anchorForSelection(frame.anchorIndex, selection);
72
+ }
73
+ case "formatting-inline":
74
+ case "suggestion-review":
75
+ case "comment-thread":
76
+ case "workflow-task":
77
+ case "blocked-explainer":
78
+ return anchorForSelection(frame.anchorIndex, selection);
79
+ default: {
80
+ const _exhaustive: never = tool;
81
+ void _exhaustive;
82
+ return anchorForSelection(frame.anchorIndex, selection);
83
+ }
84
+ }
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Internals
89
+ // ---------------------------------------------------------------------------
90
+
91
+ function anchorForSelection(
92
+ anchorIndex: import("../../api/public-types").RenderAnchorIndex,
93
+ selection: SelectionSnapshot,
94
+ ): RenderFrameRect | null {
95
+ if (selection.activeRange.kind === "node") {
96
+ // Node selection — snap to the node's runtime offset.
97
+ return anchorIndex.byRuntimeOffset(selection.activeRange.at);
98
+ }
99
+ const from = Math.min(selection.anchor, selection.head);
100
+ const to = Math.max(selection.anchor, selection.head);
101
+ if (from === to) {
102
+ return anchorIndex.byRuntimeOffset(from);
103
+ }
104
+ return anchorIndex.bySelection(from, to);
105
+ }
106
+
107
+ function resolveStructuralAnchor(
108
+ frame: import("../../api/public-types").RenderFrame,
109
+ tool: ActiveSelectionToolModel & { kind: "structure-context" },
110
+ ): RenderFrameRect | null {
111
+ const { anchorIndex } = frame;
112
+ switch (tool.structureKind) {
113
+ case "image":
114
+ return resolveImageAnchor(anchorIndex, tool.activeImage);
115
+ case "object":
116
+ return resolveObjectAnchor(anchorIndex, tool.activeObject);
117
+ case "table":
118
+ return resolveTableAnchor(frame, tool.activeTable ?? undefined);
119
+ case "list":
120
+ return null;
121
+ }
122
+ }
123
+
124
+ function resolveImageAnchor(
125
+ anchorIndex: import("../../api/public-types").RenderAnchorIndex,
126
+ image: ActiveImageContext | undefined,
127
+ ): RenderFrameRect | null {
128
+ if (!image) return null;
129
+ // Images are identified by mediaId; the engine emits block fragments
130
+ // whose blockId ties back to the image's anchor block. When the
131
+ // chrome has no better handle, fall back to byBlockId(mediaId) — which
132
+ // may or may not match depending on the engine's mapping. If it
133
+ // doesn't, the outer caller falls through to bySelection.
134
+ const rect = anchorIndex.byBlockId(image.mediaId);
135
+ return rect;
136
+ }
137
+
138
+ function resolveObjectAnchor(
139
+ anchorIndex: import("../../api/public-types").RenderAnchorIndex,
140
+ _object: ActiveObjectContext | undefined,
141
+ ): RenderFrameRect | null {
142
+ void anchorIndex;
143
+ void _object;
144
+ // Shape/textbox anchors don't have a stable id today; fall through to
145
+ // the selection path. Sibling plan P4 adds `byTableCell` and similar
146
+ // precise accessors for structural objects; this lane will adopt them.
147
+ return null;
148
+ }
149
+
150
+ function resolveTableAnchor(
151
+ frame: import("../../api/public-types").RenderFrame,
152
+ table: TableStructureContextSnapshot | undefined,
153
+ ): RenderFrameRect | null {
154
+ if (!table) return null;
155
+ // Sibling plan P4 will expose `byTableCell(tableBlockId, row, col)`
156
+ // and `byTableColumnEdge` / `byTableRowEdge` on the anchor index.
157
+ // Today we have only `tableBlockIndex` (ordinal), so the best-effort
158
+ // fallback is the selection path — callers chain to `anchorForSelection`
159
+ // when this returns null.
160
+ void frame;
161
+ void table;
162
+ return null;
163
+ }