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