@beyondwork/docx-react-component 1.0.36 → 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 (107) hide show
  1. package/README.md +103 -13
  2. package/package.json +1 -1
  3. package/src/api/package-version.ts +13 -0
  4. package/src/api/public-types.ts +402 -1
  5. package/src/core/commands/index.ts +18 -1
  6. package/src/core/commands/section-layout-commands.ts +58 -0
  7. package/src/core/commands/table-grid.ts +431 -0
  8. package/src/core/commands/table-structure-commands.ts +815 -55
  9. package/src/core/selection/mapping.ts +6 -0
  10. package/src/io/docx-session.ts +24 -9
  11. package/src/io/export/build-app-properties-xml.ts +88 -0
  12. package/src/io/export/serialize-comments.ts +6 -1
  13. package/src/io/export/serialize-footnotes.ts +10 -9
  14. package/src/io/export/serialize-headers-footers.ts +11 -10
  15. package/src/io/export/serialize-main-document.ts +328 -50
  16. package/src/io/export/serialize-numbering.ts +114 -24
  17. package/src/io/export/serialize-tables.ts +87 -11
  18. package/src/io/export/table-properties-xml.ts +174 -20
  19. package/src/io/export/twip.ts +66 -0
  20. package/src/io/normalize/normalize-text.ts +20 -0
  21. package/src/io/ooxml/parse-footnotes.ts +62 -1
  22. package/src/io/ooxml/parse-headers-footers.ts +62 -1
  23. package/src/io/ooxml/parse-main-document.ts +158 -1
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/legal/bookmarks.ts +78 -0
  26. package/src/model/canonical-document.ts +45 -0
  27. package/src/review/store/scope-tag-diff.ts +130 -0
  28. package/src/runtime/document-layout.ts +4 -2
  29. package/src/runtime/document-navigation.ts +2 -306
  30. package/src/runtime/document-runtime.ts +287 -11
  31. package/src/runtime/layout/default-page-format.ts +96 -0
  32. package/src/runtime/layout/docx-font-loader.ts +143 -0
  33. package/src/runtime/layout/index.ts +233 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +59 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +628 -0
  36. package/src/runtime/layout/layout-invalidation.ts +257 -0
  37. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  38. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  39. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  40. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  43. package/src/runtime/layout/page-graph.ts +452 -0
  44. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  45. package/src/runtime/layout/page-story-resolver.ts +195 -0
  46. package/src/runtime/layout/paginated-layout-engine.ts +921 -0
  47. package/src/runtime/layout/project-block-fragments.ts +91 -0
  48. package/src/runtime/layout/public-facet.ts +1398 -0
  49. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  50. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  51. package/src/runtime/layout/table-render-plan.ts +229 -0
  52. package/src/runtime/render/block-fragment-projection.ts +35 -0
  53. package/src/runtime/render/decoration-resolver.ts +189 -0
  54. package/src/runtime/render/index.ts +57 -0
  55. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  56. package/src/runtime/render/render-frame-types.ts +317 -0
  57. package/src/runtime/render/render-kernel.ts +755 -0
  58. package/src/runtime/scope-tag-registry.ts +95 -0
  59. package/src/runtime/surface-projection.ts +1 -0
  60. package/src/runtime/text-ack-range.ts +49 -0
  61. package/src/runtime/view-state.ts +67 -0
  62. package/src/runtime/workflow-markup.ts +1 -5
  63. package/src/runtime/workflow-rail-segments.ts +280 -0
  64. package/src/ui/WordReviewEditor.tsx +99 -15
  65. package/src/ui/editor-runtime-boundary.ts +10 -1
  66. package/src/ui/editor-shell-view.tsx +6 -0
  67. package/src/ui/editor-surface-controller.tsx +3 -0
  68. package/src/ui/headless/chrome-registry.ts +501 -0
  69. package/src/ui/headless/scoped-chrome-policy.ts +183 -0
  70. package/src/ui/headless/selection-tool-context.ts +2 -0
  71. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  72. package/src/ui/headless/selection-tool-types.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  74. package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
  75. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  76. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
  77. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
  78. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  79. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  80. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
  81. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
  82. package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
  83. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  85. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
  86. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +337 -0
  87. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +100 -0
  88. package/src/ui-tailwind/editor-surface/perf-probe.ts +27 -1
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +20 -2
  90. package/src/ui-tailwind/editor-surface/pm-decorations.ts +93 -23
  91. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  92. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  93. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  94. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +176 -6
  95. package/src/ui-tailwind/index.ts +33 -0
  96. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  97. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  98. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  99. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  100. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  101. package/src/ui-tailwind/theme/editor-theme.css +505 -144
  102. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
  103. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  104. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  105. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  106. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +304 -166
  107. package/src/ui-tailwind/tw-review-workspace.tsx +163 -2
@@ -6,9 +6,15 @@ import type {
6
6
  import type {
7
7
  DocumentRootNode,
8
8
  ParagraphNode,
9
+ TableBorders,
10
+ TableCellBorders,
11
+ TableCellMargins,
9
12
  TableCellNode,
13
+ TableIndent,
10
14
  TableNode,
11
15
  TableRowNode,
16
+ TableWidth,
17
+ CellShading,
12
18
  } from "../../model/canonical-document.ts";
13
19
  import type { TableSelectionDescriptor } from "../../runtime/table-commands.ts";
14
20
  import type { CanonicalDocumentEnvelope, SelectionSnapshot } from "../state/editor-state.ts";
@@ -24,8 +30,26 @@ import {
24
30
  findTopLevelParagraphSelectionNearBlock,
25
31
  type StructuralMutationResult,
26
32
  } from "./structural-helpers.ts";
27
-
28
- type TableStructureOperation =
33
+ import {
34
+ analyzeRect,
35
+ buildLogicalGrid,
36
+ computeLogicalColumnCount as computeLogicalColumnCountFromGrid,
37
+ findCellIndexAtColumn,
38
+ originAt,
39
+ slotAt,
40
+ type GridOriginSlot,
41
+ type LogicalGrid,
42
+ } from "./table-grid.ts";
43
+
44
+ export type CellLocator =
45
+ | { kind: "anchor" }
46
+ | { kind: "index"; rowIndex: number; columnIndex: number }
47
+ | { kind: "rect"; rect: { top: number; left: number; bottom: number; right: number } }
48
+ | { kind: "row"; rowIndex: number }
49
+ | { kind: "column"; columnIndex: number }
50
+ | { kind: "selection" };
51
+
52
+ export type TableStructureOperation =
29
53
  | { type: "add-row-before" }
30
54
  | { type: "add-row-after" }
31
55
  | { type: "add-column-before" }
@@ -35,7 +59,35 @@ type TableStructureOperation =
35
59
  | { type: "delete-table" }
36
60
  | { type: "merge-cells" }
37
61
  | { type: "split-cell" }
38
- | { type: "set-cell-background"; color: string };
62
+ | { type: "set-cell-background"; color: string }
63
+ // P2g — table-level
64
+ | { type: "set-table-width"; width: TableWidth }
65
+ | { type: "set-table-alignment"; alignment: "left" | "center" | "right" }
66
+ | { type: "set-table-indent"; indent: TableIndent }
67
+ | { type: "set-table-layout-mode"; mode: "fixed" | "autofit" }
68
+ | { type: "set-table-cell-margins"; margins: Partial<TableCellMargins> }
69
+ | { type: "set-table-borders"; borders: Partial<TableBorders> }
70
+ | { type: "set-table-style"; styleId: string | null }
71
+ | { type: "set-table-caption"; caption: string | null }
72
+ | { type: "set-table-description"; description: string | null }
73
+ // P2h — column / row sizing + row props
74
+ | { type: "set-column-width"; columnIndex: number; twips: number }
75
+ | { type: "distribute-columns-evenly"; columnRange?: { from: number; to: number } }
76
+ | { type: "set-row-height"; rowIndex: number; twips: number; rule: "auto" | "atLeast" | "exact" }
77
+ | { type: "set-row-cant-split"; rowIndex: number; value: boolean }
78
+ | { type: "set-row-is-header"; rowIndex: number; value: boolean }
79
+ | { type: "set-row-alignment"; rowIndex: number; alignment: "left" | "center" | "right" }
80
+ | { type: "insert-rows"; rowIndex: number; at: "before" | "after"; count: number }
81
+ | { type: "insert-columns"; columnIndex: number; at: "before" | "after"; count: number; widths?: readonly number[] }
82
+ // P2i — cell-level
83
+ | { type: "set-cell-borders"; locator: CellLocator; borders: Partial<TableCellBorders> }
84
+ | { type: "set-cell-shading"; locator: CellLocator; shading: Partial<CellShading> | null }
85
+ | { type: "clear-cell-shading"; locator: CellLocator }
86
+ | { type: "set-cell-margins"; locator: CellLocator; margins: Partial<TableCellMargins> }
87
+ | { type: "set-cell-vertical-align"; locator: CellLocator; align: "top" | "center" | "bottom" }
88
+ | { type: "set-cell-text-direction"; locator: CellLocator; direction: "lrTb" | "tbRl" | "btLr" }
89
+ | { type: "set-cell-no-wrap"; locator: CellLocator; value: boolean }
90
+ | { type: "set-cell-fit-text"; locator: CellLocator; value: boolean };
39
91
 
40
92
  export function applyTableStructureOperation(
41
93
  document: CanonicalDocumentEnvelope,
@@ -81,6 +133,127 @@ export function applyTableStructureOperation(
81
133
  return addColumn(document, root, target, effectiveSelection, "after", fallbackSelection);
82
134
  case "delete-column":
83
135
  return deleteColumn(document, root, target, effectiveSelection, fallbackSelection);
136
+ case "set-table-width":
137
+ return patchTable(document, root, target, effectiveSelection, { width: operation.width }, fallbackSelection);
138
+ case "set-table-alignment":
139
+ return patchTable(document, root, target, effectiveSelection, { alignment: operation.alignment }, fallbackSelection);
140
+ case "set-table-indent":
141
+ return patchTable(document, root, target, effectiveSelection, { indent: operation.indent }, fallbackSelection);
142
+ case "set-table-layout-mode":
143
+ return patchTable(document, root, target, effectiveSelection, { layoutMode: operation.mode }, fallbackSelection);
144
+ case "set-table-cell-margins":
145
+ return patchTable(
146
+ document,
147
+ root,
148
+ target,
149
+ effectiveSelection,
150
+ { cellMargins: mergeTableCellMargins(target.cellMargins, operation.margins) },
151
+ fallbackSelection,
152
+ );
153
+ case "set-table-borders":
154
+ return patchTable(
155
+ document,
156
+ root,
157
+ target,
158
+ effectiveSelection,
159
+ { borders: mergeTableBorders(target.borders, operation.borders) },
160
+ fallbackSelection,
161
+ );
162
+ case "set-table-style":
163
+ return patchTable(
164
+ document,
165
+ root,
166
+ target,
167
+ effectiveSelection,
168
+ { styleId: operation.styleId ?? undefined },
169
+ fallbackSelection,
170
+ );
171
+ case "set-table-caption":
172
+ return patchTable(
173
+ document,
174
+ root,
175
+ target,
176
+ effectiveSelection,
177
+ { caption: operation.caption ?? undefined },
178
+ fallbackSelection,
179
+ );
180
+ case "set-table-description":
181
+ return patchTable(
182
+ document,
183
+ root,
184
+ target,
185
+ effectiveSelection,
186
+ { description: operation.description ?? undefined },
187
+ fallbackSelection,
188
+ );
189
+ case "set-column-width":
190
+ return setColumnWidth(document, root, target, effectiveSelection, operation.columnIndex, operation.twips, fallbackSelection);
191
+ case "distribute-columns-evenly":
192
+ return distributeColumnsEvenly(document, root, target, effectiveSelection, operation.columnRange, fallbackSelection);
193
+ case "set-row-height":
194
+ return patchRow(document, root, target, effectiveSelection, operation.rowIndex, {
195
+ height: operation.twips,
196
+ heightRule: operation.rule,
197
+ }, fallbackSelection);
198
+ case "set-row-cant-split":
199
+ return patchRow(document, root, target, effectiveSelection, operation.rowIndex, {
200
+ cantSplit: operation.value,
201
+ }, fallbackSelection);
202
+ case "set-row-is-header":
203
+ return patchRow(document, root, target, effectiveSelection, operation.rowIndex, {
204
+ isHeader: operation.value,
205
+ }, fallbackSelection);
206
+ case "set-row-alignment":
207
+ return patchRow(document, root, target, effectiveSelection, operation.rowIndex, {
208
+ horizontalAlignment: operation.alignment,
209
+ }, fallbackSelection);
210
+ case "insert-rows":
211
+ return insertRows(document, root, target, effectiveSelection, operation.rowIndex, operation.at, operation.count, fallbackSelection);
212
+ case "insert-columns":
213
+ return insertColumns(document, root, target, effectiveSelection, operation.columnIndex, operation.at, operation.count, operation.widths, fallbackSelection);
214
+ case "set-cell-borders":
215
+ return patchCells(document, root, target, effectiveSelection, operation.locator, (cell) => ({
216
+ ...cell,
217
+ borders: mergeCellBorders(cell.borders, operation.borders),
218
+ }), fallbackSelection);
219
+ case "set-cell-shading":
220
+ return patchCells(document, root, target, effectiveSelection, operation.locator, (cell) => {
221
+ if (operation.shading === null) {
222
+ const { shading: _drop, ...rest } = cell;
223
+ return rest as TableCellNode;
224
+ }
225
+ return { ...cell, shading: { ...(cell.shading ?? {}), ...operation.shading } };
226
+ }, fallbackSelection);
227
+ case "clear-cell-shading":
228
+ return patchCells(document, root, target, effectiveSelection, operation.locator, (cell) => {
229
+ const { shading: _drop, ...rest } = cell;
230
+ return rest as TableCellNode;
231
+ }, fallbackSelection);
232
+ case "set-cell-margins":
233
+ return patchCells(document, root, target, effectiveSelection, operation.locator, (cell) => ({
234
+ ...cell,
235
+ margins: { ...(cell.margins ?? {}), ...operation.margins },
236
+ }), fallbackSelection);
237
+ case "set-cell-vertical-align":
238
+ return patchCells(document, root, target, effectiveSelection, operation.locator, (cell) => ({
239
+ ...cell,
240
+ verticalAlign: operation.align,
241
+ }), fallbackSelection);
242
+ case "set-cell-text-direction":
243
+ return patchCells(document, root, target, effectiveSelection, operation.locator, (cell) => ({
244
+ ...cell,
245
+ textDirection: operation.direction,
246
+ }), fallbackSelection);
247
+ case "set-cell-no-wrap":
248
+ return patchCells(document, root, target, effectiveSelection, operation.locator, (cell) => ({
249
+ ...cell,
250
+ noWrap: operation.value,
251
+ }), fallbackSelection);
252
+ case "set-cell-fit-text":
253
+ return patchCells(document, root, target, effectiveSelection, operation.locator, (cell) => ({
254
+ ...cell,
255
+ fitText: operation.value,
256
+ }), fallbackSelection);
84
257
  default:
85
258
  return createNoopStructuralMutation(document, fallbackSelection);
86
259
  }
@@ -135,6 +308,19 @@ export function getTableStructureContext(
135
308
  )
136
309
  : 1;
137
310
 
311
+ // Build the logical grid once so merge-aware capability checks share the
312
+ // same traversal as the commands they gate.
313
+ const grid = buildLogicalGrid(target);
314
+ const mergeRectCoverage =
315
+ effectiveSelection.selectionKind === "cell" && selectedCellCount > 1
316
+ ? analyzeRect(grid, {
317
+ top: effectiveSelection.rect.top,
318
+ left: effectiveSelection.rect.left,
319
+ bottom: effectiveSelection.rect.bottom,
320
+ right: effectiveSelection.rect.right,
321
+ })
322
+ : null;
323
+
138
324
  return {
139
325
  tableBlockIndex: effectiveSelection.tableBlockIndex,
140
326
  currentStyleId: target.styleId ?? null,
@@ -151,41 +337,66 @@ export function getTableStructureContext(
151
337
  operations: {
152
338
  setTableStyle: enabledCapability(),
153
339
  setCellBackground: enabledCapability(),
154
- addRowBefore: simpleTable
155
- ? enabledCapability()
156
- : disabledCapability("Only simple rectangular tables support row insertion right now."),
157
- addRowAfter: simpleTable
158
- ? enabledCapability()
159
- : disabledCapability("Only simple rectangular tables support row insertion right now."),
340
+ addRowBefore: enabledCapability(),
341
+ addRowAfter: enabledCapability(),
160
342
  deleteRow:
161
- !simpleTable
162
- ? disabledCapability("Only simple rectangular tables support row deletion right now.")
163
- : target.rows.length <= 1
164
- ? disabledCapability("At least two rows are required before a row can be deleted.")
165
- : enabledCapability(),
166
- addColumnBefore: simpleTable
167
- ? enabledCapability()
168
- : disabledCapability("Only simple rectangular tables support column insertion right now."),
169
- addColumnAfter: simpleTable
170
- ? enabledCapability()
171
- : disabledCapability("Only simple rectangular tables support column insertion right now."),
343
+ target.rows.length <= 1
344
+ ? disabledCapability("At least two rows are required before a row can be deleted.")
345
+ : enabledCapability(),
346
+ addColumnBefore: enabledCapability(),
347
+ addColumnAfter: enabledCapability(),
172
348
  deleteColumn:
173
- !simpleTable
174
- ? disabledCapability("Only simple rectangular tables support column deletion right now.")
175
- : columnCount <= 1
176
- ? disabledCapability("At least two columns are required before a column can be deleted.")
177
- : enabledCapability(),
349
+ columnCount <= 1
350
+ ? disabledCapability("At least two columns are required before a column can be deleted.")
351
+ : enabledCapability(),
178
352
  mergeCells:
179
- !simpleTable
180
- ? disabledCapability("Only simple rectangular tables support merging right now.")
181
- : effectiveSelection.selectionKind !== "cell" || selectedCellCount <= 1
353
+ effectiveSelection.selectionKind !== "cell"
354
+ ? disabledCapability("Select more than one cell to merge them.")
355
+ : selectedCellCount <= 1
182
356
  ? disabledCapability("Select more than one cell to merge them.")
183
- : enabledCapability(),
357
+ : mergeRectCoverage && !mergeRectCoverage.clean
358
+ ? disabledCapability(
359
+ "Selection cuts through a merged cell. Extend the selection to fully enclose the span.",
360
+ )
361
+ : enabledCapability(),
184
362
  splitCell:
185
363
  splitWidth === 1 && splitHeight === 1
186
364
  ? disabledCapability("Select a merged or spanning cell to split it.")
187
365
  : enabledCapability(),
188
366
  deleteTable: enabledCapability(),
367
+ // P2g — table-level
368
+ setTableWidth: enabledCapability(),
369
+ setTableAlignment: enabledCapability(),
370
+ setTableIndent: enabledCapability(),
371
+ setTableLayoutMode: enabledCapability(),
372
+ setTableCellMargins: enabledCapability(),
373
+ setTableBorders: enabledCapability(),
374
+ setTableCaption: enabledCapability(),
375
+ setTableDescription: enabledCapability(),
376
+ // P2h — column/row sizing + row props
377
+ setColumnWidth:
378
+ target.gridColumns.length > 0
379
+ ? enabledCapability()
380
+ : disabledCapability("Table has no grid columns to size."),
381
+ distributeColumnsEvenly:
382
+ target.gridColumns.length > 1
383
+ ? enabledCapability()
384
+ : disabledCapability("At least two columns are required to distribute width."),
385
+ setRowHeight: enabledCapability(),
386
+ setRowCantSplit: enabledCapability(),
387
+ setRowIsHeader: enabledCapability(),
388
+ setRowAlignment: enabledCapability(),
389
+ insertRows: enabledCapability(),
390
+ insertColumns: enabledCapability(),
391
+ // P2i — cell-level
392
+ setCellBorders: enabledCapability(),
393
+ setCellShading: enabledCapability(),
394
+ clearCellShading: enabledCapability(),
395
+ setCellMargins: enabledCapability(),
396
+ setCellVerticalAlign: enabledCapability(),
397
+ setCellTextDirection: enabledCapability(),
398
+ setCellNoWrap: enabledCapability(),
399
+ setCellFitText: enabledCapability(),
189
400
  },
190
401
  };
191
402
  }
@@ -198,17 +409,15 @@ function addRow(
198
409
  position: "before" | "after",
199
410
  fallbackSelection: SelectionSnapshot,
200
411
  ): StructuralMutationResult {
201
- if (!isSimpleTable(table)) {
412
+ const grid = buildLogicalGrid(table);
413
+ const columnCount = grid.columnCount;
414
+ if (columnCount === 0) {
202
415
  return createNoopStructuralMutation(document, fallbackSelection);
203
416
  }
204
417
 
205
- const columnCount = getLogicalColumnCount(table);
206
418
  const insertIndex =
207
419
  position === "before" ? selection.anchorCell.rowIndex : selection.anchorCell.rowIndex + 1;
208
- const nextRow: TableRowNode = {
209
- type: "table_row",
210
- cells: Array.from({ length: columnCount }, () => createEmptyCell()),
211
- };
420
+ const nextRow = buildInsertedRow(grid, insertIndex);
212
421
  const nextTable: TableNode = {
213
422
  ...table,
214
423
  rows: [
@@ -229,6 +438,58 @@ function addRow(
229
438
  );
230
439
  }
231
440
 
441
+ /**
442
+ * Build the cells for a new row inserted at `insertIndex` (index in the
443
+ * original table, before the insertion is applied). For each logical column,
444
+ * if both the row above and the row below belong to the same vMerge chain,
445
+ * the new row extends that chain with a `verticalMerge: "continue"` cell.
446
+ * Otherwise an empty cell is emitted with gridSpan=1.
447
+ */
448
+ function buildInsertedRow(grid: LogicalGrid, insertIndex: number): TableRowNode {
449
+ const cells: TableCellNode[] = [];
450
+ const columnCount = grid.columnCount;
451
+ let column = 0;
452
+ while (column < columnCount) {
453
+ const above = insertIndex > 0 ? originAt(grid, insertIndex - 1, column) : null;
454
+ const below =
455
+ insertIndex < grid.rowCount ? originAt(grid, insertIndex, column) : null;
456
+
457
+ // A vMerge chain continues across the insertion point when:
458
+ // - both above and below resolve to the same origin, and
459
+ // - that origin lives above the insertion point (otherwise the chain
460
+ // starts at `below` and is fully below the insertion point).
461
+ if (
462
+ above &&
463
+ below &&
464
+ above === below &&
465
+ above.rowIndex < insertIndex
466
+ ) {
467
+ const span = above.columnSpan;
468
+ // Align to the origin's leftmost column: if we started iteration
469
+ // mid-span, still treat the full span as the unit.
470
+ if (column !== above.columnIndex) {
471
+ // We're scanning forward, so this shouldn't happen — if it does,
472
+ // fall through to an empty cell for this column only.
473
+ cells.push(createEmptyCell());
474
+ column += 1;
475
+ continue;
476
+ }
477
+ cells.push({
478
+ type: "table_cell",
479
+ children: [createEmptyParagraph()],
480
+ verticalMerge: "continue",
481
+ ...(span > 1 ? { gridSpan: span } : {}),
482
+ });
483
+ column += span;
484
+ continue;
485
+ }
486
+
487
+ cells.push(createEmptyCell());
488
+ column += 1;
489
+ }
490
+ return { type: "table_row", cells };
491
+ }
492
+
232
493
  function deleteRow(
233
494
  document: CanonicalDocumentEnvelope,
234
495
  root: DocumentRootNode,
@@ -236,12 +497,52 @@ function deleteRow(
236
497
  selection: TableSelectionDescriptor,
237
498
  fallbackSelection: SelectionSnapshot,
238
499
  ): StructuralMutationResult {
239
- if (!isSimpleTable(table) || table.rows.length <= 1) {
500
+ if (table.rows.length <= 1) {
240
501
  return createNoopStructuralMutation(document, fallbackSelection);
241
502
  }
242
503
 
243
504
  const deleteIndex = selection.anchorCell.rowIndex;
244
- const nextRows = table.rows.filter((_, rowIndex) => rowIndex !== deleteIndex);
505
+ const grid = buildLogicalGrid(table);
506
+
507
+ // For every logical column, check if this row is the origin of a vMerge
508
+ // chain that extends beyond this row. If so, we must promote the first
509
+ // following continue cell to "restart" and copy over the origin's
510
+ // content/properties so the chain survives the deletion.
511
+ //
512
+ // For columns where this row is a mid-chain continue, the chain's owner
513
+ // origin stays in place — we just need to drop 1 from its effective
514
+ // rowSpan (which happens automatically when the row is removed from
515
+ // the canonical tree).
516
+ const promotions: Array<{
517
+ originColumnIndex: number;
518
+ originColumnSpan: number;
519
+ promotedRowIndex: number;
520
+ originCell: TableCellNode;
521
+ }> = [];
522
+
523
+ for (let column = 0; column < grid.columnCount; ) {
524
+ const origin = originAt(grid, deleteIndex, column);
525
+ if (!origin) {
526
+ column += 1;
527
+ continue;
528
+ }
529
+ if (origin.rowIndex === deleteIndex && origin.rowSpan > 1) {
530
+ promotions.push({
531
+ originColumnIndex: origin.columnIndex,
532
+ originColumnSpan: origin.columnSpan,
533
+ promotedRowIndex: deleteIndex + 1,
534
+ originCell: origin.cell,
535
+ });
536
+ }
537
+ column = origin.columnIndex + origin.columnSpan;
538
+ }
539
+
540
+ // Apply promotions to the row immediately after the deleted one.
541
+ const nextRows = table.rows.map((row, rowIndex) => {
542
+ if (rowIndex !== deleteIndex + 1 || promotions.length === 0) return row;
543
+ return applyVMergePromotions(row, promotions);
544
+ }).filter((_, rowIndex) => rowIndex !== deleteIndex);
545
+
245
546
  const focusRowIndex = Math.max(0, Math.min(deleteIndex, nextRows.length - 1));
246
547
  const nextTable: TableNode = {
247
548
  ...table,
@@ -259,6 +560,43 @@ function deleteRow(
259
560
  );
260
561
  }
261
562
 
563
+ function applyVMergePromotions(
564
+ row: TableRowNode,
565
+ promotions: Array<{
566
+ originColumnIndex: number;
567
+ originColumnSpan: number;
568
+ promotedRowIndex: number;
569
+ originCell: TableCellNode;
570
+ }>,
571
+ ): TableRowNode {
572
+ const nextCells = row.cells.map((cell, cellIndex) => {
573
+ let cursor = row.gridBefore ?? 0;
574
+ // Recompute the cell's logical column position.
575
+ for (let i = 0; i < cellIndex; i += 1) {
576
+ cursor += Math.max(1, row.cells[i]!.gridSpan ?? 1);
577
+ }
578
+ const logicalColumn = cursor;
579
+
580
+ const promotion = promotions.find(
581
+ (p) =>
582
+ p.originColumnIndex === logicalColumn &&
583
+ (cell.gridSpan ?? 1) === p.originColumnSpan &&
584
+ cell.verticalMerge === "continue",
585
+ );
586
+ if (!promotion) return cell;
587
+
588
+ // Promote: copy origin's cell content + properties, mark as restart.
589
+ const { children, ...properties } = promotion.originCell;
590
+ return {
591
+ ...properties,
592
+ children: structuredClone(children),
593
+ verticalMerge: "restart" as const,
594
+ ...(promotion.originColumnSpan > 1 ? { gridSpan: promotion.originColumnSpan } : {}),
595
+ };
596
+ });
597
+ return { ...row, cells: nextCells };
598
+ }
599
+
262
600
  function addColumn(
263
601
  document: CanonicalDocumentEnvelope,
264
602
  root: DocumentRootNode,
@@ -267,25 +605,90 @@ function addColumn(
267
605
  position: "before" | "after",
268
606
  fallbackSelection: SelectionSnapshot,
269
607
  ): StructuralMutationResult {
270
- if (!isSimpleTable(table)) {
608
+ const grid = buildLogicalGrid(table);
609
+ const columnCount = grid.columnCount;
610
+ if (columnCount === 0) {
271
611
  return createNoopStructuralMutation(document, fallbackSelection);
272
612
  }
273
613
 
274
- const insertIndex =
614
+ const insertColumn =
275
615
  position === "before"
276
616
  ? selection.anchorCell.columnIndex
277
617
  : selection.anchorCell.columnIndex + 1;
278
- const nextTable: TableNode = {
279
- ...table,
280
- gridColumns: insertGridColumn(table.gridColumns, insertIndex),
281
- rows: table.rows.map((row) => ({
618
+
619
+ // Rewrite each row. For rows where the insert column falls strictly
620
+ // inside an existing horizontal span (origin.columnIndex < insertColumn
621
+ // < origin.columnIndex + origin.columnSpan), extend that span by 1.
622
+ // Otherwise insert a plain empty cell at the appropriate cellIndex.
623
+ const insertedOriginsExtended = new Set<GridOriginSlot>();
624
+ const nextRows = table.rows.map((row, rowIndex) => {
625
+ // Find the origin that covers the insertion column in this row, if any.
626
+ const leftOrigin =
627
+ insertColumn > 0 ? originAt(grid, rowIndex, insertColumn - 1) : null;
628
+ const rightSlot = slotAt(grid, rowIndex, insertColumn);
629
+ const rightOrigin = rightSlot
630
+ ? rightSlot.kind === "origin"
631
+ ? rightSlot
632
+ : rightSlot.origin
633
+ : null;
634
+
635
+ const insideHSpan =
636
+ leftOrigin &&
637
+ rightOrigin &&
638
+ leftOrigin === rightOrigin &&
639
+ leftOrigin.columnSpan > 1;
640
+
641
+ if (insideHSpan && leftOrigin && !insertedOriginsExtended.has(leftOrigin)) {
642
+ // Extend the owner cell's gridSpan by 1 (only once per origin across
643
+ // rows — each origin lives in exactly one row).
644
+ const ownerCellIndex = leftOrigin.cellIndex;
645
+ insertedOriginsExtended.add(leftOrigin);
646
+ return {
647
+ ...row,
648
+ cells: row.cells.map((cell, cellIndex) =>
649
+ cellIndex === ownerCellIndex
650
+ ? { ...cell, gridSpan: (cell.gridSpan ?? 1) + 1 }
651
+ : cell,
652
+ ),
653
+ };
654
+ }
655
+
656
+ if (insideHSpan) {
657
+ // The owner has already been extended in its origin row; this row
658
+ // is a vMerge continuation covered by the same column range. Extend
659
+ // the continue cell's gridSpan so the logical grid stays consistent.
660
+ const continueRef = findCellIndexAtColumn(row, leftOrigin!.columnIndex);
661
+ if (continueRef) {
662
+ return {
663
+ ...row,
664
+ cells: row.cells.map((cell, cellIndex) =>
665
+ cellIndex === continueRef.cellIndex
666
+ ? { ...cell, gridSpan: (cell.gridSpan ?? 1) + 1 }
667
+ : cell,
668
+ ),
669
+ };
670
+ }
671
+ return row;
672
+ }
673
+
674
+ // Not inside an hspan — insert a plain empty cell at the cellIndex
675
+ // corresponding to the insertion column. Account for gridBefore
676
+ // padding and any earlier cell spans.
677
+ const insertCellIndex = resolveInsertCellIndex(row, insertColumn);
678
+ return {
282
679
  ...row,
283
680
  cells: [
284
- ...row.cells.slice(0, insertIndex),
681
+ ...row.cells.slice(0, insertCellIndex),
285
682
  createEmptyCell(),
286
- ...row.cells.slice(insertIndex),
683
+ ...row.cells.slice(insertCellIndex),
287
684
  ],
288
- })),
685
+ };
686
+ });
687
+
688
+ const nextTable: TableNode = {
689
+ ...table,
690
+ gridColumns: insertGridColumn(table.gridColumns, insertColumn),
691
+ rows: nextRows,
289
692
  };
290
693
 
291
694
  return commitTableChange(
@@ -295,10 +698,24 @@ function addColumn(
295
698
  nextTable,
296
699
  fallbackSelection,
297
700
  selection.anchorCell.rowIndex,
298
- insertIndex,
701
+ insertColumn,
299
702
  );
300
703
  }
301
704
 
705
+ /**
706
+ * Find the index in row.cells where a new cell should be inserted so it
707
+ * lands at logical column `logicalColumn`. Accounts for gridBefore padding
708
+ * and any earlier cell spans.
709
+ */
710
+ function resolveInsertCellIndex(row: TableRowNode, logicalColumn: number): number {
711
+ let cursor = row.gridBefore ?? 0;
712
+ for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex += 1) {
713
+ if (logicalColumn <= cursor) return cellIndex;
714
+ cursor += Math.max(1, row.cells[cellIndex]!.gridSpan ?? 1);
715
+ }
716
+ return row.cells.length;
717
+ }
718
+
302
719
  function deleteColumn(
303
720
  document: CanonicalDocumentEnvelope,
304
721
  root: DocumentRootNode,
@@ -306,20 +723,67 @@ function deleteColumn(
306
723
  selection: TableSelectionDescriptor,
307
724
  fallbackSelection: SelectionSnapshot,
308
725
  ): StructuralMutationResult {
309
- if (!isSimpleTable(table) || getLogicalColumnCount(table) <= 1) {
726
+ if (getLogicalColumnCount(table) <= 1) {
310
727
  return createNoopStructuralMutation(document, fallbackSelection);
311
728
  }
312
729
 
313
- const deleteIndex = selection.anchorCell.columnIndex;
730
+ const deleteColumn = selection.anchorCell.columnIndex;
731
+ const grid = buildLogicalGrid(table);
732
+
733
+ // For each row, determine whether the deleted column hits a cell that
734
+ // is a pure single-column cell (drop it) or is inside a gridSpan > 1
735
+ // cell (decrement the span). Rows where the column lies outside the
736
+ // cell range (gridBefore/gridAfter padding) simply pass through.
737
+ const nextRows = table.rows.map((row, rowIndex) => {
738
+ const origin = originAt(grid, rowIndex, deleteColumn);
739
+ if (!origin) return row;
740
+
741
+ const cellRef = findCellIndexAtColumn(row, origin.columnIndex);
742
+ if (!cellRef) return row;
743
+
744
+ if (origin.columnSpan <= 1) {
745
+ // Pure single-column cell — drop it from the row.
746
+ return {
747
+ ...row,
748
+ cells: row.cells.filter((_, cellIndex) => cellIndex !== cellRef.cellIndex),
749
+ };
750
+ }
751
+
752
+ // Inside an hspan — decrement gridSpan. If the new span would be 1,
753
+ // drop the gridSpan attribute entirely so the canonical model stays
754
+ // tidy.
755
+ const nextSpan = origin.columnSpan - 1;
756
+ const nextCell: TableCellNode = {
757
+ ...cellRef.cell,
758
+ ...(nextSpan > 1 ? { gridSpan: nextSpan } : { gridSpan: undefined }),
759
+ };
760
+ // Remove the explicit gridSpan field when nextSpan === 1.
761
+ if (nextSpan === 1) {
762
+ const { gridSpan: _drop, ...rest } = nextCell;
763
+ return {
764
+ ...row,
765
+ cells: row.cells.map((cell, cellIndex) =>
766
+ cellIndex === cellRef.cellIndex ? (rest as TableCellNode) : cell,
767
+ ),
768
+ };
769
+ }
770
+ return {
771
+ ...row,
772
+ cells: row.cells.map((cell, cellIndex) =>
773
+ cellIndex === cellRef.cellIndex ? nextCell : cell,
774
+ ),
775
+ };
776
+ });
777
+
314
778
  const nextTable: TableNode = {
315
779
  ...table,
316
- gridColumns: deleteGridColumn(table.gridColumns, deleteIndex),
317
- rows: table.rows.map((row) => ({
318
- ...row,
319
- cells: row.cells.filter((_, cellIndex) => cellIndex !== deleteIndex),
320
- })),
780
+ gridColumns: deleteGridColumn(table.gridColumns, deleteColumn),
781
+ rows: nextRows,
321
782
  };
322
- const focusColumnIndex = Math.max(0, Math.min(deleteIndex, getLogicalColumnCount(nextTable) - 1));
783
+ const focusColumnIndex = Math.max(
784
+ 0,
785
+ Math.min(deleteColumn, getLogicalColumnCount(nextTable) - 1),
786
+ );
323
787
 
324
788
  return commitTableChange(
325
789
  document,
@@ -852,3 +1316,299 @@ function normalizeFillColor(color: string): string | null {
852
1316
  const normalized = color.trim().replace(/^#/, "");
853
1317
  return /^[0-9A-Fa-f]{3,8}$/.test(normalized) ? normalized.toUpperCase() : null;
854
1318
  }
1319
+
1320
+ // ─── P2g helpers: table-level patches ────────────────────────────────────────
1321
+
1322
+ function patchTable(
1323
+ document: CanonicalDocumentEnvelope,
1324
+ root: DocumentRootNode,
1325
+ table: TableNode,
1326
+ selection: TableSelectionDescriptor,
1327
+ patch: Partial<TableNode>,
1328
+ fallbackSelection: SelectionSnapshot,
1329
+ ): StructuralMutationResult {
1330
+ const nextTable: TableNode = { ...table, ...patch };
1331
+ return commitTableChange(
1332
+ document,
1333
+ root,
1334
+ selection.tableBlockIndex,
1335
+ nextTable,
1336
+ fallbackSelection,
1337
+ selection.anchorCell.rowIndex,
1338
+ selection.anchorCell.columnIndex,
1339
+ );
1340
+ }
1341
+
1342
+ function mergeTableBorders(
1343
+ base: TableBorders | undefined,
1344
+ override: Partial<TableBorders>,
1345
+ ): TableBorders {
1346
+ return { ...(base ?? {}), ...override };
1347
+ }
1348
+
1349
+ function mergeCellBorders(
1350
+ base: TableCellBorders | undefined,
1351
+ override: Partial<TableCellBorders>,
1352
+ ): TableCellBorders {
1353
+ return { ...(base ?? {}), ...override };
1354
+ }
1355
+
1356
+ function mergeTableCellMargins(
1357
+ base: TableCellMargins | undefined,
1358
+ override: Partial<TableCellMargins>,
1359
+ ): TableCellMargins {
1360
+ return { ...(base ?? {}), ...override };
1361
+ }
1362
+
1363
+ // ─── P2h helpers: column/row sizing + row-level patches ──────────────────────
1364
+
1365
+ function setColumnWidth(
1366
+ document: CanonicalDocumentEnvelope,
1367
+ root: DocumentRootNode,
1368
+ table: TableNode,
1369
+ selection: TableSelectionDescriptor,
1370
+ columnIndex: number,
1371
+ twips: number,
1372
+ fallbackSelection: SelectionSnapshot,
1373
+ ): StructuralMutationResult {
1374
+ if (columnIndex < 0 || columnIndex >= table.gridColumns.length) {
1375
+ return createNoopStructuralMutation(document, fallbackSelection);
1376
+ }
1377
+ const clamped = Math.max(0, Math.round(twips));
1378
+ const nextGridColumns = table.gridColumns.map((w, i) => (i === columnIndex ? clamped : w));
1379
+ return patchTable(document, root, table, selection, { gridColumns: nextGridColumns }, fallbackSelection);
1380
+ }
1381
+
1382
+ function distributeColumnsEvenly(
1383
+ document: CanonicalDocumentEnvelope,
1384
+ root: DocumentRootNode,
1385
+ table: TableNode,
1386
+ selection: TableSelectionDescriptor,
1387
+ columnRange: { from: number; to: number } | undefined,
1388
+ fallbackSelection: SelectionSnapshot,
1389
+ ): StructuralMutationResult {
1390
+ const gridColumns = table.gridColumns;
1391
+ if (gridColumns.length === 0) {
1392
+ return createNoopStructuralMutation(document, fallbackSelection);
1393
+ }
1394
+ const from = columnRange ? Math.max(0, columnRange.from) : 0;
1395
+ const to = columnRange ? Math.min(gridColumns.length, columnRange.to) : gridColumns.length;
1396
+ if (to - from <= 1) {
1397
+ return createNoopStructuralMutation(document, fallbackSelection);
1398
+ }
1399
+ const total = gridColumns.slice(from, to).reduce((sum, w) => sum + w, 0);
1400
+ const even = Math.floor(total / (to - from));
1401
+ const remainder = total - even * (to - from);
1402
+ const nextGridColumns = gridColumns.map((w, i) => {
1403
+ if (i < from || i >= to) return w;
1404
+ const offset = i - from;
1405
+ return offset === (to - from - 1) ? even + remainder : even;
1406
+ });
1407
+ return patchTable(document, root, table, selection, { gridColumns: nextGridColumns }, fallbackSelection);
1408
+ }
1409
+
1410
+ function patchRow(
1411
+ document: CanonicalDocumentEnvelope,
1412
+ root: DocumentRootNode,
1413
+ table: TableNode,
1414
+ selection: TableSelectionDescriptor,
1415
+ rowIndex: number,
1416
+ patch: Partial<TableRowNode>,
1417
+ fallbackSelection: SelectionSnapshot,
1418
+ ): StructuralMutationResult {
1419
+ if (rowIndex < 0 || rowIndex >= table.rows.length) {
1420
+ return createNoopStructuralMutation(document, fallbackSelection);
1421
+ }
1422
+ const nextRows = table.rows.map((row, i) => (i === rowIndex ? { ...row, ...patch } : row));
1423
+ return patchTable(document, root, table, selection, { rows: nextRows }, fallbackSelection);
1424
+ }
1425
+
1426
+ function insertRows(
1427
+ document: CanonicalDocumentEnvelope,
1428
+ root: DocumentRootNode,
1429
+ table: TableNode,
1430
+ selection: TableSelectionDescriptor,
1431
+ rowIndex: number,
1432
+ at: "before" | "after",
1433
+ count: number,
1434
+ fallbackSelection: SelectionSnapshot,
1435
+ ): StructuralMutationResult {
1436
+ if (count <= 0) {
1437
+ return createNoopStructuralMutation(document, fallbackSelection);
1438
+ }
1439
+ let current = table;
1440
+ const insertAt = at === "before" ? rowIndex : rowIndex + 1;
1441
+ for (let i = 0; i < count; i += 1) {
1442
+ const grid = buildLogicalGrid(current);
1443
+ const inserted = buildInsertedRow(grid, insertAt + i);
1444
+ current = {
1445
+ ...current,
1446
+ rows: [
1447
+ ...current.rows.slice(0, insertAt + i),
1448
+ inserted,
1449
+ ...current.rows.slice(insertAt + i),
1450
+ ],
1451
+ };
1452
+ }
1453
+ return patchTable(document, root, current, selection, {}, fallbackSelection);
1454
+ }
1455
+
1456
+ function insertColumns(
1457
+ document: CanonicalDocumentEnvelope,
1458
+ root: DocumentRootNode,
1459
+ table: TableNode,
1460
+ selection: TableSelectionDescriptor,
1461
+ columnIndex: number,
1462
+ at: "before" | "after",
1463
+ count: number,
1464
+ widths: readonly number[] | undefined,
1465
+ fallbackSelection: SelectionSnapshot,
1466
+ ): StructuralMutationResult {
1467
+ if (count <= 0) {
1468
+ return createNoopStructuralMutation(document, fallbackSelection);
1469
+ }
1470
+ let current = table;
1471
+ const baseInsert = at === "before" ? columnIndex : columnIndex + 1;
1472
+ for (let i = 0; i < count; i += 1) {
1473
+ const insertColumnIndex = baseInsert + i;
1474
+ const result = addColumn(
1475
+ { ...document, content: { ...root, children: [...root.children] } } as CanonicalDocumentEnvelope,
1476
+ root,
1477
+ current,
1478
+ {
1479
+ ...selection,
1480
+ anchorCell: {
1481
+ rowIndex: selection.anchorCell.rowIndex,
1482
+ columnIndex: at === "before" ? insertColumnIndex : insertColumnIndex - 1,
1483
+ },
1484
+ } as TableSelectionDescriptor,
1485
+ at,
1486
+ fallbackSelection,
1487
+ );
1488
+ if (!result.changed) break;
1489
+ current = result.document.content.children[selection.tableBlockIndex] as TableNode;
1490
+ }
1491
+ // Apply explicit widths if provided (overwrites widths at the inserted positions).
1492
+ if (widths && widths.length > 0) {
1493
+ const nextGridColumns = [...current.gridColumns];
1494
+ for (let i = 0; i < count && i < widths.length; i += 1) {
1495
+ const idx = baseInsert + i;
1496
+ if (idx >= 0 && idx < nextGridColumns.length) {
1497
+ nextGridColumns[idx] = Math.max(0, Math.round(widths[i] ?? 0));
1498
+ }
1499
+ }
1500
+ current = { ...current, gridColumns: nextGridColumns };
1501
+ }
1502
+ return patchTable(document, root, current, selection, {}, fallbackSelection);
1503
+ }
1504
+
1505
+ // ─── P2i helpers: cell-level patches over a locator ──────────────────────────
1506
+
1507
+ function patchCells(
1508
+ document: CanonicalDocumentEnvelope,
1509
+ root: DocumentRootNode,
1510
+ table: TableNode,
1511
+ selection: TableSelectionDescriptor,
1512
+ locator: CellLocator,
1513
+ transform: (cell: TableCellNode) => TableCellNode,
1514
+ fallbackSelection: SelectionSnapshot,
1515
+ ): StructuralMutationResult {
1516
+ const grid = buildLogicalGrid(table);
1517
+ const targets = resolveLocatorTargets(grid, selection, locator);
1518
+ if (targets.size === 0) {
1519
+ return createNoopStructuralMutation(document, fallbackSelection);
1520
+ }
1521
+
1522
+ // Group by row so we can do one map per row.
1523
+ const byRow = new Map<number, Set<number>>();
1524
+ for (const { rowIndex, cellIndex } of targets) {
1525
+ let set = byRow.get(rowIndex);
1526
+ if (!set) {
1527
+ set = new Set();
1528
+ byRow.set(rowIndex, set);
1529
+ }
1530
+ set.add(cellIndex);
1531
+ }
1532
+
1533
+ const nextRows = table.rows.map((row, rowIndex) => {
1534
+ const indices = byRow.get(rowIndex);
1535
+ if (!indices) return row;
1536
+ return {
1537
+ ...row,
1538
+ cells: row.cells.map((cell, cellIndex) =>
1539
+ indices.has(cellIndex) ? transform(cell) : cell,
1540
+ ),
1541
+ };
1542
+ });
1543
+
1544
+ return patchTable(document, root, table, selection, { rows: nextRows }, fallbackSelection);
1545
+ }
1546
+
1547
+ function resolveLocatorTargets(
1548
+ grid: LogicalGrid,
1549
+ selection: TableSelectionDescriptor,
1550
+ locator: CellLocator,
1551
+ ): Set<{ rowIndex: number; cellIndex: number }> {
1552
+ const targets = new Set<{ rowIndex: number; cellIndex: number }>();
1553
+ const seenOrigins = new Set<GridOriginSlot>();
1554
+
1555
+ const addOrigin = (origin: GridOriginSlot): void => {
1556
+ if (seenOrigins.has(origin)) return;
1557
+ seenOrigins.add(origin);
1558
+ targets.add({ rowIndex: origin.rowIndex, cellIndex: origin.cellIndex });
1559
+ };
1560
+
1561
+ if (locator.kind === "anchor") {
1562
+ const origin = originAt(grid, selection.anchorCell.rowIndex, selection.anchorCell.columnIndex);
1563
+ if (origin) addOrigin(origin);
1564
+ return targets;
1565
+ }
1566
+
1567
+ if (locator.kind === "index") {
1568
+ const origin = originAt(grid, locator.rowIndex, locator.columnIndex);
1569
+ if (origin) addOrigin(origin);
1570
+ return targets;
1571
+ }
1572
+
1573
+ if (locator.kind === "row") {
1574
+ const rowIndex = locator.rowIndex;
1575
+ for (let c = 0; c < grid.columnCount; c += 1) {
1576
+ const origin = originAt(grid, rowIndex, c);
1577
+ if (origin && origin.rowIndex === rowIndex) addOrigin(origin);
1578
+ }
1579
+ return targets;
1580
+ }
1581
+
1582
+ if (locator.kind === "column") {
1583
+ const columnIndex = locator.columnIndex;
1584
+ for (let r = 0; r < grid.rowCount; r += 1) {
1585
+ const origin = originAt(grid, r, columnIndex);
1586
+ if (origin && origin.columnIndex === columnIndex) addOrigin(origin);
1587
+ }
1588
+ return targets;
1589
+ }
1590
+
1591
+ if (locator.kind === "rect") {
1592
+ for (let r = locator.rect.top; r < locator.rect.bottom; r += 1) {
1593
+ for (let c = locator.rect.left; c < locator.rect.right; c += 1) {
1594
+ const origin = originAt(grid, r, c);
1595
+ if (origin) addOrigin(origin);
1596
+ }
1597
+ }
1598
+ return targets;
1599
+ }
1600
+
1601
+ // "selection" — use the descriptor's rect or the anchor cell.
1602
+ if (selection.selectionKind === "cell") {
1603
+ for (let r = selection.rect.top; r < selection.rect.bottom; r += 1) {
1604
+ for (let c = selection.rect.left; c < selection.rect.right; c += 1) {
1605
+ const origin = originAt(grid, r, c);
1606
+ if (origin) addOrigin(origin);
1607
+ }
1608
+ }
1609
+ } else {
1610
+ const origin = originAt(grid, selection.anchorCell.rowIndex, selection.anchorCell.columnIndex);
1611
+ if (origin) addOrigin(origin);
1612
+ }
1613
+ return targets;
1614
+ }