@quillmark/svelte 0.2.0 → 0.3.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 (116) hide show
  1. package/README.md +2 -2
  2. package/THEMING.md +11 -7
  3. package/dist/core/address.d.ts +7 -7
  4. package/dist/core/address.js +6 -6
  5. package/dist/core/bloom.d.ts +5 -5
  6. package/dist/core/bloom.js +8 -8
  7. package/dist/core/codec/anchor.d.ts +1 -1
  8. package/dist/core/codec/anchor.js +1 -1
  9. package/dist/core/codec/atoms.d.ts +10 -0
  10. package/dist/core/codec/atoms.js +78 -0
  11. package/dist/core/codec/clipboard.d.ts +59 -0
  12. package/dist/core/codec/clipboard.js +202 -0
  13. package/dist/core/codec/code.d.ts +1 -1
  14. package/dist/core/codec/code.js +4 -4
  15. package/dist/core/codec/decode.d.ts +1 -1
  16. package/dist/core/codec/decode.js +8 -8
  17. package/dist/core/codec/encode.d.ts +4 -4
  18. package/dist/core/codec/encode.js +10 -10
  19. package/dist/core/codec/field.d.ts +21 -26
  20. package/dist/core/codec/field.js +33 -32
  21. package/dist/core/codec/index.js +5 -8
  22. package/dist/core/codec/inputrules.js +56 -18
  23. package/dist/core/codec/islands.d.ts +11 -7
  24. package/dist/core/codec/islands.js +16 -10
  25. package/dist/core/codec/keymap.d.ts +5 -6
  26. package/dist/core/codec/keymap.js +8 -7
  27. package/dist/core/codec/lists.d.ts +3 -3
  28. package/dist/core/codec/lists.js +32 -13
  29. package/dist/core/codec/marks.d.ts +1 -1
  30. package/dist/core/codec/marks.js +1 -1
  31. package/dist/core/codec/positions.js +1 -1
  32. package/dist/core/codec/prose.css +230 -215
  33. package/dist/core/codec/schema.js +4 -4
  34. package/dist/core/codec/slash.d.ts +39 -41
  35. package/dist/core/codec/slash.js +0 -0
  36. package/dist/core/codec/table-view.d.ts +12 -16
  37. package/dist/core/codec/table-view.js +488 -292
  38. package/dist/core/codec/table.d.ts +42 -15
  39. package/dist/core/codec/table.js +70 -26
  40. package/dist/core/errors.d.ts +5 -5
  41. package/dist/core/index.js +2 -2
  42. package/dist/core/lifecycle.js +1 -1
  43. package/dist/core/rebind.svelte.d.ts +2 -2
  44. package/dist/core/rebind.svelte.js +4 -4
  45. package/dist/core/teardown.d.ts +1 -1
  46. package/dist/core/teardown.js +1 -1
  47. package/dist/core/theme.css +144 -54
  48. package/dist/preset/index.css +1 -1
  49. package/dist/preset/recipes.css +31 -29
  50. package/dist/preset/scale.css +36 -25
  51. package/dist/preview/Preview.svelte +12 -10
  52. package/dist/preview/Preview.svelte.d.ts +3 -3
  53. package/dist/preview/bridge.js +4 -4
  54. package/dist/preview/controller.js +3 -3
  55. package/dist/preview/geometry.d.ts +2 -2
  56. package/dist/preview/geometry.js +2 -2
  57. package/dist/preview/index.js +3 -3
  58. package/dist/preview/overlay.d.ts +1 -1
  59. package/dist/preview/overlay.js +3 -3
  60. package/dist/preview/paint.js +15 -17
  61. package/dist/visual/ArrayField.svelte +80 -66
  62. package/dist/visual/ArrayField.svelte.d.ts +1 -1
  63. package/dist/visual/BooleanField.svelte +8 -6
  64. package/dist/visual/BooleanField.svelte.d.ts +2 -2
  65. package/dist/visual/Card.svelte +151 -325
  66. package/dist/visual/Card.svelte.d.ts +2 -6
  67. package/dist/visual/CardControls.svelte +1 -1
  68. package/dist/visual/DateField.svelte +19 -19
  69. package/dist/visual/DateField.svelte.d.ts +3 -3
  70. package/dist/visual/DiagnosticList.svelte +1 -1
  71. package/dist/visual/EnumField.svelte +16 -16
  72. package/dist/visual/EnumField.svelte.d.ts +2 -2
  73. package/dist/visual/Field.svelte +128 -115
  74. package/dist/visual/Field.svelte.d.ts +1 -1
  75. package/dist/visual/FieldHint.svelte +24 -42
  76. package/dist/visual/FieldHint.svelte.d.ts +1 -1
  77. package/dist/visual/FieldLabel.svelte +8 -8
  78. package/dist/visual/FormatPopover.svelte +80 -35
  79. package/dist/visual/NumberField.svelte +5 -5
  80. package/dist/visual/NumberField.svelte.d.ts +1 -1
  81. package/dist/visual/ObjectField.svelte +9 -9
  82. package/dist/visual/ObjectField.svelte.d.ts +1 -1
  83. package/dist/visual/ProseArrayElement.svelte +11 -13
  84. package/dist/visual/ProseArrayElement.svelte.d.ts +1 -1
  85. package/dist/visual/ProseField.svelte +53 -84
  86. package/dist/visual/ProseField.svelte.d.ts +12 -17
  87. package/dist/visual/SlashMenu.svelte +42 -16
  88. package/dist/visual/TextField.svelte +5 -5
  89. package/dist/visual/TextField.svelte.d.ts +1 -1
  90. package/dist/visual/TipsCard.svelte +43 -28
  91. package/dist/visual/VisualEditor.svelte +6 -6
  92. package/dist/visual/VisualEditorInner.svelte +122 -129
  93. package/dist/visual/controls.css +74 -30
  94. package/dist/visual/diagnostics.d.ts +5 -5
  95. package/dist/visual/diagnostics.js +3 -3
  96. package/dist/visual/domid.js +3 -3
  97. package/dist/visual/ext.d.ts +1 -1
  98. package/dist/visual/ext.js +1 -1
  99. package/dist/visual/icons/nodes.d.ts +3 -0
  100. package/dist/visual/icons/nodes.js +2 -1
  101. package/dist/visual/index.js +4 -4
  102. package/dist/visual/leaves.d.ts +3 -3
  103. package/dist/visual/leaves.js +1 -1
  104. package/dist/visual/links.d.ts +24 -0
  105. package/dist/visual/links.js +67 -0
  106. package/dist/visual/props.d.ts +8 -8
  107. package/dist/visual/signals.d.ts +6 -6
  108. package/dist/visual/strings.d.ts +14 -19
  109. package/dist/visual/strings.js +5 -7
  110. package/dist/visual/structure.d.ts +24 -17
  111. package/dist/visual/structure.js +8 -8
  112. package/dist/visual/synced.svelte.d.ts +1 -1
  113. package/dist/visual/synced.svelte.js +4 -4
  114. package/dist/visual/tips.d.ts +2 -2
  115. package/dist/visual/tips.js +3 -3
  116. package/package.json +1 -1
@@ -3,36 +3,33 @@
3
3
  // it renders is PM's own: a leaf node's substitute DOM, holding one nested
4
4
  // `EditorView` per cell.
5
5
  //
6
- // A cell is a second content unit inside the first, so it gets the codec's INLINE mode
6
+ // A cell is a second content unit inside the first, so it gets the codec's inline mode
7
7
  // (one paragraph, no containers, no islands, marks and input rules intact);
8
8
  // `table.ts` owns that translation. A cell edit does not touch the field's text:
9
9
  // the projection goes back onto the node's `props` attribute with `setNodeMarkup`,
10
10
  // and the field's own `dispatchTransaction` lowers that to an `islandOps` `set`
11
11
  // (CODEC §Encode), which is what keeps every anchor in the field.
12
12
  //
13
- // The nested views are NOT the field's: they carry no history (Mod-z routes to the
13
+ // The nested views are not the field's: they carry no history (Mod-z routes to the
14
14
  // field's, so one undo stack covers the leaf), no anchor plugin (an anchor in a cell
15
15
  // is preserved, never minted), and no placeholder.
16
16
  //
17
- // THE CHROME OCCUPIES NO LAYOUT: every control is absolutely positioned inside a real
18
- // data cell, so a column's handle lives in its own `th` and a row's in that row's
19
- // first `td`, and a handle lines up with its line by being inside it. What that buys
20
- // and what the band costs are `codec/prose.css`'s, which draws them.
21
- //
22
- // A POINTER PRESS RESOLVES TO A CARET, always, except on a handle, which selects a
23
- // LINE (CODEC §"The table island").
17
+ // The chrome is A band and A selection (CODEC §"The table island"), and it raises
18
+ // nothing. Every control is absolutely positioned out of the grid, so the band is in no
19
+ // row and no column of it; `codec/prose.css` draws the band and the selection wash both.
24
20
  import { baseKeymap, toggleMark } from 'prosemirror-commands';
25
21
  import { redo, undo } from 'prosemirror-history';
26
22
  import { keymap } from 'prosemirror-keymap';
27
23
  import { EditorState, NodeSelection, Selection, TextSelection } from 'prosemirror-state';
28
24
  import { EditorView } from 'prosemirror-view';
25
+ import { tableFromClipboard, writeRectangle } from './clipboard.js';
29
26
  import { decode } from './decode.js';
30
27
  import { inputRulesPlugin } from './inputrules.js';
31
28
  import { tablePropsOfNode } from './islands.js';
32
29
  import { inlineSchema } from './schema.js';
33
- import { ALIGNS, cellAt, cellContent, cellEqual, cellFromDoc, clearColumn, columnCount, deleteColumn, deleteRow, insertColumn, insertRow, moveColumn, moveRow, normalizeTable, rowCells, rowCount, setAlign, shapeEqual, withCell } from './table.js';
30
+ import { allRows, cellAt, cellContent, cellEqual, cellFromDoc, clearCells, columnCount, deleteColumn, deleteRow, insertColumn, insertRow, moveColumn, moveRow, normalizeTable, pasteCells, rowCells, rowCount, rowEmpty, shapeEqual, withCell } from './table.js';
34
31
  /**
35
- * The package's English for the island chrome. It lives HERE, beside the surface
32
+ * The package's English for the island chrome. It lives here, beside the surface
36
33
  * that draws it, rather than in the visual tier's table: the codec mounts this
37
34
  * chrome and a consumer reaching `createField` directly gets wording with it. The
38
35
  * visual `strings` set extends this one, so a consumer overrides these keys beside
@@ -44,34 +41,30 @@ export const DEFAULT_TABLE_STRINGS = {
44
41
  tableRow: (index) => `Row ${index}`,
45
42
  tableColumn: (index) => `Column ${index}`,
46
43
  tableCell: (row, column) => `${row}, ${column}`,
44
+ tableSelectHeaderRow: 'Select header row',
47
45
  tableSelectRow: (index) => `Select row ${index}`,
48
46
  tableSelectColumn: (index) => `Select column ${index}`,
49
- tableSelectTable: 'Select table',
50
- tableInsertRow: (index) => `Insert row ${index}`,
51
- tableInsertColumn: (index) => `Insert column ${index}`,
52
- tableAlignDefault: 'Align: default',
53
- tableAlignLeft: 'Align left',
54
- tableAlignCenter: 'Align center',
55
- tableAlignRight: 'Align right'
47
+ tableAddRow: 'Add row',
48
+ tableAddColumn: 'Add column'
56
49
  };
57
- /** The glyphs this chrome draws, as the path data a DOM node can carry — its own set
58
- * rather than `visual/icons/nodes.ts`, this being the one place chrome is built
59
- * without Svelte, and `/core` reaching no surface module. Same 24×24 frame and the
60
- * same origin, off an earlier release than the thirteen there; `NOTICE` carries the
61
- * notices for both. */
62
- const PLUS = ['M5 12h14', 'M12 5v14'];
63
- const ALIGN_PATHS = {
64
- none: ['M21 6H3', 'M21 12H3', 'M21 18H3'],
65
- left: ['M21 6H3', 'M15 12H3', 'M17 18H3'],
66
- center: ['M21 6H3', 'M17 12H7', 'M19 18H5'],
67
- right: ['M21 6H3', 'M21 12H9', 'M21 18H7']
50
+ /** The one glyph this chrome draws, as the path data a DOM node can carry — its own set
51
+ * rather than `visual/icons/nodes.ts`, this being the one place chrome is built without
52
+ * Svelte, and `/core` reaching no surface module. Same 24×24 frame and the same origin,
53
+ * off an earlier release than the thirteen there; `NOTICE` carries the notices for
54
+ * both. The dots are zero-length strokes under a round cap, which is how that set draws
55
+ * a dot everywhere it has one. */
56
+ const GRIP = {
57
+ column: ['M5 9h.01', 'M12 9h.01', 'M19 9h.01', 'M5 15h.01', 'M12 15h.01', 'M19 15h.01'],
58
+ row: ['M9 5h.01', 'M9 12h.01', 'M9 19h.01', 'M15 5h.01', 'M15 12h.01', 'M15 19h.01']
68
59
  };
60
+ /** The grip's marks. The stroke is heavy for a chrome glyph because they are dots, and a
61
+ * dot drawn at the line weight of a stroke disappears at the size the bar renders. */
69
62
  function svg(paths) {
70
63
  const el = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
71
64
  el.setAttribute('viewBox', '0 0 24 24');
72
65
  el.setAttribute('fill', 'none');
73
66
  el.setAttribute('stroke', 'currentColor');
74
- el.setAttribute('stroke-width', '2');
67
+ el.setAttribute('stroke-width', '3');
75
68
  el.setAttribute('stroke-linecap', 'round');
76
69
  el.setAttribute('stroke-linejoin', 'round');
77
70
  el.setAttribute('aria-hidden', 'true');
@@ -88,9 +81,9 @@ function el(tag, className) {
88
81
  node.className = className;
89
82
  return node;
90
83
  }
91
- /** A chrome button. It swallows its own `mousedown` (prosemirror-menu's trick, and
84
+ /** A band button. It swallows its own `mousedown` (prosemirror-menu's trick, and
92
85
  * the format popover's): without it the browser focuses the button, blurring the
93
- * cell whose caret the op is about to be measured against. A handle wants that focus
86
+ * cell whose caret the op is about to be measured against. A grip wants that focus
94
87
  * and takes it explicitly, which is why this does not hand it out. */
95
88
  function chromeButton(className, label, run) {
96
89
  const btn = el('button', className);
@@ -109,20 +102,32 @@ function chromeButton(className, label, run) {
109
102
  function cellPlugins(keys) {
110
103
  return [inputRulesPlugin(inlineSchema), keymap(keys), keymap(baseKeymap)];
111
104
  }
112
- /** What the nested views and the controls answer for themselves. `stopEvent` reads
113
- * it to hand PM everything else, and the pointer guard reads it to leave those
114
- * presses alone: one list, so the two cannot disagree about what a cell owns. */
115
- const OWNED = '.qm-table-cell-host, .qm-table-handle, .qm-table-seam, .qm-table-align';
105
+ /** The band's controls, each of which answers for its own press. Spelled apart from
106
+ * {@link owned} because the pointer router needs them before it needs the cells: a grip
107
+ * is inside the cell it names, so the two selectors overlap on exactly the press whose
108
+ * reading they disagree about. */
109
+ const CONTROLS = '.qm-table-grip, .qm-table-add';
110
+ /** What the nested views and the band answer for themselves, which is what `stopEvent`
111
+ * keeps from PM. */
112
+ const OWNED = `.qm-table-cell-host, ${CONTROLS}`;
116
113
  /** How far a press travels before it is a drag rather than a click. Under it, a press
117
- * that jitters is still the selection gesture it was aimed as. */
114
+ * that jitters is still the gesture it was aimed as. */
118
115
  const DEAD_ZONE = 3;
119
- /** A line's key in the handle registry, spelled once. */
116
+ /** A line's key in the grip registry, spelled once. */
120
117
  const lineKey = (line) => `${line.axis}:${line.index}`;
121
118
  const within = (rect, p) => p.x >= rect.left && p.x <= rect.right && p.y >= rect.top && p.y <= rect.bottom;
122
119
  /** A point's distance to a rect: zero inside it, the gap to the nearest edge outside. */
123
120
  function distance(rect, p) {
124
121
  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));
125
122
  }
123
+ const sameCells = (a, b) => a.r0 === b.r0 && a.c0 === b.c0 && a.r1 === b.r1 && a.c1 === b.c1;
124
+ /** The rectangle two corners span, in either order. */
125
+ const spanCells = (a, b) => ({
126
+ r0: Math.min(a.r, b.r),
127
+ c0: Math.min(a.c, b.c),
128
+ r1: Math.max(a.r, b.r),
129
+ c1: Math.max(a.c, b.c)
130
+ });
126
131
  class TableIslandView {
127
132
  node;
128
133
  outer;
@@ -135,16 +140,16 @@ class TableIslandView {
135
140
  rendered;
136
141
  selected;
137
142
  drag;
138
- /** The handles, by the line each acts on: what selection paint, the hover reveal
139
- * and a drag all reach for without a query. The typed twin of `cells`. */
140
- handles = new Map();
141
- /** The line the pointer is over, memoized: a field rather than DOM-only state, so
142
- * a rebuild re-applies it the way `paintLine` re-applies the selection. */
143
- hot = { r: -1, c: -1 };
144
- scroller;
143
+ sweep;
144
+ /** The grips, by the line each acts on: what selection paint and a drag reach for
145
+ * without a query. The typed twin of `cells`. */
146
+ grips = new Map();
147
+ /** The grid's own box, and the containing block every out-of-flow control is placed
148
+ * against. Not the scroller: an absolute inside a scroll container is placed
149
+ * against a padding box the scroll then slides out from under, so a control at the
150
+ * grid's far end would drift into the middle of it. */
151
+ frame;
145
152
  dropMark;
146
- /** The one alignment cluster, parked out of the tree until a column is selected. */
147
- align;
148
153
  /** A drag's trailing `click`, which would otherwise re-select the moved line. */
149
154
  suppressClick = false;
150
155
  constructor(node, outer, getPos, deps) {
@@ -156,8 +161,13 @@ class TableIslandView {
156
161
  this.dom.setAttribute('data-island', node.attrs.islandType);
157
162
  this.dom.setAttribute('data-island-id', node.attrs.id);
158
163
  this.dom.addEventListener('mousedown', this.onPointerDown);
159
- this.dom.addEventListener('mouseover', this.onHover);
160
- this.dom.addEventListener('mouseleave', this.onLeave);
164
+ // On the document rather than on the island: a `copy` is delivered to the DOM
165
+ // selection's own node, and a held rectangle has cleared that selection (a sweep
166
+ // removes the ranges itself, a grip press moves the focus to a button), so the
167
+ // event never crosses this subtree at all. What makes one this island's is
168
+ // {@link TableIslandView.onCopy}'s guard.
169
+ this.dom.ownerDocument.addEventListener('copy', this.onCopy);
170
+ this.dom.ownerDocument.addEventListener('cut', this.onCopy);
161
171
  this.render();
162
172
  }
163
173
  // ── PM's NodeView contract ────────────────────────────────────────────────
@@ -172,7 +182,7 @@ class TableIslandView {
172
182
  return true;
173
183
  }
174
184
  // Same rectangle: reseed only the cells whose value the nested view is not
175
- // already showing. The cell that PRODUCED this update projects to exactly what
185
+ // already showing. The cell that produced this update projects to exactly what
176
186
  // it stored, so it compares equal and keeps its caret; an undo or an external
177
187
  // re-hydrate does not, and takes the fresh state.
178
188
  this.rendered = props;
@@ -180,16 +190,24 @@ class TableIslandView {
180
190
  const stored = cellAt(props, mounted.r, mounted.c);
181
191
  if (cellEqual(stored, cellFromDoc(mounted.view.state.doc, stored)))
182
192
  continue;
183
- mounted.view.updateState(EditorState.create({
193
+ const head = mounted.view.state.selection.head;
194
+ const fresh = EditorState.create({
184
195
  doc: decode(cellContent(stored), inlineSchema),
185
196
  plugins: cellPlugins(this.cellKeys(mounted.r, mounted.c))
186
- }));
197
+ });
198
+ mounted.view.updateState(fresh);
199
+ // Best-effort caret continuity, the rule a field's own re-hydrate takes: keep
200
+ // the offset, clamped into the text that is there now. A fresh state resolves
201
+ // its selection to the start of the cell, so an undo would otherwise put the
202
+ // caret somewhere the edit it undid never was.
203
+ const at = Math.min(head, fresh.doc.content.size);
204
+ mounted.view.dispatch(fresh.tr.setSelection(Selection.near(fresh.doc.resolve(at))));
187
205
  }
188
206
  return true;
189
207
  }
190
208
  /** The nested views own every event inside a cell or a control; everything else
191
209
  * stays PM's, so a key pressed over a selected island still reaches the field's
192
- * keymap. What a POINTER press means on the rest of the chrome is
210
+ * keymap. What a pointer press means on the rest of the chrome is
193
211
  * {@link TableIslandView.onPointerDown}'s and not this one's: `stopEvent` gates
194
212
  * the whole subtree, so widening it would take that routing with it. */
195
213
  stopEvent(event) {
@@ -202,65 +220,66 @@ class TableIslandView {
202
220
  }
203
221
  destroy() {
204
222
  this.dom.removeEventListener('mousedown', this.onPointerDown);
205
- this.dom.removeEventListener('mouseover', this.onHover);
206
- this.dom.removeEventListener('mouseleave', this.onLeave);
223
+ this.dom.ownerDocument.removeEventListener('copy', this.onCopy);
224
+ this.dom.ownerDocument.removeEventListener('cut', this.onCopy);
207
225
  this.endDrag();
226
+ this.endSweep();
208
227
  this.teardownCells();
209
228
  }
210
229
  // ── The pointer ───────────────────────────────────────────────────────────
211
230
  /**
212
231
  * Route a press to its band (CODEC §"The table island"): inside the frame the
213
- * nearest cell's caret, outside it the document's, and a handle answers for itself.
232
+ * nearest cell's caret, outside it the document's, and a band control answers for
233
+ * itself.
234
+ *
235
+ * A press in a cell is a caret and the nested view is taking it; what is armed here
236
+ * is only the promotion, since a press that travels into another cell stops being a
237
+ * caret and becomes a block. That is the one gesture that has to see a press the
238
+ * nested view already owns, which is why it runs ahead of the cell host's guard
239
+ * rather than behind it. A control is the other way round: a grip is inside the cell
240
+ * it names, so its guard runs ahead of the cell branch, or a grip press would plant a
241
+ * caret in that cell and arm a sweep the drag then draws a block with.
214
242
  *
215
243
  * A `mousedown` listener, and it stops the event: PM's own mousedown is what arms
216
244
  * the node selection the matching mouseup then takes. `stopEvent` is the other way
217
245
  * to reach that, and it gates the subtree's keydown and drag routing too.
218
246
  */
219
247
  onPointerDown = (event) => {
220
- // Any other island type is an atom with NO interior: a press on it is
221
- // unambiguous, and PM's to answer. So is a secondary press, which types nothing
222
- // and is on its way to a context menu.
248
+ // Any other island type is an atom with no interior: a press on it is
249
+ // unambiguous, and PM's to answer. So is a secondary press, which types nothing.
223
250
  if (!this.rendered || event.button !== 0)
224
251
  return;
225
252
  const target = event.target;
226
- if (target?.closest?.(OWNED))
227
- return; // a cell or a control answers for itself
253
+ if (target?.closest?.(CONTROLS))
254
+ return; // a control answers for itself
255
+ const point = { x: event.clientX, y: event.clientY };
256
+ const box = target?.closest?.('.qm-table-cell');
257
+ const cell = box && this.cells.find((m) => m.box === box);
258
+ if (cell) {
259
+ // A fresh press in a cell means "caret here", so it retires whatever block was
260
+ // held: a press back into the cell that already has focus raises no `focus`
261
+ // event, and without this the block would survive to eat the next Backspace.
262
+ this.clearSelection();
263
+ // A cell's own padding and its borders are the table's, not the view's: a press
264
+ // there lands the caret it aimed at rather than nothing.
265
+ if (!target?.closest?.('.qm-table-cell-host')) {
266
+ event.preventDefault();
267
+ event.stopPropagation();
268
+ this.focusCell(cell.r, cell.c);
269
+ }
270
+ this.armSweep(cell, point);
271
+ return;
272
+ }
273
+ // Past the control guard and the cell branch, the press is on the island's own
274
+ // space: the band's padding, or the frame beside the grid.
228
275
  event.preventDefault();
229
276
  event.stopPropagation();
230
- const point = { x: event.clientX, y: event.clientY };
231
277
  const grid = this.dom.querySelector('.qm-table');
232
278
  if (grid && within(grid.getBoundingClientRect(), point))
233
279
  this.focusNearestCell(point);
234
280
  else
235
281
  this.caretBeside(point);
236
282
  };
237
- /** Which line the pointer is over, so only that line's handle is drawn. The cell is
238
- * the one the event landed in, matched against the mounted set: no rect is read and
239
- * no coordinate is parsed back out of the DOM. */
240
- onHover = (event) => {
241
- const box = event.target?.closest?.('.qm-table-cell');
242
- const cell = box && this.cells.find((m) => m.box === box);
243
- if (cell)
244
- this.setHot(cell.r, cell.c);
245
- };
246
- onLeave = () => {
247
- if (!this.drag)
248
- this.setHot(-1, -1);
249
- };
250
- /** Arm one row handle and one column handle. Memoized, because `mouseover` fires
251
- * per element crossed and the answer changes only per line crossed. */
252
- setHot(r, c) {
253
- if (r === this.hot.r && c === this.hot.c)
254
- return;
255
- this.hot = { r, c };
256
- this.paintHot();
257
- }
258
- paintHot() {
259
- for (const [key, handle] of this.handles) {
260
- handle.classList.toggle('qm-hot', key === lineKey({ axis: 'row', index: this.hot.r }) ||
261
- key === lineKey({ axis: 'column', index: this.hot.c }));
262
- }
263
- }
264
283
  /** The cell a press inside the frame belongs to: the nearest by rect, which for a
265
284
  * press on a border or a cell's own padding is the cell it is against. */
266
285
  focusNearestCell(point) {
@@ -276,7 +295,7 @@ class TableIslandView {
276
295
  if (best)
277
296
  this.focusCell(best.r, best.c);
278
297
  }
279
- /** A caret beside the island, on the side the press landed: a GAP CURSOR where the
298
+ /** A caret beside the island, on the side the press landed: a gap cursor where the
280
299
  * document holds no text position there, and the neighbouring block's own edge
281
300
  * where it holds one.
282
301
  *
@@ -298,33 +317,113 @@ class TableIslandView {
298
317
  this.outer.focus();
299
318
  this.outer.dispatch(this.outer.state.tr.setSelection(selection));
300
319
  }
301
- /** The island as the selection: the corner's gesture, and Escape's out of a line.
302
- * Those two are the whole of what selects it, and Backspace over it is what
303
- * deletes the table (CODEC §"The table island"). */
320
+ /** The island as the selection: Escape out of a cell or a line, which is the whole of
321
+ * what selects it. Backspace there deletes the table, as it does over every other
322
+ * island; the cell selection covering every rank reaches the same delete without
323
+ * leaving the grid (CODEC §"The table island"). */
304
324
  selectIsland() {
305
325
  const pos = this.getPos();
306
326
  if (pos == null)
307
327
  return;
308
- this.clearLine();
328
+ this.clearSelection();
309
329
  this.outer.focus();
310
330
  this.outer.dispatch(this.outer.state.tr.setSelection(NodeSelection.create(this.outer.state.doc, pos)));
311
331
  }
312
- // ── The line selection ────────────────────────────────────────────────────
313
- /** Take the line's handle: the focus a selection's keys land on. */
314
- handleFor(line) {
315
- return this.handles.get(lineKey(line));
332
+ // ── Sweeping a block of cells ─────────────────────────────────────────────
333
+ armSweep(origin, from) {
334
+ this.endSweep();
335
+ this.sweep = {
336
+ origin: { r: origin.r, c: origin.c },
337
+ from,
338
+ engaged: false,
339
+ live: false,
340
+ boxes: []
341
+ };
342
+ document.addEventListener('mousemove', this.onSweepMove, true);
343
+ document.addEventListener('mouseup', this.onSweepUp, true);
316
344
  }
317
- /** The index space an axis allows. Stated once, because "the header is the floor"
318
- * is a rule three callers would otherwise each spell out. */
345
+ /**
346
+ * Promote a travelling press to a block. Until the pointer leaves the cell it
347
+ * started in the gesture is still a caret and the nested view keeps it; from the
348
+ * first cell it crosses, the block is the selection and the browser's own text drag
349
+ * is dropped, two selections over one press being one too many.
350
+ */
351
+ onSweepMove = (event) => {
352
+ const sweep = this.sweep;
353
+ if (!sweep)
354
+ return;
355
+ const at = { x: event.clientX, y: event.clientY };
356
+ if (!sweep.engaged) {
357
+ if (Math.hypot(at.x - sweep.from.x, at.y - sweep.from.y) < DEAD_ZONE)
358
+ return;
359
+ sweep.engaged = true;
360
+ sweep.boxes = this.cells.map((m) => ({
361
+ r: m.r,
362
+ c: m.c,
363
+ rect: m.box.getBoundingClientRect()
364
+ }));
365
+ }
366
+ const head = this.cellNear(sweep.boxes, at);
367
+ if (!head)
368
+ return;
369
+ if (!sweep.live && head.r === sweep.origin.r && head.c === sweep.origin.c)
370
+ return;
371
+ sweep.live = true;
372
+ this.select(spanCells(sweep.origin, head));
373
+ document.getSelection()?.removeAllRanges();
374
+ event.preventDefault();
375
+ };
376
+ onSweepUp = () => this.endSweep();
377
+ endSweep() {
378
+ if (!this.sweep)
379
+ return;
380
+ this.sweep = undefined;
381
+ document.removeEventListener('mousemove', this.onSweepMove, true);
382
+ document.removeEventListener('mouseup', this.onSweepUp, true);
383
+ }
384
+ /** The cell a point is in, or the nearest one outside the grid: a sweep that runs
385
+ * past the last row still names a cell, which is what lets it reach the edge. */
386
+ cellNear(boxes, at) {
387
+ let best;
388
+ let nearest = Infinity;
389
+ for (const box of boxes) {
390
+ const gap = distance(box.rect, at);
391
+ if (gap >= nearest)
392
+ continue;
393
+ nearest = gap;
394
+ best = { r: box.r, c: box.c };
395
+ }
396
+ return best;
397
+ }
398
+ // ── The selection ─────────────────────────────────────────────────────────
399
+ /** Take the line's grip: the focus a selection's keys land on. */
400
+ gripFor(line) {
401
+ return this.grips.get(lineKey(line));
402
+ }
403
+ /** The rectangle a line covers, which is what selecting one means: a grip draws no
404
+ * second kind of selection, it draws this one. */
405
+ lineCells(line) {
406
+ const props = this.props();
407
+ return line.axis === 'row'
408
+ ? { r0: line.index, c0: 0, r1: line.index, c1: columnCount(props) - 1 }
409
+ : { r0: 0, c0: line.index, r1: rowCount(props) - 1, c1: line.index };
410
+ }
411
+ /** The index space an axis allows, which is the whole of it on both. The header is
412
+ * row 0 rather than a line above the floor: a walk reaches it, a drag lands on it,
413
+ * and the row that lands there is the header (`table.ts` §{@link moveRow}). */
319
414
  bounds(axis, props) {
320
415
  return axis === 'row'
321
- ? { floor: 1, limit: rowCount(props) - 1 }
416
+ ? { floor: 0, limit: rowCount(props) - 1 }
322
417
  : { floor: 0, limit: columnCount(props) - 1 };
323
418
  }
324
- /** Select a line. The handle takes the FOCUS, because the selection's keys bind
419
+ /** Select a line. The grip takes the focus, because the selection's keys bind
325
420
  * there and need somewhere to land. */
326
421
  selectLine(line) {
327
- // One subject at a time: a line selection retires an island one, or the surface
422
+ this.select(this.lineCells(line));
423
+ this.gripFor(line)?.focus();
424
+ }
425
+ select(cells) {
426
+ // One subject at a time: a cell selection retires an island one, or the surface
328
427
  // paints a washed row inside an outlined table and the next Backspace has two
329
428
  // honest readings.
330
429
  const pos = this.getPos();
@@ -336,38 +435,35 @@ class TableIslandView {
336
435
  if (beside)
337
436
  this.outer.dispatch(this.outer.state.tr.setSelection(beside));
338
437
  }
339
- this.selected = line;
340
- this.paintLine();
341
- this.handleFor(line)?.focus();
438
+ this.selected = cells;
439
+ this.paintSelection();
342
440
  }
343
- clearLine() {
441
+ clearSelection() {
344
442
  if (!this.selected)
345
443
  return;
346
444
  this.selected = undefined;
347
- this.paintLine();
348
- }
349
- /** Wash the selected line's cells and mark its handle. Imperative rather than a
350
- * re-render: a rebuild destroys the nested views, and a selection is exactly the
351
- * state that must not cost the carets in them. */
352
- paintLine() {
353
- const line = this.selected;
354
- const key = line && lineKey(line);
355
- for (const [at, handle] of this.handles)
356
- handle.setAttribute('aria-pressed', String(at === key));
357
- for (const cell of this.cells) {
358
- const on = !!line && (line.axis === 'row' ? line.index === cell.r : line.index === cell.c);
359
- cell.box.classList.toggle('qm-table-line', on);
445
+ this.paintSelection();
446
+ }
447
+ /** Wash the selected cells and mark the grip of any line the selection exactly
448
+ * covers. Imperative rather than a re-render: a rebuild destroys the nested views,
449
+ * and a selection is exactly the state that must not cost the carets in them. */
450
+ paintSelection() {
451
+ const held = this.selected;
452
+ for (const [key, grip] of this.grips) {
453
+ const [axis, index] = key.split(':');
454
+ const line = { axis: axis, index: Number(index) };
455
+ grip.setAttribute('aria-pressed', String(!!held && sameCells(held, this.lineCells(line))));
360
456
  }
361
- this.paintAlign();
457
+ for (const cell of this.cells)
458
+ cell.box.toggleAttribute('data-selected', !!held && cell.r >= held.r0 && cell.r <= held.r1 && cell.c >= held.c0 && cell.c <= held.c1);
362
459
  }
363
460
  /**
364
- * A selected line's keys, bound to the line its own handle names rather than read
365
- * off `selected`: focus and selection are separate, and a Tab between handles would
461
+ * A selected line's keys, bound to the line its own grip names rather than read off
462
+ * the selection: focus and selection are separate, and a Tab between grips would
366
463
  * otherwise aim the next key at whichever line was last pressed.
367
464
  *
368
- * Backspace CLEARS rather than deletes at the last column, which the model keeps
369
- * (CODEC §"The table island"). Undo is forwarded for the reason a cell forwards it:
370
- * one undo stack per leaf, and a `button` has no history of its own.
465
+ * Undo is forwarded for the reason a cell forwards it: one undo stack per leaf, and
466
+ * a `button` has no history of its own.
371
467
  */
372
468
  lineKeys(line) {
373
469
  return (event) => {
@@ -388,11 +484,11 @@ class TableIslandView {
388
484
  if (key === 'Escape')
389
485
  return this.selectIsland();
390
486
  if (key === 'Enter') {
391
- this.clearLine();
487
+ this.clearSelection();
392
488
  return line.axis === 'row' ? this.focusCell(line.index, 0) : this.focusCell(0, line.index);
393
489
  }
394
490
  if (key === 'Backspace' || key === 'Delete')
395
- return this.deleteLine(line);
491
+ return this.deleteSelection();
396
492
  const by = key === 'ArrowUp' || key === 'ArrowLeft' ? -1 : 1;
397
493
  if (event.altKey)
398
494
  return this.moveLine(line, by);
@@ -403,27 +499,111 @@ class TableIslandView {
403
499
  });
404
500
  };
405
501
  }
406
- /** Delete the line, or clear it where the model keeps it. The two arms are two
407
- * model rules rather than one shape: a row runs out (`rows: []` is a legal
408
- * header-only table), a column cannot (the rectangle floors at one). */
409
- deleteLine(line) {
502
+ // ── The clipboard ─────────────────────────────────────────────────────────
503
+ /** The held rectangle's cells, read off the stored props: the unit that crosses the
504
+ * clipboard (`clipboard.ts`), so the unit the verbs here read. */
505
+ heldBlock(held) {
410
506
  const props = this.props();
411
- if (line.axis === 'row') {
412
- const next = Math.max(1, Math.min(line.index, rowCount(props) - 2));
413
- this.write(deleteRow(props, line.index));
414
- if (rowCount(this.props()) > 1)
415
- this.selectLine({ axis: 'row', index: next });
416
- else
417
- this.clearLine();
507
+ return Array.from({ length: held.r1 - held.r0 + 1 }, (_, i) => Array.from({ length: held.c1 - held.c0 + 1 }, (_, j) => cellAt(props, held.r0 + i, held.c0 + j)));
508
+ }
509
+ /**
510
+ * Copy or cut the held rectangle (`clipboard.ts` writes both wire formats). A cut is
511
+ * a copy plus the delete Backspace already means over that rectangle, so what it
512
+ * takes out is read by the same extent rule: a covered rank goes, a sub-rectangle
513
+ * clears.
514
+ *
515
+ * The guard is the whole of what makes a document-level listener this island's. With
516
+ * no rectangle the event belongs to whatever holds the caret. With the focus outside,
517
+ * it is another surface's copy over a rectangle this island still happens to hold.
518
+ */
519
+ onCopy = (event) => {
520
+ const held = this.selected;
521
+ const data = event.clipboardData;
522
+ const owner = this.dom.ownerDocument;
523
+ if (!held || !data || !this.dom.contains(owner.activeElement))
418
524
  return;
419
- }
420
- if (columnCount(props) <= 1) {
421
- this.write(clearColumn(props, line.index));
422
- this.selectLine(line);
525
+ writeRectangle(data, this.heldBlock(held), this.props().aligns.slice(held.c0, held.c1 + 1), owner);
526
+ event.preventDefault();
527
+ if (event.type === 'cut')
528
+ this.deleteSelection();
529
+ };
530
+ /**
531
+ * A table pasted into a cell is written in at that cell, growing the table to hold
532
+ * it (`table.ts` §{@link pasteCells}); anything else is the cell's own paste and
533
+ * this declines. It lands at the caret's cell, which over a swept block is the cell
534
+ * the press started in — the block's anchor rather than its first corner, a sweep
535
+ * running up or left putting those at opposite ends.
536
+ *
537
+ * Without this arm the cell's inline schema reads the same clipboard as text and
538
+ * flattens the table into one cell, which is the destructive case one level down.
539
+ */
540
+ pasteIntoCell(r, c, event) {
541
+ const props = tableFromClipboard(event.clipboardData);
542
+ if (!props)
543
+ return false;
544
+ this.clearSelection();
545
+ this.write(pasteCells(this.props(), r, c, allRows(props)));
546
+ return true;
547
+ }
548
+ /**
549
+ * What Backspace means over the selection, decided by its extent rather than by the
550
+ * gesture that drew it (CODEC §"The table island").
551
+ *
552
+ * The both-axes arm is the rule's own limit rather than an exception to it: every rank
553
+ * going at once leaves no table for a rank rule to have produced. It is the whole of
554
+ * how a pointer deletes a table, and on a one-column or one-row table a single grip
555
+ * draws it.
556
+ */
557
+ deleteSelection() {
558
+ const held = this.selected;
559
+ if (!held)
423
560
  return;
424
- }
425
- this.write(deleteColumn(props, line.index));
426
- this.selectLine({ axis: 'column', index: Math.max(0, line.index - 1) });
561
+ const props = this.props();
562
+ const wide = held.c0 === 0 && held.c1 === columnCount(props) - 1;
563
+ const tall = held.r0 === 0 && held.r1 === rowCount(props) - 1;
564
+ if (tall && wide)
565
+ return this.deleteIsland();
566
+ if (tall)
567
+ return this.dropColumns(held);
568
+ if (wide)
569
+ return this.dropRows(held);
570
+ this.write(clearCells(props, held.r0, held.c0, held.r1, held.c1));
571
+ this.select(held);
572
+ }
573
+ /** Delete the whole island: an ordinary delete on the outer view, so the table goes
574
+ * the way the node selection's Backspace already took it and rides the same undo
575
+ * stack. The selection is dropped without a repaint: the DOM holding it is about to
576
+ * be gone. */
577
+ deleteIsland() {
578
+ const pos = this.getPos();
579
+ if (pos == null)
580
+ return;
581
+ this.selected = undefined;
582
+ this.outer.focus();
583
+ this.outer.dispatch(this.outer.state.tr.delete(pos, pos + this.node.nodeSize));
584
+ }
585
+ /** Drop the rows the selection covers, high index first so the ones still to go keep
586
+ * their indices, and select whatever took the first one's place. The header is among
587
+ * them like any other row, and the row left at index 0 is the header afterwards. One
588
+ * `write`, so the whole gesture is one undo step. */
589
+ dropRows(held) {
590
+ let props = this.props();
591
+ for (let r = held.r1; r >= held.r0; r--)
592
+ props = deleteRow(props, r);
593
+ this.write(props);
594
+ this.selectLine({ axis: 'row', index: Math.min(held.r0, rowCount(this.props()) - 1) });
595
+ }
596
+ /** Drop the columns the selection covers, and select what took the first one's place —
597
+ * the row arm's rule, spelled the same way, since a rank going is a rank going on
598
+ * either axis. Neither this nor {@link TableIslandView.dropRows} can empty the table:
599
+ * a rectangle covering every rank of its axis spans the other one too, which is the
600
+ * island arm above, so a rank always survives here. */
601
+ dropColumns(held) {
602
+ let props = this.props();
603
+ for (let c = held.c1; c >= held.c0; c--)
604
+ props = deleteColumn(props, c);
605
+ this.write(props);
606
+ this.selectLine({ axis: 'column', index: Math.min(held.c0, columnCount(this.props()) - 1) });
427
607
  }
428
608
  /** Move the line and keep it selected: a move whose selection did not travel would
429
609
  * leave the next Alt+arrow acting on whatever took the index. */
@@ -438,11 +618,11 @@ class TableIslandView {
438
618
  }
439
619
  // ── Drag to reorder ───────────────────────────────────────────────────────
440
620
  /**
441
- * Press-and-drag a handle moves its line. The press still SELECTS: the dead zone is
621
+ * Press-and-drag a grip moves its line. The press still selects: the dead zone is
442
622
  * what tells the two apart, so a click that jitters is the gesture it was aimed as
443
623
  * and only a real travel becomes a drag.
444
624
  */
445
- onHandleDown = (line, handle, event) => {
625
+ onGripDown = (line, grip, event) => {
446
626
  if (event.button !== 0)
447
627
  return;
448
628
  this.drag = {
@@ -452,16 +632,18 @@ class TableIslandView {
452
632
  drop: line.index,
453
633
  painted: -1,
454
634
  pointerId: event.pointerId,
455
- handle,
635
+ grip,
456
636
  lines: [],
457
637
  origin0: { left: 0, top: 0 }
458
638
  };
459
- handle.setPointerCapture(event.pointerId);
460
- handle.addEventListener('pointermove', this.onHandleMove);
461
- handle.addEventListener('pointerup', this.onHandleUp);
462
- handle.addEventListener('pointercancel', this.onHandleUp);
639
+ // Optional for the reason the release is: pointer capture is the browser's, and a
640
+ // DOM without it still routes the move and up events the drag reads.
641
+ grip.setPointerCapture?.(event.pointerId);
642
+ grip.addEventListener('pointermove', this.onGripMove);
643
+ grip.addEventListener('pointerup', this.onGripUp);
644
+ grip.addEventListener('pointercancel', this.onGripUp);
463
645
  };
464
- onHandleMove = (event) => {
646
+ onGripMove = (event) => {
465
647
  const drag = this.drag;
466
648
  if (!drag)
467
649
  return;
@@ -473,21 +655,21 @@ class TableIslandView {
473
655
  drag.drop = this.dropIndex(drag, drag.line.axis === 'row' ? event.clientY : event.clientX);
474
656
  this.paintDrop(drag);
475
657
  };
476
- /** The drag becomes one: lift the line, and measure the table ONCE. */
658
+ /** The drag becomes one: lift the line, and measure the table once. */
477
659
  engage(drag) {
478
660
  drag.engaged = true;
479
661
  this.dom.classList.add('qm-table-dragging');
480
- drag.handle.classList.add('qm-table-lifted');
481
- for (const cell of this.lineCells(drag.line))
662
+ drag.grip.classList.add('qm-table-lifted');
663
+ for (const cell of this.boxesOf(drag.line))
482
664
  cell.classList.add('qm-table-lifted');
483
- const scroller = this.scroller;
484
- if (!scroller)
665
+ const frame = this.frame;
666
+ if (!frame)
485
667
  return;
486
- const box = scroller.getBoundingClientRect();
487
- drag.origin0 = { left: box.left - scroller.scrollLeft, top: box.top - scroller.scrollTop };
668
+ const box = frame.getBoundingClientRect();
669
+ drag.origin0 = { left: box.left, top: box.top };
488
670
  const { floor, limit } = this.bounds(drag.line.axis, this.props());
489
671
  for (let i = floor; i <= limit; i++) {
490
- const cells = this.lineCells({ axis: drag.line.axis, index: i });
672
+ const cells = this.boxesOf({ axis: drag.line.axis, index: i });
491
673
  if (!cells.length)
492
674
  continue;
493
675
  const head = cells[0].getBoundingClientRect();
@@ -509,7 +691,7 @@ class TableIslandView {
509
691
  });
510
692
  }
511
693
  }
512
- onHandleUp = () => {
694
+ onGripUp = (event) => {
513
695
  const drag = this.drag;
514
696
  if (!drag)
515
697
  return;
@@ -517,6 +699,10 @@ class TableIslandView {
517
699
  this.endDrag();
518
700
  if (!engaged)
519
701
  return;
702
+ // A cancel is not a release: the line stays where it was, and no `click` follows
703
+ // it, so arming the guard below would leave it to swallow the next press instead.
704
+ if (event.type !== 'pointerup')
705
+ return;
520
706
  // The press that ends a drag is not the press that selects: the click still to
521
707
  // come would re-select the line the drag just moved off.
522
708
  this.suppressClick = true;
@@ -535,11 +721,11 @@ class TableIslandView {
535
721
  this.dom.classList.remove('qm-table-dragging');
536
722
  for (const lifted of this.dom.querySelectorAll('.qm-table-lifted'))
537
723
  lifted.classList.remove('qm-table-lifted');
538
- drag.handle.removeEventListener('pointermove', this.onHandleMove);
539
- drag.handle.removeEventListener('pointerup', this.onHandleUp);
540
- drag.handle.removeEventListener('pointercancel', this.onHandleUp);
541
- if (drag.handle.hasPointerCapture?.(drag.pointerId))
542
- drag.handle.releasePointerCapture(drag.pointerId);
724
+ drag.grip.removeEventListener('pointermove', this.onGripMove);
725
+ drag.grip.removeEventListener('pointerup', this.onGripUp);
726
+ drag.grip.removeEventListener('pointercancel', this.onGripUp);
727
+ if (drag.grip.hasPointerCapture?.(drag.pointerId))
728
+ drag.grip.releasePointerCapture(drag.pointerId);
543
729
  }
544
730
  /** Which line the pointer is over, along the drag's axis: the nearest by the extents
545
731
  * measured at engage. One dimension, because every cell of a row shares its top and
@@ -557,26 +743,26 @@ class TableIslandView {
557
743
  }
558
744
  return best;
559
745
  }
560
- lineCells(line) {
746
+ boxesOf(line) {
561
747
  return this.cells
562
748
  .filter((m) => (line.axis === 'row' ? m.r === line.index : m.c === line.index))
563
749
  .map((m) => m.box);
564
750
  }
565
751
  /** The drop indicator: one rule on the boundary the line would land against, drawn
566
- * in the scroller so it spans the grid and scrolls with it. Redrawn only when the
752
+ * in the frame so it spans the grid and travels with it. Redrawn only when the
567
753
  * boundary changes, which is once per line crossed rather than once per move. */
568
754
  paintDrop(drag) {
569
755
  if (drag.drop === drag.painted)
570
756
  return;
571
757
  const line = drag.lines.find((l) => l.index === drag.drop);
572
- const scroller = this.scroller;
573
- if (!line || !scroller)
758
+ const frame = this.frame;
759
+ if (!line || !frame)
574
760
  return;
575
761
  drag.painted = drag.drop;
576
762
  if (!this.dropMark) {
577
763
  this.dropMark = el('div', 'qm-table-drop');
578
764
  this.dropMark.setAttribute('data-axis', drag.line.axis);
579
- scroller.appendChild(this.dropMark);
765
+ frame.appendChild(this.dropMark);
580
766
  }
581
767
  const edge = (drag.drop > drag.line.index ? line.end : line.start) - 1;
582
768
  const mark = this.dropMark;
@@ -600,9 +786,11 @@ class TableIslandView {
600
786
  this.cells = [];
601
787
  }
602
788
  render() {
789
+ const seat = this.focusedSeat();
603
790
  this.endDrag();
791
+ this.endSweep();
604
792
  this.teardownCells();
605
- this.handles.clear();
793
+ this.grips.clear();
606
794
  this.dom.textContent = '';
607
795
  const props = tablePropsOfNode(this.node);
608
796
  this.rendered = props;
@@ -624,19 +812,57 @@ class TableIslandView {
624
812
  for (let r = 1; r < rowCount(props); r++)
625
813
  body.appendChild(this.row(props, r, s));
626
814
  table.append(head, body);
815
+ // The frame is the grid's own box, and the two controls about an axis rather than
816
+ // about a line hang off its edges: an add bar along each trailing edge. A cell
817
+ // would have served for neither — a table with no body rows has no last row to
818
+ // hang the row bar in, and a bar spans the whole edge rather than one line of it.
819
+ const frame = el('div', 'qm-table-frame');
820
+ frame.append(table, this.addBar('column', s.tableAddColumn), this.addBar('row', s.tableAddRow));
627
821
  const scroller = el('div', 'qm-table-scroller');
628
- scroller.appendChild(table);
629
- this.scroller = scroller;
630
- this.align = this.alignCluster(s);
822
+ scroller.appendChild(frame);
823
+ this.frame = frame;
631
824
  this.dom.appendChild(scroller);
632
- // Both derived paints re-apply: a rebuild replaced every element they wrote on.
633
- this.paintLine();
634
- this.paintHot();
825
+ this.paintSelection();
826
+ this.reseat(seat);
827
+ }
828
+ /**
829
+ * Where the focus sits, in terms a rebuild can restore it by: a cell's position or a
830
+ * grip's line, neither of which is an element that survives one.
831
+ *
832
+ * A rebuild is what a changed rectangle costs, and the op that changed it usually
833
+ * says where the caret lands ({@link TableIslandView.write}'s `focus`). An undo says
834
+ * nothing — it is the outer history's transaction, not an op of this view's — so
835
+ * without this the DOM under the focus is removed and the focus falls to the
836
+ * document body, where the next undo reaches no view at all.
837
+ */
838
+ focusedSeat() {
839
+ const held = this.cells.find((m) => m.view.hasFocus());
840
+ if (held)
841
+ return { cell: { r: held.r, c: held.c } };
842
+ const active = this.dom.ownerDocument.activeElement;
843
+ for (const [key, grip] of this.grips) {
844
+ if (grip !== active)
845
+ continue;
846
+ const [axis, index] = key.split(':');
847
+ return { line: { axis: axis, index: Number(index) } };
848
+ }
849
+ return undefined;
850
+ }
851
+ /** Put the focus back where {@link TableIslandView.focusedSeat} found it, clamped into
852
+ * the rectangle that is there now: the seat's line may be the one the rebuild
853
+ * removed. A caller that has a landing of its own overrides this by running after. */
854
+ reseat(seat) {
855
+ if (seat?.cell)
856
+ return this.focusCell(seat.cell.r, seat.cell.c);
857
+ if (!seat?.line)
858
+ return;
859
+ const { floor, limit } = this.bounds(seat.line.axis, this.props());
860
+ const index = Math.max(floor, Math.min(seat.line.index, limit));
861
+ this.gripFor({ axis: seat.line.axis, index })?.focus();
635
862
  }
636
863
  /** One table row: its cells, each carrying whatever chrome hangs off it. */
637
864
  row(props, r, s) {
638
865
  const tr = el('tr', r === 0 ? 'qm-table-header-row' : undefined);
639
- const cols = columnCount(props);
640
866
  rowCells(props, r).forEach((cell, c) => {
641
867
  const box = el(r === 0 ? 'th' : 'td', 'qm-table-cell');
642
868
  if (r === 0)
@@ -648,121 +874,73 @@ class TableIslandView {
648
874
  box.style.textAlign = align;
649
875
  const host = el('div', 'qm-table-cell-host');
650
876
  box.appendChild(host);
651
- // The COLUMN band hangs off the header row; the ROW band off each body row's
652
- // first cell. Both are absolutely positioned into the scroller's padding, so
653
- // neither is in the grid's layout and neither is a cell of its own.
654
- if (r === 0) {
655
- box.appendChild(this.handle({ axis: 'column', index: c }, s.tableSelectColumn(c + 1)));
656
- box.appendChild(this.seam('column', c, s.tableInsertColumn(c + 1), 'lead'));
657
- // The last column carries the trailing seam too: a boundary belongs to the
658
- // cell it is against, and the right edge is against no next column.
659
- if (c === cols - 1)
660
- box.appendChild(this.seam('column', c + 1, s.tableInsertColumn(c + 2), 'trail'));
661
- // The header carries no row handle: it cannot be deleted (`header: []` is
662
- // not a table) and nothing goes above it, so there is no line to select.
663
- if (c === 0)
664
- box.appendChild(this.cornerHandle(s));
665
- }
666
- else if (c === 0) {
667
- box.appendChild(this.handle({ axis: 'row', index: r }, s.tableSelectRow(r)));
668
- box.appendChild(this.seam('row', r, s.tableInsertRow(r), 'lead'));
669
- if (r === rowCount(props) - 1)
670
- box.appendChild(this.seam('row', r + 1, s.tableInsertRow(r + 1), 'trail'));
671
- }
877
+ // The column band hangs off the header row; the row band off every row's first
878
+ // cell, the header's included. Both are absolutely positioned into the frame's
879
+ // own padding, so neither is in the grid's layout and neither is a cell of its
880
+ // own, and the two the header's first cell carries hang off perpendicular edges
881
+ // and meet at no point. The header takes a grip because a row grip acts on a
882
+ // row and the header is one: it selects, it deletes, and it drags, all by the
883
+ // rules every other row is under.
884
+ if (r === 0)
885
+ box.appendChild(this.grip({ axis: 'column', index: c }, s.tableSelectColumn(c + 1)));
886
+ if (c === 0)
887
+ box.appendChild(this.grip({ axis: 'row', index: r }, r === 0 ? s.tableSelectHeaderRow : s.tableSelectRow(r)));
672
888
  tr.appendChild(box);
673
889
  this.mountCell(box, host, r, c, s);
674
890
  });
675
891
  return tr;
676
892
  }
677
- /** A line's handle, and the whole of that line's chrome: a press selects the line, a
678
- * press that travels drags it. Both are "this line", asked once with the pointer. */
679
- handle(line, label) {
680
- const btn = chromeButton('qm-table-handle', label, () => {
893
+ /** A line's grip, and the whole of that line's chrome: a press selects the line, a
894
+ * press that travels drags it. Both are "this line", asked once with the pointer,
895
+ * and the dead zone is what tells them apart.
896
+ *
897
+ * Out of the tab order, at no cost in reach: a grip follows the cell it hangs in, so
898
+ * a forward Tab is the cell traversal's before the browser gets there and a backward
899
+ * one leaves the grid off cell (0,0) without passing a grip
900
+ * (§{@link TableIslandView.step}). A line's chrome answers to the pointer. Focus is
901
+ * still taken here, by the routes this view owns: a selection, a drag, a rebuild's
902
+ * reseat. */
903
+ grip(line, label) {
904
+ const btn = chromeButton('qm-table-grip', label, () => {
681
905
  if (this.suppressClick)
682
906
  this.suppressClick = false;
683
907
  else
684
908
  this.selectLine(line);
685
909
  });
910
+ btn.tabIndex = -1;
686
911
  btn.setAttribute('aria-pressed', 'false');
687
912
  btn.setAttribute('data-axis', line.axis);
688
- btn.appendChild(el('span', 'qm-table-handle-bar'));
689
- btn.addEventListener('pointerdown', (e) => this.onHandleDown(line, btn, e));
913
+ const bar = el('span', 'qm-table-grip-bar');
914
+ bar.appendChild(svg(GRIP[line.axis]));
915
+ btn.appendChild(bar);
916
+ btn.addEventListener('pointerdown', (e) => this.onGripDown(line, btn, e));
690
917
  btn.addEventListener('keydown', this.lineKeys(line));
691
- this.handles.set(lineKey(line), btn);
918
+ this.grips.set(lineKey(line), btn);
692
919
  return btn;
693
920
  }
694
- /** The island's own handle, at the grid origin: the spreadsheet's select-all
695
- * position. A press selects the island, which is the state Backspace deletes the
696
- * whole table from. */
697
- cornerHandle(s) {
698
- const btn = chromeButton('qm-table-handle qm-table-corner-handle', s.tableSelectTable, () => this.selectIsland());
699
- btn.setAttribute('data-axis', 'island');
700
- btn.appendChild(el('span', 'qm-table-corner-mark'));
701
- return btn;
702
- }
703
- /** A seam: the boundary between two lines, and the whole of how a table grows. */
704
- seam(axis, index, label, side) {
705
- const btn = chromeButton('qm-table-seam', label, () => {
921
+ /** A trailing bar: the whole edge past the last line of its axis, and the one way a
922
+ * pointer grows the table. It spans the edge rather than capping it, because what it
923
+ * appends to is the axis and not a line. It draws no glyph — the bar arriving under
924
+ * the pointer out past the last line is the claim — so its name carries the verb for
925
+ * everything that does not read position.
926
+ *
927
+ * In the tab order, where a grip is not: the column bar is the keyboard's only route
928
+ * to a column at all, and the row bar is that control on the other axis, where Enter
929
+ * at the last row and Tab past the last cell reach the verb from inside a cell. They
930
+ * sit after the grid, so the Tab that declines off the last cell lands on them, and
931
+ * the focus rung draws the one it lands on (`codec/prose.css`). */
932
+ addBar(axis, label) {
933
+ const btn = chromeButton('qm-table-add', label, () => {
706
934
  const props = this.props();
707
935
  if (axis === 'row')
708
- this.write(insertRow(props, index - 1), { r: index, c: 0 });
936
+ this.write(insertRow(props, props.rows.length), { r: rowCount(props), c: 0 });
709
937
  else
710
- this.write(insertColumn(props, index - 1), { r: 0, c: index });
938
+ this.write(insertColumn(props, columnCount(props) - 1), { r: 0, c: columnCount(props) });
711
939
  });
712
940
  btn.setAttribute('data-axis', axis);
713
- btn.setAttribute('data-side', side);
714
- btn.appendChild(svg(PLUS));
941
+ btn.appendChild(el('span', 'qm-table-add-bar'));
715
942
  return btn;
716
943
  }
717
- /**
718
- * A column's alignment: the one op with no gesture, so the one surface the chrome
719
- * still raises. It rides the column's SELECTION, so at most ONE is ever live, which
720
- * is why there is one cluster re-parented into the selected column rather than one
721
- * built per column and hidden.
722
- */
723
- alignCluster(s) {
724
- const labels = {
725
- none: s.tableAlignDefault,
726
- left: s.tableAlignLeft,
727
- center: s.tableAlignCenter,
728
- right: s.tableAlignRight
729
- };
730
- const box = el('div', 'qm-table-align');
731
- box.setAttribute('role', 'group');
732
- for (const align of ALIGNS) {
733
- const btn = chromeButton('qm-table-align-item', labels[align], () => {
734
- const at = this.selected;
735
- if (!at || at.axis !== 'column')
736
- return;
737
- this.write(setAlign(this.props(), at.index, align));
738
- this.selectLine(at);
739
- });
740
- btn.setAttribute('data-align', align);
741
- btn.appendChild(svg(ALIGN_PATHS[align]));
742
- box.appendChild(btn);
743
- }
744
- return box;
745
- }
746
- /** Hang the cluster off the selected column's header cell and mark the alignment
747
- * that column already has: an exclusive set, so it shows which one is live. */
748
- paintAlign() {
749
- const cluster = this.align;
750
- if (!cluster)
751
- return;
752
- const line = this.selected;
753
- if (!line || line.axis !== 'column') {
754
- cluster.remove();
755
- return;
756
- }
757
- const head = this.cells.find((m) => m.r === 0 && m.c === line.index);
758
- if (!head)
759
- return;
760
- head.box.appendChild(cluster);
761
- cluster.setAttribute('aria-label', this.deps.strings().tableSelectColumn(line.index + 1));
762
- const align = this.props().aligns[line.index] ?? 'none';
763
- for (const btn of cluster.querySelectorAll('.qm-table-align-item'))
764
- btn.setAttribute('aria-pressed', String(btn.getAttribute('data-align') === align));
765
- }
766
944
  mountCell(box, host, r, c, s) {
767
945
  const props = this.props();
768
946
  const name = s.tableCell(r === 0 ? s.tableHeaderRow : s.tableRow(r), s.tableColumn(c + 1));
@@ -780,9 +958,10 @@ class TableIslandView {
780
958
  const now = this.props();
781
959
  this.write(withCell(now, r, c, cellFromDoc(next.doc, cellAt(now, r, c))));
782
960
  },
961
+ handlePaste: (_view, event) => this.pasteIntoCell(r, c, event),
783
962
  handleDOMEvents: {
784
963
  focus: () => {
785
- this.clearLine();
964
+ this.clearSelection();
786
965
  this.deps.onCellFocus();
787
966
  return false;
788
967
  }
@@ -799,7 +978,7 @@ class TableIslandView {
799
978
  /**
800
979
  * Commit a new rectangle onto the node. `setNodeMarkup` is an ordinary PM
801
980
  * transaction, so the field lowers it through the island channel and the whole
802
- * op is one commit and one undo step, which is why every op writes a WHOLE
981
+ * op is one commit and one undo step, which is why every op writes a whole
803
982
  * table rather than mutating the props in place.
804
983
  */
805
984
  write(next, focus) {
@@ -831,11 +1010,11 @@ class TableIslandView {
831
1010
  }
832
1011
  /**
833
1012
  * A cell's keys. Traversal is the island's link in the leaf's chain
834
- * (VISUAL_EDITOR §Chrome), except that it binds on the NESTED view: the outer
1013
+ * (VISUAL_EDITOR §Chrome), except that it binds on the nested view: the outer
835
1014
  * keymap never sees a keystroke a cell handled (`stopEvent`).
836
1015
  *
837
1016
  * Enter is the next row, forced: a `TableCell` has one `text` and no line
838
- * concept, and `continues` is a LINE flag with no cell analogue, so a newline in
1017
+ * concept, and `continues` is a line flag with no cell analogue, so a newline in
839
1018
  * a cell has no representation to be a preference about.
840
1019
  */
841
1020
  cellKeys(r, c) {
@@ -862,6 +1041,15 @@ class TableIslandView {
862
1041
  return true;
863
1042
  };
864
1043
  };
1044
+ // A block selection outranks the caret it was swept from: the origin cell still
1045
+ // holds the focus, so its own view is where the block's Backspace lands. Declining
1046
+ // when no block is held is what leaves an ordinary Backspace to `baseKeymap`.
1047
+ const erase = () => {
1048
+ if (!this.selected)
1049
+ return false;
1050
+ this.deleteSelection();
1051
+ return true;
1052
+ };
865
1053
  return {
866
1054
  ...marks,
867
1055
  // One undo stack per leaf: a cell carries no history of its own, so Mod-z
@@ -869,16 +1057,12 @@ class TableIslandView {
869
1057
  'Mod-z': () => undo(this.outer.state, this.outer.dispatch),
870
1058
  'Mod-y': () => redo(this.outer.state, this.outer.dispatch),
871
1059
  'Shift-Mod-z': () => redo(this.outer.state, this.outer.dispatch),
1060
+ Backspace: erase,
1061
+ Delete: erase,
872
1062
  ArrowUp: walk('up'),
873
1063
  ArrowDown: walk('down'),
874
- Tab: () => {
875
- this.step(r, c, 1);
876
- return true;
877
- },
878
- 'Shift-Tab': () => {
879
- this.step(r, c, -1);
880
- return true;
881
- },
1064
+ Tab: () => this.step(r, c, 1),
1065
+ 'Shift-Tab': () => this.step(r, c, -1),
882
1066
  Enter: () => {
883
1067
  const props = this.props();
884
1068
  if (r === rowCount(props) - 1)
@@ -895,9 +1079,18 @@ class TableIslandView {
895
1079
  }
896
1080
  };
897
1081
  }
898
- /** Tab's traversal: the next (or previous) cell in reading order. Past the last
899
- * cell it APPENDS a row, which is the growth affordance the keyboard has; before
900
- * the first it declines and the caret stays. */
1082
+ /**
1083
+ * Tab's traversal: the next (or previous) cell in reading order. Past the last cell it
1084
+ * appends a row, which is the growth affordance the keyboard has.
1085
+ *
1086
+ * It declines at both ends, and that is the island's keyboard exit: the key is not
1087
+ * swallowed, so the browser moves the focus out of the grid the way it moved it in.
1088
+ * Backward that end is the first cell. Forward it is the last cell of an empty
1089
+ * trailing row: a row is on offer, and walking off the end of an unwritten one
1090
+ * refuses it, the reading an empty item's Enter takes in a list (`lists.ts`). Growth
1091
+ * that never declined leaves Tab no forward exit at all, every press past the last
1092
+ * cell appending.
1093
+ */
901
1094
  step(r, c, dir) {
902
1095
  const props = this.props();
903
1096
  const cols = columnCount(props);
@@ -913,12 +1106,15 @@ class TableIslandView {
913
1106
  nc = cols - 1;
914
1107
  }
915
1108
  if (nr < 0)
916
- return;
1109
+ return false;
917
1110
  if (nr >= rows) {
1111
+ if (rowEmpty(props, r))
1112
+ return false;
918
1113
  this.write(insertRow(props, r), { r: nr, c: 0 });
919
- return;
920
1114
  }
921
- this.focusCell(nr, nc);
1115
+ else
1116
+ this.focusCell(nr, nc);
1117
+ return true;
922
1118
  }
923
1119
  }
924
1120
  /** The `island_block` node view: a table island's editing surface, and the literal