@beyondwork/docx-react-component 1.0.18 → 1.0.20

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 (105) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +710 -4
  5. package/src/api/session-state.ts +60 -0
  6. package/src/core/commands/formatting-commands.ts +2 -1
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +19 -3
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +357 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +4 -1
  16. package/src/index.ts +51 -0
  17. package/src/io/docx-session.ts +623 -56
  18. package/src/io/export/serialize-comments.ts +104 -34
  19. package/src/io/export/serialize-footnotes.ts +198 -1
  20. package/src/io/export/serialize-headers-footers.ts +203 -10
  21. package/src/io/export/serialize-main-document.ts +285 -8
  22. package/src/io/export/serialize-numbering.ts +28 -7
  23. package/src/io/export/split-review-boundaries.ts +181 -19
  24. package/src/io/normalize/normalize-text.ts +144 -32
  25. package/src/io/ooxml/highlight-colors.ts +39 -0
  26. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  27. package/src/io/ooxml/parse-comments.ts +85 -19
  28. package/src/io/ooxml/parse-fields.ts +396 -0
  29. package/src/io/ooxml/parse-footnotes.ts +452 -22
  30. package/src/io/ooxml/parse-headers-footers.ts +657 -29
  31. package/src/io/ooxml/parse-inline-media.ts +30 -0
  32. package/src/io/ooxml/parse-main-document.ts +807 -20
  33. package/src/io/ooxml/parse-numbering.ts +7 -0
  34. package/src/io/ooxml/parse-revisions.ts +317 -38
  35. package/src/io/ooxml/parse-settings.ts +184 -0
  36. package/src/io/ooxml/parse-shapes.ts +25 -0
  37. package/src/io/ooxml/parse-styles.ts +463 -0
  38. package/src/io/ooxml/parse-theme.ts +32 -0
  39. package/src/legal/bookmarks.ts +44 -0
  40. package/src/legal/cross-references.ts +59 -1
  41. package/src/model/canonical-document.ts +250 -4
  42. package/src/model/cds-1.0.0.ts +13 -0
  43. package/src/model/snapshot.ts +87 -2
  44. package/src/review/store/revision-store.ts +6 -0
  45. package/src/review/store/revision-types.ts +1 -0
  46. package/src/runtime/document-layout.ts +332 -0
  47. package/src/runtime/document-navigation.ts +603 -0
  48. package/src/runtime/document-runtime.ts +1754 -78
  49. package/src/runtime/document-search.ts +145 -0
  50. package/src/runtime/numbering-prefix.ts +47 -26
  51. package/src/runtime/page-layout-estimation.ts +212 -0
  52. package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
  53. package/src/runtime/session-capabilities.ts +35 -3
  54. package/src/runtime/story-context.ts +164 -0
  55. package/src/runtime/story-targeting.ts +162 -0
  56. package/src/runtime/surface-projection.ts +324 -36
  57. package/src/runtime/table-schema.ts +89 -7
  58. package/src/runtime/view-state.ts +477 -0
  59. package/src/runtime/workflow-markup.ts +349 -0
  60. package/src/ui/WordReviewEditor.tsx +2469 -1344
  61. package/src/ui/browser-export.ts +52 -0
  62. package/src/ui/editor-command-bag.ts +120 -0
  63. package/src/ui/editor-runtime-boundary.ts +1422 -0
  64. package/src/ui/editor-shell-view.tsx +134 -0
  65. package/src/ui/editor-surface-controller.tsx +51 -0
  66. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  67. package/src/ui/headless/revision-decoration-model.ts +4 -4
  68. package/src/ui/headless/selection-helpers.ts +20 -0
  69. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  70. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  71. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  72. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  73. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  74. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  75. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  76. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  77. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
  78. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  79. package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
  80. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
  81. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  82. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  83. package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
  84. package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
  85. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
  86. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  87. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  88. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  89. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  90. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
  91. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  92. package/src/ui-tailwind/index.ts +2 -1
  93. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  94. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  95. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  96. package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
  97. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  98. package/src/ui-tailwind/theme/editor-theme.css +127 -0
  99. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  100. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
  101. package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
  102. package/src/validation/compatibility-engine.ts +119 -24
  103. package/src/validation/compatibility-report.ts +1 -0
  104. package/src/validation/diagnostics.ts +1 -0
  105. package/src/validation/docx-comment-proof.ts +707 -0
@@ -1,61 +1,295 @@
1
- import React, { type ReactNode, useState } from "react";
1
+ import React, {
2
+ type CSSProperties,
3
+ type FocusEventHandler,
4
+ type ReactNode,
5
+ type Ref,
6
+ useCallback,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
2
12
 
3
13
  import * as Tooltip from "@radix-ui/react-tooltip";
14
+ import { ChevronLeft, ChevronRight, List } from "lucide-react";
4
15
 
5
16
  import type {
6
17
  CommentSidebarThreadSnapshot,
18
+ DocumentNavigationSnapshot,
19
+ EditorViewStateSnapshot,
20
+ FormattingStateSnapshot,
21
+ FormattingAlignment,
22
+ HeaderFooterLinkPatch,
23
+ InteractionGuardSnapshot,
24
+ InsertImageOptions,
7
25
  RuntimeRenderSnapshot,
26
+ SectionPageNumberingPatch,
27
+ SectionBreakType,
28
+ StyleCatalogSnapshot,
29
+ SurfaceBlockSnapshot,
8
30
  TrackedChangeEntrySnapshot,
31
+ WorkflowScopeSnapshot,
32
+ WorkspaceMode,
33
+ ZoomLevel,
9
34
  } from "../api/public-types";
35
+ import { findPageForOffset } from "../runtime/document-navigation.ts";
36
+ import {
37
+ DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
38
+ estimateBlockHeight,
39
+ estimateParagraphLineCount,
40
+ estimateParagraphLineHeight,
41
+ getUsableColumnWidth,
42
+ } from "../runtime/page-layout-estimation.ts";
43
+ import {
44
+ incrementInvalidationCounter,
45
+ recordPerfSample,
46
+ } from "./editor-surface/perf-probe.ts";
47
+ import { computeLineMarkersIfEnabled } from "./page-chrome-model.ts";
10
48
  import type { SessionCapabilities } from "../runtime/session-capabilities";
49
+ import type {
50
+ SelectionToolbarAnchor,
51
+ SelectionToolbarModel,
52
+ } from "../ui/headless/selection-toolbar-model";
11
53
  import type { MarkupDisplay } from "../ui/headless/comment-decoration-model";
54
+ import type { EditorCommandBag } from "../ui/editor-command-bag.ts";
55
+ import { preserveEditorSelectionMouseDown } from "../ui/headless/preserve-editor-selection";
12
56
  import { TwAlertBanner } from "./chrome/tw-alert-banner";
57
+ import { TwImageContextToolbar, type ActiveImageContext } from "./chrome/tw-image-context-toolbar";
58
+ import { TwLayoutPanel } from "./chrome/tw-layout-panel";
59
+ import { TwObjectContextToolbar, type ActiveObjectContext } from "./chrome/tw-object-context-toolbar";
60
+ import { TwPageRuler } from "./chrome/tw-page-ruler";
13
61
  import { TwSelectionToolbar } from "./chrome/tw-selection-toolbar";
62
+ import { TwTableContextToolbar } from "./chrome/tw-table-context-toolbar";
14
63
  import { TwReviewRail, type ReviewRailTab } from "./review/tw-review-rail";
15
64
  import { TwStatusBar } from "./status/tw-status-bar";
16
- import { TwToolbar, type ViewMode } from "./toolbar/tw-toolbar";
65
+ import { TwToolbar } from "./toolbar/tw-toolbar";
17
66
 
18
67
  export interface TwReviewWorkspaceProps {
19
68
  snapshot: RuntimeRenderSnapshot;
69
+ viewState: EditorViewStateSnapshot;
70
+ markupDisplay: MarkupDisplay;
20
71
  currentUserId?: string;
21
72
  capabilities?: SessionCapabilities;
22
73
  reviewMode?: "editing" | "review";
23
74
  document: ReactNode;
24
- viewMode: ViewMode;
75
+ workspaceMode: WorkspaceMode;
76
+ zoomLevel?: ZoomLevel;
77
+ formattingState?: FormattingStateSnapshot;
78
+ styleCatalog?: StyleCatalogSnapshot;
25
79
  activeRailTab: ReviewRailTab;
26
80
  activeCommentId?: string;
27
81
  activeRevisionId?: string;
28
82
  showTrackedChanges: boolean;
29
- selectionPreview?: string | null;
30
- addCommentDisabledReason?: string;
31
- onViewModeChange: (value: ViewMode) => void;
32
- onActiveRailTabChange: (value: ReviewRailTab) => void;
33
- onShowTrackedChangesChange: (show: boolean) => void;
34
- onUndo: () => void;
35
- onRedo: () => void;
36
- onAddComment: () => void;
37
- onExport: () => void;
38
- onOpenComment: (thread: CommentSidebarThreadSnapshot) => void;
39
- onResolveComment: (commentId: string) => void;
83
+ workflowScopeSnapshot?: WorkflowScopeSnapshot | null;
84
+ interactionGuardSnapshot?: InteractionGuardSnapshot;
85
+ commands: EditorCommandBag;
86
+ selectionToolbar?: SelectionToolbarModel | null;
87
+ selectionToolbarAnchor?: SelectionToolbarAnchor | null;
88
+ documentNavigation?: DocumentNavigationSnapshot;
89
+ onWorkspaceModeChange?: (value: WorkspaceMode) => void;
90
+ onZoomChange?: (level: ZoomLevel) => void;
91
+ onActiveRailTabChange?: (value: ReviewRailTab) => void;
92
+ onShowTrackedChangesChange?: (show: boolean) => void;
93
+ onUndo?: () => void;
94
+ onRedo?: () => void;
95
+ onSetParagraphStyle?: (styleId: string) => void;
96
+ onToggleBold?: () => void;
97
+ onToggleItalic?: () => void;
98
+ onToggleUnderline?: () => void;
99
+ onSetSelectionTextColor?: (color: string) => void;
100
+ onSetSelectionHighlightColor?: (color: string | null) => void;
101
+ onToggleStrikethrough?: () => void;
102
+ onToggleSuperscript?: () => void;
103
+ onToggleSubscript?: () => void;
104
+ onSetFontFamily?: (fontFamily: string) => void;
105
+ onSetFontSize?: (fontSize: number) => void;
106
+ onSetTextColor?: (color: string) => void;
107
+ onSetHighlightColor?: (color: string | null) => void;
108
+ onSetAlignment?: (alignment: FormattingAlignment) => void;
109
+ onOutdent?: () => void;
110
+ onIndent?: () => void;
111
+ onAddComment?: () => void;
112
+ onInsertPageBreak?: () => void;
113
+ onInsertTable?: () => void;
114
+ onInsertSectionBreak?: (type: SectionBreakType) => void;
115
+ onInsertImage?: (options: InsertImageOptions) => void;
116
+ onSetTableStyle?: (styleId: string) => void;
117
+ onAddRowBefore?: () => void;
118
+ onAddRowAfter?: () => void;
119
+ onAddColumnBefore?: () => void;
120
+ onAddColumnAfter?: () => void;
121
+ onDeleteRow?: () => void;
122
+ onDeleteColumn?: () => void;
123
+ onDeleteTable?: () => void;
124
+ onMergeCells?: () => void;
125
+ onSplitCell?: () => void;
126
+ onSetCellBackground?: (color: string) => void;
127
+ activeImageContext?: ActiveImageContext | null;
128
+ activeObjectContext?: ActiveObjectContext | null;
129
+ onSetImageLayout?: (
130
+ mediaId: string,
131
+ dimensions: { widthEmu: number; heightEmu: number },
132
+ ) => void;
133
+ onSetImageFrame?: (
134
+ mediaId: string,
135
+ offsets: { horizontalOffsetEmu?: number; verticalOffsetEmu?: number },
136
+ ) => void;
137
+ onDeleteSectionBreak?: (sectionIndex: number) => void;
138
+ onUpdateSectionLayout?: (
139
+ sectionIndex: number,
140
+ patch: {
141
+ pageSize?: { width?: number; height?: number; orientation?: "portrait" | "landscape" };
142
+ pageMargins?: {
143
+ top?: number;
144
+ right?: number;
145
+ bottom?: number;
146
+ left?: number;
147
+ header?: number;
148
+ footer?: number;
149
+ gutter?: number;
150
+ };
151
+ columns?: {
152
+ count?: number;
153
+ space?: number;
154
+ equalWidth?: boolean;
155
+ columns?: Array<{ width: number; space?: number }>;
156
+ separator?: boolean;
157
+ };
158
+ titlePage?: boolean;
159
+ sectionType?: SectionBreakType;
160
+ },
161
+ ) => void;
162
+ onSetSectionPageNumbering?: (
163
+ sectionIndex: number,
164
+ patch: SectionPageNumberingPatch | null,
165
+ ) => void;
166
+ onSetHeaderFooterLink?: (
167
+ sectionIndex: number,
168
+ patch: HeaderFooterLinkPatch,
169
+ ) => void;
170
+ onAddCommentFromSelection?: () => void;
171
+ onExport?: () => void;
172
+ onDismissSelectionToolbar?: () => void;
173
+ onSelectionToolbarFocusCapture?: FocusEventHandler<HTMLDivElement>;
174
+ onSelectionToolbarBlurCapture?: FocusEventHandler<HTMLDivElement>;
175
+ selectionToolbarRef?: Ref<HTMLDivElement>;
176
+ onOpenComment?: (thread: CommentSidebarThreadSnapshot) => void;
177
+ onResolveComment?: (commentId: string) => void;
40
178
  onReopenComment?: (commentId: string) => void;
41
179
  onAddReply?: (commentId: string, body: string) => void;
42
180
  onEditBody?: (commentId: string, body: string) => void;
43
- onOpenRevision: (revision: TrackedChangeEntrySnapshot) => void;
44
- onAcceptRevision: (revisionId: string) => void;
45
- onRejectRevision: (revisionId: string) => void;
46
- onAcceptAllChanges: () => void;
47
- onRejectAllChanges: () => void;
181
+ onOpenRevision?: (revision: TrackedChangeEntrySnapshot) => void;
182
+ onAcceptRevision?: (revisionId: string) => void;
183
+ onRejectRevision?: (revisionId: string) => void;
184
+ onAcceptAllChanges?: () => void;
185
+ onRejectAllChanges?: () => void;
186
+ onCloseStory?: () => void;
187
+ onOpenHeaderStory?: () => void;
188
+ onOpenFooterStory?: () => void;
189
+ onSetParagraphIndentation?: (indentation: {
190
+ left?: number;
191
+ right?: number;
192
+ firstLine?: number;
193
+ hanging?: number;
194
+ }) => void;
195
+ onSetParagraphTabStops?: (tabStops: Array<{ pos: number; val?: string; leader?: string }>) => void;
196
+ onRestartNumbering?: () => void;
197
+ onContinueNumbering?: () => void;
198
+ onNavigateHeading?: (headingId: string) => void;
48
199
  }
49
200
 
50
- export function TwReviewWorkspace(props: TwReviewWorkspaceProps) {
51
- const { snapshot } = props;
201
+ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
202
+ const props = {
203
+ ...inputProps,
204
+ ...inputProps.commands,
205
+ } as TwReviewWorkspaceProps & EditorCommandBag;
206
+ const { snapshot, viewState } = props;
207
+ const selectionToolbarRootRef = useRef<HTMLDivElement>(null);
52
208
  const caps = props.capabilities;
53
- const markupDisplay: MarkupDisplay = props.viewMode === "document" ? "all" : "clean";
209
+ const isPageWorkspace = props.workspaceMode === "page";
210
+ const markupDisplay = props.markupDisplay;
211
+ const [navOpen, setNavOpen] = useState(false);
212
+ const [layoutToolsOpen, setLayoutToolsOpen] = useState(false);
213
+ const zoomLevel = props.zoomLevel ?? 100;
214
+ const zoomScale = typeof zoomLevel === "number" ? zoomLevel / 100 : 1;
54
215
  const preserveOnlyCount = caps?.preserveOnlyCount ??
55
216
  snapshot.compatibility.featureEntries.filter(
56
217
  (entry) => entry.featureClass === "preserve-only",
57
218
  ).length;
219
+ const blockedReasons =
220
+ props.interactionGuardSnapshot?.blockedReasons ??
221
+ props.workflowScopeSnapshot?.blockedReasons ??
222
+ [];
58
223
  const showReviewRail = caps?.reviewRailVisible ?? true;
224
+ const headings = props.documentNavigation?.headings ?? [];
225
+ const headerVariant = snapshot.pageLayout?.headerVariants[0]?.variant ?? "default";
226
+ const footerVariant = snapshot.pageLayout?.footerVariants[0]?.variant ?? "default";
227
+ const selectionPosition =
228
+ viewState.selection.activeRange.kind === "node"
229
+ ? viewState.selection.activeRange.at
230
+ : viewState.selection.head;
231
+ const activeParagraphLayout = useMemo(
232
+ () => resolveActiveParagraphLayout(snapshot.surface, selectionPosition),
233
+ [selectionPosition, snapshot.surface],
234
+ );
235
+ const isTableContext = Boolean(
236
+ props.formattingState?.breadcrumb.some((item) => item.kind === "table" || item.kind === "table_cell" || item.kind === "table_row"),
237
+ );
238
+ const contextualSurface =
239
+ props.activeImageContext
240
+ ? "image"
241
+ : props.activeObjectContext
242
+ ? "object"
243
+ : isTableContext
244
+ ? "table"
245
+ : null;
246
+ const pageChromeModel = useMemo(
247
+ () =>
248
+ buildPageChromeModel(
249
+ snapshot.surface,
250
+ snapshot.pageLayout,
251
+ props.documentNavigation,
252
+ snapshot.activeStory,
253
+ ),
254
+ [props.documentNavigation, snapshot.activeStory, snapshot.pageLayout, snapshot.surface],
255
+ );
256
+ const selectionToolbarPlacement = resolveSelectionToolbarPlacement(
257
+ props.selectionToolbarAnchor,
258
+ selectionToolbarRootRef.current,
259
+ zoomScale,
260
+ );
261
+ const activePage = props.documentNavigation?.pages[props.documentNavigation.activePageIndex] ?? null;
262
+ const pageShellMetrics = useMemo(
263
+ () => buildPageShellMetrics(snapshot.pageLayout),
264
+ [snapshot.pageLayout],
265
+ );
266
+ const hidePageBorderForActiveEditing =
267
+ isPageWorkspace &&
268
+ snapshot.activeStory.kind === "main" &&
269
+ shouldHidePageBorderForSelection(viewState.selection);
270
+
271
+ useEffect(() => {
272
+ recordPerfSample("workspace.chrome");
273
+ incrementInvalidationCounter("workspace.chrome.recomputes");
274
+ }, [activeParagraphLayout, pageChromeModel, pageShellMetrics]);
275
+
276
+ useEffect(() => {
277
+ if (isPageWorkspace && snapshot.activeStory.kind !== "main") {
278
+ setLayoutToolsOpen(true);
279
+ }
280
+ }, [isPageWorkspace, snapshot.activeStory.kind]);
281
+
282
+ const dismissSelectionToolbar = useCallback(() => {
283
+ props.onDismissSelectionToolbar?.();
284
+ }, [props.onDismissSelectionToolbar]);
285
+
286
+ const runWithSelectionToolbarDismiss = useCallback(
287
+ (action?: () => void) => () => {
288
+ dismissSelectionToolbar();
289
+ action?.();
290
+ },
291
+ [dismissSelectionToolbar],
292
+ );
59
293
 
60
294
  return (
61
295
  <Tooltip.Provider delayDuration={400}>
@@ -65,41 +299,540 @@ export function TwReviewWorkspace(props: TwReviewWorkspaceProps) {
65
299
  capabilities={caps}
66
300
  compatibility={snapshot.compatibility}
67
301
  warnings={snapshot.warnings}
68
- viewMode={props.viewMode}
302
+ workspaceMode={props.workspaceMode}
303
+ zoomLevel={props.zoomLevel}
304
+ formattingState={props.formattingState}
305
+ styleCatalog={props.styleCatalog}
69
306
  showTrackedChanges={props.showTrackedChanges}
70
- onUndo={props.onUndo}
71
- onRedo={props.onRedo}
72
- onAddComment={props.onAddComment}
73
- onExport={props.onExport}
74
- onViewModeChange={props.onViewModeChange}
75
- onShowTrackedChangesChange={props.onShowTrackedChangesChange}
307
+ onUndo={runWithSelectionToolbarDismiss(props.onUndo)}
308
+ onRedo={runWithSelectionToolbarDismiss(props.onRedo)}
309
+ onSetParagraphStyle={props.onSetParagraphStyle
310
+ ? (styleId) => {
311
+ dismissSelectionToolbar();
312
+ props.onSetParagraphStyle?.(styleId);
313
+ }
314
+ : undefined}
315
+ onToggleBold={runWithSelectionToolbarDismiss(props.onToggleBold)}
316
+ onToggleItalic={runWithSelectionToolbarDismiss(props.onToggleItalic)}
317
+ onToggleUnderline={runWithSelectionToolbarDismiss(props.onToggleUnderline)}
318
+ onToggleStrikethrough={runWithSelectionToolbarDismiss(props.onToggleStrikethrough)}
319
+ onToggleSuperscript={runWithSelectionToolbarDismiss(props.onToggleSuperscript)}
320
+ onToggleSubscript={runWithSelectionToolbarDismiss(props.onToggleSubscript)}
321
+ onSetFontFamily={props.onSetFontFamily
322
+ ? (fontFamily) => {
323
+ dismissSelectionToolbar();
324
+ props.onSetFontFamily?.(fontFamily);
325
+ }
326
+ : undefined}
327
+ onSetFontSize={props.onSetFontSize
328
+ ? (fontSize) => {
329
+ dismissSelectionToolbar();
330
+ props.onSetFontSize?.(fontSize);
331
+ }
332
+ : undefined}
333
+ onSetTextColor={props.onSetTextColor
334
+ ? (color) => {
335
+ dismissSelectionToolbar();
336
+ props.onSetTextColor?.(color);
337
+ }
338
+ : undefined}
339
+ onSetHighlightColor={props.onSetHighlightColor
340
+ ? (color) => {
341
+ dismissSelectionToolbar();
342
+ props.onSetHighlightColor?.(color);
343
+ }
344
+ : undefined}
345
+ onSetAlignment={props.onSetAlignment
346
+ ? (alignment) => {
347
+ dismissSelectionToolbar();
348
+ props.onSetAlignment?.(alignment);
349
+ }
350
+ : undefined}
351
+ onOutdent={runWithSelectionToolbarDismiss(props.onOutdent)}
352
+ onIndent={runWithSelectionToolbarDismiss(props.onIndent)}
353
+ onAddComment={runWithSelectionToolbarDismiss(props.onAddComment)}
354
+ onInsertPageBreak={runWithSelectionToolbarDismiss(props.onInsertPageBreak)}
355
+ onInsertTable={runWithSelectionToolbarDismiss(props.onInsertTable)}
356
+ onInsertSectionBreak={props.onInsertSectionBreak
357
+ ? (type) => {
358
+ dismissSelectionToolbar();
359
+ props.onInsertSectionBreak?.(type);
360
+ }
361
+ : undefined}
362
+ onInsertImage={props.onInsertImage
363
+ ? (options) => {
364
+ dismissSelectionToolbar();
365
+ props.onInsertImage?.(options);
366
+ }
367
+ : undefined}
368
+ onExport={runWithSelectionToolbarDismiss(props.onExport)}
369
+ activeStory={snapshot.activeStory}
370
+ onCloseStory={props.onCloseStory
371
+ ? runWithSelectionToolbarDismiss(props.onCloseStory)
372
+ : undefined}
373
+ onWorkspaceModeChange={(value) => {
374
+ dismissSelectionToolbar();
375
+ props.onWorkspaceModeChange(value);
376
+ }}
377
+ onZoomChange={props.onZoomChange
378
+ ? (level) => {
379
+ dismissSelectionToolbar();
380
+ props.onZoomChange?.(level);
381
+ }
382
+ : undefined}
383
+ onShowTrackedChangesChange={(show) => {
384
+ dismissSelectionToolbar();
385
+ props.onShowTrackedChangesChange(show);
386
+ }}
387
+ blockedReasons={blockedReasons}
76
388
  />
77
389
 
78
- <TwAlertBanner snapshot={snapshot} preserveOnlyCount={preserveOnlyCount} />
390
+ <TwAlertBanner
391
+ snapshot={snapshot}
392
+ preserveOnlyCount={preserveOnlyCount}
393
+ workflowBlockedReasons={blockedReasons}
394
+ />
79
395
 
80
396
  <div className="flex flex-1 min-h-0">
397
+ {/* Collapsible document navigator — page mode only */}
398
+ {isPageWorkspace ? (
399
+ <aside
400
+ aria-label="Document navigator"
401
+ className={`shrink-0 border-r border-border bg-surface transition-[width] duration-200 ${
402
+ navOpen ? "w-48" : "w-0"
403
+ } overflow-hidden`}
404
+ >
405
+ {navOpen ? (
406
+ <div className="flex h-full flex-col">
407
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border">
408
+ <span className="text-xs font-medium text-secondary uppercase tracking-wider">Navigator</span>
409
+ <Tooltip.Root>
410
+ <Tooltip.Trigger asChild>
411
+ <button
412
+ type="button"
413
+ aria-label="Collapse navigator"
414
+ onMouseDown={preserveEditorSelectionMouseDown}
415
+ onClick={() => {
416
+ dismissSelectionToolbar();
417
+ setNavOpen(false);
418
+ }}
419
+ className="inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary hover:bg-surface-hover transition-colors"
420
+ >
421
+ <ChevronLeft className="h-3.5 w-3.5" />
422
+ </button>
423
+ </Tooltip.Trigger>
424
+ <Tooltip.Portal>
425
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
426
+ Collapse navigator
427
+ </Tooltip.Content>
428
+ </Tooltip.Portal>
429
+ </Tooltip.Root>
430
+ </div>
431
+ <nav className="flex-1 overflow-y-auto px-2 py-2" aria-label="Document headings">
432
+ {headings.length > 0 ? (
433
+ <ul className="space-y-0.5">
434
+ {headings.map((entry) => (
435
+ <li key={entry.headingId}>
436
+ <button
437
+ type="button"
438
+ className="block w-full truncate rounded-md px-2 py-1 text-left text-xs text-primary hover:bg-surface-hover"
439
+ style={{ paddingLeft: `${8 + (entry.level - 1) * 12}px` }}
440
+ onMouseDown={preserveEditorSelectionMouseDown}
441
+ onClick={() => {
442
+ dismissSelectionToolbar();
443
+ props.onNavigateHeading?.(entry.headingId);
444
+ setNavOpen(false);
445
+ }}
446
+ >
447
+ {entry.text}
448
+ </button>
449
+ </li>
450
+ ))}
451
+ </ul>
452
+ ) : (
453
+ <p className="px-2 py-4 text-xs text-tertiary">No headings found.</p>
454
+ )}
455
+ </nav>
456
+ </div>
457
+ ) : null}
458
+ </aside>
459
+ ) : null}
460
+
461
+ {/* Navigator expand toggle — page mode only when collapsed */}
462
+ {isPageWorkspace && !navOpen ? (
463
+ <div className="shrink-0 flex items-start pt-2 pl-1">
464
+ <Tooltip.Root>
465
+ <Tooltip.Trigger asChild>
466
+ <button
467
+ type="button"
468
+ aria-label="Open document navigator"
469
+ onMouseDown={preserveEditorSelectionMouseDown}
470
+ onClick={() => {
471
+ dismissSelectionToolbar();
472
+ setNavOpen(true);
473
+ }}
474
+ className="inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary hover:bg-surface-hover transition-colors"
475
+ >
476
+ <List className="h-3.5 w-3.5" />
477
+ </button>
478
+ </Tooltip.Trigger>
479
+ <Tooltip.Portal>
480
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
481
+ Open document navigator
482
+ </Tooltip.Content>
483
+ </Tooltip.Portal>
484
+ </Tooltip.Root>
485
+ </div>
486
+ ) : null}
487
+
81
488
  {/* Document column */}
82
489
  <div className="flex flex-1 flex-col min-w-0">
83
- <div className={`flex-1 overflow-y-auto ${props.viewMode === "document" ? "bg-surface" : "bg-canvas"}`}>
490
+ <div
491
+ className={`flex-1 overflow-y-auto ${isPageWorkspace ? "bg-surface" : "bg-canvas"}`}
492
+ data-wre-scroll-root="true"
493
+ >
84
494
  <div
495
+ ref={selectionToolbarRootRef}
85
496
  className={`mx-auto min-h-full ${
86
- props.viewMode === "document"
87
- ? "max-w-[780px] my-8 rounded-xl ring-1 ring-border shadow-sm bg-canvas"
88
- : "bg-canvas"
497
+ isPageWorkspace
498
+ ? "wre-page-chrome wre-page-surface relative max-w-[840px] my-8 overflow-hidden"
499
+ : "wre-canvas-surface relative bg-canvas"
89
500
  }`}
501
+ style={isPageWorkspace && zoomScale !== 1 ? { transform: `scale(${zoomScale})`, transformOrigin: "top center" } : undefined}
90
502
  >
91
- {props.selectionPreview ? (
92
- <div className="flex justify-center pt-4 px-4">
93
- <TwSelectionToolbar
94
- selectionPreview={props.selectionPreview}
503
+ {isPageWorkspace && snapshot.pageLayout ? (
504
+ <div className="border-b border-border/70 bg-surface/65 px-5 py-3" data-testid="page-context-summary">
505
+ <div className="flex flex-wrap items-center justify-between gap-2">
506
+ <div className="flex flex-wrap items-center gap-2 text-xs text-secondary">
507
+ <span className="rounded-full bg-canvas px-2 py-1 font-medium text-primary">
508
+ {activePage
509
+ ? `Page ${activePage.pageIndex + 1} of ${props.documentNavigation?.pageCount ?? 1}`
510
+ : "Page workspace"}
511
+ </span>
512
+ <span>{`Section ${snapshot.pageLayout.sectionIndex + 1}`}</span>
513
+ <span className="uppercase tracking-[0.12em] text-tertiary">
514
+ {snapshot.pageLayout.orientation}
515
+ </span>
516
+ </div>
517
+ <div className="flex items-center gap-2">
518
+ {snapshot.activeStory.kind !== "main" ? (
519
+ <button
520
+ type="button"
521
+ aria-label="Return to document body"
522
+ onMouseDown={preserveEditorSelectionMouseDown}
523
+ onClick={runWithSelectionToolbarDismiss(props.onCloseStory)}
524
+ className="inline-flex items-center gap-1 rounded-md border border-border bg-canvas px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface"
525
+ >
526
+ Body
527
+ </button>
528
+ ) : null}
529
+ {snapshot.activeStory.kind === "main" && snapshot.pageLayout.sectionIndex > 0 ? (
530
+ <>
531
+ <button
532
+ type="button"
533
+ aria-label="Link header to previous"
534
+ disabled={!props.onSetHeaderFooterLink}
535
+ onMouseDown={preserveEditorSelectionMouseDown}
536
+ onClick={() => {
537
+ dismissSelectionToolbar();
538
+ props.onSetHeaderFooterLink?.(snapshot.pageLayout!.sectionIndex, {
539
+ kind: "header",
540
+ variant: headerVariant,
541
+ linkToPrevious: true,
542
+ });
543
+ }}
544
+ className="inline-flex items-center gap-1 rounded-md border border-border bg-canvas px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40"
545
+ >
546
+ Link header
547
+ </button>
548
+ <button
549
+ type="button"
550
+ aria-label="Link footer to previous"
551
+ disabled={!props.onSetHeaderFooterLink}
552
+ onMouseDown={preserveEditorSelectionMouseDown}
553
+ onClick={() => {
554
+ dismissSelectionToolbar();
555
+ props.onSetHeaderFooterLink?.(snapshot.pageLayout!.sectionIndex, {
556
+ kind: "footer",
557
+ variant: footerVariant,
558
+ linkToPrevious: true,
559
+ });
560
+ }}
561
+ className="inline-flex items-center gap-1 rounded-md border border-border bg-canvas px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40"
562
+ >
563
+ Link footer
564
+ </button>
565
+ </>
566
+ ) : null}
567
+ <button
568
+ type="button"
569
+ aria-label="Toggle layout tools"
570
+ aria-expanded={layoutToolsOpen}
571
+ onMouseDown={preserveEditorSelectionMouseDown}
572
+ onClick={() => {
573
+ dismissSelectionToolbar();
574
+ setLayoutToolsOpen((open) => !open);
575
+ }}
576
+ className="inline-flex items-center gap-1 rounded-md border border-border bg-canvas px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface"
577
+ >
578
+ <ChevronRight className={`h-3.5 w-3.5 transition-transform ${layoutToolsOpen ? "rotate-90" : ""}`} />
579
+ Layout tools
580
+ </button>
581
+ </div>
582
+ </div>
583
+ </div>
584
+ ) : null}
585
+ {isPageWorkspace && snapshot.pageLayout && layoutToolsOpen ? (
586
+ <div className="px-5 pt-3">
587
+ <TwPageRuler
588
+ pageLayout={snapshot.pageLayout}
589
+ viewState={viewState}
590
+ paragraphLayout={activeParagraphLayout}
95
591
  readOnly={snapshot.readOnly}
96
- canAddComment={props.capabilities?.canAddComment}
97
- disabledReason={props.addCommentDisabledReason}
98
- onAddComment={props.onAddComment}
592
+ onReturnToBody={props.onCloseStory
593
+ ? runWithSelectionToolbarDismiss(props.onCloseStory)
594
+ : () => undefined}
595
+ onOpenHeader={props.onOpenHeaderStory
596
+ ? runWithSelectionToolbarDismiss(props.onOpenHeaderStory)
597
+ : undefined}
598
+ onOpenFooter={props.onOpenFooterStory
599
+ ? runWithSelectionToolbarDismiss(props.onOpenFooterStory)
600
+ : undefined}
601
+ onSetIndentation={props.onSetParagraphIndentation
602
+ ? (indentation) => {
603
+ dismissSelectionToolbar();
604
+ props.onSetParagraphIndentation?.(indentation);
605
+ }
606
+ : undefined}
607
+ onSetTabStops={props.onSetParagraphTabStops
608
+ ? (tabStops) => {
609
+ dismissSelectionToolbar();
610
+ props.onSetParagraphTabStops?.(tabStops);
611
+ }
612
+ : undefined}
613
+ onRestartNumbering={props.onRestartNumbering
614
+ ? runWithSelectionToolbarDismiss(props.onRestartNumbering)
615
+ : undefined}
616
+ onContinueNumbering={props.onContinueNumbering
617
+ ? runWithSelectionToolbarDismiss(props.onContinueNumbering)
618
+ : undefined}
99
619
  />
620
+ <TwLayoutPanel
621
+ pageLayout={snapshot.pageLayout}
622
+ readOnly={snapshot.readOnly || snapshot.activeStory.kind !== "main"}
623
+ onInsertSectionBreak={props.onInsertSectionBreak
624
+ ? (type) => {
625
+ dismissSelectionToolbar();
626
+ props.onInsertSectionBreak?.(type);
627
+ }
628
+ : undefined}
629
+ onDeleteSectionBreak={props.onDeleteSectionBreak
630
+ ? (sectionIndex) => {
631
+ dismissSelectionToolbar();
632
+ props.onDeleteSectionBreak?.(sectionIndex);
633
+ }
634
+ : undefined}
635
+ onUpdateSectionLayout={props.onUpdateSectionLayout
636
+ ? (sectionIndex, patch) => {
637
+ dismissSelectionToolbar();
638
+ props.onUpdateSectionLayout?.(sectionIndex, patch);
639
+ }
640
+ : undefined}
641
+ onSetSectionPageNumbering={props.onSetSectionPageNumbering
642
+ ? (sectionIndex, patch) => {
643
+ dismissSelectionToolbar();
644
+ props.onSetSectionPageNumbering?.(sectionIndex, patch);
645
+ }
646
+ : undefined}
647
+ />
648
+ </div>
649
+ ) : null}
650
+ {contextualSurface ? (
651
+ <div className="px-5 pt-3 space-y-3">
652
+ {contextualSurface === "table" ? (
653
+ <TwTableContextToolbar
654
+ disabled={!caps?.canEdit}
655
+ tableStyles={props.styleCatalog?.tables ?? []}
656
+ onSetTableStyle={props.onSetTableStyle
657
+ ? (styleId) => {
658
+ dismissSelectionToolbar();
659
+ props.onSetTableStyle?.(styleId);
660
+ }
661
+ : undefined}
662
+ onAddRowBefore={runWithSelectionToolbarDismiss(props.onAddRowBefore)}
663
+ onAddRowAfter={runWithSelectionToolbarDismiss(props.onAddRowAfter)}
664
+ onAddColumnBefore={runWithSelectionToolbarDismiss(props.onAddColumnBefore)}
665
+ onAddColumnAfter={runWithSelectionToolbarDismiss(props.onAddColumnAfter)}
666
+ onDeleteRow={runWithSelectionToolbarDismiss(props.onDeleteRow)}
667
+ onDeleteColumn={runWithSelectionToolbarDismiss(props.onDeleteColumn)}
668
+ onDeleteTable={runWithSelectionToolbarDismiss(props.onDeleteTable)}
669
+ onMergeCells={runWithSelectionToolbarDismiss(props.onMergeCells)}
670
+ onSplitCell={runWithSelectionToolbarDismiss(props.onSplitCell)}
671
+ onSetCellBackground={props.onSetCellBackground
672
+ ? (color) => {
673
+ dismissSelectionToolbar();
674
+ props.onSetCellBackground?.(color);
675
+ }
676
+ : undefined}
677
+ />
678
+ ) : null}
679
+ {contextualSurface === "image" && props.activeImageContext ? (
680
+ <TwImageContextToolbar
681
+ activeImage={props.activeImageContext}
682
+ disabled={!caps?.canEdit}
683
+ onSetImageLayout={props.onSetImageLayout
684
+ ? (mediaId, dimensions) => {
685
+ dismissSelectionToolbar();
686
+ props.onSetImageLayout?.(mediaId, dimensions);
687
+ }
688
+ : undefined}
689
+ onSetImageFrame={props.onSetImageFrame
690
+ ? (mediaId, offsets) => {
691
+ dismissSelectionToolbar();
692
+ props.onSetImageFrame?.(mediaId, offsets);
693
+ }
694
+ : undefined}
695
+ />
696
+ ) : null}
697
+ {contextualSurface === "object" && props.activeObjectContext ? (
698
+ <TwObjectContextToolbar activeObject={props.activeObjectContext} />
699
+ ) : null}
700
+ </div>
701
+ ) : null}
702
+ {props.selectionToolbar && selectionToolbarPlacement ? (
703
+ <div className="pointer-events-none absolute inset-0 z-20" data-testid="selection-toolbar-overlay">
704
+ <div
705
+ className="pointer-events-auto absolute"
706
+ data-placement={selectionToolbarPlacement.placement}
707
+ style={selectionToolbarPlacement.style}
708
+ >
709
+ <TwSelectionToolbar
710
+ ref={props.selectionToolbarRef}
711
+ model={props.selectionToolbar}
712
+ disabledReason={props.selectionToolbar.disabledReason}
713
+ onFocusCapture={props.onSelectionToolbarFocusCapture}
714
+ onBlurCapture={props.onSelectionToolbarBlurCapture}
715
+ onToggleBold={props.onToggleBold}
716
+ onToggleItalic={props.onToggleItalic}
717
+ onToggleUnderline={props.onToggleUnderline}
718
+ onSetTextColor={props.onSetSelectionTextColor}
719
+ onSetHighlightColor={props.onSetSelectionHighlightColor}
720
+ onAddComment={props.onAddCommentFromSelection ?? props.onAddComment}
721
+ />
722
+ </div>
723
+ </div>
724
+ ) : null}
725
+ {props.selectionToolbar && !selectionToolbarPlacement ? (
726
+ <div
727
+ className="pointer-events-none absolute inset-x-0 top-0 z-20 flex justify-center px-4 pt-3"
728
+ data-testid="selection-toolbar-fallback"
729
+ >
730
+ <div className="pointer-events-auto" data-placement="fallback">
731
+ <TwSelectionToolbar
732
+ ref={props.selectionToolbarRef}
733
+ model={props.selectionToolbar}
734
+ disabledReason={props.selectionToolbar.disabledReason}
735
+ onFocusCapture={props.onSelectionToolbarFocusCapture}
736
+ onBlurCapture={props.onSelectionToolbarBlurCapture}
737
+ onToggleBold={props.onToggleBold}
738
+ onToggleItalic={props.onToggleItalic}
739
+ onToggleUnderline={props.onToggleUnderline}
740
+ onSetTextColor={props.onSetSelectionTextColor}
741
+ onSetHighlightColor={props.onSetSelectionHighlightColor}
742
+ onAddComment={props.onAddCommentFromSelection ?? props.onAddComment}
743
+ />
744
+ </div>
100
745
  </div>
101
746
  ) : null}
102
- {props.document}
747
+ <div
748
+ className={isPageWorkspace ? "relative" : undefined}
749
+ data-line-numbering={pageChromeModel.lineNumberingEnabled ? "enabled" : "disabled"}
750
+ >
751
+ {isPageWorkspace && pageChromeModel.lineNumberingEnabled ? (
752
+ <div
753
+ aria-hidden="true"
754
+ className="pointer-events-none absolute inset-y-0 left-0 z-10"
755
+ data-testid="page-line-number-gutter"
756
+ style={{ width: `${pageChromeModel.gutterWidthPx}px` }}
757
+ >
758
+ {pageChromeModel.lineMarkers.map((marker) => (
759
+ <span
760
+ key={marker.id}
761
+ className="absolute right-2 font-[family-name:var(--font-legal-sans)] text-[10px] font-medium tabular-nums tracking-[0.12em] text-tertiary/80"
762
+ style={{ top: `${marker.topPx}px` }}
763
+ >
764
+ {marker.label}
765
+ </span>
766
+ ))}
767
+ </div>
768
+ ) : null}
769
+ <div
770
+ className={isPageWorkspace && pageChromeModel.lineNumberingEnabled ? "pl-12" : undefined}
771
+ style={isPageWorkspace ? pageShellMetrics.contentInsetStyle : undefined}
772
+ >
773
+ <div
774
+ className={isPageWorkspace ? "relative" : undefined}
775
+ data-document-grid={pageChromeModel.documentGridType}
776
+ data-page-border-display={pageChromeModel.pageBorderDisplay}
777
+ style={isPageWorkspace
778
+ ? {
779
+ ...pageChromeModel.documentGridStyle,
780
+ ...pageShellMetrics.pageFrameStyle,
781
+ }
782
+ : pageChromeModel.documentGridStyle}
783
+ >
784
+ {isPageWorkspace ? (
785
+ <div
786
+ data-testid="page-header-band"
787
+ className="relative z-10 flex items-center justify-between border-b border-dashed border-border/60 px-4 text-[11px] text-secondary"
788
+ style={pageShellMetrics.headerBandStyle}
789
+ >
790
+ <span className="uppercase tracking-[0.12em] text-tertiary">Header</span>
791
+ {snapshot.pageLayout?.headerVariants[0] ? (
792
+ <button
793
+ type="button"
794
+ aria-label="Open header story"
795
+ onClick={props.onOpenHeaderStory}
796
+ className="rounded-md px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface"
797
+ >
798
+ Edit header
799
+ </button>
800
+ ) : null}
801
+ </div>
802
+ ) : null}
803
+ {isPageWorkspace && pageChromeModel.showPageBorder && !hidePageBorderForActiveEditing ? (
804
+ <div
805
+ aria-hidden="true"
806
+ className="pointer-events-none absolute inset-0 z-0 rounded-[2px]"
807
+ data-testid="page-border-overlay"
808
+ style={pageChromeModel.pageBorderStyle}
809
+ />
810
+ ) : null}
811
+ <div className={isPageWorkspace ? "relative z-10" : undefined}>
812
+ {props.document}
813
+ </div>
814
+ {isPageWorkspace ? (
815
+ <div
816
+ data-testid="page-footer-band"
817
+ className="relative z-10 flex items-center justify-between border-t border-dashed border-border/60 px-4 text-[11px] text-secondary"
818
+ style={pageShellMetrics.footerBandStyle}
819
+ >
820
+ <span className="uppercase tracking-[0.12em] text-tertiary">Footer</span>
821
+ {snapshot.pageLayout?.footerVariants[0] ? (
822
+ <button
823
+ type="button"
824
+ aria-label="Open footer story"
825
+ onClick={props.onOpenFooterStory}
826
+ className="rounded-md px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface"
827
+ >
828
+ Edit footer
829
+ </button>
830
+ ) : null}
831
+ </div>
832
+ ) : null}
833
+ </div>
834
+ </div>
835
+ </div>
103
836
  </div>
104
837
  </div>
105
838
 
@@ -141,3 +874,466 @@ export function TwReviewWorkspace(props: TwReviewWorkspaceProps) {
141
874
  </Tooltip.Provider>
142
875
  );
143
876
  }
877
+
878
+ function shouldHidePageBorderForSelection(
879
+ selection: EditorViewStateSnapshot["selection"],
880
+ ): boolean {
881
+ if (selection.isCollapsed) {
882
+ return false;
883
+ }
884
+
885
+ return selection.activeRange.kind === "range";
886
+ }
887
+
888
+ function resolveActiveParagraphLayout(
889
+ surface: RuntimeRenderSnapshot["surface"],
890
+ position: number,
891
+ ): {
892
+ leftIndent: number;
893
+ rightIndent: number;
894
+ firstLineOffset: number;
895
+ tabStops: Array<{ pos: number; val?: string; leader?: string }>;
896
+ } | null {
897
+ const paragraph = surface ? findActiveParagraph(surface.blocks, position) : null;
898
+ if (!paragraph) {
899
+ return null;
900
+ }
901
+
902
+ return {
903
+ leftIndent: paragraph.indentation?.left ?? 0,
904
+ rightIndent: paragraph.indentation?.right ?? 0,
905
+ firstLineOffset:
906
+ paragraph.indentation?.firstLine ??
907
+ (paragraph.indentation?.hanging ? -paragraph.indentation.hanging : 0),
908
+ tabStops: paragraph.tabStops ? [...paragraph.tabStops] : [],
909
+ };
910
+ }
911
+
912
+ function findActiveParagraph(
913
+ blocks: readonly SurfaceBlockSnapshot[],
914
+ position: number,
915
+ ): Extract<SurfaceBlockSnapshot, { kind: "paragraph" }> | null {
916
+ for (const block of blocks) {
917
+ if (block.kind === "paragraph" && position >= block.from && position <= block.to) {
918
+ return block;
919
+ }
920
+ if (block.kind === "table") {
921
+ for (const row of block.rows) {
922
+ for (const cell of row.cells) {
923
+ const paragraph = findActiveParagraph(cell.content, position);
924
+ if (paragraph) {
925
+ return paragraph;
926
+ }
927
+ }
928
+ }
929
+ }
930
+ if (block.kind === "sdt_block") {
931
+ const paragraph = findActiveParagraph(block.children, position);
932
+ if (paragraph) {
933
+ return paragraph;
934
+ }
935
+ }
936
+ }
937
+ return null;
938
+ }
939
+
940
+ interface PageChromeModel {
941
+ lineNumberingEnabled: boolean;
942
+ gutterWidthPx: number;
943
+ lineMarkers: Array<{ id: string; label: string; topPx: number }>;
944
+ showPageBorder: boolean;
945
+ pageBorderDisplay: string;
946
+ pageBorderStyle: CSSProperties | undefined;
947
+ documentGridType: string;
948
+ documentGridStyle: CSSProperties | undefined;
949
+ }
950
+
951
+ const EMPTY_PAGE_CHROME_MODEL: PageChromeModel = {
952
+ lineNumberingEnabled: false,
953
+ gutterWidthPx: 0,
954
+ lineMarkers: [],
955
+ showPageBorder: false,
956
+ pageBorderDisplay: "none",
957
+ pageBorderStyle: undefined,
958
+ documentGridType: "none",
959
+ documentGridStyle: undefined,
960
+ };
961
+
962
+ const DOCUMENT_CONTENT_TOP_PADDING_PX = 40;
963
+
964
+ interface PageShellMetrics {
965
+ contentInsetStyle: CSSProperties;
966
+ pageFrameStyle: CSSProperties;
967
+ headerBandStyle: CSSProperties;
968
+ footerBandStyle: CSSProperties;
969
+ }
970
+
971
+ function buildPageChromeModel(
972
+ surface: RuntimeRenderSnapshot["surface"] | undefined,
973
+ pageLayout: RuntimeRenderSnapshot["pageLayout"] | undefined,
974
+ navigation: DocumentNavigationSnapshot | undefined,
975
+ activeStory: RuntimeRenderSnapshot["activeStory"],
976
+ ): PageChromeModel {
977
+ if (!surface || !pageLayout || !navigation || activeStory.kind !== "main") {
978
+ return EMPTY_PAGE_CHROME_MODEL;
979
+ }
980
+
981
+ const lineMarkers = computeLineMarkersIfEnabled({
982
+ pageLayout,
983
+ surfaceBlocks: surface.blocks,
984
+ pages: navigation.pages,
985
+ buildLineNumberMarkers,
986
+ });
987
+ const lineNumberingEnabled =
988
+ Boolean(pageLayout.lineNumbering) && lineMarkers.length > 0;
989
+ const distance = pageLayout.lineNumbering?.distance ?? 0;
990
+ const gutterWidthPx = lineNumberingEnabled
991
+ ? Math.max(40, Math.min(88, 24 + Math.round(distance * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP)))
992
+ : 0;
993
+ const showPageBorder = shouldRenderPageBorder(pageLayout, navigation.pages, navigation.activePageIndex);
994
+
995
+ return {
996
+ lineNumberingEnabled,
997
+ gutterWidthPx,
998
+ lineMarkers,
999
+ showPageBorder,
1000
+ pageBorderDisplay: pageLayout.pageBorders?.display ?? "none",
1001
+ pageBorderStyle: showPageBorder ? buildPageBorderStyle(pageLayout) : undefined,
1002
+ documentGridType: pageLayout.documentGrid?.type ?? "none",
1003
+ documentGridStyle: buildDocumentGridStyle(pageLayout.documentGrid),
1004
+ };
1005
+ }
1006
+
1007
+ function buildPageShellMetrics(
1008
+ pageLayout: RuntimeRenderSnapshot["pageLayout"] | undefined,
1009
+ ): PageShellMetrics {
1010
+ if (!pageLayout) {
1011
+ return {
1012
+ contentInsetStyle: {},
1013
+ pageFrameStyle: {},
1014
+ headerBandStyle: {},
1015
+ footerBandStyle: {},
1016
+ };
1017
+ }
1018
+
1019
+ const horizontalInsetPx = Math.max(
1020
+ 24,
1021
+ Math.min(120, Math.round(pageLayout.marginLeft * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP)),
1022
+ );
1023
+ const verticalInsetPx = Math.max(
1024
+ 24,
1025
+ Math.min(140, Math.round(pageLayout.marginTop * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP)),
1026
+ );
1027
+ const headerBandHeightPx = Math.max(
1028
+ 40,
1029
+ Math.min(96, Math.round(pageLayout.headerMargin * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP + 16)),
1030
+ );
1031
+ const footerBandHeightPx = Math.max(
1032
+ 40,
1033
+ Math.min(96, Math.round(pageLayout.footerMargin * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP + 16)),
1034
+ );
1035
+
1036
+ return {
1037
+ contentInsetStyle: {
1038
+ paddingLeft: `${horizontalInsetPx}px`,
1039
+ paddingRight: `${horizontalInsetPx}px`,
1040
+ paddingTop: `${Math.max(20, verticalInsetPx - 12)}px`,
1041
+ paddingBottom: `${Math.max(20, Math.round(pageLayout.marginBottom * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP) - 12)}px`,
1042
+ },
1043
+ pageFrameStyle: {
1044
+ backgroundColor: "var(--color-page-bg)",
1045
+ },
1046
+ headerBandStyle: {
1047
+ minHeight: `${headerBandHeightPx}px`,
1048
+ },
1049
+ footerBandStyle: {
1050
+ minHeight: `${footerBandHeightPx}px`,
1051
+ },
1052
+ };
1053
+ }
1054
+
1055
+ function buildLineNumberMarkers(
1056
+ blocks: readonly SurfaceBlockSnapshot[],
1057
+ pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,
1058
+ ): Array<{ id: string; label: string; topPx: number }> {
1059
+ const markers: Array<{ id: string; label: string; topPx: number }> = [];
1060
+ if (pages.length === 0) {
1061
+ return markers;
1062
+ }
1063
+
1064
+ let currentTopTwips = 0;
1065
+ let lineNumber = 1;
1066
+ let lastPageIndex = -1;
1067
+ let lastSectionIndex = -1;
1068
+
1069
+ for (const block of blocks) {
1070
+ const pageIndex = findPageForOffset(pages, block.from);
1071
+ const page = pages[pageIndex];
1072
+ if (!page) {
1073
+ continue;
1074
+ }
1075
+
1076
+ const lineNumbering = page.layout.lineNumbering;
1077
+ const restartMode = lineNumbering?.restart ?? "newPage";
1078
+ const restartStart = lineNumbering?.start ?? 1;
1079
+ const countBy = Math.max(1, lineNumbering?.countBy ?? 1);
1080
+ const columnWidth = getUsableColumnWidth(page.layout);
1081
+
1082
+ if (pageIndex !== lastPageIndex) {
1083
+ if (restartMode === "newPage" || lastPageIndex === -1) {
1084
+ lineNumber = restartStart;
1085
+ }
1086
+ lastPageIndex = pageIndex;
1087
+ }
1088
+ if (page.sectionIndex !== lastSectionIndex) {
1089
+ if (restartMode === "newSection" || lastSectionIndex === -1) {
1090
+ lineNumber = restartStart;
1091
+ }
1092
+ lastSectionIndex = page.sectionIndex;
1093
+ }
1094
+
1095
+ if (block.kind === "paragraph" && lineNumbering) {
1096
+ const lineCount = estimateParagraphLineCount(block, columnWidth);
1097
+ const lineHeight = estimateParagraphLineHeight(block);
1098
+ const suppress = block.suppressLineNumbers === true;
1099
+ for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) {
1100
+ if (!suppress && (lineNumber - restartStart) % countBy === 0) {
1101
+ markers.push({
1102
+ id: `${block.blockId}-${lineIndex}`,
1103
+ label: String(lineNumber),
1104
+ topPx:
1105
+ DOCUMENT_CONTENT_TOP_PADDING_PX +
1106
+ (currentTopTwips + lineIndex * lineHeight) * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
1107
+ });
1108
+ }
1109
+ if (!suppress) {
1110
+ lineNumber += 1;
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ currentTopTwips += estimateBlockHeight(block, columnWidth);
1116
+ }
1117
+
1118
+ return markers;
1119
+ }
1120
+
1121
+ function shouldRenderPageBorder(
1122
+ pageLayout: RuntimeRenderSnapshot["pageLayout"],
1123
+ pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,
1124
+ activePageIndex: number,
1125
+ ): boolean {
1126
+ const display = pageLayout?.pageBorders?.display ?? "allPages";
1127
+ const activePage = pages[activePageIndex];
1128
+ if (!pageLayout?.pageBorders || !activePage) {
1129
+ return false;
1130
+ }
1131
+
1132
+ switch (display) {
1133
+ case "firstPage":
1134
+ return activePage.pageInSection === 0;
1135
+ case "notFirstPage":
1136
+ return activePage.pageInSection > 0;
1137
+ default:
1138
+ return true;
1139
+ }
1140
+ }
1141
+
1142
+ function buildPageBorderStyle(
1143
+ pageLayout: NonNullable<RuntimeRenderSnapshot["pageLayout"]>,
1144
+ ): CSSProperties | undefined {
1145
+ const pageBorders = pageLayout.pageBorders;
1146
+ if (!pageBorders) {
1147
+ return undefined;
1148
+ }
1149
+
1150
+ const leftInset = createInsetValue(
1151
+ pageBorders.left?.space,
1152
+ pageBorders.offsetFrom === "text"
1153
+ ? (pageLayout.marginLeft / Math.max(1, pageLayout.pageWidth)) * 100
1154
+ : 1.25,
1155
+ );
1156
+ const rightInset = createInsetValue(
1157
+ pageBorders.right?.space,
1158
+ pageBorders.offsetFrom === "text"
1159
+ ? (pageLayout.marginRight / Math.max(1, pageLayout.pageWidth)) * 100
1160
+ : 1.25,
1161
+ );
1162
+ const topInset = createInsetValue(
1163
+ pageBorders.top?.space,
1164
+ pageBorders.offsetFrom === "text"
1165
+ ? (pageLayout.marginTop / Math.max(1, pageLayout.pageHeight)) * 100
1166
+ : 1.5,
1167
+ );
1168
+ const bottomInset = createInsetValue(
1169
+ pageBorders.bottom?.space,
1170
+ pageBorders.offsetFrom === "text"
1171
+ ? (pageLayout.marginBottom / Math.max(1, pageLayout.pageHeight)) * 100
1172
+ : 1.5,
1173
+ );
1174
+
1175
+ return {
1176
+ top: topInset,
1177
+ right: rightInset,
1178
+ bottom: bottomInset,
1179
+ left: leftInset,
1180
+ borderTop: toBorderCss(pageBorders.top),
1181
+ borderRight: toBorderCss(pageBorders.right),
1182
+ borderBottom: toBorderCss(pageBorders.bottom),
1183
+ borderLeft: toBorderCss(pageBorders.left),
1184
+ boxSizing: "border-box",
1185
+ mixBlendMode: pageBorders.zOrder === "back" ? "multiply" : undefined,
1186
+ };
1187
+ }
1188
+
1189
+ function buildDocumentGridStyle(
1190
+ documentGrid: NonNullable<RuntimeRenderSnapshot["pageLayout"]>["documentGrid"] | undefined,
1191
+ ): CSSProperties | undefined {
1192
+ if (!documentGrid || !documentGrid.type || documentGrid.type === "default") {
1193
+ return undefined;
1194
+ }
1195
+
1196
+ const linePitchPx = Math.max(
1197
+ 18,
1198
+ Math.round((documentGrid.linePitch ?? 360) * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP),
1199
+ );
1200
+ const charSpacePx = Math.max(
1201
+ 12,
1202
+ Math.round((documentGrid.charSpace ?? 204) * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP),
1203
+ );
1204
+ const gridColor = "rgba(15, 23, 42, 0.06)";
1205
+ const backgrounds: string[] = [];
1206
+
1207
+ if (
1208
+ documentGrid.type === "lines" ||
1209
+ documentGrid.type === "linesAndChars" ||
1210
+ documentGrid.type === "snapToChars"
1211
+ ) {
1212
+ backgrounds.push(
1213
+ `repeating-linear-gradient(to bottom, ${gridColor} 0, ${gridColor} 1px, transparent 1px, transparent ${linePitchPx}px)`,
1214
+ );
1215
+ }
1216
+ if (
1217
+ documentGrid.type === "linesAndChars" ||
1218
+ documentGrid.type === "snapToChars"
1219
+ ) {
1220
+ backgrounds.push(
1221
+ `repeating-linear-gradient(to right, rgba(15, 23, 42, 0.04) 0, rgba(15, 23, 42, 0.04) 1px, transparent 1px, transparent ${charSpacePx}px)`,
1222
+ );
1223
+ }
1224
+
1225
+ if (backgrounds.length === 0) {
1226
+ return undefined;
1227
+ }
1228
+
1229
+ return {
1230
+ backgroundImage: backgrounds.join(", "),
1231
+ backgroundOrigin: "content-box",
1232
+ };
1233
+ }
1234
+
1235
+ function createInsetValue(spaceTwips: number | undefined, percent: number): string {
1236
+ const spacingPx = Math.max(0, Math.round((spaceTwips ?? 0) * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP));
1237
+ return `calc(${percent.toFixed(2)}% + ${spacingPx}px)`;
1238
+ }
1239
+
1240
+ function resolveSelectionToolbarPlacement(
1241
+ anchor: SelectionToolbarAnchor | null | undefined,
1242
+ root: HTMLDivElement | null,
1243
+ zoomScale: number,
1244
+ ): { placement: "right" | "left" | "above" | "below"; style: CSSProperties } | null {
1245
+ if (!anchor || !root) {
1246
+ return null;
1247
+ }
1248
+
1249
+ const rootRect = root.getBoundingClientRect();
1250
+ if (rootRect.width <= 0 || rootRect.height <= 0 || zoomScale <= 0) {
1251
+ return null;
1252
+ }
1253
+
1254
+ const centerX = (anchor.left + anchor.right) / 2;
1255
+ const centerY = (anchor.top + anchor.bottom) / 2;
1256
+ const localLeftEdge = (anchor.left - rootRect.left) / zoomScale;
1257
+ const localRightEdge = (anchor.right - rootRect.left) / zoomScale;
1258
+ const localLeft = (centerX - rootRect.left) / zoomScale;
1259
+ const localCenterY = (centerY - rootRect.top) / zoomScale;
1260
+ const localTop = (anchor.top - rootRect.top) / zoomScale;
1261
+ const localBottom = (anchor.bottom - rootRect.top) / zoomScale;
1262
+ const edgePadding = 16 / zoomScale;
1263
+ const containerWidth = rootRect.width / zoomScale;
1264
+ const containerHeight = rootRect.height / zoomScale;
1265
+ const gapPx = 12 / zoomScale;
1266
+ const estimatedToolbarWidth = Math.min(260 / zoomScale, Math.max(168 / zoomScale, containerWidth * 0.32));
1267
+ const estimatedToolbarHeight = 44 / zoomScale;
1268
+ const clampedCenterLeft = Math.max(
1269
+ edgePadding,
1270
+ Math.min(localLeft, Math.max(edgePadding, containerWidth - edgePadding)),
1271
+ );
1272
+ const clampedCenterY = Math.max(
1273
+ edgePadding + estimatedToolbarHeight / 2,
1274
+ Math.min(localCenterY, Math.max(edgePadding + estimatedToolbarHeight / 2, containerHeight - edgePadding - estimatedToolbarHeight / 2)),
1275
+ );
1276
+ const rightClearance = containerWidth - localRightEdge - gapPx - edgePadding;
1277
+ const leftClearance = localLeftEdge - gapPx - edgePadding;
1278
+
1279
+ if (rightClearance >= estimatedToolbarWidth) {
1280
+ return {
1281
+ placement: "right",
1282
+ style: {
1283
+ left: `${localRightEdge}px`,
1284
+ top: `${clampedCenterY}px`,
1285
+ maxWidth: `${Math.max(220, containerWidth - edgePadding * 2)}px`,
1286
+ transform: `translate(${gapPx}px, -50%)`,
1287
+ },
1288
+ };
1289
+ }
1290
+
1291
+ if (leftClearance >= estimatedToolbarWidth) {
1292
+ return {
1293
+ placement: "left",
1294
+ style: {
1295
+ left: `${localLeftEdge}px`,
1296
+ top: `${clampedCenterY}px`,
1297
+ maxWidth: `${Math.max(220, containerWidth - edgePadding * 2)}px`,
1298
+ transform: `translate(calc(-100% - ${gapPx}px), -50%)`,
1299
+ },
1300
+ };
1301
+ }
1302
+
1303
+ const placement = localTop < estimatedToolbarHeight + gapPx + edgePadding ? "below" : "above";
1304
+
1305
+ return {
1306
+ placement,
1307
+ style: {
1308
+ left: `${clampedCenterLeft}px`,
1309
+ top: `${placement === "above" ? localTop : localBottom}px`,
1310
+ maxWidth: `${Math.max(220, containerWidth - edgePadding * 2)}px`,
1311
+ transform:
1312
+ placement === "above"
1313
+ ? `translate(-50%, calc(-100% - ${gapPx}px))`
1314
+ : `translate(-50%, ${gapPx}px)`,
1315
+ },
1316
+ };
1317
+ }
1318
+
1319
+ function toBorderCss(
1320
+ border:
1321
+ | NonNullable<NonNullable<RuntimeRenderSnapshot["pageLayout"]>["pageBorders"]>["top"]
1322
+ | undefined,
1323
+ ): string | undefined {
1324
+ if (!border || border.value === "none" || border.value === "nil") {
1325
+ return undefined;
1326
+ }
1327
+
1328
+ const width = border.size ? `${Math.max(1, Math.round(border.size / 8))}px` : "1px";
1329
+ const style =
1330
+ border.value === "double"
1331
+ ? "double"
1332
+ : border.value === "dotted"
1333
+ ? "dotted"
1334
+ : border.value === "dashed" || border.value === "dashSmallGap"
1335
+ ? "dashed"
1336
+ : "solid";
1337
+ const color = border.color && border.color !== "auto" ? `#${border.color}` : "rgba(31, 31, 31, 0.28)";
1338
+ return `${width} ${style} ${color}`;
1339
+ }