@quillmark/svelte 0.21.0 → 0.23.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 (69) 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/cap.d.ts +21 -0
  11. package/dist/core/codec/cap.js +63 -0
  12. package/dist/core/codec/code.d.ts +1 -1
  13. package/dist/core/codec/code.js +1 -1
  14. package/dist/core/codec/decode.d.ts +5 -5
  15. package/dist/core/codec/decode.js +18 -12
  16. package/dist/core/codec/encode.js +6 -1
  17. package/dist/core/codec/field.d.ts +22 -10
  18. package/dist/core/codec/field.js +54 -18
  19. package/dist/core/codec/index.d.ts +3 -1
  20. package/dist/core/codec/index.js +3 -2
  21. package/dist/core/codec/inputrules.js +1 -1
  22. package/dist/core/codec/lists.d.ts +3 -2
  23. package/dist/core/codec/lists.js +21 -5
  24. package/dist/core/codec/nesting.d.ts +20 -0
  25. package/dist/core/codec/nesting.js +62 -0
  26. package/dist/core/codec/prose.css +74 -20
  27. package/dist/core/codec/schema.d.ts +2 -2
  28. package/dist/core/codec/schema.js +16 -7
  29. package/dist/core/codec/table-copy.d.ts +17 -0
  30. package/dist/core/codec/table-copy.js +84 -0
  31. package/dist/core/codec/table-view.d.ts +1 -7
  32. package/dist/core/codec/table-view.js +70 -119
  33. package/dist/core/codec/table.d.ts +2 -9
  34. package/dist/core/codec/table.js +10 -19
  35. package/dist/core/codec/tasks.d.ts +19 -0
  36. package/dist/core/codec/tasks.js +140 -0
  37. package/dist/core/errors.d.ts +2 -2
  38. package/dist/visual/ArrayField.svelte +103 -63
  39. package/dist/visual/ArrayField.svelte.d.ts +0 -2
  40. package/dist/visual/Card.svelte +9 -3
  41. package/dist/visual/Card.svelte.d.ts +2 -2
  42. package/dist/visual/Field.svelte +33 -17
  43. package/dist/visual/Field.svelte.d.ts +4 -2
  44. package/dist/visual/FieldLabel.svelte +19 -33
  45. package/dist/visual/FieldLabel.svelte.d.ts +4 -2
  46. package/dist/visual/FormatPopover.svelte +54 -73
  47. package/dist/visual/MatrixField.svelte +18 -12
  48. package/dist/visual/NumberField.svelte +32 -3
  49. package/dist/visual/ObjectField.svelte +50 -22
  50. package/dist/visual/ObjectField.svelte.d.ts +6 -0
  51. package/dist/visual/ProseField.svelte +20 -14
  52. package/dist/visual/ProseField.svelte.d.ts +8 -2
  53. package/dist/visual/ProseValue.svelte +30 -19
  54. package/dist/visual/ProseValue.svelte.d.ts +7 -4
  55. package/dist/visual/TextField.svelte +3 -2
  56. package/dist/visual/TipsCard.svelte +5 -2
  57. package/dist/visual/VariantField.svelte +1 -1
  58. package/dist/visual/VariantField.svelte.d.ts +1 -1
  59. package/dist/visual/VisualEditorInner.svelte +13 -13
  60. package/dist/visual/controls.css +19 -1
  61. package/dist/visual/icons/nodes.d.ts +0 -21
  62. package/dist/visual/icons/nodes.js +1 -7
  63. package/dist/visual/leaves.d.ts +12 -9
  64. package/dist/visual/leaves.js +19 -0
  65. package/dist/visual/strings.d.ts +16 -5
  66. package/dist/visual/strings.js +17 -9
  67. package/dist/visual/structure.d.ts +26 -32
  68. package/dist/visual/structure.js +71 -53
  69. package/package.json +3 -3
@@ -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
@@ -24,6 +24,7 @@ import { reportError, errorMessage } from '../errors.js';
24
24
  import { decode, fitsLeaf } from './decode.js';
25
25
  import { usvToPM, pmToUsv, buildLineIndex, lineIndexOf } from './positions.js';
26
26
  import { lower, pmToContent, scanContent, scanDoc, contentEdit } from './encode.js';
27
+ import { capPlugin } from './cap.js';
27
28
  import { islandPastePlugin } from './islands.js';
28
29
  import { anchorsFromContent } from './marks.js';
29
30
  import { contentEqual, createReconciler } from './reconcile.js';
@@ -33,7 +34,10 @@ import { bodyKeymap } from './keymap.js';
33
34
  import { blockSchema, isBlockSchema, leafSchema, plainSchema } from './schema.js';
34
35
  import { linkPastePlugin } from './link-paste.js';
35
36
  import { markdownPastePlugin } from './markdown-paste.js';
37
+ import { nestingPlugin } from './nesting.js';
36
38
  import { tablePastePlugin } from './table-paste.js';
39
+ import { tableCopyPlugin } from './table-copy.js';
40
+ import { taskPlugin } from './tasks.js';
37
41
  import { DEFAULT_TABLE_STRINGS, tableNodeView } from './table-view.js';
38
42
  import { DEFAULT_SLASH_STRINGS, focusSlashItem, runSlashItem, slashPlugin } from './slash.js';
39
43
  const anchorKey = new PluginKey('quill-anchors');
@@ -75,8 +79,8 @@ function anchorPlugin(seed) {
75
79
  * and the verbatim read cannot answer it alone. A field the editor committed rests
76
80
  * as `Content`; a field a markdown parse left rests as the authored string, and
77
81
  * 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. */
82
+ * `text` is literal text, and reading one as the other silently eats every `*` the
83
+ * author typed. */
80
84
  function readLeaf(reader, addr) {
81
85
  return reader.getContent(addr) ?? emptyContent();
82
86
  }
@@ -85,14 +89,18 @@ function readLeaf(reader, addr) {
85
89
  export function emptyContent() {
86
90
  return { text: '', lines: [{ containers: [], kind: 'para' }], marks: [], islands: [] };
87
91
  }
92
+ /** Whether `rt` is the empty leaf: {@link emptyContent}, nothing typed and no shape left. */
93
+ export function isEmptyContent(rt) {
94
+ return contentEqual(rt, emptyContent());
95
+ }
88
96
  /** Whether ops may commit here: the stored value is a `Content` object, so
89
97
  * `applyChange` splices exactly the content the leaf read and PM is showing.
90
98
  *
91
99
  * The two other rest forms both take `overwrite` instead. An unset field (`undefined`;
92
100
  * a default-only richtext field before its first edit) has nothing to splice, and an
93
101
  * 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}). */
102
+ * string carries no anchors to pay. A body always rests as `Content`. A `text` leaf
103
+ * never asks: it commits through the writer ({@link createField}). */
96
104
  function opsCommittable(doc, addr) {
97
105
  const stored = doc.getStored(addr);
98
106
  return stored !== null && typeof stored === 'object';
@@ -162,7 +170,7 @@ export function createField(opts) {
162
170
  const slash = block ? opts.onSlash : undefined;
163
171
  // Judged of the value being mounted rather than read off a diagnostic: a routed set
164
172
  // can predate the value it arrives with, and `validation::not_plain` stands in for
165
- // `not_inline` on a `plaintext(inline)` field.
173
+ // `not_single_line` on a one-line `text` field.
166
174
  const holds = (rt) => !fitsLeaf(rt, { inline, plaintext, islands: true });
167
175
  let held = holds(reconciler.last);
168
176
  const named = proseAttributes(opts) ?? {};
@@ -210,6 +218,9 @@ export function createField(opts) {
210
218
  dispatchTransaction: (tr) => {
211
219
  const oldRt = reconciler.last;
212
220
  const next = view.state.apply(tr);
221
+ // A filtered transaction (`nesting.ts`) leaves the state as it was.
222
+ if (next === view.state)
223
+ return;
213
224
  // An edit takes a held default: the commit below writes the whole leaf, so the
214
225
  // mark goes in the same update that draws the edit.
215
226
  if (!held && (tr.docChanged || tr.getMeta(anchorKey)))
@@ -272,6 +283,7 @@ export function createField(opts) {
272
283
  // given one later; the plugin draws nothing while the text is empty.
273
284
  placeholder: () => placeholderText,
274
285
  placeholderUntilEdit: opts.placeholderUntilEdit,
286
+ cap: opts.cap,
275
287
  afterHistory: [anchorPlugin(seededAnchors)]
276
288
  })
277
289
  });
@@ -282,6 +294,8 @@ export function createField(opts) {
282
294
  // Returns whether anything landed: the caller's change signal, which must not
283
295
  // be a property of which branch the commit took.
284
296
  function commitEdit(oldRt, newRt) {
297
+ if (opts.emptyUnsets && heldAnchors().length === 0 && isEmptyContent(newRt))
298
+ return commitUnset();
285
299
  if (plaintext)
286
300
  return commitPlain(newRt.text);
287
301
  // The text splice, computed once per keystroke: the gate below, both overwrite
@@ -335,8 +349,8 @@ export function createField(opts) {
335
349
  }
336
350
  }
337
351
  /**
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
352
+ * A `text` leaf's commit: its whole text through the typed writer, which rests the
353
+ * field as that literal string (CODEC §Inline mode). A content object is the
340
354
  * other rest form, and `toMarkdown` emits one as markdown: a break spelled as a
341
355
  * backslash, a `*` and a trailing space escaped, a blank line doubled, so the value
342
356
  * reads back changed. The leaf carries no anchors, so the op path has none to keep.
@@ -348,7 +362,7 @@ export function createField(opts) {
348
362
  try {
349
363
  const name = addr.field;
350
364
  if (name === undefined)
351
- throw new Error('a plaintext leaf names a field');
365
+ throw new Error('a text leaf names a field');
352
366
  (addr.card === undefined ? writer : writer.card(addr.card)).set(name, text);
353
367
  reconciler.commit(readLeaf(reader, addr));
354
368
  return true;
@@ -363,6 +377,23 @@ export function createField(opts) {
363
377
  return false;
364
378
  }
365
379
  }
380
+ /** An emptied optional leaf's commit: the field removed, back to unanswered. */
381
+ function commitUnset() {
382
+ try {
383
+ doc.removeField(addr);
384
+ reconciler.commit(readLeaf(reader, addr));
385
+ return true;
386
+ }
387
+ catch (e) {
388
+ reportError(opts.onError, {
389
+ code: 'commit-lost',
390
+ severity: 'error',
391
+ message: `the field could not be removed; the stored value is stale while the editor keeps the edit: ${errorMessage(e)}`,
392
+ cause: e
393
+ });
394
+ return false;
395
+ }
396
+ }
366
397
  /** The anchors the plugin currently holds (PM coords); `[]` before the first apply. */
367
398
  const heldAnchors = () => anchorKey.getState(view.state) ?? [];
368
399
  /** The plugin's anchor positions as USV, against `index` — which `dispatchTransaction`
@@ -437,7 +468,7 @@ export function createField(opts) {
437
468
  return readLeaf(reader, addr);
438
469
  },
439
470
  insertAnchor(id, pos) {
440
- // A plaintext field carries no marks (§Inline mode), and a held one commits nothing.
471
+ // A text field carries no marks (§Inline mode), and a held one commits nothing.
441
472
  if (plaintext || held)
442
473
  return;
443
474
  if (heldAnchors().some((a) => a.id === id))
@@ -509,16 +540,21 @@ export function createField(opts) {
509
540
  * Backspace that takes one back, the field keymap over the base keymap, the URL paste
510
541
  * that links a selection (`link-paste.ts`), and last the ones that answer to a block
511
542
  * 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`).
543
+ * (`islands.ts`), the table paste (`table-paste.ts`), the markdown paste
544
+ * (`markdown-paste.ts`) and the task item's checkbox (`tasks.ts`).
514
545
  *
515
- * Every mark-shaped plugin reads the schema rather than a flag: over either plaintext
546
+ * Every mark-shaped plugin reads the schema rather than a flag: over either mark-free
516
547
  * schema the shorthand rules build nothing (each is guarded on its mark type), the
517
548
  * toggles bind no key and a pasted URL stays text, so a `**bold**` keeps the delimiters
518
549
  * its author typed without a second rule saying so.
519
550
  */
520
551
  export function proseLeafPlugins(schema, opts) {
521
- const list = [history(), ...(opts.afterHistory ?? []), linebreakPlugin(schema)];
552
+ const list = [
553
+ history(),
554
+ ...(opts.cap ? [capPlugin(opts.cap)] : []),
555
+ ...(opts.afterHistory ?? []),
556
+ linebreakPlugin(schema)
557
+ ];
522
558
  if (opts.slash)
523
559
  list.push(slashPlugin(opts.slash, opts.slashHint));
524
560
  if (!opts.noInputRules)
@@ -530,7 +566,7 @@ export function proseLeafPlugins(schema, opts) {
530
566
  // Each answers to something only the block schema holds: any other leaf is
531
567
  // textblocks alone, with no island and no gap to put a cursor in.
532
568
  if (isBlockSchema(schema))
533
- list.push(gapCursor(), pastAtomPlugin(), islandPastePlugin(), tablePastePlugin(), markdownPastePlugin());
569
+ list.push(gapCursor(), pastAtomPlugin(), islandPastePlugin(), tablePastePlugin(), tableCopyPlugin(), markdownPastePlugin(), taskPlugin(), nestingPlugin());
534
570
  if (schema === plainSchema)
535
571
  list.push(plainClipboardPlugin());
536
572
  if (opts.placeholder)
@@ -641,9 +677,9 @@ function ghostPlugin(opts) {
641
677
  /**
642
678
  * The field's keymap: history, mark toggles, and the body's structural keys; Enter
643
679
  * 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.
680
+ * `text` leaf leaves Mod-b/i/u to the browser.
645
681
  *
646
- * Tab forks on the leaf's role, not on the caret's position. An inline/plaintext
682
+ * Tab forks on the leaf's role, not on the caret's position. A one-line or `text`
647
683
  * leaf is a form field: Tab stays unbound, so field navigation is open for a shell
648
684
  * keymap (VISUAL_EDITOR §Settled and open). A block-schema body is a document: Tab
649
685
  * is structural, a chain each nested surface prepends to (`keymap.ts`). One key
@@ -1,7 +1,9 @@
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
+ export { capCount } from './cap.js';
6
+ export type { TextCap } from './cap.js';
5
7
  export { DEFAULT_TABLE_STRINGS } from './table-view.js';
6
8
  export type { TableChromeStrings } from './table-view.js';
7
9
  export { DEFAULT_SLASH_STRINGS } from './slash.js';
@@ -13,7 +13,8 @@ import './prose.css';
13
13
  // selection (`visual/FormatPopover.svelte`), so it is reached off-barrel.
14
14
  export { rangeAnchor } from './anchor.js';
15
15
  // The prose leaf.
16
- export { createField, emptyContent, heldAttributes, proseAttributes, proseLeafPlugins } from './field.js';
16
+ export { createField, emptyContent, heldAttributes, isEmptyContent, proseAttributes, proseLeafPlugins } from './field.js';
17
+ export { capCount } from './cap.js';
17
18
  // The table island's chrome vocabulary: the visual tier's `strings` set extends it,
18
19
  // so the island's wording is overridden beside every other key.
19
20
  export { DEFAULT_TABLE_STRINGS } from './table-view.js';
@@ -37,7 +38,7 @@ export { pmToContent, contentEdit, lower } from './encode.js';
37
38
  export { usvToPM, pmToUsv, buildLineIndex } from './positions.js';
38
39
  // Reconciliation gate.
39
40
  export { createReconciler, contentEqual, valueEqual } from './reconcile.js';
40
- // Input rules (`createField` mounts them unless `noInputRules`/`plaintext`).
41
+ // Input rules (`createField` mounts them unless `noInputRules`, or over a mark-free schema).
41
42
  export { inputRulesPlugin } from './inputrules.js';
42
43
  // The body leaf's structural keys: `createField` binds the composed chains
43
44
  // (`bodyKeymap`); the suite drives both it and the list link directly.
@@ -58,7 +58,7 @@ export function markdownInputRules(schema) {
58
58
  const rules = [];
59
59
  const m = schema.marks;
60
60
  // Inline marks. Non-greedy capture between matching delimiters. Built only where the
61
- // schema has the mark: the inline schema omits them, and a plaintext one declares none
61
+ // schema has the mark: the inline schema omits them, and a `text` one declares none
62
62
  // at all.
63
63
  if (m.strong)
64
64
  rules.push(markInputRule(/\*\*([^*]+)\*\*$/, m.strong, 2));
@@ -1,7 +1,7 @@
1
1
  import type { Schema } from 'prosemirror-model';
2
2
  import type { Command } from 'prosemirror-state';
3
3
  /**
4
- * The list bindings for a block-schema leaf: `{}` for the inline/plaintext
4
+ * The list bindings for a block-schema leaf: `{}` for the one-line and plain
5
5
  * schemas, which declare no list nodes.
6
6
  *
7
7
  * `Tab` / `Shift-Tab` hold the list link of the body's Tab chain. Surfaces that own
@@ -13,7 +13,8 @@ import type { Command } from 'prosemirror-state';
13
13
  * shell structural keymap (VISUAL_EDITOR §Settled and open).
14
14
  *
15
15
  * `Enter` is a chain in precedence order: the escape-above gesture, then
16
- * `splitListItem`, then `liftListItem`. The middle link carries two behaviors of
16
+ * `splitListItem` (`splitTaskItem`, which leaves a task item's new half an open task),
17
+ * then `liftListItem`. The middle link carries two behaviors of
17
18
  * its own: it splits a non-empty item, and on an empty item it either splits the
18
19
  * wrapping item (a nested empty item, so Enter lifts exactly one level) or bails
19
20
  * so the third link lifts a top-level empty item out to a paragraph. `Backspace` lifts
@@ -1,6 +1,8 @@
1
- import { liftListItem, sinkListItem, splitListItem } from 'prosemirror-schema-list';
1
+ import { liftListItem, sinkListItem } from 'prosemirror-schema-list';
2
2
  import { EditorState, Selection, TextSelection } from 'prosemirror-state';
3
3
  import { chainCommands, joinTextblockBackward, joinTextblockForward } from 'prosemirror-commands';
4
+ import { withinNesting } from './nesting.js';
5
+ import { splitTaskItem } from './tasks.js';
4
6
  /** The caret's enclosing `list_item` when it sits at the very start of that item's
5
7
  * first block: the position both structural keys branch on. `null` otherwise
6
8
  * (mid-text, a non-first block of a multi-paragraph item, a non-empty selection),
@@ -187,8 +189,21 @@ function backspaceBelow(itemType, press) {
187
189
  return press(EditorState.create({ doc: state.doc, selection: below }), dispatch && keepCaret);
188
190
  };
189
191
  }
192
+ /** Tab → `sinkListItem`, declined where the sink would nest past `MAX_NESTING_DEPTH`
193
+ * (`nesting.ts`), so Tab there leaves the body as it does on an item with nothing to
194
+ * sink under. */
195
+ function sinkWithin(itemType) {
196
+ const sink = sinkListItem(itemType);
197
+ return (state, dispatch) => {
198
+ let sunk;
199
+ if (!sink(state, (tr) => (sunk = tr)) || !sunk || !withinNesting(sunk.doc))
200
+ return false;
201
+ dispatch?.(sunk);
202
+ return true;
203
+ };
204
+ }
190
205
  /**
191
- * The list bindings for a block-schema leaf: `{}` for the inline/plaintext
206
+ * The list bindings for a block-schema leaf: `{}` for the one-line and plain
192
207
  * schemas, which declare no list nodes.
193
208
  *
194
209
  * `Tab` / `Shift-Tab` hold the list link of the body's Tab chain. Surfaces that own
@@ -200,7 +215,8 @@ function backspaceBelow(itemType, press) {
200
215
  * shell structural keymap (VISUAL_EDITOR §Settled and open).
201
216
  *
202
217
  * `Enter` is a chain in precedence order: the escape-above gesture, then
203
- * `splitListItem`, then `liftListItem`. The middle link carries two behaviors of
218
+ * `splitListItem` (`splitTaskItem`, which leaves a task item's new half an open task),
219
+ * then `liftListItem`. The middle link carries two behaviors of
204
220
  * its own: it splits a non-empty item, and on an empty item it either splits the
205
221
  * wrapping item (a nested empty item, so Enter lifts exactly one level) or bails
206
222
  * so the third link lifts a top-level empty item out to a paragraph. `Backspace` lifts
@@ -217,9 +233,9 @@ export function listKeymap(schema) {
217
233
  return {};
218
234
  const backspace = chainCommands(liftAtItemStart(itemType), joinOntoList(itemType));
219
235
  return {
220
- Tab: sinkListItem(itemType),
236
+ Tab: sinkWithin(itemType),
221
237
  'Shift-Tab': liftListItem(itemType),
222
- Enter: chainCommands(paragraphAboveList(itemType, paragraph), splitListItem(itemType), liftListItem(itemType)),
238
+ Enter: chainCommands(paragraphAboveList(itemType, paragraph), splitTaskItem(itemType), liftListItem(itemType)),
223
239
  Backspace: backspace,
224
240
  Delete: backspaceBelow(itemType, backspace)
225
241
  };
@@ -0,0 +1,20 @@
1
+ import type { Node as PMNode } from 'prosemirror-model';
2
+ import { Plugin } from 'prosemirror-state';
3
+ /** The store's bound on the containers around one line: a commit nesting past it is
4
+ * refused, and `@quillmark/wasm` exports no constant to read it from. */
5
+ export declare const MAX_NESTING_DEPTH = 12;
6
+ /** The most containers any block in `doc` stands inside, counted as the store counts
7
+ * them: a `list_item` or a `blockquote` is one, the list around an item none, and no
8
+ * node holds an element (`decode.ts`). */
9
+ export declare function nestingDepth(doc: PMNode): number;
10
+ /** Whether `doc` nests within {@link MAX_NESTING_DEPTH}: what a command that nests asks
11
+ * of the doc it would dispatch. */
12
+ export declare function withinNesting(doc: PMNode): boolean;
13
+ /**
14
+ * Refuses a transaction leaving `doc` past {@link MAX_NESTING_DEPTH}, which the commit
15
+ * would lose: a paste or a drop into a deep item, whose slice the import bounded alone.
16
+ * The nesting commands ask first and keep their key; this is what reaches the rest, and
17
+ * a refused paste lands nothing. Past the bound, a transaction passes where it leaves the
18
+ * deepest level no deeper, so a body that arrived that deep keeps its Enter.
19
+ */
20
+ export declare function nestingPlugin(): Plugin;
@@ -0,0 +1,62 @@
1
+ import { Plugin } from 'prosemirror-state';
2
+ import { ReplaceAroundStep, ReplaceStep } from 'prosemirror-transform';
3
+ /** The store's bound on the containers around one line: a commit nesting past it is
4
+ * refused, and `@quillmark/wasm` exports no constant to read it from. */
5
+ export const MAX_NESTING_DEPTH = 12;
6
+ /** The most containers any block in `doc` stands inside, counted as the store counts
7
+ * them: a `list_item` or a `blockquote` is one, the list around an item none, and no
8
+ * node holds an element (`decode.ts`). */
9
+ export function nestingDepth(doc) {
10
+ const walk = (node, depth) => {
11
+ let deepest = depth;
12
+ node.forEach((child) => {
13
+ if (child.isTextblock || child.isAtom)
14
+ return;
15
+ const name = child.type.name;
16
+ const d = depth + (name === 'list_item' || name === 'blockquote' ? 1 : 0);
17
+ deepest = Math.max(deepest, walk(child, d));
18
+ });
19
+ return deepest;
20
+ };
21
+ return walk(doc, 0);
22
+ }
23
+ /** Whether `doc` nests within {@link MAX_NESTING_DEPTH}: what a command that nests asks
24
+ * of the doc it would dispatch. */
25
+ export function withinNesting(doc) {
26
+ return nestingDepth(doc) <= MAX_NESTING_DEPTH;
27
+ }
28
+ /** Whether a step of `tr` can move a block under another container: any replace but
29
+ * inline content inside one textblock, which is every keystroke's. Mark and attribute
30
+ * steps move no node. */
31
+ function mayNest(tr) {
32
+ return tr.steps.some((step, i) => {
33
+ if (step instanceof ReplaceAroundStep)
34
+ return true;
35
+ if (!(step instanceof ReplaceStep))
36
+ return false;
37
+ const doc = tr.docs[i];
38
+ const $from = doc.resolve(step.from);
39
+ if (!$from.parent.isTextblock || !$from.sameParent(doc.resolve(step.to)))
40
+ return true;
41
+ let block = false;
42
+ step.slice.content.forEach((node) => (block ||= node.isBlock));
43
+ return block;
44
+ });
45
+ }
46
+ /**
47
+ * Refuses a transaction leaving `doc` past {@link MAX_NESTING_DEPTH}, which the commit
48
+ * would lose: a paste or a drop into a deep item, whose slice the import bounded alone.
49
+ * The nesting commands ask first and keep their key; this is what reaches the rest, and
50
+ * a refused paste lands nothing. Past the bound, a transaction passes where it leaves the
51
+ * deepest level no deeper, so a body that arrived that deep keeps its Enter.
52
+ */
53
+ export function nestingPlugin() {
54
+ return new Plugin({
55
+ filterTransaction: (tr) => {
56
+ if (!tr.docChanged || !mayNest(tr))
57
+ return true;
58
+ const after = nestingDepth(tr.doc);
59
+ return after <= MAX_NESTING_DEPTH || after <= nestingDepth(tr.before);
60
+ }
61
+ });
62
+ }
@@ -148,6 +148,20 @@ li.ProseMirror-selectednode::after {
148
148
  font-size: inherit;
149
149
  }
150
150
 
151
+ /* A fence's language at the label rung in its top corner, read off the `data-lang` the
152
+ node writes (`schema.ts`). Generated content, so it is never text the caret reaches
153
+ or a copy takes; the code's lines wrap around it. Read-only: the language is changed
154
+ in source mode. */
155
+ .ProseMirror pre[data-lang]::before {
156
+ content: attr(data-lang);
157
+ float: inline-end;
158
+ margin-inline-start: var(--_qm-space-3);
159
+ font-size: var(--_qm-text-label);
160
+ color: var(--_qm-ink-label);
161
+ user-select: none;
162
+ pointer-events: none;
163
+ }
164
+
151
165
  /* A link takes the surface's ink (the UA's blue is the `#8cf` defect again) and a
152
166
  dotted underline. Dotted rather than solid because `underline` is a mark in the
153
167
  same schema, and a solid rule renders an authored `<u>` and a link identically in a
@@ -191,6 +205,59 @@ li.ProseMirror-selectednode::after {
191
205
  list-style: revert;
192
206
  }
193
207
 
208
+ /* A task item's checkbox (`tasks.ts`) stands after the item's marker and ahead of its
209
+ first line, the text wrapping back under it, as the paper sets the box. Its face is a
210
+ matrix tick's: the input drawn `appearance: none` as an edged well, held at the
211
+ accent, the check glyph over it in the surface ink. A disabled box is a held leaf's
212
+ or a copy's, and takes no press. */
213
+ .ProseMirror .qm-task {
214
+ position: relative;
215
+ float: left;
216
+ display: flex;
217
+ align-items: center;
218
+ height: 1lh;
219
+ margin-right: var(--_qm-space-2);
220
+ }
221
+ .ProseMirror .qm-task-box {
222
+ appearance: none;
223
+ margin: 0;
224
+ width: var(--_qm-glyph-control);
225
+ height: var(--_qm-glyph-control);
226
+ border: var(--_qm-border-width) solid var(--_qm-border);
227
+ border-radius: calc(var(--_qm-radius-inner) / 2);
228
+ background: var(--_qm-surface-well);
229
+ cursor: pointer;
230
+ transition:
231
+ background-color var(--_qm-duration-fast) var(--_qm-ease-reverse),
232
+ border-color var(--_qm-duration-fast) var(--_qm-ease-reverse);
233
+ }
234
+ .ProseMirror .qm-task-box:checked {
235
+ background: var(--_qm-accent);
236
+ border-color: var(--_qm-accent);
237
+ }
238
+ .ProseMirror .qm-task-box:disabled {
239
+ cursor: default;
240
+ }
241
+ .ProseMirror .qm-task-box:focus-visible {
242
+ outline: var(--_qm-ring);
243
+ outline-offset: var(--_qm-ring-offset);
244
+ }
245
+ .ProseMirror .qm-task-mark {
246
+ position: absolute;
247
+ top: 50%;
248
+ left: 0;
249
+ width: var(--_qm-glyph-control);
250
+ height: var(--_qm-glyph-control);
251
+ translate: 0 -50%;
252
+ pointer-events: none;
253
+ color: var(--_qm-surface);
254
+ opacity: 0;
255
+ transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
256
+ }
257
+ .ProseMirror .qm-task:has(:checked) .qm-task-mark {
258
+ opacity: 1;
259
+ }
260
+
194
261
  /* Six levels, one weight, no size step. The quill reads no level at all, so a size
195
262
  ramp here would be the editor predicting a hierarchy the paper does not set; the
196
263
  level stays in the model, which is where the codec round-trips it. It is also all
@@ -516,37 +583,24 @@ li.ProseMirror-selectednode::after {
516
583
  height: var(--_qm-tap-min);
517
584
  }
518
585
  /* The reveal is the control's own (CODEC §"The table island"): one comes up while the
519
- pointer is on that control, while it holds the focus, or while it is what a gesture is
520
- about — the selected line's grip, and the lifted one, which is drawn through a drag the
586
+ pointer is on that control, or while it is what a gesture is about — the selected line's grip, and the lifted one, which is drawn through a drag the
521
587
  pointer has travelled off. A cell under the pointer draws its row's and its column's
522
588
  grips (`table-view.ts` §`onHover`), one pill on each axis and none anywhere else.
523
589
 
524
590
  Opacity rather than absence, so every control keeps its box and is hit-tested wherever
525
- it sits. A grip's reach is the pointer's alone and it holds no focus, so it carries no
526
- focus rung: what a keyboard draws a line with is the cell the selection runs through
527
- (`table-view.ts` §`cellKeys`), and the wash and the selected grip's tint are what say
528
- so. The two bars are in the tab order, the Tab that declines off the last cell landing
529
- on them, so the rung there draws one a keyboard has reached. */
591
+ it sits. A grip's reach and a bar's are the pointer's alone and neither holds focus, so
592
+ neither carries a focus rung: what a keyboard draws a line with is the cell the
593
+ selection runs through (`table-view.ts` §`cellKeys`), and the wash and the selected
594
+ grip's tint are what say so. */
530
595
  :is(.qm-table-grip, .qm-table-add) {
531
596
  opacity: 0;
532
597
  transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
533
598
  }
534
- .qm-table-add:is(:hover, :focus-visible),
599
+ .qm-table-add:hover,
535
600
  .qm-table-grip:is([data-near], :hover, [aria-pressed='true'], .qm-table-lifted) {
536
601
  opacity: 1;
537
602
  }
538
603
 
539
- /* The ring, on the two add bars and nowhere else in this file. A prose leaf's focus
540
- indicator is its caret and a cell's is too, so the outline is dropped there; a control
541
- that comes up out of nothing has the reveal, and a ring under a keyboard is what says
542
- the focus is there rather than on the paper behind it. Declared here rather than
543
- reached for as `.qm-focus-ring`: that recipe is the visual tier's stylesheet, and the
544
- island is inside the leaf, which is the codec's. */
545
- .qm-table-add:focus-visible {
546
- outline: var(--_qm-ring);
547
- outline-offset: var(--_qm-ring-offset);
548
- }
549
-
550
604
  /* A weighted table spans the leaf, laid out fixed, so the stored weights are the
551
605
  proportions its columns draw, as the page's fractional columns span the text block
552
606
  (`table-view.ts` §`weigh`). An auto-fit table keeps its content's width. */
@@ -610,7 +664,7 @@ li.ProseMirror-selectednode::after {
610
664
  display: none;
611
665
  }
612
666
  /* The header toggle hangs under the first row's last cell, flush with the grid's
613
- trailing edge, clear of whatever a held row raises over its first. */
667
+ trailing edge. */
614
668
  .qm-table-align[data-header] {
615
669
  margin-inline: auto 0;
616
670
  }
@@ -23,7 +23,7 @@ export declare const plaintextSchema: Schema<string, never>;
23
23
  */
24
24
  export declare const cellSchema: Schema<"hard_break", string>;
25
25
  /**
26
- * A `plaintext` field without `inline`: paragraphs and hard breaks, no marks, no
26
+ * A `text` field declaring `multiline: true`: paragraphs and hard breaks, no marks, no
27
27
  * islands, no containers, which is the shape `Content::is_plain` admits. Mark-free
28
28
  * for the reason {@link plaintextSchema} is.
29
29
  *
@@ -38,7 +38,7 @@ export declare function leafSchema(opts: {
38
38
  plaintext?: boolean;
39
39
  inline?: boolean;
40
40
  }): Schema;
41
- /** Whether `schema` can carry formatting at all: false for the two plaintext schemas.
41
+ /** Whether `schema` can carry formatting at all: false for the two mark-free schemas.
42
42
  * What a mark command asks before it offers itself. */
43
43
  export declare function hasMarks(schema: Schema): boolean;
44
44
  /** True for the one-line schemas, which declare no `hard_break`; decode branches on it. */
@@ -3,9 +3,9 @@
3
3
  // (list_item/quote → lists/blockquote); marks mirror the content formatting set.
4
4
  // Each set is closed upstream, so the schema names its whole vocabulary and carries
5
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, for
8
- // `plaintext(inline)`; `plainSchema` is a `plaintext` field without `inline`,
6
+ // single-textblock form for a one-line `richtext` (one paragraph, no block split, no
7
+ // containers, no islands) and `plaintextSchema` is that one without marks, for a
8
+ // one-line `text`; `plainSchema` is a `text` field declaring `multiline: true`,
9
9
  // paragraphs and hard breaks and nothing else; `cellSchema` is the inline form with
10
10
  // hard breaks, for a table cell. Same decode/lower/position machinery,
11
11
  // narrower shapes. Anchors are not marks here (decorations).
@@ -18,6 +18,7 @@
18
18
  // marks, a `<table>` being the block leaf's clipboard parser's (`table-paste.ts`).
19
19
  import { Schema } from 'prosemirror-model';
20
20
  import { islandBlockSpec, islandInlineSpec } from './islands.js';
21
+ import { taskBody, taskChecked, taskDOM } from './tasks.js';
21
22
  import { rendersHref, storableUrl } from './urls.js';
22
23
  // ── Marks spelled as style ──────────────────────────────────────────────────
23
24
  // A word processor writes formatting as a span's inline style rather than as a tag:
@@ -193,11 +194,19 @@ const blockNodes = {
193
194
  parseDOM: [{ tag: 'ul' }],
194
195
  toDOM: () => ['ul', 0]
195
196
  },
197
+ // `checked` is a task item's, `null` on every other item (`tasks.ts`).
196
198
  list_item: {
197
199
  content: 'block+',
198
200
  defining: true,
199
- parseDOM: [{ tag: 'li' }],
200
- toDOM: () => ['li', 0]
201
+ attrs: { checked: { default: null } },
202
+ parseDOM: [
203
+ {
204
+ tag: 'li',
205
+ getAttrs: (el) => ({ checked: taskChecked(el) }),
206
+ contentElement: taskBody
207
+ }
208
+ ],
209
+ toDOM: (node) => node.attrs.checked === null ? ['li', 0] : taskDOM(node.attrs.checked)
201
210
  },
202
211
  island_block: islandBlockSpec
203
212
  };
@@ -254,7 +263,7 @@ export const cellSchema = new Schema({
254
263
  marks
255
264
  });
256
265
  /**
257
- * A `plaintext` field without `inline`: paragraphs and hard breaks, no marks, no
266
+ * A `text` field declaring `multiline: true`: paragraphs and hard breaks, no marks, no
258
267
  * islands, no containers, which is the shape `Content::is_plain` admits. Mark-free
259
268
  * for the reason {@link plaintextSchema} is.
260
269
  *
@@ -278,7 +287,7 @@ export function leafSchema(opts) {
278
287
  return opts.inline ? plaintextSchema : plainSchema;
279
288
  return opts.inline ? inlineSchema : blockSchema;
280
289
  }
281
- /** Whether `schema` can carry formatting at all: false for the two plaintext schemas.
290
+ /** Whether `schema` can carry formatting at all: false for the two mark-free schemas.
282
291
  * What a mark command asks before it offers itself. */
283
292
  export function hasMarks(schema) {
284
293
  return Object.keys(schema.marks).length > 0;
@@ -0,0 +1,17 @@
1
+ import { Plugin } from 'prosemirror-state';
2
+ import type { TableProps } from '@quillmark/wasm';
3
+ /** A block of cells, both corners inclusive, in the row space `table.ts` names. */
4
+ export interface CellRange {
5
+ r0: number;
6
+ c0: number;
7
+ r1: number;
8
+ c1: number;
9
+ }
10
+ /** The cells of `range` as a `<table>`, the header row in a `<thead>` where the range
11
+ * holds it and the table has one. The cells carry their marks and breaks, and nothing
12
+ * of the table's layout. */
13
+ export declare function tableElement(props: TableProps, range?: CellRange): HTMLTableElement;
14
+ /** The cells of `range` as tab-separated text, a line a row. */
15
+ export declare function tableText(props: TableProps, range?: CellRange): string;
16
+ /** A block leaf's copy: a table in the slice writes as a table in both spellings. */
17
+ export declare function tableCopyPlugin(): Plugin;