@quillmark/svelte 0.0.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 (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +58 -0
  3. package/README.md +241 -0
  4. package/THEMING.md +153 -0
  5. package/dist/core/address.d.ts +56 -0
  6. package/dist/core/address.js +95 -0
  7. package/dist/core/bloom.d.ts +37 -0
  8. package/dist/core/bloom.js +116 -0
  9. package/dist/core/codec/anchor.d.ts +19 -0
  10. package/dist/core/codec/anchor.js +36 -0
  11. package/dist/core/codec/code.d.ts +12 -0
  12. package/dist/core/codec/code.js +118 -0
  13. package/dist/core/codec/decode.d.ts +24 -0
  14. package/dist/core/codec/decode.js +270 -0
  15. package/dist/core/codec/encode.d.ts +71 -0
  16. package/dist/core/codec/encode.js +480 -0
  17. package/dist/core/codec/field.d.ts +171 -0
  18. package/dist/core/codec/field.js +493 -0
  19. package/dist/core/codec/index.d.ts +22 -0
  20. package/dist/core/codec/index.js +43 -0
  21. package/dist/core/codec/inputrules.d.ts +7 -0
  22. package/dist/core/codec/inputrules.js +168 -0
  23. package/dist/core/codec/islands.d.ts +36 -0
  24. package/dist/core/codec/islands.js +82 -0
  25. package/dist/core/codec/keymap.d.ts +13 -0
  26. package/dist/core/codec/keymap.js +29 -0
  27. package/dist/core/codec/lists.d.ts +22 -0
  28. package/dist/core/codec/lists.js +104 -0
  29. package/dist/core/codec/marks.d.ts +37 -0
  30. package/dist/core/codec/marks.js +74 -0
  31. package/dist/core/codec/positions.d.ts +17 -0
  32. package/dist/core/codec/positions.js +108 -0
  33. package/dist/core/codec/prose.css +458 -0
  34. package/dist/core/codec/reconcile.d.ts +26 -0
  35. package/dist/core/codec/reconcile.js +64 -0
  36. package/dist/core/codec/schema.d.ts +13 -0
  37. package/dist/core/codec/schema.js +155 -0
  38. package/dist/core/codec/slash.d.ts +79 -0
  39. package/dist/core/codec/slash.js +0 -0
  40. package/dist/core/codec/table-view.d.ts +50 -0
  41. package/dist/core/codec/table-view.js +928 -0
  42. package/dist/core/codec/table.d.ts +85 -0
  43. package/dist/core/codec/table.js +224 -0
  44. package/dist/core/errors.d.ts +73 -0
  45. package/dist/core/errors.js +24 -0
  46. package/dist/core/index.d.ts +5 -0
  47. package/dist/core/index.js +19 -0
  48. package/dist/core/lifecycle.d.ts +7 -0
  49. package/dist/core/lifecycle.js +21 -0
  50. package/dist/core/rebind.svelte.d.ts +18 -0
  51. package/dist/core/rebind.svelte.js +44 -0
  52. package/dist/core/teardown.d.ts +20 -0
  53. package/dist/core/teardown.js +37 -0
  54. package/dist/core/theme.css +276 -0
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.js +9 -0
  57. package/dist/preset/index.css +10 -0
  58. package/dist/preset/recipes.css +188 -0
  59. package/dist/preset/scale.css +97 -0
  60. package/dist/preview/Preview.svelte +118 -0
  61. package/dist/preview/Preview.svelte.d.ts +39 -0
  62. package/dist/preview/bridge.d.ts +13 -0
  63. package/dist/preview/bridge.js +88 -0
  64. package/dist/preview/controller.d.ts +40 -0
  65. package/dist/preview/controller.js +141 -0
  66. package/dist/preview/geometry.d.ts +37 -0
  67. package/dist/preview/geometry.js +45 -0
  68. package/dist/preview/index.d.ts +5 -0
  69. package/dist/preview/index.js +14 -0
  70. package/dist/preview/overlay.d.ts +10 -0
  71. package/dist/preview/overlay.js +82 -0
  72. package/dist/preview/paint.d.ts +17 -0
  73. package/dist/preview/paint.js +262 -0
  74. package/dist/preview/strings.d.ts +14 -0
  75. package/dist/preview/strings.js +22 -0
  76. package/dist/visual/ArrayField.svelte +362 -0
  77. package/dist/visual/ArrayField.svelte.d.ts +24 -0
  78. package/dist/visual/BooleanField.svelte +88 -0
  79. package/dist/visual/BooleanField.svelte.d.ts +20 -0
  80. package/dist/visual/Card.svelte +849 -0
  81. package/dist/visual/Card.svelte.d.ts +43 -0
  82. package/dist/visual/CardControls.svelte +92 -0
  83. package/dist/visual/CardControls.svelte.d.ts +11 -0
  84. package/dist/visual/DateField.svelte +203 -0
  85. package/dist/visual/DateField.svelte.d.ts +28 -0
  86. package/dist/visual/DiagnosticList.svelte +50 -0
  87. package/dist/visual/DiagnosticList.svelte.d.ts +7 -0
  88. package/dist/visual/EnumField.svelte +169 -0
  89. package/dist/visual/EnumField.svelte.d.ts +25 -0
  90. package/dist/visual/Field.svelte +270 -0
  91. package/dist/visual/Field.svelte.d.ts +37 -0
  92. package/dist/visual/FieldHint.svelte +185 -0
  93. package/dist/visual/FieldHint.svelte.d.ts +13 -0
  94. package/dist/visual/FieldLabel.svelte +141 -0
  95. package/dist/visual/FieldLabel.svelte.d.ts +19 -0
  96. package/dist/visual/FormatPopover.svelte +396 -0
  97. package/dist/visual/FormatPopover.svelte.d.ts +9 -0
  98. package/dist/visual/NumberField.svelte +72 -0
  99. package/dist/visual/NumberField.svelte.d.ts +19 -0
  100. package/dist/visual/ObjectField.svelte +131 -0
  101. package/dist/visual/ObjectField.svelte.d.ts +17 -0
  102. package/dist/visual/ProseArrayElement.svelte +98 -0
  103. package/dist/visual/ProseArrayElement.svelte.d.ts +19 -0
  104. package/dist/visual/ProseField.svelte +213 -0
  105. package/dist/visual/ProseField.svelte.d.ts +50 -0
  106. package/dist/visual/SlashMenu.svelte +126 -0
  107. package/dist/visual/SlashMenu.svelte.d.ts +14 -0
  108. package/dist/visual/TextField.svelte +63 -0
  109. package/dist/visual/TextField.svelte.d.ts +23 -0
  110. package/dist/visual/TipsCard.svelte +191 -0
  111. package/dist/visual/TipsCard.svelte.d.ts +13 -0
  112. package/dist/visual/VisualEditor.svelte +96 -0
  113. package/dist/visual/VisualEditor.svelte.d.ts +14 -0
  114. package/dist/visual/VisualEditorInner.svelte +1032 -0
  115. package/dist/visual/VisualEditorInner.svelte.d.ts +17 -0
  116. package/dist/visual/controls.css +272 -0
  117. package/dist/visual/diagnostics.d.ts +52 -0
  118. package/dist/visual/diagnostics.js +83 -0
  119. package/dist/visual/domid.d.ts +21 -0
  120. package/dist/visual/domid.js +36 -0
  121. package/dist/visual/ext.d.ts +19 -0
  122. package/dist/visual/ext.js +29 -0
  123. package/dist/visual/icons/Icon.svelte +39 -0
  124. package/dist/visual/icons/Icon.svelte.d.ts +12 -0
  125. package/dist/visual/icons/nodes.d.ts +103 -0
  126. package/dist/visual/icons/nodes.js +55 -0
  127. package/dist/visual/index.d.ts +9 -0
  128. package/dist/visual/index.js +24 -0
  129. package/dist/visual/motion.d.ts +18 -0
  130. package/dist/visual/motion.js +53 -0
  131. package/dist/visual/props.d.ts +77 -0
  132. package/dist/visual/props.js +1 -0
  133. package/dist/visual/signals.d.ts +59 -0
  134. package/dist/visual/signals.js +1 -0
  135. package/dist/visual/strings.d.ts +123 -0
  136. package/dist/visual/strings.js +80 -0
  137. package/dist/visual/structure.d.ts +224 -0
  138. package/dist/visual/structure.js +270 -0
  139. package/dist/visual/synced.svelte.d.ts +15 -0
  140. package/dist/visual/synced.svelte.js +34 -0
  141. package/dist/visual/tips.d.ts +24 -0
  142. package/dist/visual/tips.js +50 -0
  143. package/package.json +102 -0
@@ -0,0 +1,928 @@
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.
5
+ //
6
+ // A cell is a second content unit inside the first, so it gets the codec's INLINE mode
7
+ // (one paragraph, 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.
12
+ //
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.
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").
24
+ import { baseKeymap, toggleMark } from 'prosemirror-commands';
25
+ import { redo, undo } from 'prosemirror-history';
26
+ import { keymap } from 'prosemirror-keymap';
27
+ import { EditorState, NodeSelection, Selection, TextSelection } from 'prosemirror-state';
28
+ import { EditorView } from 'prosemirror-view';
29
+ import { decode } from './decode.js';
30
+ import { inputRulesPlugin } from './inputrules.js';
31
+ import { tablePropsOfNode } from './islands.js';
32
+ 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';
34
+ /**
35
+ * The package's English for the island chrome. It lives HERE, beside the surface
36
+ * that draws it, rather than in the visual tier's table: the codec mounts this
37
+ * chrome and a consumer reaching `createField` directly gets wording with it. The
38
+ * visual `strings` set extends this one, so a consumer overrides these keys beside
39
+ * every other key and there is still one English list.
40
+ */
41
+ export const DEFAULT_TABLE_STRINGS = {
42
+ tableLabel: 'Table',
43
+ tableHeaderRow: 'Header row',
44
+ tableRow: (index) => `Row ${index}`,
45
+ tableColumn: (index) => `Column ${index}`,
46
+ tableCell: (row, column) => `${row}, ${column}`,
47
+ tableSelectRow: (index) => `Select row ${index}`,
48
+ 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'
56
+ };
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']
68
+ };
69
+ function svg(paths) {
70
+ const el = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
71
+ el.setAttribute('viewBox', '0 0 24 24');
72
+ el.setAttribute('fill', 'none');
73
+ el.setAttribute('stroke', 'currentColor');
74
+ el.setAttribute('stroke-width', '2');
75
+ el.setAttribute('stroke-linecap', 'round');
76
+ el.setAttribute('stroke-linejoin', 'round');
77
+ el.setAttribute('aria-hidden', 'true');
78
+ for (const d of paths) {
79
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
80
+ path.setAttribute('d', d);
81
+ el.appendChild(path);
82
+ }
83
+ return el;
84
+ }
85
+ function el(tag, className) {
86
+ const node = document.createElement(tag);
87
+ if (className)
88
+ node.className = className;
89
+ return node;
90
+ }
91
+ /** A chrome button. It swallows its own `mousedown` (prosemirror-menu's trick, and
92
+ * 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
94
+ * and takes it explicitly, which is why this does not hand it out. */
95
+ function chromeButton(className, label, run) {
96
+ const btn = el('button', className);
97
+ btn.type = 'button';
98
+ btn.title = label;
99
+ btn.setAttribute('aria-label', label);
100
+ btn.addEventListener('mousedown', (e) => e.preventDefault());
101
+ btn.addEventListener('click', (e) => {
102
+ e.preventDefault();
103
+ run();
104
+ });
105
+ return btn;
106
+ }
107
+ /** A cell's plugin stack: marks and the markdown shorthands, and nothing that
108
+ * belongs to the field (history, anchors, the ghost). */
109
+ function cellPlugins(keys) {
110
+ return [inputRulesPlugin(inlineSchema), keymap(keys), keymap(baseKeymap)];
111
+ }
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';
116
+ /** 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. */
118
+ const DEAD_ZONE = 3;
119
+ /** A line's key in the handle registry, spelled once. */
120
+ const lineKey = (line) => `${line.axis}:${line.index}`;
121
+ const within = (rect, p) => p.x >= rect.left && p.x <= rect.right && p.y >= rect.top && p.y <= rect.bottom;
122
+ /** A point's distance to a rect: zero inside it, the gap to the nearest edge outside. */
123
+ function distance(rect, p) {
124
+ 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
+ }
126
+ class TableIslandView {
127
+ node;
128
+ outer;
129
+ getPos;
130
+ deps;
131
+ dom;
132
+ cells = [];
133
+ /** The props the current DOM was built from: what an `update` compares against to
134
+ * tell a reseed from a rebuild. */
135
+ rendered;
136
+ selected;
137
+ 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;
145
+ dropMark;
146
+ /** The one alignment cluster, parked out of the tree until a column is selected. */
147
+ align;
148
+ /** A drag's trailing `click`, which would otherwise re-select the moved line. */
149
+ suppressClick = false;
150
+ constructor(node, outer, getPos, deps) {
151
+ this.node = node;
152
+ this.outer = outer;
153
+ this.getPos = getPos;
154
+ this.deps = deps;
155
+ this.dom = el('div', 'qm-island qm-table-island');
156
+ this.dom.setAttribute('data-island', node.attrs.islandType);
157
+ this.dom.setAttribute('data-island-id', node.attrs.id);
158
+ this.dom.addEventListener('mousedown', this.onPointerDown);
159
+ this.dom.addEventListener('mouseover', this.onHover);
160
+ this.dom.addEventListener('mouseleave', this.onLeave);
161
+ this.render();
162
+ }
163
+ // ── PM's NodeView contract ────────────────────────────────────────────────
164
+ update(node) {
165
+ if (node.type !== this.node.type)
166
+ return false;
167
+ const before = this.rendered;
168
+ this.node = node;
169
+ const props = tablePropsOfNode(node);
170
+ if (!props || !before || !shapeEqual(before, props)) {
171
+ this.render();
172
+ return true;
173
+ }
174
+ // 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
176
+ // it stored, so it compares equal and keeps its caret; an undo or an external
177
+ // re-hydrate does not, and takes the fresh state.
178
+ this.rendered = props;
179
+ for (const mounted of this.cells) {
180
+ const stored = cellAt(props, mounted.r, mounted.c);
181
+ if (cellEqual(stored, cellFromDoc(mounted.view.state.doc, stored)))
182
+ continue;
183
+ mounted.view.updateState(EditorState.create({
184
+ doc: decode(cellContent(stored), inlineSchema),
185
+ plugins: cellPlugins(this.cellKeys(mounted.r, mounted.c))
186
+ }));
187
+ }
188
+ return true;
189
+ }
190
+ /** The nested views own every event inside a cell or a control; everything else
191
+ * 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
193
+ * {@link TableIslandView.onPointerDown}'s and not this one's: `stopEvent` gates
194
+ * the whole subtree, so widening it would take that routing with it. */
195
+ stopEvent(event) {
196
+ const target = event.target;
197
+ return !!target?.closest?.(OWNED);
198
+ }
199
+ /** Nothing in this subtree is PM-managed: the cells are separate views. */
200
+ ignoreMutation() {
201
+ return true;
202
+ }
203
+ destroy() {
204
+ this.dom.removeEventListener('mousedown', this.onPointerDown);
205
+ this.dom.removeEventListener('mouseover', this.onHover);
206
+ this.dom.removeEventListener('mouseleave', this.onLeave);
207
+ this.endDrag();
208
+ this.teardownCells();
209
+ }
210
+ // ── The pointer ───────────────────────────────────────────────────────────
211
+ /**
212
+ * 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.
214
+ *
215
+ * A `mousedown` listener, and it stops the event: PM's own mousedown is what arms
216
+ * the node selection the matching mouseup then takes. `stopEvent` is the other way
217
+ * to reach that, and it gates the subtree's keydown and drag routing too.
218
+ */
219
+ 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.
223
+ if (!this.rendered || event.button !== 0)
224
+ return;
225
+ const target = event.target;
226
+ if (target?.closest?.(OWNED))
227
+ return; // a cell or a control answers for itself
228
+ event.preventDefault();
229
+ event.stopPropagation();
230
+ const point = { x: event.clientX, y: event.clientY };
231
+ const grid = this.dom.querySelector('.qm-table');
232
+ if (grid && within(grid.getBoundingClientRect(), point))
233
+ this.focusNearestCell(point);
234
+ else
235
+ this.caretBeside(point);
236
+ };
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
+ /** The cell a press inside the frame belongs to: the nearest by rect, which for a
265
+ * press on a border or a cell's own padding is the cell it is against. */
266
+ focusNearestCell(point) {
267
+ let best;
268
+ let nearest = Infinity;
269
+ for (const mounted of this.cells) {
270
+ const at = distance(mounted.host.getBoundingClientRect(), point);
271
+ if (at >= nearest)
272
+ continue;
273
+ nearest = at;
274
+ best = mounted;
275
+ }
276
+ if (best)
277
+ this.focusCell(best.r, best.c);
278
+ }
279
+ /** A caret beside the island, on the side the press landed: a GAP CURSOR where the
280
+ * document holds no text position there, and the neighbouring block's own edge
281
+ * where it holds one.
282
+ *
283
+ * The gap is asked for through the plugin's own `createSelectionBetween` rather
284
+ * than built here: whether a position takes one is the gap cursor's rule, and a
285
+ * leaf mounted without that plugin then answers "no gap" instead of dispatching
286
+ * a selection nothing draws. */
287
+ caretBeside(point) {
288
+ const pos = this.getPos();
289
+ if (pos == null)
290
+ return;
291
+ const box = this.dom.getBoundingClientRect();
292
+ const before = point.y < (box.top + box.bottom) / 2;
293
+ const $at = this.outer.state.doc.resolve(before ? pos : pos + this.node.nodeSize);
294
+ const selection = this.outer.someProp('createSelectionBetween', (f) => f(this.outer, $at, $at)) ??
295
+ Selection.findFrom($at, before ? -1 : 1, true);
296
+ if (!selection)
297
+ return;
298
+ this.outer.focus();
299
+ this.outer.dispatch(this.outer.state.tr.setSelection(selection));
300
+ }
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"). */
304
+ selectIsland() {
305
+ const pos = this.getPos();
306
+ if (pos == null)
307
+ return;
308
+ this.clearLine();
309
+ this.outer.focus();
310
+ this.outer.dispatch(this.outer.state.tr.setSelection(NodeSelection.create(this.outer.state.doc, pos)));
311
+ }
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));
316
+ }
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. */
319
+ bounds(axis, props) {
320
+ return axis === 'row'
321
+ ? { floor: 1, limit: rowCount(props) - 1 }
322
+ : { floor: 0, limit: columnCount(props) - 1 };
323
+ }
324
+ /** Select a line. The handle takes the FOCUS, because the selection's keys bind
325
+ * there and need somewhere to land. */
326
+ selectLine(line) {
327
+ // One subject at a time: a line selection retires an island one, or the surface
328
+ // paints a washed row inside an outlined table and the next Backspace has two
329
+ // honest readings.
330
+ const pos = this.getPos();
331
+ const outer = this.outer.state.selection;
332
+ if (pos != null && outer instanceof NodeSelection && outer.from === pos) {
333
+ const $at = this.outer.state.doc.resolve(pos);
334
+ const beside = this.outer.someProp('createSelectionBetween', (f) => f(this.outer, $at, $at)) ??
335
+ Selection.findFrom($at, -1, true);
336
+ if (beside)
337
+ this.outer.dispatch(this.outer.state.tr.setSelection(beside));
338
+ }
339
+ this.selected = line;
340
+ this.paintLine();
341
+ this.handleFor(line)?.focus();
342
+ }
343
+ clearLine() {
344
+ if (!this.selected)
345
+ return;
346
+ 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);
360
+ }
361
+ this.paintAlign();
362
+ }
363
+ /**
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
366
+ * otherwise aim the next key at whichever line was last pressed.
367
+ *
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.
371
+ */
372
+ lineKeys(line) {
373
+ return (event) => {
374
+ const key = event.key;
375
+ const mod = event.ctrlKey || event.metaKey;
376
+ if (mod && (key === 'z' || key === 'Z' || key === 'y')) {
377
+ event.preventDefault();
378
+ const redoing = key === 'y' || (key === 'Z' && event.shiftKey);
379
+ (redoing ? redo : undo)(this.outer.state, this.outer.dispatch);
380
+ return;
381
+ }
382
+ const along = line.axis === 'row' ? ['ArrowUp', 'ArrowDown'] : ['ArrowLeft', 'ArrowRight'];
383
+ const acts = along.includes(key) || ['Backspace', 'Delete', 'Enter', 'Escape'].includes(key);
384
+ if (!acts)
385
+ return;
386
+ event.preventDefault();
387
+ event.stopPropagation();
388
+ if (key === 'Escape')
389
+ return this.selectIsland();
390
+ if (key === 'Enter') {
391
+ this.clearLine();
392
+ return line.axis === 'row' ? this.focusCell(line.index, 0) : this.focusCell(0, line.index);
393
+ }
394
+ if (key === 'Backspace' || key === 'Delete')
395
+ return this.deleteLine(line);
396
+ const by = key === 'ArrowUp' || key === 'ArrowLeft' ? -1 : 1;
397
+ if (event.altKey)
398
+ return this.moveLine(line, by);
399
+ const { floor, limit } = this.bounds(line.axis, this.props());
400
+ this.selectLine({
401
+ axis: line.axis,
402
+ index: Math.max(floor, Math.min(line.index + by, limit))
403
+ });
404
+ };
405
+ }
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) {
410
+ 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();
418
+ return;
419
+ }
420
+ if (columnCount(props) <= 1) {
421
+ this.write(clearColumn(props, line.index));
422
+ this.selectLine(line);
423
+ return;
424
+ }
425
+ this.write(deleteColumn(props, line.index));
426
+ this.selectLine({ axis: 'column', index: Math.max(0, line.index - 1) });
427
+ }
428
+ /** Move the line and keep it selected: a move whose selection did not travel would
429
+ * leave the next Alt+arrow acting on whatever took the index. */
430
+ moveLine(line, by) {
431
+ const props = this.props();
432
+ const { floor, limit } = this.bounds(line.axis, props);
433
+ const to = Math.max(floor, Math.min(line.index + by, limit));
434
+ if (to === line.index)
435
+ return;
436
+ this.write(line.axis === 'row' ? moveRow(props, line.index, by) : moveColumn(props, line.index, by));
437
+ this.selectLine({ axis: line.axis, index: to });
438
+ }
439
+ // ── Drag to reorder ───────────────────────────────────────────────────────
440
+ /**
441
+ * Press-and-drag a handle moves its line. The press still SELECTS: the dead zone is
442
+ * what tells the two apart, so a click that jitters is the gesture it was aimed as
443
+ * and only a real travel becomes a drag.
444
+ */
445
+ onHandleDown = (line, handle, event) => {
446
+ if (event.button !== 0)
447
+ return;
448
+ this.drag = {
449
+ line,
450
+ origin: { x: event.clientX, y: event.clientY },
451
+ engaged: false,
452
+ drop: line.index,
453
+ painted: -1,
454
+ pointerId: event.pointerId,
455
+ handle,
456
+ lines: [],
457
+ origin0: { left: 0, top: 0 }
458
+ };
459
+ handle.setPointerCapture(event.pointerId);
460
+ handle.addEventListener('pointermove', this.onHandleMove);
461
+ handle.addEventListener('pointerup', this.onHandleUp);
462
+ handle.addEventListener('pointercancel', this.onHandleUp);
463
+ };
464
+ onHandleMove = (event) => {
465
+ const drag = this.drag;
466
+ if (!drag)
467
+ return;
468
+ const travel = Math.hypot(event.clientX - drag.origin.x, event.clientY - drag.origin.y);
469
+ if (!drag.engaged && travel < DEAD_ZONE)
470
+ return;
471
+ if (!drag.engaged)
472
+ this.engage(drag);
473
+ drag.drop = this.dropIndex(drag, drag.line.axis === 'row' ? event.clientY : event.clientX);
474
+ this.paintDrop(drag);
475
+ };
476
+ /** The drag becomes one: lift the line, and measure the table ONCE. */
477
+ engage(drag) {
478
+ drag.engaged = true;
479
+ this.dom.classList.add('qm-table-dragging');
480
+ drag.handle.classList.add('qm-table-lifted');
481
+ for (const cell of this.lineCells(drag.line))
482
+ cell.classList.add('qm-table-lifted');
483
+ const scroller = this.scroller;
484
+ if (!scroller)
485
+ return;
486
+ const box = scroller.getBoundingClientRect();
487
+ drag.origin0 = { left: box.left - scroller.scrollLeft, top: box.top - scroller.scrollTop };
488
+ const { floor, limit } = this.bounds(drag.line.axis, this.props());
489
+ for (let i = floor; i <= limit; i++) {
490
+ const cells = this.lineCells({ axis: drag.line.axis, index: i });
491
+ if (!cells.length)
492
+ continue;
493
+ const head = cells[0].getBoundingClientRect();
494
+ const tail = cells[cells.length - 1].getBoundingClientRect();
495
+ drag.lines.push(drag.line.axis === 'row'
496
+ ? {
497
+ index: i,
498
+ start: head.top,
499
+ end: head.bottom,
500
+ cross: head.left,
501
+ span: tail.right - head.left
502
+ }
503
+ : {
504
+ index: i,
505
+ start: head.left,
506
+ end: head.right,
507
+ cross: head.top,
508
+ span: tail.bottom - head.top
509
+ });
510
+ }
511
+ }
512
+ onHandleUp = () => {
513
+ const drag = this.drag;
514
+ if (!drag)
515
+ return;
516
+ const { line, drop, engaged } = drag;
517
+ this.endDrag();
518
+ if (!engaged)
519
+ return;
520
+ // The press that ends a drag is not the press that selects: the click still to
521
+ // come would re-select the line the drag just moved off.
522
+ this.suppressClick = true;
523
+ if (drop !== line.index)
524
+ this.moveLine(line, drop - line.index);
525
+ else
526
+ this.selectLine(line);
527
+ };
528
+ endDrag() {
529
+ const drag = this.drag;
530
+ if (!drag)
531
+ return;
532
+ this.drag = undefined;
533
+ this.dropMark?.remove();
534
+ this.dropMark = undefined;
535
+ this.dom.classList.remove('qm-table-dragging');
536
+ for (const lifted of this.dom.querySelectorAll('.qm-table-lifted'))
537
+ 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);
543
+ }
544
+ /** Which line the pointer is over, along the drag's axis: the nearest by the extents
545
+ * measured at engage. One dimension, because every cell of a row shares its top and
546
+ * bottom and every cell of a column shares its left and right, so the cross-axis
547
+ * term is identical across the candidates and cancels out of the comparison. */
548
+ dropIndex(drag, at) {
549
+ let best = drag.line.index;
550
+ let nearest = Infinity;
551
+ for (const line of drag.lines) {
552
+ const gap = at < line.start ? line.start - at : at > line.end ? at - line.end : 0;
553
+ if (gap >= nearest)
554
+ continue;
555
+ nearest = gap;
556
+ best = line.index;
557
+ }
558
+ return best;
559
+ }
560
+ lineCells(line) {
561
+ return this.cells
562
+ .filter((m) => (line.axis === 'row' ? m.r === line.index : m.c === line.index))
563
+ .map((m) => m.box);
564
+ }
565
+ /** 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
567
+ * boundary changes, which is once per line crossed rather than once per move. */
568
+ paintDrop(drag) {
569
+ if (drag.drop === drag.painted)
570
+ return;
571
+ const line = drag.lines.find((l) => l.index === drag.drop);
572
+ const scroller = this.scroller;
573
+ if (!line || !scroller)
574
+ return;
575
+ drag.painted = drag.drop;
576
+ if (!this.dropMark) {
577
+ this.dropMark = el('div', 'qm-table-drop');
578
+ this.dropMark.setAttribute('data-axis', drag.line.axis);
579
+ scroller.appendChild(this.dropMark);
580
+ }
581
+ const edge = (drag.drop > drag.line.index ? line.end : line.start) - 1;
582
+ const mark = this.dropMark;
583
+ if (drag.line.axis === 'row') {
584
+ mark.style.top = `${edge - drag.origin0.top}px`;
585
+ mark.style.left = `${line.cross - drag.origin0.left}px`;
586
+ mark.style.width = `${line.span}px`;
587
+ }
588
+ else {
589
+ mark.style.left = `${edge - drag.origin0.left}px`;
590
+ mark.style.top = `${line.cross - drag.origin0.top}px`;
591
+ mark.style.height = `${line.span}px`;
592
+ }
593
+ }
594
+ // ── Render ────────────────────────────────────────────────────────────────
595
+ teardownCells() {
596
+ for (const mounted of this.cells) {
597
+ mounted.unregister();
598
+ mounted.view.destroy();
599
+ }
600
+ this.cells = [];
601
+ }
602
+ render() {
603
+ this.endDrag();
604
+ this.teardownCells();
605
+ this.handles.clear();
606
+ this.dom.textContent = '';
607
+ const props = tablePropsOfNode(this.node);
608
+ this.rendered = props;
609
+ if (!props) {
610
+ // Any other island type: the literal placeholder `toDOM` draws (islands.ts).
611
+ this.dom.appendChild(document.createTextNode(`[${this.node.attrs.islandType || 'island'}]`));
612
+ return;
613
+ }
614
+ const s = this.deps.strings();
615
+ this.dom.setAttribute('role', 'group');
616
+ this.dom.setAttribute('aria-label', s.tableLabel);
617
+ // `thead`/`tbody` rather than one `tbody`: the header is a separate field in the
618
+ // model, and this is the markup that says so to something that cannot see the
619
+ // weight the header row draws.
620
+ const table = el('table', 'qm-table');
621
+ const head = el('thead');
622
+ head.appendChild(this.row(props, 0, s));
623
+ const body = el('tbody');
624
+ for (let r = 1; r < rowCount(props); r++)
625
+ body.appendChild(this.row(props, r, s));
626
+ table.append(head, body);
627
+ const scroller = el('div', 'qm-table-scroller');
628
+ scroller.appendChild(table);
629
+ this.scroller = scroller;
630
+ this.align = this.alignCluster(s);
631
+ this.dom.appendChild(scroller);
632
+ // Both derived paints re-apply: a rebuild replaced every element they wrote on.
633
+ this.paintLine();
634
+ this.paintHot();
635
+ }
636
+ /** One table row: its cells, each carrying whatever chrome hangs off it. */
637
+ row(props, r, s) {
638
+ const tr = el('tr', r === 0 ? 'qm-table-header-row' : undefined);
639
+ const cols = columnCount(props);
640
+ rowCells(props, r).forEach((cell, c) => {
641
+ const box = el(r === 0 ? 'th' : 'td', 'qm-table-cell');
642
+ if (r === 0)
643
+ box.setAttribute('scope', 'col');
644
+ box.setAttribute('data-r', String(r));
645
+ box.setAttribute('data-c', String(c));
646
+ const align = props.aligns[c] ?? 'none';
647
+ if (align !== 'none')
648
+ box.style.textAlign = align;
649
+ const host = el('div', 'qm-table-cell-host');
650
+ 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
+ }
672
+ tr.appendChild(box);
673
+ this.mountCell(box, host, r, c, s);
674
+ });
675
+ return tr;
676
+ }
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, () => {
681
+ if (this.suppressClick)
682
+ this.suppressClick = false;
683
+ else
684
+ this.selectLine(line);
685
+ });
686
+ btn.setAttribute('aria-pressed', 'false');
687
+ 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));
690
+ btn.addEventListener('keydown', this.lineKeys(line));
691
+ this.handles.set(lineKey(line), btn);
692
+ return btn;
693
+ }
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, () => {
706
+ const props = this.props();
707
+ if (axis === 'row')
708
+ this.write(insertRow(props, index - 1), { r: index, c: 0 });
709
+ else
710
+ this.write(insertColumn(props, index - 1), { r: 0, c: index });
711
+ });
712
+ btn.setAttribute('data-axis', axis);
713
+ btn.setAttribute('data-side', side);
714
+ btn.appendChild(svg(PLUS));
715
+ return btn;
716
+ }
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
+ mountCell(box, host, r, c, s) {
767
+ const props = this.props();
768
+ const name = s.tableCell(r === 0 ? s.tableHeaderRow : s.tableRow(r), s.tableColumn(c + 1));
769
+ const view = new EditorView(host, {
770
+ state: EditorState.create({
771
+ doc: decode(cellContent(cellAt(props, r, c)), inlineSchema),
772
+ plugins: cellPlugins(this.cellKeys(r, c))
773
+ }),
774
+ attributes: { 'aria-label': name, class: 'qm-table-cell-editor' },
775
+ dispatchTransaction: (tr) => {
776
+ const next = view.state.apply(tr);
777
+ view.updateState(next);
778
+ if (!tr.docChanged)
779
+ return;
780
+ const now = this.props();
781
+ this.write(withCell(now, r, c, cellFromDoc(next.doc, cellAt(now, r, c))));
782
+ },
783
+ handleDOMEvents: {
784
+ focus: () => {
785
+ this.clearLine();
786
+ this.deps.onCellFocus();
787
+ return false;
788
+ }
789
+ }
790
+ });
791
+ this.cells.push({ view, host, box, unregister: this.deps.register(view), r, c });
792
+ }
793
+ // ── Ops ───────────────────────────────────────────────────────────────────
794
+ /** The table this view is currently showing; the zero table if the node stopped
795
+ * being one, which no op can produce. */
796
+ props() {
797
+ return tablePropsOfNode(this.node) ?? normalizeTable({ header: [], rows: [], aligns: [] });
798
+ }
799
+ /**
800
+ * Commit a new rectangle onto the node. `setNodeMarkup` is an ordinary PM
801
+ * 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
803
+ * table rather than mutating the props in place.
804
+ */
805
+ write(next, focus) {
806
+ const pos = this.getPos();
807
+ if (pos == null)
808
+ return;
809
+ const node = this.outer.state.doc.nodeAt(pos);
810
+ if (!node || node.type !== this.node.type)
811
+ return;
812
+ this.outer.dispatch(this.outer.state.tr.setNodeMarkup(pos, undefined, {
813
+ ...node.attrs,
814
+ props: normalizeTable(next)
815
+ }));
816
+ if (focus)
817
+ this.focusCell(focus.r, focus.c);
818
+ }
819
+ /** Land the caret at the end of a cell, clamped into the rectangle: where a row
820
+ * or column op puts it, since the op rebuilt the views the caret was in. */
821
+ focusCell(r, c) {
822
+ const props = this.props();
823
+ const row = Math.max(0, Math.min(r, rowCount(props) - 1));
824
+ const col = Math.max(0, Math.min(c, columnCount(props) - 1));
825
+ const mounted = this.cells.find((m) => m.r === row && m.c === col);
826
+ if (!mounted)
827
+ return;
828
+ const { view } = mounted;
829
+ view.focus();
830
+ view.dispatch(view.state.tr.setSelection(Selection.atEnd(view.state.doc)));
831
+ }
832
+ /**
833
+ * 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
835
+ * keymap never sees a keystroke a cell handled (`stopEvent`).
836
+ *
837
+ * 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
839
+ * a cell has no representation to be a preference about.
840
+ */
841
+ cellKeys(r, c) {
842
+ const marks = {};
843
+ if (inlineSchema.marks.strong)
844
+ marks['Mod-b'] = toggleMark(inlineSchema.marks.strong);
845
+ if (inlineSchema.marks.em)
846
+ marks['Mod-i'] = toggleMark(inlineSchema.marks.em);
847
+ if (inlineSchema.marks.underline)
848
+ marks['Mod-u'] = toggleMark(inlineSchema.marks.underline);
849
+ // Up and down are the grid's own walk: nothing else moves the caret vertically,
850
+ // and `focusCell` clamps, so neither can grow the table. Left and right are
851
+ // deliberately absent: at a text edge they would call what Tab and Shift-Tab
852
+ // already call, and inherit the append-past-the-last-cell that makes Tab a
853
+ // growth affordance and would make a caret key one.
854
+ const walk = (dir) => {
855
+ return (_state, _dispatch, view) => {
856
+ const { selection } = view?.state ?? {};
857
+ if (!view || !(selection instanceof TextSelection) || !selection.empty)
858
+ return false;
859
+ if (!view.endOfTextblock(dir))
860
+ return false;
861
+ this.focusCell(dir === 'up' ? r - 1 : r + 1, c);
862
+ return true;
863
+ };
864
+ };
865
+ return {
866
+ ...marks,
867
+ // One undo stack per leaf: a cell carries no history of its own, so Mod-z
868
+ // unwinds a cell keystroke and a row op in the order they happened.
869
+ 'Mod-z': () => undo(this.outer.state, this.outer.dispatch),
870
+ 'Mod-y': () => redo(this.outer.state, this.outer.dispatch),
871
+ 'Shift-Mod-z': () => redo(this.outer.state, this.outer.dispatch),
872
+ ArrowUp: walk('up'),
873
+ 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
+ },
882
+ Enter: () => {
883
+ const props = this.props();
884
+ if (r === rowCount(props) - 1)
885
+ this.write(insertRow(props, r), { r: r + 1, c });
886
+ else
887
+ this.focusCell(r + 1, c);
888
+ return true;
889
+ },
890
+ // The innermost Escape: out of the cell, onto the island. What the next one
891
+ // means is the shell's (VISUAL_EDITOR §"Settled and open").
892
+ Escape: () => {
893
+ this.selectIsland();
894
+ return true;
895
+ }
896
+ };
897
+ }
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. */
901
+ step(r, c, dir) {
902
+ const props = this.props();
903
+ const cols = columnCount(props);
904
+ const rows = rowCount(props);
905
+ let nr = r;
906
+ let nc = c + dir;
907
+ if (nc >= cols) {
908
+ nr = r + 1;
909
+ nc = 0;
910
+ }
911
+ else if (nc < 0) {
912
+ nr = r - 1;
913
+ nc = cols - 1;
914
+ }
915
+ if (nr < 0)
916
+ return;
917
+ if (nr >= rows) {
918
+ this.write(insertRow(props, r), { r: nr, c: 0 });
919
+ return;
920
+ }
921
+ this.focusCell(nr, nc);
922
+ }
923
+ }
924
+ /** The `island_block` node view: a table island's editing surface, and the literal
925
+ * placeholder for every other island type. */
926
+ export function tableNodeView(deps) {
927
+ return (node, view, getPos) => new TableIslandView(node, view, getPos, deps);
928
+ }