@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.
@@ -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 outside a code block, so the list links keep the keys elsewhere.
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>;
@@ -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 then out of the leaf, which is the body's
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 outside a code block, so the list links keep the keys elsewhere.
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
- // An ordinal reset is an adjacent sibling list (content preserves the
114
- // shape): merging it here would re-encode `[0,1,0]` as `[0,1,2]`,
115
- // breaking the decode round-trip on the first edit.
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 plus its payload, NUL-joined
166
- * as `markKey` joins a mark's, so no `attrs` content can forge a name boundary.
167
- * Every arm but `list_item` (which has its own run rule) keys here. */
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
- return 'attrs' in c ? `${c.container}\u0000${JSON.stringify(c.attrs)}` : c.container;
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
- scanBlock(acc, child, pm, containers);
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
- function scanBlock(acc, node, nodePos, containers) {
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, [...containers, { container: 'quote' }]);
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
- emitText(acc, child.text ?? '', pm, child.marks);
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
- /** Per-line `setContainers` / `setKind` / `setContinues` when line metadata changed; else none. */
239
- function diffLines(oldRt, newRt) {
240
- if (lineMetaEqual(oldRt.lines, newRt.lines))
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), the
156
- * markdown-shorthand input rules, then the field keymap over the base keymap, and
157
- * last the two that answer to a caret or a selection beside a block: the gap cursor
158
- * and {@link pastAtomPlugin}.
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
@@ -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), the
395
- * markdown-shorthand input rules, then the field keymap over the base keymap, and
396
- * last the two that answer to a caret or a selection beside a block: the gap cursor
397
- * and {@link pastAtomPlugin}.
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
- // Both answer to something only a block leaf holds: an inline leaf is one textblock
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 { Node as PMNode, NodeSpec } from 'prosemirror-model';
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: the boundary's own
22
- * guard over the entry the node carries, so the open `type` arm narrows once here
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
- toDOM: (node) => [
26
- 'div',
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
- toDOM: (node) => [
39
- 'span',
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
- /** A node's `TableProps`, or `undefined` for any other island: the boundary's own
54
- * guard over the entry the node carries, so the open `type` arm narrows once here
55
- * rather than at each reader. */
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.type.name !== 'island_block' && node.type.name !== 'island_inline')
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>;
@@ -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
- * an atom (an island, a rule) does not, and neither does a list opening its parent.
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
- let before = doc.resolve(pos).nodeBefore;
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 that opens a document, or one under an island or a rule, has
40
- * nowhere above to write and nothing else that opens one. Where a caret already
41
- * fits above, the key is the conventional split instead, at every item alike: an
42
- * escape a writer reaches by pressing Up needs no key of its own.
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: chainCommands(liftAtListStart(itemType), mergeIntoPreviousItem(itemType))
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
- toDOM: () => ['span', { 'data-unknown': '' }, 0]
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: [{ tag: 'p' }],
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: [{ tag: 'pre', preserveWhitespace: 'full' }],
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: [{ tag: 'ol' }],
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
- toDOM: (node) => ['div', { 'data-qm-unknown-container': node.attrs.container }, 0]
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.8.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.108.3-0",
69
+ "@quillmark/wasm": ">=0.109.0-0",
70
70
  "svelte": "^5.0.0"
71
71
  },
72
72
  "dependencies": {