@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
@@ -1,8 +1,34 @@
1
+ // List editing: the body leaf's structural commands, over
2
+ // `prosemirror-schema-list` primitives. Indent/outdent on Tab, and the three
3
+ // structural keys lists redefine: Enter (split, take the marker off an empty middle
4
+ // item, give an empty continuation line one, open an empty line above an unnumbered
5
+ // one), Backspace (take an item's marker off, or join a block onto the
6
+ // list above it) and Delete (the marker off at the seam below, and the two lines
7
+ // joined).
8
+ //
9
+ // An item's number is its position: no edit writes `start`. Taking a marker off keeps
10
+ // the text where it stands, a continuation of the item above, so the items below close
11
+ // up by position. `start` is what a writer typed (`N. `, `blocks.ts`) or what arrived
12
+ // with a whole list. The one split left is Backspace at the head of a top-level
13
+ // continuation line, which takes the line out to full width; the tail counts from 1.
14
+ //
15
+ // Cleanup is command-local, never a global pass: a command joins the boundary it opens
16
+ // and `sinkListItem` reuses the previous item's nested list, so no mutation strands a
17
+ // node, and two lists of a kind a command leaves touching it joins (`touching.ts`). A
18
+ // whole-doc normalizer would instead fuse every adjacent same-type pair, and that
19
+ // boundary is load-bearing: `Container.instance` is how `Content` marks one, the
20
+ // upstream normalizer keeps it (`tests/codec/list-shapes.test.ts`), and fusing
21
+ // renumbers an imported `1, 2, 1` in a region no edit touched. Reads preserve it; only
22
+ // an edit normalizes.
23
+ import { NodeRange } from 'prosemirror-model';
1
24
  import { liftListItem, sinkListItem } from 'prosemirror-schema-list';
2
25
  import { EditorState, Selection, TextSelection } from 'prosemirror-state';
26
+ import { canJoin, findWrapping, liftTarget } from 'prosemirror-transform';
3
27
  import { chainCommands, joinTextblockBackward, joinTextblockForward } from 'prosemirror-commands';
4
28
  import { withinNesting } from './nesting.js';
29
+ import { toggleList } from './list-toggle.js';
5
30
  import { splitTaskItem } from './tasks.js';
31
+ import { joinAll, joinLists, touching } from './touching.js';
6
32
  /** The caret's enclosing `list_item` when it sits at the very start of that item's
7
33
  * first block: the position both structural keys branch on. `null` otherwise
8
34
  * (mid-text, a non-first block of a multi-paragraph item, a non-empty selection),
@@ -25,10 +51,9 @@ function atItemStart(state, itemType) {
25
51
  }
26
52
  /**
27
53
  * The textblock at the end of `node`: `node` itself where it is one, else its last
28
- * descendant that is — the block a caret above a boundary lands in, and the block a
29
- * join across one reaches, `joinTextblockBackward` walking to the same one. `null` on
30
- * an atom (an island, a rule), which `prosemirror-gapcursor` declines to sit beside
31
- * too, its `closedBefore` asking this question from the other side.
54
+ * descendant that is — the block a join across a boundary reaches,
55
+ * `joinTextblockBackward` walking to the same one. `null` on an atom (an island, a
56
+ * rule), which holds no line to join onto.
32
57
  */
33
58
  function lastBlockIn(node) {
34
59
  let last = node ?? null;
@@ -37,74 +62,207 @@ function lastBlockIn(node) {
37
62
  return last?.isTextblock ? last : null;
38
63
  }
39
64
  /**
40
- * Whether a caret already fits immediately above the list at `pos`. The node ending
41
- * there answers for its last child, so a quote counts through its final paragraph; a
42
- * list opening its parent has no node there at all.
65
+ * Enter at the head of an unnumbered line that holds text → an empty unnumbered line
66
+ * above it, the caret staying with the text it pushed down: the split a numbered line
67
+ * makes at its head, kept to the line. `splitListItem` would split the item there
68
+ * instead, turning the line's text into the next item and leaving the empty line
69
+ * behind under the marker it had.
43
70
  */
44
- function writableAbove(doc, pos) {
45
- return !!lastBlockIn(doc.resolve(pos).nodeBefore);
71
+ function lineAbove(itemType, paragraph) {
72
+ return (state, dispatch) => {
73
+ const { $from, empty } = state.selection;
74
+ if (!empty || !$from.parent.isTextblock || $from.parentOffset !== 0)
75
+ return false;
76
+ if ($from.depth < 2 || $from.parent.content.size === 0)
77
+ return false;
78
+ const index = $from.index(-1);
79
+ if ($from.node(-1).type !== itemType || index === 0)
80
+ return false;
81
+ if (!$from.node(-1).canReplaceWith(index, index, paragraph))
82
+ return false;
83
+ dispatch?.(state.tr.insert($from.before(), paragraph.create()).scrollIntoView());
84
+ return true;
85
+ };
86
+ }
87
+ /** Whether `item` holds nothing but one empty textblock: a line with no text and no
88
+ * structure under it. */
89
+ function emptyItem(item) {
90
+ return (item.childCount === 1 && !!item.firstChild?.isTextblock && item.firstChild.content.size === 0);
46
91
  }
47
92
  /**
48
- * Enter at the very start of a top-level list's first item, where nothing above the
49
- * list takes a caret → an empty paragraph above it, caret staying with the text it
50
- * pushed down. A list under an island or a rule has nowhere above to write and
51
- * nothing else that opens one; a list that opens the body is the block link's, which
52
- * answers the same fact for every block ahead of this chain (`blocks.ts`). Where a
53
- * caret already fits above, the key is the conventional split instead, at every item
54
- * alike: an escape a writer reaches by pressing Up needs no key of its own.
93
+ * Take the marker off the item whose first block holds the caret's head, the text
94
+ * staying where it stands: a later item's blocks become continuation blocks of the
95
+ * item above, and a nested first item's become the parent item's, ahead of the rest of
96
+ * its list. Nothing splits, so the items below close up by position. A top-level first
97
+ * item has no item above to continue and lifts out over the list instead.
98
+ *
99
+ * An empty item is spent rather than kept as an empty continuation line, the caret
100
+ * going to the end of what stood above it: the line held nothing, and the second press
101
+ * that would join it away is the one a writer meant. `keepEmpty` is Enter's reading,
102
+ * where the empty line is the one about to be typed in.
55
103
  *
56
- * Also only where the list itself is not inside an item: in a nested list this
57
- * pushes an empty paragraph into the parent item (`list_item` is `block+`, so the
58
- * shape is representable and wrong), where the conventional split (an empty item
59
- * above) is what the next link does. An empty first item falls through to that same
60
- * link, which exits the list instead.
104
+ * Declines where the item has nothing above it in an item and is the first block of
105
+ * one besides (`list_item(ordered_list(…))`, a pasted shape): there the content would
106
+ * stand ahead of no line.
61
107
  */
62
- function paragraphAboveList(itemType, paragraph) {
108
+ function markerOff(itemType, keepEmpty = false) {
63
109
  return (state, dispatch) => {
64
110
  const at = atItemStart(state, itemType);
65
- if (!at || at.nested || at.itemIndex !== 0)
66
- return false;
67
- if (state.selection.$from.parent.content.size === 0)
68
- return false;
69
- if (writableAbove(state.doc, at.listPos))
111
+ if (!at)
70
112
  return false;
71
- if (dispatch)
72
- dispatch(state.tr.insert(at.listPos, paragraph.create()).scrollIntoView());
113
+ const { $from } = state.selection;
114
+ if (at.itemIndex === 0 && !at.nested)
115
+ return liftListItem(itemType)(state, dispatch);
116
+ const item = $from.node(-1);
117
+ const list = $from.node(-2);
118
+ const itemStart = $from.before(-1);
119
+ const itemEnd = itemStart + item.nodeSize;
120
+ const spend = !keepEmpty && emptyItem(item);
121
+ const tr = state.tr;
122
+ let into;
123
+ if (at.itemIndex > 0) {
124
+ into = itemStart - 1;
125
+ tr.delete(itemStart, itemEnd);
126
+ }
127
+ else {
128
+ if ($from.index(-3) === 0)
129
+ return false;
130
+ into = at.listPos;
131
+ if (list.childCount === 1)
132
+ tr.delete(at.listPos, at.listPos + list.nodeSize);
133
+ else
134
+ tr.delete(itemStart, itemEnd);
135
+ }
136
+ if (spend) {
137
+ tr.setSelection(Selection.near(tr.doc.resolve(into), -1));
138
+ }
139
+ else {
140
+ tr.insert(into, item.content);
141
+ const seam = into + item.content.size;
142
+ const after = tr.doc.resolve(seam).nodeAfter;
143
+ if (after && after.type === item.content.lastChild?.type && canJoin(tr.doc, seam))
144
+ tr.join(seam);
145
+ tr.setSelection(TextSelection.create(tr.doc, into + 1));
146
+ }
147
+ dispatch?.(tr.scrollIntoView());
73
148
  return true;
74
149
  };
75
150
  }
76
151
  /**
77
- * Backspace at the start of an item → lift it: one level out for a nested item, out of
78
- * the list to a paragraph at the top, whichever item it is. The first press takes off
79
- * the item's own shape and joins nothing; the press after is the join
152
+ * Backspace at the head of a continuation line in a top-level item → the line out of
153
+ * the list to full width, with the item's blocks after it: the second rung of the
154
+ * marker coming off, the line losing its indent as it lost its number. The list splits
155
+ * there and the tail counts from 1, a number that names its position in the list it now
156
+ * is. The press after is the ordinary join onto the list's last line
80
157
  * ({@link joinOntoList}).
81
158
  *
82
- * Lifting a later item out splits its list, and an ordered list's items past the cut
83
- * keep their numbers: the list after what the item held starts where they were, where a
84
- * fresh `ordered_list` would count from 1 again.
159
+ * Declines where a sub-list stands below the line in its item, which the line cannot
160
+ * leave without orphaning, and in a nested item, whose continuation lines keep the
161
+ * ordinary join: there the next rung out is still inside the list.
85
162
  */
86
- function liftAtItemStart(itemType) {
163
+ function lineOut(itemType) {
164
+ return (state, dispatch) => {
165
+ const { $from, empty } = state.selection;
166
+ if (!empty || !$from.parent.isTextblock || $from.parentOffset !== 0)
167
+ return false;
168
+ if ($from.depth < 3)
169
+ return false;
170
+ const item = $from.node(-1);
171
+ if (item.type !== itemType || $from.index(-1) === 0)
172
+ return false;
173
+ if ($from.node(-3).type === itemType)
174
+ return false;
175
+ for (let i = $from.index(-1) + 1; i < item.childCount; i++)
176
+ if (item.child(i).firstChild?.type === itemType)
177
+ return false;
178
+ const range = new NodeRange($from.doc.resolve($from.before()), $from.doc.resolve($from.end(-1)), $from.depth - 1);
179
+ const target = liftTarget(range);
180
+ if (target == null)
181
+ return false;
182
+ if (!dispatch)
183
+ return true;
184
+ const tr = state.tr.lift(range, target);
185
+ const $at = tr.doc.resolve(tr.mapping.map($from.before()));
186
+ const tail = $at.parent.maybeChild($at.index() + item.childCount - $from.index(-1));
187
+ if (tail?.firstChild?.type === itemType && tail.attrs.start != null && tail.attrs.start !== 1)
188
+ tr.setNodeMarkup($at.posAtIndex($at.index() + item.childCount - $from.index(-1)), null, {
189
+ ...tail.attrs,
190
+ start: 1
191
+ });
192
+ dispatch(tr.scrollIntoView());
193
+ return true;
194
+ };
195
+ }
196
+ /**
197
+ * Shift-Tab → `liftListItem` in a nested list, the item up one level. A top-level item
198
+ * has no level above it inside the list: the key is claimed and does nothing, as Tab is
199
+ * on a first item, so a press too many while backing out of a sub-list renumbers
200
+ * nothing. Leaving the list is Backspace's, one rung a press. Outside every item the
201
+ * key declines.
202
+ */
203
+ function outdent(itemType) {
204
+ const lift = liftListItem(itemType);
205
+ return (state, dispatch) => {
206
+ const { $from, $to } = state.selection;
207
+ const range = $from.blockRange($to, (node) => node.childCount > 0 && node.firstChild.type === itemType);
208
+ if (!range)
209
+ return false;
210
+ if (range.$from.node(range.depth - 1).type === itemType)
211
+ return lift(state, dispatch);
212
+ return true;
213
+ };
214
+ }
215
+ /**
216
+ * Enter on an empty item that has items after it → the marker off, the empty line kept
217
+ * as a continuation of the item above: the unnumbered line a writer opens between two
218
+ * numbered ones. An empty last item still leaves the list (`liftListItem`, the chain's
219
+ * last link), and a first item has no item above to continue.
220
+ */
221
+ function continueAbove(itemType) {
222
+ const off = markerOff(itemType, true);
87
223
  return (state, dispatch) => {
88
224
  const at = atItemStart(state, itemType);
89
- if (!at)
225
+ const { $from } = state.selection;
226
+ if (!at || at.itemIndex === 0 || !emptyItem($from.node(-1)))
227
+ return false;
228
+ if (at.itemIndex === $from.node(-2).childCount - 1)
90
229
  return false;
91
- const list = state.doc.nodeAt(at.listPos);
92
- const cut = list?.type.name === 'ordered_list' && !at.nested && at.itemIndex < list.childCount - 1;
93
- const listEnd = at.listPos + (list?.nodeSize ?? 0);
94
- return liftListItem(itemType)(state, dispatch &&
95
- ((tr) => {
96
- if (cut)
97
- renumberEndingAt(tr, tr.mapping.map(listEnd, -1), list.attrs.start + at.itemIndex + 1);
98
- dispatch(tr);
99
- }));
230
+ return off(state, dispatch);
100
231
  };
101
232
  }
102
- /** Start the ordered list ending at `end` at `start`: the items a lift cut off, which
103
- * end where the whole list did, past whatever the lifted item held. */
104
- function renumberEndingAt(tr, end, start) {
105
- const list = tr.doc.resolve(end).nodeBefore;
106
- if (list?.type.name === 'ordered_list' && list.attrs.start !== start)
107
- tr.setNodeMarkup(end - list.nodeSize, undefined, { ...list.attrs, start });
233
+ /**
234
+ * Enter on an empty continuation line, the last block of an item past its first → the
235
+ * marker back on: the line becomes an item of its own after the one it continued. In
236
+ * the last item of a list it leaves the list instead, standing after it in the list's
237
+ * parent, the exit an empty last item takes. Never `liftListItem`, which would lift the
238
+ * whole item the line continues and take that item's marker with it.
239
+ */
240
+ function markerOn(itemType) {
241
+ return (state, dispatch) => {
242
+ const { $from, empty } = state.selection;
243
+ if (!empty || $from.depth < 3 || $from.parent.content.size !== 0)
244
+ return false;
245
+ const item = $from.node(-1);
246
+ if (item.type !== itemType || $from.index(-1) === 0)
247
+ return false;
248
+ if ($from.index(-1) !== item.childCount - 1)
249
+ return false;
250
+ const list = $from.node(-2);
251
+ const block = $from.parent;
252
+ const tr = state.tr.delete($from.before(), $from.after());
253
+ let at;
254
+ if ($from.index(-2) === list.childCount - 1) {
255
+ at = tr.mapping.map($from.after(-2));
256
+ tr.insert(at, block);
257
+ }
258
+ else {
259
+ at = tr.mapping.map($from.after(-1));
260
+ tr.insert(at, itemType.create(null, block));
261
+ at += 1;
262
+ }
263
+ dispatch?.(tr.setSelection(TextSelection.create(tr.doc, at + 1)).scrollIntoView());
264
+ return true;
265
+ };
108
266
  }
109
267
  /**
110
268
  * Backspace at the head of a block that stands right after a list → join its text into
@@ -116,6 +274,9 @@ function renumberEndingAt(tr, end, start) {
116
274
  * line on, and the join would retype one side's whole text as the other's (`code.ts`,
117
275
  * which refuses the same join between siblings). The block moves into the list's last
118
276
  * item whole instead, a block of that item under its first.
277
+ *
278
+ * Either way the block was what stood between the list and the one below it, and two
279
+ * lists of a kind it leaves touching join (`touching.ts`).
119
280
  */
120
281
  function joinOntoList(itemType) {
121
282
  return (state, dispatch, view) => {
@@ -128,16 +289,54 @@ function joinOntoList(itemType) {
128
289
  const into = lastBlockIn(before);
129
290
  if (!into)
130
291
  return false;
292
+ const listPos = $from.before() - before.nodeSize;
293
+ const joinBelow = (tr) => {
294
+ joinLists(tr, listPos + tr.doc.nodeAt(listPos).nodeSize);
295
+ return tr;
296
+ };
131
297
  if (!!into.type.spec.code === !!$from.parent.type.spec.code)
132
- return joinTextblockBackward(state, dispatch, view);
298
+ return joinTextblockBackward(state, dispatch && ((tr) => dispatch(joinBelow(tr))), view);
133
299
  if (dispatch) {
134
300
  const at = $from.before() - 2;
135
301
  const tr = state.tr.delete($from.before(), $from.after()).insert(at, $from.parent);
136
- dispatch(tr.setSelection(TextSelection.create(tr.doc, at + 1)).scrollIntoView());
302
+ tr.setSelection(TextSelection.create(tr.doc, at + 1));
303
+ dispatch(joinBelow(tr).scrollIntoView());
137
304
  }
138
305
  return true;
139
306
  };
140
307
  }
308
+ /**
309
+ * Backspace or Delete over a selection that spans blocks → the base keymap's delete, and
310
+ * the two lists of a kind it leaves touching joined: a selection taking the paragraph
311
+ * between two lists takes the restart with it. A seam that touched before the delete
312
+ * is one no edit opened, and stays. Declines wherever the delete opens no seam, the base
313
+ * keymap's answer standing.
314
+ */
315
+ const deleteAcross = (state, dispatch) => {
316
+ const { $from, $to, empty } = state.selection;
317
+ if (empty || $from.sameParent($to))
318
+ return false;
319
+ const tr = state.tr.deleteSelection();
320
+ const kept = new Set();
321
+ for (const $end of [$from, $to])
322
+ for (const seam of seamsAround($end))
323
+ if (touching(state.doc, seam))
324
+ kept.add(tr.mapping.map(seam));
325
+ const opened = seamsAround(tr.doc.resolve(tr.mapping.map($from.pos))).filter((seam) => !kept.has(seam) && touching(tr.doc, seam));
326
+ if (!opened.length)
327
+ return false;
328
+ joinAll(tr, opened);
329
+ dispatch?.(tr.scrollIntoView());
330
+ return true;
331
+ };
332
+ /** The boundaries of every node around `$pos`, and `$pos` itself: where a delete ending
333
+ * there can leave two blocks meeting. */
334
+ function seamsAround($pos) {
335
+ const seams = [$pos.pos];
336
+ for (let depth = $pos.depth; depth > 0; depth--)
337
+ seams.push($pos.before(depth), $pos.after(depth));
338
+ return seams;
339
+ }
141
340
  /** Whether the caret sits inside a `list_item` at any depth: a quote's paragraph
142
341
  * inside an item is still an item's, and a caret outside every list is at no seam of
143
342
  * one. */
@@ -149,34 +348,44 @@ function inItem($pos, itemType) {
149
348
  return false;
150
349
  }
151
350
  /**
152
- * Delete at the very end of a block inside an item → `press`, the Backspace chain, at
153
- * the head of the block below: one seam, one edit, whichever side a writer approaches
154
- * it from. Not the base keymap's `joinForward`, which merges the blocks and leaves the
155
- * `list_item(paragraph, paragraph)` above.
351
+ * Delete at the very end of a block inside an item → the item below's marker off and
352
+ * its first line joined onto the caret's, as every editor merges two lines on a forward
353
+ * delete: one keystroke, one line. The marker comes off by the Backspace chain's own
354
+ * link (`markerOff`), so the item's other blocks, a nested list among them, stay where
355
+ * they stood under the joined line; the join is `joinTextblockForward`'s. Backspace at
356
+ * the same seam stops at the marker, the one place the two keys differ: Backspace edits
357
+ * the block it is pressed in, and Delete the line after the caret.
156
358
  *
157
- * The reflection carries the edit and not the caret, which stays where the key was
158
- * pressed: a lift moves the item below, and Delete does not follow it.
359
+ * The caret stays where the key was pressed.
159
360
  *
160
- * An empty item is the exception: its line is the whole of what Delete edits, so the
161
- * item below moves up into it rather than being lifted, and the empty item is spent. A
162
- * fence below takes no prose line (`joinOntoList`), so there the empty line goes alone.
361
+ * An empty item below is spent, and nothing after it joins. An empty item at the caret
362
+ * is the other way round: its line is the whole of what Delete edits, so the item below
363
+ * moves up into it. A fence takes no prose line (`joinOntoList`): across one only the
364
+ * marker comes off, and an empty line above one goes alone.
163
365
  *
164
- * Declining wherever the chain does is what keeps this to the list's own seams: a
165
- * boundary inside one item, and the list's outer edge, stay the base keymap's, where
166
- * the two keys already answer alike.
366
+ * A line above a list is the same seam from outside it: the first item's marker comes
367
+ * off and its line joins the caret's. Below that, declining wherever the Backspace chain
368
+ * does keeps this to the list's own seams: a boundary inside one item stays the base
369
+ * keymap's.
167
370
  */
168
- function backspaceBelow(itemType, press) {
371
+ function deleteBelow(itemType, press) {
372
+ const off = markerOff(itemType);
169
373
  return (state, dispatch) => {
170
374
  const { $from, empty } = state.selection;
171
- if (!empty || $from.parentOffset !== $from.parent.content.size)
375
+ if (!empty || !$from.parent.isTextblock || $from.depth === 0)
172
376
  return false;
173
- if (!inItem($from, itemType))
377
+ if ($from.parentOffset !== $from.parent.content.size)
174
378
  return false;
175
379
  const below = Selection.near(state.doc.resolve($from.after()), 1);
176
380
  if (below.from <= $from.pos)
177
381
  return false;
382
+ const at = EditorState.create({ doc: state.doc, selection: below });
383
+ const head = atItemStart(at, itemType);
384
+ if (!head && !inItem($from, itemType))
385
+ return false;
386
+ const code = !!$from.parent.type.spec.code;
178
387
  if ($from.parent.content.size === 0) {
179
- if (!!below.$from.parent.type.spec.code === !!$from.parent.type.spec.code)
388
+ if (!!below.$from.parent.type.spec.code === code)
180
389
  return joinTextblockForward(state, dispatch);
181
390
  if (dispatch) {
182
391
  const tr = state.tr.deleteRange($from.before(), $from.after());
@@ -184,24 +393,113 @@ function backspaceBelow(itemType, press) {
184
393
  }
185
394
  return true;
186
395
  }
187
- const pressed = $from.pos;
188
- const keepCaret = (tr) => dispatch?.(tr.setSelection(TextSelection.create(tr.doc, tr.mapping.map(pressed))));
189
- return press(EditorState.create({ doc: state.doc, selection: below }), dispatch && keepCaret);
396
+ if (!head)
397
+ return press(at, dispatch && keepCaret(state, dispatch));
398
+ const spent = emptyItem(below.$from.node(-1));
399
+ let lifted;
400
+ if (!off(at, (tr) => (lifted = tr)) || !lifted)
401
+ return false;
402
+ if (!dispatch)
403
+ return true;
404
+ const tr = state.tr;
405
+ for (const step of lifted.steps)
406
+ tr.step(step);
407
+ if (!spent && !!below.$from.parent.type.spec.code === code) {
408
+ const caret = TextSelection.create(tr.doc, tr.mapping.map($from.pos));
409
+ joinTextblockForward(EditorState.create({ doc: tr.doc, selection: caret }), (join) => {
410
+ for (const step of join.steps)
411
+ tr.step(step);
412
+ });
413
+ }
414
+ dispatch(tr.setSelection(TextSelection.create(tr.doc, tr.mapping.map($from.pos, -1))));
415
+ return true;
416
+ };
417
+ }
418
+ /** A dispatch that keeps the caret where the key was pressed, through whatever edit a
419
+ * command made at a seam below it. */
420
+ function keepCaret(state, dispatch) {
421
+ const pressed = state.selection.from;
422
+ return (tr) => dispatch(tr.setSelection(TextSelection.create(tr.doc, tr.mapping.map(pressed))));
423
+ }
424
+ /**
425
+ * Tab on an unnumbered line, a block of an item past its first → that line alone the
426
+ * first item of a sub-list of its list's kind, as `- ` or `1. ` typed at its head makes
427
+ * it. `sinkListItem` would sink the whole item, its numbered line with it. A sub-list of
428
+ * that kind standing right above or below the line in its item takes it in, the wrap
429
+ * joining the boundaries it opens (§cleanup). Declined past `MAX_NESTING_DEPTH`, as
430
+ * every sink is.
431
+ */
432
+ function sinkLine(itemType) {
433
+ return (state, dispatch) => {
434
+ const { $from, $to } = state.selection;
435
+ if (!$from.sameParent($to) || !$from.parent.isTextblock || $from.depth < 3)
436
+ return false;
437
+ if ($from.node(-1).type !== itemType || $from.index(-1) === 0)
438
+ return false;
439
+ const list = $from.node(-2).type;
440
+ const range = $from.blockRange($to);
441
+ const wrapping = range && findWrapping(range, list);
442
+ if (!range || !wrapping)
443
+ return false;
444
+ const at = $from.before();
445
+ const tr = state.tr.wrap(range, wrapping);
446
+ const end = at + $from.parent.nodeSize + 4;
447
+ if (tr.doc.resolve(end).nodeAfter?.type === list && canJoin(tr.doc, end))
448
+ tr.join(end);
449
+ if (tr.doc.resolve(at).nodeBefore?.type === list && canJoin(tr.doc, at))
450
+ tr.join(at);
451
+ if (!withinNesting(tr.doc))
452
+ return false;
453
+ dispatch?.(tr.scrollIntoView());
454
+ return true;
190
455
  };
191
456
  }
192
457
  /** Tab → `sinkListItem`, declined where the sink would nest past `MAX_NESTING_DEPTH`
193
- * (`nesting.ts`), so Tab there leaves the body as it does on an item with nothing to
194
- * sink under. */
458
+ * (`nesting.ts`). On a first item, with nothing above to sink under, the key is claimed
459
+ * and does nothing: a writer indenting a list's first line is still writing the list. */
195
460
  function sinkWithin(itemType) {
196
461
  const sink = sinkListItem(itemType);
197
462
  return (state, dispatch) => {
198
463
  let sunk;
199
- if (!sink(state, (tr) => (sunk = tr)) || !sunk || !withinNesting(sunk.doc))
464
+ if (!sink(state, (tr) => (sunk = tr)) || !sunk) {
465
+ const { $from } = state.selection;
466
+ return $from.depth >= 2 && $from.node(-1).type === itemType && $from.index(-2) === 0;
467
+ }
468
+ if (!withinNesting(sunk.doc))
200
469
  return false;
201
470
  dispatch?.(sunk);
202
471
  return true;
203
472
  };
204
473
  }
474
+ /**
475
+ * Tab on a block standing right after a list, outside every item → the block moves into
476
+ * the list's last item, after what that item holds: one step in, under the item above,
477
+ * as Tab nests an item. The block keeps its kind, so a paragraph lands as the item's
478
+ * unnumbered line. Declined past `MAX_NESTING_DEPTH`, and over a selection that leaves
479
+ * the block.
480
+ */
481
+ function sinkIntoList(itemType) {
482
+ return (state, dispatch) => {
483
+ const { $from, $to } = state.selection;
484
+ if (!$from.parent.isTextblock || !$from.sameParent($to) || $from.depth < 1)
485
+ return false;
486
+ if ($from.node(-1).type === itemType)
487
+ return false;
488
+ const list = $from.index(-1) > 0 ? $from.node(-1).child($from.index(-1) - 1) : null;
489
+ if (!list || list.firstChild?.type !== itemType)
490
+ return false;
491
+ const block = $from.parent;
492
+ const start = $from.before();
493
+ const into = start - 2;
494
+ const tr = state.tr.delete(start, start + block.nodeSize).insert(into, block);
495
+ if (!withinNesting(tr.doc))
496
+ return false;
497
+ const shift = into - start;
498
+ tr.setSelection(TextSelection.create(tr.doc, state.selection.anchor + shift, state.selection.head + shift));
499
+ dispatch?.(tr.scrollIntoView());
500
+ return true;
501
+ };
502
+ }
205
503
  /**
206
504
  * The list bindings for a block-schema leaf: `{}` for the one-line and plain
207
505
  * schemas, which declare no list nodes.
@@ -209,34 +507,43 @@ function sinkWithin(itemType) {
209
507
  * `Tab` / `Shift-Tab` hold the list link of the body's Tab chain. Surfaces that own
210
508
  * Tab more locally under the same rule (`prose/canon/VISUAL_EDITOR.md` §Chrome)
211
509
  * prepend a link ahead of these in `keymap.ts`: a `code_block` takes literal
212
- * indentation (`code.ts`), an island takes cell traversal once added. Outside all
213
- * of them every link returns false and the key is not swallowed: Tab keeps its
214
- * default meaning, which is the body's only keyboard exit and the open seam for a
215
- * shell structural keymap (VISUAL_EDITOR §Settled and open).
510
+ * indentation (`code.ts`). Where every link declines, the body holds the key
511
+ * (`keymap.ts`).
512
+ *
513
+ * `Mod-Shift-7` / `Mod-Shift-8` are the list toggle (`list-toggle.ts`), numbering or
514
+ * bulleting the selection's lines or taking their markers off.
515
+ *
516
+ * `Tab` sinks an unnumbered line alone into a sub-list, any other line its whole item,
517
+ * and a block right after a list into the list's last item.
216
518
  *
217
- * `Enter` is a chain in precedence order: the escape-above gesture, then
218
- * `splitListItem` (`splitTaskItem`, which leaves a task item's new half an open task),
219
- * then `liftListItem`. The middle link carries two behaviors of
220
- * its own: it splits a non-empty item, and on an empty item it either splits the
221
- * wrapping item (a nested empty item, so Enter lifts exactly one level) or bails
222
- * so the third link lifts a top-level empty item out to a paragraph. `Backspace` lifts
223
- * an item at its start, and joins a block onto the list it stands after. `Delete` is
224
- * that same chain at the seam below rather than a second table of cases beside it, so
225
- * the two keys cannot come to differ over a seam this link reaches. An empty line is
226
- * not one: the block link takes it ahead of this chain (`blocks.ts`), the caret's own
227
- * line being a nearer fact than the boundary under it.
519
+ * `Enter` is a chain in precedence order: the empty line above an unnumbered one, the
520
+ * marker off an empty middle item and back on an empty continuation line, then `splitListItem`
521
+ * (`splitTaskItem`, which leaves a task item's new half an open task), then
522
+ * `liftListItem`. The split link carries two behaviors of its own: it splits a
523
+ * non-empty item, and on an empty last item it either splits the wrapping item (a
524
+ * nested one, so Enter lifts exactly one level) or bails so the last link lifts a
525
+ * top-level one out to a paragraph. `Backspace` takes an item's marker off at its
526
+ * start, a top-level continuation line out to full width, and joins a block onto the
527
+ * list it stands after, and both keys over a selection spanning blocks join the lists
528
+ * the delete leaves touching. `Delete` is the marker off at the seam below with the two lines
529
+ * joined, never the line out: a forward delete edits the line after the caret, not its
530
+ * indent. An empty line is neither key's here:
531
+ * the block link takes it ahead of this chain (`blocks.ts`), the caret's own line
532
+ * being a nearer fact than the boundary under it.
228
533
  */
229
534
  export function listKeymap(schema) {
230
535
  const itemType = schema.nodes.list_item;
231
536
  const paragraph = schema.nodes.paragraph;
232
537
  if (!itemType || !paragraph)
233
538
  return {};
234
- const backspace = chainCommands(liftAtItemStart(itemType), joinOntoList(itemType));
539
+ const backspace = chainCommands(markerOff(itemType), joinOntoList(itemType));
235
540
  return {
236
- Tab: sinkWithin(itemType),
237
- 'Shift-Tab': liftListItem(itemType),
238
- Enter: chainCommands(paragraphAboveList(itemType, paragraph), splitTaskItem(itemType), liftListItem(itemType)),
239
- Backspace: backspace,
240
- Delete: backspaceBelow(itemType, backspace)
541
+ 'Mod-Shift-7': toggleList(true),
542
+ 'Mod-Shift-8': toggleList(false),
543
+ Tab: chainCommands(sinkLine(itemType), sinkWithin(itemType), sinkIntoList(itemType)),
544
+ 'Shift-Tab': outdent(itemType),
545
+ Enter: chainCommands(lineAbove(itemType, paragraph), continueAbove(itemType), markerOn(itemType), splitTaskItem(itemType), liftListItem(itemType)),
546
+ Backspace: chainCommands(deleteAcross, markerOff(itemType), lineOut(itemType), joinOntoList(itemType)),
547
+ Delete: chainCommands(deleteAcross, deleteBelow(itemType, backspace))
241
548
  };
242
549
  }
@@ -1,4 +1,13 @@
1
1
  import { Plugin } from 'prosemirror-state';
2
+ /**
3
+ * `text` with every item line spelled as markdown, where some line marks its item as
4
+ * markdown does not; otherwise `text` as it stands. A line's level is the open one of its
5
+ * kind at its indent, give or take a column, the levels below it closing; a marker no open
6
+ * level holds opens one beneath the deepest. Markdown nests by indent alone, so each line
7
+ * is indented past the markers of the levels above it. A letter's ordinal is its place in
8
+ * the alphabet. Lines in a fence and lines opening no item stand as they are.
9
+ */
10
+ export declare function plainTextList(text: string): string;
2
11
  /**
3
12
  * A block leaf's markdown paste, consulted after the table paste: ProseMirror hands text
4
13
  * over only where the clipboard holds no HTML, whose DOM parse reads the source's own