@quillmark/svelte 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/NOTICE +4 -2
  2. package/dist/core/codec/blocks.d.ts +11 -2
  3. package/dist/core/codec/blocks.js +45 -14
  4. package/dist/core/codec/breaks.d.ts +3 -2
  5. package/dist/core/codec/breaks.js +3 -2
  6. package/dist/core/codec/decode.d.ts +7 -3
  7. package/dist/core/codec/decode.js +34 -8
  8. package/dist/core/codec/field.d.ts +11 -5
  9. package/dist/core/codec/field.js +11 -8
  10. package/dist/core/codec/index.d.ts +1 -1
  11. package/dist/core/codec/index.js +1 -1
  12. package/dist/core/codec/islands.d.ts +3 -1
  13. package/dist/core/codec/islands.js +8 -5
  14. package/dist/core/codec/markdown-paste.d.ts +9 -0
  15. package/dist/core/codec/markdown-paste.js +49 -0
  16. package/dist/core/codec/prose.css +109 -1
  17. package/dist/core/codec/schema.d.ts +8 -2
  18. package/dist/core/codec/schema.js +16 -5
  19. package/dist/core/codec/slash.d.ts +9 -6
  20. package/dist/core/codec/slash.js +0 -0
  21. package/dist/core/codec/table-paste.d.ts +7 -0
  22. package/dist/core/codec/table-paste.js +132 -0
  23. package/dist/core/codec/table-view.d.ts +12 -0
  24. package/dist/core/codec/table-view.js +401 -57
  25. package/dist/core/codec/table.d.ts +38 -16
  26. package/dist/core/codec/table.js +106 -35
  27. package/dist/core/theme.css +1 -1
  28. package/dist/visual/ArrayField.svelte +4 -0
  29. package/dist/visual/Field.svelte +8 -2
  30. package/dist/visual/Field.svelte.d.ts +1 -1
  31. package/dist/visual/FieldHint.svelte +62 -10
  32. package/dist/visual/FormatPopover.svelte +27 -31
  33. package/dist/visual/FormatPopover.svelte.d.ts +2 -2
  34. package/dist/visual/MatrixAdd.svelte +13 -132
  35. package/dist/visual/MatrixAdd.svelte.d.ts +2 -5
  36. package/dist/visual/MatrixField.svelte +5 -63
  37. package/dist/visual/MatrixField.svelte.d.ts +0 -3
  38. package/dist/visual/ObjectField.svelte +7 -3
  39. package/dist/visual/ObjectField.svelte.d.ts +4 -2
  40. package/dist/visual/ProseField.svelte +2 -1
  41. package/dist/visual/ProseValue.svelte +113 -40
  42. package/dist/visual/ProseValue.svelte.d.ts +8 -4
  43. package/dist/visual/VisualEditorInner.svelte +22 -22
  44. package/dist/visual/VisualEditorInner.svelte.d.ts +2 -2
  45. package/dist/visual/checklists.d.ts +0 -79
  46. package/dist/visual/checklists.js +0 -164
  47. package/dist/visual/guidance.d.ts +15 -0
  48. package/dist/visual/guidance.js +37 -0
  49. package/dist/visual/leaves.d.ts +27 -1
  50. package/dist/visual/leaves.js +46 -0
  51. package/dist/visual/strings.d.ts +3 -8
  52. package/dist/visual/strings.js +0 -3
  53. package/dist/visual/structure.js +1 -1
  54. package/dist/visual/tips.js +1 -1
  55. package/package.json +2 -2
@@ -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 the codec's inline mode
7
- // (one paragraph, no containers, no islands, marks and input rules intact);
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 it raises
18
- // nothing. Every control is absolutely positioned out of the grid, so the band is in no
19
- // row and no column of it; `codec/prose.css` draws the band and the selection wash both.
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 { inlineSchema } from './schema.js';
29
- import { cellAt, cellContent, cellEqual, cellFromDoc, clearCells, columnCount, deleteColumn, deleteRow, insertColumn, insertRow, moveColumn, moveRow, normalizeTable, rowCells, rowCount, rowEmpty, shapeEqual, withCell } from './table.js';
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 one glyph this chrome draws, as the path data a DOM node can carry — its own set
50
- * rather than `visual/icons/nodes.ts`, this being the one place chrome is built without
51
- * Svelte, and `/core` reaching no surface module. Same 24×24 frame and the same origin,
52
- * off an earlier release than the thirteen there; `NOTICE` carries the notices for
53
- * both. The dots are zero-length strokes under a round cap, which is how that set draws
54
- * a dot everywhere it has one. */
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 grip's marks. The stroke is heavy for a chrome glyph because they are dots, and a
60
- * dot drawn at the line weight of a stroke disappears at the size the bar renders. */
61
- function svg(paths) {
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', '3');
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(inlineSchema), keymap(keys), keymap(baseKeymap)];
164
+ return [inputRulesPlugin(cellSchema), keymap(keys), keymap(baseKeymap)];
104
165
  }
105
- /** The band's controls, each of which answers for its own press. Spelled apart from
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
- * is inside the cell it names, so the two selectors overlap on exactly the press whose
108
- * reading they disagree about. */
109
- const CONTROLS = '.qm-table-grip, .qm-table-add';
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), inlineSchema),
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 and mark the grip of any line the selection exactly
498
- * covers. Imperative rather than a re-render: a rebuild destroys the nested views,
499
- * and a selection is exactly the state that must not cost the carets in them. */
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
- grip.setAttribute('aria-pressed', String(!!held && sameCells(held, this.lineCells(line, props))));
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 head = el('thead');
774
- head.appendChild(this.row(props, 0, s));
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 tr = el('tr', r === 0 ? 'qm-table-header-row' : undefined);
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(r === 0 ? 'th' : 'td', 'qm-table-cell');
820
- if (r === 0)
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
- if (align !== 'none')
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.appendChild(this.grip({ axis: 'column', index: c }, s.tableSelectColumn(c + 1)));
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 }, r === 0 ? s.tableSelectHeaderRow : s.tableSelectRow(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 name = s.tableCell(r === 0 ? s.tableHeaderRow : s.tableRow(r), s.tableColumn(c + 1));
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), inlineSchema),
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, forced: a `TableCell` has one `text` and no line
996
- * concept, and `continues` is a line flag with no cell analogue, so a newline in
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 (inlineSchema.marks.strong)
1002
- marks['Mod-b'] = toggleMark(inlineSchema.marks.strong);
1003
- if (inlineSchema.marks.em)
1004
- marks['Mod-i'] = toggleMark(inlineSchema.marks.em);
1005
- if (inlineSchema.marks.underline)
1006
- marks['Mod-u'] = toggleMark(inlineSchema.marks.underline);
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.