@beyondwork/docx-react-component 1.0.78 → 1.0.80
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 +60 -1
- package/src/api/v3/ai/resolve.ts +13 -7
- package/src/api/v3/runtime/workflow.ts +12 -2
- package/src/core/commands/add-scope.ts +222 -69
- package/src/runtime/document-runtime.ts +77 -2
- package/src/runtime/formatting/formatting-types.ts +16 -0
- package/src/runtime/formatting/revision-display.ts +16 -10
- 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/scopes/compile-scope-bundle.ts +9 -1
- package/src/runtime/scopes/compile-scope.ts +16 -0
- package/src/runtime/scopes/enumerate-scopes.ts +116 -3
- package/src/runtime/scopes/replaceability.ts +16 -0
- package/src/runtime/scopes/replacement/apply.ts +13 -3
- package/src/runtime/scopes/resolve-reference.ts +5 -0
- package/src/runtime/scopes/scope-kinds/scope.ts +87 -0
- package/src/runtime/scopes/scope-range.ts +11 -0
- package/src/runtime/workflow/coordinator.ts +60 -10
- package/src/runtime/workflow/scope-writer.ts +69 -2
- package/src/ui/headless/revision-decoration-model.ts +10 -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/theme/editor-theme.css +10 -1
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.80",
|
|
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
|
@@ -1211,6 +1211,18 @@ export type SurfaceInlineSegment =
|
|
|
1211
1211
|
* is enforced by `test/runtime/formatting/production-boundary.test.ts`.
|
|
1212
1212
|
*/
|
|
1213
1213
|
revisionDisplay?: {
|
|
1214
|
+
/**
|
|
1215
|
+
* Identity of the attached revision. Render consumers emit
|
|
1216
|
+
* `data-revision-id` and route sidebar scroll-to-revision off
|
|
1217
|
+
* this field rather than re-deriving from the review store.
|
|
1218
|
+
*/
|
|
1219
|
+
revisionId: string;
|
|
1220
|
+
/**
|
|
1221
|
+
* The revision's kind. Mirrors `RevisionRecord.kind` on the
|
|
1222
|
+
* canonical document. Render consumers branch on this (e.g.
|
|
1223
|
+
* widget-bracket placement in suggestions mode).
|
|
1224
|
+
*/
|
|
1225
|
+
kind: "insertion" | "deletion" | "formatting" | "move" | "property-change";
|
|
1214
1226
|
markupMode: "clean" | "simple" | "all";
|
|
1215
1227
|
hidden?: boolean;
|
|
1216
1228
|
strikethrough?: boolean;
|
|
@@ -1566,6 +1578,22 @@ export type SurfaceBlockSnapshot =
|
|
|
1566
1578
|
* See CLAUDE.md (lane status table)
|
|
1567
1579
|
*/
|
|
1568
1580
|
placeholderSize?: number;
|
|
1581
|
+
/**
|
|
1582
|
+
* Visual height of the block the placeholder stands in for, in twips,
|
|
1583
|
+
* as computed by the L04 paginator against the fully-realized surface.
|
|
1584
|
+
* When present, the PM placeholder emits a fixed-height `<div>` so the
|
|
1585
|
+
* scrollable canvas matches what the realized block will occupy once
|
|
1586
|
+
* it re-enters the viewport — eliminating the "paragraphs jump around
|
|
1587
|
+
* pagination gaps" flicker that plagued culled regions rendered as
|
|
1588
|
+
* single-line ZWSP stubs.
|
|
1589
|
+
*
|
|
1590
|
+
* Populated by `DocumentRuntime.buildRenderSnapshot()` after each
|
|
1591
|
+
* pagination pass; surface-projection itself does not set it (L03 has
|
|
1592
|
+
* no layout data). Consumers that build a surface without a runtime
|
|
1593
|
+
* (tests, plain-text preview) safely leave it `undefined` and fall
|
|
1594
|
+
* back to the `min-height: 20px` behaviour.
|
|
1595
|
+
*/
|
|
1596
|
+
placeholderHeightTwips?: number;
|
|
1569
1597
|
state: "locked-preserve-only" | "placeholder-culled";
|
|
1570
1598
|
};
|
|
1571
1599
|
|
|
@@ -2487,9 +2515,40 @@ export interface AddScopeParams {
|
|
|
2487
2515
|
}
|
|
2488
2516
|
|
|
2489
2517
|
export interface AddScopeResult {
|
|
2518
|
+
/**
|
|
2519
|
+
* Minted scope id. Empty string (`""`) iff the marker plant failed —
|
|
2520
|
+
* in that case `anchor.kind === "detached"` + `plantStatus.planted`
|
|
2521
|
+
* is `false`. Pre-2026-04-24 a plant failure silently returned a
|
|
2522
|
+
* non-empty scopeId that later resolved to `scope-not-resolvable`;
|
|
2523
|
+
* callers must now check `scopeId.length > 0` (or equivalently
|
|
2524
|
+
* `plantStatus?.planted !== false`) before using the id.
|
|
2525
|
+
*/
|
|
2490
2526
|
scopeId: string;
|
|
2491
|
-
/** Range anchor derived from the just-inserted marker positions. */
|
|
2527
|
+
/** Range anchor derived from the just-inserted marker positions, or a detached placeholder on plant failure. */
|
|
2492
2528
|
anchor: EditorAnchorProjection;
|
|
2529
|
+
/**
|
|
2530
|
+
* Structured diagnostic for the marker-plant attempt. Omitted on the
|
|
2531
|
+
* happy path for back-compat; populated with `planted: false` + a
|
|
2532
|
+
* typed reason when the plant was refused (non-paragraph target,
|
|
2533
|
+
* out-of-bounds, empty-document). Cross-paragraph ranges plant
|
|
2534
|
+
* successfully as of 2026-04-24 — see the multi-paragraph-scopes
|
|
2535
|
+
* slice. Callers that previously assumed every returned `scopeId`
|
|
2536
|
+
* was live should migrate to checking this field.
|
|
2537
|
+
*/
|
|
2538
|
+
plantStatus?: {
|
|
2539
|
+
readonly planted: false;
|
|
2540
|
+
readonly reason:
|
|
2541
|
+
| "non-paragraph-target"
|
|
2542
|
+
| "range-out-of-bounds"
|
|
2543
|
+
| "empty-document";
|
|
2544
|
+
readonly requestedFrom: number;
|
|
2545
|
+
readonly requestedTo: number;
|
|
2546
|
+
/** Present on `non-paragraph-target`. */
|
|
2547
|
+
readonly blockIndex?: number;
|
|
2548
|
+
readonly blockKind?: string;
|
|
2549
|
+
/** Present on `range-out-of-bounds`. */
|
|
2550
|
+
readonly storyLength?: number;
|
|
2551
|
+
};
|
|
2493
2552
|
}
|
|
2494
2553
|
|
|
2495
2554
|
export interface ExportDocxOptions {
|
package/src/api/v3/ai/resolve.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @endStateApi v3 — `ai.resolve` family.
|
|
3
3
|
*
|
|
4
|
-
* Slice 3 of refactor/08
|
|
5
|
-
* `live-with-adapter
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* Slice 3 of refactor/08 graduated `resolveReference` to
|
|
5
|
+
* `live-with-adapter`. Post-KI-P9 (2026-04-24) the reference union
|
|
6
|
+
* carries **three durable reference kinds**: `scope-id`, `semantic-path`,
|
|
7
|
+
* and `natural-language`. Positional queries (`offset` / `range`) were
|
|
8
|
+
* split into `queryScopeAtPosition(at)` / `queryScopeInRange(from, to)`
|
|
9
|
+
* one-shot APIs that return `ScopeHandle | null` directly — the
|
|
10
|
+
* type-system split prevents a positional hint from being cached or
|
|
11
|
+
* round-tripped as a reference across a mutation.
|
|
12
|
+
*
|
|
13
|
+
* Offset / range lookups still honor precise nested-kind ranges (fields,
|
|
14
|
+
* table rows, table cells) via the scope-compiler's specificity
|
|
15
|
+
* tie-breaker — see `docs/architecture/08-semantic-scope-compiler.md`
|
|
16
|
+
* §"Resolve-reference precision" for the specificity table.
|
|
11
17
|
*
|
|
12
18
|
* The `natural-language` hint remains **partial by design**. The live
|
|
13
19
|
* path is a deterministic substring matcher over:
|
|
@@ -160,13 +160,17 @@ 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
|
+
| "non-paragraph-target"
|
|
165
|
+
| "empty-document";
|
|
164
166
|
readonly from: number;
|
|
165
167
|
readonly to: number;
|
|
166
168
|
readonly storyLength: number;
|
|
169
|
+
readonly blockIndex?: number;
|
|
170
|
+
readonly blockKind?: string;
|
|
167
171
|
/** Agent-actionable single-sentence explanation. Safe to surface to LLM tool replies as-is. */
|
|
168
172
|
readonly message: string;
|
|
169
|
-
/** Machine-routable next-step hint
|
|
173
|
+
/** Machine-routable next-step hint. */
|
|
170
174
|
readonly nextStep: string;
|
|
171
175
|
};
|
|
172
176
|
|
|
@@ -533,6 +537,12 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
|
|
|
533
537
|
from: adapterResult.from,
|
|
534
538
|
to: adapterResult.to,
|
|
535
539
|
storyLength: adapterResult.storyLength,
|
|
540
|
+
...(adapterResult.blockIndex !== undefined
|
|
541
|
+
? { blockIndex: adapterResult.blockIndex }
|
|
542
|
+
: {}),
|
|
543
|
+
...(adapterResult.blockKind !== undefined
|
|
544
|
+
? { blockKind: adapterResult.blockKind }
|
|
545
|
+
: {}),
|
|
536
546
|
message: adapterResult.message,
|
|
537
547
|
nextStep: adapterResult.nextStep,
|
|
538
548
|
};
|
|
@@ -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
|
+
* Cross-paragraph ranges are first-class (planted) as of the
|
|
21
|
+
* 2026-04-24 multi-paragraph-scopes slice. The `cross-paragraph-range`
|
|
22
|
+
* failure variant no longer exists — markers now span paragraphs.
|
|
23
|
+
*/
|
|
24
|
+
export type InsertScopeMarkersResult =
|
|
25
|
+
| {
|
|
26
|
+
readonly status: "planted";
|
|
27
|
+
readonly document: CanonicalDocumentEnvelope;
|
|
28
|
+
readonly scopeId: string;
|
|
29
|
+
/** Absolute span that got planted (after from/to normalization). */
|
|
30
|
+
readonly plantedRange: { readonly from: number; readonly to: number };
|
|
31
|
+
}
|
|
32
|
+
| {
|
|
33
|
+
readonly status: "non-paragraph-target";
|
|
34
|
+
readonly scopeId: string;
|
|
35
|
+
readonly from: number;
|
|
36
|
+
readonly to: number;
|
|
37
|
+
readonly blockIndex: number;
|
|
38
|
+
readonly blockKind: string;
|
|
39
|
+
}
|
|
40
|
+
| {
|
|
41
|
+
readonly status: "range-out-of-bounds";
|
|
42
|
+
readonly scopeId: string;
|
|
43
|
+
readonly from: number;
|
|
44
|
+
readonly to: number;
|
|
45
|
+
readonly storyLength: number;
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
readonly status: "empty-document";
|
|
49
|
+
readonly scopeId: string;
|
|
50
|
+
readonly from: number;
|
|
51
|
+
readonly to: number;
|
|
52
|
+
};
|
|
14
53
|
|
|
15
54
|
/**
|
|
16
|
-
* Pure helper — returns a
|
|
17
|
-
*
|
|
55
|
+
* Pure helper — returns a discriminated result describing whether a
|
|
56
|
+
* pair of `scope_marker_start` / `scope_marker_end` inline nodes got
|
|
57
|
+
* inserted at `[from, to]`.
|
|
58
|
+
*
|
|
59
|
+
* Same-paragraph ranges insert both markers into the single owning
|
|
60
|
+
* paragraph. Cross-paragraph ranges place the start marker inside the
|
|
61
|
+
* paragraph containing `from` and the end marker inside the paragraph
|
|
62
|
+
* containing `to`; intermediate top-level blocks (paragraphs, tables,
|
|
63
|
+
* section breaks) are passed through unmodified and fall inside the
|
|
64
|
+
* scope by document order.
|
|
65
|
+
*
|
|
66
|
+
* Failure modes (all previously silent — pre-2026-04-24):
|
|
67
|
+
* - `non-paragraph-target` — either `from` or `to` lands inside a
|
|
68
|
+
* non-paragraph block (table / SDT / section_break). Marker
|
|
69
|
+
* scopes only plant inside paragraphs.
|
|
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,
|
|
@@ -28,59 +82,166 @@ export function insertScopeMarkers(
|
|
|
28
82
|
to: number;
|
|
29
83
|
},
|
|
30
84
|
): InsertScopeMarkersResult {
|
|
31
|
-
const { scopeId
|
|
85
|
+
const { scopeId } = params;
|
|
32
86
|
const root = document.content as DocumentRootNode;
|
|
33
|
-
|
|
87
|
+
const normalizedFrom = Math.min(params.from, params.to);
|
|
88
|
+
const normalizedTo = Math.max(params.from, params.to);
|
|
34
89
|
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
}
|
|
37
98
|
|
|
99
|
+
// Pre-pass — locate which block each of `from` and `to` resolves to,
|
|
100
|
+
// compute story length, and build per-paragraph slot envelopes.
|
|
101
|
+
// **Invariant:** the cursor arithmetic here MUST match
|
|
102
|
+
// `src/runtime/scopes/position-map.ts::computeBlockPositions`
|
|
103
|
+
// (same `inlineLength`, same `+1` per-block boundary). Layer 04
|
|
104
|
+
// cannot change one walker without the other or cross-paragraph
|
|
105
|
+
// marker insertion silently drifts vs. enumerated scope ranges.
|
|
106
|
+
type ParaSlot = { index: number; from: number; to: number };
|
|
107
|
+
const paraSlots: ParaSlot[] = [];
|
|
38
108
|
let cursor = 0;
|
|
39
|
-
let
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (blockIndex < root.children.length - 1) cursor += 1;
|
|
45
|
-
return block;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const paragraphFrom = cursor;
|
|
49
|
-
const paragraphLength = block.children.reduce(
|
|
50
|
-
(total, child) => total + inlineLength(child as InlineNode),
|
|
51
|
-
0,
|
|
52
|
-
);
|
|
53
|
-
const paragraphTo = paragraphFrom + paragraphLength;
|
|
54
|
-
cursor = paragraphTo + 1;
|
|
109
|
+
let fromBlockIndex = -1;
|
|
110
|
+
let fromBlockKind: string | null = null;
|
|
111
|
+
let toBlockIndex = -1;
|
|
112
|
+
let toBlockKind: string | null = null;
|
|
113
|
+
let storyLength = 0;
|
|
55
114
|
|
|
115
|
+
for (let i = 0; i < root.children.length; i += 1) {
|
|
116
|
+
const block = root.children[i]!;
|
|
117
|
+
const blockFrom = cursor;
|
|
118
|
+
let blockLength: number;
|
|
119
|
+
if (block.type === "paragraph") {
|
|
120
|
+
blockLength = block.children.reduce(
|
|
121
|
+
(total, child) => total + inlineLength(child as InlineNode),
|
|
122
|
+
0,
|
|
123
|
+
);
|
|
124
|
+
paraSlots.push({ index: i, from: blockFrom, to: blockFrom + blockLength });
|
|
125
|
+
} else {
|
|
126
|
+
blockLength = 1;
|
|
127
|
+
}
|
|
128
|
+
const blockTo = blockFrom + blockLength;
|
|
56
129
|
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
normalizedFrom
|
|
130
|
+
fromBlockIndex === -1 &&
|
|
131
|
+
normalizedFrom >= blockFrom &&
|
|
132
|
+
normalizedFrom <= blockTo
|
|
60
133
|
) {
|
|
61
|
-
|
|
134
|
+
fromBlockIndex = i;
|
|
135
|
+
fromBlockKind = block.type;
|
|
62
136
|
}
|
|
137
|
+
if (
|
|
138
|
+
toBlockIndex === -1 &&
|
|
139
|
+
normalizedTo >= blockFrom &&
|
|
140
|
+
normalizedTo <= blockTo
|
|
141
|
+
) {
|
|
142
|
+
toBlockIndex = i;
|
|
143
|
+
toBlockKind = block.type;
|
|
144
|
+
}
|
|
145
|
+
cursor = blockTo;
|
|
146
|
+
if (i < root.children.length - 1) cursor += 1;
|
|
147
|
+
storyLength = cursor;
|
|
148
|
+
}
|
|
63
149
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
150
|
+
if (normalizedFrom < 0 || normalizedTo > storyLength) {
|
|
151
|
+
return {
|
|
152
|
+
status: "range-out-of-bounds",
|
|
153
|
+
scopeId,
|
|
154
|
+
from: normalizedFrom,
|
|
155
|
+
to: normalizedTo,
|
|
156
|
+
storyLength,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (fromBlockIndex === -1 || toBlockIndex === -1) {
|
|
160
|
+
return {
|
|
161
|
+
status: "range-out-of-bounds",
|
|
162
|
+
scopeId,
|
|
163
|
+
from: normalizedFrom,
|
|
164
|
+
to: normalizedTo,
|
|
165
|
+
storyLength,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (fromBlockKind !== "paragraph" || toBlockKind !== "paragraph") {
|
|
169
|
+
// Either endpoint lands on a non-paragraph top-level block
|
|
170
|
+
// (table / section_break / sdt). Report the first non-paragraph
|
|
171
|
+
// offender for diagnostic clarity.
|
|
172
|
+
const nonParaIndex =
|
|
173
|
+
fromBlockKind !== "paragraph" ? fromBlockIndex : toBlockIndex;
|
|
174
|
+
const nonParaKind =
|
|
175
|
+
fromBlockKind !== "paragraph" ? fromBlockKind : toBlockKind;
|
|
176
|
+
return {
|
|
177
|
+
status: "non-paragraph-target",
|
|
178
|
+
scopeId,
|
|
179
|
+
from: normalizedFrom,
|
|
180
|
+
to: normalizedTo,
|
|
181
|
+
blockIndex: nonParaIndex,
|
|
182
|
+
blockKind: nonParaKind ?? "unknown",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const startSlot = paraSlots.find((s) => s.index === fromBlockIndex)!;
|
|
187
|
+
const endSlot = paraSlots.find((s) => s.index === toBlockIndex)!;
|
|
188
|
+
|
|
189
|
+
// Same-paragraph fast path: inject both markers into one paragraph.
|
|
190
|
+
// Preserves all existing single-paragraph behaviour byte-for-byte
|
|
191
|
+
// (regression pin [C9]).
|
|
192
|
+
if (startSlot.index === endSlot.index) {
|
|
67
193
|
const newChildren = injectMarkersIntoInlineList(
|
|
68
|
-
|
|
194
|
+
(root.children[startSlot.index] as ParagraphNode).children as InlineNode[],
|
|
69
195
|
scopeId,
|
|
70
|
-
|
|
71
|
-
|
|
196
|
+
normalizedFrom - startSlot.from,
|
|
197
|
+
normalizedTo - startSlot.from,
|
|
198
|
+
"both",
|
|
72
199
|
);
|
|
73
|
-
|
|
74
|
-
|
|
200
|
+
const children = root.children.map((block, i) =>
|
|
201
|
+
i === startSlot.index
|
|
202
|
+
? ({ ...block, children: newChildren } as ParagraphNode)
|
|
203
|
+
: block,
|
|
204
|
+
);
|
|
205
|
+
return {
|
|
206
|
+
status: "planted",
|
|
207
|
+
document: { ...document, content: { ...root, children } },
|
|
208
|
+
scopeId,
|
|
209
|
+
plantedRange: { from: normalizedFrom, to: normalizedTo },
|
|
210
|
+
};
|
|
211
|
+
}
|
|
75
212
|
|
|
76
|
-
|
|
213
|
+
// Cross-paragraph path — start marker goes in the start-bearing
|
|
214
|
+
// paragraph; end marker goes in the end-bearing paragraph;
|
|
215
|
+
// intermediate blocks pass through unchanged.
|
|
216
|
+
const children = root.children.map((block, i) => {
|
|
217
|
+
if (i === startSlot.index) {
|
|
218
|
+
const newChildren = injectMarkersIntoInlineList(
|
|
219
|
+
(block as ParagraphNode).children as InlineNode[],
|
|
220
|
+
scopeId,
|
|
221
|
+
normalizedFrom - startSlot.from,
|
|
222
|
+
Number.POSITIVE_INFINITY,
|
|
223
|
+
"start-only",
|
|
224
|
+
);
|
|
225
|
+
return { ...block, children: newChildren } as ParagraphNode;
|
|
226
|
+
}
|
|
227
|
+
if (i === endSlot.index) {
|
|
228
|
+
const newChildren = injectMarkersIntoInlineList(
|
|
229
|
+
(block as ParagraphNode).children as InlineNode[],
|
|
230
|
+
scopeId,
|
|
231
|
+
Number.NEGATIVE_INFINITY,
|
|
232
|
+
normalizedTo - endSlot.from,
|
|
233
|
+
"end-only",
|
|
234
|
+
);
|
|
235
|
+
return { ...block, children: newChildren } as ParagraphNode;
|
|
236
|
+
}
|
|
237
|
+
return block;
|
|
238
|
+
});
|
|
77
239
|
|
|
78
240
|
return {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
content: { ...root, children },
|
|
82
|
-
},
|
|
241
|
+
status: "planted",
|
|
242
|
+
document: { ...document, content: { ...root, children } },
|
|
83
243
|
scopeId,
|
|
244
|
+
plantedRange: { from: normalizedFrom, to: normalizedTo },
|
|
84
245
|
};
|
|
85
246
|
}
|
|
86
247
|
|
|
@@ -146,28 +307,28 @@ function injectMarkersIntoInlineList(
|
|
|
146
307
|
scopeId: string,
|
|
147
308
|
startOffset: number,
|
|
148
309
|
endOffset: number,
|
|
310
|
+
mode: "both" | "start-only" | "end-only",
|
|
149
311
|
): InlineNode[] {
|
|
150
|
-
const start: ScopeMarkerStartNode = {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
type: "scope_marker_end",
|
|
156
|
-
scopeId,
|
|
157
|
-
};
|
|
312
|
+
const start: ScopeMarkerStartNode = { type: "scope_marker_start", scopeId };
|
|
313
|
+
const end: ScopeMarkerEndNode = { type: "scope_marker_end", scopeId };
|
|
314
|
+
|
|
315
|
+
const needStart = mode === "both" || mode === "start-only";
|
|
316
|
+
const needEnd = mode === "both" || mode === "end-only";
|
|
158
317
|
|
|
159
318
|
const output: InlineNode[] = [];
|
|
160
319
|
let cursor = 0;
|
|
161
|
-
let startEmitted =
|
|
162
|
-
let endEmitted =
|
|
320
|
+
let startEmitted = !needStart;
|
|
321
|
+
let endEmitted = !needEnd;
|
|
163
322
|
|
|
164
323
|
for (const node of inlines) {
|
|
165
324
|
const length = inlineLength(node);
|
|
166
325
|
const nodeStart = cursor;
|
|
167
326
|
const nodeEnd = cursor + length;
|
|
168
327
|
|
|
169
|
-
const startInside =
|
|
170
|
-
|
|
328
|
+
const startInside =
|
|
329
|
+
needStart && !startEmitted && startOffset >= nodeStart && startOffset <= nodeEnd;
|
|
330
|
+
const endInside =
|
|
331
|
+
needEnd && !endEmitted && endOffset >= nodeStart && endOffset <= nodeEnd;
|
|
171
332
|
|
|
172
333
|
if (!startInside && !endInside) {
|
|
173
334
|
output.push(node);
|
|
@@ -175,10 +336,7 @@ function injectMarkersIntoInlineList(
|
|
|
175
336
|
continue;
|
|
176
337
|
}
|
|
177
338
|
|
|
178
|
-
// Currently only text nodes support splitting for an internal cut.
|
|
179
339
|
if (node.type !== "text") {
|
|
180
|
-
// For non-text nodes, markers land at the node boundary closest to the
|
|
181
|
-
// target offset — avoids mid-atom splits which would corrupt the node.
|
|
182
340
|
if (startInside && !startEmitted && startOffset <= nodeStart) {
|
|
183
341
|
output.push(start);
|
|
184
342
|
startEmitted = true;
|
|
@@ -203,11 +361,7 @@ function injectMarkersIntoInlineList(
|
|
|
203
361
|
const text = node.text;
|
|
204
362
|
const chars = Array.from(text);
|
|
205
363
|
const marks = node.marks;
|
|
206
|
-
const pieces: {
|
|
207
|
-
cut: number;
|
|
208
|
-
emit: "start" | "end";
|
|
209
|
-
}[] = [];
|
|
210
|
-
|
|
364
|
+
const pieces: { cut: number; emit: "start" | "end" }[] = [];
|
|
211
365
|
if (startInside) pieces.push({ cut: startOffset - nodeStart, emit: "start" });
|
|
212
366
|
if (endInside) pieces.push({ cut: endOffset - nodeStart, emit: "end" });
|
|
213
367
|
pieces.sort((a, b) => a.cut - b.cut || (a.emit === "start" ? -1 : 1));
|
|
@@ -243,12 +397,11 @@ function injectMarkersIntoInlineList(
|
|
|
243
397
|
cursor = nodeEnd;
|
|
244
398
|
}
|
|
245
399
|
|
|
246
|
-
|
|
247
|
-
if (!startEmitted) {
|
|
400
|
+
if (needStart && !startEmitted) {
|
|
248
401
|
output.push(start);
|
|
249
402
|
startEmitted = true;
|
|
250
403
|
}
|
|
251
|
-
if (!endEmitted) {
|
|
404
|
+
if (needEnd && !endEmitted) {
|
|
252
405
|
output.push(end);
|
|
253
406
|
endEmitted = true;
|
|
254
407
|
}
|
|
@@ -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 {
|
|
@@ -110,6 +110,22 @@ export interface EffectiveFieldDisplay {
|
|
|
110
110
|
* visual vocabulary (CSS class, inline style, decoration).
|
|
111
111
|
*/
|
|
112
112
|
export interface RevisionDisplayFlags {
|
|
113
|
+
/**
|
|
114
|
+
* The attached revision's id. Enables render consumers to emit
|
|
115
|
+
* `data-revision-id` + route sidebar scroll-to-revision off the
|
|
116
|
+
* authoritative L03 flags rather than re-deriving from the review-store
|
|
117
|
+
* side channel. Present whenever `input.revision !== undefined` drove
|
|
118
|
+
* flag emission.
|
|
119
|
+
*/
|
|
120
|
+
readonly revisionId: string;
|
|
121
|
+
/**
|
|
122
|
+
* The revision's kind. Mirrors `RevisionRecord.kind` from the canonical
|
|
123
|
+
* document (`src/model/canonical-document.ts`). Render consumers use
|
|
124
|
+
* this to branch on insertion/deletion/move/property-change variants
|
|
125
|
+
* (e.g. widget-bracket placement in suggestions mode) without reading
|
|
126
|
+
* the review store.
|
|
127
|
+
*/
|
|
128
|
+
readonly kind: "insertion" | "deletion" | "formatting" | "move" | "property-change";
|
|
113
129
|
readonly markupMode: "clean" | "simple" | "all";
|
|
114
130
|
/** Hide the run entirely (e.g. open deletion in "clean" mode). */
|
|
115
131
|
readonly hidden?: boolean;
|