@beyondwork/docx-react-component 1.0.42 → 1.0.45

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 (82) hide show
  1. package/README.md +17 -0
  2. package/package.json +5 -4
  3. package/src/api/editor-state-types.ts +110 -0
  4. package/src/api/public-types.ts +333 -4
  5. package/src/core/commands/formatting-commands.ts +7 -1
  6. package/src/core/commands/index.ts +60 -10
  7. package/src/core/commands/text-commands.ts +59 -0
  8. package/src/core/search/search-text.ts +15 -2
  9. package/src/core/selection/review-anchors.ts +131 -21
  10. package/src/index.ts +29 -1
  11. package/src/io/chart-preview-resolver.ts +281 -0
  12. package/src/io/docx-session.ts +692 -2
  13. package/src/io/export/build-app-properties-xml.ts +1 -1
  14. package/src/io/export/serialize-comments.ts +38 -9
  15. package/src/io/export/twip.ts +1 -1
  16. package/src/io/load-scheduler.ts +230 -0
  17. package/src/io/normalize/normalize-text.ts +116 -0
  18. package/src/io/ooxml/parse-comments.ts +0 -33
  19. package/src/io/ooxml/parse-complex-content.ts +14 -0
  20. package/src/io/ooxml/parse-main-document.ts +4 -0
  21. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  22. package/src/io/ooxml/workflow-payload.ts +172 -1
  23. package/src/preservation/opaque-region.ts +5 -0
  24. package/src/review/store/comment-remapping.ts +2 -2
  25. package/src/runtime/collab-session.ts +1 -1
  26. package/src/runtime/document-runtime.ts +661 -42
  27. package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
  28. package/src/runtime/edit-dispatch/index.ts +2 -0
  29. package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
  30. package/src/runtime/editor-state-channel.ts +544 -0
  31. package/src/runtime/editor-state-integration.ts +217 -0
  32. package/src/runtime/editor-surface/capabilities.ts +411 -0
  33. package/src/runtime/layout/index.ts +2 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +63 -2
  36. package/src/runtime/layout/layout-engine-version.ts +41 -0
  37. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  38. package/src/runtime/layout/public-facet.ts +430 -1
  39. package/src/runtime/perf-counters.ts +28 -0
  40. package/src/runtime/prerender/cache-envelope.ts +29 -0
  41. package/src/runtime/prerender/cache-key.ts +66 -0
  42. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  43. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  44. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  45. package/src/runtime/prerender/prerender-document.ts +145 -0
  46. package/src/runtime/render/block-fragment-projection.ts +2 -0
  47. package/src/runtime/render/render-frame-types.ts +17 -0
  48. package/src/runtime/render/render-kernel.ts +172 -29
  49. package/src/runtime/selection/post-edit-validator.ts +77 -0
  50. package/src/runtime/surface-projection.ts +45 -7
  51. package/src/runtime/workflow-markup.ts +71 -16
  52. package/src/ui/WordReviewEditor.tsx +142 -237
  53. package/src/ui/editor-command-bag.ts +14 -0
  54. package/src/ui/editor-runtime-boundary.ts +115 -12
  55. package/src/ui/editor-shell-view.tsx +10 -0
  56. package/src/ui/editor-surface-controller.tsx +5 -0
  57. package/src/ui/headless/selection-helpers.ts +10 -0
  58. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  59. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  60. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  61. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  62. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  63. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  64. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  65. package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
  66. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
  67. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  68. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  69. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  70. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
  71. package/src/ui-tailwind/index.ts +5 -1
  72. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  73. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  74. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  75. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  76. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  77. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  78. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  79. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  80. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  81. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  82. package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
@@ -17,7 +17,10 @@ import { MAIN_STORY_TARGET } from "../../core/selection/mapping.ts";
17
17
  import { recordPerfSample } from "../../ui-tailwind/editor-surface/perf-probe.ts";
18
18
  import type { EditorStoryTarget } from "../../api/public-types";
19
19
  import type {
20
+ PublicBlockFragment,
20
21
  PublicPageNode,
22
+ PublicPageRegion,
23
+ PublicRegionBlock,
21
24
  WordReviewEditorLayoutFacet,
22
25
  } from "../layout/public-facet.ts";
23
26
  import type {
@@ -305,6 +308,7 @@ function buildPage(
305
308
  zoom,
306
309
  "header",
307
310
  page.stories.header,
311
+ facet,
308
312
  );
309
313
  }
310
314
  if (page.stories.footer) {
@@ -314,9 +318,18 @@ function buildPage(
314
318
  zoom,
315
319
  "footer",
316
320
  page.stories.footer,
321
+ facet,
317
322
  );
318
323
  }
319
324
 
325
+ const footnoteRegions = buildFootnoteRegions(page, topPx, zoom, facet);
326
+ if (footnoteRegions.length > 0) {
327
+ regions.footnotes = footnoteRegions;
328
+ }
329
+ // Endnotes intentionally skipped — per-page endnote projection is not
330
+ // populated; endnotes use document-end placement via
331
+ // `facet.getDocumentEndnoteBlocks()`.
332
+
320
333
  const chromeReservations: PageChromeReservations = {
321
334
  ...defaultChromeReservations(layout, zoom),
322
335
  };
@@ -440,51 +453,181 @@ function buildHeaderFooterRegion(
440
453
  zoom: RenderZoom,
441
454
  kind: "header" | "footer",
442
455
  storyTarget: EditorStoryTarget,
456
+ facet: WordReviewEditorLayoutFacet,
443
457
  ): RenderStoryRegion {
444
458
  const layout = page.layout;
445
- const widthTwips =
459
+ const fallbackWidthTwips =
446
460
  layout.pageWidth - layout.marginLeft - layout.marginRight;
447
- let topTwips = 0;
448
- let heightTwips = 0;
449
- if (kind === "header") {
450
- topTwips = layout.headerMargin ?? 720;
451
- heightTwips = Math.max(0, layout.marginTop - topTwips);
452
- } else {
453
- topTwips = layout.pageHeight - layout.marginBottom;
454
- heightTwips = Math.max(0, layout.marginBottom - (layout.footerMargin ?? 720));
455
- }
461
+ const fallbackTopTwips =
462
+ kind === "header"
463
+ ? (layout.headerMargin ?? 720)
464
+ : layout.pageHeight - layout.marginBottom;
465
+ const fallbackHeightTwips =
466
+ kind === "header"
467
+ ? Math.max(0, layout.marginTop - (layout.headerMargin ?? 720))
468
+ : Math.max(0, layout.marginBottom - (layout.footerMargin ?? 720));
469
+
470
+ const region: PublicPageRegion =
471
+ (kind === "header" ? page.regions.header : page.regions.footer) ?? {
472
+ kind,
473
+ originTwips: fallbackTopTwips,
474
+ widthTwips: fallbackWidthTwips,
475
+ heightTwips: fallbackHeightTwips,
476
+ fragmentCount: 0,
477
+ };
456
478
 
457
479
  const frame: RenderFrameRect = {
458
480
  leftPx: layout.marginLeft * zoom.pxPerTwip,
459
- topPx: pageTopPx + topTwips * zoom.pxPerTwip,
460
- widthPx: widthTwips * zoom.pxPerTwip,
461
- heightPx: heightTwips * zoom.pxPerTwip,
481
+ topPx: pageTopPx + region.originTwips * zoom.pxPerTwip,
482
+ widthPx: region.widthTwips * zoom.pxPerTwip,
483
+ heightPx: region.heightTwips * zoom.pxPerTwip,
462
484
  };
463
485
 
464
- const region = kind === "header" ? page.regions.header : page.regions.footer;
465
- if (!region) {
466
- return {
467
- storyTarget,
468
- region: {
469
- kind,
470
- originTwips: topTwips,
471
- widthTwips,
472
- heightTwips,
473
- fragmentCount: 0,
474
- },
475
- frame,
476
- blocks: [],
477
- };
478
- }
486
+ const regionBlocks = facet.getStoryBlocksForRegion(page.pageIndex, kind);
487
+ const blocks = projectRegionBlocks(
488
+ regionBlocks,
489
+ frame,
490
+ zoom.pxPerTwip,
491
+ page.pageId,
492
+ page.pageIndex,
493
+ kind,
494
+ );
479
495
 
480
496
  return {
481
497
  storyTarget,
482
498
  region,
483
499
  frame,
484
- blocks: [],
500
+ blocks,
485
501
  };
486
502
  }
487
503
 
504
+ /**
505
+ * P8.3 — Build one `RenderStoryRegion` per `page.regions.footnotes` entry.
506
+ * The page graph reserves footnote regions at the bottom of the page
507
+ * (above the footer band) when `noteAllocations` produced fragments.
508
+ * Blocks come from `facet.getStoryBlocksForRegion(pageIndex, "footnote-area")`
509
+ * — one entry per allocated note body, stacked vertically.
510
+ *
511
+ * Currently the page graph emits a single footnote region per page
512
+ * covering every allocation, so the returned array has length 0 or 1. The
513
+ * shape allows for future allocation-splitting without changing the
514
+ * render-kernel contract.
515
+ */
516
+ function buildFootnoteRegions(
517
+ page: PublicPageNode,
518
+ pageTopPx: number,
519
+ zoom: RenderZoom,
520
+ facet: WordReviewEditorLayoutFacet,
521
+ ): RenderStoryRegion[] {
522
+ const footnoteRegions = page.regions.footnotes;
523
+ if (!footnoteRegions || footnoteRegions.length === 0) return [];
524
+
525
+ const regionBlocks = facet.getStoryBlocksForRegion(
526
+ page.pageIndex,
527
+ "footnote-area",
528
+ );
529
+ if (regionBlocks.length === 0) return [];
530
+
531
+ const results: RenderStoryRegion[] = [];
532
+ // Today the runtime emits a single footnote-area region per page; if that
533
+ // ever changes we will split `regionBlocks` across each region entry's
534
+ // `fragmentCount`. Preserve the shape so consumers can iterate safely.
535
+ let cursor = 0;
536
+ for (const region of footnoteRegions) {
537
+ const frame: RenderFrameRect = {
538
+ leftPx: page.layout.marginLeft * zoom.pxPerTwip,
539
+ topPx: pageTopPx + region.originTwips * zoom.pxPerTwip,
540
+ widthPx: region.widthTwips * zoom.pxPerTwip,
541
+ heightPx: region.heightTwips * zoom.pxPerTwip,
542
+ };
543
+
544
+ const blocksForThisRegion =
545
+ footnoteRegions.length === 1
546
+ ? regionBlocks
547
+ : regionBlocks.slice(cursor, cursor + region.fragmentCount);
548
+ cursor += region.fragmentCount;
549
+
550
+ const blocks = projectRegionBlocks(
551
+ blocksForThisRegion,
552
+ frame,
553
+ zoom.pxPerTwip,
554
+ page.pageId,
555
+ page.pageIndex,
556
+ "footnote-area",
557
+ );
558
+
559
+ // Footnote regions don't have a single canonical storyTarget (each
560
+ // block belongs to a different footnote body). Pick the first block's
561
+ // note as the region's primary story when available so chrome surfaces
562
+ // keyed on `storyTarget.kind === "footnote"` can dispatch on the band.
563
+ const firstNote = page.noteAllocations.find(
564
+ (alloc) => alloc.noteKind === "footnote",
565
+ );
566
+ const storyTarget: EditorStoryTarget = firstNote
567
+ ? { kind: "footnote", noteId: firstNote.noteId }
568
+ : MAIN_STORY_TARGET;
569
+
570
+ results.push({
571
+ storyTarget,
572
+ region,
573
+ frame,
574
+ blocks,
575
+ });
576
+ }
577
+
578
+ return results;
579
+ }
580
+
581
+ /**
582
+ * P8.3 — Stack a `PublicRegionBlock[]` into `RenderBlock[]` inside the
583
+ * given region frame. The cursor starts at `regionFrame.topPx` and
584
+ * advances by each block's `heightTwips × pxPerTwip`. Synthesizes a
585
+ * `PublicBlockFragment` shape for each block so chrome surfaces reading
586
+ * `RenderBlock.fragment` see a consistent shape across body / header /
587
+ * footer / footnote-area regions.
588
+ */
589
+ function projectRegionBlocks(
590
+ regionBlocks: readonly PublicRegionBlock[],
591
+ regionFrame: RenderFrameRect,
592
+ pxPerTwip: number,
593
+ pageId: string,
594
+ pageIndex: number,
595
+ regionKind: PublicPageRegion["kind"],
596
+ ): RenderBlock[] {
597
+ const blocks: RenderBlock[] = [];
598
+ let y = regionFrame.topPx;
599
+ for (let i = 0; i < regionBlocks.length; i += 1) {
600
+ const regionBlock = regionBlocks[i]!;
601
+ const blockHeightPx = Math.max(0, regionBlock.heightTwips) * pxPerTwip;
602
+ const blockFrame: RenderFrameRect = {
603
+ leftPx: regionFrame.leftPx,
604
+ topPx: y,
605
+ widthPx: regionFrame.widthPx,
606
+ heightPx: blockHeightPx,
607
+ };
608
+ const fragment: PublicBlockFragment = {
609
+ fragmentId: regionBlock.fragmentId,
610
+ blockId: regionBlock.blockId,
611
+ pageId,
612
+ pageIndex,
613
+ regionKind,
614
+ from: regionBlock.runtimeFromOffset,
615
+ to: regionBlock.runtimeToOffset,
616
+ heightTwips: regionBlock.heightTwips,
617
+ orderInRegion: i,
618
+ };
619
+ blocks.push({
620
+ fragment,
621
+ frame: blockFrame,
622
+ kind: classifyBlockKindFromId(regionBlock.blockId),
623
+ lines: [],
624
+ blockDecorations: [],
625
+ });
626
+ y += blockHeightPx;
627
+ }
628
+ return blocks;
629
+ }
630
+
488
631
  // classifyBlockKind moved to `./block-fragment-projection.ts` (P4).
489
632
 
490
633
  function buildAnchorIndex(
@@ -0,0 +1,77 @@
1
+ import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
2
+ import type { SelectionSnapshot } from "../../core/state/editor-state.ts";
3
+
4
+ /**
5
+ * Snap a selection to a valid position relative to the document.
6
+ *
7
+ * Pure function. O(1) on the identity (in-bounds) fast path — returns
8
+ * the SAME object reference when no change is needed. Callers should
9
+ * compare with `!==` to detect a snap (e.g. to decide whether to
10
+ * re-spread runtime state).
11
+ *
12
+ * Wired into the runtime snapshot-emit chokepoint
13
+ * (`applyTransactionToState` -> `cachedRenderSnapshot = refreshRenderSnapshot()`),
14
+ * so it runs once per transaction commit. Must NOT walk the document;
15
+ * the caller is responsible for passing a valid `maxOffset` (the
16
+ * POST-mutation `surface.storySize`, primed via
17
+ * `getCachedSurface(state.document, activeStory).storySize`).
18
+ *
19
+ * NodeAnchor invalidation is deferred until CanonicalDocumentEnvelope
20
+ * grows an O(1) node-by-id accessor. Until then, NodeAnchor selections
21
+ * are returned unchanged (identity).
22
+ *
23
+ * @param document The post-mutation canonical document. Currently
24
+ * unused except for the deferred NodeAnchor branch;
25
+ * the parameter is kept for API stability.
26
+ * @param selection The selection to validate.
27
+ * @param maxOffset The POST-mutation maximum story offset. Caller
28
+ * passes `getCachedSurface(state.document,
29
+ * activeStory).storySize` (which primes the cache
30
+ * that `refreshRenderSnapshot` reuses on its next
31
+ * call — no extra surface walk). The validator does
32
+ * NOT walk the document to compute this. Do NOT pass
33
+ * the pre-mutation snapshot's storySize: at end-of-doc
34
+ * inserts, the new selection legitimately exceeds the
35
+ * old bound and the validator would clamp the caret
36
+ * backward by one position per keystroke. Pass
37
+ * `Number.POSITIVE_INFINITY` to skip the upper-bound
38
+ * clamp.
39
+ */
40
+ export function validateSelectionAgainstDocument(
41
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- reserved for deferred NodeAnchor lookup
42
+ document: CanonicalDocumentEnvelope,
43
+ selection: SelectionSnapshot,
44
+ maxOffset: number,
45
+ ): SelectionSnapshot {
46
+ if (selection.activeRange.kind === "node") {
47
+ // Deferred: NodeAnchor invalidation requires an O(1) node-by-id
48
+ // accessor on CanonicalDocumentEnvelope. Until that lands, return
49
+ // identity so we never falsely invalidate a still-valid node anchor.
50
+ return selection;
51
+ }
52
+
53
+ const anchor = clamp(selection.anchor, 0, maxOffset);
54
+ const head = clamp(selection.head, 0, maxOffset);
55
+
56
+ if (anchor === selection.anchor && head === selection.head) {
57
+ // Identity fast path — no allocation, same reference returned.
58
+ return selection;
59
+ }
60
+
61
+ const range = { from: Math.min(anchor, head), to: Math.max(anchor, head) };
62
+ const assoc =
63
+ selection.activeRange.kind === "range"
64
+ ? selection.activeRange.assoc
65
+ : { start: 1 as const, end: 1 as const };
66
+
67
+ return {
68
+ anchor,
69
+ head,
70
+ isCollapsed: anchor === head,
71
+ activeRange: { kind: "range", range, assoc },
72
+ };
73
+ }
74
+
75
+ function clamp(n: number, lo: number, hi: number): number {
76
+ return n < lo ? lo : n > hi ? hi : n;
77
+ }
@@ -68,11 +68,17 @@ interface ParagraphAccumulator {
68
68
  segments: SurfaceInlineSegment[];
69
69
  }
70
70
 
71
+ export interface SurfaceProjectionOptions {
72
+ viewportBlockRange?: { start: number; end: number } | null;
73
+ }
74
+
71
75
  export function createEditorSurfaceSnapshot(
72
76
  document: CanonicalDocumentEnvelope,
73
77
  _selection: SelectionSnapshot,
74
78
  activeStory: EditorStoryTarget = { kind: "main" },
79
+ options: SurfaceProjectionOptions = {},
75
80
  ): EditorSurfaceSnapshot {
81
+ const viewportBlockRange = options.viewportBlockRange ?? null;
76
82
  const root = normalizeDocumentRoot({
77
83
  type: "doc",
78
84
  children: [...getStoryBlocks(document, activeStory)],
@@ -99,15 +105,41 @@ export function createEditorSurfaceSnapshot(
99
105
  numberingPrefixResolver,
100
106
  activeStory.kind !== "main",
101
107
  );
102
- blocks.push(surfaceBlock.block);
103
- lockedFragmentIds.push(...surfaceBlock.lockedFragmentIds);
108
+ const isInViewport =
109
+ viewportBlockRange === null ||
110
+ (index >= viewportBlockRange.start && index < viewportBlockRange.end);
111
+
112
+ if (isInViewport) {
113
+ blocks.push(surfaceBlock.block);
114
+ lockedFragmentIds.push(...surfaceBlock.lockedFragmentIds);
115
+ } else {
116
+ // Replace with size-preserving placeholder. from/to track the SAME
117
+ // position range as the real block, so selection and anchor stability
118
+ // outside the viewport is preserved.
119
+ const placeholderSize = surfaceBlock.nextCursor - cursor;
120
+ const placeholderBlockId = `placeholder-culled-${index}`;
121
+ blocks.push({
122
+ blockId: placeholderBlockId,
123
+ kind: "opaque_block",
124
+ from: cursor,
125
+ to: surfaceBlock.nextCursor,
126
+ fragmentId: placeholderBlockId,
127
+ warningId: placeholderBlockId,
128
+ label: "",
129
+ detail: "",
130
+ placeholderSize,
131
+ state: "placeholder-culled",
132
+ } as SurfaceBlockSnapshot);
133
+ // Do NOT push lockedFragmentIds — placeholder has no real fragment.
134
+ }
135
+
104
136
  cursor = surfaceBlock.nextCursor;
105
137
  if (index < root.children.length - 1 && root.children[index + 1]?.type === "paragraph") {
106
138
  cursor += 1;
107
139
  }
108
140
  }
109
141
 
110
- const secondaryStories = createSecondaryStorySurfaces(document);
142
+ const secondaryStories = createSecondaryStorySurfaces(document, numberingPrefixResolver);
111
143
 
112
144
  return {
113
145
  storySize: cursor,
@@ -115,6 +147,7 @@ export function createEditorSurfaceSnapshot(
115
147
  blocks,
116
148
  lockedFragmentIds,
117
149
  secondaryStories,
150
+ viewportBlockRange,
118
151
  };
119
152
  }
120
153
 
@@ -886,9 +919,13 @@ function appendInlineSegments(
886
919
  return { nextCursor: start + 1, lockedFragmentIds: [node.fragmentId] };
887
920
  }
888
921
  case "chart_preview":
889
- return appendComplexPreviewSegment(paragraph, node, start, "Embedded chart", createChartDetail(node));
922
+ return appendComplexPreviewSegment(paragraph, node, start, "Embedded chart", createChartDetail(node), {
923
+ previewMediaId: node.previewMediaId,
924
+ });
890
925
  case "smartart_preview":
891
- return appendComplexPreviewSegment(paragraph, node, start, "SmartArt diagram", createSmartArtDetail(node));
926
+ return appendComplexPreviewSegment(paragraph, node, start, "SmartArt diagram", createSmartArtDetail(node), {
927
+ previewMediaId: node.previewMediaId,
928
+ });
892
929
  case "shape":
893
930
  if (promoteSecondaryStoryTextBoxes && node.isTextBox && node.text) {
894
931
  return appendTextBoxSegment(
@@ -1023,6 +1060,7 @@ function appendComplexPreviewSegment(
1023
1060
  start: number,
1024
1061
  label: string,
1025
1062
  detail: string,
1063
+ extras: { previewMediaId?: string } = {},
1026
1064
  ): { nextCursor: number; lockedFragmentIds: string[] } {
1027
1065
  paragraph.segments.push({
1028
1066
  segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
@@ -1033,6 +1071,7 @@ function appendComplexPreviewSegment(
1033
1071
  warningId: `warning:complex-preview:${start}`,
1034
1072
  label,
1035
1073
  detail,
1074
+ ...(extras.previewMediaId ? { previewMediaId: extras.previewMediaId } : {}),
1036
1075
  state: "locked-preserve-only",
1037
1076
  });
1038
1077
  return { nextCursor: start + 1, lockedFragmentIds: [] };
@@ -1174,6 +1213,7 @@ function createPlainText(
1174
1213
 
1175
1214
  function createSecondaryStorySurfaces(
1176
1215
  document: CanonicalDocumentEnvelope,
1216
+ numberingPrefixResolver: NumberingPrefixResolver,
1177
1217
  ): SecondaryStorySurface[] {
1178
1218
  const surfaces: SecondaryStorySurface[] = [];
1179
1219
  const subParts = document.subParts;
@@ -1181,8 +1221,6 @@ function createSecondaryStorySurfaces(
1181
1221
  return surfaces;
1182
1222
  }
1183
1223
 
1184
- const numberingPrefixResolver = createNumberingPrefixResolver(document.numbering);
1185
-
1186
1224
  for (const section of collectSectionContexts(document)) {
1187
1225
  const headerVariants = resolveSectionVariants(
1188
1226
  "header",
@@ -22,28 +22,32 @@ import type {
22
22
  WorkflowCommentMarkup,
23
23
  } from "../api/public-types";
24
24
  import { MAIN_STORY_TARGET } from "../core/selection/mapping.ts";
25
- import { searchSurfaceBlocks } from "../core/search/search-text.ts";
25
+ import {
26
+ projectSurfaceText,
27
+ searchProjectedSurfaceText,
28
+ } from "../core/search/search-text.ts";
26
29
  import { describeOpaqueFragment, isBlockedImportFeatureKey } from "../preservation/store.ts";
27
30
  import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
28
31
 
29
- export function collectWorkflowMarkupSnapshot(input: {
30
- renderSnapshot: RuntimeRenderSnapshot;
31
- fieldSnapshot: FieldSnapshot;
32
- protectionSnapshot: ProtectionSnapshot;
33
- preservation: CanonicalDocumentEnvelope["preservation"];
34
- workflowMetadataSnapshot?: WorkflowMetadataSnapshot;
35
- }): WorkflowMarkupSnapshot {
32
+ /**
33
+ * Surface-derived markup (highlights + block-level opaque fragments).
34
+ *
35
+ * Pure function of `(surface, preservation)` — extracted from
36
+ * `collectWorkflowMarkupSnapshot` so callers can cache the expensive walk
37
+ * separately from the cheap reference-equal inputs (metadata, comments,
38
+ * revisions, protected ranges).
39
+ */
40
+ export function collectWorkflowSurfaceMarkup(
41
+ surface: RuntimeRenderSnapshot["surface"],
42
+ preservation: CanonicalDocumentEnvelope["preservation"],
43
+ ): { highlights: WorkflowHighlightMarkup[]; opaqueFragments: WorkflowOpaqueFragmentMarkup[] } {
36
44
  const highlights: WorkflowHighlightMarkup[] = [];
37
- const metadata = collectWorkflowMetadataMarkup(input.workflowMetadataSnapshot);
38
- const fields: WorkflowFieldMarkup[] = [];
39
45
  const opaqueFragments: WorkflowOpaqueFragmentMarkup[] = [];
40
- const surface = input.renderSnapshot.surface;
41
-
42
46
  if (surface) {
43
47
  collectSurfaceMarkup(
44
48
  surface.blocks,
45
49
  MAIN_STORY_TARGET,
46
- input.preservation,
50
+ preservation,
47
51
  highlights,
48
52
  opaqueFragments,
49
53
  );
@@ -51,16 +55,55 @@ export function collectWorkflowMarkupSnapshot(input: {
51
55
  collectSurfaceMarkup(
52
56
  story.blocks,
53
57
  story.target,
54
- input.preservation,
58
+ preservation,
55
59
  highlights,
56
60
  opaqueFragments,
57
61
  );
58
62
  }
63
+ }
64
+ return { highlights, opaqueFragments };
65
+ }
59
66
 
67
+ export function collectWorkflowMarkupSnapshot(input: {
68
+ renderSnapshot: RuntimeRenderSnapshot;
69
+ fieldSnapshot: FieldSnapshot;
70
+ protectionSnapshot: ProtectionSnapshot;
71
+ preservation: CanonicalDocumentEnvelope["preservation"];
72
+ workflowMetadataSnapshot?: WorkflowMetadataSnapshot;
73
+ surfaceMarkupCache?: {
74
+ highlights: WorkflowHighlightMarkup[];
75
+ opaqueFragments: WorkflowOpaqueFragmentMarkup[];
76
+ };
77
+ perfStage?: (name: string, durationMs: number) => void;
78
+ }): WorkflowMarkupSnapshot {
79
+ const perf = input.perfStage;
80
+ const stageStart = perf ? () => performance.now() : () => 0;
81
+ const stageEnd = perf ? (name: string, t0: number) => perf(name, performance.now() - t0) : () => {};
82
+
83
+ const tMeta = stageStart();
84
+ const metadata = collectWorkflowMetadataMarkup(input.workflowMetadataSnapshot);
85
+ stageEnd("metadata", tMeta);
86
+
87
+ const fields: WorkflowFieldMarkup[] = [];
88
+ const surface = input.renderSnapshot.surface;
89
+
90
+ const tSurface = stageStart();
91
+ const surfaceMarkup = input.surfaceMarkupCache ??
92
+ collectWorkflowSurfaceMarkup(surface, input.preservation);
93
+ const highlights = surfaceMarkup.highlights.slice();
94
+ const opaqueFragments = surfaceMarkup.opaqueFragments.slice();
95
+ stageEnd("surface", tSurface);
96
+
97
+ if (surface) {
98
+ const tFields = stageStart();
60
99
  fields.push(...collectFieldMarkup(surface, input.fieldSnapshot));
100
+ stageEnd("fields", tFields);
61
101
  }
102
+ const tOpaqueRest = stageStart();
62
103
  opaqueFragments.push(...collectOpaqueFragmentMarkup(input.preservation, opaqueFragments));
104
+ stageEnd("opaqueRest", tOpaqueRest);
63
105
 
106
+ const tCommentsEtc = stageStart();
64
107
  const comments = input.renderSnapshot.comments.threads.map((thread): WorkflowCommentMarkup => ({
65
108
  markupId: `comment:${thread.commentId}`,
66
109
  kind: "comment",
@@ -113,6 +156,8 @@ export function collectWorkflowMarkupSnapshot(input: {
113
156
  }),
114
157
  );
115
158
 
159
+ stageEnd("commentsEtc", tCommentsEtc);
160
+
116
161
  const items: WorkflowMarkupItem[] = [
117
162
  ...highlights,
118
163
  ...metadata,
@@ -303,11 +348,21 @@ function collectFieldMarkup(
303
348
  return [];
304
349
  }
305
350
 
351
+ // L7 Phase 1.5: project each story's text once up-front. The prior code
352
+ // called searchSurfaceBlocks per field, which re-projected the entire
353
+ // surface on every invocation. For the CCEP large-tables fixture this
354
+ // was ~220 ms per commit. Hoisting the projection out of the per-field
355
+ // loop collapses that to a single projection per story.
306
356
  const stories = [
307
- { blocks: surface.blocks, storyTarget: MAIN_STORY_TARGET },
357
+ {
358
+ blocks: surface.blocks,
359
+ storyTarget: MAIN_STORY_TARGET,
360
+ projection: projectSurfaceText(surface.blocks),
361
+ },
308
362
  ...surface.secondaryStories.map((story) => ({
309
363
  blocks: story.blocks,
310
364
  storyTarget: story.target,
365
+ projection: projectSurfaceText(story.blocks),
311
366
  })),
312
367
  ];
313
368
 
@@ -318,7 +373,7 @@ function collectFieldMarkup(
318
373
  }
319
374
 
320
375
  for (const story of stories) {
321
- const matches = searchSurfaceBlocks(story.blocks, displayText, { limit: 2 });
376
+ const matches = searchProjectedSurfaceText(story.projection, displayText, { limit: 2 });
322
377
  if (matches.length === 1) {
323
378
  const match = matches[0]!;
324
379
  return [