@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
@@ -85,6 +85,54 @@ export function resolveMarkerAlignCss(raw: string | undefined): React.CSSPropert
85
85
  }
86
86
  }
87
87
 
88
+ /**
89
+ * Precompute per-tab-segment widths from the paragraph's tabStops.
90
+ *
91
+ * Returns a map `segmentId -> widthPt`. The N-th tab segment gets the
92
+ * width `(tabStops[N].pos - (tabStops[N-1]?.pos ?? 0)) / 20` points —
93
+ * the horizontal span from the previous stop (or the paragraph's
94
+ * content-left edge) to the current stop.
95
+ *
96
+ * This is a conservative approximation of Word's real tab algorithm
97
+ * (which advances to the next stop >= cumulative X considering prior
98
+ * content width). Without run-width measurement, the approximation
99
+ * holds best when the text in each zone is short relative to the zone
100
+ * width — which is exactly the three-zone-header / TOC case L11 needs
101
+ * to render correctly.
102
+ *
103
+ * Direct `block.tabStops` wins over `block.resolvedParagraphFormatting.tabStops`
104
+ * (consistent with other resolved fields).
105
+ */
106
+ export function computeTabWidthsInPoints(
107
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
108
+ ): Map<string, number> {
109
+ const widths = new Map<string, number>();
110
+ // Direct `block.tabStops` uses the public `{pos, val?, leader?}` shape;
111
+ // `block.resolvedParagraphFormatting.tabStops` uses the canonical
112
+ // `{position, align, leader?}` shape. Normalize to a single accessor.
113
+ const readPos = (stop: { pos?: number; position?: number }): number =>
114
+ stop.pos ?? stop.position ?? 0;
115
+
116
+ const rawStops: Array<{ pos?: number; position?: number }> | undefined =
117
+ block.tabStops ?? block.resolvedParagraphFormatting?.tabStops;
118
+ if (!rawStops || rawStops.length === 0) return widths;
119
+
120
+ let tabIndex = 0;
121
+ for (const seg of block.segments) {
122
+ if (seg.kind !== "tab") continue;
123
+ const stop = rawStops[tabIndex];
124
+ if (stop) {
125
+ const prevPos = tabIndex > 0 ? readPos(rawStops[tabIndex - 1]) : 0;
126
+ const widthTwips = readPos(stop) - prevPos;
127
+ if (widthTwips > 0) {
128
+ widths.set(seg.segmentId, widthTwips / 20);
129
+ }
130
+ }
131
+ tabIndex += 1;
132
+ }
133
+ return widths;
134
+ }
135
+
88
136
  /** Build CSSProperties for a paragraph block from spacing/indent/alignment. */
89
137
  export function buildParagraphStyle(
90
138
  block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
@@ -159,6 +207,43 @@ export function buildParagraphStyle(
159
207
  const shadingColor = safeHexColor(shadingFill);
160
208
  if (shadingColor) style.backgroundColor = shadingColor;
161
209
 
210
+ // Paragraph borders (`w:pBdr`). Mirror `pm-schema.ts::paragraph.toDOM`
211
+ // so the static render path (pages 2+, non-PM) paints the same
212
+ // borders as the PM-rendered page 1. Reads the PublicBorderSpec shape
213
+ // (`{value, size, space, color}`); `size` is eighths of a point.
214
+ const borders = block.borders;
215
+ if (borders) {
216
+ const sideToCss: Array<["top" | "bottom" | "left" | "right", keyof NonNullable<typeof borders>]> = [
217
+ ["top", "top"],
218
+ ["bottom", "bottom"],
219
+ ["left", "left"],
220
+ ["right", "right"],
221
+ ];
222
+ for (const [cssSide, key] of sideToCss) {
223
+ const border = borders[key];
224
+ if (!border || !border.value || border.value === "none" || border.value === "nil") continue;
225
+ const width = border.size ? `${Math.max(1, Math.round(border.size / 8))}px` : "1px";
226
+ const color = safeHexColor(border.color ?? null) ?? "#000000";
227
+ const bStyle =
228
+ border.value === "double"
229
+ ? "double"
230
+ : border.value === "dashed" || border.value === "dashSmallGap"
231
+ ? "dashed"
232
+ : border.value === "dotted"
233
+ ? "dotted"
234
+ : "solid";
235
+ const cssKey =
236
+ cssSide === "top"
237
+ ? "borderTop"
238
+ : cssSide === "bottom"
239
+ ? "borderBottom"
240
+ : cssSide === "left"
241
+ ? "borderLeft"
242
+ : "borderRight";
243
+ (style as Record<string, string>)[cssKey] = `${width} ${bStyle} ${color}`;
244
+ }
245
+ }
246
+
162
247
  // Page break visual indicator
163
248
  if (block.pageBreakBefore) {
164
249
  style.borderTop = "2px dashed rgba(0,0,0,0.1)";
@@ -166,6 +251,35 @@ export function buildParagraphStyle(
166
251
  style.marginTop = "16px";
167
252
  }
168
253
 
254
+ // `<w:framePr>` out-of-flow frame (ECMA-376 §17.3.1.11). L04 returns 0
255
+ // from measureBlockHeight for these paragraphs (a298391e) so the inline
256
+ // flow doesn't double-count; L11 renders them absolutely positioned.
257
+ // Drop-cap (dropCap="drop"|"margin") is in-flow — only the initial
258
+ // letter is framed — so skip the absolute switch there.
259
+ const framePr = block.frameProperties;
260
+ if (framePr && framePr.dropCap !== "drop" && framePr.dropCap !== "margin") {
261
+ style.position = "absolute";
262
+ if (typeof framePr.xTwips === "number") {
263
+ style.left = `${framePr.xTwips / 20}pt`;
264
+ }
265
+ if (typeof framePr.yTwips === "number") {
266
+ style.top = `${framePr.yTwips / 20}pt`;
267
+ }
268
+ if (typeof framePr.widthTwips === "number") {
269
+ style.width = `${framePr.widthTwips / 20}pt`;
270
+ }
271
+ if (typeof framePr.heightTwips === "number") {
272
+ if (framePr.hRule === "exact") {
273
+ style.height = `${framePr.heightTwips / 20}pt`;
274
+ } else if (framePr.hRule === "atLeast") {
275
+ style.minHeight = `${framePr.heightTwips / 20}pt`;
276
+ }
277
+ // hRule === "auto" (or missing) leaves the frame's vertical size
278
+ // content-driven. The height field is ignored — OOXML treats the
279
+ // value as a hint that layout engines may override.
280
+ }
281
+ }
282
+
169
283
  return style;
170
284
  }
171
285
 
@@ -8,6 +8,7 @@ import {
8
8
  buildMarkerStyle,
9
9
  buildParagraphStyle,
10
10
  buildSegmentStyle,
11
+ computeTabWidthsInPoints,
11
12
  hasStyleEntries,
12
13
  headingClassList,
13
14
  resolveHeadingLevel,
@@ -18,7 +19,7 @@ import {
18
19
  // ---------------------------------------------------------------------------
19
20
 
20
21
  /** Render a single inline segment. */
21
- function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
22
+ function renderSegment(seg: SurfaceInlineSegment, tabWidthsPt: Map<string, number>): React.ReactNode {
22
23
  switch (seg.kind) {
23
24
  case "text": {
24
25
  const style = buildSegmentStyle(seg.marks, seg.markAttrs);
@@ -32,16 +33,22 @@ function renderSegment(seg: SurfaceInlineSegment): React.ReactNode {
32
33
  </span>
33
34
  );
34
35
  }
35
- case "tab":
36
+ case "tab": {
37
+ const widthPt = tabWidthsPt.get(seg.segmentId);
38
+ const tabStyle: React.CSSProperties =
39
+ typeof widthPt === "number"
40
+ ? { display: "inline-block", width: `${widthPt}pt`, minWidth: "8px" }
41
+ : { display: "inline-block", width: "32px", minWidth: "8px" };
36
42
  return (
37
43
  <span
38
44
  key={seg.segmentId}
39
45
  data-node-type="tab"
40
- style={{ display: "inline-block", width: "32px", minWidth: "8px" }}
46
+ style={tabStyle}
41
47
  >
42
48
  {"\u00A0"}
43
49
  </span>
44
50
  );
51
+ }
45
52
  case "hard_break":
46
53
  return <br key={seg.segmentId} />;
47
54
  case "image":
@@ -114,6 +121,7 @@ function ParagraphBlock({
114
121
  }
115
122
 
116
123
  const pStyle = buildParagraphStyle(block);
124
+ const tabWidthsPt = computeTabWidthsInPoints(block);
117
125
  const attrs: React.HTMLAttributes<HTMLParagraphElement> & {
118
126
  "data-heading-level"?: string;
119
127
  "data-numbered"?: string;
@@ -177,7 +185,7 @@ function ParagraphBlock({
177
185
  <p {...attrs}>
178
186
  {prefixSpan}
179
187
  <span className="pm-paragraph-content">
180
- {block.segments.map((seg) => renderSegment(seg))}
188
+ {block.segments.map((seg) => renderSegment(seg, tabWidthsPt))}
181
189
  </span>
182
190
  </p>
183
191
  );
@@ -53,7 +53,7 @@ import { buildDecorations } from "./pm-decorations";
53
53
  import { buildPageBreakDecorations } from "./pm-page-break-decorations";
54
54
  import { DecorationSet } from "prosemirror-view";
55
55
  import type { WordReviewEditorLayoutFacet } from "../../api/public-types.ts";
56
- import { buildPagePreviewMaps } from "../../runtime/layout/resolve-page-previews";
56
+ import { buildPagePreviewMaps } from "../../api/public-types";
57
57
  import { createContextualInteractionPlugin } from "./pm-contextual-ui";
58
58
  import {
59
59
  finishPerfProbe,
@@ -65,7 +65,10 @@ import { buildPositionMap, type PositionMap } from "./pm-position-map";
65
65
  import { createLocalEditSessionState } from "./local-edit-session-state";
66
66
  import { createFastTextEditLane } from "./fast-text-edit-lane";
67
67
  import { createPredictedTxGate } from "./predicted-tx-gate";
68
- import { createScopeTagRegistry } from "../../runtime/workflow/scope-tag-registry";
68
+ import {
69
+ createScopeTagRegistry,
70
+ type ScopeTagRegistry,
71
+ } from "../../api/public-types";
69
72
  import { hasBailIfCrossedTagInRange } from "./predicted-tag-preflight";
70
73
  import {
71
74
  clearSearch as clearSearchPlugin,
@@ -265,6 +268,20 @@ export interface TwProseMirrorSurfaceProps {
265
268
  runtimeGetTableSelectionDescriptor?: (
266
269
  state: import("prosemirror-state").EditorState,
267
270
  ) => TableSelectionDescriptor | null;
271
+ /**
272
+ * Coord-10 L11-4 — host-supplied scope-tag registry factory.
273
+ * Threaded from `api.runtime.workflow.createScopeTagRegistry` at the
274
+ * WordReviewEditor mount. Mints a fresh default-seeded
275
+ * `ScopeTagRegistry` that the PM surface uses to drive annotation-
276
+ * family boundary behaviors (bail-on-cross, split-on-cross, etc.).
277
+ *
278
+ * When omitted, the surface falls back to the public-types re-export
279
+ * (`createScopeTagRegistry` from `src/api/public-types.ts`, shipped
280
+ * 2026-04-24 per refactor/11 §4.17 as a pragmatic pass-through).
281
+ * The fallback is retained for back-compat with any consumer that
282
+ * mounts the surface without threading an api handle (legacy tests).
283
+ */
284
+ scopeTagRegistryFactory?: () => ScopeTagRegistry;
268
285
  onPasteApplied?: (meta: {
269
286
  segmentCount: number;
270
287
  charCount: number;
@@ -435,9 +452,17 @@ export const TwProseMirrorSurface = forwardRef<
435
452
  const snapshotRef = useRef(snapshot);
436
453
  snapshotRef.current = snapshot;
437
454
 
455
+ // Coord-10 L11-4 — prefer the host-supplied `scopeTagRegistryFactory`
456
+ // (threaded from `api.runtime.workflow.createScopeTagRegistry` at the
457
+ // WordReviewEditor mount); fall back to the public-types re-export
458
+ // for legacy mounts that don't thread an api handle.
459
+ const scopeTagRegistryFactoryProp = props.scopeTagRegistryFactory;
438
460
  const scopeTagRegistry = useMemo(
439
- () => createScopeTagRegistry(),
440
- [],
461
+ () =>
462
+ scopeTagRegistryFactoryProp
463
+ ? scopeTagRegistryFactoryProp()
464
+ : createScopeTagRegistry(),
465
+ [scopeTagRegistryFactoryProp],
441
466
  );
442
467
 
443
468
  // Keep callbacks ref up to date (avoids stale closures in PM plugins)
@@ -112,11 +112,13 @@ export {
112
112
  type TwScopeRailLayerProps,
113
113
  } from "./chrome-overlay";
114
114
 
115
- // Session capabilities
115
+ // Session capabilities — sourced via `src/api/public-types` re-export
116
+ // so the public facade under `src/ui-tailwind/**` stays P3-clean.
117
+ // Canonical impl lives on `src/runtime/session-capabilities.ts`.
116
118
  export {
117
119
  deriveCapabilities,
118
120
  type SessionCapabilities,
119
- } from "../runtime/session-capabilities";
121
+ } from "../api/public-types";
120
122
 
121
123
  // Headless (re-export for convenience)
122
124
  export {
@@ -1,16 +1,14 @@
1
- import type {
2
- DocumentNavigationSnapshot,
3
- PageLayoutSnapshot,
4
- SurfaceBlockSnapshot,
5
- } from "../api/public-types.ts";
6
- import { findPageForOffset } from "../runtime/document-navigation.ts";
7
1
  import {
2
+ type DocumentNavigationSnapshot,
3
+ type PageLayoutSnapshot,
4
+ type SurfaceBlockSnapshot,
5
+ findPageForOffset,
8
6
  DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP,
9
7
  estimateBlockHeight,
10
8
  estimateParagraphLineCount,
11
9
  estimateParagraphLineHeight,
12
10
  getUsableColumnWidth,
13
- } from "../runtime/page-layout-estimation.ts";
11
+ } from "../api/public-types.ts";
14
12
 
15
13
  const DOCUMENT_CONTENT_TOP_PADDING_PX = 40;
16
14
 
@@ -7,8 +7,11 @@ import type {
7
7
  SurfaceInlineSegment,
8
8
  } from "../../api/public-types.ts";
9
9
  import type { WordReviewEditorLayoutFacet } from "../../api/public-types.ts";
10
- import { storyTargetKey } from "../../runtime/story-targeting.ts";
11
- import { EMU_PER_PX, TWIPS_PER_PX } from "../../api/public-types.ts";
10
+ import {
11
+ storyTargetKey,
12
+ EMU_PER_PX,
13
+ TWIPS_PER_PX,
14
+ } from "../../api/public-types.ts";
12
15
  import type { PageOverlayRect } from "../chrome-overlay/tw-page-stack-overlay-layer.tsx";
13
16
 
14
17
  /**
@@ -98,40 +101,57 @@ export function collectFloatingImageOverlayItems(input: {
98
101
  );
99
102
  const items: FloatingImageOverlayItem[] = [];
100
103
 
101
- walkSurfaceBlocks(surface.blocks, (segment) => {
102
- if (segment.kind !== "image" || !shouldRenderAbsoluteFloatingImageInPageOverlay(segment.anchor)) {
103
- return;
104
- }
105
-
106
- const pages = resolveTargetPages(facet, segment.from, activeStory);
107
- for (const page of pages) {
108
- const pageRect = rectByPageIndex.get(page.pageIndex);
109
- if (!pageRect) {
110
- continue;
104
+ const collectFromStory = (
105
+ blocks: readonly SurfaceBlockSnapshot[],
106
+ storyTarget: EditorStoryTarget,
107
+ ) => {
108
+ walkSurfaceBlocks(blocks, (segment) => {
109
+ if (
110
+ segment.kind !== "image" ||
111
+ !shouldRenderAbsoluteFloatingImageInPageOverlay(segment.anchor)
112
+ ) {
113
+ return;
111
114
  }
112
- const localRect = resolveFloatingImageLocalRect(page, activeStory, segment, pxPerTwip);
113
- if (!localRect) {
114
- continue;
115
+
116
+ const pages = resolveTargetPages(facet, segment.from, storyTarget);
117
+ for (const page of pages) {
118
+ const pageRect = rectByPageIndex.get(page.pageIndex);
119
+ if (!pageRect) {
120
+ continue;
121
+ }
122
+ const localRect = resolveFloatingImageLocalRect(page, storyTarget, segment, pxPerTwip);
123
+ if (!localRect) {
124
+ continue;
125
+ }
126
+ const preview = input.mediaPreviews?.[segment.mediaId];
127
+ items.push({
128
+ key: `${segment.segmentId}:${page.pageId}`,
129
+ mediaId: segment.mediaId,
130
+ from: segment.from,
131
+ to: segment.to,
132
+ pageId: page.pageId,
133
+ pageIndex: page.pageIndex,
134
+ topPx: pageRect.topPx + localRect.topPx,
135
+ leftPx: localRect.leftPx,
136
+ widthPx: localRect.widthPx,
137
+ heightPx: localRect.heightPx,
138
+ behindDoc: Boolean(segment.anchor?.behindDoc),
139
+ src: preview?.src ?? null,
140
+ altText: segment.altText ?? null,
141
+ detail: segment.detail ?? null,
142
+ });
115
143
  }
116
- const preview = input.mediaPreviews?.[segment.mediaId];
117
- items.push({
118
- key: `${segment.segmentId}:${page.pageId}`,
119
- mediaId: segment.mediaId,
120
- from: segment.from,
121
- to: segment.to,
122
- pageId: page.pageId,
123
- pageIndex: page.pageIndex,
124
- topPx: pageRect.topPx + localRect.topPx,
125
- leftPx: localRect.leftPx,
126
- widthPx: localRect.widthPx,
127
- heightPx: localRect.heightPx,
128
- behindDoc: Boolean(segment.anchor?.behindDoc),
129
- src: preview?.src ?? null,
130
- altText: segment.altText ?? null,
131
- detail: segment.detail ?? null,
132
- });
133
- }
134
- });
144
+ });
145
+ };
146
+
147
+ // coord-01 §9 / §5.1 — CCEP logos live in header stories; collect from
148
+ // the main story for the active-story case AND from every secondary
149
+ // story so header/footer images reach the overlay regardless of which
150
+ // story is active in the editor.
151
+ collectFromStory(surface.blocks, activeStory);
152
+ for (const secondary of surface.secondaryStories ?? []) {
153
+ collectFromStory(secondary.blocks, secondary.target);
154
+ }
135
155
 
136
156
  return items;
137
157
  }
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
 
3
3
  import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
4
5
  import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
6
 
6
7
  // ---------------------------------------------------------------------------
@@ -27,11 +28,13 @@ import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
27
28
  export interface TwEndnoteAreaProps {
28
29
  blocks: readonly SurfaceBlockSnapshot[];
29
30
  "data-testid"?: string;
31
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
30
32
  }
31
33
 
32
34
  export const TwEndnoteArea: React.FC<TwEndnoteAreaProps> = ({
33
35
  blocks,
34
36
  "data-testid": testId,
37
+ mediaPreviews,
35
38
  }) => {
36
39
  if (blocks.length === 0) return null;
37
40
  return (
@@ -49,7 +52,7 @@ export const TwEndnoteArea: React.FC<TwEndnoteAreaProps> = ({
49
52
  marginBottom: "8pt",
50
53
  }}
51
54
  />
52
- <TwRegionBlockRenderer blocks={blocks} />
55
+ <TwRegionBlockRenderer blocks={blocks} mediaPreviews={mediaPreviews} />
53
56
  </div>
54
57
  );
55
58
  };
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
 
3
3
  import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
4
5
  import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
6
 
6
7
  // ---------------------------------------------------------------------------
@@ -30,6 +31,7 @@ export interface TwFootnoteAreaProps {
30
31
  widthPx: number;
31
32
  heightPx: number;
32
33
  "data-testid"?: string;
34
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
33
35
  }
34
36
 
35
37
  export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = React.memo(({
@@ -40,6 +42,7 @@ export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = React.memo(({
40
42
  widthPx,
41
43
  heightPx,
42
44
  "data-testid": testId,
45
+ mediaPreviews,
43
46
  }) => {
44
47
  return (
45
48
  <div
@@ -63,7 +66,7 @@ export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = React.memo(({
63
66
  marginBottom: "4pt",
64
67
  }}
65
68
  />
66
- <TwRegionBlockRenderer blocks={blocks} />
69
+ <TwRegionBlockRenderer blocks={blocks} mediaPreviews={mediaPreviews} />
67
70
  </div>
68
71
  );
69
72
  });
@@ -26,6 +26,7 @@ import { FRAME_PX_PER_TWIP_AT_96DPI } from "../tw-review-workspace.tsx";
26
26
  import { TwPageHeaderBand } from "./tw-page-header-band.tsx";
27
27
  import { TwPageFooterBand } from "./tw-page-footer-band.tsx";
28
28
  import { TwFootnoteArea } from "./tw-footnote-area.tsx";
29
+ import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
29
30
 
30
31
  export interface TwPageChromeEntryProps {
31
32
  rect: PageOverlayRect;
@@ -36,6 +37,9 @@ export interface TwPageChromeEntryProps {
36
37
  onOpenStory?: (target: EditorStoryTarget) => void;
37
38
  visiblePageIndexRange?: { start: number; end: number } | null;
38
39
  renderFrameRevision: number;
40
+ /** Preview catalog threaded into header/footer/footnote region renderers
41
+ * so images (CCEP logos on 7-of-8 CCEP docs) render as real <img>s. */
42
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
39
43
  }
40
44
 
41
45
  function TwPageChromeEntryInner({
@@ -47,6 +51,7 @@ function TwPageChromeEntryInner({
47
51
  onOpenStory,
48
52
  visiblePageIndexRange,
49
53
  renderFrameRevision,
54
+ mediaPreviews,
50
55
  }: TwPageChromeEntryProps): React.ReactElement {
51
56
  const layout = page.layout;
52
57
  const headerStory = page.stories.header;
@@ -186,6 +191,7 @@ function TwPageChromeEntryInner({
186
191
  isActiveSlot={Boolean(headerActive)}
187
192
  sectionLabel={headerActive ? headerSectionLabel : undefined}
188
193
  onClick={handleHeaderClick}
194
+ mediaPreviews={mediaPreviews}
189
195
  />
190
196
  ) : null}
191
197
  {footerRegion && footerStory ? (
@@ -199,6 +205,7 @@ function TwPageChromeEntryInner({
199
205
  isActiveSlot={Boolean(footerActive)}
200
206
  sectionLabel={footerActive ? footerSectionLabel : undefined}
201
207
  onClick={handleFooterClick}
208
+ mediaPreviews={mediaPreviews}
202
209
  />
203
210
  ) : null}
204
211
  {footnoteRegion ? (
@@ -209,6 +216,7 @@ function TwPageChromeEntryInner({
209
216
  leftPx={bandLeftPx}
210
217
  widthPx={px(footnoteRegion.widthTwips)}
211
218
  heightPx={px(footnoteRegion.heightTwips)}
219
+ mediaPreviews={mediaPreviews}
212
220
  />
213
221
  ) : null}
214
222
  {continuationTableEntries.map(({ blockId, headerRows }) => (
@@ -240,7 +248,8 @@ function propsAreEqual(
240
248
  prev.renderFrameRevision === next.renderFrameRevision &&
241
249
  prev.rect.topPx === next.rect.topPx &&
242
250
  prev.rect.bottomPx === next.rect.bottomPx &&
243
- prev.rect.pageId === next.rect.pageId
251
+ prev.rect.pageId === next.rect.pageId &&
252
+ prev.mediaPreviews === next.mediaPreviews
244
253
  );
245
254
  }
246
255
 
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
 
3
3
  import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
4
5
  import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
6
 
6
7
  // ---------------------------------------------------------------------------
@@ -34,6 +35,7 @@ export interface TwPageFooterBandProps {
34
35
  sectionLabel?: string;
35
36
  onClick: () => void;
36
37
  "data-testid"?: string;
38
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
37
39
  }
38
40
 
39
41
  export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
@@ -47,6 +49,7 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
47
49
  sectionLabel,
48
50
  onClick,
49
51
  "data-testid": testId,
52
+ mediaPreviews,
50
53
  }) => {
51
54
  return (
52
55
  <div
@@ -77,7 +80,11 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
77
80
  style={{ width: "100%", height: "100%" }}
78
81
  />
79
82
  ) : (
80
- <TwRegionBlockRenderer blocks={blocks} />
83
+ <TwRegionBlockRenderer
84
+ blocks={blocks}
85
+ mediaPreviews={mediaPreviews}
86
+ fallbackDisplay="hidden"
87
+ />
81
88
  )}
82
89
  </div>
83
90
  );
@@ -1,6 +1,7 @@
1
1
  import React from "react";
2
2
 
3
3
  import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
4
5
  import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
6
 
6
7
  // ---------------------------------------------------------------------------
@@ -35,6 +36,10 @@ export interface TwPageHeaderBandProps {
35
36
  sectionLabel?: string;
36
37
  onClick: () => void;
37
38
  "data-testid"?: string;
39
+ /** Preview catalog threaded to the region renderer so header images
40
+ * (CCEP logos on 7-of-8 CCEP docs) render as real <img>s instead of
41
+ * the 48×32 placeholder chip. */
42
+ mediaPreviews?: Record<string, MediaPreviewDescriptor>;
38
43
  }
39
44
 
40
45
  export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
@@ -48,6 +53,7 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
48
53
  sectionLabel,
49
54
  onClick,
50
55
  "data-testid": testId,
56
+ mediaPreviews,
51
57
  }) => {
52
58
  return (
53
59
  <div
@@ -78,7 +84,11 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
78
84
  style={{ width: "100%", height: "100%" }}
79
85
  />
80
86
  ) : (
81
- <TwRegionBlockRenderer blocks={blocks} />
87
+ <TwRegionBlockRenderer
88
+ blocks={blocks}
89
+ mediaPreviews={mediaPreviews}
90
+ fallbackDisplay="hidden"
91
+ />
82
92
  )}
83
93
  </div>
84
94
  );
@@ -145,6 +145,10 @@ export interface TwPageStackChromeLayerProps {
145
145
  visiblePageIndexRange?: { start: number; end: number } | null;
146
146
  /** Optional test id applied to the layer root. */
147
147
  "data-testid"?: string;
148
+ /** Preview catalog threaded through to per-page chrome bands so images
149
+ * in headers/footers/footnote bodies render as real <img>s. Without
150
+ * this, image segments fall back to the 48×32 gray placeholder chip. */
151
+ mediaPreviews?: Record<string, import("../editor-surface/pm-state-from-snapshot.ts").MediaPreviewDescriptor>;
148
152
  }
149
153
 
150
154
  const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
@@ -157,6 +161,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
157
161
  pmView,
158
162
  visiblePageIndexRange,
159
163
  "data-testid": testId,
164
+ mediaPreviews,
160
165
  }) => {
161
166
  const [rects, setRects] = React.useState<readonly PageOverlayRect[]>([]);
162
167
  const overlayRootRef = React.useRef<HTMLDivElement | null>(null);
@@ -401,10 +406,11 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
401
406
  onOpenStory={onOpenStory}
402
407
  visiblePageIndexRange={visiblePageIndexRange}
403
408
  renderFrameRevision={renderFrameRevision}
409
+ mediaPreviews={mediaPreviews}
404
410
  />
405
411
  );
406
412
  })}
407
- <TwEndnoteArea blocks={endnoteBlocks} />
413
+ <TwEndnoteArea blocks={endnoteBlocks} mediaPreviews={mediaPreviews} />
408
414
  </div>
409
415
  );
410
416
  };