@beyondwork/docx-react-component 1.0.78 → 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/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/editor-surface/pm-schema.ts +20 -1
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +17 -2
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
|
|
|
@@ -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
|
|
@@ -569,6 +569,19 @@ export interface WordReviewEditorLayoutFacet {
|
|
|
569
569
|
*/
|
|
570
570
|
getTableBodyYOffsetOnPage(blockId: string, pageIndex: number): number | null;
|
|
571
571
|
|
|
572
|
+
/**
|
|
573
|
+
* Viewport-cull height resolver — returns total rendered height (twips)
|
|
574
|
+
* for every block in the current page graph, computed as the sum of each
|
|
575
|
+
* fragment's `heightTwips` grouped by `blockId`. Consumers (the render
|
|
576
|
+
* surface builder in particular) use this to size `placeholder-culled`
|
|
577
|
+
* opaque stubs so the scrollable canvas does not change height when a
|
|
578
|
+
* block realizes during scroll.
|
|
579
|
+
*
|
|
580
|
+
* Returns an empty map on the inert facet or before the first successful
|
|
581
|
+
* pagination pass. Cached per `graph.revision`.
|
|
582
|
+
*/
|
|
583
|
+
getBlockHeightsTwips(): ReadonlyMap<string, number>;
|
|
584
|
+
|
|
572
585
|
// Fields ---------------------------------------------------------------
|
|
573
586
|
getDirtyFieldFamilies(): readonly string[];
|
|
574
587
|
getFieldDirtinessReport(): PublicFieldDirtinessReport;
|
|
@@ -707,6 +720,13 @@ export function createLayoutFacet(
|
|
|
707
720
|
revision: number;
|
|
708
721
|
blocks: readonly PublicRegionBlock[] | null;
|
|
709
722
|
} = { revision: -1, blocks: null };
|
|
723
|
+
// Viewport-cull flicker fix — per-revision cache for getBlockHeightsTwips.
|
|
724
|
+
// One entry per blockId; value is the sum of that block's fragments'
|
|
725
|
+
// `heightTwips`. Busts on `graph.revision` change.
|
|
726
|
+
let blockHeightsCache: {
|
|
727
|
+
revision: number;
|
|
728
|
+
map: ReadonlyMap<string, number> | null;
|
|
729
|
+
} = { revision: -1, map: null };
|
|
710
730
|
|
|
711
731
|
function resolveCanonicalDocument(): CanonicalDocumentEnvelope {
|
|
712
732
|
if (input.canonicalDocument) {
|
|
@@ -1234,6 +1254,21 @@ export function createLayoutFacet(
|
|
|
1234
1254
|
return null;
|
|
1235
1255
|
},
|
|
1236
1256
|
|
|
1257
|
+
getBlockHeightsTwips() {
|
|
1258
|
+
const graph = currentGraph();
|
|
1259
|
+
if (blockHeightsCache.revision === graph.revision && blockHeightsCache.map) {
|
|
1260
|
+
return blockHeightsCache.map;
|
|
1261
|
+
}
|
|
1262
|
+
const map = new Map<string, number>();
|
|
1263
|
+
for (const frag of graph.fragments) {
|
|
1264
|
+
const prev = map.get(frag.blockId) ?? 0;
|
|
1265
|
+
map.set(frag.blockId, prev + frag.heightTwips);
|
|
1266
|
+
}
|
|
1267
|
+
const frozen: ReadonlyMap<string, number> = map;
|
|
1268
|
+
blockHeightsCache = { revision: graph.revision, map: frozen };
|
|
1269
|
+
return frozen;
|
|
1270
|
+
},
|
|
1271
|
+
|
|
1237
1272
|
getDirtyFieldFamilies() {
|
|
1238
1273
|
return engine.getDirtyFieldFamilies();
|
|
1239
1274
|
},
|
|
@@ -791,19 +791,72 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
|
|
|
791
791
|
return { scopeId, anchor: params.anchor };
|
|
792
792
|
}
|
|
793
793
|
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
794
|
+
const plantResult = insertScopeMarkers(deps.getDocument(), {
|
|
795
|
+
scopeId,
|
|
796
|
+
from: anchor.from,
|
|
797
|
+
to: anchor.to,
|
|
798
|
+
});
|
|
798
799
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
800
|
+
// Plant failed — pre-2026-04-24 this returned silently with a dead
|
|
801
|
+
// scopeId; callers later saw `scope-not-resolvable`. Now surface
|
|
802
|
+
// the typed failure on the AddScopeResult so consumers can detect
|
|
803
|
+
// the plant-failed path without a round-trip through
|
|
804
|
+
// resolveReference.
|
|
805
|
+
if (plantResult.status !== "planted") {
|
|
806
|
+
const callerAssoc: { readonly start: -1 | 1; readonly end: -1 | 1 } =
|
|
807
|
+
params.anchor.kind === "range"
|
|
808
|
+
? params.anchor.assoc
|
|
809
|
+
: { start: -1, end: 1 };
|
|
810
|
+
// Return the caller's input range as an informational range
|
|
811
|
+
// anchor. The authoritative failure signal is `scopeId: ""` +
|
|
812
|
+
// `plantStatus.planted === false`. The detached-anchor shape has
|
|
813
|
+
// a fixed reason enum (`deleted|invalidatedByStructureChange|
|
|
814
|
+
// importAmbiguity`) that doesn't cover plant-refused, so we keep
|
|
815
|
+
// the range kind and let callers discriminate via plantStatus.
|
|
816
|
+
return {
|
|
817
|
+
scopeId: "",
|
|
818
|
+
anchor: {
|
|
819
|
+
kind: "range",
|
|
820
|
+
from: anchor.from,
|
|
821
|
+
to: anchor.to,
|
|
822
|
+
assoc: callerAssoc,
|
|
823
|
+
},
|
|
824
|
+
plantStatus: {
|
|
825
|
+
planted: false,
|
|
826
|
+
reason: plantResult.status,
|
|
827
|
+
...(plantResult.status === "cross-paragraph-range"
|
|
828
|
+
? {
|
|
829
|
+
fromBlockIndex: plantResult.fromBlockIndex,
|
|
830
|
+
toBlockIndex: plantResult.toBlockIndex,
|
|
831
|
+
}
|
|
832
|
+
: {}),
|
|
833
|
+
...(plantResult.status === "non-paragraph-target"
|
|
834
|
+
? {
|
|
835
|
+
blockIndex: plantResult.blockIndex,
|
|
836
|
+
blockKind: plantResult.blockKind,
|
|
837
|
+
}
|
|
838
|
+
: {}),
|
|
839
|
+
...(plantResult.status === "range-out-of-bounds"
|
|
840
|
+
? { storyLength: plantResult.storyLength }
|
|
841
|
+
: {}),
|
|
842
|
+
requestedFrom: plantResult.from,
|
|
843
|
+
requestedTo: plantResult.to,
|
|
844
|
+
},
|
|
845
|
+
};
|
|
846
|
+
// Intentionally NOT dispatching document.replace or workflow.set-overlay —
|
|
847
|
+
// a failed plant must not leave a half-registered scope. Prevents the
|
|
848
|
+
// pre-fix "overlay carries scopeId but canonical tree has no markers"
|
|
849
|
+
// state that produced `scope-not-resolvable` on every follow-up call.
|
|
805
850
|
}
|
|
806
851
|
|
|
852
|
+
const nextDocument = plantResult.document;
|
|
853
|
+
|
|
854
|
+
deps.dispatch({
|
|
855
|
+
type: "document.replace",
|
|
856
|
+
document: nextDocument,
|
|
857
|
+
origin: { source: "api", at: clock() },
|
|
858
|
+
});
|
|
859
|
+
|
|
807
860
|
// Coord-06 §13d — preserve the caller's assoc on the public anchor.
|
|
808
861
|
// resolveScope re-derives the range from the inserted markers but emits
|
|
809
862
|
// a hardcoded { start: -1, end: 1 }; without this override the caller's
|
|
@@ -276,10 +276,19 @@ export type CreateScopeFromAnchorResult =
|
|
|
276
276
|
readonly reason:
|
|
277
277
|
| "from-negative"
|
|
278
278
|
| "to-less-than-from"
|
|
279
|
-
| "range-exceeds-story-length"
|
|
279
|
+
| "range-exceeds-story-length"
|
|
280
|
+
| "cross-paragraph-range"
|
|
281
|
+
| "non-paragraph-target"
|
|
282
|
+
| "empty-document";
|
|
280
283
|
readonly from: number;
|
|
281
284
|
readonly to: number;
|
|
282
285
|
readonly storyLength: number;
|
|
286
|
+
/** Cross-paragraph only — the two block indices the range straddled. */
|
|
287
|
+
readonly fromBlockIndex?: number;
|
|
288
|
+
readonly toBlockIndex?: number;
|
|
289
|
+
/** Non-paragraph target only — the offending block's index and kind. */
|
|
290
|
+
readonly blockIndex?: number;
|
|
291
|
+
readonly blockKind?: string;
|
|
283
292
|
/**
|
|
284
293
|
* Single-sentence, agent-actionable explanation. Tells the caller
|
|
285
294
|
* what the failure was and the concrete next step — no guesswork
|
|
@@ -291,7 +300,9 @@ export type CreateScopeFromAnchorResult =
|
|
|
291
300
|
* Short machine-routable next-step hint for thin consumers that
|
|
292
301
|
* don't want to pattern-match on `reason`. Examples:
|
|
293
302
|
* "clamp-from-to-zero", "swap-from-and-to",
|
|
294
|
-
* "clamp-to-to-storyLength-or-pick-a-different-range"
|
|
303
|
+
* "clamp-to-to-storyLength-or-pick-a-different-range",
|
|
304
|
+
* "narrow-to-single-paragraph",
|
|
305
|
+
* "pick-a-paragraph-target".
|
|
295
306
|
*/
|
|
296
307
|
readonly nextStep: string;
|
|
297
308
|
};
|
|
@@ -420,5 +431,82 @@ export function createScopeFromAnchor(
|
|
|
420
431
|
...(scopeMetadataFields.length > 0 ? { scopeMetadataFields } : {}),
|
|
421
432
|
});
|
|
422
433
|
|
|
434
|
+
// Pre-2026-04-24 the coordinator silently returned a minted scopeId
|
|
435
|
+
// even when insertScopeMarkers refused to plant (cross-paragraph
|
|
436
|
+
// range, non-paragraph target, out-of-bounds after the story-length
|
|
437
|
+
// check passed). Now the coordinator surfaces `plantStatus.planted:
|
|
438
|
+
// false`; translate each reason into the same `range-invalid` shape
|
|
439
|
+
// used by the bounds checks above so the caller gets one uniform
|
|
440
|
+
// discriminator to branch on.
|
|
441
|
+
if (result.plantStatus && result.plantStatus.planted === false) {
|
|
442
|
+
const ps = result.plantStatus;
|
|
443
|
+
if (ps.reason === "cross-paragraph-range") {
|
|
444
|
+
return {
|
|
445
|
+
status: "range-invalid",
|
|
446
|
+
reason: "cross-paragraph-range",
|
|
447
|
+
from,
|
|
448
|
+
to,
|
|
449
|
+
storyLength,
|
|
450
|
+
fromBlockIndex: ps.fromBlockIndex ?? -1,
|
|
451
|
+
toBlockIndex: ps.toBlockIndex ?? -1,
|
|
452
|
+
message:
|
|
453
|
+
`createScopeFromAnchor refused: range [${from}, ${to}] straddles ` +
|
|
454
|
+
`paragraphs ${ps.fromBlockIndex} and ${ps.toBlockIndex}. Marker-backed ` +
|
|
455
|
+
`scopes only plant inside a single paragraph today. Narrow the range to ` +
|
|
456
|
+
`land inside one paragraph, or create two separate scopes.`,
|
|
457
|
+
nextStep: "narrow-to-single-paragraph",
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
if (ps.reason === "non-paragraph-target") {
|
|
461
|
+
return {
|
|
462
|
+
status: "range-invalid",
|
|
463
|
+
reason: "non-paragraph-target",
|
|
464
|
+
from,
|
|
465
|
+
to,
|
|
466
|
+
storyLength,
|
|
467
|
+
blockIndex: ps.blockIndex ?? -1,
|
|
468
|
+
blockKind: ps.blockKind ?? "unknown",
|
|
469
|
+
message:
|
|
470
|
+
`createScopeFromAnchor refused: range [${from}, ${to}] targets a ` +
|
|
471
|
+
`${ps.blockKind ?? "non-paragraph"} block (index ${ps.blockIndex}). ` +
|
|
472
|
+
`Marker scopes only plant inside paragraphs today. Pick a paragraph ` +
|
|
473
|
+
`target, or use runtime.workflow.createScope({blockId}) for ` +
|
|
474
|
+
`whole-block scopes on the containing structure.`,
|
|
475
|
+
nextStep: "pick-a-paragraph-target",
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
if (ps.reason === "range-out-of-bounds") {
|
|
479
|
+
// Shouldn't happen — storyLength was checked above — but surface
|
|
480
|
+
// it as a first-class failure in case the underlying length math
|
|
481
|
+
// drifts from our bounds check.
|
|
482
|
+
return {
|
|
483
|
+
status: "range-invalid",
|
|
484
|
+
reason: "range-exceeds-story-length",
|
|
485
|
+
from,
|
|
486
|
+
to,
|
|
487
|
+
storyLength: ps.storyLength ?? storyLength,
|
|
488
|
+
message:
|
|
489
|
+
`createScopeFromAnchor refused: coordinator reports range [${from}, ${to}] ` +
|
|
490
|
+
`is out of bounds (storyLength=${ps.storyLength}). This is usually a ` +
|
|
491
|
+
`stale-offset bug (KI-P9) — re-derive positions from the current ` +
|
|
492
|
+
`document and retry.`,
|
|
493
|
+
nextStep: "clamp-to-to-storyLength-or-pick-a-different-range",
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
// empty-document — target has no canonical blocks.
|
|
497
|
+
return {
|
|
498
|
+
status: "range-invalid",
|
|
499
|
+
reason: "empty-document",
|
|
500
|
+
from,
|
|
501
|
+
to,
|
|
502
|
+
storyLength,
|
|
503
|
+
message:
|
|
504
|
+
`createScopeFromAnchor refused: the target document has no blocks; ` +
|
|
505
|
+
`cannot plant scope markers. Open or initialize a document before ` +
|
|
506
|
+
`creating sub-block scopes.`,
|
|
507
|
+
nextStep: "initialize-document-before-creating-scopes",
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
423
511
|
return { status: "created", scopeId: result.scopeId, anchor: result.anchor };
|
|
424
512
|
}
|
|
@@ -199,6 +199,17 @@ export const editorSchema = new Schema({
|
|
|
199
199
|
pageBreakBefore: { default: null },
|
|
200
200
|
hiddenTextOnly: { default: null },
|
|
201
201
|
placeholderCulled: { default: null },
|
|
202
|
+
/**
|
|
203
|
+
* Rendered height (in twips) of the block that this placeholder
|
|
204
|
+
* stands in for, supplied by `DocumentRuntime` from L04's page
|
|
205
|
+
* graph. When present on a `placeholderCulled` paragraph, `toDOM`
|
|
206
|
+
* emits a fixed-height `<div>` (`${twips/20}pt`) instead of the
|
|
207
|
+
* `min-height: 20px` fallback, eliminating the scroll-path
|
|
208
|
+
* "paragraphs jump around pagination gaps" flicker that occurred
|
|
209
|
+
* when blocks realized at real heights larger than one line.
|
|
210
|
+
* Null / undefined preserves the pre-existing 20 px minimum.
|
|
211
|
+
*/
|
|
212
|
+
placeholderHeightTwips: { default: null },
|
|
202
213
|
blockId: { default: null },
|
|
203
214
|
/**
|
|
204
215
|
* `<w:framePr>` projection from `SurfaceBlockFragment.frameProperties`
|
|
@@ -214,6 +225,11 @@ export const editorSchema = new Schema({
|
|
|
214
225
|
toDOM(node) {
|
|
215
226
|
// Viewport-culled placeholder paragraph — cheap size-preserving leaf.
|
|
216
227
|
if (node.attrs.placeholderCulled) {
|
|
228
|
+
const heightTwips = node.attrs.placeholderHeightTwips as number | null;
|
|
229
|
+
const heightStyle =
|
|
230
|
+
typeof heightTwips === "number" && heightTwips > 0
|
|
231
|
+
? `height: ${heightTwips / 20}pt`
|
|
232
|
+
: "min-height: 20px";
|
|
217
233
|
return [
|
|
218
234
|
"div",
|
|
219
235
|
{
|
|
@@ -221,7 +237,10 @@ export const editorSchema = new Schema({
|
|
|
221
237
|
"data-placeholder-culled": "true",
|
|
222
238
|
"data-placeholder-size": String(node.nodeSize),
|
|
223
239
|
"data-placeholder-block-id": node.attrs.blockId ?? "",
|
|
224
|
-
|
|
240
|
+
...(typeof heightTwips === "number" && heightTwips > 0
|
|
241
|
+
? { "data-placeholder-height-twips": String(heightTwips) }
|
|
242
|
+
: {}),
|
|
243
|
+
style: `${heightStyle}; contain: strict;`,
|
|
225
244
|
"aria-hidden": "true",
|
|
226
245
|
},
|
|
227
246
|
0,
|
|
@@ -867,10 +867,25 @@ function buildOpaqueBlock(
|
|
|
867
867
|
const placeholderSize = block.placeholderSize ?? null;
|
|
868
868
|
if (placeholderSize !== null) {
|
|
869
869
|
const targetSize = placeholderSize as number;
|
|
870
|
+
// Flicker fix — when DocumentRuntime has enriched the placeholder with
|
|
871
|
+
// the block's known rendered height (from L04's page graph), thread it
|
|
872
|
+
// onto the paragraph node so `pm-schema.ts::toDOM` emits a fixed
|
|
873
|
+
// `height` style matching the real block. Without this, the placeholder
|
|
874
|
+
// renders at `min-height: 20px` and inflates to its real height when
|
|
875
|
+
// the block realizes on scroll, dragging content below the scroll
|
|
876
|
+
// pointer ("paragraphs jump around pagination gaps").
|
|
877
|
+
const placeholderHeightTwips = block.placeholderHeightTwips ?? null;
|
|
878
|
+
const placeholderAttrs: Record<string, unknown> = {
|
|
879
|
+
blockId: block.blockId,
|
|
880
|
+
placeholderCulled: true,
|
|
881
|
+
};
|
|
882
|
+
if (placeholderHeightTwips !== null) {
|
|
883
|
+
placeholderAttrs.placeholderHeightTwips = placeholderHeightTwips;
|
|
884
|
+
}
|
|
870
885
|
if (targetSize <= 2) {
|
|
871
886
|
// Edge case: bare empty paragraph claims exactly 2 positions.
|
|
872
887
|
return editorSchema.nodes.paragraph.create(
|
|
873
|
-
|
|
888
|
+
placeholderAttrs,
|
|
874
889
|
Fragment.empty,
|
|
875
890
|
);
|
|
876
891
|
}
|
|
@@ -878,7 +893,7 @@ function buildOpaqueBlock(
|
|
|
878
893
|
// total PM positions = 1 (open) + (targetSize - 2) (text) + 1 (close) = targetSize.
|
|
879
894
|
const filler = "\u200b".repeat(targetSize - 2);
|
|
880
895
|
return editorSchema.nodes.paragraph.create(
|
|
881
|
-
|
|
896
|
+
placeholderAttrs,
|
|
882
897
|
editorSchema.text(filler),
|
|
883
898
|
);
|
|
884
899
|
}
|