@quillmark/svelte 0.8.0 → 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/schema.js +150 -9
- package/dist/core/codec/table-view.js +2 -2
- 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;
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
// Islands: the `U+FFFC` slot + `ContentIsland` entry ↔ a PM leaf node. A slot
|
|
2
|
+
// inside a `para` line is an inline island (an image); an `island`-kind line is a
|
|
3
|
+
// block island (a table). The node carries `id`, `islandType`, and the opaque
|
|
4
|
+
// `props` verbatim, so an unknown island type round-trips untouched.
|
|
5
|
+
//
|
|
6
|
+
// A props-aware consumer reads the shape straight off the boundary:
|
|
7
|
+
// `ContentIsland.props` is the typed union (`TableProps` for `table`,
|
|
8
|
+
// `ImageProps` for `image`; DOCUMENT_MODEL §Stability seams), so the codec
|
|
9
|
+
// reads it rather than hand-rolling a shape or guard. The open `type` arm means
|
|
10
|
+
// a discriminant check does not auto-narrow `props`; key off `type` and read
|
|
11
|
+
// `props` as the matching upstream type.
|
|
12
|
+
import { Fragment, Slice } from 'prosemirror-model';
|
|
13
|
+
import { Plugin } from 'prosemirror-state';
|
|
1
14
|
import { isTableIsland } from '@quillmark/wasm';
|
|
2
15
|
/** The `U+FFFC` object-replacement char that occupies one island slot in `text`. */
|
|
3
16
|
export const ISLAND_SLOT = '';
|
|
@@ -16,17 +29,52 @@ const islandAttrs = {
|
|
|
16
29
|
props: { default: null },
|
|
17
30
|
loss: { default: 'unrepresentable' }
|
|
18
31
|
};
|
|
32
|
+
// All four attributes cross the DOM: a copy and a paste inside one body run the document
|
|
33
|
+
// through `toDOM`/`parseDOM` (CODEC §"Markdown at the edges"), and an island is a leaf
|
|
34
|
+
// whose tags alone say only `[table]`. `props` crosses as JSON, the form the payload has
|
|
35
|
+
// at the boundary. The names are `data-qm-*` like every other carrier's, and the id is
|
|
36
|
+
// what the rule requires: an island is a leaf, so a rule reading a tag someone else
|
|
37
|
+
// chose would swallow that element's whole subtree.
|
|
38
|
+
/** The DOM an island node writes and reads back, for either spec. */
|
|
39
|
+
function islandDOM(node) {
|
|
40
|
+
return {
|
|
41
|
+
'data-qm-island': node.attrs.islandType,
|
|
42
|
+
'data-qm-island-id': node.attrs.id,
|
|
43
|
+
'data-qm-island-loss': node.attrs.loss,
|
|
44
|
+
'data-qm-island-props': JSON.stringify(node.attrs.props ?? null)
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** An island node's attributes off that DOM, or `false` where the id is absent. A
|
|
48
|
+
* `props` that is not the JSON written above reads as `null`, and one of the wrong
|
|
49
|
+
* shape is `undefined` at its reader (§`tablePropsOfNode`): the entry survives as an
|
|
50
|
+
* island of its type rather than taking the paste down with it. */
|
|
51
|
+
function islandAttrsFromDOM(el) {
|
|
52
|
+
const id = el.getAttribute('data-qm-island-id');
|
|
53
|
+
if (id == null)
|
|
54
|
+
return false;
|
|
55
|
+
const raw = el.getAttribute('data-qm-island-props');
|
|
56
|
+
let props = null;
|
|
57
|
+
try {
|
|
58
|
+
props = raw == null ? null : JSON.parse(raw);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
props = null;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
islandType: el.getAttribute('data-qm-island') ?? '',
|
|
66
|
+
props,
|
|
67
|
+
loss: (el.getAttribute('data-qm-island-loss') ?? 'unrepresentable')
|
|
68
|
+
};
|
|
69
|
+
}
|
|
19
70
|
/** Block island node (a table): one `island`-kind content line. Atom, unselectable content. */
|
|
20
71
|
export const islandBlockSpec = {
|
|
21
72
|
group: 'block',
|
|
22
73
|
atom: true,
|
|
23
74
|
selectable: true,
|
|
24
75
|
attrs: islandAttrs,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{ 'data-island': node.attrs.islandType, 'data-island-id': node.attrs.id },
|
|
28
|
-
`[${node.attrs.islandType || 'island'}]`
|
|
29
|
-
]
|
|
76
|
+
parseDOM: [{ tag: 'div[data-qm-island]', getAttrs: (el) => islandAttrsFromDOM(el) }],
|
|
77
|
+
toDOM: (node) => ['div', islandDOM(node), `[${node.attrs.islandType || 'island'}]`]
|
|
30
78
|
};
|
|
31
79
|
/** Inline island node (an image): a `U+FFFC` slot inside a `para` line. */
|
|
32
80
|
export const islandInlineSpec = {
|
|
@@ -35,11 +83,8 @@ export const islandInlineSpec = {
|
|
|
35
83
|
atom: true,
|
|
36
84
|
selectable: true,
|
|
37
85
|
attrs: islandAttrs,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
{ 'data-island': node.attrs.islandType, 'data-island-id': node.attrs.id },
|
|
41
|
-
`[${node.attrs.islandType || 'island'}]`
|
|
42
|
-
]
|
|
86
|
+
parseDOM: [{ tag: 'span[data-qm-island]', getAttrs: (el) => islandAttrsFromDOM(el) }],
|
|
87
|
+
toDOM: (node) => ['span', islandDOM(node), `[${node.attrs.islandType || 'island'}]`]
|
|
43
88
|
};
|
|
44
89
|
/** Build a content island entry from a PM island node's attrs (block or inline). */
|
|
45
90
|
export function islandEntryFromNode(attrs) {
|
|
@@ -50,12 +95,25 @@ export function islandEntryFromNode(attrs) {
|
|
|
50
95
|
loss: attrs.loss
|
|
51
96
|
};
|
|
52
97
|
}
|
|
53
|
-
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
98
|
+
/** The `TableProps` shape, over a value that reached the node off the DOM rather than
|
|
99
|
+
* out of a decode: the rectangle every reader indexes (`table.ts`), down to the `text`
|
|
100
|
+
* a row's emptiness is read off. */
|
|
101
|
+
function isTableProps(props) {
|
|
102
|
+
if (typeof props !== 'object' || props === null)
|
|
103
|
+
return false;
|
|
104
|
+
const { header, rows, aligns } = props;
|
|
105
|
+
const cells = (v) => Array.isArray(v) &&
|
|
106
|
+
v.every((c) => typeof c === 'object' && c !== null && typeof c.text === 'string');
|
|
107
|
+
return (cells(header) && Array.isArray(aligns) && Array.isArray(rows) && rows.every((row) => cells(row)));
|
|
108
|
+
}
|
|
109
|
+
/** A node's `TableProps`, or `undefined` for any other island and for a payload of the
|
|
110
|
+
* wrong shape: the boundary's own guard over the entry the node carries, so the open
|
|
111
|
+
* `type` arm narrows once here rather than at each reader. The type says which reader,
|
|
112
|
+
* the shape says whether it can read; every caller draws the placeholder for
|
|
113
|
+
* `undefined` (`table-view.ts` §`render`). */
|
|
56
114
|
export function tablePropsOfNode(node) {
|
|
57
115
|
const entry = islandEntryFromNode(node.attrs);
|
|
58
|
-
return isTableIsland(entry) ? entry.props : undefined;
|
|
116
|
+
return isTableIsland(entry) && isTableProps(entry.props) ? entry.props : undefined;
|
|
59
117
|
}
|
|
60
118
|
/**
|
|
61
119
|
* A minter over one document: the positional `isl-{n}` sequence continued past the
|
|
@@ -72,7 +130,7 @@ export function tablePropsOfNode(node) {
|
|
|
72
130
|
export function islandMinter(doc) {
|
|
73
131
|
let next = 0;
|
|
74
132
|
doc.descendants((node) => {
|
|
75
|
-
if (node
|
|
133
|
+
if (!isIsland(node))
|
|
76
134
|
return true;
|
|
77
135
|
const m = /^isl-(\d+)$/.exec(node.attrs.id);
|
|
78
136
|
if (m)
|
|
@@ -86,3 +144,70 @@ export function islandMinter(doc) {
|
|
|
86
144
|
export function mintIslandId(doc) {
|
|
87
145
|
return islandMinter(doc)();
|
|
88
146
|
}
|
|
147
|
+
/** Either island node: the test the minter, the id sweep and the paste pass share, so a
|
|
148
|
+
* third island shape is one arm rather than three. */
|
|
149
|
+
function isIsland(node) {
|
|
150
|
+
return node.type.name === 'island_block' || node.type.name === 'island_inline';
|
|
151
|
+
}
|
|
152
|
+
/** The ids a document already holds. */
|
|
153
|
+
function heldIds(doc) {
|
|
154
|
+
const held = new Set();
|
|
155
|
+
doc.descendants((node) => {
|
|
156
|
+
if (!isIsland(node))
|
|
157
|
+
return true;
|
|
158
|
+
held.add(node.attrs.id);
|
|
159
|
+
return false;
|
|
160
|
+
});
|
|
161
|
+
return held;
|
|
162
|
+
}
|
|
163
|
+
/** `fragment` with every island whose id is already `taken` re-minted, or `null` where
|
|
164
|
+
* none was: an untouched paste stays the slice it arrived as. Every surviving id joins
|
|
165
|
+
* the set, so a slice carrying one id twice comes apart in the same pass. */
|
|
166
|
+
function remintIslands(fragment, taken, mint) {
|
|
167
|
+
const out = [];
|
|
168
|
+
let changed = false;
|
|
169
|
+
fragment.forEach((node) => {
|
|
170
|
+
if (!isIsland(node)) {
|
|
171
|
+
const inner = node.isLeaf ? null : remintIslands(node.content, taken, mint);
|
|
172
|
+
changed ||= inner !== null;
|
|
173
|
+
out.push(inner ? node.copy(inner) : node);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
let id = node.attrs.id;
|
|
177
|
+
if (taken.has(id)) {
|
|
178
|
+
do
|
|
179
|
+
id = mint();
|
|
180
|
+
while (taken.has(id));
|
|
181
|
+
changed = true;
|
|
182
|
+
}
|
|
183
|
+
taken.add(id);
|
|
184
|
+
out.push(id === node.attrs.id ? node : node.type.create({ ...node.attrs, id }, null, node.marks));
|
|
185
|
+
});
|
|
186
|
+
return changed ? Fragment.fromArray(out) : null;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The paste's island pass: an id the field already holds is re-minted on the way in.
|
|
190
|
+
*
|
|
191
|
+
* An id is an identity in the content and unique across the field, so two islands
|
|
192
|
+
* wearing one is a projection the store has no shape for. A copy rather than a cut is
|
|
193
|
+
* where that arrives, and a paste out of a second field is the other; a cut pasted back
|
|
194
|
+
* collides with nothing and keeps the identity it had.
|
|
195
|
+
*
|
|
196
|
+
* The test is the document as it stands, which a drag moving a selection over an island
|
|
197
|
+
* reads one too many: the original is still there when the slice is transformed and gone
|
|
198
|
+
* when it lands, so that island arrives under a fresh id. A fresh id costs it nothing a
|
|
199
|
+
* duplicate would cost the whole field.
|
|
200
|
+
*
|
|
201
|
+
* A plugin prop rather than a view option: the leaf mounts the codec's plugin stack, and
|
|
202
|
+
* which nodes carry an identity is the codec's to know.
|
|
203
|
+
*/
|
|
204
|
+
export function islandPastePlugin() {
|
|
205
|
+
return new Plugin({
|
|
206
|
+
props: {
|
|
207
|
+
transformPasted: (slice, view) => {
|
|
208
|
+
const content = remintIslands(slice.content, heldIds(view.state.doc), islandMinter(view.state.doc));
|
|
209
|
+
return content ? new Slice(content, slice.openStart, slice.openEnd) : slice;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { chainCommands } from 'prosemirror-commands';
|
|
2
2
|
import { atomKeymap } from './atoms.js';
|
|
3
|
+
import { blockKeymap } from './blocks.js';
|
|
3
4
|
import { codeKeymap } from './code.js';
|
|
4
5
|
import { listKeymap } from './lists.js';
|
|
5
6
|
import { slashKeymap } from './slash.js';
|
|
@@ -26,5 +27,5 @@ function chainKeymaps(...maps) {
|
|
|
26
27
|
* no visible reason. */
|
|
27
28
|
export function bodyKeymap(schema, slash) {
|
|
28
29
|
const links = slash ? [slashKeymap()] : [];
|
|
29
|
-
return chainKeymaps(...links, codeKeymap(schema), listKeymap(schema), atomKeymap(schema));
|
|
30
|
+
return chainKeymaps(...links, blockKeymap(schema), codeKeymap(schema), listKeymap(schema), atomKeymap(schema));
|
|
30
31
|
}
|
|
@@ -17,6 +17,10 @@ import type { Command } from 'prosemirror-state';
|
|
|
17
17
|
* its own: it splits a non-empty item, and on an empty item it either splits the
|
|
18
18
|
* wrapping item (a nested empty item, so Enter lifts exactly one level) or bails
|
|
19
19
|
* so the third link lifts a top-level empty item out to a paragraph. `Backspace`
|
|
20
|
-
* forks on the item's index: the first item lifts, any later one merges.
|
|
20
|
+
* forks on the item's index: the first item lifts, any later one merges. `Delete` is
|
|
21
|
+
* that same chain at the seam below rather than a second table of cases beside it, so
|
|
22
|
+
* the two keys cannot come to differ over a seam this link reaches. An empty line is
|
|
23
|
+
* not one: the block link takes it ahead of this chain (`blocks.ts`), the caret's own
|
|
24
|
+
* line being a nearer fact than the boundary under it.
|
|
21
25
|
*/
|
|
22
26
|
export declare function listKeymap(schema: Schema): Record<string, Command>;
|
package/dist/core/codec/lists.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { liftListItem, sinkListItem, splitListItem } from 'prosemirror-schema-list';
|
|
2
|
+
import { EditorState, Selection, TextSelection } from 'prosemirror-state';
|
|
2
3
|
import { chainCommands, joinTextblockBackward } from 'prosemirror-commands';
|
|
3
4
|
/** The caret's enclosing `list_item` when it sits at the very start of that item's
|
|
4
5
|
* first block: the position both structural keys branch on. `null` otherwise
|
|
@@ -20,26 +21,35 @@ function atItemStart(state, itemType) {
|
|
|
20
21
|
listPos: $from.before(-2)
|
|
21
22
|
};
|
|
22
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* The textblock at the end of `node`: `node` itself where it is one, else its last
|
|
26
|
+
* descendant that is — the block a caret above a boundary lands in, and the block a
|
|
27
|
+
* join across one reaches, `joinTextblockBackward` walking to the same one. `null` on
|
|
28
|
+
* an atom (an island, a rule), which `prosemirror-gapcursor` declines to sit beside
|
|
29
|
+
* too, its `closedBefore` asking this question from the other side.
|
|
30
|
+
*/
|
|
31
|
+
function lastBlockIn(node) {
|
|
32
|
+
let last = node ?? null;
|
|
33
|
+
while (last && !last.isTextblock && !last.isAtom)
|
|
34
|
+
last = last.lastChild;
|
|
35
|
+
return last?.isTextblock ? last : null;
|
|
36
|
+
}
|
|
23
37
|
/**
|
|
24
38
|
* Whether a caret already fits immediately above the list at `pos`. The node ending
|
|
25
|
-
* there answers for its last child, so a quote counts through its final paragraph;
|
|
26
|
-
*
|
|
27
|
-
* Those are the shapes `prosemirror-gapcursor` declines too, its `closedBefore`
|
|
28
|
-
* asking this question from the other side.
|
|
39
|
+
* there answers for its last child, so a quote counts through its final paragraph; a
|
|
40
|
+
* list opening its parent has no node there at all.
|
|
29
41
|
*/
|
|
30
42
|
function writableAbove(doc, pos) {
|
|
31
|
-
|
|
32
|
-
while (before && !before.isTextblock && !before.isAtom)
|
|
33
|
-
before = before.lastChild;
|
|
34
|
-
return !!before?.isTextblock;
|
|
43
|
+
return !!lastBlockIn(doc.resolve(pos).nodeBefore);
|
|
35
44
|
}
|
|
36
45
|
/**
|
|
37
46
|
* Enter at the very start of a top-level list's first item, where nothing above the
|
|
38
47
|
* list takes a caret → an empty paragraph above it, caret staying with the text it
|
|
39
|
-
* pushed down. A list
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
48
|
+
* pushed down. A list under an island or a rule has nowhere above to write and
|
|
49
|
+
* nothing else that opens one; a list that opens the body is the block link's, which
|
|
50
|
+
* answers the same fact for every block ahead of this chain (`blocks.ts`). Where a
|
|
51
|
+
* caret already fits above, the key is the conventional split instead, at every item
|
|
52
|
+
* alike: an escape a writer reaches by pressing Up needs no key of its own.
|
|
43
53
|
*
|
|
44
54
|
* Also only where the list itself is not inside an item: in a nested list this
|
|
45
55
|
* pushes an empty paragraph into the parent item (`list_item` is `block+`, so the
|
|
@@ -81,15 +91,62 @@ function liftAtListStart(itemType) {
|
|
|
81
91
|
* `list_item(paragraph, paragraph)`: the item's marker gone while its text stays
|
|
82
92
|
* on its own line, which the reference quill typesets as an unnumbered
|
|
83
93
|
* continuation paragraph.
|
|
94
|
+
*
|
|
95
|
+
* Only between two blocks of one kind. A fence is not a line to put another line on,
|
|
96
|
+
* and the join across that edge would retype one side's whole text as the other's
|
|
97
|
+
* (`code.ts`, which refuses the same join between siblings); the base keymap's answer
|
|
98
|
+
* stands here instead, merging the items with each block kept whole.
|
|
84
99
|
*/
|
|
85
100
|
function mergeIntoPreviousItem(itemType) {
|
|
86
101
|
return (state, dispatch, view) => {
|
|
87
102
|
const at = atItemStart(state, itemType);
|
|
88
103
|
if (!at || at.itemIndex === 0)
|
|
89
104
|
return false;
|
|
105
|
+
const into = lastBlockIn(state.doc.nodeAt(at.listPos)?.child(at.itemIndex - 1));
|
|
106
|
+
if (!into || !!into.type.spec.code !== !!state.selection.$from.parent.type.spec.code) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
90
109
|
return joinTextblockBackward(state, dispatch, view);
|
|
91
110
|
};
|
|
92
111
|
}
|
|
112
|
+
/** Whether the caret sits inside a `list_item` at any depth: a quote's paragraph
|
|
113
|
+
* inside an item is still an item's, and a caret outside every list is at no seam of
|
|
114
|
+
* one. */
|
|
115
|
+
function inItem($pos, itemType) {
|
|
116
|
+
for (let depth = $pos.depth; depth > 0; depth--) {
|
|
117
|
+
if ($pos.node(depth).type === itemType)
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Delete at the very end of a block inside an item → `press`, the Backspace chain, at
|
|
124
|
+
* the head of the block below: one seam, one edit, whichever side a writer approaches
|
|
125
|
+
* it from. Not the base keymap's `joinForward`, which merges the blocks and leaves the
|
|
126
|
+
* `list_item(paragraph, paragraph)` above.
|
|
127
|
+
*
|
|
128
|
+
* The reflection carries the edit and not the caret, which stays where the key was
|
|
129
|
+
* pressed: a lift moves the item below, and Delete does not follow it.
|
|
130
|
+
*
|
|
131
|
+
* Declining wherever the chain does is what keeps this to the list's own seams: a
|
|
132
|
+
* boundary inside one item, and the list's outer edge, stay the base keymap's, where
|
|
133
|
+
* the two keys already answer alike.
|
|
134
|
+
*/
|
|
135
|
+
function backspaceBelow(itemType, press) {
|
|
136
|
+
return (state, dispatch) => {
|
|
137
|
+
const { $from, empty } = state.selection;
|
|
138
|
+
if (!empty || $from.parentOffset !== $from.parent.content.size)
|
|
139
|
+
return false;
|
|
140
|
+
if (!inItem($from, itemType))
|
|
141
|
+
return false;
|
|
142
|
+
const below = Selection.near(state.doc.resolve($from.after()), 1);
|
|
143
|
+
if (below.from <= $from.pos)
|
|
144
|
+
return false;
|
|
145
|
+
const pressed = $from.pos;
|
|
146
|
+
const keepCaret = (tr) => dispatch?.(tr.setSelection(TextSelection.create(tr.doc, tr.mapping.map(pressed))));
|
|
147
|
+
return press(EditorState.create({ doc: state.doc, selection: below }), dispatch && keepCaret);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
93
150
|
/**
|
|
94
151
|
* The list bindings for a block-schema leaf: `{}` for the inline/plaintext
|
|
95
152
|
* schemas, which declare no list nodes.
|
|
@@ -107,17 +164,23 @@ function mergeIntoPreviousItem(itemType) {
|
|
|
107
164
|
* its own: it splits a non-empty item, and on an empty item it either splits the
|
|
108
165
|
* wrapping item (a nested empty item, so Enter lifts exactly one level) or bails
|
|
109
166
|
* so the third link lifts a top-level empty item out to a paragraph. `Backspace`
|
|
110
|
-
* forks on the item's index: the first item lifts, any later one merges.
|
|
167
|
+
* forks on the item's index: the first item lifts, any later one merges. `Delete` is
|
|
168
|
+
* that same chain at the seam below rather than a second table of cases beside it, so
|
|
169
|
+
* the two keys cannot come to differ over a seam this link reaches. An empty line is
|
|
170
|
+
* not one: the block link takes it ahead of this chain (`blocks.ts`), the caret's own
|
|
171
|
+
* line being a nearer fact than the boundary under it.
|
|
111
172
|
*/
|
|
112
173
|
export function listKeymap(schema) {
|
|
113
174
|
const itemType = schema.nodes.list_item;
|
|
114
175
|
const paragraph = schema.nodes.paragraph;
|
|
115
176
|
if (!itemType || !paragraph)
|
|
116
177
|
return {};
|
|
178
|
+
const backspace = chainCommands(liftAtListStart(itemType), mergeIntoPreviousItem(itemType));
|
|
117
179
|
return {
|
|
118
180
|
Tab: sinkListItem(itemType),
|
|
119
181
|
'Shift-Tab': liftListItem(itemType),
|
|
120
182
|
Enter: chainCommands(paragraphAboveList(itemType, paragraph), splitListItem(itemType), liftListItem(itemType)),
|
|
121
|
-
Backspace:
|
|
183
|
+
Backspace: backspace,
|
|
184
|
+
Delete: backspaceBelow(itemType, backspace)
|
|
122
185
|
};
|
|
123
186
|
}
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
// no block split, no containers, no islands) and `plaintextSchema` is that one
|
|
9
9
|
// without marks: same decode/lower/position machinery, narrower shape. Anchors are
|
|
10
10
|
// not marks here (decorations).
|
|
11
|
+
//
|
|
12
|
+
// `toDOM` and `parseDOM` are one tier, not two halves of a rendering: a copy and a
|
|
13
|
+
// paste inside one body run the whole document through them (CODEC §"Markdown at the
|
|
14
|
+
// edges"), so every attribute written here is read back here. A carrier's payload
|
|
15
|
+
// crosses as JSON under a `data-qm-*` name, which is the form the content's open sets
|
|
16
|
+
// already have. Foreign HTML spells none of those names, so what a paste takes off the
|
|
17
|
+
// web is unchanged; the one rule that widens that door on purpose is the fence's.
|
|
11
18
|
import { Schema } from 'prosemirror-model';
|
|
12
19
|
import { islandBlockSpec, islandInlineSpec } from './islands.js';
|
|
13
20
|
// ── The href gate ───────────────────────────────────────────────────────────
|
|
@@ -37,6 +44,44 @@ export function rendersHref(href) {
|
|
|
37
44
|
const scheme = SCHEME.exec(href.replace(IGNORED, ''));
|
|
38
45
|
return scheme === null || RENDERED_SCHEMES.has(scheme[1].toLowerCase());
|
|
39
46
|
}
|
|
47
|
+
// ── The open sets' DOM payload ──────────────────────────────────────────────
|
|
48
|
+
// A carrier's tag is a `data-qm-unknown-*` name and its `attrs` are JSON beside it,
|
|
49
|
+
// under one name across all three: an element carries at most one carrier, a
|
|
50
|
+
// paragraph's tag and the mark's sitting on different elements.
|
|
51
|
+
/** The `attrs` half of a carrier, absent where there is none. */
|
|
52
|
+
function packAttrs(attrs) {
|
|
53
|
+
return attrs == null ? {} : { 'data-qm-unknown-attrs': JSON.stringify(attrs) };
|
|
54
|
+
}
|
|
55
|
+
/** The `attrs` half back off an element. `null` for anything that is not the JSON
|
|
56
|
+
* this schema wrote: a hand-built document reaches the same parse a paste does, and a
|
|
57
|
+
* carrier's payload is the keyed object upstream spells, never a bare scalar. */
|
|
58
|
+
function unpackAttrs(el) {
|
|
59
|
+
const raw = el.getAttribute('data-qm-unknown-attrs');
|
|
60
|
+
if (raw == null)
|
|
61
|
+
return null;
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(raw);
|
|
64
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// ── The names a carrier may not wear ────────────────────────────────────────
|
|
71
|
+
// A carrier holds a value this build does not know, so a name the model does know is
|
|
72
|
+
// the one it cannot carry: the projection re-emits it with `attrs` beside it
|
|
73
|
+
// (`encode.ts` §`textblockKind`) and the store refuses `attrs` beside a built-in
|
|
74
|
+
// discriminant, failing every write for the rest of the session. `decode` applies the
|
|
75
|
+
// same guard from the other side, where a `para` line mints no carrier.
|
|
76
|
+
/** The line kinds `ContentLineKind` names, which decode maps to a node of their own. */
|
|
77
|
+
const BUILTIN_LINE_KINDS = new Set(['para', 'heading', 'code', 'rule', 'island']);
|
|
78
|
+
/** The containers `ContentContainer` names. */
|
|
79
|
+
const BUILTIN_CONTAINERS = new Set(['list_item', 'quote']);
|
|
80
|
+
/** A carrier's name, or `null` where it is empty or one the model owns. */
|
|
81
|
+
function carrierName(el, attr, builtin) {
|
|
82
|
+
const name = el.getAttribute(attr);
|
|
83
|
+
return name && !builtin.has(name) ? name : null;
|
|
84
|
+
}
|
|
40
85
|
// ── Marks (the block and inline schemas share them; plaintext declares none) ─
|
|
41
86
|
const marks = {
|
|
42
87
|
// Order matters: it fixes mark-set sort order and parse precedence. `link`
|
|
@@ -51,13 +96,17 @@ const marks = {
|
|
|
51
96
|
attrs: { href: { default: '' } },
|
|
52
97
|
inclusive: false,
|
|
53
98
|
parseDOM: [
|
|
54
|
-
{ tag: 'a[href]', getAttrs: (el) => ({ href: el.getAttribute('href') }) }
|
|
99
|
+
{ tag: 'a[href]', getAttrs: (el) => ({ href: el.getAttribute('href') }) },
|
|
100
|
+
{ tag: 'span[data-qm-href]', getAttrs: (el) => ({ href: el.getAttribute('data-qm-href') }) }
|
|
55
101
|
],
|
|
56
102
|
// A refused href draws as a bare span: the text stands, unstyled and
|
|
57
|
-
// unclickable, and the mark keeps its value for encode.
|
|
103
|
+
// unclickable, and the mark keeps its value for encode. The value rides on the
|
|
104
|
+
// span rather than going with the tag — an `href` no renderer will follow is one
|
|
105
|
+
// the document still holds, and a copy is not the explicit conversion allowed to
|
|
106
|
+
// lose it.
|
|
58
107
|
toDOM: (mark) => {
|
|
59
108
|
const href = mark.attrs.href;
|
|
60
|
-
return rendersHref(href) ? ['a', { href }, 0] : ['span', 0];
|
|
109
|
+
return rendersHref(href) ? ['a', { href }, 0] : ['span', { 'data-qm-href': href }, 0];
|
|
61
110
|
}
|
|
62
111
|
},
|
|
63
112
|
// The open-set escape hatch: an inert mark that renders as a bare span and
|
|
@@ -66,9 +115,50 @@ const marks = {
|
|
|
66
115
|
unknown: {
|
|
67
116
|
attrs: { type: { default: '' }, attrs: { default: null } },
|
|
68
117
|
excludes: '',
|
|
69
|
-
|
|
118
|
+
parseDOM: [
|
|
119
|
+
{
|
|
120
|
+
tag: 'span[data-qm-unknown-mark]',
|
|
121
|
+
// `false` declines the rule, so the text arrives unmarked rather than
|
|
122
|
+
// carrying a second spelling of a mark this schema already has.
|
|
123
|
+
getAttrs: (el) => {
|
|
124
|
+
const type = carrierName(el, 'data-qm-unknown-mark', BUILTIN_MARKS);
|
|
125
|
+
return type ? { type, attrs: unpackAttrs(el) } : false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
],
|
|
129
|
+
toDOM: (mark) => [
|
|
130
|
+
'span',
|
|
131
|
+
{ 'data-qm-unknown-mark': mark.attrs.type, ...packAttrs(mark.attrs.attrs) },
|
|
132
|
+
0
|
|
133
|
+
]
|
|
70
134
|
}
|
|
71
135
|
};
|
|
136
|
+
/** The mark types the content model names: this schema's own, and `anchor`, which is a
|
|
137
|
+
* decoration here rather than a mark (§Marks). Read off the record above, so a mark
|
|
138
|
+
* added there closes the carrier's door on its name in the same edit. */
|
|
139
|
+
const BUILTIN_MARKS = new Set([...Object.keys(marks), 'anchor']);
|
|
140
|
+
// ── The fence's language ────────────────────────────────────────────────────
|
|
141
|
+
// The one attribute no keystroke in the visual editor mints: the shorthand fires on
|
|
142
|
+
// the third backtick, before a language could be typed, and a slash command is a name
|
|
143
|
+
// (VISUAL_EDITOR §"Settled and open"). A paste is the gesture that does, and this is
|
|
144
|
+
// what reads it — a fence copied off a highlighted page arrives carrying the language
|
|
145
|
+
// that page stated, one copied inside the body keeps its own.
|
|
146
|
+
/** A `language-x` / `lang-x` token in a class list. */
|
|
147
|
+
const LANG_CLASS = /(?:^|\s)lang(?:uage)?-(\S+)/;
|
|
148
|
+
/** The language a `<pre>` states: `data-lang`, which is what `toDOM` writes, else the
|
|
149
|
+
* class convention on the `pre` or on the `code` it wraps. `null` where it states
|
|
150
|
+
* none, which is also what an empty value is. */
|
|
151
|
+
function fenceLang(pre) {
|
|
152
|
+
const stated = pre.getAttribute('data-lang');
|
|
153
|
+
if (stated)
|
|
154
|
+
return stated;
|
|
155
|
+
for (const el of [pre, pre.firstElementChild]) {
|
|
156
|
+
const match = el && LANG_CLASS.exec(el.getAttribute('class') ?? '');
|
|
157
|
+
if (match)
|
|
158
|
+
return match[1];
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
72
162
|
// ── Block nodes ─────────────────────────────────────────────────────────────
|
|
73
163
|
const blockNodes = {
|
|
74
164
|
doc: { content: 'block+' },
|
|
@@ -81,10 +171,18 @@ const blockNodes = {
|
|
|
81
171
|
content: 'inline*',
|
|
82
172
|
group: 'block',
|
|
83
173
|
attrs: { unknown: { default: null } },
|
|
84
|
-
parseDOM: [
|
|
174
|
+
parseDOM: [
|
|
175
|
+
{
|
|
176
|
+
tag: 'p',
|
|
177
|
+
getAttrs: (el) => {
|
|
178
|
+
const kind = carrierName(el, 'data-qm-unknown-line', BUILTIN_LINE_KINDS);
|
|
179
|
+
return { unknown: kind ? { kind, attrs: unpackAttrs(el) } : null };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
],
|
|
85
183
|
toDOM: (node) => {
|
|
86
184
|
const u = node.attrs.unknown;
|
|
87
|
-
return u ? ['p', { 'data-qm-unknown-line': u.kind }, 0] : ['p', 0];
|
|
185
|
+
return u ? ['p', { 'data-qm-unknown-line': u.kind, ...packAttrs(u.attrs) }, 0] : ['p', 0];
|
|
88
186
|
}
|
|
89
187
|
},
|
|
90
188
|
heading: {
|
|
@@ -103,7 +201,13 @@ const blockNodes = {
|
|
|
103
201
|
marks: '',
|
|
104
202
|
whitespace: 'pre',
|
|
105
203
|
attrs: { lang: { default: null } },
|
|
106
|
-
parseDOM: [
|
|
204
|
+
parseDOM: [
|
|
205
|
+
{
|
|
206
|
+
tag: 'pre',
|
|
207
|
+
preserveWhitespace: 'full',
|
|
208
|
+
getAttrs: (el) => ({ lang: fenceLang(el) })
|
|
209
|
+
}
|
|
210
|
+
],
|
|
107
211
|
toDOM: (node) => [
|
|
108
212
|
'pre',
|
|
109
213
|
node.attrs.lang ? { 'data-lang': node.attrs.lang } : {},
|
|
@@ -122,7 +226,22 @@ const blockNodes = {
|
|
|
122
226
|
content: 'list_item+',
|
|
123
227
|
group: 'block',
|
|
124
228
|
attrs: { start: { default: 1 } },
|
|
125
|
-
parseDOM: [
|
|
229
|
+
parseDOM: [
|
|
230
|
+
{
|
|
231
|
+
tag: 'ol',
|
|
232
|
+
getAttrs: (el) => {
|
|
233
|
+
// A list stating no `start` starts at one, the absence `toDOM` writes for
|
|
234
|
+
// it; `Number(null)` would read that absence as zero. A value the store
|
|
235
|
+
// would normalize away reads as that absence too: the leaf re-hydrates
|
|
236
|
+
// only on an external change (CODEC §Reconciliation), so a PM doc keeping
|
|
237
|
+
// one disagrees with the store for the rest of the session. Zero is not
|
|
238
|
+
// one — `0.` is an ordinal `importMarkdown` produces and the store keeps.
|
|
239
|
+
const stated = el.getAttribute('start');
|
|
240
|
+
const start = stated ? Number(stated) : 1;
|
|
241
|
+
return { start: Number.isSafeInteger(start) && start >= 0 ? start : 1 };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
],
|
|
126
245
|
toDOM: (node) => node.attrs.start === 1 ? ['ol', 0] : ['ol', { start: node.attrs.start }, 0]
|
|
127
246
|
},
|
|
128
247
|
bullet_list: {
|
|
@@ -146,17 +265,39 @@ const blockNodes = {
|
|
|
146
265
|
group: 'block',
|
|
147
266
|
defining: true,
|
|
148
267
|
attrs: { container: { default: '' }, attrs: { default: null } },
|
|
149
|
-
|
|
268
|
+
parseDOM: [
|
|
269
|
+
{
|
|
270
|
+
tag: 'div[data-qm-unknown-container]',
|
|
271
|
+
// `false` declines the rule, so the div's children flow at the enclosing
|
|
272
|
+
// level — which is how a transparent container renders anyway.
|
|
273
|
+
getAttrs: (el) => {
|
|
274
|
+
const container = carrierName(el, 'data-qm-unknown-container', BUILTIN_CONTAINERS);
|
|
275
|
+
return container ? { container, attrs: unpackAttrs(el) } : false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
],
|
|
279
|
+
toDOM: (node) => [
|
|
280
|
+
'div',
|
|
281
|
+
{
|
|
282
|
+
'data-qm-unknown-container': node.attrs.container,
|
|
283
|
+
...packAttrs(node.attrs.attrs)
|
|
284
|
+
},
|
|
285
|
+
0
|
|
286
|
+
]
|
|
150
287
|
},
|
|
151
288
|
island_block: islandBlockSpec
|
|
152
289
|
};
|
|
153
290
|
// ── Inline nodes ────────────────────────────────────────────────────────────
|
|
154
291
|
const inlineLeafNodes = {
|
|
155
292
|
text: { group: 'inline' },
|
|
293
|
+
// `linebreakReplacement` is what makes a join across a `code_block`'s edge
|
|
294
|
+
// lossless: `Transform.join` and `setBlockType` convert its `\n`s to breaks
|
|
295
|
+
// rather than flattening them to spaces, and a break is a `continues` line.
|
|
156
296
|
hard_break: {
|
|
157
297
|
inline: true,
|
|
158
298
|
group: 'inline',
|
|
159
299
|
selectable: false,
|
|
300
|
+
linebreakReplacement: true,
|
|
160
301
|
parseDOM: [{ tag: 'br' }],
|
|
161
302
|
toDOM: () => ['br']
|
|
162
303
|
},
|
|
@@ -158,8 +158,8 @@ class TableIslandView {
|
|
|
158
158
|
this.getPos = getPos;
|
|
159
159
|
this.deps = deps;
|
|
160
160
|
this.dom = el('div', 'qm-island qm-table-island');
|
|
161
|
-
this.dom.setAttribute('data-island', node.attrs.islandType);
|
|
162
|
-
this.dom.setAttribute('data-island-id', node.attrs.id);
|
|
161
|
+
this.dom.setAttribute('data-qm-island', node.attrs.islandType);
|
|
162
|
+
this.dom.setAttribute('data-qm-island-id', node.attrs.id);
|
|
163
163
|
this.dom.addEventListener('mousedown', this.onPointerDown);
|
|
164
164
|
this.render();
|
|
165
165
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quillmark/svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Editor, live-preview and source surfaces over a Quillmark WASM session.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"check": "svelte-check --tsconfig ./tsconfig.json"
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
|
-
"@quillmark/wasm": ">=0.
|
|
69
|
+
"@quillmark/wasm": ">=0.109.0-0",
|
|
70
70
|
"svelte": "^5.0.0"
|
|
71
71
|
},
|
|
72
72
|
"dependencies": {
|