@quillmark/svelte 0.1.1 → 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.
- package/README.md +16 -12
- package/THEMING.md +48 -25
- package/dist/core/address.d.ts +42 -7
- package/dist/core/address.js +49 -21
- package/dist/core/bloom.d.ts +5 -5
- package/dist/core/bloom.js +8 -8
- package/dist/core/codec/anchor.d.ts +1 -1
- package/dist/core/codec/anchor.js +1 -1
- package/dist/core/codec/atoms.d.ts +10 -0
- package/dist/core/codec/atoms.js +78 -0
- package/dist/core/codec/clipboard.d.ts +59 -0
- package/dist/core/codec/clipboard.js +202 -0
- package/dist/core/codec/code.d.ts +1 -1
- package/dist/core/codec/code.js +4 -4
- package/dist/core/codec/decode.d.ts +1 -1
- package/dist/core/codec/decode.js +8 -8
- package/dist/core/codec/encode.d.ts +4 -4
- package/dist/core/codec/encode.js +14 -11
- package/dist/core/codec/field.d.ts +22 -27
- package/dist/core/codec/field.js +33 -32
- package/dist/core/codec/index.js +5 -8
- package/dist/core/codec/inputrules.js +56 -18
- package/dist/core/codec/islands.d.ts +11 -7
- package/dist/core/codec/islands.js +16 -10
- package/dist/core/codec/keymap.d.ts +5 -6
- package/dist/core/codec/keymap.js +8 -7
- package/dist/core/codec/lists.d.ts +3 -3
- package/dist/core/codec/lists.js +32 -13
- package/dist/core/codec/marks.d.ts +1 -1
- package/dist/core/codec/marks.js +1 -1
- package/dist/core/codec/positions.js +1 -1
- package/dist/core/codec/prose.css +266 -228
- package/dist/core/codec/schema.js +4 -4
- package/dist/core/codec/slash.d.ts +39 -41
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-view.d.ts +12 -16
- package/dist/core/codec/table-view.js +488 -292
- package/dist/core/codec/table.d.ts +42 -15
- package/dist/core/codec/table.js +73 -27
- package/dist/core/errors.d.ts +17 -7
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +6 -5
- package/dist/core/lifecycle.d.ts +21 -5
- package/dist/core/lifecycle.js +34 -8
- package/dist/core/rebind.svelte.d.ts +18 -4
- package/dist/core/rebind.svelte.js +30 -12
- package/dist/core/teardown.d.ts +1 -1
- package/dist/core/teardown.js +1 -1
- package/dist/core/theme.css +173 -58
- package/dist/preset/index.css +1 -1
- package/dist/preset/recipes.css +79 -30
- package/dist/preset/scale.css +40 -28
- package/dist/preview/Preview.svelte +22 -19
- package/dist/preview/Preview.svelte.d.ts +8 -8
- package/dist/preview/bridge.d.ts +6 -4
- package/dist/preview/bridge.js +19 -9
- package/dist/preview/controller.d.ts +13 -6
- package/dist/preview/controller.js +8 -5
- package/dist/preview/geometry.d.ts +17 -2
- package/dist/preview/geometry.js +21 -1
- package/dist/preview/index.d.ts +1 -0
- package/dist/preview/index.js +9 -2
- package/dist/preview/overlay.d.ts +2 -1
- package/dist/preview/overlay.js +26 -11
- package/dist/preview/paint.js +19 -17
- package/dist/visual/ArrayField.svelte +103 -83
- package/dist/visual/ArrayField.svelte.d.ts +5 -4
- package/dist/visual/BooleanField.svelte +8 -6
- package/dist/visual/BooleanField.svelte.d.ts +2 -2
- package/dist/visual/Card.svelte +161 -333
- package/dist/visual/Card.svelte.d.ts +5 -4
- package/dist/visual/CardControls.svelte +1 -1
- package/dist/visual/DateField.svelte +19 -19
- package/dist/visual/DateField.svelte.d.ts +3 -3
- package/dist/visual/DiagnosticList.svelte +2 -2
- package/dist/visual/EnumField.svelte +48 -28
- package/dist/visual/EnumField.svelte.d.ts +5 -4
- package/dist/visual/Field.svelte +207 -132
- package/dist/visual/Field.svelte.d.ts +11 -7
- package/dist/visual/FieldHint.svelte +79 -48
- package/dist/visual/FieldHint.svelte.d.ts +1 -1
- package/dist/visual/FieldLabel.svelte +8 -8
- package/dist/visual/FormatPopover.svelte +82 -35
- package/dist/visual/NumberField.svelte +5 -5
- package/dist/visual/NumberField.svelte.d.ts +1 -1
- package/dist/visual/ObjectField.svelte +28 -9
- package/dist/visual/ObjectField.svelte.d.ts +4 -2
- package/dist/visual/ProseArrayElement.svelte +12 -19
- package/dist/visual/ProseArrayElement.svelte.d.ts +1 -2
- package/dist/visual/ProseField.svelte +62 -85
- package/dist/visual/ProseField.svelte.d.ts +14 -17
- package/dist/visual/SlashMenu.svelte +42 -16
- package/dist/visual/TextField.svelte +5 -5
- package/dist/visual/TextField.svelte.d.ts +1 -1
- package/dist/visual/TipsCard.svelte +43 -28
- package/dist/visual/VisualEditor.svelte +18 -19
- package/dist/visual/VisualEditor.svelte.d.ts +2 -3
- package/dist/visual/VisualEditorInner.svelte +251 -184
- package/dist/visual/VisualEditorInner.svelte.d.ts +2 -3
- package/dist/visual/controls.css +85 -38
- package/dist/visual/diagnostics.d.ts +13 -21
- package/dist/visual/diagnostics.js +7 -19
- package/dist/visual/domid.js +3 -3
- package/dist/visual/ext.d.ts +1 -1
- package/dist/visual/ext.js +1 -1
- package/dist/visual/icons/nodes.d.ts +3 -0
- package/dist/visual/icons/nodes.js +2 -1
- package/dist/visual/index.d.ts +1 -0
- package/dist/visual/index.js +10 -3
- package/dist/visual/leaves.d.ts +47 -0
- package/dist/visual/leaves.js +31 -0
- package/dist/visual/links.d.ts +24 -0
- package/dist/visual/links.js +67 -0
- package/dist/visual/props.d.ts +33 -12
- package/dist/visual/signals.d.ts +6 -6
- package/dist/visual/strings.d.ts +14 -19
- package/dist/visual/strings.js +5 -7
- package/dist/visual/structure.d.ts +24 -17
- package/dist/visual/structure.js +8 -8
- package/dist/visual/synced.svelte.d.ts +1 -1
- package/dist/visual/synced.svelte.js +4 -4
- package/dist/visual/tips.d.ts +4 -2
- package/dist/visual/tips.js +7 -4
- package/package.json +2 -2
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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 {
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
58
|
-
* rather than `visual/icons/nodes.ts`, this being the one place chrome is built
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
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', '
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
|
|
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
|
|
114
|
+
* that jitters is still the gesture it was aimed as. */
|
|
118
115
|
const DEAD_ZONE = 3;
|
|
119
|
-
/** A line's key in the
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
|
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.
|
|
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
|
|
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('
|
|
206
|
-
this.dom.removeEventListener('
|
|
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
|
|
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
|
|
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?.(
|
|
227
|
-
return; // a
|
|
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
|
|
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:
|
|
302
|
-
*
|
|
303
|
-
*
|
|
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.
|
|
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
|
-
// ──
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
/**
|
|
318
|
-
*
|
|
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:
|
|
416
|
+
? { floor: 0, limit: rowCount(props) - 1 }
|
|
322
417
|
: { floor: 0, limit: columnCount(props) - 1 };
|
|
323
418
|
}
|
|
324
|
-
/** Select a line. The
|
|
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
|
-
|
|
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 =
|
|
340
|
-
this.
|
|
341
|
-
this.handleFor(line)?.focus();
|
|
438
|
+
this.selected = cells;
|
|
439
|
+
this.paintSelection();
|
|
342
440
|
}
|
|
343
|
-
|
|
441
|
+
clearSelection() {
|
|
344
442
|
if (!this.selected)
|
|
345
443
|
return;
|
|
346
444
|
this.selected = undefined;
|
|
347
|
-
this.
|
|
348
|
-
}
|
|
349
|
-
/** Wash the selected
|
|
350
|
-
* re-render: a rebuild destroys the nested views,
|
|
351
|
-
* state that must not cost the carets in them. */
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
const key
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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.
|
|
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
|
|
365
|
-
*
|
|
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
|
-
*
|
|
369
|
-
*
|
|
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.
|
|
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.
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
*
|
|
409
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
this.
|
|
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
|
-
|
|
426
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
635
|
+
grip,
|
|
456
636
|
lines: [],
|
|
457
637
|
origin0: { left: 0, top: 0 }
|
|
458
638
|
};
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
481
|
-
for (const cell of this.
|
|
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
|
|
484
|
-
if (!
|
|
665
|
+
const frame = this.frame;
|
|
666
|
+
if (!frame)
|
|
485
667
|
return;
|
|
486
|
-
const box =
|
|
487
|
-
drag.origin0 = { left: box.left
|
|
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.
|
|
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
|
-
|
|
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.
|
|
539
|
-
drag.
|
|
540
|
-
drag.
|
|
541
|
-
if (drag.
|
|
542
|
-
drag.
|
|
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
|
-
|
|
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
|
|
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
|
|
573
|
-
if (!line || !
|
|
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
|
-
|
|
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.
|
|
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(
|
|
629
|
-
this.
|
|
630
|
-
this.align = this.alignCluster(s);
|
|
822
|
+
scroller.appendChild(frame);
|
|
823
|
+
this.frame = frame;
|
|
631
824
|
this.dom.appendChild(scroller);
|
|
632
|
-
|
|
633
|
-
this.
|
|
634
|
-
|
|
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
|
|
652
|
-
//
|
|
653
|
-
// neither is in the grid's layout and neither is a cell of its
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
|
678
|
-
* press that travels drags it. Both are "this line", asked once with the pointer
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
689
|
-
|
|
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.
|
|
918
|
+
this.grips.set(lineKey(line), btn);
|
|
692
919
|
return btn;
|
|
693
920
|
}
|
|
694
|
-
/**
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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,
|
|
936
|
+
this.write(insertRow(props, props.rows.length), { r: rowCount(props), c: 0 });
|
|
709
937
|
else
|
|
710
|
-
this.write(insertColumn(props,
|
|
938
|
+
this.write(insertColumn(props, columnCount(props) - 1), { r: 0, c: columnCount(props) });
|
|
711
939
|
});
|
|
712
940
|
btn.setAttribute('data-axis', axis);
|
|
713
|
-
btn.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
899
|
-
*
|
|
900
|
-
*
|
|
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
|
-
|
|
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
|