@quillmark/svelte 0.23.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.
- package/README.md +7 -8
- package/THEMING.md +1 -1
- package/dist/core/codec/blocks.d.ts +23 -6
- package/dist/core/codec/blocks.js +163 -38
- package/dist/core/codec/code.js +3 -8
- package/dist/core/codec/decode.d.ts +2 -1
- package/dist/core/codec/decode.js +19 -11
- package/dist/core/codec/field.d.ts +6 -3
- package/dist/core/codec/field.js +12 -4
- package/dist/core/codec/index.d.ts +2 -0
- package/dist/core/codec/index.js +4 -0
- package/dist/core/codec/inputrules.js +40 -10
- package/dist/core/codec/islands.d.ts +4 -1
- package/dist/core/codec/islands.js +43 -16
- package/dist/core/codec/keymap.js +19 -1
- package/dist/core/codec/line-paste.d.ts +7 -0
- package/dist/core/codec/line-paste.js +36 -0
- package/dist/core/codec/list-move.d.ts +6 -0
- package/dist/core/codec/list-move.js +30 -0
- package/dist/core/codec/list-paste.d.ts +2 -0
- package/dist/core/codec/list-paste.js +142 -0
- package/dist/core/codec/list-toggle.d.ts +4 -0
- package/dist/core/codec/list-toggle.js +170 -0
- package/dist/core/codec/lists.d.ts +22 -15
- package/dist/core/codec/lists.js +404 -97
- package/dist/core/codec/markdown-paste.d.ts +13 -3
- package/dist/core/codec/markdown-paste.js +70 -6
- package/dist/core/codec/nesting.d.ts +5 -1
- package/dist/core/codec/nesting.js +1 -1
- package/dist/core/codec/prose.css +36 -20
- package/dist/core/codec/slash.d.ts +2 -1
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/touching.d.ts +12 -0
- package/dist/core/codec/touching.js +33 -0
- package/dist/core/codec/undo.d.ts +5 -0
- package/dist/core/codec/undo.js +21 -0
- package/dist/index.js +1 -1
- package/dist/preview/Preview.svelte +88 -13
- package/dist/preview/Preview.svelte.d.ts +2 -2
- package/dist/preview/bridge.js +8 -2
- package/dist/preview/controller.d.ts +8 -4
- package/dist/preview/controller.js +10 -2
- package/dist/preview/index.js +1 -1
- package/dist/preview/paint.d.ts +6 -2
- package/dist/preview/paint.js +67 -56
- package/dist/preview/strings.d.ts +2 -0
- package/dist/preview/strings.js +4 -2
- package/dist/preview/viewport.d.ts +10 -0
- package/dist/preview/viewport.js +271 -0
- package/dist/preview/zoom.d.ts +41 -0
- package/dist/preview/zoom.js +52 -0
- package/dist/visual/ArrayField.svelte +24 -4
- package/dist/visual/Card.svelte +5 -4
- package/dist/visual/DateField.svelte +26 -13
- package/dist/visual/Field.svelte +2 -2
- package/dist/visual/FormatPopover.svelte +41 -2
- package/dist/visual/ObjectField.svelte +4 -7
- package/dist/visual/ObjectField.svelte.d.ts +2 -2
- package/dist/visual/ProseValue.svelte +35 -3
- package/dist/visual/ProseValue.svelte.d.ts +7 -0
- package/dist/visual/VisualEditorInner.svelte +5 -11
- package/dist/visual/icons/nodes.d.ts +26 -0
- package/dist/visual/icons/nodes.js +16 -0
- package/dist/visual/strings.d.ts +6 -4
- package/dist/visual/strings.js +9 -3
- package/dist/visual/structure.d.ts +22 -10
- package/dist/visual/structure.js +35 -11
- package/package.json +2 -2
package/dist/core/codec/lists.js
CHANGED
|
@@ -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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
45
|
-
return
|
|
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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
108
|
+
function markerOff(itemType, keepEmpty = false) {
|
|
63
109
|
return (state, dispatch) => {
|
|
64
110
|
const at = atItemStart(state, itemType);
|
|
65
|
-
if (!at
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
78
|
-
* the list to
|
|
79
|
-
* the
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
103
|
-
*
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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 →
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* `
|
|
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
|
|
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
|
|
161
|
-
*
|
|
162
|
-
* fence
|
|
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
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* the
|
|
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
|
|
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 ||
|
|
375
|
+
if (!empty || !$from.parent.isTextblock || $from.depth === 0)
|
|
172
376
|
return false;
|
|
173
|
-
if (
|
|
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 ===
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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`)
|
|
194
|
-
*
|
|
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
|
|
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`)
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
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
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
* line
|
|
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(
|
|
539
|
+
const backspace = chainCommands(markerOff(itemType), joinOntoList(itemType));
|
|
235
540
|
return {
|
|
236
|
-
|
|
237
|
-
'Shift-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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,9 +1,19 @@
|
|
|
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
|
|
5
|
-
* structure, so HTML wins. A paste as plain text keeps the text
|
|
6
|
-
*
|
|
7
|
-
* re-mints where the
|
|
14
|
+
* structure, so HTML wins. A paste as plain text keeps the text, and so does a parse
|
|
15
|
+
* building a construct no leaf edits (`fitsLeaf`). An island the parse builds arrives
|
|
16
|
+
* under the id `importMarkdown` minted, which the paste's island pass re-mints where the
|
|
17
|
+
* field holds it (`islands.ts`).
|
|
8
18
|
*/
|
|
9
19
|
export declare function markdownPastePlugin(): Plugin;
|