@beyondwork/docx-react-component 1.0.49 → 1.0.51
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 -2
- package/package.json +1 -1
- package/src/api/public-types.ts +20 -1
- package/src/core/commands/index.ts +21 -0
- package/src/io/export/serialize-comments.ts +50 -5
- package/src/io/paste/word-clipboard.ts +114 -0
- package/src/runtime/document-runtime.ts +24 -1
- package/src/runtime/layout/layout-engine-version.ts +52 -1
- package/src/runtime/layout/layout-invalidation.ts +62 -5
- package/src/runtime/layout/page-graph.ts +94 -1
- package/src/runtime/layout/public-facet.ts +5 -12
- 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 +22 -1
- package/src/runtime/render/render-kernel.ts +80 -12
- package/src/runtime/selection/cursor-ops.ts +202 -0
- package/src/runtime/selection/index.ts +91 -0
- package/src/runtime/structure-ops/fragment-insert.ts +134 -0
- package/src/runtime/surface-projection.ts +10 -1
- package/src/runtime/theme-color-resolver.ts +46 -0
- package/src/ui/WordReviewEditor.tsx +4 -2
- package/src/ui/editor-runtime-boundary.ts +1 -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
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +60 -5
|
@@ -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
|
+
}
|
|
@@ -230,6 +230,20 @@ export interface RenderAnchorIndex {
|
|
|
230
230
|
tableBlockId: string,
|
|
231
231
|
rowIndex: number,
|
|
232
232
|
): RenderFrameRect | null;
|
|
233
|
+
/**
|
|
234
|
+
* Chrome-kind resolvers (P9 Phase A). Read against the frame's
|
|
235
|
+
* `decorationIndex` so chrome surfaces (scope rails, comment balloons,
|
|
236
|
+
* revision margin bars, Lane 1 R.1 SelectionLayer) query one unified
|
|
237
|
+
* API instead of reaching into `frame.decorationIndex` directly.
|
|
238
|
+
*
|
|
239
|
+
* `byScopeId` returns every rect because a single workflow scope may
|
|
240
|
+
* cover multiple pages (one `RenderBlockDecoration` per page); chrome
|
|
241
|
+
* rails read the list. `byCommentId` and `byRevisionId` return a single
|
|
242
|
+
* rect — `resolveDecorationIndex` emits one entry per thread/revision.
|
|
243
|
+
*/
|
|
244
|
+
byScopeId(scopeId: string): readonly RenderFrameRect[];
|
|
245
|
+
byCommentId(commentId: string): RenderFrameRect | null;
|
|
246
|
+
byRevisionId(revisionId: string): RenderFrameRect | null;
|
|
233
247
|
}
|
|
234
248
|
|
|
235
249
|
// ---------------------------------------------------------------------------
|
|
@@ -280,7 +294,14 @@ export type RenderKernelEvent =
|
|
|
280
294
|
| {
|
|
281
295
|
kind: "frame_diff";
|
|
282
296
|
revision: number;
|
|
283
|
-
|
|
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;
|
|
284
305
|
}
|
|
285
306
|
| { kind: "decoration_resolved"; revision: number };
|
|
286
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) => {
|
|
@@ -192,7 +198,16 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
|
|
|
192
198
|
}
|
|
193
199
|
|
|
194
200
|
const pendingDeltas = input.getPendingOpDeltas?.() ?? [];
|
|
195
|
-
|
|
201
|
+
// P9 Phase A — two-phase anchor-index build. The decoration resolver
|
|
202
|
+
// reads the anchor index to map runtime ranges to frame rects; the
|
|
203
|
+
// final anchor index then exposes chrome-kind resolvers that read
|
|
204
|
+
// back from the resolved decoration index. Rebuilding the index with
|
|
205
|
+
// the resolved decoration data avoids a post-hoc mutation seam.
|
|
206
|
+
const baseAnchorIndex = buildAnchorIndex(
|
|
207
|
+
renderPages,
|
|
208
|
+
pendingDeltas,
|
|
209
|
+
zoom.pxPerTwip,
|
|
210
|
+
);
|
|
196
211
|
const includeDecorations = options?.includeDecorations ?? true;
|
|
197
212
|
const sources = input.getDecorationSources?.();
|
|
198
213
|
const hasSources =
|
|
@@ -205,8 +220,14 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
|
|
|
205
220
|
const decorationIndex: DecorationIndex = !includeDecorations
|
|
206
221
|
? EMPTY_DECORATION_INDEX
|
|
207
222
|
: hasSources
|
|
208
|
-
? resolveDecorationIndex({ anchorIndex, ...sources })
|
|
223
|
+
? resolveDecorationIndex({ anchorIndex: baseAnchorIndex, ...sources })
|
|
209
224
|
: buildDecorationIndex(renderPages);
|
|
225
|
+
const anchorIndex = buildAnchorIndex(
|
|
226
|
+
renderPages,
|
|
227
|
+
pendingDeltas,
|
|
228
|
+
zoom.pxPerTwip,
|
|
229
|
+
decorationIndex,
|
|
230
|
+
);
|
|
210
231
|
|
|
211
232
|
// Revision: keyed off the engine's current page graph so repeated reads
|
|
212
233
|
// at the same revision return the same cached frame. We derive it
|
|
@@ -239,6 +260,20 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
|
|
|
239
260
|
if (options === undefined) {
|
|
240
261
|
cache = { revision: frame.revision, frame };
|
|
241
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;
|
|
242
277
|
}
|
|
243
278
|
return frame;
|
|
244
279
|
},
|
|
@@ -634,6 +669,7 @@ function buildAnchorIndex(
|
|
|
634
669
|
pages: readonly RenderPage[],
|
|
635
670
|
pendingDeltas: readonly PendingOpDelta[] = [],
|
|
636
671
|
pxPerTwip = 1,
|
|
672
|
+
decorationIndex: DecorationIndex = EMPTY_DECORATION_INDEX,
|
|
637
673
|
): RenderAnchorIndex {
|
|
638
674
|
const byRuntimeOffset = new Map<number, RenderFrameRect>();
|
|
639
675
|
const byFragmentId = new Map<string, RenderFrameRect>();
|
|
@@ -672,25 +708,31 @@ function buildAnchorIndex(
|
|
|
672
708
|
}
|
|
673
709
|
}
|
|
674
710
|
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
|
|
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
|
+
};
|
|
682
723
|
|
|
683
724
|
const resolveByRuntimeOffset = (
|
|
684
725
|
offset: number,
|
|
685
726
|
_story?: EditorStoryTarget,
|
|
686
727
|
): RenderFrameRect | null => {
|
|
687
728
|
void _story;
|
|
688
|
-
const
|
|
729
|
+
const lookup = shiftForDeltas(offset);
|
|
730
|
+
const exact = byRuntimeOffset.get(lookup);
|
|
689
731
|
if (exact) return exact;
|
|
690
732
|
let best: RenderFrameRect | null = null;
|
|
691
733
|
let bestDistance = Number.POSITIVE_INFINITY;
|
|
692
734
|
for (const [key, rect] of byRuntimeOffset) {
|
|
693
|
-
const distance = Math.abs(key -
|
|
735
|
+
const distance = Math.abs(key - lookup);
|
|
694
736
|
if (distance < bestDistance) {
|
|
695
737
|
best = rect;
|
|
696
738
|
bestDistance = distance;
|
|
@@ -718,9 +760,13 @@ function buildAnchorIndex(
|
|
|
718
760
|
if (lo === hi) {
|
|
719
761
|
return resolveByRuntimeOffset(lo, story);
|
|
720
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);
|
|
721
767
|
let union: RenderFrameRect | null = null;
|
|
722
768
|
for (const [key, rect] of byRuntimeOffset) {
|
|
723
|
-
if (key <
|
|
769
|
+
if (key < loShifted || key >= hiShifted) continue;
|
|
724
770
|
union = unionRects(union, rect);
|
|
725
771
|
}
|
|
726
772
|
if (union) return union;
|
|
@@ -739,6 +785,28 @@ function buildAnchorIndex(
|
|
|
739
785
|
byTableRowEdge(tableBlockId, rowIndex) {
|
|
740
786
|
return tableRowEdges.get(`${tableBlockId}:${rowIndex}`) ?? null;
|
|
741
787
|
},
|
|
788
|
+
// P9 Phase A — chrome-kind resolvers sourced from the resolved
|
|
789
|
+
// decoration index. Empty by default (the initial frame build passes
|
|
790
|
+
// `EMPTY_DECORATION_INDEX` until decoration resolution runs); the
|
|
791
|
+
// kernel re-invokes `buildAnchorIndex` with the resolved index so the
|
|
792
|
+
// final anchor index carries chrome-aware lookups.
|
|
793
|
+
byScopeId(scopeId) {
|
|
794
|
+
return decorationIndex.workflow
|
|
795
|
+
.filter((decoration) => decoration.refId === scopeId)
|
|
796
|
+
.map((decoration) => decoration.frame);
|
|
797
|
+
},
|
|
798
|
+
byCommentId(commentId) {
|
|
799
|
+
const match = decorationIndex.comments.find(
|
|
800
|
+
(decoration) => decoration.refId === commentId,
|
|
801
|
+
);
|
|
802
|
+
return match?.frame ?? null;
|
|
803
|
+
},
|
|
804
|
+
byRevisionId(revisionId) {
|
|
805
|
+
const match = decorationIndex.revisions.find(
|
|
806
|
+
(decoration) => decoration.refId === revisionId,
|
|
807
|
+
);
|
|
808
|
+
return match?.frame ?? null;
|
|
809
|
+
},
|
|
742
810
|
};
|
|
743
811
|
}
|
|
744
812
|
|