@quillmark/svelte 0.8.0 → 0.10.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,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
  },
Binary file
@@ -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
  }