@beyondwork/docx-react-component 1.0.37 → 1.0.39

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 (116) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +496 -1
  3. package/src/core/commands/section-layout-commands.ts +58 -0
  4. package/src/core/commands/table-grid.ts +431 -0
  5. package/src/core/commands/table-structure-commands.ts +845 -56
  6. package/src/core/commands/text-commands.ts +122 -2
  7. package/src/io/docx-session.ts +1 -0
  8. package/src/io/export/serialize-main-document.ts +2 -11
  9. package/src/io/export/serialize-numbering.ts +43 -10
  10. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  11. package/src/io/export/serialize-run-formatting.ts +90 -0
  12. package/src/io/export/serialize-styles.ts +212 -0
  13. package/src/io/export/serialize-tables.ts +74 -0
  14. package/src/io/export/table-properties-xml.ts +139 -4
  15. package/src/io/normalize/normalize-text.ts +15 -0
  16. package/src/io/ooxml/parse-fields.ts +10 -3
  17. package/src/io/ooxml/parse-footnotes.ts +60 -0
  18. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  19. package/src/io/ooxml/parse-main-document.ts +137 -0
  20. package/src/io/ooxml/parse-numbering.ts +41 -1
  21. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  22. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  23. package/src/io/ooxml/parse-styles.ts +31 -0
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  26. package/src/io/ooxml/xml-element.ts +19 -0
  27. package/src/model/canonical-document.ts +117 -3
  28. package/src/runtime/collab/event-types.ts +165 -0
  29. package/src/runtime/collab/index.ts +22 -0
  30. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  31. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  32. package/src/runtime/document-layout.ts +4 -2
  33. package/src/runtime/document-navigation.ts +1 -1
  34. package/src/runtime/document-runtime.ts +248 -18
  35. package/src/runtime/layout/default-page-format.ts +96 -0
  36. package/src/runtime/layout/index.ts +47 -0
  37. package/src/runtime/layout/inert-layout-facet.ts +16 -0
  38. package/src/runtime/layout/layout-engine-instance.ts +100 -23
  39. package/src/runtime/layout/layout-invalidation.ts +14 -5
  40. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-graph.ts +55 -0
  43. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  44. package/src/runtime/layout/paginated-layout-engine.ts +484 -37
  45. package/src/runtime/layout/project-block-fragments.ts +225 -0
  46. package/src/runtime/layout/public-facet.ts +748 -16
  47. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  48. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  49. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  50. package/src/runtime/layout/table-render-plan.ts +249 -0
  51. package/src/runtime/numbering-prefix.ts +5 -0
  52. package/src/runtime/paragraph-style-resolver.ts +194 -0
  53. package/src/runtime/render/block-fragment-projection.ts +35 -0
  54. package/src/runtime/render/decoration-resolver.ts +189 -0
  55. package/src/runtime/render/index.ts +57 -0
  56. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  57. package/src/runtime/render/render-frame-types.ts +317 -0
  58. package/src/runtime/render/render-kernel.ts +759 -0
  59. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  60. package/src/runtime/surface-projection.ts +129 -9
  61. package/src/runtime/table-schema.ts +11 -0
  62. package/src/runtime/view-state.ts +67 -0
  63. package/src/runtime/workflow-markup.ts +1 -5
  64. package/src/runtime/workflow-rail-segments.ts +280 -0
  65. package/src/ui/WordReviewEditor.tsx +368 -19
  66. package/src/ui/editor-command-bag.ts +4 -0
  67. package/src/ui/editor-runtime-boundary.ts +16 -0
  68. package/src/ui/editor-shell-view.tsx +10 -0
  69. package/src/ui/editor-surface-controller.tsx +9 -1
  70. package/src/ui/headless/chrome-registry.ts +310 -15
  71. package/src/ui/headless/scoped-chrome-policy.ts +49 -1
  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/review-queue-bar.tsx +2 -14
  75. package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
  76. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  77. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
  78. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
  79. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  80. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  81. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  82. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  83. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
  84. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  85. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
  86. package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
  88. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  89. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  90. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  91. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  92. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  93. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
  94. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  95. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  96. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  97. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  98. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  99. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  100. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
  101. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  102. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  103. package/src/ui-tailwind/index.ts +29 -0
  104. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  105. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  106. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  107. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  108. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  109. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  110. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
  111. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  112. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  113. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
  114. package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
  115. package/src/runtime/collab-review-sync.ts +0 -254
  116. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -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,73 @@ 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
+ : mergeRectCoverage &&
362
+ mergeRectCoverage.fullyCoveredOrigins.some(
363
+ (origin) => origin.columnSpan > 1 || origin.rowSpan > 1,
364
+ )
365
+ ? disabledCapability(
366
+ "Selection encloses an already-merged cell. Split it first, then merge.",
367
+ )
368
+ : enabledCapability(),
184
369
  splitCell:
185
370
  splitWidth === 1 && splitHeight === 1
186
371
  ? disabledCapability("Select a merged or spanning cell to split it.")
187
372
  : enabledCapability(),
188
373
  deleteTable: enabledCapability(),
374
+ // P2g — table-level
375
+ setTableWidth: enabledCapability(),
376
+ setTableAlignment: enabledCapability(),
377
+ setTableIndent: enabledCapability(),
378
+ setTableLayoutMode: enabledCapability(),
379
+ setTableCellMargins: enabledCapability(),
380
+ setTableBorders: enabledCapability(),
381
+ setTableCaption: enabledCapability(),
382
+ setTableDescription: enabledCapability(),
383
+ // P2h — column/row sizing + row props
384
+ setColumnWidth:
385
+ target.gridColumns.length > 0
386
+ ? enabledCapability()
387
+ : disabledCapability("Table has no grid columns to size."),
388
+ distributeColumnsEvenly:
389
+ target.gridColumns.length > 1
390
+ ? enabledCapability()
391
+ : disabledCapability("At least two columns are required to distribute width."),
392
+ setRowHeight: enabledCapability(),
393
+ setRowCantSplit: enabledCapability(),
394
+ setRowIsHeader: enabledCapability(),
395
+ setRowAlignment: enabledCapability(),
396
+ insertRows: enabledCapability(),
397
+ insertColumns: enabledCapability(),
398
+ // P2i — cell-level
399
+ setCellBorders: enabledCapability(),
400
+ setCellShading: enabledCapability(),
401
+ clearCellShading: enabledCapability(),
402
+ setCellMargins: enabledCapability(),
403
+ setCellVerticalAlign: enabledCapability(),
404
+ setCellTextDirection: enabledCapability(),
405
+ setCellNoWrap: enabledCapability(),
406
+ setCellFitText: enabledCapability(),
189
407
  },
190
408
  };
191
409
  }
@@ -198,17 +416,15 @@ function addRow(
198
416
  position: "before" | "after",
199
417
  fallbackSelection: SelectionSnapshot,
200
418
  ): StructuralMutationResult {
201
- if (!isSimpleTable(table)) {
419
+ const grid = buildLogicalGrid(table);
420
+ const columnCount = grid.columnCount;
421
+ if (columnCount === 0) {
202
422
  return createNoopStructuralMutation(document, fallbackSelection);
203
423
  }
204
424
 
205
- const columnCount = getLogicalColumnCount(table);
206
425
  const insertIndex =
207
426
  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
- };
427
+ const nextRow = buildInsertedRow(grid, insertIndex);
212
428
  const nextTable: TableNode = {
213
429
  ...table,
214
430
  rows: [
@@ -229,6 +445,58 @@ function addRow(
229
445
  );
230
446
  }
231
447
 
448
+ /**
449
+ * Build the cells for a new row inserted at `insertIndex` (index in the
450
+ * original table, before the insertion is applied). For each logical column,
451
+ * if both the row above and the row below belong to the same vMerge chain,
452
+ * the new row extends that chain with a `verticalMerge: "continue"` cell.
453
+ * Otherwise an empty cell is emitted with gridSpan=1.
454
+ */
455
+ function buildInsertedRow(grid: LogicalGrid, insertIndex: number): TableRowNode {
456
+ const cells: TableCellNode[] = [];
457
+ const columnCount = grid.columnCount;
458
+ let column = 0;
459
+ while (column < columnCount) {
460
+ const above = insertIndex > 0 ? originAt(grid, insertIndex - 1, column) : null;
461
+ const below =
462
+ insertIndex < grid.rowCount ? originAt(grid, insertIndex, column) : null;
463
+
464
+ // A vMerge chain continues across the insertion point when:
465
+ // - both above and below resolve to the same origin, and
466
+ // - that origin lives above the insertion point (otherwise the chain
467
+ // starts at `below` and is fully below the insertion point).
468
+ if (
469
+ above &&
470
+ below &&
471
+ above === below &&
472
+ above.rowIndex < insertIndex
473
+ ) {
474
+ const span = above.columnSpan;
475
+ // Align to the origin's leftmost column: if we started iteration
476
+ // mid-span, still treat the full span as the unit.
477
+ if (column !== above.columnIndex) {
478
+ // We're scanning forward, so this shouldn't happen — if it does,
479
+ // fall through to an empty cell for this column only.
480
+ cells.push(createEmptyCell());
481
+ column += 1;
482
+ continue;
483
+ }
484
+ cells.push({
485
+ type: "table_cell",
486
+ children: [createEmptyParagraph()],
487
+ verticalMerge: "continue",
488
+ ...(span > 1 ? { gridSpan: span } : {}),
489
+ });
490
+ column += span;
491
+ continue;
492
+ }
493
+
494
+ cells.push(createEmptyCell());
495
+ column += 1;
496
+ }
497
+ return { type: "table_row", cells };
498
+ }
499
+
232
500
  function deleteRow(
233
501
  document: CanonicalDocumentEnvelope,
234
502
  root: DocumentRootNode,
@@ -236,12 +504,52 @@ function deleteRow(
236
504
  selection: TableSelectionDescriptor,
237
505
  fallbackSelection: SelectionSnapshot,
238
506
  ): StructuralMutationResult {
239
- if (!isSimpleTable(table) || table.rows.length <= 1) {
507
+ if (table.rows.length <= 1) {
240
508
  return createNoopStructuralMutation(document, fallbackSelection);
241
509
  }
242
510
 
243
511
  const deleteIndex = selection.anchorCell.rowIndex;
244
- const nextRows = table.rows.filter((_, rowIndex) => rowIndex !== deleteIndex);
512
+ const grid = buildLogicalGrid(table);
513
+
514
+ // For every logical column, check if this row is the origin of a vMerge
515
+ // chain that extends beyond this row. If so, we must promote the first
516
+ // following continue cell to "restart" and copy over the origin's
517
+ // content/properties so the chain survives the deletion.
518
+ //
519
+ // For columns where this row is a mid-chain continue, the chain's owner
520
+ // origin stays in place — we just need to drop 1 from its effective
521
+ // rowSpan (which happens automatically when the row is removed from
522
+ // the canonical tree).
523
+ const promotions: Array<{
524
+ originColumnIndex: number;
525
+ originColumnSpan: number;
526
+ promotedRowIndex: number;
527
+ originCell: TableCellNode;
528
+ }> = [];
529
+
530
+ for (let column = 0; column < grid.columnCount; ) {
531
+ const origin = originAt(grid, deleteIndex, column);
532
+ if (!origin) {
533
+ column += 1;
534
+ continue;
535
+ }
536
+ if (origin.rowIndex === deleteIndex && origin.rowSpan > 1) {
537
+ promotions.push({
538
+ originColumnIndex: origin.columnIndex,
539
+ originColumnSpan: origin.columnSpan,
540
+ promotedRowIndex: deleteIndex + 1,
541
+ originCell: origin.cell,
542
+ });
543
+ }
544
+ column = origin.columnIndex + origin.columnSpan;
545
+ }
546
+
547
+ // Apply promotions to the row immediately after the deleted one.
548
+ const nextRows = table.rows.map((row, rowIndex) => {
549
+ if (rowIndex !== deleteIndex + 1 || promotions.length === 0) return row;
550
+ return applyVMergePromotions(row, promotions);
551
+ }).filter((_, rowIndex) => rowIndex !== deleteIndex);
552
+
245
553
  const focusRowIndex = Math.max(0, Math.min(deleteIndex, nextRows.length - 1));
246
554
  const nextTable: TableNode = {
247
555
  ...table,
@@ -259,6 +567,43 @@ function deleteRow(
259
567
  );
260
568
  }
261
569
 
570
+ function applyVMergePromotions(
571
+ row: TableRowNode,
572
+ promotions: Array<{
573
+ originColumnIndex: number;
574
+ originColumnSpan: number;
575
+ promotedRowIndex: number;
576
+ originCell: TableCellNode;
577
+ }>,
578
+ ): TableRowNode {
579
+ const nextCells = row.cells.map((cell, cellIndex) => {
580
+ let cursor = row.gridBefore ?? 0;
581
+ // Recompute the cell's logical column position.
582
+ for (let i = 0; i < cellIndex; i += 1) {
583
+ cursor += Math.max(1, row.cells[i]!.gridSpan ?? 1);
584
+ }
585
+ const logicalColumn = cursor;
586
+
587
+ const promotion = promotions.find(
588
+ (p) =>
589
+ p.originColumnIndex === logicalColumn &&
590
+ (cell.gridSpan ?? 1) === p.originColumnSpan &&
591
+ cell.verticalMerge === "continue",
592
+ );
593
+ if (!promotion) return cell;
594
+
595
+ // Promote: copy origin's cell content + properties, mark as restart.
596
+ const { children, ...properties } = promotion.originCell;
597
+ return {
598
+ ...properties,
599
+ children: structuredClone(children),
600
+ verticalMerge: "restart" as const,
601
+ ...(promotion.originColumnSpan > 1 ? { gridSpan: promotion.originColumnSpan } : {}),
602
+ };
603
+ });
604
+ return { ...row, cells: nextCells };
605
+ }
606
+
262
607
  function addColumn(
263
608
  document: CanonicalDocumentEnvelope,
264
609
  root: DocumentRootNode,
@@ -267,25 +612,90 @@ function addColumn(
267
612
  position: "before" | "after",
268
613
  fallbackSelection: SelectionSnapshot,
269
614
  ): StructuralMutationResult {
270
- if (!isSimpleTable(table)) {
615
+ const grid = buildLogicalGrid(table);
616
+ const columnCount = grid.columnCount;
617
+ if (columnCount === 0) {
271
618
  return createNoopStructuralMutation(document, fallbackSelection);
272
619
  }
273
620
 
274
- const insertIndex =
621
+ const insertColumn =
275
622
  position === "before"
276
623
  ? selection.anchorCell.columnIndex
277
624
  : selection.anchorCell.columnIndex + 1;
278
- const nextTable: TableNode = {
279
- ...table,
280
- gridColumns: insertGridColumn(table.gridColumns, insertIndex),
281
- rows: table.rows.map((row) => ({
625
+
626
+ // Rewrite each row. For rows where the insert column falls strictly
627
+ // inside an existing horizontal span (origin.columnIndex < insertColumn
628
+ // < origin.columnIndex + origin.columnSpan), extend that span by 1.
629
+ // Otherwise insert a plain empty cell at the appropriate cellIndex.
630
+ const insertedOriginsExtended = new Set<GridOriginSlot>();
631
+ const nextRows = table.rows.map((row, rowIndex) => {
632
+ // Find the origin that covers the insertion column in this row, if any.
633
+ const leftOrigin =
634
+ insertColumn > 0 ? originAt(grid, rowIndex, insertColumn - 1) : null;
635
+ const rightSlot = slotAt(grid, rowIndex, insertColumn);
636
+ const rightOrigin = rightSlot
637
+ ? rightSlot.kind === "origin"
638
+ ? rightSlot
639
+ : rightSlot.origin
640
+ : null;
641
+
642
+ const insideHSpan =
643
+ leftOrigin &&
644
+ rightOrigin &&
645
+ leftOrigin === rightOrigin &&
646
+ leftOrigin.columnSpan > 1;
647
+
648
+ if (insideHSpan && leftOrigin && !insertedOriginsExtended.has(leftOrigin)) {
649
+ // Extend the owner cell's gridSpan by 1 (only once per origin across
650
+ // rows — each origin lives in exactly one row).
651
+ const ownerCellIndex = leftOrigin.cellIndex;
652
+ insertedOriginsExtended.add(leftOrigin);
653
+ return {
654
+ ...row,
655
+ cells: row.cells.map((cell, cellIndex) =>
656
+ cellIndex === ownerCellIndex
657
+ ? { ...cell, gridSpan: (cell.gridSpan ?? 1) + 1 }
658
+ : cell,
659
+ ),
660
+ };
661
+ }
662
+
663
+ if (insideHSpan) {
664
+ // The owner has already been extended in its origin row; this row
665
+ // is a vMerge continuation covered by the same column range. Extend
666
+ // the continue cell's gridSpan so the logical grid stays consistent.
667
+ const continueRef = findCellIndexAtColumn(row, leftOrigin!.columnIndex);
668
+ if (continueRef) {
669
+ return {
670
+ ...row,
671
+ cells: row.cells.map((cell, cellIndex) =>
672
+ cellIndex === continueRef.cellIndex
673
+ ? { ...cell, gridSpan: (cell.gridSpan ?? 1) + 1 }
674
+ : cell,
675
+ ),
676
+ };
677
+ }
678
+ return row;
679
+ }
680
+
681
+ // Not inside an hspan — insert a plain empty cell at the cellIndex
682
+ // corresponding to the insertion column. Account for gridBefore
683
+ // padding and any earlier cell spans.
684
+ const insertCellIndex = resolveInsertCellIndex(row, insertColumn);
685
+ return {
282
686
  ...row,
283
687
  cells: [
284
- ...row.cells.slice(0, insertIndex),
688
+ ...row.cells.slice(0, insertCellIndex),
285
689
  createEmptyCell(),
286
- ...row.cells.slice(insertIndex),
690
+ ...row.cells.slice(insertCellIndex),
287
691
  ],
288
- })),
692
+ };
693
+ });
694
+
695
+ const nextTable: TableNode = {
696
+ ...table,
697
+ gridColumns: insertGridColumn(table.gridColumns, insertColumn),
698
+ rows: nextRows,
289
699
  };
290
700
 
291
701
  return commitTableChange(
@@ -295,10 +705,24 @@ function addColumn(
295
705
  nextTable,
296
706
  fallbackSelection,
297
707
  selection.anchorCell.rowIndex,
298
- insertIndex,
708
+ insertColumn,
299
709
  );
300
710
  }
301
711
 
712
+ /**
713
+ * Find the index in row.cells where a new cell should be inserted so it
714
+ * lands at logical column `logicalColumn`. Accounts for gridBefore padding
715
+ * and any earlier cell spans.
716
+ */
717
+ function resolveInsertCellIndex(row: TableRowNode, logicalColumn: number): number {
718
+ let cursor = row.gridBefore ?? 0;
719
+ for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex += 1) {
720
+ if (logicalColumn <= cursor) return cellIndex;
721
+ cursor += Math.max(1, row.cells[cellIndex]!.gridSpan ?? 1);
722
+ }
723
+ return row.cells.length;
724
+ }
725
+
302
726
  function deleteColumn(
303
727
  document: CanonicalDocumentEnvelope,
304
728
  root: DocumentRootNode,
@@ -306,20 +730,67 @@ function deleteColumn(
306
730
  selection: TableSelectionDescriptor,
307
731
  fallbackSelection: SelectionSnapshot,
308
732
  ): StructuralMutationResult {
309
- if (!isSimpleTable(table) || getLogicalColumnCount(table) <= 1) {
733
+ if (getLogicalColumnCount(table) <= 1) {
310
734
  return createNoopStructuralMutation(document, fallbackSelection);
311
735
  }
312
736
 
313
- const deleteIndex = selection.anchorCell.columnIndex;
737
+ const deleteColumn = selection.anchorCell.columnIndex;
738
+ const grid = buildLogicalGrid(table);
739
+
740
+ // For each row, determine whether the deleted column hits a cell that
741
+ // is a pure single-column cell (drop it) or is inside a gridSpan > 1
742
+ // cell (decrement the span). Rows where the column lies outside the
743
+ // cell range (gridBefore/gridAfter padding) simply pass through.
744
+ const nextRows = table.rows.map((row, rowIndex) => {
745
+ const origin = originAt(grid, rowIndex, deleteColumn);
746
+ if (!origin) return row;
747
+
748
+ const cellRef = findCellIndexAtColumn(row, origin.columnIndex);
749
+ if (!cellRef) return row;
750
+
751
+ if (origin.columnSpan <= 1) {
752
+ // Pure single-column cell — drop it from the row.
753
+ return {
754
+ ...row,
755
+ cells: row.cells.filter((_, cellIndex) => cellIndex !== cellRef.cellIndex),
756
+ };
757
+ }
758
+
759
+ // Inside an hspan — decrement gridSpan. If the new span would be 1,
760
+ // drop the gridSpan attribute entirely so the canonical model stays
761
+ // tidy.
762
+ const nextSpan = origin.columnSpan - 1;
763
+ const nextCell: TableCellNode = {
764
+ ...cellRef.cell,
765
+ ...(nextSpan > 1 ? { gridSpan: nextSpan } : { gridSpan: undefined }),
766
+ };
767
+ // Remove the explicit gridSpan field when nextSpan === 1.
768
+ if (nextSpan === 1) {
769
+ const { gridSpan: _drop, ...rest } = nextCell;
770
+ return {
771
+ ...row,
772
+ cells: row.cells.map((cell, cellIndex) =>
773
+ cellIndex === cellRef.cellIndex ? (rest as TableCellNode) : cell,
774
+ ),
775
+ };
776
+ }
777
+ return {
778
+ ...row,
779
+ cells: row.cells.map((cell, cellIndex) =>
780
+ cellIndex === cellRef.cellIndex ? nextCell : cell,
781
+ ),
782
+ };
783
+ });
784
+
314
785
  const nextTable: TableNode = {
315
786
  ...table,
316
- gridColumns: deleteGridColumn(table.gridColumns, deleteIndex),
317
- rows: table.rows.map((row) => ({
318
- ...row,
319
- cells: row.cells.filter((_, cellIndex) => cellIndex !== deleteIndex),
320
- })),
787
+ gridColumns: deleteGridColumn(table.gridColumns, deleteColumn),
788
+ rows: nextRows,
321
789
  };
322
- const focusColumnIndex = Math.max(0, Math.min(deleteIndex, getLogicalColumnCount(nextTable) - 1));
790
+ const focusColumnIndex = Math.max(
791
+ 0,
792
+ Math.min(deleteColumn, getLogicalColumnCount(nextTable) - 1),
793
+ );
323
794
 
324
795
  return commitTableChange(
325
796
  document,
@@ -339,14 +810,36 @@ function mergeSelectedCells(
339
810
  selection: TableSelectionDescriptor,
340
811
  fallbackSelection: SelectionSnapshot,
341
812
  ): StructuralMutationResult {
813
+ // R1a: the merge command was previously gated on `!isSimpleTable(table)`,
814
+ // which blocked merging on any table that already carried a single merged
815
+ // span anywhere in the table, even when the user's current selection was
816
+ // over a simple region. The correct gate is whether the rect itself is:
817
+ // (a) clean — every origin it touches is fully enclosed (analyzeRect);
818
+ // (b) internally simple — no pre-existing merged origin lives inside
819
+ // the rect. findCellAtColumn in the splice body below assumes cells
820
+ // at logical column C correspond 1:1 with row.cells entries; that
821
+ // assumption holds iff the rect's internal cells are all gridSpan=1
822
+ // and verticalMerge=undefined.
823
+ // A rect that satisfies both is safe to merge even on a table that has
824
+ // merged spans elsewhere (the common imported-agreement case).
342
825
  if (
343
- !isSimpleTable(table) ||
344
826
  selection.selectionKind !== "cell" ||
345
827
  selection.rect.bottom - selection.rect.top < 1 ||
346
828
  selection.rect.right - selection.rect.left < 1
347
829
  ) {
348
830
  return createNoopStructuralMutation(document, fallbackSelection);
349
831
  }
832
+ const grid = buildLogicalGrid(table);
833
+ const coverage = analyzeRect(grid, selection.rect);
834
+ if (!coverage.clean) {
835
+ return createNoopStructuralMutation(document, fallbackSelection);
836
+ }
837
+ const rectInternallySimple = coverage.fullyCoveredOrigins.every(
838
+ (origin) => origin.columnSpan === 1 && origin.rowSpan === 1,
839
+ );
840
+ if (!rectInternallySimple) {
841
+ return createNoopStructuralMutation(document, fallbackSelection);
842
+ }
350
843
 
351
844
  const height = selection.rect.bottom - selection.rect.top;
352
845
  const width = selection.rect.right - selection.rect.left;
@@ -852,3 +1345,299 @@ function normalizeFillColor(color: string): string | null {
852
1345
  const normalized = color.trim().replace(/^#/, "");
853
1346
  return /^[0-9A-Fa-f]{3,8}$/.test(normalized) ? normalized.toUpperCase() : null;
854
1347
  }
1348
+
1349
+ // ─── P2g helpers: table-level patches ────────────────────────────────────────
1350
+
1351
+ function patchTable(
1352
+ document: CanonicalDocumentEnvelope,
1353
+ root: DocumentRootNode,
1354
+ table: TableNode,
1355
+ selection: TableSelectionDescriptor,
1356
+ patch: Partial<TableNode>,
1357
+ fallbackSelection: SelectionSnapshot,
1358
+ ): StructuralMutationResult {
1359
+ const nextTable: TableNode = { ...table, ...patch };
1360
+ return commitTableChange(
1361
+ document,
1362
+ root,
1363
+ selection.tableBlockIndex,
1364
+ nextTable,
1365
+ fallbackSelection,
1366
+ selection.anchorCell.rowIndex,
1367
+ selection.anchorCell.columnIndex,
1368
+ );
1369
+ }
1370
+
1371
+ function mergeTableBorders(
1372
+ base: TableBorders | undefined,
1373
+ override: Partial<TableBorders>,
1374
+ ): TableBorders {
1375
+ return { ...(base ?? {}), ...override };
1376
+ }
1377
+
1378
+ function mergeCellBorders(
1379
+ base: TableCellBorders | undefined,
1380
+ override: Partial<TableCellBorders>,
1381
+ ): TableCellBorders {
1382
+ return { ...(base ?? {}), ...override };
1383
+ }
1384
+
1385
+ function mergeTableCellMargins(
1386
+ base: TableCellMargins | undefined,
1387
+ override: Partial<TableCellMargins>,
1388
+ ): TableCellMargins {
1389
+ return { ...(base ?? {}), ...override };
1390
+ }
1391
+
1392
+ // ─── P2h helpers: column/row sizing + row-level patches ──────────────────────
1393
+
1394
+ function setColumnWidth(
1395
+ document: CanonicalDocumentEnvelope,
1396
+ root: DocumentRootNode,
1397
+ table: TableNode,
1398
+ selection: TableSelectionDescriptor,
1399
+ columnIndex: number,
1400
+ twips: number,
1401
+ fallbackSelection: SelectionSnapshot,
1402
+ ): StructuralMutationResult {
1403
+ if (columnIndex < 0 || columnIndex >= table.gridColumns.length) {
1404
+ return createNoopStructuralMutation(document, fallbackSelection);
1405
+ }
1406
+ const clamped = Math.max(0, Math.round(twips));
1407
+ const nextGridColumns = table.gridColumns.map((w, i) => (i === columnIndex ? clamped : w));
1408
+ return patchTable(document, root, table, selection, { gridColumns: nextGridColumns }, fallbackSelection);
1409
+ }
1410
+
1411
+ function distributeColumnsEvenly(
1412
+ document: CanonicalDocumentEnvelope,
1413
+ root: DocumentRootNode,
1414
+ table: TableNode,
1415
+ selection: TableSelectionDescriptor,
1416
+ columnRange: { from: number; to: number } | undefined,
1417
+ fallbackSelection: SelectionSnapshot,
1418
+ ): StructuralMutationResult {
1419
+ const gridColumns = table.gridColumns;
1420
+ if (gridColumns.length === 0) {
1421
+ return createNoopStructuralMutation(document, fallbackSelection);
1422
+ }
1423
+ const from = columnRange ? Math.max(0, columnRange.from) : 0;
1424
+ const to = columnRange ? Math.min(gridColumns.length, columnRange.to) : gridColumns.length;
1425
+ if (to - from <= 1) {
1426
+ return createNoopStructuralMutation(document, fallbackSelection);
1427
+ }
1428
+ const total = gridColumns.slice(from, to).reduce((sum, w) => sum + w, 0);
1429
+ const even = Math.floor(total / (to - from));
1430
+ const remainder = total - even * (to - from);
1431
+ const nextGridColumns = gridColumns.map((w, i) => {
1432
+ if (i < from || i >= to) return w;
1433
+ const offset = i - from;
1434
+ return offset === (to - from - 1) ? even + remainder : even;
1435
+ });
1436
+ return patchTable(document, root, table, selection, { gridColumns: nextGridColumns }, fallbackSelection);
1437
+ }
1438
+
1439
+ function patchRow(
1440
+ document: CanonicalDocumentEnvelope,
1441
+ root: DocumentRootNode,
1442
+ table: TableNode,
1443
+ selection: TableSelectionDescriptor,
1444
+ rowIndex: number,
1445
+ patch: Partial<TableRowNode>,
1446
+ fallbackSelection: SelectionSnapshot,
1447
+ ): StructuralMutationResult {
1448
+ if (rowIndex < 0 || rowIndex >= table.rows.length) {
1449
+ return createNoopStructuralMutation(document, fallbackSelection);
1450
+ }
1451
+ const nextRows = table.rows.map((row, i) => (i === rowIndex ? { ...row, ...patch } : row));
1452
+ return patchTable(document, root, table, selection, { rows: nextRows }, fallbackSelection);
1453
+ }
1454
+
1455
+ function insertRows(
1456
+ document: CanonicalDocumentEnvelope,
1457
+ root: DocumentRootNode,
1458
+ table: TableNode,
1459
+ selection: TableSelectionDescriptor,
1460
+ rowIndex: number,
1461
+ at: "before" | "after",
1462
+ count: number,
1463
+ fallbackSelection: SelectionSnapshot,
1464
+ ): StructuralMutationResult {
1465
+ if (count <= 0) {
1466
+ return createNoopStructuralMutation(document, fallbackSelection);
1467
+ }
1468
+ let current = table;
1469
+ const insertAt = at === "before" ? rowIndex : rowIndex + 1;
1470
+ for (let i = 0; i < count; i += 1) {
1471
+ const grid = buildLogicalGrid(current);
1472
+ const inserted = buildInsertedRow(grid, insertAt + i);
1473
+ current = {
1474
+ ...current,
1475
+ rows: [
1476
+ ...current.rows.slice(0, insertAt + i),
1477
+ inserted,
1478
+ ...current.rows.slice(insertAt + i),
1479
+ ],
1480
+ };
1481
+ }
1482
+ return patchTable(document, root, current, selection, {}, fallbackSelection);
1483
+ }
1484
+
1485
+ function insertColumns(
1486
+ document: CanonicalDocumentEnvelope,
1487
+ root: DocumentRootNode,
1488
+ table: TableNode,
1489
+ selection: TableSelectionDescriptor,
1490
+ columnIndex: number,
1491
+ at: "before" | "after",
1492
+ count: number,
1493
+ widths: readonly number[] | undefined,
1494
+ fallbackSelection: SelectionSnapshot,
1495
+ ): StructuralMutationResult {
1496
+ if (count <= 0) {
1497
+ return createNoopStructuralMutation(document, fallbackSelection);
1498
+ }
1499
+ let current = table;
1500
+ const baseInsert = at === "before" ? columnIndex : columnIndex + 1;
1501
+ for (let i = 0; i < count; i += 1) {
1502
+ const insertColumnIndex = baseInsert + i;
1503
+ const result = addColumn(
1504
+ { ...document, content: { ...root, children: [...root.children] } } as CanonicalDocumentEnvelope,
1505
+ root,
1506
+ current,
1507
+ {
1508
+ ...selection,
1509
+ anchorCell: {
1510
+ rowIndex: selection.anchorCell.rowIndex,
1511
+ columnIndex: at === "before" ? insertColumnIndex : insertColumnIndex - 1,
1512
+ },
1513
+ } as TableSelectionDescriptor,
1514
+ at,
1515
+ fallbackSelection,
1516
+ );
1517
+ if (!result.changed) break;
1518
+ current = result.document.content.children[selection.tableBlockIndex] as TableNode;
1519
+ }
1520
+ // Apply explicit widths if provided (overwrites widths at the inserted positions).
1521
+ if (widths && widths.length > 0) {
1522
+ const nextGridColumns = [...current.gridColumns];
1523
+ for (let i = 0; i < count && i < widths.length; i += 1) {
1524
+ const idx = baseInsert + i;
1525
+ if (idx >= 0 && idx < nextGridColumns.length) {
1526
+ nextGridColumns[idx] = Math.max(0, Math.round(widths[i] ?? 0));
1527
+ }
1528
+ }
1529
+ current = { ...current, gridColumns: nextGridColumns };
1530
+ }
1531
+ return patchTable(document, root, current, selection, {}, fallbackSelection);
1532
+ }
1533
+
1534
+ // ─── P2i helpers: cell-level patches over a locator ──────────────────────────
1535
+
1536
+ function patchCells(
1537
+ document: CanonicalDocumentEnvelope,
1538
+ root: DocumentRootNode,
1539
+ table: TableNode,
1540
+ selection: TableSelectionDescriptor,
1541
+ locator: CellLocator,
1542
+ transform: (cell: TableCellNode) => TableCellNode,
1543
+ fallbackSelection: SelectionSnapshot,
1544
+ ): StructuralMutationResult {
1545
+ const grid = buildLogicalGrid(table);
1546
+ const targets = resolveLocatorTargets(grid, selection, locator);
1547
+ if (targets.size === 0) {
1548
+ return createNoopStructuralMutation(document, fallbackSelection);
1549
+ }
1550
+
1551
+ // Group by row so we can do one map per row.
1552
+ const byRow = new Map<number, Set<number>>();
1553
+ for (const { rowIndex, cellIndex } of targets) {
1554
+ let set = byRow.get(rowIndex);
1555
+ if (!set) {
1556
+ set = new Set();
1557
+ byRow.set(rowIndex, set);
1558
+ }
1559
+ set.add(cellIndex);
1560
+ }
1561
+
1562
+ const nextRows = table.rows.map((row, rowIndex) => {
1563
+ const indices = byRow.get(rowIndex);
1564
+ if (!indices) return row;
1565
+ return {
1566
+ ...row,
1567
+ cells: row.cells.map((cell, cellIndex) =>
1568
+ indices.has(cellIndex) ? transform(cell) : cell,
1569
+ ),
1570
+ };
1571
+ });
1572
+
1573
+ return patchTable(document, root, table, selection, { rows: nextRows }, fallbackSelection);
1574
+ }
1575
+
1576
+ function resolveLocatorTargets(
1577
+ grid: LogicalGrid,
1578
+ selection: TableSelectionDescriptor,
1579
+ locator: CellLocator,
1580
+ ): Set<{ rowIndex: number; cellIndex: number }> {
1581
+ const targets = new Set<{ rowIndex: number; cellIndex: number }>();
1582
+ const seenOrigins = new Set<GridOriginSlot>();
1583
+
1584
+ const addOrigin = (origin: GridOriginSlot): void => {
1585
+ if (seenOrigins.has(origin)) return;
1586
+ seenOrigins.add(origin);
1587
+ targets.add({ rowIndex: origin.rowIndex, cellIndex: origin.cellIndex });
1588
+ };
1589
+
1590
+ if (locator.kind === "anchor") {
1591
+ const origin = originAt(grid, selection.anchorCell.rowIndex, selection.anchorCell.columnIndex);
1592
+ if (origin) addOrigin(origin);
1593
+ return targets;
1594
+ }
1595
+
1596
+ if (locator.kind === "index") {
1597
+ const origin = originAt(grid, locator.rowIndex, locator.columnIndex);
1598
+ if (origin) addOrigin(origin);
1599
+ return targets;
1600
+ }
1601
+
1602
+ if (locator.kind === "row") {
1603
+ const rowIndex = locator.rowIndex;
1604
+ for (let c = 0; c < grid.columnCount; c += 1) {
1605
+ const origin = originAt(grid, rowIndex, c);
1606
+ if (origin && origin.rowIndex === rowIndex) addOrigin(origin);
1607
+ }
1608
+ return targets;
1609
+ }
1610
+
1611
+ if (locator.kind === "column") {
1612
+ const columnIndex = locator.columnIndex;
1613
+ for (let r = 0; r < grid.rowCount; r += 1) {
1614
+ const origin = originAt(grid, r, columnIndex);
1615
+ if (origin && origin.columnIndex === columnIndex) addOrigin(origin);
1616
+ }
1617
+ return targets;
1618
+ }
1619
+
1620
+ if (locator.kind === "rect") {
1621
+ for (let r = locator.rect.top; r < locator.rect.bottom; r += 1) {
1622
+ for (let c = locator.rect.left; c < locator.rect.right; c += 1) {
1623
+ const origin = originAt(grid, r, c);
1624
+ if (origin) addOrigin(origin);
1625
+ }
1626
+ }
1627
+ return targets;
1628
+ }
1629
+
1630
+ // "selection" — use the descriptor's rect or the anchor cell.
1631
+ if (selection.selectionKind === "cell") {
1632
+ for (let r = selection.rect.top; r < selection.rect.bottom; r += 1) {
1633
+ for (let c = selection.rect.left; c < selection.rect.right; c += 1) {
1634
+ const origin = originAt(grid, r, c);
1635
+ if (origin) addOrigin(origin);
1636
+ }
1637
+ }
1638
+ } else {
1639
+ const origin = originAt(grid, selection.anchorCell.rowIndex, selection.anchorCell.columnIndex);
1640
+ if (origin) addOrigin(origin);
1641
+ }
1642
+ return targets;
1643
+ }