@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.
- package/README.md +19 -11
- package/package.json +30 -41
- package/src/api/public-types.ts +103 -12
- package/src/core/commands/index.ts +30 -1
- package/src/core/commands/text-commands.ts +3 -1
- package/src/core/selection/anchor-conversion.ts +112 -0
- package/src/core/selection/review-anchors.ts +108 -3
- package/src/core/state/text-transaction.ts +86 -2
- package/src/internal/harness-debug-ports.ts +168 -0
- package/src/io/chart-preview-resolver.ts +32 -1
- package/src/io/export/serialize-comments.ts +50 -5
- package/src/io/export/serialize-main-document.ts +9 -0
- package/src/io/export/serialize-paragraph-formatting.ts +8 -0
- package/src/io/export/serialize-run-formatting.ts +10 -1
- package/src/io/ooxml/chart/chart-style-table.ts +543 -0
- package/src/io/ooxml/chart/color-palette.ts +101 -0
- package/src/io/ooxml/chart/compose-series-color.ts +147 -0
- package/src/io/ooxml/chart/parse-chart-space.ts +118 -46
- package/src/io/ooxml/chart/parse-series.ts +76 -11
- package/src/io/ooxml/chart/resolve-color.ts +16 -6
- package/src/io/ooxml/chart/types.ts +30 -11
- package/src/io/ooxml/parse-complex-content.ts +6 -3
- package/src/io/ooxml/parse-main-document.ts +41 -0
- package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
- package/src/io/ooxml/parse-run-formatting.ts +49 -0
- package/src/io/ooxml/property-grab-bag.ts +211 -0
- package/src/io/paste/word-clipboard.ts +114 -0
- package/src/model/canonical-document.ts +69 -3
- package/src/runtime/collab/index.ts +7 -0
- package/src/runtime/collab/runtime-collab-sync.ts +51 -0
- package/src/runtime/collab/workflow-shared.ts +247 -0
- package/src/runtime/document-locations.ts +1 -9
- package/src/runtime/document-outline.ts +1 -9
- package/src/runtime/document-runtime.ts +98 -50
- package/src/runtime/hyperlink-color-resolver.ts +119 -0
- package/src/runtime/layout/layout-engine-version.ts +11 -1
- package/src/runtime/layout/public-facet.ts +5 -12
- package/src/runtime/render/render-frame-types.ts +14 -0
- package/src/runtime/render/render-kernel.ts +40 -2
- package/src/runtime/structure-ops/fragment-insert.ts +134 -0
- package/src/runtime/surface-projection.ts +94 -36
- package/src/runtime/theme-color-resolver.ts +188 -0
- package/src/runtime/workflow-markup.ts +7 -18
- package/src/ui/WordReviewEditor.tsx +22 -4
- package/src/ui/editor-runtime-boundary.ts +37 -0
- package/src/ui/headless/selection-helpers.ts +10 -23
- package/src/ui/unsupported-previews-policy.ts +23 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +60 -5
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
- 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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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={
|
|
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
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
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
|
|
@@ -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
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
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?.(
|