@quillmark/svelte 0.20.0 → 0.21.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 (46) hide show
  1. package/THEMING.md +1 -1
  2. package/dist/core/codec/atoms.js +25 -10
  3. package/dist/core/codec/blocks.d.ts +5 -5
  4. package/dist/core/codec/blocks.js +23 -6
  5. package/dist/core/codec/code.d.ts +3 -3
  6. package/dist/core/codec/code.js +51 -21
  7. package/dist/core/codec/field.d.ts +9 -7
  8. package/dist/core/codec/field.js +22 -11
  9. package/dist/core/codec/inputrules.d.ts +4 -0
  10. package/dist/core/codec/inputrules.js +43 -5
  11. package/dist/core/codec/link-paste.d.ts +11 -0
  12. package/dist/core/codec/link-paste.js +32 -0
  13. package/dist/core/codec/lists.d.ts +2 -2
  14. package/dist/core/codec/lists.js +66 -26
  15. package/dist/core/codec/prose.css +71 -43
  16. package/dist/core/codec/schema.js +55 -11
  17. package/dist/core/codec/slash.js +0 -0
  18. package/dist/core/codec/table-view.d.ts +5 -0
  19. package/dist/core/codec/table-view.js +232 -103
  20. package/dist/core/rungs.d.ts +6 -0
  21. package/dist/core/rungs.js +11 -0
  22. package/dist/core/theme.css +24 -12
  23. package/dist/visual/ArrayField.svelte +168 -48
  24. package/dist/visual/BooleanField.svelte +12 -1
  25. package/dist/visual/Card.svelte +32 -20
  26. package/dist/visual/Card.svelte.d.ts +1 -0
  27. package/dist/visual/CardControls.svelte +15 -15
  28. package/dist/visual/DateField.svelte +93 -64
  29. package/dist/visual/EnumField.svelte +9 -4
  30. package/dist/visual/FieldHint.svelte +22 -5
  31. package/dist/visual/FieldLabel.svelte +3 -3
  32. package/dist/visual/FormatPopover.svelte +25 -9
  33. package/dist/visual/MatrixField.svelte +10 -7
  34. package/dist/visual/ObjectField.svelte +2 -4
  35. package/dist/visual/TipsCard.svelte +2 -5
  36. package/dist/visual/VisualEditorInner.svelte +108 -26
  37. package/dist/visual/controls.css +2 -1
  38. package/dist/visual/icons/Icon.svelte +3 -2
  39. package/dist/visual/icons/Icon.svelte.d.ts +3 -2
  40. package/dist/visual/icons/nodes.d.ts +25 -0
  41. package/dist/visual/icons/nodes.js +8 -0
  42. package/dist/visual/motion.d.ts +25 -3
  43. package/dist/visual/motion.js +58 -9
  44. package/dist/visual/strings.d.ts +6 -5
  45. package/dist/visual/strings.js +3 -3
  46. package/package.json +8 -8
package/THEMING.md CHANGED
@@ -56,7 +56,7 @@ The poles take a colour, not `transparent` or `inherit`: the raised surfaces, th
56
56
  | `--qm-bg` | `#fff` / `#272729` | Base surface: a card, and the preview's sheet. Fields, the popover and the column behind the cards each step off it. |
57
57
  | `--qm-fg` | `#1c1c1c` / `#d7dadc` | Base ink. Body text, labels, and borders step off it. |
58
58
  | `--qm-accent` | `#71717a` / `#a1a1aa` | Focus rings, active marks, the preview's active field box. The surface spends no hue of its own, so this is the one it wears. |
59
- | `--qm-danger` | `#9e1518` / `#f9786c` | Error diagnostics, the required marker, the delete glyph. |
59
+ | `--qm-danger` | `#9e1518` / `#f9786c` | Error diagnostics and the delete glyph. |
60
60
  | `--qm-font` | `ui-sans-serif, system-ui, sans-serif` | The editor surface's font family. Controls and buttons take it too, in place of the UA face. |
61
61
  | `--qm-font-mono` | `ui-monospace, monospace` | The monospace face: the JSON array control, the tips card. |
62
62
  | `--qm-font-size` | `0.875rem` | Chrome text: every control's size, the anchor the ramp derives up (title) and down (label/meta) from, and the anchor a document body sits one small step over. |
@@ -1,5 +1,7 @@
1
1
  // The block-atom link of the body's Backspace/Delete chains: a delete against an
2
- // island or a divider selects it, and the press after that deletes it.
2
+ // island or a divider selects it, and the press after that deletes it. A table is the
3
+ // exception, having cells a caret can enter: the press steps into it instead, so a
4
+ // delete against one edits nothing (§`againstAtom`).
3
5
  //
4
6
  // This is the neighbour half of the rule the leaf already holds for a key typed over a
5
7
  // selection (CODEC §"The table island"): a selection is the subject of the next
@@ -12,6 +14,7 @@
12
14
  // first item lifts the item, and that stays the item's answer whatever sits above the
13
15
  // list.
14
16
  import { NodeSelection, TextSelection } from 'prosemirror-state';
17
+ import { tableAt } from './table-view.js';
15
18
  /** The caret, when it sits at a textblock edge with an empty selection: the only place
16
19
  * a delete is about a neighbour rather than about a character. `null` everywhere else,
17
20
  * which is where the key keeps its ordinary meaning. */
@@ -44,20 +47,32 @@ function cutAt($pos, side) {
44
47
  }
45
48
  return null;
46
49
  }
47
- /** Select the block atom the caret is against, rather than deleting it. Declines when
48
- * the neighbour is anything a caret can enter, so a paragraph, a heading, a quote and
49
- * a list all keep the join the base keymap gives them. */
50
- function selectAtom(side) {
51
- return (state, dispatch) => {
50
+ /** The press against a block atom: select it, rather than deleting it, or step into a
51
+ * mounted table, its last cell from below and its first from above. An empty line under
52
+ * a table goes with the step, its Backspace having nothing of its own to edit. Declines
53
+ * when the neighbour is anything else a caret can enter, so a paragraph, a heading, a
54
+ * quote and a list all keep the join the base keymap gives them. */
55
+ function againstAtom(side) {
56
+ return (state, dispatch, view) => {
52
57
  const $head = atEdge(state, side);
53
58
  const $cut = $head && cutAt($head, side);
54
- if (!$cut)
59
+ if (!$head || !$cut)
55
60
  return false;
56
61
  const node = side < 0 ? $cut.nodeBefore : $cut.nodeAfter;
57
62
  if (!node || !node.isAtom || !node.isBlock || !NodeSelection.isSelectable(node))
58
63
  return false;
59
64
  const at = side < 0 ? $cut.pos - node.nodeSize : $cut.pos;
60
- dispatch?.(state.tr.setSelection(NodeSelection.create(state.doc, at)).scrollIntoView());
65
+ const table = view && tableAt(view, at);
66
+ if (!table) {
67
+ dispatch?.(state.tr.setSelection(NodeSelection.create(state.doc, at)).scrollIntoView());
68
+ return true;
69
+ }
70
+ if (!dispatch)
71
+ return true;
72
+ const index = $head.index(-1);
73
+ if (side < 0 && $head.parent.content.size === 0 && $head.node(-1).canReplace(index, index + 1))
74
+ dispatch(state.tr.delete($head.before(), $head.after()));
75
+ table.enter(side);
61
76
  return true;
62
77
  };
63
78
  }
@@ -72,7 +87,7 @@ export function atomKeymap(schema) {
72
87
  if (!schema.nodes.island_block)
73
88
  return {};
74
89
  return {
75
- Backspace: selectAtom(-1),
76
- Delete: selectAtom(1)
90
+ Backspace: againstAtom(-1),
91
+ Delete: againstAtom(1)
77
92
  };
78
93
  }
@@ -3,11 +3,11 @@ import type { Command, EditorState, Transaction } from 'prosemirror-state';
3
3
  /** `# `: the heading `level` deep. `list_item` is `block+`, so one inside an item is a
4
4
  * shape the content holds and `importMarkdown` produces from `- # title`. */
5
5
  export declare function toHeading(level: number): Command;
6
- /** A fence. The exit paragraph after it is the one a block island mints for the same
7
- * reason, sharpened: a code block is the one block a gap cursor will not sit beside
8
- * either, so a fence at the end of a body leaves the caret nowhere to go. The caret
9
- * stays in the fence, not in the exit it minted. */
10
- export declare function toCodeBlock(): Command;
6
+ /** A fence, carrying `attrs` (its `lang`). The exit paragraph after it is the one a
7
+ * block island mints for the same reason, sharpened: a code block is the one block a
8
+ * gap cursor will not sit beside either, so a fence at the end of a body leaves the
9
+ * caret nowhere to go. The caret stays in the fence, not in the exit it minted. */
10
+ export declare function toCodeBlock(attrs?: Attrs): Command;
11
11
  /** `---`. A divider is a whole block, so this replaces its textblock rather than
12
12
  * retyping one: `horizontal_rule` holds no content to retype into, and an empty block
13
13
  * is the whole of what it may replace — anything else in one is a paragraph being
@@ -80,17 +80,17 @@ function wrapAt(tr, pos, type, attrs, joinBefore) {
80
80
  export function toHeading(level) {
81
81
  return retype('heading', { level });
82
82
  }
83
- /** A fence. The exit paragraph after it is the one a block island mints for the same
84
- * reason, sharpened: a code block is the one block a gap cursor will not sit beside
85
- * either, so a fence at the end of a body leaves the caret nowhere to go. The caret
86
- * stays in the fence, not in the exit it minted. */
87
- export function toCodeBlock() {
83
+ /** A fence, carrying `attrs` (its `lang`). The exit paragraph after it is the one a
84
+ * block island mints for the same reason, sharpened: a code block is the one block a
85
+ * gap cursor will not sit beside either, so a fence at the end of a body leaves the
86
+ * caret nowhere to go. The caret stays in the fence, not in the exit it minted. */
87
+ export function toCodeBlock(attrs) {
88
88
  return (state, dispatch) => {
89
89
  const { code_block: code, paragraph } = state.schema.nodes;
90
90
  const { $from } = state.selection;
91
91
  if (!code || !paragraph || !atHead(state) || !fits($from, code))
92
92
  return false;
93
- const tr = state.tr.setBlockType($from.pos, $from.pos, code);
93
+ const tr = state.tr.setBlockType($from.pos, $from.pos, code, attrs);
94
94
  const at = tr.doc.resolve($from.pos).after();
95
95
  if (!tr.doc.nodeAt(at))
96
96
  tr.insert(at, paragraph.create());
@@ -322,6 +322,22 @@ const takeEmptyLine = (state, dispatch) => {
322
322
  dispatch?.(state.tr.delete($from.before(), $from.after()).scrollIntoView());
323
323
  return true;
324
324
  };
325
+ /**
326
+ * Backspace at a heading's head → a paragraph, where it stands, and the press after is
327
+ * the join. The first press takes off the block's own shape, which is the whole of what
328
+ * it edits: the base keymap's join would merge the heading's text into the block above
329
+ * and lose the heading with it, and at the body's start it does nothing at all.
330
+ */
331
+ const unheading = (state, dispatch) => {
332
+ const paragraph = state.schema.nodes.paragraph;
333
+ const { $from } = state.selection;
334
+ if (!paragraph || !atHead(state) || $from.parent.type.name !== 'heading')
335
+ return false;
336
+ if (!fits($from, paragraph))
337
+ return false;
338
+ dispatch?.(state.tr.setBlockType($from.pos, $from.pos, paragraph).scrollIntoView());
339
+ return true;
340
+ };
325
341
  /** The body-wide link of the leaf's key chains: `{}` for a schema with no paragraph
326
342
  * to mint. Outermost of the structural links, being about the body, or about the
327
343
  * caret's own line, rather than about the surface either stands in. */
@@ -330,6 +346,7 @@ export function blockKeymap(schema) {
330
346
  return {};
331
347
  return {
332
348
  Enter: chainCommands(paragraphAtBodyStart, paragraphFromEmptyFence, splitAcrossBlocks),
349
+ Backspace: unheading,
333
350
  Delete: takeEmptyLine
334
351
  };
335
352
  }
@@ -9,8 +9,8 @@ import { type Command } from 'prosemirror-state';
9
9
  * otherwise splits the item in two, each half holding a code block. `Shift-Enter` is
10
10
  * the same command because it is the same edit: the line break the key means
11
11
  * everywhere else (`breaks.ts`) is, inside a fence, the newline Enter already takes.
12
- * Every command here declines away from a code block — inside one for the four keys
13
- * about its text, against one for the two about its edge — so the list links keep the
14
- * keys elsewhere.
12
+ * Every command here declines away from a code block — inside one for the keys about
13
+ * its text and its last line, against one for the two about its edge — so the list
14
+ * links keep the keys elsewhere.
15
15
  */
16
16
  export declare function codeKeymap(schema: Schema): Record<string, Command>;
@@ -1,4 +1,4 @@
1
- import { TextSelection } from 'prosemirror-state';
1
+ import { Selection, TextSelection } from 'prosemirror-state';
2
2
  import { newlineInCode } from 'prosemirror-commands';
3
3
  /** One indent level. */
4
4
  const UNIT = ' ';
@@ -107,7 +107,7 @@ const outdentInCode = (state, dispatch) => {
107
107
  * The textblock the caret's own block stands directly against on `side`: its sibling
108
108
  * under one parent, which is the only neighbour a press joins the text of. `null` at a
109
109
  * container's edge, where the base keymap moves the whole block in or out rather than
110
- * joining anything, and so retypes nothing.
110
+ * joining anything, and so changes nothing here.
111
111
  */
112
112
  function blockAgainst(state, side) {
113
113
  const { $from, empty } = state.selection;
@@ -122,22 +122,22 @@ function blockAgainst(state, side) {
122
122
  return { node, pos: side < 0 ? $edge.pos - node.nodeSize : $edge.pos };
123
123
  }
124
124
  /**
125
- * Backspace at a fence's head or Delete at its end, and the same two presses from the
126
- * block on the other side: retype the code block to a paragraph, and join nothing. The
127
- * press after that is an ordinary join between two prose blocks.
125
+ * Backspace or Delete at a fence's edge, from either side, edits only the block the caret
126
+ * is in.
128
127
  *
129
- * The join itself retypes one side's whole text as the other's kind on one keystroke —
130
- * a fence's lines as prose, or a typed line as code — which is the destruction
131
- * `atoms.ts` refuses for a neighbouring island, with nothing drawn first to say what is
132
- * about to go. This is `toCodeBlock`'s inverse in its place (`blocks.ts`), and it takes
133
- * no text with it: `linebreakReplacement` carries the fence's newlines across as breaks
134
- * (`schema.ts`). The caret stays in the block it was in.
128
+ * Inside the fence, a press at its head or its end retypes it to a paragraph and joins
129
+ * nothing: `toCodeBlock`'s inverse in its place (`blocks.ts`), taking no text with it,
130
+ * since `linebreakReplacement` carries the fence's newlines across as breaks
131
+ * (`schema.ts`). The press after that is an ordinary join between two prose blocks.
132
+ *
133
+ * From the prose block against it, the caret steps into the fence, to the end it faces,
134
+ * and the fence is left as it is: a join would retype one side's whole text as the
135
+ * other's kind, and a retype from outside edits a block the caret is not in. An empty
136
+ * line under a fence goes with the step, its Backspace having nothing of its own to edit.
135
137
  *
136
138
  * Declines where both sides are code and where neither is: those joins retype nothing.
137
- * An empty block on either side never reaches this at all: the block link takes the
138
- * empty line ahead of the chain (`blocks.ts`), and there is no text to retype.
139
139
  */
140
- function retypeAtProseEdge(side) {
140
+ function atProseEdge(side) {
141
141
  return (state, dispatch) => {
142
142
  const paragraph = state.schema.nodes.paragraph;
143
143
  const edge = blockAgainst(state, side);
@@ -147,11 +147,40 @@ function retypeAtProseEdge(side) {
147
147
  const inside = !!$from.parent.type.spec.code;
148
148
  if (inside === !!edge.node.type.spec.code)
149
149
  return false;
150
- const at = inside ? $from.pos : edge.pos + 1;
151
- dispatch?.(state.tr.setBlockType(at, at, paragraph).scrollIntoView());
150
+ if (inside) {
151
+ dispatch?.(state.tr.setBlockType($from.pos, $from.pos, paragraph).scrollIntoView());
152
+ return true;
153
+ }
154
+ const tr = state.tr;
155
+ const index = $from.index(-1);
156
+ if (side < 0 && $from.parent.content.size === 0 && $from.node(-1).canReplace(index, index + 1))
157
+ tr.delete($from.before(), $from.after());
158
+ const into = side < 0 ? edge.pos + edge.node.nodeSize - 1 : edge.pos + 1;
159
+ dispatch?.(tr.setSelection(TextSelection.create(tr.doc, into)).scrollIntoView());
152
160
  return true;
153
161
  };
154
162
  }
163
+ /** ↓ on the last line of a fence nothing follows → a paragraph after it, the caret
164
+ * there: no block stands below to arrow to, and a gap cursor will not sit beside a
165
+ * fence. */
166
+ const belowFinalFence = (state, dispatch) => {
167
+ const paragraph = state.schema.nodes.paragraph;
168
+ const { $from, empty } = state.selection;
169
+ if (!paragraph || !empty || !$from.parent.type.spec.code)
170
+ return false;
171
+ if ($from.parent.textBetween($from.parentOffset, $from.parent.content.size).includes('\n'))
172
+ return false;
173
+ const $after = state.doc.resolve($from.after());
174
+ if (Selection.findFrom($after, 1))
175
+ return false;
176
+ if (!$after.parent.canReplaceWith($after.index(), $after.index(), paragraph))
177
+ return false;
178
+ if (dispatch) {
179
+ const tr = state.tr.insert($after.pos, paragraph.create());
180
+ dispatch(tr.setSelection(TextSelection.create(tr.doc, $after.pos + 1)).scrollIntoView());
181
+ }
182
+ return true;
183
+ };
155
184
  /**
156
185
  * The `code_block` link of the body's key chains: `{}` for the inline/plaintext
157
186
  * schemas, which declare no code node.
@@ -161,9 +190,9 @@ function retypeAtProseEdge(side) {
161
190
  * otherwise splits the item in two, each half holding a code block. `Shift-Enter` is
162
191
  * the same command because it is the same edit: the line break the key means
163
192
  * everywhere else (`breaks.ts`) is, inside a fence, the newline Enter already takes.
164
- * Every command here declines away from a code block — inside one for the four keys
165
- * about its text, against one for the two about its edge — so the list links keep the
166
- * keys elsewhere.
193
+ * Every command here declines away from a code block — inside one for the keys about
194
+ * its text and its last line, against one for the two about its edge — so the list
195
+ * links keep the keys elsewhere.
167
196
  */
168
197
  export function codeKeymap(schema) {
169
198
  if (!schema.nodes.code_block)
@@ -173,7 +202,8 @@ export function codeKeymap(schema) {
173
202
  'Shift-Tab': outdentInCode,
174
203
  Enter: newlineInCode,
175
204
  'Shift-Enter': newlineInCode,
176
- Backspace: retypeAtProseEdge(-1),
177
- Delete: retypeAtProseEdge(1)
205
+ Backspace: atProseEdge(-1),
206
+ Delete: atProseEdge(1),
207
+ ArrowDown: belowFinalFence
178
208
  };
179
209
  }
@@ -193,15 +193,17 @@ export declare function createField(opts: CreateFieldOpts): FieldController;
193
193
  * never fork the keymap/plugin ordering. History first, then any leaf-specific
194
194
  * plugins (`afterHistory`: the addressed leaf passes its anchor-position plugin;
195
195
  * 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`).
196
+ * the rest of the stack leaves, then the markdown-shorthand input rules and the
197
+ * Backspace that takes one back, the field keymap over the base keymap, the URL paste
198
+ * that links a selection (`link-paste.ts`), and last the ones that answer to a block
199
+ * 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`).
200
202
  *
201
203
  * 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.
204
+ * schema the shorthand rules build nothing (each is guarded on its mark type), the
205
+ * toggles bind no key and a pasted URL stays text, so a `**bold**` keeps the delimiters
206
+ * its author typed without a second rule saying so.
205
207
  */
206
208
  export declare function proseLeafPlugins(schema: Schema, opts: {
207
209
  inline: boolean;
@@ -27,10 +27,11 @@ 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';
35
36
  import { tablePastePlugin } from './table-paste.js';
36
37
  import { DEFAULT_TABLE_STRINGS, tableNodeView } from './table-view.js';
@@ -504,24 +505,28 @@ export function createField(opts) {
504
505
  * never fork the keymap/plugin ordering. History first, then any leaf-specific
505
506
  * plugins (`afterHistory`: the addressed leaf passes its anchor-position plugin;
506
507
  * 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`).
508
+ * the rest of the stack leaves, then the markdown-shorthand input rules and the
509
+ * Backspace that takes one back, the field keymap over the base keymap, the URL paste
510
+ * that links a selection (`link-paste.ts`), and last the ones that answer to a block
511
+ * 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`).
511
514
  *
512
515
  * 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.
516
+ * schema the shorthand rules build nothing (each is guarded on its mark type), the
517
+ * toggles bind no key and a pasted URL stays text, so a `**bold**` keeps the delimiters
518
+ * its author typed without a second rule saying so.
516
519
  */
517
520
  export function proseLeafPlugins(schema, opts) {
518
521
  const list = [history(), ...(opts.afterHistory ?? []), linebreakPlugin(schema)];
519
522
  if (opts.slash)
520
523
  list.push(slashPlugin(opts.slash, opts.slashHint));
521
524
  if (!opts.noInputRules)
522
- list.push(inputRulesPlugin(schema));
525
+ list.push(inputRulesPlugin(schema), undoRuleKeymap());
523
526
  list.push(keymap(editorKeymap(schema, opts.inline, !!opts.slash)));
524
527
  list.push(keymap(baseKeymap));
528
+ if (schema.marks.link)
529
+ list.push(linkPastePlugin(schema.marks.link));
525
530
  // Each answers to something only the block schema holds: any other leaf is
526
531
  // textblocks alone, with no island and no gap to put a cursor in.
527
532
  if (isBlockSchema(schema))
@@ -553,8 +558,10 @@ function plainClipboardPlugin() {
553
558
  * A printable key over a selected atom writes past it. A selection is the subject
554
559
  * of the next command, never a thing armed for
555
560
  * 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.
561
+ * it. A block island takes the empty paragraph after it, or a new one where there is
562
+ * none; an inline one the caret after the image in the same line: one rule, both node
563
+ * types. Only a paragraph is reused, since an empty heading or fence would set the
564
+ * text in a shape the key never asked for.
558
565
  *
559
566
  * PM routes text input over a non-`TextSelection` through `handleTextInput` before
560
567
  * falling back to replacing the selection, so this one prop is the whole of it.
@@ -570,9 +577,13 @@ function pastAtomPlugin() {
570
577
  return false;
571
578
  const tr = view.state.tr;
572
579
  const at = selection.to; // just past the node, in both coordinate senses
580
+ const after = tr.doc.resolve(at).nodeAfter;
573
581
  if (selection.node.isInline) {
574
582
  tr.setSelection(TextSelection.create(tr.doc, at)).insertText(text);
575
583
  }
584
+ else if (after?.type === schema.nodes.paragraph && after.content.size === 0) {
585
+ tr.setSelection(TextSelection.create(tr.doc, at + 1)).insertText(text);
586
+ }
576
587
  else {
577
588
  const para = schema.nodes.paragraph.create(null, schema.text(text));
578
589
  tr.insert(at, para);
@@ -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,9 +48,10 @@ 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) {
@@ -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
+ }
@@ -16,8 +16,8 @@ import type { Command } from 'prosemirror-state';
16
16
  * `splitListItem`, then `liftListItem`. The middle link carries two behaviors of
17
17
  * its own: it splits a non-empty item, and on an empty item it either splits the
18
18
  * 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
19
+ * so the third link lifts a top-level empty item out to a paragraph. `Backspace` lifts
20
+ * an item at its start, and joins a block onto the list it stands after. `Delete` is
21
21
  * that same chain at the seam below rather than a second table of cases beside it, so
22
22
  * the two keys cannot come to differ over a seam this link reaches. An empty line is
23
23
  * not one: the block link takes it ahead of this chain (`blocks.ts`), the caret's own