@beyondwork/docx-react-component 1.0.37 → 1.0.38

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.
Files changed (74) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +319 -1
  3. package/src/core/commands/section-layout-commands.ts +58 -0
  4. package/src/core/commands/table-grid.ts +431 -0
  5. package/src/core/commands/table-structure-commands.ts +815 -55
  6. package/src/io/export/serialize-main-document.ts +2 -11
  7. package/src/io/export/serialize-numbering.ts +1 -2
  8. package/src/io/export/serialize-tables.ts +74 -0
  9. package/src/io/export/table-properties-xml.ts +139 -4
  10. package/src/io/normalize/normalize-text.ts +15 -0
  11. package/src/io/ooxml/parse-footnotes.ts +60 -0
  12. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  13. package/src/io/ooxml/parse-main-document.ts +137 -0
  14. package/src/io/ooxml/parse-tables.ts +249 -0
  15. package/src/model/canonical-document.ts +34 -0
  16. package/src/runtime/document-layout.ts +4 -2
  17. package/src/runtime/document-navigation.ts +1 -1
  18. package/src/runtime/document-runtime.ts +114 -0
  19. package/src/runtime/layout/default-page-format.ts +96 -0
  20. package/src/runtime/layout/index.ts +45 -0
  21. package/src/runtime/layout/inert-layout-facet.ts +14 -0
  22. package/src/runtime/layout/layout-engine-instance.ts +33 -23
  23. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  24. package/src/runtime/layout/page-format-catalog.ts +233 -0
  25. package/src/runtime/layout/page-graph.ts +19 -0
  26. package/src/runtime/layout/paginated-layout-engine.ts +142 -9
  27. package/src/runtime/layout/project-block-fragments.ts +91 -0
  28. package/src/runtime/layout/public-facet.ts +709 -16
  29. package/src/runtime/layout/table-render-plan.ts +229 -0
  30. package/src/runtime/render/block-fragment-projection.ts +35 -0
  31. package/src/runtime/render/decoration-resolver.ts +189 -0
  32. package/src/runtime/render/index.ts +57 -0
  33. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  34. package/src/runtime/render/render-frame-types.ts +317 -0
  35. package/src/runtime/render/render-kernel.ts +755 -0
  36. package/src/runtime/view-state.ts +67 -0
  37. package/src/runtime/workflow-markup.ts +1 -5
  38. package/src/runtime/workflow-rail-segments.ts +280 -0
  39. package/src/ui/WordReviewEditor.tsx +84 -15
  40. package/src/ui/editor-shell-view.tsx +6 -0
  41. package/src/ui/headless/chrome-registry.ts +280 -14
  42. package/src/ui/headless/scoped-chrome-policy.ts +20 -1
  43. package/src/ui/headless/selection-tool-types.ts +10 -0
  44. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  45. package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
  46. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  47. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
  48. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
  49. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  50. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  51. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
  52. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
  53. package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
  54. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
  55. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  56. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
  57. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  58. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  59. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  60. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +12 -1
  61. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  62. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +3 -0
  63. package/src/ui-tailwind/index.ts +33 -0
  64. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  65. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  66. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  67. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  68. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  69. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  70. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
  71. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  72. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  73. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +69 -0
  74. package/src/ui-tailwind/tw-review-workspace.tsx +136 -1
@@ -0,0 +1,755 @@
1
+ /**
2
+ * Render kernel (Phase R1 MVP).
3
+ *
4
+ * Projects the runtime-owned layout engine's page graph into a
5
+ * `RenderFrame` that every chrome surface reads. Keeps workflow scope
6
+ * and decoration projection minimal so consumers can land on a stable
7
+ * shape while the richer projectors (line-box per run, decoration
8
+ * resolver, frame diff, table render plan) are built out in R1 and R4.
9
+ *
10
+ * Ownership:
11
+ * - the kernel is a pure projection over the layout facet. It never
12
+ * mutates runtime or layout state.
13
+ * - it never consults the DOM. Zoom/viewport come in as input.
14
+ */
15
+
16
+ import { MAIN_STORY_TARGET } from "../../core/selection/mapping.ts";
17
+ import type { EditorStoryTarget } from "../../api/public-types";
18
+ import type {
19
+ PublicPageNode,
20
+ WordReviewEditorLayoutFacet,
21
+ } from "../layout/public-facet.ts";
22
+ import type {
23
+ CommentDecorationModel,
24
+ } from "../../ui/headless/comment-decoration-model.ts";
25
+ import type {
26
+ RevisionDecorationModel,
27
+ } from "../../ui/headless/revision-decoration-model.ts";
28
+ import type { ScopeRailSegment } from "../workflow-rail-segments.ts";
29
+ import {
30
+ resolveDecorationIndex,
31
+ type LockedRangeInput,
32
+ type SearchMatchRange,
33
+ } from "./decoration-resolver.ts";
34
+ import { classifyBlockKind as classifyBlockKindFromId } from "./block-fragment-projection.ts";
35
+ import {
36
+ EMPTY_DECORATION_INDEX,
37
+ defaultChromeReservations,
38
+ resolveDefaultZoom,
39
+ type DecorationIndex,
40
+ type PageChromeReservations,
41
+ type RenderAnchorIndex,
42
+ type RenderBlock,
43
+ type RenderFrame,
44
+ type RenderFrameQueryOptions,
45
+ type RenderFrameRect,
46
+ type RenderKernelEvent,
47
+ type RenderLine,
48
+ type RenderLineAnchor,
49
+ type RenderPage,
50
+ type RenderPageRegions,
51
+ type RenderStoryRegion,
52
+ type RenderZoom,
53
+ } from "./render-frame-types.ts";
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Kernel interface
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export interface RenderKernel {
60
+ /** Build (or return from cache) the RenderFrame for a query. */
61
+ getRenderFrame(options?: RenderFrameQueryOptions): RenderFrame;
62
+ /** Active zoom the kernel uses when no zoom is supplied. */
63
+ getZoom(): RenderZoom;
64
+ /** Swap the zoom the kernel composes frames at. */
65
+ setZoom(zoom: RenderZoom): void;
66
+ /** Subscribe to kernel-scoped events. */
67
+ subscribe(listener: (event: RenderKernelEvent) => void): () => void;
68
+ /** Invalidate the cache so the next read rebuilds. */
69
+ invalidate(): void;
70
+ }
71
+
72
+ export interface CreateRenderKernelInput {
73
+ facet: WordReviewEditorLayoutFacet;
74
+ getActiveStory?: () => EditorStoryTarget;
75
+ /** Initial zoom; see `resolveDefaultZoom`. */
76
+ initialZoom?: RenderZoom;
77
+ /**
78
+ * Optional accessor for pending predicted-op deltas. When supplied, the
79
+ * kernel shifts anchor resolution through these deltas so chrome
80
+ * overlays stay aligned with the visible (predicted) text during the
81
+ * predicted-dispatch window. Returns `+N` for insert, `-N` for delete.
82
+ */
83
+ getPendingOpDeltas?: () => readonly PendingOpDelta[];
84
+ /**
85
+ * Optional decoration sources. When any of these is supplied, the
86
+ * kernel invokes `resolveDecorationIndex` to populate
87
+ * `RenderFrame.decorationIndex` with per-decoration anchor rects so
88
+ * chrome surfaces can read them directly instead of re-projecting. When
89
+ * all are omitted, the kernel falls back to the MVP walk over
90
+ * `block.blockDecorations` (typically empty today).
91
+ */
92
+ getDecorationSources?: () => DecorationSources;
93
+ }
94
+
95
+ /**
96
+ * Canonical decoration sources the kernel aggregates into
97
+ * `DecorationIndex`. Every field is optional; unspecified lanes stay
98
+ * empty.
99
+ */
100
+ export interface DecorationSources {
101
+ workflowSegments?: readonly ScopeRailSegment[];
102
+ comments?: CommentDecorationModel | null | undefined;
103
+ revisions?: RevisionDecorationModel | null | undefined;
104
+ searchMatches?: readonly SearchMatchRange[];
105
+ lockedRanges?: readonly LockedRangeInput[];
106
+ }
107
+
108
+ export interface PendingOpDelta {
109
+ /** Runtime offset at which the op was applied. */
110
+ fromRuntime: number;
111
+ /** Signed character delta applied at that offset. */
112
+ delta: number;
113
+ }
114
+
115
+ /**
116
+ * Sum the pending-op deltas that apply at or before a runtime offset. Used
117
+ * by the kernel's anchor index to shift anchor resolution during the
118
+ * predicted-dispatch window.
119
+ */
120
+ export function sumDeltasBefore(
121
+ deltas: readonly PendingOpDelta[],
122
+ runtimeOffset: number,
123
+ ): number {
124
+ let total = 0;
125
+ for (const d of deltas) {
126
+ if (d.fromRuntime <= runtimeOffset) total += d.delta;
127
+ }
128
+ return total;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Factory
133
+ // ---------------------------------------------------------------------------
134
+
135
+ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel {
136
+ const { facet } = input;
137
+ const getActiveStory = input.getActiveStory ?? (() => MAIN_STORY_TARGET);
138
+ let zoom: RenderZoom = input.initialZoom ?? resolveDefaultZoom();
139
+ let cache: { revision: number; frame: RenderFrame } | null = null;
140
+
141
+ const listeners = new Set<(event: RenderKernelEvent) => void>();
142
+ const unsubscribeFacet = facet.subscribe((event) => {
143
+ // Any layout-changing event invalidates the cached frame. We rely on
144
+ // the frame cache check below rather than rebuilding eagerly so single
145
+ // layout events don't produce multiple kernel builds.
146
+ if (
147
+ event.kind === "layout_recomputed" ||
148
+ event.kind === "incremental_relayout" ||
149
+ event.kind === "measurement_backend_ready" ||
150
+ event.kind === "zoom_changed"
151
+ ) {
152
+ cache = null;
153
+ }
154
+ });
155
+ void unsubscribeFacet;
156
+
157
+ function emit(event: RenderKernelEvent): void {
158
+ for (const listener of listeners) {
159
+ try {
160
+ listener(event);
161
+ } catch {
162
+ // never let listener errors break the kernel
163
+ }
164
+ }
165
+ }
166
+
167
+ function buildFrame(options?: RenderFrameQueryOptions): RenderFrame {
168
+ const activeStory = options?.story ?? getActiveStory();
169
+ const measurementFidelity = facet.getMeasurementFidelity();
170
+ const rawPages = facet.getPages();
171
+ const pageRange = options?.pageRange;
172
+ const filteredPages = pageRange
173
+ ? rawPages.filter(
174
+ (p) =>
175
+ p.pageIndex >= pageRange.fromPageIndex &&
176
+ p.pageIndex <= pageRange.toPageIndex,
177
+ )
178
+ : rawPages;
179
+
180
+ // Compose pages sequentially with a running y-cursor in pixels.
181
+ let y = 0;
182
+ const renderPages: RenderPage[] = [];
183
+ for (const page of filteredPages) {
184
+ const renderPage = buildPage(page, y, zoom, activeStory, facet);
185
+ renderPages.push(renderPage);
186
+ y += renderPage.frame.heightPx + PAGE_GAP_PX;
187
+ }
188
+
189
+ const pendingDeltas = input.getPendingOpDeltas?.() ?? [];
190
+ const anchorIndex = buildAnchorIndex(renderPages, pendingDeltas, zoom.pxPerTwip);
191
+ const includeDecorations = options?.includeDecorations ?? true;
192
+ const sources = input.getDecorationSources?.();
193
+ const hasSources =
194
+ sources !== undefined &&
195
+ ((sources.workflowSegments && sources.workflowSegments.length > 0) ||
196
+ (sources.comments?.threads?.length ?? 0) > 0 ||
197
+ (sources.revisions?.revisions?.length ?? 0) > 0 ||
198
+ (sources.searchMatches && sources.searchMatches.length > 0) ||
199
+ (sources.lockedRanges && sources.lockedRanges.length > 0));
200
+ const decorationIndex: DecorationIndex = !includeDecorations
201
+ ? EMPTY_DECORATION_INDEX
202
+ : hasSources
203
+ ? resolveDecorationIndex({ anchorIndex, ...sources })
204
+ : buildDecorationIndex(renderPages);
205
+
206
+ // Revision: keyed off the engine's current page graph so repeated reads
207
+ // at the same revision return the same cached frame. We derive it
208
+ // from the first page since the engine stamps every page with the
209
+ // graph's revision indirectly via pageId; fall back to 0 if empty.
210
+ const revision = filteredPages[0]
211
+ ? Number(extractRevisionFromPageId(filteredPages[0].pageId))
212
+ : 0;
213
+
214
+ return {
215
+ revision: Number.isFinite(revision) ? revision : 0,
216
+ measurementFidelity,
217
+ activeStory,
218
+ zoom: { ...zoom },
219
+ pages: renderPages,
220
+ decorationIndex,
221
+ anchorIndex,
222
+ };
223
+ }
224
+
225
+ return {
226
+ getRenderFrame(options) {
227
+ const rebuild = options !== undefined || cache === null;
228
+ if (!rebuild && cache) {
229
+ return cache.frame;
230
+ }
231
+ const frame = buildFrame(options);
232
+ if (options === undefined) {
233
+ cache = { revision: frame.revision, frame };
234
+ emit({ kind: "frame_built", revision: frame.revision, reason: "full" });
235
+ }
236
+ return frame;
237
+ },
238
+
239
+ getZoom() {
240
+ return { ...zoom };
241
+ },
242
+
243
+ setZoom(next) {
244
+ zoom = { ...next };
245
+ cache = null;
246
+ },
247
+
248
+ subscribe(listener) {
249
+ listeners.add(listener);
250
+ return () => {
251
+ listeners.delete(listener);
252
+ };
253
+ },
254
+
255
+ invalidate() {
256
+ cache = null;
257
+ },
258
+ };
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Internals
263
+ // ---------------------------------------------------------------------------
264
+
265
+ const PAGE_GAP_PX = 16;
266
+
267
+ function buildPage(
268
+ page: PublicPageNode,
269
+ topPx: number,
270
+ zoom: RenderZoom,
271
+ activeStory: EditorStoryTarget,
272
+ facet: WordReviewEditorLayoutFacet,
273
+ ): RenderPage {
274
+ const layout = page.layout;
275
+ const widthPx = layout.pageWidth * zoom.pxPerTwip;
276
+ const heightPx = layout.pageHeight * zoom.pxPerTwip;
277
+
278
+ const frame: RenderFrameRect = {
279
+ leftPx: 0,
280
+ topPx,
281
+ widthPx,
282
+ heightPx,
283
+ };
284
+
285
+ const bodyRegion: RenderStoryRegion = buildBodyRegion(
286
+ page,
287
+ topPx,
288
+ zoom,
289
+ activeStory,
290
+ facet,
291
+ );
292
+
293
+ const regions: RenderPageRegions = {
294
+ body: bodyRegion,
295
+ };
296
+
297
+ if (page.stories.header) {
298
+ regions.header = buildHeaderFooterRegion(
299
+ page,
300
+ topPx,
301
+ zoom,
302
+ "header",
303
+ page.stories.header,
304
+ );
305
+ }
306
+ if (page.stories.footer) {
307
+ regions.footer = buildHeaderFooterRegion(
308
+ page,
309
+ topPx,
310
+ zoom,
311
+ "footer",
312
+ page.stories.footer,
313
+ );
314
+ }
315
+
316
+ const chromeReservations: PageChromeReservations = {
317
+ ...defaultChromeReservations(layout, zoom),
318
+ };
319
+
320
+ return {
321
+ page,
322
+ frame,
323
+ regions,
324
+ chromeReservations,
325
+ };
326
+ }
327
+
328
+ function buildBodyRegion(
329
+ page: PublicPageNode,
330
+ pageTopPx: number,
331
+ zoom: RenderZoom,
332
+ activeStory: EditorStoryTarget,
333
+ facet: WordReviewEditorLayoutFacet,
334
+ ): RenderStoryRegion {
335
+ const layout = page.layout;
336
+ const bodyLeftTwips = layout.marginLeft;
337
+ const bodyTopTwips = layout.marginTop;
338
+ const bodyWidthTwips = page.regions.body.widthTwips;
339
+ const bodyHeightTwips = page.regions.body.heightTwips;
340
+
341
+ const regionFrame: RenderFrameRect = {
342
+ leftPx: bodyLeftTwips * zoom.pxPerTwip,
343
+ topPx: pageTopPx + bodyTopTwips * zoom.pxPerTwip,
344
+ widthPx: bodyWidthTwips * zoom.pxPerTwip,
345
+ heightPx: bodyHeightTwips * zoom.pxPerTwip,
346
+ };
347
+
348
+ const fragments = facet.getFragmentsForPage(page.pageIndex);
349
+ const bodyLineBoxes = facet.getLineBoxes(page.pageIndex, { region: "body" });
350
+
351
+ // Current layout engine does not track per-fragment line boxes with
352
+ // baselines that have been laid out against a cursor. The kernel
353
+ // distributes height across fragments proportional to their
354
+ // `heightTwips`, giving chrome a stable anchor per block; richer
355
+ // per-line projection lands with the full kernel in later phases.
356
+ const blocks: RenderBlock[] = [];
357
+ let blockY = regionFrame.topPx;
358
+ const totalFragmentHeight = fragments.reduce(
359
+ (acc, fragment) => acc + Math.max(0, fragment.heightTwips),
360
+ 0,
361
+ );
362
+
363
+ for (const fragment of fragments) {
364
+ if (fragment.regionKind !== "body") continue;
365
+ const fragmentHeightTwips = fragment.heightTwips > 0
366
+ ? fragment.heightTwips
367
+ : totalFragmentHeight === 0
368
+ ? bodyHeightTwips / Math.max(1, fragments.length)
369
+ : 0;
370
+ const blockHeightPx = fragmentHeightTwips * zoom.pxPerTwip;
371
+ const blockFrame: RenderFrameRect = {
372
+ leftPx: regionFrame.leftPx,
373
+ topPx: blockY,
374
+ widthPx: regionFrame.widthPx,
375
+ heightPx: blockHeightPx,
376
+ };
377
+
378
+ const blockLines = bodyLineBoxes
379
+ .filter((box) => box.fragmentId === fragment.fragmentId)
380
+ .map<RenderLine>((box) => {
381
+ const lineTopPx =
382
+ pageTopPx + (bodyTopTwips + box.baselineTwips) * zoom.pxPerTwip;
383
+ const lineFrame: RenderFrameRect = {
384
+ leftPx: regionFrame.leftPx,
385
+ topPx: lineTopPx,
386
+ widthPx: Math.min(
387
+ regionFrame.widthPx,
388
+ box.widthTwips * zoom.pxPerTwip,
389
+ ),
390
+ heightPx: box.heightTwips * zoom.pxPerTwip,
391
+ };
392
+ const anchors: RenderLineAnchor[] = [
393
+ {
394
+ runtimeOffset: fragment.from,
395
+ frame: lineFrame,
396
+ fragmentId: fragment.fragmentId,
397
+ blockId: fragment.blockId,
398
+ },
399
+ ];
400
+ return {
401
+ line: box,
402
+ frame: lineFrame,
403
+ anchors,
404
+ };
405
+ });
406
+
407
+ const kind = classifyBlockKindFromId(fragment.blockId);
408
+ const tablePlan =
409
+ kind === "table"
410
+ ? facet.getTableRenderPlan(fragment.blockId, page.pageIndex)
411
+ : undefined;
412
+
413
+ blocks.push({
414
+ fragment,
415
+ frame: blockFrame,
416
+ kind,
417
+ lines: blockLines,
418
+ blockDecorations: [],
419
+ ...(tablePlan !== undefined ? { tablePlan } : {}),
420
+ });
421
+
422
+ blockY += blockHeightPx;
423
+ }
424
+
425
+ return {
426
+ storyTarget: activeStory,
427
+ region: page.regions.body,
428
+ frame: regionFrame,
429
+ blocks,
430
+ };
431
+ }
432
+
433
+ function buildHeaderFooterRegion(
434
+ page: PublicPageNode,
435
+ pageTopPx: number,
436
+ zoom: RenderZoom,
437
+ kind: "header" | "footer",
438
+ storyTarget: EditorStoryTarget,
439
+ ): RenderStoryRegion {
440
+ const layout = page.layout;
441
+ const widthTwips =
442
+ layout.pageWidth - layout.marginLeft - layout.marginRight;
443
+ let topTwips = 0;
444
+ let heightTwips = 0;
445
+ if (kind === "header") {
446
+ topTwips = layout.headerMargin ?? 720;
447
+ heightTwips = Math.max(0, layout.marginTop - topTwips);
448
+ } else {
449
+ topTwips = layout.pageHeight - layout.marginBottom;
450
+ heightTwips = Math.max(0, layout.marginBottom - (layout.footerMargin ?? 720));
451
+ }
452
+
453
+ const frame: RenderFrameRect = {
454
+ leftPx: layout.marginLeft * zoom.pxPerTwip,
455
+ topPx: pageTopPx + topTwips * zoom.pxPerTwip,
456
+ widthPx: widthTwips * zoom.pxPerTwip,
457
+ heightPx: heightTwips * zoom.pxPerTwip,
458
+ };
459
+
460
+ const region = kind === "header" ? page.regions.header : page.regions.footer;
461
+ if (!region) {
462
+ return {
463
+ storyTarget,
464
+ region: {
465
+ kind,
466
+ originTwips: topTwips,
467
+ widthTwips,
468
+ heightTwips,
469
+ fragmentCount: 0,
470
+ },
471
+ frame,
472
+ blocks: [],
473
+ };
474
+ }
475
+
476
+ return {
477
+ storyTarget,
478
+ region,
479
+ frame,
480
+ blocks: [],
481
+ };
482
+ }
483
+
484
+ // classifyBlockKind moved to `./block-fragment-projection.ts` (P4).
485
+
486
+ function buildAnchorIndex(
487
+ pages: readonly RenderPage[],
488
+ pendingDeltas: readonly PendingOpDelta[] = [],
489
+ pxPerTwip = 1,
490
+ ): RenderAnchorIndex {
491
+ const byRuntimeOffset = new Map<number, RenderFrameRect>();
492
+ const byFragmentId = new Map<string, RenderFrameRect>();
493
+ const byBlockId = new Map<string, RenderFrameRect>();
494
+ const byPageIndex = new Map<number, RenderFrameRect>();
495
+ // Per-table geometry: cell rects (key = `${blockId}:${row}:${col}`),
496
+ // column edges (key = `${blockId}:${columnIndex}`), row edges
497
+ // (key = `${blockId}:${rowIndex}`).
498
+ const tableCellRects = new Map<string, RenderFrameRect>();
499
+ const tableColumnEdges = new Map<string, RenderFrameRect>();
500
+ const tableRowEdges = new Map<string, RenderFrameRect>();
501
+
502
+ for (const page of pages) {
503
+ byPageIndex.set(page.page.pageIndex, page.frame);
504
+ for (const block of page.regions.body.blocks) {
505
+ byFragmentId.set(block.fragment.fragmentId, block.frame);
506
+ byBlockId.set(block.fragment.blockId, block.frame);
507
+ byRuntimeOffset.set(block.fragment.from, block.frame);
508
+ for (const line of block.lines) {
509
+ for (const anchor of line.anchors) {
510
+ byRuntimeOffset.set(anchor.runtimeOffset, anchor.frame);
511
+ }
512
+ }
513
+ // P4d: derive per-table cell/edge rects from the attached plan.
514
+ if (block.kind === "table" && block.tablePlan) {
515
+ recordTableAnchors(
516
+ block.fragment.blockId,
517
+ block.frame,
518
+ block.tablePlan,
519
+ pxPerTwip,
520
+ tableCellRects,
521
+ tableColumnEdges,
522
+ tableRowEdges,
523
+ );
524
+ }
525
+ }
526
+ }
527
+
528
+ // Pending-op deltas are read here as a seam for a future decoration-
529
+ // resolver-driven rect shift; today the kernel does not mutate rects
530
+ // during the predicted-dispatch window because the correct reconciliation
531
+ // belongs to R4's decoration resolver (it needs per-line width info the
532
+ // MVP doesn't surface yet). The deltas are still read so consumers can
533
+ // rely on the accessor shape landing now.
534
+ void pendingDeltas;
535
+
536
+ const resolveByRuntimeOffset = (
537
+ offset: number,
538
+ _story?: EditorStoryTarget,
539
+ ): RenderFrameRect | null => {
540
+ void _story;
541
+ const exact = byRuntimeOffset.get(offset);
542
+ if (exact) return exact;
543
+ let best: RenderFrameRect | null = null;
544
+ let bestDistance = Number.POSITIVE_INFINITY;
545
+ for (const [key, rect] of byRuntimeOffset) {
546
+ const distance = Math.abs(key - offset);
547
+ if (distance < bestDistance) {
548
+ best = rect;
549
+ bestDistance = distance;
550
+ }
551
+ }
552
+ return best;
553
+ };
554
+
555
+ return {
556
+ byRuntimeOffset(offset, story) {
557
+ return resolveByRuntimeOffset(offset, story);
558
+ },
559
+ byBlockId(blockId) {
560
+ return byBlockId.get(blockId) ?? null;
561
+ },
562
+ byFragmentId(fragmentId) {
563
+ return byFragmentId.get(fragmentId) ?? null;
564
+ },
565
+ byPageIndex(pageIndex) {
566
+ return byPageIndex.get(pageIndex) ?? null;
567
+ },
568
+ bySelection(fromOffset, toOffset, story) {
569
+ const lo = Math.min(fromOffset, toOffset);
570
+ const hi = Math.max(fromOffset, toOffset);
571
+ if (lo === hi) {
572
+ return resolveByRuntimeOffset(lo, story);
573
+ }
574
+ let union: RenderFrameRect | null = null;
575
+ for (const [key, rect] of byRuntimeOffset) {
576
+ if (key < lo || key >= hi) continue;
577
+ union = unionRects(union, rect);
578
+ }
579
+ if (union) return union;
580
+ // Fallback: union the endpoint resolutions so callers always get a
581
+ // rect when at least one endpoint is in the document.
582
+ const fromRect = resolveByRuntimeOffset(lo, story);
583
+ const toRect = resolveByRuntimeOffset(hi - 1, story);
584
+ return unionRects(fromRect, toRect);
585
+ },
586
+ byTableCell(tableBlockId, rowIndex, columnIndex) {
587
+ return tableCellRects.get(`${tableBlockId}:${rowIndex}:${columnIndex}`) ?? null;
588
+ },
589
+ byTableColumnEdge(tableBlockId, columnIndex) {
590
+ return tableColumnEdges.get(`${tableBlockId}:${columnIndex}`) ?? null;
591
+ },
592
+ byTableRowEdge(tableBlockId, rowIndex) {
593
+ return tableRowEdges.get(`${tableBlockId}:${rowIndex}`) ?? null;
594
+ },
595
+ };
596
+ }
597
+
598
+ /**
599
+ * Emit cell, column-edge, and row-edge anchor rects for a single table
600
+ * block from its attached render plan. Uses the block's frame (pixel
601
+ * coordinates at the frame's zoom) as the origin and lays out cell rects
602
+ * by summing `columnsTwips` horizontally and letting rows inherit the
603
+ * block's vertical height (row-level splits will refine this in a
604
+ * follow-up).
605
+ */
606
+ function recordTableAnchors(
607
+ tableBlockId: string,
608
+ blockFrame: RenderFrameRect,
609
+ plan: import("./render-frame-types.ts").RenderBlock["tablePlan"],
610
+ pxPerTwip: number,
611
+ cellRects: Map<string, RenderFrameRect>,
612
+ columnEdges: Map<string, RenderFrameRect>,
613
+ rowEdges: Map<string, RenderFrameRect>,
614
+ ): void {
615
+ if (!plan) return;
616
+ const columnCount = plan.columnsTwips.length;
617
+ if (columnCount === 0) return;
618
+
619
+ // Column x-positions in px, starting from the block's leftPx.
620
+ const columnLeftsPx: number[] = [blockFrame.leftPx];
621
+ for (let i = 0; i < columnCount; i += 1) {
622
+ columnLeftsPx.push(
623
+ columnLeftsPx[i]! + (plan.columnsTwips[i] ?? 0) * pxPerTwip,
624
+ );
625
+ }
626
+
627
+ // Row y-positions: even split of blockFrame height across rows (row-
628
+ // level heights require per-row pagination in a future phase).
629
+ const rowCount = Math.max(
630
+ 1,
631
+ plan.bandClasses.rows.length ||
632
+ plan.bandClasses.cells.reduce(
633
+ (max, c) => Math.max(max, c.rowIndex + 1),
634
+ 0,
635
+ ),
636
+ );
637
+ const rowHeightPx = rowCount > 0 ? blockFrame.heightPx / rowCount : blockFrame.heightPx;
638
+ const rowTopsPx: number[] = [];
639
+ for (let r = 0; r <= rowCount; r += 1) {
640
+ rowTopsPx.push(blockFrame.topPx + r * rowHeightPx);
641
+ }
642
+
643
+ // Cell rects from bandClasses.cells (single entry per logical origin).
644
+ for (const cell of plan.bandClasses.cells) {
645
+ // Determine the columnSpan for this origin by checking whether the
646
+ // next cell in the same row has a columnIndex > this + 1 (sparse
647
+ // representation: only origins are emitted).
648
+ const sameRow = plan.bandClasses.cells.filter((c) => c.rowIndex === cell.rowIndex);
649
+ const sorted = [...sameRow].sort((a, b) => a.columnIndex - b.columnIndex);
650
+ const idx = sorted.findIndex((c) => c === cell);
651
+ const next = sorted[idx + 1];
652
+ const columnSpan = (next?.columnIndex ?? columnCount) - cell.columnIndex;
653
+
654
+ const left = columnLeftsPx[cell.columnIndex] ?? blockFrame.leftPx;
655
+ const right =
656
+ columnLeftsPx[cell.columnIndex + columnSpan] ??
657
+ blockFrame.leftPx + blockFrame.widthPx;
658
+ const top = rowTopsPx[cell.rowIndex] ?? blockFrame.topPx;
659
+ const bottom =
660
+ rowTopsPx[cell.rowIndex + 1] ?? blockFrame.topPx + blockFrame.heightPx;
661
+
662
+ cellRects.set(`${tableBlockId}:${cell.rowIndex}:${cell.columnIndex}`, {
663
+ leftPx: left,
664
+ topPx: top,
665
+ widthPx: Math.max(0, right - left),
666
+ heightPx: Math.max(0, bottom - top),
667
+ });
668
+ }
669
+
670
+ // Column edges — zero-width rect at each internal column boundary.
671
+ for (const handle of plan.columnResizeHandles) {
672
+ const x = blockFrame.leftPx + handle.originTwips * pxPerTwip;
673
+ columnEdges.set(`${tableBlockId}:${handle.columnIndex}`, {
674
+ leftPx: x,
675
+ topPx: blockFrame.topPx,
676
+ widthPx: 0,
677
+ heightPx: handle.heightTwips * pxPerTwip,
678
+ });
679
+ }
680
+
681
+ // Row edges — zero-height rect at each internal row boundary.
682
+ for (let r = 0; r < rowCount - 1; r += 1) {
683
+ const y = rowTopsPx[r + 1] ?? blockFrame.topPx + blockFrame.heightPx;
684
+ rowEdges.set(`${tableBlockId}:${r}`, {
685
+ leftPx: blockFrame.leftPx,
686
+ topPx: y,
687
+ widthPx: blockFrame.widthPx,
688
+ heightPx: 0,
689
+ });
690
+ }
691
+ }
692
+
693
+ function unionRects(
694
+ a: RenderFrameRect | null,
695
+ b: RenderFrameRect | null,
696
+ ): RenderFrameRect | null {
697
+ if (!a) return b ?? null;
698
+ if (!b) return a;
699
+ const left = Math.min(a.leftPx, b.leftPx);
700
+ const top = Math.min(a.topPx, b.topPx);
701
+ const right = Math.max(a.leftPx + a.widthPx, b.leftPx + b.widthPx);
702
+ const bottom = Math.max(a.topPx + a.heightPx, b.topPx + b.heightPx);
703
+ return {
704
+ leftPx: left,
705
+ topPx: top,
706
+ widthPx: right - left,
707
+ heightPx: bottom - top,
708
+ };
709
+ }
710
+
711
+ function buildDecorationIndex(pages: readonly RenderPage[]): DecorationIndex {
712
+ // Minimum-viable decoration projection: collect block decorations directly
713
+ // off the rendered blocks. The full kernel phase adds a dedicated
714
+ // decoration-resolver that unions workflow scopes, comments, revisions,
715
+ // search matches, and locked zones into a single pass.
716
+ const workflow: import("./render-frame-types.ts").RenderBlockDecoration[] = [];
717
+ const comments: import("./render-frame-types.ts").RenderBlockDecoration[] = [];
718
+ const revisions: import("./render-frame-types.ts").RenderBlockDecoration[] = [];
719
+ const search: import("./render-frame-types.ts").RenderBlockDecoration[] = [];
720
+ const locked: import("./render-frame-types.ts").RenderBlockDecoration[] = [];
721
+
722
+ for (const page of pages) {
723
+ for (const block of page.regions.body.blocks) {
724
+ for (const decoration of block.blockDecorations) {
725
+ switch (decoration.kind) {
726
+ case "workflow":
727
+ workflow.push(decoration);
728
+ break;
729
+ case "comment":
730
+ comments.push(decoration);
731
+ break;
732
+ case "revision":
733
+ revisions.push(decoration);
734
+ break;
735
+ case "search":
736
+ search.push(decoration);
737
+ break;
738
+ case "locked":
739
+ locked.push(decoration);
740
+ break;
741
+ }
742
+ }
743
+ }
744
+ }
745
+
746
+ return { workflow, comments, revisions, search, locked };
747
+ }
748
+
749
+ function extractRevisionFromPageId(pageId: string): number | string {
750
+ // pageIds look like `page-<revision>-<index>`; extract the revision so the
751
+ // kernel's frame-level cache keys track graph revisions.
752
+ const match = /^page-(\d+)-/.exec(pageId);
753
+ if (!match) return pageId;
754
+ return Number(match[1]);
755
+ }