@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.
- package/README.md +16 -11
- package/package.json +30 -41
- package/src/api/public-types.ts +199 -13
- package/src/compare/diff-engine.ts +4 -0
- package/src/core/commands/add-scope.ts +257 -0
- package/src/core/commands/formatting-commands.ts +2 -0
- package/src/core/commands/index.ts +9 -1
- package/src/core/commands/text-commands.ts +3 -1
- package/src/core/schema/text-schema.ts +95 -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 +103 -7
- package/src/internal/harness-debug-ports.ts +168 -0
- package/src/io/chart-preview-resolver.ts +59 -1
- package/src/io/docx-session.ts +226 -38
- package/src/io/export/serialize-main-document.ts +46 -0
- package/src/io/export/serialize-paragraph-formatting.ts +8 -0
- package/src/io/export/serialize-run-formatting.ts +10 -1
- package/src/io/export/serialize-settings.ts +421 -0
- package/src/io/export/serialize-styles.ts +10 -0
- package/src/io/normalize/normalize-text.ts +1 -0
- 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-axis.ts +277 -0
- package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
- package/src/io/ooxml/chart/parse-series.ts +635 -0
- package/src/io/ooxml/chart/resolve-color.ts +261 -0
- package/src/io/ooxml/chart/types.ts +439 -0
- package/src/io/ooxml/parse-block-structure.ts +99 -0
- package/src/io/ooxml/parse-complex-content.ts +90 -2
- package/src/io/ooxml/parse-main-document.ts +156 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
- package/src/io/ooxml/parse-run-formatting.ts +49 -0
- package/src/io/ooxml/parse-scope-markers.ts +184 -0
- package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
- package/src/io/ooxml/parse-settings.ts +97 -1
- package/src/io/ooxml/parse-styles.ts +65 -0
- package/src/io/ooxml/parse-theme.ts +2 -127
- package/src/io/ooxml/property-grab-bag.ts +211 -0
- package/src/io/ooxml/xml-attr-helpers.ts +59 -1
- package/src/io/ooxml/xml-parser.ts +142 -0
- package/src/model/canonical-document.ts +160 -0
- package/src/model/scope-markers.ts +144 -0
- package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
- package/src/runtime/collab/checkpoint-election.ts +75 -0
- package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
- package/src/runtime/collab/checkpoint-store.ts +115 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab/index.ts +29 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
- package/src/runtime/collab/runtime-collab-sync.ts +330 -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 +288 -65
- package/src/runtime/editor-surface/capabilities.ts +63 -50
- package/src/runtime/hyperlink-color-resolver.ts +119 -0
- package/src/runtime/layout/layout-engine-version.ts +8 -1
- package/src/runtime/prerender/cache-envelope.ts +19 -7
- package/src/runtime/prerender/cache-key.ts +25 -14
- package/src/runtime/prerender/canonical-document-hash.ts +63 -0
- package/src/runtime/prerender/customxml-cache.ts +211 -0
- package/src/runtime/prerender/customxml-probe.ts +78 -0
- package/src/runtime/prerender/prerender-document.ts +74 -7
- package/src/runtime/scope-resolver.ts +148 -0
- package/src/runtime/scope-tag-registry.ts +10 -0
- package/src/runtime/surface-projection.ts +102 -37
- package/src/runtime/theme-color-resolver.ts +188 -0
- package/src/runtime/workflow-markup.ts +7 -18
- package/src/ui/WordReviewEditor.tsx +48 -2
- package/src/ui/editor-runtime-boundary.ts +42 -1
- package/src/ui/headless/selection-helpers.ts +10 -23
- package/src/ui/runtime-shortcut-dispatch.ts +12 -7
- 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/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
|
@@ -2,6 +2,8 @@ import type { EditorSurfaceSnapshot } from "../../api/public-types";
|
|
|
2
2
|
import { createSelectionSnapshot } from "../../core/state/editor-state.ts";
|
|
3
3
|
import { loadDocxEditorSessionAsync } from "../../io/docx-session.ts";
|
|
4
4
|
import { createLoadScheduler } from "../../io/load-scheduler.ts";
|
|
5
|
+
import { readOpcPackage } from "../../io/opc/package-reader.ts";
|
|
6
|
+
import { writeOpcPackage } from "../../io/opc/package-writer.ts";
|
|
5
7
|
import {
|
|
6
8
|
LAYCACHE_SCHEMA_VERSION,
|
|
7
9
|
LAYOUT_ENGINE_VERSION,
|
|
@@ -14,6 +16,8 @@ import {
|
|
|
14
16
|
deriveCacheKey,
|
|
15
17
|
type CacheKeyBlock,
|
|
16
18
|
} from "./cache-key.ts";
|
|
19
|
+
import { computeCanonicalDocumentHash } from "./canonical-document-hash.ts";
|
|
20
|
+
import { writeEnvelopeToOpcPackage } from "./customxml-cache.ts";
|
|
17
21
|
import { resolveFontFingerprint } from "./font-fingerprint.ts";
|
|
18
22
|
import { canonicalizeGraph } from "./graph-canonicalize.ts";
|
|
19
23
|
|
|
@@ -46,11 +50,18 @@ import { canonicalizeGraph } from "./graph-canonicalize.ts";
|
|
|
46
50
|
export interface PrerenderOptions {
|
|
47
51
|
readonly fontFingerprint?: string;
|
|
48
52
|
/**
|
|
49
|
-
* Plan B
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
53
|
+
* Plan B — when true, prerenderDocument injects the cache envelope into
|
|
54
|
+
* the document's workflow-payload customXml part under a `laycache`
|
|
55
|
+
* unknown-namespace entry, and returns the re-serialized bytes in
|
|
56
|
+
* `docWithCustomXml`. Default: `false`, in which case `docWithCustomXml`
|
|
57
|
+
* equals the input bytes.
|
|
58
|
+
*
|
|
59
|
+
* Scope caveat: requires the input docx to already have a
|
|
60
|
+
* `/customXml/item1.xml` part. If the part is absent, the flag is a
|
|
61
|
+
* no-op (documented via `counters.persistedToCustomXml`) and the caller
|
|
62
|
+
* falls back to IndexedDB-only caching. Fresh/minimal docs that lack
|
|
63
|
+
* the part are already fast-opening; Plan B targets CCEP-scale
|
|
64
|
+
* templates which reliably carry the part.
|
|
54
65
|
*/
|
|
55
66
|
readonly persistToCustomXml?: boolean;
|
|
56
67
|
}
|
|
@@ -59,6 +70,13 @@ export interface PrerenderCounters {
|
|
|
59
70
|
readonly blockCount: number;
|
|
60
71
|
readonly pageCount: number;
|
|
61
72
|
readonly prerenderMs: number;
|
|
73
|
+
/**
|
|
74
|
+
* Plan B signal: `true` when `persistToCustomXml` was requested AND the
|
|
75
|
+
* docx had an existing workflow-payload part to mutate. `false` when
|
|
76
|
+
* the flag was off OR the docx had no such part (caller should fall
|
|
77
|
+
* back to IndexedDB caching).
|
|
78
|
+
*/
|
|
79
|
+
readonly persistedToCustomXml: boolean;
|
|
62
80
|
}
|
|
63
81
|
|
|
64
82
|
export interface PrerenderResult {
|
|
@@ -70,6 +88,15 @@ export interface PrerenderResult {
|
|
|
70
88
|
|
|
71
89
|
const PRERENDER_DOCUMENT_ID = "prerender";
|
|
72
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Fixed ISO8601 timestamp used to override session-birth `createdAt` /
|
|
93
|
+
* `updatedAt` on the prerendered envelope. Epoch zero — a valid ISO8601
|
|
94
|
+
* value that downstream validators accept — replacing `Date.now()`-driven
|
|
95
|
+
* values that would otherwise defeat byte-identical `docWithCustomXml`
|
|
96
|
+
* output across two prerender calls on identical bytes.
|
|
97
|
+
*/
|
|
98
|
+
const PRERENDER_NORMALIZED_TIMESTAMP = "1970-01-01T00:00:00.000Z";
|
|
99
|
+
|
|
73
100
|
function toUint8Array(input: ArrayBuffer | Uint8Array): Uint8Array {
|
|
74
101
|
if (input instanceof Uint8Array) return input;
|
|
75
102
|
return new Uint8Array(input);
|
|
@@ -106,7 +133,20 @@ export async function prerenderDocument(
|
|
|
106
133
|
);
|
|
107
134
|
}
|
|
108
135
|
|
|
109
|
-
|
|
136
|
+
// Normalize session-birth timestamps. `loadDocxEditorSessionAsync` sets
|
|
137
|
+
// `createdAt`/`updatedAt` from `new Date().toISOString()`; without this
|
|
138
|
+
// override, two sequential prerender calls on identical bytes would
|
|
139
|
+
// produce different envelopes → different customXml bytes → determinism
|
|
140
|
+
// failure on the B.5 byte-identical gate. Using the epoch keeps the
|
|
141
|
+
// value a valid ISO8601 string (downstream validators accept it) while
|
|
142
|
+
// eliminating the only remaining source of non-determinism. The live
|
|
143
|
+
// session's updatedAt is re-populated by runtime mutations anyway, so
|
|
144
|
+
// the normalized value is irrelevant at runtime.
|
|
145
|
+
const envelope: typeof session.initialSessionState.canonicalDocument = {
|
|
146
|
+
...session.initialSessionState.canonicalDocument,
|
|
147
|
+
createdAt: PRERENDER_NORMALIZED_TIMESTAMP,
|
|
148
|
+
updatedAt: PRERENDER_NORMALIZED_TIMESTAMP,
|
|
149
|
+
};
|
|
110
150
|
const surface = createEditorSurfaceSnapshot(envelope, createSelectionSnapshot(), { kind: "main" });
|
|
111
151
|
|
|
112
152
|
const engine = createLayoutEngine({ autoUpgradeToCanvasBackend: false });
|
|
@@ -114,11 +154,17 @@ export async function prerenderDocument(
|
|
|
114
154
|
const graph = canonicalizeGraph(rawGraph);
|
|
115
155
|
|
|
116
156
|
const structuralHash = await computeStructuralHash(blocksToCacheKeyBlocks(surface));
|
|
157
|
+
// L7 Phase 2.5 Plan B B.2 — sha256 of stable-stringified canonical document.
|
|
158
|
+
// Enters the cache key as the 5th input so style / metadata / comments /
|
|
159
|
+
// preservation mutations correctly invalidate (structuralHash alone misses
|
|
160
|
+
// them because the block-id list is unchanged).
|
|
161
|
+
const canonicalDocumentHash = await computeCanonicalDocumentHash(envelope);
|
|
117
162
|
const cacheKey = await deriveCacheKey({
|
|
118
163
|
blocks: blocksToCacheKeyBlocks(surface),
|
|
119
164
|
fontFingerprint,
|
|
120
165
|
engineVersion: LAYOUT_ENGINE_VERSION,
|
|
121
166
|
schemaVersion: LAYCACHE_SCHEMA_VERSION,
|
|
167
|
+
canonicalDocumentHash,
|
|
122
168
|
});
|
|
123
169
|
|
|
124
170
|
const cacheBlob: CacheEnvelope = {
|
|
@@ -126,20 +172,41 @@ export async function prerenderDocument(
|
|
|
126
172
|
engineVersion: LAYOUT_ENGINE_VERSION,
|
|
127
173
|
fontFingerprint,
|
|
128
174
|
structuralHash,
|
|
175
|
+
canonicalDocumentHash,
|
|
129
176
|
graph,
|
|
130
177
|
surface,
|
|
178
|
+
canonicalDocument: envelope,
|
|
131
179
|
};
|
|
132
180
|
|
|
181
|
+
// Plan B B.5 — persistToCustomXml: inject the envelope into the docx's
|
|
182
|
+
// workflow-payload part. Re-parses the OPC package from bytes (~17 ms on
|
|
183
|
+
// extra-large) because `LoadedDocxEditorSession` does not expose
|
|
184
|
+
// `sourcePackage` publicly. Acceptable cost on the one-shot ingest path.
|
|
185
|
+
let docWithCustomXml = bytes;
|
|
186
|
+
let persistedToCustomXml = false;
|
|
187
|
+
if (options.persistToCustomXml === true) {
|
|
188
|
+
const opcPackage = readOpcPackage(bytes);
|
|
189
|
+
const writeResult = writeEnvelopeToOpcPackage(opcPackage, cacheBlob);
|
|
190
|
+
if (writeResult.written) {
|
|
191
|
+
docWithCustomXml = writeOpcPackage(opcPackage);
|
|
192
|
+
persistedToCustomXml = true;
|
|
193
|
+
}
|
|
194
|
+
// writeResult.written === false → docx had no customXml part; silently
|
|
195
|
+
// fall through with docWithCustomXml = input bytes. Caller observes via
|
|
196
|
+
// counters.persistedToCustomXml.
|
|
197
|
+
}
|
|
198
|
+
|
|
133
199
|
const prerenderMs = performance.now() - t0;
|
|
134
200
|
|
|
135
201
|
return {
|
|
136
|
-
docWithCustomXml
|
|
202
|
+
docWithCustomXml,
|
|
137
203
|
cacheBlob,
|
|
138
204
|
cacheKey,
|
|
139
205
|
counters: {
|
|
140
206
|
blockCount: surface.blocks.length,
|
|
141
207
|
pageCount: graph.pages.length,
|
|
142
208
|
prerenderMs,
|
|
209
|
+
persistedToCustomXml,
|
|
143
210
|
},
|
|
144
211
|
};
|
|
145
212
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CanonicalDocument,
|
|
3
|
+
DocumentRootNode,
|
|
4
|
+
InlineNode,
|
|
5
|
+
ParagraphNode,
|
|
6
|
+
} from "../model/canonical-document.ts";
|
|
7
|
+
import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
|
|
8
|
+
import type { EditorAnchorProjection } from "../api/public-types.ts";
|
|
9
|
+
|
|
10
|
+
export interface ResolvedScopeLocation {
|
|
11
|
+
scopeId: string;
|
|
12
|
+
startPos: number;
|
|
13
|
+
endPos: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function inlineLength(node: InlineNode): number {
|
|
17
|
+
switch (node.type) {
|
|
18
|
+
case "text":
|
|
19
|
+
return Array.from(node.text).length;
|
|
20
|
+
case "hyperlink":
|
|
21
|
+
case "field":
|
|
22
|
+
return node.children.reduce(
|
|
23
|
+
(total, child) => total + inlineLength(child as InlineNode),
|
|
24
|
+
0,
|
|
25
|
+
);
|
|
26
|
+
case "bookmark_start":
|
|
27
|
+
case "bookmark_end":
|
|
28
|
+
case "scope_marker_start":
|
|
29
|
+
case "scope_marker_end":
|
|
30
|
+
return 0;
|
|
31
|
+
default:
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function walkParagraphs(
|
|
37
|
+
document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope,
|
|
38
|
+
): { paragraph: ParagraphNode; from: number }[] {
|
|
39
|
+
const envelope = document as CanonicalDocumentEnvelope;
|
|
40
|
+
const root =
|
|
41
|
+
"content" in envelope
|
|
42
|
+
? (envelope.content as DocumentRootNode)
|
|
43
|
+
: (document as unknown as DocumentRootNode);
|
|
44
|
+
const out: { paragraph: ParagraphNode; from: number }[] = [];
|
|
45
|
+
let cursor = 0;
|
|
46
|
+
for (let index = 0; index < root.children.length; index += 1) {
|
|
47
|
+
const block = root.children[index];
|
|
48
|
+
if (block && block.type === "paragraph") {
|
|
49
|
+
out.push({ paragraph: block, from: cursor });
|
|
50
|
+
cursor += block.children.reduce(
|
|
51
|
+
(total, child) => total + inlineLength(child as InlineNode),
|
|
52
|
+
0,
|
|
53
|
+
);
|
|
54
|
+
} else if (block && block.type === "table") {
|
|
55
|
+
cursor += 1;
|
|
56
|
+
} else {
|
|
57
|
+
cursor += 1;
|
|
58
|
+
}
|
|
59
|
+
if (index < root.children.length - 1) {
|
|
60
|
+
cursor += 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Walk all paragraphs in the document and return the absolute positions of the
|
|
68
|
+
* start and end markers for each scope that has either side present. Used by
|
|
69
|
+
* `resolveScope` + `findScopeAt`; exported so test code can inspect the
|
|
70
|
+
* marker-to-position mapping directly.
|
|
71
|
+
*/
|
|
72
|
+
export function collectScopeLocations(
|
|
73
|
+
document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope,
|
|
74
|
+
): Map<string, { startPos?: number; endPos?: number }> {
|
|
75
|
+
const locations = new Map<string, { startPos?: number; endPos?: number }>();
|
|
76
|
+
const paragraphs = walkParagraphs(document);
|
|
77
|
+
for (const { paragraph, from } of paragraphs) {
|
|
78
|
+
let cursor = from;
|
|
79
|
+
for (const child of paragraph.children) {
|
|
80
|
+
if (child.type === "scope_marker_start") {
|
|
81
|
+
const prior = locations.get(child.scopeId) ?? {};
|
|
82
|
+
locations.set(child.scopeId, { ...prior, startPos: cursor });
|
|
83
|
+
} else if (child.type === "scope_marker_end") {
|
|
84
|
+
const prior = locations.get(child.scopeId) ?? {};
|
|
85
|
+
locations.set(child.scopeId, { ...prior, endPos: cursor });
|
|
86
|
+
}
|
|
87
|
+
cursor += inlineLength(child as InlineNode);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return locations;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve a scopeId to a live public range anchor derived from the marker
|
|
95
|
+
* positions currently in the document. Returns:
|
|
96
|
+
* - A range anchor when both markers are present
|
|
97
|
+
* - A detached anchor when one or zero markers survive
|
|
98
|
+
* - `null` when neither marker is in the document
|
|
99
|
+
*/
|
|
100
|
+
export function resolveScope(
|
|
101
|
+
document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope,
|
|
102
|
+
scopeId: string,
|
|
103
|
+
): EditorAnchorProjection | null {
|
|
104
|
+
const locations = collectScopeLocations(document);
|
|
105
|
+
const loc = locations.get(scopeId);
|
|
106
|
+
if (!loc) return null;
|
|
107
|
+
|
|
108
|
+
if (loc.startPos !== undefined && loc.endPos !== undefined) {
|
|
109
|
+
const from = Math.min(loc.startPos, loc.endPos);
|
|
110
|
+
const to = Math.max(loc.startPos, loc.endPos);
|
|
111
|
+
return {
|
|
112
|
+
kind: "range",
|
|
113
|
+
from,
|
|
114
|
+
to,
|
|
115
|
+
assoc: { start: -1, end: 1 },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
kind: "detached",
|
|
121
|
+
reason: "deleted",
|
|
122
|
+
lastKnownRange: {
|
|
123
|
+
from: loc.startPos ?? loc.endPos ?? 0,
|
|
124
|
+
to: loc.endPos ?? loc.startPos ?? 0,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Given a position, return the innermost enclosing scope (by document order).
|
|
131
|
+
* Used by the chrome overlay hit-test and by the edit-dispatch guard that
|
|
132
|
+
* routes delete-through-marker to `removeScope`.
|
|
133
|
+
*/
|
|
134
|
+
export function findScopeAt(
|
|
135
|
+
document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope,
|
|
136
|
+
position: number,
|
|
137
|
+
): { scopeId: string; startPos: number; endPos: number } | null {
|
|
138
|
+
const locations = collectScopeLocations(document);
|
|
139
|
+
let best: { scopeId: string; startPos: number; endPos: number } | null = null;
|
|
140
|
+
for (const [scopeId, loc] of locations) {
|
|
141
|
+
if (loc.startPos === undefined || loc.endPos === undefined) continue;
|
|
142
|
+
if (position < loc.startPos || position > loc.endPos) continue;
|
|
143
|
+
if (!best || loc.startPos > best.startPos) {
|
|
144
|
+
best = { scopeId, startPos: loc.startPos, endPos: loc.endPos };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return best;
|
|
148
|
+
}
|
|
@@ -53,6 +53,16 @@ export const DEFAULT_REGISTRY_ENTRIES: Readonly<Record<string, ScopeTagBehavior>
|
|
|
53
53
|
trimOnDelete: true,
|
|
54
54
|
bailIfCrossed: false,
|
|
55
55
|
},
|
|
56
|
+
// S1 scope-marker anchoring. `trimOnDelete: false` is the load-bearing
|
|
57
|
+
// difference vs. `bookmark` — a delete that crosses a scope marker routes
|
|
58
|
+
// through `removeScope` instead of silently trimming the marker, so
|
|
59
|
+
// half-scope states (orphaned metadata in customXml) never appear.
|
|
60
|
+
"workflow-scope-marker": {
|
|
61
|
+
extendOnInsertLeft: true,
|
|
62
|
+
extendOnInsertRight: true,
|
|
63
|
+
trimOnDelete: false,
|
|
64
|
+
bailIfCrossed: false,
|
|
65
|
+
},
|
|
56
66
|
sdt: {
|
|
57
67
|
extendOnInsertLeft: false,
|
|
58
68
|
extendOnInsertRight: false,
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
resolveEffectiveRunFormatting,
|
|
52
52
|
resolveNumberingMarkerRunFormatting,
|
|
53
53
|
} from "./paragraph-style-resolver.ts";
|
|
54
|
+
import { resolveHyperlinkRunFormatting } from "./hyperlink-color-resolver.ts";
|
|
54
55
|
import type { CanonicalParagraphFormatting, CanonicalRunFormatting } from "../model/canonical-document.ts";
|
|
55
56
|
|
|
56
57
|
interface ParagraphAccumulator {
|
|
@@ -97,6 +98,17 @@ export function createEditorSurfaceSnapshot(
|
|
|
97
98
|
};
|
|
98
99
|
|
|
99
100
|
for (let index = 0; index < root.children.length; index += 1) {
|
|
101
|
+
const isInViewport =
|
|
102
|
+
viewportBlockRange === null ||
|
|
103
|
+
(index >= viewportBlockRange.start && index < viewportBlockRange.end);
|
|
104
|
+
// L7 Phase 2.9 — viewport bail on the style-cascade work. When the
|
|
105
|
+
// block is outside the viewport, the surface block produced below is
|
|
106
|
+
// immediately discarded in favor of a `placeholder-culled` entry (we
|
|
107
|
+
// only consume `nextCursor`). Pass `cullBuild: true` so the paragraph
|
|
108
|
+
// + inline walkers skip `resolveEffectiveParagraphFormatting`,
|
|
109
|
+
// `resolveNumberingMarkerRunFormatting`, and per-segment
|
|
110
|
+
// `resolveEffectiveRunFormatting` — the expensive style-catalog walks
|
|
111
|
+
// that dominate surface-projection cost on large docs.
|
|
100
112
|
const surfaceBlock = createSurfaceBlock(
|
|
101
113
|
root.children[index],
|
|
102
114
|
document,
|
|
@@ -104,10 +116,8 @@ export function createEditorSurfaceSnapshot(
|
|
|
104
116
|
counters,
|
|
105
117
|
numberingPrefixResolver,
|
|
106
118
|
activeStory.kind !== "main",
|
|
119
|
+
!isInViewport,
|
|
107
120
|
);
|
|
108
|
-
const isInViewport =
|
|
109
|
-
viewportBlockRange === null ||
|
|
110
|
-
(index >= viewportBlockRange.start && index < viewportBlockRange.end);
|
|
111
121
|
|
|
112
122
|
if (isInViewport) {
|
|
113
123
|
blocks.push(surfaceBlock.block);
|
|
@@ -165,6 +175,7 @@ function createSurfaceBlock(
|
|
|
165
175
|
},
|
|
166
176
|
numberingPrefixResolver: NumberingPrefixResolver,
|
|
167
177
|
promoteSecondaryStoryTextBoxes: boolean,
|
|
178
|
+
cullBuild: boolean = false,
|
|
168
179
|
): { block: SurfaceBlockSnapshot; lockedFragmentIds: string[]; nextCursor: number } {
|
|
169
180
|
if (block.type === "opaque_block") {
|
|
170
181
|
const fragment = getOpaqueFragment(document.preservation as never, block.fragmentId);
|
|
@@ -205,6 +216,7 @@ function createSurfaceBlock(
|
|
|
205
216
|
counters,
|
|
206
217
|
numberingPrefixResolver,
|
|
207
218
|
promoteSecondaryStoryTextBoxes,
|
|
219
|
+
cullBuild,
|
|
208
220
|
);
|
|
209
221
|
}
|
|
210
222
|
|
|
@@ -244,6 +256,7 @@ function createSurfaceBlock(
|
|
|
244
256
|
counters,
|
|
245
257
|
numberingPrefixResolver,
|
|
246
258
|
promoteSecondaryStoryTextBoxes,
|
|
259
|
+
cullBuild,
|
|
247
260
|
);
|
|
248
261
|
}
|
|
249
262
|
|
|
@@ -336,6 +349,7 @@ function createSurfaceBlock(
|
|
|
336
349
|
cursor,
|
|
337
350
|
numberingPrefixResolver,
|
|
338
351
|
promoteSecondaryStoryTextBoxes,
|
|
352
|
+
cullBuild,
|
|
339
353
|
);
|
|
340
354
|
}
|
|
341
355
|
|
|
@@ -354,6 +368,7 @@ function createTableBlock(
|
|
|
354
368
|
},
|
|
355
369
|
numberingPrefixResolver: NumberingPrefixResolver,
|
|
356
370
|
promoteSecondaryStoryTextBoxes: boolean,
|
|
371
|
+
cullBuild: boolean = false,
|
|
357
372
|
): { block: SurfaceBlockSnapshot; lockedFragmentIds: string[]; nextCursor: number } {
|
|
358
373
|
const lockedFragmentIds: string[] = [];
|
|
359
374
|
let innerCursor = cursor;
|
|
@@ -374,6 +389,7 @@ function createTableBlock(
|
|
|
374
389
|
counters,
|
|
375
390
|
numberingPrefixResolver,
|
|
376
391
|
promoteSecondaryStoryTextBoxes,
|
|
392
|
+
cullBuild,
|
|
377
393
|
);
|
|
378
394
|
cellContent.push(result.block);
|
|
379
395
|
lockedFragmentIds.push(...result.lockedFragmentIds);
|
|
@@ -518,6 +534,7 @@ function createSdtBlock(
|
|
|
518
534
|
},
|
|
519
535
|
numberingPrefixResolver: NumberingPrefixResolver,
|
|
520
536
|
promoteSecondaryStoryTextBoxes: boolean,
|
|
537
|
+
cullBuild: boolean = false,
|
|
521
538
|
): { block: SurfaceBlockSnapshot; lockedFragmentIds: string[]; nextCursor: number } {
|
|
522
539
|
const children: SurfaceBlockSnapshot[] = [];
|
|
523
540
|
const lockedFragmentIds: string[] = [];
|
|
@@ -531,6 +548,7 @@ function createSdtBlock(
|
|
|
531
548
|
counters,
|
|
532
549
|
numberingPrefixResolver,
|
|
533
550
|
promoteSecondaryStoryTextBoxes,
|
|
551
|
+
cullBuild,
|
|
534
552
|
);
|
|
535
553
|
children.push(result.block);
|
|
536
554
|
lockedFragmentIds.push(...result.lockedFragmentIds);
|
|
@@ -566,34 +584,52 @@ function createParagraphBlock(
|
|
|
566
584
|
start: number,
|
|
567
585
|
numberingPrefixResolver: NumberingPrefixResolver,
|
|
568
586
|
promoteSecondaryStoryTextBoxes: boolean,
|
|
587
|
+
cullBuild: boolean = false,
|
|
569
588
|
): {
|
|
570
589
|
block: SurfaceBlockSnapshot;
|
|
571
590
|
nextCursor: number;
|
|
572
591
|
lockedFragmentIds: string[];
|
|
573
592
|
} {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
//
|
|
593
|
+
// L7 Phase 2.9 — viewport bail. When the paragraph is outside the
|
|
594
|
+
// viewport, the returned block is discarded (the outer caller in
|
|
595
|
+
// `createEditorSurfaceSnapshot` replaces it with a placeholder-culled
|
|
596
|
+
// entry and only consumes `nextCursor`). Skip the two style-catalog
|
|
597
|
+
// walks (`resolveEffectiveParagraphFormatting`,
|
|
598
|
+
// `resolveNumberingMarkerRunFormatting`) — their results are not read
|
|
599
|
+
// by the placeholder path. Segment-level work inside
|
|
600
|
+
// `appendInlineSegments` is suppressed symmetrically via the same
|
|
601
|
+
// `cullBuild` flag, preserving cursor arithmetic.
|
|
602
|
+
const effectiveNumbering = cullBuild
|
|
603
|
+
? undefined
|
|
604
|
+
: resolveEffectiveParagraphNumbering(document, paragraph);
|
|
605
|
+
const resolvedNumbering =
|
|
606
|
+
!cullBuild && effectiveNumbering
|
|
607
|
+
? numberingPrefixResolver.resolveDetailed(effectiveNumbering, paragraph)
|
|
608
|
+
: null;
|
|
609
|
+
|
|
610
|
+
// Task 11: compute cascaded paragraph formatting (expensive — styles-catalog walk).
|
|
580
611
|
const stylesCatalog = document.styles;
|
|
581
|
-
const directParagraphFormatting =
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
const markerRunProperties = effectiveNumbering
|
|
589
|
-
? resolveNumberingMarkerRunFormatting(
|
|
590
|
-
{
|
|
591
|
-
paragraphStyleId: paragraph.styleId,
|
|
592
|
-
levelRunProperties: resolvedNumbering?.markerRunProperties,
|
|
593
|
-
},
|
|
612
|
+
const directParagraphFormatting = cullBuild
|
|
613
|
+
? undefined
|
|
614
|
+
: buildDirectParagraphFormattingFromNode(paragraph);
|
|
615
|
+
const resolvedParagraphFormatting = cullBuild
|
|
616
|
+
? undefined
|
|
617
|
+
: resolveEffectiveParagraphFormatting(
|
|
618
|
+
{ styleId: paragraph.styleId, direct: directParagraphFormatting },
|
|
594
619
|
stylesCatalog,
|
|
595
|
-
)
|
|
596
|
-
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
// Task 11: compute cascaded marker run formatting (expensive).
|
|
623
|
+
const markerRunProperties =
|
|
624
|
+
!cullBuild && effectiveNumbering
|
|
625
|
+
? resolveNumberingMarkerRunFormatting(
|
|
626
|
+
{
|
|
627
|
+
paragraphStyleId: paragraph.styleId,
|
|
628
|
+
levelRunProperties: resolvedNumbering?.markerRunProperties,
|
|
629
|
+
},
|
|
630
|
+
stylesCatalog,
|
|
631
|
+
)
|
|
632
|
+
: undefined;
|
|
597
633
|
|
|
598
634
|
const accumulator: ParagraphAccumulator = {
|
|
599
635
|
blockId: `paragraph-${paragraphIndex}`,
|
|
@@ -650,6 +686,8 @@ function createParagraphBlock(
|
|
|
650
686
|
document,
|
|
651
687
|
cursor,
|
|
652
688
|
promoteSecondaryStoryTextBoxes,
|
|
689
|
+
undefined,
|
|
690
|
+
cullBuild,
|
|
653
691
|
);
|
|
654
692
|
cursor = result.nextCursor;
|
|
655
693
|
lockedFragmentIds.push(...result.lockedFragmentIds);
|
|
@@ -812,22 +850,39 @@ function appendInlineSegments(
|
|
|
812
850
|
start: number,
|
|
813
851
|
promoteSecondaryStoryTextBoxes: boolean,
|
|
814
852
|
hyperlinkHref?: string,
|
|
853
|
+
cullBuild: boolean = false,
|
|
815
854
|
): { nextCursor: number; lockedFragmentIds: string[] } {
|
|
816
855
|
switch (node.type) {
|
|
817
856
|
case "text": {
|
|
818
857
|
const cloned = node.marks ? cloneMarks(node.marks) : { marks: [] as SurfaceTextMark[] };
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
858
|
+
// L7 Phase 2.9 — skip the styles-catalog run-cascade walk when
|
|
859
|
+
// the block will be culled. `resolveEffectiveRunFormatting`
|
|
860
|
+
// dominates per-text-segment cost on style-heavy docs; the
|
|
861
|
+
// placeholder path does not read `resolvedRunFormatting`.
|
|
862
|
+
const directRunFormatting = cullBuild
|
|
863
|
+
? undefined
|
|
864
|
+
: buildDirectRunFormattingFromMarks(
|
|
865
|
+
cloned.marks.length > 0 ? cloned.marks : undefined,
|
|
866
|
+
cloned.markAttrs,
|
|
867
|
+
);
|
|
868
|
+
// V7 — runs inside <w:hyperlink> go through the hyperlink color
|
|
869
|
+
// cascade (auto-applies the Hyperlink character style + resolves
|
|
870
|
+
// theme hlink slot with Word-default fallback). Non-hyperlink
|
|
871
|
+
// runs take the unchanged cascade path.
|
|
872
|
+
const runResolveInput = {
|
|
873
|
+
paragraphStyleId: paragraph.styleId,
|
|
874
|
+
characterStyleId: undefined,
|
|
875
|
+
direct: directRunFormatting,
|
|
876
|
+
};
|
|
877
|
+
const resolvedRunFormatting = cullBuild
|
|
878
|
+
? {}
|
|
879
|
+
: hyperlinkHref
|
|
880
|
+
? resolveHyperlinkRunFormatting(
|
|
881
|
+
runResolveInput,
|
|
882
|
+
document.styles,
|
|
883
|
+
document.subParts?.resolvedTheme,
|
|
884
|
+
)
|
|
885
|
+
: resolveEffectiveRunFormatting(runResolveInput, document.styles);
|
|
831
886
|
paragraph.segments.push({
|
|
832
887
|
segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
|
|
833
888
|
kind: "text",
|
|
@@ -869,6 +924,7 @@ function appendInlineSegments(
|
|
|
869
924
|
cursor,
|
|
870
925
|
promoteSecondaryStoryTextBoxes,
|
|
871
926
|
node.href,
|
|
927
|
+
cullBuild,
|
|
872
928
|
);
|
|
873
929
|
cursor = result.nextCursor;
|
|
874
930
|
}
|
|
@@ -1003,6 +1059,8 @@ function appendInlineSegments(
|
|
|
1003
1059
|
document,
|
|
1004
1060
|
cursor,
|
|
1005
1061
|
promoteSecondaryStoryTextBoxes,
|
|
1062
|
+
undefined,
|
|
1063
|
+
cullBuild,
|
|
1006
1064
|
);
|
|
1007
1065
|
cursor = result.nextCursor;
|
|
1008
1066
|
lockedIds.push(...result.lockedFragmentIds);
|
|
@@ -1047,7 +1105,11 @@ function appendInlineSegments(
|
|
|
1047
1105
|
}
|
|
1048
1106
|
case "bookmark_start":
|
|
1049
1107
|
case "bookmark_end":
|
|
1050
|
-
|
|
1108
|
+
case "scope_marker_start":
|
|
1109
|
+
case "scope_marker_end":
|
|
1110
|
+
// Zero-width markers — no visual, no cursor advancement. Scope markers
|
|
1111
|
+
// (S1) follow the bookmark precedent: structural anchors whose positions
|
|
1112
|
+
// track with surrounding text but which don't occupy cursor positions.
|
|
1051
1113
|
return { nextCursor: start, lockedFragmentIds: [] };
|
|
1052
1114
|
default:
|
|
1053
1115
|
return { nextCursor: start + 1, lockedFragmentIds: [] };
|
|
@@ -1466,6 +1528,9 @@ function summarizePreviewInline(node: InlineNode): string {
|
|
|
1466
1528
|
return node.name ? `[Bookmark: ${node.name}]` : "[Bookmark]";
|
|
1467
1529
|
case "bookmark_end":
|
|
1468
1530
|
return "";
|
|
1531
|
+
case "scope_marker_start":
|
|
1532
|
+
case "scope_marker_end":
|
|
1533
|
+
return "";
|
|
1469
1534
|
case "image":
|
|
1470
1535
|
return node.altText ? `[Image: ${node.altText}]` : "[Image]";
|
|
1471
1536
|
case "opaque_inline":
|