@quillmark/svelte 0.27.0 → 0.28.0

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 (95) hide show
  1. package/NOTICE +4 -4
  2. package/README.md +26 -8
  3. package/THEMING.md +10 -8
  4. package/dist/core/address.d.ts +35 -64
  5. package/dist/core/address.js +35 -55
  6. package/dist/core/bloom.d.ts +4 -10
  7. package/dist/core/bloom.js +14 -27
  8. package/dist/core/codec/blocks.d.ts +41 -43
  9. package/dist/core/codec/blocks.js +97 -130
  10. package/dist/core/codec/code.d.ts +7 -10
  11. package/dist/core/codec/code.js +37 -46
  12. package/dist/core/codec/decode.d.ts +19 -23
  13. package/dist/core/codec/decode.js +76 -111
  14. package/dist/core/codec/encode.d.ts +11 -15
  15. package/dist/core/codec/encode.js +60 -67
  16. package/dist/core/codec/field.d.ts +62 -95
  17. package/dist/core/codec/field.js +107 -179
  18. package/dist/core/codec/islands.d.ts +33 -41
  19. package/dist/core/codec/islands.js +76 -86
  20. package/dist/core/codec/lists.d.ts +17 -26
  21. package/dist/core/codec/lists.js +117 -157
  22. package/dist/core/codec/marks.d.ts +4 -3
  23. package/dist/core/codec/marks.js +8 -3
  24. package/dist/core/codec/prose.css +84 -46
  25. package/dist/core/codec/schema.d.ts +18 -23
  26. package/dist/core/codec/schema.js +84 -76
  27. package/dist/core/codec/slash.d.ts +42 -48
  28. package/dist/core/codec/slash.js +0 -0
  29. package/dist/core/codec/table-copy.d.ts +4 -3
  30. package/dist/core/codec/table-copy.js +25 -7
  31. package/dist/core/codec/table-view.d.ts +12 -12
  32. package/dist/core/codec/table-view.js +460 -383
  33. package/dist/core/codec/table.d.ts +94 -61
  34. package/dist/core/codec/table.js +331 -83
  35. package/dist/core/codec/tasks.d.ts +6 -0
  36. package/dist/core/codec/tasks.js +30 -3
  37. package/dist/core/errors.d.ts +15 -30
  38. package/dist/core/errors.js +2 -6
  39. package/dist/core/index.js +7 -12
  40. package/dist/core/lifecycle.d.ts +7 -16
  41. package/dist/core/lifecycle.js +10 -21
  42. package/dist/core/rebind.svelte.d.ts +10 -20
  43. package/dist/core/rebind.svelte.js +15 -27
  44. package/dist/core/rungs.d.ts +14 -11
  45. package/dist/core/rungs.js +17 -19
  46. package/dist/core/teardown.d.ts +4 -6
  47. package/dist/core/teardown.js +7 -10
  48. package/dist/core/theme.css +74 -54
  49. package/dist/index.js +3 -8
  50. package/dist/preset/app.css +3 -1
  51. package/dist/preset/index.css +6 -8
  52. package/dist/preset/recipes.css +12 -15
  53. package/dist/preset/scale.css +18 -17
  54. package/dist/preset/shell.css +10 -0
  55. package/dist/preview/bridge.d.ts +18 -22
  56. package/dist/preview/bridge.js +29 -58
  57. package/dist/preview/controller.d.ts +21 -29
  58. package/dist/preview/controller.js +14 -39
  59. package/dist/preview/geometry.d.ts +13 -34
  60. package/dist/preview/geometry.js +13 -34
  61. package/dist/preview/index.js +4 -16
  62. package/dist/preview/paint.d.ts +3 -3
  63. package/dist/preview/paint.js +28 -68
  64. package/dist/preview/strings.d.ts +1 -1
  65. package/dist/preview/strings.js +2 -14
  66. package/dist/preview/zoom.d.ts +18 -23
  67. package/dist/preview/zoom.js +28 -27
  68. package/dist/visual/ArrayField.svelte +77 -16
  69. package/dist/visual/BooleanField.svelte +27 -14
  70. package/dist/visual/Card.svelte +61 -48
  71. package/dist/visual/CardControls.svelte +5 -11
  72. package/dist/visual/DateField.svelte +20 -4
  73. package/dist/visual/EnumField.svelte +3 -0
  74. package/dist/visual/FieldHint.svelte +2 -1
  75. package/dist/visual/FormatPopover.svelte +82 -15
  76. package/dist/visual/FormatPopover.svelte.d.ts +4 -0
  77. package/dist/visual/MatrixAdd.svelte +30 -12
  78. package/dist/visual/MatrixField.svelte +24 -16
  79. package/dist/visual/NumberField.svelte +7 -0
  80. package/dist/visual/ObjectField.svelte +4 -4
  81. package/dist/visual/ProseField.svelte +0 -5
  82. package/dist/visual/TipsCard.svelte +11 -10
  83. package/dist/visual/VariantField.svelte +3 -1
  84. package/dist/visual/VisualEditorInner.svelte +19 -3
  85. package/dist/visual/controls.css +7 -2
  86. package/dist/visual/fonts.d.ts +18 -0
  87. package/dist/visual/fonts.js +59 -0
  88. package/dist/visual/icons/nodes.d.ts +0 -5
  89. package/dist/visual/icons/nodes.js +0 -4
  90. package/dist/visual/props.d.ts +14 -1
  91. package/dist/visual/strings.d.ts +7 -0
  92. package/dist/visual/strings.js +5 -2
  93. package/dist/visual/structure.d.ts +43 -87
  94. package/dist/visual/structure.js +42 -83
  95. package/package.json +2 -2
@@ -1,24 +1,14 @@
1
- // The table island's NodeView: the surface a `table` island is edited through
2
- // (CODEC §"The table island"). Vanilla DOM rather than Svelte chrome, because what
3
- // it renders is PM's own: a leaf node's substitute DOM, holding one nested
4
- // `EditorView` per cell.
1
+ // The table island's NodeView (CODEC §"The table island"): vanilla DOM holding one nested
2
+ // `EditorView` per cell, each on its own schema (`table.ts`).
5
3
  //
6
- // A cell is a second content unit inside the first, so it gets its own schema (one
7
- // paragraph with hard breaks, no containers, no islands, marks and input rules intact);
8
- // `table.ts` owns that translation. A cell edit does not touch the field's text:
9
- // the projection goes back onto the node's `props` attribute with `setNodeMarkup`,
10
- // and the field's own `dispatchTransaction` lowers that to an `islandOps` `set`
11
- // (CODEC §Encode), which is what keeps every anchor in the field.
4
+ // A cell edit writes the projection back onto the node's `props` with `setNodeMarkup`, which the
5
+ // field lowers to an `islandOps` `set` (CODEC §Encode), so every anchor stays in the field.
12
6
  //
13
- // The nested views are not the field's: they carry no history (Mod-z routes to the
14
- // field's, so one undo stack covers the leaf), no anchor plugin (an anchor in a cell
15
- // is preserved, never minted), and no placeholder.
7
+ // The nested views carry no history (Mod-z routes to the field's), no anchor plugin (an anchor in a
8
+ // cell is preserved, never minted), and no placeholder.
16
9
  //
17
- // The chrome is a band and a selection (CODEC §"The table island"), and the band raises
18
- // nothing: what floats over the grid is a held column's alignment or the held first
19
- // row's header toggle. Every control is absolutely positioned out of the grid, so none
20
- // is in a row or a column of it; `codec/prose.css` draws the band, the clusters and the
21
- // selection wash.
10
+ // Every control is absolutely positioned out of the grid; `codec/prose.css` draws the band, the
11
+ // clusters and the selection wash.
22
12
  import { baseKeymap, chainCommands, selectAll, toggleMark } from 'prosemirror-commands';
23
13
  import { redo, undo } from 'prosemirror-history';
24
14
  import { keymap } from 'prosemirror-keymap';
@@ -31,13 +21,10 @@ import { tablePropsOfNode } from './islands.js';
31
21
  import { tableElement, tableText } from './table-copy.js';
32
22
  import { breakKeymap } from './breaks.js';
33
23
  import { cellSchema } from './schema.js';
34
- import { ALIGNS, cellAt, cellContent, cellEqual, cellFromDoc, clearCells, columnCount, deleteColumn, deleteRow, insertColumn, insertRow, moveColumn, moveRow, normalizeTable, rowCells, rowCount, rowEmpty, setAlign, setHeadless, columnShares, setWidths, shapeEqual, withCell } from './table.js';
24
+ import { ALIGNS, canMerge, cellAt, closeBlock, cellContent, cellEqual, cellFromDoc, clearCells, columnCount, deleteColumn, deleteRow, insertColumn, insertRow, mergeCells, moveColumn, moveRow, normalizeTable, rowCells, rowCount, rowEmpty, setAlign, setHead, headRows, columnShares, setWidths, shapeEqual, spanGrid, splitCell, withCell } from './table.js';
35
25
  /**
36
- * The package's English for the island chrome. It lives here, beside the surface
37
- * that draws it, rather than in the visual tier's table: the codec mounts this
38
- * chrome and a consumer reaching `createField` directly gets wording with it. The
39
- * visual `strings` set extends this one, so a consumer overrides these keys beside
40
- * every other key and there is still one English list.
26
+ * The package's English for the island chrome. It lives beside the surface that draws it so a
27
+ * consumer reaching `createField` directly gets wording; the visual `strings` set extends it.
41
28
  */
42
29
  export const DEFAULT_TABLE_STRINGS = {
43
30
  tableLabel: 'Table',
@@ -53,14 +40,15 @@ export const DEFAULT_TABLE_STRINGS = {
53
40
  tableAlignLeft: 'Align left',
54
41
  tableAlignCenter: 'Align center',
55
42
  tableAlignRight: 'Align right',
56
- tableHeaderToggle: 'Header row'
43
+ tableHeaderToggle: 'Header row',
44
+ tableMergeCells: 'Merge cells',
45
+ tableSplitCell: 'Split cell'
57
46
  };
58
- /** The grip's glyph, as the path data a DOM node can carry — this chrome's own set rather
59
- * than `visual/icons/nodes.ts`, this being the one place chrome is built without Svelte,
60
- * and `/core` reaching no surface module. Same 24×24 frame and the same origin, off an
61
- * earlier release than the set there; `NOTICE` carries the notices for both. The dots
62
- * are zero-length strokes under a round cap, which is how that set draws a dot
63
- * everywhere it has one. */
47
+ /**
48
+ * The grip's glyph as path data: this chrome's own set, since it is built without Svelte and
49
+ * `/core` reaches no surface module. Same 24×24 frame as `visual/icons/nodes.ts`, off an earlier
50
+ * release; `NOTICE` carries both notices. The dots are zero-length strokes under a round cap.
51
+ */
64
52
  const GRIP = {
65
53
  column: ['M5 9h.01', 'M12 9h.01', 'M19 9h.01', 'M5 15h.01', 'M12 15h.01', 'M19 15h.01'],
66
54
  row: ['M9 5h.01', 'M9 12h.01', 'M9 19h.01', 'M15 5h.01', 'M15 12h.01', 'M15 19h.01']
@@ -118,11 +106,10 @@ function el(tag, className) {
118
106
  node.className = className;
119
107
  return node;
120
108
  }
121
- /** A band button. It swallows its own `mousedown` (prosemirror-menu's trick, and
122
- * the format popover's): without it the browser focuses the button, blurring the
123
- * cell whose caret the op is about to be measured against. No control on the band ever
124
- * holds the focus: a cell is the island's one host, and what a press acts on is where
125
- * it lands the caret. */
109
+ /**
110
+ * A band button. It swallows its own `mousedown` (prosemirror-menu's trick) so the browser does not
111
+ * focus it and blur the cell whose caret the op is measured against.
112
+ */
126
113
  function chromeButton(className, label, run) {
127
114
  const btn = el('button', className);
128
115
  btn.type = 'button';
@@ -146,16 +133,16 @@ function cellPlugins(keys) {
146
133
  keymap(baseKeymap)
147
134
  ];
148
135
  }
149
- /** The controls, each of which answers for its own press. Spelled apart from
150
- * {@link owned} because the pointer router needs them before it needs the cells: a grip
151
- * and a cluster are inside the cell they name, so the two selectors overlap on exactly
152
- * the press whose reading they disagree about. */
136
+ /**
137
+ * The controls, each answering for its own press. Spelled apart from {@link owned} because a grip
138
+ * and a cluster sit inside the cell they name, so the two selectors overlap on exactly the press
139
+ * whose reading they disagree about.
140
+ */
153
141
  const CONTROLS = '.qm-table-grip, .qm-table-add, .qm-table-align, .qm-table-resize';
154
142
  /** What the nested views and the band answer for themselves, which is what `stopEvent`
155
143
  * keeps from PM. */
156
144
  const OWNED = `.qm-table-cell-host, ${CONTROLS}`;
157
- /** How far a press travels before it is a drag rather than a click. Under it, a press
158
- * that jitters is still the gesture it was aimed as. */
145
+ /** How far a press travels before it is a drag rather than a click. */
159
146
  const DEAD_ZONE = 3;
160
147
  /** A line's key in the grip registry, spelled once. */
161
148
  const lineKey = (line) => `${line.axis}:${line.index}`;
@@ -164,6 +151,13 @@ const within = (rect, p) => p.x >= rect.left && p.x <= rect.right && p.y >= rect
164
151
  function distance(rect, p) {
165
152
  return Math.hypot(Math.max(rect.left - p.x, 0, p.x - rect.right), Math.max(rect.top - p.y, 0, p.y - rect.bottom));
166
153
  }
154
+ /** Whether a mounted cell's span reaches into an inclusive block. */
155
+ const touches = (m, b) => m.r <= b.r1 &&
156
+ m.r + m.span.rowspan - 1 >= b.r0 &&
157
+ m.c <= b.c1 &&
158
+ m.c + m.span.colspan - 1 >= b.c0;
159
+ /** Whether a mounted cell's span covers the slot `(r, c)`. */
160
+ const covers = (m, r, c) => touches(m, { r0: r, c0: c, r1: r, c1: c });
167
161
  const sameCells = (a, b) => a.r0 === b.r0 && a.c0 === b.c0 && a.r1 === b.r1 && a.c1 === b.c1 && a.swept === b.swept;
168
162
  /** The block two corners span, in either order: what a sweep holds. */
169
163
  const spanCells = (a, b) => ({
@@ -173,15 +167,15 @@ const spanCells = (a, b) => ({
173
167
  c1: Math.max(a.c, b.c),
174
168
  swept: true
175
169
  });
176
- /** Each mounted island drawing a table, by its `dom`, which is what the outer view hands
177
- * back for a position: how a key in the field reaches into a table ({@link tableAt}). A
178
- * placeholder is absent, so it answers as the atom it draws. */
170
+ /**
171
+ * Each mounted island drawing a table, by its `dom`: how a key in the field reaches into a table
172
+ * ({@link tableAt}). A placeholder is absent.
173
+ */
179
174
  const mounted = new WeakMap();
180
175
  /**
181
- * Where the caret stood as each table value was written over, keyed by that value. An
182
- * undo hands the node back the very `props` object the undone edit replaced, so the seat
183
- * held against it is where the edit was made from: where PM's history puts the caret
184
- * back in a field's own text.
176
+ * Where the caret stood as each table value was written over, keyed by that value: an undo hands
177
+ * back the `props` object the edit replaced, so the seat held against it is where the edit was made
178
+ * from.
185
179
  */
186
180
  const departures = new WeakMap();
187
181
  class TableIslandView {
@@ -197,18 +191,22 @@ class TableIslandView {
197
191
  selected;
198
192
  drag;
199
193
  sweep;
200
- /** The grips, by the line each acts on: what selection paint and a drag reach for
201
- * without a query. The typed twin of `cells`, `Line` included — a key parsed back
202
- * into one is the `data-` attribute `MountedCell.box` refuses. */
194
+ /** The grips, by the line each acts on: the typed twin of `cells`. */
203
195
  grips = new Map();
204
196
  /** The alignment clusters, by column. */
205
197
  clusters = [];
206
198
  /** The first row's header toggle, up while that row is held. */
207
199
  headerCluster;
208
- /** The grid's own box, and the containing block every out-of-flow control is placed
209
- * against. Not the scroller: an absolute inside a scroll container is placed
210
- * against a padding box the scroll then slides out from under, so a control at the
211
- * grid's far end would drift into the middle of it. */
200
+ /** The merge and split cluster, hung under the held block's first cell or the caret's
201
+ * merged cell while either is up. */
202
+ cellCluster;
203
+ /** The rail's cells, one per column: a row of no height above the first, which the
204
+ * column chrome hangs off, a merged cell in the first row standing over several. */
205
+ rails = [];
206
+ /**
207
+ * The grid's own box, the containing block for out-of-flow controls. Not the scroller: an
208
+ * absolute inside a scroll container drifts as it scrolls.
209
+ */
212
210
  frame;
213
211
  dropMark;
214
212
  /** The grid's `<col>`s, which a border drag sizes live and a render sizes from the
@@ -244,14 +242,10 @@ class TableIslandView {
244
242
  this.render();
245
243
  return true;
246
244
  }
247
- // Same rectangle: reseed only the cells whose value the nested view is not
248
- // already showing. The cell that produced this update wrote exactly what it is
249
- // showing, so it compares equal and keeps its caret; an undo or an external
250
- // re-hydrate does not, and takes the fresh state.
251
- //
252
- // Against `shown` rather than a projection of the doc: this runs on every outer
253
- // transaction, which is every keystroke in the table, and projecting each cell
254
- // costs a `mapMarks` across the WASM boundary wherever one holds an anchor.
245
+ // Same rectangle: reseed only cells whose value the nested view is not showing. The cell
246
+ // that produced this update compares equal and keeps its caret; an undo or external
247
+ // re-hydrate takes fresh state. Compared against `shown`, not a doc projection: this runs
248
+ // every keystroke and projecting costs a `mapMarks` across WASM per anchored cell.
255
249
  this.rendered = props;
256
250
  const departure = departures.get(props);
257
251
  for (const mounted of this.cells) {
@@ -265,9 +259,8 @@ class TableIslandView {
265
259
  });
266
260
  mounted.view.updateState(fresh);
267
261
  mounted.shown = stored;
268
- // The rule a field's own re-hydrate takes: keep the offset, clamped into the
269
- // text that is there now, a fresh state resolving to the cell's start. An undo
270
- // outranks it below, the departure being where the edit was.
262
+ // Keep the offset clamped into the text now there (a field's re-hydrate rule); an undo
263
+ // outranks it below.
271
264
  const at = Math.min(head, fresh.doc.content.size);
272
265
  mounted.view.dispatch(fresh.tr.setSelection(Selection.near(fresh.doc.resolve(at))));
273
266
  }
@@ -280,11 +273,11 @@ class TableIslandView {
280
273
  this.paintSelection();
281
274
  return true;
282
275
  }
283
- /** The nested views own every event inside a cell or a control; everything else
284
- * stays PM's, so a key pressed over a selected island still reaches the field's
285
- * keymap. What a pointer press means on the rest of the chrome is
286
- * {@link TableIslandView.onPointerDown}'s and not this one's: `stopEvent` gates
287
- * the whole subtree, so widening it would take that routing with it. */
276
+ /**
277
+ * The nested views own every event inside a cell or control; the rest stays PM's so a key over
278
+ * a selected island reaches the field's keymap. Pointer routing is {@link
279
+ * TableIslandView.onPointerDown}'s: `stopEvent` gates the whole subtree.
280
+ */
288
281
  stopEvent(event) {
289
282
  const target = event.target;
290
283
  return !!target?.closest?.(OWNED);
@@ -305,21 +298,15 @@ class TableIslandView {
305
298
  }
306
299
  // ── The pointer ───────────────────────────────────────────────────────────
307
300
  /**
308
- * Route a press to its band (CODEC §"The table island"): inside the frame the
309
- * nearest cell's caret, outside it the document's, and a band control answers for
310
- * itself.
301
+ * Route a press to its band (CODEC §"The table island"): inside the frame the nearest cell's
302
+ * caret, outside it the document's, and a band control answers for itself.
311
303
  *
312
- * A press in a cell is a caret and the nested view is taking it; what is armed here
313
- * is only the promotion, since a press that travels into another cell stops being a
314
- * caret and becomes a block. That is the one gesture that has to see a press the
315
- * nested view already owns, which is why it runs ahead of the cell host's guard
316
- * rather than behind it. A control is the other way round: a grip is inside the cell
317
- * it names, so its guard runs ahead of the cell branch, or a grip press would plant a
318
- * caret in that cell and arm a sweep the drag then draws a block with.
304
+ * A press in a cell is a caret the nested view takes; only the promotion to a block is armed
305
+ * here, which is why this runs ahead of the cell host's guard. A grip is inside the cell it
306
+ * names, so the control guard runs ahead of the cell branch.
319
307
  *
320
- * A `mousedown` listener, and it stops the event: PM's own mousedown is what arms
321
- * the node selection the matching mouseup then takes. `stopEvent` is the other way
322
- * to reach that, and it gates the subtree's keydown and drag routing too.
308
+ * A `mousedown` listener that stops the event, since PM's own mousedown arms the node selection
309
+ * the mouseup takes.
323
310
  */
324
311
  onPointerDown = (event) => {
325
312
  // Any other island type is an atom with no interior: a press on it is
@@ -333,9 +320,8 @@ class TableIslandView {
333
320
  const box = target?.closest?.('.qm-table-cell');
334
321
  const cell = box && this.cells.find((m) => m.box === box);
335
322
  if (cell) {
336
- // A fresh press in a cell means "caret here", so it retires whatever block was
337
- // held: a press back into the cell that already has focus raises no `focus`
338
- // event, and without this the block would survive to eat the next Backspace.
323
+ // A fresh press in a cell retires the held block: a press into the already-focused cell
324
+ // raises no focus event.
339
325
  this.clearSelection();
340
326
  // A cell's own padding and its borders are the table's, not the view's: a press
341
327
  // there lands the caret it aimed at rather than nothing.
@@ -357,9 +343,10 @@ class TableIslandView {
357
343
  else
358
344
  this.caretBeside(point);
359
345
  };
360
- /** A cell under the pointer draws its row's and its column's grips, so a line's
361
- * handle is found from the line rather than only from the band beside it. A control
362
- * under the pointer is its own reveal, and draws no other. */
346
+ /**
347
+ * A cell under the pointer draws its row's and column's grips; a control under it is its own
348
+ * reveal.
349
+ */
363
350
  onHover = (event) => {
364
351
  const target = event.target;
365
352
  const box = target?.closest?.(CONTROLS) ? null : target?.closest?.('.qm-table-cell');
@@ -394,14 +381,11 @@ class TableIslandView {
394
381
  event.preventDefault();
395
382
  event.stopPropagation();
396
383
  };
397
- /** The cell a press inside the frame belongs to: the nearest by rect, which for a
398
- * press on a border or a cell's own padding is the cell it is against.
399
- *
400
- * A row's cells share a vertical extent and a column's a horizontal one, and the
401
- * distance is a hypotenuse of the two, so the nearest cell is the nearest row
402
- * crossed with the nearest column: the header row and the first column answer for
403
- * the whole grid. R+C rects rather than R×C, for the reason a line drag measures
404
- * once — at 20x8 that is 27 forced layouts a press instead of 160. */
384
+ /**
385
+ * The cell a press inside the frame belongs to: the nearest by rect. Row cells share a vertical
386
+ * extent and column cells a horizontal one, so the nearest cell is the nearest row crossed with
387
+ * the nearest column: R+C rects rather than R×C (27 layouts a press at 20x8, not 160).
388
+ */
405
389
  focusNearestCell(point) {
406
390
  let row;
407
391
  let column;
@@ -429,14 +413,11 @@ class TableIslandView {
429
413
  if (row !== undefined && column !== undefined)
430
414
  this.focusCell(row, column);
431
415
  }
432
- /** A caret beside the island, on the side the press landed: a gap cursor where the
433
- * document holds no text position there, and the neighbouring block's own edge
434
- * where it holds one.
435
- *
436
- * The gap is asked for through the plugin's own `createSelectionBetween` rather
437
- * than built here: whether a position takes one is the gap cursor's rule, and a
438
- * leaf mounted without that plugin then answers "no gap" instead of dispatching
439
- * a selection nothing draws. */
416
+ /**
417
+ * A caret beside the island, on the side the press landed: a gap cursor where the document
418
+ * holds no text position, the neighbouring block's edge where it does. The gap is asked of the
419
+ * plugin's `createSelectionBetween`, so a leaf without that plugin answers "no gap".
420
+ */
440
421
  caretBeside(point) {
441
422
  const box = this.dom.getBoundingClientRect();
442
423
  this.leave(point.y < (box.top + box.bottom) / 2 ? -1 : 1);
@@ -465,10 +446,10 @@ class TableIslandView {
465
446
  else
466
447
  this.focusCell(0, 0, 'start');
467
448
  }
468
- /** The island as the selection: the Escape that climbs out of a cell or a held
469
- * rectangle, which is the whole of what selects it. Backspace there deletes the
470
- * table, as it does over every other island; the cell selection covering every rank
471
- * reaches the same delete without leaving the grid (CODEC §"The table island"). */
449
+ /**
450
+ * The island as the selection: the Escape that climbs out of a cell or held rectangle.
451
+ * Backspace there deletes the table, as over every island (CODEC §"The table island").
452
+ */
472
453
  selectIsland() {
473
454
  const pos = this.getPos();
474
455
  if (pos == null)
@@ -491,10 +472,9 @@ class TableIslandView {
491
472
  document.addEventListener('mouseup', this.onSweepUp, true);
492
473
  }
493
474
  /**
494
- * Promote a travelling press to a block. Until the pointer leaves the cell it
495
- * started in the gesture is still a caret and the nested view keeps it; from the
496
- * first cell it crosses, the block is the selection and the browser's own text drag
497
- * is dropped, two selections over one press being one too many.
475
+ * Promote a travelling press to a block: until the pointer leaves its start cell it is a caret;
476
+ * from the first cell it crosses the block is the selection and the browser's text drag is
477
+ * dropped.
498
478
  */
499
479
  onSweepMove = (event) => {
500
480
  const sweep = this.sweep;
@@ -544,10 +524,10 @@ class TableIslandView {
544
524
  return best;
545
525
  }
546
526
  // ── The selection ─────────────────────────────────────────────────────────
547
- /** The rectangle a line covers, which is what selecting one means: a grip draws no
548
- * second kind of selection, it draws this one. `props` is the caller's where it
549
- * reads a line per grip ({@link TableIslandView.paintSelection}): each `props()`
550
- * revalidates the whole table. */
527
+ /**
528
+ * The rectangle a line covers, which is what selecting one means. `props` is the caller's where
529
+ * it reads a line per grip, since each `props()` revalidates the whole table.
530
+ */
551
531
  lineCells(line, props = this.props()) {
552
532
  return line.axis === 'row'
553
533
  ? { r0: line.index, c0: 0, r1: line.index, c1: columnCount(props) - 1, swept: false }
@@ -597,7 +577,9 @@ class TableIslandView {
597
577
  ? held.c0
598
578
  : undefined;
599
579
  }
600
- select(cells) {
580
+ select(held) {
581
+ // A block over part of a merged cell holds all of it, as the cell is one.
582
+ const cells = held.swept ? { ...closeBlock(this.props(), held), swept: true } : held;
601
583
  // A sweep resolves a rectangle per pointer move and most of them are the one
602
584
  // already held, which is a repaint of the state that is up.
603
585
  if (this.selected && sameCells(this.selected, cells))
@@ -629,10 +611,11 @@ class TableIslandView {
629
611
  this.selected = undefined;
630
612
  this.paintSelection();
631
613
  }
632
- /** Wash the selected cells, mark the grip of any line the selection exactly covers, and
633
- * put up the cluster of a column it covers. Imperative rather than a re-render: a
634
- * rebuild destroys the nested views, and a selection is exactly the state that must
635
- * not cost the carets in them. */
614
+ /**
615
+ * Wash the selected cells, mark the grip of any line the selection exactly covers, and put up
616
+ * the cluster of a column it covers. Imperative: a rebuild destroys the nested views and the
617
+ * carets in them.
618
+ */
636
619
  paintSelection() {
637
620
  const held = this.selected;
638
621
  const props = this.props();
@@ -646,13 +629,66 @@ class TableIslandView {
646
629
  this.headerCluster.hidden = !named;
647
630
  }
648
631
  for (const cell of this.cells)
649
- cell.box.toggleAttribute('data-selected', !!held && cell.r >= held.r0 && cell.r <= held.r1 && cell.c >= held.c0 && cell.c <= held.c1);
632
+ cell.box.toggleAttribute('data-selected', !!held && touches(cell, held));
633
+ this.paintCellCluster();
650
634
  }
651
635
  /**
652
- * What Backspace means over the selection (CODEC §"The table island"). Swept cells
653
- * clear and keep their lines. Named lines go, read off their extent: every rank on
654
- * both axes is the table, which goes as the island, and on a one-column or one-row
655
- * table a single grip draws that.
636
+ * The merge and split cluster: under the block's bottom-left cell, offering Merge for more than
637
+ * one cell and Split where it holds a merged one; under the caret's own merged cell when
638
+ * nothing is held.
639
+ */
640
+ paintCellCluster() {
641
+ const cluster = this.cellCluster;
642
+ if (!cluster)
643
+ return;
644
+ const props = this.props();
645
+ const held = this.selected;
646
+ const caret = held ? undefined : this.cells.find((m) => m.view.hasFocus());
647
+ const block = held?.swept
648
+ ? held
649
+ : caret && (caret.span.colspan > 1 || caret.span.rowspan > 1)
650
+ ? { r0: caret.r, c0: caret.c, r1: caret.r, c1: caret.c }
651
+ : undefined;
652
+ const merge = !!held && canMerge(props, held);
653
+ const split = !!block &&
654
+ this.cells.some((m) => touches(m, block) && (m.span.colspan > 1 || m.span.rowspan > 1));
655
+ const host = block && this.cells.find((m) => covers(m, block.r1, block.c0));
656
+ cluster.hidden = !host || (!merge && !split);
657
+ if (cluster.hidden || !host)
658
+ return cluster.remove();
659
+ const [mergeBtn, splitBtn] = cluster.children;
660
+ mergeBtn.hidden = !merge;
661
+ splitBtn.hidden = !split;
662
+ if (cluster.parentElement !== host.box)
663
+ host.box.appendChild(cluster);
664
+ }
665
+ /** Merge the held block into one cell, holding the caret in it. */
666
+ merge() {
667
+ const held = this.selected;
668
+ if (!held)
669
+ return;
670
+ const block = closeBlock(this.props(), held);
671
+ this.selected = undefined;
672
+ this.write(mergeCells(this.props(), block), { r: block.r0, c: block.c0 });
673
+ }
674
+ /** Split every merged cell the held block or the caret's cell holds. */
675
+ split() {
676
+ const held = this.selected;
677
+ const caret = held ? undefined : this.cells.find((m) => m.view.hasFocus());
678
+ const block = held ?? (caret && { r0: caret.r, c0: caret.c, r1: caret.r, c1: caret.c });
679
+ if (!block)
680
+ return;
681
+ let props = this.props();
682
+ for (const m of this.cells)
683
+ if (touches(m, block) && (m.span.colspan > 1 || m.span.rowspan > 1))
684
+ props = splitCell(props, m.r, m.c);
685
+ this.selected = undefined;
686
+ this.write(props, { r: block.r0, c: block.c0 });
687
+ }
688
+ /**
689
+ * What Backspace means over the selection (CODEC §"The table island"). Swept cells clear and
690
+ * keep their lines. Named lines go; every rank on both axes is the table, which goes as the
691
+ * island.
656
692
  */
657
693
  deleteSelection() {
658
694
  const held = this.selected;
@@ -675,10 +711,11 @@ class TableIslandView {
675
711
  this.write(clearCells(props, held.r0, held.c0, held.r1, held.c1));
676
712
  this.select(held);
677
713
  }
678
- /** Delete the whole island: an ordinary delete on the outer view, so the table goes
679
- * the way the node selection's Backspace already took it and rides the same undo
680
- * stack. The selection is dropped without a repaint: the DOM holding it is about to
681
- * be gone. */
714
+ /**
715
+ * Delete the whole island as an ordinary delete on the outer view, riding the same undo stack
716
+ * as the node selection's Backspace. The selection is dropped without a repaint: its DOM is
717
+ * about to be gone.
718
+ */
682
719
  deleteIsland() {
683
720
  const pos = this.getPos();
684
721
  if (pos == null)
@@ -687,10 +724,10 @@ class TableIslandView {
687
724
  this.outer.focus();
688
725
  this.outer.dispatch(this.outer.state.tr.delete(pos, pos + this.node.nodeSize));
689
726
  }
690
- /** Drop the rows the selection covers, high index first so the ones still to go keep
691
- * their indices, and select whatever took the first one's place. The header is among
692
- * them like any other row, and the row left at index 0 is the header afterwards. One
693
- * `write`, so the whole gesture is one undo step. */
727
+ /**
728
+ * Drop the rows the selection covers, high index first so the rest keep their indices, and
729
+ * select whatever took the first one's place. One `write`, so one undo step.
730
+ */
694
731
  dropRows(held) {
695
732
  let props = this.props();
696
733
  for (let r = held.r1; r >= held.r0; r--)
@@ -698,11 +735,11 @@ class TableIslandView {
698
735
  this.write(props);
699
736
  this.selectLine({ axis: 'row', index: Math.min(held.r0, rowCount(this.props()) - 1) });
700
737
  }
701
- /** Drop the columns the selection covers, and select what took the first one's place —
702
- * the row arm's rule, spelled the same way, since a rank going is a rank going on
703
- * either axis. Neither this nor {@link TableIslandView.dropRows} can empty the table:
704
- * a rectangle covering every rank of its axis spans the other one too, which is the
705
- * island arm above, so a rank always survives here. */
738
+ /**
739
+ * Drop the columns the selection covers, and select what took the first one's place. Neither
740
+ * this nor {@link TableIslandView.dropRows} can empty the table: a rectangle covering every
741
+ * rank of its axis spans the other too, which is the island arm above.
742
+ */
706
743
  dropColumns(held) {
707
744
  let props = this.props();
708
745
  for (let c = held.c1; c >= held.c0; c--)
@@ -710,8 +747,10 @@ class TableIslandView {
710
747
  this.write(props);
711
748
  this.selectLine({ axis: 'column', index: Math.min(held.c0, columnCount(this.props()) - 1) });
712
749
  }
713
- /** Move the line and keep it selected: a move whose selection did not travel would
714
- * leave the next Alt+arrow acting on whatever took the index. */
750
+ /**
751
+ * Move the line and keep it selected, so the next Alt+arrow acts on it and not on whatever took
752
+ * the index.
753
+ */
715
754
  moveLine(line, by) {
716
755
  const props = this.props();
717
756
  const { floor, limit } = this.bounds(line.axis, props);
@@ -721,10 +760,10 @@ class TableIslandView {
721
760
  this.write(line.axis === 'row' ? moveRow(props, line.index, by) : moveColumn(props, line.index, by));
722
761
  this.selectLine({ axis: line.axis, index: to });
723
762
  }
724
- /** Set column `c`'s alignment, clear its cells' own, which would outrank it, and keep
725
- * the column held: a changed alignment rebuilds the views, which retires the selection
726
- * as a move's rebuild does. One `set` for both, and a column already at `to` with no
727
- * cell of its own writes nothing. */
763
+ /**
764
+ * Set column `c`'s alignment, clear its cells' own (which would outrank it), and keep the
765
+ * column held. One `set`; a column already at `to` with no cell of its own writes nothing.
766
+ */
728
767
  align(c, to) {
729
768
  const props = this.props();
730
769
  const own = Array.from({ length: rowCount(props) }, (_, r) => cellAt(props, r, c)).some((cell) => cell.align !== undefined);
@@ -734,16 +773,14 @@ class TableIslandView {
734
773
  }
735
774
  // ── Drag to reorder ───────────────────────────────────────────────────────
736
775
  /**
737
- * Press-and-drag a grip moves its line. The press still selects: the dead zone is
738
- * what tells the two apart, so a click that jitters is the gesture it was aimed as
739
- * and only a real travel becomes a drag.
776
+ * Press-and-drag a grip moves its line. The press still selects; the dead zone tells the two
777
+ * apart.
740
778
  */
741
779
  onGripDown = (line, grip, event) => {
742
780
  if (event.button !== 0)
743
781
  return;
744
- // The previous gesture ends first, as `armSweep`'s does: `endDrag` reads
745
- // `this.drag` to know which grip to unbind, so a second press over a live drag
746
- // would orphan the first grip's listeners and its pointer capture.
782
+ // The previous gesture ends first: `endDrag` reads `this.drag` to unbind the grip, so a
783
+ // second press over a live drag would orphan the first grip's listeners and capture.
747
784
  this.endDrag();
748
785
  this.drag = {
749
786
  line,
@@ -756,8 +793,8 @@ class TableIslandView {
756
793
  lines: [],
757
794
  origin0: { left: 0, top: 0 }
758
795
  };
759
- // Optional for the reason the release is: pointer capture is the browser's, and a
760
- // DOM without it still routes the move and up events the drag reads.
796
+ // Optional: pointer capture is the browser's, and a DOM without it still routes the move
797
+ // and up events.
761
798
  grip.setPointerCapture?.(event.pointerId);
762
799
  grip.addEventListener('pointermove', this.onGripMove);
763
800
  grip.addEventListener('pointerup', this.onGripUp);
@@ -789,26 +826,21 @@ class TableIslandView {
789
826
  drag.origin0 = { left: box.left, top: box.top };
790
827
  const { floor, limit } = this.bounds(drag.line.axis, this.props());
791
828
  for (let i = floor; i <= limit; i++) {
792
- const cells = this.boxesOf({ axis: drag.line.axis, index: i });
793
- if (!cells.length)
829
+ // A line's extent is read off the cells standing in it alone: a merged cell
830
+ // stretches across the lines it covers.
831
+ const row = drag.line.axis === 'row';
832
+ const cells = this.cells
833
+ .filter((m) => row ? m.r === i && m.span.rowspan === 1 : m.c === i && m.span.colspan === 1)
834
+ .map((m) => m.box.getBoundingClientRect());
835
+ const all = this.boxesOf({ axis: drag.line.axis, index: i }).map((b) => b.getBoundingClientRect());
836
+ const own = cells[0];
837
+ if (!own || !all.length)
794
838
  continue;
795
- const head = cells[0].getBoundingClientRect();
796
- const tail = cells[cells.length - 1].getBoundingClientRect();
797
- drag.lines.push(drag.line.axis === 'row'
798
- ? {
799
- index: i,
800
- start: head.top,
801
- end: head.bottom,
802
- cross: head.left,
803
- span: tail.right - head.left
804
- }
805
- : {
806
- index: i,
807
- start: head.left,
808
- end: head.right,
809
- cross: head.top,
810
- span: tail.bottom - head.top
811
- });
839
+ const cross = Math.min(...all.map((b) => (row ? b.left : b.top)));
840
+ const far = Math.max(...all.map((b) => (row ? b.right : b.bottom)));
841
+ drag.lines.push(row
842
+ ? { index: i, start: own.top, end: own.bottom, cross, span: far - cross }
843
+ : { index: i, start: own.left, end: own.right, cross, span: far - cross });
812
844
  }
813
845
  }
814
846
  onGripUp = (event) => {
@@ -819,12 +851,12 @@ class TableIslandView {
819
851
  this.endDrag();
820
852
  if (!engaged)
821
853
  return;
822
- // A cancel is not a release: the line stays where it was, and no `click` follows
823
- // it, so arming the guard below would leave it to swallow the next press instead.
854
+ // A cancel is not a release: no `click` follows, so arming the guard would swallow the next
855
+ // press.
824
856
  if (event.type !== 'pointerup')
825
857
  return;
826
- // The press that ends a drag is not the press that selects: the click still to
827
- // come would re-select the line the drag just moved off.
858
+ // The press that ends a drag must not re-select the line it just moved off, so the
859
+ // following click is swallowed.
828
860
  this.suppressClick = true;
829
861
  if (drop !== line.index)
830
862
  this.moveLine(line, drop - line.index);
@@ -847,10 +879,10 @@ class TableIslandView {
847
879
  if (drag.grip.hasPointerCapture?.(drag.pointerId))
848
880
  drag.grip.releasePointerCapture(drag.pointerId);
849
881
  }
850
- /** Which line the pointer is over, along the drag's axis: the nearest by the extents
851
- * measured at engage. One dimension, because every cell of a row shares its top and
852
- * bottom and every cell of a column shares its left and right, so the cross-axis
853
- * term is identical across the candidates and cancels out of the comparison. */
882
+ /**
883
+ * Which line the pointer is over along the drag's axis: the nearest by the extents measured at
884
+ * engage. One dimension suffices: the cross-axis term is identical across the candidates.
885
+ */
854
886
  dropIndex(drag, at) {
855
887
  let best = drag.line.index;
856
888
  let nearest = Infinity;
@@ -864,13 +896,13 @@ class TableIslandView {
864
896
  return best;
865
897
  }
866
898
  boxesOf(line) {
867
- return this.cells
868
- .filter((m) => (line.axis === 'row' ? m.r === line.index : m.c === line.index))
869
- .map((m) => m.box);
899
+ const props = this.props();
900
+ return this.cells.filter((m) => touches(m, this.lineCells(line, props))).map((m) => m.box);
870
901
  }
871
- /** The drop indicator: one rule on the boundary the line would land against, drawn
872
- * in the frame so it spans the grid and travels with it. Redrawn only when the
873
- * boundary changes, which is once per line crossed rather than once per move. */
902
+ /**
903
+ * The drop indicator: one rule on the boundary the line would land against, redrawn only when
904
+ * the boundary changes.
905
+ */
874
906
  paintDrop(drag) {
875
907
  if (drag.drop === drag.painted)
876
908
  return;
@@ -898,21 +930,20 @@ class TableIslandView {
898
930
  }
899
931
  }
900
932
  // ── Column widths ─────────────────────────────────────────────────────────
901
- /** Size the grid's columns, or return it to auto-fit. A weighted grid spans the leaf
902
- * laid out fixed, as the page's fractional columns span the text block, so the
903
- * proportions it draws are the ones the page prints. */
933
+ /**
934
+ * Size the grid's columns, or return it to auto-fit. A weighted grid is laid out fixed so its
935
+ * proportions are the ones the page prints.
936
+ */
904
937
  weigh(widths) {
905
938
  this.frame?.toggleAttribute('data-weighted', widths !== undefined);
906
939
  this.cols.forEach((col, c) => (col.style.width = widths?.[c] ?? ''));
907
940
  }
908
941
  /**
909
- * The border after column `c`, in the band: a press and a drag move it, trading width
910
- * between the two columns it divides, and the release writes every column's measured
911
- * width as its weight, one `set` and one undo step. A double-click returns the table
912
- * to auto-fit.
942
+ * The border after column `c`, in the band: a press and a drag trade width between the two
943
+ * columns it divides, and the release writes every column's measured width as its weight, one
944
+ * `set` and one undo step. A double-click returns the table to auto-fit.
913
945
  *
914
- * Pointer chrome for the reason a grip is, and named to nothing: it is no button, and
915
- * a column's width has no keyboard route.
946
+ * Pointer chrome like a grip: it is no button and a column's width has no keyboard route.
916
947
  */
917
948
  resizer(c) {
918
949
  const handle = el('span', 'qm-table-resize');
@@ -929,11 +960,11 @@ class TableIslandView {
929
960
  if (event.button !== 0)
930
961
  return;
931
962
  this.endResize();
932
- const heads = this.cells.filter((m) => m.r === 0).sort((a, b) => a.c - b.c);
933
- const widths = heads.map((m) => m.box.getBoundingClientRect().width);
934
- // A cell's floor is its host's measure plus the box's own padding and border, read
935
- // off the header cell the border hangs from.
936
- const head = heads[c];
963
+ // Each column's width and a cell's floor, its host's measure plus the box's own
964
+ // padding and border, read off a cell standing in that column alone.
965
+ const alone = (x) => this.cells.find((m) => m.c === x && m.span.colspan === 1);
966
+ const widths = this.rails.map((rail, x) => (alone(x)?.box ?? rail).getBoundingClientRect().width);
967
+ const head = alone(c);
937
968
  const floor = head
938
969
  ? Number.parseFloat(getComputedStyle(head.host).minWidth) +
939
970
  head.box.getBoundingClientRect().width -
@@ -953,8 +984,10 @@ class TableIslandView {
953
984
  handle.addEventListener('pointerup', this.onResizeUp);
954
985
  handle.addEventListener('pointercancel', this.onResizeUp);
955
986
  };
956
- /** The widths a border at `x` leaves: the two columns it divides trade the travel,
957
- * each clamped at the floor, and every other column keeps the width it was pressed at. */
987
+ /**
988
+ * The widths a border at `x` leaves: the two columns it divides trade the travel, each clamped
989
+ * at the floor.
990
+ */
958
991
  resized(resize, x) {
959
992
  const { c, widths, floor } = resize;
960
993
  const left = widths[c] ?? 0;
@@ -1018,6 +1051,8 @@ class TableIslandView {
1018
1051
  this.near = [];
1019
1052
  this.clusters = [];
1020
1053
  this.headerCluster = undefined;
1054
+ this.cellCluster = undefined;
1055
+ this.rails = [];
1021
1056
  this.dom.textContent = '';
1022
1057
  const props = tablePropsOfNode(this.node);
1023
1058
  const departure = props && departures.get(props);
@@ -1034,31 +1069,23 @@ class TableIslandView {
1034
1069
  const s = this.deps.strings();
1035
1070
  this.dom.setAttribute('role', 'group');
1036
1071
  this.dom.setAttribute('aria-label', s.tableLabel);
1037
- // `thead`/`tbody` rather than one `tbody`: the header is a separate field in the
1038
- // model, and this is the markup that says so to something that cannot see the
1039
- // weight the header row draws. A headless table's first row is a body row on the
1040
- // page, so it is one here too, still holding row 0's chrome.
1072
+ // `thead`/`tbody`, since the header is a separate field in the model; a table with no
1073
+ // header row draws its first row as a body row, still holding row 0's chrome.
1041
1074
  const table = el('table', 'qm-table');
1042
1075
  const colgroup = el('colgroup');
1043
1076
  this.cols = Array.from({ length: columnCount(props) }, () => el('col'));
1044
- colgroup.append(...this.cols);
1045
- const first = this.row(props, 0, s);
1077
+ colgroup.append(el('col', 'qm-table-rail-col'), ...this.cols);
1078
+ const heads = Math.min(headRows(props), rowCount(props));
1079
+ const grid = spanGrid(props);
1046
1080
  const body = el('tbody');
1047
- if (props.headless) {
1048
- body.appendChild(first);
1049
- table.append(colgroup, body);
1050
- }
1051
- else {
1052
- const head = el('thead');
1053
- head.appendChild(first);
1054
- table.append(colgroup, head, body);
1055
- }
1056
- for (let r = 1; r < rowCount(props); r++)
1057
- body.appendChild(this.row(props, r, s));
1058
- // The frame is the grid's own box, and the two controls about an axis rather than
1059
- // about a line hang off its edges: an add bar along each trailing edge. A cell
1060
- // would have served for neither — a table with no body rows has no last row to
1061
- // hang the row bar in, and a bar spans the whole edge rather than one line of it.
1081
+ const head = heads ? el('thead') : body;
1082
+ head.appendChild(this.rail(props, s));
1083
+ for (let r = 0; r < rowCount(props); r++)
1084
+ (r < heads ? head : body).appendChild(this.row(props, grid, r, s));
1085
+ table.append(colgroup, ...(heads ? [head] : []), body);
1086
+ this.cellCluster = this.mergeCluster(s);
1087
+ // The frame is the grid's own box; the add bars hang off its trailing edges, since a table
1088
+ // with no body rows has no last row to hang the row bar in.
1062
1089
  const frame = el('div', 'qm-table-frame');
1063
1090
  frame.append(table, this.addBar('column', s.tableAddColumn), this.addBar('row', s.tableAddRow));
1064
1091
  this.frame = frame;
@@ -1070,25 +1097,20 @@ class TableIslandView {
1070
1097
  this.reseat(seat, departure);
1071
1098
  }
1072
1099
  /**
1073
- * Where the focus sits, in terms a rebuild can restore it by: the caret's cell by
1074
- * position, that being the one seat the island has and not an element that survives
1075
- * one.
1076
- *
1077
- * A rebuild is what a changed rectangle costs, and the op that changed it usually
1078
- * says where the caret lands ({@link TableIslandView.write}'s `focus`). An undo says
1079
- * nothing — it is the outer history's transaction, not an op of this view's — so
1080
- * without this the DOM under the focus is removed and the focus falls to the
1081
- * document body, where the next undo reaches no view at all.
1100
+ * Where the focus sits, in terms a rebuild can restore it by: the caret's cell by position. An
1101
+ * undo is the outer history's transaction, not an op of this view's, so without this the focus
1102
+ * falls to the document body when the DOM under it is removed.
1082
1103
  */
1083
1104
  focusedSeat() {
1084
1105
  const held = this.cells.find((m) => m.view.hasFocus());
1085
1106
  return held && { r: held.r, c: held.c, head: held.view.state.selection.head };
1086
1107
  }
1087
- /** Put the focus back after a rebuild: at the departure where the table returns to a
1088
- * value an edit was made from, which is an undo, else at the end of the cell
1089
- * {@link TableIslandView.focusedSeat} found, clamped by `focusCell` into the
1090
- * rectangle that is there now, the seat being perhaps a cell the rebuild removed. A
1091
- * caller that has a landing of its own overrides this by running after. */
1108
+ /**
1109
+ * Put the focus back after a rebuild: at the departure where the table returns to a value an
1110
+ * edit was made from (an undo), else at the end of the cell {@link TableIslandView.focusedSeat}
1111
+ * found, clamped into the rectangle now there. A caller with its own landing overrides this by
1112
+ * running after.
1113
+ */
1092
1114
  reseat(seat, departure) {
1093
1115
  if (!seat)
1094
1116
  return;
@@ -1097,50 +1119,92 @@ class TableIslandView {
1097
1119
  else
1098
1120
  this.focusCell(seat.r, seat.c);
1099
1121
  }
1100
- /** One table row: its cells, each carrying whatever chrome hangs off it. */
1101
- row(props, r, s) {
1102
- const heads = r === 0 && !props.headless;
1122
+ /**
1123
+ * The rail: a row of no height above the first, a cell per column holding each column's grip
1124
+ * and the border handle after it. The column chrome hangs here because a merged cell in the
1125
+ * first row stands over several columns.
1126
+ */
1127
+ rail(props, s) {
1128
+ const tr = el('tr', 'qm-table-rail');
1129
+ tr.setAttribute('aria-hidden', 'true');
1130
+ tr.appendChild(el('td', 'qm-table-rail-cell'));
1131
+ for (let c = 0; c < columnCount(props); c++) {
1132
+ const cell = el('td', 'qm-table-rail-cell');
1133
+ cell.appendChild(this.grip({ axis: 'column', index: c }, s.tableSelectColumn(c + 1)));
1134
+ if (c < columnCount(props) - 1)
1135
+ cell.appendChild(this.resizer(c));
1136
+ this.rails.push(cell);
1137
+ tr.appendChild(cell);
1138
+ }
1139
+ return tr;
1140
+ }
1141
+ /**
1142
+ * One table row: its drawn cells, each carrying its chrome, after a rail cell of no width
1143
+ * holding the row's grip. A slot a merged cell covers draws nothing.
1144
+ */
1145
+ row(props, grid, r, s) {
1146
+ const head = headRows(props);
1147
+ const heads = r < head;
1148
+ const cols = columnCount(props);
1149
+ const rows = rowCount(props);
1103
1150
  const tr = el('tr', heads ? 'qm-table-header-row' : undefined);
1104
- rowCells(props, r).forEach((cell, c) => {
1151
+ const rail = el('td', 'qm-table-rail-cell');
1152
+ rail.setAttribute('aria-hidden', 'true');
1153
+ // The header's grip takes a name of its own: the gesture is every other row's, but the line
1154
+ // is the header.
1155
+ rail.appendChild(this.grip({ axis: 'row', index: r }, heads ? s.tableSelectHeaderRow : s.tableSelectRow(r - head + 1)));
1156
+ tr.appendChild(rail);
1157
+ rowCells(props, r).forEach((_, c) => {
1158
+ const span = grid[r]?.[c];
1159
+ if (!span)
1160
+ return;
1105
1161
  const box = el(heads ? 'th' : 'td', 'qm-table-cell');
1106
1162
  if (heads)
1107
- box.setAttribute('scope', 'col');
1163
+ box.setAttribute('scope', span.colspan > 1 ? 'colgroup' : 'col');
1164
+ if (span.colspan > 1)
1165
+ box.setAttribute('colspan', String(span.colspan));
1166
+ if (span.rowspan > 1)
1167
+ box.setAttribute('rowspan', String(span.rowspan));
1108
1168
  box.setAttribute('data-r', String(r));
1109
1169
  box.setAttribute('data-c', String(c));
1110
- const align = props.aligns[c] ?? 'none';
1170
+ // Which edges of the frame the box meets, which the stroke and the corners read
1171
+ // (`prose.css`): a merged cell meets the edge its span reaches.
1172
+ const right = c + span.colspan === cols;
1173
+ const last = r + span.rowspan === rows;
1174
+ const corners = [
1175
+ r === 0 && c === 0 && 'start-start',
1176
+ r === 0 && right && 'start-end',
1177
+ last && c === 0 && 'end-start',
1178
+ last && right && 'end-end'
1179
+ ].filter((corner) => !!corner);
1180
+ if (right)
1181
+ box.setAttribute('data-edge-end', '');
1182
+ if (last && rows > Math.min(head, rows))
1183
+ box.setAttribute('data-edge-bottom', '');
1184
+ if (corners.length)
1185
+ box.setAttribute('data-corners', corners.join(' '));
1111
1186
  layCell(box, props, r, c);
1112
1187
  const host = el('div', 'qm-table-cell-host');
1113
1188
  box.appendChild(host);
1114
- // The column band hangs off the header row; the row band off every row's first
1115
- // cell, the header's included. Both are absolutely positioned into the frame's
1116
- // own padding, so neither is in the grid's layout and neither is a cell of its
1117
- // own, and the two the header's first cell carries hang off perpendicular edges
1118
- // and meet at no point. The header takes a grip because a row grip acts on a
1119
- // row and the header is one: it selects, it deletes, and it drags, all by the
1120
- // rules every other row is under.
1189
+ // The first row's cells hold the column clusters, each under the drawn cell over its
1190
+ // column, and the header toggle under the last.
1121
1191
  if (r === 0)
1122
- box.append(this.grip({ axis: 'column', index: c }, s.tableSelectColumn(c + 1)), this.alignCluster(c, align, s));
1123
- if (r === 0 && c < columnCount(props) - 1)
1124
- box.appendChild(this.resizer(c));
1125
- if (c === 0)
1126
- box.appendChild(this.grip({ axis: 'row', index: r }, heads ? s.tableSelectHeaderRow : s.tableSelectRow(props.headless ? r + 1 : r)));
1127
- if (r === 0 && c === columnCount(props) - 1)
1128
- box.appendChild(this.headerToggle(!props.headless, s));
1192
+ for (let x = c; x < c + span.colspan; x++)
1193
+ box.appendChild(this.alignCluster(x, props.aligns[x] ?? 'none', s));
1194
+ if (r === 0 && right)
1195
+ box.appendChild(this.headerToggle(head > 0, s));
1129
1196
  tr.appendChild(box);
1130
- this.mountCell(box, host, r, c, s);
1197
+ this.mountCell(box, host, r, c, span, s);
1131
1198
  });
1132
1199
  return tr;
1133
1200
  }
1134
- /** A line's grip, and the whole of that line's chrome: a press selects the line, a
1135
- * press that travels drags it. Both are "this line", asked once with the pointer,
1136
- * and the dead zone is what tells them apart.
1137
- *
1138
- * Pointer chrome and nothing else: no key binds here and no route focuses it, the
1139
- * line verbs binding in the cell the selection runs through
1140
- * (§{@link TableIslandView.cellKeys}). Out of the tab order at no cost in reach,
1141
- * since a grip follows the cell it hangs in: a forward Tab is the cell traversal's
1142
- * before the browser gets there, and a backward one leaves the grid off cell (0,0)
1143
- * without passing a grip (§{@link TableIslandView.step}). */
1201
+ /**
1202
+ * A line's grip, the whole of that line's chrome: a press selects the line, a press that
1203
+ * travels drags it. Pointer chrome only: no key binds here, the line verbs binding in the cell
1204
+ * the selection runs through (§{@link TableIslandView.cellKeys}). Out of the tab order at no
1205
+ * cost in reach: a forward Tab is the cell traversal's, and a backward one leaves the grid off
1206
+ * cell (0,0) without passing a grip (§{@link TableIslandView.step}).
1207
+ */
1144
1208
  grip(line, label) {
1145
1209
  const btn = chromeButton('qm-table-grip', label, () => {
1146
1210
  if (this.suppressClick)
@@ -1158,12 +1222,13 @@ class TableIslandView {
1158
1222
  this.grips.set(lineKey(line), { line, grip: btn });
1159
1223
  return btn;
1160
1224
  }
1161
- /** A column's alignment cluster: a toggle per alignment, pressed on the one the column
1162
- * holds. Up only while this column is the named line
1163
- * ({@link TableIslandView.paintSelection}), and absent rather than transparent the
1164
- * rest of the time: it floats over the first row, and a box hit-tested there at rest
1165
- * would take that cell's presses. Out of the tab order for the reason a grip is, its
1166
- * keyboard twins being Shift+Left and Shift+Right (§{@link TableIslandView.cellKeys}). */
1225
+ /**
1226
+ * A column's alignment cluster: a toggle per alignment, pressed on the one the column holds. Up
1227
+ * only while this column is the named line ({@link TableIslandView.paintSelection}), absent
1228
+ * rather than transparent, since it floats over the first row and would take that cell's
1229
+ * presses. Out of the tab order; Shift+Left and Shift+Right are its keyboard twins (§{@link
1230
+ * TableIslandView.cellKeys}).
1231
+ */
1167
1232
  alignCluster(c, held, s) {
1168
1233
  const cluster = el('div', 'qm-table-align');
1169
1234
  cluster.setAttribute('role', 'group');
@@ -1188,17 +1253,17 @@ class TableIslandView {
1188
1253
  this.clusters[c] = cluster;
1189
1254
  return cluster;
1190
1255
  }
1191
- /** The first row's header toggle: one press draws the row as the header or as a body
1192
- * row, writing the table's `headless` as one `set`, and the row stays held. Up only
1193
- * while the first row is held, hanging under its last cell, and out of the tab order
1194
- * for the reason a grip is. */
1256
+ /**
1257
+ * The first row's header toggle: one press draws the row as the header or as a body row,
1258
+ * writing `head` as one `set`, and the row stays held. Up only while the first row is held.
1259
+ */
1195
1260
  headerToggle(header, s) {
1196
1261
  const cluster = el('div', 'qm-table-align');
1197
1262
  cluster.setAttribute('data-header', '');
1198
1263
  cluster.hidden = true;
1199
1264
  cluster.addEventListener('mousedown', (e) => e.preventDefault());
1200
1265
  const btn = chromeButton('qm-table-align-option', s.tableHeaderToggle, () => {
1201
- this.write(setHeadless(this.props(), header));
1266
+ this.write(setHead(this.props(), header ? 0 : 1));
1202
1267
  this.selectLine({ axis: 'row', index: 0 });
1203
1268
  });
1204
1269
  btn.tabIndex = -1;
@@ -1208,16 +1273,34 @@ class TableIslandView {
1208
1273
  this.headerCluster = cluster;
1209
1274
  return cluster;
1210
1275
  }
1211
- /** A trailing bar: the whole edge past the last line of its axis, and the one way a
1212
- * pointer grows the table. It spans the edge rather than capping it, because what it
1213
- * appends to is the axis and not a line. It draws no glyph — the bar arriving under
1214
- * the pointer out past the last line is the claim — so its name carries the verb for
1215
- * everything that does not read position.
1216
- *
1217
- * Out of the tab order, as a grip is: the Tab that declines off the last cell leaves
1218
- * the island rather than landing on two controls drawn only under the pointer. A row
1219
- * is the keyboard's from inside a cell, by Enter at the last row and Tab past the
1220
- * last cell, and a column is the pointer's alone. */
1276
+ /**
1277
+ * The merge and split cluster, one for the grid, hung by {@link
1278
+ * TableIslandView.paintCellCluster} where it applies.
1279
+ */
1280
+ mergeCluster(s) {
1281
+ const cluster = el('div', 'qm-table-align');
1282
+ cluster.setAttribute('data-cells', '');
1283
+ cluster.setAttribute('role', 'group');
1284
+ cluster.hidden = true;
1285
+ cluster.addEventListener('mousedown', (e) => e.preventDefault());
1286
+ for (const [label, run] of [
1287
+ [s.tableMergeCells, () => this.merge()],
1288
+ [s.tableSplitCell, () => this.split()]
1289
+ ]) {
1290
+ const btn = chromeButton('qm-table-cells-option', label, run);
1291
+ btn.tabIndex = -1;
1292
+ btn.textContent = label;
1293
+ cluster.appendChild(btn);
1294
+ }
1295
+ return cluster;
1296
+ }
1297
+ /**
1298
+ * A trailing bar: the whole edge past the last line of its axis, the one way a pointer grows
1299
+ * the table. It draws no glyph, so its name carries the verb. Out of the tab order: the Tab
1300
+ * that declines off the last cell leaves the island rather than landing on controls drawn only
1301
+ * under the pointer. A row is the keyboard's by Enter at the last row and Tab past the last
1302
+ * cell; a column is the pointer's alone.
1303
+ */
1221
1304
  addBar(axis, label) {
1222
1305
  const btn = chromeButton('qm-table-add', label, () => {
1223
1306
  const props = this.props();
@@ -1231,9 +1314,10 @@ class TableIslandView {
1231
1314
  btn.appendChild(el('span', 'qm-table-add-bar'));
1232
1315
  return btn;
1233
1316
  }
1234
- mountCell(box, host, r, c, s) {
1317
+ mountCell(box, host, r, c, span, s) {
1235
1318
  const props = this.props();
1236
- const row = props.headless ? s.tableRow(r + 1) : r === 0 ? s.tableHeaderRow : s.tableRow(r);
1319
+ const head = headRows(props);
1320
+ const row = r < head ? s.tableHeaderRow : s.tableRow(r - head + 1);
1237
1321
  const name = s.tableCell(row, s.tableColumn(c + 1));
1238
1322
  const seed = cellAt(props, r, c);
1239
1323
  const view = new EditorView(host, {
@@ -1242,9 +1326,8 @@ class TableIslandView {
1242
1326
  plugins: cellPlugins(this.cellKeys(r, c))
1243
1327
  }),
1244
1328
  attributes: { 'aria-label': name, class: 'qm-table-cell-editor' },
1245
- // A cell is one of the leaf's lines, so a reveal here keeps the leaf's
1246
- // clearance: PM's 5px default is the caret visible and unusable that rung
1247
- // exists to refuse (`field.ts`).
1329
+ // A cell is one of the leaf's lines, so a reveal keeps the leaf's clearance, not PM's
1330
+ // 5px default (`field.ts`).
1248
1331
  scrollThreshold: this.deps.clearance,
1249
1332
  scrollMargin: this.deps.clearance,
1250
1333
  dispatchTransaction: (tr) => {
@@ -1253,10 +1336,9 @@ class TableIslandView {
1253
1336
  view.updateState(next);
1254
1337
  if (!tr.docChanged)
1255
1338
  return;
1256
- // A cell edit is the caret's, so it retires the rectangle: a wash left up
1257
- // over text being typed says the next Backspace takes a rank, and it does
1258
- // not. The clear op is not this path — it writes through the leaf, and the
1259
- // reseed it comes back as changes no cell's own doc.
1339
+ // A cell edit is the caret's, so it retires the rectangle: a wash left over typed
1340
+ // text implies the next Backspace takes a rank. The clear op writes through the
1341
+ // leaf and does not take this path.
1260
1342
  this.clearSelection();
1261
1343
  const now = this.props();
1262
1344
  const cell = cellFromDoc(next.doc, cellAt(now, r, c));
@@ -1267,8 +1349,13 @@ class TableIslandView {
1267
1349
  focus: () => {
1268
1350
  this.clearSelection();
1269
1351
  this.retireIsland();
1352
+ this.paintCellCluster();
1270
1353
  this.deps.onCellFocus();
1271
1354
  return false;
1355
+ },
1356
+ blur: () => {
1357
+ queueMicrotask(() => this.paintCellCluster());
1358
+ return false;
1272
1359
  }
1273
1360
  }
1274
1361
  });
@@ -1281,21 +1368,20 @@ class TableIslandView {
1281
1368
  shown: seed,
1282
1369
  unregister: this.deps.register(view),
1283
1370
  r,
1284
- c
1371
+ c,
1372
+ span
1285
1373
  };
1286
1374
  this.cells.push(mounted);
1287
1375
  }
1288
1376
  // ── Ops ───────────────────────────────────────────────────────────────────
1289
- /** The table this view is currently showing; the zero table if the node stopped
1290
- * being one, which no op can produce. */
1377
+ /** The table this view is currently showing; the zero table if the node stopped being one. */
1291
1378
  props() {
1292
1379
  return tablePropsOfNode(this.node) ?? normalizeTable({ header: [], rows: [], aligns: [] });
1293
1380
  }
1294
1381
  /**
1295
- * Commit a new rectangle onto the node. `setNodeMarkup` is an ordinary PM
1296
- * transaction, so the field lowers it through the island channel and the whole
1297
- * op is one commit and one undo step, which is why every op writes a whole
1298
- * table rather than mutating the props in place.
1382
+ * Commit a new rectangle onto the node. `setNodeMarkup` is an ordinary PM transaction, so the
1383
+ * field lowers it through the island channel: one commit, one undo step, which is why every op
1384
+ * writes a whole table.
1299
1385
  */
1300
1386
  write(next, focus, from = this.focusedSeat()) {
1301
1387
  const pos = this.getPos();
@@ -1314,14 +1400,15 @@ class TableIslandView {
1314
1400
  if (focus)
1315
1401
  this.focusCell(focus.r, focus.c);
1316
1402
  }
1317
- /** Land the caret at the end of a cell, at its start, or at an offset clamped into its
1318
- * text, the cell itself clamped into the rectangle: where a row or column op puts it,
1319
- * since the op rebuilt the views the caret was in. */
1403
+ /**
1404
+ * Land the caret at the end of a cell, at its start, or at an offset clamped into its text, the
1405
+ * cell clamped into the rectangle: where a row or column op puts it after rebuilding the views.
1406
+ */
1320
1407
  focusCell(r, c, edge = 'end') {
1321
1408
  const props = this.props();
1322
1409
  const row = Math.max(0, Math.min(r, rowCount(props) - 1));
1323
1410
  const col = Math.max(0, Math.min(c, columnCount(props) - 1));
1324
- const mounted = this.cells.find((m) => m.r === row && m.c === col);
1411
+ const mounted = this.cells.find((m) => covers(m, row, col));
1325
1412
  if (!mounted)
1326
1413
  return;
1327
1414
  const { view } = mounted;
@@ -1331,31 +1418,38 @@ class TableIslandView {
1331
1418
  : edge === 'end'
1332
1419
  ? Selection.atEnd(doc)
1333
1420
  : Selection.near(doc.resolve(Math.min(edge, doc.content.size)));
1334
- // Flagged, for the reason the leaf's own landing is (`field.ts`, `setCaret`): PM
1335
- // focuses with `preventScroll`, so an unflagged dispatch lands the caret where no
1336
- // scroller has moved to — a Tab past the right edge of the horizontal scroller a
1337
- // wide table lives in, or the row an Enter on the last one appends below the fold.
1421
+ // Flagged like the leaf's own landing (`field.ts`, `setCaret`): PM focuses with
1422
+ // `preventScroll`, so an unflagged dispatch leaves the caret off-screen in a wide table's
1423
+ // scroller or below the fold.
1338
1424
  view.focus();
1339
1425
  view.dispatch(view.state.tr.setSelection(at).scrollIntoView());
1340
1426
  }
1427
+ /** The span of the drawn cell at `(r, c)`: one each where nothing is merged. */
1428
+ spanAt(r, c) {
1429
+ return this.cells.find((m) => m.r === r && m.c === c)?.span ?? { colspan: 1, rowspan: 1 };
1430
+ }
1341
1431
  /** The cell beside `(r, c)` in reading order, a row's end wrapping to the next row's
1342
1432
  * start; past the first or last cell its row is outside the table. */
1343
1433
  reading(r, c, dir) {
1344
- const cols = columnCount(this.props());
1345
- const next = c + dir;
1346
- if (next >= cols)
1347
- return { r: r + 1, c: 0 };
1348
- if (next < 0)
1349
- return { r: r - 1, c: cols - 1 };
1350
- return { r, c: next };
1434
+ const props = this.props();
1435
+ const cols = columnCount(props);
1436
+ const grid = spanGrid(props);
1437
+ let at = { r, c };
1438
+ do {
1439
+ const next = at.c + dir;
1440
+ at =
1441
+ next >= cols
1442
+ ? { r: at.r + 1, c: 0 }
1443
+ : next < 0
1444
+ ? { r: at.r - 1, c: cols - 1 }
1445
+ : { r: at.r, c: next };
1446
+ } while (at.r >= 0 && at.r < rowCount(props) && !grid[at.r]?.[at.c]);
1447
+ return at;
1351
1448
  }
1352
1449
  /**
1353
- * A cell's keys. Traversal is the island's link in the leaf's chain
1354
- * (VISUAL_EDITOR §Chrome), except that it binds on the nested view: the outer
1355
- * keymap never sees a keystroke a cell handled (`stopEvent`).
1356
- *
1357
- * Enter is the next row. Shift-Enter is a line break, the body's own command
1358
- * (`breaks.ts`): one `\n` in the cell's `text`.
1450
+ * A cell's keys. Traversal binds on the nested view, since the outer keymap never sees a
1451
+ * keystroke a cell handled (`stopEvent`; VISUAL_EDITOR §Chrome). Enter is the next row;
1452
+ * Shift-Enter is the body's line break (`breaks.ts`).
1359
1453
  */
1360
1454
  cellKeys(r, c) {
1361
1455
  const marks = {};
@@ -1365,12 +1459,13 @@ class TableIslandView {
1365
1459
  marks['Mod-i'] = toggleMark(cellSchema.marks.em);
1366
1460
  if (cellSchema.marks.underline)
1367
1461
  marks['Mod-u'] = toggleMark(cellSchema.marks.underline);
1368
- // The arrows are the grid's own walk, each leaving a cell only from the text's edge
1369
- // on its side. Up and down reach the cell above or below, `focusCell` clamping at
1370
- // the top and bottom. Left and right reach the neighbour in reading order, landing
1371
- // at the edge they entered by and stopping at the first and last cells. No arrow
1372
- // grows the table, growth being Tab's and Enter's: on a caret key it would make a
1373
- // walk a growth affordance.
1462
+ if (cellSchema.marks.strike)
1463
+ marks['Mod-Shift-x'] = toggleMark(cellSchema.marks.strike);
1464
+ if (cellSchema.marks.code)
1465
+ marks['Mod-e'] = toggleMark(cellSchema.marks.code);
1466
+ // The arrows walk the grid, each leaving a cell only from the text's edge on its side. Up
1467
+ // and down clamp at the top and bottom; left and right follow reading order and stop at the
1468
+ // first and last cells. No arrow grows the table.
1374
1469
  const caretAtEdge = (view, dir) => {
1375
1470
  const { selection } = view?.state ?? {};
1376
1471
  return (!!view && selection instanceof TextSelection && selection.empty && view.endOfTextblock(dir));
@@ -1379,7 +1474,7 @@ class TableIslandView {
1379
1474
  return (_state, _dispatch, view) => {
1380
1475
  if (!caretAtEdge(view, dir))
1381
1476
  return false;
1382
- this.focusCell(dir === 'up' ? r - 1 : r + 1, c);
1477
+ this.focusCell(dir === 'up' ? r - 1 : r + this.spanAt(r, c).rowspan, c);
1383
1478
  return true;
1384
1479
  };
1385
1480
  };
@@ -1394,25 +1489,19 @@ class TableIslandView {
1394
1489
  return true;
1395
1490
  };
1396
1491
  };
1397
- // A block selection outranks the caret it was swept from: the origin cell still
1398
- // holds the focus, so its own view is where the block's Backspace lands. Declining
1399
- // when no block is held is what leaves an ordinary Backspace to `baseKeymap`.
1492
+ // A block selection outranks the caret it was swept from; declining when none is held
1493
+ // leaves an ordinary Backspace to `baseKeymap`.
1400
1494
  const erase = () => {
1401
1495
  if (!this.selected)
1402
1496
  return false;
1403
1497
  this.deleteSelection();
1404
1498
  return true;
1405
1499
  };
1406
- // The line verbs, over the held rectangle and nothing else: with none held every
1407
- // one of these declines and the key is the caret's, which is what lets them sit on
1408
- // keys a cell is already typing under. An arrow over swept cells lets go of them,
1409
- // leaving the caret where it is, as an arrow collapses a text selection. Over named
1410
- // lines it names the line on its own axis through the caret — the one already held
1411
- // steps to its neighbour and carries the caret, and any other turns into it, which
1412
- // is how a row becomes the column the caret is in. Alt moves the line instead, and
1413
- // over a rectangle that is not one it does nothing but keep the key: while a
1414
- // rectangle is held every arrow is its own, and an Alt+arrow handed back is the
1415
- // browser's Back on two platforms, mid-gesture.
1500
+ // The line verbs act on the held rectangle only; with none held they decline and the key is
1501
+ // the caret's. An arrow over swept cells lets go of them. Over named lines it names the
1502
+ // line on its own axis through the caret: the held one steps to its neighbour, any other
1503
+ // turns into it. Alt moves the line, and over a non-line rectangle keeps the key: every
1504
+ // arrow is the rectangle's while held, and a handed-back Alt+arrow is the browser's Back.
1416
1505
  const line = (axis, by, move) => {
1417
1506
  return () => {
1418
1507
  if (!this.selected)
@@ -1436,9 +1525,8 @@ class TableIslandView {
1436
1525
  return true;
1437
1526
  };
1438
1527
  };
1439
- // Shift steps a held column's alignment a place along the cluster's order, stopping
1440
- // at either end, and a column with none steps from the first. Over any other
1441
- // rectangle it keeps the key, every arrow being the rectangle's while one is held.
1528
+ // Shift steps a held column's alignment along the cluster's order, stopping at either end;
1529
+ // over any other rectangle it keeps the key.
1442
1530
  const slide = (by) => {
1443
1531
  return () => {
1444
1532
  if (!this.selected)
@@ -1452,9 +1540,8 @@ class TableIslandView {
1452
1540
  return true;
1453
1541
  };
1454
1542
  };
1455
- // Select-all grows a rung a press: the cell's text, which the base keymap's
1456
- // `selectAll` takes, then the caret's row, then every cell, then the document. Any
1457
- // held rectangle short of every cell grows to every cell.
1543
+ // Select-all grows a rung a press: the cell's text, the caret's row, every cell, the
1544
+ // document. A held rectangle short of every cell grows to every cell.
1458
1545
  const grow = (state) => {
1459
1546
  const props = this.props();
1460
1547
  const every = {
@@ -1483,8 +1570,7 @@ class TableIslandView {
1483
1570
  return {
1484
1571
  ...marks,
1485
1572
  ...breakKeymap(cellSchema),
1486
- // One undo stack per leaf: a cell carries no history of its own, so Mod-z
1487
- // unwinds a cell keystroke and a row op in the order they happened.
1573
+ // One undo stack per leaf: Mod-z unwinds a cell keystroke and a row op in order.
1488
1574
  'Mod-z': () => undo(this.outer.state, this.outer.dispatch),
1489
1575
  'Mod-y': () => redo(this.outer.state, this.outer.dispatch),
1490
1576
  'Shift-Mod-z': () => redo(this.outer.state, this.outer.dispatch),
@@ -1504,30 +1590,26 @@ class TableIslandView {
1504
1590
  Tab: () => this.step(r, c, 1),
1505
1591
  'Shift-Tab': () => this.step(r, c, -1),
1506
1592
  Enter: () => {
1507
- // Over a rectangle it hands the caret back, which is the line verbs' own
1508
- // exit: the caret never left the cell, so there is nowhere else to put it.
1593
+ // Over a rectangle it hands the caret back: the caret never left the cell.
1509
1594
  if (this.selected) {
1510
1595
  this.clearSelection();
1511
1596
  return true;
1512
1597
  }
1513
- // Past the last row it appends one, and on an empty trailing row it refuses the
1514
- // row on offer, as Tab's decline does and an empty item's Enter leaves a list:
1515
- // the row goes and the caret lands past the table. Row 1 is never the one
1516
- // refused, so the table keeps a row under its first.
1598
+ // Past the last row it appends one; on an empty trailing row it refuses the row on
1599
+ // offer, as Tab's decline does and an empty item's Enter leaves a list. Row 1 is
1600
+ // never refused.
1517
1601
  const props = this.props();
1518
- if (r < rowCount(props) - 1)
1519
- this.focusCell(r + 1, c);
1602
+ const below = r + this.spanAt(r, c).rowspan;
1603
+ if (below < rowCount(props))
1604
+ this.focusCell(below, c);
1520
1605
  else if (r > 1 && rowEmpty(props, r) && this.leave(1))
1521
1606
  this.write(deleteRow(props, r));
1522
1607
  else
1523
- this.write(insertRow(props, r), { r: r + 1, c });
1608
+ this.write(insertRow(props, below - 1), { r: below, c });
1524
1609
  return true;
1525
1610
  },
1526
- // Escape climbs a rung a press: the caret's own row, then the island. The row
1527
- // is the entry the band has no other route to — every line verb reads a held
1528
- // rectangle, and this is the gesture that draws one from the keyboard — and it
1529
- // is the row rather than the column because the column is one arrow further
1530
- // on. What the press past the island means is the shell's
1611
+ // Escape climbs a rung a press: the caret's row (the keyboard's only route to a held
1612
+ // rectangle), then the island. What a press past the island means is the shell's
1531
1613
  // (VISUAL_EDITOR §"Settled and open").
1532
1614
  Escape: () => {
1533
1615
  if (this.selected)
@@ -1539,16 +1621,10 @@ class TableIslandView {
1539
1621
  };
1540
1622
  }
1541
1623
  /**
1542
- * Tab's traversal: the next (or previous) cell in reading order. Past the last cell it
1543
- * appends a row, which is the growth affordance the keyboard has.
1544
- *
1545
- * It declines at both ends, and that is the island's keyboard exit: the key is not
1546
- * swallowed, so the browser moves the focus out of the grid the way it moved it in.
1547
- * Backward that end is the first cell. Forward it is the last cell of an empty
1548
- * trailing row: a row is on offer, and walking off the end of an unwritten one
1549
- * refuses it, the reading an empty item's Enter takes in a list (`lists.ts`). Growth
1550
- * that never declined leaves Tab no forward exit at all, every press past the last
1551
- * cell appending.
1624
+ * Tab's traversal: the next (or previous) cell in reading order, appending a row past the last
1625
+ * cell. It declines at both ends, which is the island's keyboard exit: backward at the first
1626
+ * cell, forward at the last cell of an empty trailing row (the reading an empty item's Enter
1627
+ * takes in a list, `lists.ts`). Growth that never declined would leave Tab no forward exit.
1552
1628
  */
1553
1629
  step(r, c, dir) {
1554
1630
  const props = this.props();
@@ -1556,9 +1632,10 @@ class TableIslandView {
1556
1632
  if (to.r < 0)
1557
1633
  return false;
1558
1634
  if (to.r >= rowCount(props)) {
1559
- if (rowEmpty(props, r))
1635
+ const last = rowCount(props) - 1;
1636
+ if (rowEmpty(props, last))
1560
1637
  return false;
1561
- this.write(insertRow(props, r), to);
1638
+ this.write(insertRow(props, last), { r: last + 1, c: 0 });
1562
1639
  }
1563
1640
  else
1564
1641
  this.focusCell(to.r, to.c);