@beyondwork/docx-react-component 1.0.48 → 1.0.50

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 (53) hide show
  1. package/README.md +19 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +103 -12
  4. package/src/core/commands/index.ts +30 -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-comments.ts +50 -5
  12. package/src/io/export/serialize-main-document.ts +9 -0
  13. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  14. package/src/io/export/serialize-run-formatting.ts +10 -1
  15. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  16. package/src/io/ooxml/chart/color-palette.ts +101 -0
  17. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  18. package/src/io/ooxml/chart/parse-chart-space.ts +118 -46
  19. package/src/io/ooxml/chart/parse-series.ts +76 -11
  20. package/src/io/ooxml/chart/resolve-color.ts +16 -6
  21. package/src/io/ooxml/chart/types.ts +30 -11
  22. package/src/io/ooxml/parse-complex-content.ts +6 -3
  23. package/src/io/ooxml/parse-main-document.ts +41 -0
  24. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  25. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  26. package/src/io/ooxml/property-grab-bag.ts +211 -0
  27. package/src/io/paste/word-clipboard.ts +114 -0
  28. package/src/model/canonical-document.ts +69 -3
  29. package/src/runtime/collab/index.ts +7 -0
  30. package/src/runtime/collab/runtime-collab-sync.ts +51 -0
  31. package/src/runtime/collab/workflow-shared.ts +247 -0
  32. package/src/runtime/document-locations.ts +1 -9
  33. package/src/runtime/document-outline.ts +1 -9
  34. package/src/runtime/document-runtime.ts +98 -50
  35. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  36. package/src/runtime/layout/layout-engine-version.ts +11 -1
  37. package/src/runtime/layout/public-facet.ts +5 -12
  38. package/src/runtime/render/render-frame-types.ts +14 -0
  39. package/src/runtime/render/render-kernel.ts +40 -2
  40. package/src/runtime/structure-ops/fragment-insert.ts +134 -0
  41. package/src/runtime/surface-projection.ts +94 -36
  42. package/src/runtime/theme-color-resolver.ts +188 -0
  43. package/src/runtime/workflow-markup.ts +7 -18
  44. package/src/ui/WordReviewEditor.tsx +22 -4
  45. package/src/ui/editor-runtime-boundary.ts +37 -0
  46. package/src/ui/headless/selection-helpers.ts +10 -23
  47. package/src/ui/unsupported-previews-policy.ts +23 -0
  48. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  49. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  50. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +60 -5
  51. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  52. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  53. 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 {
@@ -484,7 +486,8 @@ export function __createWordReviewEditorRefBridge(
484
486
  blur: () => runtime.blur(),
485
487
  undo: () => runtime.undo(),
486
488
  redo: () => runtime.redo(),
487
- replaceText: (text, target) => runtime.replaceText(text, target),
489
+ replaceText: (text, target, formatting) => runtime.replaceText(text, target, formatting),
490
+ insertFragment: (fragment, target) => runtime.insertFragment(fragment, target),
488
491
  addComment: (params) => runtime.addComment(params),
489
492
  openComment: (commentId) => runtime.openComment(commentId),
490
493
  resolveComment: (commentId) => runtime.resolveComment(commentId),
@@ -779,6 +782,9 @@ export function __createWordReviewEditorRefBridge(
779
782
  clearWorkflowOverlay: () => {
780
783
  runtime.clearWorkflowOverlay();
781
784
  },
785
+ setSharedWorkflowState: (state) => {
786
+ runtime.setSharedWorkflowState(state === null ? null : clonePublicValue(state));
787
+ },
782
788
  getWorkflowScopeSnapshot: () => {
783
789
  return clonePublicValue(runtime.getWorkflowScopeSnapshot());
784
790
  },
@@ -1036,7 +1042,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1036
1042
  chromePreset,
1037
1043
  chromeOptions,
1038
1044
  markupDisplay,
1039
- showUnsupportedObjectPreviews = false,
1045
+ __harnessDebugPorts,
1046
+ unsupportedPreviewsPolicy = "never",
1040
1047
  onError,
1041
1048
  onEvent,
1042
1049
  onWarning,
@@ -1470,7 +1477,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1470
1477
  blur: () => activeRuntime.blur(),
1471
1478
  undo: () => activeRuntime.undo(),
1472
1479
  redo: () => activeRuntime.redo(),
1473
- replaceText: (text, target) => activeRuntime.replaceText(text, target),
1480
+ replaceText: (text, target, formatting) => activeRuntime.replaceText(text, target, formatting),
1481
+ insertFragment: (fragment, target) => activeRuntime.insertFragment(fragment, target),
1474
1482
  addComment: (params) =>
1475
1483
  activeRuntime.addComment({
1476
1484
  ...params,
@@ -1825,6 +1833,9 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1825
1833
  clearWorkflowOverlay: () => {
1826
1834
  activeRuntime.clearWorkflowOverlay();
1827
1835
  },
1836
+ setSharedWorkflowState: (state) => {
1837
+ activeRuntime.setSharedWorkflowState(state === null ? null : clonePublicValue(state));
1838
+ },
1828
1839
  getWorkflowScopeSnapshot: () => {
1829
1840
  return clonePublicValue(activeRuntime.getWorkflowScopeSnapshot());
1830
1841
  },
@@ -2938,6 +2949,13 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2938
2949
  },
2939
2950
  });
2940
2951
 
2952
+ const harnessShowUnsupportedPreviews = readHarnessDebugPortsFlag(__harnessDebugPorts, "unsupportedObjectPreviews");
2953
+ const effectiveShowUnsupportedPreviews = computeEffectiveShowUnsupportedPreviews({
2954
+ harnessShowUnsupportedPreviews,
2955
+ unsupportedPreviewsPolicy,
2956
+ reviewMode,
2957
+ });
2958
+
2941
2959
  const documentElement = (
2942
2960
  <EditorSurfaceController
2943
2961
  ref={surfaceRef}
@@ -2948,7 +2966,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2948
2966
  documentNavigation={documentNavigation}
2949
2967
  reviewMode={reviewMode}
2950
2968
  markupDisplay={liveMarkupDisplay}
2951
- showUnsupportedObjectPreviews={showUnsupportedObjectPreviews}
2969
+ showUnsupportedObjectPreviews={effectiveShowUnsupportedPreviews}
2952
2970
  activeRevisionId={activeRevisionId}
2953
2971
  activeSelectionToolKind={activeSelectionTool?.kind ?? null}
2954
2972
  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);
@@ -877,6 +912,7 @@ function createLoadingRuntimeBridge(input: {
877
912
  getCanonicalDocument: () => input.sessionState.canonicalDocument,
878
913
  getSourcePackage: () => input.sessionState.sourcePackage,
879
914
  replaceText: () => undefined,
915
+ insertFragment: () => undefined,
880
916
  applyActiveStoryTextCommand: () => ({
881
917
  kind: "rejected",
882
918
  newRevisionToken: "",
@@ -965,6 +1001,7 @@ function createLoadingRuntimeBridge(input: {
965
1001
  Promise.reject(createLoadingBoundaryError(input.snapshot.documentId, "export")),
966
1002
  setWorkflowOverlay: () => undefined,
967
1003
  clearWorkflowOverlay: () => undefined,
1004
+ setSharedWorkflowState: () => undefined,
968
1005
  getWorkflowOverlay: () => null,
969
1006
  getWorkflowScopeSnapshot: () => null,
970
1007
  getInteractionGuardSnapshot: () => ({ effectiveMode: "edit", blockedReasons: [] }),
@@ -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
 
@@ -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"
@@ -11,8 +11,29 @@ import {
11
11
  extractPlainTextSegments,
12
12
  type PastePlainSegment,
13
13
  } from "./paste-plain-text";
14
+ import { parseCanonicalFragmentFromWordML } from "../../io/paste/word-clipboard";
14
15
  import type { PositionMap } from "./pm-position-map";
15
16
 
17
+ /**
18
+ * I2 Tier B Slice 2 — MIME types Word + the browser use for WordprocessingML
19
+ * clipboard payloads. The first one is the legacy MS-Office HTML-embedded
20
+ * format; the second is the native Word clipboard type. Browsers expose both
21
+ * under `ClipboardEvent.clipboardData.getData(mime)`.
22
+ */
23
+ const WORDML_MIMES = [
24
+ "application/x-docx-fragment",
25
+ "application/vnd.ms-word.wordprocessingml.paste",
26
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
27
+ ] as const;
28
+
29
+ function readWordMLPayload(clipboard: DataTransfer): string | null {
30
+ for (const mime of WORDML_MIMES) {
31
+ const value = clipboard.getData(mime);
32
+ if (value && value.trim().length > 0) return value;
33
+ }
34
+ return null;
35
+ }
36
+
16
37
  /**
17
38
  * Callback subset used by paste / drop dispatch. Exported so tests can
18
39
  * record dispatch order without constructing the full
@@ -93,6 +114,18 @@ export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
93
114
  charCount: number;
94
115
  source: "paste" | "drop";
95
116
  }) => void;
117
+ /**
118
+ * I2 Tier B Slice 2 — optional. Fires when the paste handler detects an
119
+ * Office-clipboard WordprocessingML payload and parses it successfully into
120
+ * a canonical fragment. The host is responsible for dispatching
121
+ * `runtime.insertFragment(fragment)`; the bridge does not reach into the
122
+ * runtime directly so this plumbing stays consistent with the Tier A
123
+ * plain-text callback pattern.
124
+ */
125
+ onPasteFragment?: (meta: {
126
+ fragment: import("../../api/public-types.ts").CanonicalDocumentFragment;
127
+ source: "wordml";
128
+ }) => void;
96
129
  /**
97
130
  * Optional. Fires on `compositionstart` (true) and `compositionend`
98
131
  * (false). The surface forwards this to the predicted lane's session
@@ -195,11 +228,17 @@ export function createCommandBridgePlugins(
195
228
  return true; // Block PM from processing
196
229
  },
197
230
 
198
- // Plain-text paste: extract text/plain from the clipboard and
199
- // dispatch through the runtime-owned callbacks that typing uses.
200
- // Rich paste (HTML, Office clipboard) stays blocked — hosts that
201
- // listen for onBlockedInput still get notified when a non-plain-
202
- // text payload arrives. See docs/plans/editor-paste-drop.md.
231
+ // I2 paste handler Tier B (WordML) preferred, Tier A (plain) fallback.
232
+ //
233
+ // Preference order per `docs/plans/lane-1-i2-tier-b-rich-paste.md`:
234
+ // 1. Office-clipboard WordprocessingML payload if the host wired
235
+ // `onPasteFragment` AND the clipboard carries the MIME. Parsed via
236
+ // `parseCanonicalFragmentFromWordML`.
237
+ // 2. Plain text via `extractPlainTextSegments` (Tier A).
238
+ // 3. `onBlockedInput` for HTML-only / empty payloads.
239
+ //
240
+ // Rich-paste fallback on parse failure or missing host callback: fall
241
+ // through to Tier A so the user isn't left with a silent no-op.
203
242
  handlePaste(_view, event) {
204
243
  if (isComposing) return true;
205
244
  const clipboard = event.clipboardData;
@@ -207,6 +246,22 @@ export function createCommandBridgePlugins(
207
246
  callbacks.onBlockedInput?.("paste", "Clipboard data was not available.");
208
247
  return true;
209
248
  }
249
+
250
+ // Tier B: WordprocessingML
251
+ if (callbacks.onPasteFragment) {
252
+ const wordml = readWordMLPayload(clipboard);
253
+ if (wordml) {
254
+ const parsed = parseCanonicalFragmentFromWordML(wordml);
255
+ if (parsed.ok && parsed.fragment.blocks.length > 0) {
256
+ callbacks.onPasteFragment({ fragment: parsed.fragment, source: "wordml" });
257
+ return true;
258
+ }
259
+ // Parse failed or empty — fall through to plain-text so the paste
260
+ // still does something (defensive against malformed clipboard payloads).
261
+ }
262
+ }
263
+
264
+ // Tier A: plain text
210
265
  const plain = clipboard.getData("text/plain");
211
266
  if (!plain) {
212
267
  callbacks.onBlockedInput?.(