@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.
@@ -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
- pageRange: { fromPageIndex: number; toPageIndex: number };
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
- const anchorIndex = buildAnchorIndex(renderPages, pendingDeltas, zoom.pxPerTwip);
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
- // Pending-op deltas are read here as a seam for a future decoration-
676
- // resolver-driven rect shift; today the kernel does not mutate rects
677
- // during the predicted-dispatch window because the correct reconciliation
678
- // belongs to R4's decoration resolver (it needs per-line width info the
679
- // MVP doesn't surface yet). The deltas are still read so consumers can
680
- // rely on the accessor shape landing now.
681
- void pendingDeltas;
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 exact = byRuntimeOffset.get(offset);
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 - offset);
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 < lo || key >= hi) continue;
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