@beyondwork/docx-react-component 1.0.71 → 1.0.73

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 (87) hide show
  1. package/README.md +964 -75
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +280 -1
  4. package/src/api/v3/_create.ts +16 -1
  5. package/src/api/v3/_runtime-handle.ts +2 -0
  6. package/src/api/v3/ai/evaluate.ts +113 -0
  7. package/src/api/v3/ai/outline.ts +140 -0
  8. package/src/api/v3/ai/policy.ts +31 -0
  9. package/src/api/v3/ai/replacement.ts +8 -0
  10. package/src/api/v3/ai/review.ts +342 -0
  11. package/src/api/v3/ai/stats.ts +62 -0
  12. package/src/api/v3/runtime/viewport.ts +181 -0
  13. package/src/api/v3/runtime/workflow.ts +114 -1
  14. package/src/api/v3/ui/_types.ts +35 -0
  15. package/src/api/v3/ui/chrome-preset-model.ts +6 -0
  16. package/src/api/v3/ui/index.ts +1 -0
  17. package/src/api/v3/ui/viewport.ts +112 -0
  18. package/src/compare/diff-engine.ts +2 -0
  19. package/src/core/commands/formatting-commands.ts +1 -0
  20. package/src/core/commands/table-structure-commands.ts +1 -0
  21. package/src/core/state/editor-state.ts +49 -6
  22. package/src/io/export/serialize-footnotes.ts +6 -0
  23. package/src/io/export/serialize-headers-footers.ts +7 -0
  24. package/src/io/export/serialize-main-document.ts +20 -0
  25. package/src/io/export/serialize-paragraph-formatting.ts +34 -0
  26. package/src/io/export/split-review-boundaries.ts +1 -0
  27. package/src/io/normalize/normalize-text.ts +49 -2
  28. package/src/io/ooxml/parse-headers-footers.ts +31 -0
  29. package/src/io/ooxml/parse-main-document.ts +148 -7
  30. package/src/io/ooxml/parse-paragraph-formatting.ts +105 -0
  31. package/src/model/canonical-document.ts +401 -1
  32. package/src/runtime/formatting/formatting-context.ts +2 -1
  33. package/src/runtime/geometry/overlay-rects.ts +7 -10
  34. package/src/runtime/layout/layout-engine-version.ts +278 -1
  35. package/src/runtime/layout/paginated-layout-engine.ts +181 -8
  36. package/src/runtime/layout/resolved-formatting-state.ts +108 -13
  37. package/src/runtime/markdown-sanitizer.ts +21 -4
  38. package/src/runtime/render/render-kernel.ts +21 -1
  39. package/src/runtime/scopes/action-validation.ts +30 -4
  40. package/src/runtime/scopes/audit-bundle.ts +8 -0
  41. package/src/runtime/scopes/compiler-service.ts +1 -0
  42. package/src/runtime/scopes/enumerate-scopes.ts +61 -3
  43. package/src/runtime/scopes/replacement/apply.ts +50 -3
  44. package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
  45. package/src/runtime/scopes/semantic-scope-types.ts +27 -0
  46. package/src/runtime/surface-projection.ts +77 -0
  47. package/src/runtime/workflow/coordinator.ts +3 -0
  48. package/src/runtime/workflow/scope-writer.ts +34 -0
  49. package/src/session/export/embedded-reconstitute.ts +37 -3
  50. package/src/session/import/embedded-offload.ts +26 -1
  51. package/src/session/import/loader-types.ts +18 -0
  52. package/src/session/import/loader.ts +2 -0
  53. package/src/shell/media-previews.ts +8 -6
  54. package/src/ui/WordReviewEditor.tsx +1 -0
  55. package/src/ui/editor-surface-controller.tsx +11 -0
  56. package/src/ui/headless/selection-helpers.ts +2 -2
  57. package/src/ui/runtime-shortcut-dispatch.ts +4 -4
  58. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +22 -4
  59. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +11 -11
  60. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -1
  61. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +5 -0
  62. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +18 -1
  63. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +22 -6
  64. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +18 -1
  65. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +98 -3
  66. package/src/ui-tailwind/editor-surface/pm-schema.ts +50 -4
  67. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
  68. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -1
  69. package/src/ui-tailwind/editor-surface/search-plugin.ts +2 -4
  70. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +114 -0
  71. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
  72. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +29 -4
  73. package/src/ui-tailwind/index.ts +4 -2
  74. package/src/ui-tailwind/page-chrome-model.ts +5 -7
  75. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +54 -34
  76. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +4 -1
  77. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +4 -1
  78. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +10 -1
  79. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +8 -1
  80. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +11 -1
  81. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +7 -1
  82. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +139 -10
  83. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +1 -1
  84. package/src/ui-tailwind/review-workspace/page-chrome.ts +4 -4
  85. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +1 -1
  86. package/src/ui-tailwind/theme/editor-theme.css +15 -16
  87. package/src/ui-tailwind/tw-review-workspace.tsx +22 -14
@@ -6,10 +6,28 @@ import React, {
6
6
  } from "react";
7
7
 
8
8
  import type { WordReviewEditorRef } from "../../api/public-types";
9
- import type { DocumentRuntime } from "../../runtime/document-runtime";
9
+
10
+ /**
11
+ * Opaque runtime handle for the REPL. The REPL hands the value to
12
+ * `new Function("runtime", ...)` for user-supplied JS evaluation and
13
+ * does not introspect its shape — so the dialog's prop type is
14
+ * deliberately structurally loose (`object | null`) rather than the
15
+ * non-public `DocumentRuntime`.
16
+ *
17
+ * Callers inside the component tree that actually hold a
18
+ * `DocumentRuntime` / `RuntimeApiHandle` / `ApiV3` can pass it here
19
+ * unchanged; the widening only affects compile-time autocomplete for
20
+ * the REPL's internal use, not the live eval target.
21
+ *
22
+ * Retired handoff §4.8 residual: the previous `DocumentRuntime`
23
+ * prop type was the last direct import of the non-public runtime
24
+ * class name in `src/ui-tailwind/**` per refactor/11 handoff §4.8
25
+ * remainder row.
26
+ */
27
+ export type TwRuntimeReplTarget = object;
10
28
 
11
29
  export interface TwRuntimeReplDialogProps {
12
- runtime: DocumentRuntime | null;
30
+ runtime: TwRuntimeReplTarget | null;
13
31
  /**
14
32
  * Optional editor ref. When provided, the REPL exposes it to evaluated
15
33
  * expressions as `ref` — e.g. `ref.getRenderSnapshot()`. The REPL reads
@@ -370,11 +388,11 @@ export function isReplToggleShortcut(event: KeyboardEvent): boolean {
370
388
 
371
389
  export async function evaluateReplExpression(
372
390
  code: string,
373
- runtime: DocumentRuntime,
391
+ runtime: TwRuntimeReplTarget,
374
392
  ref: WordReviewEditorRef | null = null,
375
393
  ): Promise<unknown> {
376
394
  type ReplFn = (
377
- runtime: DocumentRuntime,
395
+ runtime: TwRuntimeReplTarget,
378
396
  ref: WordReviewEditorRef | null,
379
397
  ) => Promise<unknown>;
380
398
  let fn: ReplFn | null = null;
@@ -214,17 +214,17 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
214
214
  capability={tableContext?.operations.setTableAlignment}
215
215
  disabled={props.disabled}
216
216
  onClick={() => props.onSetTableAlignment?.(align)}
217
- // Chrome Closure Pass · Task 3 pre-fix bug:
218
- // `active` was hardcoded to `align === "left"`, lighting
219
- // up the Left button regardless of the actual table
220
- // alignment. `TableStructureContextSnapshot` does not
221
- // currently surface table-level alignment (the field
222
- // lives on `RuntimeTableRenderPlanSnapshot`); until the
223
- // runtime exposes it on the structure context, treat
224
- // alignment buttons as plain action buttons (no toggle).
225
- // Fix scope: presentation-only correction; runtime
226
- // enrichment is a follow-up issue against L07.
227
- active={false}
217
+ // Refactor/11 handoff §4.13L07 shipped
218
+ // `TableStructureContextSnapshot.alignment` in
219
+ // `4cfe52a3` (2026-04-24), landing alignment on the
220
+ // structure context alongside the existing
221
+ // `PublicTableSummary.alignment`. Toggle the active
222
+ // state against the live value. `null` means no
223
+ // explicit alignment declared (parent default, typically
224
+ // left) all three buttons remain inactive in that
225
+ // case; setting any alignment via the callback produces
226
+ // a subsequent snapshot with a non-null value.
227
+ active={tableContext?.alignment === align}
228
228
  >
229
229
  {align[0]!.toUpperCase()}
230
230
  </ToolbarButton>
@@ -28,9 +28,9 @@ import type {
28
28
  WordReviewEditorLayoutFacet,
29
29
  } from "../../api/public-types";
30
30
  import type { GeometryFacet } from "../../api/public-types";
31
+ import { DEFAULT_PX_PER_TWIP } from "../../api/public-types";
31
32
  import type { OverlayCoordinateSpace } from "../chrome-overlay/chrome-overlay-projector";
32
33
  import { projectRectToOverlay } from "../chrome-overlay/chrome-overlay-projector";
33
- import { DEFAULT_PX_PER_TWIP } from "../../runtime/render/render-frame-types";
34
34
  import { forwardNonDragClick } from "./forward-non-drag-click";
35
35
 
36
36
  const GRIP_PX = 2;
@@ -183,6 +183,9 @@ export interface TwChromeOverlayProps {
183
183
  * See `useVisiblePageIndexRange` in `src/ui-tailwind/page-stack/use-visible-block-range.ts`.
184
184
  */
185
185
  visiblePageIndexRange?: { start: number; end: number } | null;
186
+ /** Preview catalog threaded into the page-stack chrome so header /
187
+ * footer / footnote / endnote regions render real <img>s. */
188
+ mediaPreviews?: Record<string, import("../editor-surface/pm-state-from-snapshot").MediaPreviewDescriptor>;
186
189
  }
187
190
 
188
191
  /**
@@ -226,6 +229,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
226
229
  pmSurfaceElement,
227
230
  pmView,
228
231
  visiblePageIndexRange,
232
+ mediaPreviews,
229
233
  }) => {
230
234
  return (
231
235
  <div
@@ -243,6 +247,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
243
247
  pmSurfaceElement={pmSurfaceElement}
244
248
  pmView={pmView}
245
249
  visiblePageIndexRange={visiblePageIndexRange ?? null}
250
+ mediaPreviews={mediaPreviews}
246
251
  />
247
252
  ) : null}
248
253
  <TwScopeRailLayer
@@ -1,6 +1,10 @@
1
1
  import * as React from "react";
2
2
  import type { RenderBlockDecoration } from "../../api/public-types.ts";
3
3
  import { TwCommentPreview } from "../chrome/tw-comment-preview";
4
+ import {
5
+ projectRectToOverlay,
6
+ type OverlayCoordinateSpace,
7
+ } from "./chrome-overlay-projector.ts";
4
8
 
5
9
  const WIDE_BREAKPOINT_PX = 1024;
6
10
  const CONNECTOR_GAP_PX = 16;
@@ -24,14 +28,26 @@ export interface TwCommentBalloonLayerProps {
24
28
  viewportWidthPx: number;
25
29
  /** Right edge of the page frame in overlay coordinates (px). */
26
30
  pageRightEdgePx: number;
31
+ /**
32
+ * Overlay coordinate space — subtracted from each decoration rect so
33
+ * balloon `top` is relative to the overlay's own top-left, not the
34
+ * document column. Matches `TwScopeCardLayer` / `TwTableGripLayer`
35
+ * precedent. Defaults to the zero-origin space so existing callers
36
+ * that mount the layer at the document column's origin continue to
37
+ * work unchanged.
38
+ */
39
+ space?: OverlayCoordinateSpace;
27
40
  onOpenThread?: (commentId: string) => void;
28
41
  }
29
42
 
43
+ const ZERO_SPACE: OverlayCoordinateSpace = { originLeftPx: 0, originTopPx: 0 };
44
+
30
45
  export const TwCommentBalloonLayer = React.memo(function TwCommentBalloonLayer({
31
46
  commentDecorations,
32
47
  commentDataById,
33
48
  viewportWidthPx,
34
49
  pageRightEdgePx,
50
+ space = ZERO_SPACE,
35
51
  onOpenThread,
36
52
  }: TwCommentBalloonLayerProps) {
37
53
  if (viewportWidthPx < WIDE_BREAKPOINT_PX) return null;
@@ -42,12 +58,13 @@ export const TwCommentBalloonLayer = React.memo(function TwCommentBalloonLayer({
42
58
  {commentDecorations.map((dec) => {
43
59
  const data = commentDataById.get(dec.refId);
44
60
  if (!data) return null;
61
+ const projected = projectRectToOverlay(dec.frame, space);
45
62
  return (
46
63
  <div
47
64
  key={dec.refId}
48
65
  style={{
49
66
  position: "absolute",
50
- top: dec.frame.topPx,
67
+ top: projected.top,
51
68
  left: pageRightEdgePx + CONNECTOR_GAP_PX,
52
69
  width: BALLOON_MAX_WIDTH_PX,
53
70
  pointerEvents: "auto",
@@ -176,6 +176,7 @@ export function resolvePageOverlayRects(
176
176
  if (!scrollRoot || count <= 0) return [];
177
177
  widgets = measureWidgetsViaOffsetChain(scrollRoot);
178
178
  pageCount = count;
179
+ // geometry:allow-dom-fallback
179
180
  scrollHeight = scrollRoot.clientHeight;
180
181
  } else if (
181
182
  input !== null &&
@@ -195,6 +196,7 @@ export function resolvePageOverlayRects(
195
196
  if (legacyPageCount <= 0) return [];
196
197
  widgets = measureWidgetsViaOffsetChain(scrollRoot);
197
198
  pageCount = legacyPageCount;
199
+ // geometry:allow-dom-fallback
198
200
  scrollHeight = scrollRoot.clientHeight;
199
201
  } else {
200
202
  return [];
@@ -275,6 +277,10 @@ export function measureWidgetsViaBoundingRect(
275
277
  },
276
278
  ): PageBoundaryMeasurement[] {
277
279
  if (!queryRoot || !originElement) return [];
280
+ // Cold-open / pre-paint DOM fallback — warm path flows through
281
+ // `geometryFacet.getPage(i)` at the caller; this branch fires only
282
+ // before the first render frame.
283
+ // geometry:allow-dom-fallback
278
284
  const originRect = originElement.getBoundingClientRect();
279
285
  const normalizedVisiblePageIndexRange = normalizeVisiblePageIndexRange(
280
286
  options?.visiblePageIndexRange,
@@ -301,6 +307,7 @@ export function measureWidgetsViaBoundingRect(
301
307
  const prevPageId = widget.getAttribute("data-page-frame-end");
302
308
  const nextPageId = widget.getAttribute("data-page-frame-start");
303
309
  if (!prevPageId || !nextPageId) continue;
310
+ // geometry:allow-dom-fallback
304
311
  const rect = widget.getBoundingClientRect();
305
312
  out.push({
306
313
  prevPageId,
@@ -380,10 +387,14 @@ function resolveOffsetTop(
380
387
  // still defined and defaults to 0. Browsers set it relative to the
381
388
  // offsetParent. We walk up the offset chain until we reach the scroll
382
389
  // root (or exit the document) so the result is scroll-root-relative.
390
+ // Cold-open / pre-paint DOM fallback — warm path flows through
391
+ // `geometryFacet.getPage(i)` at the caller.
383
392
  let node: HTMLElement | null = widget;
384
393
  let top = 0;
385
394
  while (node) {
395
+ // geometry:allow-dom-fallback
386
396
  top += node.offsetTop ?? 0;
397
+ // geometry:allow-dom-fallback
387
398
  const parent = node.offsetParent as HTMLElement | null;
388
399
  if (parent === scrollRoot || parent === null) break;
389
400
  node = parent;
@@ -392,6 +403,7 @@ function resolveOffsetTop(
392
403
  }
393
404
 
394
405
  function resolveOffsetHeight(widget: HTMLElement): number {
406
+ // geometry:allow-dom-fallback
395
407
  return widget.offsetHeight ?? 0;
396
408
  }
397
409
 
@@ -437,12 +449,10 @@ export interface TwPageStackOverlayLayerProps {
437
449
  * owns the projection math; the overlay component re-exports from this
438
450
  * module for back-compat with existing imports.
439
451
  *
440
- * Known coordinate-space caveat documented in
441
- * `src/runtime/geometry/overlay-rects.ts` the kernel's page-stacking
442
- * gap (16 px) diverges from the DOM chrome's inter-page gap (48 px).
443
- * Until Slice 3c reconciles them, production consumers should not pass
444
- * a `geometryFacet` to this overlay — the DOM-measurement path remains
445
- * the default.
452
+ * **Slice 3c reconciliation shipped 2026-04-23** kernel `PAGE_GAP_PX`
453
+ * (16 48) now matches the DOM chrome's `interGapPx`, so `geometryFacet`
454
+ * is the production warm path. The DOM-measurement branch below stays as
455
+ * the cold-open fallback only.
446
456
  */
447
457
  export const resolvePageOverlayRectsFromGeometry =
448
458
  resolvePageOverlayRectsFromGeometryImpl;
@@ -628,17 +638,22 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
628
638
  }
629
639
  }
630
640
 
641
+ // Cold-open / pre-paint DOM fallback — warm path early-returned
642
+ // above via `geometryFacet` or the UI-API resolver. Lines below
643
+ // fire only before the first render frame.
631
644
  if (origin) {
632
645
  const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin, {
633
646
  pageCount,
634
647
  visiblePageIndexRange,
635
648
  });
649
+ // geometry:allow-dom-fallback
636
650
  const originRect = origin.getBoundingClientRect();
637
651
  setRects(
638
652
  resolvePageOverlayRects({
639
653
  widgets,
640
654
  pageCount,
641
655
  scrollHeight:
656
+ // geometry:allow-dom-fallback
642
657
  origin.clientHeight > 0 ? origin.clientHeight : originRect.height,
643
658
  visiblePageIndexRange,
644
659
  }),
@@ -652,6 +667,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
652
667
  resolvePageOverlayRects({
653
668
  widgets,
654
669
  pageCount,
670
+ // geometry:allow-dom-fallback
655
671
  scrollHeight: scrollRoot.clientHeight,
656
672
  visiblePageIndexRange,
657
673
  }),
@@ -1,6 +1,10 @@
1
1
  import * as React from "react";
2
2
  import type { RenderBlockDecoration } from "../../api/public-types.ts";
3
3
  import { AUTHOR_PALETTE } from "../../ui/headless/revision-decoration-model";
4
+ import {
5
+ projectRectToOverlay,
6
+ type OverlayCoordinateSpace,
7
+ } from "./chrome-overlay-projector.ts";
4
8
 
5
9
  const BAR_WIDTH_PX = 3;
6
10
  const BAR_LEFT_OFFSET_PX = 8;
@@ -15,12 +19,24 @@ export interface TwRevisionMarginBarLayerProps {
15
19
  authorPaletteIndexById: ReadonlyMap<string, number>;
16
20
  /** Left edge of the page body in overlay coordinates (px). */
17
21
  pageBodyLeftPx: number;
22
+ /**
23
+ * Overlay coordinate space — subtracted from each decoration rect so
24
+ * bar `top` is relative to the overlay's own top-left, not the
25
+ * document column. Matches `TwScopeCardLayer` / `TwTableGripLayer`
26
+ * precedent. Defaults to the zero-origin space so existing callers
27
+ * that mount the layer at the document column's origin continue to
28
+ * work unchanged.
29
+ */
30
+ space?: OverlayCoordinateSpace;
18
31
  }
19
32
 
33
+ const ZERO_SPACE: OverlayCoordinateSpace = { originLeftPx: 0, originTopPx: 0 };
34
+
20
35
  export const TwRevisionMarginBarLayer = React.memo(function TwRevisionMarginBarLayer({
21
36
  revisionDecorations,
22
37
  authorPaletteIndexById,
23
38
  pageBodyLeftPx,
39
+ space = ZERO_SPACE,
24
40
  }: TwRevisionMarginBarLayerProps) {
25
41
  if (revisionDecorations.length === 0) return null;
26
42
 
@@ -29,13 +45,14 @@ export const TwRevisionMarginBarLayer = React.memo(function TwRevisionMarginBarL
29
45
  {revisionDecorations.map((dec, idx) => {
30
46
  const paletteIdx = authorPaletteIndexById.get(dec.refId) ?? 0;
31
47
  const color = AUTHOR_PALETTE[paletteIdx % AUTHOR_PALETTE.length];
48
+ const projected = projectRectToOverlay(dec.frame, space);
32
49
  return (
33
50
  <div
34
51
  key={`rev-bar-${dec.refId}-${idx}`}
35
52
  aria-hidden
36
53
  style={{
37
54
  position: "absolute",
38
- top: dec.frame.topPx,
55
+ top: projected.top,
39
56
  left: pageBodyLeftPx - BAR_LEFT_OFFSET_PX - BAR_WIDTH_PX,
40
57
  width: BAR_WIDTH_PX,
41
58
  height: dec.frame.heightPx,
@@ -24,8 +24,10 @@
24
24
  */
25
25
 
26
26
  import { Decoration } from "prosemirror-view";
27
- import type { RuntimePageGraph } from "../../api/public-types.ts";
28
- import { resolvePageFieldDisplayText } from "../../runtime/layout/resolve-page-fields.ts";
27
+ import {
28
+ type RuntimePageGraph,
29
+ resolvePageFieldDisplayText,
30
+ } from "../../api/public-types.ts";
29
31
 
30
32
  export const PAGE_CHROME_DEFAULTS = {
31
33
  headerBandPx: 32,
@@ -93,7 +95,7 @@ export function buildPageBreakDecorations(
93
95
  input: PageBreakDecorationInput,
94
96
  ): Decoration[] {
95
97
  const { graph, posture, runtimeToPmOffset } = input;
96
- if (!graph || graph.pages.length < 2) return [];
98
+ if (!graph) return [];
97
99
 
98
100
  const headerBandPx =
99
101
  input.headerBandPx ?? PAGE_CHROME_DEFAULTS.headerBandPx;
@@ -103,6 +105,15 @@ export function buildPageBreakDecorations(
103
105
 
104
106
  const decorations: Decoration[] = [];
105
107
 
108
+ // Inter-page boundary widgets (existing) — emitted first so
109
+ // `decorations[0..N-2]` remain boundary widgets for any legacy
110
+ // callers that index by position. Per-page content-anchor widgets
111
+ // (coord-11 §19) are emitted in a second pass at the end of this
112
+ // function.
113
+ if (graph.pages.length < 2) {
114
+ return buildPageAnchorDecorationsInto(decorations, graph, posture, runtimeToPmOffset);
115
+ }
116
+
106
117
  for (let i = 1; i < graph.pages.length; i += 1) {
107
118
  const prev = graph.pages[i - 1]!;
108
119
  const next = graph.pages[i]!;
@@ -160,6 +171,58 @@ export function buildPageBreakDecorations(
160
171
  ),
161
172
  );
162
173
  }
174
+ return buildPageAnchorDecorationsInto(decorations, graph, posture, runtimeToPmOffset);
175
+ }
176
+
177
+ /**
178
+ * Append per-page content-anchor widgets to `decorations` for coord-11
179
+ * §19. One zero-height anchor per non-filler page at the page's content-
180
+ * start offset. Chrome-preset-independent (PM widget = content layer) +
181
+ * markup-mode stable (document-offset-anchored, not chrome-dependent).
182
+ *
183
+ * Each anchor carries `data-page-content-wrapper` + `data-page-number`
184
+ * (1-based, skipping blank fillers) + `data-page-id` (from
185
+ * `RuntimePageNode.pageId` — stable across chrome/markup transitions).
186
+ * Consumed by `runtime.viewport.getPageAnchor(n)` (coord-07 §2.9) and
187
+ * `ui.viewport.scrollToPage(n)` (coord-10 §γ); also by the visual-
188
+ * fidelity harness at `test/visual-fidelity/` per coord-11 §20.
189
+ *
190
+ * Anchors use `side: 1` so they sit AFTER the document position
191
+ * (inside page N's content). Boundary widgets use `side: -1` so they
192
+ * sit BEFORE the same offset (at the end of page N-1's content + gap).
193
+ * Ordering places each anchor at the top of its page's content area.
194
+ */
195
+ function buildPageAnchorDecorationsInto(
196
+ decorations: Decoration[],
197
+ graph: RuntimePageGraph,
198
+ posture: "page" | "canvas",
199
+ runtimeToPmOffset: ((runtimeOffset: number) => number | null) | undefined,
200
+ ): Decoration[] {
201
+ let contentPageOrdinal = 0;
202
+ for (const page of graph.pages) {
203
+ if (page.isBlankFiller) continue;
204
+ contentPageOrdinal += 1;
205
+ const pagePmOffset = runtimeToPmOffset
206
+ ? runtimeToPmOffset(page.startOffset)
207
+ : page.startOffset;
208
+ if (pagePmOffset === null || pagePmOffset === undefined) continue;
209
+ const anchorPageNumber = contentPageOrdinal;
210
+ const anchorPageId = page.pageId;
211
+ decorations.push(
212
+ Decoration.widget(
213
+ pagePmOffset,
214
+ () => buildPageAnchorWidgetDom({
215
+ pageNumber: anchorPageNumber,
216
+ pageId: anchorPageId,
217
+ }),
218
+ {
219
+ side: 1,
220
+ key: `pa-${page.pageId}-${posture}`,
221
+ ignoreSelection: true,
222
+ },
223
+ ),
224
+ );
225
+ }
163
226
  return decorations;
164
227
  }
165
228
 
@@ -262,6 +325,38 @@ export function __resetPageBreakWidgetCache(): void {
262
325
  widgetDomCache.clear();
263
326
  }
264
327
 
328
+ /**
329
+ * Build the per-page content-anchor widget DOM for coord-11 §19.
330
+ *
331
+ * Zero-height, non-visual marker carrying `data-page-content-wrapper`,
332
+ * `data-page-number` (1-based), and `data-page-id` (stable across
333
+ * chrome preset + markup mode transitions — sourced from
334
+ * `RuntimePageNode.pageId`). Consumed by `runtime.viewport.getPageAnchor(n)`
335
+ * (coord-07 §2.9) + `ui.viewport.scrollToPage(n)` (coord-10 §γ) and by
336
+ * the visual-fidelity harness.
337
+ *
338
+ * Emitted as a PM widget via `buildPageBreakDecorations` so it lives
339
+ * on the content layer (present under chrome=none) rather than on an
340
+ * absolute-positioned chrome overlay.
341
+ */
342
+ function buildPageAnchorWidgetDom(input: {
343
+ pageNumber: number;
344
+ pageId: string;
345
+ }): HTMLElement {
346
+ const root = document.createElement("span");
347
+ root.setAttribute("data-kind", "page-content-anchor");
348
+ root.setAttribute("data-page-content-wrapper", "");
349
+ root.setAttribute("data-page-number", String(input.pageNumber));
350
+ root.setAttribute("data-page-id", input.pageId);
351
+ root.setAttribute("aria-hidden", "true");
352
+ root.contentEditable = "false";
353
+ root.style.display = "block";
354
+ root.style.height = "0";
355
+ root.style.width = "100%";
356
+ root.style.userSelect = "none";
357
+ return root;
358
+ }
359
+
265
360
  function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
266
361
  const root = document.createElement("div");
267
362
  root.className = "wre-page-chrome-widget";
@@ -200,6 +200,15 @@ export const editorSchema = new Schema({
200
200
  hiddenTextOnly: { default: null },
201
201
  placeholderCulled: { default: null },
202
202
  blockId: { default: null },
203
+ /**
204
+ * `<w:framePr>` projection from `SurfaceBlockFragment.frameProperties`
205
+ * (ECMA-376 §17.3.1.11). When set (and `dropCap` is neither `"drop"`
206
+ * nor `"margin"`), `toDOM` emits `position: absolute` with the
207
+ * xTwips/yTwips/widthTwips/heightTwips fields; the inline flow
208
+ * already treats the paragraph as zero-height per L04
209
+ * `measureBlockHeight` short-circuit (a298391e / coord-04 §1.19.d).
210
+ */
211
+ frameProperties: { default: null },
203
212
  },
204
213
  parseDOM: [{ tag: "p" }],
205
214
  toDOM(node) {
@@ -253,17 +262,54 @@ export const editorSchema = new Schema({
253
262
  else if (indentFirstLine) styles.push(`text-indent: ${indentFirstLine / 20}pt`);
254
263
  const shadingColor = safeHexColor(node.attrs.shadingFill as string | null);
255
264
  if (shadingColor) styles.push(`background-color: ${shadingColor}`);
265
+ // Paragraph borders. Reads the PublicBorderSpec shape
266
+ // (`{value, size, space, color}`) that `pm-state-from-snapshot.ts`
267
+ // forwards verbatim from `SurfaceBlockFragment.borders`. `size` is
268
+ // in eighths of a point (ECMA-376 §17.18.88 ST_Border); `value`
269
+ // follows ECMA-376 §17.18.2 ST_Border enumeration.
256
270
  for (const [side, attrName] of [["top", "borderTop"], ["bottom", "borderBottom"], ["left", "borderLeft"], ["right", "borderRight"]] as const) {
257
- const border = node.attrs[attrName] as { color?: string; sz?: number; val?: string } | null;
258
- if (border && border.val && border.val !== "none") {
259
- const width = border.sz ? `${border.sz / 8}px` : "1px";
271
+ const border = node.attrs[attrName] as
272
+ | { color?: string; size?: number; space?: number; value?: string }
273
+ | null;
274
+ if (border && border.value && border.value !== "none" && border.value !== "nil") {
275
+ const width = border.size ? `${Math.max(1, Math.round(border.size / 8))}px` : "1px";
260
276
  const color = safeHexColor(border.color ?? null) ?? "#000000";
261
- const bStyle = border.val === "dotted" ? "dotted" : border.val === "dashed" ? "dashed" : "solid";
277
+ const bStyle =
278
+ border.value === "double"
279
+ ? "double"
280
+ : border.value === "dashed" || border.value === "dashSmallGap"
281
+ ? "dashed"
282
+ : border.value === "dotted"
283
+ ? "dotted"
284
+ : "solid";
262
285
  styles.push(`border-${side}: ${width} ${bStyle} ${color}`);
263
286
  }
264
287
  }
265
288
  const pageBreak = node.attrs.pageBreakBefore as boolean | null;
266
289
  if (pageBreak) styles.push("border-top: 2px dashed rgba(0,0,0,0.1); padding-top: 8px; margin-top: 16px");
290
+ // `<w:framePr>` out-of-flow frame — mirror the static-path branch in
291
+ // tw-page-block-view.helpers.ts so PM-rendered page 1 absolutely
292
+ // positions the frame identically to pages 2+.
293
+ const framePr = node.attrs.frameProperties as
294
+ | {
295
+ xTwips?: number;
296
+ yTwips?: number;
297
+ widthTwips?: number;
298
+ heightTwips?: number;
299
+ hRule?: "auto" | "atLeast" | "exact";
300
+ dropCap?: "none" | "drop" | "margin";
301
+ }
302
+ | null;
303
+ if (framePr && framePr.dropCap !== "drop" && framePr.dropCap !== "margin") {
304
+ styles.push("position: absolute");
305
+ if (typeof framePr.xTwips === "number") styles.push(`left: ${framePr.xTwips / 20}pt`);
306
+ if (typeof framePr.yTwips === "number") styles.push(`top: ${framePr.yTwips / 20}pt`);
307
+ if (typeof framePr.widthTwips === "number") styles.push(`width: ${framePr.widthTwips / 20}pt`);
308
+ if (typeof framePr.heightTwips === "number") {
309
+ if (framePr.hRule === "exact") styles.push(`height: ${framePr.heightTwips / 20}pt`);
310
+ else if (framePr.hRule === "atLeast") styles.push(`min-height: ${framePr.heightTwips / 20}pt`);
311
+ }
312
+ }
267
313
  const hiddenTextOnly = node.attrs.hiddenTextOnly as boolean | null;
268
314
  if (hiddenTextOnly) {
269
315
  attrs["data-hidden-paragraph"] = "true";
@@ -426,6 +426,12 @@ function buildParagraph(
426
426
  bidi: block.bidi ?? cascade?.bidi ?? null,
427
427
  pageBreakBefore: block.pageBreakBefore ?? cascade?.pageBreakBefore ?? null,
428
428
  hiddenTextOnly: fullyVanishedParagraph || null,
429
+ // `<w:framePr>` out-of-flow frame — forward to the PM paragraph node
430
+ // so `pm-schema.ts::paragraph.toDOM` emits the absolute positioning
431
+ // that matches the static `buildParagraphStyle` path (L04
432
+ // short-circuits inline flow height in measureBlockHeight; L11 owns
433
+ // the absolute render on BOTH the PM path and the static path).
434
+ frameProperties: block.frameProperties ?? null,
429
435
  },
430
436
  content.length > 0 ? Fragment.from(content) : undefined,
431
437
  );
@@ -101,10 +101,13 @@ export function findScrollAnchor(
101
101
 
102
102
  // Cold-open / pre-paint fallback — the single permitted DOM-origin
103
103
  // branch under `src/ui-tailwind/editor-surface/scroll-anchor.ts` per
104
- // the Slice-3 plan.
104
+ // the Slice-3 plan. Warm path flows through `geometryFacet.getBlock`
105
+ // above; this fallback fires only before the first render frame.
106
+ // geometry:allow-dom-fallback
105
107
  const rootRect = root.getBoundingClientRect();
106
108
  const rootTop = rootRect.top;
107
109
  for (const block of blocks) {
110
+ // geometry:allow-dom-fallback
108
111
  const rect = block.getBoundingClientRect();
109
112
  if (rect.bottom < rootTop) continue;
110
113
  const blockId = block.getAttribute("data-block-id");
@@ -153,7 +156,11 @@ export function restoreScrollAnchor(
153
156
  const selector = `[data-block-id="${cssEscape(anchor.blockId)}"]`;
154
157
  const block = root.querySelector<HTMLElement>(selector);
155
158
  if (!block) return;
159
+ // Cold-open / pre-paint DOM fallback — same rationale as
160
+ // findScrollAnchor's fallback above.
161
+ // geometry:allow-dom-fallback
156
162
  const rootRect = root.getBoundingClientRect();
163
+ // geometry:allow-dom-fallback
157
164
  const blockRect = block.getBoundingClientRect();
158
165
  // We want, post-restore, `blockRect.top === rootRect.top - offsetWithinBlock`
159
166
  // (i.e. the block's leading edge sits `offsetWithinBlock` px above the
@@ -15,17 +15,15 @@ import { Plugin, PluginKey } from "prosemirror-state";
15
15
  import type { EditorState, Transaction } from "prosemirror-state";
16
16
  import { Decoration, DecorationSet } from "prosemirror-view";
17
17
 
18
- import type {
19
- SearchOptions as PublicSearchOptions,
20
- } from "../../api/public-types";
21
18
  import {
19
+ type SearchOptions as PublicSearchOptions,
22
20
  buildSearchPattern,
23
21
  createSearchExcerpt,
24
22
  findSearchMatches,
25
23
  searchSecondaryStories,
26
24
  type SecondaryStorySearchResult,
27
25
  type SearchTextOptions,
28
- } from "../../core/search/search-text.ts";
26
+ } from "../../api/public-types";
29
27
 
30
28
  // ---------------------------------------------------------------------------
31
29
  // Public types