@beyondwork/docx-react-component 1.0.18 → 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/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 +374 -4
  5. package/src/api/session-state.ts +58 -0
  6. package/src/core/commands/formatting-commands.ts +1 -0
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +5 -1
  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 +329 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +1 -1
  16. package/src/index.ts +30 -0
  17. package/src/io/docx-session.ts +260 -39
  18. package/src/io/export/serialize-main-document.ts +202 -5
  19. package/src/io/export/serialize-numbering.ts +28 -7
  20. package/src/io/normalize/normalize-text.ts +63 -25
  21. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  22. package/src/io/ooxml/parse-footnotes.ts +212 -20
  23. package/src/io/ooxml/parse-headers-footers.ts +229 -25
  24. package/src/io/ooxml/parse-inline-media.ts +16 -0
  25. package/src/io/ooxml/parse-main-document.ts +411 -6
  26. package/src/io/ooxml/parse-numbering.ts +7 -0
  27. package/src/io/ooxml/parse-settings.ts +184 -0
  28. package/src/io/ooxml/parse-shapes.ts +25 -0
  29. package/src/io/ooxml/parse-styles.ts +463 -0
  30. package/src/io/ooxml/parse-theme.ts +32 -0
  31. package/src/model/canonical-document.ts +133 -3
  32. package/src/model/cds-1.0.0.ts +13 -0
  33. package/src/model/snapshot.ts +2 -1
  34. package/src/runtime/document-layout.ts +332 -0
  35. package/src/runtime/document-navigation.ts +564 -0
  36. package/src/runtime/document-runtime.ts +265 -35
  37. package/src/runtime/document-search.ts +145 -0
  38. package/src/runtime/numbering-prefix.ts +47 -26
  39. package/src/runtime/page-layout-estimation.ts +212 -0
  40. package/src/runtime/read-only-diagnostics-runtime.ts +1 -0
  41. package/src/runtime/session-capabilities.ts +2 -0
  42. package/src/runtime/story-context.ts +164 -0
  43. package/src/runtime/story-targeting.ts +162 -0
  44. package/src/runtime/surface-projection.ts +239 -12
  45. package/src/runtime/table-schema.ts +87 -5
  46. package/src/runtime/view-state.ts +459 -0
  47. package/src/ui/WordReviewEditor.tsx +1902 -312
  48. package/src/ui/browser-export.ts +52 -0
  49. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  50. package/src/ui/headless/selection-helpers.ts +20 -0
  51. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  52. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  53. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  54. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +125 -14
  55. package/src/ui-tailwind/editor-surface/perf-probe.ts +107 -0
  56. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +45 -6
  57. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  58. package/src/ui-tailwind/editor-surface/pm-position-map.ts +2 -2
  59. package/src/ui-tailwind/editor-surface/pm-schema.ts +47 -5
  60. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +95 -22
  61. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  62. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  63. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +394 -77
  64. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  65. package/src/ui-tailwind/index.ts +2 -1
  66. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  67. package/src/ui-tailwind/review/tw-review-rail.tsx +6 -6
  68. package/src/ui-tailwind/theme/editor-theme.css +123 -0
  69. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  70. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +291 -12
  71. package/src/ui-tailwind/tw-review-workspace.tsx +926 -27
  72. package/src/validation/compatibility-engine.ts +92 -20
  73. package/src/validation/diagnostics.ts +1 -0
  74. package/src/validation/docx-comment-proof.ts +487 -0
@@ -1,40 +1,89 @@
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,
7
21
  RuntimeRenderSnapshot,
22
+ StyleCatalogSnapshot,
23
+ SurfaceBlockSnapshot,
8
24
  TrackedChangeEntrySnapshot,
25
+ WorkspaceMode,
26
+ ZoomLevel,
9
27
  } from "../api/public-types";
28
+ import { findPageForOffset } from "../runtime/document-navigation.ts";
29
+ import {
30
+ DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
31
+ estimateBlockHeight,
32
+ estimateParagraphLineCount,
33
+ estimateParagraphLineHeight,
34
+ getUsableColumnWidth,
35
+ } from "../runtime/page-layout-estimation.ts";
10
36
  import type { SessionCapabilities } from "../runtime/session-capabilities";
37
+ import type {
38
+ SelectionToolbarAnchor,
39
+ SelectionToolbarModel,
40
+ } from "../ui/headless/selection-toolbar-model";
11
41
  import type { MarkupDisplay } from "../ui/headless/comment-decoration-model";
42
+ import { preserveEditorSelectionMouseDown } from "../ui/headless/preserve-editor-selection";
12
43
  import { TwAlertBanner } from "./chrome/tw-alert-banner";
44
+ import { TwPageRuler } from "./chrome/tw-page-ruler";
13
45
  import { TwSelectionToolbar } from "./chrome/tw-selection-toolbar";
14
46
  import { TwReviewRail, type ReviewRailTab } from "./review/tw-review-rail";
15
47
  import { TwStatusBar } from "./status/tw-status-bar";
16
- import { TwToolbar, type ViewMode } from "./toolbar/tw-toolbar";
48
+ import { TwToolbar } from "./toolbar/tw-toolbar";
17
49
 
18
50
  export interface TwReviewWorkspaceProps {
19
51
  snapshot: RuntimeRenderSnapshot;
52
+ viewState: EditorViewStateSnapshot;
20
53
  currentUserId?: string;
21
54
  capabilities?: SessionCapabilities;
22
55
  reviewMode?: "editing" | "review";
23
56
  document: ReactNode;
24
- viewMode: ViewMode;
57
+ workspaceMode: WorkspaceMode;
58
+ zoomLevel?: ZoomLevel;
59
+ formattingState?: FormattingStateSnapshot;
60
+ styleCatalog?: StyleCatalogSnapshot;
25
61
  activeRailTab: ReviewRailTab;
26
62
  activeCommentId?: string;
27
63
  activeRevisionId?: string;
28
64
  showTrackedChanges: boolean;
29
- selectionPreview?: string | null;
30
- addCommentDisabledReason?: string;
31
- onViewModeChange: (value: ViewMode) => void;
65
+ selectionToolbar?: SelectionToolbarModel | null;
66
+ selectionToolbarAnchor?: SelectionToolbarAnchor | null;
67
+ documentNavigation?: DocumentNavigationSnapshot;
68
+ onWorkspaceModeChange: (value: WorkspaceMode) => void;
69
+ onZoomChange?: (level: ZoomLevel) => void;
32
70
  onActiveRailTabChange: (value: ReviewRailTab) => void;
33
71
  onShowTrackedChangesChange: (show: boolean) => void;
34
72
  onUndo: () => void;
35
73
  onRedo: () => void;
74
+ onSetParagraphStyle?: (styleId: string) => void;
75
+ onToggleBold?: () => void;
76
+ onToggleItalic?: () => void;
77
+ onToggleUnderline?: () => void;
78
+ onOutdent?: () => void;
79
+ onIndent?: () => void;
36
80
  onAddComment: () => void;
81
+ onAddCommentFromSelection?: () => void;
37
82
  onExport: () => void;
83
+ onDismissSelectionToolbar?: () => void;
84
+ onSelectionToolbarFocusCapture?: FocusEventHandler<HTMLDivElement>;
85
+ onSelectionToolbarBlurCapture?: FocusEventHandler<HTMLDivElement>;
86
+ selectionToolbarRef?: Ref<HTMLDivElement>;
38
87
  onOpenComment: (thread: CommentSidebarThreadSnapshot) => void;
39
88
  onResolveComment: (commentId: string) => void;
40
89
  onReopenComment?: (commentId: string) => void;
@@ -45,17 +94,84 @@ export interface TwReviewWorkspaceProps {
45
94
  onRejectRevision: (revisionId: string) => void;
46
95
  onAcceptAllChanges: () => void;
47
96
  onRejectAllChanges: () => void;
97
+ onCloseStory?: () => void;
98
+ onOpenHeaderStory?: () => void;
99
+ onOpenFooterStory?: () => void;
100
+ onSetParagraphIndentation?: (indentation: {
101
+ left?: number;
102
+ right?: number;
103
+ firstLine?: number;
104
+ hanging?: number;
105
+ }) => void;
106
+ onSetParagraphTabStops?: (tabStops: Array<{ pos: number; val?: string; leader?: string }>) => void;
107
+ onRestartNumbering?: () => void;
108
+ onContinueNumbering?: () => void;
109
+ onNavigateHeading?: (headingId: string) => void;
48
110
  }
49
111
 
50
112
  export function TwReviewWorkspace(props: TwReviewWorkspaceProps) {
51
- const { snapshot } = props;
113
+ const { snapshot, viewState } = props;
114
+ const selectionToolbarRootRef = useRef<HTMLDivElement>(null);
52
115
  const caps = props.capabilities;
53
- const markupDisplay: MarkupDisplay = props.viewMode === "document" ? "all" : "clean";
116
+ const isPageWorkspace = props.workspaceMode === "page";
117
+ const markupDisplay: MarkupDisplay = isPageWorkspace ? "all" : "clean";
118
+ const [navOpen, setNavOpen] = useState(false);
119
+ const [layoutToolsOpen, setLayoutToolsOpen] = useState(false);
120
+ const zoomLevel = props.zoomLevel ?? 100;
121
+ const zoomScale = typeof zoomLevel === "number" ? zoomLevel / 100 : 1;
54
122
  const preserveOnlyCount = caps?.preserveOnlyCount ??
55
123
  snapshot.compatibility.featureEntries.filter(
56
124
  (entry) => entry.featureClass === "preserve-only",
57
125
  ).length;
58
126
  const showReviewRail = caps?.reviewRailVisible ?? true;
127
+ const headings = props.documentNavigation?.headings ?? [];
128
+ const selectionPosition =
129
+ viewState.selection.activeRange.kind === "node"
130
+ ? viewState.selection.activeRange.at
131
+ : viewState.selection.head;
132
+ const activeParagraphLayout = resolveActiveParagraphLayout(snapshot.surface, selectionPosition);
133
+ const pageChromeModel = useMemo(
134
+ () =>
135
+ buildPageChromeModel(
136
+ snapshot.surface,
137
+ snapshot.pageLayout,
138
+ props.documentNavigation,
139
+ snapshot.activeStory,
140
+ ),
141
+ [props.documentNavigation, snapshot.activeStory, snapshot.pageLayout, snapshot.surface],
142
+ );
143
+ const selectionToolbarPlacement = resolveSelectionToolbarPlacement(
144
+ props.selectionToolbarAnchor,
145
+ selectionToolbarRootRef.current,
146
+ zoomScale,
147
+ );
148
+ const activePage = props.documentNavigation?.pages[props.documentNavigation.activePageIndex] ?? null;
149
+ const pageShellMetrics = useMemo(
150
+ () => buildPageShellMetrics(snapshot.pageLayout),
151
+ [snapshot.pageLayout],
152
+ );
153
+ const hidePageBorderForActiveEditing =
154
+ isPageWorkspace &&
155
+ snapshot.activeStory.kind === "main" &&
156
+ shouldHidePageBorderForSelection(viewState.selection);
157
+
158
+ useEffect(() => {
159
+ if (isPageWorkspace && snapshot.activeStory.kind !== "main") {
160
+ setLayoutToolsOpen(true);
161
+ }
162
+ }, [isPageWorkspace, snapshot.activeStory.kind]);
163
+
164
+ const dismissSelectionToolbar = useCallback(() => {
165
+ props.onDismissSelectionToolbar?.();
166
+ }, [props.onDismissSelectionToolbar]);
167
+
168
+ const runWithSelectionToolbarDismiss = useCallback(
169
+ (action?: () => void) => () => {
170
+ dismissSelectionToolbar();
171
+ action?.();
172
+ },
173
+ [dismissSelectionToolbar],
174
+ );
59
175
 
60
176
  return (
61
177
  <Tooltip.Provider delayDuration={400}>
@@ -65,41 +181,366 @@ export function TwReviewWorkspace(props: TwReviewWorkspaceProps) {
65
181
  capabilities={caps}
66
182
  compatibility={snapshot.compatibility}
67
183
  warnings={snapshot.warnings}
68
- viewMode={props.viewMode}
184
+ workspaceMode={props.workspaceMode}
185
+ zoomLevel={props.zoomLevel}
186
+ formattingState={props.formattingState}
187
+ styleCatalog={props.styleCatalog}
69
188
  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}
189
+ onUndo={runWithSelectionToolbarDismiss(props.onUndo)}
190
+ onRedo={runWithSelectionToolbarDismiss(props.onRedo)}
191
+ onSetParagraphStyle={props.onSetParagraphStyle
192
+ ? (styleId) => {
193
+ dismissSelectionToolbar();
194
+ props.onSetParagraphStyle?.(styleId);
195
+ }
196
+ : undefined}
197
+ onToggleBold={runWithSelectionToolbarDismiss(props.onToggleBold)}
198
+ onToggleItalic={runWithSelectionToolbarDismiss(props.onToggleItalic)}
199
+ onToggleUnderline={runWithSelectionToolbarDismiss(props.onToggleUnderline)}
200
+ onOutdent={runWithSelectionToolbarDismiss(props.onOutdent)}
201
+ onIndent={runWithSelectionToolbarDismiss(props.onIndent)}
202
+ onAddComment={runWithSelectionToolbarDismiss(props.onAddComment)}
203
+ onExport={runWithSelectionToolbarDismiss(props.onExport)}
204
+ activeStory={snapshot.activeStory}
205
+ onCloseStory={props.onCloseStory
206
+ ? runWithSelectionToolbarDismiss(props.onCloseStory)
207
+ : undefined}
208
+ onWorkspaceModeChange={(value) => {
209
+ dismissSelectionToolbar();
210
+ props.onWorkspaceModeChange(value);
211
+ }}
212
+ onZoomChange={props.onZoomChange
213
+ ? (level) => {
214
+ dismissSelectionToolbar();
215
+ props.onZoomChange?.(level);
216
+ }
217
+ : undefined}
218
+ onShowTrackedChangesChange={(show) => {
219
+ dismissSelectionToolbar();
220
+ props.onShowTrackedChangesChange(show);
221
+ }}
76
222
  />
77
223
 
78
224
  <TwAlertBanner snapshot={snapshot} preserveOnlyCount={preserveOnlyCount} />
79
225
 
80
226
  <div className="flex flex-1 min-h-0">
227
+ {/* Collapsible document navigator — page mode only */}
228
+ {isPageWorkspace ? (
229
+ <aside
230
+ aria-label="Document navigator"
231
+ className={`shrink-0 border-r border-border bg-surface transition-[width] duration-200 ${
232
+ navOpen ? "w-48" : "w-0"
233
+ } overflow-hidden`}
234
+ >
235
+ {navOpen ? (
236
+ <div className="flex h-full flex-col">
237
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border">
238
+ <span className="text-xs font-medium text-secondary uppercase tracking-wider">Navigator</span>
239
+ <Tooltip.Root>
240
+ <Tooltip.Trigger asChild>
241
+ <button
242
+ type="button"
243
+ aria-label="Collapse navigator"
244
+ onMouseDown={preserveEditorSelectionMouseDown}
245
+ onClick={() => {
246
+ dismissSelectionToolbar();
247
+ setNavOpen(false);
248
+ }}
249
+ className="inline-flex h-6 w-6 items-center justify-center rounded-md text-secondary hover:bg-surface-hover transition-colors"
250
+ >
251
+ <ChevronLeft className="h-3.5 w-3.5" />
252
+ </button>
253
+ </Tooltip.Trigger>
254
+ <Tooltip.Portal>
255
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
256
+ Collapse navigator
257
+ </Tooltip.Content>
258
+ </Tooltip.Portal>
259
+ </Tooltip.Root>
260
+ </div>
261
+ <nav className="flex-1 overflow-y-auto px-2 py-2" aria-label="Document headings">
262
+ {headings.length > 0 ? (
263
+ <ul className="space-y-0.5">
264
+ {headings.map((entry) => (
265
+ <li key={entry.headingId}>
266
+ <button
267
+ type="button"
268
+ className="block w-full truncate rounded-md px-2 py-1 text-left text-xs text-primary hover:bg-surface-hover"
269
+ style={{ paddingLeft: `${8 + (entry.level - 1) * 12}px` }}
270
+ onMouseDown={preserveEditorSelectionMouseDown}
271
+ onClick={() => {
272
+ dismissSelectionToolbar();
273
+ props.onNavigateHeading?.(entry.headingId);
274
+ setNavOpen(false);
275
+ }}
276
+ >
277
+ {entry.text}
278
+ </button>
279
+ </li>
280
+ ))}
281
+ </ul>
282
+ ) : (
283
+ <p className="px-2 py-4 text-xs text-tertiary">No headings found.</p>
284
+ )}
285
+ </nav>
286
+ </div>
287
+ ) : null}
288
+ </aside>
289
+ ) : null}
290
+
291
+ {/* Navigator expand toggle — page mode only when collapsed */}
292
+ {isPageWorkspace && !navOpen ? (
293
+ <div className="shrink-0 flex items-start pt-2 pl-1">
294
+ <Tooltip.Root>
295
+ <Tooltip.Trigger asChild>
296
+ <button
297
+ type="button"
298
+ aria-label="Open document navigator"
299
+ onMouseDown={preserveEditorSelectionMouseDown}
300
+ onClick={() => {
301
+ dismissSelectionToolbar();
302
+ setNavOpen(true);
303
+ }}
304
+ className="inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary hover:bg-surface-hover transition-colors"
305
+ >
306
+ <List className="h-3.5 w-3.5" />
307
+ </button>
308
+ </Tooltip.Trigger>
309
+ <Tooltip.Portal>
310
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
311
+ Open document navigator
312
+ </Tooltip.Content>
313
+ </Tooltip.Portal>
314
+ </Tooltip.Root>
315
+ </div>
316
+ ) : null}
317
+
81
318
  {/* Document column */}
82
319
  <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"}`}>
320
+ <div
321
+ className={`flex-1 overflow-y-auto ${isPageWorkspace ? "bg-surface" : "bg-canvas"}`}
322
+ data-wre-scroll-root="true"
323
+ >
84
324
  <div
325
+ ref={selectionToolbarRootRef}
85
326
  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"
327
+ isPageWorkspace
328
+ ? "wre-page-chrome wre-page-surface relative max-w-[840px] my-8 overflow-hidden"
329
+ : "wre-canvas-surface relative bg-canvas"
89
330
  }`}
331
+ style={isPageWorkspace && zoomScale !== 1 ? { transform: `scale(${zoomScale})`, transformOrigin: "top center" } : undefined}
90
332
  >
91
- {props.selectionPreview ? (
92
- <div className="flex justify-center pt-4 px-4">
93
- <TwSelectionToolbar
94
- selectionPreview={props.selectionPreview}
333
+ {isPageWorkspace && snapshot.pageLayout ? (
334
+ <div className="border-b border-border/70 bg-surface/65 px-5 py-3" data-testid="page-context-summary">
335
+ <div className="flex flex-wrap items-center justify-between gap-2">
336
+ <div className="flex flex-wrap items-center gap-2 text-xs text-secondary">
337
+ <span className="rounded-full bg-canvas px-2 py-1 font-medium text-primary">
338
+ {activePage
339
+ ? `Page ${activePage.pageIndex + 1} of ${props.documentNavigation?.pageCount ?? 1}`
340
+ : "Page workspace"}
341
+ </span>
342
+ <span>{`Section ${snapshot.pageLayout.sectionIndex + 1}`}</span>
343
+ <span className="uppercase tracking-[0.12em] text-tertiary">
344
+ {snapshot.pageLayout.orientation}
345
+ </span>
346
+ </div>
347
+ <div className="flex items-center gap-2">
348
+ {snapshot.activeStory.kind !== "main" ? (
349
+ <button
350
+ type="button"
351
+ aria-label="Return to document body"
352
+ onMouseDown={preserveEditorSelectionMouseDown}
353
+ onClick={runWithSelectionToolbarDismiss(props.onCloseStory)}
354
+ 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"
355
+ >
356
+ Body
357
+ </button>
358
+ ) : null}
359
+ <button
360
+ type="button"
361
+ aria-label="Toggle layout tools"
362
+ aria-expanded={layoutToolsOpen}
363
+ onMouseDown={preserveEditorSelectionMouseDown}
364
+ onClick={() => {
365
+ dismissSelectionToolbar();
366
+ setLayoutToolsOpen((open) => !open);
367
+ }}
368
+ 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"
369
+ >
370
+ <ChevronRight className={`h-3.5 w-3.5 transition-transform ${layoutToolsOpen ? "rotate-90" : ""}`} />
371
+ Layout tools
372
+ </button>
373
+ </div>
374
+ </div>
375
+ </div>
376
+ ) : null}
377
+ {isPageWorkspace && snapshot.pageLayout && layoutToolsOpen ? (
378
+ <div className="px-5 pt-3">
379
+ <TwPageRuler
380
+ pageLayout={snapshot.pageLayout}
381
+ viewState={viewState}
382
+ paragraphLayout={activeParagraphLayout}
95
383
  readOnly={snapshot.readOnly}
96
- canAddComment={props.capabilities?.canAddComment}
97
- disabledReason={props.addCommentDisabledReason}
98
- onAddComment={props.onAddComment}
384
+ onReturnToBody={props.onCloseStory
385
+ ? runWithSelectionToolbarDismiss(props.onCloseStory)
386
+ : () => undefined}
387
+ onOpenHeader={props.onOpenHeaderStory
388
+ ? runWithSelectionToolbarDismiss(props.onOpenHeaderStory)
389
+ : undefined}
390
+ onOpenFooter={props.onOpenFooterStory
391
+ ? runWithSelectionToolbarDismiss(props.onOpenFooterStory)
392
+ : undefined}
393
+ onSetIndentation={props.onSetParagraphIndentation
394
+ ? (indentation) => {
395
+ dismissSelectionToolbar();
396
+ props.onSetParagraphIndentation?.(indentation);
397
+ }
398
+ : undefined}
399
+ onSetTabStops={props.onSetParagraphTabStops
400
+ ? (tabStops) => {
401
+ dismissSelectionToolbar();
402
+ props.onSetParagraphTabStops?.(tabStops);
403
+ }
404
+ : undefined}
405
+ onRestartNumbering={props.onRestartNumbering
406
+ ? runWithSelectionToolbarDismiss(props.onRestartNumbering)
407
+ : undefined}
408
+ onContinueNumbering={props.onContinueNumbering
409
+ ? runWithSelectionToolbarDismiss(props.onContinueNumbering)
410
+ : undefined}
99
411
  />
100
412
  </div>
101
413
  ) : null}
102
- {props.document}
414
+ {props.selectionToolbar && selectionToolbarPlacement ? (
415
+ <div className="pointer-events-none absolute inset-0 z-20" data-testid="selection-toolbar-overlay">
416
+ <div
417
+ className="pointer-events-auto absolute"
418
+ data-placement={selectionToolbarPlacement.placement}
419
+ style={selectionToolbarPlacement.style}
420
+ >
421
+ <TwSelectionToolbar
422
+ ref={props.selectionToolbarRef}
423
+ model={props.selectionToolbar}
424
+ disabledReason={props.selectionToolbar.disabledReason}
425
+ onFocusCapture={props.onSelectionToolbarFocusCapture}
426
+ onBlurCapture={props.onSelectionToolbarBlurCapture}
427
+ onToggleBold={props.onToggleBold}
428
+ onToggleItalic={props.onToggleItalic}
429
+ onToggleUnderline={props.onToggleUnderline}
430
+ onAddComment={props.onAddCommentFromSelection ?? props.onAddComment}
431
+ />
432
+ </div>
433
+ </div>
434
+ ) : null}
435
+ {props.selectionToolbar && !selectionToolbarPlacement ? (
436
+ <div
437
+ className="pointer-events-none absolute inset-x-0 top-0 z-20 flex justify-center px-4 pt-3"
438
+ data-testid="selection-toolbar-fallback"
439
+ >
440
+ <div className="pointer-events-auto" data-placement="fallback">
441
+ <TwSelectionToolbar
442
+ ref={props.selectionToolbarRef}
443
+ model={props.selectionToolbar}
444
+ disabledReason={props.selectionToolbar.disabledReason}
445
+ onFocusCapture={props.onSelectionToolbarFocusCapture}
446
+ onBlurCapture={props.onSelectionToolbarBlurCapture}
447
+ onToggleBold={props.onToggleBold}
448
+ onToggleItalic={props.onToggleItalic}
449
+ onToggleUnderline={props.onToggleUnderline}
450
+ onAddComment={props.onAddCommentFromSelection ?? props.onAddComment}
451
+ />
452
+ </div>
453
+ </div>
454
+ ) : null}
455
+ <div
456
+ className={isPageWorkspace ? "relative" : undefined}
457
+ data-line-numbering={pageChromeModel.lineNumberingEnabled ? "enabled" : "disabled"}
458
+ >
459
+ {isPageWorkspace && pageChromeModel.lineNumberingEnabled ? (
460
+ <div
461
+ aria-hidden="true"
462
+ className="pointer-events-none absolute inset-y-0 left-0 z-10"
463
+ data-testid="page-line-number-gutter"
464
+ style={{ width: `${pageChromeModel.gutterWidthPx}px` }}
465
+ >
466
+ {pageChromeModel.lineMarkers.map((marker) => (
467
+ <span
468
+ key={marker.id}
469
+ className="absolute right-2 font-[family-name:var(--font-legal-sans)] text-[10px] font-medium tabular-nums tracking-[0.12em] text-tertiary/80"
470
+ style={{ top: `${marker.topPx}px` }}
471
+ >
472
+ {marker.label}
473
+ </span>
474
+ ))}
475
+ </div>
476
+ ) : null}
477
+ <div
478
+ className={isPageWorkspace && pageChromeModel.lineNumberingEnabled ? "pl-12" : undefined}
479
+ style={isPageWorkspace ? pageShellMetrics.contentInsetStyle : undefined}
480
+ >
481
+ <div
482
+ className={isPageWorkspace ? "relative" : undefined}
483
+ data-document-grid={pageChromeModel.documentGridType}
484
+ data-page-border-display={pageChromeModel.pageBorderDisplay}
485
+ style={isPageWorkspace
486
+ ? {
487
+ ...pageChromeModel.documentGridStyle,
488
+ ...pageShellMetrics.pageFrameStyle,
489
+ }
490
+ : pageChromeModel.documentGridStyle}
491
+ >
492
+ {isPageWorkspace ? (
493
+ <div
494
+ data-testid="page-header-band"
495
+ className="relative z-10 flex items-center justify-between border-b border-dashed border-border/60 px-4 text-[11px] text-secondary"
496
+ style={pageShellMetrics.headerBandStyle}
497
+ >
498
+ <span className="uppercase tracking-[0.12em] text-tertiary">Header</span>
499
+ {snapshot.pageLayout?.headerVariants[0] ? (
500
+ <button
501
+ type="button"
502
+ aria-label="Open header story"
503
+ onClick={props.onOpenHeaderStory}
504
+ className="rounded-md px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface"
505
+ >
506
+ Edit header
507
+ </button>
508
+ ) : null}
509
+ </div>
510
+ ) : null}
511
+ {isPageWorkspace && pageChromeModel.showPageBorder && !hidePageBorderForActiveEditing ? (
512
+ <div
513
+ aria-hidden="true"
514
+ className="pointer-events-none absolute inset-0 z-0 rounded-[2px]"
515
+ data-testid="page-border-overlay"
516
+ style={pageChromeModel.pageBorderStyle}
517
+ />
518
+ ) : null}
519
+ <div className={isPageWorkspace ? "relative z-10" : undefined}>
520
+ {props.document}
521
+ </div>
522
+ {isPageWorkspace ? (
523
+ <div
524
+ data-testid="page-footer-band"
525
+ className="relative z-10 flex items-center justify-between border-t border-dashed border-border/60 px-4 text-[11px] text-secondary"
526
+ style={pageShellMetrics.footerBandStyle}
527
+ >
528
+ <span className="uppercase tracking-[0.12em] text-tertiary">Footer</span>
529
+ {snapshot.pageLayout?.footerVariants[0] ? (
530
+ <button
531
+ type="button"
532
+ aria-label="Open footer story"
533
+ onClick={props.onOpenFooterStory}
534
+ className="rounded-md px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface"
535
+ >
536
+ Edit footer
537
+ </button>
538
+ ) : null}
539
+ </div>
540
+ ) : null}
541
+ </div>
542
+ </div>
543
+ </div>
103
544
  </div>
104
545
  </div>
105
546
 
@@ -141,3 +582,461 @@ export function TwReviewWorkspace(props: TwReviewWorkspaceProps) {
141
582
  </Tooltip.Provider>
142
583
  );
143
584
  }
585
+
586
+ function shouldHidePageBorderForSelection(
587
+ selection: EditorViewStateSnapshot["selection"],
588
+ ): boolean {
589
+ if (selection.isCollapsed) {
590
+ return false;
591
+ }
592
+
593
+ return selection.activeRange.kind === "range";
594
+ }
595
+
596
+ function resolveActiveParagraphLayout(
597
+ surface: RuntimeRenderSnapshot["surface"],
598
+ position: number,
599
+ ): {
600
+ leftIndent: number;
601
+ rightIndent: number;
602
+ firstLineOffset: number;
603
+ tabStops: Array<{ pos: number; val?: string; leader?: string }>;
604
+ } | null {
605
+ const paragraph = surface ? findActiveParagraph(surface.blocks, position) : null;
606
+ if (!paragraph) {
607
+ return null;
608
+ }
609
+
610
+ return {
611
+ leftIndent: paragraph.indentation?.left ?? 0,
612
+ rightIndent: paragraph.indentation?.right ?? 0,
613
+ firstLineOffset:
614
+ paragraph.indentation?.firstLine ??
615
+ (paragraph.indentation?.hanging ? -paragraph.indentation.hanging : 0),
616
+ tabStops: paragraph.tabStops ? [...paragraph.tabStops] : [],
617
+ };
618
+ }
619
+
620
+ function findActiveParagraph(
621
+ blocks: readonly SurfaceBlockSnapshot[],
622
+ position: number,
623
+ ): Extract<SurfaceBlockSnapshot, { kind: "paragraph" }> | null {
624
+ for (const block of blocks) {
625
+ if (block.kind === "paragraph" && position >= block.from && position <= block.to) {
626
+ return block;
627
+ }
628
+ if (block.kind === "table") {
629
+ for (const row of block.rows) {
630
+ for (const cell of row.cells) {
631
+ const paragraph = findActiveParagraph(cell.content, position);
632
+ if (paragraph) {
633
+ return paragraph;
634
+ }
635
+ }
636
+ }
637
+ }
638
+ if (block.kind === "sdt_block") {
639
+ const paragraph = findActiveParagraph(block.children, position);
640
+ if (paragraph) {
641
+ return paragraph;
642
+ }
643
+ }
644
+ }
645
+ return null;
646
+ }
647
+
648
+ interface PageChromeModel {
649
+ lineNumberingEnabled: boolean;
650
+ gutterWidthPx: number;
651
+ lineMarkers: Array<{ id: string; label: string; topPx: number }>;
652
+ showPageBorder: boolean;
653
+ pageBorderDisplay: string;
654
+ pageBorderStyle: CSSProperties | undefined;
655
+ documentGridType: string;
656
+ documentGridStyle: CSSProperties | undefined;
657
+ }
658
+
659
+ const EMPTY_PAGE_CHROME_MODEL: PageChromeModel = {
660
+ lineNumberingEnabled: false,
661
+ gutterWidthPx: 0,
662
+ lineMarkers: [],
663
+ showPageBorder: false,
664
+ pageBorderDisplay: "none",
665
+ pageBorderStyle: undefined,
666
+ documentGridType: "none",
667
+ documentGridStyle: undefined,
668
+ };
669
+
670
+ const DOCUMENT_CONTENT_TOP_PADDING_PX = 40;
671
+
672
+ interface PageShellMetrics {
673
+ contentInsetStyle: CSSProperties;
674
+ pageFrameStyle: CSSProperties;
675
+ headerBandStyle: CSSProperties;
676
+ footerBandStyle: CSSProperties;
677
+ }
678
+
679
+ function buildPageChromeModel(
680
+ surface: RuntimeRenderSnapshot["surface"] | undefined,
681
+ pageLayout: RuntimeRenderSnapshot["pageLayout"] | undefined,
682
+ navigation: DocumentNavigationSnapshot | undefined,
683
+ activeStory: RuntimeRenderSnapshot["activeStory"],
684
+ ): PageChromeModel {
685
+ if (!surface || !pageLayout || !navigation || activeStory.kind !== "main") {
686
+ return EMPTY_PAGE_CHROME_MODEL;
687
+ }
688
+
689
+ const lineMarkers = buildLineNumberMarkers(surface.blocks, navigation.pages);
690
+ const lineNumberingEnabled =
691
+ Boolean(pageLayout.lineNumbering) && lineMarkers.length > 0;
692
+ const distance = pageLayout.lineNumbering?.distance ?? 0;
693
+ const gutterWidthPx = lineNumberingEnabled
694
+ ? Math.max(40, Math.min(88, 24 + Math.round(distance * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP)))
695
+ : 0;
696
+ const showPageBorder = shouldRenderPageBorder(pageLayout, navigation.pages, navigation.activePageIndex);
697
+
698
+ return {
699
+ lineNumberingEnabled,
700
+ gutterWidthPx,
701
+ lineMarkers,
702
+ showPageBorder,
703
+ pageBorderDisplay: pageLayout.pageBorders?.display ?? "none",
704
+ pageBorderStyle: showPageBorder ? buildPageBorderStyle(pageLayout) : undefined,
705
+ documentGridType: pageLayout.documentGrid?.type ?? "none",
706
+ documentGridStyle: buildDocumentGridStyle(pageLayout.documentGrid),
707
+ };
708
+ }
709
+
710
+ function buildPageShellMetrics(
711
+ pageLayout: RuntimeRenderSnapshot["pageLayout"] | undefined,
712
+ ): PageShellMetrics {
713
+ if (!pageLayout) {
714
+ return {
715
+ contentInsetStyle: {},
716
+ pageFrameStyle: {},
717
+ headerBandStyle: {},
718
+ footerBandStyle: {},
719
+ };
720
+ }
721
+
722
+ const horizontalInsetPx = Math.max(
723
+ 24,
724
+ Math.min(120, Math.round(pageLayout.marginLeft * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP)),
725
+ );
726
+ const verticalInsetPx = Math.max(
727
+ 24,
728
+ Math.min(140, Math.round(pageLayout.marginTop * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP)),
729
+ );
730
+ const headerBandHeightPx = Math.max(
731
+ 40,
732
+ Math.min(96, Math.round(pageLayout.headerMargin * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP + 16)),
733
+ );
734
+ const footerBandHeightPx = Math.max(
735
+ 40,
736
+ Math.min(96, Math.round(pageLayout.footerMargin * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP + 16)),
737
+ );
738
+
739
+ return {
740
+ contentInsetStyle: {
741
+ paddingLeft: `${horizontalInsetPx}px`,
742
+ paddingRight: `${horizontalInsetPx}px`,
743
+ paddingTop: `${Math.max(20, verticalInsetPx - 12)}px`,
744
+ paddingBottom: `${Math.max(20, Math.round(pageLayout.marginBottom * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP) - 12)}px`,
745
+ },
746
+ pageFrameStyle: {
747
+ backgroundColor: "var(--color-page-bg)",
748
+ },
749
+ headerBandStyle: {
750
+ minHeight: `${headerBandHeightPx}px`,
751
+ },
752
+ footerBandStyle: {
753
+ minHeight: `${footerBandHeightPx}px`,
754
+ },
755
+ };
756
+ }
757
+
758
+ function buildLineNumberMarkers(
759
+ blocks: readonly SurfaceBlockSnapshot[],
760
+ pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,
761
+ ): Array<{ id: string; label: string; topPx: number }> {
762
+ const markers: Array<{ id: string; label: string; topPx: number }> = [];
763
+ if (pages.length === 0) {
764
+ return markers;
765
+ }
766
+
767
+ let currentTopTwips = 0;
768
+ let lineNumber = 1;
769
+ let lastPageIndex = -1;
770
+ let lastSectionIndex = -1;
771
+
772
+ for (const block of blocks) {
773
+ const pageIndex = findPageForOffset(pages, block.from);
774
+ const page = pages[pageIndex];
775
+ if (!page) {
776
+ continue;
777
+ }
778
+
779
+ const lineNumbering = page.layout.lineNumbering;
780
+ const restartMode = lineNumbering?.restart ?? "newPage";
781
+ const restartStart = lineNumbering?.start ?? 1;
782
+ const countBy = Math.max(1, lineNumbering?.countBy ?? 1);
783
+ const columnWidth = getUsableColumnWidth(page.layout);
784
+
785
+ if (pageIndex !== lastPageIndex) {
786
+ if (restartMode === "newPage" || lastPageIndex === -1) {
787
+ lineNumber = restartStart;
788
+ }
789
+ lastPageIndex = pageIndex;
790
+ }
791
+ if (page.sectionIndex !== lastSectionIndex) {
792
+ if (restartMode === "newSection" || lastSectionIndex === -1) {
793
+ lineNumber = restartStart;
794
+ }
795
+ lastSectionIndex = page.sectionIndex;
796
+ }
797
+
798
+ if (block.kind === "paragraph" && lineNumbering) {
799
+ const lineCount = estimateParagraphLineCount(block, columnWidth);
800
+ const lineHeight = estimateParagraphLineHeight(block);
801
+ const suppress = block.suppressLineNumbers === true;
802
+ for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) {
803
+ if (!suppress && (lineNumber - restartStart) % countBy === 0) {
804
+ markers.push({
805
+ id: `${block.blockId}-${lineIndex}`,
806
+ label: String(lineNumber),
807
+ topPx:
808
+ DOCUMENT_CONTENT_TOP_PADDING_PX +
809
+ (currentTopTwips + lineIndex * lineHeight) * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
810
+ });
811
+ }
812
+ if (!suppress) {
813
+ lineNumber += 1;
814
+ }
815
+ }
816
+ }
817
+
818
+ currentTopTwips += estimateBlockHeight(block, columnWidth);
819
+ }
820
+
821
+ return markers;
822
+ }
823
+
824
+ function shouldRenderPageBorder(
825
+ pageLayout: RuntimeRenderSnapshot["pageLayout"],
826
+ pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,
827
+ activePageIndex: number,
828
+ ): boolean {
829
+ const display = pageLayout?.pageBorders?.display ?? "allPages";
830
+ const activePage = pages[activePageIndex];
831
+ if (!pageLayout?.pageBorders || !activePage) {
832
+ return false;
833
+ }
834
+
835
+ switch (display) {
836
+ case "firstPage":
837
+ return activePage.pageInSection === 0;
838
+ case "notFirstPage":
839
+ return activePage.pageInSection > 0;
840
+ default:
841
+ return true;
842
+ }
843
+ }
844
+
845
+ function buildPageBorderStyle(
846
+ pageLayout: NonNullable<RuntimeRenderSnapshot["pageLayout"]>,
847
+ ): CSSProperties | undefined {
848
+ const pageBorders = pageLayout.pageBorders;
849
+ if (!pageBorders) {
850
+ return undefined;
851
+ }
852
+
853
+ const leftInset = createInsetValue(
854
+ pageBorders.left?.space,
855
+ pageBorders.offsetFrom === "text"
856
+ ? (pageLayout.marginLeft / Math.max(1, pageLayout.pageWidth)) * 100
857
+ : 1.25,
858
+ );
859
+ const rightInset = createInsetValue(
860
+ pageBorders.right?.space,
861
+ pageBorders.offsetFrom === "text"
862
+ ? (pageLayout.marginRight / Math.max(1, pageLayout.pageWidth)) * 100
863
+ : 1.25,
864
+ );
865
+ const topInset = createInsetValue(
866
+ pageBorders.top?.space,
867
+ pageBorders.offsetFrom === "text"
868
+ ? (pageLayout.marginTop / Math.max(1, pageLayout.pageHeight)) * 100
869
+ : 1.5,
870
+ );
871
+ const bottomInset = createInsetValue(
872
+ pageBorders.bottom?.space,
873
+ pageBorders.offsetFrom === "text"
874
+ ? (pageLayout.marginBottom / Math.max(1, pageLayout.pageHeight)) * 100
875
+ : 1.5,
876
+ );
877
+
878
+ return {
879
+ top: topInset,
880
+ right: rightInset,
881
+ bottom: bottomInset,
882
+ left: leftInset,
883
+ borderTop: toBorderCss(pageBorders.top),
884
+ borderRight: toBorderCss(pageBorders.right),
885
+ borderBottom: toBorderCss(pageBorders.bottom),
886
+ borderLeft: toBorderCss(pageBorders.left),
887
+ boxSizing: "border-box",
888
+ mixBlendMode: pageBorders.zOrder === "back" ? "multiply" : undefined,
889
+ };
890
+ }
891
+
892
+ function buildDocumentGridStyle(
893
+ documentGrid: NonNullable<RuntimeRenderSnapshot["pageLayout"]>["documentGrid"] | undefined,
894
+ ): CSSProperties | undefined {
895
+ if (!documentGrid || !documentGrid.type || documentGrid.type === "default") {
896
+ return undefined;
897
+ }
898
+
899
+ const linePitchPx = Math.max(
900
+ 18,
901
+ Math.round((documentGrid.linePitch ?? 360) * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP),
902
+ );
903
+ const charSpacePx = Math.max(
904
+ 12,
905
+ Math.round((documentGrid.charSpace ?? 204) * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP),
906
+ );
907
+ const gridColor = "rgba(15, 23, 42, 0.06)";
908
+ const backgrounds: string[] = [];
909
+
910
+ if (
911
+ documentGrid.type === "lines" ||
912
+ documentGrid.type === "linesAndChars" ||
913
+ documentGrid.type === "snapToChars"
914
+ ) {
915
+ backgrounds.push(
916
+ `repeating-linear-gradient(to bottom, ${gridColor} 0, ${gridColor} 1px, transparent 1px, transparent ${linePitchPx}px)`,
917
+ );
918
+ }
919
+ if (
920
+ documentGrid.type === "linesAndChars" ||
921
+ documentGrid.type === "snapToChars"
922
+ ) {
923
+ backgrounds.push(
924
+ `repeating-linear-gradient(to right, rgba(15, 23, 42, 0.04) 0, rgba(15, 23, 42, 0.04) 1px, transparent 1px, transparent ${charSpacePx}px)`,
925
+ );
926
+ }
927
+
928
+ if (backgrounds.length === 0) {
929
+ return undefined;
930
+ }
931
+
932
+ return {
933
+ backgroundImage: backgrounds.join(", "),
934
+ backgroundOrigin: "content-box",
935
+ };
936
+ }
937
+
938
+ function createInsetValue(spaceTwips: number | undefined, percent: number): string {
939
+ const spacingPx = Math.max(0, Math.round((spaceTwips ?? 0) * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP));
940
+ return `calc(${percent.toFixed(2)}% + ${spacingPx}px)`;
941
+ }
942
+
943
+ function resolveSelectionToolbarPlacement(
944
+ anchor: SelectionToolbarAnchor | null | undefined,
945
+ root: HTMLDivElement | null,
946
+ zoomScale: number,
947
+ ): { placement: "right" | "left" | "above" | "below"; style: CSSProperties } | null {
948
+ if (!anchor || !root) {
949
+ return null;
950
+ }
951
+
952
+ const rootRect = root.getBoundingClientRect();
953
+ if (rootRect.width <= 0 || rootRect.height <= 0 || zoomScale <= 0) {
954
+ return null;
955
+ }
956
+
957
+ const centerX = (anchor.left + anchor.right) / 2;
958
+ const centerY = (anchor.top + anchor.bottom) / 2;
959
+ const localLeftEdge = (anchor.left - rootRect.left) / zoomScale;
960
+ const localRightEdge = (anchor.right - rootRect.left) / zoomScale;
961
+ const localLeft = (centerX - rootRect.left) / zoomScale;
962
+ const localCenterY = (centerY - rootRect.top) / zoomScale;
963
+ const localTop = (anchor.top - rootRect.top) / zoomScale;
964
+ const localBottom = (anchor.bottom - rootRect.top) / zoomScale;
965
+ const edgePadding = 16 / zoomScale;
966
+ const containerWidth = rootRect.width / zoomScale;
967
+ const containerHeight = rootRect.height / zoomScale;
968
+ const gapPx = 12 / zoomScale;
969
+ const estimatedToolbarWidth = Math.min(260 / zoomScale, Math.max(168 / zoomScale, containerWidth * 0.32));
970
+ const estimatedToolbarHeight = 44 / zoomScale;
971
+ const clampedCenterLeft = Math.max(
972
+ edgePadding,
973
+ Math.min(localLeft, Math.max(edgePadding, containerWidth - edgePadding)),
974
+ );
975
+ const clampedCenterY = Math.max(
976
+ edgePadding + estimatedToolbarHeight / 2,
977
+ Math.min(localCenterY, Math.max(edgePadding + estimatedToolbarHeight / 2, containerHeight - edgePadding - estimatedToolbarHeight / 2)),
978
+ );
979
+ const rightClearance = containerWidth - localRightEdge - gapPx - edgePadding;
980
+ const leftClearance = localLeftEdge - gapPx - edgePadding;
981
+
982
+ if (rightClearance >= estimatedToolbarWidth) {
983
+ return {
984
+ placement: "right",
985
+ style: {
986
+ left: `${localRightEdge}px`,
987
+ top: `${clampedCenterY}px`,
988
+ maxWidth: `${Math.max(220, containerWidth - edgePadding * 2)}px`,
989
+ transform: `translate(${gapPx}px, -50%)`,
990
+ },
991
+ };
992
+ }
993
+
994
+ if (leftClearance >= estimatedToolbarWidth) {
995
+ return {
996
+ placement: "left",
997
+ style: {
998
+ left: `${localLeftEdge}px`,
999
+ top: `${clampedCenterY}px`,
1000
+ maxWidth: `${Math.max(220, containerWidth - edgePadding * 2)}px`,
1001
+ transform: `translate(calc(-100% - ${gapPx}px), -50%)`,
1002
+ },
1003
+ };
1004
+ }
1005
+
1006
+ const placement = localTop < estimatedToolbarHeight + gapPx + edgePadding ? "below" : "above";
1007
+
1008
+ return {
1009
+ placement,
1010
+ style: {
1011
+ left: `${clampedCenterLeft}px`,
1012
+ top: `${placement === "above" ? localTop : localBottom}px`,
1013
+ maxWidth: `${Math.max(220, containerWidth - edgePadding * 2)}px`,
1014
+ transform:
1015
+ placement === "above"
1016
+ ? `translate(-50%, calc(-100% - ${gapPx}px))`
1017
+ : `translate(-50%, ${gapPx}px)`,
1018
+ },
1019
+ };
1020
+ }
1021
+
1022
+ function toBorderCss(
1023
+ border:
1024
+ | NonNullable<NonNullable<RuntimeRenderSnapshot["pageLayout"]>["pageBorders"]>["top"]
1025
+ | undefined,
1026
+ ): string | undefined {
1027
+ if (!border || border.value === "none" || border.value === "nil") {
1028
+ return undefined;
1029
+ }
1030
+
1031
+ const width = border.size ? `${Math.max(1, Math.round(border.size / 8))}px` : "1px";
1032
+ const style =
1033
+ border.value === "double"
1034
+ ? "double"
1035
+ : border.value === "dotted"
1036
+ ? "dotted"
1037
+ : border.value === "dashed" || border.value === "dashSmallGap"
1038
+ ? "dashed"
1039
+ : "solid";
1040
+ const color = border.color && border.color !== "auto" ? `#${border.color}` : "rgba(31, 31, 31, 0.28)";
1041
+ return `${width} ${style} ${color}`;
1042
+ }