@quillmark/svelte 0.24.0 → 0.25.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 (55) hide show
  1. package/THEMING.md +1 -1
  2. package/dist/core/codec/blocks.d.ts +23 -6
  3. package/dist/core/codec/blocks.js +163 -38
  4. package/dist/core/codec/code.js +3 -8
  5. package/dist/core/codec/field.d.ts +4 -2
  6. package/dist/core/codec/field.js +12 -4
  7. package/dist/core/codec/index.d.ts +2 -0
  8. package/dist/core/codec/index.js +4 -0
  9. package/dist/core/codec/inputrules.js +40 -10
  10. package/dist/core/codec/keymap.js +19 -1
  11. package/dist/core/codec/line-paste.d.ts +7 -0
  12. package/dist/core/codec/line-paste.js +36 -0
  13. package/dist/core/codec/list-move.d.ts +6 -0
  14. package/dist/core/codec/list-move.js +30 -0
  15. package/dist/core/codec/list-paste.d.ts +2 -0
  16. package/dist/core/codec/list-paste.js +142 -0
  17. package/dist/core/codec/list-toggle.d.ts +4 -0
  18. package/dist/core/codec/list-toggle.js +170 -0
  19. package/dist/core/codec/lists.d.ts +22 -15
  20. package/dist/core/codec/lists.js +404 -97
  21. package/dist/core/codec/markdown-paste.d.ts +9 -0
  22. package/dist/core/codec/markdown-paste.js +63 -2
  23. package/dist/core/codec/nesting.d.ts +5 -1
  24. package/dist/core/codec/nesting.js +1 -1
  25. package/dist/core/codec/prose.css +21 -20
  26. package/dist/core/codec/slash.d.ts +2 -1
  27. package/dist/core/codec/slash.js +0 -0
  28. package/dist/core/codec/touching.d.ts +12 -0
  29. package/dist/core/codec/touching.js +33 -0
  30. package/dist/core/codec/undo.d.ts +5 -0
  31. package/dist/core/codec/undo.js +21 -0
  32. package/dist/preview/Preview.svelte +88 -13
  33. package/dist/preview/Preview.svelte.d.ts +2 -2
  34. package/dist/preview/bridge.js +8 -2
  35. package/dist/preview/controller.d.ts +8 -4
  36. package/dist/preview/controller.js +10 -2
  37. package/dist/preview/index.js +1 -1
  38. package/dist/preview/paint.d.ts +6 -2
  39. package/dist/preview/paint.js +67 -56
  40. package/dist/preview/strings.d.ts +2 -0
  41. package/dist/preview/strings.js +4 -2
  42. package/dist/preview/viewport.d.ts +10 -0
  43. package/dist/preview/viewport.js +271 -0
  44. package/dist/preview/zoom.d.ts +41 -0
  45. package/dist/preview/zoom.js +52 -0
  46. package/dist/visual/ArrayField.svelte +24 -4
  47. package/dist/visual/DateField.svelte +26 -13
  48. package/dist/visual/FormatPopover.svelte +41 -2
  49. package/dist/visual/ProseValue.svelte +35 -3
  50. package/dist/visual/ProseValue.svelte.d.ts +7 -0
  51. package/dist/visual/icons/nodes.d.ts +26 -0
  52. package/dist/visual/icons/nodes.js +16 -0
  53. package/dist/visual/strings.d.ts +4 -2
  54. package/dist/visual/strings.js +9 -3
  55. package/package.json +1 -1
package/THEMING.md CHANGED
@@ -175,7 +175,7 @@ An app that mounts an editor beside a preview over one session needs a screen to
175
175
  | `.qm-switch` | The band that says which track is showing, and nothing at the widths where both are. Its controls read as even halves and stand at the touch floor (`--qmh-tap`, 44px); depth past that is yours. |
176
176
  | `.qm-frame` | The mounting site: an edge, a corner, and the clip that holds a surface to them. What is inside it is the surface's own. |
177
177
 
178
- The narrow shape is one mount at a time, not two of them stacked: half a narrow viewport is under the width either surface reads at. The track you are not showing is `display: none`, so it leaves the tab order, hit-testing and the accessibility tree together: you need no `inert` on it, and no breakpoint in your JS, since the only state you hold is which of the two you last asked for. Keep both mounted: the hidden pane keeps its pixels, its caret and its scroll, and comes back to them — it rasters nothing at a box it has none of, and repaints only where it returns to a box of another width. A preview scroll asked for while it was hidden is held and run on the way back, so it opens at the caret rather than several edits behind it. A click that lands in the hidden pane is the one thing the shape adds to your wiring, so send the caret and reveal the track together, or the hit goes somewhere the reader cannot see.
178
+ The narrow shape is one mount at a time, not two of them stacked: half a narrow viewport is under the width either surface reads at. The track you are not showing is `display: none`, so it leaves the tab order, hit-testing and the accessibility tree together: you need no `inert` on it, and no breakpoint in your JS, since the only state you hold is which of the two you last asked for. Keep both mounted: the hidden pane keeps its pixels, its caret and its scroll, and comes back to them — it rasters nothing at a box it has none of, and repaints only where it returns to a box that draws its pages at another scale. A preview scroll asked for while it was hidden is held and run on the way back, so it opens at the caret rather than several edits behind it. A click that lands in the hidden pane is the one thing the shape adds to your wiring, so send the caret and reveal the track together, or the hit goes somewhere the reader cannot see.
179
179
 
180
180
  The shape is shared; the look of a band is not. Your wordmark, your nav and the depth of your head are the most app-specific things on the screen, and a preset that drew them would hand every tool built on this the same face, so they read `--qmh-*` like everything else and you write the rules. `.qm-split`'s gap is a default rather than a fact about splitting: close it to `0` and carry a hairline on one pane if you want the two mounts to meet instead of stand apart.
181
181
 
@@ -1,4 +1,4 @@
1
- import type { Attrs, Node as PMNode, Schema } from 'prosemirror-model';
1
+ import type { Attrs, Schema } from 'prosemirror-model';
2
2
  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`. */
@@ -17,19 +17,36 @@ export declare function insertDivider(): Command;
17
17
  * content holds, where a list wrapping one mints an item whose only content is an
18
18
  * item. */
19
19
  export declare function wrapInQuote(): Command;
20
- /** `- ` / `1. `. `joinBefore` is the shorthand's ordinal test: `2. ` continues the list
21
- * above where `7. ` opens one. */
22
- export declare function wrapInList(ordered: boolean, attrs?: Attrs | null, joinBefore?: (node: PMNode) => boolean): Command;
20
+ /** `- ` / `1. `. Beside a list of its kind the line joins it, whatever `N` is typed: a
21
+ * number comes from position. Elsewhere `N. ` is the number the new list counts from,
22
+ * and at the head of an item the shorthand is the item's (§`listAtItem`), `N` reading
23
+ * the same way; a marker the item already shows is taken as typed and changes nothing. */
24
+ export declare function wrapInList(ordered: boolean, attrs?: Attrs | null): Command;
25
+ /**
26
+ * `a. `, `(1) `, `(a) `: the markers a nested level draws, typed at the head of a nested
27
+ * item's first line, where the item already is the thing typed. Taken as typed and
28
+ * changing nothing, as `N. ` on an item of a numbered list is: the marker comes from the
29
+ * item's position and the quill's style for its depth, never from the text. Anywhere
30
+ * else these stay text, being no markdown list marker.
31
+ */
32
+ export declare function takeNestedMarker(): Command;
23
33
  /**
24
34
  * A list pick, at whatever caret the writer has. `atHead` is the shorthand's guard,
25
35
  * where a prefix firing mid-sentence would eat a dash; a pick is a gesture at the caret.
26
36
  *
27
37
  * At the end of a non-empty block it opens a fresh empty item below it, Enter then `- `
28
38
  * as one gesture. Anywhere else it wraps the whole block, the smaller surprise: a writer
29
- * who meant to split presses Enter first. It passes no `joinBefore`, so it continues
30
- * whatever list is above.
39
+ * who meant to split presses Enter first. In an item's first block it is the shorthand's
40
+ * item gesture (§`listAtItem`), offered only where that changes something.
31
41
  */
32
42
  export declare function pickList(ordered: boolean): Command;
43
+ /**
44
+ * The bullet pick where it puts a line under an item one level deeper than the caret
45
+ * stood, and nowhere else: the pick a subparagraph names. Outside every item the bullet
46
+ * it would mint stands at the first level, and on a list's first item the bullet pick
47
+ * retypes the list instead.
48
+ */
49
+ export declare function pickSubList(): Command;
33
50
  /**
34
51
  * Run `command` in place of the text at `[from, to)`: one transaction carrying the
35
52
  * delete and the command's own steps. One rather than two because two would be two
@@ -1,8 +1,10 @@
1
1
  import { Selection } from 'prosemirror-state';
2
2
  import { chainCommands, splitBlock } from 'prosemirror-commands';
3
+ import { sinkListItem } from 'prosemirror-schema-list';
3
4
  import { canJoin, findWrapping } from 'prosemirror-transform';
4
5
  import { withinNesting } from './nesting.js';
5
6
  import { takesLineBreak } from './schema.js';
7
+ import { joinLists } from './touching.js';
6
8
  /** The head of a textblock: where a shorthand fires, and where a pick lands once its
7
9
  * run is consumed. */
8
10
  function atHead(state) {
@@ -15,12 +17,9 @@ function fits($from, type) {
15
17
  return $from.node(-1).canReplaceWith($from.index(-1), $from.indexAfter(-1), type);
16
18
  }
17
19
  /**
18
- * The head of an item that already exists: where a list shorthand is the text an author
19
- * typed rather than a shorthand at all.
20
- *
21
- * Firing there wraps the item's own paragraph in a fresh list, minting an item whose
22
- * only content is another item — a level with nothing on it. Tab is the indent gesture
23
- * and lands the shape a nesting is supposed to have, under the previous sibling. A
20
+ * The first block of an item that already exists: where a list gesture retypes the
21
+ * item's list rather than wrapping the block, which would mint an item whose only
22
+ * content is another item, a level with nothing on it. Tab is the indent gesture. A
24
23
  * later block of the item is not this case: wrapping a continuation paragraph is how a
25
24
  * sub-list opens under text.
26
25
  */
@@ -58,23 +57,23 @@ function spaceBreaks(tr, $from, type) {
58
57
  at += child.nodeSize;
59
58
  });
60
59
  }
61
- /** `wrappingInputRule`'s body: wrap the block range at `pos`, then join a preceding
62
- * sibling of the same type — the one boundary the wrap itself opens (`lists.ts`
63
- * §cleanup: command-local, never a global pass). False where no wrap fits, or where
64
- * the one that does nests past `MAX_NESTING_DEPTH` (`nesting.ts`). */
65
- function wrapAt(tr, pos, type, attrs, joinBefore) {
60
+ /** `wrappingInputRule`'s body: wrap the block range at `pos`, then join a sibling of
61
+ * the same type — the boundaries the wrap itself opens (`lists.ts` §cleanup:
62
+ * command-local, never a global pass). A list joins on both sides (`touching.ts`), a
63
+ * quote the one above. False where no wrap fits, or where the one that does nests past
64
+ * `MAX_NESTING_DEPTH` (`nesting.ts`). */
65
+ function wrapAt(tr, pos, type, attrs) {
66
66
  const range = tr.doc.resolve(pos).blockRange();
67
67
  const wrapping = range && findWrapping(range, type, attrs);
68
68
  if (!range || !wrapping)
69
69
  return false;
70
70
  tr.wrap(range, wrapping);
71
- const before = tr.doc.resolve(pos - 1).nodeBefore;
72
- if (before &&
73
- before.type === type &&
74
- canJoin(tr.doc, pos - 1) &&
75
- (!joinBefore || joinBefore(before))) {
76
- tr.join(pos - 1);
77
- }
71
+ const wrapped = tr.doc.nodeAt(range.start);
72
+ if (wrapped)
73
+ joinLists(tr, range.start + wrapped.nodeSize);
74
+ const before = tr.doc.resolve(range.start).nodeBefore;
75
+ if (before && before.type === type && canJoin(tr.doc, range.start))
76
+ tr.join(range.start);
78
77
  return withinNesting(tr.doc);
79
78
  }
80
79
  /** `# `: the heading `level` deep. `list_item` is `block+`, so one inside an item is a
@@ -138,10 +137,96 @@ export function wrapInQuote() {
138
137
  return true;
139
138
  };
140
139
  }
141
- /** `- ` / `1. `. `joinBefore` is the shorthand's ordinal test: `2. ` continues the list
142
- * above where `7. ` opens one. */
143
- export function wrapInList(ordered, attrs = null, joinBefore) {
144
- return (state, dispatch) => atHead(state) && wrapBlock(state, dispatch, ordered, attrs, joinBefore);
140
+ /** `- ` / `1. `. Beside a list of its kind the line joins it, whatever `N` is typed: a
141
+ * number comes from position. Elsewhere `N. ` is the number the new list counts from,
142
+ * and at the head of an item the shorthand is the item's (§`listAtItem`), `N` reading
143
+ * the same way; a marker the item already shows is taken as typed and changes nothing. */
144
+ export function wrapInList(ordered, attrs = null) {
145
+ return (state, dispatch) => {
146
+ if (!atHead(state))
147
+ return false;
148
+ const { $from } = state.selection;
149
+ if (openingAnItem($from, state.schema.nodes.list_item))
150
+ return listAtItem(ordered, attrs?.start ?? null, true)(state, dispatch);
151
+ return wrapBlock(state, dispatch, ordered, attrs);
152
+ };
153
+ }
154
+ /**
155
+ * `a. `, `(1) `, `(a) `: the markers a nested level draws, typed at the head of a nested
156
+ * item's first line, where the item already is the thing typed. Taken as typed and
157
+ * changing nothing, as `N. ` on an item of a numbered list is: the marker comes from the
158
+ * item's position and the quill's style for its depth, never from the text. Anywhere
159
+ * else these stay text, being no markdown list marker.
160
+ */
161
+ export function takeNestedMarker() {
162
+ return (state, dispatch) => {
163
+ const item = state.schema.nodes.list_item;
164
+ const { $from } = state.selection;
165
+ if (!atHead(state) || !openingAnItem($from, item) || $from.depth < 3)
166
+ return false;
167
+ if ($from.node(-3).type !== item)
168
+ return false;
169
+ dispatch?.(state.tr);
170
+ return true;
171
+ };
172
+ }
173
+ /**
174
+ * A list gesture in an item's first block. On a list's first item it retypes the list,
175
+ * every item with it: a list is one kind, and that item is where a writer changes it.
176
+ * `number` is the shorthand's `N`, the list's `start`, the one door back to 1 for a list
177
+ * that arrived counting from elsewhere. On a later item, a list of the other kind is a
178
+ * sub-list: the item sinks under the one above, as Tab sinks it, into a list of that
179
+ * kind — `- ` in a new numbered paragraph is its first lettered subparagraph, never the
180
+ * whole memo turned to bullets. A sub-list already standing there of that kind takes the
181
+ * item as its next.
182
+ *
183
+ * A gesture that changes nothing — the kind the item's list already is, and on a first
184
+ * item the number it already counts from — goes unoffered as a pick. As a shorthand
185
+ * (`take`) it is claimed and changes nothing, so the run is consumed: a writer typing
186
+ * `2. Second` into the second item meant the number the item already shows, and an item
187
+ * reading `2. 2. Second` is the one outcome no writer typed for. Backspace on the press
188
+ * after gives the text back.
189
+ */
190
+ function listAtItem(ordered, number, take = false) {
191
+ return (state, dispatch) => {
192
+ const { $from } = state.selection;
193
+ const type = state.schema.nodes[ordered ? 'ordered_list' : 'bullet_list'];
194
+ const item = state.schema.nodes.list_item;
195
+ if (!type || !item || $from.depth < 2)
196
+ return false;
197
+ const list = $from.node(-2);
198
+ const unchanged = () => {
199
+ if (take)
200
+ dispatch?.(state.tr);
201
+ return take;
202
+ };
203
+ if ($from.index(-2) === 0) {
204
+ const start = number ?? (list.type === type ? list.attrs.start : 1);
205
+ if (list.type === type && (!ordered || start === list.attrs.start))
206
+ return unchanged();
207
+ dispatch?.(state.tr.setNodeMarkup($from.before(-2), type, ordered ? { start } : null));
208
+ return true;
209
+ }
210
+ if (list.type === type)
211
+ return unchanged();
212
+ let sunk;
213
+ if (!sinkListItem(item)(state, (tr) => (sunk = tr)) || !sunk)
214
+ return false;
215
+ const tr = sunk;
216
+ const $at = tr.doc.resolve(tr.mapping.map($from.pos));
217
+ const nested = $at.node(-2);
218
+ if (nested.childCount === 1 && nested.type !== type) {
219
+ const at = $at.before(-2);
220
+ tr.setNodeMarkup(at, type, ordered ? { start: number ?? 1 } : null);
221
+ const before = tr.doc.resolve(at).nodeBefore;
222
+ if (before?.type === type && canJoin(tr.doc, at))
223
+ tr.join(at);
224
+ }
225
+ if (!withinNesting(tr.doc))
226
+ return false;
227
+ dispatch?.(tr.scrollIntoView());
228
+ return true;
229
+ };
145
230
  }
146
231
  /**
147
232
  * A list pick, at whatever caret the writer has. `atHead` is the shorthand's guard,
@@ -149,8 +234,8 @@ export function wrapInList(ordered, attrs = null, joinBefore) {
149
234
  *
150
235
  * At the end of a non-empty block it opens a fresh empty item below it, Enter then `- `
151
236
  * as one gesture. Anywhere else it wraps the whole block, the smaller surprise: a writer
152
- * who meant to split presses Enter first. It passes no `joinBefore`, so it continues
153
- * whatever list is above.
237
+ * who meant to split presses Enter first. In an item's first block it is the shorthand's
238
+ * item gesture (§`listAtItem`), offered only where that changes something.
154
239
  */
155
240
  export function pickList(ordered) {
156
241
  return (state, dispatch) => {
@@ -172,14 +257,47 @@ export function pickList(ordered) {
172
257
  return true;
173
258
  };
174
259
  }
260
+ /** How many items the caret stands in. */
261
+ function itemDepth($pos, item) {
262
+ let n = 0;
263
+ for (let depth = $pos.depth; depth > 0; depth--)
264
+ if ($pos.node(depth).type === item)
265
+ n++;
266
+ return n;
267
+ }
268
+ /**
269
+ * The bullet pick where it puts a line under an item one level deeper than the caret
270
+ * stood, and nowhere else: the pick a subparagraph names. Outside every item the bullet
271
+ * it would mint stands at the first level, and on a list's first item the bullet pick
272
+ * retypes the list instead.
273
+ */
274
+ export function pickSubList() {
275
+ const pick = pickList(false);
276
+ return (state, dispatch) => {
277
+ const item = state.schema.nodes.list_item;
278
+ const depth = item ? itemDepth(state.selection.$from, item) : 0;
279
+ if (!depth)
280
+ return false;
281
+ let picked;
282
+ if (!pick(state, (tr) => (picked = tr)) || !picked)
283
+ return false;
284
+ if (itemDepth(picked.selection.$from, item) <= depth)
285
+ return false;
286
+ dispatch?.(picked);
287
+ return true;
288
+ };
289
+ }
175
290
  /** Wrap the caret's whole block in a list, wherever in the block the caret sits: the
176
- * wrap opens at the block's head, so it joins the list above as the shorthand's does. */
177
- function wrapBlock(state, dispatch, ordered, attrs, joinBefore) {
291
+ * wrap opens at the block's head, so it joins the lists beside it as the shorthand's
292
+ * does. */
293
+ function wrapBlock(state, dispatch, ordered, attrs) {
178
294
  const { paragraph, list_item: item } = state.schema.nodes;
179
295
  const list = state.schema.nodes[ordered ? 'ordered_list' : 'bullet_list'];
180
296
  const { $from } = state.selection;
181
- if (!list || !paragraph || openingAnItem($from, item))
297
+ if (!list || !paragraph)
182
298
  return false;
299
+ if (openingAnItem($from, item))
300
+ return listAtItem(ordered, null)(state, dispatch);
183
301
  const pos = $from.start();
184
302
  const tr = state.tr;
185
303
  // A paragraph item wherever this fires: `# ` inside the item is the gesture that
@@ -187,7 +305,7 @@ function wrapBlock(state, dispatch, ordered, attrs, joinBefore) {
187
305
  // that shape. Positions survive the retype — same content, same size.
188
306
  if ($from.parent.type !== paragraph)
189
307
  tr.setBlockType(pos, pos, paragraph);
190
- if (!wrapAt(tr, pos, list, attrs, joinBefore))
308
+ if (!wrapAt(tr, pos, list, attrs))
191
309
  return false;
192
310
  dispatch?.(tr);
193
311
  return true;
@@ -224,18 +342,18 @@ function atBodyStart($from) {
224
342
  * Enter at the very start of a body whose first block takes no caret above it → an
225
343
  * empty paragraph there, the caret staying with the text it pushed down.
226
344
  *
227
- * A code block, a quote and a list are the blocks `prosemirror-gapcursor` declines
228
- * to sit beside, and nothing else mints a block above one: three keystrokes on an
229
- * empty body (` ``` `, `> `, `- `) put one first, and a document carrying one there
230
- * can never be given a title. The argument is `paragraphAboveList`'s, about the
231
- * block rather than about lists, so this link comes first and that one holds the
232
- * position it alone reaches, a list under an island or a rule.
345
+ * A code block and a quote are blocks `prosemirror-gapcursor` declines to sit beside,
346
+ * and nothing else mints a block above one: two keystrokes on an empty body (` ``` `,
347
+ * `> `) put one first, and a document carrying one there could never be given a title.
233
348
  *
234
349
  * Declines wherever the first block is a plain textblock, whose Enter-split already
235
- * leaves a paragraph above, and on an empty one, which the links after it answer.
350
+ * leaves a paragraph above, and on an empty one, which the links after it answer. An
351
+ * item's first line of prose declines too: Enter at its head is the list's new first
352
+ * item, as at every item, and the empty item's own Enter lifts it out to the paragraph a
353
+ * title takes. A fence opening that item is the fence case and takes the paragraph.
236
354
  */
237
355
  const paragraphAtBodyStart = (state, dispatch) => {
238
- const paragraph = state.schema.nodes.paragraph;
356
+ const { paragraph, list_item: item } = state.schema.nodes;
239
357
  const { $from, empty } = state.selection;
240
358
  if (!paragraph || !empty || !atBodyStart($from))
241
359
  return false;
@@ -243,6 +361,8 @@ const paragraphAtBodyStart = (state, dispatch) => {
243
361
  return false;
244
362
  if ($from.depth === 1 && $from.parent.isTextblock && !$from.parent.type.spec.code)
245
363
  return false;
364
+ if (item && $from.depth >= 2 && $from.node(-1).type === item && !$from.parent.type.spec.code)
365
+ return false;
246
366
  dispatch?.(state.tr.insert(0, paragraph.create()).scrollIntoView());
247
367
  return true;
248
368
  };
@@ -310,7 +430,8 @@ const splitAcrossBlocks = (state, dispatch) => {
310
430
  * are the whole of what holds them, and the key is whichever link answers a construct
311
431
  * with nothing in it. An empty fence is not that case — the doc spares it, and it
312
432
  * carries no text to lose. Declines with nothing after it too: a line with no next
313
- * line is Backspace's.
433
+ * line is Backspace's. The line was the one thing between two lists of a kind, they
434
+ * are one list after it.
314
435
  */
315
436
  const takeEmptyLine = (state, dispatch) => {
316
437
  const { $from, empty } = state.selection;
@@ -321,7 +442,11 @@ const takeEmptyLine = (state, dispatch) => {
321
442
  return false;
322
443
  if (!Selection.findFrom(state.doc.resolve($from.after()), 1))
323
444
  return false;
324
- dispatch?.(state.tr.delete($from.before(), $from.after()).scrollIntoView());
445
+ if (dispatch) {
446
+ const tr = state.tr.delete($from.before(), $from.after());
447
+ joinLists(tr, $from.before());
448
+ dispatch(tr.scrollIntoView());
449
+ }
325
450
  return true;
326
451
  };
327
452
  /**
@@ -75,14 +75,9 @@ const indentInCode = (state, dispatch) => {
75
75
  /**
76
76
  * Shift-Tab in a code block: remove one indent level from every covered line.
77
77
  *
78
- * Declines when no covered line carries an indent, so the key is not swallowed: it
79
- * falls through to the list link and, outside a list, on out of the leaf — the
80
- * body's keyboard exit from inside a code block (VISUAL_EDITOR §Chrome). Inside
81
- * a `list_item > code_block` the list link claims it and lifts the item out
82
- * (`tests/codec/code-keys.test.ts`), so a fence in an item has no keyboard exit in
83
- * either direction — Tab is a code block's whole point and takes the key
84
- * unconditionally. What a body's keyboard exit should be settles in the shell keymap
85
- * (VISUAL_EDITOR §Settled and open), not here.
78
+ * Declines when no covered line carries an indent, so the key falls through to the
79
+ * list link: inside a `list_item > code_block` it lifts the item out
80
+ * (`tests/codec/code-keys.test.ts`), and outside a list the body holds it (`keymap.ts`).
86
81
  */
87
82
  const outdentInCode = (state, dispatch) => {
88
83
  const block = codeBlockAt(state);
@@ -200,7 +200,8 @@ export declare function createField(opts: CreateFieldOpts): FieldController;
200
200
  /**
201
201
  * The prose-leaf plugin stack (VISUAL_EDITOR §Surface): shared by
202
202
  * {@link createField} and the by-value editor (`ProseValue`), so the two
203
- * never fork the keymap/plugin ordering. History first, then any leaf-specific
203
+ * never fork the keymap/plugin ordering. History first, with the plugin that starts an
204
+ * undo step at each block-moving edit (`undo.ts`), then any leaf-specific
204
205
  * plugins (`afterHistory`: the addressed leaf passes its anchor-position plugin;
205
206
  * a by-value leaf passes none), then {@link linebreakPlugin}, which normalizes what
206
207
  * the rest of the stack leaves, then the markdown-shorthand input rules and the
@@ -208,7 +209,8 @@ export declare function createField(opts: CreateFieldOpts): FieldController;
208
209
  * that links a selection (`link-paste.ts`), and last the ones that answer to a block
209
210
  * leaf's own shapes: the gap cursor, {@link pastAtomPlugin}, the island paste pass
210
211
  * (`islands.ts`), the table paste (`table-paste.ts`), the markdown paste
211
- * (`markdown-paste.ts`) and the task item's checkbox (`tasks.ts`).
212
+ * (`markdown-paste.ts`) and the task item's checkbox (`tasks.ts`). A one-line leaf's
213
+ * paste joins its lines (`line-paste.ts`).
212
214
  *
213
215
  * Every mark-shaped plugin reads the schema rather than a flag: over either mark-free
214
216
  * schema the shorthand rules build nothing (each is guarded on its mark type), the
@@ -31,10 +31,13 @@ import { contentEqual, createReconciler } from './reconcile.js';
31
31
  import { inputRulesPlugin, undoRuleKeymap } from './inputrules.js';
32
32
  import { linebreakPlugin } from './breaks.js';
33
33
  import { bodyKeymap } from './keymap.js';
34
- import { blockSchema, isBlockSchema, leafSchema, plainSchema } from './schema.js';
34
+ import { blockSchema, isBlockSchema, isInlineSchema, leafSchema, plainSchema } from './schema.js';
35
35
  import { linkPastePlugin } from './link-paste.js';
36
+ import { linePastePlugin } from './line-paste.js';
37
+ import { listPastePlugin } from './list-paste.js';
36
38
  import { markdownPastePlugin } from './markdown-paste.js';
37
39
  import { nestingPlugin } from './nesting.js';
40
+ import { undoStepPlugin } from './undo.js';
38
41
  import { tablePastePlugin } from './table-paste.js';
39
42
  import { tableCopyPlugin } from './table-copy.js';
40
43
  import { taskPlugin } from './tasks.js';
@@ -533,7 +536,8 @@ export function createField(opts) {
533
536
  /**
534
537
  * The prose-leaf plugin stack (VISUAL_EDITOR §Surface): shared by
535
538
  * {@link createField} and the by-value editor (`ProseValue`), so the two
536
- * never fork the keymap/plugin ordering. History first, then any leaf-specific
539
+ * never fork the keymap/plugin ordering. History first, with the plugin that starts an
540
+ * undo step at each block-moving edit (`undo.ts`), then any leaf-specific
537
541
  * plugins (`afterHistory`: the addressed leaf passes its anchor-position plugin;
538
542
  * a by-value leaf passes none), then {@link linebreakPlugin}, which normalizes what
539
543
  * the rest of the stack leaves, then the markdown-shorthand input rules and the
@@ -541,7 +545,8 @@ export function createField(opts) {
541
545
  * that links a selection (`link-paste.ts`), and last the ones that answer to a block
542
546
  * leaf's own shapes: the gap cursor, {@link pastAtomPlugin}, the island paste pass
543
547
  * (`islands.ts`), the table paste (`table-paste.ts`), the markdown paste
544
- * (`markdown-paste.ts`) and the task item's checkbox (`tasks.ts`).
548
+ * (`markdown-paste.ts`) and the task item's checkbox (`tasks.ts`). A one-line leaf's
549
+ * paste joins its lines (`line-paste.ts`).
545
550
  *
546
551
  * Every mark-shaped plugin reads the schema rather than a flag: over either mark-free
547
552
  * schema the shorthand rules build nothing (each is guarded on its mark type), the
@@ -551,6 +556,7 @@ export function createField(opts) {
551
556
  export function proseLeafPlugins(schema, opts) {
552
557
  const list = [
553
558
  history(),
559
+ undoStepPlugin(),
554
560
  ...(opts.cap ? [capPlugin(opts.cap)] : []),
555
561
  ...(opts.afterHistory ?? []),
556
562
  linebreakPlugin(schema)
@@ -566,9 +572,11 @@ export function proseLeafPlugins(schema, opts) {
566
572
  // Each answers to something only the block schema holds: any other leaf is
567
573
  // textblocks alone, with no island and no gap to put a cursor in.
568
574
  if (isBlockSchema(schema))
569
- list.push(gapCursor(), pastAtomPlugin(), islandPastePlugin(), tablePastePlugin(), tableCopyPlugin(), markdownPastePlugin(), taskPlugin(), nestingPlugin());
575
+ list.push(gapCursor(), pastAtomPlugin(), islandPastePlugin(), tablePastePlugin(), tableCopyPlugin(), markdownPastePlugin(), listPastePlugin(), taskPlugin(), nestingPlugin());
570
576
  if (schema === plainSchema)
571
577
  list.push(plainClipboardPlugin());
578
+ if (isInlineSchema(schema))
579
+ list.push(linePastePlugin());
572
580
  if (opts.placeholder)
573
581
  list.push(ghostPlugin(opts));
574
582
  return list;
@@ -3,6 +3,7 @@ export type { RangeAnchor } from './anchor.js';
3
3
  export { createField, emptyContent, heldAttributes, isEmptyContent, proseAttributes, proseLeafPlugins } from './field.js';
4
4
  export type { CreateFieldOpts, FieldController, LeafViews } from './field.js';
5
5
  export { capCount } from './cap.js';
6
+ export { joinLines, pastedLines } from './line-paste.js';
6
7
  export type { TextCap } from './cap.js';
7
8
  export { DEFAULT_TABLE_STRINGS } from './table-view.js';
8
9
  export type { TableChromeStrings } from './table-view.js';
@@ -20,3 +21,4 @@ export type { Reconciler } from './reconcile.js';
20
21
  export { inputRulesPlugin } from './inputrules.js';
21
22
  export { bodyKeymap } from './keymap.js';
22
23
  export { listKeymap } from './lists.js';
24
+ export { toggleList } from './list-toggle.js';
@@ -15,6 +15,7 @@ export { rangeAnchor } from './anchor.js';
15
15
  // The prose leaf.
16
16
  export { createField, emptyContent, heldAttributes, isEmptyContent, proseAttributes, proseLeafPlugins } from './field.js';
17
17
  export { capCount } from './cap.js';
18
+ export { joinLines, pastedLines } from './line-paste.js';
18
19
  // The table island's chrome vocabulary: the visual tier's `strings` set extends it,
19
20
  // so the island's wording is overridden beside every other key.
20
21
  export { DEFAULT_TABLE_STRINGS } from './table-view.js';
@@ -44,3 +45,6 @@ export { inputRulesPlugin } from './inputrules.js';
44
45
  // (`bodyKeymap`); the suite drives both it and the list link directly.
45
46
  export { bodyKeymap } from './keymap.js';
46
47
  export { listKeymap } from './lists.js';
48
+ // The list toggle the keymap binds to Mod-Shift-7/8, and the selection toolbar's list
49
+ // buttons run (`visual/FormatPopover.svelte`).
50
+ export { toggleList } from './list-toggle.js';
@@ -1,6 +1,7 @@
1
- // Input rules: the markdown-shorthand plugin the codec owns (`**`→strong, `*`→em,
2
- // `~~`→strike, `` ` ``→code, `# `→heading, `- `/`1. `→lists, `> `→blockquote,
3
- // `---`→divider), and the fence, which fires on Enter rather than as typed.
1
+ // Input rules: the markdown-shorthand plugin the codec owns (`**`/`__`→strong,
2
+ // `*`/`_`→em, `~~`→strike, `` ` ``→code, `# `→heading, `- `/`1. `/`1) `→lists,
3
+ // `> `→blockquote, `---`→divider, a typed URL→link), and the fence, which fires on Enter
4
+ // rather than as typed.
4
5
  // Mark rules are hand-rolled (prosemirror-inputrules ships only node rules); every
5
6
  // block rule is one regexp over a command in `blocks.ts`, which is the slash menu's
6
7
  // implementation too. These produce transactions the codec already lowers: they add no
@@ -10,11 +11,14 @@
10
11
  // another gesture owns — never on how a quill renders it. What a quill typesets is not
11
12
  // knowable here (a quill this build has not seen is the open-sets case, CODEC §"Open
12
13
  // sets"), and a construct that arrives through `importMarkdown` must stay authorable or
13
- // a document opens carrying a shape the editor refuses to make. So one guard survives:
14
- // `- ` / `1. ` at the head of an existing item, where Tab owns the nesting.
14
+ // a document opens carrying a shape the editor refuses to make. So one rule answers
15
+ // otherwise: `- ` / `1. ` at the head of an existing item retypes the item's list, where
16
+ // a wrap would nest and Tab owns the nesting. A marker the item already shows, and `a. `,
17
+ // `(1) `, `(a) ` in a nested item, are taken as typed and change nothing.
15
18
  import { InputRule, inputRules, undoInputRule } from 'prosemirror-inputrules';
16
19
  import { keymap } from 'prosemirror-keymap';
17
- import { consuming, insertDivider, toCodeBlock, toHeading, wrapInList, wrapInQuote } from './blocks.js';
20
+ import { rendersHref } from './urls.js';
21
+ import { consuming, insertDivider, takeNestedMarker, toCodeBlock, toHeading, wrapInList, wrapInQuote } from './blocks.js';
18
22
  /**
19
23
  * A mark input rule: when `regexp` (ending in the closing delimiter) matches
20
24
  * before the caret, wrap capture group 1 in `markType` and drop the delimiters.
@@ -46,6 +50,25 @@ function markInputRule(regexp, markType, delimLen) {
46
50
  return tr;
47
51
  });
48
52
  }
53
+ /** A typed URL, closed by the space after it: a scheme `rendersHref` follows, or a bare
54
+ * `www.`, less the punctuation around it. */
55
+ const TYPED_URL = /(?:^|[\s(["'])((?:[a-z][a-z0-9+.-]*:\/\/|www\.)[^\s.]\S*?)([.,;:!?)\]'"]*)\s$/i;
56
+ /** The link a typed URL takes, as a paste over a selection does (`link-paste.ts`): the
57
+ * URL's own text, with a bare `www.` read as `https://`. The space that closed it is
58
+ * typed after the link. Declined over a span already linked or set as code. */
59
+ function autolinkRule(link, code) {
60
+ return new InputRule(TYPED_URL, (state, match, start, end) => {
61
+ const [whole, url, trail] = match;
62
+ const from = start + whole.length - 1 - trail.length - url.length;
63
+ const to = from + url.length;
64
+ const href = /^www\./i.test(url) ? `https://${url}` : url;
65
+ if (!rendersHref(href) || state.doc.rangeHasMark(from, to, link))
66
+ return null;
67
+ if (code && state.doc.rangeHasMark(from, to, code))
68
+ return null;
69
+ return state.tr.addMark(from, to, link.create({ href })).insertText(whole.slice(-1), end);
70
+ });
71
+ }
49
72
  /** A block shorthand: the regexp that fires it, and the command it runs in the match's
50
73
  * place. Every one is `^`-anchored, so the command's own head-of-a-textblock guard is
51
74
  * the same position the regexp already stands at. The caret it places is scrolled to
@@ -59,23 +82,30 @@ export function markdownInputRules(schema) {
59
82
  const m = schema.marks;
60
83
  // Inline marks. Non-greedy capture between matching delimiters. Built only where the
61
84
  // schema has the mark: the inline schema omits them, and a `text` one declares none
62
- // at all.
85
+ // at all. An underscore pair opens only past a word, so `snake_case_name` stays text.
63
86
  if (m.strong)
64
87
  rules.push(markInputRule(/\*\*([^*]+)\*\*$/, m.strong, 2));
88
+ if (m.strong)
89
+ rules.push(markInputRule(/(?:^|[^\w])__([^_]+)__$/, m.strong, 2));
65
90
  if (m.em)
66
91
  rules.push(markInputRule(/(?:^|[^*])\*([^*]+)\*$/, m.em, 1));
92
+ if (m.em)
93
+ rules.push(markInputRule(/(?:^|[^\w])_([^_]+)_$/, m.em, 1));
67
94
  if (m.strike)
68
95
  rules.push(markInputRule(/~~([^~]+)~~$/, m.strike, 2));
69
96
  if (m.code)
70
97
  rules.push(markInputRule(/`([^`]+)`$/, m.code, 1));
98
+ if (m.link)
99
+ rules.push(autolinkRule(m.link, m.code));
71
100
  // Block shorthands. Each command answers for the schema it is run against, so a
72
101
  // schema without the node declines rather than being guarded here.
73
102
  rules.push(blockRule(/^(#{1,6})\s$/, (match) => toHeading(match[1].length)));
74
103
  rules.push(blockRule(/^---$/, insertDivider));
75
- // `- ` / `1. ` at the start of a block, here or nested inside an item — but not at
76
- // the start of an item's own first block, where the command declines.
104
+ // `- ` / `1. ` / `1) ` at the start of a block, here or nested inside an item; at the
105
+ // start of an item's own first block the command retypes the item's list instead.
77
106
  rules.push(blockRule(/^\s*([-+*])\s$/, () => wrapInList(false)));
78
- rules.push(blockRule(/^(\d+)\.\s$/, (match) => wrapInList(true, { start: +match[1] }, (node) => node.childCount + node.attrs.start === +match[1])));
107
+ rules.push(blockRule(/^(\d+)[.)]\s$/, (match) => wrapInList(true, { start: +match[1] })));
108
+ rules.push(blockRule(/^(?:[a-z]\.|\((?:\d+|[a-z])\))\s$/, takeNestedMarker));
79
109
  rules.push(blockRule(/^\s*>\s$/, wrapInQuote));
80
110
  return rules;
81
111
  }
@@ -4,6 +4,7 @@ import { blockKeymap } from './blocks.js';
4
4
  import { breakKeymap } from './breaks.js';
5
5
  import { codeKeymap } from './code.js';
6
6
  import { listKeymap } from './lists.js';
7
+ import { listMoveKeymap } from './list-move.js';
7
8
  import { slashKeymap } from './slash.js';
8
9
  /** One chain per key, the maps taken in precedence order: on a key several bind, an
9
10
  * earlier map's command runs first and the next runs only if it declines. A key one
@@ -17,6 +18,23 @@ function chainKeymaps(...maps) {
17
18
  }
18
19
  return out;
19
20
  }
21
+ /** The last link of a document body: Tab and Shift-Tab held wherever no surface took
22
+ * them, and Escape giving the focus up, so the browser's next Tab leaves from the body.
23
+ * `{}` over `plainSchema`, a form field whose Tab is field navigation. */
24
+ function exitKeymap(schema) {
25
+ if (!schema.nodes.list_item)
26
+ return {};
27
+ return {
28
+ Tab: () => true,
29
+ 'Shift-Tab': () => true,
30
+ Escape: (_state, _dispatch, view) => {
31
+ if (!view)
32
+ return false;
33
+ view.dom.blur();
34
+ return true;
35
+ }
36
+ };
37
+ }
20
38
  /** A multi-block leaf's structural keys, each link binding only what its schema
21
39
  * declares: over `plainSchema`, which holds neither lists nor code blocks nor islands,
22
40
  * the block link and the break link alone. The argument list is the precedence order
@@ -29,5 +47,5 @@ function chainKeymaps(...maps) {
29
47
  * no visible reason. */
30
48
  export function bodyKeymap(schema, slash) {
31
49
  const links = slash ? [slashKeymap()] : [];
32
- return chainKeymaps(...links, blockKeymap(schema), codeKeymap(schema), breakKeymap(schema), listKeymap(schema), atomKeymap(schema));
50
+ return chainKeymaps(...links, blockKeymap(schema), codeKeymap(schema), breakKeymap(schema), listKeymap(schema), listMoveKeymap(schema), atomKeymap(schema), exitKeymap(schema));
33
51
  }
@@ -0,0 +1,7 @@
1
+ import { Fragment, Slice, type Node as PMNode, type Schema } from 'prosemirror-model';
2
+ import { Plugin } from 'prosemirror-state';
3
+ /** The lines a slice holds that hold anything, in order. */
4
+ export declare function pastedLines(slice: Slice): PMNode[];
5
+ /** `lines` as the content of one line, a space between each two. */
6
+ export declare function joinLines(lines: readonly PMNode[], schema: Schema): Fragment;
7
+ export declare function linePastePlugin(): Plugin;