@quillmark/svelte 0.5.1 → 0.7.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 (66) hide show
  1. package/README.md +6 -0
  2. package/THEMING.md +13 -17
  3. package/dist/core/address.d.ts +17 -3
  4. package/dist/core/address.js +20 -3
  5. package/dist/core/codec/blocks.d.ts +37 -0
  6. package/dist/core/codec/blocks.js +163 -0
  7. package/dist/core/codec/field.js +5 -6
  8. package/dist/core/codec/index.d.ts +1 -1
  9. package/dist/core/codec/index.js +3 -2
  10. package/dist/core/codec/inputrules.js +24 -144
  11. package/dist/core/codec/prose.css +14 -25
  12. package/dist/core/codec/schema.d.ts +8 -0
  13. package/dist/core/codec/schema.js +33 -1
  14. package/dist/core/codec/slash.d.ts +18 -16
  15. package/dist/core/codec/slash.js +0 -0
  16. package/dist/core/codec/table-view.d.ts +4 -0
  17. package/dist/core/codec/table-view.js +137 -158
  18. package/dist/core/codec/table.d.ts +0 -10
  19. package/dist/core/codec/table.js +0 -15
  20. package/dist/core/theme.css +357 -346
  21. package/dist/preset/recipes.css +25 -21
  22. package/dist/preview/Preview.svelte +4 -2
  23. package/dist/preview/bridge.d.ts +23 -0
  24. package/dist/preview/bridge.js +92 -35
  25. package/dist/preview/controller.d.ts +6 -5
  26. package/dist/preview/controller.js +13 -7
  27. package/dist/preview/geometry.d.ts +10 -9
  28. package/dist/preview/geometry.js +11 -13
  29. package/dist/preview/index.js +3 -2
  30. package/dist/preview/paint.js +9 -1
  31. package/dist/visual/ArrayField.svelte +246 -63
  32. package/dist/visual/ArrayField.svelte.d.ts +5 -1
  33. package/dist/visual/Card.svelte +84 -50
  34. package/dist/visual/EnumField.svelte +37 -3
  35. package/dist/visual/Field.svelte +28 -4
  36. package/dist/visual/FieldLabel.svelte +2 -2
  37. package/dist/visual/FormatPopover.svelte +13 -1
  38. package/dist/visual/ObjectField.svelte +128 -35
  39. package/dist/visual/ObjectField.svelte.d.ts +11 -4
  40. package/dist/visual/ProseArrayElement.svelte +22 -2
  41. package/dist/visual/ProseArrayElement.svelte.d.ts +1 -0
  42. package/dist/visual/SlashMenu.svelte +7 -0
  43. package/dist/visual/TipsCard.svelte +33 -13
  44. package/dist/visual/VariantField.svelte +95 -0
  45. package/dist/visual/VariantField.svelte.d.ts +21 -0
  46. package/dist/visual/VisualEditorInner.svelte +44 -21
  47. package/dist/visual/controls.css +282 -283
  48. package/dist/visual/diagnostics.d.ts +13 -11
  49. package/dist/visual/diagnostics.js +11 -9
  50. package/dist/visual/domid.d.ts +13 -0
  51. package/dist/visual/domid.js +16 -0
  52. package/dist/visual/hold.d.ts +17 -0
  53. package/dist/visual/hold.js +39 -0
  54. package/dist/visual/index.js +1 -1
  55. package/dist/visual/leaves.d.ts +9 -7
  56. package/dist/visual/links.d.ts +5 -2
  57. package/dist/visual/links.js +9 -3
  58. package/dist/visual/motion.d.ts +9 -0
  59. package/dist/visual/motion.js +10 -2
  60. package/dist/visual/strings.d.ts +15 -0
  61. package/dist/visual/strings.js +3 -0
  62. package/dist/visual/structure.d.ts +32 -6
  63. package/dist/visual/structure.js +49 -3
  64. package/package.json +2 -2
  65. package/dist/core/codec/clipboard.d.ts +0 -59
  66. package/dist/core/codec/clipboard.js +0 -202
@@ -432,11 +432,11 @@ li.ProseMirror-selectednode::after {
432
432
  which is drawn through a drag the pointer has travelled off — answering which line.
433
433
 
434
434
  Opacity rather than absence, so every control keeps its box and is hit-tested wherever
435
- it sits. A grip's reach is the pointer's — no Tab arrives at one, the cell traversal
436
- claiming the key first (`table-view.ts` §`step`) — so it is out of the tab order, and
437
- the focus rung there draws the focus the view takes itself. The two bars are in it, the
438
- Tab that declines off the last cell landing on them, and the same rung is what draws
439
- one a keyboard has reached. */
435
+ it sits. A grip's reach is the pointer's alone and it holds no focus, so it carries no
436
+ focus rung: what a keyboard draws a line with is the cell the selection runs through
437
+ (`table-view.ts` §`cellKeys`), and the wash and the selected grip's tint are what say
438
+ so. The two bars are in the tab order, the Tab that declines off the last cell landing
439
+ on them, so the rung there draws one a keyboard has reached. */
440
440
  :is(.qm-table-grip, .qm-table-add) {
441
441
  opacity: 0;
442
442
  transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
@@ -447,32 +447,21 @@ li.ProseMirror-selectednode::after {
447
447
  :where(.qm-table-island:hover) :is(.qm-table-grip, .qm-table-add) {
448
448
  opacity: var(--_qm-opacity-muted);
449
449
  }
450
- :is(.qm-table-grip, .qm-table-add):is(:hover, :focus-visible),
451
- .qm-table-grip:is([aria-pressed='true'], .qm-table-lifted) {
450
+ .qm-table-add:is(:hover, :focus-visible),
451
+ .qm-table-grip:is(:hover, [aria-pressed='true'], .qm-table-lifted) {
452
452
  opacity: 1;
453
453
  }
454
454
 
455
- /* The ring, on the chrome and nowhere else in this file. A prose leaf's focus indicator
456
- is its caret and a cell's is too, so the outline is dropped there; a control that comes
457
- up out of nothing has the reveal, and a ring under a keyboard is what says the focus is
458
- there rather than on the paper behind it. Declared here rather than reached for as
459
- `.qm-focus-ring`: that recipe is the visual tier's stylesheet, and the island is inside
460
- the leaf, which is the codec's.
461
-
462
- A selected grip takes none. Focus and selection arrive together there — the grip takes
463
- the focus because the selection's keys bind on it — and the tint plus the wash already
464
- say where the next command lands. A ring on top is a second box around the loudest
465
- thing on the island, drawn on the press that made the selection: a pointer gesture
466
- answering with a keyboard's indicator. Stated as `none` rather than left unset, because
467
- what fills the gap is the browser's own ring, which is the same box in a colour the
468
- surface does not own. */
469
- :is(.qm-table-grip, .qm-table-add):focus-visible {
455
+ /* The ring, on the two add bars and nowhere else in this file. A prose leaf's focus
456
+ indicator is its caret and a cell's is too, so the outline is dropped there; a control
457
+ that comes up out of nothing has the reveal, and a ring under a keyboard is what says
458
+ the focus is there rather than on the paper behind it. Declared here rather than
459
+ reached for as `.qm-focus-ring`: that recipe is the visual tier's stylesheet, and the
460
+ island is inside the leaf, which is the codec's. */
461
+ .qm-table-add:focus-visible {
470
462
  outline: var(--_qm-ring);
471
463
  outline-offset: var(--_qm-ring-offset);
472
464
  }
473
- .qm-table-grip[aria-pressed='true']:focus-visible {
474
- outline: none;
475
- }
476
465
 
477
466
  /* A drag in flight. The lifted line reads as picked up by tone, never a shadow:
478
467
  elevation is a surface rung here, which says the same thing at both poles. The drop
@@ -1,4 +1,12 @@
1
1
  import { Schema } from 'prosemirror-model';
2
+ /**
3
+ * Whether a link carrying `href` renders as one. A value with no scheme is relative
4
+ * to the embedding page and has none to refuse.
5
+ *
6
+ * A refused href is unchanged: it stays on the mark and round-trips, so a document
7
+ * survives an editor that declines to make it clickable.
8
+ */
9
+ export declare function rendersHref(href: string): boolean;
2
10
  /** The full field schema: every block kind, container, mark, and island. */
3
11
  export declare const blockSchema: Schema<string, string>;
4
12
  /** The constrained inline schema: one paragraph, no block splitting, no
@@ -10,6 +10,33 @@
10
10
  // not marks here (decorations).
11
11
  import { Schema } from 'prosemirror-model';
12
12
  import { islandBlockSpec, islandInlineSpec } from './islands.js';
13
+ // ── The href gate ───────────────────────────────────────────────────────────
14
+ // An `href` is an attribute value and not markup, so the markdown → typed node → DOM
15
+ // path that keeps a document's text from becoming tags never reaches it: what the
16
+ // mark carries is what `toDOM` emits, and `renderContent` paints marks outside a
17
+ // `contenteditable` (the tips card), where a click is a plain one.
18
+ //
19
+ // An allowlist, because the set a document can spell is open: naming the dangerous
20
+ // schemes instead loses to the first one this has not heard of. It holds what the
21
+ // surface has a caller for; a scheme reaching it later is a line.
22
+ const RENDERED_SCHEMES = new Set(['http', 'https', 'mailto', 'tel', 'ftp']);
23
+ /** A scheme and its colon: a letter, then letters, digits, `+`, `-` or `.` (RFC 3986). */
24
+ const SCHEME = /^([a-z][a-z0-9+.-]*):/i;
25
+ /** Dropped before the scheme is read, so a tab spliced into `javascript:` is tested
26
+ * as what it navigates to. Wider than the URL parser's own tab/newline rule: no
27
+ * scheme carries a control character, and the strip decides without rewriting. */
28
+ const IGNORED = /[\u0000-\u0020]/g;
29
+ /**
30
+ * Whether a link carrying `href` renders as one. A value with no scheme is relative
31
+ * to the embedding page and has none to refuse.
32
+ *
33
+ * A refused href is unchanged: it stays on the mark and round-trips, so a document
34
+ * survives an editor that declines to make it clickable.
35
+ */
36
+ export function rendersHref(href) {
37
+ const scheme = SCHEME.exec(href.replace(IGNORED, ''));
38
+ return scheme === null || RENDERED_SCHEMES.has(scheme[1].toLowerCase());
39
+ }
13
40
  // ── Marks (the block and inline schemas share them; plaintext declares none) ─
14
41
  const marks = {
15
42
  // Order matters: it fixes mark-set sort order and parse precedence. `link`
@@ -26,7 +53,12 @@ const marks = {
26
53
  parseDOM: [
27
54
  { tag: 'a[href]', getAttrs: (el) => ({ href: el.getAttribute('href') }) }
28
55
  ],
29
- toDOM: (mark) => ['a', { href: mark.attrs.href }, 0]
56
+ // A refused href draws as a bare span: the text stands, unstyled and
57
+ // unclickable, and the mark keeps its value for encode.
58
+ toDOM: (mark) => {
59
+ const href = mark.attrs.href;
60
+ return rendersHref(href) ? ['a', { href }, 0] : ['span', 0];
61
+ }
30
62
  },
31
63
  // The open-set escape hatch: an inert mark that renders as a bare span and
32
64
  // re-emits its stored `type`/`attrs` on encode. `excludes: ''` lets marks of
@@ -1,4 +1,4 @@
1
- import { Plugin, type Command } from 'prosemirror-state';
1
+ import { EditorState, Plugin, type Command } from 'prosemirror-state';
2
2
  import type { EditorView } from 'prosemirror-view';
3
3
  import { type RangeAnchor } from './anchor.js';
4
4
  /** The menu as the chrome sees it: the command names the query matched, the keyboard's
@@ -25,18 +25,21 @@ export interface SlashStrings {
25
25
  * chrome's wording follows, and the visual `strings` set extends both. */
26
26
  export declare const DEFAULT_SLASH_STRINGS: SlashStrings;
27
27
  /**
28
- * The vocabulary, in menu order: the names `SLASH_COMMANDS` is keyed by, read off the
29
- * table itself so an offer and its pick cannot disagree.
28
+ * The vocabulary a caret can run, in menu order: the names `SLASH_COMMANDS` is keyed
29
+ * by, each asked whether it would fire here. A row that declines when picked is worse
30
+ * than an absent one, and asking the command rather than restating its guards is what
31
+ * keeps the menu from drifting off what the shorthand does. So the offers narrow as the
32
+ * caret moves: the island alone mid-paragraph, no list at an item's head.
30
33
  *
31
- * The set is what no shorthand reaches. A list is `- ` / `1. `, a heading `# `, a quote
32
- * `> `, a rule `---`, a code block a fence: a shorthand fires where the caret already
33
- * is, and a second door onto the same construct is a second thing to keep true. A table
34
- * has no prefix a rule can fire on (a pipe row is not one), so it is the menu's alone.
34
+ * `state` is the state a pick leaves behind — the run already consumed — since that is
35
+ * the caret every command answers for (`blocks.ts`).
35
36
  */
36
- export declare function slashItems(): string[];
37
- /** The query filter: a case-insensitive prefix of the name, which is how a command line
38
- * completes. An empty query offers everything, and the vocabulary's order survives, so
39
- * the menu never reorders under a keystroke. */
37
+ export declare function slashItems(state: EditorState): string[];
38
+ /** The query filter: a case-insensitive prefix of the name or of any of its words, which
39
+ * is how a command line completes and how a two-word name stays reachable by the noun a
40
+ * writer has — `/list` offers both lists. A prefix and not a substring: `note` is inside
41
+ * `footnote` and completes nothing. An empty query offers everything, and the
42
+ * vocabulary's order survives, so the menu never reorders under a keystroke. */
40
43
  export declare function filterItems(items: string[], query: string): string[];
41
44
  /** The live trigger: where the `/` sits, what has been typed after it, and which
42
45
  * offer the keyboard is on. */
@@ -67,11 +70,10 @@ export declare function slashKeymap(): Record<string, Command>;
67
70
  * highlight has one lane rather than a hover painting a second one. */
68
71
  export declare function focusSlashItem(view: EditorView, name: string): void;
69
72
  /**
70
- * Run a command: one transaction that deletes the trigger run and applies the
71
- * command's own edit. One rather than two because two would be two commits, two undo
72
- * steps and two recompiles for one gesture; the command is built against the
73
- * post-delete state and its steps are replayed onto the same transaction, which is
74
- * exactly what they were computed for.
73
+ * Run a command: one transaction that deletes the trigger run and applies the command's
74
+ * own edit (`blocks.ts` §`consuming`). A command that declines consumes nothing. The
75
+ * menu never picks one, offering only what would run; `slashPick` is a public seam and
76
+ * can.
75
77
  */
76
78
  export declare function runSlashItem(view: EditorView, name: string): void;
77
79
  export {};
Binary file
@@ -40,6 +40,10 @@ export interface TableViewDeps {
40
40
  /** A cell took focus: the leaf's own `focus` handler never fires for one (a focus
41
41
  * event does not bubble), so the active address would not follow the caret. */
42
42
  onCellFocus: () => void;
43
+ /** The clearance a revealed caret keeps, in the leaf's own line box (`field.ts`), which
44
+ * is a cell's line box too. `undefined` where the derivation is out of reach (jsdom),
45
+ * which is PM's 5px default. */
46
+ clearance: number | undefined;
43
47
  }
44
48
  /** The `island_block` node view: a table island's editing surface, and the literal
45
49
  * placeholder for every other island type. */
@@ -17,17 +17,16 @@
17
17
  // The chrome is A band and A selection (CODEC §"The table island"), and it raises
18
18
  // nothing. Every control is absolutely positioned out of the grid, so the band is in no
19
19
  // row and no column of it; `codec/prose.css` draws the band and the selection wash both.
20
- import { baseKeymap, toggleMark } from 'prosemirror-commands';
20
+ import { baseKeymap, chainCommands, toggleMark } from 'prosemirror-commands';
21
21
  import { redo, undo } from 'prosemirror-history';
22
22
  import { keymap } from 'prosemirror-keymap';
23
23
  import { EditorState, NodeSelection, Selection, TextSelection } from 'prosemirror-state';
24
24
  import { EditorView } from 'prosemirror-view';
25
- import { tableFromClipboard, writeRectangle } from './clipboard.js';
26
25
  import { decode } from './decode.js';
27
26
  import { inputRulesPlugin } from './inputrules.js';
28
27
  import { tablePropsOfNode } from './islands.js';
29
28
  import { inlineSchema } from './schema.js';
30
- import { allRows, cellAt, cellContent, cellEqual, cellFromDoc, clearCells, columnCount, deleteColumn, deleteRow, insertColumn, insertRow, moveColumn, moveRow, normalizeTable, pasteCells, rowCells, rowCount, rowEmpty, shapeEqual, withCell } from './table.js';
29
+ import { cellAt, cellContent, cellEqual, cellFromDoc, clearCells, columnCount, deleteColumn, deleteRow, insertColumn, insertRow, moveColumn, moveRow, normalizeTable, rowCells, rowCount, rowEmpty, shapeEqual, withCell } from './table.js';
31
30
  /**
32
31
  * The package's English for the island chrome. It lives here, beside the surface
33
32
  * that draws it, rather than in the visual tier's table: the codec mounts this
@@ -83,8 +82,9 @@ function el(tag, className) {
83
82
  }
84
83
  /** A band button. It swallows its own `mousedown` (prosemirror-menu's trick, and
85
84
  * the format popover's): without it the browser focuses the button, blurring the
86
- * cell whose caret the op is about to be measured against. A grip wants that focus
87
- * and takes it explicitly, which is why this does not hand it out. */
85
+ * cell whose caret the op is about to be measured against. No control on the band ever
86
+ * holds the focus: a cell is the island's one host, and what a press acts on is where
87
+ * it lands the caret. */
88
88
  function chromeButton(className, label, run) {
89
89
  const btn = el('button', className);
90
90
  btn.type = 'button';
@@ -161,13 +161,6 @@ class TableIslandView {
161
161
  this.dom.setAttribute('data-island', node.attrs.islandType);
162
162
  this.dom.setAttribute('data-island-id', node.attrs.id);
163
163
  this.dom.addEventListener('mousedown', this.onPointerDown);
164
- // On the document rather than on the island: a `copy` is delivered to the DOM
165
- // selection's own node, and a held rectangle has cleared that selection (a sweep
166
- // removes the ranges itself, a grip press moves the focus to a button), so the
167
- // event never crosses this subtree at all. What makes one this island's is
168
- // {@link TableIslandView.onCopy}'s guard.
169
- this.dom.ownerDocument.addEventListener('copy', this.onCopy);
170
- this.dom.ownerDocument.addEventListener('cut', this.onCopy);
171
164
  this.render();
172
165
  }
173
166
  // ── PM's NodeView contract ────────────────────────────────────────────────
@@ -220,8 +213,6 @@ class TableIslandView {
220
213
  }
221
214
  destroy() {
222
215
  this.dom.removeEventListener('mousedown', this.onPointerDown);
223
- this.dom.ownerDocument.removeEventListener('copy', this.onCopy);
224
- this.dom.ownerDocument.removeEventListener('cut', this.onCopy);
225
216
  this.endDrag();
226
217
  this.endSweep();
227
218
  this.teardownCells();
@@ -317,10 +308,10 @@ class TableIslandView {
317
308
  this.outer.focus();
318
309
  this.outer.dispatch(this.outer.state.tr.setSelection(selection));
319
310
  }
320
- /** The island as the selection: Escape out of a cell or a line, which is the whole of
321
- * what selects it. Backspace there deletes the table, as it does over every other
322
- * island; the cell selection covering every rank reaches the same delete without
323
- * leaving the grid (CODEC §"The table island"). */
311
+ /** The island as the selection: the Escape that climbs out of a cell or a held
312
+ * rectangle, which is the whole of what selects it. Backspace there deletes the
313
+ * table, as it does over every other island; the cell selection covering every rank
314
+ * reaches the same delete without leaving the grid (CODEC §"The table island"). */
324
315
  selectIsland() {
325
316
  const pos = this.getPos();
326
317
  if (pos == null)
@@ -396,10 +387,6 @@ class TableIslandView {
396
387
  return best;
397
388
  }
398
389
  // ── The selection ─────────────────────────────────────────────────────────
399
- /** Take the line's grip: the focus a selection's keys land on. */
400
- gripFor(line) {
401
- return this.grips.get(lineKey(line));
402
- }
403
390
  /** The rectangle a line covers, which is what selecting one means: a grip draws no
404
391
  * second kind of selection, it draws this one. */
405
392
  lineCells(line) {
@@ -416,11 +403,43 @@ class TableIslandView {
416
403
  ? { floor: 0, limit: rowCount(props) - 1 }
417
404
  : { floor: 0, limit: columnCount(props) - 1 };
418
405
  }
419
- /** Select a line. The grip takes the focus, because the selection's keys bind
420
- * there and need somewhere to land. */
406
+ /** Select a line, and land the caret on it. The line verbs bind in the cell
407
+ * (CODEC §"The table island"), so the rectangle has to run through the caret for the
408
+ * next key to read a line off it. The focus moves first and the paint follows: a cell
409
+ * taking the focus retires whatever was held. */
421
410
  selectLine(line) {
411
+ this.carryCaret(line);
422
412
  this.select(this.lineCells(line));
423
- this.gripFor(line)?.focus();
413
+ }
414
+ /** Put the caret on the line, keeping its other coordinate, and leave it exactly
415
+ * where it is on a line it is already in: a walk down the rows stays in its column,
416
+ * and a grip press on the caret's own row does not shunt it to a cell's end. */
417
+ carryCaret(line) {
418
+ const held = this.cells.find((m) => m.view.hasFocus());
419
+ if (line.axis === 'row') {
420
+ if (held?.r !== line.index)
421
+ this.focusCell(line.index, held?.c ?? 0);
422
+ }
423
+ else if (held?.c !== line.index)
424
+ this.focusCell(held?.r ?? 0, line.index);
425
+ }
426
+ /** Which line the held rectangle is, on one axis: a row spanning every column, a
427
+ * column spanning every row, and nothing at all for a block that spans neither. The
428
+ * extent rule reaching the line verbs ({@link TableIslandView.deleteSelection}) — a
429
+ * rectangle answers by what it covers, never by the gesture that drew it, so a row
430
+ * swept cell by cell moves exactly as the row a grip named does. */
431
+ lineOn(axis) {
432
+ const held = this.selected;
433
+ if (!held)
434
+ return undefined;
435
+ const props = this.props();
436
+ if (axis === 'row')
437
+ return held.r0 === held.r1 && held.c0 === 0 && held.c1 === columnCount(props) - 1
438
+ ? held.r0
439
+ : undefined;
440
+ return held.c0 === held.c1 && held.r0 === 0 && held.r1 === rowCount(props) - 1
441
+ ? held.c0
442
+ : undefined;
424
443
  }
425
444
  select(cells) {
426
445
  // One subject at a time: a cell selection retires an island one, or the surface
@@ -457,94 +476,6 @@ class TableIslandView {
457
476
  for (const cell of this.cells)
458
477
  cell.box.toggleAttribute('data-selected', !!held && cell.r >= held.r0 && cell.r <= held.r1 && cell.c >= held.c0 && cell.c <= held.c1);
459
478
  }
460
- /**
461
- * A selected line's keys, bound to the line its own grip names rather than read off
462
- * the selection: focus and selection are separate, and a Tab between grips would
463
- * otherwise aim the next key at whichever line was last pressed.
464
- *
465
- * Undo is forwarded for the reason a cell forwards it: one undo stack per leaf, and
466
- * a `button` has no history of its own.
467
- */
468
- lineKeys(line) {
469
- return (event) => {
470
- const key = event.key;
471
- const mod = event.ctrlKey || event.metaKey;
472
- if (mod && (key === 'z' || key === 'Z' || key === 'y')) {
473
- event.preventDefault();
474
- const redoing = key === 'y' || (key === 'Z' && event.shiftKey);
475
- (redoing ? redo : undo)(this.outer.state, this.outer.dispatch);
476
- return;
477
- }
478
- const along = line.axis === 'row' ? ['ArrowUp', 'ArrowDown'] : ['ArrowLeft', 'ArrowRight'];
479
- const acts = along.includes(key) || ['Backspace', 'Delete', 'Enter', 'Escape'].includes(key);
480
- if (!acts)
481
- return;
482
- event.preventDefault();
483
- event.stopPropagation();
484
- if (key === 'Escape')
485
- return this.selectIsland();
486
- if (key === 'Enter') {
487
- this.clearSelection();
488
- return line.axis === 'row' ? this.focusCell(line.index, 0) : this.focusCell(0, line.index);
489
- }
490
- if (key === 'Backspace' || key === 'Delete')
491
- return this.deleteSelection();
492
- const by = key === 'ArrowUp' || key === 'ArrowLeft' ? -1 : 1;
493
- if (event.altKey)
494
- return this.moveLine(line, by);
495
- const { floor, limit } = this.bounds(line.axis, this.props());
496
- this.selectLine({
497
- axis: line.axis,
498
- index: Math.max(floor, Math.min(line.index + by, limit))
499
- });
500
- };
501
- }
502
- // ── The clipboard ─────────────────────────────────────────────────────────
503
- /** The held rectangle's cells, read off the stored props: the unit that crosses the
504
- * clipboard (`clipboard.ts`), so the unit the verbs here read. */
505
- heldBlock(held) {
506
- const props = this.props();
507
- return Array.from({ length: held.r1 - held.r0 + 1 }, (_, i) => Array.from({ length: held.c1 - held.c0 + 1 }, (_, j) => cellAt(props, held.r0 + i, held.c0 + j)));
508
- }
509
- /**
510
- * Copy or cut the held rectangle (`clipboard.ts` writes both wire formats). A cut is
511
- * a copy plus the delete Backspace already means over that rectangle, so what it
512
- * takes out is read by the same extent rule: a covered rank goes, a sub-rectangle
513
- * clears.
514
- *
515
- * The guard is the whole of what makes a document-level listener this island's. With
516
- * no rectangle the event belongs to whatever holds the caret. With the focus outside,
517
- * it is another surface's copy over a rectangle this island still happens to hold.
518
- */
519
- onCopy = (event) => {
520
- const held = this.selected;
521
- const data = event.clipboardData;
522
- const owner = this.dom.ownerDocument;
523
- if (!held || !data || !this.dom.contains(owner.activeElement))
524
- return;
525
- writeRectangle(data, this.heldBlock(held), this.props().aligns.slice(held.c0, held.c1 + 1), owner);
526
- event.preventDefault();
527
- if (event.type === 'cut')
528
- this.deleteSelection();
529
- };
530
- /**
531
- * A table pasted into a cell is written in at that cell, growing the table to hold
532
- * it (`table.ts` §{@link pasteCells}); anything else is the cell's own paste and
533
- * this declines. It lands at the caret's cell, which over a swept block is the cell
534
- * the press started in — the block's anchor rather than its first corner, a sweep
535
- * running up or left putting those at opposite ends.
536
- *
537
- * Without this arm the cell's inline schema reads the same clipboard as text and
538
- * flattens the table into one cell, which is the destructive case one level down.
539
- */
540
- pasteIntoCell(r, c, event) {
541
- const props = tableFromClipboard(event.clipboardData);
542
- if (!props)
543
- return false;
544
- this.clearSelection();
545
- this.write(pasteCells(this.props(), r, c, allRows(props)));
546
- return true;
547
- }
548
479
  /**
549
480
  * What Backspace means over the selection, decided by its extent rather than by the
550
481
  * gesture that drew it (CODEC §"The table island").
@@ -826,8 +757,9 @@ class TableIslandView {
826
757
  this.reseat(seat);
827
758
  }
828
759
  /**
829
- * Where the focus sits, in terms a rebuild can restore it by: a cell's position or a
830
- * grip's line, neither of which is an element that survives one.
760
+ * Where the focus sits, in terms a rebuild can restore it by: the caret's cell by
761
+ * position, that being the one seat the island has and not an element that survives
762
+ * one.
831
763
  *
832
764
  * A rebuild is what a changed rectangle costs, and the op that changed it usually
833
765
  * says where the caret lands ({@link TableIslandView.write}'s `focus`). An undo says
@@ -837,28 +769,15 @@ class TableIslandView {
837
769
  */
838
770
  focusedSeat() {
839
771
  const held = this.cells.find((m) => m.view.hasFocus());
840
- if (held)
841
- return { cell: { r: held.r, c: held.c } };
842
- const active = this.dom.ownerDocument.activeElement;
843
- for (const [key, grip] of this.grips) {
844
- if (grip !== active)
845
- continue;
846
- const [axis, index] = key.split(':');
847
- return { line: { axis: axis, index: Number(index) } };
848
- }
849
- return undefined;
772
+ return held && { r: held.r, c: held.c };
850
773
  }
851
- /** Put the focus back where {@link TableIslandView.focusedSeat} found it, clamped into
852
- * the rectangle that is there now: the seat's line may be the one the rebuild
853
- * removed. A caller that has a landing of its own overrides this by running after. */
774
+ /** Put the caret back where {@link TableIslandView.focusedSeat} found it, clamped by
775
+ * `focusCell` into the rectangle that is there now: the seat may be the cell the
776
+ * rebuild removed. A caller that has a landing of its own overrides this by running
777
+ * after. */
854
778
  reseat(seat) {
855
- if (seat?.cell)
856
- return this.focusCell(seat.cell.r, seat.cell.c);
857
- if (!seat?.line)
858
- return;
859
- const { floor, limit } = this.bounds(seat.line.axis, this.props());
860
- const index = Math.max(floor, Math.min(seat.line.index, limit));
861
- this.gripFor({ axis: seat.line.axis, index })?.focus();
779
+ if (seat)
780
+ this.focusCell(seat.r, seat.c);
862
781
  }
863
782
  /** One table row: its cells, each carrying whatever chrome hangs off it. */
864
783
  row(props, r, s) {
@@ -894,12 +813,12 @@ class TableIslandView {
894
813
  * press that travels drags it. Both are "this line", asked once with the pointer,
895
814
  * and the dead zone is what tells them apart.
896
815
  *
897
- * Out of the tab order, at no cost in reach: a grip follows the cell it hangs in, so
898
- * a forward Tab is the cell traversal's before the browser gets there and a backward
899
- * one leaves the grid off cell (0,0) without passing a grip
900
- * (§{@link TableIslandView.step}). A line's chrome answers to the pointer. Focus is
901
- * still taken here, by the routes this view owns: a selection, a drag, a rebuild's
902
- * reseat. */
816
+ * Pointer chrome and nothing else: no key binds here and no route focuses it, the
817
+ * line verbs binding in the cell the selection runs through
818
+ * (§{@link TableIslandView.cellKeys}). Out of the tab order at no cost in reach,
819
+ * since a grip follows the cell it hangs in: a forward Tab is the cell traversal's
820
+ * before the browser gets there, and a backward one leaves the grid off cell (0,0)
821
+ * without passing a grip (§{@link TableIslandView.step}). */
903
822
  grip(line, label) {
904
823
  const btn = chromeButton('qm-table-grip', label, () => {
905
824
  if (this.suppressClick)
@@ -914,7 +833,6 @@ class TableIslandView {
914
833
  bar.appendChild(svg(GRIP[line.axis]));
915
834
  btn.appendChild(bar);
916
835
  btn.addEventListener('pointerdown', (e) => this.onGripDown(line, btn, e));
917
- btn.addEventListener('keydown', this.lineKeys(line));
918
836
  this.grips.set(lineKey(line), btn);
919
837
  return btn;
920
838
  }
@@ -924,11 +842,12 @@ class TableIslandView {
924
842
  * the pointer out past the last line is the claim — so its name carries the verb for
925
843
  * everything that does not read position.
926
844
  *
927
- * In the tab order, where a grip is not: the column bar is the keyboard's only route
928
- * to a column at all, and the row bar is that control on the other axis, where Enter
929
- * at the last row and Tab past the last cell reach the verb from inside a cell. They
930
- * sit after the grid, so the Tab that declines off the last cell lands on them, and
931
- * the focus rung draws the one it lands on (`codec/prose.css`). */
845
+ * In the tab order, where a grip is not: growth is what the line verbs do not carry,
846
+ * so the column bar is the keyboard's only route to a new column, and the row bar is
847
+ * that control on the other axis, where Enter at the last row and Tab past the last
848
+ * cell reach the verb from inside a cell. They sit after the grid, so the Tab that
849
+ * declines off the last cell lands on them, and the focus rung draws the one it lands
850
+ * on (`codec/prose.css`). */
932
851
  addBar(axis, label) {
933
852
  const btn = chromeButton('qm-table-add', label, () => {
934
853
  const props = this.props();
@@ -950,15 +869,24 @@ class TableIslandView {
950
869
  plugins: cellPlugins(this.cellKeys(r, c))
951
870
  }),
952
871
  attributes: { 'aria-label': name, class: 'qm-table-cell-editor' },
872
+ // A cell is one of the leaf's lines, so a reveal here keeps the leaf's
873
+ // clearance: PM's 5px default is the caret visible and unusable that rung
874
+ // exists to refuse (`field.ts`).
875
+ scrollThreshold: this.deps.clearance,
876
+ scrollMargin: this.deps.clearance,
953
877
  dispatchTransaction: (tr) => {
954
878
  const next = view.state.apply(tr);
955
879
  view.updateState(next);
956
880
  if (!tr.docChanged)
957
881
  return;
882
+ // A cell edit is the caret's, so it retires the rectangle: a wash left up
883
+ // over text being typed says the next Backspace takes a rank, and it does
884
+ // not. The clear op is not this path — it writes through the leaf, and the
885
+ // reseed it comes back as changes no cell's own doc.
886
+ this.clearSelection();
958
887
  const now = this.props();
959
888
  this.write(withCell(now, r, c, cellFromDoc(next.doc, cellAt(now, r, c))));
960
889
  },
961
- handlePaste: (_view, event) => this.pasteIntoCell(r, c, event),
962
890
  handleDOMEvents: {
963
891
  focus: () => {
964
892
  this.clearSelection();
@@ -1005,8 +933,12 @@ class TableIslandView {
1005
933
  if (!mounted)
1006
934
  return;
1007
935
  const { view } = mounted;
936
+ // Flagged, for the reason the leaf's own landing is (`field.ts`, `setCaret`): PM
937
+ // focuses with `preventScroll`, so an unflagged dispatch lands the caret where no
938
+ // scroller has moved to — a Tab past the right edge of the horizontal scroller a
939
+ // wide table lives in, or the row an Enter on the last one appends below the fold.
1008
940
  view.focus();
1009
- view.dispatch(view.state.tr.setSelection(Selection.atEnd(view.state.doc)));
941
+ view.dispatch(view.state.tr.setSelection(Selection.atEnd(view.state.doc)).scrollIntoView());
1010
942
  }
1011
943
  /**
1012
944
  * A cell's keys. Traversal is the island's link in the leaf's chain
@@ -1026,10 +958,10 @@ class TableIslandView {
1026
958
  if (inlineSchema.marks.underline)
1027
959
  marks['Mod-u'] = toggleMark(inlineSchema.marks.underline);
1028
960
  // Up and down are the grid's own walk: nothing else moves the caret vertically,
1029
- // and `focusCell` clamps, so neither can grow the table. Left and right are
1030
- // deliberately absent: at a text edge they would call what Tab and Shift-Tab
1031
- // already call, and inherit the append-past-the-last-cell that makes Tab a
1032
- // growth affordance and would make a caret key one.
961
+ // and `focusCell` clamps, so neither can grow the table. Left and right do not
962
+ // traverse at all: at a text edge they would call what Tab and Shift-Tab already
963
+ // call, and inherit the append-past-the-last-cell that makes Tab a growth
964
+ // affordance and would make a caret key one.
1033
965
  const walk = (dir) => {
1034
966
  return (_state, _dispatch, view) => {
1035
967
  const { selection } = view?.state ?? {};
@@ -1050,6 +982,34 @@ class TableIslandView {
1050
982
  this.deleteSelection();
1051
983
  return true;
1052
984
  };
985
+ // The line verbs, over the held rectangle and nothing else: with none held every
986
+ // one of these declines and the key is the caret's, which is what lets them sit on
987
+ // keys a cell is already typing under. An arrow names the line on its own axis
988
+ // through the caret — the one already held steps to its neighbour and carries the
989
+ // caret, and any other rectangle turns into it, which is how a row becomes the
990
+ // column the caret is in. Alt moves the line instead, and over a rectangle that is
991
+ // not one it does nothing but keep the key: while a rectangle is held every arrow
992
+ // is its own, and an Alt+arrow handed back is the browser's Back on two platforms,
993
+ // mid-gesture.
994
+ const line = (axis, by, move) => {
995
+ return () => {
996
+ if (!this.selected)
997
+ return false;
998
+ const on = this.lineOn(axis);
999
+ if (move) {
1000
+ if (on !== undefined)
1001
+ this.moveLine({ axis, index: on }, by);
1002
+ return true;
1003
+ }
1004
+ if (on === undefined) {
1005
+ this.selectLine({ axis, index: axis === 'row' ? r : c });
1006
+ return true;
1007
+ }
1008
+ const { floor, limit } = this.bounds(axis, this.props());
1009
+ this.selectLine({ axis, index: Math.max(floor, Math.min(on + by, limit)) });
1010
+ return true;
1011
+ };
1012
+ };
1053
1013
  return {
1054
1014
  ...marks,
1055
1015
  // One undo stack per leaf: a cell carries no history of its own, so Mod-z
@@ -1059,11 +1019,23 @@ class TableIslandView {
1059
1019
  'Shift-Mod-z': () => redo(this.outer.state, this.outer.dispatch),
1060
1020
  Backspace: erase,
1061
1021
  Delete: erase,
1062
- ArrowUp: walk('up'),
1063
- ArrowDown: walk('down'),
1022
+ ArrowUp: chainCommands(line('row', -1, false), walk('up')),
1023
+ ArrowDown: chainCommands(line('row', 1, false), walk('down')),
1024
+ ArrowLeft: line('column', -1, false),
1025
+ ArrowRight: line('column', 1, false),
1026
+ 'Alt-ArrowUp': line('row', -1, true),
1027
+ 'Alt-ArrowDown': line('row', 1, true),
1028
+ 'Alt-ArrowLeft': line('column', -1, true),
1029
+ 'Alt-ArrowRight': line('column', 1, true),
1064
1030
  Tab: () => this.step(r, c, 1),
1065
1031
  'Shift-Tab': () => this.step(r, c, -1),
1066
1032
  Enter: () => {
1033
+ // Over a rectangle it hands the caret back, which is the line verbs' own
1034
+ // exit: the caret never left the cell, so there is nowhere else to put it.
1035
+ if (this.selected) {
1036
+ this.clearSelection();
1037
+ return true;
1038
+ }
1067
1039
  const props = this.props();
1068
1040
  if (r === rowCount(props) - 1)
1069
1041
  this.write(insertRow(props, r), { r: r + 1, c });
@@ -1071,10 +1043,17 @@ class TableIslandView {
1071
1043
  this.focusCell(r + 1, c);
1072
1044
  return true;
1073
1045
  },
1074
- // The innermost Escape: out of the cell, onto the island. What the next one
1075
- // means is the shell's (VISUAL_EDITOR §"Settled and open").
1046
+ // Escape climbs a rung a press: the caret's own row, then the island. The row
1047
+ // is the entry the band has no other route to — every line verb reads a held
1048
+ // rectangle, and this is the gesture that draws one from the keyboard — and it
1049
+ // is the row rather than the column because the column is one arrow further
1050
+ // on. What the press past the island means is the shell's
1051
+ // (VISUAL_EDITOR §"Settled and open").
1076
1052
  Escape: () => {
1077
- this.selectIsland();
1053
+ if (this.selected)
1054
+ this.selectIsland();
1055
+ else
1056
+ this.selectLine({ axis: 'row', index: r });
1078
1057
  return true;
1079
1058
  }
1080
1059
  };