@quillmark/svelte 0.19.0 → 0.20.1
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 -2
- package/dist/core/codec/blocks.d.ts +11 -2
- package/dist/core/codec/blocks.js +45 -14
- package/dist/core/codec/breaks.d.ts +3 -2
- package/dist/core/codec/breaks.js +3 -2
- package/dist/core/codec/decode.d.ts +7 -3
- package/dist/core/codec/decode.js +34 -8
- package/dist/core/codec/field.d.ts +11 -5
- package/dist/core/codec/field.js +11 -8
- package/dist/core/codec/index.d.ts +1 -1
- package/dist/core/codec/index.js +1 -1
- package/dist/core/codec/islands.d.ts +3 -1
- package/dist/core/codec/islands.js +8 -5
- package/dist/core/codec/markdown-paste.d.ts +9 -0
- package/dist/core/codec/markdown-paste.js +49 -0
- package/dist/core/codec/prose.css +109 -1
- package/dist/core/codec/schema.d.ts +8 -2
- package/dist/core/codec/schema.js +16 -5
- package/dist/core/codec/slash.d.ts +9 -6
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-paste.d.ts +7 -0
- package/dist/core/codec/table-paste.js +132 -0
- package/dist/core/codec/table-view.d.ts +12 -0
- package/dist/core/codec/table-view.js +401 -57
- package/dist/core/codec/table.d.ts +38 -16
- package/dist/core/codec/table.js +106 -35
- package/dist/core/theme.css +1 -1
- package/dist/visual/ArrayField.svelte +4 -0
- package/dist/visual/Field.svelte +8 -2
- package/dist/visual/Field.svelte.d.ts +1 -1
- package/dist/visual/FieldHint.svelte +62 -10
- package/dist/visual/FormatPopover.svelte +27 -31
- package/dist/visual/FormatPopover.svelte.d.ts +2 -2
- package/dist/visual/MatrixAdd.svelte +13 -132
- package/dist/visual/MatrixAdd.svelte.d.ts +2 -5
- package/dist/visual/MatrixField.svelte +5 -63
- package/dist/visual/MatrixField.svelte.d.ts +0 -3
- package/dist/visual/ObjectField.svelte +7 -3
- package/dist/visual/ObjectField.svelte.d.ts +4 -2
- package/dist/visual/ProseField.svelte +2 -1
- package/dist/visual/ProseValue.svelte +113 -40
- package/dist/visual/ProseValue.svelte.d.ts +8 -4
- package/dist/visual/VisualEditorInner.svelte +22 -22
- package/dist/visual/VisualEditorInner.svelte.d.ts +2 -2
- package/dist/visual/checklists.d.ts +0 -79
- package/dist/visual/checklists.js +0 -164
- package/dist/visual/guidance.d.ts +15 -0
- package/dist/visual/guidance.js +37 -0
- package/dist/visual/leaves.d.ts +27 -1
- package/dist/visual/leaves.js +46 -0
- package/dist/visual/strings.d.ts +3 -8
- package/dist/visual/strings.js +0 -3
- package/dist/visual/structure.js +1 -1
- package/dist/visual/tips.js +1 -1
- package/package.json +8 -8
|
@@ -3,8 +3,8 @@
|
|
|
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
|
|
7
|
-
//
|
|
6
|
+
// A cell is a second content unit inside the first, so it gets its own schema (one
|
|
7
|
+
// paragraph with hard breaks, no containers, no islands, marks and input rules intact);
|
|
8
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`
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
// field's, so one undo stack covers the leaf), no anchor plugin (an anchor in a cell
|
|
15
15
|
// is preserved, never minted), and no placeholder.
|
|
16
16
|
//
|
|
17
|
-
// The chrome is a band and a selection (CODEC §"The table island"), and
|
|
18
|
-
// nothing
|
|
19
|
-
//
|
|
17
|
+
// The chrome is a band and a selection (CODEC §"The table island"), and the band raises
|
|
18
|
+
// nothing: what floats over the grid is the held column's alignment cluster alone. Every
|
|
19
|
+
// control is absolutely positioned out of the grid, so none is in a row or a column of
|
|
20
|
+
// it; `codec/prose.css` draws the band, the cluster and the selection wash.
|
|
20
21
|
import { baseKeymap, chainCommands, toggleMark } from 'prosemirror-commands';
|
|
21
22
|
import { redo, undo } from 'prosemirror-history';
|
|
22
23
|
import { keymap } from 'prosemirror-keymap';
|
|
@@ -25,8 +26,9 @@ import { EditorView } from 'prosemirror-view';
|
|
|
25
26
|
import { decode } from './decode.js';
|
|
26
27
|
import { inputRulesPlugin } from './inputrules.js';
|
|
27
28
|
import { tablePropsOfNode } from './islands.js';
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
29
|
+
import { breakKeymap } from './breaks.js';
|
|
30
|
+
import { cellSchema } from './schema.js';
|
|
31
|
+
import { ALIGNS, cellAt, cellContent, cellEqual, cellFromDoc, clearCells, columnCount, deleteColumn, deleteRow, insertColumn, insertRow, moveColumn, moveRow, normalizeTable, rowCells, rowCount, rowEmpty, setAlign, setHeadless, setCellLayout, columnShares, setWidths, shapeEqual, withCell } from './table.js';
|
|
30
32
|
/**
|
|
31
33
|
* The package's English for the island chrome. It lives here, beside the surface
|
|
32
34
|
* that draws it, rather than in the visual tier's table: the codec mounts this
|
|
@@ -44,26 +46,85 @@ export const DEFAULT_TABLE_STRINGS = {
|
|
|
44
46
|
tableSelectRow: (index) => `Select row ${index}`,
|
|
45
47
|
tableSelectColumn: (index) => `Select column ${index}`,
|
|
46
48
|
tableAddRow: 'Add row',
|
|
47
|
-
tableAddColumn: 'Add column'
|
|
49
|
+
tableAddColumn: 'Add column',
|
|
50
|
+
tableAlignLeft: 'Align left',
|
|
51
|
+
tableAlignCenter: 'Align center',
|
|
52
|
+
tableAlignRight: 'Align right',
|
|
53
|
+
tableHeaderToggle: 'Header row',
|
|
54
|
+
tableAlignTop: 'Align top',
|
|
55
|
+
tableAlignMiddle: 'Align middle',
|
|
56
|
+
tableAlignBottom: 'Align bottom',
|
|
57
|
+
tableCellAlignment: 'Cell alignment'
|
|
48
58
|
};
|
|
49
|
-
/** The
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
59
|
+
/** The grip's glyph, as the path data a DOM node can carry — this chrome's own set rather
|
|
60
|
+
* than `visual/icons/nodes.ts`, this being the one place chrome is built without Svelte,
|
|
61
|
+
* and `/core` reaching no surface module. Same 24×24 frame and the same origin, off an
|
|
62
|
+
* earlier release than the set there; `NOTICE` carries the notices for both. The dots
|
|
63
|
+
* are zero-length strokes under a round cap, which is how that set draws a dot
|
|
64
|
+
* everywhere it has one. */
|
|
55
65
|
const GRIP = {
|
|
56
66
|
column: ['M5 9h.01', 'M12 9h.01', 'M19 9h.01', 'M5 15h.01', 'M12 15h.01', 'M19 15h.01'],
|
|
57
67
|
row: ['M9 5h.01', 'M9 12h.01', 'M9 19h.01', 'M15 5h.01', 'M15 12h.01', 'M15 19h.01']
|
|
58
68
|
};
|
|
59
|
-
/** The
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
/** The alignments the cluster draws and Shift+arrow steps through, in that order. */
|
|
70
|
+
const SETTABLE = ALIGNS.filter((a) => a !== 'none');
|
|
71
|
+
/** The cluster's glyphs, off the same release as the grip's. */
|
|
72
|
+
const ALIGN_GLYPH = {
|
|
73
|
+
left: ['M21 6H3', 'M15 12H3', 'M17 18H3'],
|
|
74
|
+
center: ['M21 6H3', 'M17 12H7', 'M19 18H5'],
|
|
75
|
+
right: ['M21 6H3', 'M21 12H9', 'M21 18H7']
|
|
76
|
+
};
|
|
77
|
+
/** The header toggle's glyph, off the same release: a frame with its top band ruled off. */
|
|
78
|
+
const HEADER_GLYPH = [
|
|
79
|
+
'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z',
|
|
80
|
+
'M3 9h18'
|
|
81
|
+
];
|
|
82
|
+
/** The held cells' vertical toggles' glyphs, off the same release. */
|
|
83
|
+
const VALIGN_GLYPH = {
|
|
84
|
+
top: [
|
|
85
|
+
'M6 6h2a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z',
|
|
86
|
+
'M16 6h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z',
|
|
87
|
+
'M22 2H2'
|
|
88
|
+
],
|
|
89
|
+
middle: [
|
|
90
|
+
'M2 12h20',
|
|
91
|
+
'M10 16v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-4',
|
|
92
|
+
'M10 8V4a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v4',
|
|
93
|
+
'M20 16v1a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-1',
|
|
94
|
+
'M14 8V7c0-1.1.9-2 2-2h2a2 2 0 0 1 2 2v1'
|
|
95
|
+
],
|
|
96
|
+
bottom: [
|
|
97
|
+
'M6 2h2a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z',
|
|
98
|
+
'M16 9h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2z',
|
|
99
|
+
'M22 22H2'
|
|
100
|
+
]
|
|
101
|
+
};
|
|
102
|
+
/** The vertical alignments a cell takes, in the cluster's order. */
|
|
103
|
+
const VALIGNS = ['top', 'middle', 'bottom'];
|
|
104
|
+
/** Draw a cell's alignment on its box: its own where it holds one, its column's
|
|
105
|
+
* otherwise, and the vertical one where it holds one. */
|
|
106
|
+
function layCell(box, props, r, c) {
|
|
107
|
+
const cell = cellAt(props, r, c);
|
|
108
|
+
const column = props.aligns[c] ?? 'none';
|
|
109
|
+
box.style.textAlign = cell.align ?? (column === 'none' ? '' : column);
|
|
110
|
+
box.style.verticalAlign = cell.valign ?? '';
|
|
111
|
+
}
|
|
112
|
+
/** The cells of an inclusive rectangle, in reading order. */
|
|
113
|
+
function allCells(props, held) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (let r = held.r0; r <= held.r1; r++)
|
|
116
|
+
for (let c = held.c0; c <= held.c1; c++)
|
|
117
|
+
out.push(cellAt(props, r, c));
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
/** A glyph's marks at `weight`: the set's own is 2, and the grip takes 3, its marks being
|
|
121
|
+
* dots, which at the line weight of a stroke disappear at the size the bar renders. */
|
|
122
|
+
function svg(paths, weight) {
|
|
62
123
|
const el = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
63
124
|
el.setAttribute('viewBox', '0 0 24 24');
|
|
64
125
|
el.setAttribute('fill', 'none');
|
|
65
126
|
el.setAttribute('stroke', 'currentColor');
|
|
66
|
-
el.setAttribute('stroke-width',
|
|
127
|
+
el.setAttribute('stroke-width', String(weight));
|
|
67
128
|
el.setAttribute('stroke-linecap', 'round');
|
|
68
129
|
el.setAttribute('stroke-linejoin', 'round');
|
|
69
130
|
el.setAttribute('aria-hidden', 'true');
|
|
@@ -100,13 +161,13 @@ function chromeButton(className, label, run) {
|
|
|
100
161
|
/** A cell's plugin stack: marks and the markdown shorthands, and nothing that
|
|
101
162
|
* belongs to the field (history, anchors, the ghost). */
|
|
102
163
|
function cellPlugins(keys) {
|
|
103
|
-
return [inputRulesPlugin(
|
|
164
|
+
return [inputRulesPlugin(cellSchema), keymap(keys), keymap(baseKeymap)];
|
|
104
165
|
}
|
|
105
|
-
/** The
|
|
166
|
+
/** The controls, each of which answers for its own press. Spelled apart from
|
|
106
167
|
* {@link owned} because the pointer router needs them before it needs the cells: a grip
|
|
107
|
-
*
|
|
108
|
-
* reading they disagree about. */
|
|
109
|
-
const CONTROLS = '.qm-table-grip, .qm-table-add';
|
|
168
|
+
* and a cluster are inside the cell they name, so the two selectors overlap on exactly
|
|
169
|
+
* the press whose reading they disagree about. */
|
|
170
|
+
const CONTROLS = '.qm-table-grip, .qm-table-add, .qm-table-align, .qm-table-resize';
|
|
110
171
|
/** What the nested views and the band answer for themselves, which is what `stopEvent`
|
|
111
172
|
* keeps from PM. */
|
|
112
173
|
const OWNED = `.qm-table-cell-host, ${CONTROLS}`;
|
|
@@ -145,12 +206,23 @@ class TableIslandView {
|
|
|
145
206
|
* without a query. The typed twin of `cells`, `Line` included — a key parsed back
|
|
146
207
|
* into one is the `data-` attribute `MountedCell.box` refuses. */
|
|
147
208
|
grips = new Map();
|
|
209
|
+
/** The alignment clusters, by column. */
|
|
210
|
+
clusters = [];
|
|
211
|
+
/** The first row's header toggle, up while that row is held. */
|
|
212
|
+
headerCluster;
|
|
213
|
+
/** The held cells' cluster, up over the first cell of a held rectangle that is no
|
|
214
|
+
* column, and built for each paint since what it presses is the cells'. */
|
|
215
|
+
cellCluster;
|
|
148
216
|
/** The grid's own box, and the containing block every out-of-flow control is placed
|
|
149
217
|
* against. Not the scroller: an absolute inside a scroll container is placed
|
|
150
218
|
* against a padding box the scroll then slides out from under, so a control at the
|
|
151
219
|
* grid's far end would drift into the middle of it. */
|
|
152
220
|
frame;
|
|
153
221
|
dropMark;
|
|
222
|
+
/** The grid's `<col>`s, which a border drag sizes live and a render sizes from the
|
|
223
|
+
* stored weights. */
|
|
224
|
+
cols = [];
|
|
225
|
+
resize;
|
|
154
226
|
/** A drag's trailing `click`, which would otherwise re-select the moved line. */
|
|
155
227
|
suppressClick = false;
|
|
156
228
|
constructor(node, outer, getPos, deps) {
|
|
@@ -190,7 +262,7 @@ class TableIslandView {
|
|
|
190
262
|
continue;
|
|
191
263
|
const head = mounted.view.state.selection.head;
|
|
192
264
|
const fresh = EditorState.create({
|
|
193
|
-
doc: decode(cellContent(stored),
|
|
265
|
+
doc: decode(cellContent(stored), cellSchema),
|
|
194
266
|
plugins: cellPlugins(this.cellKeys(mounted.r, mounted.c))
|
|
195
267
|
});
|
|
196
268
|
mounted.view.updateState(fresh);
|
|
@@ -202,6 +274,11 @@ class TableIslandView {
|
|
|
202
274
|
const at = Math.min(head, fresh.doc.content.size);
|
|
203
275
|
mounted.view.dispatch(fresh.tr.setSelection(Selection.near(fresh.doc.resolve(at))));
|
|
204
276
|
}
|
|
277
|
+
// A cell's alignment is no part of its text, so it is restated on every box: the
|
|
278
|
+
// reseed above compares text and marks alone.
|
|
279
|
+
for (const mounted of this.cells)
|
|
280
|
+
layCell(mounted.box, props, mounted.r, mounted.c);
|
|
281
|
+
this.paintSelection();
|
|
205
282
|
return true;
|
|
206
283
|
}
|
|
207
284
|
/** The nested views own every event inside a cell or a control; everything else
|
|
@@ -221,6 +298,7 @@ class TableIslandView {
|
|
|
221
298
|
this.dom.removeEventListener('mousedown', this.onPointerDown);
|
|
222
299
|
this.endDrag();
|
|
223
300
|
this.endSweep();
|
|
301
|
+
this.endResize();
|
|
224
302
|
this.teardownCells();
|
|
225
303
|
}
|
|
226
304
|
// ── The pointer ───────────────────────────────────────────────────────────
|
|
@@ -494,14 +572,23 @@ class TableIslandView {
|
|
|
494
572
|
this.selected = undefined;
|
|
495
573
|
this.paintSelection();
|
|
496
574
|
}
|
|
497
|
-
/** Wash the selected cells
|
|
498
|
-
* covers. Imperative rather than a re-render: a
|
|
499
|
-
* and a selection is exactly the state that must
|
|
575
|
+
/** Wash the selected cells, mark the grip of any line the selection exactly covers, and
|
|
576
|
+
* put up the cluster of a column it covers. Imperative rather than a re-render: a
|
|
577
|
+
* rebuild destroys the nested views, and a selection is exactly the state that must
|
|
578
|
+
* not cost the carets in them. */
|
|
500
579
|
paintSelection() {
|
|
501
580
|
const held = this.selected;
|
|
502
581
|
const props = this.props();
|
|
503
|
-
for (const { line, grip } of this.grips.values())
|
|
504
|
-
|
|
582
|
+
for (const { line, grip } of this.grips.values()) {
|
|
583
|
+
const named = !!held && sameCells(held, this.lineCells(line, props));
|
|
584
|
+
grip.setAttribute('aria-pressed', String(named));
|
|
585
|
+
const cluster = line.axis === 'column' ? this.clusters[line.index] : undefined;
|
|
586
|
+
if (cluster)
|
|
587
|
+
cluster.hidden = !named;
|
|
588
|
+
if (line.axis === 'row' && line.index === 0 && this.headerCluster)
|
|
589
|
+
this.headerCluster.hidden = !named;
|
|
590
|
+
}
|
|
591
|
+
this.paintCellCluster(props);
|
|
505
592
|
for (const cell of this.cells)
|
|
506
593
|
cell.box.toggleAttribute('data-selected', !!held && cell.r >= held.r0 && cell.r <= held.r1 && cell.c >= held.c0 && cell.c <= held.c1);
|
|
507
594
|
}
|
|
@@ -576,6 +663,73 @@ class TableIslandView {
|
|
|
576
663
|
this.write(line.axis === 'row' ? moveRow(props, line.index, by) : moveColumn(props, line.index, by));
|
|
577
664
|
this.selectLine({ axis: line.axis, index: to });
|
|
578
665
|
}
|
|
666
|
+
/** Set column `c`'s alignment and keep the column held: a changed alignment rebuilds
|
|
667
|
+
* the views, which retires the selection as a move's rebuild does. The alignment the
|
|
668
|
+
* column already holds writes nothing. */
|
|
669
|
+
align(c, to) {
|
|
670
|
+
const props = this.props();
|
|
671
|
+
if (props.aligns[c] !== to)
|
|
672
|
+
this.write(setAlign(props, c, to));
|
|
673
|
+
this.selectLine({ axis: 'column', index: c });
|
|
674
|
+
}
|
|
675
|
+
/** The held cells' cluster: up over the first cell of a held rectangle, where no
|
|
676
|
+
* column's own cluster is, each toggle pressed where every cell in the rectangle
|
|
677
|
+
* holds it. It floats over the cell under that one, as the column's floats over the
|
|
678
|
+
* first row, and is out of the tab order for the reason a grip is, its keyboard twin
|
|
679
|
+
* being `Mod-Shift-l`, `-e` and `-r` (§{@link TableIslandView.cellKeys}). */
|
|
680
|
+
paintCellCluster(props) {
|
|
681
|
+
this.cellCluster?.remove();
|
|
682
|
+
this.cellCluster = undefined;
|
|
683
|
+
const held = this.selected;
|
|
684
|
+
if (!held || this.lineOn('column') !== undefined)
|
|
685
|
+
return;
|
|
686
|
+
const first = this.cells.find((m) => m.r === held.r0 && m.c === held.c0);
|
|
687
|
+
if (!first)
|
|
688
|
+
return;
|
|
689
|
+
const s = this.deps.strings();
|
|
690
|
+
const covered = allCells(props, held);
|
|
691
|
+
const holds = (key, value) => covered.every((cell) => cell[key] === value);
|
|
692
|
+
const cluster = el('div', 'qm-table-align');
|
|
693
|
+
cluster.setAttribute('role', 'group');
|
|
694
|
+
cluster.setAttribute('aria-label', s.tableCellAlignment);
|
|
695
|
+
cluster.setAttribute('data-cells', '');
|
|
696
|
+
cluster.addEventListener('mousedown', (e) => e.preventDefault());
|
|
697
|
+
const toggle = (label, glyph, pressed, run) => {
|
|
698
|
+
const btn = chromeButton('qm-table-align-option', label, run);
|
|
699
|
+
btn.tabIndex = -1;
|
|
700
|
+
btn.setAttribute('aria-pressed', String(pressed));
|
|
701
|
+
btn.appendChild(svg(glyph, 2));
|
|
702
|
+
cluster.appendChild(btn);
|
|
703
|
+
return btn;
|
|
704
|
+
};
|
|
705
|
+
const names = {
|
|
706
|
+
left: s.tableAlignLeft,
|
|
707
|
+
center: s.tableAlignCenter,
|
|
708
|
+
right: s.tableAlignRight
|
|
709
|
+
};
|
|
710
|
+
for (const align of SETTABLE)
|
|
711
|
+
toggle(names[align], ALIGN_GLYPH[align], holds('align', align), () => this.layCells('align', align)).setAttribute('data-align', align);
|
|
712
|
+
const vnames = {
|
|
713
|
+
top: s.tableAlignTop,
|
|
714
|
+
middle: s.tableAlignMiddle,
|
|
715
|
+
bottom: s.tableAlignBottom
|
|
716
|
+
};
|
|
717
|
+
for (const valign of VALIGNS)
|
|
718
|
+
toggle(vnames[valign], VALIGN_GLYPH[valign], holds('valign', valign), () => this.layCells('valign', valign)).setAttribute('data-valign', valign);
|
|
719
|
+
first.box.appendChild(cluster);
|
|
720
|
+
this.cellCluster = cluster;
|
|
721
|
+
}
|
|
722
|
+
/** Lay every held cell, or the caret's where none is held, at `value`, and clear the
|
|
723
|
+
* key from them where every one already holds it: the press that set an alignment
|
|
724
|
+
* takes it back to the column's. One `set`, and the rectangle stays held. */
|
|
725
|
+
layCells(key, value, at) {
|
|
726
|
+
const held = this.selected ?? (at && { r0: at.r, c0: at.c, r1: at.r, c1: at.c });
|
|
727
|
+
if (!held)
|
|
728
|
+
return;
|
|
729
|
+
const props = this.props();
|
|
730
|
+
const every = allCells(props, held).every((cell) => cell[key] === value);
|
|
731
|
+
this.write(setCellLayout(props, held.r0, held.c0, held.r1, held.c1, key, every ? undefined : value));
|
|
732
|
+
}
|
|
579
733
|
// ── Drag to reorder ───────────────────────────────────────────────────────
|
|
580
734
|
/**
|
|
581
735
|
* Press-and-drag a grip moves its line. The press still selects: the dead zone is
|
|
@@ -741,6 +895,109 @@ class TableIslandView {
|
|
|
741
895
|
mark.style.height = `${line.span}px`;
|
|
742
896
|
}
|
|
743
897
|
}
|
|
898
|
+
// ── Column widths ─────────────────────────────────────────────────────────
|
|
899
|
+
/** Size the grid's columns, or return it to auto-fit. A weighted grid spans the leaf
|
|
900
|
+
* laid out fixed, as the page's fractional columns span the text block, so the
|
|
901
|
+
* proportions it draws are the ones the page prints. */
|
|
902
|
+
weigh(widths) {
|
|
903
|
+
this.frame?.toggleAttribute('data-weighted', widths !== undefined);
|
|
904
|
+
this.cols.forEach((col, c) => (col.style.width = widths?.[c] ?? ''));
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* The border after column `c`, in the band: a press and a drag move it, trading width
|
|
908
|
+
* between the two columns it divides, and the release writes every column's measured
|
|
909
|
+
* width as its weight, one `set` and one undo step. A double-click returns the table
|
|
910
|
+
* to auto-fit.
|
|
911
|
+
*
|
|
912
|
+
* Pointer chrome for the reason a grip is, and named to nothing: it is no button, and
|
|
913
|
+
* a column's width has no keyboard route.
|
|
914
|
+
*/
|
|
915
|
+
resizer(c) {
|
|
916
|
+
const handle = el('span', 'qm-table-resize');
|
|
917
|
+
handle.setAttribute('aria-hidden', 'true');
|
|
918
|
+
handle.addEventListener('mousedown', (e) => e.preventDefault());
|
|
919
|
+
handle.addEventListener('pointerdown', (e) => this.onResizeDown(c, handle, e));
|
|
920
|
+
handle.addEventListener('dblclick', () => {
|
|
921
|
+
if (this.props().widths)
|
|
922
|
+
this.write(setWidths(this.props(), undefined));
|
|
923
|
+
});
|
|
924
|
+
return handle;
|
|
925
|
+
}
|
|
926
|
+
onResizeDown = (c, handle, event) => {
|
|
927
|
+
if (event.button !== 0)
|
|
928
|
+
return;
|
|
929
|
+
this.endResize();
|
|
930
|
+
const heads = this.cells.filter((m) => m.r === 0).sort((a, b) => a.c - b.c);
|
|
931
|
+
const widths = heads.map((m) => m.box.getBoundingClientRect().width);
|
|
932
|
+
// A cell's floor is its host's measure plus the box's own padding and border, read
|
|
933
|
+
// off the header cell the border hangs from.
|
|
934
|
+
const head = heads[c];
|
|
935
|
+
const floor = head
|
|
936
|
+
? Number.parseFloat(getComputedStyle(head.host).minWidth) +
|
|
937
|
+
head.box.getBoundingClientRect().width -
|
|
938
|
+
head.host.getBoundingClientRect().width
|
|
939
|
+
: 0;
|
|
940
|
+
this.resize = {
|
|
941
|
+
c,
|
|
942
|
+
handle,
|
|
943
|
+
pointerId: event.pointerId,
|
|
944
|
+
from: event.clientX,
|
|
945
|
+
widths,
|
|
946
|
+
floor: Number.isFinite(floor) ? floor : 0,
|
|
947
|
+
engaged: false
|
|
948
|
+
};
|
|
949
|
+
handle.setPointerCapture?.(event.pointerId);
|
|
950
|
+
handle.addEventListener('pointermove', this.onResizeMove);
|
|
951
|
+
handle.addEventListener('pointerup', this.onResizeUp);
|
|
952
|
+
handle.addEventListener('pointercancel', this.onResizeUp);
|
|
953
|
+
};
|
|
954
|
+
/** The widths a border at `x` leaves: the two columns it divides trade the travel,
|
|
955
|
+
* each clamped at the floor, and every other column keeps the width it was pressed at. */
|
|
956
|
+
resized(resize, x) {
|
|
957
|
+
const { c, widths, floor } = resize;
|
|
958
|
+
const left = widths[c] ?? 0;
|
|
959
|
+
const right = widths[c + 1] ?? 0;
|
|
960
|
+
const travel = Math.max(floor - left, Math.min(right - floor, x - resize.from));
|
|
961
|
+
return widths.map((w, i) => (i === c ? left + travel : i === c + 1 ? right - travel : w));
|
|
962
|
+
}
|
|
963
|
+
onResizeMove = (event) => {
|
|
964
|
+
const resize = this.resize;
|
|
965
|
+
if (!resize)
|
|
966
|
+
return;
|
|
967
|
+
if (!resize.engaged && Math.abs(event.clientX - resize.from) < DEAD_ZONE)
|
|
968
|
+
return;
|
|
969
|
+
resize.engaged = true;
|
|
970
|
+
resize.handle.setAttribute('data-active', '');
|
|
971
|
+
const widths = this.resized(resize, event.clientX);
|
|
972
|
+
const total = widths.reduce((a, b) => a + b, 0);
|
|
973
|
+
this.weigh(widths.map((w) => `${(w / total) * 100}%`));
|
|
974
|
+
};
|
|
975
|
+
onResizeUp = (event) => {
|
|
976
|
+
const resize = this.resize;
|
|
977
|
+
if (!resize)
|
|
978
|
+
return;
|
|
979
|
+
this.endResize();
|
|
980
|
+
if (!resize.engaged)
|
|
981
|
+
return;
|
|
982
|
+
if (event.type !== 'pointerup') {
|
|
983
|
+
this.weigh(columnShares(this.props())?.map((share) => `${share * 100}%`));
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
const widths = this.resized(resize, event.clientX).map((w) => Math.max(1, Math.round(w)));
|
|
987
|
+
this.write(setWidths(this.props(), widths));
|
|
988
|
+
};
|
|
989
|
+
endResize() {
|
|
990
|
+
const resize = this.resize;
|
|
991
|
+
if (!resize)
|
|
992
|
+
return;
|
|
993
|
+
this.resize = undefined;
|
|
994
|
+
resize.handle.removeAttribute('data-active');
|
|
995
|
+
resize.handle.removeEventListener('pointermove', this.onResizeMove);
|
|
996
|
+
resize.handle.removeEventListener('pointerup', this.onResizeUp);
|
|
997
|
+
resize.handle.removeEventListener('pointercancel', this.onResizeUp);
|
|
998
|
+
if (resize.handle.hasPointerCapture?.(resize.pointerId))
|
|
999
|
+
resize.handle.releasePointerCapture(resize.pointerId);
|
|
1000
|
+
}
|
|
744
1001
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
745
1002
|
teardownCells() {
|
|
746
1003
|
for (const mounted of this.cells) {
|
|
@@ -753,8 +1010,11 @@ class TableIslandView {
|
|
|
753
1010
|
const seat = this.focusedSeat();
|
|
754
1011
|
this.endDrag();
|
|
755
1012
|
this.endSweep();
|
|
1013
|
+
this.endResize();
|
|
756
1014
|
this.teardownCells();
|
|
757
1015
|
this.grips.clear();
|
|
1016
|
+
this.clusters = [];
|
|
1017
|
+
this.headerCluster = undefined;
|
|
758
1018
|
this.dom.textContent = '';
|
|
759
1019
|
const props = tablePropsOfNode(this.node);
|
|
760
1020
|
this.rendered = props;
|
|
@@ -768,23 +1028,35 @@ class TableIslandView {
|
|
|
768
1028
|
this.dom.setAttribute('aria-label', s.tableLabel);
|
|
769
1029
|
// `thead`/`tbody` rather than one `tbody`: the header is a separate field in the
|
|
770
1030
|
// model, and this is the markup that says so to something that cannot see the
|
|
771
|
-
// weight the header row draws.
|
|
1031
|
+
// weight the header row draws. A headless table's first row is a body row on the
|
|
1032
|
+
// page, so it is one here too, still holding row 0's chrome.
|
|
772
1033
|
const table = el('table', 'qm-table');
|
|
773
|
-
const
|
|
774
|
-
|
|
1034
|
+
const colgroup = el('colgroup');
|
|
1035
|
+
this.cols = Array.from({ length: columnCount(props) }, () => el('col'));
|
|
1036
|
+
colgroup.append(...this.cols);
|
|
1037
|
+
const first = this.row(props, 0, s);
|
|
775
1038
|
const body = el('tbody');
|
|
1039
|
+
if (props.headless) {
|
|
1040
|
+
body.appendChild(first);
|
|
1041
|
+
table.append(colgroup, body);
|
|
1042
|
+
}
|
|
1043
|
+
else {
|
|
1044
|
+
const head = el('thead');
|
|
1045
|
+
head.appendChild(first);
|
|
1046
|
+
table.append(colgroup, head, body);
|
|
1047
|
+
}
|
|
776
1048
|
for (let r = 1; r < rowCount(props); r++)
|
|
777
1049
|
body.appendChild(this.row(props, r, s));
|
|
778
|
-
table.append(head, body);
|
|
779
1050
|
// The frame is the grid's own box, and the two controls about an axis rather than
|
|
780
1051
|
// about a line hang off its edges: an add bar along each trailing edge. A cell
|
|
781
1052
|
// would have served for neither — a table with no body rows has no last row to
|
|
782
1053
|
// hang the row bar in, and a bar spans the whole edge rather than one line of it.
|
|
783
1054
|
const frame = el('div', 'qm-table-frame');
|
|
784
1055
|
frame.append(table, this.addBar('column', s.tableAddColumn), this.addBar('row', s.tableAddRow));
|
|
1056
|
+
this.frame = frame;
|
|
1057
|
+
this.weigh(columnShares(props)?.map((share) => `${share * 100}%`));
|
|
785
1058
|
const scroller = el('div', 'qm-table-scroller');
|
|
786
1059
|
scroller.appendChild(frame);
|
|
787
|
-
this.frame = frame;
|
|
788
1060
|
this.dom.appendChild(scroller);
|
|
789
1061
|
this.paintSelection();
|
|
790
1062
|
this.reseat(seat);
|
|
@@ -814,16 +1086,16 @@ class TableIslandView {
|
|
|
814
1086
|
}
|
|
815
1087
|
/** One table row: its cells, each carrying whatever chrome hangs off it. */
|
|
816
1088
|
row(props, r, s) {
|
|
817
|
-
const
|
|
1089
|
+
const heads = r === 0 && !props.headless;
|
|
1090
|
+
const tr = el('tr', heads ? 'qm-table-header-row' : undefined);
|
|
818
1091
|
rowCells(props, r).forEach((cell, c) => {
|
|
819
|
-
const box = el(
|
|
820
|
-
if (
|
|
1092
|
+
const box = el(heads ? 'th' : 'td', 'qm-table-cell');
|
|
1093
|
+
if (heads)
|
|
821
1094
|
box.setAttribute('scope', 'col');
|
|
822
1095
|
box.setAttribute('data-r', String(r));
|
|
823
1096
|
box.setAttribute('data-c', String(c));
|
|
824
1097
|
const align = props.aligns[c] ?? 'none';
|
|
825
|
-
|
|
826
|
-
box.style.textAlign = align;
|
|
1098
|
+
layCell(box, props, r, c);
|
|
827
1099
|
const host = el('div', 'qm-table-cell-host');
|
|
828
1100
|
box.appendChild(host);
|
|
829
1101
|
// The column band hangs off the header row; the row band off every row's first
|
|
@@ -834,9 +1106,13 @@ class TableIslandView {
|
|
|
834
1106
|
// row and the header is one: it selects, it deletes, and it drags, all by the
|
|
835
1107
|
// rules every other row is under.
|
|
836
1108
|
if (r === 0)
|
|
837
|
-
box.
|
|
1109
|
+
box.append(this.grip({ axis: 'column', index: c }, s.tableSelectColumn(c + 1)), this.alignCluster(c, align, s));
|
|
1110
|
+
if (r === 0 && c < columnCount(props) - 1)
|
|
1111
|
+
box.appendChild(this.resizer(c));
|
|
838
1112
|
if (c === 0)
|
|
839
|
-
box.appendChild(this.grip({ axis: 'row', index: r },
|
|
1113
|
+
box.appendChild(this.grip({ axis: 'row', index: r }, heads ? s.tableSelectHeaderRow : s.tableSelectRow(props.headless ? r + 1 : r)));
|
|
1114
|
+
if (r === 0 && c === columnCount(props) - 1)
|
|
1115
|
+
box.appendChild(this.headerToggle(!props.headless, s));
|
|
840
1116
|
tr.appendChild(box);
|
|
841
1117
|
this.mountCell(box, host, r, c, s);
|
|
842
1118
|
});
|
|
@@ -863,12 +1139,62 @@ class TableIslandView {
|
|
|
863
1139
|
btn.setAttribute('aria-pressed', 'false');
|
|
864
1140
|
btn.setAttribute('data-axis', line.axis);
|
|
865
1141
|
const bar = el('span', 'qm-table-grip-bar');
|
|
866
|
-
bar.appendChild(svg(GRIP[line.axis]));
|
|
1142
|
+
bar.appendChild(svg(GRIP[line.axis], 3));
|
|
867
1143
|
btn.appendChild(bar);
|
|
868
1144
|
btn.addEventListener('pointerdown', (e) => this.onGripDown(line, btn, e));
|
|
869
1145
|
this.grips.set(lineKey(line), { line, grip: btn });
|
|
870
1146
|
return btn;
|
|
871
1147
|
}
|
|
1148
|
+
/** A column's alignment cluster: a toggle per alignment, pressed on the one the column
|
|
1149
|
+
* holds. Up only while the held rectangle is this column
|
|
1150
|
+
* ({@link TableIslandView.paintSelection}), and absent rather than transparent the
|
|
1151
|
+
* rest of the time: it floats over the first row, and a box hit-tested there at rest
|
|
1152
|
+
* would take that cell's presses. Out of the tab order for the reason a grip is, its
|
|
1153
|
+
* keyboard twin being Shift+Left and Shift+Right (§{@link TableIslandView.cellKeys}). */
|
|
1154
|
+
alignCluster(c, held, s) {
|
|
1155
|
+
const cluster = el('div', 'qm-table-align');
|
|
1156
|
+
cluster.setAttribute('role', 'group');
|
|
1157
|
+
cluster.setAttribute('aria-label', s.tableColumn(c + 1));
|
|
1158
|
+
cluster.hidden = true;
|
|
1159
|
+
// The cluster's own edge is no button, and a press on it would otherwise take the
|
|
1160
|
+
// focus off the caret the write lands back on.
|
|
1161
|
+
cluster.addEventListener('mousedown', (e) => e.preventDefault());
|
|
1162
|
+
const names = {
|
|
1163
|
+
left: s.tableAlignLeft,
|
|
1164
|
+
center: s.tableAlignCenter,
|
|
1165
|
+
right: s.tableAlignRight
|
|
1166
|
+
};
|
|
1167
|
+
for (const align of SETTABLE) {
|
|
1168
|
+
const btn = chromeButton('qm-table-align-option', names[align], () => this.align(c, align));
|
|
1169
|
+
btn.tabIndex = -1;
|
|
1170
|
+
btn.setAttribute('aria-pressed', String(held === align));
|
|
1171
|
+
btn.setAttribute('data-align', align);
|
|
1172
|
+
btn.appendChild(svg(ALIGN_GLYPH[align], 2));
|
|
1173
|
+
cluster.appendChild(btn);
|
|
1174
|
+
}
|
|
1175
|
+
this.clusters[c] = cluster;
|
|
1176
|
+
return cluster;
|
|
1177
|
+
}
|
|
1178
|
+
/** The first row's header toggle: one press draws the row as the header or as a body
|
|
1179
|
+
* row, writing the table's `headless` as one `set`, and the row stays held. Up only
|
|
1180
|
+
* while the first row is held, hanging under its last cell, and out of the tab order
|
|
1181
|
+
* for the reason a grip is. */
|
|
1182
|
+
headerToggle(header, s) {
|
|
1183
|
+
const cluster = el('div', 'qm-table-align');
|
|
1184
|
+
cluster.setAttribute('data-header', '');
|
|
1185
|
+
cluster.hidden = true;
|
|
1186
|
+
cluster.addEventListener('mousedown', (e) => e.preventDefault());
|
|
1187
|
+
const btn = chromeButton('qm-table-align-option', s.tableHeaderToggle, () => {
|
|
1188
|
+
this.write(setHeadless(this.props(), header));
|
|
1189
|
+
this.selectLine({ axis: 'row', index: 0 });
|
|
1190
|
+
});
|
|
1191
|
+
btn.tabIndex = -1;
|
|
1192
|
+
btn.setAttribute('aria-pressed', String(header));
|
|
1193
|
+
btn.appendChild(svg(HEADER_GLYPH, 2));
|
|
1194
|
+
cluster.appendChild(btn);
|
|
1195
|
+
this.headerCluster = cluster;
|
|
1196
|
+
return cluster;
|
|
1197
|
+
}
|
|
872
1198
|
/** A trailing bar: the whole edge past the last line of its axis, and the one way a
|
|
873
1199
|
* pointer grows the table. It spans the edge rather than capping it, because what it
|
|
874
1200
|
* appends to is the axis and not a line. It draws no glyph — the bar arriving under
|
|
@@ -895,11 +1221,12 @@ class TableIslandView {
|
|
|
895
1221
|
}
|
|
896
1222
|
mountCell(box, host, r, c, s) {
|
|
897
1223
|
const props = this.props();
|
|
898
|
-
const
|
|
1224
|
+
const row = props.headless ? s.tableRow(r + 1) : r === 0 ? s.tableHeaderRow : s.tableRow(r);
|
|
1225
|
+
const name = s.tableCell(row, s.tableColumn(c + 1));
|
|
899
1226
|
const seed = cellAt(props, r, c);
|
|
900
1227
|
const view = new EditorView(host, {
|
|
901
1228
|
state: EditorState.create({
|
|
902
|
-
doc: decode(cellContent(seed),
|
|
1229
|
+
doc: decode(cellContent(seed), cellSchema),
|
|
903
1230
|
plugins: cellPlugins(this.cellKeys(r, c))
|
|
904
1231
|
}),
|
|
905
1232
|
attributes: { 'aria-label': name, class: 'qm-table-cell-editor' },
|
|
@@ -992,18 +1319,17 @@ class TableIslandView {
|
|
|
992
1319
|
* (VISUAL_EDITOR §Chrome), except that it binds on the nested view: the outer
|
|
993
1320
|
* keymap never sees a keystroke a cell handled (`stopEvent`).
|
|
994
1321
|
*
|
|
995
|
-
* Enter is the next row
|
|
996
|
-
*
|
|
997
|
-
* a cell has no representation to be a preference about.
|
|
1322
|
+
* Enter is the next row. Shift-Enter is a line break, the body's own command
|
|
1323
|
+
* (`breaks.ts`): one `\n` in the cell's `text`.
|
|
998
1324
|
*/
|
|
999
1325
|
cellKeys(r, c) {
|
|
1000
1326
|
const marks = {};
|
|
1001
|
-
if (
|
|
1002
|
-
marks['Mod-b'] = toggleMark(
|
|
1003
|
-
if (
|
|
1004
|
-
marks['Mod-i'] = toggleMark(
|
|
1005
|
-
if (
|
|
1006
|
-
marks['Mod-u'] = toggleMark(
|
|
1327
|
+
if (cellSchema.marks.strong)
|
|
1328
|
+
marks['Mod-b'] = toggleMark(cellSchema.marks.strong);
|
|
1329
|
+
if (cellSchema.marks.em)
|
|
1330
|
+
marks['Mod-i'] = toggleMark(cellSchema.marks.em);
|
|
1331
|
+
if (cellSchema.marks.underline)
|
|
1332
|
+
marks['Mod-u'] = toggleMark(cellSchema.marks.underline);
|
|
1007
1333
|
// Up and down are the grid's own walk: nothing else moves the caret vertically,
|
|
1008
1334
|
// and `focusCell` clamps, so neither can grow the table. Left and right do not
|
|
1009
1335
|
// traverse at all: at a text edge they would call what Tab and Shift-Tab already
|
|
@@ -1057,8 +1383,25 @@ class TableIslandView {
|
|
|
1057
1383
|
return true;
|
|
1058
1384
|
};
|
|
1059
1385
|
};
|
|
1386
|
+
// Shift steps a held column's alignment a place along the cluster's order, stopping
|
|
1387
|
+
// at either end, and a column with none steps from the first. Over any other
|
|
1388
|
+
// rectangle it keeps the key, every arrow being the rectangle's while one is held.
|
|
1389
|
+
const slide = (by) => {
|
|
1390
|
+
return () => {
|
|
1391
|
+
if (!this.selected)
|
|
1392
|
+
return false;
|
|
1393
|
+
const on = this.lineOn('column');
|
|
1394
|
+
if (on === undefined)
|
|
1395
|
+
return true;
|
|
1396
|
+
const held = this.props().aligns[on];
|
|
1397
|
+
const at = Math.max(0, SETTABLE.findIndex((a) => a === held));
|
|
1398
|
+
this.align(on, SETTABLE[Math.max(0, Math.min(at + by, SETTABLE.length - 1))]);
|
|
1399
|
+
return true;
|
|
1400
|
+
};
|
|
1401
|
+
};
|
|
1060
1402
|
return {
|
|
1061
1403
|
...marks,
|
|
1404
|
+
...breakKeymap(cellSchema),
|
|
1062
1405
|
// One undo stack per leaf: a cell carries no history of its own, so Mod-z
|
|
1063
1406
|
// unwinds a cell keystroke and a row op in the order they happened.
|
|
1064
1407
|
'Mod-z': () => undo(this.outer.state, this.outer.dispatch),
|
|
@@ -1074,12 +1417,13 @@ class TableIslandView {
|
|
|
1074
1417
|
'Alt-ArrowDown': line('row', 1, true),
|
|
1075
1418
|
'Alt-ArrowLeft': line('column', -1, true),
|
|
1076
1419
|
'Alt-ArrowRight': line('column', 1, true),
|
|
1420
|
+
'Shift-ArrowLeft': slide(-1),
|
|
1421
|
+
'Shift-ArrowRight': slide(1),
|
|
1422
|
+
'Shift-Mod-l': () => (this.layCells('align', 'left', { r, c }), true),
|
|
1423
|
+
'Shift-Mod-e': () => (this.layCells('align', 'center', { r, c }), true),
|
|
1424
|
+
'Shift-Mod-r': () => (this.layCells('align', 'right', { r, c }), true),
|
|
1077
1425
|
Tab: () => this.step(r, c, 1),
|
|
1078
1426
|
'Shift-Tab': () => this.step(r, c, -1),
|
|
1079
|
-
// A cell is one line (`normalize` rewrites a cell newline to a space), so the
|
|
1080
|
-
// key the body answers with a break is claimed here and does nothing: Enter
|
|
1081
|
-
// already means the next row, and an unclaimed key is the browser's `<br>`.
|
|
1082
|
-
'Shift-Enter': () => true,
|
|
1083
1427
|
Enter: () => {
|
|
1084
1428
|
// Over a rectangle it hands the caret back, which is the line verbs' own
|
|
1085
1429
|
// exit: the caret never left the cell, so there is nowhere else to put it.
|