@beyondwork/docx-react-component 1.0.53 → 1.0.54

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 (86) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +35 -7
  3. package/src/io/docx-session.ts +30 -6
  4. package/src/runtime/collab/checkpoint-store.ts +1 -1
  5. package/src/runtime/collab/event-types.ts +4 -0
  6. package/src/runtime/collab/runtime-collab-sync.ts +1 -2
  7. package/src/runtime/document-runtime.ts +23 -9
  8. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  9. package/src/runtime/layout/layout-engine-version.ts +58 -1
  10. package/src/runtime/layout/layout-invalidation.ts +150 -30
  11. package/src/runtime/layout/page-graph.ts +19 -0
  12. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  13. package/src/runtime/layout/project-block-fragments.ts +27 -0
  14. package/src/runtime/layout/public-facet.ts +27 -0
  15. package/src/runtime/render/render-frame-diff.ts +38 -2
  16. package/src/ui/WordReviewEditor.tsx +6 -3
  17. package/src/ui/headless/comment-decoration-model.ts +60 -5
  18. package/src/ui/headless/revision-decoration-model.ts +94 -6
  19. package/src/ui/shared/revision-filters.ts +16 -6
  20. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  21. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  22. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  23. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  24. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  25. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  26. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  27. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  28. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  29. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  30. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  31. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  32. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  33. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  34. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  35. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  36. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  37. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  38. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  39. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  40. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  41. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  42. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  43. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  44. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  45. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  46. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  47. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  48. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  49. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  50. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  51. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  52. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  53. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  54. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  55. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  56. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  57. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  58. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  59. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  60. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  61. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  62. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  63. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  64. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  65. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  66. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  67. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  68. package/src/ui-tailwind/index.ts +11 -0
  69. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
  70. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
  71. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
  72. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  73. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  74. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  75. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  76. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  77. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  78. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  79. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  80. package/src/ui-tailwind/theme/editor-theme.css +249 -22
  81. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  82. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  83. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  84. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  85. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  86. package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.53",
4
+ "version": "1.0.54",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -3058,12 +3058,16 @@ export interface WordReviewEditorRef {
3058
3058
  setWorkflowOverlay(overlay: WorkflowOverlay): void;
3059
3059
  clearWorkflowOverlay(): void;
3060
3060
  /**
3061
- * Read the current runtime-backed `WorkflowOverlay`, or `null` when no
3062
- * overlay is active. Hosts use this for read-modify-write flows against
3063
- * `setWorkflowOverlay(...)` notably to preserve engine-authored scopes
3064
- * minted via `addScope(...)` when updating candidates / work items.
3065
- * Returns a defensive clone; mutating the returned object does not affect
3066
- * runtime state.
3061
+ * Return a structural clone of the currently-registered workflow
3062
+ * overlay, or `null` when no overlay has been set (or
3063
+ * `clearWorkflowOverlay` has been called). Intended for the canonical
3064
+ * host read-before-write pattern read current, merge host-owned
3065
+ * fields (e.g. `candidates`), write back via `setWorkflowOverlay`
3066
+ * without clobbering engine-authored `scopes`, `workItems`, or the
3067
+ * overlay-level `metadataPersistence` default. `setWorkflowOverlay`
3068
+ * replaces the overlay wholesale; passing `scopes: []` will drop
3069
+ * every scope registered via `addScope` and cause subsequent
3070
+ * `replaceText` calls to block with `workflow_comment_only`.
3067
3071
  */
3068
3072
  getWorkflowOverlay(): WorkflowOverlay | null;
3069
3073
  setSharedWorkflowState(state: SharedWorkflowState | null): void;
@@ -3304,7 +3308,31 @@ export interface WordReviewEditorProps {
3304
3308
  suggestionsEnabled?: boolean;
3305
3309
  chromePreset?: WordReviewEditorChromePreset;
3306
3310
  chromeOptions?: Partial<WordReviewEditorChromeOptions>;
3307
- markupDisplay?: "clean" | "simple" | "all";
3311
+ /**
3312
+ * Controls how tracked changes and comments render. Accepts Word's
3313
+ * 4-mode grammar (`"all-markup" | "simple-markup" | "no-markup" | "original"`)
3314
+ * or the legacy triple (`"all" | "simple" | "clean"`), which maps 1:1
3315
+ * onto the first three canonical names. `"original"` is new in 6d.N2:
3316
+ * insertions are hidden, deletions render as plain body text so the
3317
+ * reviewer sees the pre-change state.
3318
+ */
3319
+ markupDisplay?:
3320
+ | "all-markup"
3321
+ | "simple-markup"
3322
+ | "no-markup"
3323
+ | "original"
3324
+ | "clean"
3325
+ | "simple"
3326
+ | "all";
3327
+ /**
3328
+ * L6d.N2 — invoked when the user picks a different display mode from
3329
+ * the in-chrome selector. Values are always emitted in the canonical
3330
+ * Word grammar (`"all-markup" | "simple-markup" | "no-markup" | "original"`),
3331
+ * not the legacy aliases.
3332
+ */
3333
+ onMarkupDisplayChange?: (
3334
+ value: "all-markup" | "simple-markup" | "no-markup" | "original",
3335
+ ) => void;
3308
3336
  /**
3309
3337
  * @internal HARNESS-ONLY debug-ports token.
3310
3338
  *
@@ -67,13 +67,12 @@ import {
67
67
  getDocumentBackedWorkflowMetadata,
68
68
  parseWorkflowPayloadEnvelopeFromPackage,
69
69
  resolvePayloadPartPath,
70
+ resolveWorkflowPayloadPartPaths,
70
71
  WORKFLOW_PAYLOAD_CONTENT_TYPE,
71
72
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_CONTENT_TYPE,
72
73
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
73
74
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_RELATIONSHIP_TYPE,
74
75
  WORKFLOW_PAYLOAD_ITEM_PROPS_CONTENT_TYPE,
75
- WORKFLOW_PAYLOAD_ITEM_PROPS_PART_PATH,
76
- WORKFLOW_PAYLOAD_PART_PATH,
77
76
  WORKFLOW_PAYLOAD_RELATIONSHIP_TYPE,
78
77
  } from "./ooxml/workflow-payload.ts";
79
78
  import {
@@ -2105,13 +2104,20 @@ function exportDocxEditorSession(
2105
2104
  const hasSettingsSurface =
2106
2105
  Boolean(state.sourceSettingsPartPath) ||
2107
2106
  exportedSubParts?.settings !== undefined;
2107
+ const resolvedWorkflowPayloadPartPaths = resolveWorkflowPayloadPartPaths(
2108
+ state.sourcePackage,
2109
+ sessionState.documentId,
2110
+ );
2111
+ const internalEditorState = (
2112
+ options as { _editorState?: import("./ooxml/workflow-payload.ts").EditorStatePayload } | undefined
2113
+ )?._editorState;
2108
2114
 
2109
2115
  const exportSession = createExportSession(state.sourcePackage, [
2110
2116
  state.sourceDocumentPartPath,
2111
2117
  APP_PROPERTIES_PART_PATH,
2112
2118
  CORE_PROPERTIES_PART_PATH,
2113
- WORKFLOW_PAYLOAD_PART_PATH,
2114
- WORKFLOW_PAYLOAD_ITEM_PROPS_PART_PATH,
2119
+ resolvedWorkflowPayloadPartPaths.payloadPartPath,
2120
+ resolvedWorkflowPayloadPartPaths.itemPropsPartPath,
2115
2121
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
2116
2122
  numberingPartPath,
2117
2123
  commentsPartPath,
@@ -2360,8 +2366,14 @@ function exportDocxEditorSession(
2360
2366
 
2361
2367
  ensureHostMetadataParts(exportSession, state.sourcePackage, currentDocument);
2362
2368
  // Schema 1.2: pass through editorState payload collected by the runtime channel.
2363
- const internalEditorState = (options as { _editorState?: import("./ooxml/workflow-payload.ts").EditorStatePayload } | undefined)?._editorState;
2364
- ensureWorkflowPayloadParts(exportSession, sessionState, currentDocument, state.sourcePackage, internalEditorState);
2369
+ ensureWorkflowPayloadParts(
2370
+ exportSession,
2371
+ sessionState,
2372
+ currentDocument,
2373
+ state.sourcePackage,
2374
+ resolvedWorkflowPayloadPartPaths,
2375
+ internalEditorState,
2376
+ );
2365
2377
 
2366
2378
  return {
2367
2379
  bytes: exportSession.serialize(),
@@ -4087,6 +4099,10 @@ function ensureWorkflowPayloadParts(
4087
4099
  sessionState: EditorSessionState,
4088
4100
  document: CanonicalDocumentEnvelope,
4089
4101
  sourcePackage: OpcPackage,
4102
+ resolvedPartPaths: {
4103
+ payloadPartPath: string;
4104
+ itemPropsPartPath: string;
4105
+ },
4090
4106
  editorState?: import("./ooxml/workflow-payload.ts").EditorStatePayload,
4091
4107
  ): void {
4092
4108
  const payloadParts = buildWorkflowPayloadParts({
@@ -4102,6 +4118,14 @@ function ensureWorkflowPayloadParts(
4102
4118
  if (!payloadParts) {
4103
4119
  return;
4104
4120
  }
4121
+ if (
4122
+ payloadParts.payloadPartPath !== resolvedPartPaths.payloadPartPath ||
4123
+ payloadParts.itemPropsPartPath !== resolvedPartPaths.itemPropsPartPath
4124
+ ) {
4125
+ throw new Error(
4126
+ "Workflow payload export resolved inconsistent customXml paths; export session ownership no longer matches payload serialization.",
4127
+ );
4128
+ }
4105
4129
 
4106
4130
  const payloadPart = sourcePackage.parts.get(payloadParts.payloadPartPath);
4107
4131
  const itemPropsPart = sourcePackage.parts.get(payloadParts.itemPropsPartPath);
@@ -30,7 +30,7 @@ export interface Checkpoint {
30
30
  authorClientId: number;
31
31
  }
32
32
 
33
- const CHECKPOINTS_KEY = "checkpoints";
33
+ export const CHECKPOINTS_KEY = "checkpoints";
34
34
 
35
35
  export interface CreateCheckpointStoreOptions {
36
36
  ydoc: Y.Doc;
@@ -141,6 +141,10 @@ export const BROADCAST_COMMAND_TYPES: ReadonlySet<EditorCommand["type"]> = new S
141
141
  "section.set-header-footer-link",
142
142
  "content.insert-page-break",
143
143
  "content.insert-table",
144
+ // C1: Shift+Tab list/paragraph de-indent — produces a document mutation, must broadcast
145
+ "text.outdent-tab",
146
+ // C2: host insertFragment() API — routes through executeEditorCommand same as other mutations
147
+ "fragment.insert",
144
148
  ]);
145
149
 
146
150
  /**
@@ -22,7 +22,7 @@ import {
22
22
  type CommandEvent,
23
23
  } from "./event-types.ts";
24
24
  import { computeBaseDocFingerprint } from "./base-doc-fingerprint.ts";
25
- import type { Checkpoint } from "./checkpoint-store.ts";
25
+ import { CHECKPOINTS_KEY, type Checkpoint } from "./checkpoint-store.ts";
26
26
  import { createWorkflowShared, type WorkflowSharedHandle } from "./workflow-shared.ts";
27
27
 
28
28
  /** Shared Y.Map key — {@link SHARED_META_MAP_KEY}. */
@@ -30,7 +30,6 @@ const SHARED_META_MAP_KEY = "meta";
30
30
  const META_BASE_DOC_HASH_KEY = "baseDocHash";
31
31
  const META_SCHEMA_VERSION_KEY = "schemaVersion";
32
32
  const META_CREATED_AT_KEY = "createdAt";
33
- const CHECKPOINTS_KEY = "checkpoints";
34
33
 
35
34
  /**
36
35
  * Lifecycle + correctness events surfaced by a
@@ -160,6 +160,7 @@ import {
160
160
  createDocumentSectionSnapshots,
161
161
  createSectionLocations,
162
162
  createTocSnapshot,
163
+ findBookmarkNameForOffset,
163
164
  findDocumentSectionSnapshot,
164
165
  } from "./document-outline.ts";
165
166
  import {
@@ -209,6 +210,7 @@ import type {
209
210
  BlockNode,
210
211
  FieldNode,
211
212
  FieldRefreshStatus,
213
+ HyperlinkNode,
212
214
  InlineNode,
213
215
  PageMargins,
214
216
  ParagraphNode,
@@ -5197,7 +5199,7 @@ function refreshDocumentTableOfContents(
5197
5199
  } {
5198
5200
  const navigation = createDocumentNavigationSnapshot(document, selectionHead, activeStory);
5199
5201
  let changed = false;
5200
- let resultEntries: Array<{ level: number; text: string; pageIndex: number }> = [];
5202
+ let resultEntries: Array<{ level: number; text: string; pageIndex: number; bookmarkName?: string }> = [];
5201
5203
  let changedFrom: number | undefined;
5202
5204
  let changedTo: number | undefined;
5203
5205
  const nextChildren = refreshBlocksWithCursor(document.content.children, (field, range) => {
@@ -5209,11 +5211,15 @@ function refreshDocumentTableOfContents(
5209
5211
  : parseTocLevelRange(field.instruction);
5210
5212
  const entries = navigation.headings
5211
5213
  .filter((heading) => heading.level >= levelRange.from && heading.level <= levelRange.to)
5212
- .map((heading) => ({
5213
- level: heading.level,
5214
- text: heading.text,
5215
- pageIndex: heading.pageIndex,
5216
- }));
5214
+ .map((heading) => {
5215
+ const bookmarkName = findBookmarkNameForOffset(document, heading.offset);
5216
+ return {
5217
+ level: heading.level,
5218
+ text: heading.text,
5219
+ pageIndex: heading.pageIndex,
5220
+ ...(bookmarkName ? { bookmarkName } : {}),
5221
+ };
5222
+ });
5217
5223
  if (resultEntries.length === 0) {
5218
5224
  resultEntries = entries;
5219
5225
  }
@@ -5410,12 +5416,20 @@ function buildInlineNodesFromDisplayText(text: string): InlineNode[] {
5410
5416
  * resolver, falls back to the raw `pageIndex + 1` (pre-P5 behavior).
5411
5417
  */
5412
5418
  function buildTocInlineNodes(
5413
- entries: ReadonlyArray<{ level: number; text: string; pageIndex: number }>,
5419
+ entries: ReadonlyArray<{ level: number; text: string; pageIndex: number; bookmarkName?: string }>,
5414
5420
  resolveDisplayPageNumber?: (pageIndex: number) => number | null,
5415
5421
  ): InlineNode[] {
5416
5422
  const children: InlineNode[] = [];
5417
5423
  entries.forEach((entry, index) => {
5418
- children.push({ type: "text", text: entry.text });
5424
+ if (entry.bookmarkName) {
5425
+ children.push({
5426
+ type: "hyperlink",
5427
+ href: `#${entry.bookmarkName}`,
5428
+ children: [{ type: "text", text: entry.text }],
5429
+ } as HyperlinkNode);
5430
+ } else {
5431
+ children.push({ type: "text", text: entry.text });
5432
+ }
5419
5433
  children.push({ type: "tab" });
5420
5434
  const displayed = resolveDisplayPageNumber?.(entry.pageIndex);
5421
5435
  children.push({
@@ -5431,7 +5445,7 @@ function buildTocInlineNodes(
5431
5445
 
5432
5446
  /** Test-only export of `buildTocInlineNodes` (P5 unit tests). */
5433
5447
  export function __buildTocInlineNodes(
5434
- entries: ReadonlyArray<{ level: number; text: string; pageIndex: number }>,
5448
+ entries: ReadonlyArray<{ level: number; text: string; pageIndex: number; bookmarkName?: string }>,
5435
5449
  resolveDisplayPageNumber?: (pageIndex: number) => number | null,
5436
5450
  ): InlineNode[] {
5437
5451
  return buildTocInlineNodes(entries, resolveDisplayPageNumber);
@@ -59,6 +59,7 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
59
59
  swapMeasurementProvider: () => undefined,
60
60
  invalidateMeasurementCache: () => undefined,
61
61
  getTableRenderPlan: () => null,
62
+ getTableBodyYOffsetOnPage: () => null,
62
63
  getDirtyFieldFamilies: () => [],
63
64
  getFieldDirtinessReport: () => emptyReport,
64
65
  setVisibleBlockRange: () => undefined,
@@ -113,6 +113,13 @@
113
113
  * pages so chrome can prepend header rows visually. No
114
114
  * pixel-geometry change; cache envelopes from v11 invalidate
115
115
  * because the table-render-plan contract changed.
116
+ * 13 — Lane 6d.U2 canvas-seam pill polish: the canvas-posture page-break
117
+ * widget's "N / M" badge is promoted from transparent text over the
118
+ * dotted seam to a true pill with `--radius-pill` geometry, hairline
119
+ * `--color-border-default` border, and `--shadow-soft`. Widget DOM
120
+ * shape changed (new `data-variant="pill"` attribute; additional
121
+ * inline style declarations on the badge). Cache envelopes from v12
122
+ * invalidate because the decoration's cacheable DOM shape changed.
116
123
  * 13 — Lane 3a P14.c: render-kernel gains a single-slot `DecorationIndex`
117
124
  * cache keyed on (revision, activeStory.kind, zoom.pxPerTwip, and
118
125
  * reference equality on each decoration source). When layout
@@ -122,8 +129,58 @@
122
129
  * rebuild path (on every keystroke that triggers a layout event).
123
130
  * No pixel-geometry change; cache envelopes from v12 invalidate
124
131
  * because the render-kernel source changed.
132
+ * 14 — Lane 3a Slice 5: `RuntimeBlockFragment` gains `resolvedStyleChainRef`
133
+ * (block's styleId) and `numberingInstanceId` (block's list-instance id).
134
+ * `analyzeInvalidation` for `styles-change` (when `dirtyStyleIds` is
135
+ * supplied) and `numbering-change` (when `numberingInstanceId` is
136
+ * supplied) now return `scope: "bounded"` starting from the first page
137
+ * whose fragments reference the dirty style / instance. Fallback to
138
+ * `scope: "full"` when payload is absent or no match found. No
139
+ * pixel-geometry change; cache envelopes from v13 invalidate because
140
+ * the fragment shape and invalidation-scope contract changed.
141
+ * 15 — Bug fixes: `pageNodesStructurallyEqual` now compares
142
+ * `lineBoxes.length` and `noteAllocations.length` as structural
143
+ * proxies to prevent stale-node reuse when line geometry changes
144
+ * with stable fragment IDs (L1). `analyzeSectionChange` normalizes
145
+ * `dirtySectionRange` to guarantee from ≤ to for all graph states
146
+ * including empty-sections fallback (L2).
147
+ * 16 — Bug fixes: `diffRenderFrames` now flags pages whose physical frame
148
+ * changed (but block regions are stable) with `pageFrameChanged: true`
149
+ * in `changedPages` so consumers can re-project without a block-region
150
+ * signal (R1). Chrome reservation changes (`railLaneTwips`,
151
+ * `balloonLaneTwips`, `footnoteAreaTwips`, `pageFrameWidthPx`,
152
+ * `pageFrameHeightPx`) now trigger `changedPages` so overlay
153
+ * re-projection is not silently skipped (R2).
154
+ * 17 — Lane 3a Slice 2 + R4: `WordReviewEditorLayoutFacet` gains
155
+ * `getTableBodyYOffsetOnPage(blockId, pageIndex)` which returns the
156
+ * Y offset (in twips from body top) of the table's first fragment on
157
+ * a given page by summing prior body-fragment heights. Used by the
158
+ * new `TwTableContinuationHeader` chrome overlay to position repeated
159
+ * header rows on continuation pages of multi-page tables — no DOM
160
+ * measurement, layout-engine fragment heights only. No cached-geometry
161
+ * change; cache envelopes from v16 invalidate because the facet
162
+ * interface changed.
163
+ * 18 — Lane 3a Slice 6: `buildPageStackFromWithSplits` no longer discards
164
+ * `resumeAt.startOffset`. When `startOffset > 0` and no block
165
+ * straddles the dirty section boundary, only sections at and after
166
+ * the first dirty section are paginated; the resulting page indices
167
+ * are shifted by `startPageIndex` so they align with the global graph.
168
+ * Full-paginate + tail-slice fallback used when a block straddles the
169
+ * section boundary (safety guard). This eliminates re-paginating
170
+ * settled head sections on every bounded-invalidation relayout.
171
+ * No pixel-geometry change; cache envelopes from v17 invalidate
172
+ * because `buildPageStackFromWithSplits` output contract changed.
173
+ * 19 — Slice 5 bug-fix: `analyzeNumberingChange` now honors its own
174
+ * "Fallback to full rebuild when absent or no match" contract. When
175
+ * `numberingInstanceId` is supplied but no materialized fragment
176
+ * matches it, the analyzer returns `scope: "full"` +
177
+ * `requiresFullRecompute: true` instead of the prior "bounded over
178
+ * full range" shortcut, which bypassed the safety guard and could
179
+ * leak stale field-family projections. No pixel-geometry change;
180
+ * cache envelopes from v18 invalidate because the invalidation
181
+ * classifier's contract corrected.
125
182
  */
126
- export const LAYOUT_ENGINE_VERSION = 13 as const;
183
+ export const LAYOUT_ENGINE_VERSION = 19 as const;
127
184
 
128
185
  /**
129
186
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -13,19 +13,21 @@
13
13
  * edit offsets (§analyzeContentEdit).
14
14
  * - `section-change`: bounded to the first page of the affected section
15
15
  * onward — P10 Phase C (§analyzeSectionChange).
16
- * - `numbering-change`: bounded to the whole document (dirtyPageRange
17
- * spans pages [0..end]); tightening to the earliest page referencing
18
- * the numbering instance requires per-fragment numbering metadata
19
- * on `RuntimeBlockFragment` which is not available today.
16
+ * - `styles-change` (Slice 5): when `dirtyStyleIds` is supplied, bounded
17
+ * to the first page whose fragments carry a matching `resolvedStyleChainRef`.
18
+ * Fallback to full rebuild when the payload is absent or no match found.
19
+ * - `numbering-change` (Slice 5): when `numberingInstanceId` is supplied,
20
+ * bounded to the first page whose fragments carry a matching
21
+ * `numberingInstanceId`. Fallback to full rebuild when absent or no match.
20
22
  *
21
23
  * Remaining full-rebuild triggers:
22
- * - `styles-change` / `theme-change`: without per-fragment style-chain
23
- * metadata on the graph, we cannot safely tighten these. Flagged as
24
- * follow-up for when `RuntimeBlockFragment.resolvedStyleChain` lands.
24
+ * - `theme-change`: theme token changes cascade through the entire
25
+ * style chain; no fragment-level tightening possible without knowing
26
+ * which blocks reference theme tokens.
25
27
  */
26
28
 
27
29
  import type { LayoutInvalidationReason } from "./paginated-layout-engine.ts";
28
- import type { RuntimePageGraph, RuntimePageNode } from "./page-graph.ts";
30
+ import type { RuntimeBlockFragment, RuntimePageGraph } from "./page-graph.ts";
29
31
  import type { ResolvedDocumentSection } from "../document-layout.ts";
30
32
 
31
33
  // ---------------------------------------------------------------------------
@@ -92,15 +94,16 @@ export function analyzeInvalidation(
92
94
 
93
95
  switch (reason.kind) {
94
96
  case "full":
95
- case "styles-change":
96
97
  case "theme-change":
97
- // These affect the entire document
98
98
  return {
99
99
  scope: "full",
100
100
  requiresFullRecompute: true,
101
101
  dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
102
102
  };
103
103
 
104
+ case "styles-change":
105
+ return analyzeStylesChange(reason, graph);
106
+
104
107
  case "content-edit":
105
108
  return analyzeContentEdit(reason, graph);
106
109
 
@@ -108,26 +111,9 @@ export function analyzeInvalidation(
108
111
  return analyzeSectionChange(reason, graph);
109
112
 
110
113
  case "numbering-change":
111
- if (!reason.numberingInstanceId) {
112
- return {
113
- scope: "full",
114
- requiresFullRecompute: true,
115
- dirtyFieldFamilies: [],
116
- };
117
- }
118
- return {
119
- scope: "bounded",
120
- requiresFullRecompute: false,
121
- dirtyPageRange: {
122
- firstPageIndex: 0,
123
- lastPageIndex: Math.max(0, graph.pages.length - 1),
124
- },
125
- dirtySectionRange: null,
126
- dirtyFieldFamilies: [],
127
- };
114
+ return analyzeNumberingChange(reason, graph);
128
115
 
129
116
  case "field-refresh":
130
- // Field refresh doesn't change layout, just field display values
131
117
  return {
132
118
  scope: "field-only",
133
119
  requiresFullRecompute: false,
@@ -277,12 +263,18 @@ function analyzeSectionChange(
277
263
  reason.sectionIndex,
278
264
  );
279
265
  if (firstPageOfSection < 0) {
266
+ // L2: Clamp `from` so the range is never backward when the affected
267
+ // section index exceeds the number of materialized sections (e.g.
268
+ // section-change on a section that hasn't rendered yet, or an empty
269
+ // graph). Without the clamp, {from:5, to:0} is a backward range that
270
+ // confuses consumers iterating [from..to].
271
+ const lastSectionIdx = Math.max(0, graph.sections.length - 1);
280
272
  return {
281
273
  scope: "full",
282
274
  requiresFullRecompute: true,
283
275
  dirtySectionRange: {
284
- from: reason.sectionIndex,
285
- to: Math.max(0, graph.sections.length - 1),
276
+ from: Math.min(reason.sectionIndex, lastSectionIdx),
277
+ to: lastSectionIdx,
286
278
  },
287
279
  dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
288
280
  };
@@ -321,3 +313,131 @@ function findFirstPageIndexForSection(
321
313
  // rendered tail. Caller treats this as a full-rebuild trigger.
322
314
  return -1;
323
315
  }
316
+
317
+ /**
318
+ * Slice 5 — narrow `styles-change` invalidation.
319
+ *
320
+ * When `reason.dirtyStyleIds` is present, scan fragments for the first
321
+ * fragment whose `resolvedStyleChainRef` matches any dirty style id.
322
+ * The page containing that fragment is the first that must rebuild.
323
+ *
324
+ * Fallback to full rebuild when `dirtyStyleIds` is absent, empty, or no
325
+ * fragment matches (e.g. graphs built before Slice 5 shipped).
326
+ */
327
+ function analyzeStylesChange(
328
+ reason: Extract<LayoutInvalidationReason, { kind: "styles-change" }>,
329
+ graph: RuntimePageGraph,
330
+ ): InvalidationResult {
331
+ if (!reason.dirtyStyleIds || reason.dirtyStyleIds.length === 0) {
332
+ return {
333
+ scope: "full",
334
+ requiresFullRecompute: true,
335
+ dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
336
+ };
337
+ }
338
+
339
+ const dirtySet = new Set(reason.dirtyStyleIds);
340
+ const firstDirtyPageIndex = findFirstPageIndexByFragmentPredicate(
341
+ graph,
342
+ (f) =>
343
+ f.resolvedStyleChainRef !== undefined &&
344
+ dirtySet.has(f.resolvedStyleChainRef),
345
+ );
346
+
347
+ if (firstDirtyPageIndex < 0) {
348
+ return {
349
+ scope: "full",
350
+ requiresFullRecompute: true,
351
+ dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
352
+ };
353
+ }
354
+
355
+ return {
356
+ scope: "bounded",
357
+ requiresFullRecompute: false,
358
+ dirtyPageRange: {
359
+ firstPageIndex: firstDirtyPageIndex,
360
+ lastPageIndex: Math.max(0, graph.pages.length - 1),
361
+ },
362
+ dirtySectionRange: null,
363
+ dirtyFieldFamilies: [],
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Slice 5 — narrow `numbering-change` invalidation.
369
+ *
370
+ * When `reason.numberingInstanceId` is present, find the first page
371
+ * containing a fragment with matching `numberingInstanceId` and start
372
+ * the rebuild there. Fallback to full rebuild when absent or no match.
373
+ */
374
+ function analyzeNumberingChange(
375
+ reason: Extract<LayoutInvalidationReason, { kind: "numbering-change" }>,
376
+ graph: RuntimePageGraph,
377
+ ): InvalidationResult {
378
+ if (!reason.numberingInstanceId) {
379
+ return {
380
+ scope: "full",
381
+ requiresFullRecompute: true,
382
+ dirtyFieldFamilies: [],
383
+ };
384
+ }
385
+
386
+ const instanceId = reason.numberingInstanceId;
387
+ const firstDirtyPageIndex = findFirstPageIndexByFragmentPredicate(
388
+ graph,
389
+ (f) => f.numberingInstanceId === instanceId,
390
+ );
391
+
392
+ if (firstDirtyPageIndex < 0) {
393
+ // No fragment matches (e.g. graph has no fragments yet, or the
394
+ // numberingInstanceId references an instance that hasn't materialized on
395
+ // any page). Per the function's contract — "Fallback to full rebuild
396
+ // when absent or no match" — return a full rebuild so the caller
397
+ // re-paginates from scratch rather than a bounded pass over stale
398
+ // geometry.
399
+ return {
400
+ scope: "full",
401
+ requiresFullRecompute: true,
402
+ dirtyFieldFamilies: [],
403
+ };
404
+ }
405
+
406
+ return {
407
+ scope: "bounded",
408
+ requiresFullRecompute: false,
409
+ dirtyPageRange: {
410
+ firstPageIndex: firstDirtyPageIndex,
411
+ lastPageIndex: Math.max(0, graph.pages.length - 1),
412
+ },
413
+ dirtySectionRange: null,
414
+ dirtyFieldFamilies: [],
415
+ };
416
+ }
417
+
418
+ /**
419
+ * Find the pageIndex of the first non-filler page whose fragments include
420
+ * at least one fragment satisfying `predicate`. Returns -1 if none found.
421
+ */
422
+ function findFirstPageIndexByFragmentPredicate(
423
+ graph: RuntimePageGraph,
424
+ predicate: (f: RuntimeBlockFragment) => boolean,
425
+ ): number {
426
+ const pageIdToIndex = new Map<string, number>();
427
+ for (const page of graph.pages) {
428
+ if (!page.isBlankFiller) {
429
+ pageIdToIndex.set(page.pageId, page.pageIndex);
430
+ }
431
+ }
432
+
433
+ let firstDirtyPageIndex = -1;
434
+ for (const fragment of graph.fragments) {
435
+ if (!predicate(fragment)) continue;
436
+ const pageIndex = pageIdToIndex.get(fragment.pageId);
437
+ if (pageIndex === undefined) continue;
438
+ if (firstDirtyPageIndex < 0 || pageIndex < firstDirtyPageIndex) {
439
+ firstDirtyPageIndex = pageIndex;
440
+ }
441
+ }
442
+ return firstDirtyPageIndex;
443
+ }
@@ -149,6 +149,18 @@ export interface RuntimeBlockFragment {
149
149
  to: number;
150
150
  totalRows: number;
151
151
  };
152
+ /**
153
+ * Slice 5 — opaque style-chain ref derived from the block's `styleId`.
154
+ * Used by `analyzeStylesChange` to bound invalidation to the first page
155
+ * that carries a fragment referencing a dirty style.
156
+ */
157
+ resolvedStyleChainRef?: string;
158
+ /**
159
+ * Slice 5 — numbering instance id copied from the block's `numbering`
160
+ * field. Used by `analyzeNumberingChange` to bound invalidation to the
161
+ * first page that carries a fragment from the affected list instance.
162
+ */
163
+ numberingInstanceId?: string;
152
164
  }
153
165
 
154
166
  export interface RuntimeLineBox {
@@ -629,6 +641,13 @@ function pageNodesStructurallyEqual(
629
641
  for (let i = 0; i < aFoot.length; i += 1) {
630
642
  if (!regionFragmentsEqual(aFoot[i]!, bFoot[i]!)) return false;
631
643
  }
644
+ // L1: Include line-box and note-allocation counts as structural proxies.
645
+ // Fragment IDs staying stable while line geometry changes (font-metric
646
+ // change, float-wrap) would allow stale node reuse without these guards.
647
+ // Length-only is O(1) and catches the common case; deep baseline
648
+ // comparison is deferred.
649
+ if (a.lineBoxes.length !== b.lineBoxes.length) return false;
650
+ if (a.noteAllocations.length !== b.noteAllocations.length) return false;
632
651
  return true;
633
652
  }
634
653