@quillmark/svelte 0.10.0 → 0.11.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 (60) hide show
  1. package/README.md +1 -1
  2. package/THEMING.md +4 -2
  3. package/dist/core/codec/blocks.js +21 -1
  4. package/dist/core/codec/breaks.d.ts +10 -2
  5. package/dist/core/codec/breaks.js +65 -9
  6. package/dist/core/codec/code.d.ts +6 -4
  7. package/dist/core/codec/code.js +7 -4
  8. package/dist/core/codec/decode.js +91 -62
  9. package/dist/core/codec/encode.d.ts +5 -8
  10. package/dist/core/codec/encode.js +55 -85
  11. package/dist/core/codec/field.js +43 -24
  12. package/dist/core/codec/index.d.ts +2 -1
  13. package/dist/core/codec/index.js +6 -3
  14. package/dist/core/codec/islands.d.ts +13 -9
  15. package/dist/core/codec/islands.js +75 -49
  16. package/dist/core/codec/keymap.js +2 -1
  17. package/dist/core/codec/marks.d.ts +17 -13
  18. package/dist/core/codec/marks.js +39 -42
  19. package/dist/core/codec/positions.d.ts +4 -1
  20. package/dist/core/codec/positions.js +5 -1
  21. package/dist/core/codec/prose.css +10 -0
  22. package/dist/core/codec/reconcile.d.ts +6 -0
  23. package/dist/core/codec/reconcile.js +19 -0
  24. package/dist/core/codec/schema.d.ts +5 -8
  25. package/dist/core/codec/schema.js +30 -156
  26. package/dist/core/codec/table-view.js +77 -26
  27. package/dist/core/codec/table.d.ts +6 -5
  28. package/dist/core/codec/table.js +14 -31
  29. package/dist/core/codec/urls.d.ts +20 -0
  30. package/dist/core/codec/urls.js +45 -0
  31. package/dist/preset/recipes.css +1 -1
  32. package/dist/preset/scale.css +7 -4
  33. package/dist/preview/Preview.svelte +6 -0
  34. package/dist/preview/bridge.d.ts +8 -1
  35. package/dist/preview/bridge.js +31 -25
  36. package/dist/preview/controller.js +13 -13
  37. package/dist/preview/geometry.d.ts +12 -1
  38. package/dist/preview/geometry.js +14 -1
  39. package/dist/preview/paint.js +6 -4
  40. package/dist/preview/strings.d.ts +0 -2
  41. package/dist/preview/strings.js +5 -6
  42. package/dist/visual/ArrayField.svelte +11 -13
  43. package/dist/visual/Card.svelte +5 -8
  44. package/dist/visual/DateField.svelte +2 -2
  45. package/dist/visual/Field.svelte +7 -3
  46. package/dist/visual/FieldHint.svelte +1 -1
  47. package/dist/visual/ObjectField.svelte +6 -1
  48. package/dist/visual/VisualEditor.svelte +1 -1
  49. package/dist/visual/VisualEditorInner.svelte +73 -18
  50. package/dist/visual/controls.css +8 -9
  51. package/dist/visual/ext.d.ts +12 -13
  52. package/dist/visual/ext.js +22 -19
  53. package/dist/visual/index.js +2 -3
  54. package/dist/visual/links.d.ts +4 -0
  55. package/dist/visual/links.js +15 -4
  56. package/dist/visual/strings.d.ts +7 -4
  57. package/dist/visual/strings.js +1 -0
  58. package/dist/visual/structure.d.ts +9 -1
  59. package/dist/visual/structure.js +26 -4
  60. package/package.json +2 -2
@@ -1,92 +1,25 @@
1
1
  // The PM schema: the codec owns it; decode/encode target it. Nodes mirror the
2
2
  // content block kinds (para/heading/code/rule/island) and its container nesting
3
3
  // (list_item/quote → lists/blockquote); marks mirror the content formatting set.
4
- // Each of the three open sets gets an inert carrier so an unrecognized value
5
- // survives a round-trip: the `unknown` mark, the paragraph's `unknown` attribute,
6
- // and the `unknown_container` node. `blockSchema` is the full field; `inlineSchema`
7
- // is the constrained single-textblock form for `richtext(inline)` (one paragraph,
8
- // no block split, no containers, no islands) and `plaintextSchema` is that one
9
- // without marks: same decode/lower/position machinery, narrower shape. Anchors are
10
- // not marks here (decorations).
4
+ // Each set is closed upstream, so the schema names its whole vocabulary and carries
5
+ // nothing inert. `blockSchema` is the full field; `inlineSchema` is the constrained
6
+ // single-textblock form for `richtext(inline)` (one paragraph, no block split, no
7
+ // containers, no islands) and `plaintextSchema` is that one without marks: same
8
+ // decode/lower/position machinery, narrower shape. Anchors are not marks here
9
+ // (decorations).
11
10
  //
12
11
  // `toDOM` and `parseDOM` are one tier, not two halves of a rendering: a copy and a
13
12
  // paste inside one body run the whole document through them (CODEC §"Markdown at the
14
- // edges"), so every attribute written here is read back here. A carrier's payload
15
- // crosses as JSON under a `data-qm-*` name, which is the form the content's open sets
16
- // already have. Foreign HTML spells none of those names, so what a paste takes off the
17
- // web is unchanged; the one rule that widens that door on purpose is the fence's.
13
+ // edges"), so every attribute written here is read back here. Foreign HTML spells none
14
+ // of this package's `data-qm-*` names, so what a paste takes off the web is unchanged;
15
+ // the one rule that widens that door on purpose is the fence's.
18
16
  import { Schema } from 'prosemirror-model';
19
17
  import { islandBlockSpec, islandInlineSpec } from './islands.js';
20
- // ── The href gate ───────────────────────────────────────────────────────────
21
- // An `href` is an attribute value and not markup, so the markdown → typed node → DOM
22
- // path that keeps a document's text from becoming tags never reaches it: what the
23
- // mark carries is what `toDOM` emits, and `renderContent` paints marks outside a
24
- // `contenteditable` (the tips card), where a click is a plain one.
25
- //
26
- // An allowlist, because the set a document can spell is open: naming the dangerous
27
- // schemes instead loses to the first one this has not heard of. It holds what the
28
- // surface has a caller for; a scheme reaching it later is a line.
29
- const RENDERED_SCHEMES = new Set(['http', 'https', 'mailto', 'tel', 'ftp']);
30
- /** A scheme and its colon: a letter, then letters, digits, `+`, `-` or `.` (RFC 3986). */
31
- const SCHEME = /^([a-z][a-z0-9+.-]*):/i;
32
- /** Dropped before the scheme is read, so a tab spliced into `javascript:` is tested
33
- * as what it navigates to. Wider than the URL parser's own tab/newline rule: no
34
- * scheme carries a control character, and the strip decides without rewriting. */
35
- const IGNORED = /[\u0000-\u0020]/g;
36
- /**
37
- * Whether a link carrying `href` renders as one. A value with no scheme is relative
38
- * to the embedding page and has none to refuse.
39
- *
40
- * A refused href is unchanged: it stays on the mark and round-trips, so a document
41
- * survives an editor that declines to make it clickable.
42
- */
43
- export function rendersHref(href) {
44
- const scheme = SCHEME.exec(href.replace(IGNORED, ''));
45
- return scheme === null || RENDERED_SCHEMES.has(scheme[1].toLowerCase());
46
- }
47
- // ── The open sets' DOM payload ──────────────────────────────────────────────
48
- // A carrier's tag is a `data-qm-unknown-*` name and its `attrs` are JSON beside it,
49
- // under one name across all three: an element carries at most one carrier, a
50
- // paragraph's tag and the mark's sitting on different elements.
51
- /** The `attrs` half of a carrier, absent where there is none. */
52
- function packAttrs(attrs) {
53
- return attrs == null ? {} : { 'data-qm-unknown-attrs': JSON.stringify(attrs) };
54
- }
55
- /** The `attrs` half back off an element. `null` for anything that is not the JSON
56
- * this schema wrote: a hand-built document reaches the same parse a paste does, and a
57
- * carrier's payload is the keyed object upstream spells, never a bare scalar. */
58
- function unpackAttrs(el) {
59
- const raw = el.getAttribute('data-qm-unknown-attrs');
60
- if (raw == null)
61
- return null;
62
- try {
63
- const parsed = JSON.parse(raw);
64
- return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null;
65
- }
66
- catch {
67
- return null;
68
- }
69
- }
70
- // ── The names a carrier may not wear ────────────────────────────────────────
71
- // A carrier holds a value this build does not know, so a name the model does know is
72
- // the one it cannot carry: the projection re-emits it with `attrs` beside it
73
- // (`encode.ts` §`textblockKind`) and the store refuses `attrs` beside a built-in
74
- // discriminant, failing every write for the rest of the session. `decode` applies the
75
- // same guard from the other side, where a `para` line mints no carrier.
76
- /** The line kinds `ContentLineKind` names, which decode maps to a node of their own. */
77
- const BUILTIN_LINE_KINDS = new Set(['para', 'heading', 'code', 'rule', 'island']);
78
- /** The containers `ContentContainer` names. */
79
- const BUILTIN_CONTAINERS = new Set(['list_item', 'quote']);
80
- /** A carrier's name, or `null` where it is empty or one the model owns. */
81
- function carrierName(el, attr, builtin) {
82
- const name = el.getAttribute(attr);
83
- return name && !builtin.has(name) ? name : null;
84
- }
18
+ import { rendersHref, storableUrl } from './urls.js';
85
19
  // ── Marks (the block and inline schemas share them; plaintext declares none) ─
86
20
  const marks = {
87
21
  // Order matters: it fixes mark-set sort order and parse precedence. `link`
88
- // last so it wraps outermost; `unknown` after it, non-exclusive so several
89
- // distinct unknown marks coexist on one range.
22
+ // last so it wraps outermost.
90
23
  strong: { parseDOM: [{ tag: 'strong' }, { tag: 'b' }], toDOM: () => ['strong', 0] },
91
24
  em: { parseDOM: [{ tag: 'em' }, { tag: 'i' }], toDOM: () => ['em', 0] },
92
25
  underline: { parseDOM: [{ tag: 'u' }], toDOM: () => ['u', 0] },
@@ -96,8 +29,11 @@ const marks = {
96
29
  attrs: { href: { default: '' } },
97
30
  inclusive: false,
98
31
  parseDOM: [
99
- { tag: 'a[href]', getAttrs: (el) => ({ href: el.getAttribute('href') }) },
100
- { tag: 'span[data-qm-href]', getAttrs: (el) => ({ href: el.getAttribute('data-qm-href') }) }
32
+ { tag: 'a[href]', getAttrs: (el) => ({ href: storableUrl(el.getAttribute('href') ?? '') }) },
33
+ {
34
+ tag: 'span[data-qm-href]',
35
+ getAttrs: (el) => ({ href: storableUrl(el.getAttribute('data-qm-href') ?? '') })
36
+ }
101
37
  ],
102
38
  // A refused href draws as a bare span: the text stands, unstyled and
103
39
  // unclickable, and the mark keeps its value for encode. The value rides on the
@@ -108,35 +44,8 @@ const marks = {
108
44
  const href = mark.attrs.href;
109
45
  return rendersHref(href) ? ['a', { href }, 0] : ['span', { 'data-qm-href': href }, 0];
110
46
  }
111
- },
112
- // The open-set escape hatch: an inert mark that renders as a bare span and
113
- // re-emits its stored `type`/`attrs` on encode. `excludes: ''` lets marks of
114
- // this type with different attrs share a range (they are distinct families).
115
- unknown: {
116
- attrs: { type: { default: '' }, attrs: { default: null } },
117
- excludes: '',
118
- parseDOM: [
119
- {
120
- tag: 'span[data-qm-unknown-mark]',
121
- // `false` declines the rule, so the text arrives unmarked rather than
122
- // carrying a second spelling of a mark this schema already has.
123
- getAttrs: (el) => {
124
- const type = carrierName(el, 'data-qm-unknown-mark', BUILTIN_MARKS);
125
- return type ? { type, attrs: unpackAttrs(el) } : false;
126
- }
127
- }
128
- ],
129
- toDOM: (mark) => [
130
- 'span',
131
- { 'data-qm-unknown-mark': mark.attrs.type, ...packAttrs(mark.attrs.attrs) },
132
- 0
133
- ]
134
47
  }
135
48
  };
136
- /** The mark types the content model names: this schema's own, and `anchor`, which is a
137
- * decoration here rather than a mark (§Marks). Read off the record above, so a mark
138
- * added there closes the carrier's door on its name in the same edit. */
139
- const BUILTIN_MARKS = new Set([...Object.keys(marks), 'anchor']);
140
49
  // ── The fence's language ────────────────────────────────────────────────────
141
50
  // The one attribute no keystroke in the visual editor mints: the shorthand fires on
142
51
  // the third backtick, before a language could be typed, and a slash command is a name
@@ -162,31 +71,18 @@ function fenceLang(pre) {
162
71
  // ── Block nodes ─────────────────────────────────────────────────────────────
163
72
  const blockNodes = {
164
73
  doc: { content: 'block+' },
165
- // `unknown` carries a line `kind` this build does not know (`{ kind, attrs }`,
166
- // else null). A paragraph is how such a line renders, so the carrier is an
167
- // attribute rather than a node type: every paragraph command already reaches it,
168
- // and retyping it to a heading or a list drops the attribute: an explicit
169
- // conversion, which is the one place the unknown kind should be lost.
170
74
  paragraph: {
171
75
  content: 'inline*',
172
76
  group: 'block',
173
- attrs: { unknown: { default: null } },
174
- parseDOM: [
175
- {
176
- tag: 'p',
177
- getAttrs: (el) => {
178
- const kind = carrierName(el, 'data-qm-unknown-line', BUILTIN_LINE_KINDS);
179
- return { unknown: kind ? { kind, attrs: unpackAttrs(el) } : null };
180
- }
181
- }
182
- ],
183
- toDOM: (node) => {
184
- const u = node.attrs.unknown;
185
- return u ? ['p', { 'data-qm-unknown-line': u.kind, ...packAttrs(u.attrs) }, 0] : ['p', 0];
186
- }
77
+ parseDOM: [{ tag: 'p' }],
78
+ toDOM: () => ['p', 0]
187
79
  },
80
+ // A heading holds no `hard_break`: it is a block of one line, so the store clears a
81
+ // `continues` after one and markdown has no syntax for a break inside it. Declaring
82
+ // it out is what makes that structural — a paste parses the `<br>` away and a
83
+ // conversion onto a heading drops it, rather than each door holding the rule.
188
84
  heading: {
189
- content: 'inline*',
85
+ content: '(text | island_inline)*',
190
86
  group: 'block',
191
87
  defining: true,
192
88
  attrs: { level: { default: 1 } },
@@ -256,35 +152,6 @@ const blockNodes = {
256
152
  parseDOM: [{ tag: 'li' }],
257
153
  toDOM: () => ['li', 0]
258
154
  },
259
- // A container this build does not know: a transparent wrapper that renders as a
260
- // bare block (its children flow at the enclosing level, which is how upstream
261
- // renders it) and re-emits its `container`/`attrs` on encode. `defining` so a
262
- // lift out of it is deliberate rather than a backspace away.
263
- unknown_container: {
264
- content: 'block+',
265
- group: 'block',
266
- defining: true,
267
- attrs: { container: { default: '' }, attrs: { default: null } },
268
- parseDOM: [
269
- {
270
- tag: 'div[data-qm-unknown-container]',
271
- // `false` declines the rule, so the div's children flow at the enclosing
272
- // level — which is how a transparent container renders anyway.
273
- getAttrs: (el) => {
274
- const container = carrierName(el, 'data-qm-unknown-container', BUILTIN_CONTAINERS);
275
- return container ? { container, attrs: unpackAttrs(el) } : false;
276
- }
277
- }
278
- ],
279
- toDOM: (node) => [
280
- 'div',
281
- {
282
- 'data-qm-unknown-container': node.attrs.container,
283
- ...packAttrs(node.attrs.attrs)
284
- },
285
- 0
286
- ]
287
- },
288
155
  island_block: islandBlockSpec
289
156
  };
290
157
  // ── Inline nodes ────────────────────────────────────────────────────────────
@@ -339,3 +206,10 @@ export function hasMarks(schema) {
339
206
  export function isInlineSchema(schema) {
340
207
  return !schema.nodes.blockquote;
341
208
  }
209
+ /** Whether `type` can hold a within-block line break, which a heading cannot (§`heading`).
210
+ * What a `\n` landing in a textblock is rewritten to turns on it (`breaks.ts`), and so
211
+ * does what decode puts between a block's continued segments. */
212
+ export function takesLineBreak(type) {
213
+ const br = type.schema.nodes.hard_break;
214
+ return !!br && type.contentMatch.matchType(br) !== null;
215
+ }
@@ -14,7 +14,7 @@
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
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
20
  import { baseKeymap, chainCommands, toggleMark } from 'prosemirror-commands';
@@ -142,7 +142,8 @@ class TableIslandView {
142
142
  drag;
143
143
  sweep;
144
144
  /** The grips, by the line each acts on: what selection paint and a drag reach for
145
- * without a query. The typed twin of `cells`. */
145
+ * without a query. The typed twin of `cells`, `Line` included — a key parsed back
146
+ * into one is the `data-` attribute `MountedCell.box` refuses. */
146
147
  grips = new Map();
147
148
  /** The grid's own box, and the containing block every out-of-flow control is placed
148
149
  * against. Not the scroller: an absolute inside a scroll container is placed
@@ -175,13 +176,17 @@ class TableIslandView {
175
176
  return true;
176
177
  }
177
178
  // Same rectangle: reseed only the cells whose value the nested view is not
178
- // already showing. The cell that produced this update projects to exactly what
179
- // it stored, so it compares equal and keeps its caret; an undo or an external
179
+ // already showing. The cell that produced this update wrote exactly what it is
180
+ // showing, so it compares equal and keeps its caret; an undo or an external
180
181
  // re-hydrate does not, and takes the fresh state.
182
+ //
183
+ // Against `shown` rather than a projection of the doc: this runs on every outer
184
+ // transaction, which is every keystroke in the table, and projecting each cell
185
+ // costs a `mapMarks` across the WASM boundary wherever one holds an anchor.
181
186
  this.rendered = props;
182
187
  for (const mounted of this.cells) {
183
188
  const stored = cellAt(props, mounted.r, mounted.c);
184
- if (cellEqual(stored, cellFromDoc(mounted.view.state.doc, stored)))
189
+ if (cellEqual(stored, mounted.shown))
185
190
  continue;
186
191
  const head = mounted.view.state.selection.head;
187
192
  const fresh = EditorState.create({
@@ -189,6 +194,7 @@ class TableIslandView {
189
194
  plugins: cellPlugins(this.cellKeys(mounted.r, mounted.c))
190
195
  });
191
196
  mounted.view.updateState(fresh);
197
+ mounted.shown = stored;
192
198
  // Best-effort caret continuity, the rule a field's own re-hydrate takes: keep
193
199
  // the offset, clamped into the text that is there now. A fresh state resolves
194
200
  // its selection to the start of the cell, so an undo would otherwise put the
@@ -272,19 +278,39 @@ class TableIslandView {
272
278
  this.caretBeside(point);
273
279
  };
274
280
  /** The cell a press inside the frame belongs to: the nearest by rect, which for a
275
- * press on a border or a cell's own padding is the cell it is against. */
281
+ * press on a border or a cell's own padding is the cell it is against.
282
+ *
283
+ * A row's cells share a vertical extent and a column's a horizontal one, and the
284
+ * distance is a hypotenuse of the two, so the nearest cell is the nearest row
285
+ * crossed with the nearest column: the header row and the first column answer for
286
+ * the whole grid. R+C rects rather than R×C, for the reason a line drag measures
287
+ * once — at 20x8 that is 27 forced layouts a press instead of 160. */
276
288
  focusNearestCell(point) {
277
- let best;
278
- let nearest = Infinity;
289
+ let row;
290
+ let column;
291
+ let nearestRow = Infinity;
292
+ let nearestColumn = Infinity;
279
293
  for (const mounted of this.cells) {
280
- const at = distance(mounted.host.getBoundingClientRect(), point);
281
- if (at >= nearest)
294
+ if (mounted.c !== 0 && mounted.r !== 0)
282
295
  continue;
283
- nearest = at;
284
- best = mounted;
296
+ const box = mounted.host.getBoundingClientRect();
297
+ if (mounted.c === 0) {
298
+ const at = Math.max(box.top - point.y, 0, point.y - box.bottom);
299
+ if (at < nearestRow) {
300
+ nearestRow = at;
301
+ row = mounted.r;
302
+ }
303
+ }
304
+ if (mounted.r === 0) {
305
+ const at = Math.max(box.left - point.x, 0, point.x - box.right);
306
+ if (at < nearestColumn) {
307
+ nearestColumn = at;
308
+ column = mounted.c;
309
+ }
310
+ }
285
311
  }
286
- if (best)
287
- this.focusCell(best.r, best.c);
312
+ if (row !== undefined && column !== undefined)
313
+ this.focusCell(row, column);
288
314
  }
289
315
  /** A caret beside the island, on the side the press landed: a gap cursor where the
290
316
  * document holds no text position there, and the neighbouring block's own edge
@@ -388,9 +414,10 @@ class TableIslandView {
388
414
  }
389
415
  // ── The selection ─────────────────────────────────────────────────────────
390
416
  /** The rectangle a line covers, which is what selecting one means: a grip draws no
391
- * second kind of selection, it draws this one. */
392
- lineCells(line) {
393
- const props = this.props();
417
+ * second kind of selection, it draws this one. `props` is the caller's where it
418
+ * reads a line per grip ({@link TableIslandView.paintSelection}): each `props()`
419
+ * revalidates the whole table. */
420
+ lineCells(line, props = this.props()) {
394
421
  return line.axis === 'row'
395
422
  ? { r0: line.index, c0: 0, r1: line.index, c1: columnCount(props) - 1 }
396
423
  : { r0: 0, c0: line.index, r1: rowCount(props) - 1, c1: line.index };
@@ -442,6 +469,10 @@ class TableIslandView {
442
469
  : undefined;
443
470
  }
444
471
  select(cells) {
472
+ // A sweep resolves a rectangle per pointer move and most of them are the one
473
+ // already held, which is a repaint of the state that is up.
474
+ if (this.selected && sameCells(this.selected, cells))
475
+ return;
445
476
  // One subject at a time: a cell selection retires an island one, or the surface
446
477
  // paints a washed row inside an outlined table and the next Backspace has two
447
478
  // honest readings.
@@ -468,11 +499,9 @@ class TableIslandView {
468
499
  * and a selection is exactly the state that must not cost the carets in them. */
469
500
  paintSelection() {
470
501
  const held = this.selected;
471
- for (const [key, grip] of this.grips) {
472
- const [axis, index] = key.split(':');
473
- const line = { axis: axis, index: Number(index) };
474
- grip.setAttribute('aria-pressed', String(!!held && sameCells(held, this.lineCells(line))));
475
- }
502
+ 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))));
476
505
  for (const cell of this.cells)
477
506
  cell.box.toggleAttribute('data-selected', !!held && cell.r >= held.r0 && cell.r <= held.r1 && cell.c >= held.c0 && cell.c <= held.c1);
478
507
  }
@@ -556,6 +585,10 @@ class TableIslandView {
556
585
  onGripDown = (line, grip, event) => {
557
586
  if (event.button !== 0)
558
587
  return;
588
+ // The previous gesture ends first, as `armSweep`'s does: `endDrag` reads
589
+ // `this.drag` to know which grip to unbind, so a second press over a live drag
590
+ // would orphan the first grip's listeners and its pointer capture.
591
+ this.endDrag();
559
592
  this.drag = {
560
593
  line,
561
594
  origin: { x: event.clientX, y: event.clientY },
@@ -833,7 +866,7 @@ class TableIslandView {
833
866
  bar.appendChild(svg(GRIP[line.axis]));
834
867
  btn.appendChild(bar);
835
868
  btn.addEventListener('pointerdown', (e) => this.onGripDown(line, btn, e));
836
- this.grips.set(lineKey(line), btn);
869
+ this.grips.set(lineKey(line), { line, grip: btn });
837
870
  return btn;
838
871
  }
839
872
  /** A trailing bar: the whole edge past the last line of its axis, and the one way a
@@ -863,9 +896,10 @@ class TableIslandView {
863
896
  mountCell(box, host, r, c, s) {
864
897
  const props = this.props();
865
898
  const name = s.tableCell(r === 0 ? s.tableHeaderRow : s.tableRow(r), s.tableColumn(c + 1));
899
+ const seed = cellAt(props, r, c);
866
900
  const view = new EditorView(host, {
867
901
  state: EditorState.create({
868
- doc: decode(cellContent(cellAt(props, r, c)), inlineSchema),
902
+ doc: decode(cellContent(seed), inlineSchema),
869
903
  plugins: cellPlugins(this.cellKeys(r, c))
870
904
  }),
871
905
  attributes: { 'aria-label': name, class: 'qm-table-cell-editor' },
@@ -885,7 +919,9 @@ class TableIslandView {
885
919
  // reseed it comes back as changes no cell's own doc.
886
920
  this.clearSelection();
887
921
  const now = this.props();
888
- this.write(withCell(now, r, c, cellFromDoc(next.doc, cellAt(now, r, c))));
922
+ const cell = cellFromDoc(next.doc, cellAt(now, r, c));
923
+ mounted.shown = cell;
924
+ this.write(withCell(now, r, c, cell));
889
925
  },
890
926
  handleDOMEvents: {
891
927
  focus: () => {
@@ -895,7 +931,18 @@ class TableIslandView {
895
931
  }
896
932
  }
897
933
  });
898
- this.cells.push({ view, host, box, unregister: this.deps.register(view), r, c });
934
+ // `seed` is what the doc above was decoded from, and decode∘project is identity
935
+ // (`table.ts`, the cell codec), so it is what this view is showing.
936
+ const mounted = {
937
+ view,
938
+ host,
939
+ box,
940
+ shown: seed,
941
+ unregister: this.deps.register(view),
942
+ r,
943
+ c
944
+ };
945
+ this.cells.push(mounted);
899
946
  }
900
947
  // ── Ops ───────────────────────────────────────────────────────────────────
901
948
  /** The table this view is currently showing; the zero table if the node stopped
@@ -1029,6 +1076,10 @@ class TableIslandView {
1029
1076
  'Alt-ArrowRight': line('column', 1, true),
1030
1077
  Tab: () => this.step(r, c, 1),
1031
1078
  '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,
1032
1083
  Enter: () => {
1033
1084
  // Over a rectangle it hands the caret back, which is the line verbs' own
1034
1085
  // exit: the caret never left the cell, so there is nowhere else to put it.
@@ -88,14 +88,15 @@ export declare function cellContent(cell: TableCell): Content;
88
88
  * the field plugin's coordinate space (the field's position map holds one `atom`
89
89
  * run for the whole table), so nothing here can mint one and the popover withholds
90
90
  * its `anchor` button while the caret is in a cell. Preserving them still means
91
- * rebasing: a cell edit is a splice like any other, so each held anchor maps through
92
- * the cell's own text delta by the rule `applyChange` uses on a field's
93
- * (start-assoc `after`), and a mark the splice deleted through lands where the
94
- * splice left it rather than pointing past the text.
91
+ * rebasing: a cell edit is a splice like any other, so `mapMarks` carries each held
92
+ * anchor through the cell's own delta by the rule the store applies to a field's,
93
+ * and a mark the splice deleted through lands where the splice left it rather than
94
+ * pointing past the text.
95
95
  */
96
96
  export declare function cellFromDoc(doc: PMNode, prior: TableCell): TableCell;
97
97
  /** Whether two cells are the same value: what tells an own edit (the projection the
98
- * view just produced) from an external one (an undo, a re-hydrate) without a flag. */
98
+ * view just produced) from an external one (an undo, a re-hydrate) without a flag.
99
+ * The marks structurally, for the reason `reconcile.ts` states. */
99
100
  export declare function cellEqual(a: TableCell, b: TableCell): boolean;
100
101
  /** Whether two tables have the same rectangle and alignment: the change that forces
101
102
  * a rebuild rather than a per-cell reseed. */
@@ -1,23 +1,6 @@
1
- // The table island's model: the cell codec (a `TableCell` ↔ one inline-schema PM
2
- // doc) and the rectangularizing constructors every row and column op goes through
3
- // (CODEC §Islands, §"The table island").
4
- //
5
- // `TableProps` normalizes to one column count shared by `header`, every row, and
6
- // `aligns`, so a ragged table is not a state the content can hold. The editor keeps
7
- // its optimistic PM and re-hydrates only on an external change (CODEC
8
- // §Reconciliation), so an op that emitted a ragged table would leave the store
9
- // rectangularized and PM ragged with no error channel and no repair. Every
10
- // constructor here therefore ends in {@link normalizeTable}, and a caller reaches
11
- // the shape only through them.
12
- //
13
- // A cell is its own content unit: `marks` are USV offsets into that cell's `text`,
14
- // not into `Content.text`. That is the second coordinate space, and the inline mode
15
- // the codec already runs (one paragraph, no containers, no islands) is exactly its
16
- // shape, so a cell decodes and projects through the same machinery a
17
- // `richtext(inline)` field does.
18
- import { isAnchorMark } from '@quillmark/wasm';
19
1
  import { core } from '../lifecycle.js';
20
2
  import { contentEdit, pmToContent } from './encode.js';
3
+ import { valueEqual } from './reconcile.js';
21
4
  /** The alignments a column can take: the four the content declares, `none` first
22
5
  * because it is what a column arrives at and what a set is cleared back to. */
23
6
  export const ALIGNS = ['none', 'left', 'center', 'right'];
@@ -219,32 +202,32 @@ export function cellContent(cell) {
219
202
  * the field plugin's coordinate space (the field's position map holds one `atom`
220
203
  * run for the whole table), so nothing here can mint one and the popover withholds
221
204
  * its `anchor` button while the caret is in a cell. Preserving them still means
222
- * rebasing: a cell edit is a splice like any other, so each held anchor maps through
223
- * the cell's own text delta by the rule `applyChange` uses on a field's
224
- * (start-assoc `after`), and a mark the splice deleted through lands where the
225
- * splice left it rather than pointing past the text.
205
+ * rebasing: a cell edit is a splice like any other, so `mapMarks` carries each held
206
+ * anchor through the cell's own delta by the rule the store applies to a field's,
207
+ * and a mark the splice deleted through lands where the splice left it rather than
208
+ * pointing past the text.
226
209
  */
227
210
  export function cellFromDoc(doc, prior) {
228
211
  const projected = pmToContent(doc);
229
212
  const text = projected.text;
230
- const anchors = prior.marks.filter(isAnchorMark);
213
+ const anchors = prior.marks.filter((m) => m.type === 'anchor');
231
214
  if (!anchors.length)
232
215
  return { text, marks: projected.marks };
233
- const delta = contentEdit(cellContent(prior), cellContent({ text, marks: [] })).delta;
234
- const { mapPos } = core();
235
- const rebased = anchors.map((a) => {
236
- const pos = delta ? mapPos(delta, a.start, 'after') : a.start;
237
- return { ...a, start: pos, end: pos };
238
- });
216
+ const before = cellContent(prior);
217
+ const delta = contentEdit(before, cellContent({ text, marks: [] })).delta;
218
+ const rebased = core()
219
+ .mapMarks(before, delta ? { delta } : {})
220
+ .filter((m) => m.type === 'anchor');
239
221
  return {
240
222
  text,
241
223
  marks: [...projected.marks, ...rebased].sort((a, b) => a.start - b.start || a.end - b.end)
242
224
  };
243
225
  }
244
226
  /** Whether two cells are the same value: what tells an own edit (the projection the
245
- * view just produced) from an external one (an undo, a re-hydrate) without a flag. */
227
+ * view just produced) from an external one (an undo, a re-hydrate) without a flag.
228
+ * The marks structurally, for the reason `reconcile.ts` states. */
246
229
  export function cellEqual(a, b) {
247
- return a.text === b.text && JSON.stringify(a.marks) === JSON.stringify(b.marks);
230
+ return a.text === b.text && valueEqual(a.marks, b.marks);
248
231
  }
249
232
  /** Whether two tables have the same rectangle and alignment: the change that forces
250
233
  * a rebuild rather than a per-cell reseed. */
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Whether a link carrying `href` renders as one. A value with no scheme is relative
3
+ * to the embedding page and has none to refuse.
4
+ *
5
+ * A refused href is unchanged: it stays on the mark and round-trips, so a document
6
+ * survives an editor that declines to make it clickable.
7
+ */
8
+ export declare function rendersHref(href: string): boolean;
9
+ /**
10
+ * A url the store will take: the one character class the authored lane refuses, a CR
11
+ * or an LF, percent-encoded. CommonMark admits no line ending in a link destination,
12
+ * so `MarkOp.add` of a `link`, an `image` island op and `overwrite` all throw on one —
13
+ * and both of a leaf's commit lanes throwing is a field that stops persisting for the
14
+ * session, since the PM doc still holds the mark on the next keystroke.
15
+ *
16
+ * Applied where a value enters the model from outside it (a paste's `href`, the link
17
+ * prompt), which is the only door that can produce one: `importMarkdown` mints none,
18
+ * and `toMarkdown` writes the same `%0A` / `%0D`.
19
+ */
20
+ export declare function storableUrl(url: string): string;
@@ -0,0 +1,45 @@
1
+ // URLs at the model's edge. A link's `href` and an image island's `url` are the two
2
+ // values a document carries that a renderer follows, and both enter the model from
3
+ // outside it — a paste's markup, the link prompt. One module because the two rules are
4
+ // one rule: what renders, and what the store will take.
5
+ // ── The href gate ───────────────────────────────────────────────────────────
6
+ // An `href` is an attribute value and not markup, so the markdown → typed node → DOM
7
+ // path that keeps a document's text from becoming tags never reaches it: what the
8
+ // mark carries is what `toDOM` emits, and `renderContent` paints marks outside a
9
+ // `contenteditable` (the tips card), where a click is a plain one.
10
+ //
11
+ // An allowlist, because the set a document can spell is open: naming the dangerous
12
+ // schemes instead loses to the first one this has not heard of. It holds what the
13
+ // surface has a caller for; a scheme reaching it later is a line.
14
+ const RENDERED_SCHEMES = new Set(['http', 'https', 'mailto', 'tel', 'ftp']);
15
+ /** A scheme and its colon: a letter, then letters, digits, `+`, `-` or `.` (RFC 3986). */
16
+ const SCHEME = /^([a-z][a-z0-9+.-]*):/i;
17
+ /** Dropped before the scheme is read, so a tab spliced into `javascript:` is tested
18
+ * as what it navigates to. Wider than the URL parser's own tab/newline rule: no
19
+ * scheme carries a control character, and the strip decides without rewriting. */
20
+ const IGNORED = /[\u0000-\u0020]/g;
21
+ /**
22
+ * Whether a link carrying `href` renders as one. A value with no scheme is relative
23
+ * to the embedding page and has none to refuse.
24
+ *
25
+ * A refused href is unchanged: it stays on the mark and round-trips, so a document
26
+ * survives an editor that declines to make it clickable.
27
+ */
28
+ export function rendersHref(href) {
29
+ const scheme = SCHEME.exec(href.replace(IGNORED, ''));
30
+ return scheme === null || RENDERED_SCHEMES.has(scheme[1].toLowerCase());
31
+ }
32
+ /**
33
+ * A url the store will take: the one character class the authored lane refuses, a CR
34
+ * or an LF, percent-encoded. CommonMark admits no line ending in a link destination,
35
+ * so `MarkOp.add` of a `link`, an `image` island op and `overwrite` all throw on one —
36
+ * and both of a leaf's commit lanes throwing is a field that stops persisting for the
37
+ * session, since the PM doc still holds the mark on the next keystroke.
38
+ *
39
+ * Applied where a value enters the model from outside it (a paste's `href`, the link
40
+ * prompt), which is the only door that can produce one: `importMarkdown` mints none,
41
+ * and `toMarkdown` writes the same `%0A` / `%0D`.
42
+ */
43
+ export function storableUrl(url) {
44
+ return url.replace(/\r/g, '%0D').replace(/\n/g, '%0A');
45
+ }
@@ -112,7 +112,7 @@ html:has(.qm-workspace) {
112
112
  display: none;
113
113
  }
114
114
 
115
- /* One mount at A time under the threshold, rather than two of them abreast. Half a narrow
115
+ /* One mount at a time under the threshold, rather than two of them abreast. Half a narrow
116
116
  viewport is under the width either surface reads at, so the split stops being one and
117
117
  the tracks become views of the same room: the host says which with `data-qm-show`, and
118
118
  the attribute names the track rather than what is in it, so nothing here knows an editor
@@ -1,10 +1,13 @@
1
- /* The host'S derivation: the same public dials in, a closed `--qmh-*` scale for the
1
+ /* The host's derivation: the same public dials in, a closed `--qmh-*` scale for the
2
2
  page around the surfaces out. `core/theme.css` is the same shape one level down
3
3
  (dials in, `--_qm-*` out), and the two are calibrated against each other: the
4
- figures here are the figures there (the ground step's ramp, 8% hover, 17%
5
- border, 45% ink, the ring's width, the status hue at both poles) and the
4
+ figures here are the figures there (the ground step's ramp, 8% hover, the
5
+ ring's width, the status hue at both poles) and the
6
6
  dial means the same plane in both, so a host plate and a card on it land on the same
7
- tone at both poles and a host page meets an editor's own column exactly. The well
7
+ tone at both poles and a host page meets an editor's own column exactly. The border
8
+ and ink rungs are each calibrated for their own plane rather than shared: a page's
9
+ chrome is read at one size against one ground, a card's interior at several against
10
+ the rungs under it, so the mixes are not the same figure. The well
8
11
  under the ground has no counterpart here: a host's own controls are boxed and carry a
9
12
  hairline, so nothing on a page needs the step an edgeless field does.
10
13