@beyondwork/docx-react-component 1.0.58 → 1.0.59

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 (134) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -1
  3. package/src/api/awareness-identity-types.ts +4 -2
  4. package/src/api/comment-negotiation-types.ts +4 -1
  5. package/src/api/external-custody-types.ts +16 -0
  6. package/src/api/internal/build-ref-projections.ts +108 -0
  7. package/src/api/package-version.ts +1 -1
  8. package/src/api/participants-types.ts +11 -1
  9. package/src/api/public-types.ts +978 -10
  10. package/src/api/scope-metadata-resolver-types.ts +6 -0
  11. package/src/compare/diff-engine.ts +3 -0
  12. package/src/core/commands/formatting-commands.ts +1 -0
  13. package/src/core/commands/index.ts +225 -16
  14. package/src/core/commands/legacy-form-field-commands.ts +181 -0
  15. package/src/core/commands/table-structure-commands.ts +149 -31
  16. package/src/core/selection/mapping.ts +20 -0
  17. package/src/core/state/editor-state.ts +2 -1
  18. package/src/index.ts +28 -0
  19. package/src/io/docx-session.ts +22 -3
  20. package/src/io/export/export-session.ts +11 -7
  21. package/src/io/export/ooxml-namespaces.ts +47 -0
  22. package/src/io/export/reattach-preserved-parts.ts +4 -16
  23. package/src/io/export/serialize-comments.ts +3 -131
  24. package/src/io/export/serialize-ffdata.ts +89 -0
  25. package/src/io/export/serialize-headers-footers.ts +5 -0
  26. package/src/io/export/serialize-main-document.ts +224 -34
  27. package/src/io/export/serialize-numbering.ts +22 -2
  28. package/src/io/export/serialize-revisions.ts +99 -0
  29. package/src/io/export/serialize-tables.ts +9 -0
  30. package/src/io/export/split-review-boundaries.ts +1 -0
  31. package/src/io/export/table-properties-xml.ts +14 -0
  32. package/src/io/load-scheduler.ts +70 -28
  33. package/src/io/normalize/normalize-text.ts +13 -0
  34. package/src/io/ooxml/_mini-xml.ts +198 -0
  35. package/src/io/ooxml/canonicalize-payload.ts +1 -4
  36. package/src/io/ooxml/chart/chart-style-table.ts +4 -3
  37. package/src/io/ooxml/chart/parse-chart-space.ts +2 -4
  38. package/src/io/ooxml/chart/parse-series.ts +2 -1
  39. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  40. package/src/io/ooxml/chart/types.ts +6 -434
  41. package/src/io/ooxml/comment-presentation-payload.ts +6 -5
  42. package/src/io/ooxml/highlight-colors.ts +8 -5
  43. package/src/io/ooxml/parse-anchor.ts +68 -53
  44. package/src/io/ooxml/parse-comments.ts +14 -142
  45. package/src/io/ooxml/parse-complex-content.ts +3 -106
  46. package/src/io/ooxml/parse-drawing.ts +100 -195
  47. package/src/io/ooxml/parse-ffdata.ts +93 -0
  48. package/src/io/ooxml/parse-fields.ts +7 -146
  49. package/src/io/ooxml/parse-fill.ts +88 -8
  50. package/src/io/ooxml/parse-font-table.ts +5 -105
  51. package/src/io/ooxml/parse-footnotes.ts +28 -152
  52. package/src/io/ooxml/parse-headers-footers.ts +106 -212
  53. package/src/io/ooxml/parse-inline-media.ts +3 -200
  54. package/src/io/ooxml/parse-main-document.ts +180 -217
  55. package/src/io/ooxml/parse-numbering.ts +154 -335
  56. package/src/io/ooxml/parse-object.ts +147 -0
  57. package/src/io/ooxml/parse-ole-relationship.ts +82 -0
  58. package/src/io/ooxml/parse-paragraph-formatting.ts +7 -10
  59. package/src/io/ooxml/parse-picture-sdt.ts +85 -0
  60. package/src/io/ooxml/parse-picture.ts +72 -42
  61. package/src/io/ooxml/parse-revisions.ts +285 -51
  62. package/src/io/ooxml/parse-settings.ts +6 -99
  63. package/src/io/ooxml/parse-shapes.ts +25 -140
  64. package/src/io/ooxml/parse-styles.ts +3 -218
  65. package/src/io/ooxml/parse-tables.ts +76 -256
  66. package/src/io/ooxml/parse-theme.ts +1 -4
  67. package/src/io/ooxml/property-grab-bag.ts +5 -47
  68. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  69. package/src/io/ooxml/xml-parser.ts +183 -0
  70. package/src/legal/bookmarks.ts +1 -1
  71. package/src/legal/cross-references.ts +1 -1
  72. package/src/legal/defined-terms.ts +1 -1
  73. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  74. package/src/legal/signature-blocks.ts +1 -1
  75. package/src/model/canonical-document.ts +159 -6
  76. package/src/model/chart-types.ts +439 -0
  77. package/src/model/snapshot.ts +3 -1
  78. package/src/review/store/comment-remapping.ts +24 -11
  79. package/src/review/store/revision-actions.ts +482 -2
  80. package/src/review/store/revision-store.ts +15 -0
  81. package/src/review/store/revision-types.ts +76 -0
  82. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  83. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  84. package/src/runtime/diagnostics/build-diagnostic.ts +151 -0
  85. package/src/runtime/diagnostics/code-metadata-table.ts +221 -0
  86. package/src/runtime/document-runtime.ts +476 -34
  87. package/src/runtime/document-search.ts +115 -0
  88. package/src/runtime/edit-ops/index.ts +18 -2
  89. package/src/runtime/footnote-resolver.ts +130 -0
  90. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  91. package/src/runtime/layout/layout-engine-version.ts +37 -1
  92. package/src/runtime/layout/page-graph.ts +14 -1
  93. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  94. package/src/runtime/numbering-prefix.ts +17 -0
  95. package/src/runtime/query-scopes.ts +5 -8
  96. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  97. package/src/runtime/revision-runtime.ts +27 -1
  98. package/src/runtime/selection/post-edit-validator.ts +60 -6
  99. package/src/runtime/structure-ops/index.ts +20 -4
  100. package/src/runtime/surface-projection.ts +290 -21
  101. package/src/runtime/table-schema.ts +6 -0
  102. package/src/runtime/theme-color-resolver.ts +2 -2
  103. package/src/runtime/units.ts +9 -0
  104. package/src/runtime/workflow-rail-segments.ts +4 -0
  105. package/src/ui/WordReviewEditor.tsx +187 -43
  106. package/src/ui/editor-runtime-boundary.ts +10 -0
  107. package/src/ui/editor-shell-view.tsx +4 -1
  108. package/src/ui/headless/chrome-registry.ts +53 -0
  109. package/src/ui/headless/selection-tool-resolver.ts +11 -1
  110. package/src/ui-tailwind/chrome/chrome-preset-model.ts +13 -0
  111. package/src/ui-tailwind/chrome/tw-command-palette-mount.tsx +96 -0
  112. package/src/ui-tailwind/chrome/tw-context-menu.tsx +2 -1
  113. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +5 -4
  114. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +6 -2
  115. package/src/ui-tailwind/chrome/use-container-breakpoint.ts +111 -0
  116. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +0 -9
  117. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +1 -0
  118. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +6 -7
  119. package/src/ui-tailwind/editor-surface/pm-schema.ts +87 -25
  120. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +9 -0
  121. package/src/ui-tailwind/editor-surface/shape-renderer.ts +76 -14
  122. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +18 -1
  123. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +2 -0
  124. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +18 -2
  125. package/src/ui-tailwind/index.ts +9 -0
  126. package/src/ui-tailwind/page-chrome-model.ts +77 -5
  127. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +56 -1
  128. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +2 -0
  129. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +116 -113
  130. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +2 -2
  131. package/src/ui-tailwind/theme/tokens.ts +14 -0
  132. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +5 -0
  133. package/src/ui-tailwind/tw-review-workspace.tsx +29 -87
  134. package/src/validation/diagnostics.ts +1 -0
@@ -17,7 +17,7 @@
17
17
  * fill; `noLine: true` → `stroke: "none"`.
18
18
  */
19
19
 
20
- import { EMU_PER_PX } from "../../runtime/units";
20
+ import { EMU_PER_PX, ROTATION_UNITS_PER_DEGREE } from "../../runtime/units";
21
21
 
22
22
  const SUPPORTED_GEOMETRIES = new Set(["rect", "ellipse", "roundRect"]);
23
23
 
@@ -81,9 +81,18 @@ export function resolveLineCss(line: ShapeLine | undefined): ResolvedLineCss {
81
81
  return { stroke, strokeWidth };
82
82
  }
83
83
 
84
+ export type GradientFill = {
85
+ kind: "gradient";
86
+ stops: Array<{ pos: number; color: string; colorType: "srgbClr" | "schemeClr" }>;
87
+ direction:
88
+ | { kind: "linear"; angle: number; scaled?: boolean }
89
+ | { kind: "path"; path: "circle" | "rect" | "shape" };
90
+ rotWithShape?: boolean;
91
+ };
92
+
84
93
  export interface ShapeSegmentLike {
85
94
  geometry?: string;
86
- fill?: ShapeFill;
95
+ fill?: ShapeFill | GradientFill;
87
96
  line?: ShapeLine;
88
97
  }
89
98
 
@@ -94,6 +103,44 @@ export interface ShapeSegmentLike {
94
103
  */
95
104
  export type SvgSpec = readonly [string, Record<string, string>, ...SvgSpec[]];
96
105
 
106
+ /**
107
+ * Build an SVG `<defs>` element containing a `<linearGradient>` or
108
+ * `<radialGradient>` for the given gradient fill. `id` is the gradient
109
+ * element ID referenced via `fill="url(#id)"` on the geometry element.
110
+ *
111
+ * OOXML linear gradient angle: 60000ths of a degree, clockwise from north.
112
+ * Converted to SVG objectBoundingBox coordinates via:
113
+ * x1 = 0.5 − 0.5·sin(θ), y1 = 0.5 + 0.5·cos(θ)
114
+ * x2 = 0.5 + 0.5·sin(θ), y2 = 0.5 − 0.5·cos(θ)
115
+ * where θ is in radians.
116
+ *
117
+ * OOXML stop pos: 0–100000 (= 0–100%).
118
+ */
119
+ function renderGradientDefs(fill: GradientFill, id: string): SvgSpec {
120
+ const stopEls: SvgSpec[] = fill.stops.map(
121
+ (s): SvgSpec => [
122
+ "stop",
123
+ {
124
+ offset: `${(s.pos / 1000).toFixed(2)}%`,
125
+ "stop-color": s.colorType === "srgbClr" ? `#${s.color}` : "currentColor",
126
+ },
127
+ ],
128
+ );
129
+
130
+ let gradEl: SvgSpec;
131
+ if (fill.direction.kind === "linear") {
132
+ const rad = (fill.direction.angle / ROTATION_UNITS_PER_DEGREE) * (Math.PI / 180);
133
+ const x1 = (0.5 - 0.5 * Math.sin(rad)).toFixed(4);
134
+ const y1 = (0.5 + 0.5 * Math.cos(rad)).toFixed(4);
135
+ const x2 = (0.5 + 0.5 * Math.sin(rad)).toFixed(4);
136
+ const y2 = (0.5 - 0.5 * Math.cos(rad)).toFixed(4);
137
+ gradEl = ["linearGradient", { id, x1, y1, x2, y2, gradientUnits: "objectBoundingBox" }, ...stopEls];
138
+ } else {
139
+ gradEl = ["radialGradient", { id, cx: "50%", cy: "50%", r: "50%", gradientUnits: "objectBoundingBox" }, ...stopEls];
140
+ }
141
+ return ["defs", {}, gradEl];
142
+ }
143
+
97
144
  /**
98
145
  * Render a supported geometry into a PM-compatible DOMOutputSpec tree
99
146
  * for an inline `<svg>`. Returns `null` when the geometry is unsupported
@@ -101,6 +148,9 @@ export type SvgSpec = readonly [string, Record<string, string>, ...SvgSpec[]];
101
148
  *
102
149
  * The SVG is sized 1:1 to its container; the wrapper span owns the
103
150
  * outer `width:Xpx; height:Ypx`.
151
+ *
152
+ * Gradient fills: emits `<defs><linearGradient>` / `<radialGradient>` and
153
+ * references it via `fill="url(#g0)"` on the geometry element.
104
154
  */
105
155
  export function renderShapeSvg(
106
156
  segment: ShapeSegmentLike,
@@ -111,7 +161,19 @@ export function renderShapeSvg(
111
161
  if (!segment.geometry || !SUPPORTED_GEOMETRIES.has(segment.geometry)) {
112
162
  return null;
113
163
  }
114
- const fillCss = resolveFillCss(segment.fill);
164
+
165
+ let fillAttr: string;
166
+ let gradDefs: SvgSpec | null = null;
167
+ if (segment.fill && segment.fill.kind === "gradient") {
168
+ gradDefs = renderGradientDefs(segment.fill, "g0");
169
+ fillAttr = "url(#g0)";
170
+ } else {
171
+ fillAttr = resolveFillCss(segment.fill as ShapeFill | undefined).fill;
172
+ }
173
+
174
+ // Keep resolveFillCss call for non-gradient so the isSchemePlaceholder
175
+ // warning path (future) still fires when needed.
176
+ const fillCss = { fill: fillAttr, isSchemePlaceholder: false };
115
177
  const lineCss = resolveLineCss(segment.line);
116
178
  const sw = lineCss.strokeWidth;
117
179
  // Inset the geometry by half the stroke so the stroke paints inside
@@ -184,17 +246,17 @@ export function renderShapeSvg(
184
246
  // `xmlns` *attribute* after createElement() is meaningless — the
185
247
  // resulting node is HTMLUnknownElement and won't paint as SVG.
186
248
  // Children inherit the namespace, so the geometry tag stays bare.
187
- return [
188
- "http://www.w3.org/2000/svg svg",
189
- {
190
- viewBox: `0 0 ${widthPx} ${heightPx}`,
191
- width: String(widthPx),
192
- height: String(heightPx),
193
- preserveAspectRatio: "none",
194
- "aria-hidden": "true",
195
- },
196
- geometryEl,
197
- ];
249
+ const svgAttrs = {
250
+ viewBox: `0 0 ${widthPx} ${heightPx}`,
251
+ width: String(widthPx),
252
+ height: String(heightPx),
253
+ preserveAspectRatio: "none",
254
+ "aria-hidden": "true",
255
+ };
256
+ if (gradDefs) {
257
+ return ["http://www.w3.org/2000/svg svg", svgAttrs, gradDefs, geometryEl];
258
+ }
259
+ return ["http://www.w3.org/2000/svg svg", svgAttrs, geometryEl];
198
260
  }
199
261
 
200
262
  /**
@@ -71,6 +71,20 @@ export function resolveMarkerJustificationCss(raw: string | undefined): string {
71
71
  }
72
72
  }
73
73
 
74
+ export function resolveMarkerAlignCss(raw: string | undefined): React.CSSProperties["textAlign"] {
75
+ switch (raw) {
76
+ case "left":
77
+ return "left";
78
+ case "center":
79
+ return "center";
80
+ case "right":
81
+ case "both":
82
+ case "distribute":
83
+ default:
84
+ return "right";
85
+ }
86
+ }
87
+
74
88
  /** Build CSSProperties for a paragraph block from spacing/indent/alignment. */
75
89
  export function buildParagraphStyle(
76
90
  block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
@@ -144,11 +158,12 @@ export function buildMarkerStyle(
144
158
  suffix: "tab" | "space" | "nothing" | undefined,
145
159
  markerRunProperties: CanonicalRunFormatting | undefined,
146
160
  markerWidth: number | undefined,
161
+ markerStart: number | undefined,
147
162
  markerJustification: string | undefined,
148
163
  ): React.CSSProperties {
149
164
  const style: React.CSSProperties = {
150
165
  fontVariantNumeric: "tabular-nums",
151
- justifyContent: resolveMarkerJustificationCss(markerJustification),
166
+ textAlign: resolveMarkerAlignCss(markerJustification),
152
167
  };
153
168
 
154
169
  if (markerRunProperties) {
@@ -180,8 +195,10 @@ export function buildMarkerStyle(
180
195
  style.width = `${markerWidthPt}pt`;
181
196
  style.minWidth = `${markerWidthPt}pt`;
182
197
  style.flexBasis = `${markerWidthPt}pt`;
198
+ style.marginLeft = `-${markerWidthPt}pt`;
183
199
  style.marginRight = 0;
184
200
  style.overflow = "visible";
201
+ void markerStart; // consumed via paragraph padding-left geometry
185
202
  } else {
186
203
  const fallbackMinWidth = Math.min(Math.max(prefix.length + 1, 4), 14);
187
204
  const fallbackMarginRight =
@@ -142,6 +142,7 @@ function ParagraphBlock({
142
142
  const resolvedNumbering = block.resolvedNumbering;
143
143
  const markerRunProperties = resolvedNumbering?.markerRunProperties;
144
144
  const markerWidth = resolvedNumbering?.geometry?.markerLane?.width;
145
+ const markerStart = resolvedNumbering?.geometry?.markerLane?.start;
145
146
  const markerJustification = resolvedNumbering?.geometry?.markerJustification;
146
147
 
147
148
  const prefixSpan =
@@ -164,6 +165,7 @@ function ParagraphBlock({
164
165
  numberingSuffix,
165
166
  markerRunProperties,
166
167
  markerWidth,
168
+ markerStart,
167
169
  markerJustification,
168
170
  )}
169
171
  >
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { Node as PMNode } from "prosemirror-model";
12
12
  import type { NodeViewConstructor, ViewMutationRecord } from "prosemirror-view";
13
+ import { PERCENTAGE_PARTS } from "../../runtime/units.ts";
13
14
 
14
15
  // R2c: band class styles live in ./tw-table-bands.module.css. Consumers import
15
16
  // that stylesheet through their build pipeline (same pattern as editor-theme.css).
@@ -325,7 +326,7 @@ function applyTableAttrs(table: HTMLTableElement, node: PMNode): void {
325
326
  const tableWidthType = node.attrs.tableWidthType as string | null | undefined;
326
327
  let baseClasses = "border-collapse w-full my-2 text-sm";
327
328
  if (tableWidthType === "pct" && typeof tableWidth === "number") {
328
- // OOXML pct widths are fiftieths of a percent (5000 = 100%).
329
+ // OOXML pct widths are fiftieths of a percent (PERCENTAGE_PARTS = 100%).
329
330
  table.style.width = `${tableWidth / 50}%`;
330
331
  baseClasses = "border-collapse my-2 text-sm";
331
332
  } else if (tableWidthType === "dxa" && typeof tableWidth === "number") {
@@ -406,6 +407,14 @@ function syncColgroup(table: HTMLTableElement, node: PMNode): void {
406
407
  const gridColumns = Array.isArray(node.attrs.gridColumns)
407
408
  ? (node.attrs.gridColumns as number[])
408
409
  : [];
410
+ // SOW gap G1 — percent widths win when the table itself is sized in
411
+ // percent. The relative array sums to 100 and comes from
412
+ // `computeRelativeGridColumns` in surface-projection so the column
413
+ // proportions track the container instead of the absolute `pt` widths
414
+ // sliding against it. `null` (the default) keeps the legacy pt path.
415
+ const gridColumnsRelative = Array.isArray(node.attrs.gridColumnsRelative)
416
+ ? (node.attrs.gridColumnsRelative as number[])
417
+ : null;
409
418
  const existing = Array.from(table.children).find(
410
419
  (child): child is HTMLTableColElement =>
411
420
  child instanceof (table.ownerDocument?.defaultView?.HTMLTableColElement ??
@@ -429,12 +438,19 @@ function syncColgroup(table: HTMLTableElement, node: PMNode): void {
429
438
  while (colgroup.childElementCount > desired) {
430
439
  colgroup.lastElementChild?.remove();
431
440
  }
441
+ const usePct =
442
+ gridColumnsRelative !== null && gridColumnsRelative.length === desired;
432
443
  for (let i = 0; i < desired; i += 1) {
433
444
  const col = colgroup.children[i] as HTMLTableColElement;
434
445
  const twips = gridColumns[i] ?? 0;
435
446
  col.setAttribute("data-col-index", String(i));
436
447
  col.setAttribute("data-col-twips", String(twips));
437
- col.style.width = twips > 0 ? `${twips / 20}pt` : "";
448
+ if (usePct) {
449
+ const pct = gridColumnsRelative[i] ?? 0;
450
+ col.style.width = pct > 0 ? `${pct.toFixed(4)}%` : "";
451
+ } else {
452
+ col.style.width = twips > 0 ? `${twips / 20}pt` : "";
453
+ }
438
454
  }
439
455
 
440
456
  if (!existing) {
@@ -67,6 +67,15 @@ export {
67
67
  type CommandPaletteItem,
68
68
  type TwCommandPaletteProps,
69
69
  } from "./chrome/tw-command-palette";
70
+ export {
71
+ TwCommandPaletteMount,
72
+ type TwCommandPaletteMountProps,
73
+ } from "./chrome/tw-command-palette-mount";
74
+ export {
75
+ useContainerBreakpoint,
76
+ resolveBreakpoint,
77
+ type BreakpointMap,
78
+ } from "./chrome/use-container-breakpoint";
70
79
 
71
80
  // Collab chrome (P9) — mount when chromePreset === "collab"; each
72
81
  // component is pure presentational and takes snapshots + callbacks.
@@ -3,6 +3,16 @@ import type {
3
3
  PageLayoutSnapshot,
4
4
  SurfaceBlockSnapshot,
5
5
  } from "../api/public-types.ts";
6
+ import { findPageForOffset } from "../runtime/document-navigation.ts";
7
+ import {
8
+ DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
9
+ estimateBlockHeight,
10
+ estimateParagraphLineCount,
11
+ estimateParagraphLineHeight,
12
+ getUsableColumnWidth,
13
+ } from "../runtime/page-layout-estimation.ts";
14
+
15
+ const DOCUMENT_CONTENT_TOP_PADDING_PX = 40;
6
16
 
7
17
  export interface LineMarker {
8
18
  id: string;
@@ -14,14 +24,76 @@ export function computeLineMarkersIfEnabled(input: {
14
24
  pageLayout: PageLayoutSnapshot | undefined;
15
25
  surfaceBlocks: readonly SurfaceBlockSnapshot[];
16
26
  pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>;
17
- buildLineNumberMarkers: (
18
- blocks: readonly SurfaceBlockSnapshot[],
19
- pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,
20
- ) => LineMarker[];
21
27
  }): LineMarker[] {
22
28
  if (!input.pageLayout?.lineNumbering) {
23
29
  return [];
24
30
  }
25
31
 
26
- return input.buildLineNumberMarkers(input.surfaceBlocks, input.pages);
32
+ return buildLineNumberMarkers(input.surfaceBlocks, input.pages);
33
+ }
34
+
35
+ function buildLineNumberMarkers(
36
+ blocks: readonly SurfaceBlockSnapshot[],
37
+ pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,
38
+ ): LineMarker[] {
39
+ const markers: LineMarker[] = [];
40
+ if (pages.length === 0) {
41
+ return markers;
42
+ }
43
+
44
+ let currentTopTwips = 0;
45
+ let lineNumber = 1;
46
+ let lastPageIndex = -1;
47
+ let lastSectionIndex = -1;
48
+
49
+ for (const block of blocks) {
50
+ const pageIndex = findPageForOffset(pages, block.from);
51
+ const page = pages[pageIndex];
52
+ if (!page) {
53
+ continue;
54
+ }
55
+
56
+ const lineNumbering = page.layout.lineNumbering;
57
+ const restartMode = lineNumbering?.restart ?? "newPage";
58
+ const restartStart = lineNumbering?.start ?? 1;
59
+ const countBy = Math.max(1, lineNumbering?.countBy ?? 1);
60
+ const columnWidth = getUsableColumnWidth(page.layout);
61
+
62
+ if (pageIndex !== lastPageIndex) {
63
+ if (restartMode === "newPage" || lastPageIndex === -1) {
64
+ lineNumber = restartStart;
65
+ }
66
+ lastPageIndex = pageIndex;
67
+ }
68
+ if (page.sectionIndex !== lastSectionIndex) {
69
+ if (restartMode === "newSection" || lastSectionIndex === -1) {
70
+ lineNumber = restartStart;
71
+ }
72
+ lastSectionIndex = page.sectionIndex;
73
+ }
74
+
75
+ if (block.kind === "paragraph" && lineNumbering) {
76
+ const lineCount = estimateParagraphLineCount(block, columnWidth);
77
+ const lineHeight = estimateParagraphLineHeight(block);
78
+ const suppress = block.suppressLineNumbers === true;
79
+ for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) {
80
+ if (!suppress && (lineNumber - restartStart) % countBy === 0) {
81
+ markers.push({
82
+ id: `${block.blockId}-${lineIndex}`,
83
+ label: String(lineNumber),
84
+ topPx:
85
+ DOCUMENT_CONTENT_TOP_PADDING_PX +
86
+ (currentTopTwips + lineIndex * lineHeight) * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
87
+ });
88
+ }
89
+ if (!suppress) {
90
+ lineNumber += 1;
91
+ }
92
+ }
93
+ }
94
+
95
+ currentTopTwips += estimateBlockHeight(block, columnWidth);
96
+ }
97
+
98
+ return markers;
27
99
  }
@@ -147,7 +147,7 @@ export interface TwPageStackChromeLayerProps {
147
147
  "data-testid"?: string;
148
148
  }
149
149
 
150
- export const TwPageStackChromeLayer: React.FC<TwPageStackChromeLayerProps> = ({
150
+ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
151
151
  facet,
152
152
  scrollRoot,
153
153
  renderFrameRevision,
@@ -409,4 +409,59 @@ export const TwPageStackChromeLayer: React.FC<TwPageStackChromeLayerProps> = ({
409
409
  );
410
410
  };
411
411
 
412
+ function storyTargetEqual(
413
+ a: TwPageStackChromeLayerProps["activeStory"],
414
+ b: TwPageStackChromeLayerProps["activeStory"],
415
+ ): boolean {
416
+ if (a.kind !== b.kind) return false;
417
+ if (a.kind === "main") return true;
418
+ if (a.kind === "footnote" || a.kind === "endnote") {
419
+ // TS narrows a to { noteId: string }; b shares the same kind (guard above)
420
+ return a.noteId === (b as Extract<typeof b, { noteId: string }>).noteId;
421
+ }
422
+ if (a.kind === "header" || a.kind === "footer") {
423
+ const bh = b as Extract<typeof b, { kind: "header" | "footer" }>;
424
+ return (
425
+ a.relationshipId === bh.relationshipId &&
426
+ a.variant === bh.variant &&
427
+ a.sectionIndex === bh.sectionIndex
428
+ );
429
+ }
430
+ return false;
431
+ }
432
+
433
+ function rangeEqual(
434
+ a: { start: number; end: number } | null | undefined,
435
+ b: { start: number; end: number } | null | undefined,
436
+ ): boolean {
437
+ if (a == null && b == null) return true;
438
+ if (a == null || b == null) return false;
439
+ return a.start === b.start && a.end === b.end;
440
+ }
441
+
442
+ function propsAreEqual(
443
+ prev: TwPageStackChromeLayerProps,
444
+ next: TwPageStackChromeLayerProps,
445
+ ): boolean {
446
+ return (
447
+ prev.facet === next.facet &&
448
+ prev.scrollRoot === next.scrollRoot &&
449
+ prev.renderFrameRevision === next.renderFrameRevision &&
450
+ storyTargetEqual(prev.activeStory, next.activeStory) &&
451
+ prev.onOpenStory === next.onOpenStory &&
452
+ prev.pmSurfaceElement === next.pmSurfaceElement &&
453
+ prev.pmView === next.pmView &&
454
+ rangeEqual(prev.visiblePageIndexRange, next.visiblePageIndexRange) &&
455
+ prev["data-testid"] === next["data-testid"]
456
+ );
457
+ }
458
+
459
+ export const TwPageStackChromeLayer = React.memo(
460
+ TwPageStackChromeLayerInner,
461
+ propsAreEqual,
462
+ );
463
+
464
+ /** Exported for unit testing only. */
465
+ export { propsAreEqual as _propsAreEqualForTest };
466
+
412
467
  export default TwPageStackChromeLayer;
@@ -140,6 +140,7 @@ function RegionParagraph({
140
140
  const resolvedNumbering = block.resolvedNumbering;
141
141
  const markerRunProperties = resolvedNumbering?.markerRunProperties;
142
142
  const markerWidth = resolvedNumbering?.geometry?.markerLane?.width;
143
+ const markerStart = resolvedNumbering?.geometry?.markerLane?.start;
143
144
  const markerJustification = resolvedNumbering?.geometry?.markerJustification;
144
145
 
145
146
  const prefixSpan =
@@ -164,6 +165,7 @@ function RegionParagraph({
164
165
  numberingSuffix,
165
166
  markerRunProperties,
166
167
  markerWidth,
168
+ markerStart,
167
169
  markerJustification,
168
170
  )}
169
171
  >