@beyondwork/docx-react-component 1.0.38 → 1.0.39

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 (76) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +183 -6
  3. package/src/core/commands/table-structure-commands.ts +31 -2
  4. package/src/core/commands/text-commands.ts +122 -2
  5. package/src/io/docx-session.ts +1 -0
  6. package/src/io/export/serialize-numbering.ts +42 -8
  7. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  8. package/src/io/export/serialize-run-formatting.ts +90 -0
  9. package/src/io/export/serialize-styles.ts +212 -0
  10. package/src/io/ooxml/parse-fields.ts +10 -3
  11. package/src/io/ooxml/parse-numbering.ts +41 -1
  12. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  13. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  14. package/src/io/ooxml/parse-styles.ts +31 -0
  15. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  16. package/src/io/ooxml/xml-element.ts +19 -0
  17. package/src/model/canonical-document.ts +83 -3
  18. package/src/runtime/collab/event-types.ts +165 -0
  19. package/src/runtime/collab/index.ts +22 -0
  20. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  21. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  22. package/src/runtime/document-runtime.ts +134 -18
  23. package/src/runtime/layout/index.ts +2 -0
  24. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  25. package/src/runtime/layout/layout-engine-instance.ts +69 -2
  26. package/src/runtime/layout/layout-invalidation.ts +14 -5
  27. package/src/runtime/layout/page-graph.ts +36 -0
  28. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  29. package/src/runtime/layout/paginated-layout-engine.ts +342 -28
  30. package/src/runtime/layout/project-block-fragments.ts +154 -20
  31. package/src/runtime/layout/public-facet.ts +40 -1
  32. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  33. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  34. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  35. package/src/runtime/layout/table-render-plan.ts +21 -1
  36. package/src/runtime/numbering-prefix.ts +5 -0
  37. package/src/runtime/paragraph-style-resolver.ts +194 -0
  38. package/src/runtime/render/render-kernel.ts +5 -1
  39. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  40. package/src/runtime/surface-projection.ts +129 -9
  41. package/src/runtime/table-schema.ts +11 -0
  42. package/src/ui/WordReviewEditor.tsx +285 -5
  43. package/src/ui/editor-command-bag.ts +4 -0
  44. package/src/ui/editor-runtime-boundary.ts +16 -0
  45. package/src/ui/editor-shell-view.tsx +4 -0
  46. package/src/ui/editor-surface-controller.tsx +9 -1
  47. package/src/ui/headless/chrome-registry.ts +34 -5
  48. package/src/ui/headless/scoped-chrome-policy.ts +29 -0
  49. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  50. package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
  51. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
  52. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
  53. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  54. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  55. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
  56. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  57. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
  58. package/src/ui-tailwind/chrome-overlay/index.ts +0 -6
  59. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +27 -17
  60. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  61. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
  62. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  63. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  64. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  65. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  66. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  67. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
  68. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  69. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  70. package/src/ui-tailwind/index.ts +1 -5
  71. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
  72. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
  73. package/src/ui-tailwind/tw-review-workspace.tsx +132 -54
  74. package/src/runtime/collab-review-sync.ts +0 -254
  75. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
  76. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -15,6 +15,8 @@
15
15
 
16
16
  import React, { useState } from "react";
17
17
  import * as Popover from "@radix-ui/react-popover";
18
+ import * as Toggle from "@radix-ui/react-toggle";
19
+ import * as Tooltip from "@radix-ui/react-tooltip";
18
20
  import {
19
21
  BookmarkCheck,
20
22
  Check,
@@ -23,8 +25,13 @@ import {
23
25
  ChevronLeft,
24
26
  ChevronRight,
25
27
  CircleOff,
28
+ Eye,
29
+ EyeOff,
30
+ FileDiff,
26
31
  Flag,
27
32
  Hand,
33
+ MessageSquare,
34
+ MessageSquareDot,
28
35
  MessageSquareText,
29
36
  Rows3,
30
37
  SkipForward,
@@ -38,6 +45,7 @@ import type {
38
45
  ReviewQueueSnapshot,
39
46
  ScopeRailPosture,
40
47
  } from "../../api/public-types";
48
+ import type { SessionCapabilities } from "../../runtime/session-capabilities";
41
49
  import type { ScopedChromePolicy } from "../../ui/headless/scoped-chrome-policy";
42
50
  import type { ToolbarChromeItemId } from "../../ui/headless/chrome-registry";
43
51
  import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
@@ -68,7 +76,23 @@ export interface TwRoleActionRegionProps {
68
76
  workflowItem?: WorkflowWorkItemSnapshot | null;
69
77
  markupDisplay?: MarkupDisplayMode;
70
78
 
71
- // Editor role
79
+ // Shared: editor + review role
80
+ canAddComment?: boolean;
81
+ showTrackedChanges?: boolean;
82
+ onAddComment?: () => void;
83
+ onShowTrackedChangesChange?: (show: boolean) => void;
84
+ /**
85
+ * Session capabilities used to gate the role-region tracked-changes
86
+ * toggle (mirrors the right-cluster gate at tw-toolbar.tsx). When
87
+ * `capabilities.trackChangesSupported` is false, the toggle is disabled.
88
+ */
89
+ capabilities?: SessionCapabilities;
90
+
91
+ // Review sidebar panel (optional — hidden when not provided)
92
+ onReviewSidebarTrackedChanges?: () => void;
93
+ onReviewSidebarComments?: () => void;
94
+
95
+ // Workflow + review role: scope posture menu
72
96
  onMarkScopePosture?: (posture: ScopeRailPosture) => void;
73
97
 
74
98
  // Review role
@@ -142,7 +166,104 @@ interface RoleActionButtonProps {
142
166
 
143
167
  function RoleActionButton(arg: RoleActionButtonProps): React.JSX.Element | null {
144
168
  const { id, props } = arg;
169
+ const focusRingClass =
170
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
145
171
  switch (id) {
172
+ case "comment":
173
+ return (
174
+ <Tooltip.Root>
175
+ <Tooltip.Trigger asChild>
176
+ <button
177
+ type="button"
178
+ aria-label="Add comment"
179
+ disabled={!props.canAddComment || !props.onAddComment}
180
+ onMouseDown={preserveEditorSelectionMouseDown}
181
+ onClick={props.onAddComment}
182
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40 outline-none ${focusRingClass}`}
183
+ data-testid="role-add-comment"
184
+ >
185
+ <MessageSquare className="h-3.5 w-3.5" />
186
+ </button>
187
+ </Tooltip.Trigger>
188
+ <Tooltip.Portal>
189
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
190
+ Add comment
191
+ </Tooltip.Content>
192
+ </Tooltip.Portal>
193
+ </Tooltip.Root>
194
+ );
195
+ case "tracked-changes-toggle":
196
+ return (
197
+ <Tooltip.Root>
198
+ <Tooltip.Trigger asChild>
199
+ <Toggle.Root
200
+ pressed={props.showTrackedChanges ?? false}
201
+ onPressedChange={(v) => props.onShowTrackedChangesChange?.(v)}
202
+ disabled={props.capabilities ? !props.capabilities.trackChangesSupported : false}
203
+ onMouseDown={preserveEditorSelectionMouseDown}
204
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface data-[state=on]:bg-canvas data-[state=on]:text-accent data-[state=on]:ring-1 data-[state=on]:ring-accent/30 data-[state=on]:shadow-sm outline-none disabled:opacity-40 ${focusRingClass}`}
205
+ data-testid="role-tracked-changes-toggle"
206
+ >
207
+ {(props.showTrackedChanges ?? false) ? (
208
+ <Eye className="h-3.5 w-3.5" />
209
+ ) : (
210
+ <EyeOff className="h-3.5 w-3.5" />
211
+ )}
212
+ </Toggle.Root>
213
+ </Tooltip.Trigger>
214
+ <Tooltip.Portal>
215
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
216
+ {(props.showTrackedChanges ?? false) ? "Hide tracked changes" : "Show tracked changes"}
217
+ </Tooltip.Content>
218
+ </Tooltip.Portal>
219
+ </Tooltip.Root>
220
+ );
221
+ case "review-sidebar-tracked-changes":
222
+ return (
223
+ <Tooltip.Root>
224
+ <Tooltip.Trigger asChild>
225
+ <button
226
+ type="button"
227
+ aria-label="Show tracked changes in sidebar"
228
+ disabled={!props.onReviewSidebarTrackedChanges}
229
+ onMouseDown={preserveEditorSelectionMouseDown}
230
+ onClick={props.onReviewSidebarTrackedChanges}
231
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40 outline-none ${focusRingClass}`}
232
+ data-testid="role-sidebar-tracked-changes"
233
+ >
234
+ <FileDiff className="h-3.5 w-3.5" />
235
+ </button>
236
+ </Tooltip.Trigger>
237
+ <Tooltip.Portal>
238
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
239
+ Tracked changes panel
240
+ </Tooltip.Content>
241
+ </Tooltip.Portal>
242
+ </Tooltip.Root>
243
+ );
244
+ case "review-sidebar-comments":
245
+ return (
246
+ <Tooltip.Root>
247
+ <Tooltip.Trigger asChild>
248
+ <button
249
+ type="button"
250
+ aria-label="Show comments in sidebar"
251
+ disabled={!props.onReviewSidebarComments}
252
+ onMouseDown={preserveEditorSelectionMouseDown}
253
+ onClick={props.onReviewSidebarComments}
254
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40 outline-none ${focusRingClass}`}
255
+ data-testid="role-sidebar-comments"
256
+ >
257
+ <MessageSquareDot className="h-3.5 w-3.5" />
258
+ </button>
259
+ </Tooltip.Trigger>
260
+ <Tooltip.Portal>
261
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
262
+ Comments panel
263
+ </Tooltip.Content>
264
+ </Tooltip.Portal>
265
+ </Tooltip.Root>
266
+ );
146
267
  case "editor-scope-posture-menu":
147
268
  return (
148
269
  <TwScopePostureMenu
@@ -42,6 +42,8 @@ import {
42
42
 
43
43
  import type {
44
44
  ActiveListContext,
45
+ ChromePinSurface,
46
+ ChromePinsState,
45
47
  CompatibilityPanelSnapshot,
46
48
  EditorRole,
47
49
  EditorStoryTarget,
@@ -49,6 +51,7 @@ import type {
49
51
  FormattingStateSnapshot,
50
52
  FormattingAlignment,
51
53
  InsertImageOptions,
54
+ PinState,
52
55
  ReviewQueueSnapshot,
53
56
  ScopeRailPosture,
54
57
  SectionBreakType,
@@ -61,6 +64,7 @@ import type {
61
64
  import type { SessionCapabilities } from "../../runtime/session-capabilities";
62
65
  import {
63
66
  getToolbarChromePlacement,
67
+ isChromeItemOwnedByRoleRegion,
64
68
  isToolbarChromeItemVisible,
65
69
  resolveScopedChromePolicy,
66
70
  type ScopedChromePolicy,
@@ -72,6 +76,7 @@ import {
72
76
  type MarkupDisplayMode,
73
77
  type WorkflowWorkItemSnapshot,
74
78
  } from "./tw-role-action-region";
79
+ import { TwDetachHandle } from "../chrome/tw-detach-handle";
75
80
  import { TwToolbarIconButton } from "./tw-toolbar-icon-button";
76
81
 
77
82
  export interface TwToolbarProps {
@@ -144,7 +149,10 @@ export interface TwToolbarProps {
144
149
  /** Markup display mode for the review role. */
145
150
  markupDisplay?: MarkupDisplayMode;
146
151
 
147
- // Editor role
152
+ // Shared: editor + review role (comment + TC in role region)
153
+ onReviewSidebarTrackedChanges?: () => void;
154
+ onReviewSidebarComments?: () => void;
155
+ // Workflow + review role: scope posture
148
156
  onMarkScopePosture?: (posture: ScopeRailPosture) => void;
149
157
  // Review role
150
158
  onReviewPrev?: () => void;
@@ -162,6 +170,10 @@ export interface TwToolbarProps {
162
170
  onWorkflowSkip?: () => void;
163
171
  onWorkflowMarkBlocked?: () => void;
164
172
  onWorkflowJumpToScope?: () => void;
173
+ /** Current chrome pin state; when supplied enables the topnav detach handle. */
174
+ chromePins?: ChromePinsState;
175
+ /** Called when the user detaches or re-attaches the topnav. */
176
+ onChromePinChange?: (surface: ChromePinSurface, pin: PinState | null) => void;
165
177
  }
166
178
 
167
179
  export interface ToolbarInteractionPolicy {
@@ -219,7 +231,12 @@ export function TwToolbar(props: TwToolbarProps) {
219
231
  const showTextColors = isToolbarChromeItemVisible(scopedChromePolicy, "text-colors");
220
232
  const showParagraphAlignment = isToolbarChromeItemVisible(scopedChromePolicy, "paragraph-alignment");
221
233
  const showInsertMenu = isToolbarChromeItemVisible(scopedChromePolicy, "insert-actions");
222
- const showTrackedChangesToggle = isToolbarChromeItemVisible(scopedChromePolicy, "tracked-changes-toggle");
234
+ const showTrackedChangesToggle =
235
+ isToolbarChromeItemVisible(scopedChromePolicy, "tracked-changes-toggle") &&
236
+ !isChromeItemOwnedByRoleRegion("tracked-changes-toggle", props.role);
237
+ const showRightClusterComment =
238
+ isToolbarChromeItemVisible(scopedChromePolicy, "comment") &&
239
+ !isChromeItemOwnedByRoleRegion("comment", props.role);
223
240
  const showHealth =
224
241
  showDiagnosticsChrome &&
225
242
  isToolbarChromeItemVisible(scopedChromePolicy, "health") &&
@@ -527,6 +544,13 @@ export function TwToolbar(props: TwToolbarProps) {
527
544
  reviewQueue={props.reviewQueue}
528
545
  workflowItem={props.workflowItem}
529
546
  markupDisplay={props.markupDisplay}
547
+ canAddComment={canAddComment}
548
+ showTrackedChanges={props.showTrackedChanges}
549
+ capabilities={caps}
550
+ onAddComment={props.onAddComment}
551
+ onShowTrackedChangesChange={props.onShowTrackedChangesChange}
552
+ onReviewSidebarTrackedChanges={props.onReviewSidebarTrackedChanges}
553
+ onReviewSidebarComments={props.onReviewSidebarComments}
530
554
  onMarkScopePosture={props.onMarkScopePosture}
531
555
  onReviewPrev={props.onReviewPrev}
532
556
  onReviewNext={props.onReviewNext}
@@ -567,7 +591,7 @@ export function TwToolbar(props: TwToolbarProps) {
567
591
  </>
568
592
  ) : null}
569
593
 
570
- {isToolbarChromeItemVisible(scopedChromePolicy, "comment") ? (
594
+ {showRightClusterComment ? (
571
595
  <TwToolbarIconButton
572
596
  icon={MessageSquare}
573
597
  label="Add comment"
@@ -809,6 +833,15 @@ export function TwToolbar(props: TwToolbarProps) {
809
833
  onClick={props.onExport}
810
834
  />
811
835
  ) : null}
836
+
837
+ {props.onChromePinChange ? (
838
+ <TwDetachHandle
839
+ surface="topnav"
840
+ pin={props.chromePins?.topnav}
841
+ onChange={props.onChromePinChange}
842
+ label="Detach toolbar"
843
+ />
844
+ ) : null}
812
845
  </div>
813
846
  </header>
814
847
  );
@@ -39,6 +39,7 @@ import type {
39
39
  ZoomLevel,
40
40
  } from "../api/public-types";
41
41
  import { findPageForOffset } from "../runtime/document-navigation.ts";
42
+ import { createCanvasBackend } from "../runtime/layout/index.ts";
42
43
  import {
43
44
  DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
44
45
  estimateBlockHeight,
@@ -50,6 +51,8 @@ import {
50
51
  incrementInvalidationCounter,
51
52
  recordPerfSample,
52
53
  } from "./editor-surface/perf-probe.ts";
54
+ import { sliceBlocksForPage } from "./editor-surface/page-slice-util.ts";
55
+ import { TwPageBlockView } from "./editor-surface/tw-page-block-view.tsx";
53
56
  import { computeLineMarkersIfEnabled } from "./page-chrome-model.ts";
54
57
  import type { SessionCapabilities } from "../runtime/session-capabilities";
55
58
  import type {
@@ -77,8 +80,9 @@ import {
77
80
  resolveChromePresetOptions,
78
81
  resolveChromeVisibilityForPreset,
79
82
  } from "./chrome/chrome-preset-model";
80
- import { TwReviewQueueBar } from "./chrome/review-queue-bar";
81
83
  import { TwSelectionToolHost } from "./chrome/tw-selection-tool-host";
84
+ import { resolveSelectionAnchor } from "./chrome/tw-selection-anchor-resolver";
85
+ import { resolveSelectionToolPlacement } from "./chrome/tw-selection-tool-placement";
82
86
  import { TwReviewRail, type ReviewRailTab } from "./review/tw-review-rail";
83
87
  import { TwStatusBar } from "./status/tw-status-bar";
84
88
  import { type ToolbarInteractionPolicy } from "./toolbar/tw-toolbar";
@@ -262,6 +266,14 @@ export interface TwReviewWorkspaceProps {
262
266
  onCloseStory?: () => void;
263
267
  onOpenHeaderStory?: () => void;
264
268
  onOpenFooterStory?: () => void;
269
+ /**
270
+ * Open a header/footer story for a specific page. Called when the user
271
+ * double-clicks a per-page header/footer band in the page-stack chrome.
272
+ * Must resolve the correct variant for that page's section and call
273
+ * `runtime.openStory()`.
274
+ */
275
+ onOpenHeaderStoryForPage?: (pageIndex: number) => void;
276
+ onOpenFooterStoryForPage?: (pageIndex: number) => void;
265
277
  onSetParagraphIndentation?: (indentation: {
266
278
  left?: number;
267
279
  right?: number;
@@ -271,13 +283,37 @@ export interface TwReviewWorkspaceProps {
271
283
  onSetParagraphTabStops?: (tabStops: Array<{ pos: number; val?: string; leader?: string }>) => void;
272
284
  onRestartNumbering?: () => void;
273
285
  onContinueNumbering?: () => void;
286
+ // P6: new table ops
287
+ onToggleRowHeader?: () => void;
288
+ onToggleRowCantSplit?: () => void;
289
+ onDistributeColumnsEvenly?: () => void;
290
+ onSetTableAlignment?: (alignment: "left" | "center" | "right") => void;
291
+ onSetCellVerticalAlign?: (align: "top" | "center" | "bottom") => void;
292
+ /** P6: active table context for chrome overlay grips. */
293
+ tableContext?: import("../api/public-types").TableStructureContextSnapshot | null;
294
+ /** P6: column resize committed from overlay grip → set-column-width op. */
295
+ onSetColumnWidth?: (columnIndex: number, twips: number) => void;
296
+ /** P6: row resize committed from overlay grip → set-row-height op. */
297
+ onSetRowHeight?: (rowIndex: number, twips: number, rule: "auto" | "atLeast" | "exact") => void;
298
+ onListIndent?: () => void;
299
+ onListOutdent?: () => void;
274
300
  onUpdateFields?: () => void;
275
301
  onUpdateTableOfContents?: () => void;
276
302
  onGoToPreviousReviewItem?: () => void;
277
303
  onGoToNextReviewItem?: () => void;
278
304
  onMarkSectionForReview?: () => void;
305
+ /** Optional: open sidebar to tracked-changes panel. When provided, the review role shows a sidebar-TC icon. */
306
+ onReviewSidebarTrackedChanges?: () => void;
307
+ /** Optional: open sidebar to comments panel. When provided, the review role shows a sidebar-comments icon. */
308
+ onReviewSidebarComments?: () => void;
279
309
  onNavigateHeading?: (headingId: string) => void;
280
310
  chromeVisibility?: Partial<ReviewWorkspaceChromeVisibility>;
311
+ /**
312
+ * Called when the shell-header mode tab changes or any chrome surface fires
313
+ * a role switch. Wire to `runtime.setEditorRole(role)` so the workspace
314
+ * re-renders with the new per-role action set.
315
+ */
316
+ onEditorRoleChange?: (role: import("../api/public-types.ts").EditorRole) => void;
281
317
  }
282
318
 
283
319
  export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
@@ -319,6 +355,9 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
319
355
  reviewRailAvailable,
320
356
  }),
321
357
  );
358
+ // Incremented on zoom_changed / render_frame_ready so the placement
359
+ // useMemo below re-executes when the render kernel emits new rects.
360
+ const [renderFrameRevision, setRenderFrameRevision] = useState(0);
322
361
  const responsiveChromeSignatureRef = useRef<string | null>(null);
323
362
  const headings = props.documentNavigation?.headings ?? [];
324
363
  const headerVariant = snapshot.pageLayout?.headerVariants[0]?.variant ?? "default";
@@ -348,11 +387,6 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
348
387
  ),
349
388
  [props.documentNavigation, snapshot.activeStory, snapshot.pageLayout, snapshot.surface],
350
389
  );
351
- const selectionToolbarPlacement = resolveSelectionToolbarPlacement(
352
- props.selectionToolAnchor,
353
- selectionToolbarRootRef.current,
354
- zoomScale,
355
- );
356
390
  const effectiveSelectionMode = props.interactionGuardSnapshot?.effectiveMode ?? "edit";
357
391
  const allowLocalChromeMutations = Boolean(caps?.canEdit) && effectiveSelectionMode === "edit";
358
392
  const gatedSelectionTool = useMemo(() => {
@@ -364,6 +398,40 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
364
398
  }
365
399
  return props.activeSelectionTool;
366
400
  }, [props.activeSelectionTool, chromeVisibility.contextToolbars]);
401
+ const selectionToolbarPlacement = useMemo(() => {
402
+ // Prefer render-frame anchors when the layout facet is available — this
403
+ // keeps the tool glued to kernel coordinates across zoom, scroll, and
404
+ // predicted-text reconciliation (R4).
405
+ if (props.layoutFacet && gatedSelectionTool) {
406
+ const anchorRect = resolveSelectionAnchor({
407
+ facet: props.layoutFacet,
408
+ selection: viewState.selection,
409
+ tool: gatedSelectionTool,
410
+ });
411
+ if (anchorRect && selectionToolbarRootRef.current) {
412
+ const containerRect = selectionToolbarRootRef.current.getBoundingClientRect();
413
+ const result = resolveSelectionToolPlacement({
414
+ anchor: anchorRect,
415
+ container: { widthPx: containerRect.width, heightPx: containerRect.height },
416
+ });
417
+ if (result) return result;
418
+ }
419
+ }
420
+ // Fall back to DOM rects for hosts that do not supply a layout facet.
421
+ return resolveSelectionToolbarPlacement(
422
+ props.selectionToolAnchor,
423
+ selectionToolbarRootRef.current,
424
+ zoomScale,
425
+ );
426
+ // eslint-disable-next-line react-hooks/exhaustive-deps
427
+ }, [
428
+ props.layoutFacet,
429
+ props.selectionToolAnchor,
430
+ gatedSelectionTool,
431
+ viewState.selection,
432
+ zoomScale,
433
+ renderFrameRevision,
434
+ ]);
367
435
  const activePage = props.documentNavigation?.pages[props.documentNavigation.activePageIndex] ?? null;
368
436
  const pageShellMetrics = useMemo(
369
437
  () => buildPageShellMetrics(snapshot.pageLayout),
@@ -388,6 +456,9 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
388
456
  }),
389
457
  [reviewRailAvailable, reviewRailOpen, viewportWidth],
390
458
  );
459
+ const hasSidebarPanelAccess = Boolean(
460
+ props.onReviewSidebarTrackedChanges || props.onReviewSidebarComments,
461
+ );
391
462
  const scopedChromePolicy = useMemo(
392
463
  () =>
393
464
  resolveScopedChromePolicy({
@@ -397,14 +468,18 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
397
468
  interactionGuardSnapshot: props.interactionGuardSnapshot,
398
469
  workflowScopeSnapshot: props.workflowScopeSnapshot,
399
470
  activeListContext: props.activeListContext,
471
+ role: viewState.editorRole,
472
+ hasSidebarPanelAccess,
400
473
  }),
401
474
  [
402
475
  caps,
403
476
  chromePreset,
477
+ hasSidebarPanelAccess,
404
478
  props.activeListContext,
405
479
  props.interactionGuardSnapshot,
406
480
  props.workflowScopeSnapshot,
407
481
  responsiveChrome.isNarrow,
482
+ viewState.editorRole,
408
483
  ],
409
484
  );
410
485
  const toolbarInteractionPolicy: ToolbarInteractionPolicy | undefined = caps
@@ -446,6 +521,18 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
446
521
  };
447
522
  }, []);
448
523
 
524
+ // Subscribe to layout facet events so chrome re-projects on zoom changes
525
+ // and after incremental relayout (R4).
526
+ useEffect(() => {
527
+ if (!props.layoutFacet) return;
528
+ const unsub = props.layoutFacet.subscribe((event) => {
529
+ if (event.kind === "zoom_changed" || event.kind === "render_frame_ready") {
530
+ setRenderFrameRevision((n) => n + 1);
531
+ }
532
+ });
533
+ return unsub;
534
+ }, [props.layoutFacet]);
535
+
449
536
  useEffect(() => {
450
537
  const responsiveSignature = `${reviewRailAvailable ? "1" : "0"}:${isNarrowChromeViewport(viewportWidth) ? "n" : "d"}`;
451
538
  if (responsiveChromeSignatureRef.current === responsiveSignature) {
@@ -492,6 +579,14 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
492
579
  };
493
580
  }, [responsiveChrome.showDrawerReviewRail]);
494
581
 
582
+ useEffect(() => {
583
+ if (!props.layoutFacet) return;
584
+ const facet = props.layoutFacet;
585
+ void document.fonts.ready.then(() => {
586
+ facet.swapMeasurementProvider(createCanvasBackend());
587
+ });
588
+ }, [props.layoutFacet]);
589
+
495
590
  return (
496
591
  <Tooltip.Provider delayDuration={400}>
497
592
  <div className="flex h-full flex-col bg-canvas text-primary">
@@ -609,6 +704,12 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
609
704
  role={viewState.editorRole}
610
705
  reviewQueue={props.reviewQueue}
611
706
  markupDisplay={markupDisplay}
707
+ onReviewSidebarTrackedChanges={props.onReviewSidebarTrackedChanges
708
+ ? runWithSelectionToolbarDismiss(props.onReviewSidebarTrackedChanges)
709
+ : undefined}
710
+ onReviewSidebarComments={props.onReviewSidebarComments
711
+ ? runWithSelectionToolbarDismiss(props.onReviewSidebarComments)
712
+ : undefined}
612
713
  onMarkScopePosture={props.onMarkSectionForReview
613
714
  ? runWithSelectionToolbarDismiss(props.onMarkSectionForReview)
614
715
  : undefined}
@@ -642,27 +743,14 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
642
743
  props.onRejectRevision?.(revisionId);
643
744
  };
644
745
  })()}
746
+ chromePins={viewState.chromePins}
747
+ onChromePinChange={props.onChromePinChange}
645
748
  />
646
749
  </div>
647
750
  ) : null}
648
751
 
649
- {viewState.editorRole !== "review" &&
650
- chromePreset === "review" &&
651
- chromeOptions.showReviewQueueBar &&
652
- props.reviewQueue ? (
653
- <TwReviewQueueBar
654
- queue={props.reviewQueue}
655
- onPrevious={props.onGoToPreviousReviewItem
656
- ? runWithSelectionToolbarDismiss(props.onGoToPreviousReviewItem)
657
- : undefined}
658
- onNext={props.onGoToNextReviewItem
659
- ? runWithSelectionToolbarDismiss(props.onGoToNextReviewItem)
660
- : undefined}
661
- onMarkSection={chromeOptions.showSectionTagAction && props.onMarkSectionForReview
662
- ? runWithSelectionToolbarDismiss(props.onMarkSectionForReview)
663
- : undefined}
664
- />
665
- ) : null}
752
+ {/* Legacy TwReviewQueueBar is suppressed — review role's action region
753
+ now owns queue prev/next + counts inline in the top toolbar. */}
666
754
 
667
755
  {chromeVisibility.alerts ? <TwAlertBanner
668
756
  snapshot={snapshot}
@@ -967,6 +1055,11 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
967
1055
  onSetImageFrame={props.onSetImageFrame}
968
1056
  onRestartNumbering={props.onRestartNumbering}
969
1057
  onContinueNumbering={props.onContinueNumbering}
1058
+ onToggleRowHeader={props.onToggleRowHeader}
1059
+ onToggleRowCantSplit={props.onToggleRowCantSplit}
1060
+ onDistributeColumnsEvenly={props.onDistributeColumnsEvenly}
1061
+ onSetTableAlignment={props.onSetTableAlignment}
1062
+ onSetCellVerticalAlign={props.onSetCellVerticalAlign}
970
1063
  chromePins={viewState.chromePins}
971
1064
  onChromePinChange={props.onChromePinChange}
972
1065
  />
@@ -1035,15 +1128,22 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1035
1128
  style={pageChromeModel.pageBorderStyle}
1036
1129
  />
1037
1130
  ) : null}
1038
- <div className={isPageWorkspace ? "relative z-10" : undefined}>
1131
+ <div className={isPageWorkspace ? "relative z-10" : "relative"}>
1132
+ {/* Page chrome (frame borders, header/footer bands,
1133
+ page-number labels, inter-page separators) is
1134
+ rendered as in-flow widget decorations inside
1135
+ the PM surface itself — see
1136
+ `pm-page-break-decorations.ts`. That keeps the
1137
+ chrome perfectly aligned with PM content without
1138
+ any absolute-positioned overlay that would drift
1139
+ relative to the browser's line layout. */}
1039
1140
  {props.document}
1040
1141
  {props.layoutFacet ? (
1041
1142
  <TwChromeOverlay
1042
1143
  facet={props.layoutFacet}
1043
- activeWorkspaceView={
1044
- props.reviewMode === "review" ? "review" : "workflow"
1045
- }
1046
- showWorkspaceDock={chromeVisibility.pageChrome}
1144
+ tableContext={props.tableContext}
1145
+ onSetColumnWidth={props.onSetColumnWidth}
1146
+ onSetRowHeight={props.onSetRowHeight}
1047
1147
  />
1048
1148
  ) : null}
1049
1149
  </div>
@@ -1070,32 +1170,10 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1070
1170
  </div>
1071
1171
  </div>
1072
1172
  </div>
1073
- {isPageWorkspace && (props.documentNavigation?.pages.length ?? 0) > 1 ? (
1074
- <div className="flex flex-col items-center gap-8 pb-8" data-testid="page-stack-continuation">
1075
- {props.documentNavigation!.pages.slice(1).map((page) => (
1076
- <div
1077
- key={`page-${page.pageIndex}`}
1078
- data-wre-page-frame="true"
1079
- data-page-index={page.pageIndex}
1080
- className="wre-page-chrome wre-page-surface relative mx-auto w-full max-w-[840px] overflow-hidden rounded-[2px] bg-canvas"
1081
- style={{
1082
- minHeight: "600px",
1083
- boxShadow: "0 1px 2px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.04)",
1084
- border: "1px solid var(--color-border, rgba(0,0,0,0.08))",
1085
- }}
1086
- >
1087
- <div className="absolute left-4 top-3 text-[11px] uppercase tracking-[0.12em] text-tertiary">
1088
- Page {page.pageIndex + 1} of {props.documentNavigation!.pageCount}
1089
- </div>
1090
- <div className="absolute inset-0 flex items-center justify-center text-sm text-secondary">
1091
- Continuation of document flow.
1092
- <br />
1093
- (Editing occurs in the page above.)
1094
- </div>
1095
- </div>
1096
- ))}
1097
- </div>
1098
- ) : null}
1173
+ {/* Pages 2..N in page mode are now rendered by TwPageStackChrome
1174
+ as absolute overlays above the single flowing PM surface.
1175
+ The PM surface holds all editable content; page frames draw
1176
+ borders, header/footer bands, and per-page numbers on top. */}
1099
1177
  </div>
1100
1178
 
1101
1179
  {chromeVisibility.statusBar ? (