@beyondwork/docx-react-component 1.0.75 → 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/fast-text-edit-lane.ts +44 -5
- 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 +230 -0
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -5
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +49 -5
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
* 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;
|
|
172
|
+
/**
|
|
173
|
+
* Microtask scheduler. Defaults to the global `queueMicrotask`.
|
|
174
|
+
* Tests override it to capture the release callback.
|
|
175
|
+
*/
|
|
176
|
+
scheduleMicrotask?: (callback: () => void) => void;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Replace the view's state with `newState`, suppressing the
|
|
181
|
+
* selection-sync echo during the swap.
|
|
182
|
+
*
|
|
183
|
+
* Ordering invariant (regression-guarded by
|
|
184
|
+
* `preserve-position-ordering.test.ts`):
|
|
185
|
+
*
|
|
186
|
+
* 1. (optional) capture scroll anchor — gated on
|
|
187
|
+
* `preserveScrollAnchor: true` and a live `geometryFacet`
|
|
188
|
+
* 2. suppressionRef.current = true
|
|
189
|
+
* 3. view.updateState(newState) ← PM may fire selection events here
|
|
190
|
+
* 4. (optional) restore scroll anchor
|
|
191
|
+
* 5. queueMicrotask(() => suppressionRef.current = false)
|
|
192
|
+
*
|
|
193
|
+
* The microtask release guarantees the flag is still `true` for any
|
|
194
|
+
* synchronous selection-change handler that fires during (3), and
|
|
195
|
+
* false by the time any subsequent user-initiated selection change
|
|
196
|
+
* reaches the selection-sync plugin.
|
|
197
|
+
*
|
|
198
|
+
* Using `queueMicrotask` rather than `setTimeout(..., 0)` /
|
|
199
|
+
* `requestAnimationFrame` is deliberate — a later scheduler would
|
|
200
|
+
* leave the flag stuck true across a macrotask boundary, causing
|
|
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.
|
|
209
|
+
*/
|
|
210
|
+
export function replaceStatePreservingPosition(
|
|
211
|
+
options: ReplaceStateOptions,
|
|
212
|
+
newState: import("prosemirror-state").EditorState,
|
|
213
|
+
): void {
|
|
214
|
+
const preserved = options.preserveScrollAnchor
|
|
215
|
+
? capturePosition(options)
|
|
216
|
+
: null;
|
|
217
|
+
options.suppressionRef.current = true;
|
|
218
|
+
options.view.updateState(newState);
|
|
219
|
+
if (preserved) {
|
|
220
|
+
restorePosition(preserved, options);
|
|
221
|
+
}
|
|
222
|
+
const release = () => {
|
|
223
|
+
options.suppressionRef.current = false;
|
|
224
|
+
};
|
|
225
|
+
if (options.scheduleMicrotask) {
|
|
226
|
+
options.scheduleMicrotask(release);
|
|
227
|
+
} else {
|
|
228
|
+
queueMicrotask(release);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -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.
|
|
@@ -65,6 +65,7 @@ import { buildPositionMap, type PositionMap } from "./pm-position-map";
|
|
|
65
65
|
import { createLocalEditSessionState } from "./local-edit-session-state";
|
|
66
66
|
import { createFastTextEditLane } from "./fast-text-edit-lane";
|
|
67
67
|
import { createPredictedTxGate } from "./predicted-tx-gate";
|
|
68
|
+
import { replaceStatePreservingPosition } from "./preserve-position";
|
|
68
69
|
import {
|
|
69
70
|
createScopeTagRegistry,
|
|
70
71
|
type ScopeTagRegistry,
|
|
@@ -772,11 +773,35 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
772
773
|
laneRef.current = null;
|
|
773
774
|
return;
|
|
774
775
|
}
|
|
776
|
+
// Wave 1 Slice E1/E2 — lane observability.
|
|
777
|
+
//
|
|
778
|
+
// `typing.reconcile` measures the dispatch → ack window per keystroke
|
|
779
|
+
// (predicted path). `typing.divergence` fires on the
|
|
780
|
+
// structural-divergence ack kind (the rollback-all path). Both probe
|
|
781
|
+
// kinds are declared in `PerfProbeKind` and were previously
|
|
782
|
+
// unemitted — wiring them here closes the instrumentation gap so
|
|
783
|
+
// lane quality regressions show up in the standard perf summary.
|
|
784
|
+
const pendingReconcileTokens = new Map<string, string | null>();
|
|
775
785
|
laneRef.current = createFastTextEditLane({
|
|
776
786
|
session: sessionRef.current,
|
|
777
787
|
getView: () => viewRef.current,
|
|
778
788
|
getPositionMap: () => positionMapRef.current,
|
|
779
789
|
dispatchRuntimeCommand: props.dispatchRuntimeCommand,
|
|
790
|
+
probe: {
|
|
791
|
+
markPredicted(opId: string) {
|
|
792
|
+
pendingReconcileTokens.set(opId, startPerfProbe("typing.reconcile"));
|
|
793
|
+
},
|
|
794
|
+
markReconciled(opId: string, kind) {
|
|
795
|
+
const token = pendingReconcileTokens.get(opId);
|
|
796
|
+
if (token !== undefined) {
|
|
797
|
+
finishPerfProbe(token);
|
|
798
|
+
pendingReconcileTokens.delete(opId);
|
|
799
|
+
}
|
|
800
|
+
if (kind === "structural-divergence") {
|
|
801
|
+
recordPerfSample("typing.divergence");
|
|
802
|
+
}
|
|
803
|
+
},
|
|
804
|
+
},
|
|
780
805
|
suppressSelectionSync: (suppressed) => {
|
|
781
806
|
suppressSelectionEchoRef.current = suppressed;
|
|
782
807
|
},
|
|
@@ -891,11 +916,30 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
891
916
|
viewRef.current = view;
|
|
892
917
|
recordPerfSample("pm.mount");
|
|
893
918
|
} else {
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
919
|
+
// Wave 1 Slice C · the single funnel for snapshot replacement.
|
|
920
|
+
//
|
|
921
|
+
// `replaceStatePreservingPosition` encapsulates two invariants:
|
|
922
|
+
// 1. Scroll position preservation — capture the anchor block
|
|
923
|
+
// before `view.updateState`, restore scroll after, so the
|
|
924
|
+
// user's viewport doesn't jump when blocks above change
|
|
925
|
+
// height (invariant 7: geometry-facet warm path, no DOM
|
|
926
|
+
// measurement on the hot path).
|
|
927
|
+
// 2. Echo-suppression ordering — `suppressSelectionEchoRef` is
|
|
928
|
+
// set to `true` BEFORE the state swap and released in a
|
|
929
|
+
// microtask AFTER, so PM's internal selection-change events
|
|
930
|
+
// during the swap are swallowed by the selection-sync
|
|
931
|
+
// plugin.
|
|
932
|
+
//
|
|
933
|
+
// Ordering is regression-guarded by
|
|
934
|
+
// `test/ui-tailwind/editor-surface/preserve-position-ordering.test.ts`.
|
|
935
|
+
replaceStatePreservingPosition(
|
|
936
|
+
{
|
|
937
|
+
view: viewRef.current,
|
|
938
|
+
geometryFacet: props.geometryFacet,
|
|
939
|
+
suppressionRef: suppressSelectionEchoRef,
|
|
940
|
+
},
|
|
941
|
+
state,
|
|
942
|
+
);
|
|
899
943
|
}
|
|
900
944
|
documentBuildKeyRef.current = documentBuildKey;
|
|
901
945
|
applyDecorationProps(viewRef.current, positionMap);
|