@beyondwork/docx-react-component 1.0.38 → 1.0.40

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 (85) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +305 -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/index.ts +9 -0
  6. package/src/io/docx-session.ts +1 -0
  7. package/src/io/export/serialize-numbering.ts +42 -8
  8. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  9. package/src/io/export/serialize-run-formatting.ts +90 -0
  10. package/src/io/export/serialize-styles.ts +212 -0
  11. package/src/io/ooxml/parse-fields.ts +10 -3
  12. package/src/io/ooxml/parse-numbering.ts +41 -1
  13. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  14. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  15. package/src/io/ooxml/parse-styles.ts +31 -0
  16. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  17. package/src/io/ooxml/xml-element.ts +19 -0
  18. package/src/model/canonical-document.ts +83 -3
  19. package/src/runtime/collab/event-types.ts +165 -0
  20. package/src/runtime/collab/index.ts +22 -0
  21. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  22. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  23. package/src/runtime/document-runtime.ts +141 -18
  24. package/src/runtime/layout/docx-font-loader.ts +30 -11
  25. package/src/runtime/layout/index.ts +2 -0
  26. package/src/runtime/layout/inert-layout-facet.ts +3 -0
  27. package/src/runtime/layout/layout-engine-instance.ts +69 -2
  28. package/src/runtime/layout/layout-invalidation.ts +14 -5
  29. package/src/runtime/layout/page-graph.ts +36 -0
  30. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  31. package/src/runtime/layout/paginated-layout-engine.ts +342 -28
  32. package/src/runtime/layout/project-block-fragments.ts +154 -20
  33. package/src/runtime/layout/public-facet.ts +81 -1
  34. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  35. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  36. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  37. package/src/runtime/layout/table-render-plan.ts +21 -1
  38. package/src/runtime/numbering-prefix.ts +5 -0
  39. package/src/runtime/paragraph-style-resolver.ts +194 -0
  40. package/src/runtime/render/render-kernel.ts +5 -1
  41. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  42. package/src/runtime/surface-projection.ts +129 -9
  43. package/src/runtime/table-schema.ts +11 -0
  44. package/src/runtime/workflow-rail-segments.ts +149 -1
  45. package/src/ui/WordReviewEditor.tsx +302 -5
  46. package/src/ui/editor-command-bag.ts +4 -0
  47. package/src/ui/editor-runtime-boundary.ts +16 -0
  48. package/src/ui/editor-shell-view.tsx +22 -0
  49. package/src/ui/editor-surface-controller.tsx +9 -1
  50. package/src/ui/headless/chrome-registry.ts +34 -5
  51. package/src/ui/headless/scoped-chrome-policy.ts +29 -0
  52. package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
  53. package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
  54. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +80 -0
  55. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
  56. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
  57. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  58. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  59. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
  60. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +353 -0
  61. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
  62. package/src/ui-tailwind/chrome-overlay/index.ts +2 -6
  63. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +82 -18
  64. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +133 -0
  65. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +386 -0
  66. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +140 -69
  67. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  68. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
  69. package/src/ui-tailwind/editor-surface/pm-decorations.ts +7 -2
  70. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  71. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  72. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +170 -63
  73. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  74. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  75. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
  76. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  77. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  78. package/src/ui-tailwind/index.ts +6 -5
  79. package/src/ui-tailwind/theme/editor-theme.css +108 -15
  80. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
  81. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
  82. package/src/ui-tailwind/tw-review-workspace.tsx +207 -54
  83. package/src/runtime/collab-review-sync.ts +0 -254
  84. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
  85. 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 {
@@ -64,6 +67,7 @@ import {
64
67
  import type { EditorCommandBag } from "../ui/editor-command-bag.ts";
65
68
  import { preserveEditorSelectionMouseDown } from "../ui/headless/preserve-editor-selection";
66
69
  import { TwAlertBanner } from "./chrome/tw-alert-banner";
70
+ import { TwModeDock } from "./chrome/tw-mode-dock";
67
71
  import { TwLayoutPanel } from "./chrome/tw-layout-panel";
68
72
  import { TwPageRuler } from "./chrome/tw-page-ruler";
69
73
  import {
@@ -77,8 +81,9 @@ import {
77
81
  resolveChromePresetOptions,
78
82
  resolveChromeVisibilityForPreset,
79
83
  } from "./chrome/chrome-preset-model";
80
- import { TwReviewQueueBar } from "./chrome/review-queue-bar";
81
84
  import { TwSelectionToolHost } from "./chrome/tw-selection-tool-host";
85
+ import { resolveSelectionAnchor } from "./chrome/tw-selection-anchor-resolver";
86
+ import { resolveSelectionToolPlacement } from "./chrome/tw-selection-tool-placement";
82
87
  import { TwReviewRail, type ReviewRailTab } from "./review/tw-review-rail";
83
88
  import { TwStatusBar } from "./status/tw-status-bar";
84
89
  import { type ToolbarInteractionPolicy } from "./toolbar/tw-toolbar";
@@ -125,6 +130,15 @@ export interface TwReviewWorkspaceProps {
125
130
  searchLabel?: string;
126
131
  helpLabel?: string;
127
132
  };
133
+ /**
134
+ * Opt-in floating mode dock pinned to the bottom of the workspace.
135
+ * Hosts pass the derived label / icon / actions; defaults to hidden.
136
+ */
137
+ modeDock?: {
138
+ label: string;
139
+ icon?: ReactNode;
140
+ actions?: readonly import("./chrome/tw-mode-dock").TwModeDockAction[];
141
+ };
128
142
  document: ReactNode;
129
143
  workspaceMode: WorkspaceMode;
130
144
  zoomLevel?: ZoomLevel;
@@ -262,6 +276,14 @@ export interface TwReviewWorkspaceProps {
262
276
  onCloseStory?: () => void;
263
277
  onOpenHeaderStory?: () => void;
264
278
  onOpenFooterStory?: () => void;
279
+ /**
280
+ * Open a header/footer story for a specific page. Called when the user
281
+ * double-clicks a per-page header/footer band in the page-stack chrome.
282
+ * Must resolve the correct variant for that page's section and call
283
+ * `runtime.openStory()`.
284
+ */
285
+ onOpenHeaderStoryForPage?: (pageIndex: number) => void;
286
+ onOpenFooterStoryForPage?: (pageIndex: number) => void;
265
287
  onSetParagraphIndentation?: (indentation: {
266
288
  left?: number;
267
289
  right?: number;
@@ -271,13 +293,56 @@ export interface TwReviewWorkspaceProps {
271
293
  onSetParagraphTabStops?: (tabStops: Array<{ pos: number; val?: string; leader?: string }>) => void;
272
294
  onRestartNumbering?: () => void;
273
295
  onContinueNumbering?: () => void;
296
+ // P6: new table ops
297
+ onToggleRowHeader?: () => void;
298
+ onToggleRowCantSplit?: () => void;
299
+ onDistributeColumnsEvenly?: () => void;
300
+ onSetTableAlignment?: (alignment: "left" | "center" | "right") => void;
301
+ onSetCellVerticalAlign?: (align: "top" | "center" | "bottom") => void;
302
+ /** P6: active table context for chrome overlay grips. */
303
+ tableContext?: import("../api/public-types").TableStructureContextSnapshot | null;
304
+ /** P6: column resize committed from overlay grip → set-column-width op. */
305
+ onSetColumnWidth?: (columnIndex: number, twips: number) => void;
306
+ /** P6: row resize committed from overlay grip → set-row-height op. */
307
+ onSetRowHeight?: (rowIndex: number, twips: number, rule: "auto" | "atLeast" | "exact") => void;
308
+ onListIndent?: () => void;
309
+ onListOutdent?: () => void;
274
310
  onUpdateFields?: () => void;
275
311
  onUpdateTableOfContents?: () => void;
276
312
  onGoToPreviousReviewItem?: () => void;
277
313
  onGoToNextReviewItem?: () => void;
278
314
  onMarkSectionForReview?: () => void;
315
+ /** Optional: open sidebar to tracked-changes panel. When provided, the review role shows a sidebar-TC icon. */
316
+ onReviewSidebarTrackedChanges?: () => void;
317
+ /** Optional: open sidebar to comments panel. When provided, the review role shows a sidebar-comments icon. */
318
+ onReviewSidebarComments?: () => void;
279
319
  onNavigateHeading?: (headingId: string) => void;
280
320
  chromeVisibility?: Partial<ReviewWorkspaceChromeVisibility>;
321
+ /**
322
+ * Called when the shell-header mode tab changes or any chrome surface fires
323
+ * a role switch. Wire to `runtime.setEditorRole(role)` so the workspace
324
+ * re-renders with the new per-role action set.
325
+ */
326
+ onEditorRoleChange?: (role: import("../api/public-types.ts").EditorRole) => void;
327
+ /**
328
+ * Scope card mode selector fired a mode change. Wire to the host's
329
+ * existing overlay-apply path (or an equivalent CCEP workflow
330
+ * endpoint). The card never mutates runtime state directly.
331
+ */
332
+ onScopeModeChangeRequested?: (payload: {
333
+ scopeId: string;
334
+ mode: import("../api/public-types.ts").WorkflowScopeMode;
335
+ }) => void;
336
+ /**
337
+ * Scope card issue row fired an action (resolve/waive/escalate/
338
+ * acknowledge). Host updates the attached `IssueMetadataValue`
339
+ * state and re-pushes via `setWorkflowMetadataEntries`.
340
+ */
341
+ onScopeIssueActionRequested?: (payload: {
342
+ scopeId: string;
343
+ issueId: string;
344
+ action: import("../api/public-types.ts").ScopeIssueAction;
345
+ }) => void;
281
346
  }
282
347
 
283
348
  export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
@@ -292,6 +357,40 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
292
357
  const markupDisplay = props.markupDisplay;
293
358
  const [navOpen, setNavOpen] = useState(false);
294
359
  const [layoutToolsOpen, setLayoutToolsOpen] = useState(false);
360
+
361
+ // Scope card state — tracks which scope's card is currently open so
362
+ // the ChromeOverlay's card layer renders the right one. The card
363
+ // closes on click-outside, Escape, or a repeat click on its stripe.
364
+ const [activeScopeId, setActiveScopeId] = useState<string | null>(null);
365
+ const handleScopeStripeClick = useCallback(
366
+ (segment: { scopeId: string }) => {
367
+ setActiveScopeId((current) =>
368
+ current === segment.scopeId ? null : segment.scopeId,
369
+ );
370
+ },
371
+ [],
372
+ );
373
+ const handleScopeCardClose = useCallback(() => {
374
+ setActiveScopeId(null);
375
+ }, []);
376
+ const onScopeModeChangeRequested = props.onScopeModeChangeRequested;
377
+ const handleScopeCardModeChange = useCallback(
378
+ (scopeId: string, mode: import("../api/public-types.ts").WorkflowScopeMode) => {
379
+ onScopeModeChangeRequested?.({ scopeId, mode });
380
+ },
381
+ [onScopeModeChangeRequested],
382
+ );
383
+ const onScopeIssueActionRequested = props.onScopeIssueActionRequested;
384
+ const handleScopeCardIssueAction = useCallback(
385
+ (
386
+ scopeId: string,
387
+ issueId: string,
388
+ action: import("../api/public-types.ts").ScopeIssueAction,
389
+ ) => {
390
+ onScopeIssueActionRequested?.({ scopeId, issueId, action });
391
+ },
392
+ [onScopeIssueActionRequested],
393
+ );
295
394
  const zoomLevel = props.zoomLevel ?? 100;
296
395
  const zoomScale = typeof zoomLevel === "number" ? zoomLevel / 100 : 1;
297
396
  const pageZoomBucket =
@@ -319,6 +418,9 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
319
418
  reviewRailAvailable,
320
419
  }),
321
420
  );
421
+ // Incremented on zoom_changed / render_frame_ready so the placement
422
+ // useMemo below re-executes when the render kernel emits new rects.
423
+ const [renderFrameRevision, setRenderFrameRevision] = useState(0);
322
424
  const responsiveChromeSignatureRef = useRef<string | null>(null);
323
425
  const headings = props.documentNavigation?.headings ?? [];
324
426
  const headerVariant = snapshot.pageLayout?.headerVariants[0]?.variant ?? "default";
@@ -348,11 +450,6 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
348
450
  ),
349
451
  [props.documentNavigation, snapshot.activeStory, snapshot.pageLayout, snapshot.surface],
350
452
  );
351
- const selectionToolbarPlacement = resolveSelectionToolbarPlacement(
352
- props.selectionToolAnchor,
353
- selectionToolbarRootRef.current,
354
- zoomScale,
355
- );
356
453
  const effectiveSelectionMode = props.interactionGuardSnapshot?.effectiveMode ?? "edit";
357
454
  const allowLocalChromeMutations = Boolean(caps?.canEdit) && effectiveSelectionMode === "edit";
358
455
  const gatedSelectionTool = useMemo(() => {
@@ -364,6 +461,40 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
364
461
  }
365
462
  return props.activeSelectionTool;
366
463
  }, [props.activeSelectionTool, chromeVisibility.contextToolbars]);
464
+ const selectionToolbarPlacement = useMemo(() => {
465
+ // Prefer render-frame anchors when the layout facet is available — this
466
+ // keeps the tool glued to kernel coordinates across zoom, scroll, and
467
+ // predicted-text reconciliation (R4).
468
+ if (props.layoutFacet && gatedSelectionTool) {
469
+ const anchorRect = resolveSelectionAnchor({
470
+ facet: props.layoutFacet,
471
+ selection: viewState.selection,
472
+ tool: gatedSelectionTool,
473
+ });
474
+ if (anchorRect && selectionToolbarRootRef.current) {
475
+ const containerRect = selectionToolbarRootRef.current.getBoundingClientRect();
476
+ const result = resolveSelectionToolPlacement({
477
+ anchor: anchorRect,
478
+ container: { widthPx: containerRect.width, heightPx: containerRect.height },
479
+ });
480
+ if (result) return result;
481
+ }
482
+ }
483
+ // Fall back to DOM rects for hosts that do not supply a layout facet.
484
+ return resolveSelectionToolbarPlacement(
485
+ props.selectionToolAnchor,
486
+ selectionToolbarRootRef.current,
487
+ zoomScale,
488
+ );
489
+ // eslint-disable-next-line react-hooks/exhaustive-deps
490
+ }, [
491
+ props.layoutFacet,
492
+ props.selectionToolAnchor,
493
+ gatedSelectionTool,
494
+ viewState.selection,
495
+ zoomScale,
496
+ renderFrameRevision,
497
+ ]);
367
498
  const activePage = props.documentNavigation?.pages[props.documentNavigation.activePageIndex] ?? null;
368
499
  const pageShellMetrics = useMemo(
369
500
  () => buildPageShellMetrics(snapshot.pageLayout),
@@ -388,6 +519,9 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
388
519
  }),
389
520
  [reviewRailAvailable, reviewRailOpen, viewportWidth],
390
521
  );
522
+ const hasSidebarPanelAccess = Boolean(
523
+ props.onReviewSidebarTrackedChanges || props.onReviewSidebarComments,
524
+ );
391
525
  const scopedChromePolicy = useMemo(
392
526
  () =>
393
527
  resolveScopedChromePolicy({
@@ -397,14 +531,18 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
397
531
  interactionGuardSnapshot: props.interactionGuardSnapshot,
398
532
  workflowScopeSnapshot: props.workflowScopeSnapshot,
399
533
  activeListContext: props.activeListContext,
534
+ role: viewState.editorRole,
535
+ hasSidebarPanelAccess,
400
536
  }),
401
537
  [
402
538
  caps,
403
539
  chromePreset,
540
+ hasSidebarPanelAccess,
404
541
  props.activeListContext,
405
542
  props.interactionGuardSnapshot,
406
543
  props.workflowScopeSnapshot,
407
544
  responsiveChrome.isNarrow,
545
+ viewState.editorRole,
408
546
  ],
409
547
  );
410
548
  const toolbarInteractionPolicy: ToolbarInteractionPolicy | undefined = caps
@@ -446,6 +584,18 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
446
584
  };
447
585
  }, []);
448
586
 
587
+ // Subscribe to layout facet events so chrome re-projects on zoom changes
588
+ // and after incremental relayout (R4).
589
+ useEffect(() => {
590
+ if (!props.layoutFacet) return;
591
+ const unsub = props.layoutFacet.subscribe((event) => {
592
+ if (event.kind === "zoom_changed" || event.kind === "render_frame_ready") {
593
+ setRenderFrameRevision((n) => n + 1);
594
+ }
595
+ });
596
+ return unsub;
597
+ }, [props.layoutFacet]);
598
+
449
599
  useEffect(() => {
450
600
  const responsiveSignature = `${reviewRailAvailable ? "1" : "0"}:${isNarrowChromeViewport(viewportWidth) ? "n" : "d"}`;
451
601
  if (responsiveChromeSignatureRef.current === responsiveSignature) {
@@ -492,6 +642,14 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
492
642
  };
493
643
  }, [responsiveChrome.showDrawerReviewRail]);
494
644
 
645
+ useEffect(() => {
646
+ if (!props.layoutFacet) return;
647
+ const facet = props.layoutFacet;
648
+ void document.fonts.ready.then(() => {
649
+ facet.swapMeasurementProvider(createCanvasBackend());
650
+ });
651
+ }, [props.layoutFacet]);
652
+
495
653
  return (
496
654
  <Tooltip.Provider delayDuration={400}>
497
655
  <div className="flex h-full flex-col bg-canvas text-primary">
@@ -609,6 +767,12 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
609
767
  role={viewState.editorRole}
610
768
  reviewQueue={props.reviewQueue}
611
769
  markupDisplay={markupDisplay}
770
+ onReviewSidebarTrackedChanges={props.onReviewSidebarTrackedChanges
771
+ ? runWithSelectionToolbarDismiss(props.onReviewSidebarTrackedChanges)
772
+ : undefined}
773
+ onReviewSidebarComments={props.onReviewSidebarComments
774
+ ? runWithSelectionToolbarDismiss(props.onReviewSidebarComments)
775
+ : undefined}
612
776
  onMarkScopePosture={props.onMarkSectionForReview
613
777
  ? runWithSelectionToolbarDismiss(props.onMarkSectionForReview)
614
778
  : undefined}
@@ -642,27 +806,14 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
642
806
  props.onRejectRevision?.(revisionId);
643
807
  };
644
808
  })()}
809
+ chromePins={viewState.chromePins}
810
+ onChromePinChange={props.onChromePinChange}
645
811
  />
646
812
  </div>
647
813
  ) : null}
648
814
 
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}
815
+ {/* Legacy TwReviewQueueBar is suppressed — review role's action region
816
+ now owns queue prev/next + counts inline in the top toolbar. */}
666
817
 
667
818
  {chromeVisibility.alerts ? <TwAlertBanner
668
819
  snapshot={snapshot}
@@ -967,6 +1118,11 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
967
1118
  onSetImageFrame={props.onSetImageFrame}
968
1119
  onRestartNumbering={props.onRestartNumbering}
969
1120
  onContinueNumbering={props.onContinueNumbering}
1121
+ onToggleRowHeader={props.onToggleRowHeader}
1122
+ onToggleRowCantSplit={props.onToggleRowCantSplit}
1123
+ onDistributeColumnsEvenly={props.onDistributeColumnsEvenly}
1124
+ onSetTableAlignment={props.onSetTableAlignment}
1125
+ onSetCellVerticalAlign={props.onSetCellVerticalAlign}
970
1126
  chromePins={viewState.chromePins}
971
1127
  onChromePinChange={props.onChromePinChange}
972
1128
  />
@@ -1035,15 +1191,27 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1035
1191
  style={pageChromeModel.pageBorderStyle}
1036
1192
  />
1037
1193
  ) : null}
1038
- <div className={isPageWorkspace ? "relative z-10" : undefined}>
1194
+ <div className={isPageWorkspace ? "relative z-10" : "relative"}>
1195
+ {/* Page chrome (frame borders, header/footer bands,
1196
+ page-number labels, inter-page separators) is
1197
+ rendered as in-flow widget decorations inside
1198
+ the PM surface itself — see
1199
+ `pm-page-break-decorations.ts`. That keeps the
1200
+ chrome perfectly aligned with PM content without
1201
+ any absolute-positioned overlay that would drift
1202
+ relative to the browser's line layout. */}
1039
1203
  {props.document}
1040
1204
  {props.layoutFacet ? (
1041
1205
  <TwChromeOverlay
1042
1206
  facet={props.layoutFacet}
1043
- activeWorkspaceView={
1044
- props.reviewMode === "review" ? "review" : "workflow"
1045
- }
1046
- showWorkspaceDock={chromeVisibility.pageChrome}
1207
+ tableContext={props.tableContext}
1208
+ onSetColumnWidth={props.onSetColumnWidth}
1209
+ onSetRowHeight={props.onSetRowHeight}
1210
+ activeScopeId={activeScopeId}
1211
+ onScopeStripeClick={handleScopeStripeClick}
1212
+ onScopeCardClose={handleScopeCardClose}
1213
+ onScopeCardModeChange={handleScopeCardModeChange}
1214
+ onScopeCardIssueAction={handleScopeCardIssueAction}
1047
1215
  />
1048
1216
  ) : null}
1049
1217
  </div>
@@ -1070,32 +1238,10 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1070
1238
  </div>
1071
1239
  </div>
1072
1240
  </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}
1241
+ {/* Pages 2..N in page mode are now rendered by TwPageStackChrome
1242
+ as absolute overlays above the single flowing PM surface.
1243
+ The PM surface holds all editable content; page frames draw
1244
+ borders, header/footer bands, and per-page numbers on top. */}
1099
1245
  </div>
1100
1246
 
1101
1247
  {chromeVisibility.statusBar ? (
@@ -1202,6 +1348,13 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1202
1348
  </div>
1203
1349
  ) : null}
1204
1350
  </div>
1351
+ {props.modeDock ? (
1352
+ <TwModeDock
1353
+ label={props.modeDock.label}
1354
+ icon={props.modeDock.icon}
1355
+ actions={props.modeDock.actions}
1356
+ />
1357
+ ) : null}
1205
1358
  </div>
1206
1359
  </Tooltip.Provider>
1207
1360
  );
@@ -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
- }