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