@beyondwork/docx-react-component 1.0.109 → 1.0.111

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 (59) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +3 -0
  3. package/src/model/layout/page-graph-types.ts +33 -0
  4. package/src/model/layout/runtime-page-graph-types.ts +25 -0
  5. package/src/runtime/document-runtime.ts +46 -0
  6. package/src/runtime/geometry/adjacent-geometry-intake.ts +820 -15
  7. package/src/runtime/geometry/caret-geometry.ts +219 -7
  8. package/src/runtime/geometry/geometry-index.ts +52 -12
  9. package/src/runtime/geometry/object-handles.ts +42 -1
  10. package/src/runtime/layout/index.ts +3 -0
  11. package/src/runtime/layout/inert-layout-facet.ts +13 -0
  12. package/src/runtime/layout/layout-engine-instance.ts +233 -4
  13. package/src/runtime/layout/layout-engine-version.ts +47 -2
  14. package/src/runtime/layout/layout-facet-types.ts +3 -0
  15. package/src/runtime/layout/page-graph.ts +88 -7
  16. package/src/runtime/layout/paginated-layout-engine.ts +34 -0
  17. package/src/runtime/layout/project-block-fragments.ts +144 -1
  18. package/src/runtime/layout/public-facet.ts +228 -9
  19. package/src/runtime/layout/resolve-page-previews.ts +46 -8
  20. package/src/runtime/scopes/adjacent-geometry-evidence.ts +456 -0
  21. package/src/runtime/scopes/compile-scope-bundle.ts +8 -0
  22. package/src/runtime/scopes/evidence.ts +16 -0
  23. package/src/runtime/scopes/index.ts +13 -0
  24. package/src/runtime/scopes/semantic-scope-types.ts +67 -0
  25. package/src/ui-tailwind/chrome-overlay/tw-table-split-row-carry-overlay.tsx +62 -0
  26. package/src/ui-tailwind/debug/layer11-consumer-readiness.ts +104 -0
  27. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +50 -5
  28. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +27 -0
  29. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +62 -0
  30. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +1 -0
  31. package/src/README.md +0 -85
  32. package/src/api/README.md +0 -26
  33. package/src/api/v3/README.md +0 -91
  34. package/src/component-inventory.md +0 -99
  35. package/src/core/README.md +0 -10
  36. package/src/core/commands/README.md +0 -3
  37. package/src/core/schema/README.md +0 -3
  38. package/src/core/selection/README.md +0 -3
  39. package/src/core/state/README.md +0 -3
  40. package/src/io/README.md +0 -10
  41. package/src/io/export/README.md +0 -3
  42. package/src/io/normalize/README.md +0 -3
  43. package/src/io/ooxml/README.md +0 -3
  44. package/src/io/opc/README.md +0 -3
  45. package/src/model/README.md +0 -3
  46. package/src/preservation/README.md +0 -3
  47. package/src/review/README.md +0 -16
  48. package/src/review/store/README.md +0 -3
  49. package/src/runtime/README.md +0 -3
  50. package/src/ui/README.md +0 -30
  51. package/src/ui/comments/README.md +0 -3
  52. package/src/ui/compatibility/README.md +0 -3
  53. package/src/ui/editor-surface/README.md +0 -3
  54. package/src/ui/review/README.md +0 -3
  55. package/src/ui/status/README.md +0 -3
  56. package/src/ui/theme/README.md +0 -3
  57. package/src/ui/toolbar/README.md +0 -3
  58. package/src/ui-tailwind/debug/README.md +0 -22
  59. package/src/validation/README.md +0 -3
@@ -289,6 +289,12 @@ export interface ScopeBundleEvidence {
289
289
  * `requires-rehydration` or `unavailable`; Layer 08 never fabricates rects.
290
290
  */
291
291
  readonly geometry?: ScopeGeometryEvidence;
292
+ /**
293
+ * Bounded Layer-05 adjacent geometry evidence. This is compositor/read
294
+ * evidence only; it does not make a scope replaceable. Rows are present only
295
+ * when L05 marked them `compositorReady` in `frame-px` space.
296
+ */
297
+ readonly adjacentGeometry?: ScopeAdjacentGeometryEvidence;
292
298
  /**
293
299
  * Presentation hint only. Consumers may use this to choose a cheap inline
294
300
  * treatment for field-like scopes versus a broader overlay treatment for
@@ -449,6 +455,67 @@ export interface ScopeGeometryEvidence {
449
455
  };
450
456
  }
451
457
 
458
+ export type ScopeAdjacentGeometryAxis = "numbering-marker" | "field-region";
459
+
460
+ export interface ScopeAdjacentGeometryFramePixelRect {
461
+ readonly leftPx: number;
462
+ readonly topPx: number;
463
+ readonly widthPx: number;
464
+ readonly heightPx: number;
465
+ readonly coordinateSpace: "frame-px";
466
+ }
467
+
468
+ export interface ScopeAdjacentGeometryFramePixelPoint {
469
+ readonly xPx: number;
470
+ readonly yPx: number;
471
+ readonly coordinateSpace: "frame-px";
472
+ }
473
+
474
+ export interface ScopeAdjacentGeometryRowEvidence {
475
+ readonly axis: ScopeAdjacentGeometryAxis;
476
+ readonly source: "l05-adjacent-geometry-intake";
477
+ readonly docId?: string;
478
+ readonly canonicalBlockId?: string;
479
+ readonly runtimeFragmentId?: string;
480
+ readonly pageId?: string;
481
+ readonly pageIndex?: number;
482
+ readonly frameId?: string;
483
+ readonly precision: "word-page-local-calibration";
484
+ readonly coordinateSpace: "frame-px";
485
+ readonly compositorReady: true;
486
+ readonly scaleSource?: "runtime-render-frame-page-rect";
487
+ readonly renderFrameRevision?: number;
488
+ readonly numbering?: {
489
+ readonly runtimeNumberingId?: string;
490
+ readonly canonicalNumberingInstanceId?: string;
491
+ readonly canonicalLevel?: number;
492
+ readonly markerKind?: string;
493
+ readonly markerSuffix?: string;
494
+ readonly markerLane?: ScopeAdjacentGeometryFramePixelRect;
495
+ readonly textColumn?: ScopeAdjacentGeometryFramePixelRect;
496
+ };
497
+ readonly fieldRegion?: {
498
+ readonly canonicalFieldId?: string;
499
+ readonly runtimeFieldRegionId?: string;
500
+ readonly instructionFamily?: string;
501
+ readonly fieldStartAnchorPx?: ScopeAdjacentGeometryFramePixelPoint;
502
+ readonly fieldEndAnchorPx?: ScopeAdjacentGeometryFramePixelPoint;
503
+ readonly fieldResultRangePx?: ScopeAdjacentGeometryFramePixelRect;
504
+ };
505
+ }
506
+
507
+ export interface ScopeAdjacentGeometryEvidence {
508
+ readonly status: "available" | "unavailable";
509
+ readonly source: "l05-adjacent-geometry-intake";
510
+ readonly schemaVersion?: "layer-05-adjacent-geometry-intake/v2";
511
+ readonly rowCount: number;
512
+ readonly rows?: readonly ScopeAdjacentGeometryRowEvidence[];
513
+ readonly reason?:
514
+ | "adjacent-geometry-provider-unavailable"
515
+ | "scope-adjacent-frame-pixel-row-unavailable"
516
+ | "l05-adjacent-intake-not-compositor-ready";
517
+ }
518
+
452
519
  export type ScopeVisualizationClass = "field" | "broad";
453
520
 
454
521
  export interface ScopeVisualizationHint {
@@ -0,0 +1,62 @@
1
+ import React from "react";
2
+
3
+ export interface TableSplitRowCarryOverlayEntry {
4
+ blockId: string;
5
+ rowIndex: number;
6
+ topPx: number;
7
+ leftPx: number;
8
+ widthPx: number;
9
+ heightPx: number;
10
+ continuesFromPreviousPage?: boolean;
11
+ continuesToNextPage?: boolean;
12
+ }
13
+
14
+ export interface TwTableSplitRowCarryOverlayProps {
15
+ entries: readonly TableSplitRowCarryOverlayEntry[];
16
+ }
17
+
18
+ function TwTableSplitRowCarryOverlayInner({
19
+ entries,
20
+ }: TwTableSplitRowCarryOverlayProps): React.ReactElement | null {
21
+ if (entries.length === 0) return null;
22
+
23
+ return (
24
+ <>
25
+ {entries.map((entry) => (
26
+ <div
27
+ key={`${entry.blockId}:${entry.rowIndex}:${entry.topPx}:${entry.heightPx}`}
28
+ aria-hidden
29
+ data-table-split-row-carry=""
30
+ data-block-id={entry.blockId}
31
+ data-row-index={entry.rowIndex}
32
+ data-continues-from-previous-page={
33
+ entry.continuesFromPreviousPage ? "true" : undefined
34
+ }
35
+ data-continues-to-next-page={
36
+ entry.continuesToNextPage ? "true" : undefined
37
+ }
38
+ style={{
39
+ position: "absolute",
40
+ top: `${entry.topPx}px`,
41
+ left: `${entry.leftPx}px`,
42
+ width: `${entry.widthPx}px`,
43
+ height: `${entry.heightPx}px`,
44
+ pointerEvents: "none",
45
+ boxSizing: "border-box",
46
+ borderTop: entry.continuesFromPreviousPage
47
+ ? "1px dashed var(--color-border-accent)"
48
+ : undefined,
49
+ borderBottom: entry.continuesToNextPage
50
+ ? "1px dashed var(--color-border-accent)"
51
+ : undefined,
52
+ backgroundColor: "color-mix(in srgb, var(--color-accent) 8%, transparent)",
53
+ }}
54
+ />
55
+ ))}
56
+ </>
57
+ );
58
+ }
59
+
60
+ export const TwTableSplitRowCarryOverlay = React.memo(
61
+ TwTableSplitRowCarryOverlayInner,
62
+ );
@@ -215,6 +215,38 @@ export interface Layer11RenderCalibrationSummary {
215
215
  route: "presentation-check" | "lower-layer-fact-required" | "insufficient-evidence";
216
216
  }
217
217
 
218
+ export type Layer11AdjacentGeometryAxis = "numbering-marker" | "field-region";
219
+
220
+ export interface Layer11AdjacentGeometryIntakeLike {
221
+ readonly schemaVersion?: unknown;
222
+ readonly totals?: {
223
+ readonly numberingCompositorReadyRows?: unknown;
224
+ readonly fieldRegionCompositorReadyRows?: unknown;
225
+ };
226
+ readonly pageLocalNormalization?: {
227
+ readonly framePixelCoordinateSpace?: unknown;
228
+ readonly framePixelPrecision?: unknown;
229
+ readonly compositorReady?: unknown;
230
+ };
231
+ }
232
+
233
+ export interface Layer11AdjacentGeometryAxisReadiness {
234
+ axis: Layer11AdjacentGeometryAxis;
235
+ compositorReadyRows: number;
236
+ assertionReady: boolean;
237
+ route: "presentation-consumer-ready" | "lower-layer-fact-required";
238
+ reason: string;
239
+ }
240
+
241
+ export interface Layer11AdjacentGeometryConsumerSummary {
242
+ schemaVersion: string | null;
243
+ framePixelCoordinateSpace: string | null;
244
+ framePixelPrecision: string | null;
245
+ assertionReadyCount: number;
246
+ lowerLayerFactRequiredCount: number;
247
+ entries: readonly Layer11AdjacentGeometryAxisReadiness[];
248
+ }
249
+
218
250
  const PRIMARY_RENDER_EVIDENCE = new Set<Layer11RenderEvidenceKind>([
219
251
  "word-oracle",
220
252
  "render-word",
@@ -270,3 +302,75 @@ export function summarizeWordFirstRenderCalibration(
270
302
  route: "presentation-check",
271
303
  };
272
304
  }
305
+
306
+ export function summarizeLayer11AdjacentGeometryConsumer(
307
+ intake: Layer11AdjacentGeometryIntakeLike,
308
+ ): Layer11AdjacentGeometryConsumerSummary {
309
+ const schemaVersion =
310
+ typeof intake.schemaVersion === "string" ? intake.schemaVersion : null;
311
+ const normalization = intake.pageLocalNormalization;
312
+ const framePixelCoordinateSpace =
313
+ typeof normalization?.framePixelCoordinateSpace === "string"
314
+ ? normalization.framePixelCoordinateSpace
315
+ : null;
316
+ const framePixelPrecision =
317
+ typeof normalization?.framePixelPrecision === "string"
318
+ ? normalization.framePixelPrecision
319
+ : null;
320
+ const normalizationReady =
321
+ schemaVersion === "layer-05-adjacent-geometry-intake/v2" &&
322
+ framePixelCoordinateSpace === "frame-px" &&
323
+ normalization?.compositorReady === true;
324
+ const entries: Layer11AdjacentGeometryAxisReadiness[] = [
325
+ summarizeAdjacentGeometryAxis(
326
+ "numbering-marker",
327
+ numberValue(intake.totals?.numberingCompositorReadyRows),
328
+ normalizationReady,
329
+ ),
330
+ summarizeAdjacentGeometryAxis(
331
+ "field-region",
332
+ numberValue(intake.totals?.fieldRegionCompositorReadyRows),
333
+ normalizationReady,
334
+ ),
335
+ ];
336
+
337
+ return {
338
+ schemaVersion,
339
+ framePixelCoordinateSpace,
340
+ framePixelPrecision,
341
+ assertionReadyCount: entries.filter((entry) => entry.assertionReady).length,
342
+ lowerLayerFactRequiredCount: entries.filter((entry) => !entry.assertionReady)
343
+ .length,
344
+ entries,
345
+ };
346
+ }
347
+
348
+ function summarizeAdjacentGeometryAxis(
349
+ axis: Layer11AdjacentGeometryAxis,
350
+ compositorReadyRows: number,
351
+ normalizationReady: boolean,
352
+ ): Layer11AdjacentGeometryAxisReadiness {
353
+ const assertionReady = normalizationReady && compositorReadyRows > 0;
354
+ if (assertionReady) {
355
+ return {
356
+ axis,
357
+ compositorReadyRows,
358
+ assertionReady,
359
+ route: "presentation-consumer-ready",
360
+ reason:
361
+ "L05 published frame-pixel rows with compositorReady provenance; L11 may consume this bounded subset without reconstructing geometry.",
362
+ };
363
+ }
364
+ return {
365
+ axis,
366
+ compositorReadyRows,
367
+ assertionReady,
368
+ route: "lower-layer-fact-required",
369
+ reason:
370
+ "L11 must wait for L05 frame-pixel rows marked compositorReady before treating this axis as a presentation assertion.",
371
+ };
372
+ }
373
+
374
+ function numberValue(value: unknown): number {
375
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
376
+ }
@@ -89,6 +89,15 @@ export interface PageBreakDecorationInput {
89
89
  * heuristic.
90
90
  */
91
91
  blockIndexRangeByPageIndex?: ReadonlyMap<number, { first: number; last: number }>;
92
+ /**
93
+ * Page indexes whose boundary/content widgets would be inserted at a document
94
+ * position inside a table. PM widgets inherit that table-cell containing
95
+ * block, so visible chrome there paints page numbers inside the cell and
96
+ * block-level invisible anchors can perturb table layout. Keep the diagnostic
97
+ * markers but suppress the visible spacer/seam and shrink anchors to inline
98
+ * zero-size markers.
99
+ */
100
+ suppressChromeForPageIndex?: ReadonlySet<number>;
92
101
  }
93
102
 
94
103
  export function buildPageBreakDecorations(
@@ -111,7 +120,13 @@ export function buildPageBreakDecorations(
111
120
  // (coord-11 §19) are emitted in a second pass at the end of this
112
121
  // function.
113
122
  if (graph.pages.length < 2) {
114
- return buildPageAnchorDecorationsInto(decorations, graph, posture, runtimeToPmOffset);
123
+ return buildPageAnchorDecorationsInto(
124
+ decorations,
125
+ graph,
126
+ posture,
127
+ runtimeToPmOffset,
128
+ input.suppressChromeForPageIndex,
129
+ );
115
130
  }
116
131
 
117
132
  for (let i = 1; i < graph.pages.length; i += 1) {
@@ -133,6 +148,8 @@ export function buildPageBreakDecorations(
133
148
  input.headerPreviewByPageId?.get(next.pageId) ?? "";
134
149
 
135
150
  const nextBlockRange = input.blockIndexRangeByPageIndex?.get(next.pageIndex);
151
+ const suppressVisibleChrome =
152
+ input.suppressChromeForPageIndex?.has(next.pageIndex) === true;
136
153
 
137
154
  decorations.push(
138
155
  Decoration.widget(
@@ -155,6 +172,7 @@ export function buildPageBreakDecorations(
155
172
  nextHeaderPreview,
156
173
  nextPageFirstBlockIndex: nextBlockRange?.first ?? -1,
157
174
  nextPageLastBlockIndex: nextBlockRange?.last ?? -1,
175
+ suppressVisibleChrome,
158
176
  }),
159
177
  {
160
178
  side: -1,
@@ -171,7 +189,13 @@ export function buildPageBreakDecorations(
171
189
  ),
172
190
  );
173
191
  }
174
- return buildPageAnchorDecorationsInto(decorations, graph, posture, runtimeToPmOffset);
192
+ return buildPageAnchorDecorationsInto(
193
+ decorations,
194
+ graph,
195
+ posture,
196
+ runtimeToPmOffset,
197
+ input.suppressChromeForPageIndex,
198
+ );
175
199
  }
176
200
 
177
201
  /**
@@ -197,6 +221,7 @@ function buildPageAnchorDecorationsInto(
197
221
  graph: RuntimePageGraph,
198
222
  posture: "page" | "canvas",
199
223
  runtimeToPmOffset: ((runtimeOffset: number) => number | null) | undefined,
224
+ tableInteriorPageIndex?: ReadonlySet<number>,
200
225
  ): Decoration[] {
201
226
  let contentPageOrdinal = 0;
202
227
  for (const page of graph.pages) {
@@ -214,6 +239,7 @@ function buildPageAnchorDecorationsInto(
214
239
  () => buildPageAnchorWidgetDom({
215
240
  pageNumber: anchorPageNumber,
216
241
  pageId: anchorPageId,
242
+ tableInterior: tableInteriorPageIndex?.has(page.pageIndex) === true,
217
243
  }),
218
244
  {
219
245
  side: 1,
@@ -268,6 +294,7 @@ interface ChromeWidgetInput {
268
294
  * -1 when block-index info was not supplied to the decoration builder.
269
295
  */
270
296
  nextPageLastBlockIndex: number;
297
+ suppressVisibleChrome?: boolean;
271
298
  }
272
299
 
273
300
  // P14.c — cache the widget DOM by input identity. PM rebuilds the
@@ -299,6 +326,7 @@ function widgetCacheKey(input: ChromeWidgetInput): string {
299
326
  input.nextHeaderPreview,
300
327
  input.nextPageFirstBlockIndex,
301
328
  input.nextPageLastBlockIndex,
329
+ input.suppressVisibleChrome ? "1" : "0",
302
330
  ].join("\x1f");
303
331
  }
304
332
 
@@ -337,22 +365,29 @@ export function __resetPageBreakWidgetCache(): void {
337
365
  *
338
366
  * Emitted as a PM widget via `buildPageBreakDecorations` so it lives
339
367
  * on the content layer (present under chrome=none) rather than on an
340
- * absolute-positioned chrome overlay.
368
+ * absolute-positioned chrome overlay. When the page starts inside a table,
369
+ * the marker stays inline and zero-width so it does not create a block box
370
+ * inside the table cell.
341
371
  */
342
372
  function buildPageAnchorWidgetDom(input: {
343
373
  pageNumber: number;
344
374
  pageId: string;
375
+ tableInterior?: boolean;
345
376
  }): HTMLElement {
346
377
  const root = document.createElement("span");
347
378
  root.setAttribute("data-kind", "page-content-anchor");
348
379
  root.setAttribute("data-page-content-wrapper", "");
349
380
  root.setAttribute("data-page-number", String(input.pageNumber));
350
381
  root.setAttribute("data-page-id", input.pageId);
382
+ if (input.tableInterior) {
383
+ root.setAttribute("data-page-anchor-suppressed", "table-interior");
384
+ }
351
385
  root.setAttribute("aria-hidden", "true");
352
386
  root.contentEditable = "false";
353
- root.style.display = "block";
387
+ root.style.display = input.tableInterior ? "inline-block" : "block";
354
388
  root.style.height = "0";
355
- root.style.width = "100%";
389
+ root.style.width = input.tableInterior ? "0" : "100%";
390
+ root.style.overflow = "hidden";
356
391
  root.style.userSelect = "none";
357
392
  return root;
358
393
  }
@@ -391,6 +426,16 @@ function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
391
426
  root.style.width = "100%";
392
427
  root.style.userSelect = "none";
393
428
 
429
+ if (input.suppressVisibleChrome) {
430
+ root.setAttribute("data-page-chrome-suppressed", "table-interior");
431
+ root.setAttribute("aria-hidden", "true");
432
+ root.style.height = "0";
433
+ root.style.width = "0";
434
+ root.style.overflow = "hidden";
435
+ root.style.pointerEvents = "none";
436
+ return root;
437
+ }
438
+
394
439
  if (input.posture === "canvas") {
395
440
  // Single dotted horizontal line with an unframed page-number label.
396
441
  root.style.height = `${input.interGapPx + 1}px`;
@@ -151,6 +151,7 @@ function buildPageBreakDecorationsFromProps(
151
151
  pageIndex: p.page.pageIndex,
152
152
  startOffset: p.page.startOffset,
153
153
  isBlankFiller: p.page.isBlankFiller,
154
+ frame: p.page.frame,
154
155
  stories: {
155
156
  displayPageNumber: p.page.stories.displayPageNumber,
156
157
  header: p.page.stories.header,
@@ -184,6 +185,7 @@ function buildPageBreakDecorationsFromProps(
184
185
  // carry `data-page-first-block-index` / `data-page-last-block-index`
185
186
  // attributes needed by `useVisibleBlockRange`.
186
187
  let blockIndexRangeByPageIndex: Map<number, { first: number; last: number }> | undefined;
188
+ let suppressChromeForPageIndex: Set<number> | undefined;
187
189
  if (surfaceBlocks && surfaceBlocks.length > 0 && frame.pages.length > 0) {
188
190
  blockIndexRangeByPageIndex = new Map();
189
191
  for (let pi = 0; pi < frame.pages.length; pi++) {
@@ -193,6 +195,13 @@ function buildPageBreakDecorationsFromProps(
193
195
  if (range) {
194
196
  blockIndexRangeByPageIndex.set(page.page.pageIndex, range);
195
197
  }
198
+ if (
199
+ pi > 0 &&
200
+ isRuntimeOffsetInsideTableBlock(surfaceBlocks, page.page.startOffset)
201
+ ) {
202
+ if (!suppressChromeForPageIndex) suppressChromeForPageIndex = new Set();
203
+ suppressChromeForPageIndex.add(page.page.pageIndex);
204
+ }
196
205
  }
197
206
  }
198
207
 
@@ -206,9 +215,27 @@ function buildPageBreakDecorationsFromProps(
206
215
  headerPreviewByPageId: previews?.headerPreviewByPageId,
207
216
  footerPreviewByPageId: previews?.footerPreviewByPageId,
208
217
  blockIndexRangeByPageIndex,
218
+ suppressChromeForPageIndex,
209
219
  });
210
220
  }
211
221
 
222
+ function isRuntimeOffsetInsideTableBlock(
223
+ blocks: readonly import("../../api/public-types.ts").SurfaceBlockSnapshot[],
224
+ offset: number,
225
+ ): boolean {
226
+ for (const block of blocks) {
227
+ if (offset <= block.from || offset >= block.to) continue;
228
+ if (block.kind === "table") return true;
229
+ if (
230
+ block.kind === "sdt_block" &&
231
+ isRuntimeOffsetInsideTableBlock(block.children, offset)
232
+ ) {
233
+ return true;
234
+ }
235
+ }
236
+ return false;
237
+ }
238
+
212
239
  function extractDecorations(
213
240
  set: DecorationSet,
214
241
  _doc: unknown,
@@ -16,6 +16,7 @@
16
16
  import React from "react";
17
17
  import type {
18
18
  EditorStoryTarget,
19
+ GeometryFacet,
19
20
  PublicPageNode,
20
21
  SurfaceTableRowSnapshot,
21
22
  WordReviewEditorLayoutFacet,
@@ -23,6 +24,10 @@ import type {
23
24
  import { buildPageAnchorAttributes } from "../../api/v3/_page-anchor-id.ts";
24
25
  import type { PageOverlayRect } from "../chrome-overlay/tw-page-stack-overlay-layer.tsx";
25
26
  import { TwTableContinuationHeader } from "../chrome-overlay/tw-table-continuation-header.tsx";
27
+ import {
28
+ TwTableSplitRowCarryOverlay,
29
+ type TableSplitRowCarryOverlayEntry,
30
+ } from "../chrome-overlay/tw-table-split-row-carry-overlay.tsx";
26
31
  import { FRAME_PX_PER_TWIP_AT_96DPI } from "../tw-review-workspace.tsx";
27
32
  import { TwPageHeaderBand } from "./tw-page-header-band.tsx";
28
33
  import { TwPageFooterBand } from "./tw-page-footer-band.tsx";
@@ -35,6 +40,7 @@ export interface TwPageChromeEntryProps {
35
40
  pageIndex: number;
36
41
  page: PublicPageNode;
37
42
  facet: WordReviewEditorLayoutFacet;
43
+ geometryFacet?: GeometryFacet;
38
44
  activeStory: EditorStoryTarget;
39
45
  activeStoryPageIndex?: number | null;
40
46
  onOpenStory?: (target: EditorStoryTarget, pageIndex: number) => void;
@@ -60,6 +66,7 @@ function TwPageChromeEntryInner({
60
66
  pageIndex,
61
67
  page,
62
68
  facet,
69
+ geometryFacet,
63
70
  activeStory,
64
71
  activeStoryPageIndex,
65
72
  onOpenStory,
@@ -134,6 +141,16 @@ function TwPageChromeEntryInner({
134
141
  // eslint-disable-next-line react-hooks/exhaustive-deps
135
142
  }, [facet, pageIndex, page, renderFrameRevision]);
136
143
 
144
+ const splitRowCarryEntries = React.useMemo(
145
+ () =>
146
+ collectSplitRowCarryOverlayEntries({
147
+ geometryFacet,
148
+ pageIndex,
149
+ pageTopPx: rect.topPx,
150
+ }),
151
+ [geometryFacet, pageIndex, rect.topPx, renderFrameRevision],
152
+ );
153
+
137
154
  const handleHeaderDoubleClick = React.useCallback(
138
155
  () => headerStory && onOpenStory?.(headerStory, pageIndex),
139
156
  [onOpenStory, headerStory, pageIndex],
@@ -257,6 +274,7 @@ function TwPageChromeEntryInner({
257
274
  bodyOriginTopPx={px(layout.marginTop)}
258
275
  />
259
276
  ))}
277
+ <TwTableSplitRowCarryOverlay entries={splitRowCarryEntries} />
260
278
  </div>
261
279
  );
262
280
  }
@@ -269,6 +287,7 @@ function propsAreEqual(
269
287
  prev.pageIndex === next.pageIndex &&
270
288
  prev.page === next.page &&
271
289
  prev.facet === next.facet &&
290
+ prev.geometryFacet === next.geometryFacet &&
272
291
  prev.activeStory === next.activeStory &&
273
292
  prev.activeStoryPageIndex === next.activeStoryPageIndex &&
274
293
  prev.onOpenStory === next.onOpenStory &&
@@ -285,6 +304,49 @@ function propsAreEqual(
285
304
 
286
305
  export const TwPageChromeEntry = React.memo(TwPageChromeEntryInner, propsAreEqual);
287
306
 
307
+ function collectSplitRowCarryOverlayEntries(input: {
308
+ geometryFacet?: GeometryFacet;
309
+ pageIndex: number;
310
+ pageTopPx: number;
311
+ }): readonly TableSplitRowCarryOverlayEntry[] {
312
+ const index = input.geometryFacet?.getGeometryIndex();
313
+ if (!index) return [];
314
+
315
+ const entries: TableSplitRowCarryOverlayEntry[] = [];
316
+ const seen = new Set<string>();
317
+ for (const entry of index.semanticEntries) {
318
+ if (entry.kind !== "table-row") continue;
319
+ if (entry.pageIndex !== input.pageIndex) continue;
320
+ if (entry.rect.space !== "frame") continue;
321
+ const carries = entry.tableContinuation?.splitRowCarry ?? [];
322
+ if (carries.length === 0) continue;
323
+ const blockId = entry.blockId;
324
+ if (!blockId) continue;
325
+ for (const carry of carries) {
326
+ const rowIndex = entry.rowIndex ?? carry.rowIndex;
327
+ if (rowIndex === undefined) continue;
328
+ const topPx = Math.max(0, entry.rect.topPx - input.pageTopPx);
329
+ const leftPx = Math.max(0, entry.rect.leftPx);
330
+ const widthPx = Math.max(1, entry.rect.widthPx);
331
+ const heightPx = Math.max(1, entry.rect.heightPx);
332
+ const key = `${blockId}:${rowIndex}:${topPx}:${leftPx}:${widthPx}:${heightPx}`;
333
+ if (seen.has(key)) continue;
334
+ seen.add(key);
335
+ entries.push({
336
+ blockId,
337
+ rowIndex,
338
+ topPx,
339
+ leftPx,
340
+ widthPx,
341
+ heightPx,
342
+ continuesFromPreviousPage: carry.continuesFromPreviousPage,
343
+ continuesToNextPage: carry.continuesToNextPage,
344
+ });
345
+ }
346
+ }
347
+ return entries;
348
+ }
349
+
288
350
  function isActiveStoryMatch(
289
351
  active: EditorStoryTarget,
290
352
  candidate: EditorStoryTarget,
@@ -503,6 +503,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
503
503
  pageIndex={rect.pageIndex}
504
504
  page={page}
505
505
  facet={facet}
506
+ geometryFacet={geometryFacet}
506
507
  activeStory={activeStory}
507
508
  activeStoryPageIndex={activeStoryPageIndex}
508
509
  onOpenStory={handleOpenStoryForPage}
package/src/README.md DELETED
@@ -1,85 +0,0 @@
1
- # Source Layout
2
-
3
- The landed source tree is still organized around the active docx implementation, not around the future target office-wide layout.
4
-
5
- ## Current Landed Layout
6
-
7
- ```text
8
- src/
9
- api/
10
- model/
11
- core/
12
- schema/
13
- state/
14
- commands/
15
- selection/
16
- review/
17
- store/
18
- io/
19
- opc/
20
- ooxml/
21
- normalize/
22
- export/
23
- preservation/
24
- validation/
25
- runtime/
26
- ui/
27
- headless/ # Shared pure logic (framework-free)
28
- shared/ # Shared utilities (revision-filters)
29
- WordReviewEditor.tsx # Entry point
30
- ui-tailwind/ # Default rendering (Tailwind + Radix + Lucide)
31
- editor-surface/
32
- toolbar/
33
- review/
34
- status/
35
- chrome/
36
- theme/
37
- ```
38
-
39
- This is the truthful current layout for implementation work today.
40
-
41
- ## Target Broader Layout
42
-
43
- The broader repo story now targets a future layout like this:
44
-
45
- ```text
46
- src/
47
- platform/
48
- formats/
49
- docx/
50
- xlsx/
51
- pdf/
52
- ```
53
-
54
- That layout is not landed yet. Use it as a planning direction, not as evidence that the current code has already been reorganized.
55
-
56
- ## UI Layer Strategy
57
-
58
- - `ui/headless/` — Pure logic: keyboard handling, decoration models, selection utilities. No React dependencies.
59
- - `ui-tailwind/` — Default rendering: Tailwind CSS, Radix UI primitives, Lucide icons. All styling via CSS custom properties.
60
- - `ui/WordReviewEditor.tsx` — Public entry point that bridges the runtime to the Tailwind layer.
61
-
62
- Legacy inline-CSSProperties components have been removed. See `docs/reference/word-review-editor-frontend-architecture.md` for the canonical frontend architecture.
63
-
64
- Keep business rules close to the subsystem that owns them. Do not centralize unrelated logic into generic utility layers.
65
-
66
- Ownership rules:
67
-
68
- - `api` exposes public contracts only.
69
- - `model`, `core`, `review`, `io`, `preservation`, and `validation` should remain React-free when possible.
70
- - `runtime` is the only mutation boundary the UI calls into.
71
- - `ui` consumes runtime contracts and design tokens; it does not own canonical document truth.
72
-
73
- ## Broader Repo Direction
74
-
75
- As the repo broadens:
76
-
77
- - shared package and preservation concerns should move toward `src/platform/`
78
- - current docx runtime code remains the active implementation track until a deliberate source move lands
79
- - future xlsx work should gain its own source area rather than widening docx-specific modules by implication
80
- - future pdf work should remain separate from the first OOXML platform layer unless architecture decisions intentionally broaden it
81
-
82
- Wave 0 inventory references:
83
-
84
- - `component-inventory.md`
85
- Wave-owned inventory for the major editor subsystems, their boundaries, and the promotion target for this scaffold phase.
package/src/api/README.md DELETED
@@ -1,26 +0,0 @@
1
- # API
2
-
3
- Public TypeScript contracts for `WordReviewEditor` belong here.
4
-
5
- This layer should expose:
6
-
7
- - component props and ref types
8
- - session-state and snapshot compatibility types
9
- - host adapter and datastore adapter interfaces
10
- - runtime-derived position and selection projection types used by the public API
11
- - discriminated event unions
12
- - warning and error payloads
13
- - persisted snapshot contracts
14
- - export and compatibility report types
15
-
16
- Do not place runtime logic here.
17
-
18
- Frozen naming and boundary rules:
19
-
20
- - the shipped component name is `WordReviewEditor`
21
- - `DocumentRuntime` is an internal runtime contract, not a public React component name
22
- - `EditorSessionState` is the canonical host-facing live-session contract
23
- - `PersistedEditorSnapshot` is the legacy/store compatibility envelope
24
- - `EditorHostAdapter` is the preferred persistence boundary; `EditorDatastoreAdapter` remains the snapshot-compatible bridge
25
- - public range references are canonical-position projections, not DOM-path handles
26
- - render snapshots stay in `src/runtime`; `src/api` only exposes persisted host-facing snapshot types