@beyondwork/docx-react-component 1.0.55 → 1.0.57

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 (107) hide show
  1. package/package.json +43 -32
  2. package/src/api/public-types.ts +157 -0
  3. package/src/compare/diff-engine.ts +3 -0
  4. package/src/core/commands/formatting-commands.ts +1 -0
  5. package/src/core/commands/index.ts +17 -11
  6. package/src/core/selection/mapping.ts +18 -1
  7. package/src/core/selection/review-anchors.ts +29 -18
  8. package/src/io/chart-preview-resolver.ts +175 -41
  9. package/src/io/docx-session.ts +57 -2
  10. package/src/io/export/serialize-main-document.ts +82 -0
  11. package/src/io/export/serialize-styles.ts +61 -3
  12. package/src/io/export/table-properties-xml.ts +19 -4
  13. package/src/io/normalize/normalize-text.ts +33 -0
  14. package/src/io/ooxml/parse-anchor.ts +182 -0
  15. package/src/io/ooxml/parse-drawing.ts +319 -0
  16. package/src/io/ooxml/parse-fields.ts +115 -2
  17. package/src/io/ooxml/parse-fill.ts +215 -0
  18. package/src/io/ooxml/parse-font-table.ts +190 -0
  19. package/src/io/ooxml/parse-footnotes.ts +52 -1
  20. package/src/io/ooxml/parse-main-document.ts +241 -1
  21. package/src/io/ooxml/parse-numbering.ts +96 -0
  22. package/src/io/ooxml/parse-picture.ts +107 -0
  23. package/src/io/ooxml/parse-settings.ts +34 -0
  24. package/src/io/ooxml/parse-shapes.ts +87 -0
  25. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  26. package/src/io/ooxml/parse-styles.ts +74 -1
  27. package/src/io/ooxml/parse-theme.ts +60 -0
  28. package/src/io/paste/html-clipboard.ts +449 -0
  29. package/src/io/paste/word-clipboard.ts +5 -1
  30. package/src/legal/_document-root.ts +26 -0
  31. package/src/legal/bookmarks.ts +4 -3
  32. package/src/legal/cross-references.ts +3 -2
  33. package/src/legal/defined-terms.ts +2 -1
  34. package/src/legal/signature-blocks.ts +2 -1
  35. package/src/model/canonical-document.ts +415 -3
  36. package/src/runtime/chart/chart-model-store.ts +73 -10
  37. package/src/runtime/document-runtime.ts +693 -41
  38. package/src/runtime/edit-ops/index.ts +129 -0
  39. package/src/runtime/event-refresh-hints.ts +7 -0
  40. package/src/runtime/field-resolver.ts +341 -0
  41. package/src/runtime/footnote-resolver.ts +55 -0
  42. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  43. package/src/runtime/object-grab/index.ts +51 -0
  44. package/src/runtime/paragraph-style-resolver.ts +105 -0
  45. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  46. package/src/runtime/selection/cursor-ops.ts +186 -15
  47. package/src/runtime/selection/index.ts +17 -1
  48. package/src/runtime/structure-ops/index.ts +77 -0
  49. package/src/runtime/styles-cascade.ts +33 -0
  50. package/src/runtime/surface-projection.ts +186 -12
  51. package/src/runtime/theme-color-resolver.ts +189 -44
  52. package/src/runtime/units.ts +46 -0
  53. package/src/runtime/view-state.ts +13 -2
  54. package/src/ui/WordReviewEditor.tsx +168 -10
  55. package/src/ui/editor-runtime-boundary.ts +94 -1
  56. package/src/ui/editor-shell-view.tsx +1 -1
  57. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  58. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  59. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  60. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  61. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  62. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  63. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  64. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  65. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  66. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  67. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  68. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  69. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  70. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  71. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  72. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  73. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  74. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  75. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  76. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  77. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  78. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
  79. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  80. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  81. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  83. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  85. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  86. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  87. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  88. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  90. package/src/ui-tailwind/editor-surface/pm-schema.ts +192 -11
  91. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -3
  92. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  93. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  94. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  95. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  96. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  97. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  98. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  99. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  100. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  101. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  102. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  103. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  104. package/src/ui-tailwind/theme/tokens.css +6 -0
  105. package/src/ui-tailwind/theme/tokens.ts +10 -0
  106. package/src/validation/compatibility-engine.ts +2 -0
  107. package/src/validation/docx-comment-proof.ts +12 -3
@@ -44,10 +44,15 @@ function ScatterChartImpl({ model, layout, theme }: ScatterChartProps): React.Re
44
44
  const totalPoints = Math.max(0, ...model.series.map(s => s.xValues.length));
45
45
  const visiblePoints = useProgressiveCount(totalPoints);
46
46
 
47
- const toPoint = (x: number, y: number): Point => [
48
- plot.x + ((x - xMin) / xSpan) * plot.w,
49
- plot.y + plot.h - ((y - yMin) / ySpan) * plot.h,
50
- ];
47
+ const reverseX = model.xAxis.reverse === true;
48
+ const reverseY = model.yAxis.reverse === true;
49
+ const toPoint = (x: number, y: number): Point => {
50
+ const xFrac = (x - xMin) / xSpan;
51
+ const yFrac = (y - yMin) / ySpan;
52
+ const px = reverseX ? plot.x + plot.w - xFrac * plot.w : plot.x + xFrac * plot.w;
53
+ const py = reverseY ? plot.y + yFrac * plot.h : plot.y + plot.h - yFrac * plot.h;
54
+ return [px, py];
55
+ };
51
56
 
52
57
  const style = model.style;
53
58
  const showLine = style === "line" || style === "lineMarker" || style === "smooth" || style === "smoothMarker";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Derives up to two initials from a display name in a Unicode-safe way.
3
+ * Splits on whitespace, takes the first character of the first two words.
4
+ * Falls back to the first grapheme cluster for single-word names.
5
+ */
6
+ export function getInitials(name: string): string {
7
+ const words = name.trim().split(/\s+/).filter(Boolean);
8
+ if (words.length === 0) return "";
9
+ if (words.length === 1) {
10
+ return (words[0].at(0) ?? "").toLocaleUpperCase();
11
+ }
12
+ const first = words[0].at(0) ?? "";
13
+ const second = words[1].at(0) ?? "";
14
+ return (first + second).toLocaleUpperCase();
15
+ }
@@ -1,5 +1,7 @@
1
1
  import * as React from "react";
2
2
 
3
+ import { getInitials } from "./avatar-initials";
4
+
3
5
  export interface TwCommentPreviewProps {
4
6
  author: {
5
7
  name: string;
@@ -19,7 +21,7 @@ const focusRingClass =
19
21
 
20
22
  export function TwCommentPreview(props: TwCommentPreviewProps): React.JSX.Element {
21
23
  const initials =
22
- props.author.initials ?? props.author.name.slice(0, 2).toUpperCase();
24
+ props.author.initials ?? getInitials(props.author.name);
23
25
 
24
26
  const containerClass = [
25
27
  "flex flex-col gap-1.5",
@@ -90,6 +90,11 @@ export function filterContextMenuEntries(
90
90
  if (ctx.tableToolbarVisible) {
91
91
  suppressedGroups.add("table");
92
92
  }
93
+ if (ctx.commentCardVisible) {
94
+ suppressedGroups.add("comment");
95
+ }
96
+ // "clipboard" is intentionally never suppressed — Cut/Copy/Paste remain
97
+ // available as a fallback regardless of which floating chrome is visible.
93
98
 
94
99
  const filtered = entries.filter((e) => {
95
100
  if (e.kind === "separator") return true;
@@ -128,6 +133,13 @@ export interface TwContextMenuProps {
128
133
  entries: ContextMenuEntry[];
129
134
  context?: Partial<ContextMenuContext>;
130
135
  platform?: "mac" | "win";
136
+ /**
137
+ * Accessible label for the menu container. WCAG 2.5.3 requires
138
+ * `role="menu"` to have an accessible name. Defaults to
139
+ * "Editor context menu"; hosts can override for domain context
140
+ * (e.g. "Review menu", "Table menu").
141
+ */
142
+ "aria-label"?: string;
131
143
  "data-testid"?: string;
132
144
  }
133
145
 
@@ -143,6 +155,7 @@ export function TwContextMenu(props: TwContextMenuProps): React.JSX.Element {
143
155
  entries,
144
156
  context,
145
157
  platform,
158
+ "aria-label": ariaLabel = "Editor context menu",
146
159
  "data-testid": testId = "tw-context-menu",
147
160
  } = props;
148
161
 
@@ -153,6 +166,7 @@ export function TwContextMenu(props: TwContextMenuProps): React.JSX.Element {
153
166
  <div
154
167
  data-testid={testId}
155
168
  role="menu"
169
+ aria-label={ariaLabel}
156
170
  className={[
157
171
  "flex flex-col",
158
172
  "rounded-[var(--radius-lg)]",
@@ -107,6 +107,7 @@ function useDwellDensity(tool: ActiveSelectionToolModel | null): "micro" | "full
107
107
 
108
108
  export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
109
109
  const density = useDwellDensity(props.tool);
110
+ const { onChromePinChange } = props;
110
111
 
111
112
  if (!props.tool) {
112
113
  return null;
@@ -124,9 +125,9 @@ export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
124
125
 
125
126
  const handlePinChange = useCallback(
126
127
  (surface: ChromePinSurface, next: PinState | null) => {
127
- props.onChromePinChange?.(surface, next);
128
+ onChromePinChange?.(surface, next);
128
129
  },
129
- [props.onChromePinChange], // eslint-disable-line react-hooks/exhaustive-deps
130
+ [onChromePinChange],
130
131
  );
131
132
 
132
133
  const overlayTestId = getOverlayTestId(props.tool.kind, Boolean(props.placement));
@@ -5,6 +5,7 @@ import { Baseline, Bold, Highlighter, Italic, MessageSquare, Underline } from "l
5
5
 
6
6
  import type { SelectionToolbarModel } from "../../ui/headless/selection-toolbar-model";
7
7
  import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
8
+ import { BRAND_TOKENS } from "../theme/tokens";
8
9
 
9
10
  export interface TwSelectionToolbarProps {
10
11
  model: SelectionToolbarModel;
@@ -24,13 +25,24 @@ const focusRingClass =
24
25
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
25
26
 
26
27
  /**
27
- * Fallback colors for the selection-toolbar one-click apply buttons.
28
- * The model can override via `textColorDefault` / `highlightColorDefault`
29
- * which R2.5 plumbs through from `formattingState` so the apply button
30
- * reflects the user's recent color pick.
28
+ * Fallback highlight color for the selection-toolbar "apply highlight"
29
+ * button. The model overrides via `highlightColorDefault` which R2.5
30
+ * plumbs through from `formattingState` so the apply button reflects
31
+ * the user's recent highlight pick.
32
+ *
33
+ * Drawn from the design-token system (`color.highlight.default`): user-
34
+ * driven content color, stable across themes (so highlights persist
35
+ * when the document is re-opened under a different theme).
31
36
  */
32
- const DEFAULT_TEXT_COLOR = "#1660a8";
33
- const DEFAULT_HIGHLIGHT_COLOR = "#ffff00";
37
+ const DEFAULT_HIGHLIGHT_COLOR = BRAND_TOKENS.color.highlight.default;
38
+
39
+ /**
40
+ * No static default text color — the "apply text color" button waits
41
+ * for the user's first color pick (via `model.textColorDefault`). Until
42
+ * then the button is disabled. This avoids shipping an arbitrary hex
43
+ * fallback and keeps the toolbar theme-neutral.
44
+ */
45
+ const DEFAULT_TEXT_COLOR: string | null = null;
34
46
 
35
47
  export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarProps>(function TwSelectionToolbar(props, ref) {
36
48
  const { model } = props;
@@ -84,10 +96,17 @@ export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarP
84
96
  <>
85
97
  <ToolbarActionButton
86
98
  icon={<Baseline className="h-3.5 w-3.5" />}
87
- label={`Apply ${model.textColorDefault ?? DEFAULT_TEXT_COLOR}`}
99
+ label={
100
+ model.textColorDefault
101
+ ? `Apply ${model.textColorDefault}`
102
+ : "Apply text color (pick a color first)"
103
+ }
88
104
  pressed={false}
89
- disabled={formattingDisabled}
90
- onClick={() => props.onSetTextColor?.(model.textColorDefault ?? DEFAULT_TEXT_COLOR)}
105
+ disabled={formattingDisabled || !model.textColorDefault}
106
+ onClick={() => {
107
+ const color = model.textColorDefault ?? DEFAULT_TEXT_COLOR;
108
+ if (color) props.onSetTextColor?.(color);
109
+ }}
91
110
  />
92
111
  <ToolbarActionButton
93
112
  icon={<Highlighter className="h-3.5 w-3.5" />}
@@ -117,7 +136,7 @@ export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarP
117
136
  </Tooltip.Trigger>
118
137
  <Tooltip.Portal>
119
138
  <Tooltip.Content
120
- className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50"
139
+ className="rounded-md bg-primary px-2 py-1 text-xs text-[var(--color-text-inverse)] shadow-md z-50"
121
140
  sideOffset={6}
122
141
  >
123
142
  {tooltipLabel}
@@ -198,7 +217,7 @@ function ToolbarActionButton(props: ToolbarActionButtonProps) {
198
217
  </Tooltip.Trigger>
199
218
  <Tooltip.Portal>
200
219
  <Tooltip.Content
201
- className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50"
220
+ className="rounded-md bg-primary px-2 py-1 text-xs text-[var(--color-text-inverse)] shadow-md z-50"
202
221
  sideOffset={6}
203
222
  >
204
223
  {props.label}
@@ -23,12 +23,25 @@ const MAC_SYMBOL: Record<string, string> = {
23
23
 
24
24
  function detectPlatform(): "mac" | "win" {
25
25
  if (typeof navigator === "undefined") return "win";
26
+ // Prefer the modern userAgentData.platform API (avoids navigator.platform deprecation warning).
27
+ const uaData = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData;
28
+ if (uaData?.platform) return /mac|iPhone|iPad/i.test(uaData.platform) ? "mac" : "win";
26
29
  return /Mac|iPhone|iPad/.test(navigator.platform) ? "mac" : "win";
27
30
  }
28
31
 
29
32
  export function TwShortcutHint(props: TwShortcutHintProps): React.JSX.Element {
30
33
  const { keys, className } = props;
31
- const platform = props.platform ?? detectPlatform();
34
+
35
+ // SSR-safe platform detection: render with the prop value (or "win" default)
36
+ // on the server, then update after hydration when navigator is available.
37
+ const [platform, setPlatform] = React.useState<"mac" | "win">(
38
+ props.platform ?? "win",
39
+ );
40
+
41
+ React.useEffect(() => {
42
+ if (props.platform) return; // explicit prop wins; no client detection needed
43
+ setPlatform(detectPlatform());
44
+ }, [props.platform]);
32
45
 
33
46
  const mapped = keys.map((k) =>
34
47
  platform === "mac"
@@ -56,7 +69,7 @@ export function TwShortcutHint(props: TwShortcutHintProps): React.JSX.Element {
56
69
  <span className={containerClass} data-testid="tw-shortcut-hint">
57
70
  {mapped.map((label, i) => (
58
71
  <kbd
59
- key={i}
72
+ key={`${label}-${i}`}
60
73
  className={chipClass}
61
74
  data-testid="tw-shortcut-hint__chip"
62
75
  >
@@ -89,7 +89,7 @@ export function TwSuggestionCard(props: TwSuggestionCardProps) {
89
89
  </Tooltip.Trigger>
90
90
  <Tooltip.Portal>
91
91
  <Tooltip.Content
92
- className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50"
92
+ className="rounded-md bg-primary px-2 py-1 text-xs text-[var(--color-text-inverse)] shadow-md z-50"
93
93
  sideOffset={6}
94
94
  >
95
95
  {tooltipLabel}
@@ -5,6 +5,7 @@ import type {
5
5
  TableStructureContextSnapshot,
6
6
  } from "../../api/public-types";
7
7
  import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
8
+ import { BRAND_TOKENS } from "../theme/tokens";
8
9
 
9
10
  export interface TwTableContextToolbarProps {
10
11
  disabled: boolean;
@@ -29,13 +30,29 @@ export interface TwTableContextToolbarProps {
29
30
  onSetCellVerticalAlign?: (align: "top" | "center" | "bottom") => void;
30
31
  }
31
32
 
32
- const CELL_COLORS = [
33
+ /**
34
+ * Cell fill presets — user-driven content colors, NOT design-system
35
+ * tokens. Cell fills are document content and must persist across
36
+ * themes exactly as authored, so the palette is a stable component-
37
+ * local constant rather than a theme-adaptive token binding.
38
+ *
39
+ * Slot 1 is pure white (opaque "overwrite" content), slot 2 is
40
+ * "transparent" (no fill — clear existing). The remaining four slots
41
+ * draw from the scope-tint palette so the starter options stay in a
42
+ * consistent soft-pastel family that reads well on both light and dark
43
+ * canvases when reopened.
44
+ *
45
+ * Hosts may extend this palette via future props; R3.c replaced the
46
+ * earlier arbitrary Tailwind-palette picks with values drawn from the
47
+ * token manifest (scope-tint family).
48
+ */
49
+ const CELL_FILL_PRESETS = [
33
50
  "#ffffff",
34
- "#f0f0ee",
35
- "#dbeafe",
36
- "#fef3c7",
37
- "#dcfce7",
38
- "#fce7f3",
51
+ "transparent",
52
+ BRAND_TOKENS.color.scopeTint.inScope,
53
+ BRAND_TOKENS.color.scopeTint.scheduled,
54
+ BRAND_TOKENS.color.scopeTint.suggest,
55
+ BRAND_TOKENS.color.scopeTint.comment,
39
56
  ] as const;
40
57
 
41
58
  /**
@@ -298,7 +315,7 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
298
315
  {tier !== "caret-in-cell" ? (
299
316
  <ToolbarSection label="Fill">
300
317
  <div className="flex items-center gap-1">
301
- {CELL_COLORS.map((color) => (
318
+ {CELL_FILL_PRESETS.map((color) => (
302
319
  <button
303
320
  key={color}
304
321
  type="button"
@@ -102,11 +102,19 @@ export function TwTableGripLayer({
102
102
  const rect = frame.anchorIndex.byTableRowEdge(blockId, rowIndex);
103
103
  if (!rect) return null;
104
104
  const pos = projectRectToOverlay(rect, space);
105
+ // Current row height in twips is derived from the first cell's rect.
106
+ // Without this, mid-drag arithmetic collapses rows to MIN_ROW_TWIPS
107
+ // on any upward (negative-delta) drag.
108
+ const cellRect = frame.anchorIndex.byTableCell(blockId, rowIndex, 0);
109
+ const originalTwips = cellRect
110
+ ? Math.round(cellRect.heightPx / pxPerTwip)
111
+ : MIN_ROW_TWIPS;
105
112
  return (
106
113
  <RowResizeGrip
107
114
  key={`row-${blockId}-${rowIndex}`}
108
115
  pos={pos}
109
116
  rowIndex={rowIndex}
117
+ originalTwips={originalTwips}
110
118
  pxPerTwip={pxPerTwip}
111
119
  disabled={!!disabled || !onSetRowHeight}
112
120
  onCommit={onSetRowHeight}
@@ -143,6 +151,8 @@ function ColResizeGrip({
143
151
  originalTwips: number;
144
152
  dragStarted: boolean;
145
153
  gripEl: HTMLElement;
154
+ // Captured at mousedown so re-renders can't swap out onCommit mid-drag.
155
+ onCommit: ColResizeGripProps["onCommit"];
146
156
  } | null>(null);
147
157
  const [isActive, setIsActive] = useState(false);
148
158
 
@@ -154,9 +164,10 @@ function ColResizeGrip({
154
164
  originalTwips,
155
165
  dragStarted: false,
156
166
  gripEl: e.currentTarget,
167
+ onCommit,
157
168
  };
158
169
  },
159
- [disabled, originalTwips],
170
+ [disabled, originalTwips, onCommit],
160
171
  );
161
172
 
162
173
  useEffect(() => {
@@ -185,7 +196,7 @@ function ColResizeGrip({
185
196
  MIN_COLUMN_TWIPS,
186
197
  Math.round(drag.originalTwips + deltaTwips),
187
198
  );
188
- onCommit?.(colIndex, newTwips);
199
+ drag.onCommit?.(colIndex, newTwips);
189
200
  };
190
201
  window.addEventListener("mousemove", handleMove);
191
202
  window.addEventListener("mouseup", handleUp);
@@ -193,7 +204,7 @@ function ColResizeGrip({
193
204
  window.removeEventListener("mousemove", handleMove);
194
205
  window.removeEventListener("mouseup", handleUp);
195
206
  };
196
- }, [colIndex, pxPerTwip, onCommit]);
207
+ }, [colIndex, pxPerTwip]);
197
208
 
198
209
  return (
199
210
  <div
@@ -226,6 +237,7 @@ function ColResizeGrip({
226
237
  interface RowResizeGripProps {
227
238
  pos: { left: string; top: string; width: string; height: string };
228
239
  rowIndex: number;
240
+ originalTwips: number;
229
241
  pxPerTwip: number;
230
242
  disabled: boolean;
231
243
  onCommit?: (
@@ -238,14 +250,18 @@ interface RowResizeGripProps {
238
250
  function RowResizeGrip({
239
251
  pos,
240
252
  rowIndex,
253
+ originalTwips,
241
254
  pxPerTwip,
242
255
  disabled,
243
256
  onCommit,
244
257
  }: RowResizeGripProps) {
245
258
  const dragRef = useRef<{
246
259
  startY: number;
260
+ originalTwips: number;
247
261
  dragStarted: boolean;
248
262
  gripEl: HTMLElement;
263
+ // Captured at mousedown so re-renders can't swap out onCommit mid-drag.
264
+ onCommit: RowResizeGripProps["onCommit"];
249
265
  } | null>(null);
250
266
  const [isActive, setIsActive] = useState(false);
251
267
 
@@ -254,11 +270,13 @@ function RowResizeGrip({
254
270
  if (disabled) return;
255
271
  dragRef.current = {
256
272
  startY: e.clientY,
273
+ originalTwips,
257
274
  dragStarted: false,
258
275
  gripEl: e.currentTarget,
276
+ onCommit,
259
277
  };
260
278
  },
261
- [disabled],
279
+ [disabled, originalTwips, onCommit],
262
280
  );
263
281
 
264
282
  useEffect(() => {
@@ -282,13 +300,14 @@ function RowResizeGrip({
282
300
  forwardNonDragClick(drag.gripEl, e);
283
301
  return;
284
302
  }
285
- const deltaY = e.clientY - drag.startY;
286
- const deltaTwips = deltaY / pxPerTwip;
287
- // We don't know the current row height from the grip alone; use atLeast
288
- // so that a positive drag expands and a negative drag collapses to auto.
289
- const newTwips = Math.max(MIN_ROW_TWIPS, Math.round(deltaTwips));
290
- const rule = newTwips > 0 ? "atLeast" : "auto";
291
- onCommit?.(rowIndex, newTwips, rule as "atLeast");
303
+ const deltaTwips = (e.clientY - drag.startY) / pxPerTwip;
304
+ const newTwips = Math.max(
305
+ MIN_ROW_TWIPS,
306
+ Math.round(drag.originalTwips + deltaTwips),
307
+ );
308
+ const rule: "atLeast" | "auto" =
309
+ newTwips > MIN_ROW_TWIPS ? "atLeast" : "auto";
310
+ drag.onCommit?.(rowIndex, newTwips, rule);
292
311
  };
293
312
  window.addEventListener("mousemove", handleMove);
294
313
  window.addEventListener("mouseup", handleUp);
@@ -296,7 +315,7 @@ function RowResizeGrip({
296
315
  window.removeEventListener("mousemove", handleMove);
297
316
  window.removeEventListener("mouseup", handleUp);
298
317
  };
299
- }, [rowIndex, pxPerTwip, onCommit]);
318
+ }, [rowIndex, pxPerTwip]);
300
319
 
301
320
  return (
302
321
  <div
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Lane 6d — Slice N5: pure resolvers for page borders + column separators.
3
+ *
4
+ * Page-border data is parsed from OOXML `<w:pgBorders>` and surfaced on
5
+ * `PageLayoutSnapshot.pageBorders`. Units follow OOXML conventions:
6
+ *
7
+ * - `size` → border width in **eighths of a point** (`w:sz`).
8
+ * - `space` → spacing from page edge in **points** (`w:space`).
9
+ * - `value` → OOXML border-style name (`single`, `double`, …).
10
+ *
11
+ * 1 pt = 96/72 px at 96 dpi → these helpers convert into px so the
12
+ * overlay can render inline `style="…"` declarations.
13
+ */
14
+
15
+ import type { PageLayoutSnapshot } from "../../api/public-types";
16
+
17
+ const PX_PER_PT = 96 / 72;
18
+ const PX_PER_EIGHTH_PT = PX_PER_PT / 8;
19
+
20
+ /**
21
+ * Convert an OOXML `w:sz` (eighths of a point) into pixels.
22
+ */
23
+ export function borderSizeToPx(sizeEighthsPt: number | undefined): number {
24
+ if (!sizeEighthsPt || sizeEighthsPt <= 0) return 0;
25
+ return sizeEighthsPt * PX_PER_EIGHTH_PT;
26
+ }
27
+
28
+ /**
29
+ * Convert an OOXML `w:space` (points) into pixels.
30
+ */
31
+ export function borderSpaceToPx(spacePt: number | undefined): number {
32
+ if (!spacePt || spacePt <= 0) return 0;
33
+ return spacePt * PX_PER_PT;
34
+ }
35
+
36
+ /**
37
+ * Map OOXML border-style names onto the closest CSS `border-style`.
38
+ * Unknown / fancy variants (`wave`, `dotDash`, `triple`, …) collapse to
39
+ * `solid` so the page still gets a visible edge — matching Word's
40
+ * fallback behavior in non-Word renderers.
41
+ */
42
+ export function borderValueToCss(value: string | undefined): string {
43
+ if (!value) return "solid";
44
+ switch (value) {
45
+ case "none":
46
+ case "nil":
47
+ return "none";
48
+ case "single":
49
+ case "thick":
50
+ case "thinThickSmallGap":
51
+ case "thickThinSmallGap":
52
+ case "thinThickThinSmallGap":
53
+ case "thinThickMediumGap":
54
+ case "thickThinMediumGap":
55
+ case "thinThickThinMediumGap":
56
+ case "thinThickLargeGap":
57
+ case "thickThinLargeGap":
58
+ case "thinThickThinLargeGap":
59
+ return "solid";
60
+ case "double":
61
+ return "double";
62
+ case "dotted":
63
+ case "dottedHeavy":
64
+ return "dotted";
65
+ case "dashed":
66
+ case "dashedHeavy":
67
+ case "dashSmallGap":
68
+ case "dashDotStroked":
69
+ return "dashed";
70
+ default:
71
+ return "solid";
72
+ }
73
+ }
74
+
75
+ export interface PageBorderDecisionInput {
76
+ /** 0-based index of the page within its owning section. */
77
+ pageInSection: number;
78
+ /** Total page count in the section (informational; reserved for future rules). */
79
+ pageCountInSection?: number;
80
+ /** Display policy from `pageBorders.display`; missing → `allPages`. */
81
+ display?: "allPages" | "firstPage" | "notFirstPage";
82
+ }
83
+
84
+ /**
85
+ * Apply Word's `display` policy to decide whether the border paints on a
86
+ * given page. `allPages` (or undefined) → always; `firstPage` → only
87
+ * page 0 in its section; `notFirstPage` → every page except page 0.
88
+ */
89
+ export function shouldDisplayPageBorderOnPage(
90
+ input: PageBorderDecisionInput,
91
+ ): boolean {
92
+ const policy = input.display ?? "allPages";
93
+ switch (policy) {
94
+ case "allPages":
95
+ return true;
96
+ case "firstPage":
97
+ return input.pageInSection === 0;
98
+ case "notFirstPage":
99
+ return input.pageInSection > 0;
100
+ }
101
+ }
102
+
103
+ export interface ResolvedBorderEdge {
104
+ /** CSS `border-{edge}-style` value. */
105
+ style: string;
106
+ /** Width in pixels (already converted from `w:sz` units). */
107
+ widthPx: number;
108
+ /** Color string ready for inline style; defaults to `currentColor` when no color set. */
109
+ color: string;
110
+ /** Distance from the page (or text margin) edge, in pixels. */
111
+ spacePx: number;
112
+ }
113
+
114
+ /**
115
+ * Resolve a single border-spec into render-ready CSS values. Returns
116
+ * `null` when the spec is `none`/`nil` or has zero width — the caller
117
+ * should skip rendering that edge.
118
+ */
119
+ export function resolveBorderEdge(
120
+ spec: { value?: string; size?: number; space?: number; color?: string } | undefined,
121
+ ): ResolvedBorderEdge | null {
122
+ if (!spec) return null;
123
+ const style = borderValueToCss(spec.value);
124
+ if (style === "none") return null;
125
+ const widthPx = borderSizeToPx(spec.size);
126
+ if (widthPx <= 0) return null;
127
+ return {
128
+ style,
129
+ widthPx,
130
+ color: spec.color && spec.color !== "auto" ? `#${spec.color}` : "currentColor",
131
+ spacePx: borderSpaceToPx(spec.space),
132
+ };
133
+ }
134
+
135
+ export interface ResolvedPageBorders {
136
+ /** When `false`, the overlay should render nothing for this page. */
137
+ display: boolean;
138
+ /** Distance anchor — page edge or text-margin edge. */
139
+ offsetFrom: "page" | "text";
140
+ /** zOrder — `back` paints below content, `front` above (overlay default). */
141
+ zOrder: "front" | "back";
142
+ /** Per-edge resolved borders; missing keys mean "do not render this edge". */
143
+ top: ResolvedBorderEdge | null;
144
+ right: ResolvedBorderEdge | null;
145
+ bottom: ResolvedBorderEdge | null;
146
+ left: ResolvedBorderEdge | null;
147
+ }
148
+
149
+ /**
150
+ * Top-level resolver. Combines display-policy gating with per-edge
151
+ * conversion. Returns `null` when the section has no `pageBorders` at
152
+ * all so the overlay can short-circuit.
153
+ */
154
+ export function resolvePageBorders(
155
+ layout: PageLayoutSnapshot | undefined,
156
+ pageInSection: number,
157
+ ): ResolvedPageBorders | null {
158
+ const pageBorders = layout?.pageBorders;
159
+ if (!pageBorders) return null;
160
+
161
+ const display = shouldDisplayPageBorderOnPage({
162
+ pageInSection,
163
+ display: pageBorders.display,
164
+ });
165
+
166
+ return {
167
+ display,
168
+ offsetFrom: pageBorders.offsetFrom ?? "page",
169
+ zOrder: pageBorders.zOrder ?? "front",
170
+ top: resolveBorderEdge(pageBorders.top),
171
+ right: resolveBorderEdge(pageBorders.right),
172
+ bottom: resolveBorderEdge(pageBorders.bottom),
173
+ left: resolveBorderEdge(pageBorders.left),
174
+ };
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Column separators (P11.7)
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * Compute pixel x-offsets (relative to the content box) where vertical
183
+ * column-separator lines should paint. Separators sit centered in the
184
+ * gap between adjacent columns.
185
+ *
186
+ * Returns `[]` when separators are disabled, columns are equal-width
187
+ * (Word draws no separator there unless explicitly requested), or the
188
+ * snapshot has fewer than 2 columns.
189
+ */
190
+ export function resolveColumnSeparatorXOffsets(
191
+ layout: PageLayoutSnapshot | undefined,
192
+ ): number[] {
193
+ if (!layout?.columnSeparator) return [];
194
+ const cols = layout.columnDefinitions ?? [];
195
+ if (cols.length < 2) return [];
196
+
197
+ // OOXML column widths + spaces are in twips; 1 px ≈ 15 twips at 96 dpi.
198
+ const TWIPS_PER_PX = 1440 / 96;
199
+ const offsets: number[] = [];
200
+ let cursorPx = 0;
201
+ for (let i = 0; i < cols.length - 1; i += 1) {
202
+ const col = cols[i]!;
203
+ const widthPx = col.width / TWIPS_PER_PX;
204
+ const spacePx = (col.space ?? 0) / TWIPS_PER_PX;
205
+ cursorPx += widthPx;
206
+ // Separator centers in the gap between column[i] and column[i+1].
207
+ offsets.push(cursorPx + spacePx / 2);
208
+ cursorPx += spacePx;
209
+ }
210
+ return offsets;
211
+ }
@@ -209,6 +209,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
209
209
  facet={facet}
210
210
  scrollRoot={pageStackScrollRoot}
211
211
  renderFrameRevision={renderFrameRevision ?? 0}
212
+ visiblePageIndexRange={visiblePageIndexRange ?? null}
212
213
  />
213
214
  ) : null}
214
215
  {pageStackScrollRoot !== undefined ? (