@beyondwork/docx-react-component 1.0.37 → 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 (116) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +496 -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 +845 -56
  6. package/src/core/commands/text-commands.ts +122 -2
  7. package/src/io/docx-session.ts +1 -0
  8. package/src/io/export/serialize-main-document.ts +2 -11
  9. package/src/io/export/serialize-numbering.ts +43 -10
  10. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  11. package/src/io/export/serialize-run-formatting.ts +90 -0
  12. package/src/io/export/serialize-styles.ts +212 -0
  13. package/src/io/export/serialize-tables.ts +74 -0
  14. package/src/io/export/table-properties-xml.ts +139 -4
  15. package/src/io/normalize/normalize-text.ts +15 -0
  16. package/src/io/ooxml/parse-fields.ts +10 -3
  17. package/src/io/ooxml/parse-footnotes.ts +60 -0
  18. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  19. package/src/io/ooxml/parse-main-document.ts +137 -0
  20. package/src/io/ooxml/parse-numbering.ts +41 -1
  21. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  22. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  23. package/src/io/ooxml/parse-styles.ts +31 -0
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  26. package/src/io/ooxml/xml-element.ts +19 -0
  27. package/src/model/canonical-document.ts +117 -3
  28. package/src/runtime/collab/event-types.ts +165 -0
  29. package/src/runtime/collab/index.ts +22 -0
  30. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  31. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  32. package/src/runtime/document-layout.ts +4 -2
  33. package/src/runtime/document-navigation.ts +1 -1
  34. package/src/runtime/document-runtime.ts +248 -18
  35. package/src/runtime/layout/default-page-format.ts +96 -0
  36. package/src/runtime/layout/index.ts +47 -0
  37. package/src/runtime/layout/inert-layout-facet.ts +16 -0
  38. package/src/runtime/layout/layout-engine-instance.ts +100 -23
  39. package/src/runtime/layout/layout-invalidation.ts +14 -5
  40. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-graph.ts +55 -0
  43. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  44. package/src/runtime/layout/paginated-layout-engine.ts +484 -37
  45. package/src/runtime/layout/project-block-fragments.ts +225 -0
  46. package/src/runtime/layout/public-facet.ts +748 -16
  47. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  48. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  49. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  50. package/src/runtime/layout/table-render-plan.ts +249 -0
  51. package/src/runtime/numbering-prefix.ts +5 -0
  52. package/src/runtime/paragraph-style-resolver.ts +194 -0
  53. package/src/runtime/render/block-fragment-projection.ts +35 -0
  54. package/src/runtime/render/decoration-resolver.ts +189 -0
  55. package/src/runtime/render/index.ts +57 -0
  56. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  57. package/src/runtime/render/render-frame-types.ts +317 -0
  58. package/src/runtime/render/render-kernel.ts +759 -0
  59. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  60. package/src/runtime/surface-projection.ts +129 -9
  61. package/src/runtime/table-schema.ts +11 -0
  62. package/src/runtime/view-state.ts +67 -0
  63. package/src/runtime/workflow-markup.ts +1 -5
  64. package/src/runtime/workflow-rail-segments.ts +280 -0
  65. package/src/ui/WordReviewEditor.tsx +368 -19
  66. package/src/ui/editor-command-bag.ts +4 -0
  67. package/src/ui/editor-runtime-boundary.ts +16 -0
  68. package/src/ui/editor-shell-view.tsx +10 -0
  69. package/src/ui/editor-surface-controller.tsx +9 -1
  70. package/src/ui/headless/chrome-registry.ts +310 -15
  71. package/src/ui/headless/scoped-chrome-policy.ts +49 -1
  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/review-queue-bar.tsx +2 -14
  75. package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
  76. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  77. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
  78. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
  79. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  80. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  81. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  82. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  83. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
  84. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  85. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
  86. package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
  88. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  89. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  90. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  91. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  92. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  93. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
  94. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  95. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  96. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  97. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  98. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  99. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  100. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
  101. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  102. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  103. package/src/ui-tailwind/index.ts +29 -0
  104. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  105. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  106. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  107. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  108. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  109. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  110. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
  111. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  112. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  113. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
  114. package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
  115. package/src/runtime/collab-review-sync.ts +0 -254
  116. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -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,11 +80,13 @@ 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";
89
+ import { TwChromeOverlay } from "./chrome-overlay";
85
90
 
86
91
  export type ReviewWorkspaceChromeVisibility = WordReviewEditorChromeVisibility;
87
92
 
@@ -92,6 +97,38 @@ export interface TwReviewWorkspaceProps {
92
97
  currentUserId?: string;
93
98
  capabilities?: SessionCapabilities;
94
99
  reviewMode?: "editing" | "review";
100
+ /**
101
+ * Runtime-owned layout facet. Optional so existing tests + host apps
102
+ * continue to mount the workspace without installing a facet. When
103
+ * supplied, the ChromeOverlay plane (scope rail, workflow dock, etc.)
104
+ * renders over the document column.
105
+ */
106
+ layoutFacet?: import("../runtime/layout/index.ts").WordReviewEditorLayoutFacet;
107
+ /**
108
+ * Optional shell header mounted above the formatting toolbar. Pass a
109
+ * pre-assembled `<TwShellHeader />` with brand / mode switcher /
110
+ * primaryAction, or any other ReactNode. Hosts that do not supply this
111
+ * get the legacy layout.
112
+ */
113
+ shellHeader?: ReactNode;
114
+ /**
115
+ * Optional host-provided Workflow-tab override for the review rail.
116
+ * When unset the rail renders the built-in `TwWorkflowTab` sourced from
117
+ * `layoutFacet.getAllScopeRailSegments()`.
118
+ */
119
+ reviewRailWorkflowTab?: ReactNode;
120
+ reviewRailWorkflowCount?: number;
121
+ reviewRailWorkflowScopesTitle?: string;
122
+ reviewRailIntelligenceEyebrow?: string;
123
+ /** Opt in to the editorial DOCUMENT INTELLIGENCE header + underline tab chip. */
124
+ reviewRailIntelligenceHeader?: boolean;
125
+ /** Optional SEARCH / HELP utility footer at the bottom of the rail. */
126
+ reviewRailFooter?: {
127
+ onSearch?: () => void;
128
+ helpHref?: string;
129
+ searchLabel?: string;
130
+ helpLabel?: string;
131
+ };
95
132
  document: ReactNode;
96
133
  workspaceMode: WorkspaceMode;
97
134
  zoomLevel?: ZoomLevel;
@@ -114,6 +151,17 @@ export interface TwReviewWorkspaceProps {
114
151
  activeSelectionTool?: ActiveSelectionToolModel | null;
115
152
  selectionToolAnchor?: SelectionToolAnchor | null;
116
153
  documentNavigation?: DocumentNavigationSnapshot;
154
+ /**
155
+ * R2.3: chrome-pin change handler. When supplied, selection tools
156
+ * expose their detach affordance and persist pin state through to
157
+ * runtime ViewState (via the host's `setChromePin` action). When
158
+ * omitted, the detach handle is suppressed — the tool behaves as
159
+ * a non-pinnable anchored panel (pre-R2 behavior for most kinds).
160
+ */
161
+ onChromePinChange?: (
162
+ surface: import("../api/public-types").ChromePinSurface,
163
+ pin: import("../api/public-types").PinState | null,
164
+ ) => void;
117
165
  onWorkspaceModeChange?: (value: WorkspaceMode) => void;
118
166
  onZoomChange?: (level: ZoomLevel) => void;
119
167
  onActiveRailTabChange?: (value: ReviewRailTab) => void;
@@ -218,6 +266,14 @@ export interface TwReviewWorkspaceProps {
218
266
  onCloseStory?: () => void;
219
267
  onOpenHeaderStory?: () => void;
220
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;
221
277
  onSetParagraphIndentation?: (indentation: {
222
278
  left?: number;
223
279
  right?: number;
@@ -227,13 +283,37 @@ export interface TwReviewWorkspaceProps {
227
283
  onSetParagraphTabStops?: (tabStops: Array<{ pos: number; val?: string; leader?: string }>) => void;
228
284
  onRestartNumbering?: () => void;
229
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;
230
300
  onUpdateFields?: () => void;
231
301
  onUpdateTableOfContents?: () => void;
232
302
  onGoToPreviousReviewItem?: () => void;
233
303
  onGoToNextReviewItem?: () => void;
234
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;
235
309
  onNavigateHeading?: (headingId: string) => void;
236
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;
237
317
  }
238
318
 
239
319
  export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
@@ -275,6 +355,9 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
275
355
  reviewRailAvailable,
276
356
  }),
277
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);
278
361
  const responsiveChromeSignatureRef = useRef<string | null>(null);
279
362
  const headings = props.documentNavigation?.headings ?? [];
280
363
  const headerVariant = snapshot.pageLayout?.headerVariants[0]?.variant ?? "default";
@@ -304,11 +387,6 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
304
387
  ),
305
388
  [props.documentNavigation, snapshot.activeStory, snapshot.pageLayout, snapshot.surface],
306
389
  );
307
- const selectionToolbarPlacement = resolveSelectionToolbarPlacement(
308
- props.selectionToolAnchor,
309
- selectionToolbarRootRef.current,
310
- zoomScale,
311
- );
312
390
  const effectiveSelectionMode = props.interactionGuardSnapshot?.effectiveMode ?? "edit";
313
391
  const allowLocalChromeMutations = Boolean(caps?.canEdit) && effectiveSelectionMode === "edit";
314
392
  const gatedSelectionTool = useMemo(() => {
@@ -320,6 +398,40 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
320
398
  }
321
399
  return props.activeSelectionTool;
322
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
+ ]);
323
435
  const activePage = props.documentNavigation?.pages[props.documentNavigation.activePageIndex] ?? null;
324
436
  const pageShellMetrics = useMemo(
325
437
  () => buildPageShellMetrics(snapshot.pageLayout),
@@ -344,6 +456,9 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
344
456
  }),
345
457
  [reviewRailAvailable, reviewRailOpen, viewportWidth],
346
458
  );
459
+ const hasSidebarPanelAccess = Boolean(
460
+ props.onReviewSidebarTrackedChanges || props.onReviewSidebarComments,
461
+ );
347
462
  const scopedChromePolicy = useMemo(
348
463
  () =>
349
464
  resolveScopedChromePolicy({
@@ -353,14 +468,18 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
353
468
  interactionGuardSnapshot: props.interactionGuardSnapshot,
354
469
  workflowScopeSnapshot: props.workflowScopeSnapshot,
355
470
  activeListContext: props.activeListContext,
471
+ role: viewState.editorRole,
472
+ hasSidebarPanelAccess,
356
473
  }),
357
474
  [
358
475
  caps,
359
476
  chromePreset,
477
+ hasSidebarPanelAccess,
360
478
  props.activeListContext,
361
479
  props.interactionGuardSnapshot,
362
480
  props.workflowScopeSnapshot,
363
481
  responsiveChrome.isNarrow,
482
+ viewState.editorRole,
364
483
  ],
365
484
  );
366
485
  const toolbarInteractionPolicy: ToolbarInteractionPolicy | undefined = caps
@@ -402,6 +521,18 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
402
521
  };
403
522
  }, []);
404
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
+
405
536
  useEffect(() => {
406
537
  const responsiveSignature = `${reviewRailAvailable ? "1" : "0"}:${isNarrowChromeViewport(viewportWidth) ? "n" : "d"}`;
407
538
  if (responsiveChromeSignatureRef.current === responsiveSignature) {
@@ -448,9 +579,18 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
448
579
  };
449
580
  }, [responsiveChrome.showDrawerReviewRail]);
450
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
+
451
590
  return (
452
591
  <Tooltip.Provider delayDuration={400}>
453
592
  <div className="flex h-full flex-col bg-canvas text-primary">
593
+ {props.shellHeader}
454
594
  {chromeVisibility.toolbar ? (
455
595
  <div className="px-3 pt-3">
456
596
  <ChromePresetToolbar
@@ -561,24 +701,56 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
561
701
  dismissSelectionToolbar();
562
702
  props.onShowTrackedChangesChange(show);
563
703
  }}
704
+ role={viewState.editorRole}
705
+ reviewQueue={props.reviewQueue}
706
+ markupDisplay={markupDisplay}
707
+ onReviewSidebarTrackedChanges={props.onReviewSidebarTrackedChanges
708
+ ? runWithSelectionToolbarDismiss(props.onReviewSidebarTrackedChanges)
709
+ : undefined}
710
+ onReviewSidebarComments={props.onReviewSidebarComments
711
+ ? runWithSelectionToolbarDismiss(props.onReviewSidebarComments)
712
+ : undefined}
713
+ onMarkScopePosture={props.onMarkSectionForReview
714
+ ? runWithSelectionToolbarDismiss(props.onMarkSectionForReview)
715
+ : undefined}
716
+ onReviewPrev={props.onGoToPreviousReviewItem
717
+ ? runWithSelectionToolbarDismiss(props.onGoToPreviousReviewItem)
718
+ : undefined}
719
+ onReviewNext={props.onGoToNextReviewItem
720
+ ? runWithSelectionToolbarDismiss(props.onGoToNextReviewItem)
721
+ : undefined}
722
+ onReviewAccept={(() => {
723
+ const active = props.reviewQueue?.items[props.reviewQueue.activeIndex];
724
+ if (active?.kind !== "change" || !props.onAcceptRevision) {
725
+ return undefined;
726
+ }
727
+ // ReviewQueueItem.itemId for a "change" entry is the
728
+ // revision id (set by the runtime review-queue projection).
729
+ const revisionId = active.itemId;
730
+ return () => {
731
+ dismissSelectionToolbar();
732
+ props.onAcceptRevision?.(revisionId);
733
+ };
734
+ })()}
735
+ onReviewReject={(() => {
736
+ const active = props.reviewQueue?.items[props.reviewQueue.activeIndex];
737
+ if (active?.kind !== "change" || !props.onRejectRevision) {
738
+ return undefined;
739
+ }
740
+ const revisionId = active.itemId;
741
+ return () => {
742
+ dismissSelectionToolbar();
743
+ props.onRejectRevision?.(revisionId);
744
+ };
745
+ })()}
746
+ chromePins={viewState.chromePins}
747
+ onChromePinChange={props.onChromePinChange}
564
748
  />
565
749
  </div>
566
750
  ) : null}
567
751
 
568
- {chromePreset === "review" && chromeOptions.showReviewQueueBar && props.reviewQueue ? (
569
- <TwReviewQueueBar
570
- queue={props.reviewQueue}
571
- onPrevious={props.onGoToPreviousReviewItem
572
- ? runWithSelectionToolbarDismiss(props.onGoToPreviousReviewItem)
573
- : undefined}
574
- onNext={props.onGoToNextReviewItem
575
- ? runWithSelectionToolbarDismiss(props.onGoToNextReviewItem)
576
- : undefined}
577
- onMarkSection={chromeOptions.showSectionTagAction && props.onMarkSectionForReview
578
- ? runWithSelectionToolbarDismiss(props.onMarkSectionForReview)
579
- : undefined}
580
- />
581
- ) : null}
752
+ {/* Legacy TwReviewQueueBar is suppressed review role's action region
753
+ now owns queue prev/next + counts inline in the top toolbar. */}
582
754
 
583
755
  {chromeVisibility.alerts ? <TwAlertBanner
584
756
  snapshot={snapshot}
@@ -883,6 +1055,13 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
883
1055
  onSetImageFrame={props.onSetImageFrame}
884
1056
  onRestartNumbering={props.onRestartNumbering}
885
1057
  onContinueNumbering={props.onContinueNumbering}
1058
+ onToggleRowHeader={props.onToggleRowHeader}
1059
+ onToggleRowCantSplit={props.onToggleRowCantSplit}
1060
+ onDistributeColumnsEvenly={props.onDistributeColumnsEvenly}
1061
+ onSetTableAlignment={props.onSetTableAlignment}
1062
+ onSetCellVerticalAlign={props.onSetCellVerticalAlign}
1063
+ chromePins={viewState.chromePins}
1064
+ onChromePinChange={props.onChromePinChange}
886
1065
  />
887
1066
  ) : null}
888
1067
  <div
@@ -949,8 +1128,24 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
949
1128
  style={pageChromeModel.pageBorderStyle}
950
1129
  />
951
1130
  ) : null}
952
- <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. */}
953
1140
  {props.document}
1141
+ {props.layoutFacet ? (
1142
+ <TwChromeOverlay
1143
+ facet={props.layoutFacet}
1144
+ tableContext={props.tableContext}
1145
+ onSetColumnWidth={props.onSetColumnWidth}
1146
+ onSetRowHeight={props.onSetRowHeight}
1147
+ />
1148
+ ) : null}
954
1149
  </div>
955
1150
  {isPageWorkspace && chromeVisibility.pageChrome ? (
956
1151
  <div
@@ -975,6 +1170,10 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
975
1170
  </div>
976
1171
  </div>
977
1172
  </div>
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. */}
978
1177
  </div>
979
1178
 
980
1179
  {chromeVisibility.statusBar ? (
@@ -1021,6 +1220,13 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1021
1220
  onRejectRevision={props.onRejectRevision}
1022
1221
  onAcceptAllChanges={props.onAcceptAllChanges}
1023
1222
  onRejectAllChanges={props.onRejectAllChanges}
1223
+ scopeRailSegments={props.layoutFacet?.getAllScopeRailSegments?.() ?? []}
1224
+ workflowTab={props.reviewRailWorkflowTab}
1225
+ workflowCount={props.reviewRailWorkflowCount}
1226
+ workflowScopesTitle={props.reviewRailWorkflowScopesTitle}
1227
+ intelligenceEyebrow={props.reviewRailIntelligenceEyebrow}
1228
+ intelligenceHeader={props.reviewRailIntelligenceHeader}
1229
+ railFooter={props.reviewRailFooter}
1024
1230
  /> : null}
1025
1231
 
1026
1232
  {responsiveChrome.showDrawerReviewRail ? (
@@ -1062,6 +1268,13 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1062
1268
  onRejectRevision={props.onRejectRevision}
1063
1269
  onAcceptAllChanges={props.onAcceptAllChanges}
1064
1270
  onRejectAllChanges={props.onRejectAllChanges}
1271
+ scopeRailSegments={props.layoutFacet?.getAllScopeRailSegments?.() ?? []}
1272
+ workflowTab={props.reviewRailWorkflowTab}
1273
+ workflowCount={props.reviewRailWorkflowCount}
1274
+ workflowScopesTitle={props.reviewRailWorkflowScopesTitle}
1275
+ intelligenceEyebrow={props.reviewRailIntelligenceEyebrow}
1276
+ intelligenceHeader={props.reviewRailIntelligenceHeader}
1277
+ railFooter={props.reviewRailFooter}
1065
1278
  />
1066
1279
  </div>
1067
1280
  </div>
@@ -1,254 +0,0 @@
1
- import * as Y from "yjs";
2
-
3
- import type { DocumentRuntime, DocumentRuntimeEvent, Unsubscribe } from "./document-runtime.ts";
4
-
5
- // ---------------------------------------------------------------------------
6
- // Serialised shapes stored inside the Y.Maps
7
- // ---------------------------------------------------------------------------
8
-
9
- interface YCommentThread {
10
- commentId: string;
11
- status: "open" | "resolved" | "detached";
12
- anchor: { kind: string; [key: string]: unknown };
13
- createdAt: string;
14
- createdBy: string;
15
- authorId: string;
16
- body: string;
17
- entries: Array<{
18
- entryId: string;
19
- authorId: string;
20
- body: string;
21
- createdAt: string;
22
- }>;
23
- resolvedAt?: string;
24
- resolvedBy?: string;
25
- warningIds: string[];
26
- sourceClientId: number;
27
- }
28
-
29
- interface YRevisionAction {
30
- changeId: string;
31
- action: "accept" | "reject";
32
- sourceClientId: number;
33
- }
34
-
35
- // ---------------------------------------------------------------------------
36
- // Public API
37
- // ---------------------------------------------------------------------------
38
-
39
- export interface CollabReviewSyncHandle {
40
- destroy(): void;
41
- }
42
-
43
- export function createCollabReviewSync(
44
- ydoc: Y.Doc,
45
- runtime: DocumentRuntime,
46
- ): CollabReviewSyncHandle {
47
- const yComments = ydoc.getMap<YCommentThread>("comments");
48
- const yRevisionActions = ydoc.getMap<YRevisionAction>("revisionActions");
49
- const clientId = ydoc.clientID;
50
-
51
- let suppressLocalEvents = false;
52
-
53
- // --- Local → Yjs ---------------------------------------------------------
54
-
55
- const unsubEvents: Unsubscribe = runtime.subscribeToEvents((event) => {
56
- if (suppressLocalEvents) return;
57
-
58
- switch (event.type) {
59
- case "comment_added":
60
- pushCommentToYjs(event.commentId);
61
- break;
62
- case "comment_resolved":
63
- syncCommentFieldToYjs(event.commentId);
64
- break;
65
- case "change_accepted":
66
- yRevisionActions.set(revisionActionKey(event.changeId, "accept"), {
67
- changeId: event.changeId,
68
- action: "accept",
69
- sourceClientId: clientId,
70
- });
71
- break;
72
- case "change_rejected":
73
- yRevisionActions.set(revisionActionKey(event.changeId, "reject"), {
74
- changeId: event.changeId,
75
- action: "reject",
76
- sourceClientId: clientId,
77
- });
78
- break;
79
- }
80
- });
81
-
82
- function pushCommentToYjs(commentId: string): void {
83
- const thread = runtime
84
- .getRenderSnapshot()
85
- .comments.threads.find((t) => t.commentId === commentId);
86
- if (!thread) return;
87
-
88
- yComments.set(commentId, {
89
- commentId: thread.commentId,
90
- status: thread.status,
91
- anchor: thread.anchor,
92
- createdAt: thread.createdAt,
93
- createdBy: thread.createdBy,
94
- authorId: thread.createdBy,
95
- body: thread.entries[0]?.body ?? "",
96
- entries: thread.entries.map((e) => ({
97
- entryId: e.entryId,
98
- authorId: e.authorId,
99
- body: e.body,
100
- createdAt: e.createdAt,
101
- })),
102
- resolvedAt: thread.resolvedAt,
103
- resolvedBy: thread.resolvedBy,
104
- warningIds: [],
105
- sourceClientId: clientId,
106
- });
107
- }
108
-
109
- function syncCommentFieldToYjs(commentId: string): void {
110
- const existing = yComments.get(commentId);
111
- const thread = runtime
112
- .getRenderSnapshot()
113
- .comments.threads.find((t) => t.commentId === commentId);
114
- if (!existing || !thread) return;
115
-
116
- yComments.set(commentId, {
117
- ...existing,
118
- status: thread.status,
119
- entries: thread.entries.map((e) => ({
120
- entryId: e.entryId,
121
- authorId: e.authorId,
122
- body: e.body,
123
- createdAt: e.createdAt,
124
- })),
125
- resolvedAt: thread.resolvedAt,
126
- resolvedBy: thread.resolvedBy,
127
- sourceClientId: clientId,
128
- });
129
- }
130
-
131
- // --- Yjs → Local ---------------------------------------------------------
132
-
133
- function onCommentMapChange(event: Y.YMapEvent<YCommentThread>): void {
134
- suppressLocalEvents = true;
135
- try {
136
- for (const [commentId, change] of event.changes.keys) {
137
- const entry = yComments.get(commentId);
138
- if (!entry || entry.sourceClientId === clientId) continue;
139
-
140
- const existing = runtime
141
- .getRenderSnapshot()
142
- .comments.threads.find((t) => t.commentId === commentId);
143
-
144
- if (change.action === "add" && !existing) {
145
- runtime.dispatch({
146
- type: "comment.add",
147
- comment: {
148
- commentId: entry.commentId,
149
- status: entry.status,
150
- anchor: entry.anchor as never,
151
- createdAt: entry.createdAt,
152
- createdBy: entry.createdBy,
153
- authorId: entry.authorId,
154
- body: entry.body,
155
- entries: entry.entries,
156
- warningIds: entry.warningIds,
157
- isResolved: entry.status === "resolved",
158
- metadata: { source: "runtime" },
159
- },
160
- });
161
- } else if (change.action === "update" && existing) {
162
- if (entry.status === "resolved" && existing.status !== "resolved") {
163
- runtime.dispatch({
164
- type: "comment.resolve",
165
- commentId,
166
- resolvedBy: entry.resolvedBy,
167
- });
168
- } else if (entry.status === "open" && existing.status === "resolved") {
169
- runtime.dispatch({ type: "comment.reopen", commentId });
170
- }
171
-
172
- const localEntryCount = existing.entries.length;
173
- if (entry.entries.length > localEntryCount) {
174
- for (const newEntry of entry.entries.slice(localEntryCount)) {
175
- runtime.dispatch({
176
- type: "comment.add-reply",
177
- commentId,
178
- body: newEntry.body,
179
- authorId: newEntry.authorId,
180
- });
181
- }
182
- }
183
- }
184
- }
185
- } finally {
186
- suppressLocalEvents = false;
187
- }
188
- }
189
-
190
- function onRevisionActionMapChange(event: Y.YMapEvent<YRevisionAction>): void {
191
- suppressLocalEvents = true;
192
- try {
193
- for (const [, change] of event.changes.keys) {
194
- if (change.action !== "add") continue;
195
- const entries = [...yRevisionActions.entries()];
196
- const latest = entries[entries.length - 1];
197
- if (!latest) continue;
198
- const entry = latest[1];
199
- if (entry.sourceClientId === clientId) continue;
200
-
201
- runtime.dispatch({
202
- type: entry.action === "accept" ? "change.accept" : "change.reject",
203
- changeId: entry.changeId,
204
- });
205
- }
206
- } finally {
207
- suppressLocalEvents = false;
208
- }
209
- }
210
-
211
- yComments.observe(onCommentMapChange);
212
- yRevisionActions.observe(onRevisionActionMapChange);
213
-
214
- // --- Initial sync: push existing comments to Yjs if first client ----------
215
-
216
- const snapshot = runtime.getRenderSnapshot();
217
- if (yComments.size === 0 && snapshot.comments.threads.length > 0) {
218
- ydoc.transact(() => {
219
- for (const thread of snapshot.comments.threads) {
220
- yComments.set(thread.commentId, {
221
- commentId: thread.commentId,
222
- status: thread.status,
223
- anchor: thread.anchor,
224
- createdAt: thread.createdAt,
225
- createdBy: thread.createdBy,
226
- authorId: thread.createdBy,
227
- body: thread.entries[0]?.body ?? "",
228
- entries: thread.entries.map((e) => ({
229
- entryId: e.entryId,
230
- authorId: e.authorId,
231
- body: e.body,
232
- createdAt: e.createdAt,
233
- })),
234
- resolvedAt: thread.resolvedAt,
235
- resolvedBy: thread.resolvedBy,
236
- warningIds: [],
237
- sourceClientId: clientId,
238
- });
239
- }
240
- });
241
- }
242
-
243
- return {
244
- destroy() {
245
- unsubEvents();
246
- yComments.unobserve(onCommentMapChange);
247
- yRevisionActions.unobserve(onRevisionActionMapChange);
248
- },
249
- };
250
- }
251
-
252
- function revisionActionKey(changeId: string, action: string): string {
253
- return `${changeId}:${action}`;
254
- }