@quillmark/svelte 0.7.3 → 0.9.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/dist/core/codec/blocks.d.ts +5 -1
- package/dist/core/codec/blocks.js +121 -0
- package/dist/core/codec/breaks.d.ts +10 -0
- package/dist/core/codec/breaks.js +37 -0
- package/dist/core/codec/code.d.ts +3 -1
- package/dist/core/codec/code.js +62 -4
- package/dist/core/codec/decode.js +11 -10
- package/dist/core/codec/encode.js +84 -16
- package/dist/core/codec/field.d.ts +5 -4
- package/dist/core/codec/field.js +11 -8
- package/dist/core/codec/islands.d.ts +24 -4
- package/dist/core/codec/islands.js +140 -15
- package/dist/core/codec/keymap.js +2 -1
- package/dist/core/codec/lists.d.ts +5 -1
- package/dist/core/codec/lists.js +77 -14
- package/dist/core/codec/prose.css +6 -12
- package/dist/core/codec/schema.js +150 -9
- package/dist/core/codec/table-view.js +2 -2
- package/dist/core/theme.css +16 -16
- package/dist/visual/ArrayField.svelte +14 -22
- package/dist/visual/BooleanField.svelte +6 -1
- package/dist/visual/Card.svelte +114 -61
- package/dist/visual/CardControls.svelte +31 -26
- package/dist/visual/EnumField.svelte +17 -2
- package/dist/visual/ObjectField.svelte +1 -1
- package/dist/visual/controls.css +63 -28
- package/dist/visual/ext.d.ts +2 -3
- package/dist/visual/ext.js +2 -3
- package/dist/visual/icons/Icon.svelte +2 -3
- package/dist/visual/icons/Icon.svelte.d.ts +2 -3
- package/dist/visual/strings.d.ts +1 -1
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Attrs, Node as PMNode } from 'prosemirror-model';
|
|
1
|
+
import type { Attrs, Node as PMNode, 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`. */
|
|
@@ -35,3 +35,7 @@ export declare function consuming(state: EditorState, from: number, to: number,
|
|
|
35
35
|
tr: Transaction;
|
|
36
36
|
produced: Transaction;
|
|
37
37
|
} | null;
|
|
38
|
+
/** The body-wide link of the leaf's key chains: `{}` for a schema with no paragraph
|
|
39
|
+
* to mint. Outermost of the structural links, being about the body, or about the
|
|
40
|
+
* caret's own line, rather than about the surface either stands in. */
|
|
41
|
+
export declare function blockKeymap(schema: Schema): Record<string, Command>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Selection } from 'prosemirror-state';
|
|
2
|
+
import { chainCommands, splitBlock } from 'prosemirror-commands';
|
|
2
3
|
import { canJoin, findWrapping } from 'prosemirror-transform';
|
|
3
4
|
/** The head of a textblock: where a shorthand fires, and where a pick lands once its
|
|
4
5
|
* run is consumed. */
|
|
@@ -161,3 +162,123 @@ export function consuming(state, from, to, command) {
|
|
|
161
162
|
tr.setSelection(Selection.fromJSON(tr.doc, produced.selection.toJSON()));
|
|
162
163
|
return { tr, produced };
|
|
163
164
|
}
|
|
165
|
+
/** The head of the body's first block: one position per open token above the caret,
|
|
166
|
+
* and nothing before it. */
|
|
167
|
+
function atBodyStart($from) {
|
|
168
|
+
return $from.pos === $from.depth;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Enter at the very start of a body whose first block takes no caret above it → an
|
|
172
|
+
* empty paragraph there, the caret staying with the text it pushed down.
|
|
173
|
+
*
|
|
174
|
+
* A code block, a quote and a list are the blocks `prosemirror-gapcursor` declines
|
|
175
|
+
* to sit beside, and nothing else mints a block above one: three keystrokes on an
|
|
176
|
+
* empty body (` ``` `, `> `, `- `) put one first, and a document carrying one there
|
|
177
|
+
* can never be given a title. The argument is `paragraphAboveList`'s, about the
|
|
178
|
+
* block rather than about lists, so this link comes first and that one holds the
|
|
179
|
+
* position it alone reaches, a list under an island or a rule.
|
|
180
|
+
*
|
|
181
|
+
* Declines wherever the first block is a plain textblock, whose Enter-split already
|
|
182
|
+
* leaves a paragraph above, and on an empty one, which the links after it answer.
|
|
183
|
+
*/
|
|
184
|
+
const paragraphAtBodyStart = (state, dispatch) => {
|
|
185
|
+
const paragraph = state.schema.nodes.paragraph;
|
|
186
|
+
const { $from, empty } = state.selection;
|
|
187
|
+
if (!paragraph || !empty || !atBodyStart($from))
|
|
188
|
+
return false;
|
|
189
|
+
if ($from.parent.content.size === 0)
|
|
190
|
+
return false;
|
|
191
|
+
if ($from.depth === 1 && $from.parent.isTextblock && !$from.parent.type.spec.code)
|
|
192
|
+
return false;
|
|
193
|
+
dispatch?.(state.tr.insert(0, paragraph.create()).scrollIntoView());
|
|
194
|
+
return true;
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* Enter in an empty fence that opens the body → the fence, retyped to a paragraph.
|
|
198
|
+
*
|
|
199
|
+
* An empty first item exits its list and an empty first quoted paragraph lifts out of
|
|
200
|
+
* the quote, each leaving the one paragraph a caret above needs. A fence answers with
|
|
201
|
+
* `newlineInCode` instead — a line inside the block, in the direction already open —
|
|
202
|
+
* so the empty fence a body's first ` ``` ` mints holds a caret with nothing above it
|
|
203
|
+
* and no key that mints one.
|
|
204
|
+
*
|
|
205
|
+
* Retyping rather than inserting above, for the reason those exits leave one paragraph
|
|
206
|
+
* and not two: an empty fence carries nothing to keep.
|
|
207
|
+
*/
|
|
208
|
+
const paragraphFromEmptyFence = (state, dispatch) => {
|
|
209
|
+
const paragraph = state.schema.nodes.paragraph;
|
|
210
|
+
const { $from, empty } = state.selection;
|
|
211
|
+
if (!paragraph || !empty || !atBodyStart($from))
|
|
212
|
+
return false;
|
|
213
|
+
if (!$from.parent.type.spec.code || $from.parent.content.size !== 0)
|
|
214
|
+
return false;
|
|
215
|
+
if (!fits($from, paragraph))
|
|
216
|
+
return false;
|
|
217
|
+
dispatch?.(state.tr.setBlockType($from.pos, $from.pos, paragraph).scrollIntoView());
|
|
218
|
+
return true;
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* Enter over a selection spanning two textblocks: take the selection, then split
|
|
222
|
+
* what is left, both in one transaction.
|
|
223
|
+
*
|
|
224
|
+
* The edit upstream's `splitBlock` means, computed against a document that exists.
|
|
225
|
+
* It deletes the selection inside its own transaction and then reads the default
|
|
226
|
+
* block's fit off the *pre-delete* document — `$from.node(splitDepth - 1)` against an
|
|
227
|
+
* index resolved in the new one — so where the range starts at the head of a
|
|
228
|
+
* `defining` block and ends inside a list, it retypes a `list_item` to a paragraph
|
|
229
|
+
* and throws `RangeError` out of the keystroke.
|
|
230
|
+
*/
|
|
231
|
+
const splitAcrossBlocks = (state, dispatch) => {
|
|
232
|
+
const { $from, $to, empty } = state.selection;
|
|
233
|
+
if (empty || $from.parent === $to.parent)
|
|
234
|
+
return false;
|
|
235
|
+
if (!dispatch)
|
|
236
|
+
return true;
|
|
237
|
+
const tr = state.tr.deleteSelection();
|
|
238
|
+
splitBlock(state.apply(tr), (split) => {
|
|
239
|
+
for (const step of split.steps)
|
|
240
|
+
tr.step(step);
|
|
241
|
+
tr.setSelection(Selection.fromJSON(tr.doc, split.selection.toJSON()));
|
|
242
|
+
});
|
|
243
|
+
dispatch(tr.scrollIntoView());
|
|
244
|
+
return true;
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* Delete on an empty textblock → take the line, whatever stands below moving up
|
|
248
|
+
* whole and keeping what it is.
|
|
249
|
+
*
|
|
250
|
+
* The links below it leave the line standing: the base keymap joins the block below
|
|
251
|
+
* into it — a list's first item lifted out to fill it, its marker gone — and the atom
|
|
252
|
+
* link arms an island for the press after. Upstream takes the line only where the
|
|
253
|
+
* sibling holds the same kind of content, so a heading below keeps its level where a
|
|
254
|
+
* bullet loses its marker.
|
|
255
|
+
*
|
|
256
|
+
* Declines where the parent cannot spare the block: an empty item and an empty quote
|
|
257
|
+
* are the whole of what holds them, and the key is whichever link answers a construct
|
|
258
|
+
* with nothing in it. An empty fence is not that case — the doc spares it, and it
|
|
259
|
+
* carries no text to lose. Declines with nothing after it too: a line with no next
|
|
260
|
+
* line is Backspace's.
|
|
261
|
+
*/
|
|
262
|
+
const takeEmptyLine = (state, dispatch) => {
|
|
263
|
+
const { $from, empty } = state.selection;
|
|
264
|
+
if (!empty || !$from.parent.isTextblock || $from.parent.content.size !== 0)
|
|
265
|
+
return false;
|
|
266
|
+
const index = $from.index(-1);
|
|
267
|
+
if (!$from.node(-1).canReplace(index, index + 1))
|
|
268
|
+
return false;
|
|
269
|
+
if (!Selection.findFrom(state.doc.resolve($from.after()), 1))
|
|
270
|
+
return false;
|
|
271
|
+
dispatch?.(state.tr.delete($from.before(), $from.after()).scrollIntoView());
|
|
272
|
+
return true;
|
|
273
|
+
};
|
|
274
|
+
/** The body-wide link of the leaf's key chains: `{}` for a schema with no paragraph
|
|
275
|
+
* to mint. Outermost of the structural links, being about the body, or about the
|
|
276
|
+
* caret's own line, rather than about the surface either stands in. */
|
|
277
|
+
export function blockKeymap(schema) {
|
|
278
|
+
if (!schema.nodes.paragraph)
|
|
279
|
+
return {};
|
|
280
|
+
return {
|
|
281
|
+
Enter: chainCommands(paragraphAtBodyStart, paragraphFromEmptyFence, splitAcrossBlocks),
|
|
282
|
+
Delete: takeEmptyLine
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Schema } from 'prosemirror-model';
|
|
2
|
+
import { Plugin } from 'prosemirror-state';
|
|
3
|
+
/**
|
|
4
|
+
* Rewrite every `\n` a transaction leaves in a non-`code` textblock to a `hard_break`.
|
|
5
|
+
*
|
|
6
|
+
* Reads the schema rather than a flag: the inline schemas declare neither node, and
|
|
7
|
+
* nothing puts a `\n` in one — a paste parses its whitespace away and the keys type
|
|
8
|
+
* characters.
|
|
9
|
+
*/
|
|
10
|
+
export declare function linebreakPlugin(schema: Schema): Plugin;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Plugin } from 'prosemirror-state';
|
|
2
|
+
/**
|
|
3
|
+
* Rewrite every `\n` a transaction leaves in a non-`code` textblock to a `hard_break`.
|
|
4
|
+
*
|
|
5
|
+
* Reads the schema rather than a flag: the inline schemas declare neither node, and
|
|
6
|
+
* nothing puts a `\n` in one — a paste parses its whitespace away and the keys type
|
|
7
|
+
* characters.
|
|
8
|
+
*/
|
|
9
|
+
export function linebreakPlugin(schema) {
|
|
10
|
+
const br = schema.nodes.hard_break;
|
|
11
|
+
return new Plugin({
|
|
12
|
+
appendTransaction(trs, _before, state) {
|
|
13
|
+
if (!br || !trs.some((tr) => tr.docChanged))
|
|
14
|
+
return null;
|
|
15
|
+
const at = [];
|
|
16
|
+
state.doc.descendants((node, pos) => {
|
|
17
|
+
// A code block's newlines are its content (`whitespace: 'pre'`).
|
|
18
|
+
if (node.type.spec.code)
|
|
19
|
+
return false;
|
|
20
|
+
if (!node.isText)
|
|
21
|
+
return true;
|
|
22
|
+
const text = node.text ?? '';
|
|
23
|
+
for (let i = text.indexOf('\n'); i >= 0; i = text.indexOf('\n', i + 1))
|
|
24
|
+
at.push(pos + i);
|
|
25
|
+
return true;
|
|
26
|
+
});
|
|
27
|
+
if (!at.length)
|
|
28
|
+
return null;
|
|
29
|
+
// A break and the `\n` it replaces are both one position wide, so an earlier
|
|
30
|
+
// replacement leaves every later one addressed.
|
|
31
|
+
const tr = state.tr;
|
|
32
|
+
for (const pos of at)
|
|
33
|
+
tr.replaceWith(pos, pos + 1, br.create());
|
|
34
|
+
return tr;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -7,6 +7,8 @@ import { type Command } from 'prosemirror-state';
|
|
|
7
7
|
* `Enter` is upstream's `newlineInCode`, and it is load-bearing rather than
|
|
8
8
|
* cosmetic: inside a `list_item > code_block` the list link's `splitListItem`
|
|
9
9
|
* otherwise splits the item in two, each half holding a code block. Every command
|
|
10
|
-
* here declines
|
|
10
|
+
* here declines away from a code block — inside one for the three keys about its
|
|
11
|
+
* text, against one for the two about its edge — so the list links keep the keys
|
|
12
|
+
* elsewhere.
|
|
11
13
|
*/
|
|
12
14
|
export declare function codeKeymap(schema: Schema): Record<string, Command>;
|
package/dist/core/codec/code.js
CHANGED
|
@@ -76,8 +76,13 @@ const indentInCode = (state, dispatch) => {
|
|
|
76
76
|
* Shift-Tab in a code block: remove one indent level from every covered line.
|
|
77
77
|
*
|
|
78
78
|
* Declines when no covered line carries an indent, so the key is not swallowed: it
|
|
79
|
-
* falls through to the list link and
|
|
80
|
-
* keyboard exit from inside a code block (VISUAL_EDITOR §Chrome).
|
|
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.
|
|
81
86
|
*/
|
|
82
87
|
const outdentInCode = (state, dispatch) => {
|
|
83
88
|
const block = codeBlockAt(state);
|
|
@@ -98,6 +103,55 @@ const outdentInCode = (state, dispatch) => {
|
|
|
98
103
|
}
|
|
99
104
|
return true;
|
|
100
105
|
};
|
|
106
|
+
/**
|
|
107
|
+
* The textblock the caret's own block stands directly against on `side`: its sibling
|
|
108
|
+
* under one parent, which is the only neighbour a press joins the text of. `null` at a
|
|
109
|
+
* container's edge, where the base keymap moves the whole block in or out rather than
|
|
110
|
+
* joining anything, and so retypes nothing.
|
|
111
|
+
*/
|
|
112
|
+
function blockAgainst(state, side) {
|
|
113
|
+
const { $from, empty } = state.selection;
|
|
114
|
+
if (!empty || !$from.parent.isTextblock)
|
|
115
|
+
return null;
|
|
116
|
+
if ($from.parentOffset !== (side < 0 ? 0 : $from.parent.content.size))
|
|
117
|
+
return null;
|
|
118
|
+
const $edge = state.doc.resolve(side < 0 ? $from.before() : $from.after());
|
|
119
|
+
const node = side < 0 ? $edge.nodeBefore : $edge.nodeAfter;
|
|
120
|
+
if (!node?.isTextblock)
|
|
121
|
+
return null;
|
|
122
|
+
return { node, pos: side < 0 ? $edge.pos - node.nodeSize : $edge.pos };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Backspace at a fence's head or Delete at its end, and the same two presses from the
|
|
126
|
+
* block on the other side: retype the code block to a paragraph, and join nothing. The
|
|
127
|
+
* press after that is an ordinary join between two prose blocks.
|
|
128
|
+
*
|
|
129
|
+
* The join itself retypes one side's whole text as the other's kind on one keystroke —
|
|
130
|
+
* a fence's lines as prose, or a typed line as code — which is the destruction
|
|
131
|
+
* `atoms.ts` refuses for a neighbouring island, with nothing drawn first to say what is
|
|
132
|
+
* about to go. This is `toCodeBlock`'s inverse in its place (`blocks.ts`), and it takes
|
|
133
|
+
* no text with it: `linebreakReplacement` carries the fence's newlines across as breaks
|
|
134
|
+
* (`schema.ts`). The caret stays in the block it was in.
|
|
135
|
+
*
|
|
136
|
+
* Declines where both sides are code and where neither is: those joins retype nothing.
|
|
137
|
+
* An empty block on either side never reaches this at all: the block link takes the
|
|
138
|
+
* empty line ahead of the chain (`blocks.ts`), and there is no text to retype.
|
|
139
|
+
*/
|
|
140
|
+
function retypeAtProseEdge(side) {
|
|
141
|
+
return (state, dispatch) => {
|
|
142
|
+
const paragraph = state.schema.nodes.paragraph;
|
|
143
|
+
const edge = blockAgainst(state, side);
|
|
144
|
+
if (!paragraph || !edge)
|
|
145
|
+
return false;
|
|
146
|
+
const { $from } = state.selection;
|
|
147
|
+
const inside = !!$from.parent.type.spec.code;
|
|
148
|
+
if (inside === !!edge.node.type.spec.code)
|
|
149
|
+
return false;
|
|
150
|
+
const at = inside ? $from.pos : edge.pos + 1;
|
|
151
|
+
dispatch?.(state.tr.setBlockType(at, at, paragraph).scrollIntoView());
|
|
152
|
+
return true;
|
|
153
|
+
};
|
|
154
|
+
}
|
|
101
155
|
/**
|
|
102
156
|
* The `code_block` link of the body's key chains: `{}` for the inline/plaintext
|
|
103
157
|
* schemas, which declare no code node.
|
|
@@ -105,7 +159,9 @@ const outdentInCode = (state, dispatch) => {
|
|
|
105
159
|
* `Enter` is upstream's `newlineInCode`, and it is load-bearing rather than
|
|
106
160
|
* cosmetic: inside a `list_item > code_block` the list link's `splitListItem`
|
|
107
161
|
* otherwise splits the item in two, each half holding a code block. Every command
|
|
108
|
-
* here declines
|
|
162
|
+
* here declines away from a code block — inside one for the three keys about its
|
|
163
|
+
* text, against one for the two about its edge — so the list links keep the keys
|
|
164
|
+
* elsewhere.
|
|
109
165
|
*/
|
|
110
166
|
export function codeKeymap(schema) {
|
|
111
167
|
if (!schema.nodes.code_block)
|
|
@@ -113,6 +169,8 @@ export function codeKeymap(schema) {
|
|
|
113
169
|
return {
|
|
114
170
|
Tab: indentInCode,
|
|
115
171
|
'Shift-Tab': outdentInCode,
|
|
116
|
-
Enter: newlineInCode
|
|
172
|
+
Enter: newlineInCode,
|
|
173
|
+
Backspace: retypeAtProseEdge(-1),
|
|
174
|
+
Delete: retypeAtProseEdge(1)
|
|
117
175
|
};
|
|
118
176
|
}
|
|
@@ -104,18 +104,16 @@ function groupBlocks(schema, leaves, depth, marks, cursor) {
|
|
|
104
104
|
// Gather the maximal run of sibling `list_item` leaves at this depth
|
|
105
105
|
// (same ordered/start), then split it into items by `ordinal`.
|
|
106
106
|
const { ordered, start } = here;
|
|
107
|
+
const instance = here.instance ?? 0;
|
|
107
108
|
let j = i + 1;
|
|
108
|
-
let prevOrd = here.ordinal;
|
|
109
109
|
while (j < leaves.length) {
|
|
110
110
|
const c = atDepth(leaves[j], depth);
|
|
111
111
|
if (!c || !isListItemContainer(c) || c.ordered !== ordered || c.start !== start)
|
|
112
112
|
break;
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
if (c.ordinal < prevOrd)
|
|
113
|
+
// `instance` is the boundary between two adjacent lists; the normalizer
|
|
114
|
+
// numbers a run's `ordinal`s gaplessly from 0, so a reset carries none.
|
|
115
|
+
if ((c.instance ?? 0) !== instance)
|
|
117
116
|
break;
|
|
118
|
-
prevOrd = c.ordinal;
|
|
119
117
|
j++;
|
|
120
118
|
}
|
|
121
119
|
const run = leaves.slice(i, j);
|
|
@@ -162,11 +160,14 @@ function ordinalAt(leaf, depth) {
|
|
|
162
160
|
const c = atDepth(leaf, depth);
|
|
163
161
|
return c && isListItemContainer(c) ? c.ordinal : -1;
|
|
164
162
|
}
|
|
165
|
-
/** Identity of a container for run gathering: its name
|
|
166
|
-
* as `markKey` joins a mark's, so no `attrs` content can forge a name
|
|
167
|
-
*
|
|
163
|
+
/** Identity of a container for run gathering: its name, its `instance` and its payload,
|
|
164
|
+
* NUL-joined as `markKey` joins a mark's, so no `attrs` content can forge a name
|
|
165
|
+
* boundary. `instance` is what tells one container from an adjacent sibling of identical
|
|
166
|
+
* shape, which contiguity alone reads as one. Every arm but `list_item` (which has its
|
|
167
|
+
* own run rule) keys here. */
|
|
168
168
|
function containerKey(c) {
|
|
169
|
-
|
|
169
|
+
const id = `${c.container}\u0000${c.instance ?? 0}`;
|
|
170
|
+
return 'attrs' in c ? `${id}\u0000${JSON.stringify(c.attrs)}` : id;
|
|
170
171
|
}
|
|
171
172
|
/** A single leaf block node (para/heading/code/rule/island, or an unknown kind
|
|
172
173
|
* carried on a paragraph) from its segments. `kind` is an open set, so the two
|
|
@@ -35,15 +35,46 @@ export function pmToContent(doc) {
|
|
|
35
35
|
const { text, lines, marks, islands } = scanDoc(doc);
|
|
36
36
|
return { text, lines, marks, islands };
|
|
37
37
|
}
|
|
38
|
-
/** Walk a block-content fragment; `contentStart` is the PM pos before its first child.
|
|
38
|
+
/** Walk a block-content fragment; `contentStart` is the PM pos before its first child.
|
|
39
|
+
* Two container siblings of one run shape read as a single container — contiguity plus
|
|
40
|
+
* an equal path is what the store means by "the same one" — so each alternates its
|
|
41
|
+
* `instance` against the sibling before it. Sibling adjacency is the whole scope: a
|
|
42
|
+
* container one level in has a differing path above it, which already tells it apart. */
|
|
39
43
|
function scanBlocks(acc, parent, contentStart, containers) {
|
|
40
44
|
let pm = contentStart;
|
|
45
|
+
let prevShape = null;
|
|
46
|
+
let instance = 0;
|
|
41
47
|
parent.forEach((child) => {
|
|
42
|
-
|
|
48
|
+
const shape = runShape(child);
|
|
49
|
+
instance = shape !== null && shape === prevShape ? instance ^ 1 : 0;
|
|
50
|
+
prevShape = shape;
|
|
51
|
+
scanBlock(acc, child, pm, containers, instance);
|
|
43
52
|
pm += child.nodeSize;
|
|
44
53
|
});
|
|
45
54
|
}
|
|
46
|
-
|
|
55
|
+
/** A block's container run shape, `ordinal` and `instance` aside; null where it opens
|
|
56
|
+
* no container. A list's `start` is not in it — the store welds two runs differing only
|
|
57
|
+
* there — and an unknown's `attrs` is, as `containerKey` reads it on the way back. */
|
|
58
|
+
function runShape(node) {
|
|
59
|
+
switch (node.type.name) {
|
|
60
|
+
case 'blockquote':
|
|
61
|
+
return 'quote';
|
|
62
|
+
case 'bullet_list':
|
|
63
|
+
return 'list\u0000bullet';
|
|
64
|
+
case 'ordered_list':
|
|
65
|
+
return 'list\u0000ordered';
|
|
66
|
+
case 'unknown_container':
|
|
67
|
+
return `unknown\u0000${node.attrs.container}\u0000${JSON.stringify(node.attrs.attrs)}`;
|
|
68
|
+
default:
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** `instance` where it tells a container from an identical adjacent sibling. The
|
|
73
|
+
* canonical form omits a zero, so emitting one would diff against every store read. */
|
|
74
|
+
function discriminate(container, instance) {
|
|
75
|
+
return instance ? { ...container, instance } : container;
|
|
76
|
+
}
|
|
77
|
+
function scanBlock(acc, node, nodePos, containers, instance) {
|
|
47
78
|
const name = node.type.name;
|
|
48
79
|
const contentStart = nodePos + 1;
|
|
49
80
|
switch (name) {
|
|
@@ -84,12 +115,15 @@ function scanBlock(acc, node, nodePos, containers) {
|
|
|
84
115
|
acc.lastContentEndPm = nodePos + 1;
|
|
85
116
|
break;
|
|
86
117
|
case 'blockquote':
|
|
87
|
-
scanBlocks(acc, node, contentStart, [
|
|
118
|
+
scanBlocks(acc, node, contentStart, [
|
|
119
|
+
...containers,
|
|
120
|
+
discriminate({ container: 'quote' }, instance)
|
|
121
|
+
]);
|
|
88
122
|
break;
|
|
89
123
|
case 'unknown_container':
|
|
90
124
|
scanBlocks(acc, node, contentStart, [
|
|
91
125
|
...containers,
|
|
92
|
-
{ container: node.attrs.container, attrs: node.attrs.attrs }
|
|
126
|
+
discriminate({ container: node.attrs.container, attrs: node.attrs.attrs }, instance)
|
|
93
127
|
]);
|
|
94
128
|
break;
|
|
95
129
|
case 'bullet_list':
|
|
@@ -98,12 +132,7 @@ function scanBlock(acc, node, nodePos, containers) {
|
|
|
98
132
|
const start = ordered ? node.attrs.start : 1;
|
|
99
133
|
let itemPos = contentStart;
|
|
100
134
|
node.forEach((item, _off, index) => {
|
|
101
|
-
const container = {
|
|
102
|
-
container: 'list_item',
|
|
103
|
-
ordered,
|
|
104
|
-
start,
|
|
105
|
-
ordinal: index
|
|
106
|
-
};
|
|
135
|
+
const container = discriminate({ container: 'list_item', ordered, start, ordinal: index }, instance);
|
|
107
136
|
scanBlocks(acc, item, itemPos + 1, [...containers, container]);
|
|
108
137
|
itemPos += item.nodeSize;
|
|
109
138
|
});
|
|
@@ -141,7 +170,16 @@ function scanInline(acc, block, contentStart, containers, kind) {
|
|
|
141
170
|
let pm = contentStart;
|
|
142
171
|
block.forEach((child) => {
|
|
143
172
|
if (child.isText) {
|
|
144
|
-
|
|
173
|
+
const text = child.text ?? '';
|
|
174
|
+
emitText(acc, text, pm, child.marks);
|
|
175
|
+
// A `\n` in a textblock that is not `whitespace: 'pre'` is the same line
|
|
176
|
+
// boundary a hard break is: `paragraph` is `inline*`, so the schema admits
|
|
177
|
+
// one, and `joinTextblocksAround` — a bare `replaceStep` running neither
|
|
178
|
+
// `clearIncompatible` nor `replaceNewlines` — leaves one. It rides the text
|
|
179
|
+
// run, where PM and USV advance together.
|
|
180
|
+
const breaks = text.split('\n').length - 1;
|
|
181
|
+
for (let i = 0; i < breaks; i++)
|
|
182
|
+
acc.lines.push({ containers, continues: true, ...kind });
|
|
145
183
|
}
|
|
146
184
|
else if (child.type.name === 'hard_break') {
|
|
147
185
|
// A within-block hard break: the content `\n` (a `continues` line).
|
|
@@ -196,7 +234,7 @@ export function contentEdit(oldRt, newRt) {
|
|
|
196
234
|
export function lower(edit, opts = {}) {
|
|
197
235
|
const { oldRt, newRt } = edit;
|
|
198
236
|
const { delta, islandOps, insertedAt } = splitIslands(edit);
|
|
199
|
-
const lineOps = diffLines(oldRt, newRt);
|
|
237
|
+
const lineOps = diffLines(oldRt, newRt, edit.delta);
|
|
200
238
|
const markOps = diffMarks(oldRt, newRt, delta, insertedAt, opts);
|
|
201
239
|
const bundle = {};
|
|
202
240
|
if (delta)
|
|
@@ -235,9 +273,39 @@ function diffText(oldText, newText) {
|
|
|
235
273
|
ops.push({ retain: s });
|
|
236
274
|
return { ops };
|
|
237
275
|
}
|
|
238
|
-
/**
|
|
239
|
-
|
|
240
|
-
|
|
276
|
+
/**
|
|
277
|
+
* Whether the splice moves a line boundary — a `\n` deleted, or one inserted. Where
|
|
278
|
+
* it does not, every line keeps the metadata it had and equal metadata on both sides
|
|
279
|
+
* is the whole story. Where it does, the store's split/join inheritance decides what
|
|
280
|
+
* each line ends up carrying, and an array that still matches position for position
|
|
281
|
+
* says nothing about it: one splice that both joins a boundary and opens another
|
|
282
|
+
* leaves the line count and the metadata array unchanged while the kinds slide.
|
|
283
|
+
*/
|
|
284
|
+
function movesBoundary(oldText, delta) {
|
|
285
|
+
if (!delta)
|
|
286
|
+
return false;
|
|
287
|
+
let at = 0;
|
|
288
|
+
let cps;
|
|
289
|
+
for (const op of delta.ops) {
|
|
290
|
+
if ('retain' in op)
|
|
291
|
+
at += op.retain;
|
|
292
|
+
else if ('insert' in op) {
|
|
293
|
+
if (op.insert.includes('\n'))
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
cps ??= codePoints(oldText);
|
|
298
|
+
if (cps.slice(at, at + op.delete).includes('\n'))
|
|
299
|
+
return true;
|
|
300
|
+
at += op.delete;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
/** Per-line `setContainers` / `setKind` / `setContinues` when line metadata changed or
|
|
306
|
+
* the splice moved a boundary; else none. */
|
|
307
|
+
function diffLines(oldRt, newRt, delta) {
|
|
308
|
+
if (!movesBoundary(oldRt.text, delta) && lineMetaEqual(oldRt.lines, newRt.lines))
|
|
241
309
|
return [];
|
|
242
310
|
const ops = [];
|
|
243
311
|
// Force every new line's metadata. Redundant ops are safe no-ops (verified),
|
|
@@ -152,10 +152,11 @@ export declare function createField(opts: CreateFieldOpts): FieldController;
|
|
|
152
152
|
* {@link createField} and the by-value inline editor (`ProseValue`), so the two
|
|
153
153
|
* never fork the keymap/plugin ordering. History first, then any leaf-specific
|
|
154
154
|
* plugins (`afterHistory`: the addressed leaf passes its anchor-position plugin;
|
|
155
|
-
* a by-value leaf passes none),
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
155
|
+
* a by-value leaf passes none), then {@link linebreakPlugin}, which normalizes what
|
|
156
|
+
* the rest of the stack leaves, then the markdown-shorthand input rules, the field
|
|
157
|
+
* keymap over the base keymap, and last the three that answer to a block leaf's own
|
|
158
|
+
* shapes: the gap cursor, {@link pastAtomPlugin}, and the island paste pass
|
|
159
|
+
* (`islands.ts`).
|
|
159
160
|
*
|
|
160
161
|
* Every mark-shaped plugin reads the schema rather than a flag: over
|
|
161
162
|
* `plaintextSchema` the shorthand rules build nothing (each is guarded on its mark
|
package/dist/core/codec/field.js
CHANGED
|
@@ -22,9 +22,11 @@ import { reportError, errorMessage } from '../errors.js';
|
|
|
22
22
|
import { decode } from './decode.js';
|
|
23
23
|
import { usvToPM, pmToUsv, buildLineIndex } from './positions.js';
|
|
24
24
|
import { lower, pmToContent, contentEdit } from './encode.js';
|
|
25
|
+
import { islandPastePlugin } from './islands.js';
|
|
25
26
|
import { anchorsFromContent } from './marks.js';
|
|
26
27
|
import { createReconciler } from './reconcile.js';
|
|
27
28
|
import { inputRulesPlugin } from './inputrules.js';
|
|
29
|
+
import { linebreakPlugin } from './breaks.js';
|
|
28
30
|
import { bodyKeymap } from './keymap.js';
|
|
29
31
|
import { blockSchema, inlineSchema, plaintextSchema } from './schema.js';
|
|
30
32
|
import { DEFAULT_TABLE_STRINGS, tableNodeView } from './table-view.js';
|
|
@@ -391,10 +393,11 @@ export function createField(opts) {
|
|
|
391
393
|
* {@link createField} and the by-value inline editor (`ProseValue`), so the two
|
|
392
394
|
* never fork the keymap/plugin ordering. History first, then any leaf-specific
|
|
393
395
|
* plugins (`afterHistory`: the addressed leaf passes its anchor-position plugin;
|
|
394
|
-
* a by-value leaf passes none),
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
396
|
+
* a by-value leaf passes none), then {@link linebreakPlugin}, which normalizes what
|
|
397
|
+
* the rest of the stack leaves, then the markdown-shorthand input rules, the field
|
|
398
|
+
* keymap over the base keymap, and last the three that answer to a block leaf's own
|
|
399
|
+
* shapes: the gap cursor, {@link pastAtomPlugin}, and the island paste pass
|
|
400
|
+
* (`islands.ts`).
|
|
398
401
|
*
|
|
399
402
|
* Every mark-shaped plugin reads the schema rather than a flag: over
|
|
400
403
|
* `plaintextSchema` the shorthand rules build nothing (each is guarded on its mark
|
|
@@ -402,17 +405,17 @@ export function createField(opts) {
|
|
|
402
405
|
* typed without a second rule saying so.
|
|
403
406
|
*/
|
|
404
407
|
export function proseLeafPlugins(schema, opts) {
|
|
405
|
-
const list = [history(), ...(opts.afterHistory ?? [])];
|
|
408
|
+
const list = [history(), ...(opts.afterHistory ?? []), linebreakPlugin(schema)];
|
|
406
409
|
if (opts.slash)
|
|
407
410
|
list.push(slashPlugin(opts.slash));
|
|
408
411
|
if (!opts.noInputRules)
|
|
409
412
|
list.push(inputRulesPlugin(schema));
|
|
410
413
|
list.push(keymap(editorKeymap(schema, opts.inline, !!opts.slash)));
|
|
411
414
|
list.push(keymap(baseKeymap));
|
|
412
|
-
//
|
|
413
|
-
// with no island and no gap to put a cursor in.
|
|
415
|
+
// All three answer to something only a block leaf holds: an inline leaf is one
|
|
416
|
+
// textblock with no island and no gap to put a cursor in.
|
|
414
417
|
if (!opts.inline)
|
|
415
|
-
list.push(gapCursor(), pastAtomPlugin());
|
|
418
|
+
list.push(gapCursor(), pastAtomPlugin(), islandPastePlugin());
|
|
416
419
|
if (opts.placeholder)
|
|
417
420
|
list.push(placeholderPlugin(opts.placeholder));
|
|
418
421
|
return list;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Node as PMNode, type NodeSpec } from 'prosemirror-model';
|
|
2
|
+
import { Plugin } from 'prosemirror-state';
|
|
2
3
|
import type { ContentIsland, TableProps } from '@quillmark/wasm';
|
|
3
4
|
/** The `U+FFFC` object-replacement char that occupies one island slot in `text`. */
|
|
4
5
|
export declare const ISLAND_SLOT = "\uFFFC";
|
|
@@ -18,9 +19,11 @@ export interface IslandNodeAttrs {
|
|
|
18
19
|
}
|
|
19
20
|
/** Build a content island entry from a PM island node's attrs (block or inline). */
|
|
20
21
|
export declare function islandEntryFromNode(attrs: IslandNodeAttrs): ContentIsland;
|
|
21
|
-
/** A node's `TableProps`, or `undefined` for any other island
|
|
22
|
-
* guard over the entry the node carries, so the open
|
|
23
|
-
* rather than at each reader.
|
|
22
|
+
/** A node's `TableProps`, or `undefined` for any other island and for a payload of the
|
|
23
|
+
* wrong shape: the boundary's own guard over the entry the node carries, so the open
|
|
24
|
+
* `type` arm narrows once here rather than at each reader. The type says which reader,
|
|
25
|
+
* the shape says whether it can read; every caller draws the placeholder for
|
|
26
|
+
* `undefined` (`table-view.ts` §`render`). */
|
|
24
27
|
export declare function tablePropsOfNode(node: PMNode): TableProps | undefined;
|
|
25
28
|
/**
|
|
26
29
|
* A minter over one document: the positional `isl-{n}` sequence continued past the
|
|
@@ -38,3 +41,20 @@ export declare function islandMinter(doc: PMNode): () => string;
|
|
|
38
41
|
/** The next island id for a field: one draw from {@link islandMinter}, which is what a
|
|
39
42
|
* command placing exactly one island wants. */
|
|
40
43
|
export declare function mintIslandId(doc: PMNode): string;
|
|
44
|
+
/**
|
|
45
|
+
* The paste's island pass: an id the field already holds is re-minted on the way in.
|
|
46
|
+
*
|
|
47
|
+
* An id is an identity in the content and unique across the field, so two islands
|
|
48
|
+
* wearing one is a projection the store has no shape for. A copy rather than a cut is
|
|
49
|
+
* where that arrives, and a paste out of a second field is the other; a cut pasted back
|
|
50
|
+
* collides with nothing and keeps the identity it had.
|
|
51
|
+
*
|
|
52
|
+
* The test is the document as it stands, which a drag moving a selection over an island
|
|
53
|
+
* reads one too many: the original is still there when the slice is transformed and gone
|
|
54
|
+
* when it lands, so that island arrives under a fresh id. A fresh id costs it nothing a
|
|
55
|
+
* duplicate would cost the whole field.
|
|
56
|
+
*
|
|
57
|
+
* A plugin prop rather than a view option: the leaf mounts the codec's plugin stack, and
|
|
58
|
+
* which nodes carry an identity is the codec's to know.
|
|
59
|
+
*/
|
|
60
|
+
export declare function islandPastePlugin(): Plugin;
|