@beyondwork/docx-react-component 1.0.37 → 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 (74) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +319 -1
  3. package/src/core/commands/section-layout-commands.ts +58 -0
  4. package/src/core/commands/table-grid.ts +431 -0
  5. package/src/core/commands/table-structure-commands.ts +815 -55
  6. package/src/io/export/serialize-main-document.ts +2 -11
  7. package/src/io/export/serialize-numbering.ts +1 -2
  8. package/src/io/export/serialize-tables.ts +74 -0
  9. package/src/io/export/table-properties-xml.ts +139 -4
  10. package/src/io/normalize/normalize-text.ts +15 -0
  11. package/src/io/ooxml/parse-footnotes.ts +60 -0
  12. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  13. package/src/io/ooxml/parse-main-document.ts +137 -0
  14. package/src/io/ooxml/parse-tables.ts +249 -0
  15. package/src/model/canonical-document.ts +34 -0
  16. package/src/runtime/document-layout.ts +4 -2
  17. package/src/runtime/document-navigation.ts +1 -1
  18. package/src/runtime/document-runtime.ts +114 -0
  19. package/src/runtime/layout/default-page-format.ts +96 -0
  20. package/src/runtime/layout/index.ts +45 -0
  21. package/src/runtime/layout/inert-layout-facet.ts +14 -0
  22. package/src/runtime/layout/layout-engine-instance.ts +33 -23
  23. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  24. package/src/runtime/layout/page-format-catalog.ts +233 -0
  25. package/src/runtime/layout/page-graph.ts +19 -0
  26. package/src/runtime/layout/paginated-layout-engine.ts +142 -9
  27. package/src/runtime/layout/project-block-fragments.ts +91 -0
  28. package/src/runtime/layout/public-facet.ts +709 -16
  29. package/src/runtime/layout/table-render-plan.ts +229 -0
  30. package/src/runtime/render/block-fragment-projection.ts +35 -0
  31. package/src/runtime/render/decoration-resolver.ts +189 -0
  32. package/src/runtime/render/index.ts +57 -0
  33. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  34. package/src/runtime/render/render-frame-types.ts +317 -0
  35. package/src/runtime/render/render-kernel.ts +755 -0
  36. package/src/runtime/view-state.ts +67 -0
  37. package/src/runtime/workflow-markup.ts +1 -5
  38. package/src/runtime/workflow-rail-segments.ts +280 -0
  39. package/src/ui/WordReviewEditor.tsx +84 -15
  40. package/src/ui/editor-shell-view.tsx +6 -0
  41. package/src/ui/headless/chrome-registry.ts +280 -14
  42. package/src/ui/headless/scoped-chrome-policy.ts +20 -1
  43. package/src/ui/headless/selection-tool-types.ts +10 -0
  44. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  45. package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
  46. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  47. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
  48. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
  49. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  50. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  51. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
  52. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
  53. package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
  54. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
  55. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  56. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
  57. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  58. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  59. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  60. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +12 -1
  61. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  62. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +3 -0
  63. package/src/ui-tailwind/index.ts +33 -0
  64. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  65. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  66. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  67. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  68. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  69. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  70. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
  71. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  72. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  73. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +69 -0
  74. package/src/ui-tailwind/tw-review-workspace.tsx +136 -1
@@ -19,13 +19,17 @@ import type {
19
19
  ActiveListContext,
20
20
  ActiveNoteContext,
21
21
  CaretAffinity,
22
+ ChromePinsState,
23
+ ChromePinSurface,
22
24
  DocumentMode,
25
+ EditorRole,
23
26
  EditorStoryTarget,
24
27
  EditorSurfaceSnapshot,
25
28
  EditorViewStateSnapshot,
26
29
  LayoutMeasurement,
27
30
  PageLayoutSnapshot,
28
31
  PageRegionHitTest,
32
+ PinState,
29
33
  SelectionSnapshot,
30
34
  SurfaceBlockSnapshot,
31
35
  SurfaceInlineSegment,
@@ -44,6 +48,20 @@ export interface ViewState {
44
48
  caretAffinity: CaretAffinity;
45
49
  activePageRegion: PageRegionHitTest | null;
46
50
  activeObjectFrame: LayoutMeasurement["objectFrame"] | null;
51
+ /**
52
+ * Role-scoped chrome dimension (spec §6.4). Host apps drive the role via
53
+ * `setEditorRole`; the mounted shell reads this to pick a per-role
54
+ * toolbar action set. Independent of `viewMode` — one user session may
55
+ * start in "review" role and switch to "editor" without changing
56
+ * workspace or document mode.
57
+ */
58
+ editorRole: EditorRole;
59
+ /**
60
+ * Pin state for detachable chrome surfaces (topnav, selection tier).
61
+ * Lives here so it survives snapshot rebuilds within one session.
62
+ * Absent key ⇒ docked default.
63
+ */
64
+ chromePins: ChromePinsState;
47
65
  }
48
66
 
49
67
  const MIN_ZOOM_PERCENT = 50;
@@ -58,6 +76,8 @@ const DEFAULT_VIEW_STATE: ViewState = {
58
76
  caretAffinity: "none",
59
77
  activePageRegion: null,
60
78
  activeObjectFrame: null,
79
+ editorRole: "editor",
80
+ chromePins: {},
61
81
  };
62
82
 
63
83
  export function createViewState(initial?: Partial<ViewState>): ViewState {
@@ -113,6 +133,37 @@ export function setActiveObjectFrame(
113
133
  return { ...state, activeObjectFrame: frame };
114
134
  }
115
135
 
136
+ export function setEditorRole(state: ViewState, role: EditorRole): ViewState {
137
+ if (state.editorRole === role) return state;
138
+ return { ...state, editorRole: role };
139
+ }
140
+
141
+ export function setChromePin(
142
+ state: ViewState,
143
+ surface: ChromePinSurface,
144
+ pin: PinState | null,
145
+ ): ViewState {
146
+ const next: ChromePinsState = { ...state.chromePins };
147
+ if (pin === null) {
148
+ if (next[surface] === undefined) {
149
+ return state;
150
+ }
151
+ delete next[surface];
152
+ } else {
153
+ const current = next[surface];
154
+ if (
155
+ current &&
156
+ current.detached === pin.detached &&
157
+ current.offset.x === pin.offset.x &&
158
+ current.offset.y === pin.offset.y
159
+ ) {
160
+ return state;
161
+ }
162
+ next[surface] = pin;
163
+ }
164
+ return { ...state, chromePins: next };
165
+ }
166
+
116
167
  /**
117
168
  * Derive list context from the surface block at the current selection head.
118
169
  */
@@ -219,9 +270,25 @@ export function createEditorViewStateSnapshot(
219
270
  activeObjectFrame: derivedViewState.activeObjectFrame,
220
271
  measurement,
221
272
  isFocused: derivedViewState.isFocused,
273
+ editorRole: derivedViewState.editorRole,
274
+ chromePins: cloneChromePins(derivedViewState.chromePins),
222
275
  };
223
276
  }
224
277
 
278
+ function cloneChromePins(pins: ChromePinsState): ChromePinsState {
279
+ const out: ChromePinsState = {};
280
+ for (const key of Object.keys(pins) as ChromePinSurface[]) {
281
+ const pin = pins[key];
282
+ if (pin) {
283
+ out[key] = {
284
+ detached: pin.detached,
285
+ offset: { x: pin.offset.x, y: pin.offset.y },
286
+ };
287
+ }
288
+ }
289
+ return out;
290
+ }
291
+
225
292
  // ---------------------------------------------------------------------------
226
293
  // Internal helpers
227
294
  // ---------------------------------------------------------------------------
@@ -341,11 +341,7 @@ function collectOpaqueFragmentMarkup(
341
341
  const seen = new Set(existing.map((item) => item.fragmentId));
342
342
 
343
343
  return Object.values(preservation.opaqueFragments)
344
- .filter(
345
- (fragment) =>
346
- !seen.has(fragment.fragmentId)
347
- && fragment.packagePartName === "/word/document.xml",
348
- )
344
+ .filter((fragment) => !seen.has(fragment.fragmentId))
349
345
  .map((fragment) => {
350
346
  const descriptor = describeOpaqueFragment(fragment);
351
347
  const blockedReasonCode = isBlockedImportFeatureKey(descriptor.featureKey)
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Workflow rail-segment projection.
3
+ *
4
+ * Per runtime-rendering-and-chrome-phase.md §5, the action rail v2 renders
5
+ * OUTSIDE the PM NodeView tree as an overlay layer positioned from
6
+ * canonical scope data. This module joins the host-supplied
7
+ * `WorkflowOverlay` (scopes, candidates) + blocked-reason ranges +
8
+ * locked-zone data with the runtime page graph to produce
9
+ * `ScopeRailSegment[]` — the shape chrome consumes to render the
10
+ * left-gutter label column and the flat block-tint.
11
+ *
12
+ * The segments are pure reads over canonical state; no DOM, no PM.
13
+ */
14
+
15
+ import type {
16
+ EditorAnchorProjection,
17
+ EditorStoryTarget,
18
+ WorkflowBlockedCommandReason,
19
+ WorkflowCandidateRange,
20
+ WorkflowLockedZone,
21
+ WorkflowScope,
22
+ } from "../api/public-types";
23
+ import { MAIN_STORY_TARGET, storyTargetsEqual } from "../core/selection/mapping.ts";
24
+ import type { RuntimePageGraph } from "./layout/page-graph.ts";
25
+ import type { RenderFrameRect } from "./render/render-frame-types.ts";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Public shape
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export type ScopeRailPosture =
32
+ | "edit"
33
+ | "suggest"
34
+ | "comment"
35
+ | "view"
36
+ | "candidate"
37
+ | "preserve-only"
38
+ | "blocked-import";
39
+
40
+ export interface ScopeRailSegment {
41
+ /** Identifier the chrome uses to sync with the Workflow rail tab. */
42
+ scopeId: string;
43
+ /** Visual+accessibility posture keyed off the scope mode or block reason. */
44
+ posture: ScopeRailPosture;
45
+ /** Human label; empty string when none was supplied by the host. */
46
+ label: string;
47
+ /** Runtime offsets (inclusive from, exclusive to) this segment spans on the active story. */
48
+ fromOffset: number;
49
+ toOffset: number;
50
+ /** Story the segment sits on. */
51
+ storyTarget: EditorStoryTarget;
52
+ /** Page index the segment renders on (may span multiple pages; emitted per page). */
53
+ pageIndex: number;
54
+ /** Section index derived from the page graph. */
55
+ sectionIndex: number;
56
+ /** True when the scope is the active work item. */
57
+ isActiveWorkItem: boolean;
58
+ /**
59
+ * Body-tint rect in overlay-space pixels, populated when the render kernel
60
+ * is available. Chrome consumers read this directly instead of
61
+ * re-projecting per render via the overlay projector. `null` when the
62
+ * segment is produced without a kernel (e.g., in tests or before the
63
+ * facet is bound to a page graph) — consumers fall back to per-render
64
+ * anchor resolution in that case.
65
+ */
66
+ bodyTintRect: RenderFrameRect | null;
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Collector input
71
+ // ---------------------------------------------------------------------------
72
+
73
+ export interface CollectScopeRailSegmentsInput {
74
+ scopes: readonly WorkflowScope[] | undefined;
75
+ candidates?: readonly WorkflowCandidateRange[];
76
+ blockedReasons?: readonly WorkflowBlockedCommandReason[];
77
+ lockedZones?: readonly WorkflowLockedZone[];
78
+ activeWorkItemScopeIds?: readonly string[];
79
+ /** Active story scopes render on; segments for other stories are skipped. */
80
+ activeStory?: EditorStoryTarget;
81
+ pageGraph: RuntimePageGraph;
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Entry point
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Build segments for every page in the graph. Callers that only want a
90
+ * single page can filter by `segment.pageIndex` on the returned list.
91
+ */
92
+ export function collectScopeRailSegments(
93
+ input: CollectScopeRailSegmentsInput,
94
+ ): ScopeRailSegment[] {
95
+ const segments: ScopeRailSegment[] = [];
96
+ const activeStory = input.activeStory ?? MAIN_STORY_TARGET;
97
+ const activeIds = new Set(input.activeWorkItemScopeIds ?? []);
98
+
99
+ for (const scope of input.scopes ?? []) {
100
+ const range = anchorToRuntimeRange(scope.anchor);
101
+ if (!range) continue;
102
+ const storyTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
103
+ if (!storyTargetsEqual(storyTarget, activeStory)) continue;
104
+
105
+ const posture = resolveScopePosture(scope);
106
+ const isActiveWorkItem = activeIds.has(scope.scopeId);
107
+ for (const page of pagesCoveringRange(input.pageGraph, range.from, range.to)) {
108
+ const { from, to } = clipRangeToPage(range.from, range.to, page);
109
+ if (from >= to) continue;
110
+ segments.push({
111
+ scopeId: scope.scopeId,
112
+ posture,
113
+ label: scope.label ?? "",
114
+ fromOffset: from,
115
+ toOffset: to,
116
+ storyTarget,
117
+ pageIndex: page.pageIndex,
118
+ sectionIndex: page.sectionIndex,
119
+ isActiveWorkItem,
120
+ bodyTintRect: null,
121
+ });
122
+ }
123
+ }
124
+
125
+ // Candidates render as a faint "candidate" posture so the reader knows
126
+ // where the host is proposing scopes before they're committed.
127
+ for (const candidate of input.candidates ?? []) {
128
+ const range = anchorToRuntimeRange(candidate.anchor);
129
+ if (!range) continue;
130
+ const storyTarget = candidate.storyTarget ?? MAIN_STORY_TARGET;
131
+ if (!storyTargetsEqual(storyTarget, activeStory)) continue;
132
+
133
+ for (const page of pagesCoveringRange(input.pageGraph, range.from, range.to)) {
134
+ const { from, to } = clipRangeToPage(range.from, range.to, page);
135
+ if (from >= to) continue;
136
+ segments.push({
137
+ scopeId: candidate.candidateId,
138
+ posture: "candidate",
139
+ label: candidate.label ?? "",
140
+ fromOffset: from,
141
+ toOffset: to,
142
+ storyTarget,
143
+ pageIndex: page.pageIndex,
144
+ sectionIndex: page.sectionIndex,
145
+ isActiveWorkItem: false,
146
+ bodyTintRect: null,
147
+ });
148
+ }
149
+ }
150
+
151
+ // Blocked-reason anchors: render a "blocked-import" or "preserve-only"
152
+ // posture so the rail signal matches the chrome message in image copy.png.
153
+ for (const reason of input.blockedReasons ?? []) {
154
+ if (!reason.anchor) continue;
155
+ const range = anchorToRuntimeRange(reason.anchor);
156
+ if (!range) continue;
157
+ const storyTarget = reason.storyTarget ?? MAIN_STORY_TARGET;
158
+ if (!storyTargetsEqual(storyTarget, activeStory)) continue;
159
+
160
+ const posture: ScopeRailPosture =
161
+ reason.code === "workflow_blocked_import"
162
+ ? "blocked-import"
163
+ : reason.code === "workflow_preserve_only"
164
+ ? "preserve-only"
165
+ : "view";
166
+ for (const page of pagesCoveringRange(input.pageGraph, range.from, range.to)) {
167
+ const { from, to } = clipRangeToPage(range.from, range.to, page);
168
+ if (from >= to) continue;
169
+ segments.push({
170
+ scopeId: `blocked:${reason.code}:${range.from}-${range.to}`,
171
+ posture,
172
+ label: reason.label ?? reason.message ?? "",
173
+ fromOffset: from,
174
+ toOffset: to,
175
+ storyTarget,
176
+ pageIndex: page.pageIndex,
177
+ sectionIndex: page.sectionIndex,
178
+ isActiveWorkItem: false,
179
+ bodyTintRect: null,
180
+ });
181
+ }
182
+ }
183
+
184
+ // Locked zones project as their own preserve-only / blocked-import rails so
185
+ // locked cells, locked images, and locked runs show the lock posture in
186
+ // the gutter even without a workflow scope covering them.
187
+ for (const zone of input.lockedZones ?? []) {
188
+ const range = anchorToRuntimeRange(zone.anchor);
189
+ if (!range) continue;
190
+ const storyTarget = zone.storyTarget ?? MAIN_STORY_TARGET;
191
+ if (!storyTargetsEqual(storyTarget, activeStory)) continue;
192
+
193
+ const posture: ScopeRailPosture =
194
+ zone.code === "workflow_blocked_import"
195
+ ? "blocked-import"
196
+ : "preserve-only";
197
+ for (const page of pagesCoveringRange(input.pageGraph, range.from, range.to)) {
198
+ const { from, to } = clipRangeToPage(range.from, range.to, page);
199
+ if (from >= to) continue;
200
+ segments.push({
201
+ scopeId: zone.fragmentId ?? `locked:${range.from}-${range.to}`,
202
+ posture,
203
+ label: zone.label ?? "",
204
+ fromOffset: from,
205
+ toOffset: to,
206
+ storyTarget,
207
+ pageIndex: page.pageIndex,
208
+ sectionIndex: page.sectionIndex,
209
+ isActiveWorkItem: false,
210
+ bodyTintRect: null,
211
+ });
212
+ }
213
+ }
214
+
215
+ return segments;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Internals
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function anchorToRuntimeRange(
223
+ anchor: EditorAnchorProjection,
224
+ ): { from: number; to: number } | null {
225
+ if (anchor.kind === "range") {
226
+ const from = Math.min(anchor.from, anchor.to);
227
+ const to = Math.max(anchor.from, anchor.to);
228
+ return from < to ? { from, to } : null;
229
+ }
230
+ if (anchor.kind === "node") {
231
+ return { from: anchor.at, to: anchor.at + 1 };
232
+ }
233
+ // detached anchors cannot be rendered; use the last-known range as a
234
+ // best-effort ghost until the scope is re-anchored or dismissed.
235
+ if (anchor.lastKnownRange) {
236
+ const from = Math.min(anchor.lastKnownRange.from, anchor.lastKnownRange.to);
237
+ const to = Math.max(anchor.lastKnownRange.from, anchor.lastKnownRange.to);
238
+ return from < to ? { from, to } : null;
239
+ }
240
+ return null;
241
+ }
242
+
243
+ function pagesCoveringRange(
244
+ graph: RuntimePageGraph,
245
+ from: number,
246
+ to: number,
247
+ ): RuntimePageGraph["pages"] {
248
+ return graph.pages.filter(
249
+ (page) =>
250
+ !page.isBlankFiller &&
251
+ page.endOffset > from &&
252
+ page.startOffset < to,
253
+ );
254
+ }
255
+
256
+ function clipRangeToPage(
257
+ from: number,
258
+ to: number,
259
+ page: RuntimePageGraph["pages"][number],
260
+ ): { from: number; to: number } {
261
+ return {
262
+ from: Math.max(from, page.startOffset),
263
+ to: Math.min(to, page.endOffset),
264
+ };
265
+ }
266
+
267
+ function resolveScopePosture(scope: WorkflowScope): ScopeRailPosture {
268
+ switch (scope.mode) {
269
+ case "edit":
270
+ return "edit";
271
+ case "suggest":
272
+ return "suggest";
273
+ case "comment":
274
+ return "comment";
275
+ case "view":
276
+ return "view";
277
+ default:
278
+ return "view";
279
+ }
280
+ }
@@ -48,6 +48,8 @@ import type {
48
48
  StyleCatalogSnapshot,
49
49
  SurfaceBlockSnapshot,
50
50
  SurfaceInlineSegment,
51
+ TableOp,
52
+ TableOpResult,
51
53
  TrackedChangeEntrySnapshot,
52
54
  TocRefreshResult,
53
55
  UpdateFieldsResult,
@@ -113,6 +115,7 @@ import {
113
115
  import {
114
116
  applyTableStructureOperation,
115
117
  getTableStructureContext,
118
+ type TableStructureOperation,
116
119
  } from "../core/commands/table-structure-commands.ts";
117
120
  import {
118
121
  deleteSelectionOrBackward,
@@ -579,6 +582,7 @@ export function __createWordReviewEditorRefBridge(
579
582
  return clonePublicValue(runtime.getRuntimeContextAnalytics(query));
580
583
  },
581
584
  layout: runtime.layout,
585
+ tables: buildTablesFacet(runtime, mountedSurface ?? null),
582
586
  goToNextReviewItem: () => {
583
587
  return clonePublicValue(navigateReviewQueue(runtime, "next"));
584
588
  },
@@ -1394,6 +1398,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1394
1398
  return clonePublicValue(activeRuntime.getRuntimeContextAnalytics(query));
1395
1399
  },
1396
1400
  layout: activeRuntime.layout,
1401
+ tables: buildTablesFacet(activeRuntime, surfaceRef.current ?? null),
1397
1402
  goToNextReviewItem: () => {
1398
1403
  return clonePublicValue(navigateMountedReviewQueue("next"));
1399
1404
  },
@@ -2300,6 +2305,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2300
2305
  activeRevisionId={activeRevisionId}
2301
2306
  showTrackedChanges={showTrackedChanges}
2302
2307
  workflowScopeSnapshot={workflowScopeSnapshot}
2308
+ layoutFacet={activeRuntime.layout}
2303
2309
  interactionGuardSnapshot={interactionGuardSnapshot}
2304
2310
  chromePreset={effectiveChromePreset}
2305
2311
  chromeOptions={chromeOptions}
@@ -3388,28 +3394,19 @@ function applyRuntimeImageReposition(
3388
3394
  function applyRuntimeTableStructureOperation(
3389
3395
  runtime: WordReviewEditorRuntime,
3390
3396
  mountedSurface: TwProseMirrorSurfaceRef | null | undefined,
3391
- operation:
3392
- | { type: "add-row-before" }
3393
- | { type: "add-row-after" }
3394
- | { type: "add-column-before" }
3395
- | { type: "add-column-after" }
3396
- | { type: "delete-row" }
3397
- | { type: "delete-column" }
3398
- | { type: "delete-table" }
3399
- | { type: "merge-cells" }
3400
- | { type: "split-cell" }
3401
- | { type: "set-cell-background"; color: string },
3402
- ): void {
3397
+ operation: TableStructureOperation,
3398
+ ): { changed: boolean; coercedReason: string | null } {
3403
3399
  if (isSelectionSuggesting(runtime)) {
3400
+ const coercedReason = `Table operation "${operation.type}" is not supported in suggesting mode.`;
3404
3401
  runtime.emitBlockedCommand(`table.${operation.type}`, [{
3405
3402
  code: "unsupported_surface",
3406
- message: `Table operation "${operation.type}" is not supported in suggesting mode.`,
3403
+ message: coercedReason,
3407
3404
  }]);
3408
- return;
3405
+ return { changed: false, coercedReason };
3409
3406
  }
3410
3407
  const context = getStoryMutationContext(runtime, `table.${operation.type}`);
3411
3408
  if (!context) {
3412
- return;
3409
+ return { changed: false, coercedReason: "No active mutation context." };
3413
3410
  }
3414
3411
 
3415
3412
  const result = applyTableStructureOperation(
@@ -3419,6 +3416,78 @@ function applyRuntimeTableStructureOperation(
3419
3416
  operation,
3420
3417
  );
3421
3418
  dispatchStoryMutationResult(runtime, context, result, context.timestamp);
3419
+ return {
3420
+ changed: result.changed,
3421
+ coercedReason: result.changed ? null : "Op was a no-op against the active selection.",
3422
+ };
3423
+ }
3424
+
3425
+ /**
3426
+ * Translate a public-API `TableOp` (kebab-case `kind` discriminator) to
3427
+ * the internal `TableStructureOperation` (`type` discriminator). The
3428
+ * shape values are identical aside from the discriminator name.
3429
+ */
3430
+ export function __publicTableOpToInternal(op: TableOp): TableStructureOperation {
3431
+ return publicTableOpToInternal(op);
3432
+ }
3433
+
3434
+ function publicTableOpToInternal(op: TableOp): TableStructureOperation {
3435
+ const { kind, ...rest } = op as { kind: string } & Record<string, unknown>;
3436
+ if (kind === "insert") {
3437
+ throw new Error(
3438
+ "TableOp kind \"insert\" is not routed through ref.tables.apply; use ref.insertTable(...).",
3439
+ );
3440
+ }
3441
+ return { type: kind, ...rest } as TableStructureOperation;
3442
+ }
3443
+
3444
+ /**
3445
+ * Build the `ref.tables` facet: a typed dispatch boundary + capability
3446
+ * read. Delegates every op through the same `applyRuntimeTableStructureOperation`
3447
+ * helper the flat ref verbs use, so there is exactly one server-side path
3448
+ * for every table mutation.
3449
+ */
3450
+ function buildTablesFacet(
3451
+ runtime: WordReviewEditorRuntime,
3452
+ mountedSurface: TwProseMirrorSurfaceRef | null,
3453
+ ) {
3454
+ const getCapabilities = () => {
3455
+ const snapshot = runtime.getRenderSnapshot();
3456
+ const document = runtime.getSessionState().canonicalDocument;
3457
+ return (
3458
+ clonePublicValue(
3459
+ getTableStructureContext(
3460
+ document,
3461
+ snapshot,
3462
+ mountedSurface?.getTableSelection() ?? null,
3463
+ ),
3464
+ ) ?? null
3465
+ );
3466
+ };
3467
+ return {
3468
+ apply(op: TableOp): TableOpResult {
3469
+ if (op.kind === "insert") {
3470
+ applyRuntimeInsertTable(runtime, { rows: op.rows, columns: op.columns });
3471
+ return {
3472
+ changed: true,
3473
+ coercedReason: null,
3474
+ capabilities: getCapabilities(),
3475
+ };
3476
+ }
3477
+ const internal = publicTableOpToInternal(op);
3478
+ const outcome = applyRuntimeTableStructureOperation(
3479
+ runtime,
3480
+ mountedSurface,
3481
+ internal,
3482
+ );
3483
+ return {
3484
+ changed: outcome.changed,
3485
+ coercedReason: outcome.coercedReason,
3486
+ capabilities: getCapabilities(),
3487
+ };
3488
+ },
3489
+ getCapabilities,
3490
+ };
3422
3491
  }
3423
3492
 
3424
3493
  function applyRuntimeTextCommand(
@@ -55,6 +55,12 @@ export interface EditorShellViewProps {
55
55
  activeRevisionId?: string;
56
56
  showTrackedChanges: boolean;
57
57
  workflowScopeSnapshot?: WorkflowScopeSnapshot | null;
58
+ /**
59
+ * Runtime-owned layout facet passed through to the workspace so the
60
+ * ChromeOverlay (scope rail, workflow dock, etc.) can render over the
61
+ * document column and the review rail's Workflow tab can read segments.
62
+ */
63
+ layoutFacet?: import("../runtime/layout/index.ts").WordReviewEditorLayoutFacet;
58
64
  interactionGuardSnapshot?: InteractionGuardSnapshot;
59
65
  chromePreset?: WordReviewEditorChromePreset;
60
66
  chromeOptions?: Partial<WordReviewEditorChromeOptions>;