@beyondwork/docx-react-component 1.0.76 → 1.0.77
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/v3/ai/resolve.ts +104 -4
- package/src/io/ooxml/parse-bookmark-references.ts +123 -0
- package/src/io/ooxml/parse-footnotes.ts +26 -3
- package/src/io/ooxml/parse-headers-footers.ts +96 -1
- package/src/io/ooxml/parse-main-document.ts +256 -4
- package/src/io/ooxml/parse-shapes.ts +29 -1
- package/src/io/ooxml/table-opaque-preservation.ts +70 -5
- package/src/runtime/scopes/action-validation.ts +39 -12
- package/src/runtime/scopes/index.ts +3 -0
- package/src/runtime/scopes/resolve-reference.ts +99 -43
- package/src/session/import/loader-types.ts +26 -0
- package/src/session/import/loader.ts +12 -2
- package/src/ui-tailwind/editor-surface/perf-probe.ts +3 -0
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +44 -0
- package/src/ui-tailwind/editor-surface/preserve-position.ts +28 -9
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Reference resolution + position queries.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* (ambiguous), a detached placeholder (scopeId was once valid), or a
|
|
7
|
-
* `not-found`.
|
|
4
|
+
* Two distinct surfaces live in this module now, matching the two
|
|
5
|
+
* distinct semantics (KI-P9 fix):
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
7
|
+
* 1. `resolveReference(ref)` — **identity lookup**. Takes a durable
|
|
8
|
+
* `ScopeReference` (`scope-id`, `semantic-path`, or
|
|
9
|
+
* `natural-language` hint) and returns the scope it names. Safe
|
|
10
|
+
* across mutations: `scope-id` survives text replaces; missing
|
|
11
|
+
* references return typed `not-found` / `detached`. Callers that
|
|
12
|
+
* hold a `ScopeReference` across a mutation window should only ever
|
|
13
|
+
* be holding one of these durable kinds.
|
|
14
|
+
*
|
|
15
|
+
* 2. `queryScopeAtPosition(at)` / `queryScopeInRange(from, to)` —
|
|
16
|
+
* **one-shot positional queries**. Return a `ScopeHandle | null`
|
|
17
|
+
* against the *current* document state. The caller's next use of
|
|
18
|
+
* the returned handle MUST be through its `scopeId` — the position
|
|
19
|
+
* is consumed by the query and never round-trips as a reference.
|
|
20
|
+
* This is the click-to-scope / hit-test / selection-to-scope
|
|
21
|
+
* surface. Positions that lived at time T1 are meaningless at time
|
|
22
|
+
* T2 after any intervening mutation, so these functions refuse to
|
|
23
|
+
* be part of the stored-reference vocabulary.
|
|
24
|
+
*
|
|
25
|
+
* Before 2026-04-24, `ScopeReference` had `offset` and `range` variants
|
|
26
|
+
* that flowed through `resolveReference` with the same
|
|
27
|
+
* `{status:"resolved", handle, confidence}` shape as identity lookups.
|
|
28
|
+
* That conflation is the KI-P9 trap — documented in
|
|
29
|
+
* `docs/testing/scopes.md §6c` and `KNOWN-ISSUES.md` KI-P9. Removed.
|
|
30
|
+
*
|
|
31
|
+
* Natural-language hints continue to resolve through `resolveReference`
|
|
32
|
+
* with `confidence: "low"`; richer NL matching is a later slice.
|
|
14
33
|
*/
|
|
15
34
|
|
|
16
35
|
import type { CanonicalDocument } from "../../model/canonical-document.ts";
|
|
@@ -23,11 +42,28 @@ import { buildScopePositionMap, type ScopePositionRange } from "./position-map.t
|
|
|
23
42
|
import { resolveScopeRange, scopeSpecificity } from "./scope-range.ts";
|
|
24
43
|
import type { ScopeHandle } from "./semantic-scope-types.ts";
|
|
25
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Durable references to a scope. Each kind is safe to cache / store /
|
|
47
|
+
* round-trip through a tool-call boundary and re-resolve after
|
|
48
|
+
* arbitrary mutations:
|
|
49
|
+
*
|
|
50
|
+
* - `scope-id` — identity primitive. Preserved through text edits;
|
|
51
|
+
* returns `not-found` / `detached` cleanly on delete.
|
|
52
|
+
* - `semantic-path` — structural path (`body/paragraph/5`). Stable
|
|
53
|
+
* when block structure doesn't change; returns `not-found` when
|
|
54
|
+
* the path no longer resolves. Fragile on KI-001-class fixtures
|
|
55
|
+
* where a neighbour block drop shifts all later indices by 1.
|
|
56
|
+
* - `natural-language` — substring heuristic. Always
|
|
57
|
+
* `confidence: "low"`; callers must surface that to humans.
|
|
58
|
+
*
|
|
59
|
+
* Positional references (`offset`, `range`) are **not** part of this
|
|
60
|
+
* union — see `queryScopeAtPosition` / `queryScopeInRange` for those.
|
|
61
|
+
* They are one-shot queries that return a `ScopeHandle | null`; the
|
|
62
|
+
* position never becomes a stored reference. Cf. KI-P9.
|
|
63
|
+
*/
|
|
26
64
|
export type ScopeReference =
|
|
27
65
|
| { readonly kind: "scope-id"; readonly value: string }
|
|
28
66
|
| { readonly kind: "semantic-path"; readonly path: readonly string[] }
|
|
29
|
-
| { readonly kind: "offset"; readonly at: number }
|
|
30
|
-
| { readonly kind: "range"; readonly from: number; readonly to: number }
|
|
31
67
|
| { readonly kind: "natural-language"; readonly hint: string };
|
|
32
68
|
|
|
33
69
|
export type ResolveReferenceResult =
|
|
@@ -161,50 +197,74 @@ function innermostContaining(
|
|
|
161
197
|
return best?.entry ?? null;
|
|
162
198
|
}
|
|
163
199
|
|
|
164
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Inputs for the one-shot positional query functions. Narrower than
|
|
202
|
+
* `ResolveReferenceInputs` because the query functions never need NL
|
|
203
|
+
* overlay labels — they're pure structural lookups.
|
|
204
|
+
*/
|
|
205
|
+
export interface QueryScopePositionInputs {
|
|
206
|
+
readonly document:
|
|
207
|
+
| Pick<CanonicalDocument, "content" | "docId" | "review">
|
|
208
|
+
| CanonicalDocumentEnvelope;
|
|
209
|
+
readonly overlay?: WorkflowOverlay | null;
|
|
210
|
+
readonly scopes?: readonly EnumeratedScope[];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Return the innermost scope whose range contains `at` in the current
|
|
215
|
+
* document state, or `null` if no scope contains the position.
|
|
216
|
+
*
|
|
217
|
+
* **One-shot query, not a stored reference.** The returned handle is
|
|
218
|
+
* the durable object — its `scopeId` may be stored and re-resolved via
|
|
219
|
+
* `resolveReference({kind:"scope-id", value: handle.scopeId})`. The
|
|
220
|
+
* input position must NOT be stored; it is meaningful only against the
|
|
221
|
+
* document state it was queried against. See KI-P9 for the trap this
|
|
222
|
+
* separation closes.
|
|
223
|
+
*
|
|
224
|
+
* Precision: marker-backed handles (workflow / comment / revision
|
|
225
|
+
* scopes anchored by inline `scope_marker_*` nodes) are returned with
|
|
226
|
+
* full confidence; derived handles (paragraph / heading / list-item /
|
|
227
|
+
* field / table etc. enumerated from the canonical tree) are just as
|
|
228
|
+
* authoritative — the confidence distinction that previously lived on
|
|
229
|
+
* the `{status:"resolved", confidence}` return went away with
|
|
230
|
+
* `resolveReference`'s offset/range cases. Callers that need to know
|
|
231
|
+
* whether the hit is marker-backed can read `handle.provenance`.
|
|
232
|
+
*/
|
|
233
|
+
export function queryScopeAtPosition(
|
|
165
234
|
at: number,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
235
|
+
inputs: QueryScopePositionInputs,
|
|
236
|
+
): ScopeHandle | null {
|
|
237
|
+
const scopes = scopesFor({ ...inputs, overlay: inputs.overlay ?? null });
|
|
238
|
+
const positionMap = buildScopePositionMap(inputs.document);
|
|
169
239
|
const hit = innermostContaining(
|
|
170
240
|
scopes,
|
|
171
241
|
positionMap,
|
|
172
242
|
(range) => range.from <= at && at <= range.to,
|
|
173
243
|
);
|
|
174
|
-
|
|
175
|
-
return { status: "not-found", reason: `no scope contains offset ${at}` };
|
|
176
|
-
}
|
|
177
|
-
return {
|
|
178
|
-
status: "resolved",
|
|
179
|
-
handle: hit.handle,
|
|
180
|
-
confidence: hit.handle.provenance === "marker-backed" ? "high" : "medium",
|
|
181
|
-
};
|
|
244
|
+
return hit?.handle ?? null;
|
|
182
245
|
}
|
|
183
246
|
|
|
184
|
-
|
|
247
|
+
/**
|
|
248
|
+
* Return the innermost scope that fully contains the range `[from, to]`
|
|
249
|
+
* in the current document state, or `null` if no single scope contains
|
|
250
|
+
* the whole range. Same one-shot-query semantics as
|
|
251
|
+
* `queryScopeAtPosition`.
|
|
252
|
+
*/
|
|
253
|
+
export function queryScopeInRange(
|
|
185
254
|
from: number,
|
|
186
255
|
to: number,
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
): ResolveReferenceResult {
|
|
256
|
+
inputs: QueryScopePositionInputs,
|
|
257
|
+
): ScopeHandle | null {
|
|
190
258
|
const low = Math.min(from, to);
|
|
191
259
|
const high = Math.max(from, to);
|
|
260
|
+
const scopes = scopesFor({ ...inputs, overlay: inputs.overlay ?? null });
|
|
261
|
+
const positionMap = buildScopePositionMap(inputs.document);
|
|
192
262
|
const hit = innermostContaining(
|
|
193
263
|
scopes,
|
|
194
264
|
positionMap,
|
|
195
265
|
(range) => range.from <= low && high <= range.to,
|
|
196
266
|
);
|
|
197
|
-
|
|
198
|
-
return {
|
|
199
|
-
status: "not-found",
|
|
200
|
-
reason: `no scope fully contains range [${low}, ${high}]`,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
return {
|
|
204
|
-
status: "resolved",
|
|
205
|
-
handle: hit.handle,
|
|
206
|
-
confidence: hit.handle.provenance === "marker-backed" ? "high" : "medium",
|
|
207
|
-
};
|
|
267
|
+
return hit?.handle ?? null;
|
|
208
268
|
}
|
|
209
269
|
|
|
210
270
|
/**
|
|
@@ -329,10 +389,6 @@ export function resolveReference(
|
|
|
329
389
|
return resolveByScopeId(reference.value, scopes, inputs.overlay, positionMap);
|
|
330
390
|
case "semantic-path":
|
|
331
391
|
return resolveBySemanticPath(reference.path, scopes);
|
|
332
|
-
case "offset":
|
|
333
|
-
return resolveByOffset(reference.at, scopes, positionMap);
|
|
334
|
-
case "range":
|
|
335
|
-
return resolveByRange(reference.from, reference.to, scopes, positionMap);
|
|
336
392
|
case "natural-language":
|
|
337
393
|
return resolveByNaturalLanguage(
|
|
338
394
|
reference.hint,
|
|
@@ -137,6 +137,32 @@ export interface LoadDocxEditorSessionOptions {
|
|
|
137
137
|
* inspect every marker.
|
|
138
138
|
*/
|
|
139
139
|
stripCosmeticMarkers?: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Phase 2 bookmark-strip allowlist. When `stripCosmeticMarkers` is
|
|
142
|
+
* `true` (the default), the parser's reference scan retains
|
|
143
|
+
* bookmarks whose name is referenced by a `<w:hyperlink w:anchor>`
|
|
144
|
+
* or `<w:instrText>` (REF / PAGEREF / NOTEREF / TOC) AND any name
|
|
145
|
+
* listed here. Use this when the host depends on a stable
|
|
146
|
+
* host-authored bookmark name (e.g. `placeholder_party_name`,
|
|
147
|
+
* `signature_block_2`) that the parser's automatic scan cannot
|
|
148
|
+
* infer is load-bearing.
|
|
149
|
+
*
|
|
150
|
+
* Default: `[]` (only the automatic scan retains names).
|
|
151
|
+
*
|
|
152
|
+
* Always-retained, regardless of this list:
|
|
153
|
+
* - `_Toc*` (when any TOC field exists in the document)
|
|
154
|
+
* - `_Ref*` and any other name explicitly cited by a hyperlink
|
|
155
|
+
* anchor or REF/PAGEREF/NOTEREF instruction
|
|
156
|
+
* - `bw:scope:*` (workflow scope markers — converted to
|
|
157
|
+
* first-class scope markers by the parser before strip runs)
|
|
158
|
+
* - everything (defensive blanket-retain) when the document
|
|
159
|
+
* contains a `<w:dataBinding>` whose xpath could reference
|
|
160
|
+
* bookmarks via paths the scanner cannot statically analyze
|
|
161
|
+
*
|
|
162
|
+
* See `services/debug/docs/phase-2-bookmark-strip-audit-2026-04-24.md`
|
|
163
|
+
* for the corpus categorization that informed this contract.
|
|
164
|
+
*/
|
|
165
|
+
retainedBookmarkNames?: ReadonlyArray<string>;
|
|
140
166
|
}
|
|
141
167
|
|
|
142
168
|
/**
|
|
@@ -540,7 +540,12 @@ export async function loadDocxSessionAsync(
|
|
|
540
540
|
mediaParts,
|
|
541
541
|
mainDocumentPath,
|
|
542
542
|
chartPartLookup,
|
|
543
|
-
{
|
|
543
|
+
{
|
|
544
|
+
stripCosmeticMarkers: options.stripCosmeticMarkers !== false,
|
|
545
|
+
...(options.retainedBookmarkNames !== undefined
|
|
546
|
+
? { retainedBookmarkNames: options.retainedBookmarkNames }
|
|
547
|
+
: {}),
|
|
548
|
+
},
|
|
544
549
|
);
|
|
545
550
|
} finally {
|
|
546
551
|
if (options.telemetryBus) setActiveParseTelemetryBus(undefined);
|
|
@@ -1313,7 +1318,12 @@ export function loadDocxSessionSync(
|
|
|
1313
1318
|
mediaParts,
|
|
1314
1319
|
mainDocumentPath,
|
|
1315
1320
|
chartPartLookup,
|
|
1316
|
-
{
|
|
1321
|
+
{
|
|
1322
|
+
stripCosmeticMarkers: options.stripCosmeticMarkers !== false,
|
|
1323
|
+
...(options.retainedBookmarkNames !== undefined
|
|
1324
|
+
? { retainedBookmarkNames: options.retainedBookmarkNames }
|
|
1325
|
+
: {}),
|
|
1326
|
+
},
|
|
1317
1327
|
);
|
|
1318
1328
|
} finally {
|
|
1319
1329
|
if (options.telemetryBus) setActiveParseTelemetryBus(undefined);
|
|
@@ -20,9 +20,26 @@ import type {
|
|
|
20
20
|
WorkflowScope,
|
|
21
21
|
} from "../../api/public-types";
|
|
22
22
|
import { MAIN_STORY_TARGET, storyTargetsEqual } from "../../api/public-types.ts";
|
|
23
|
+
import {
|
|
24
|
+
incrementInvalidationCounter,
|
|
25
|
+
recordPerfSample,
|
|
26
|
+
} from "./perf-probe";
|
|
23
27
|
import type { PositionMap } from "./pm-position-map";
|
|
24
28
|
import type { Node as PMNode } from "prosemirror-model";
|
|
25
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Cheap wall-clock delta helper for sub-section probes. `performance.now`
|
|
32
|
+
* is always defined in the browser surface where this runs; the guard
|
|
33
|
+
* keeps headless Node contexts (tests) from throwing when the global
|
|
34
|
+
* isn't polyfilled.
|
|
35
|
+
*/
|
|
36
|
+
function nowMs(): number {
|
|
37
|
+
return typeof performance !== "undefined" &&
|
|
38
|
+
typeof performance.now === "function"
|
|
39
|
+
? performance.now()
|
|
40
|
+
: Date.now();
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
type RailDecorationSpec = {
|
|
27
44
|
railKind: "scope" | "candidate" | "blocked";
|
|
28
45
|
className: string;
|
|
@@ -394,6 +411,8 @@ export function buildDecorations(
|
|
|
394
411
|
const lockedPmRanges = collectLockedPmRanges(workflowLockedZones, activeStory, positionMap);
|
|
395
412
|
|
|
396
413
|
// Walk comment threads and create inline decorations
|
|
414
|
+
const commentsStartMs = nowMs();
|
|
415
|
+
let commentCount = 0;
|
|
397
416
|
if (commentModel) {
|
|
398
417
|
for (const thread of commentModel.threads) {
|
|
399
418
|
const cls = getCommentHighlightClass(
|
|
@@ -413,11 +432,18 @@ export function buildDecorations(
|
|
|
413
432
|
"data-comment-id": thread.commentId,
|
|
414
433
|
}),
|
|
415
434
|
);
|
|
435
|
+
commentCount += 1;
|
|
416
436
|
}
|
|
417
437
|
}
|
|
418
438
|
}
|
|
439
|
+
recordPerfSample("pm.decorations.comments", nowMs() - commentsStartMs);
|
|
440
|
+
if (commentCount > 0) {
|
|
441
|
+
incrementInvalidationCounter("pm.decorations.comments.count", commentCount);
|
|
442
|
+
}
|
|
419
443
|
|
|
420
444
|
// Walk revision entries and create inline decorations.
|
|
445
|
+
const revisionsStartMs = nowMs();
|
|
446
|
+
let revisionCount = 0;
|
|
421
447
|
// Deletion hiding in clean mode ALWAYS applies, even when showTrackedChanges is off.
|
|
422
448
|
// Visual styling (underlines, colors) only applies when showTrackedChanges is on.
|
|
423
449
|
if (revisionModel) {
|
|
@@ -442,6 +468,7 @@ export function buildDecorations(
|
|
|
442
468
|
"data-revision-id": rev.revisionId,
|
|
443
469
|
}),
|
|
444
470
|
);
|
|
471
|
+
revisionCount += 1;
|
|
445
472
|
}
|
|
446
473
|
continue;
|
|
447
474
|
}
|
|
@@ -477,6 +504,7 @@ export function buildDecorations(
|
|
|
477
504
|
return el;
|
|
478
505
|
}, { side: 1, key: `${rev.revisionId}-close` }),
|
|
479
506
|
);
|
|
507
|
+
revisionCount += 1;
|
|
480
508
|
} else if (rev.kind === "deletion") {
|
|
481
509
|
decorations.push(
|
|
482
510
|
Decoration.inline(pmFrom, pmTo, {
|
|
@@ -484,6 +512,7 @@ export function buildDecorations(
|
|
|
484
512
|
"data-revision-id": rev.revisionId,
|
|
485
513
|
}),
|
|
486
514
|
);
|
|
515
|
+
revisionCount += 1;
|
|
487
516
|
}
|
|
488
517
|
continue;
|
|
489
518
|
}
|
|
@@ -511,9 +540,16 @@ export function buildDecorations(
|
|
|
511
540
|
"data-revision-id": rev.revisionId,
|
|
512
541
|
}),
|
|
513
542
|
);
|
|
543
|
+
revisionCount += 1;
|
|
514
544
|
}
|
|
515
545
|
}
|
|
546
|
+
recordPerfSample("pm.decorations.revisions", nowMs() - revisionsStartMs);
|
|
547
|
+
if (revisionCount > 0) {
|
|
548
|
+
incrementInvalidationCounter("pm.decorations.revisions.count", revisionCount);
|
|
549
|
+
}
|
|
516
550
|
|
|
551
|
+
const workflowStartMs = nowMs();
|
|
552
|
+
const workflowDecorationsBefore = decorations.length;
|
|
517
553
|
if (effectiveWorkflowScopes.length > 0) {
|
|
518
554
|
for (const scope of effectiveWorkflowScopes) {
|
|
519
555
|
const scopeStoryTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
|
|
@@ -655,6 +691,14 @@ export function buildDecorations(
|
|
|
655
691
|
}, railRangeCache);
|
|
656
692
|
}
|
|
657
693
|
}
|
|
694
|
+
recordPerfSample("pm.decorations.workflow", nowMs() - workflowStartMs);
|
|
695
|
+
const workflowDecorationCount = decorations.length - workflowDecorationsBefore;
|
|
696
|
+
if (workflowDecorationCount > 0) {
|
|
697
|
+
incrementInvalidationCounter(
|
|
698
|
+
"pm.decorations.workflow.count",
|
|
699
|
+
workflowDecorationCount,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
658
702
|
|
|
659
703
|
return DecorationSet.create(doc, decorations);
|
|
660
704
|
}
|
|
@@ -161,6 +161,14 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
|
|
|
161
161
|
* `preserve-position-ordering.test.ts`.
|
|
162
162
|
*/
|
|
163
163
|
suppressionRef: EchoSuppressionRef;
|
|
164
|
+
/**
|
|
165
|
+
* When `true`, wrap the state swap in `capturePosition` /
|
|
166
|
+
* `restorePosition` so the scroll anchor block stays at the same
|
|
167
|
+
* viewport-Y across the replacement. Shipped **disabled by default**
|
|
168
|
+
* after the 2026-04-24 jump-to-top regression — re-enable under a
|
|
169
|
+
* diagnosed-safe codepath only.
|
|
170
|
+
*/
|
|
171
|
+
preserveScrollAnchor?: boolean;
|
|
164
172
|
/**
|
|
165
173
|
* Microtask scheduler. Defaults to the global `queueMicrotask`.
|
|
166
174
|
* Tests override it to capture the release callback.
|
|
@@ -169,17 +177,17 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
|
|
|
169
177
|
}
|
|
170
178
|
|
|
171
179
|
/**
|
|
172
|
-
* Replace the view's state with `newState
|
|
173
|
-
*
|
|
174
|
-
* otherwise fire when PM dispatches its internal selection-change
|
|
175
|
-
* notifications during the swap.
|
|
180
|
+
* Replace the view's state with `newState`, suppressing the
|
|
181
|
+
* selection-sync echo during the swap.
|
|
176
182
|
*
|
|
177
|
-
* Ordering invariant (regression-guarded
|
|
183
|
+
* Ordering invariant (regression-guarded by
|
|
184
|
+
* `preserve-position-ordering.test.ts`):
|
|
178
185
|
*
|
|
179
|
-
* 1. capture scroll anchor
|
|
186
|
+
* 1. (optional) capture scroll anchor — gated on
|
|
187
|
+
* `preserveScrollAnchor: true` and a live `geometryFacet`
|
|
180
188
|
* 2. suppressionRef.current = true
|
|
181
189
|
* 3. view.updateState(newState) ← PM may fire selection events here
|
|
182
|
-
* 4. restore scroll anchor
|
|
190
|
+
* 4. (optional) restore scroll anchor
|
|
183
191
|
* 5. queueMicrotask(() => suppressionRef.current = false)
|
|
184
192
|
*
|
|
185
193
|
* The microtask release guarantees the flag is still `true` for any
|
|
@@ -191,15 +199,26 @@ export interface ReplaceStateOptions extends PreservePositionOptions {
|
|
|
191
199
|
* `requestAnimationFrame` is deliberate — a later scheduler would
|
|
192
200
|
* leave the flag stuck true across a macrotask boundary, causing
|
|
193
201
|
* legitimate post-swap selection changes to be swallowed.
|
|
202
|
+
*
|
|
203
|
+
* **Scroll preservation is opt-in.** Shipped disabled by default
|
|
204
|
+
* after the 2026-04-24 jump-to-top regression report — enabling it
|
|
205
|
+
* requires evidence that the anchor math holds under the
|
|
206
|
+
* rebuild-effect's exact timing (PM DOM mid-mutation, observer-driven
|
|
207
|
+
* scrollTop resets, etc.). The capture/restore helpers are still
|
|
208
|
+
* exported + unit-tested for the eventual re-enable.
|
|
194
209
|
*/
|
|
195
210
|
export function replaceStatePreservingPosition(
|
|
196
211
|
options: ReplaceStateOptions,
|
|
197
212
|
newState: import("prosemirror-state").EditorState,
|
|
198
213
|
): void {
|
|
199
|
-
const preserved =
|
|
214
|
+
const preserved = options.preserveScrollAnchor
|
|
215
|
+
? capturePosition(options)
|
|
216
|
+
: null;
|
|
200
217
|
options.suppressionRef.current = true;
|
|
201
218
|
options.view.updateState(newState);
|
|
202
|
-
|
|
219
|
+
if (preserved) {
|
|
220
|
+
restorePosition(preserved, options);
|
|
221
|
+
}
|
|
203
222
|
const release = () => {
|
|
204
223
|
options.suppressionRef.current = false;
|
|
205
224
|
};
|