@beyondwork/docx-react-component 1.0.74 → 1.0.76
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 +7 -0
- package/src/api/v3/runtime/workflow.ts +130 -1
- package/src/io/ooxml/parse-headers-footers.ts +7 -13
- package/src/io/ooxml/parse-main-document.ts +7 -31
- package/src/io/ooxml/table-opaque-preservation.ts +171 -0
- package/src/runtime/document-runtime.ts +28 -1
- package/src/runtime/surface-projection.ts +9 -0
- package/src/runtime/workflow/scope-writer.ts +212 -10
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +44 -5
- package/src/ui-tailwind/editor-surface/pm-schema.ts +22 -2
- package/src/ui-tailwind/editor-surface/preserve-position.ts +211 -0
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -5
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +22 -3
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +49 -5
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +7 -3
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +7 -5
- package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +0 -13
- package/src/ui-tailwind/tw-review-workspace.tsx +0 -6
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Layer-06
|
|
2
|
+
* Layer-06 — marker-backed workflow-scope creators.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Two adapters share `runtime.addScope` as the underlying creator:
|
|
5
|
+
*
|
|
6
|
+
* 1. `createScopeFromBlockId` — resolves a block identifier
|
|
7
|
+
* (`paragraph-N`, `table-N`, `sdt-N`) minted by
|
|
8
|
+
* `src/runtime/surface-projection.ts` into the whole-block range.
|
|
9
|
+
* Use when the scope should cover an entire paragraph / table / SDT.
|
|
10
|
+
*
|
|
11
|
+
* 2. `createScopeFromAnchor` — takes a sub-block `{from, to}` range
|
|
12
|
+
* directly. Use when the scope should bracket a sub-span — a phrase
|
|
13
|
+
* inside a paragraph, a cross-paragraph selection, a span from a
|
|
14
|
+
* click + drag. Resolves the KI-P9 "positions-as-references" trap:
|
|
15
|
+
* positions are consumed **once** at creation to plant the markers;
|
|
16
|
+
* the returned `scopeId` is durable and survives subsequent editing.
|
|
17
|
+
* Callers must stop carrying the positions after the call returns.
|
|
8
18
|
*
|
|
9
19
|
* Resolution walks the canonical document directly, not the surface
|
|
10
20
|
* snapshot. Going through `runtime.getRenderSnapshot().surface.blocks`
|
|
11
21
|
* would miss blocks the surface replaces with `placeholder-culled-*`
|
|
12
22
|
* entries under viewport culling — a real reproducibility failure on
|
|
13
23
|
* CCEP-size documents.
|
|
14
|
-
*
|
|
15
|
-
* This adapter unblocks `v3 runtime.workflow.createScope` graduation
|
|
16
|
-
* from `mock` → `live-with-adapter`. Consumers that already carry a
|
|
17
|
-
* full `EditorAnchorProjection` should continue calling
|
|
18
|
-
* `runtime.addScope` directly.
|
|
19
24
|
*/
|
|
20
25
|
|
|
21
26
|
import type {
|
|
@@ -220,3 +225,200 @@ export function createScopeFromBlockId(
|
|
|
220
225
|
|
|
221
226
|
// Exported for tests that need to verify the resolver independently.
|
|
222
227
|
export { resolveBlockAnchorFromCanonical };
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Input for `createScopeFromAnchor`. The `anchor` fields are a one-shot
|
|
231
|
+
* position query — the runtime plants inline `scope_marker_start` /
|
|
232
|
+
* `scope_marker_end` at these offsets and returns a marker-backed
|
|
233
|
+
* `scopeId`. From that moment the markers travel with their bracketed
|
|
234
|
+
* content through any number of edits; callers must re-resolve by
|
|
235
|
+
* `scopeId` afterward and MUST NOT store or reuse `from`/`to`.
|
|
236
|
+
*/
|
|
237
|
+
export interface CreateScopeFromAnchorInput {
|
|
238
|
+
/**
|
|
239
|
+
* Range in the current document state. `from` and `to` are measured
|
|
240
|
+
* in the editor's surface-projection offset space (the same space
|
|
241
|
+
* `findText`, `replaceText`, and `addScope` ranges use). Must satisfy
|
|
242
|
+
* `0 <= from <= to <= storyLength`.
|
|
243
|
+
*/
|
|
244
|
+
readonly anchor: {
|
|
245
|
+
readonly from: number;
|
|
246
|
+
readonly to: number;
|
|
247
|
+
readonly storyTarget?: EditorStoryTarget;
|
|
248
|
+
};
|
|
249
|
+
readonly mode?: WorkflowScopeMode;
|
|
250
|
+
readonly label?: string;
|
|
251
|
+
readonly scopeId?: string;
|
|
252
|
+
readonly persistence?: WorkflowMetadataPersistence;
|
|
253
|
+
readonly metadata?: Partial<WorkflowMetadataEntry>;
|
|
254
|
+
/**
|
|
255
|
+
* Per-scope edge stickiness for the range anchor. Defaults to
|
|
256
|
+
* `{ start: 1, end: -1 }` (greedy — absorbs boundary inserts). See
|
|
257
|
+
* `CreateScopeFromBlockIdInput.assoc` for the full matrix.
|
|
258
|
+
*/
|
|
259
|
+
readonly assoc?: { readonly start: -1 | 1; readonly end: -1 | 1 };
|
|
260
|
+
/** Caller-steerable identity strategy; same semantics as on `CreateScopeFromBlockIdInput`. */
|
|
261
|
+
readonly stableRefHint?:
|
|
262
|
+
| "scope-id"
|
|
263
|
+
| "bookmark"
|
|
264
|
+
| "semantic-path"
|
|
265
|
+
| "runtime-handle";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export type CreateScopeFromAnchorResult =
|
|
269
|
+
| {
|
|
270
|
+
readonly status: "created";
|
|
271
|
+
readonly scopeId: string;
|
|
272
|
+
readonly anchor: EditorAnchorProjection;
|
|
273
|
+
}
|
|
274
|
+
| {
|
|
275
|
+
readonly status: "range-invalid";
|
|
276
|
+
readonly reason:
|
|
277
|
+
| "from-negative"
|
|
278
|
+
| "to-less-than-from"
|
|
279
|
+
| "range-exceeds-story-length";
|
|
280
|
+
readonly from: number;
|
|
281
|
+
readonly to: number;
|
|
282
|
+
readonly storyLength: number;
|
|
283
|
+
/**
|
|
284
|
+
* Single-sentence, agent-actionable explanation. Tells the caller
|
|
285
|
+
* what the failure was and the concrete next step — no guesswork
|
|
286
|
+
* required. Safe to surface directly to an LLM tool-use loop as
|
|
287
|
+
* the `error` content of the tool reply.
|
|
288
|
+
*/
|
|
289
|
+
readonly message: string;
|
|
290
|
+
/**
|
|
291
|
+
* Short machine-routable next-step hint for thin consumers that
|
|
292
|
+
* don't want to pattern-match on `reason`. Examples:
|
|
293
|
+
* "clamp-from-to-zero", "swap-from-and-to",
|
|
294
|
+
* "clamp-to-to-storyLength-or-pick-a-different-range".
|
|
295
|
+
*/
|
|
296
|
+
readonly nextStep: string;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Sum the total length of the main story by walking blocks with
|
|
301
|
+
* inter-block gap semantics matched to `resolveBlockAnchorFromCanonical`.
|
|
302
|
+
* Used as the bounds check for `createScopeFromAnchor`.
|
|
303
|
+
*/
|
|
304
|
+
function computeMainStoryLength(document: CanonicalDocumentEnvelope): number {
|
|
305
|
+
const root = document.content as DocumentRootNode;
|
|
306
|
+
const blocks = root.children;
|
|
307
|
+
let total = 0;
|
|
308
|
+
for (let i = 0; i < blocks.length; i += 1) {
|
|
309
|
+
const block: BlockNode = blocks[i]!;
|
|
310
|
+
if (block.type === "paragraph") {
|
|
311
|
+
total += paragraphLength(block);
|
|
312
|
+
} else {
|
|
313
|
+
total += 1;
|
|
314
|
+
}
|
|
315
|
+
if (i < blocks.length - 1) total += 1;
|
|
316
|
+
}
|
|
317
|
+
return total;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Create a marker-backed workflow scope over a sub-block anchor range.
|
|
322
|
+
*
|
|
323
|
+
* The returned `scopeId` is the durable reference — after this call
|
|
324
|
+
* returns, `from`/`to` are not part of the scope's identity and must
|
|
325
|
+
* not be stored or reused. All later lookups use
|
|
326
|
+
* `resolveReference({kind:"scope-id", value: scopeId})` or `get_scope`,
|
|
327
|
+
* which walk to the markers' current positions regardless of
|
|
328
|
+
* intervening edits.
|
|
329
|
+
*
|
|
330
|
+
* Bounds: `0 <= from <= to <= storyLength`. Out-of-bounds or inverted
|
|
331
|
+
* ranges return `{status: "range-invalid", reason}` without mutating
|
|
332
|
+
* the document. `storyTarget` defaults to main; footnote / header /
|
|
333
|
+
* endnote stories are supported by passing the target through.
|
|
334
|
+
*/
|
|
335
|
+
export function createScopeFromAnchor(
|
|
336
|
+
runtime: ScopeWriterRuntime,
|
|
337
|
+
input: CreateScopeFromAnchorInput,
|
|
338
|
+
): CreateScopeFromAnchorResult {
|
|
339
|
+
const doc = runtime.getCanonicalDocument();
|
|
340
|
+
const storyTarget: EditorStoryTarget =
|
|
341
|
+
input.anchor.storyTarget ?? { kind: "main" };
|
|
342
|
+
// Bounds check only covers the main story today. Non-main stories
|
|
343
|
+
// (footnote / header / endnote) skip the numeric bound check —
|
|
344
|
+
// `runtime.addScope` is the authoritative bound enforcer for those
|
|
345
|
+
// secondary stories and returns an error anchor when the range is
|
|
346
|
+
// invalid. This matches the pre-existing `createScopeFromBlockId`
|
|
347
|
+
// contract where block resolution handles story-aware bounds.
|
|
348
|
+
const storyLength =
|
|
349
|
+
storyTarget.kind === "main" ? computeMainStoryLength(doc) : Number.MAX_SAFE_INTEGER;
|
|
350
|
+
|
|
351
|
+
const { from, to } = input.anchor;
|
|
352
|
+
if (from < 0) {
|
|
353
|
+
return {
|
|
354
|
+
status: "range-invalid",
|
|
355
|
+
reason: "from-negative",
|
|
356
|
+
from,
|
|
357
|
+
to,
|
|
358
|
+
storyLength,
|
|
359
|
+
message:
|
|
360
|
+
`createScopeFromAnchor requires from >= 0 (received from=${from}). ` +
|
|
361
|
+
`Offsets are zero-based absolute positions in the current document; ` +
|
|
362
|
+
`clamp negative values to 0 before calling.`,
|
|
363
|
+
nextStep: "clamp-from-to-zero",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
if (to < from) {
|
|
367
|
+
return {
|
|
368
|
+
status: "range-invalid",
|
|
369
|
+
reason: "to-less-than-from",
|
|
370
|
+
from,
|
|
371
|
+
to,
|
|
372
|
+
storyLength,
|
|
373
|
+
message:
|
|
374
|
+
`createScopeFromAnchor requires to >= from (received from=${from}, to=${to}). ` +
|
|
375
|
+
`The range is inverted — swap the two values before calling.`,
|
|
376
|
+
nextStep: "swap-from-and-to",
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (to > storyLength) {
|
|
380
|
+
return {
|
|
381
|
+
status: "range-invalid",
|
|
382
|
+
reason: "range-exceeds-story-length",
|
|
383
|
+
from,
|
|
384
|
+
to,
|
|
385
|
+
storyLength,
|
|
386
|
+
message:
|
|
387
|
+
`createScopeFromAnchor requires to <= storyLength (received to=${to}, ` +
|
|
388
|
+
`storyLength=${storyLength}). This typically means the offset was ` +
|
|
389
|
+
`captured from a stale document state (see KI-P9) — do not carry ` +
|
|
390
|
+
`offsets across mutations. Re-derive from the current document, or ` +
|
|
391
|
+
`clamp to <= storyLength and verify the result targets the intended content.`,
|
|
392
|
+
nextStep: "clamp-to-to-storyLength-or-pick-a-different-range",
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const anchor: EditorAnchorProjection = {
|
|
397
|
+
kind: "range",
|
|
398
|
+
from,
|
|
399
|
+
to,
|
|
400
|
+
assoc: input.assoc ?? { start: 1, end: -1 },
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const scopeMetadataFields: WorkflowScopeMetadataField[] = [];
|
|
404
|
+
if (input.stableRefHint !== undefined) {
|
|
405
|
+
scopeMetadataFields.push({
|
|
406
|
+
key: "stableRefHint",
|
|
407
|
+
valueType: "string",
|
|
408
|
+
value: input.stableRefHint,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const result: AddScopeResult = runtime.addScope({
|
|
413
|
+
anchor,
|
|
414
|
+
mode: input.mode,
|
|
415
|
+
scopeId: input.scopeId,
|
|
416
|
+
persistence: input.persistence,
|
|
417
|
+
metadata: input.metadata,
|
|
418
|
+
storyTarget,
|
|
419
|
+
label: input.label,
|
|
420
|
+
...(scopeMetadataFields.length > 0 ? { scopeMetadataFields } : {}),
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
return { status: "created", scopeId: result.scopeId, anchor: result.anchor };
|
|
424
|
+
}
|
|
@@ -123,7 +123,7 @@ export function createFastTextEditLane(
|
|
|
123
123
|
const fromRuntime = positionMap.pmToRuntime(fromPm);
|
|
124
124
|
const toRuntime = positionMap.pmToRuntime(toPm);
|
|
125
125
|
|
|
126
|
-
pushLaneDebug({
|
|
126
|
+
const debugEntry = pushLaneDebug({
|
|
127
127
|
opId,
|
|
128
128
|
intent: intent.kind,
|
|
129
129
|
pmFrom: fromPm,
|
|
@@ -137,6 +137,7 @@ export function createFastTextEditLane(
|
|
|
137
137
|
if (options.shouldBailBeforePredict?.(intent, fromRuntime, toRuntime)) {
|
|
138
138
|
const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
|
|
139
139
|
incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.bailBeforePredict);
|
|
140
|
+
markLaneDebugReconciled(debugEntry, ack.kind, true);
|
|
140
141
|
options.probe?.markReconciled(opId, ack.kind);
|
|
141
142
|
switch (ack.kind) {
|
|
142
143
|
case "equivalent":
|
|
@@ -183,6 +184,7 @@ export function createFastTextEditLane(
|
|
|
183
184
|
op.predictedSelectionHead = view.state.selection.head;
|
|
184
185
|
|
|
185
186
|
const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
|
|
187
|
+
markLaneDebugReconciled(debugEntry, ack.kind, false);
|
|
186
188
|
options.probe?.markReconciled(opId, ack.kind);
|
|
187
189
|
|
|
188
190
|
switch (ack.kind) {
|
|
@@ -280,6 +282,14 @@ interface LaneDebugEntry {
|
|
|
280
282
|
runtimeStorySize: number;
|
|
281
283
|
fromRuntime: number;
|
|
282
284
|
toRuntime: number;
|
|
285
|
+
/** Dispatch → reconcile observation. Filled by `markLaneDebugReconciled`. */
|
|
286
|
+
ackKind?: TextCommandAck["kind"];
|
|
287
|
+
/** Wall-clock ms between `pushLaneDebug` and `markLaneDebugReconciled`. */
|
|
288
|
+
reconcileMs?: number;
|
|
289
|
+
/** Whether the lane short-circuited to dispatch-only (no predicted TX). */
|
|
290
|
+
bailed?: boolean;
|
|
291
|
+
/** Wall-clock timestamp at push time — used to compute reconcileMs. */
|
|
292
|
+
startedAtMs: number;
|
|
283
293
|
}
|
|
284
294
|
|
|
285
295
|
declare global {
|
|
@@ -294,19 +304,48 @@ declare global {
|
|
|
294
304
|
* buffer is capped at 200 entries; consumers can read it from the browser
|
|
295
305
|
* console to diagnose cursor position mismatches between PM and the runtime.
|
|
296
306
|
*
|
|
307
|
+
* Returns the pushed entry (or `null` when the buffer isn't enabled) so
|
|
308
|
+
* the caller can mutate it in place with ack-kind + reconcile timing
|
|
309
|
+
* via `markLaneDebugReconciled` once the runtime dispatch returns.
|
|
310
|
+
*
|
|
297
311
|
* To enable in the browser console:
|
|
298
312
|
* window.__DOCX_LANE_DEBUG__ = [];
|
|
299
313
|
* Then type, then:
|
|
300
314
|
* JSON.stringify(window.__DOCX_LANE_DEBUG__, null, 2)
|
|
301
315
|
*/
|
|
302
|
-
function pushLaneDebug(
|
|
303
|
-
|
|
316
|
+
function pushLaneDebug(
|
|
317
|
+
entry: Omit<LaneDebugEntry, "startedAtMs">,
|
|
318
|
+
): LaneDebugEntry | null {
|
|
319
|
+
if (typeof window === "undefined") return null;
|
|
304
320
|
const buffer = window.__DOCX_LANE_DEBUG__;
|
|
305
|
-
if (!Array.isArray(buffer)) return;
|
|
306
|
-
|
|
321
|
+
if (!Array.isArray(buffer)) return null;
|
|
322
|
+
const full: LaneDebugEntry = {
|
|
323
|
+
...entry,
|
|
324
|
+
startedAtMs:
|
|
325
|
+
typeof performance !== "undefined" && typeof performance.now === "function"
|
|
326
|
+
? performance.now()
|
|
327
|
+
: Date.now(),
|
|
328
|
+
};
|
|
329
|
+
buffer.push(full);
|
|
307
330
|
if (buffer.length > 200) {
|
|
308
331
|
buffer.splice(0, buffer.length - 200);
|
|
309
332
|
}
|
|
333
|
+
return full;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function markLaneDebugReconciled(
|
|
337
|
+
entry: LaneDebugEntry | null,
|
|
338
|
+
ackKind: TextCommandAck["kind"],
|
|
339
|
+
bailed: boolean,
|
|
340
|
+
): void {
|
|
341
|
+
if (!entry) return;
|
|
342
|
+
entry.ackKind = ackKind;
|
|
343
|
+
entry.bailed = bailed;
|
|
344
|
+
const now =
|
|
345
|
+
typeof performance !== "undefined" && typeof performance.now === "function"
|
|
346
|
+
? performance.now()
|
|
347
|
+
: Date.now();
|
|
348
|
+
entry.reconcileMs = now - entry.startedAtMs;
|
|
310
349
|
}
|
|
311
350
|
|
|
312
351
|
function buildTxCompat(
|
|
@@ -289,7 +289,15 @@ export const editorSchema = new Schema({
|
|
|
289
289
|
if (pageBreak) styles.push("border-top: 2px dashed rgba(0,0,0,0.1); padding-top: 8px; margin-top: 16px");
|
|
290
290
|
// `<w:framePr>` out-of-flow frame — mirror the static-path branch in
|
|
291
291
|
// tw-page-block-view.helpers.ts so PM-rendered page 1 absolutely
|
|
292
|
-
// positions the frame identically to pages 2+.
|
|
292
|
+
// positions the frame identically to pages 2+. Gating rules
|
|
293
|
+
// match the static path: drop-cap stays in-flow; a framePr with
|
|
294
|
+
// zero positional fields stays in-flow (no out-of-flow without
|
|
295
|
+
// a declared position — M3 review finding).
|
|
296
|
+
//
|
|
297
|
+
// N1: `position: absolute` is pushed BEFORE `hiddenTextOnly`'s
|
|
298
|
+
// `display: none` by code order; `display: none` wins visually
|
|
299
|
+
// regardless of source order, so a hidden framed paragraph
|
|
300
|
+
// collapses as expected. Do not "fix" the order — it's correct.
|
|
293
301
|
const framePr = node.attrs.frameProperties as
|
|
294
302
|
| {
|
|
295
303
|
xTwips?: number;
|
|
@@ -298,9 +306,21 @@ export const editorSchema = new Schema({
|
|
|
298
306
|
heightTwips?: number;
|
|
299
307
|
hRule?: "auto" | "atLeast" | "exact";
|
|
300
308
|
dropCap?: "none" | "drop" | "margin";
|
|
309
|
+
xAlign?: string;
|
|
310
|
+
yAlign?: string;
|
|
301
311
|
}
|
|
302
312
|
| null;
|
|
303
|
-
|
|
313
|
+
const hasPosition =
|
|
314
|
+
typeof framePr?.xTwips === "number" ||
|
|
315
|
+
typeof framePr?.yTwips === "number" ||
|
|
316
|
+
typeof framePr?.xAlign === "string" ||
|
|
317
|
+
typeof framePr?.yAlign === "string";
|
|
318
|
+
if (
|
|
319
|
+
framePr &&
|
|
320
|
+
framePr.dropCap !== "drop" &&
|
|
321
|
+
framePr.dropCap !== "margin" &&
|
|
322
|
+
hasPosition
|
|
323
|
+
) {
|
|
304
324
|
styles.push("position: absolute");
|
|
305
325
|
if (typeof framePr.xTwips === "number") styles.push(`left: ${framePr.xTwips / 20}pt`);
|
|
306
326
|
if (typeof framePr.yTwips === "number") styles.push(`top: ${framePr.yTwips / 20}pt`);
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot-replacement position-preservation funnel.
|
|
3
|
+
*
|
|
4
|
+
* `view.updateState()` replaces the PM document wholesale after an
|
|
5
|
+
* `adjusted` / `rejected` / `structural-divergence` ack, after a
|
|
6
|
+
* progressive-surface swap, or after any structural edit that falls off
|
|
7
|
+
* the predicted-lane short-circuit. Between the capture and restore
|
|
8
|
+
* points, the scroll container's `scrollTop` may end up pointing at a
|
|
9
|
+
* different document position because blocks above the viewport changed
|
|
10
|
+
* height. This helper wraps a replacement `fn` so the anchor block
|
|
11
|
+
* stays at the same viewport-Y before and after.
|
|
12
|
+
*
|
|
13
|
+
* The helper builds on `findScrollAnchor` / `restoreScrollAnchor`
|
|
14
|
+
* (scroll-anchor.ts) — those already honor the
|
|
15
|
+
* `geometry:allow-dom-fallback` discipline: geometry facet first, DOM
|
|
16
|
+
* `getBoundingClientRect` only when the facet can't resolve. The helper
|
|
17
|
+
* inherits that discipline so the per-keystroke path never measures DOM
|
|
18
|
+
* (performance invariant 7); DOM reads fire only on the cold-open
|
|
19
|
+
* branch before the render kernel's first frame.
|
|
20
|
+
*
|
|
21
|
+
* Focus preservation: `EditorView.updateState` already preserves DOM
|
|
22
|
+
* focus on the view's root when the prior state was focused. This helper
|
|
23
|
+
* does not force focus back — it only captures `hadFocus` for
|
|
24
|
+
* observability so tests can assert focus invariants externally.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { EditorView } from "prosemirror-view";
|
|
28
|
+
|
|
29
|
+
import type { GeometryFacet } from "../../api/public-types.ts";
|
|
30
|
+
import {
|
|
31
|
+
findScrollAnchor,
|
|
32
|
+
restoreScrollAnchor,
|
|
33
|
+
type ScrollAnchor,
|
|
34
|
+
} from "./scroll-anchor.ts";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Scroll-container discovery selector — matches the shell's scroll root
|
|
38
|
+
* marker. Kept in sync with the selector used by
|
|
39
|
+
* `tw-prosemirror-surface.tsx` selection-toolbar anchoring and
|
|
40
|
+
* `tw-review-workspace.tsx` mode-toggle anchoring.
|
|
41
|
+
*/
|
|
42
|
+
const SCROLL_ROOT_SELECTOR = "[data-wre-scroll-root='true']";
|
|
43
|
+
|
|
44
|
+
export interface PreservePositionOptions {
|
|
45
|
+
/** The PM view whose state is about to be replaced. */
|
|
46
|
+
view: EditorView;
|
|
47
|
+
/**
|
|
48
|
+
* Geometry facet from the runtime. When supplied, capture + restore
|
|
49
|
+
* read block rects from the render kernel instead of the DOM (warm
|
|
50
|
+
* path — no layout thrashing).
|
|
51
|
+
*/
|
|
52
|
+
geometryFacet?: GeometryFacet;
|
|
53
|
+
/**
|
|
54
|
+
* Explicit scroll-container override. Defaults to
|
|
55
|
+
* `view.dom.closest("[data-wre-scroll-root='true']")`. Tests pass an
|
|
56
|
+
* element directly to avoid the DOM lookup.
|
|
57
|
+
*/
|
|
58
|
+
scrollRoot?: HTMLElement | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PreservedPosition {
|
|
62
|
+
scrollRoot: HTMLElement | null;
|
|
63
|
+
anchor: ScrollAnchor | null;
|
|
64
|
+
/** `scrollRoot.scrollTop` at capture time. Retained for regression-test assertions. */
|
|
65
|
+
scrollTop: number;
|
|
66
|
+
/** Whether the view held DOM focus at capture time. Observability only. */
|
|
67
|
+
hadFocus: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Capture the scroll-anchor state of the view's scroll container.
|
|
72
|
+
*
|
|
73
|
+
* Returns a record with `anchor: null` when no `[data-block-id]`
|
|
74
|
+
* descendants exist (typical for empty docs or pre-mount), which makes
|
|
75
|
+
* the paired `restorePosition` call a graceful no-op.
|
|
76
|
+
*/
|
|
77
|
+
export function capturePosition(
|
|
78
|
+
options: PreservePositionOptions,
|
|
79
|
+
): PreservedPosition {
|
|
80
|
+
const scrollRoot = resolveScrollRoot(options);
|
|
81
|
+
const anchor = findScrollAnchor(scrollRoot, {
|
|
82
|
+
geometryFacet: options.geometryFacet,
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
scrollRoot,
|
|
86
|
+
anchor,
|
|
87
|
+
scrollTop: scrollRoot ? scrollRoot.scrollTop : 0,
|
|
88
|
+
hadFocus: options.view.hasFocus(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Restore the scroll container's position so the anchor block sits at
|
|
94
|
+
* the same viewport-Y it had at capture time.
|
|
95
|
+
*
|
|
96
|
+
* Graceful no-op when the scroll root is gone, when no anchor was
|
|
97
|
+
* captured, or when the anchor's block no longer exists in the new
|
|
98
|
+
* document (e.g. the block was deleted by the replacement). The
|
|
99
|
+
* scroll-anchor helpers in scroll-anchor.ts handle each of these cases
|
|
100
|
+
* internally.
|
|
101
|
+
*/
|
|
102
|
+
export function restorePosition(
|
|
103
|
+
captured: PreservedPosition,
|
|
104
|
+
options: PreservePositionOptions,
|
|
105
|
+
): void {
|
|
106
|
+
if (!captured.scrollRoot || !captured.anchor) return;
|
|
107
|
+
restoreScrollAnchor(captured.scrollRoot, captured.anchor, {
|
|
108
|
+
geometryFacet: options.geometryFacet,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Execute `fn` with position preservation. Captures the scroll-anchor
|
|
114
|
+
* before `fn` runs, invokes `fn` synchronously, then restores the
|
|
115
|
+
* anchor so the user's viewport lands on the same block.
|
|
116
|
+
*
|
|
117
|
+
* Returns `fn`'s return value so callers can thread state through
|
|
118
|
+
* without a wrapping closure.
|
|
119
|
+
*
|
|
120
|
+
* Intentionally synchronous — async variants would require a
|
|
121
|
+
* two-step microtask dance we don't need today. When a caller needs a
|
|
122
|
+
* deferred restore (e.g. to wait for a second paint), use
|
|
123
|
+
* `capturePosition` / `restorePosition` directly and schedule the
|
|
124
|
+
* restore via `requestAnimationFrame` like `tw-review-workspace.tsx`
|
|
125
|
+
* does for the mode toggle.
|
|
126
|
+
*/
|
|
127
|
+
export function preservePosition<T>(
|
|
128
|
+
options: PreservePositionOptions,
|
|
129
|
+
fn: () => T,
|
|
130
|
+
): T {
|
|
131
|
+
const captured = capturePosition(options);
|
|
132
|
+
const result = fn();
|
|
133
|
+
restorePosition(captured, options);
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function resolveScrollRoot(
|
|
138
|
+
options: PreservePositionOptions,
|
|
139
|
+
): HTMLElement | null {
|
|
140
|
+
if (options.scrollRoot !== undefined) return options.scrollRoot;
|
|
141
|
+
const dom = options.view.dom;
|
|
142
|
+
if (!(dom instanceof HTMLElement)) return null;
|
|
143
|
+
return dom.closest<HTMLElement>(SCROLL_ROOT_SELECTOR);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Mutable ref shape for the echo-suppression flag. Kept minimal so
|
|
148
|
+
* callers can pass a `useRef` object or a stub in tests without
|
|
149
|
+
* importing React types here.
|
|
150
|
+
*/
|
|
151
|
+
export interface EchoSuppressionRef {
|
|
152
|
+
current: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface ReplaceStateOptions extends PreservePositionOptions {
|
|
156
|
+
/**
|
|
157
|
+
* Ref whose `.current` is read by the selection-sync plugin. The
|
|
158
|
+
* helper sets it to `true` before the replacement and releases it to
|
|
159
|
+
* `false` inside a microtask that runs AFTER the state swap — the
|
|
160
|
+
* ordering invariant tested by
|
|
161
|
+
* `preserve-position-ordering.test.ts`.
|
|
162
|
+
*/
|
|
163
|
+
suppressionRef: EchoSuppressionRef;
|
|
164
|
+
/**
|
|
165
|
+
* Microtask scheduler. Defaults to the global `queueMicrotask`.
|
|
166
|
+
* Tests override it to capture the release callback.
|
|
167
|
+
*/
|
|
168
|
+
scheduleMicrotask?: (callback: () => void) => void;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Replace the view's state with `newState` while preserving the user's
|
|
173
|
+
* scroll position AND suppressing the selection-sync echo that would
|
|
174
|
+
* otherwise fire when PM dispatches its internal selection-change
|
|
175
|
+
* notifications during the swap.
|
|
176
|
+
*
|
|
177
|
+
* Ordering invariant (regression-guarded):
|
|
178
|
+
*
|
|
179
|
+
* 1. capture scroll anchor
|
|
180
|
+
* 2. suppressionRef.current = true
|
|
181
|
+
* 3. view.updateState(newState) ← PM may fire selection events here
|
|
182
|
+
* 4. restore scroll anchor
|
|
183
|
+
* 5. queueMicrotask(() => suppressionRef.current = false)
|
|
184
|
+
*
|
|
185
|
+
* The microtask release guarantees the flag is still `true` for any
|
|
186
|
+
* synchronous selection-change handler that fires during (3), and
|
|
187
|
+
* false by the time any subsequent user-initiated selection change
|
|
188
|
+
* reaches the selection-sync plugin.
|
|
189
|
+
*
|
|
190
|
+
* Using `queueMicrotask` rather than `setTimeout(..., 0)` /
|
|
191
|
+
* `requestAnimationFrame` is deliberate — a later scheduler would
|
|
192
|
+
* leave the flag stuck true across a macrotask boundary, causing
|
|
193
|
+
* legitimate post-swap selection changes to be swallowed.
|
|
194
|
+
*/
|
|
195
|
+
export function replaceStatePreservingPosition(
|
|
196
|
+
options: ReplaceStateOptions,
|
|
197
|
+
newState: import("prosemirror-state").EditorState,
|
|
198
|
+
): void {
|
|
199
|
+
const preserved = capturePosition(options);
|
|
200
|
+
options.suppressionRef.current = true;
|
|
201
|
+
options.view.updateState(newState);
|
|
202
|
+
restorePosition(preserved, options);
|
|
203
|
+
const release = () => {
|
|
204
|
+
options.suppressionRef.current = false;
|
|
205
|
+
};
|
|
206
|
+
if (options.scheduleMicrotask) {
|
|
207
|
+
options.scheduleMicrotask(release);
|
|
208
|
+
} else {
|
|
209
|
+
queueMicrotask(release);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -143,11 +143,14 @@ export function restoreScrollAnchor(
|
|
|
143
143
|
const geometry = options.geometryFacet.getBlock(anchor.blockId);
|
|
144
144
|
if (geometry && geometry.rects.length > 0) {
|
|
145
145
|
const rect = geometry.rects[0]!;
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
146
|
+
// `offsetWithinBlock = viewportTopFramePx - blockTop` at capture
|
|
147
|
+
// time (see `findScrollAnchor` above), i.e. how far INTO the
|
|
148
|
+
// block the viewport top sat. To land the viewport at the same
|
|
149
|
+
// relative point inside the block in the new frame:
|
|
150
|
+
// newScrollTop = newBlockTop + offsetWithinBlock.
|
|
151
|
+
// Matches the DOM-path formula below (round-trip verified by
|
|
152
|
+
// `test/ui/mode-toggle-scroll-anchor.test.ts`).
|
|
153
|
+
root.scrollTop = rect.topPx + anchor.offsetWithinBlock;
|
|
151
154
|
return;
|
|
152
155
|
}
|
|
153
156
|
// No block match through facet; fall through to DOM path.
|
|
@@ -124,6 +124,9 @@ export function computeTabWidthsInPoints(
|
|
|
124
124
|
if (stop) {
|
|
125
125
|
const prevPos = tabIndex > 0 ? readPos(rawStops[tabIndex - 1]) : 0;
|
|
126
126
|
const widthTwips = readPos(stop) - prevPos;
|
|
127
|
+
// `> 0` (not `>= 0`) is intentional — a zero-width zone between
|
|
128
|
+
// two identical stops is meaningless; skip so the caller falls
|
|
129
|
+
// back to the 32 px default.
|
|
127
130
|
if (widthTwips > 0) {
|
|
128
131
|
widths.set(seg.segmentId, widthTwips / 20);
|
|
129
132
|
}
|
|
@@ -254,10 +257,26 @@ export function buildParagraphStyle(
|
|
|
254
257
|
// `<w:framePr>` out-of-flow frame (ECMA-376 §17.3.1.11). L04 returns 0
|
|
255
258
|
// from measureBlockHeight for these paragraphs (a298391e) so the inline
|
|
256
259
|
// flow doesn't double-count; L11 renders them absolutely positioned.
|
|
257
|
-
//
|
|
258
|
-
//
|
|
260
|
+
//
|
|
261
|
+
// Gating rules (M3 review finding):
|
|
262
|
+
// - Drop-cap (`dropCap` "drop" | "margin") is in-flow — only the
|
|
263
|
+
// initial letter is framed; paragraph body stays with its text.
|
|
264
|
+
// - A `<w:framePr/>` with ZERO positional fields (no xTwips / yTwips
|
|
265
|
+
// / xAlign / yAlign) is ambiguous; Word treats it as in-flow. If
|
|
266
|
+
// we emitted `position: absolute` we'd pin the paragraph at (0,0)
|
|
267
|
+
// of the nearest positioned ancestor — dramatically wrong.
|
|
259
268
|
const framePr = block.frameProperties;
|
|
260
|
-
|
|
269
|
+
const hasPosition =
|
|
270
|
+
typeof framePr?.xTwips === "number" ||
|
|
271
|
+
typeof framePr?.yTwips === "number" ||
|
|
272
|
+
typeof framePr?.xAlign === "string" ||
|
|
273
|
+
typeof framePr?.yAlign === "string";
|
|
274
|
+
if (
|
|
275
|
+
framePr &&
|
|
276
|
+
framePr.dropCap !== "drop" &&
|
|
277
|
+
framePr.dropCap !== "margin" &&
|
|
278
|
+
hasPosition
|
|
279
|
+
) {
|
|
261
280
|
style.position = "absolute";
|
|
262
281
|
if (typeof framePr.xTwips === "number") {
|
|
263
282
|
style.left = `${framePr.xTwips / 20}pt`;
|