@beyondwork/docx-react-component 1.0.77 → 1.0.79
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/package.json +1 -1
- package/src/api/public-types.ts +51 -1
- package/src/api/v3/runtime/workflow.ts +21 -2
- package/src/core/commands/add-scope.ts +163 -36
- package/src/io/ooxml/parse-shapes.ts +32 -6
- package/src/model/canonical-document.ts +45 -8
- package/src/runtime/document-runtime.ts +77 -2
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +27 -1
- package/src/runtime/layout/public-facet.ts +35 -0
- package/src/runtime/workflow/coordinator.ts +63 -10
- package/src/runtime/workflow/scope-writer.ts +90 -2
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +12 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +20 -1
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +17 -2
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +13 -13
- package/src/ui-tailwind/page-stack/tw-active-band-ribbon.tsx +229 -0
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +15 -1
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +18 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +20 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +10 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +56 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beyondwork/docx-react-component",
|
|
3
3
|
"publisher": "beyondwork",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.79",
|
|
5
5
|
"description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": [
|
package/src/api/public-types.ts
CHANGED
|
@@ -1566,6 +1566,22 @@ export type SurfaceBlockSnapshot =
|
|
|
1566
1566
|
* See CLAUDE.md (lane status table)
|
|
1567
1567
|
*/
|
|
1568
1568
|
placeholderSize?: number;
|
|
1569
|
+
/**
|
|
1570
|
+
* Visual height of the block the placeholder stands in for, in twips,
|
|
1571
|
+
* as computed by the L04 paginator against the fully-realized surface.
|
|
1572
|
+
* When present, the PM placeholder emits a fixed-height `<div>` so the
|
|
1573
|
+
* scrollable canvas matches what the realized block will occupy once
|
|
1574
|
+
* it re-enters the viewport — eliminating the "paragraphs jump around
|
|
1575
|
+
* pagination gaps" flicker that plagued culled regions rendered as
|
|
1576
|
+
* single-line ZWSP stubs.
|
|
1577
|
+
*
|
|
1578
|
+
* Populated by `DocumentRuntime.buildRenderSnapshot()` after each
|
|
1579
|
+
* pagination pass; surface-projection itself does not set it (L03 has
|
|
1580
|
+
* no layout data). Consumers that build a surface without a runtime
|
|
1581
|
+
* (tests, plain-text preview) safely leave it `undefined` and fall
|
|
1582
|
+
* back to the `min-height: 20px` behaviour.
|
|
1583
|
+
*/
|
|
1584
|
+
placeholderHeightTwips?: number;
|
|
1569
1585
|
state: "locked-preserve-only" | "placeholder-culled";
|
|
1570
1586
|
};
|
|
1571
1587
|
|
|
@@ -2487,9 +2503,43 @@ export interface AddScopeParams {
|
|
|
2487
2503
|
}
|
|
2488
2504
|
|
|
2489
2505
|
export interface AddScopeResult {
|
|
2506
|
+
/**
|
|
2507
|
+
* Minted scope id. Empty string (`""`) iff the marker plant failed —
|
|
2508
|
+
* in that case `anchor.kind === "detached"` + `plantStatus.planted`
|
|
2509
|
+
* is `false`. Pre-2026-04-24 a plant failure silently returned a
|
|
2510
|
+
* non-empty scopeId that later resolved to `scope-not-resolvable`;
|
|
2511
|
+
* callers must now check `scopeId.length > 0` (or equivalently
|
|
2512
|
+
* `plantStatus?.planted !== false`) before using the id.
|
|
2513
|
+
*/
|
|
2490
2514
|
scopeId: string;
|
|
2491
|
-
/** Range anchor derived from the just-inserted marker positions. */
|
|
2515
|
+
/** Range anchor derived from the just-inserted marker positions, or a detached placeholder on plant failure. */
|
|
2492
2516
|
anchor: EditorAnchorProjection;
|
|
2517
|
+
/**
|
|
2518
|
+
* Structured diagnostic for the marker-plant attempt. Omitted on the
|
|
2519
|
+
* happy path for back-compat; populated with `planted: false` + a
|
|
2520
|
+
* typed reason when the plant was refused (cross-paragraph range,
|
|
2521
|
+
* non-paragraph target, out-of-bounds). Callers that previously
|
|
2522
|
+
* assumed every returned `scopeId` was live should migrate to
|
|
2523
|
+
* checking this field.
|
|
2524
|
+
*/
|
|
2525
|
+
plantStatus?: {
|
|
2526
|
+
readonly planted: false;
|
|
2527
|
+
readonly reason:
|
|
2528
|
+
| "cross-paragraph-range"
|
|
2529
|
+
| "non-paragraph-target"
|
|
2530
|
+
| "range-out-of-bounds"
|
|
2531
|
+
| "empty-document";
|
|
2532
|
+
readonly requestedFrom: number;
|
|
2533
|
+
readonly requestedTo: number;
|
|
2534
|
+
/** Present on `cross-paragraph-range`. */
|
|
2535
|
+
readonly fromBlockIndex?: number;
|
|
2536
|
+
readonly toBlockIndex?: number;
|
|
2537
|
+
/** Present on `non-paragraph-target`. */
|
|
2538
|
+
readonly blockIndex?: number;
|
|
2539
|
+
readonly blockKind?: string;
|
|
2540
|
+
/** Present on `range-out-of-bounds`. */
|
|
2541
|
+
readonly storyLength?: number;
|
|
2542
|
+
};
|
|
2493
2543
|
}
|
|
2494
2544
|
|
|
2495
2545
|
export interface ExportDocxOptions {
|
|
@@ -160,13 +160,20 @@ export type CreateScopeFromAnchorResult =
|
|
|
160
160
|
readonly reason:
|
|
161
161
|
| "from-negative"
|
|
162
162
|
| "to-less-than-from"
|
|
163
|
-
| "range-exceeds-story-length"
|
|
163
|
+
| "range-exceeds-story-length"
|
|
164
|
+
| "cross-paragraph-range"
|
|
165
|
+
| "non-paragraph-target"
|
|
166
|
+
| "empty-document";
|
|
164
167
|
readonly from: number;
|
|
165
168
|
readonly to: number;
|
|
166
169
|
readonly storyLength: number;
|
|
170
|
+
readonly fromBlockIndex?: number;
|
|
171
|
+
readonly toBlockIndex?: number;
|
|
172
|
+
readonly blockIndex?: number;
|
|
173
|
+
readonly blockKind?: string;
|
|
167
174
|
/** Agent-actionable single-sentence explanation. Safe to surface to LLM tool replies as-is. */
|
|
168
175
|
readonly message: string;
|
|
169
|
-
/** Machine-routable next-step hint
|
|
176
|
+
/** Machine-routable next-step hint. */
|
|
170
177
|
readonly nextStep: string;
|
|
171
178
|
};
|
|
172
179
|
|
|
@@ -533,6 +540,18 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
|
|
|
533
540
|
from: adapterResult.from,
|
|
534
541
|
to: adapterResult.to,
|
|
535
542
|
storyLength: adapterResult.storyLength,
|
|
543
|
+
...(adapterResult.fromBlockIndex !== undefined
|
|
544
|
+
? { fromBlockIndex: adapterResult.fromBlockIndex }
|
|
545
|
+
: {}),
|
|
546
|
+
...(adapterResult.toBlockIndex !== undefined
|
|
547
|
+
? { toBlockIndex: adapterResult.toBlockIndex }
|
|
548
|
+
: {}),
|
|
549
|
+
...(adapterResult.blockIndex !== undefined
|
|
550
|
+
? { blockIndex: adapterResult.blockIndex }
|
|
551
|
+
: {}),
|
|
552
|
+
...(adapterResult.blockKind !== undefined
|
|
553
|
+
? { blockKind: adapterResult.blockKind }
|
|
554
|
+
: {}),
|
|
536
555
|
message: adapterResult.message,
|
|
537
556
|
nextStep: adapterResult.nextStep,
|
|
538
557
|
};
|
|
@@ -7,18 +7,72 @@ import type {
|
|
|
7
7
|
} from "../../model/canonical-document.ts";
|
|
8
8
|
import type { CanonicalDocumentEnvelope } from "../state/editor-state.ts";
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Discriminated return — pre-2026-04-24 this helper silently returned
|
|
12
|
+
* the input document unchanged on every failure mode, which left
|
|
13
|
+
* callers holding a minted `scopeId` that referenced nothing in the
|
|
14
|
+
* canonical tree. `compileScopeById` later returned null and the v3
|
|
15
|
+
* surface emitted `scope-not-resolvable:<scopeId>` without the caller
|
|
16
|
+
* knowing whether the scope was ever live. Every non-`"planted"`
|
|
17
|
+
* status is now typed so callers can discriminate + surface
|
|
18
|
+
* agent-actionable recovery hints.
|
|
19
|
+
*/
|
|
20
|
+
export type InsertScopeMarkersResult =
|
|
21
|
+
| {
|
|
22
|
+
readonly status: "planted";
|
|
23
|
+
readonly document: CanonicalDocumentEnvelope;
|
|
24
|
+
readonly scopeId: string;
|
|
25
|
+
/** Absolute span that got planted (after from/to normalization). */
|
|
26
|
+
readonly plantedRange: { readonly from: number; readonly to: number };
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
readonly status: "cross-paragraph-range";
|
|
30
|
+
readonly scopeId: string;
|
|
31
|
+
readonly from: number;
|
|
32
|
+
readonly to: number;
|
|
33
|
+
readonly fromBlockIndex: number;
|
|
34
|
+
readonly toBlockIndex: number;
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
readonly status: "non-paragraph-target";
|
|
38
|
+
readonly scopeId: string;
|
|
39
|
+
readonly from: number;
|
|
40
|
+
readonly to: number;
|
|
41
|
+
readonly blockIndex: number;
|
|
42
|
+
readonly blockKind: string;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
readonly status: "range-out-of-bounds";
|
|
46
|
+
readonly scopeId: string;
|
|
47
|
+
readonly from: number;
|
|
48
|
+
readonly to: number;
|
|
49
|
+
readonly storyLength: number;
|
|
50
|
+
}
|
|
51
|
+
| {
|
|
52
|
+
readonly status: "empty-document";
|
|
53
|
+
readonly scopeId: string;
|
|
54
|
+
readonly from: number;
|
|
55
|
+
readonly to: number;
|
|
56
|
+
};
|
|
14
57
|
|
|
15
58
|
/**
|
|
16
|
-
* Pure helper — returns a
|
|
17
|
-
*
|
|
59
|
+
* Pure helper — returns a discriminated result describing whether a
|
|
60
|
+
* pair of `scope_marker_start` / `scope_marker_end` inline nodes got
|
|
61
|
+
* inserted at `[from, to]`.
|
|
62
|
+
*
|
|
63
|
+
* Failure modes (all previously silent — pre-2026-04-24):
|
|
64
|
+
* - `cross-paragraph-range` — `from` and `to` resolve to different
|
|
65
|
+
* top-level paragraph blocks. Multi-block marker scopes are not
|
|
66
|
+
* yet wired; narrow the range to land inside one paragraph.
|
|
67
|
+
* - `non-paragraph-target` — the target range lands inside a
|
|
68
|
+
* non-paragraph block (table / SDT / section_break). Marker
|
|
69
|
+
* scopes only plant inside paragraphs today.
|
|
70
|
+
* - `range-out-of-bounds` — `to` exceeds the main story length.
|
|
71
|
+
* - `empty-document` — the document has no root / no children.
|
|
18
72
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
73
|
+
* Each failure variant names the scopeId the caller had minted but
|
|
74
|
+
* does NOT mutate the document — callers treat it as a hard failure
|
|
75
|
+
* and must not register the scopeId on the overlay.
|
|
22
76
|
*/
|
|
23
77
|
export function insertScopeMarkers(
|
|
24
78
|
document: CanonicalDocumentEnvelope,
|
|
@@ -30,57 +84,130 @@ export function insertScopeMarkers(
|
|
|
30
84
|
): InsertScopeMarkersResult {
|
|
31
85
|
const { scopeId, from, to } = params;
|
|
32
86
|
const root = document.content as DocumentRootNode;
|
|
33
|
-
if (!root || root.type !== "doc") return { document, scopeId };
|
|
34
|
-
|
|
35
87
|
const normalizedFrom = Math.min(from, to);
|
|
36
88
|
const normalizedTo = Math.max(from, to);
|
|
37
89
|
|
|
90
|
+
if (!root || root.type !== "doc" || root.children.length === 0) {
|
|
91
|
+
return {
|
|
92
|
+
status: "empty-document",
|
|
93
|
+
scopeId,
|
|
94
|
+
from: normalizedFrom,
|
|
95
|
+
to: normalizedTo,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// First pass — locate which block each of `from` and `to` resolves to,
|
|
100
|
+
// so we can distinguish cross-paragraph / non-paragraph / out-of-bounds
|
|
101
|
+
// failures before attempting the plant.
|
|
38
102
|
let cursor = 0;
|
|
39
|
-
let
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
103
|
+
let fromBlockIndex = -1;
|
|
104
|
+
let fromBlockKind: string | null = null;
|
|
105
|
+
let toBlockIndex = -1;
|
|
106
|
+
let toBlockKind: string | null = null;
|
|
107
|
+
let storyLength = 0;
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < root.children.length; i += 1) {
|
|
110
|
+
const block = root.children[i]!;
|
|
111
|
+
const blockFrom = cursor;
|
|
112
|
+
let blockLength: number;
|
|
113
|
+
if (block.type === "paragraph") {
|
|
114
|
+
blockLength = block.children.reduce(
|
|
115
|
+
(total, child) => total + inlineLength(child as InlineNode),
|
|
116
|
+
0,
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
blockLength = 1;
|
|
120
|
+
}
|
|
121
|
+
const blockTo = blockFrom + blockLength;
|
|
122
|
+
if (fromBlockIndex === -1 && normalizedFrom >= blockFrom && normalizedFrom <= blockTo) {
|
|
123
|
+
fromBlockIndex = i;
|
|
124
|
+
fromBlockKind = block.type;
|
|
46
125
|
}
|
|
126
|
+
if (toBlockIndex === -1 && normalizedTo >= blockFrom && normalizedTo <= blockTo) {
|
|
127
|
+
toBlockIndex = i;
|
|
128
|
+
toBlockKind = block.type;
|
|
129
|
+
}
|
|
130
|
+
cursor = blockTo;
|
|
131
|
+
if (i < root.children.length - 1) cursor += 1;
|
|
132
|
+
storyLength = cursor;
|
|
133
|
+
}
|
|
47
134
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
135
|
+
if (normalizedFrom < 0 || normalizedTo > storyLength) {
|
|
136
|
+
return {
|
|
137
|
+
status: "range-out-of-bounds",
|
|
138
|
+
scopeId,
|
|
139
|
+
from: normalizedFrom,
|
|
140
|
+
to: normalizedTo,
|
|
141
|
+
storyLength,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (fromBlockIndex === -1 || toBlockIndex === -1) {
|
|
145
|
+
return {
|
|
146
|
+
status: "range-out-of-bounds",
|
|
147
|
+
scopeId,
|
|
148
|
+
from: normalizedFrom,
|
|
149
|
+
to: normalizedTo,
|
|
150
|
+
storyLength,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (fromBlockIndex !== toBlockIndex) {
|
|
154
|
+
return {
|
|
155
|
+
status: "cross-paragraph-range",
|
|
156
|
+
scopeId,
|
|
157
|
+
from: normalizedFrom,
|
|
158
|
+
to: normalizedTo,
|
|
159
|
+
fromBlockIndex,
|
|
160
|
+
toBlockIndex,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (fromBlockKind !== "paragraph") {
|
|
164
|
+
return {
|
|
165
|
+
status: "non-paragraph-target",
|
|
166
|
+
scopeId,
|
|
167
|
+
from: normalizedFrom,
|
|
168
|
+
to: normalizedTo,
|
|
169
|
+
blockIndex: fromBlockIndex,
|
|
170
|
+
blockKind: fromBlockKind ?? "unknown",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
55
173
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
174
|
+
// Plant — we've validated the range lands inside a single paragraph.
|
|
175
|
+
cursor = 0;
|
|
176
|
+
const children = root.children.map((block, blockIndex) => {
|
|
177
|
+
if (blockIndex !== fromBlockIndex) {
|
|
178
|
+
if (block.type === "paragraph") {
|
|
179
|
+
const len = block.children.reduce(
|
|
180
|
+
(total, child) => total + inlineLength(child as InlineNode),
|
|
181
|
+
0,
|
|
182
|
+
);
|
|
183
|
+
cursor += len;
|
|
184
|
+
} else {
|
|
185
|
+
cursor += 1;
|
|
186
|
+
}
|
|
187
|
+
if (blockIndex < root.children.length - 1) cursor += 1;
|
|
61
188
|
return block;
|
|
62
189
|
}
|
|
63
|
-
|
|
64
|
-
|
|
190
|
+
const paragraphFrom = cursor;
|
|
191
|
+
const paragraph = block as ParagraphNode;
|
|
65
192
|
const startOffset = normalizedFrom - paragraphFrom;
|
|
66
193
|
const endOffset = normalizedTo - paragraphFrom;
|
|
67
194
|
const newChildren = injectMarkersIntoInlineList(
|
|
68
|
-
|
|
195
|
+
paragraph.children as InlineNode[],
|
|
69
196
|
scopeId,
|
|
70
197
|
startOffset,
|
|
71
198
|
endOffset,
|
|
72
199
|
);
|
|
73
|
-
return { ...
|
|
200
|
+
return { ...paragraph, children: newChildren };
|
|
74
201
|
});
|
|
75
202
|
|
|
76
|
-
if (!inserted) return { document, scopeId };
|
|
77
|
-
|
|
78
203
|
return {
|
|
204
|
+
status: "planted",
|
|
79
205
|
document: {
|
|
80
206
|
...document,
|
|
81
207
|
content: { ...root, children },
|
|
82
208
|
},
|
|
83
209
|
scopeId,
|
|
210
|
+
plantedRange: { from: normalizedFrom, to: normalizedTo },
|
|
84
211
|
};
|
|
85
212
|
}
|
|
86
213
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* preserved in the canonical node's rawXml field for lossless round-trip export.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type { ShapeContent } from "../../model/canonical-document.ts";
|
|
13
|
+
import type { BlockNode, ShapeContent } from "../../model/canonical-document.ts";
|
|
14
14
|
import { parseFill } from "./parse-fill.ts";
|
|
15
15
|
import {
|
|
16
16
|
type XmlElementNode,
|
|
@@ -40,7 +40,7 @@ export interface ParsedWpsShape {
|
|
|
40
40
|
* shape-textbox paragraphs). Same shape + semantics as
|
|
41
41
|
* `ShapeContent.txbxBlocks` on the drawing-frame path.
|
|
42
42
|
*/
|
|
43
|
-
txbxBlocks?: ReadonlyArray<
|
|
43
|
+
txbxBlocks?: ReadonlyArray<BlockNode>;
|
|
44
44
|
/** DrawML geometry preset, e.g. "rect", "roundRect". */
|
|
45
45
|
geometry?: string;
|
|
46
46
|
/** Original drawing XML for lossless round-trip export. */
|
|
@@ -122,10 +122,20 @@ export function parseShapeXml(
|
|
|
122
122
|
// content (CCEP "Copyright CCEP STRICTLY CONFIDENTIAL" footer band)
|
|
123
123
|
// is reachable only via the `.text` summary string — L03 cascade +
|
|
124
124
|
// L11 render can't walk runs/marks.
|
|
125
|
-
let txbxBlocks: ReadonlyArray<
|
|
125
|
+
let txbxBlocks: ReadonlyArray<BlockNode> | undefined;
|
|
126
126
|
if (txbxContentXml && blockParser) {
|
|
127
127
|
try {
|
|
128
|
-
|
|
128
|
+
// The `blockParser` callback is supplied by parse-main-document.ts
|
|
129
|
+
// as a thin wrapper over `parseBlockStreamFromXml`. That function
|
|
130
|
+
// returns `ParsedBlockNode[]` — structurally identical to canonical
|
|
131
|
+
// `BlockNode[]` at runtime for shape-textbox content (verified on
|
|
132
|
+
// CCEP SOW footer fixture 2026-04-24: paragraph + text + TextMark
|
|
133
|
+
// shapes land end-to-end with zero `ParsedBlockNode`-only fields
|
|
134
|
+
// surfaced). The cast is safe here because the runtime output IS
|
|
135
|
+
// canonical; a structural `as unknown as BlockNode[]` preserves
|
|
136
|
+
// type safety at every consumer site (L03 cascade, L11 render,
|
|
137
|
+
// validator walk).
|
|
138
|
+
txbxBlocks = blockParser(txbxContentXml) as unknown as ReadonlyArray<BlockNode>;
|
|
129
139
|
} catch {
|
|
130
140
|
txbxBlocks = undefined;
|
|
131
141
|
}
|
|
@@ -214,6 +224,17 @@ function extractAllText(node: XmlElementNode): string {
|
|
|
214
224
|
// txbxContentXml, optional recursive txbxBlocks).
|
|
215
225
|
// ───────────────────────────────────────────────────────────────────────────
|
|
216
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Callback signature for the txbx-content block parser supplied by
|
|
229
|
+
* parse-main-document.ts / parse-headers-footers.ts. The actual
|
|
230
|
+
* implementation wraps `parseBlockStreamFromXml` which returns
|
|
231
|
+
* `ParsedBlockNode[]`; its runtime output is canonical `BlockNode[]`
|
|
232
|
+
* for shape-textbox content (no `ParsedBlockNode`-only fields surface
|
|
233
|
+
* at the shape boundary — verified on CCEP SOW footer fixture
|
|
234
|
+
* 2026-04-24). The structural `unknown` return keeps the parse layer
|
|
235
|
+
* layer-pure; `parseShapeContent` + `parseShapeXml` cast to canonical
|
|
236
|
+
* `BlockNode[]` at the assembly seam.
|
|
237
|
+
*/
|
|
217
238
|
export type TxbxBlockParser = (xml: string) => ReadonlyArray<{ type: string; [key: string]: unknown }>;
|
|
218
239
|
|
|
219
240
|
export function parseShapeContent(
|
|
@@ -240,10 +261,15 @@ export function parseShapeContent(
|
|
|
240
261
|
const txbxContent = txbx ? findFirstDescendant(txbx, "txbxContent") : undefined;
|
|
241
262
|
const txbxContentXml = txbxContent ? extractRawXml(txbxContent) : undefined;
|
|
242
263
|
|
|
243
|
-
let txbxBlocks: ReadonlyArray<
|
|
264
|
+
let txbxBlocks: ReadonlyArray<BlockNode> | undefined;
|
|
244
265
|
if (txbxContentXml && blockParser) {
|
|
245
266
|
try {
|
|
246
|
-
|
|
267
|
+
// See `TxbxBlockParser` doc above: runtime output is canonical
|
|
268
|
+
// `BlockNode[]` for shape-textbox content (verified on CCEP SOW
|
|
269
|
+
// footer fixture 2026-04-24). Cast at the assembly seam so
|
|
270
|
+
// downstream consumers (L03, L11, validator) get canonical types
|
|
271
|
+
// without local `as unknown` ceremony.
|
|
272
|
+
txbxBlocks = blockParser(txbxContentXml) as unknown as ReadonlyArray<BlockNode>;
|
|
247
273
|
} catch {
|
|
248
274
|
// Preserve-only fallback: keep txbxContentXml for serialization; leave
|
|
249
275
|
// txbxBlocks undefined so consumers know recursion did not succeed.
|
|
@@ -1786,12 +1786,29 @@ export interface SmartArtPreviewNode {
|
|
|
1786
1786
|
/**
|
|
1787
1787
|
* Read-only rendering of a wps:wsp WordprocessingShape. Text content is
|
|
1788
1788
|
* extracted for display. The original drawing XML is preserved in rawXml.
|
|
1789
|
+
*
|
|
1790
|
+
* When the shape is a text-box (`isTextBox: true`), the raw textbox XML
|
|
1791
|
+
* is preserved in `txbxContentXml` for lossless round-trip, and the
|
|
1792
|
+
* parsed block structure lands in `txbxBlocks` — canonical `BlockNode[]`
|
|
1793
|
+
* with styles already resolved (coord-02 §14 / coord-11 §22 closed L01
|
|
1794
|
+
* side 2026-04-24 in `7d87f1189`; L02 type-promoted 2026-04-24 once the
|
|
1795
|
+
* runtime contract was confirmed canonical).
|
|
1789
1796
|
*/
|
|
1790
1797
|
export interface ShapeNode {
|
|
1791
1798
|
type: "shape";
|
|
1792
1799
|
text?: string;
|
|
1793
1800
|
geometry?: string;
|
|
1794
1801
|
isTextBox?: boolean;
|
|
1802
|
+
/** Raw `<w:txbxContent>` XML, preserved for serialization + round-trip. */
|
|
1803
|
+
txbxContentXml?: string;
|
|
1804
|
+
/**
|
|
1805
|
+
* Parsed canonical block-level structure from `<w:txbxContent>`,
|
|
1806
|
+
* populated when the parse path supplies a `blockParser` callback
|
|
1807
|
+
* (headers/footers via `src/io/ooxml/parse-headers-footers.ts`;
|
|
1808
|
+
* body via `src/io/ooxml/parse-main-document.ts`). Shape + semantics
|
|
1809
|
+
* identical to `ShapeContent.txbxBlocks` on the drawing-frame path.
|
|
1810
|
+
*/
|
|
1811
|
+
txbxBlocks?: ReadonlyArray<BlockNode>;
|
|
1795
1812
|
rawXml: string;
|
|
1796
1813
|
}
|
|
1797
1814
|
|
|
@@ -1971,14 +1988,16 @@ export interface ShapeContent {
|
|
|
1971
1988
|
* Parsed block-level structure from `w:txbxContent`, populated when a
|
|
1972
1989
|
* `blockParser` callback is supplied during parse (CO4 F3.3).
|
|
1973
1990
|
*
|
|
1974
|
-
*
|
|
1975
|
-
*
|
|
1976
|
-
*
|
|
1977
|
-
*
|
|
1978
|
-
*
|
|
1979
|
-
*
|
|
1991
|
+
* Canonical `BlockNode[]` — the parse path produces fully-normalized
|
|
1992
|
+
* blocks (styles resolved, marks attached, no `ParsedBlockNode`-only
|
|
1993
|
+
* fields at runtime). Verified on the CCEP SOW footer fixture 2026-04-24:
|
|
1994
|
+
* paragraph + text + `TextMark` shapes land end-to-end. Type promoted
|
|
1995
|
+
* 2026-04-24 from the earlier weakly-typed escape hatch once the L01
|
|
1996
|
+
* shape-textbox parse (commit `7d87f1189`) confirmed the runtime
|
|
1997
|
+
* contract — unblocks L03 cascade + L11 render walking `txbxBlocks`
|
|
1998
|
+
* without `as unknown as BlockNode[]` casts at the consumer site.
|
|
1980
1999
|
*/
|
|
1981
|
-
txbxBlocks?: ReadonlyArray<
|
|
2000
|
+
txbxBlocks?: ReadonlyArray<BlockNode>;
|
|
1982
2001
|
rawXml: string;
|
|
1983
2002
|
}
|
|
1984
2003
|
|
|
@@ -2860,11 +2879,29 @@ function validateDocumentNode(
|
|
|
2860
2879
|
return;
|
|
2861
2880
|
case "chart_preview":
|
|
2862
2881
|
case "smartart_preview":
|
|
2863
|
-
case "shape":
|
|
2864
2882
|
case "wordart":
|
|
2865
2883
|
case "vml_shape":
|
|
2866
2884
|
expectString(record.rawXml, `${path}.rawXml`, issues);
|
|
2867
2885
|
return;
|
|
2886
|
+
case "shape":
|
|
2887
|
+
expectString(record.rawXml, `${path}.rawXml`, issues);
|
|
2888
|
+
if (record.txbxBlocks !== undefined) {
|
|
2889
|
+
if (!Array.isArray(record.txbxBlocks)) {
|
|
2890
|
+
issues.push({
|
|
2891
|
+
path: `${path}.txbxBlocks`,
|
|
2892
|
+
message: "shape.txbxBlocks must be an array when present.",
|
|
2893
|
+
});
|
|
2894
|
+
} else {
|
|
2895
|
+
// coord-02 §14 follow-up (2026-04-24): `ShapeNode.txbxBlocks`
|
|
2896
|
+
// is canonical `BlockNode[]`. Walk it with the same validator
|
|
2897
|
+
// used for top-level document content so run marks / paragraph
|
|
2898
|
+
// structure / nested shapes all enforce the normal rules.
|
|
2899
|
+
record.txbxBlocks.forEach((child, index) => {
|
|
2900
|
+
validateDocumentNode(child, `${path}.txbxBlocks[${index}]`, issues);
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
return;
|
|
2868
2905
|
case "drawing_frame": {
|
|
2869
2906
|
const anchor = asPlainObject(record.anchor, `${path}.anchor`, issues);
|
|
2870
2907
|
const content = asPlainObject(record.content, `${path}.content`, issues);
|
|
@@ -57,6 +57,7 @@ import type {
|
|
|
57
57
|
RestoreResult,
|
|
58
58
|
ReviewWorkSnapshot,
|
|
59
59
|
RuntimeContextAnalyticsQuery,
|
|
60
|
+
EditorSurfaceSnapshot,
|
|
60
61
|
RuntimeContextAnalyticsSnapshot,
|
|
61
62
|
RuntimeRenderSnapshot,
|
|
62
63
|
ScopeTagTouch,
|
|
@@ -1561,17 +1562,91 @@ export function createDocumentRuntime(
|
|
|
1561
1562
|
});
|
|
1562
1563
|
recordPerfSample("snapshot.surface");
|
|
1563
1564
|
incrementInvalidationCounter("runtime.snapshot.surfaceMisses");
|
|
1565
|
+
// Viewport-cull flicker fix — enrich `placeholder-culled` opaque
|
|
1566
|
+
// blocks with the block's known rendered height (twips) so the PM
|
|
1567
|
+
// placeholder emits a fixed-height `<div>` sized to match what the
|
|
1568
|
+
// realized block will occupy once it re-enters the viewport. Without
|
|
1569
|
+
// this, culled stubs render at `min-height: 20px` regardless of their
|
|
1570
|
+
// real visual size; scrolling into a culled region inflates every
|
|
1571
|
+
// block to its real height in succession and drags content below
|
|
1572
|
+
// the scroll pointer (the "paragraphs jump around pagination gaps"
|
|
1573
|
+
// flicker).
|
|
1574
|
+
//
|
|
1575
|
+
// The heights come from L04's page graph, which is already computed
|
|
1576
|
+
// on a FULLY-REALIZED surface inside `layout-engine-instance.ts` —
|
|
1577
|
+
// i.e. pagination is independent of the viewport cull, so every
|
|
1578
|
+
// blockId has an authoritative height regardless of whether its
|
|
1579
|
+
// surface block is real or a placeholder. `getBlockHeightsTwips()`
|
|
1580
|
+
// sums per-block fragments and caches per `graph.revision`.
|
|
1581
|
+
//
|
|
1582
|
+
// No-op on pre-pagination calls (engine not yet ready → empty map)
|
|
1583
|
+
// and on the inert facet.
|
|
1584
|
+
const enrichedSnapshot = enrichCulledPlaceholdersWithHeights(snapshot);
|
|
1564
1585
|
cachedSurface = {
|
|
1565
1586
|
revisionToken: state.revisionToken,
|
|
1566
1587
|
activeStoryKey,
|
|
1567
1588
|
viewportRangesKey,
|
|
1568
|
-
snapshot,
|
|
1589
|
+
snapshot: enrichedSnapshot,
|
|
1569
1590
|
};
|
|
1570
1591
|
// Keep the scroll-path fingerprint in lockstep so a subsequent
|
|
1571
1592
|
// `maybeRefreshSurfaceForViewport` sees the freshly-built snapshot
|
|
1572
1593
|
// and short-circuits instead of paying a redundant projection.
|
|
1573
1594
|
cachedSurfaceFingerprint = `${state.revisionToken}|${activeStoryKey}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
|
|
1574
|
-
return
|
|
1595
|
+
return enrichedSnapshot;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function enrichCulledPlaceholdersWithHeights(
|
|
1599
|
+
snapshot: EditorSurfaceSnapshot,
|
|
1600
|
+
): EditorSurfaceSnapshot {
|
|
1601
|
+
let heights: ReadonlyMap<string, number>;
|
|
1602
|
+
try {
|
|
1603
|
+
heights = layoutFacet.getBlockHeightsTwips();
|
|
1604
|
+
} catch {
|
|
1605
|
+
return snapshot;
|
|
1606
|
+
}
|
|
1607
|
+
if (heights.size === 0) return snapshot;
|
|
1608
|
+
let changed = false;
|
|
1609
|
+
const enrichedBlocks = snapshot.blocks.map((block) => {
|
|
1610
|
+
if (
|
|
1611
|
+
block.kind !== "opaque_block" ||
|
|
1612
|
+
block.state !== "placeholder-culled"
|
|
1613
|
+
) {
|
|
1614
|
+
return block;
|
|
1615
|
+
}
|
|
1616
|
+
// The culled placeholder's blockId is `placeholder-culled-<index>`;
|
|
1617
|
+
// the underlying ParagraphNode kept its own id which is NOT carried
|
|
1618
|
+
// on the placeholder. Instead, identify the real block by its
|
|
1619
|
+
// `from` offset — surface-projection guarantees `from` on the
|
|
1620
|
+
// placeholder mirrors the real block's `from`, and fragment records
|
|
1621
|
+
// on the page graph record runtime offsets.
|
|
1622
|
+
const realBlockIdFromOffset = resolveBlockIdFromRuntimeOffset(
|
|
1623
|
+
layoutFacet,
|
|
1624
|
+
block.from,
|
|
1625
|
+
);
|
|
1626
|
+
if (!realBlockIdFromOffset) return block;
|
|
1627
|
+
const heightTwips = heights.get(realBlockIdFromOffset);
|
|
1628
|
+
if (typeof heightTwips !== "number" || heightTwips <= 0) return block;
|
|
1629
|
+
changed = true;
|
|
1630
|
+
return { ...block, placeholderHeightTwips: heightTwips };
|
|
1631
|
+
});
|
|
1632
|
+
if (!changed) return snapshot;
|
|
1633
|
+
return { ...snapshot, blocks: enrichedBlocks };
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
function resolveBlockIdFromRuntimeOffset(
|
|
1637
|
+
facet: WordReviewEditorLayoutFacet,
|
|
1638
|
+
runtimeOffset: number,
|
|
1639
|
+
): string | null {
|
|
1640
|
+
// `getFragmentForOffset` exists on the facet; use it to recover the
|
|
1641
|
+
// real blockId for a given offset without having to walk all
|
|
1642
|
+
// fragments. Returns the fragment whose `[from, to)` contains the
|
|
1643
|
+
// offset, or null.
|
|
1644
|
+
try {
|
|
1645
|
+
const frag = facet.getFragmentForOffset?.(runtimeOffset);
|
|
1646
|
+
return frag?.blockId ?? null;
|
|
1647
|
+
} catch {
|
|
1648
|
+
return null;
|
|
1649
|
+
}
|
|
1575
1650
|
}
|
|
1576
1651
|
|
|
1577
1652
|
function getCachedFieldSnapshot(document: CanonicalDocumentEnvelope): FieldSnapshot {
|
|
@@ -65,6 +65,7 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
|
|
|
65
65
|
invalidateMeasurementCache: () => undefined,
|
|
66
66
|
getTableRenderPlan: () => null,
|
|
67
67
|
getTableBodyYOffsetOnPage: () => null,
|
|
68
|
+
getBlockHeightsTwips: () => new Map(),
|
|
68
69
|
getDirtyFieldFamilies: () => [],
|
|
69
70
|
getFieldDirtinessReport: () => emptyReport,
|
|
70
71
|
setVisibleBlockRange: () => undefined,
|
|
@@ -955,8 +955,34 @@
|
|
|
955
955
|
* underlying layout algorithm is unchanged — persisted envelopes
|
|
956
956
|
* remain shape-compatible. Bump is defensive so any consumer that
|
|
957
957
|
* keyed on the facet contract refreshes the cache.
|
|
958
|
+
*
|
|
959
|
+
* 57 — viewport-cull flicker fix. The pre-v57 PM placeholder emitted
|
|
960
|
+
* for `placeholder-culled` opaque blocks rendered at
|
|
961
|
+
* `min-height: 20px` regardless of the real block's visual height
|
|
962
|
+
* (`src/ui-tailwind/editor-surface/pm-schema.ts`), because neither
|
|
963
|
+
* L03 surface-projection nor L11 PM state-build had access to
|
|
964
|
+
* layout-derived heights. Scrolling into a culled region inflated
|
|
965
|
+
* every block in turn, dragging content below the scroll pointer —
|
|
966
|
+
* the long-standing "paragraphs jump around pagination gaps"
|
|
967
|
+
* flicker.
|
|
968
|
+
*
|
|
969
|
+
* L04 now exposes `WordReviewEditorLayoutFacet.getBlockHeightsTwips():
|
|
970
|
+
* ReadonlyMap<string, number>` — one entry per blockId, value = sum
|
|
971
|
+
* of that block's fragments' `heightTwips`. Cached per
|
|
972
|
+
* `graph.revision`. `DocumentRuntime` reads the map after each
|
|
973
|
+
* surface-projection pass and enriches every
|
|
974
|
+
* `placeholder-culled` `opaque_block` with
|
|
975
|
+
* `placeholderHeightTwips`. The PM schema's paragraph node gained
|
|
976
|
+
* a `placeholderHeightTwips` attr; `toDOM` emits
|
|
977
|
+
* `height: ${twips/20}pt` instead of the `min-height: 20px`
|
|
978
|
+
* fallback when the attr is set.
|
|
979
|
+
*
|
|
980
|
+
* Pagination itself is untouched — this is purely a render-surface
|
|
981
|
+
* fix. Cache envelopes from v56 invalidate because the exposed
|
|
982
|
+
* facet surface grew one public method; any consumer relying on
|
|
983
|
+
* the prior interface shape re-derives its cache key under v57.
|
|
958
984
|
*/
|
|
959
|
-
export const LAYOUT_ENGINE_VERSION =
|
|
985
|
+
export const LAYOUT_ENGINE_VERSION = 57 as const;
|
|
960
986
|
|
|
961
987
|
/**
|
|
962
988
|
* Serialization schema version for the LayCache payload (the cache envelope
|