@beyondwork/docx-react-component 1.0.47 → 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 (80) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +199 -13
  4. package/src/compare/diff-engine.ts +4 -0
  5. package/src/core/commands/add-scope.ts +257 -0
  6. package/src/core/commands/formatting-commands.ts +2 -0
  7. package/src/core/commands/index.ts +9 -1
  8. package/src/core/commands/text-commands.ts +3 -1
  9. package/src/core/schema/text-schema.ts +95 -1
  10. package/src/core/selection/anchor-conversion.ts +112 -0
  11. package/src/core/selection/review-anchors.ts +108 -3
  12. package/src/core/state/text-transaction.ts +103 -7
  13. package/src/internal/harness-debug-ports.ts +168 -0
  14. package/src/io/chart-preview-resolver.ts +59 -1
  15. package/src/io/docx-session.ts +226 -38
  16. package/src/io/export/serialize-main-document.ts +46 -0
  17. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  18. package/src/io/export/serialize-run-formatting.ts +10 -1
  19. package/src/io/export/serialize-settings.ts +421 -0
  20. package/src/io/export/serialize-styles.ts +10 -0
  21. package/src/io/normalize/normalize-text.ts +1 -0
  22. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  23. package/src/io/ooxml/chart/color-palette.ts +101 -0
  24. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  25. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  26. package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
  27. package/src/io/ooxml/chart/parse-series.ts +635 -0
  28. package/src/io/ooxml/chart/resolve-color.ts +261 -0
  29. package/src/io/ooxml/chart/types.ts +439 -0
  30. package/src/io/ooxml/parse-block-structure.ts +99 -0
  31. package/src/io/ooxml/parse-complex-content.ts +90 -2
  32. package/src/io/ooxml/parse-main-document.ts +156 -1
  33. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  34. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  35. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  36. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  37. package/src/io/ooxml/parse-settings.ts +97 -1
  38. package/src/io/ooxml/parse-styles.ts +65 -0
  39. package/src/io/ooxml/parse-theme.ts +2 -127
  40. package/src/io/ooxml/property-grab-bag.ts +211 -0
  41. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  42. package/src/io/ooxml/xml-parser.ts +142 -0
  43. package/src/model/canonical-document.ts +160 -0
  44. package/src/model/scope-markers.ts +144 -0
  45. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  46. package/src/runtime/collab/checkpoint-election.ts +75 -0
  47. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  48. package/src/runtime/collab/checkpoint-store.ts +115 -0
  49. package/src/runtime/collab/event-types.ts +27 -0
  50. package/src/runtime/collab/index.ts +29 -0
  51. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  52. package/src/runtime/collab/runtime-collab-sync.ts +330 -0
  53. package/src/runtime/collab/workflow-shared.ts +247 -0
  54. package/src/runtime/document-locations.ts +1 -9
  55. package/src/runtime/document-outline.ts +1 -9
  56. package/src/runtime/document-runtime.ts +288 -65
  57. package/src/runtime/editor-surface/capabilities.ts +63 -50
  58. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  59. package/src/runtime/layout/layout-engine-version.ts +8 -1
  60. package/src/runtime/prerender/cache-envelope.ts +19 -7
  61. package/src/runtime/prerender/cache-key.ts +25 -14
  62. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  63. package/src/runtime/prerender/customxml-cache.ts +211 -0
  64. package/src/runtime/prerender/customxml-probe.ts +78 -0
  65. package/src/runtime/prerender/prerender-document.ts +74 -7
  66. package/src/runtime/scope-resolver.ts +148 -0
  67. package/src/runtime/scope-tag-registry.ts +10 -0
  68. package/src/runtime/surface-projection.ts +102 -37
  69. package/src/runtime/theme-color-resolver.ts +188 -0
  70. package/src/runtime/workflow-markup.ts +7 -18
  71. package/src/ui/WordReviewEditor.tsx +48 -2
  72. package/src/ui/editor-runtime-boundary.ts +42 -1
  73. package/src/ui/headless/selection-helpers.ts +10 -23
  74. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
  75. package/src/ui/unsupported-previews-policy.ts +23 -0
  76. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  77. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  78. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  79. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  80. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
@@ -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 {
@@ -494,6 +496,9 @@ export function __createWordReviewEditorRefBridge(
494
496
  deleteComment: (commentId) => {
495
497
  applyRuntimeDeleteComment(runtime, commentId);
496
498
  },
499
+ addScope: (params) => runtime.addScope(params),
500
+ getScope: (scopeId) => runtime.getScope(scopeId),
501
+ removeScope: (scopeId) => runtime.removeScope(scopeId),
497
502
  acceptChange: (changeId) => runtime.acceptChange(changeId),
498
503
  rejectChange: (changeId) => runtime.rejectChange(changeId),
499
504
  acceptAllChanges: () => runtime.acceptAllChanges(),
@@ -776,6 +781,9 @@ export function __createWordReviewEditorRefBridge(
776
781
  clearWorkflowOverlay: () => {
777
782
  runtime.clearWorkflowOverlay();
778
783
  },
784
+ setSharedWorkflowState: (state) => {
785
+ runtime.setSharedWorkflowState(state === null ? null : clonePublicValue(state));
786
+ },
779
787
  getWorkflowScopeSnapshot: () => {
780
788
  return clonePublicValue(runtime.getWorkflowScopeSnapshot());
781
789
  },
@@ -1033,7 +1041,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1033
1041
  chromePreset,
1034
1042
  chromeOptions,
1035
1043
  markupDisplay,
1036
- showUnsupportedObjectPreviews = false,
1044
+ __harnessDebugPorts,
1045
+ unsupportedPreviewsPolicy = "never",
1037
1046
  onError,
1038
1047
  onEvent,
1039
1048
  onWarning,
@@ -1042,6 +1051,12 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1042
1051
  onFindRequested,
1043
1052
  onPrintRequested,
1044
1053
  onZoomRequested,
1054
+ onReplaceRequested,
1055
+ onGoToRequested,
1056
+ onSpellRequested,
1057
+ onThesaurusRequested,
1058
+ onExtendSelectionRequested,
1059
+ onLastEditRequested,
1045
1060
  readOnly = false,
1046
1061
  reviewMode = "review",
1047
1062
  suggestionsEnabled = false,
@@ -1477,6 +1492,9 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1477
1492
  deleteComment: (commentId) => {
1478
1493
  applyRuntimeDeleteComment(activeRuntime, commentId);
1479
1494
  },
1495
+ addScope: (params) => activeRuntime.addScope(params),
1496
+ getScope: (scopeId) => activeRuntime.getScope(scopeId),
1497
+ removeScope: (scopeId) => activeRuntime.removeScope(scopeId),
1480
1498
  acceptChange: (changeId) => activeRuntime.acceptChange(changeId),
1481
1499
  rejectChange: (changeId) => activeRuntime.rejectChange(changeId),
1482
1500
  acceptAllChanges: () => activeRuntime.acceptAllChanges(),
@@ -1813,6 +1831,9 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1813
1831
  clearWorkflowOverlay: () => {
1814
1832
  activeRuntime.clearWorkflowOverlay();
1815
1833
  },
1834
+ setSharedWorkflowState: (state) => {
1835
+ activeRuntime.setSharedWorkflowState(state === null ? null : clonePublicValue(state));
1836
+ },
1816
1837
  getWorkflowScopeSnapshot: () => {
1817
1838
  return clonePublicValue(activeRuntime.getWorkflowScopeSnapshot());
1818
1839
  },
@@ -2608,6 +2629,24 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2608
2629
  shortcut.shortcut === "zoom-out" ? "out" : "reset";
2609
2630
  onZoomRequested(direction);
2610
2631
  handled = true;
2632
+ } else if (shortcut.shortcut === "replace" && onReplaceRequested) {
2633
+ onReplaceRequested({ selectionText: "", selectionRange: snapshot.selection });
2634
+ handled = true;
2635
+ } else if (shortcut.shortcut === "go-to" && onGoToRequested) {
2636
+ onGoToRequested({ selectionText: "", selectionRange: snapshot.selection });
2637
+ handled = true;
2638
+ } else if (shortcut.shortcut === "spell" && onSpellRequested) {
2639
+ onSpellRequested({ selectionText: "", selectionRange: snapshot.selection });
2640
+ handled = true;
2641
+ } else if (shortcut.shortcut === "thesaurus" && onThesaurusRequested) {
2642
+ onThesaurusRequested({ selectionText: "", selectionRange: snapshot.selection });
2643
+ handled = true;
2644
+ } else if (shortcut.shortcut === "extend-selection" && onExtendSelectionRequested) {
2645
+ onExtendSelectionRequested({ selectionText: "", selectionRange: snapshot.selection });
2646
+ handled = true;
2647
+ } else if (shortcut.shortcut === "last-edit" && onLastEditRequested) {
2648
+ onLastEditRequested({ selectionText: "", selectionRange: snapshot.selection });
2649
+ handled = true;
2611
2650
  }
2612
2651
  if (handled) {
2613
2652
  event.preventDefault();
@@ -2908,6 +2947,13 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2908
2947
  },
2909
2948
  });
2910
2949
 
2950
+ const harnessShowUnsupportedPreviews = readHarnessDebugPortsFlag(__harnessDebugPorts, "unsupportedObjectPreviews");
2951
+ const effectiveShowUnsupportedPreviews = computeEffectiveShowUnsupportedPreviews({
2952
+ harnessShowUnsupportedPreviews,
2953
+ unsupportedPreviewsPolicy,
2954
+ reviewMode,
2955
+ });
2956
+
2911
2957
  const documentElement = (
2912
2958
  <EditorSurfaceController
2913
2959
  ref={surfaceRef}
@@ -2918,7 +2964,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2918
2964
  documentNavigation={documentNavigation}
2919
2965
  reviewMode={reviewMode}
2920
2966
  markupDisplay={liveMarkupDisplay}
2921
- showUnsupportedObjectPreviews={showUnsupportedObjectPreviews}
2967
+ showUnsupportedObjectPreviews={effectiveShowUnsupportedPreviews}
2922
2968
  activeRevisionId={activeRevisionId}
2923
2969
  activeSelectionToolKind={activeSelectionTool?.kind ?? null}
2924
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);
@@ -900,6 +935,11 @@ function createLoadingRuntimeBridge(input: {
900
935
  throw createLoadingBoundaryError(input.snapshot.documentId, "comment");
901
936
  },
902
937
  editCommentBody: () => undefined,
938
+ addScope: () => {
939
+ throw createLoadingBoundaryError(input.snapshot.documentId, "scope");
940
+ },
941
+ getScope: () => null,
942
+ removeScope: () => undefined,
903
943
  acceptChange: () => undefined,
904
944
  rejectChange: () => undefined,
905
945
  acceptAllChanges: () => undefined,
@@ -960,6 +1000,7 @@ function createLoadingRuntimeBridge(input: {
960
1000
  Promise.reject(createLoadingBoundaryError(input.snapshot.documentId, "export")),
961
1001
  setWorkflowOverlay: () => undefined,
962
1002
  clearWorkflowOverlay: () => undefined,
1003
+ setSharedWorkflowState: () => undefined,
963
1004
  getWorkflowOverlay: () => null,
964
1005
  getWorkflowScopeSnapshot: () => null,
965
1006
  getInteractionGuardSnapshot: () => ({ effectiveMode: "edit", blockedReasons: [] }),
@@ -1016,7 +1057,7 @@ function createLoadingRuntimeBridge(input: {
1016
1057
 
1017
1058
  function createLoadingBoundaryError(
1018
1059
  documentId: string,
1019
- target: "comment" | "session" | "snapshot" | "export",
1060
+ target: "comment" | "session" | "snapshot" | "export" | "scope",
1020
1061
  ): EditorError {
1021
1062
  return {
1022
1063
  errorId: `${documentId}-loading-${target}`,
@@ -1,31 +1,22 @@
1
1
  import type { SelectionSnapshot } from "../../api/public-types";
2
+ import {
3
+ createPublicNodeAnchor,
4
+ createPublicRangeAnchor,
5
+ } from "../../core/selection/anchor-conversion.ts";
2
6
 
3
7
  /**
4
8
  * Headless-UI-side `createSelectionSnapshot` that produces the **public**
5
- * `EditorAnchorProjection` shape (top-level `from`/`to`). The runtime-facing
6
- * twin at `src/core/state/editor-state.ts` produces the internal
7
- * `RangeAnchor` shape (`range: { from, to }`). The two are *not*
8
- * interchangeablethey serve different type contracts. See the
9
- * `EditorAnchorProjection` definitions in `src/api/public-types.ts` vs
10
- * `src/core/selection/mapping.ts`. Do not merge without first unifying
11
- * those two definitions.
9
+ * `EditorAnchorProjection` shape via the canonical
10
+ * `createPublicRangeAnchor` constructor. The runtime-facing twin at
11
+ * `src/core/state/editor-state.ts` produces the internal `RangeAnchor`
12
+ * shape (`range: { from, to }`) the two are not interchangeable.
12
13
  */
13
14
  export function createSelectionSnapshot(anchor: number, head = anchor): SelectionSnapshot {
14
- const from = Math.min(anchor, head);
15
- const to = Math.max(anchor, head);
16
15
  return {
17
16
  anchor,
18
17
  head,
19
18
  isCollapsed: anchor === head,
20
- activeRange: {
21
- kind: "range",
22
- from,
23
- to,
24
- assoc: {
25
- start: -1,
26
- end: 1,
27
- },
28
- },
19
+ activeRange: createPublicRangeAnchor(anchor, head),
29
20
  };
30
21
  }
31
22
 
@@ -34,11 +25,7 @@ export function createNodeSelectionSnapshot(at: number, assoc: -1 | 1 = 1): Sele
34
25
  anchor: at,
35
26
  head: at,
36
27
  isCollapsed: true,
37
- activeRange: {
38
- kind: "node",
39
- at,
40
- assoc,
41
- },
28
+ activeRange: createPublicNodeAnchor(at, assoc),
42
29
  };
43
30
  }
44
31
 
@@ -23,7 +23,12 @@ export interface SurfaceShortcutContext {
23
23
 
24
24
  export type ShellShortcutResolution =
25
25
  | { kind: "none" }
26
- | { kind: "delegate"; shortcut: "find" | "print" | "zoom-in" | "zoom-out" | "zoom-reset" }
26
+ | { kind: "delegate"; shortcut:
27
+ | "find" | "print"
28
+ | "zoom-in" | "zoom-out" | "zoom-reset"
29
+ | "replace" | "go-to"
30
+ | "spell" | "thesaurus"
31
+ | "extend-selection" | "last-edit" }
27
32
  | { kind: "block"; command: string; reason: WorkflowBlockedCommandReason }
28
33
  | { kind: "history"; history: "undo" | "redo" }
29
34
  | { kind: "focus-region"; direction: 1 | -1 }
@@ -115,14 +120,14 @@ export function resolveShellShortcut(
115
120
  }
116
121
 
117
122
  if (isReplaceShortcut(input, key)) {
118
- return resolveBlockedCapability("replaceText");
123
+ return { kind: "delegate", shortcut: "replace" };
119
124
  }
120
125
 
121
126
  if (
122
127
  isGoToShortcut(input, key) ||
123
128
  (key === "f5" && !input.shiftKey)
124
129
  ) {
125
- return resolveBlockedCapability("goTo");
130
+ return { kind: "delegate", shortcut: "go-to" };
126
131
  }
127
132
 
128
133
  if (isModShiftShortcut(input, key, "e")) {
@@ -130,19 +135,19 @@ export function resolveShellShortcut(
130
135
  }
131
136
 
132
137
  if (key === "f7" && !input.shiftKey) {
133
- return resolveBlockedCapability("checkSpelling");
138
+ return { kind: "delegate", shortcut: "spell" };
134
139
  }
135
140
 
136
141
  if (key === "f7" && input.shiftKey) {
137
- return resolveBlockedCapability("openThesaurus");
142
+ return { kind: "delegate", shortcut: "thesaurus" };
138
143
  }
139
144
 
140
145
  if (key === "f8") {
141
- return resolveBlockedCapability("extendSelection");
146
+ return { kind: "delegate", shortcut: "extend-selection" };
142
147
  }
143
148
 
144
149
  if (key === "f5" && input.shiftKey) {
145
- return resolveBlockedCapability("lastEdit");
150
+ return { kind: "delegate", shortcut: "last-edit" };
146
151
  }
147
152
 
148
153
  return { kind: "none" };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * I4 — Effective-visibility rule for preserve-only previews.
3
+ *
4
+ * Combines the harness-only opaque-token flag (from
5
+ * `__harnessDebugPorts`, read via `readHarnessDebugPortsFlag(..., "unsupportedObjectPreviews")`)
6
+ * with the public sibling `unsupportedPreviewsPolicy` prop (default
7
+ * `"never"`). The harness token remains unreachable from consumer
8
+ * apps (see `test/ui/unsupported-previews-invariant.test.ts`).
9
+ */
10
+ export interface EffectiveShowUnsupportedPreviewsInput {
11
+ harnessShowUnsupportedPreviews: boolean;
12
+ unsupportedPreviewsPolicy: "never" | "review-only" | "always";
13
+ reviewMode: "editing" | "review";
14
+ }
15
+
16
+ export function computeEffectiveShowUnsupportedPreviews(
17
+ input: EffectiveShowUnsupportedPreviewsInput,
18
+ ): boolean {
19
+ if (input.harnessShowUnsupportedPreviews === true) return true;
20
+ if (input.unsupportedPreviewsPolicy === "always") return true;
21
+ if (input.unsupportedPreviewsPolicy === "review-only" && input.reviewMode === "review") return true;
22
+ return false;
23
+ }
@@ -152,6 +152,14 @@ export interface TwChromeOverlayProps {
152
152
  * handle leaves focus-restore as a no-op — DOM reparent still runs.
153
153
  */
154
154
  pmView?: PmPortalView | null;
155
+ /**
156
+ * L7 Phase 2.8 — viewport cull for `TwPageStackChromeLayer`. Sequential
157
+ * page-index range (plus overscan) that should render full chrome
158
+ * bands; pages outside the range render empty frame wrappers only.
159
+ * When omitted, every page's chrome mounts (pre-Phase-2.8 behavior).
160
+ * See `useVisiblePageIndexRange` in `src/ui-tailwind/page-stack/use-visible-block-range.ts`.
161
+ */
162
+ visiblePageIndexRange?: { start: number; end: number } | null;
155
163
  }
156
164
 
157
165
  /**
@@ -188,6 +196,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
188
196
  onOpenStory,
189
197
  pmSurfaceElement,
190
198
  pmView,
199
+ visiblePageIndexRange,
191
200
  }) => {
192
201
  return (
193
202
  <div
@@ -211,6 +220,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
211
220
  onOpenStory={onOpenStory}
212
221
  pmSurfaceElement={pmSurfaceElement}
213
222
  pmView={pmView}
223
+ visiblePageIndexRange={visiblePageIndexRange ?? null}
214
224
  />
215
225
  ) : null}
216
226
  <TwScopeRailLayer
@@ -5,6 +5,7 @@ export type PerfProbeKind =
5
5
  | "typing.divergence"
6
6
  | "selection"
7
7
  | "runtime.create"
8
+ | "loadSession.laycacheProbe"
8
9
  | "snapshot.surface"
9
10
  | "snapshot.compatibility"
10
11
  | "snapshot.navigation"