@beyondwork/docx-react-component 1.0.56 → 1.0.58

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 (113) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +330 -0
  4. package/src/compare/diff-engine.ts +3 -0
  5. package/src/core/commands/formatting-commands.ts +1 -0
  6. package/src/core/commands/index.ts +17 -11
  7. package/src/core/selection/mapping.ts +18 -1
  8. package/src/core/selection/review-anchors.ts +29 -18
  9. package/src/io/chart-preview-resolver.ts +175 -41
  10. package/src/io/docx-session.ts +57 -2
  11. package/src/io/export/serialize-main-document.ts +82 -0
  12. package/src/io/export/serialize-styles.ts +61 -3
  13. package/src/io/export/table-properties-xml.ts +19 -4
  14. package/src/io/normalize/normalize-text.ts +33 -0
  15. package/src/io/ooxml/parse-anchor.ts +182 -0
  16. package/src/io/ooxml/parse-drawing.ts +319 -0
  17. package/src/io/ooxml/parse-fields.ts +115 -2
  18. package/src/io/ooxml/parse-fill.ts +215 -0
  19. package/src/io/ooxml/parse-font-table.ts +190 -0
  20. package/src/io/ooxml/parse-footnotes.ts +52 -1
  21. package/src/io/ooxml/parse-main-document.ts +241 -1
  22. package/src/io/ooxml/parse-numbering.ts +96 -0
  23. package/src/io/ooxml/parse-picture.ts +158 -0
  24. package/src/io/ooxml/parse-settings.ts +34 -0
  25. package/src/io/ooxml/parse-shapes.ts +87 -0
  26. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  27. package/src/io/ooxml/parse-styles.ts +74 -1
  28. package/src/io/ooxml/parse-theme.ts +60 -0
  29. package/src/io/paste/html-clipboard.ts +449 -0
  30. package/src/io/paste/word-clipboard.ts +5 -1
  31. package/src/legal/_document-root.ts +26 -0
  32. package/src/legal/bookmarks.ts +4 -3
  33. package/src/legal/cross-references.ts +3 -2
  34. package/src/legal/defined-terms.ts +2 -1
  35. package/src/legal/signature-blocks.ts +2 -1
  36. package/src/model/canonical-document.ts +421 -3
  37. package/src/runtime/chart/chart-model-store.ts +73 -10
  38. package/src/runtime/document-runtime.ts +760 -41
  39. package/src/runtime/document-search.ts +61 -0
  40. package/src/runtime/edit-ops/index.ts +129 -0
  41. package/src/runtime/event-refresh-hints.ts +7 -0
  42. package/src/runtime/field-resolver.ts +341 -0
  43. package/src/runtime/footnote-resolver.ts +55 -0
  44. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  45. package/src/runtime/object-grab/index.ts +51 -0
  46. package/src/runtime/paragraph-style-resolver.ts +105 -0
  47. package/src/runtime/query-scopes.ts +186 -0
  48. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  49. package/src/runtime/scope-resolver.ts +60 -0
  50. package/src/runtime/selection/cursor-ops.ts +186 -15
  51. package/src/runtime/selection/index.ts +17 -1
  52. package/src/runtime/structure-ops/index.ts +77 -0
  53. package/src/runtime/styles-cascade.ts +33 -0
  54. package/src/runtime/surface-projection.ts +192 -12
  55. package/src/runtime/theme-color-resolver.ts +189 -44
  56. package/src/runtime/units.ts +46 -0
  57. package/src/runtime/view-state.ts +13 -2
  58. package/src/ui/WordReviewEditor.tsx +239 -11
  59. package/src/ui/editor-runtime-boundary.ts +97 -1
  60. package/src/ui/editor-shell-view.tsx +1 -1
  61. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  62. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  63. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  64. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  65. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  66. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  67. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  68. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  69. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  70. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  71. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  72. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  73. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  74. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  75. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  76. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  77. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  78. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  79. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  80. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  81. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +24 -0
  83. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  85. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +157 -0
  86. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  88. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  89. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  90. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  91. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  92. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  93. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  94. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  95. package/src/ui-tailwind/editor-surface/pm-schema.ts +214 -11
  96. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +32 -2
  97. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  98. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  99. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  100. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  101. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  102. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  103. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  104. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  105. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  106. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  107. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  108. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  109. package/src/ui-tailwind/theme/tokens.css +6 -0
  110. package/src/ui-tailwind/theme/tokens.ts +10 -0
  111. package/src/ui-tailwind/tw-review-workspace.tsx +23 -0
  112. package/src/validation/compatibility-engine.ts +2 -0
  113. package/src/validation/docx-comment-proof.ts +12 -3
@@ -28,6 +28,7 @@ import { TwScopeCardLayer } from "./tw-scope-card-layer";
28
28
  import { TwPageStackOverlayLayer } from "./tw-page-stack-overlay-layer";
29
29
  import { TwPageStackChromeLayer, type PmPortalView } from "../page-stack/tw-page-stack-chrome-layer";
30
30
  import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
31
+ import { TwObjectSelectionOverlay } from "./tw-object-selection-overlay";
31
32
 
32
33
  export interface TwChromeOverlayProps {
33
34
  /** Layout facet the overlay layers read from. */
@@ -93,6 +94,16 @@ export interface TwChromeOverlayProps {
93
94
  /** Optional extra children (e.g., future comment balloon layer). */
94
95
  children?: React.ReactNode;
95
96
 
97
+ // Object selection overlay (N6) ----------------------------------------
98
+ /** R.3 — grabbed image/shape id, or null. When set, the selection overlay renders. */
99
+ grabbedObjectId?: string | null;
100
+ /** Document `from` offset of the grabbed segment (for anchor-index lookup). */
101
+ grabbedObjectFromOffset?: number | null;
102
+ /** Document `to` offset of the grabbed segment. */
103
+ grabbedObjectToOffset?: number | null;
104
+ /** Called when the user clicks outside the selection box to deselect. */
105
+ onDeselectObject?: () => void;
106
+
96
107
  // Table grip props (P6) -----------------------------------------------
97
108
  /** Active table context — when present, column/row resize grips are shown. */
98
109
  tableContext?: TableStructureContextSnapshot | null;
@@ -187,6 +198,10 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
187
198
  scopeCardScopeTagEditor,
188
199
  "data-testid": testId,
189
200
  children,
201
+ grabbedObjectId,
202
+ grabbedObjectFromOffset,
203
+ grabbedObjectToOffset,
204
+ onDeselectObject,
190
205
  tableContext,
191
206
  onSetColumnWidth,
192
207
  onSetRowHeight,
@@ -209,6 +224,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
209
224
  facet={facet}
210
225
  scrollRoot={pageStackScrollRoot}
211
226
  renderFrameRevision={renderFrameRevision ?? 0}
227
+ visiblePageIndexRange={visiblePageIndexRange ?? null}
212
228
  />
213
229
  ) : null}
214
230
  {pageStackScrollRoot !== undefined ? (
@@ -250,6 +266,14 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
250
266
  onSetColumnWidth={onSetColumnWidth}
251
267
  onSetRowHeight={onSetRowHeight}
252
268
  />
269
+ <TwObjectSelectionOverlay
270
+ grabbedObjectId={grabbedObjectId ?? null}
271
+ grabbedObjectFromOffset={grabbedObjectFromOffset ?? null}
272
+ grabbedObjectToOffset={grabbedObjectToOffset ?? null}
273
+ facet={facet}
274
+ space={space}
275
+ onDeselect={onDeselectObject}
276
+ />
253
277
  {children}
254
278
  </div>
255
279
  );
@@ -0,0 +1,74 @@
1
+ import * as React from "react";
2
+ import type { RenderBlockDecoration } from "../../runtime/render/render-frame-types";
3
+ import { TwCommentPreview } from "../chrome/tw-comment-preview";
4
+
5
+ const WIDE_BREAKPOINT_PX = 1024;
6
+ const CONNECTOR_GAP_PX = 16;
7
+ const BALLOON_MAX_WIDTH_PX = 240;
8
+
9
+ export interface CommentBalloonData {
10
+ commentId: string;
11
+ authorName: string;
12
+ authorInitials?: string;
13
+ authorAvatarUrl?: string;
14
+ timestamp?: string;
15
+ excerpt: string;
16
+ }
17
+
18
+ export interface TwCommentBalloonLayerProps {
19
+ /** Decoration rects from `frame.decorationIndex.comments`. */
20
+ commentDecorations: readonly RenderBlockDecoration[];
21
+ /** Pre-resolved comment data keyed by commentId. */
22
+ commentDataById: ReadonlyMap<string, CommentBalloonData>;
23
+ /** Viewport width — balloons only render >= WIDE_BREAKPOINT_PX. */
24
+ viewportWidthPx: number;
25
+ /** Right edge of the page frame in overlay coordinates (px). */
26
+ pageRightEdgePx: number;
27
+ onOpenThread?: (commentId: string) => void;
28
+ }
29
+
30
+ export const TwCommentBalloonLayer = React.memo(function TwCommentBalloonLayer({
31
+ commentDecorations,
32
+ commentDataById,
33
+ viewportWidthPx,
34
+ pageRightEdgePx,
35
+ onOpenThread,
36
+ }: TwCommentBalloonLayerProps) {
37
+ if (viewportWidthPx < WIDE_BREAKPOINT_PX) return null;
38
+ if (commentDecorations.length === 0) return null;
39
+
40
+ return (
41
+ <>
42
+ {commentDecorations.map((dec) => {
43
+ const data = commentDataById.get(dec.refId);
44
+ if (!data) return null;
45
+ return (
46
+ <div
47
+ key={dec.refId}
48
+ style={{
49
+ position: "absolute",
50
+ top: dec.frame.topPx,
51
+ left: pageRightEdgePx + CONNECTOR_GAP_PX,
52
+ width: BALLOON_MAX_WIDTH_PX,
53
+ pointerEvents: "auto",
54
+ zIndex: 10,
55
+ }}
56
+ >
57
+ <TwCommentPreview
58
+ author={{
59
+ name: data.authorName,
60
+ initials: data.authorInitials,
61
+ avatarUrl: data.authorAvatarUrl,
62
+ }}
63
+ timestamp={data.timestamp}
64
+ excerpt={data.excerpt}
65
+ onOpenThread={
66
+ onOpenThread ? () => onOpenThread(data.commentId) : undefined
67
+ }
68
+ />
69
+ </div>
70
+ );
71
+ })}
72
+ </>
73
+ );
74
+ });
@@ -0,0 +1,65 @@
1
+ import * as React from "react";
2
+ import { Lock } from "lucide-react";
3
+ import type { RenderBlockDecoration } from "../../runtime/render/render-frame-types";
4
+
5
+ const OUTLINE_INSET_PX = 2;
6
+ const BADGE_SIZE_PX = 20;
7
+
8
+ export interface TwLockedBlockLayerProps {
9
+ /** Decoration rects from `frame.decorationIndex.locked`. */
10
+ lockedDecorations: readonly RenderBlockDecoration[];
11
+ }
12
+
13
+ export const TwLockedBlockLayer = React.memo(function TwLockedBlockLayer({
14
+ lockedDecorations,
15
+ }: TwLockedBlockLayerProps) {
16
+ if (lockedDecorations.length === 0) return null;
17
+
18
+ return (
19
+ <>
20
+ {lockedDecorations.map((dec, idx) => {
21
+ const { leftPx, topPx, widthPx, heightPx } = dec.frame;
22
+ return (
23
+ <React.Fragment key={`locked-${dec.refId}-${idx}`}>
24
+ {/* Dashed outline around the locked block */}
25
+ <div
26
+ aria-hidden
27
+ style={{
28
+ position: "absolute",
29
+ top: topPx + OUTLINE_INSET_PX,
30
+ left: leftPx + OUTLINE_INSET_PX,
31
+ width: widthPx - OUTLINE_INSET_PX * 2,
32
+ height: heightPx - OUTLINE_INSET_PX * 2,
33
+ border: "1.5px dashed var(--color-border-caution, #e8a020)",
34
+ borderRadius: 2,
35
+ pointerEvents: "none",
36
+ }}
37
+ />
38
+ {/* Lock badge in the top-right corner */}
39
+ <div
40
+ aria-label="Locked content"
41
+ role="img"
42
+ style={{
43
+ position: "absolute",
44
+ top: topPx - BADGE_SIZE_PX / 2,
45
+ left: leftPx + widthPx - BADGE_SIZE_PX / 2,
46
+ width: BADGE_SIZE_PX,
47
+ height: BADGE_SIZE_PX,
48
+ display: "flex",
49
+ alignItems: "center",
50
+ justifyContent: "center",
51
+ background: "var(--color-bg-surface, #fff)",
52
+ border: "1px solid var(--color-border-caution, #e8a020)",
53
+ borderRadius: "50%",
54
+ pointerEvents: "none",
55
+ zIndex: 1,
56
+ }}
57
+ >
58
+ <Lock size={10} strokeWidth={2.5} />
59
+ </div>
60
+ </React.Fragment>
61
+ );
62
+ })}
63
+ </>
64
+ );
65
+ });
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Lane 6d — N6 P11.1–P11.5: object selection chrome overlay.
3
+ *
4
+ * Renders a selection box with 8 resize handles and a rotate grip around the
5
+ * grabbed image or shape. Purely visual chrome — no resize/rotate mutations
6
+ * in this slice; handle interaction is deferred to the follow-up N6.b slice.
7
+ *
8
+ * Positioning: uses `RenderAnchorIndex.byRuntimeOffset(from)` + `bySelection`
9
+ * to compute the object rect in overlay-coordinate space, then paints over it.
10
+ *
11
+ * Dismissal: clicking outside the overlay calls `onDeselect()` which routes
12
+ * to `runtime.deselectObject()` in the workspace.
13
+ *
14
+ * v1 scope:
15
+ * - rect, ellipse, roundRect shapes and inline/floating images.
16
+ * - Resize handles are visual only (pointer-events-none on each handle).
17
+ * - Rotate grip is visual only.
18
+ * - Anchor drag indicator omitted until N6.b.
19
+ */
20
+
21
+ import * as React from "react";
22
+ import { useEffect, useRef } from "react";
23
+ import type { WordReviewEditorLayoutFacet } from "../../api/public-types";
24
+ import type { OverlayCoordinateSpace } from "./chrome-overlay-projector";
25
+ import { projectRectToOverlay } from "./chrome-overlay-projector";
26
+
27
+ /** The 8 corner/edge handle positions. */
28
+ const HANDLE_POSITIONS = [
29
+ "nw", "n", "ne",
30
+ "w", "e",
31
+ "sw", "s", "se",
32
+ ] as const;
33
+ type HandlePosition = typeof HANDLE_POSITIONS[number];
34
+
35
+ const CURSOR_MAP: Record<HandlePosition, string> = {
36
+ nw: "nw-resize", n: "n-resize", ne: "ne-resize",
37
+ w: "w-resize", e: "e-resize",
38
+ sw: "sw-resize", s: "s-resize", se: "se-resize",
39
+ };
40
+
41
+ export interface TwObjectSelectionOverlayProps {
42
+ /** Stable id of the grabbed image/shape (mediaId or shapeId), or null. */
43
+ grabbedObjectId: string | null;
44
+ /** Document offset (`from`) of the grabbed object's inline segment. Null when no object grabbed. */
45
+ grabbedObjectFromOffset: number | null;
46
+ /** Document offset (`to`) of the grabbed object's inline segment. Null when no object grabbed. */
47
+ grabbedObjectToOffset: number | null;
48
+ /** Layout facet for render-frame + anchor-index access. */
49
+ facet: WordReviewEditorLayoutFacet;
50
+ /** Optional overlay coordinate-space override. */
51
+ space?: OverlayCoordinateSpace;
52
+ /** Called when the user clicks outside the selection box. */
53
+ onDeselect?: () => void;
54
+ }
55
+
56
+ export function TwObjectSelectionOverlay({
57
+ grabbedObjectId,
58
+ grabbedObjectFromOffset,
59
+ grabbedObjectToOffset,
60
+ facet,
61
+ space,
62
+ onDeselect,
63
+ }: TwObjectSelectionOverlayProps) {
64
+ const overlayRef = useRef<HTMLDivElement>(null);
65
+
66
+ // Click-outside to deselect.
67
+ useEffect(() => {
68
+ if (!grabbedObjectId || !onDeselect) return;
69
+ function handlePointerDown(e: PointerEvent) {
70
+ if (overlayRef.current && !overlayRef.current.contains(e.target as Node)) {
71
+ onDeselect!();
72
+ }
73
+ }
74
+ document.addEventListener("pointerdown", handlePointerDown, { capture: true });
75
+ return () => document.removeEventListener("pointerdown", handlePointerDown, { capture: true });
76
+ }, [grabbedObjectId, onDeselect]);
77
+
78
+ if (!grabbedObjectId || grabbedObjectFromOffset == null) return null;
79
+
80
+ const frame = typeof facet.getRenderFrame === "function" ? facet.getRenderFrame() : null;
81
+ if (!frame) return null;
82
+
83
+ const rawRect = grabbedObjectToOffset != null
84
+ ? frame.anchorIndex.bySelection(grabbedObjectFromOffset, grabbedObjectToOffset)
85
+ : frame.anchorIndex.byRuntimeOffset(grabbedObjectFromOffset);
86
+ if (!rawRect) return null;
87
+
88
+ const rect = projectRectToOverlay(rawRect, space);
89
+
90
+ const boxStyle: React.CSSProperties = {
91
+ position: "absolute",
92
+ left: rect.left,
93
+ top: rect.top,
94
+ width: rect.width,
95
+ height: rect.height,
96
+ outline: "2px solid var(--color-accent-primary)",
97
+ boxSizing: "border-box",
98
+ pointerEvents: "auto",
99
+ };
100
+
101
+ return (
102
+ <div
103
+ ref={overlayRef}
104
+ style={boxStyle}
105
+ data-object-selection=""
106
+ data-object-id={grabbedObjectId}
107
+ aria-label="Selected object"
108
+ >
109
+ {HANDLE_POSITIONS.map((pos) => (
110
+ <ObjectHandle key={pos} position={pos} />
111
+ ))}
112
+ <RotateGrip />
113
+ </div>
114
+ );
115
+ }
116
+
117
+ function ObjectHandle({ position }: { position: HandlePosition }) {
118
+ const HANDLE_PX = 8;
119
+ const half = HANDLE_PX / 2;
120
+ const pos = position;
121
+
122
+ const style: React.CSSProperties = {
123
+ position: "absolute",
124
+ width: HANDLE_PX,
125
+ height: HANDLE_PX,
126
+ background: "white",
127
+ border: "1.5px solid var(--color-accent-primary)",
128
+ borderRadius: 1,
129
+ boxSizing: "border-box",
130
+ cursor: CURSOR_MAP[pos],
131
+ // Visual only in v1 — pointer events disabled so clicks fall through to
132
+ // the click-outside listener which deselectObjects.
133
+ pointerEvents: "none",
134
+ ...(pos.includes("w") ? { left: -half } : pos.includes("e") ? { right: -half } : { left: "50%", transform: "translateX(-50%)" }),
135
+ ...(pos.includes("n") ? { top: -half } : pos.includes("s") ? { bottom: -half } : { top: "50%", transform: `${pos === "w" || pos === "e" ? "translateY(-50%)" : "translateX(-50%) translateY(-50%)"}` }),
136
+ };
137
+
138
+ return <div style={style} data-handle={pos} aria-hidden="true" />;
139
+ }
140
+
141
+ function RotateGrip() {
142
+ const style: React.CSSProperties = {
143
+ position: "absolute",
144
+ width: 10,
145
+ height: 10,
146
+ borderRadius: "50%",
147
+ background: "white",
148
+ border: "1.5px solid var(--color-accent-primary)",
149
+ top: -24,
150
+ left: "50%",
151
+ transform: "translateX(-50%)",
152
+ cursor: "grab",
153
+ pointerEvents: "none",
154
+ boxSizing: "border-box",
155
+ };
156
+ return <div style={style} data-handle="rotate" aria-hidden="true" />;
157
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Lane 6d — Slice N5 (P11.6 + P11.7): page-border + column-separator overlay.
3
+ *
4
+ * Pure presentational component that paints the four page-border edges
5
+ * and any column-separator vertical lines for a single page. All
6
+ * positioning math lives in `page-border-resolver.ts`; this component
7
+ * does no measurement and reads no DOM — it accepts a `PageLayoutSnapshot`
8
+ * + page rect (in px) and emits inline-styled `<div>`s.
9
+ *
10
+ * Designed to be mounted by a per-page chrome layer (one
11
+ * `<TwPageBorderOverlay>` per page) so each page can use its own section's
12
+ * `pageBorders` and `columnDefinitions` independently.
13
+ */
14
+
15
+ import React from "react";
16
+ import type { PageLayoutSnapshot } from "../../api/public-types";
17
+ import {
18
+ resolveColumnSeparatorXOffsets,
19
+ resolvePageBorders,
20
+ } from "./page-border-resolver";
21
+
22
+ export interface TwPageBorderOverlayProps {
23
+ /** Section layout for the page (drives `pageBorders` + `columnSeparator`). */
24
+ layout: PageLayoutSnapshot | undefined;
25
+ /** Zero-based page index within its owning section (drives display policy). */
26
+ pageInSection: number;
27
+ /** Page rect in container-relative pixels. */
28
+ pageWidthPx: number;
29
+ pageHeightPx: number;
30
+ /** Inner content-box insets in pixels (text margin from page edge). */
31
+ marginLeftPx: number;
32
+ marginRightPx: number;
33
+ marginTopPx: number;
34
+ marginBottomPx: number;
35
+ /** Optional test id for assertion targeting. */
36
+ "data-testid"?: string;
37
+ }
38
+
39
+ /**
40
+ * Border edge geometry helpers — convert the resolved spec + page
41
+ * dimensions into the absolute `style` props for one positioned `<div>`.
42
+ */
43
+ function styleForEdge(
44
+ edge: "top" | "right" | "bottom" | "left",
45
+ widthPx: number,
46
+ styleName: string,
47
+ color: string,
48
+ insetTop: number,
49
+ insetRight: number,
50
+ insetBottom: number,
51
+ insetLeft: number,
52
+ ): React.CSSProperties {
53
+ switch (edge) {
54
+ case "top":
55
+ return {
56
+ position: "absolute",
57
+ top: `${insetTop}px`,
58
+ left: `${insetLeft}px`,
59
+ right: `${insetRight}px`,
60
+ height: `${widthPx}px`,
61
+ borderTop: `${widthPx}px ${styleName} ${color}`,
62
+ pointerEvents: "none",
63
+ };
64
+ case "bottom":
65
+ return {
66
+ position: "absolute",
67
+ bottom: `${insetBottom}px`,
68
+ left: `${insetLeft}px`,
69
+ right: `${insetRight}px`,
70
+ height: `${widthPx}px`,
71
+ borderBottom: `${widthPx}px ${styleName} ${color}`,
72
+ pointerEvents: "none",
73
+ };
74
+ case "left":
75
+ return {
76
+ position: "absolute",
77
+ top: `${insetTop}px`,
78
+ bottom: `${insetBottom}px`,
79
+ left: `${insetLeft}px`,
80
+ width: `${widthPx}px`,
81
+ borderLeft: `${widthPx}px ${styleName} ${color}`,
82
+ pointerEvents: "none",
83
+ };
84
+ case "right":
85
+ return {
86
+ position: "absolute",
87
+ top: `${insetTop}px`,
88
+ bottom: `${insetBottom}px`,
89
+ right: `${insetRight}px`,
90
+ width: `${widthPx}px`,
91
+ borderRight: `${widthPx}px ${styleName} ${color}`,
92
+ pointerEvents: "none",
93
+ };
94
+ }
95
+ }
96
+
97
+ export function TwPageBorderOverlay(
98
+ props: TwPageBorderOverlayProps,
99
+ ): React.ReactElement | null {
100
+ const {
101
+ layout,
102
+ pageInSection,
103
+ pageWidthPx,
104
+ pageHeightPx,
105
+ marginLeftPx,
106
+ marginRightPx,
107
+ marginTopPx,
108
+ marginBottomPx,
109
+ } = props;
110
+
111
+ const borders = resolvePageBorders(layout, pageInSection);
112
+ const columnOffsets = resolveColumnSeparatorXOffsets(layout);
113
+
114
+ // Nothing to paint at all → return null so React can skip the subtree.
115
+ const hasBorderEdges = !!borders?.display && (
116
+ borders.top !== null ||
117
+ borders.right !== null ||
118
+ borders.bottom !== null ||
119
+ borders.left !== null
120
+ );
121
+ if (!hasBorderEdges && columnOffsets.length === 0) {
122
+ return null;
123
+ }
124
+
125
+ // Insets — `offsetFrom: "page"` anchors to the page edge (= 0 inset);
126
+ // `offsetFrom: "text"` anchors to the text margin. Per-edge `space`
127
+ // pushes the border further inward from that anchor.
128
+ const usePageOffset = (borders?.offsetFrom ?? "page") === "page";
129
+ const baseInsetTop = usePageOffset ? 0 : marginTopPx;
130
+ const baseInsetRight = usePageOffset ? 0 : marginRightPx;
131
+ const baseInsetBottom = usePageOffset ? 0 : marginBottomPx;
132
+ const baseInsetLeft = usePageOffset ? 0 : marginLeftPx;
133
+
134
+ return (
135
+ <div
136
+ data-page-border-overlay=""
137
+ data-page-in-section={pageInSection}
138
+ aria-hidden="true"
139
+ data-testid={props["data-testid"] ?? "page-border-overlay"}
140
+ style={{
141
+ position: "absolute",
142
+ inset: 0,
143
+ // zOrder: "back" sits visually behind text; default ("front") sits
144
+ // above. Using `pointer-events: none` on every child means content
145
+ // remains clickable in either case.
146
+ zIndex: borders?.zOrder === "back" ? -1 : 1,
147
+ pointerEvents: "none",
148
+ }}
149
+ >
150
+ {hasBorderEdges && borders?.top ? (
151
+ <div
152
+ data-edge="top"
153
+ style={styleForEdge(
154
+ "top",
155
+ borders.top.widthPx,
156
+ borders.top.style,
157
+ borders.top.color,
158
+ baseInsetTop + borders.top.spacePx,
159
+ baseInsetRight,
160
+ baseInsetBottom,
161
+ baseInsetLeft,
162
+ )}
163
+ />
164
+ ) : null}
165
+ {hasBorderEdges && borders?.right ? (
166
+ <div
167
+ data-edge="right"
168
+ style={styleForEdge(
169
+ "right",
170
+ borders.right.widthPx,
171
+ borders.right.style,
172
+ borders.right.color,
173
+ baseInsetTop,
174
+ baseInsetRight + borders.right.spacePx,
175
+ baseInsetBottom,
176
+ baseInsetLeft,
177
+ )}
178
+ />
179
+ ) : null}
180
+ {hasBorderEdges && borders?.bottom ? (
181
+ <div
182
+ data-edge="bottom"
183
+ style={styleForEdge(
184
+ "bottom",
185
+ borders.bottom.widthPx,
186
+ borders.bottom.style,
187
+ borders.bottom.color,
188
+ baseInsetTop,
189
+ baseInsetRight,
190
+ baseInsetBottom + borders.bottom.spacePx,
191
+ baseInsetLeft,
192
+ )}
193
+ />
194
+ ) : null}
195
+ {hasBorderEdges && borders?.left ? (
196
+ <div
197
+ data-edge="left"
198
+ style={styleForEdge(
199
+ "left",
200
+ borders.left.widthPx,
201
+ borders.left.style,
202
+ borders.left.color,
203
+ baseInsetTop,
204
+ baseInsetRight,
205
+ baseInsetBottom,
206
+ baseInsetLeft + borders.left.spacePx,
207
+ )}
208
+ />
209
+ ) : null}
210
+ {/* Column separators — vertical hairlines centered in column gaps,
211
+ inset by the text margins so they paint inside the content box. */}
212
+ {columnOffsets.map((xPxFromContentLeft, i) => (
213
+ <div
214
+ key={`col-sep-${i}`}
215
+ data-column-separator=""
216
+ data-column-separator-index={i}
217
+ style={{
218
+ position: "absolute",
219
+ top: `${marginTopPx}px`,
220
+ bottom: `${marginBottomPx}px`,
221
+ left: `${marginLeftPx + xPxFromContentLeft}px`,
222
+ width: "1px",
223
+ backgroundColor: "var(--color-border-default, currentColor)",
224
+ opacity: 0.6,
225
+ pointerEvents: "none",
226
+ }}
227
+ />
228
+ ))}
229
+ </div>
230
+ );
231
+ }
232
+
233
+ export default TwPageBorderOverlay;