@beyondwork/docx-react-component 1.0.48 → 1.0.49

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 (45) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +84 -12
  4. package/src/core/commands/index.ts +9 -1
  5. package/src/core/commands/text-commands.ts +3 -1
  6. package/src/core/selection/anchor-conversion.ts +112 -0
  7. package/src/core/selection/review-anchors.ts +108 -3
  8. package/src/core/state/text-transaction.ts +86 -2
  9. package/src/internal/harness-debug-ports.ts +168 -0
  10. package/src/io/chart-preview-resolver.ts +32 -1
  11. package/src/io/export/serialize-main-document.ts +9 -0
  12. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  13. package/src/io/export/serialize-run-formatting.ts +10 -1
  14. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  15. package/src/io/ooxml/chart/color-palette.ts +101 -0
  16. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  17. package/src/io/ooxml/chart/parse-chart-space.ts +118 -46
  18. package/src/io/ooxml/chart/parse-series.ts +76 -11
  19. package/src/io/ooxml/chart/resolve-color.ts +16 -6
  20. package/src/io/ooxml/chart/types.ts +30 -11
  21. package/src/io/ooxml/parse-complex-content.ts +6 -3
  22. package/src/io/ooxml/parse-main-document.ts +41 -0
  23. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  24. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  25. package/src/io/ooxml/property-grab-bag.ts +211 -0
  26. package/src/model/canonical-document.ts +69 -3
  27. package/src/runtime/collab/index.ts +7 -0
  28. package/src/runtime/collab/runtime-collab-sync.ts +51 -0
  29. package/src/runtime/collab/workflow-shared.ts +247 -0
  30. package/src/runtime/document-locations.ts +1 -9
  31. package/src/runtime/document-outline.ts +1 -9
  32. package/src/runtime/document-runtime.ts +74 -49
  33. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  34. package/src/runtime/surface-projection.ts +94 -36
  35. package/src/runtime/theme-color-resolver.ts +188 -0
  36. package/src/runtime/workflow-markup.ts +7 -18
  37. package/src/ui/WordReviewEditor.tsx +18 -2
  38. package/src/ui/editor-runtime-boundary.ts +36 -0
  39. package/src/ui/headless/selection-helpers.ts +10 -23
  40. package/src/ui/unsupported-previews-policy.ts +23 -0
  41. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  42. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  43. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  44. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  45. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
@@ -51,6 +51,7 @@ import {
51
51
  resolveEffectiveRunFormatting,
52
52
  resolveNumberingMarkerRunFormatting,
53
53
  } from "./paragraph-style-resolver.ts";
54
+ import { resolveHyperlinkRunFormatting } from "./hyperlink-color-resolver.ts";
54
55
  import type { CanonicalParagraphFormatting, CanonicalRunFormatting } from "../model/canonical-document.ts";
55
56
 
56
57
  interface ParagraphAccumulator {
@@ -97,6 +98,17 @@ export function createEditorSurfaceSnapshot(
97
98
  };
98
99
 
99
100
  for (let index = 0; index < root.children.length; index += 1) {
101
+ const isInViewport =
102
+ viewportBlockRange === null ||
103
+ (index >= viewportBlockRange.start && index < viewportBlockRange.end);
104
+ // L7 Phase 2.9 — viewport bail on the style-cascade work. When the
105
+ // block is outside the viewport, the surface block produced below is
106
+ // immediately discarded in favor of a `placeholder-culled` entry (we
107
+ // only consume `nextCursor`). Pass `cullBuild: true` so the paragraph
108
+ // + inline walkers skip `resolveEffectiveParagraphFormatting`,
109
+ // `resolveNumberingMarkerRunFormatting`, and per-segment
110
+ // `resolveEffectiveRunFormatting` — the expensive style-catalog walks
111
+ // that dominate surface-projection cost on large docs.
100
112
  const surfaceBlock = createSurfaceBlock(
101
113
  root.children[index],
102
114
  document,
@@ -104,10 +116,8 @@ export function createEditorSurfaceSnapshot(
104
116
  counters,
105
117
  numberingPrefixResolver,
106
118
  activeStory.kind !== "main",
119
+ !isInViewport,
107
120
  );
108
- const isInViewport =
109
- viewportBlockRange === null ||
110
- (index >= viewportBlockRange.start && index < viewportBlockRange.end);
111
121
 
112
122
  if (isInViewport) {
113
123
  blocks.push(surfaceBlock.block);
@@ -165,6 +175,7 @@ function createSurfaceBlock(
165
175
  },
166
176
  numberingPrefixResolver: NumberingPrefixResolver,
167
177
  promoteSecondaryStoryTextBoxes: boolean,
178
+ cullBuild: boolean = false,
168
179
  ): { block: SurfaceBlockSnapshot; lockedFragmentIds: string[]; nextCursor: number } {
169
180
  if (block.type === "opaque_block") {
170
181
  const fragment = getOpaqueFragment(document.preservation as never, block.fragmentId);
@@ -205,6 +216,7 @@ function createSurfaceBlock(
205
216
  counters,
206
217
  numberingPrefixResolver,
207
218
  promoteSecondaryStoryTextBoxes,
219
+ cullBuild,
208
220
  );
209
221
  }
210
222
 
@@ -244,6 +256,7 @@ function createSurfaceBlock(
244
256
  counters,
245
257
  numberingPrefixResolver,
246
258
  promoteSecondaryStoryTextBoxes,
259
+ cullBuild,
247
260
  );
248
261
  }
249
262
 
@@ -336,6 +349,7 @@ function createSurfaceBlock(
336
349
  cursor,
337
350
  numberingPrefixResolver,
338
351
  promoteSecondaryStoryTextBoxes,
352
+ cullBuild,
339
353
  );
340
354
  }
341
355
 
@@ -354,6 +368,7 @@ function createTableBlock(
354
368
  },
355
369
  numberingPrefixResolver: NumberingPrefixResolver,
356
370
  promoteSecondaryStoryTextBoxes: boolean,
371
+ cullBuild: boolean = false,
357
372
  ): { block: SurfaceBlockSnapshot; lockedFragmentIds: string[]; nextCursor: number } {
358
373
  const lockedFragmentIds: string[] = [];
359
374
  let innerCursor = cursor;
@@ -374,6 +389,7 @@ function createTableBlock(
374
389
  counters,
375
390
  numberingPrefixResolver,
376
391
  promoteSecondaryStoryTextBoxes,
392
+ cullBuild,
377
393
  );
378
394
  cellContent.push(result.block);
379
395
  lockedFragmentIds.push(...result.lockedFragmentIds);
@@ -518,6 +534,7 @@ function createSdtBlock(
518
534
  },
519
535
  numberingPrefixResolver: NumberingPrefixResolver,
520
536
  promoteSecondaryStoryTextBoxes: boolean,
537
+ cullBuild: boolean = false,
521
538
  ): { block: SurfaceBlockSnapshot; lockedFragmentIds: string[]; nextCursor: number } {
522
539
  const children: SurfaceBlockSnapshot[] = [];
523
540
  const lockedFragmentIds: string[] = [];
@@ -531,6 +548,7 @@ function createSdtBlock(
531
548
  counters,
532
549
  numberingPrefixResolver,
533
550
  promoteSecondaryStoryTextBoxes,
551
+ cullBuild,
534
552
  );
535
553
  children.push(result.block);
536
554
  lockedFragmentIds.push(...result.lockedFragmentIds);
@@ -566,34 +584,52 @@ function createParagraphBlock(
566
584
  start: number,
567
585
  numberingPrefixResolver: NumberingPrefixResolver,
568
586
  promoteSecondaryStoryTextBoxes: boolean,
587
+ cullBuild: boolean = false,
569
588
  ): {
570
589
  block: SurfaceBlockSnapshot;
571
590
  nextCursor: number;
572
591
  lockedFragmentIds: string[];
573
592
  } {
574
- const effectiveNumbering = resolveEffectiveParagraphNumbering(document, paragraph);
575
- const resolvedNumbering = effectiveNumbering
576
- ? numberingPrefixResolver.resolveDetailed(effectiveNumbering, paragraph)
577
- : null;
578
-
579
- // Task 11: compute cascaded paragraph formatting
593
+ // L7 Phase 2.9 — viewport bail. When the paragraph is outside the
594
+ // viewport, the returned block is discarded (the outer caller in
595
+ // `createEditorSurfaceSnapshot` replaces it with a placeholder-culled
596
+ // entry and only consumes `nextCursor`). Skip the two style-catalog
597
+ // walks (`resolveEffectiveParagraphFormatting`,
598
+ // `resolveNumberingMarkerRunFormatting`) their results are not read
599
+ // by the placeholder path. Segment-level work inside
600
+ // `appendInlineSegments` is suppressed symmetrically via the same
601
+ // `cullBuild` flag, preserving cursor arithmetic.
602
+ const effectiveNumbering = cullBuild
603
+ ? undefined
604
+ : resolveEffectiveParagraphNumbering(document, paragraph);
605
+ const resolvedNumbering =
606
+ !cullBuild && effectiveNumbering
607
+ ? numberingPrefixResolver.resolveDetailed(effectiveNumbering, paragraph)
608
+ : null;
609
+
610
+ // Task 11: compute cascaded paragraph formatting (expensive — styles-catalog walk).
580
611
  const stylesCatalog = document.styles;
581
- const directParagraphFormatting = buildDirectParagraphFormattingFromNode(paragraph);
582
- const resolvedParagraphFormatting = resolveEffectiveParagraphFormatting(
583
- { styleId: paragraph.styleId, direct: directParagraphFormatting },
584
- stylesCatalog,
585
- );
586
-
587
- // Task 11: compute cascaded marker run formatting
588
- const markerRunProperties = effectiveNumbering
589
- ? resolveNumberingMarkerRunFormatting(
590
- {
591
- paragraphStyleId: paragraph.styleId,
592
- levelRunProperties: resolvedNumbering?.markerRunProperties,
593
- },
612
+ const directParagraphFormatting = cullBuild
613
+ ? undefined
614
+ : buildDirectParagraphFormattingFromNode(paragraph);
615
+ const resolvedParagraphFormatting = cullBuild
616
+ ? undefined
617
+ : resolveEffectiveParagraphFormatting(
618
+ { styleId: paragraph.styleId, direct: directParagraphFormatting },
594
619
  stylesCatalog,
595
- )
596
- : undefined;
620
+ );
621
+
622
+ // Task 11: compute cascaded marker run formatting (expensive).
623
+ const markerRunProperties =
624
+ !cullBuild && effectiveNumbering
625
+ ? resolveNumberingMarkerRunFormatting(
626
+ {
627
+ paragraphStyleId: paragraph.styleId,
628
+ levelRunProperties: resolvedNumbering?.markerRunProperties,
629
+ },
630
+ stylesCatalog,
631
+ )
632
+ : undefined;
597
633
 
598
634
  const accumulator: ParagraphAccumulator = {
599
635
  blockId: `paragraph-${paragraphIndex}`,
@@ -650,6 +686,8 @@ function createParagraphBlock(
650
686
  document,
651
687
  cursor,
652
688
  promoteSecondaryStoryTextBoxes,
689
+ undefined,
690
+ cullBuild,
653
691
  );
654
692
  cursor = result.nextCursor;
655
693
  lockedFragmentIds.push(...result.lockedFragmentIds);
@@ -812,22 +850,39 @@ function appendInlineSegments(
812
850
  start: number,
813
851
  promoteSecondaryStoryTextBoxes: boolean,
814
852
  hyperlinkHref?: string,
853
+ cullBuild: boolean = false,
815
854
  ): { nextCursor: number; lockedFragmentIds: string[] } {
816
855
  switch (node.type) {
817
856
  case "text": {
818
857
  const cloned = node.marks ? cloneMarks(node.marks) : { marks: [] as SurfaceTextMark[] };
819
- const directRunFormatting = buildDirectRunFormattingFromMarks(
820
- cloned.marks.length > 0 ? cloned.marks : undefined,
821
- cloned.markAttrs,
822
- );
823
- const resolvedRunFormatting = resolveEffectiveRunFormatting(
824
- {
825
- paragraphStyleId: paragraph.styleId,
826
- characterStyleId: undefined,
827
- direct: directRunFormatting,
828
- },
829
- document.styles,
830
- );
858
+ // L7 Phase 2.9 — skip the styles-catalog run-cascade walk when
859
+ // the block will be culled. `resolveEffectiveRunFormatting`
860
+ // dominates per-text-segment cost on style-heavy docs; the
861
+ // placeholder path does not read `resolvedRunFormatting`.
862
+ const directRunFormatting = cullBuild
863
+ ? undefined
864
+ : buildDirectRunFormattingFromMarks(
865
+ cloned.marks.length > 0 ? cloned.marks : undefined,
866
+ cloned.markAttrs,
867
+ );
868
+ // V7 — runs inside <w:hyperlink> go through the hyperlink color
869
+ // cascade (auto-applies the Hyperlink character style + resolves
870
+ // theme hlink slot with Word-default fallback). Non-hyperlink
871
+ // runs take the unchanged cascade path.
872
+ const runResolveInput = {
873
+ paragraphStyleId: paragraph.styleId,
874
+ characterStyleId: undefined,
875
+ direct: directRunFormatting,
876
+ };
877
+ const resolvedRunFormatting = cullBuild
878
+ ? {}
879
+ : hyperlinkHref
880
+ ? resolveHyperlinkRunFormatting(
881
+ runResolveInput,
882
+ document.styles,
883
+ document.subParts?.resolvedTheme,
884
+ )
885
+ : resolveEffectiveRunFormatting(runResolveInput, document.styles);
831
886
  paragraph.segments.push({
832
887
  segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
833
888
  kind: "text",
@@ -869,6 +924,7 @@ function appendInlineSegments(
869
924
  cursor,
870
925
  promoteSecondaryStoryTextBoxes,
871
926
  node.href,
927
+ cullBuild,
872
928
  );
873
929
  cursor = result.nextCursor;
874
930
  }
@@ -1003,6 +1059,8 @@ function appendInlineSegments(
1003
1059
  document,
1004
1060
  cursor,
1005
1061
  promoteSecondaryStoryTextBoxes,
1062
+ undefined,
1063
+ cullBuild,
1006
1064
  );
1007
1065
  cursor = result.nextCursor;
1008
1066
  lockedIds.push(...result.lockedFragmentIds);
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Resolve a (`<w:color>`-shaped) theme-color reference to a concrete hex
3
+ * string, applying the `w:themeTint` / `w:themeShade` HSL-luminance
4
+ * modulation per ECMA-376 §17.18.85 / §17.18.83.
5
+ *
6
+ * Usage:
7
+ * - The IO layer (`parse-run-formatting.ts`) captures
8
+ * `colorThemeSlot` / `colorThemeTint` / `colorThemeShade` on
9
+ * `CanonicalRunFormatting` as raw strings (preserved for
10
+ * byte-stable round-trip).
11
+ * - The render/cascade layer calls `resolveThemeColorHex(rPr, theme)`
12
+ * to collapse the reference to the actual hex colour the browser
13
+ * should paint. The original fields stay intact so export still
14
+ * round-trips the theme reference (not the computed hex).
15
+ *
16
+ * Lane 3 L2.c. Mirrors LibreOffice's `Color::ApplyTintOrShade` at
17
+ * `vendor/libreoffice/include/tools/color.hxx:200-240` (shape only; no
18
+ * code copied — our implementation is pure TypeScript against the
19
+ * ECMA-376 formulae).
20
+ *
21
+ * **Scope boundary:** this resolver handles the standard tint/shade
22
+ * pair. Theme-color-mapping (clrSchemeMapping — how "accent1" maps to a
23
+ * different `<w:clrScheme>` slot in older Word versions) is NOT applied
24
+ * here — `resolveThemeColor` from `src/io/ooxml/parse-theme.ts` is the
25
+ * authoritative slot→hex lookup and future work will layer scheme
26
+ * mapping into it.
27
+ */
28
+
29
+ import type { CanonicalRunFormatting, ResolvedTheme } from "../model/canonical-document.ts";
30
+ import { resolveThemeColor } from "../io/ooxml/parse-theme.ts";
31
+
32
+ /**
33
+ * Collapse `<w:color>`-style theme-slot + tint + shade references into a
34
+ * single resolved hex colour. Returns:
35
+ * - the raw `colorHex` as-is when it's a direct hex (the theme fields
36
+ * are ignored because direct colour wins per ECMA-376 cascade).
37
+ * - `"auto"` when the source declared `w:color w:val="auto"` (sentinel
38
+ * kept verbatim per our long-standing A.9 round-trip rule).
39
+ * - the theme slot's hex — tint/shade applied — when the source
40
+ * declared `w:themeColor="accent1"` etc. and the theme is available.
41
+ * - `undefined` otherwise (no colour declared, or theme slot absent).
42
+ */
43
+ export function resolveThemeColorHex(
44
+ rPr: Pick<
45
+ CanonicalRunFormatting,
46
+ "colorHex" | "colorThemeSlot" | "colorThemeTint" | "colorThemeShade"
47
+ >,
48
+ theme: ResolvedTheme | undefined,
49
+ ): string | undefined {
50
+ if (rPr.colorHex === "auto") return "auto";
51
+ if (rPr.colorHex) return rPr.colorHex;
52
+ if (!rPr.colorThemeSlot) return undefined;
53
+
54
+ const baseHex = resolveThemeColor(theme, rPr.colorThemeSlot);
55
+ if (!baseHex) return undefined;
56
+
57
+ return applyThemeTintShade(baseHex, rPr.colorThemeTint, rPr.colorThemeShade);
58
+ }
59
+
60
+ /**
61
+ * Apply `w:themeTint` (shift toward white) and/or `w:themeShade` (shift
62
+ * toward black) to a base hex colour. Pure function.
63
+ *
64
+ * Tint/shade are each a 0x00–0xFF hex byte representing a fraction of
65
+ * 255 (ECMA-376 §17.18.85 / §17.18.83). Only one may be present per
66
+ * `<w:color>`; when both are omitted, the base hex is returned.
67
+ *
68
+ * Formulae (per Microsoft's Word/OOXML guidance mirrored in LibreOffice):
69
+ * - tint byte T → frac = T / 255; newL = frac * L + (1 - frac) * 1.0
70
+ * (at T=0 the colour becomes pure white; at T=255 it stays unchanged)
71
+ * - shade byte S → frac = S / 255; newL = frac * L
72
+ * (at S=0 the colour becomes pure black; at S=255 it stays unchanged)
73
+ *
74
+ * These are LUMINANCE modulations in the HSL colour space (hue + saturation
75
+ * preserved). The conversion hex → HSL → hex is the standard 0–1 / 0–360
76
+ * form.
77
+ */
78
+ export function applyThemeTintShade(
79
+ baseHex: string,
80
+ tint: string | undefined,
81
+ shade: string | undefined,
82
+ ): string {
83
+ const tintByte = parseHexByte(tint);
84
+ const shadeByte = parseHexByte(shade);
85
+ if (tintByte === undefined && shadeByte === undefined) {
86
+ return baseHex;
87
+ }
88
+
89
+ const rgb = parseHexColor(baseHex);
90
+ if (!rgb) return baseHex;
91
+
92
+ const hsl = rgbToHsl(rgb);
93
+ let newL = hsl.l;
94
+ if (tintByte !== undefined) {
95
+ const frac = tintByte / 255;
96
+ newL = frac * hsl.l + (1 - frac);
97
+ } else if (shadeByte !== undefined) {
98
+ const frac = shadeByte / 255;
99
+ newL = frac * hsl.l;
100
+ }
101
+ newL = Math.max(0, Math.min(1, newL));
102
+
103
+ const out = hslToRgb({ h: hsl.h, s: hsl.s, l: newL });
104
+ return formatHexColor(out);
105
+ }
106
+
107
+ function parseHexByte(value: string | undefined): number | undefined {
108
+ if (!value) return undefined;
109
+ const n = Number.parseInt(value, 16);
110
+ if (!Number.isFinite(n)) return undefined;
111
+ return Math.max(0, Math.min(255, n));
112
+ }
113
+
114
+ function parseHexColor(hex: string): { r: number; g: number; b: number } | undefined {
115
+ const normalized = hex.replace(/^#/u, "").trim();
116
+ if (normalized.length !== 6) return undefined;
117
+ const n = Number.parseInt(normalized, 16);
118
+ if (!Number.isFinite(n)) return undefined;
119
+ return {
120
+ r: (n >> 16) & 0xff,
121
+ g: (n >> 8) & 0xff,
122
+ b: n & 0xff,
123
+ };
124
+ }
125
+
126
+ function formatHexColor(rgb: { r: number; g: number; b: number }): string {
127
+ const to2 = (n: number): string => Math.round(n).toString(16).padStart(2, "0").toUpperCase();
128
+ return `${to2(rgb.r)}${to2(rgb.g)}${to2(rgb.b)}`;
129
+ }
130
+
131
+ function rgbToHsl(rgb: { r: number; g: number; b: number }): {
132
+ h: number;
133
+ s: number;
134
+ l: number;
135
+ } {
136
+ const r = rgb.r / 255;
137
+ const g = rgb.g / 255;
138
+ const b = rgb.b / 255;
139
+ const max = Math.max(r, g, b);
140
+ const min = Math.min(r, g, b);
141
+ const l = (max + min) / 2;
142
+ let h = 0;
143
+ let s = 0;
144
+ if (max !== min) {
145
+ const d = max - min;
146
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
147
+ switch (max) {
148
+ case r:
149
+ h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
150
+ break;
151
+ case g:
152
+ h = ((b - r) / d + 2) * 60;
153
+ break;
154
+ default:
155
+ h = ((r - g) / d + 4) * 60;
156
+ }
157
+ }
158
+ return { h, s, l };
159
+ }
160
+
161
+ function hslToRgb(hsl: { h: number; s: number; l: number }): {
162
+ r: number;
163
+ g: number;
164
+ b: number;
165
+ } {
166
+ const { h, s, l } = hsl;
167
+ if (s === 0) {
168
+ const gray = l * 255;
169
+ return { r: gray, g: gray, b: gray };
170
+ }
171
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
172
+ const p = 2 * l - q;
173
+ const hueToRgb = (t: number): number => {
174
+ let tt = t;
175
+ if (tt < 0) tt += 1;
176
+ if (tt > 1) tt -= 1;
177
+ if (tt < 1 / 6) return p + (q - p) * 6 * tt;
178
+ if (tt < 1 / 2) return q;
179
+ if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
180
+ return p;
181
+ };
182
+ const hFrac = h / 360;
183
+ return {
184
+ r: hueToRgb(hFrac + 1 / 3) * 255,
185
+ g: hueToRgb(hFrac) * 255,
186
+ b: hueToRgb(hFrac - 1 / 3) * 255,
187
+ };
188
+ }
@@ -22,6 +22,7 @@ import type {
22
22
  WorkflowCommentMarkup,
23
23
  } from "../api/public-types";
24
24
  import { MAIN_STORY_TARGET } from "../core/selection/mapping.ts";
25
+ import { createPublicRangeAnchor } from "../core/selection/anchor-conversion.ts";
25
26
  import {
26
27
  projectSurfaceText,
27
28
  searchProjectedSurfaceText,
@@ -145,7 +146,7 @@ export function collectWorkflowMarkupSnapshot(input: {
145
146
  markupId: `protected-range:${range.rangeId}`,
146
147
  kind: "protected_range",
147
148
  rangeId: range.rangeId,
148
- anchor: createRangeAnchor(range.start, range.end),
149
+ anchor: createPublicRangeAnchor(range.start, range.end),
149
150
  storyTarget: MAIN_STORY_TARGET,
150
151
  label: `Protected range ${range.rangeId}`,
151
152
  excerpt: range.enforcementReason,
@@ -283,7 +284,7 @@ function collectSurfaceMarkup(
283
284
  kind: "opaque_fragment",
284
285
  fragmentId: block.fragmentId,
285
286
  warningId: block.warningId,
286
- anchor: createRangeAnchor(block.from, block.to),
287
+ anchor: createPublicRangeAnchor(block.from, block.to),
287
288
  storyTarget,
288
289
  label: block.label,
289
290
  excerpt: block.detail,
@@ -308,7 +309,7 @@ function collectSegmentMarkup(
308
309
  highlights.push({
309
310
  markupId: `highlight:${storyTargetKey(storyTarget)}:${segment.from}:${segment.to}:${segment.markAttrs.backgroundColor}`,
310
311
  kind: "highlight",
311
- anchor: createRangeAnchor(segment.from, segment.to),
312
+ anchor: createPublicRangeAnchor(segment.from, segment.to),
312
313
  storyTarget,
313
314
  label: `Highlight ${segment.markAttrs.backgroundColor}`,
314
315
  excerpt: segment.text,
@@ -326,7 +327,7 @@ function collectSegmentMarkup(
326
327
  kind: "opaque_fragment",
327
328
  fragmentId: segment.fragmentId,
328
329
  warningId: segment.warningId,
329
- anchor: createRangeAnchor(segment.from, segment.to),
330
+ anchor: createPublicRangeAnchor(segment.from, segment.to),
330
331
  storyTarget,
331
332
  label: segment.label,
332
333
  excerpt: segment.detail,
@@ -380,7 +381,7 @@ function collectFieldMarkup(
380
381
  {
381
382
  markupId: `field:${field.index}`,
382
383
  kind: "field",
383
- anchor: createRangeAnchor(match.from, match.to),
384
+ anchor: createPublicRangeAnchor(match.from, match.to),
384
385
  storyTarget: story.storyTarget,
385
386
  label: displayText,
386
387
  excerpt: field.instruction,
@@ -417,7 +418,7 @@ function collectOpaqueFragmentMarkup(
417
418
  kind: "opaque_fragment",
418
419
  fragmentId: fragment.fragmentId,
419
420
  warningId: fragment.warningId,
420
- anchor: createRangeAnchor(fragment.lastKnownRange.from, fragment.lastKnownRange.to),
421
+ anchor: createPublicRangeAnchor(fragment.lastKnownRange.from, fragment.lastKnownRange.to),
421
422
  storyTarget: MAIN_STORY_TARGET,
422
423
  label: descriptor.label,
423
424
  excerpt: descriptor.detail,
@@ -427,18 +428,6 @@ function collectOpaqueFragmentMarkup(
427
428
  });
428
429
  }
429
430
 
430
- function createRangeAnchor(from: number, to: number): EditorAnchorProjection {
431
- return {
432
- kind: "range",
433
- from,
434
- to,
435
- assoc: {
436
- start: -1,
437
- end: 1,
438
- },
439
- };
440
- }
441
-
442
431
  function storyTargetKey(storyTarget: EditorStoryTarget): string {
443
432
  switch (storyTarget.kind) {
444
433
  case "main":
@@ -75,6 +75,7 @@ import type {
75
75
  ZoomLevel,
76
76
  } from "../api/public-types";
77
77
  import { MetadataResolverMissingError } from "../api/public-types";
78
+ import { readHarnessDebugPortsFlag } from "../internal/harness-debug-ports.ts";
78
79
  import type { ScopeMetadataResolver } from "../api/scope-metadata-resolver-types.ts";
79
80
  import {
80
81
  editorSessionStateFromPersistedSnapshot,
@@ -178,6 +179,7 @@ import {
178
179
  useRuntimeSnapshotSlice,
179
180
  useRuntimeValue,
180
181
  } from "./runtime-snapshot-selectors.ts";
182
+ import { computeEffectiveShowUnsupportedPreviews } from "./unsupported-previews-policy.ts";
181
183
  import type { MarkupDisplay } from "./headless/comment-decoration-model";
182
184
  import { resolveScopedChromePolicy } from "./headless/scoped-chrome-policy";
183
185
  import type {
@@ -779,6 +781,9 @@ export function __createWordReviewEditorRefBridge(
779
781
  clearWorkflowOverlay: () => {
780
782
  runtime.clearWorkflowOverlay();
781
783
  },
784
+ setSharedWorkflowState: (state) => {
785
+ runtime.setSharedWorkflowState(state === null ? null : clonePublicValue(state));
786
+ },
782
787
  getWorkflowScopeSnapshot: () => {
783
788
  return clonePublicValue(runtime.getWorkflowScopeSnapshot());
784
789
  },
@@ -1036,7 +1041,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1036
1041
  chromePreset,
1037
1042
  chromeOptions,
1038
1043
  markupDisplay,
1039
- showUnsupportedObjectPreviews = false,
1044
+ __harnessDebugPorts,
1045
+ unsupportedPreviewsPolicy = "never",
1040
1046
  onError,
1041
1047
  onEvent,
1042
1048
  onWarning,
@@ -1825,6 +1831,9 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1825
1831
  clearWorkflowOverlay: () => {
1826
1832
  activeRuntime.clearWorkflowOverlay();
1827
1833
  },
1834
+ setSharedWorkflowState: (state) => {
1835
+ activeRuntime.setSharedWorkflowState(state === null ? null : clonePublicValue(state));
1836
+ },
1828
1837
  getWorkflowScopeSnapshot: () => {
1829
1838
  return clonePublicValue(activeRuntime.getWorkflowScopeSnapshot());
1830
1839
  },
@@ -2938,6 +2947,13 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2938
2947
  },
2939
2948
  });
2940
2949
 
2950
+ const harnessShowUnsupportedPreviews = readHarnessDebugPortsFlag(__harnessDebugPorts, "unsupportedObjectPreviews");
2951
+ const effectiveShowUnsupportedPreviews = computeEffectiveShowUnsupportedPreviews({
2952
+ harnessShowUnsupportedPreviews,
2953
+ unsupportedPreviewsPolicy,
2954
+ reviewMode,
2955
+ });
2956
+
2941
2957
  const documentElement = (
2942
2958
  <EditorSurfaceController
2943
2959
  ref={surfaceRef}
@@ -2948,7 +2964,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2948
2964
  documentNavigation={documentNavigation}
2949
2965
  reviewMode={reviewMode}
2950
2966
  markupDisplay={liveMarkupDisplay}
2951
- showUnsupportedObjectPreviews={showUnsupportedObjectPreviews}
2967
+ showUnsupportedObjectPreviews={effectiveShowUnsupportedPreviews}
2952
2968
  activeRevisionId={activeRevisionId}
2953
2969
  activeSelectionToolKind={activeSelectionTool?.kind ?? null}
2954
2970
  showTrackedChanges={showTrackedChanges}
@@ -44,6 +44,7 @@ import {
44
44
  loadDocxEditorSession,
45
45
  loadDocxEditorSessionAsync,
46
46
  } from "../io/docx-session.ts";
47
+ import { tryReadLaycacheEnvelope } from "../runtime/prerender/customxml-probe.ts";
47
48
  import {
48
49
  createLoadScheduler,
49
50
  type LoadScheduler,
@@ -79,6 +80,14 @@ export interface ResolvedSource {
79
80
  * does the classic synchronous load.
80
81
  */
81
82
  preloadedDocxSession?: ReturnType<typeof loadDocxEditorSession>;
83
+ /**
84
+ * L7 Phase 2.7 — when the editor-runtime-boundary probe finds a
85
+ * laycache envelope in `/customXml/item1.xml`, its `graph` is stashed
86
+ * here and forwarded to `createDocumentRuntime` as `seedLayoutCache`.
87
+ * This is the Plan A (graph-seed) win layered on top of the Plan B
88
+ * (loader short-circuit) win. Undefined when no envelope is available.
89
+ */
90
+ preloadedLaycacheGraph?: import("../runtime/layout/page-graph.ts").RuntimePageGraph;
82
91
  }
83
92
 
84
93
  export interface CreateRuntimeArgs {
@@ -404,6 +413,18 @@ export function useEditorRuntimeBoundary(
404
413
  // can paint the skeleton while the rest of the parse finishes.
405
414
  // SSR / Node tests fall through to the synchronous load inside
406
415
  // `createRuntime`.
416
+ //
417
+ // L7 Phase 2.7 — Plan B read-path wiring. Before invoking the
418
+ // async loader, probe the docx for a laycache envelope stashed
419
+ // in `/customXml/item1.xml` (shipped by Plan B Slice 2). When the
420
+ // probe returns a validated envelope, pass it to the loader so
421
+ // the five expensive parse stages short-circuit against the
422
+ // cached `canonicalDocument`. Measured win on 138-pp extra-large
423
+ // CCEP: ~947 ms cold → ~590 ms warm (Δ 376 ms), same delta the
424
+ // Node-side `prerender-cache.bench.ts` records. Probe is ~20–50 ms;
425
+ // `tryReadLaycacheEnvelope` returns null on any rejection path
426
+ // (missing part, stale structural hash, schema mismatch) and the
427
+ // loader falls through to the full parse.
407
428
  if (
408
429
  source.initialDocx !== undefined &&
409
430
  source.preloadedDocxSession === undefined &&
@@ -411,12 +432,20 @@ export function useEditorRuntimeBoundary(
411
432
  ) {
412
433
  const scheduler = createLoadScheduler();
413
434
  loadSchedulerRef.current = scheduler;
435
+ const probeResult = await tryReadLaycacheEnvelope(source.initialDocx);
436
+ if (cancelled) {
437
+ scheduler.dispose();
438
+ loadSchedulerRef.current = null;
439
+ return;
440
+ }
441
+ recordPerfSample("loadSession.laycacheProbe");
414
442
  const preloaded = await loadDocxEditorSessionAsync({
415
443
  documentId,
416
444
  sourceLabel: source.sourceLabel,
417
445
  bytes: source.initialDocx,
418
446
  editorBuild: "dev",
419
447
  scheduler,
448
+ ...(probeResult ? { laycacheEnvelope: probeResult.envelope } : {}),
420
449
  });
421
450
  if (cancelled) {
422
451
  scheduler.dispose();
@@ -424,6 +453,9 @@ export function useEditorRuntimeBoundary(
424
453
  return;
425
454
  }
426
455
  source.preloadedDocxSession = preloaded;
456
+ if (probeResult) {
457
+ source.preloadedLaycacheGraph = probeResult.envelope.graph;
458
+ }
427
459
  }
428
460
 
429
461
  const nextRuntime = createRuntime(
@@ -662,6 +694,9 @@ function createRuntime(
662
694
  editorBuild: runtimeSessionState.editorBuild,
663
695
  fatalError: docxSession?.fatalError,
664
696
  protectionSnapshot: docxSession?.protectionSnapshot,
697
+ ...(args.source.preloadedLaycacheGraph
698
+ ? { seedLayoutCache: args.source.preloadedLaycacheGraph }
699
+ : {}),
665
700
  exportDocx: async (sessionState, options) => {
666
701
  if (docxSession) {
667
702
  return docxSession.exportDocx(sessionState, options);
@@ -965,6 +1000,7 @@ function createLoadingRuntimeBridge(input: {
965
1000
  Promise.reject(createLoadingBoundaryError(input.snapshot.documentId, "export")),
966
1001
  setWorkflowOverlay: () => undefined,
967
1002
  clearWorkflowOverlay: () => undefined,
1003
+ setSharedWorkflowState: () => undefined,
968
1004
  getWorkflowOverlay: () => null,
969
1005
  getWorkflowScopeSnapshot: () => null,
970
1006
  getInteractionGuardSnapshot: () => ({ effectiveMode: "edit", blockedReasons: [] }),