@quillmark/svelte 0.21.0 → 0.22.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 (67) hide show
  1. package/NOTICE +6 -7
  2. package/README.md +1 -1
  3. package/dist/core/address.d.ts +6 -1
  4. package/dist/core/address.js +12 -3
  5. package/dist/core/bloom.d.ts +9 -4
  6. package/dist/core/bloom.js +18 -49
  7. package/dist/core/codec/atoms.d.ts +1 -1
  8. package/dist/core/codec/atoms.js +1 -1
  9. package/dist/core/codec/blocks.js +4 -2
  10. package/dist/core/codec/code.d.ts +1 -1
  11. package/dist/core/codec/code.js +1 -1
  12. package/dist/core/codec/decode.d.ts +5 -5
  13. package/dist/core/codec/decode.js +18 -12
  14. package/dist/core/codec/encode.js +6 -1
  15. package/dist/core/codec/field.d.ts +15 -10
  16. package/dist/core/codec/field.js +46 -17
  17. package/dist/core/codec/index.d.ts +1 -1
  18. package/dist/core/codec/index.js +2 -2
  19. package/dist/core/codec/inputrules.js +1 -1
  20. package/dist/core/codec/lists.d.ts +3 -2
  21. package/dist/core/codec/lists.js +21 -5
  22. package/dist/core/codec/nesting.d.ts +20 -0
  23. package/dist/core/codec/nesting.js +62 -0
  24. package/dist/core/codec/prose.css +74 -20
  25. package/dist/core/codec/schema.d.ts +2 -2
  26. package/dist/core/codec/schema.js +16 -7
  27. package/dist/core/codec/table-copy.d.ts +17 -0
  28. package/dist/core/codec/table-copy.js +84 -0
  29. package/dist/core/codec/table-view.d.ts +1 -7
  30. package/dist/core/codec/table-view.js +70 -119
  31. package/dist/core/codec/table.d.ts +2 -9
  32. package/dist/core/codec/table.js +10 -19
  33. package/dist/core/codec/tasks.d.ts +19 -0
  34. package/dist/core/codec/tasks.js +140 -0
  35. package/dist/core/errors.d.ts +2 -2
  36. package/dist/visual/ArrayField.svelte +101 -63
  37. package/dist/visual/ArrayField.svelte.d.ts +0 -2
  38. package/dist/visual/Card.svelte +9 -3
  39. package/dist/visual/Card.svelte.d.ts +2 -2
  40. package/dist/visual/Field.svelte +20 -16
  41. package/dist/visual/Field.svelte.d.ts +4 -2
  42. package/dist/visual/FieldLabel.svelte +5 -33
  43. package/dist/visual/FieldLabel.svelte.d.ts +0 -2
  44. package/dist/visual/FormatPopover.svelte +54 -73
  45. package/dist/visual/MatrixField.svelte +18 -12
  46. package/dist/visual/NumberField.svelte +32 -3
  47. package/dist/visual/ObjectField.svelte +38 -20
  48. package/dist/visual/ObjectField.svelte.d.ts +6 -0
  49. package/dist/visual/ProseField.svelte +10 -13
  50. package/dist/visual/ProseField.svelte.d.ts +5 -2
  51. package/dist/visual/ProseValue.svelte +25 -19
  52. package/dist/visual/ProseValue.svelte.d.ts +4 -4
  53. package/dist/visual/TextField.svelte +3 -2
  54. package/dist/visual/TipsCard.svelte +5 -2
  55. package/dist/visual/VariantField.svelte +1 -1
  56. package/dist/visual/VariantField.svelte.d.ts +1 -1
  57. package/dist/visual/VisualEditorInner.svelte +13 -13
  58. package/dist/visual/controls.css +19 -1
  59. package/dist/visual/icons/nodes.d.ts +0 -21
  60. package/dist/visual/icons/nodes.js +1 -7
  61. package/dist/visual/leaves.d.ts +12 -9
  62. package/dist/visual/leaves.js +19 -0
  63. package/dist/visual/strings.d.ts +10 -5
  64. package/dist/visual/strings.js +16 -9
  65. package/dist/visual/structure.d.ts +21 -31
  66. package/dist/visual/structure.js +54 -51
  67. package/package.json +3 -3
package/NOTICE CHANGED
@@ -6,14 +6,13 @@ Copyright 2026 Nibs
6
6
  This package contains SVG glyph geometry derived from Lucide, carried in source
7
7
  rather than installed.
8
8
 
9
- `visual/icons/nodes.ts` holds nineteen verbatim from @lucide/svelte 1.28.0:
9
+ `visual/icons/nodes.ts` holds eighteen verbatim from @lucide/svelte 1.28.0:
10
10
  chevron-right, arrow-right, chevron-down, chevron-up, bold, italic, underline,
11
- strikethrough, code, link, hash, info, plus, x, trash-2, minus, check, calendar
12
- and calendar-check.
11
+ strikethrough, code, link, info, plus, x, trash-2, minus, check, calendar and
12
+ calendar-check.
13
13
 
14
14
  `core/codec/table-view.ts` holds grip-horizontal, grip-vertical, align-left,
15
- align-center, align-right, align-start-horizontal, align-center-horizontal,
16
- align-end-horizontal and panel-top, derived from an earlier release of the same
15
+ align-center, align-right and panel-top, derived from an earlier release of the same
17
16
  project.
18
17
 
19
18
  ISC License
@@ -34,8 +33,8 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
34
33
 
35
34
  --------------------------------------------------------------------------------
36
35
 
37
- Thirteen of those glyphs — arrow-right, chevron-down, chevron-right, chevron-up,
38
- code, hash, info, italic, link, minus, plus, trash-2 and x — are drawn exactly as
36
+ Twelve of those glyphs — arrow-right, chevron-down, chevron-right, chevron-up,
37
+ code, info, italic, link, minus, plus, trash-2 and x — are drawn exactly as
39
38
  the Feather project draws them and carry its license as well.
40
39
 
41
40
  The MIT License (MIT)
package/README.md CHANGED
@@ -149,7 +149,7 @@ Hand it a different `doc` and the editor **re-keys itself**: every leaf remounts
149
149
 
150
150
  ### Wording
151
151
 
152
- The package ships English and a seam to replace it. `strings` is keyed and **partial**: set what you have translations for, and the rest stay the package's. Several keys are **accessible names** rather than decoration (the card controls, the add trigger, the required marker), so an untranslated surface reads the wrong language to a screen reader. `DEFAULT_VISUAL_STRINGS` is the English, exported so you can compose against it. `<Preview>` takes its own three-key `strings` for the states it shows when there is nothing to paint.
152
+ The package ships English and a seam to replace it. `strings` is keyed and **partial**: set what you have translations for, and the rest stay the package's. Several keys are **accessible names** rather than decoration (the card controls, the add trigger), so an untranslated surface reads the wrong language to a screen reader. `DEFAULT_VISUAL_STRINGS` is the English, exported so you can compose against it. `<Preview>` takes its own three-key `strings` for the states it shows when there is nothing to paint.
153
153
 
154
154
  ```svelte
155
155
  <VisualEditor
@@ -50,10 +50,15 @@ export interface Landing {
50
50
  * - `{card: i}` → `"cards.<kind>[i].body"`
51
51
  * - `{card: i, field}` → `"cards.<kind>[i].<field>"`
52
52
  *
53
+ * `steps` descend from the field to a leaf inside it, the inverse of
54
+ * {@link nestedAddrForFieldPath}: `{field: 'contact'}` with `['email']` is
55
+ * `"main.contact.email"`, `{field: 'keywords'}` with `[0]` is `"main.keywords[0]"`. A body
56
+ * holds no cells, so a field-less address with steps is `undefined`.
57
+ *
53
58
  * `cards[i]` stands in for an unknown or blank kind. The index is the addr's own: no
54
59
  * per-kind counting, since `DocPath` addresses cards by document-array index.
55
60
  */
56
- export declare function fieldPathForAddr(addr: Addr, kinds: readonly string[]): DocPath | undefined;
61
+ export declare function fieldPathForAddr(addr: Addr, kinds: readonly string[], steps?: readonly PathStep[]): DocPath | undefined;
57
62
  /**
58
63
  * A card's own path (`cards.<kind>[i]`), not a leaf's: what a structure op names,
59
64
  * where the change is the card rather than anything inside it. `undefined` for an
@@ -15,15 +15,24 @@ import { core } from './lifecycle.js';
15
15
  * - `{card: i}` → `"cards.<kind>[i].body"`
16
16
  * - `{card: i, field}` → `"cards.<kind>[i].<field>"`
17
17
  *
18
+ * `steps` descend from the field to a leaf inside it, the inverse of
19
+ * {@link nestedAddrForFieldPath}: `{field: 'contact'}` with `['email']` is
20
+ * `"main.contact.email"`, `{field: 'keywords'}` with `[0]` is `"main.keywords[0]"`. A body
21
+ * holds no cells, so a field-less address with steps is `undefined`.
22
+ *
18
23
  * `cards[i]` stands in for an unknown or blank kind. The index is the addr's own: no
19
24
  * per-kind counting, since `DocPath` addresses cards by document-array index.
20
25
  */
21
- export function fieldPathForAddr(addr, kinds) {
26
+ export function fieldPathForAddr(addr, kinds, steps = []) {
22
27
  const head = cardHead(addr.card, kinds);
23
28
  if (!head)
24
29
  return undefined;
25
- const tail = addr.field != null ? { seg: 'field', name: addr.field } : { seg: 'body' };
26
- return core().formatDocPath([head, tail]);
30
+ if (addr.field == null)
31
+ return steps.length ? undefined : core().formatDocPath([head, { seg: 'body' }]);
32
+ const segs = [head, { seg: 'field', name: addr.field }];
33
+ for (const step of steps)
34
+ segs.push(typeof step === 'number' ? { seg: 'index', index: step } : { seg: 'field', name: step });
35
+ return core().formatDocPath(segs);
27
36
  }
28
37
  /**
29
38
  * A card's own path (`cards.<kind>[i]`), not a leaf's: what a structure op names,
@@ -1,8 +1,13 @@
1
1
  /**
2
- * Bloom over `host`'s content: an inset child, so the wash neither tints the host's
3
- * own background nor fades the text under it. `host` must be positioned.
2
+ * Run one wash over `host`, a `.qm-wash` box, resolving the dwell off its root so the
3
+ * number is single-sourced in CSS (`core/rungs.ts`).
4
4
  *
5
- * One wash node per host, reused while it lives: two quick landings on the same leaf
5
+ * One property, both motion preferences: `--_qm-bloom-dwell` already shortens under
6
+ * `prefers-reduced-motion` in the derivation, so what is left to `matchMedia` here is
7
+ * the shape: a hold and a cut carry no ramps, which is a frame list rather than a
8
+ * number and the one half CSS cannot hand over.
9
+ *
10
+ * A run in flight on the same host is cancelled first: two quick landings on one leaf
6
11
  * must not stack two alphas over it.
7
12
  */
8
- export declare function bloomInside(host: HTMLElement): void;
13
+ export declare function bloom(host: HTMLElement): void;
@@ -6,9 +6,11 @@
6
6
  // fixed by `--_qm-accent-wash`, so interpolating 0→1→0 keeps the scale in CSS where
7
7
  // `color-mix` resolves, instead of in keyframe values a script would have to
8
8
  // pre-resolve and re-resolve on a theme change.
9
+ //
10
+ // The wash is the host's `::after` (`.qm-wash`, controls.css), drawn from mount: this
11
+ // module plays it and adds nothing to the tree.
9
12
  import { rungStyle } from './rungs.js';
10
- const BLOOM_CLASS = 'qm-bloom';
11
- const WASH = 'var(--_qm-accent-wash)';
13
+ const WASH = '::after';
12
14
  /** The dwell used when the derivation is out of reach (an unstyled root, or jsdom,
13
15
  * where `getComputedStyle` reports custom properties as empty). A fallback is the
14
16
  * absence of the scale, not a copy of it. */
@@ -25,61 +27,28 @@ const FRAMES = [
25
27
  ];
26
28
  const FRAMES_REDUCED = [{ opacity: 1 }, { opacity: 1 }];
27
29
  /**
28
- * Run one wash on `el`, resolving the dwell off its root so the number is
29
- * single-sourced in CSS (`core/rungs.ts`).
30
+ * Run one wash over `host`, a `.qm-wash` box, resolving the dwell off its root so the
31
+ * number is single-sourced in CSS (`core/rungs.ts`).
30
32
  *
31
33
  * One property, both motion preferences: `--_qm-bloom-dwell` already shortens under
32
34
  * `prefers-reduced-motion` in the derivation, so what is left to `matchMedia` here is
33
35
  * the shape: a hold and a cut carry no ramps, which is a frame list rather than a
34
36
  * number and the one half CSS cannot hand over.
35
- */
36
- function bloom(el) {
37
- if (typeof el.animate !== 'function')
38
- return undefined;
39
- const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
40
- const raw = rungStyle(el).getPropertyValue('--_qm-bloom-dwell').trim();
41
- const n = Number.parseFloat(raw);
42
- const duration = !Number.isFinite(n) ? FALLBACK_MS : raw.endsWith('ms') ? n : n * 1000;
43
- return el.animate(reduced ? FRAMES_REDUCED : FRAMES, { duration });
44
- }
45
- /**
46
- * Bloom over `host`'s content: an inset child, so the wash neither tints the host's
47
- * own background nor fades the text under it. `host` must be positioned.
48
37
  *
49
- * One wash node per host, reused while it lives: two quick landings on the same leaf
38
+ * A run in flight on the same host is cancelled first: two quick landings on one leaf
50
39
  * must not stack two alphas over it.
51
40
  */
52
- export function bloomInside(host) {
53
- let el = host.querySelector(`:scope > .${BLOOM_CLASS}`);
54
- if (el) {
55
- // Drop the in-flight run's handlers before cancelling: its `oncancel` would
56
- // otherwise remove the node the new run is about to animate.
57
- for (const a of el.getAnimations()) {
58
- a.onfinish = null;
59
- a.oncancel = null;
60
- a.cancel();
61
- }
62
- }
63
- else {
64
- el = document.createElement('div');
65
- el.className = BLOOM_CLASS;
66
- Object.assign(el.style, {
67
- position: 'absolute',
68
- inset: '0',
69
- pointerEvents: 'none',
70
- borderRadius: 'inherit',
71
- background: WASH,
72
- opacity: '0'
73
- });
74
- host.appendChild(el);
75
- }
76
- const node = el;
77
- const anim = bloom(node);
78
- if (!anim) {
79
- node.remove();
41
+ export function bloom(host) {
42
+ if (typeof host.animate !== 'function')
80
43
  return;
44
+ for (const a of host.getAnimations({ subtree: true })) {
45
+ const effect = a.effect;
46
+ if (effect?.target === host && effect.pseudoElement === WASH)
47
+ a.cancel();
81
48
  }
82
- const drop = () => node.remove();
83
- anim.onfinish = drop;
84
- anim.oncancel = drop;
49
+ const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
50
+ const raw = rungStyle(host).getPropertyValue('--_qm-bloom-dwell').trim();
51
+ const n = Number.parseFloat(raw);
52
+ const duration = !Number.isFinite(n) ? FALLBACK_MS : raw.endsWith('ms') ? n : n * 1000;
53
+ host.animate(reduced ? FRAMES_REDUCED : FRAMES, { duration, pseudoElement: WASH });
85
54
  }
@@ -1,7 +1,7 @@
1
1
  import { type Command } from 'prosemirror-state';
2
2
  import type { Schema } from 'prosemirror-model';
3
3
  /**
4
- * The block-atom bindings for a block-schema leaf: `{}` for the inline/plaintext
4
+ * The block-atom bindings for a block-schema leaf: `{}` for the one-line and plain
5
5
  * schemas, which hold no block to be against.
6
6
  *
7
7
  * Both keys are the same command on opposite sides, because what a delete against an
@@ -77,7 +77,7 @@ function againstAtom(side) {
77
77
  };
78
78
  }
79
79
  /**
80
- * The block-atom bindings for a block-schema leaf: `{}` for the inline/plaintext
80
+ * The block-atom bindings for a block-schema leaf: `{}` for the one-line and plain
81
81
  * schemas, which hold no block to be against.
82
82
  *
83
83
  * Both keys are the same command on opposite sides, because what a delete against an
@@ -1,6 +1,7 @@
1
1
  import { Selection } from 'prosemirror-state';
2
2
  import { chainCommands, splitBlock } from 'prosemirror-commands';
3
3
  import { canJoin, findWrapping } from 'prosemirror-transform';
4
+ import { withinNesting } from './nesting.js';
4
5
  import { takesLineBreak } from './schema.js';
5
6
  /** The head of a textblock: where a shorthand fires, and where a pick lands once its
6
7
  * run is consumed. */
@@ -59,7 +60,8 @@ function spaceBreaks(tr, $from, type) {
59
60
  }
60
61
  /** `wrappingInputRule`'s body: wrap the block range at `pos`, then join a preceding
61
62
  * sibling of the same type — the one boundary the wrap itself opens (`lists.ts`
62
- * §cleanup: command-local, never a global pass). */
63
+ * §cleanup: command-local, never a global pass). False where no wrap fits, or where
64
+ * the one that does nests past `MAX_NESTING_DEPTH` (`nesting.ts`). */
63
65
  function wrapAt(tr, pos, type, attrs, joinBefore) {
64
66
  const range = tr.doc.resolve(pos).blockRange();
65
67
  const wrapping = range && findWrapping(range, type, attrs);
@@ -73,7 +75,7 @@ function wrapAt(tr, pos, type, attrs, joinBefore) {
73
75
  (!joinBefore || joinBefore(before))) {
74
76
  tr.join(pos - 1);
75
77
  }
76
- return true;
78
+ return withinNesting(tr.doc);
77
79
  }
78
80
  /** `# `: the heading `level` deep. `list_item` is `block+`, so one inside an item is a
79
81
  * shape the content holds and `importMarkdown` produces from `- # title`. */
@@ -1,7 +1,7 @@
1
1
  import type { Schema } from 'prosemirror-model';
2
2
  import { type Command } from 'prosemirror-state';
3
3
  /**
4
- * The `code_block` link of the body's key chains: `{}` for the inline/plaintext
4
+ * The `code_block` link of the body's key chains: `{}` for the one-line and plain
5
5
  * schemas, which declare no code node.
6
6
  *
7
7
  * `Enter` is upstream's `newlineInCode`, and it is load-bearing rather than
@@ -182,7 +182,7 @@ const belowFinalFence = (state, dispatch) => {
182
182
  return true;
183
183
  };
184
184
  /**
185
- * The `code_block` link of the body's key chains: `{}` for the inline/plaintext
185
+ * The `code_block` link of the body's key chains: `{}` for the one-line and plain
186
186
  * schemas, which declare no code node.
187
187
  *
188
188
  * `Enter` is upstream's `newlineInCode`, and it is load-bearing rather than
@@ -18,10 +18,10 @@ export declare function renderContent(rt: Content, schema: Schema): Node;
18
18
  /** Decode a `Content` to a PM document under `schema`. */
19
19
  export declare function decode(rt: Content, schema: Schema): PMNode;
20
20
  /** Whether `rt` decodes under an inline schema with nothing but whitespace dropped:
21
- * upstream's `inline` rule, or one plain line and the empty one a trailing `\n`
22
- * opens. That newline is the one a YAML `|` scalar keeps in a `plaintext` value,
23
- * which upstream refuses (`trailingNewline`) and the inline decode drops. Marks are
24
- * the schema's own business (`plaintextSchema` declares none). */
21
+ * upstream's one-line rule, or one plain line and the empty one a trailing `\n`
22
+ * opens. That newline is the one a YAML `|` scalar keeps in a `text` value, which
23
+ * upstream ignores and the inline decode drops. Marks are the schema's own business
24
+ * (`plaintextSchema` declares none). */
25
25
  export declare function fitsInline(rt: Content): boolean;
26
26
  /** Whether `rt` decodes under `plainSchema` losing nothing but its marks, which the
27
27
  * next commit heals (CODEC §Inline mode): upstream's `isPlain`, asked of the content
@@ -29,7 +29,7 @@ export declare function fitsInline(rt: Content): boolean;
29
29
  export declare function fitsPlain(rt: Content): boolean;
30
30
  /** Whether a leaf so declared edits `rt` losing nothing: {@link fitsInline} narrowed
31
31
  * and {@link fitsPlain} plain, past which the decode drops what the first commit would
32
- * store; on the block schema anything but an element or a task item ({@link editable}),
32
+ * store; on the block schema anything but an element ({@link editable}),
33
33
  * and an island where the leaf mounts no island view (`islands`), which draws as its
34
34
  * placeholder tag, an atom a keystroke deletes unseen. What a leaf held over the rest
35
35
  * reads. */
@@ -47,9 +47,9 @@ export function decode(rt, schema) {
47
47
  acc += usvLength(lineTexts[i]) + 1; // +1 for the `\n` boundary
48
48
  }
49
49
  // A mark-free schema (`plaintextSchema`) has nothing to apply them with, and a
50
- // plaintext field that acquired one anyway — an older build's popover, a hand-
51
- // written document — is exactly the content that must open and then heal on the
52
- // next commit, not throw here.
50
+ // `text` field that acquired one anyway — an older build's popover, a hand-written
51
+ // document — is exactly the content that must open and then heal on the next
52
+ // commit, not throw here.
53
53
  const marks = hasMarks(schema) ? rt.marks.filter((m) => m.type !== 'anchor') : [];
54
54
  const cursor = { i: 0, rt };
55
55
  if (isInlineSchema(schema)) {
@@ -74,10 +74,10 @@ export function decode(rt, schema) {
74
74
  return schema.nodes.doc.create(null, blocks.length ? blocks : schema.nodes.paragraph.create());
75
75
  }
76
76
  /** Whether `rt` decodes under an inline schema with nothing but whitespace dropped:
77
- * upstream's `inline` rule, or one plain line and the empty one a trailing `\n`
78
- * opens. That newline is the one a YAML `|` scalar keeps in a `plaintext` value,
79
- * which upstream refuses (`trailingNewline`) and the inline decode drops. Marks are
80
- * the schema's own business (`plaintextSchema` declares none). */
77
+ * upstream's one-line rule, or one plain line and the empty one a trailing `\n`
78
+ * opens. That newline is the one a YAML `|` scalar keeps in a `text` value, which
79
+ * upstream ignores and the inline decode drops. Marks are the schema's own business
80
+ * (`plaintextSchema` declares none). */
81
81
  export function fitsInline(rt) {
82
82
  if (core().isInline(rt))
83
83
  return true;
@@ -96,7 +96,7 @@ export function fitsPlain(rt) {
96
96
  }
97
97
  /** Whether a leaf so declared edits `rt` losing nothing: {@link fitsInline} narrowed
98
98
  * and {@link fitsPlain} plain, past which the decode drops what the first commit would
99
- * store; on the block schema anything but an element or a task item ({@link editable}),
99
+ * store; on the block schema anything but an element ({@link editable}),
100
100
  * and an island where the leaf mounts no island view (`islands`), which draws as its
101
101
  * placeholder tag, an atom a keystroke deletes unseen. What a leaf held over the rest
102
102
  * reads. */
@@ -107,10 +107,10 @@ export function fitsLeaf(rt, leaf) {
107
107
  return fitsPlain(rt);
108
108
  return (leaf.islands || rt.islands.length === 0) && rt.lines.every(editable);
109
109
  }
110
- /** Whether every container on `line` is one the block schema edits: an element, and a
111
- * task item's `checked`, decode to no node, so the first commit would drop them. */
110
+ /** Whether every container on `line` is one the block schema edits: an element decodes
111
+ * to no node, so the first commit would drop it. */
112
112
  function editable(line) {
113
- return line.containers.every((c) => c.container === 'quote' || (c.container === 'list_item' && c.attrs.checked === undefined));
113
+ return line.containers.every((c) => c.container === 'quote' || c.container === 'list_item');
114
114
  }
115
115
  /** Inline decode: one paragraph, containers and islands stripped. */
116
116
  function decodeInline(rt, schema, lineTexts, starts, marks, cursor) {
@@ -184,7 +184,7 @@ function groupBlocks(schema, leaves, depth, marks, cursor) {
184
184
  let l = k + 1;
185
185
  while (l < run.length && ordinalAt(run[l], depth) === ord)
186
186
  l++;
187
- items.push(schema.nodes.list_item.create(null, groupBlocks(schema, run.slice(k, l), depth + 1, marks, cursor)));
187
+ items.push(schema.nodes.list_item.create({ checked: checkedAt(run[k], depth) }, groupBlocks(schema, run.slice(k, l), depth + 1, marks, cursor)));
188
188
  k = l;
189
189
  }
190
190
  const listType = ordered ? schema.nodes.ordered_list : schema.nodes.bullet_list;
@@ -235,6 +235,12 @@ function ordinalAt(leaf, depth) {
235
235
  const c = atDepth(leaf, depth);
236
236
  return c?.container === 'list_item' ? c.attrs.ordinal : -1;
237
237
  }
238
+ /** A task item's `checked` at `depth`, `null` on any other item: what the item's lines
239
+ * carry alike, so its first line answers for it. */
240
+ function checkedAt(leaf, depth) {
241
+ const c = atDepth(leaf, depth);
242
+ return c?.container === 'list_item' ? (c.attrs.checked ?? null) : null;
243
+ }
238
244
  /** A container's `instance`, absent meaning the zero a canonical read omits. It is what
239
245
  * tells one container from an adjacent sibling of identical shape, which contiguity
240
246
  * alone reads as one, so absent and `0` have to answer alike. */
@@ -122,7 +122,12 @@ function scanBlock(acc, node, nodePos, containers, instance) {
122
122
  const start = ordered ? node.attrs.start : 1;
123
123
  let itemPos = contentStart;
124
124
  node.forEach((item, _off, index) => {
125
- const container = withInstance({ container: 'list_item', attrs: { ordered, start, ordinal: index } }, instance);
125
+ const checked = item.attrs.checked;
126
+ const attrs = { ordered, start, ordinal: index };
127
+ const container = withInstance({
128
+ container: 'list_item',
129
+ attrs: checked === null ? attrs : { ...attrs, checked }
130
+ }, instance);
126
131
  scanBlocks(acc, item, itemPos + 1, [...containers, container]);
127
132
  itemPos += item.nodeSize;
128
133
  });
@@ -14,20 +14,20 @@ export interface CreateFieldOpts {
14
14
  quill: Quill;
15
15
  addr: Addr;
16
16
  container: HTMLElement;
17
- /** Constrained single-textblock schema (a field declaring `inline`). */
17
+ /** Constrained single-textblock schema (a one-line field). */
18
18
  inline?: boolean;
19
- /** A mark-free schema (a `plaintext` field): literal text, no formatting and no
19
+ /** A mark-free schema (a `text` field): literal text, no formatting and no
20
20
  * anchors, in paragraphs and hard breaks or, with `inline`, one textblock. */
21
21
  plaintext?: boolean;
22
22
  /** The id of the note the caller draws inside `container` for a held leaf →
23
23
  * `aria-describedby` while it holds. */
24
24
  heldNoteId?: string;
25
25
  /**
26
- * Fired when the leaf holds or releases, and at mount when it mounts held. A leaf
27
- * declaring `inline`, or a `plaintext` one, is held over a value its schema cannot
26
+ * Fired when the leaf holds or releases, and at mount when it mounts held. A one-line
27
+ * leaf, or a `text` one, is held over a value its schema cannot
28
28
  * hold (`fitsLeaf`, upstream's `isInline` and `isPlain`): the decode would join lines
29
29
  * and drop containers and islands for the first commit to store. Any leaf is held
30
- * over an element or a task item, which no node holds. A held leaf draws
30
+ * over an element, which no node holds. A held leaf draws
31
31
  * its content on the block schema, read-only, and commits nothing. Judged of the value
32
32
  * each mount and re-hydrate reads, so {@link FieldController.applyExternal} is what
33
33
  * re-evaluates it.
@@ -79,6 +79,9 @@ export interface CreateFieldOpts {
79
79
  * emptied it holds an empty answer, which prints empty. Absent, the placeholder
80
80
  * returns whenever the leaf is empty. */
81
81
  placeholderUntilEdit?: boolean;
82
+ /** An edit leaving the leaf empty removes the field: an optional cell (`t?`), whose
83
+ * unanswered `none` is what the empty leaf draws and an empty answer would hide. */
84
+ emptyUnsets?: boolean;
82
85
  onFocus?(addr: Addr): void;
83
86
  /** Fired with the new USV caret after an edit or a selection move. */
84
87
  onCaretMove?(addr: Addr, pos: number): void;
@@ -88,8 +91,8 @@ export interface CreateFieldOpts {
88
91
  * move, so a host driving a recompile off it recompiles on every arrow key.
89
92
  *
90
93
  * Fires for every commit regardless of which branch it took (`applyChange`, the
91
- * first-edit `overwrite`, the fallback `overwrite`, a `plaintext` leaf's typed
92
- * write) and for an anchor mutation,
94
+ * first-edit `overwrite`, the fallback `overwrite`, a `text` leaf's typed write)
95
+ * and for an anchor mutation,
93
96
  * which changes no text. A commit that failed outright (`commit-lost`) does not
94
97
  * fire it: nothing landed.
95
98
  */
@@ -168,6 +171,8 @@ export interface LeafViews {
168
171
  /** The canonical empty `Content`: one empty `para` line. The empty a prose
169
172
  * leaf (or an array's prose element) seeds from. */
170
173
  export declare function emptyContent(): Content;
174
+ /** Whether `rt` is the empty leaf: {@link emptyContent}, nothing typed and no shape left. */
175
+ export declare function isEmptyContent(rt: Content): boolean;
171
176
  /** The naming attributes for the `contenteditable`, shared by every prose leaf
172
177
  * (`ProseValue` takes it too, so an addressed leaf and a by-value one name their
173
178
  * regions by one rule). `aria-labelledby` supersedes `aria-label`: a leaf carrying
@@ -197,10 +202,10 @@ export declare function createField(opts: CreateFieldOpts): FieldController;
197
202
  * Backspace that takes one back, the field keymap over the base keymap, the URL paste
198
203
  * that links a selection (`link-paste.ts`), and last the ones that answer to a block
199
204
  * leaf's own shapes: the gap cursor, {@link pastAtomPlugin}, the island paste pass
200
- * (`islands.ts`), the table paste (`table-paste.ts`) and the markdown paste
201
- * (`markdown-paste.ts`).
205
+ * (`islands.ts`), the table paste (`table-paste.ts`), the markdown paste
206
+ * (`markdown-paste.ts`) and the task item's checkbox (`tasks.ts`).
202
207
  *
203
- * Every mark-shaped plugin reads the schema rather than a flag: over either plaintext
208
+ * Every mark-shaped plugin reads the schema rather than a flag: over either mark-free
204
209
  * schema the shorthand rules build nothing (each is guarded on its mark type), the
205
210
  * toggles bind no key and a pasted URL stays text, so a `**bold**` keeps the delimiters
206
211
  * its author typed without a second rule saying so.
@@ -5,8 +5,8 @@
5
5
  // (a) apply optimistically to the view,
6
6
  // (b) lower the tr to a `ChangeBundle` (or `overwrite` for a field not yet at
7
7
  // content rest, which has nothing to splice),
8
- // (c) commit via `doc.applyChange(addr, bundle)`, or a `plaintext` leaf's whole
9
- // text through the typed writer,
8
+ // (c) commit via `doc.applyChange(addr, bundle)`, or a `text` leaf's whole text
9
+ // through the typed writer,
10
10
  // (d) fire `onChange` when the transaction committed, then `onCaretMove` with
11
11
  // the new USV caret, which a bare selection move fires on its own.
12
12
  // On an `applyChange` throw the optimistic PM state stays and the failure reports
@@ -33,7 +33,10 @@ import { bodyKeymap } from './keymap.js';
33
33
  import { blockSchema, isBlockSchema, leafSchema, plainSchema } from './schema.js';
34
34
  import { linkPastePlugin } from './link-paste.js';
35
35
  import { markdownPastePlugin } from './markdown-paste.js';
36
+ import { nestingPlugin } from './nesting.js';
36
37
  import { tablePastePlugin } from './table-paste.js';
38
+ import { tableCopyPlugin } from './table-copy.js';
39
+ import { taskPlugin } from './tasks.js';
37
40
  import { DEFAULT_TABLE_STRINGS, tableNodeView } from './table-view.js';
38
41
  import { DEFAULT_SLASH_STRINGS, focusSlashItem, runSlashItem, slashPlugin } from './slash.js';
39
42
  const anchorKey = new PluginKey('quill-anchors');
@@ -75,8 +78,8 @@ function anchorPlugin(seed) {
75
78
  * and the verbatim read cannot answer it alone. A field the editor committed rests
76
79
  * as `Content`; a field a markdown parse left rests as the authored string, and
77
80
  * what that string means is the declared type's to say: `richtext` is markdown,
78
- * `plaintext` is literal text, and reading one as the other silently eats every
79
- * `*` the author typed. */
81
+ * `text` is literal text, and reading one as the other silently eats every `*` the
82
+ * author typed. */
80
83
  function readLeaf(reader, addr) {
81
84
  return reader.getContent(addr) ?? emptyContent();
82
85
  }
@@ -85,14 +88,18 @@ function readLeaf(reader, addr) {
85
88
  export function emptyContent() {
86
89
  return { text: '', lines: [{ containers: [], kind: 'para' }], marks: [], islands: [] };
87
90
  }
91
+ /** Whether `rt` is the empty leaf: {@link emptyContent}, nothing typed and no shape left. */
92
+ export function isEmptyContent(rt) {
93
+ return contentEqual(rt, emptyContent());
94
+ }
88
95
  /** Whether ops may commit here: the stored value is a `Content` object, so
89
96
  * `applyChange` splices exactly the content the leaf read and PM is showing.
90
97
  *
91
98
  * The two other rest forms both take `overwrite` instead. An unset field (`undefined`;
92
99
  * a default-only richtext field before its first edit) has nothing to splice, and an
93
100
  * authored string nothing to lose: content-only marks do not survive markdown, so a
94
- * string carries no anchors to pay. A body always rests as `Content`. A `plaintext`
95
- * leaf never asks: it commits through the writer ({@link createField}). */
101
+ * string carries no anchors to pay. A body always rests as `Content`. A `text` leaf
102
+ * never asks: it commits through the writer ({@link createField}). */
96
103
  function opsCommittable(doc, addr) {
97
104
  const stored = doc.getStored(addr);
98
105
  return stored !== null && typeof stored === 'object';
@@ -162,7 +169,7 @@ export function createField(opts) {
162
169
  const slash = block ? opts.onSlash : undefined;
163
170
  // Judged of the value being mounted rather than read off a diagnostic: a routed set
164
171
  // can predate the value it arrives with, and `validation::not_plain` stands in for
165
- // `not_inline` on a `plaintext(inline)` field.
172
+ // `not_single_line` on a one-line `text` field.
166
173
  const holds = (rt) => !fitsLeaf(rt, { inline, plaintext, islands: true });
167
174
  let held = holds(reconciler.last);
168
175
  const named = proseAttributes(opts) ?? {};
@@ -210,6 +217,9 @@ export function createField(opts) {
210
217
  dispatchTransaction: (tr) => {
211
218
  const oldRt = reconciler.last;
212
219
  const next = view.state.apply(tr);
220
+ // A filtered transaction (`nesting.ts`) leaves the state as it was.
221
+ if (next === view.state)
222
+ return;
213
223
  // An edit takes a held default: the commit below writes the whole leaf, so the
214
224
  // mark goes in the same update that draws the edit.
215
225
  if (!held && (tr.docChanged || tr.getMeta(anchorKey)))
@@ -282,6 +292,8 @@ export function createField(opts) {
282
292
  // Returns whether anything landed: the caller's change signal, which must not
283
293
  // be a property of which branch the commit took.
284
294
  function commitEdit(oldRt, newRt) {
295
+ if (opts.emptyUnsets && heldAnchors().length === 0 && isEmptyContent(newRt))
296
+ return commitUnset();
285
297
  if (plaintext)
286
298
  return commitPlain(newRt.text);
287
299
  // The text splice, computed once per keystroke: the gate below, both overwrite
@@ -335,8 +347,8 @@ export function createField(opts) {
335
347
  }
336
348
  }
337
349
  /**
338
- * A `plaintext` leaf's commit: its whole text through the typed writer, which rests
339
- * the field as that literal string (CODEC §Inline mode). A content object is the
350
+ * A `text` leaf's commit: its whole text through the typed writer, which rests the
351
+ * field as that literal string (CODEC §Inline mode). A content object is the
340
352
  * other rest form, and `toMarkdown` emits one as markdown: a break spelled as a
341
353
  * backslash, a `*` and a trailing space escaped, a blank line doubled, so the value
342
354
  * reads back changed. The leaf carries no anchors, so the op path has none to keep.
@@ -348,7 +360,7 @@ export function createField(opts) {
348
360
  try {
349
361
  const name = addr.field;
350
362
  if (name === undefined)
351
- throw new Error('a plaintext leaf names a field');
363
+ throw new Error('a text leaf names a field');
352
364
  (addr.card === undefined ? writer : writer.card(addr.card)).set(name, text);
353
365
  reconciler.commit(readLeaf(reader, addr));
354
366
  return true;
@@ -363,6 +375,23 @@ export function createField(opts) {
363
375
  return false;
364
376
  }
365
377
  }
378
+ /** An emptied optional leaf's commit: the field removed, back to unanswered. */
379
+ function commitUnset() {
380
+ try {
381
+ doc.removeField(addr);
382
+ reconciler.commit(readLeaf(reader, addr));
383
+ return true;
384
+ }
385
+ catch (e) {
386
+ reportError(opts.onError, {
387
+ code: 'commit-lost',
388
+ severity: 'error',
389
+ message: `the field could not be removed; the stored value is stale while the editor keeps the edit: ${errorMessage(e)}`,
390
+ cause: e
391
+ });
392
+ return false;
393
+ }
394
+ }
366
395
  /** The anchors the plugin currently holds (PM coords); `[]` before the first apply. */
367
396
  const heldAnchors = () => anchorKey.getState(view.state) ?? [];
368
397
  /** The plugin's anchor positions as USV, against `index` — which `dispatchTransaction`
@@ -437,7 +466,7 @@ export function createField(opts) {
437
466
  return readLeaf(reader, addr);
438
467
  },
439
468
  insertAnchor(id, pos) {
440
- // A plaintext field carries no marks (§Inline mode), and a held one commits nothing.
469
+ // A text field carries no marks (§Inline mode), and a held one commits nothing.
441
470
  if (plaintext || held)
442
471
  return;
443
472
  if (heldAnchors().some((a) => a.id === id))
@@ -509,10 +538,10 @@ export function createField(opts) {
509
538
  * Backspace that takes one back, the field keymap over the base keymap, the URL paste
510
539
  * that links a selection (`link-paste.ts`), and last the ones that answer to a block
511
540
  * leaf's own shapes: the gap cursor, {@link pastAtomPlugin}, the island paste pass
512
- * (`islands.ts`), the table paste (`table-paste.ts`) and the markdown paste
513
- * (`markdown-paste.ts`).
541
+ * (`islands.ts`), the table paste (`table-paste.ts`), the markdown paste
542
+ * (`markdown-paste.ts`) and the task item's checkbox (`tasks.ts`).
514
543
  *
515
- * Every mark-shaped plugin reads the schema rather than a flag: over either plaintext
544
+ * Every mark-shaped plugin reads the schema rather than a flag: over either mark-free
516
545
  * schema the shorthand rules build nothing (each is guarded on its mark type), the
517
546
  * toggles bind no key and a pasted URL stays text, so a `**bold**` keeps the delimiters
518
547
  * its author typed without a second rule saying so.
@@ -530,7 +559,7 @@ export function proseLeafPlugins(schema, opts) {
530
559
  // Each answers to something only the block schema holds: any other leaf is
531
560
  // textblocks alone, with no island and no gap to put a cursor in.
532
561
  if (isBlockSchema(schema))
533
- list.push(gapCursor(), pastAtomPlugin(), islandPastePlugin(), tablePastePlugin(), markdownPastePlugin());
562
+ list.push(gapCursor(), pastAtomPlugin(), islandPastePlugin(), tablePastePlugin(), tableCopyPlugin(), markdownPastePlugin(), taskPlugin(), nestingPlugin());
534
563
  if (schema === plainSchema)
535
564
  list.push(plainClipboardPlugin());
536
565
  if (opts.placeholder)
@@ -641,9 +670,9 @@ function ghostPlugin(opts) {
641
670
  /**
642
671
  * The field's keymap: history, mark toggles, and the body's structural keys; Enter
643
672
  * suppressed inline. The toggles are bound per mark type the schema declares, so a
644
- * plaintext leaf leaves Mod-b/i/u to the browser.
673
+ * `text` leaf leaves Mod-b/i/u to the browser.
645
674
  *
646
- * Tab forks on the leaf's role, not on the caret's position. An inline/plaintext
675
+ * Tab forks on the leaf's role, not on the caret's position. A one-line or `text`
647
676
  * leaf is a form field: Tab stays unbound, so field navigation is open for a shell
648
677
  * keymap (VISUAL_EDITOR §Settled and open). A block-schema body is a document: Tab
649
678
  * is structural, a chain each nested surface prepends to (`keymap.ts`). One key
@@ -1,6 +1,6 @@
1
1
  export { rangeAnchor } from './anchor.js';
2
2
  export type { RangeAnchor } from './anchor.js';
3
- export { createField, emptyContent, heldAttributes, proseAttributes, proseLeafPlugins } from './field.js';
3
+ export { createField, emptyContent, heldAttributes, isEmptyContent, proseAttributes, proseLeafPlugins } from './field.js';
4
4
  export type { CreateFieldOpts, FieldController, LeafViews } from './field.js';
5
5
  export { DEFAULT_TABLE_STRINGS } from './table-view.js';
6
6
  export type { TableChromeStrings } from './table-view.js';