@quillmark/svelte 0.20.1 → 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 (85) hide show
  1. package/NOTICE +6 -7
  2. package/README.md +1 -1
  3. package/THEMING.md +1 -1
  4. package/dist/core/address.d.ts +6 -1
  5. package/dist/core/address.js +12 -3
  6. package/dist/core/bloom.d.ts +9 -4
  7. package/dist/core/bloom.js +18 -49
  8. package/dist/core/codec/atoms.d.ts +1 -1
  9. package/dist/core/codec/atoms.js +26 -11
  10. package/dist/core/codec/blocks.d.ts +5 -5
  11. package/dist/core/codec/blocks.js +27 -8
  12. package/dist/core/codec/code.d.ts +4 -4
  13. package/dist/core/codec/code.js +52 -22
  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 -15
  18. package/dist/core/codec/field.js +66 -26
  19. package/dist/core/codec/index.d.ts +1 -1
  20. package/dist/core/codec/index.js +2 -2
  21. package/dist/core/codec/inputrules.d.ts +4 -0
  22. package/dist/core/codec/inputrules.js +44 -6
  23. package/dist/core/codec/link-paste.d.ts +11 -0
  24. package/dist/core/codec/link-paste.js +32 -0
  25. package/dist/core/codec/lists.d.ts +5 -4
  26. package/dist/core/codec/lists.js +87 -31
  27. package/dist/core/codec/nesting.d.ts +20 -0
  28. package/dist/core/codec/nesting.js +62 -0
  29. package/dist/core/codec/prose.css +145 -63
  30. package/dist/core/codec/schema.d.ts +2 -2
  31. package/dist/core/codec/schema.js +71 -18
  32. package/dist/core/codec/slash.js +0 -0
  33. package/dist/core/codec/table-copy.d.ts +17 -0
  34. package/dist/core/codec/table-copy.js +84 -0
  35. package/dist/core/codec/table-view.d.ts +6 -7
  36. package/dist/core/codec/table-view.js +281 -201
  37. package/dist/core/codec/table.d.ts +2 -9
  38. package/dist/core/codec/table.js +10 -19
  39. package/dist/core/codec/tasks.d.ts +19 -0
  40. package/dist/core/codec/tasks.js +140 -0
  41. package/dist/core/errors.d.ts +2 -2
  42. package/dist/core/rungs.d.ts +6 -0
  43. package/dist/core/rungs.js +11 -0
  44. package/dist/core/theme.css +24 -12
  45. package/dist/visual/ArrayField.svelte +236 -78
  46. package/dist/visual/ArrayField.svelte.d.ts +0 -2
  47. package/dist/visual/BooleanField.svelte +12 -1
  48. package/dist/visual/Card.svelte +41 -23
  49. package/dist/visual/Card.svelte.d.ts +3 -2
  50. package/dist/visual/CardControls.svelte +15 -15
  51. package/dist/visual/DateField.svelte +93 -64
  52. package/dist/visual/EnumField.svelte +9 -4
  53. package/dist/visual/Field.svelte +20 -16
  54. package/dist/visual/Field.svelte.d.ts +4 -2
  55. package/dist/visual/FieldHint.svelte +22 -5
  56. package/dist/visual/FieldLabel.svelte +5 -33
  57. package/dist/visual/FieldLabel.svelte.d.ts +0 -2
  58. package/dist/visual/FormatPopover.svelte +78 -81
  59. package/dist/visual/MatrixField.svelte +28 -19
  60. package/dist/visual/NumberField.svelte +32 -3
  61. package/dist/visual/ObjectField.svelte +40 -24
  62. package/dist/visual/ObjectField.svelte.d.ts +6 -0
  63. package/dist/visual/ProseField.svelte +10 -13
  64. package/dist/visual/ProseField.svelte.d.ts +5 -2
  65. package/dist/visual/ProseValue.svelte +25 -19
  66. package/dist/visual/ProseValue.svelte.d.ts +4 -4
  67. package/dist/visual/TextField.svelte +3 -2
  68. package/dist/visual/TipsCard.svelte +7 -7
  69. package/dist/visual/VariantField.svelte +1 -1
  70. package/dist/visual/VariantField.svelte.d.ts +1 -1
  71. package/dist/visual/VisualEditorInner.svelte +121 -39
  72. package/dist/visual/controls.css +21 -2
  73. package/dist/visual/icons/Icon.svelte +3 -2
  74. package/dist/visual/icons/Icon.svelte.d.ts +3 -2
  75. package/dist/visual/icons/nodes.d.ts +25 -21
  76. package/dist/visual/icons/nodes.js +9 -7
  77. package/dist/visual/leaves.d.ts +12 -9
  78. package/dist/visual/leaves.js +19 -0
  79. package/dist/visual/motion.d.ts +25 -3
  80. package/dist/visual/motion.js +58 -9
  81. package/dist/visual/strings.d.ts +15 -9
  82. package/dist/visual/strings.js +19 -12
  83. package/dist/visual/structure.d.ts +21 -31
  84. package/dist/visual/structure.js +54 -51
  85. package/package.json +3 -3
@@ -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
@@ -193,15 +198,17 @@ export declare function createField(opts: CreateFieldOpts): FieldController;
193
198
  * never fork the keymap/plugin ordering. History first, then any leaf-specific
194
199
  * plugins (`afterHistory`: the addressed leaf passes its anchor-position plugin;
195
200
  * a by-value leaf passes none), then {@link linebreakPlugin}, which normalizes what
196
- * the rest of the stack leaves, then the markdown-shorthand input rules, the field
197
- * keymap over the base keymap, and last the ones that answer to a block leaf's own
198
- * shapes: the gap cursor, {@link pastAtomPlugin}, the island paste pass (`islands.ts`),
199
- * the table paste (`table-paste.ts`) and the markdown paste (`markdown-paste.ts`).
201
+ * the rest of the stack leaves, then the markdown-shorthand input rules and the
202
+ * Backspace that takes one back, the field keymap over the base keymap, the URL paste
203
+ * that links a selection (`link-paste.ts`), and last the ones that answer to a block
204
+ * leaf's own shapes: the gap cursor, {@link pastAtomPlugin}, the island paste pass
205
+ * (`islands.ts`), the table paste (`table-paste.ts`), the markdown paste
206
+ * (`markdown-paste.ts`) and the task item's checkbox (`tasks.ts`).
200
207
  *
201
- * Every mark-shaped plugin reads the schema rather than a flag: over either plaintext
202
- * schema the shorthand rules build nothing (each is guarded on its mark type) and the
203
- * toggles bind no key, so a `**bold**` keeps the delimiters its author typed without
204
- * a second rule saying so.
208
+ * Every mark-shaped plugin reads the schema rather than a flag: over either mark-free
209
+ * schema the shorthand rules build nothing (each is guarded on its mark type), the
210
+ * toggles bind no key and a pasted URL stays text, so a `**bold**` keeps the delimiters
211
+ * its author typed without a second rule saying so.
205
212
  */
206
213
  export declare function proseLeafPlugins(schema: Schema, opts: {
207
214
  inline: boolean;
@@ -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
@@ -27,12 +27,16 @@ import { lower, pmToContent, scanContent, scanDoc, contentEdit } from './encode.
27
27
  import { islandPastePlugin } from './islands.js';
28
28
  import { anchorsFromContent } from './marks.js';
29
29
  import { contentEqual, createReconciler } from './reconcile.js';
30
- import { inputRulesPlugin } from './inputrules.js';
30
+ import { inputRulesPlugin, undoRuleKeymap } from './inputrules.js';
31
31
  import { linebreakPlugin } from './breaks.js';
32
32
  import { bodyKeymap } from './keymap.js';
33
33
  import { blockSchema, isBlockSchema, leafSchema, plainSchema } from './schema.js';
34
+ import { linkPastePlugin } from './link-paste.js';
34
35
  import { markdownPastePlugin } from './markdown-paste.js';
36
+ import { nestingPlugin } from './nesting.js';
35
37
  import { tablePastePlugin } from './table-paste.js';
38
+ import { tableCopyPlugin } from './table-copy.js';
39
+ import { taskPlugin } from './tasks.js';
36
40
  import { DEFAULT_TABLE_STRINGS, tableNodeView } from './table-view.js';
37
41
  import { DEFAULT_SLASH_STRINGS, focusSlashItem, runSlashItem, slashPlugin } from './slash.js';
38
42
  const anchorKey = new PluginKey('quill-anchors');
@@ -74,8 +78,8 @@ function anchorPlugin(seed) {
74
78
  * and the verbatim read cannot answer it alone. A field the editor committed rests
75
79
  * as `Content`; a field a markdown parse left rests as the authored string, and
76
80
  * what that string means is the declared type's to say: `richtext` is markdown,
77
- * `plaintext` is literal text, and reading one as the other silently eats every
78
- * `*` the author typed. */
81
+ * `text` is literal text, and reading one as the other silently eats every `*` the
82
+ * author typed. */
79
83
  function readLeaf(reader, addr) {
80
84
  return reader.getContent(addr) ?? emptyContent();
81
85
  }
@@ -84,14 +88,18 @@ function readLeaf(reader, addr) {
84
88
  export function emptyContent() {
85
89
  return { text: '', lines: [{ containers: [], kind: 'para' }], marks: [], islands: [] };
86
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
+ }
87
95
  /** Whether ops may commit here: the stored value is a `Content` object, so
88
96
  * `applyChange` splices exactly the content the leaf read and PM is showing.
89
97
  *
90
98
  * The two other rest forms both take `overwrite` instead. An unset field (`undefined`;
91
99
  * a default-only richtext field before its first edit) has nothing to splice, and an
92
100
  * authored string nothing to lose: content-only marks do not survive markdown, so a
93
- * string carries no anchors to pay. A body always rests as `Content`. A `plaintext`
94
- * 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}). */
95
103
  function opsCommittable(doc, addr) {
96
104
  const stored = doc.getStored(addr);
97
105
  return stored !== null && typeof stored === 'object';
@@ -161,7 +169,7 @@ export function createField(opts) {
161
169
  const slash = block ? opts.onSlash : undefined;
162
170
  // Judged of the value being mounted rather than read off a diagnostic: a routed set
163
171
  // can predate the value it arrives with, and `validation::not_plain` stands in for
164
- // `not_inline` on a `plaintext(inline)` field.
172
+ // `not_single_line` on a one-line `text` field.
165
173
  const holds = (rt) => !fitsLeaf(rt, { inline, plaintext, islands: true });
166
174
  let held = holds(reconciler.last);
167
175
  const named = proseAttributes(opts) ?? {};
@@ -209,6 +217,9 @@ export function createField(opts) {
209
217
  dispatchTransaction: (tr) => {
210
218
  const oldRt = reconciler.last;
211
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;
212
223
  // An edit takes a held default: the commit below writes the whole leaf, so the
213
224
  // mark goes in the same update that draws the edit.
214
225
  if (!held && (tr.docChanged || tr.getMeta(anchorKey)))
@@ -281,6 +292,8 @@ export function createField(opts) {
281
292
  // Returns whether anything landed: the caller's change signal, which must not
282
293
  // be a property of which branch the commit took.
283
294
  function commitEdit(oldRt, newRt) {
295
+ if (opts.emptyUnsets && heldAnchors().length === 0 && isEmptyContent(newRt))
296
+ return commitUnset();
284
297
  if (plaintext)
285
298
  return commitPlain(newRt.text);
286
299
  // The text splice, computed once per keystroke: the gate below, both overwrite
@@ -334,8 +347,8 @@ export function createField(opts) {
334
347
  }
335
348
  }
336
349
  /**
337
- * A `plaintext` leaf's commit: its whole text through the typed writer, which rests
338
- * 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
339
352
  * other rest form, and `toMarkdown` emits one as markdown: a break spelled as a
340
353
  * backslash, a `*` and a trailing space escaped, a blank line doubled, so the value
341
354
  * reads back changed. The leaf carries no anchors, so the op path has none to keep.
@@ -347,7 +360,7 @@ export function createField(opts) {
347
360
  try {
348
361
  const name = addr.field;
349
362
  if (name === undefined)
350
- throw new Error('a plaintext leaf names a field');
363
+ throw new Error('a text leaf names a field');
351
364
  (addr.card === undefined ? writer : writer.card(addr.card)).set(name, text);
352
365
  reconciler.commit(readLeaf(reader, addr));
353
366
  return true;
@@ -362,6 +375,23 @@ export function createField(opts) {
362
375
  return false;
363
376
  }
364
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
+ }
365
395
  /** The anchors the plugin currently holds (PM coords); `[]` before the first apply. */
366
396
  const heldAnchors = () => anchorKey.getState(view.state) ?? [];
367
397
  /** The plugin's anchor positions as USV, against `index` — which `dispatchTransaction`
@@ -436,7 +466,7 @@ export function createField(opts) {
436
466
  return readLeaf(reader, addr);
437
467
  },
438
468
  insertAnchor(id, pos) {
439
- // 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.
440
470
  if (plaintext || held)
441
471
  return;
442
472
  if (heldAnchors().some((a) => a.id === id))
@@ -504,28 +534,32 @@ export function createField(opts) {
504
534
  * never fork the keymap/plugin ordering. History first, then any leaf-specific
505
535
  * plugins (`afterHistory`: the addressed leaf passes its anchor-position plugin;
506
536
  * a by-value leaf passes none), then {@link linebreakPlugin}, which normalizes what
507
- * the rest of the stack leaves, then the markdown-shorthand input rules, the field
508
- * keymap over the base keymap, and last the ones that answer to a block leaf's own
509
- * shapes: the gap cursor, {@link pastAtomPlugin}, the island paste pass (`islands.ts`),
510
- * the table paste (`table-paste.ts`) and the markdown paste (`markdown-paste.ts`).
537
+ * the rest of the stack leaves, then the markdown-shorthand input rules and the
538
+ * Backspace that takes one back, the field keymap over the base keymap, the URL paste
539
+ * that links a selection (`link-paste.ts`), and last the ones that answer to a block
540
+ * leaf's own shapes: the gap cursor, {@link pastAtomPlugin}, the island paste pass
541
+ * (`islands.ts`), the table paste (`table-paste.ts`), the markdown paste
542
+ * (`markdown-paste.ts`) and the task item's checkbox (`tasks.ts`).
511
543
  *
512
- * Every mark-shaped plugin reads the schema rather than a flag: over either plaintext
513
- * schema the shorthand rules build nothing (each is guarded on its mark type) and the
514
- * toggles bind no key, so a `**bold**` keeps the delimiters its author typed without
515
- * a second rule saying so.
544
+ * Every mark-shaped plugin reads the schema rather than a flag: over either mark-free
545
+ * schema the shorthand rules build nothing (each is guarded on its mark type), the
546
+ * toggles bind no key and a pasted URL stays text, so a `**bold**` keeps the delimiters
547
+ * its author typed without a second rule saying so.
516
548
  */
517
549
  export function proseLeafPlugins(schema, opts) {
518
550
  const list = [history(), ...(opts.afterHistory ?? []), linebreakPlugin(schema)];
519
551
  if (opts.slash)
520
552
  list.push(slashPlugin(opts.slash, opts.slashHint));
521
553
  if (!opts.noInputRules)
522
- list.push(inputRulesPlugin(schema));
554
+ list.push(inputRulesPlugin(schema), undoRuleKeymap());
523
555
  list.push(keymap(editorKeymap(schema, opts.inline, !!opts.slash)));
524
556
  list.push(keymap(baseKeymap));
557
+ if (schema.marks.link)
558
+ list.push(linkPastePlugin(schema.marks.link));
525
559
  // Each answers to something only the block schema holds: any other leaf is
526
560
  // textblocks alone, with no island and no gap to put a cursor in.
527
561
  if (isBlockSchema(schema))
528
- list.push(gapCursor(), pastAtomPlugin(), islandPastePlugin(), tablePastePlugin(), markdownPastePlugin());
562
+ list.push(gapCursor(), pastAtomPlugin(), islandPastePlugin(), tablePastePlugin(), tableCopyPlugin(), markdownPastePlugin(), taskPlugin(), nestingPlugin());
529
563
  if (schema === plainSchema)
530
564
  list.push(plainClipboardPlugin());
531
565
  if (opts.placeholder)
@@ -553,8 +587,10 @@ function plainClipboardPlugin() {
553
587
  * A printable key over a selected atom writes past it. A selection is the subject
554
588
  * of the next command, never a thing armed for
555
589
  * replacement: Backspace deletes it, Mod-C copies it, and a character lands beside
556
- * it. A block island takes a new paragraph after it, an inline one the caret after
557
- * the image in the same line: one rule, both node types.
590
+ * it. A block island takes the empty paragraph after it, or a new one where there is
591
+ * none; an inline one the caret after the image in the same line: one rule, both node
592
+ * types. Only a paragraph is reused, since an empty heading or fence would set the
593
+ * text in a shape the key never asked for.
558
594
  *
559
595
  * PM routes text input over a non-`TextSelection` through `handleTextInput` before
560
596
  * falling back to replacing the selection, so this one prop is the whole of it.
@@ -570,9 +606,13 @@ function pastAtomPlugin() {
570
606
  return false;
571
607
  const tr = view.state.tr;
572
608
  const at = selection.to; // just past the node, in both coordinate senses
609
+ const after = tr.doc.resolve(at).nodeAfter;
573
610
  if (selection.node.isInline) {
574
611
  tr.setSelection(TextSelection.create(tr.doc, at)).insertText(text);
575
612
  }
613
+ else if (after?.type === schema.nodes.paragraph && after.content.size === 0) {
614
+ tr.setSelection(TextSelection.create(tr.doc, at + 1)).insertText(text);
615
+ }
576
616
  else {
577
617
  const para = schema.nodes.paragraph.create(null, schema.text(text));
578
618
  tr.insert(at, para);
@@ -630,9 +670,9 @@ function ghostPlugin(opts) {
630
670
  /**
631
671
  * The field's keymap: history, mark toggles, and the body's structural keys; Enter
632
672
  * suppressed inline. The toggles are bound per mark type the schema declares, so a
633
- * plaintext leaf leaves Mod-b/i/u to the browser.
673
+ * `text` leaf leaves Mod-b/i/u to the browser.
634
674
  *
635
- * 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`
636
676
  * leaf is a form field: Tab stays unbound, so field navigation is open for a shell
637
677
  * keymap (VISUAL_EDITOR §Settled and open). A block-schema body is a document: Tab
638
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';
@@ -13,7 +13,7 @@ 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
17
  // The table island's chrome vocabulary: the visual tier's `strings` set extends it,
18
18
  // so the island's wording is overridden beside every other key.
19
19
  export { DEFAULT_TABLE_STRINGS } from './table-view.js';
@@ -37,7 +37,7 @@ export { pmToContent, contentEdit, lower } from './encode.js';
37
37
  export { usvToPM, pmToUsv, buildLineIndex } from './positions.js';
38
38
  // Reconciliation gate.
39
39
  export { createReconciler, contentEqual, valueEqual } from './reconcile.js';
40
- // Input rules (`createField` mounts them unless `noInputRules`/`plaintext`).
40
+ // Input rules (`createField` mounts them unless `noInputRules`, or over a mark-free schema).
41
41
  export { inputRulesPlugin } from './inputrules.js';
42
42
  // The body leaf's structural keys: `createField` binds the composed chains
43
43
  // (`bodyKeymap`); the suite drives both it and the list link directly.
@@ -5,3 +5,7 @@ import type { Plugin } from 'prosemirror-state';
5
5
  export declare function markdownInputRules(schema: Schema): InputRule[];
6
6
  /** The input-rules plugin the field mounts by default (opt-out via createField). */
7
7
  export declare function inputRulesPlugin(schema: Schema): Plugin;
8
+ /** Backspace on the press right after a rule fired takes the rule back and leaves the
9
+ * text it consumed, the way a writer who meant the literal `# ` says so; and Enter fires
10
+ * the fence. Mounted ahead of every other keymap: each declines on any other press. */
11
+ export declare function undoRuleKeymap(): Plugin;
@@ -1,6 +1,6 @@
1
1
  // Input rules: the markdown-shorthand plugin the codec owns (`**`→strong, `*`→em,
2
2
  // `~~`→strike, `` ` ``→code, `# `→heading, `- `/`1. `→lists, `> `→blockquote,
3
- // `---`→divider, a fence→code block).
3
+ // `---`→divider), and the fence, which fires on Enter rather than as typed.
4
4
  // Mark rules are hand-rolled (prosemirror-inputrules ships only node rules); every
5
5
  // block rule is one regexp over a command in `blocks.ts`, which is the slash menu's
6
6
  // implementation too. These produce transactions the codec already lowers: they add no
@@ -12,7 +12,8 @@
12
12
  // sets"), and a construct that arrives through `importMarkdown` must stay authorable or
13
13
  // a document opens carrying a shape the editor refuses to make. So one guard survives:
14
14
  // `- ` / `1. ` at the head of an existing item, where Tab owns the nesting.
15
- import { InputRule, inputRules } from 'prosemirror-inputrules';
15
+ import { InputRule, inputRules, undoInputRule } from 'prosemirror-inputrules';
16
+ import { keymap } from 'prosemirror-keymap';
16
17
  import { consuming, insertDivider, toCodeBlock, toHeading, wrapInList, wrapInQuote } from './blocks.js';
17
18
  /**
18
19
  * A mark input rule: when `regexp` (ending in the closing delimiter) matches
@@ -47,16 +48,17 @@ function markInputRule(regexp, markType, delimLen) {
47
48
  }
48
49
  /** A block shorthand: the regexp that fires it, and the command it runs in the match's
49
50
  * place. Every one is `^`-anchored, so the command's own head-of-a-textblock guard is
50
- * the same position the regexp already stands at. */
51
+ * the same position the regexp already stands at. The caret it places is scrolled to
52
+ * here: `consuming` rebuilds the transaction, so a flag the command set is not on it. */
51
53
  function blockRule(regexp, command) {
52
- return new InputRule(regexp, (state, match, start, end) => consuming(state, start, end, command(match))?.tr ?? null);
54
+ return new InputRule(regexp, (state, match, start, end) => consuming(state, start, end, command(match))?.tr.scrollIntoView() ?? null);
53
55
  }
54
56
  /** The full markdown-shorthand rule set for a block schema. */
55
57
  export function markdownInputRules(schema) {
56
58
  const rules = [];
57
59
  const m = schema.marks;
58
60
  // Inline marks. Non-greedy capture between matching delimiters. Built only where the
59
- // 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
60
62
  // at all.
61
63
  if (m.strong)
62
64
  rules.push(markInputRule(/\*\*([^*]+)\*\*$/, m.strong, 2));
@@ -69,7 +71,6 @@ export function markdownInputRules(schema) {
69
71
  // Block shorthands. Each command answers for the schema it is run against, so a
70
72
  // schema without the node declines rather than being guarded here.
71
73
  rules.push(blockRule(/^(#{1,6})\s$/, (match) => toHeading(match[1].length)));
72
- rules.push(blockRule(/^```$/, toCodeBlock));
73
74
  rules.push(blockRule(/^---$/, insertDivider));
74
75
  // `- ` / `1. ` at the start of a block, here or nested inside an item — but not at
75
76
  // the start of an item's own first block, where the command declines.
@@ -82,3 +83,40 @@ export function markdownInputRules(schema) {
82
83
  export function inputRulesPlugin(schema) {
83
84
  return inputRules({ rules: markdownInputRules(schema) });
84
85
  }
86
+ /** A fence's opening line: three backticks, then the language, if one is typed. */
87
+ const FENCE = /^```([^\s`]*)$/;
88
+ /**
89
+ * Enter at the end of a paragraph that is a fence's opening line → a code block carrying
90
+ * the language typed after the backticks. The one shorthand Enter fires rather than its
91
+ * last character: on the third backtick the language is not typed yet. It records itself
92
+ * as the input-rules plugin's last rule, so the Backspace after takes it back like any
93
+ * other shorthand.
94
+ */
95
+ const fenceOnEnter = (state, dispatch) => {
96
+ const { $from, empty } = state.selection;
97
+ const line = $from.parent;
98
+ if (!empty || line.type !== state.schema.nodes.paragraph)
99
+ return false;
100
+ if ($from.parentOffset !== line.content.size || line.childCount !== 1 || !line.firstChild.isText)
101
+ return false;
102
+ const match = FENCE.exec(line.textContent);
103
+ if (!match)
104
+ return false;
105
+ const done = consuming(state, $from.start(), $from.pos, toCodeBlock({ lang: match[1] || null }));
106
+ if (!done)
107
+ return false;
108
+ if (dispatch) {
109
+ const tr = done.tr.scrollIntoView();
110
+ const rules = state.plugins.find((plugin) => plugin.spec.isInputRules);
111
+ if (rules)
112
+ tr.setMeta(rules, { transform: tr, from: $from.pos, to: $from.pos, text: '' });
113
+ dispatch(tr);
114
+ }
115
+ return true;
116
+ };
117
+ /** Backspace on the press right after a rule fired takes the rule back and leaves the
118
+ * text it consumed, the way a writer who meant the literal `# ` says so; and Enter fires
119
+ * the fence. Mounted ahead of every other keymap: each declines on any other press. */
120
+ export function undoRuleKeymap() {
121
+ return keymap({ Backspace: undoInputRule, Enter: fenceOnEnter });
122
+ }
@@ -0,0 +1,11 @@
1
+ import type { MarkType } from 'prosemirror-model';
2
+ import { Plugin } from 'prosemirror-state';
3
+ /**
4
+ * Links a selection within one block where the clipboard's text is one URL naming a
5
+ * scheme the mark renders. A selection crossing blocks, or held in one taking no link
6
+ * (a code block), keeps the default paste, and so does a URL nothing would follow.
7
+ *
8
+ * The ops are `setLink`'s pair (`visual/links.ts`), so the mark diff lowers the paste
9
+ * as one link exchanged for another.
10
+ */
11
+ export declare function linkPastePlugin(link: MarkType): Plugin;
@@ -0,0 +1,32 @@
1
+ import { Plugin, TextSelection } from 'prosemirror-state';
2
+ import { rendersHref } from './urls.js';
3
+ /** One URL and nothing else: a scheme, then no whitespace. */
4
+ const URL_ALONE = /^[a-z][a-z0-9+.-]*:\S+$/i;
5
+ /**
6
+ * Links a selection within one block where the clipboard's text is one URL naming a
7
+ * scheme the mark renders. A selection crossing blocks, or held in one taking no link
8
+ * (a code block), keeps the default paste, and so does a URL nothing would follow.
9
+ *
10
+ * The ops are `setLink`'s pair (`visual/links.ts`), so the mark diff lowers the paste
11
+ * as one link exchanged for another.
12
+ */
13
+ export function linkPastePlugin(link) {
14
+ return new Plugin({
15
+ props: {
16
+ handlePaste(view, event) {
17
+ const { selection } = view.state;
18
+ if (!(selection instanceof TextSelection) || selection.empty)
19
+ return false;
20
+ const { $from, $to } = selection;
21
+ if (!$from.sameParent($to) || !$from.parent.type.allowsMarkType(link))
22
+ return false;
23
+ const href = event.clipboardData?.getData('text/plain').trim() ?? '';
24
+ if (!URL_ALONE.test(href) || !rendersHref(href))
25
+ return false;
26
+ const { from, to } = selection;
27
+ view.dispatch(view.state.tr.removeMark(from, to, link).addMark(from, to, link.create({ href })));
28
+ return true;
29
+ }
30
+ }
31
+ });
32
+ }
@@ -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,11 +13,12 @@ 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
- * so the third link lifts a top-level empty item out to a paragraph. `Backspace`
20
- * forks on the item's index: the first item lifts, any later one merges. `Delete` is
20
+ * so the third link lifts a top-level empty item out to a paragraph. `Backspace` lifts
21
+ * an item at its start, and joins a block onto the list it stands after. `Delete` is
21
22
  * that same chain at the seam below rather than a second table of cases beside it, so
22
23
  * the two keys cannot come to differ over a seam this link reaches. An empty line is
23
24
  * not one: the block link takes it ahead of this chain (`blocks.ts`), the caret's own