@beyondwork/docx-react-component 1.0.50 → 1.0.52
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/README.md +8 -5
- package/package.json +40 -29
- package/src/api/public-types.ts +9 -0
- package/src/runtime/layout/layout-engine-version.ts +42 -1
- package/src/runtime/layout/layout-invalidation.ts +62 -5
- package/src/runtime/layout/page-graph.ts +94 -1
- package/src/runtime/render/index.ts +7 -0
- package/src/runtime/render/render-frame-diff.ts +298 -0
- package/src/runtime/render/render-frame-types.ts +8 -1
- package/src/runtime/render/render-kernel.ts +40 -10
- package/src/runtime/selection/cursor-ops.ts +202 -0
- package/src/runtime/selection/index.ts +91 -0
- package/src/runtime/surface-projection.ts +10 -1
- package/src/runtime/theme-color-resolver.ts +46 -0
- package/src/ui/WordReviewEditor.tsx +3 -0
- package/src/ui-tailwind/chart/layout/axis-layout.ts +344 -0
- package/src/ui-tailwind/chart/layout/plot-area.ts +344 -0
- package/src/ui-tailwind/chart/render/number-format.ts +287 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-frame diff (P10 Phase B).
|
|
3
|
+
*
|
|
4
|
+
* Computes a structural diff between two `RenderFrame`s so the page-stack
|
|
5
|
+
* view can skip React reconciliation on pages whose content, geometry,
|
|
6
|
+
* and decoration anchors are unchanged. The diff is a pure function over
|
|
7
|
+
* the two frames — it performs no DOM reads, holds no state, and can run
|
|
8
|
+
* in a worker-less main-thread slice safely.
|
|
9
|
+
*
|
|
10
|
+
* Critical: comparison is structural (blockId + rounded bbox +
|
|
11
|
+
* decoration-ref intersection), NEVER reference equality. The layout
|
|
12
|
+
* engine's `buildPageStackFrom` rebuilds fragment objects on every call,
|
|
13
|
+
* so reference compare would report every page as dirty and collapse the
|
|
14
|
+
* skip-render optimization.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { PublicPageRegion } from "../layout/public-facet.ts";
|
|
18
|
+
import type {
|
|
19
|
+
RenderBlock,
|
|
20
|
+
RenderFrame,
|
|
21
|
+
RenderFrameRect,
|
|
22
|
+
RenderPage,
|
|
23
|
+
RenderStoryRegion,
|
|
24
|
+
DecorationIndex,
|
|
25
|
+
} from "./render-frame-types.ts";
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Public shape
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export type RegionKind = PublicPageRegion["kind"];
|
|
32
|
+
|
|
33
|
+
export interface ChangedPageEntry {
|
|
34
|
+
pageIndex: number;
|
|
35
|
+
/**
|
|
36
|
+
* Regions of the page that carry structural changes. Empty means the
|
|
37
|
+
* page itself changed (added / removed region or page-frame geometry)
|
|
38
|
+
* without a single region being identifiable.
|
|
39
|
+
*/
|
|
40
|
+
regions: readonly {
|
|
41
|
+
kind: RegionKind;
|
|
42
|
+
/**
|
|
43
|
+
* Block ids whose bbox, kind, decoration refs, or membership
|
|
44
|
+
* differs between prev and next. `"<added>"` and `"<removed>"`
|
|
45
|
+
* sentinel ids indicate list-length changes.
|
|
46
|
+
*/
|
|
47
|
+
changedBlockIds: readonly string[];
|
|
48
|
+
}[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RenderFrameDiff {
|
|
52
|
+
/** Page indices present in `next` but not `prev`. */
|
|
53
|
+
addedPages: readonly number[];
|
|
54
|
+
/** Page indices present in `prev` but not `next`. */
|
|
55
|
+
removedPages: readonly number[];
|
|
56
|
+
/** Pages present in both whose content is structurally equal. */
|
|
57
|
+
unchangedPages: readonly number[];
|
|
58
|
+
/** Pages present in both with at least one structural change. */
|
|
59
|
+
changedPages: readonly ChangedPageEntry[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Convenience flag: a diff is empty when both frames project the same
|
|
64
|
+
* set of pages and every shared page is unchanged.
|
|
65
|
+
*/
|
|
66
|
+
export function isEmptyDiff(diff: RenderFrameDiff): boolean {
|
|
67
|
+
return (
|
|
68
|
+
diff.addedPages.length === 0 &&
|
|
69
|
+
diff.removedPages.length === 0 &&
|
|
70
|
+
diff.changedPages.length === 0
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Diff entry point
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export function diffRenderFrames(
|
|
79
|
+
prev: RenderFrame | null,
|
|
80
|
+
next: RenderFrame,
|
|
81
|
+
): RenderFrameDiff {
|
|
82
|
+
if (!prev) {
|
|
83
|
+
return {
|
|
84
|
+
addedPages: next.pages.map((p) => p.page.pageIndex),
|
|
85
|
+
removedPages: [],
|
|
86
|
+
unchangedPages: [],
|
|
87
|
+
changedPages: [],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const prevByIndex = new Map<number, RenderPage>();
|
|
92
|
+
for (const page of prev.pages) {
|
|
93
|
+
prevByIndex.set(page.page.pageIndex, page);
|
|
94
|
+
}
|
|
95
|
+
const nextIndices = new Set<number>();
|
|
96
|
+
for (const page of next.pages) {
|
|
97
|
+
nextIndices.add(page.page.pageIndex);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const addedPages: number[] = [];
|
|
101
|
+
const removedPages: number[] = [];
|
|
102
|
+
const unchangedPages: number[] = [];
|
|
103
|
+
const changedPages: ChangedPageEntry[] = [];
|
|
104
|
+
|
|
105
|
+
for (const nextPage of next.pages) {
|
|
106
|
+
const pageIndex = nextPage.page.pageIndex;
|
|
107
|
+
const prevPage = prevByIndex.get(pageIndex);
|
|
108
|
+
if (!prevPage) {
|
|
109
|
+
addedPages.push(pageIndex);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const regions = diffPage(prevPage, nextPage, prev.decorationIndex, next.decorationIndex);
|
|
113
|
+
if (regions.length === 0 && rectEquals(prevPage.frame, nextPage.frame)) {
|
|
114
|
+
unchangedPages.push(pageIndex);
|
|
115
|
+
} else {
|
|
116
|
+
changedPages.push({ pageIndex, regions });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const prevPage of prev.pages) {
|
|
121
|
+
if (!nextIndices.has(prevPage.page.pageIndex)) {
|
|
122
|
+
removedPages.push(prevPage.page.pageIndex);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { addedPages, removedPages, unchangedPages, changedPages };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Page / region / block compare
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
function diffPage(
|
|
134
|
+
prev: RenderPage,
|
|
135
|
+
next: RenderPage,
|
|
136
|
+
prevIndex: DecorationIndex,
|
|
137
|
+
nextIndex: DecorationIndex,
|
|
138
|
+
): ChangedPageEntry["regions"] {
|
|
139
|
+
const changed: ChangedPageEntry["regions"][number][] = [];
|
|
140
|
+
|
|
141
|
+
const bodyChanges = diffRegion(prev.regions.body, next.regions.body, prevIndex, nextIndex);
|
|
142
|
+
if (bodyChanges.length > 0) {
|
|
143
|
+
changed.push({ kind: "body", changedBlockIds: bodyChanges });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const headerChanges = diffOptionalRegion(
|
|
147
|
+
prev.regions.header,
|
|
148
|
+
next.regions.header,
|
|
149
|
+
prevIndex,
|
|
150
|
+
nextIndex,
|
|
151
|
+
);
|
|
152
|
+
if (headerChanges.length > 0) {
|
|
153
|
+
changed.push({ kind: "header", changedBlockIds: headerChanges });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const footerChanges = diffOptionalRegion(
|
|
157
|
+
prev.regions.footer,
|
|
158
|
+
next.regions.footer,
|
|
159
|
+
prevIndex,
|
|
160
|
+
nextIndex,
|
|
161
|
+
);
|
|
162
|
+
if (footerChanges.length > 0) {
|
|
163
|
+
changed.push({ kind: "footer", changedBlockIds: footerChanges });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const prevFoot = prev.regions.footnotes ?? [];
|
|
167
|
+
const nextFoot = next.regions.footnotes ?? [];
|
|
168
|
+
if (prevFoot.length !== nextFoot.length) {
|
|
169
|
+
changed.push({ kind: "footnote-area", changedBlockIds: ["<count-changed>"] });
|
|
170
|
+
} else {
|
|
171
|
+
for (let i = 0; i < prevFoot.length; i += 1) {
|
|
172
|
+
const fChanges = diffRegion(prevFoot[i]!, nextFoot[i]!, prevIndex, nextIndex);
|
|
173
|
+
if (fChanges.length > 0) {
|
|
174
|
+
changed.push({ kind: "footnote-area", changedBlockIds: fChanges });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return changed;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function diffOptionalRegion(
|
|
183
|
+
prev: RenderStoryRegion | undefined,
|
|
184
|
+
next: RenderStoryRegion | undefined,
|
|
185
|
+
prevIndex: DecorationIndex,
|
|
186
|
+
nextIndex: DecorationIndex,
|
|
187
|
+
): readonly string[] {
|
|
188
|
+
if (!prev && !next) return [];
|
|
189
|
+
if (!prev && next) return ["<added>"];
|
|
190
|
+
if (prev && !next) return ["<removed>"];
|
|
191
|
+
return diffRegion(prev!, next!, prevIndex, nextIndex);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function diffRegion(
|
|
195
|
+
prev: RenderStoryRegion,
|
|
196
|
+
next: RenderStoryRegion,
|
|
197
|
+
prevIndex: DecorationIndex,
|
|
198
|
+
nextIndex: DecorationIndex,
|
|
199
|
+
): readonly string[] {
|
|
200
|
+
const changed: string[] = [];
|
|
201
|
+
if (!rectEquals(prev.frame, next.frame)) {
|
|
202
|
+
changed.push("<region-frame>");
|
|
203
|
+
}
|
|
204
|
+
// Index blocks by blockId. Same blockId across frames is expected when a
|
|
205
|
+
// block is stable even if the containing RenderBlock object is
|
|
206
|
+
// different — this is the critical structural-compare invariant.
|
|
207
|
+
const prevBlocks = new Map<string, RenderBlock>();
|
|
208
|
+
for (const block of prev.blocks) {
|
|
209
|
+
prevBlocks.set(block.fragment.blockId, block);
|
|
210
|
+
}
|
|
211
|
+
const nextIds = new Set<string>();
|
|
212
|
+
for (const block of next.blocks) {
|
|
213
|
+
nextIds.add(block.fragment.blockId);
|
|
214
|
+
const prevBlock = prevBlocks.get(block.fragment.blockId);
|
|
215
|
+
if (!prevBlock) {
|
|
216
|
+
changed.push(block.fragment.blockId);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!blocksStructurallyEqual(prevBlock, block, prevIndex, nextIndex)) {
|
|
220
|
+
changed.push(block.fragment.blockId);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for (const blockId of prevBlocks.keys()) {
|
|
224
|
+
if (!nextIds.has(blockId)) changed.push(blockId);
|
|
225
|
+
}
|
|
226
|
+
return changed;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function blocksStructurallyEqual(
|
|
230
|
+
a: RenderBlock,
|
|
231
|
+
b: RenderBlock,
|
|
232
|
+
aIndex: DecorationIndex,
|
|
233
|
+
bIndex: DecorationIndex,
|
|
234
|
+
): boolean {
|
|
235
|
+
if (a.kind !== b.kind) return false;
|
|
236
|
+
if (a.fragment.regionKind !== b.fragment.regionKind) return false;
|
|
237
|
+
if (a.fragment.from !== b.fragment.from) return false;
|
|
238
|
+
if (a.fragment.to !== b.fragment.to) return false;
|
|
239
|
+
if (!rectEquals(a.frame, b.frame)) return false;
|
|
240
|
+
if (a.lines.length !== b.lines.length) return false;
|
|
241
|
+
for (let i = 0; i < a.lines.length; i += 1) {
|
|
242
|
+
if (!rectEquals(a.lines[i]!.frame, b.lines[i]!.frame)) return false;
|
|
243
|
+
}
|
|
244
|
+
// Decoration-ref intersection: a block's decoration set changes when
|
|
245
|
+
// any lane in the decoration index mentions a rect whose frame equals
|
|
246
|
+
// this block's frame (coarse but sufficient for skip-render). A more
|
|
247
|
+
// precise walk would key by runtime-offset overlap; we defer that to a
|
|
248
|
+
// follow-up once decoration-resolver exposes per-block lanes directly.
|
|
249
|
+
const aHash = decorationHashForBlock(a.frame, aIndex);
|
|
250
|
+
const bHash = decorationHashForBlock(b.frame, bIndex);
|
|
251
|
+
if (aHash !== bHash) return false;
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function decorationHashForBlock(
|
|
256
|
+
blockFrame: RenderFrameRect,
|
|
257
|
+
index: DecorationIndex,
|
|
258
|
+
): string {
|
|
259
|
+
const tokens: string[] = [];
|
|
260
|
+
for (const lane of [index.workflow, index.comments, index.revisions, index.search, index.locked] as const) {
|
|
261
|
+
for (const entry of lane) {
|
|
262
|
+
if (rectIntersects(entry.frame, blockFrame)) {
|
|
263
|
+
tokens.push(`${entry.kind}:${entry.refId}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
tokens.sort();
|
|
268
|
+
return tokens.join("|");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Geometry compare
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Compare two rects with sub-pixel tolerance so rounding differences
|
|
277
|
+
* between frame builds don't report spurious changes. 0.1 px matches the
|
|
278
|
+
* smallest meaningful chrome movement at 200% zoom.
|
|
279
|
+
*/
|
|
280
|
+
const RECT_EPS = 0.1;
|
|
281
|
+
|
|
282
|
+
function rectEquals(a: RenderFrameRect, b: RenderFrameRect): boolean {
|
|
283
|
+
return (
|
|
284
|
+
Math.abs(a.leftPx - b.leftPx) < RECT_EPS &&
|
|
285
|
+
Math.abs(a.topPx - b.topPx) < RECT_EPS &&
|
|
286
|
+
Math.abs(a.widthPx - b.widthPx) < RECT_EPS &&
|
|
287
|
+
Math.abs(a.heightPx - b.heightPx) < RECT_EPS
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function rectIntersects(a: RenderFrameRect, b: RenderFrameRect): boolean {
|
|
292
|
+
return !(
|
|
293
|
+
a.leftPx + a.widthPx <= b.leftPx ||
|
|
294
|
+
b.leftPx + b.widthPx <= a.leftPx ||
|
|
295
|
+
a.topPx + a.heightPx <= b.topPx ||
|
|
296
|
+
b.topPx + b.heightPx <= a.topPx
|
|
297
|
+
);
|
|
298
|
+
}
|
|
@@ -294,7 +294,14 @@ export type RenderKernelEvent =
|
|
|
294
294
|
| {
|
|
295
295
|
kind: "frame_diff";
|
|
296
296
|
revision: number;
|
|
297
|
-
|
|
297
|
+
/**
|
|
298
|
+
* Full structural diff between the previously-cached frame and the
|
|
299
|
+
* newly-built frame. Shipped with P10 Phase B (2026-04-19).
|
|
300
|
+
* Consumers read `diff.unchangedPages` to skip per-page React
|
|
301
|
+
* reconciliation and `diff.changedPages[].regions[].changedBlockIds`
|
|
302
|
+
* to drive targeted sub-tree updates.
|
|
303
|
+
*/
|
|
304
|
+
diff: import("./render-frame-diff.ts").RenderFrameDiff;
|
|
298
305
|
}
|
|
299
306
|
| { kind: "decoration_resolved"; revision: number };
|
|
300
307
|
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
type SearchMatchRange,
|
|
37
37
|
} from "./decoration-resolver.ts";
|
|
38
38
|
import { classifyBlockKind as classifyBlockKindFromId } from "./block-fragment-projection.ts";
|
|
39
|
+
import { diffRenderFrames } from "./render-frame-diff.ts";
|
|
39
40
|
import {
|
|
40
41
|
EMPTY_DECORATION_INDEX,
|
|
41
42
|
defaultChromeReservations,
|
|
@@ -141,6 +142,11 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
|
|
|
141
142
|
const getActiveStory = input.getActiveStory ?? (() => MAIN_STORY_TARGET);
|
|
142
143
|
let zoom: RenderZoom = input.initialZoom ?? resolveDefaultZoom();
|
|
143
144
|
let cache: { revision: number; frame: RenderFrame } | null = null;
|
|
145
|
+
// P10 Phase B — retained across `invalidate()` and `cache = null` so
|
|
146
|
+
// `frame_diff` can compute a meaningful diff when the next build
|
|
147
|
+
// produces a structurally-equal frame. Distinct from `cache`, which
|
|
148
|
+
// is an optimizer for repeat reads at the same revision.
|
|
149
|
+
let lastEmittedFrame: RenderFrame | null = null;
|
|
144
150
|
|
|
145
151
|
const listeners = new Set<(event: RenderKernelEvent) => void>();
|
|
146
152
|
const unsubscribeFacet = facet.subscribe((event) => {
|
|
@@ -254,6 +260,20 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
|
|
|
254
260
|
if (options === undefined) {
|
|
255
261
|
cache = { revision: frame.revision, frame };
|
|
256
262
|
emit({ kind: "frame_built", revision: frame.revision, reason: "full" });
|
|
263
|
+
// P10 Phase B — compute structural diff vs. the last emitted
|
|
264
|
+
// frame (retained across `invalidate()`) so page-stack consumers
|
|
265
|
+
// can skip React reconciliation on unchanged pages even after
|
|
266
|
+
// a cache invalidation. On cold start (no prior emission) the
|
|
267
|
+
// diff reports every page in `addedPages`, giving the initial
|
|
268
|
+
// mount a consistent subscription contract.
|
|
269
|
+
const diffT0 =
|
|
270
|
+
typeof performance !== "undefined" ? performance.now() : 0;
|
|
271
|
+
const diff = diffRenderFrames(lastEmittedFrame, frame);
|
|
272
|
+
if (diffT0 > 0) {
|
|
273
|
+
recordPerfSample("render.frame_diff", performance.now() - diffT0);
|
|
274
|
+
}
|
|
275
|
+
emit({ kind: "frame_diff", revision: frame.revision, diff });
|
|
276
|
+
lastEmittedFrame = frame;
|
|
257
277
|
}
|
|
258
278
|
return frame;
|
|
259
279
|
},
|
|
@@ -688,25 +708,31 @@ function buildAnchorIndex(
|
|
|
688
708
|
}
|
|
689
709
|
}
|
|
690
710
|
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
|
|
711
|
+
// P9 Phase A2 — during the predicted-dispatch window, runtime offsets
|
|
712
|
+
// passed in by chrome surfaces reflect the visible (predicted) text but
|
|
713
|
+
// the anchor-index maps are keyed on the *pre-delta* runtime offsets
|
|
714
|
+
// emitted by the last-committed page graph. Shift the lookup offset
|
|
715
|
+
// back by the sum of deltas applied at or before it so chrome rects
|
|
716
|
+
// stay aligned with the caret while the predicted text is on screen.
|
|
717
|
+
// The rect pixel positions are correct as drawn — only the offset →
|
|
718
|
+
// rect mapping needs compensation.
|
|
719
|
+
const shiftForDeltas = (offset: number): number => {
|
|
720
|
+
if (pendingDeltas.length === 0) return offset;
|
|
721
|
+
return offset - sumDeltasBefore(pendingDeltas, offset);
|
|
722
|
+
};
|
|
698
723
|
|
|
699
724
|
const resolveByRuntimeOffset = (
|
|
700
725
|
offset: number,
|
|
701
726
|
_story?: EditorStoryTarget,
|
|
702
727
|
): RenderFrameRect | null => {
|
|
703
728
|
void _story;
|
|
704
|
-
const
|
|
729
|
+
const lookup = shiftForDeltas(offset);
|
|
730
|
+
const exact = byRuntimeOffset.get(lookup);
|
|
705
731
|
if (exact) return exact;
|
|
706
732
|
let best: RenderFrameRect | null = null;
|
|
707
733
|
let bestDistance = Number.POSITIVE_INFINITY;
|
|
708
734
|
for (const [key, rect] of byRuntimeOffset) {
|
|
709
|
-
const distance = Math.abs(key -
|
|
735
|
+
const distance = Math.abs(key - lookup);
|
|
710
736
|
if (distance < bestDistance) {
|
|
711
737
|
best = rect;
|
|
712
738
|
bestDistance = distance;
|
|
@@ -734,9 +760,13 @@ function buildAnchorIndex(
|
|
|
734
760
|
if (lo === hi) {
|
|
735
761
|
return resolveByRuntimeOffset(lo, story);
|
|
736
762
|
}
|
|
763
|
+
// Shift range endpoints back to pre-delta space before iterating
|
|
764
|
+
// the anchor maps (see `shiftForDeltas` rationale above).
|
|
765
|
+
const loShifted = shiftForDeltas(lo);
|
|
766
|
+
const hiShifted = shiftForDeltas(hi);
|
|
737
767
|
let union: RenderFrameRect | null = null;
|
|
738
768
|
for (const [key, rect] of byRuntimeOffset) {
|
|
739
|
-
if (key <
|
|
769
|
+
if (key < loShifted || key >= hiShifted) continue;
|
|
740
770
|
union = unionRects(union, rect);
|
|
741
771
|
}
|
|
742
772
|
if (union) return union;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R.1 Phase 6a — SelectionLayer cursor primitives.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions over `(DocumentRootNode, CursorSelection, op) → CursorSelection`.
|
|
5
|
+
* `extend: true` keeps the anchor fixed and moves only the head — the
|
|
6
|
+
* LibreOffice `SwPaM` head/anchor split that matches Shift+arrow semantics.
|
|
7
|
+
*
|
|
8
|
+
* Positions are 0-based logical positions per the canonical story layer
|
|
9
|
+
* (`createPlainText(parseTextStory(doc))`). Scope markers are zero-width;
|
|
10
|
+
* paragraph breaks are 1 wide; text / tab / hard_break / image / opaque are 1
|
|
11
|
+
* wide. The plain-text string produced by `createPlainText` is a 1:1 mapping
|
|
12
|
+
* of those logical positions to characters, which makes position-math trivial
|
|
13
|
+
* and makes `Intl.Segmenter` (for word boundaries) a drop-in fit.
|
|
14
|
+
*
|
|
15
|
+
* What this module deliberately does NOT ship yet:
|
|
16
|
+
* - `moveUp` / `moveDown` — genuinely layout-dependent (need column tracking
|
|
17
|
+
* + line-wrap info). Follows Phase 6b once Lane 3a P9 exposes the per-run
|
|
18
|
+
* layout facet needed for column-preserving movement.
|
|
19
|
+
* - `moveLineStart` / `moveLineEnd` — same; these need soft-wrap info that
|
|
20
|
+
* the canonical story layer does not expose.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createPlainText, parseTextStory } from "../../core/schema/text-schema.ts";
|
|
24
|
+
import type { DocumentRootNode } from "../../model/canonical-document.ts";
|
|
25
|
+
|
|
26
|
+
export interface CursorSelection {
|
|
27
|
+
anchor: number;
|
|
28
|
+
head: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CursorMoveOptions {
|
|
32
|
+
/**
|
|
33
|
+
* When true, the anchor stays fixed and only the head moves — matches
|
|
34
|
+
* Shift+arrow "extend selection" semantics. When false (default), both
|
|
35
|
+
* anchor and head land at the new head, collapsing any range selection.
|
|
36
|
+
*/
|
|
37
|
+
extend?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function moveCharLeft(
|
|
41
|
+
doc: DocumentRootNode,
|
|
42
|
+
selection: CursorSelection,
|
|
43
|
+
options: CursorMoveOptions = {},
|
|
44
|
+
): CursorSelection {
|
|
45
|
+
const text = documentPlainText(doc);
|
|
46
|
+
// Word semantics (no-extend): a range selection collapses to the LEFT edge
|
|
47
|
+
// without moving; only a collapsed caret actually decrements by one.
|
|
48
|
+
if (!options.extend && selection.anchor !== selection.head) {
|
|
49
|
+
const leftEdge = Math.min(selection.anchor, selection.head);
|
|
50
|
+
return { anchor: leftEdge, head: leftEdge };
|
|
51
|
+
}
|
|
52
|
+
const currentHead = selection.head;
|
|
53
|
+
const nextHead = Math.max(0, currentHead - 1);
|
|
54
|
+
return finalize(selection, nextHead, options);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function moveCharRight(
|
|
58
|
+
doc: DocumentRootNode,
|
|
59
|
+
selection: CursorSelection,
|
|
60
|
+
options: CursorMoveOptions = {},
|
|
61
|
+
): CursorSelection {
|
|
62
|
+
const text = documentPlainText(doc);
|
|
63
|
+
// Word semantics (no-extend): a range selection collapses to the RIGHT edge
|
|
64
|
+
// without moving; only a collapsed caret actually increments by one.
|
|
65
|
+
if (!options.extend && selection.anchor !== selection.head) {
|
|
66
|
+
const rightEdge = Math.max(selection.anchor, selection.head);
|
|
67
|
+
return { anchor: rightEdge, head: rightEdge };
|
|
68
|
+
}
|
|
69
|
+
const currentHead = selection.head;
|
|
70
|
+
const nextHead = Math.min(text.length, currentHead + 1);
|
|
71
|
+
return finalize(selection, nextHead, options);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function moveParagraphStart(
|
|
75
|
+
doc: DocumentRootNode,
|
|
76
|
+
selection: CursorSelection,
|
|
77
|
+
options: CursorMoveOptions = {},
|
|
78
|
+
): CursorSelection {
|
|
79
|
+
const text = documentPlainText(doc);
|
|
80
|
+
const head = options.extend ? selection.head : selection.head;
|
|
81
|
+
// Walk backward until we find a paragraph break ('\n') or reach 0. The
|
|
82
|
+
// paragraph start is the character immediately after the break.
|
|
83
|
+
let cursor = Math.max(0, Math.min(text.length, head));
|
|
84
|
+
while (cursor > 0 && text[cursor - 1] !== "\n") {
|
|
85
|
+
cursor -= 1;
|
|
86
|
+
}
|
|
87
|
+
return finalize(selection, cursor, options);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function moveParagraphEnd(
|
|
91
|
+
doc: DocumentRootNode,
|
|
92
|
+
selection: CursorSelection,
|
|
93
|
+
options: CursorMoveOptions = {},
|
|
94
|
+
): CursorSelection {
|
|
95
|
+
const text = documentPlainText(doc);
|
|
96
|
+
const head = options.extend ? selection.head : selection.head;
|
|
97
|
+
let cursor = Math.max(0, Math.min(text.length, head));
|
|
98
|
+
while (cursor < text.length && text[cursor] !== "\n") {
|
|
99
|
+
cursor += 1;
|
|
100
|
+
}
|
|
101
|
+
return finalize(selection, cursor, options);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `Intl.Segmenter` with `granularity: "word"` returns ICU word boundaries —
|
|
106
|
+
* the standards-backed equivalent of LibreOffice's `SwBreakIt`. We walk the
|
|
107
|
+
* boundary list (cached per-`doc` call) to find the next / previous
|
|
108
|
+
* word-start relative to the head, matching Word's Ctrl+arrow behavior.
|
|
109
|
+
*
|
|
110
|
+
* Word semantics:
|
|
111
|
+
* - Ctrl+Right: jump to the end of the current word, OR to the end of the
|
|
112
|
+
* next word if already at word-end. In practice we land on the next
|
|
113
|
+
* word-boundary strictly greater than the current head.
|
|
114
|
+
* - Ctrl+Left: jump to the start of the current word, OR to the start of
|
|
115
|
+
* the previous word if already at word-start. We land on the previous
|
|
116
|
+
* word-boundary strictly less than the current head.
|
|
117
|
+
*/
|
|
118
|
+
export function moveWordRight(
|
|
119
|
+
doc: DocumentRootNode,
|
|
120
|
+
selection: CursorSelection,
|
|
121
|
+
options: CursorMoveOptions = {},
|
|
122
|
+
): CursorSelection {
|
|
123
|
+
const text = documentPlainText(doc);
|
|
124
|
+
const currentHead = options.extend
|
|
125
|
+
? selection.head
|
|
126
|
+
: Math.max(selection.anchor, selection.head);
|
|
127
|
+
const boundaries = wordBoundaries(text);
|
|
128
|
+
const nextBoundary =
|
|
129
|
+
boundaries.find((pos) => pos > currentHead) ?? text.length;
|
|
130
|
+
return finalize(selection, nextBoundary, options);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function moveWordLeft(
|
|
134
|
+
doc: DocumentRootNode,
|
|
135
|
+
selection: CursorSelection,
|
|
136
|
+
options: CursorMoveOptions = {},
|
|
137
|
+
): CursorSelection {
|
|
138
|
+
const text = documentPlainText(doc);
|
|
139
|
+
const currentHead = options.extend
|
|
140
|
+
? selection.head
|
|
141
|
+
: Math.min(selection.anchor, selection.head);
|
|
142
|
+
const boundaries = wordBoundaries(text);
|
|
143
|
+
let prevBoundary = 0;
|
|
144
|
+
for (const pos of boundaries) {
|
|
145
|
+
if (pos >= currentHead) break;
|
|
146
|
+
prevBoundary = pos;
|
|
147
|
+
}
|
|
148
|
+
return finalize(selection, prevBoundary, options);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function finalize(
|
|
152
|
+
selection: CursorSelection,
|
|
153
|
+
head: number,
|
|
154
|
+
options: CursorMoveOptions,
|
|
155
|
+
): CursorSelection {
|
|
156
|
+
if (options.extend) {
|
|
157
|
+
return { anchor: selection.anchor, head };
|
|
158
|
+
}
|
|
159
|
+
return { anchor: head, head };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function documentPlainText(doc: DocumentRootNode): string {
|
|
163
|
+
const story = parseTextStory(doc);
|
|
164
|
+
return createPlainText(story);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `Intl.Segmenter` is available in Node 16+ and all modern browsers. We lazy-
|
|
169
|
+
* init the default-locale instance; word segmentation is stateless, so one
|
|
170
|
+
* segmenter per process is fine.
|
|
171
|
+
*/
|
|
172
|
+
let cachedSegmenter: Intl.Segmenter | undefined;
|
|
173
|
+
function wordSegmenter(): Intl.Segmenter {
|
|
174
|
+
if (!cachedSegmenter) {
|
|
175
|
+
cachedSegmenter = new Intl.Segmenter(undefined, { granularity: "word" });
|
|
176
|
+
}
|
|
177
|
+
return cachedSegmenter;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Produce the sorted list of word-boundary positions in `text`. A boundary is
|
|
182
|
+
* the start of any segment whose `isWordLike` is true, plus the end-of-text.
|
|
183
|
+
* Whitespace and punctuation segments are skipped so caret jumps land on
|
|
184
|
+
* word endpoints rather than stopping at every space — matching Word's
|
|
185
|
+
* Ctrl+arrow behavior.
|
|
186
|
+
*/
|
|
187
|
+
function wordBoundaries(text: string): number[] {
|
|
188
|
+
const segments = wordSegmenter().segment(text);
|
|
189
|
+
const boundaries: number[] = [];
|
|
190
|
+
for (const segment of segments) {
|
|
191
|
+
if (segment.isWordLike) {
|
|
192
|
+
// Add both the start and the end of each word-like segment so
|
|
193
|
+
// Ctrl+Right from "hello|world" (between two words) advances to the end
|
|
194
|
+
// of "world" rather than stopping at the start.
|
|
195
|
+
boundaries.push(segment.index);
|
|
196
|
+
boundaries.push(segment.index + segment.segment.length);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Dedup + sort so callers can binary-search or linear-walk deterministically.
|
|
200
|
+
boundaries.push(text.length);
|
|
201
|
+
return Array.from(new Set(boundaries)).sort((a, b) => a - b);
|
|
202
|
+
}
|