@quillmark/svelte 0.23.0 → 0.25.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/README.md +7 -8
- package/THEMING.md +1 -1
- package/dist/core/codec/blocks.d.ts +23 -6
- package/dist/core/codec/blocks.js +163 -38
- package/dist/core/codec/code.js +3 -8
- package/dist/core/codec/decode.d.ts +2 -1
- package/dist/core/codec/decode.js +19 -11
- package/dist/core/codec/field.d.ts +6 -3
- package/dist/core/codec/field.js +12 -4
- package/dist/core/codec/index.d.ts +2 -0
- package/dist/core/codec/index.js +4 -0
- package/dist/core/codec/inputrules.js +40 -10
- package/dist/core/codec/islands.d.ts +4 -1
- package/dist/core/codec/islands.js +43 -16
- package/dist/core/codec/keymap.js +19 -1
- package/dist/core/codec/line-paste.d.ts +7 -0
- package/dist/core/codec/line-paste.js +36 -0
- package/dist/core/codec/list-move.d.ts +6 -0
- package/dist/core/codec/list-move.js +30 -0
- package/dist/core/codec/list-paste.d.ts +2 -0
- package/dist/core/codec/list-paste.js +142 -0
- package/dist/core/codec/list-toggle.d.ts +4 -0
- package/dist/core/codec/list-toggle.js +170 -0
- package/dist/core/codec/lists.d.ts +22 -15
- package/dist/core/codec/lists.js +404 -97
- package/dist/core/codec/markdown-paste.d.ts +13 -3
- package/dist/core/codec/markdown-paste.js +70 -6
- package/dist/core/codec/nesting.d.ts +5 -1
- package/dist/core/codec/nesting.js +1 -1
- package/dist/core/codec/prose.css +36 -20
- package/dist/core/codec/slash.d.ts +2 -1
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/touching.d.ts +12 -0
- package/dist/core/codec/touching.js +33 -0
- package/dist/core/codec/undo.d.ts +5 -0
- package/dist/core/codec/undo.js +21 -0
- package/dist/index.js +1 -1
- package/dist/preview/Preview.svelte +88 -13
- package/dist/preview/Preview.svelte.d.ts +2 -2
- package/dist/preview/bridge.js +8 -2
- package/dist/preview/controller.d.ts +8 -4
- package/dist/preview/controller.js +10 -2
- package/dist/preview/index.js +1 -1
- package/dist/preview/paint.d.ts +6 -2
- package/dist/preview/paint.js +67 -56
- package/dist/preview/strings.d.ts +2 -0
- package/dist/preview/strings.js +4 -2
- package/dist/preview/viewport.d.ts +10 -0
- package/dist/preview/viewport.js +271 -0
- package/dist/preview/zoom.d.ts +41 -0
- package/dist/preview/zoom.js +52 -0
- package/dist/visual/ArrayField.svelte +24 -4
- package/dist/visual/Card.svelte +5 -4
- package/dist/visual/DateField.svelte +26 -13
- package/dist/visual/Field.svelte +2 -2
- package/dist/visual/FormatPopover.svelte +41 -2
- package/dist/visual/ObjectField.svelte +4 -7
- package/dist/visual/ObjectField.svelte.d.ts +2 -2
- package/dist/visual/ProseValue.svelte +35 -3
- package/dist/visual/ProseValue.svelte.d.ts +7 -0
- package/dist/visual/VisualEditorInner.svelte +5 -11
- package/dist/visual/icons/nodes.d.ts +26 -0
- package/dist/visual/icons/nodes.js +16 -0
- package/dist/visual/strings.d.ts +6 -4
- package/dist/visual/strings.js +9 -3
- package/dist/visual/structure.d.ts +22 -10
- package/dist/visual/structure.js +35 -11
- package/package.json +2 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// Input rules: the markdown-shorthand plugin the codec owns (
|
|
2
|
-
// `~~`→strike, `` ` ``→code, `# `→heading, `- `/`1. `→lists,
|
|
3
|
-
// `---`→divider), and the fence, which fires on Enter
|
|
1
|
+
// Input rules: the markdown-shorthand plugin the codec owns (`**`/`__`→strong,
|
|
2
|
+
// `*`/`_`→em, `~~`→strike, `` ` ``→code, `# `→heading, `- `/`1. `/`1) `→lists,
|
|
3
|
+
// `> `→blockquote, `---`→divider, a typed URL→link), and the fence, which fires on Enter
|
|
4
|
+
// rather than as typed.
|
|
4
5
|
// Mark rules are hand-rolled (prosemirror-inputrules ships only node rules); every
|
|
5
6
|
// block rule is one regexp over a command in `blocks.ts`, which is the slash menu's
|
|
6
7
|
// implementation too. These produce transactions the codec already lowers: they add no
|
|
@@ -10,11 +11,14 @@
|
|
|
10
11
|
// another gesture owns — never on how a quill renders it. What a quill typesets is not
|
|
11
12
|
// knowable here (a quill this build has not seen is the open-sets case, CODEC §"Open
|
|
12
13
|
// sets"), and a construct that arrives through `importMarkdown` must stay authorable or
|
|
13
|
-
// a document opens carrying a shape the editor refuses to make. So one
|
|
14
|
-
// `- ` / `1. ` at the head of an existing item
|
|
14
|
+
// a document opens carrying a shape the editor refuses to make. So one rule answers
|
|
15
|
+
// otherwise: `- ` / `1. ` at the head of an existing item retypes the item's list, where
|
|
16
|
+
// a wrap would nest and Tab owns the nesting. A marker the item already shows, and `a. `,
|
|
17
|
+
// `(1) `, `(a) ` in a nested item, are taken as typed and change nothing.
|
|
15
18
|
import { InputRule, inputRules, undoInputRule } from 'prosemirror-inputrules';
|
|
16
19
|
import { keymap } from 'prosemirror-keymap';
|
|
17
|
-
import {
|
|
20
|
+
import { rendersHref } from './urls.js';
|
|
21
|
+
import { consuming, insertDivider, takeNestedMarker, toCodeBlock, toHeading, wrapInList, wrapInQuote } from './blocks.js';
|
|
18
22
|
/**
|
|
19
23
|
* A mark input rule: when `regexp` (ending in the closing delimiter) matches
|
|
20
24
|
* before the caret, wrap capture group 1 in `markType` and drop the delimiters.
|
|
@@ -46,6 +50,25 @@ function markInputRule(regexp, markType, delimLen) {
|
|
|
46
50
|
return tr;
|
|
47
51
|
});
|
|
48
52
|
}
|
|
53
|
+
/** A typed URL, closed by the space after it: a scheme `rendersHref` follows, or a bare
|
|
54
|
+
* `www.`, less the punctuation around it. */
|
|
55
|
+
const TYPED_URL = /(?:^|[\s(["'])((?:[a-z][a-z0-9+.-]*:\/\/|www\.)[^\s.]\S*?)([.,;:!?)\]'"]*)\s$/i;
|
|
56
|
+
/** The link a typed URL takes, as a paste over a selection does (`link-paste.ts`): the
|
|
57
|
+
* URL's own text, with a bare `www.` read as `https://`. The space that closed it is
|
|
58
|
+
* typed after the link. Declined over a span already linked or set as code. */
|
|
59
|
+
function autolinkRule(link, code) {
|
|
60
|
+
return new InputRule(TYPED_URL, (state, match, start, end) => {
|
|
61
|
+
const [whole, url, trail] = match;
|
|
62
|
+
const from = start + whole.length - 1 - trail.length - url.length;
|
|
63
|
+
const to = from + url.length;
|
|
64
|
+
const href = /^www\./i.test(url) ? `https://${url}` : url;
|
|
65
|
+
if (!rendersHref(href) || state.doc.rangeHasMark(from, to, link))
|
|
66
|
+
return null;
|
|
67
|
+
if (code && state.doc.rangeHasMark(from, to, code))
|
|
68
|
+
return null;
|
|
69
|
+
return state.tr.addMark(from, to, link.create({ href })).insertText(whole.slice(-1), end);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
49
72
|
/** A block shorthand: the regexp that fires it, and the command it runs in the match's
|
|
50
73
|
* place. Every one is `^`-anchored, so the command's own head-of-a-textblock guard is
|
|
51
74
|
* the same position the regexp already stands at. The caret it places is scrolled to
|
|
@@ -59,23 +82,30 @@ export function markdownInputRules(schema) {
|
|
|
59
82
|
const m = schema.marks;
|
|
60
83
|
// Inline marks. Non-greedy capture between matching delimiters. Built only where the
|
|
61
84
|
// schema has the mark: the inline schema omits them, and a `text` one declares none
|
|
62
|
-
// at all.
|
|
85
|
+
// at all. An underscore pair opens only past a word, so `snake_case_name` stays text.
|
|
63
86
|
if (m.strong)
|
|
64
87
|
rules.push(markInputRule(/\*\*([^*]+)\*\*$/, m.strong, 2));
|
|
88
|
+
if (m.strong)
|
|
89
|
+
rules.push(markInputRule(/(?:^|[^\w])__([^_]+)__$/, m.strong, 2));
|
|
65
90
|
if (m.em)
|
|
66
91
|
rules.push(markInputRule(/(?:^|[^*])\*([^*]+)\*$/, m.em, 1));
|
|
92
|
+
if (m.em)
|
|
93
|
+
rules.push(markInputRule(/(?:^|[^\w])_([^_]+)_$/, m.em, 1));
|
|
67
94
|
if (m.strike)
|
|
68
95
|
rules.push(markInputRule(/~~([^~]+)~~$/, m.strike, 2));
|
|
69
96
|
if (m.code)
|
|
70
97
|
rules.push(markInputRule(/`([^`]+)`$/, m.code, 1));
|
|
98
|
+
if (m.link)
|
|
99
|
+
rules.push(autolinkRule(m.link, m.code));
|
|
71
100
|
// Block shorthands. Each command answers for the schema it is run against, so a
|
|
72
101
|
// schema without the node declines rather than being guarded here.
|
|
73
102
|
rules.push(blockRule(/^(#{1,6})\s$/, (match) => toHeading(match[1].length)));
|
|
74
103
|
rules.push(blockRule(/^---$/, insertDivider));
|
|
75
|
-
// `- ` / `1. ` at the start of a block, here or nested inside an item
|
|
76
|
-
//
|
|
104
|
+
// `- ` / `1. ` / `1) ` at the start of a block, here or nested inside an item; at the
|
|
105
|
+
// start of an item's own first block the command retypes the item's list instead.
|
|
77
106
|
rules.push(blockRule(/^\s*([-+*])\s$/, () => wrapInList(false)));
|
|
78
|
-
rules.push(blockRule(/^(\d+)
|
|
107
|
+
rules.push(blockRule(/^(\d+)[.)]\s$/, (match) => wrapInList(true, { start: +match[1] })));
|
|
108
|
+
rules.push(blockRule(/^(?:[a-z]\.|\((?:\d+|[a-z])\))\s$/, takeNestedMarker));
|
|
79
109
|
rules.push(blockRule(/^\s*>\s$/, wrapInQuote));
|
|
80
110
|
return rules;
|
|
81
111
|
}
|
|
@@ -5,9 +5,12 @@ import type { ContentIsland, TableProps } from '@quillmark/wasm';
|
|
|
5
5
|
export declare const ISLAND_SLOT = "\uFFFC";
|
|
6
6
|
/** Whether an island of `type` is block markup: a `table` stands alone on its line. */
|
|
7
7
|
export declare function isBlockIsland(type: ContentIsland['type']): boolean;
|
|
8
|
+
/** Whether a leaf edits an island of `type`; one holding any other is held (`fitsLeaf`). */
|
|
9
|
+
export declare function isEditedIsland(type: ContentIsland['type']): boolean;
|
|
8
10
|
/** Block island node (a table): a `U+FFFC` slot alone on its `para` line. Atom, unselectable content. */
|
|
9
11
|
export declare const islandBlockSpec: NodeSpec;
|
|
10
|
-
/** Inline island node (an image): a `U+FFFC` slot inside a
|
|
12
|
+
/** Inline island node (an image, a footnote, an equation): a `U+FFFC` slot inside a
|
|
13
|
+
* `para` line. */
|
|
11
14
|
export declare const islandInlineSpec: NodeSpec;
|
|
12
15
|
/** A PM island node's attributes: the content entry, spelled the way a node spec
|
|
13
16
|
* spells it (`islandType`, because `type` on a PM node is the node type). `islandType`
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Islands: the `U+FFFC` slot + `ContentIsland` entry ↔ a PM leaf node. Every slot sits
|
|
2
2
|
// in a `para` line; the island's type says which node it is, a `table` a block alone on
|
|
3
|
-
// its line and
|
|
3
|
+
// its line and every other type inline. The node carries `id`, `islandType` and `props`
|
|
4
|
+
// verbatim.
|
|
4
5
|
//
|
|
5
6
|
// A props-aware consumer reads the shape straight off the boundary:
|
|
6
|
-
// `ContentIsland.props` is the typed union (`TableProps` for `table`,
|
|
7
|
-
// `
|
|
7
|
+
// `ContentIsland.props` is the typed union (`TableProps` for `table`, `ImageProps` for
|
|
8
|
+
// `image`, …; DOCUMENT_MODEL §Stability seams), so the codec
|
|
8
9
|
// reads it rather than hand-rolling a shape. `type` is a closed set, so a
|
|
9
10
|
// discriminant check narrows it; `props` still crosses the DOM as opaque JSON,
|
|
10
11
|
// which is what §`tablePropsOfNode` guards.
|
|
@@ -38,25 +39,35 @@ function islandDOM(node) {
|
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
/** The island types the content model names, each with whether its markup is a block,
|
|
41
|
-
* which decides the node its slot decodes to. A `Record`
|
|
42
|
-
* so a member added upstream is a missing key here
|
|
43
|
-
* one door with no decode behind it.
|
|
44
|
-
|
|
42
|
+
* which decides the node its slot decodes to, and whether a leaf edits it. A `Record`
|
|
43
|
+
* over the boundary's own union, so a member added upstream is a missing key here
|
|
44
|
+
* rather than a silent refusal at the one door with no decode behind it. A footnote and
|
|
45
|
+
* an equation are read, not edited (CODEC §Decode): a leaf holding one is held. */
|
|
46
|
+
const ISLANDS = {
|
|
47
|
+
table: { block: true, edited: true },
|
|
48
|
+
image: { block: false, edited: true },
|
|
49
|
+
footnote: { block: false, edited: false },
|
|
50
|
+
math: { block: false, edited: false }
|
|
51
|
+
};
|
|
45
52
|
/** Whether an island of `type` is block markup: a `table` stands alone on its line. */
|
|
46
53
|
export function isBlockIsland(type) {
|
|
47
|
-
return
|
|
54
|
+
return ISLANDS[type].block;
|
|
55
|
+
}
|
|
56
|
+
/** Whether a leaf edits an island of `type`; one holding any other is held (`fitsLeaf`). */
|
|
57
|
+
export function isEditedIsland(type) {
|
|
58
|
+
return ISLANDS[type].edited;
|
|
48
59
|
}
|
|
49
60
|
/** An island node's attributes off that DOM, or `false` where the id is absent or the
|
|
50
|
-
* type is one the
|
|
51
|
-
*
|
|
52
|
-
* written above reads as `null`, and one of the wrong shape is
|
|
53
|
-
* reader (§`tablePropsOfNode`): the entry survives as an island of
|
|
54
|
-
* than taking the paste down with it. */
|
|
61
|
+
* type is one no leaf edits — a name the vocabulary does not hold, or a footnote or an
|
|
62
|
+
* equation, which no gesture mints — so the element's text arrives as text. A `props`
|
|
63
|
+
* that is not the JSON written above reads as `null`, and one of the wrong shape is
|
|
64
|
+
* `undefined` at its reader (§`tablePropsOfNode`): the entry survives as an island of
|
|
65
|
+
* its type rather than taking the paste down with it. */
|
|
55
66
|
function islandAttrsFromDOM(el) {
|
|
56
67
|
const id = el.getAttribute('data-qm-island-id');
|
|
57
68
|
// Asserted off an untrusted attribute and checked against the set on the next line.
|
|
58
69
|
const type = el.getAttribute('data-qm-island');
|
|
59
|
-
if (id == null || type == null || !Object.hasOwn(
|
|
70
|
+
if (id == null || type == null || !Object.hasOwn(ISLANDS, type) || !isEditedIsland(type))
|
|
60
71
|
return false;
|
|
61
72
|
const raw = el.getAttribute('data-qm-island-props');
|
|
62
73
|
let props = null;
|
|
@@ -86,7 +97,8 @@ export const islandBlockSpec = {
|
|
|
86
97
|
parseDOM: [{ tag: 'div[data-qm-island]', getAttrs: (el) => islandAttrsFromDOM(el) }],
|
|
87
98
|
toDOM: (node) => ['div', islandDOM(node), `[${node.attrs.islandType || 'island'}]`]
|
|
88
99
|
};
|
|
89
|
-
/** Inline island node (an image): a `U+FFFC` slot inside a
|
|
100
|
+
/** Inline island node (an image, a footnote, an equation): a `U+FFFC` slot inside a
|
|
101
|
+
* `para` line. */
|
|
90
102
|
export const islandInlineSpec = {
|
|
91
103
|
group: 'inline',
|
|
92
104
|
inline: true,
|
|
@@ -94,8 +106,23 @@ export const islandInlineSpec = {
|
|
|
94
106
|
selectable: true,
|
|
95
107
|
attrs: islandAttrs,
|
|
96
108
|
parseDOM: [{ tag: 'span[data-qm-island]', getAttrs: (el) => islandAttrsFromDOM(el) }],
|
|
97
|
-
toDOM:
|
|
109
|
+
toDOM: inlineIslandDOM
|
|
98
110
|
};
|
|
111
|
+
/** An inline island's DOM: a footnote its reference mark, whose number `prose.css` draws
|
|
112
|
+
* in reading order and whose note is its title, an equation its TeX between the dollars
|
|
113
|
+
* that write it, and an image its tag. */
|
|
114
|
+
function inlineIslandDOM(node) {
|
|
115
|
+
const attrs = node.attrs;
|
|
116
|
+
const dom = islandDOM(node);
|
|
117
|
+
const props = attrs.props;
|
|
118
|
+
if (attrs.islandType === 'footnote')
|
|
119
|
+
return ['sup', typeof props?.text === 'string' ? { ...dom, title: props.text } : dom];
|
|
120
|
+
if (attrs.islandType === 'math' && typeof props?.tex === 'string') {
|
|
121
|
+
const fence = props.display ? '$$' : '$';
|
|
122
|
+
return ['span', dom, `${fence}${props.tex}${fence}`];
|
|
123
|
+
}
|
|
124
|
+
return ['span', dom, `[${attrs.islandType || 'island'}]`];
|
|
125
|
+
}
|
|
99
126
|
/** Build a content island entry from a PM island node's attrs (block or inline). The
|
|
100
127
|
* cast pairs a closed `type` with a `props` that crossed the DOM as opaque JSON. The
|
|
101
128
|
* store is lenient about a table's shape — it fills what it can and keeps the rest —
|
|
@@ -4,6 +4,7 @@ import { blockKeymap } from './blocks.js';
|
|
|
4
4
|
import { breakKeymap } from './breaks.js';
|
|
5
5
|
import { codeKeymap } from './code.js';
|
|
6
6
|
import { listKeymap } from './lists.js';
|
|
7
|
+
import { listMoveKeymap } from './list-move.js';
|
|
7
8
|
import { slashKeymap } from './slash.js';
|
|
8
9
|
/** One chain per key, the maps taken in precedence order: on a key several bind, an
|
|
9
10
|
* earlier map's command runs first and the next runs only if it declines. A key one
|
|
@@ -17,6 +18,23 @@ function chainKeymaps(...maps) {
|
|
|
17
18
|
}
|
|
18
19
|
return out;
|
|
19
20
|
}
|
|
21
|
+
/** The last link of a document body: Tab and Shift-Tab held wherever no surface took
|
|
22
|
+
* them, and Escape giving the focus up, so the browser's next Tab leaves from the body.
|
|
23
|
+
* `{}` over `plainSchema`, a form field whose Tab is field navigation. */
|
|
24
|
+
function exitKeymap(schema) {
|
|
25
|
+
if (!schema.nodes.list_item)
|
|
26
|
+
return {};
|
|
27
|
+
return {
|
|
28
|
+
Tab: () => true,
|
|
29
|
+
'Shift-Tab': () => true,
|
|
30
|
+
Escape: (_state, _dispatch, view) => {
|
|
31
|
+
if (!view)
|
|
32
|
+
return false;
|
|
33
|
+
view.dom.blur();
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
20
38
|
/** A multi-block leaf's structural keys, each link binding only what its schema
|
|
21
39
|
* declares: over `plainSchema`, which holds neither lists nor code blocks nor islands,
|
|
22
40
|
* the block link and the break link alone. The argument list is the precedence order
|
|
@@ -29,5 +47,5 @@ function chainKeymaps(...maps) {
|
|
|
29
47
|
* no visible reason. */
|
|
30
48
|
export function bodyKeymap(schema, slash) {
|
|
31
49
|
const links = slash ? [slashKeymap()] : [];
|
|
32
|
-
return chainKeymaps(...links, blockKeymap(schema), codeKeymap(schema), breakKeymap(schema), listKeymap(schema), atomKeymap(schema));
|
|
50
|
+
return chainKeymaps(...links, blockKeymap(schema), codeKeymap(schema), breakKeymap(schema), listKeymap(schema), listMoveKeymap(schema), atomKeymap(schema), exitKeymap(schema));
|
|
33
51
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Fragment, Slice, type Node as PMNode, type Schema } from 'prosemirror-model';
|
|
2
|
+
import { Plugin } from 'prosemirror-state';
|
|
3
|
+
/** The lines a slice holds that hold anything, in order. */
|
|
4
|
+
export declare function pastedLines(slice: Slice): PMNode[];
|
|
5
|
+
/** `lines` as the content of one line, a space between each two. */
|
|
6
|
+
export declare function joinLines(lines: readonly PMNode[], schema: Schema): Fragment;
|
|
7
|
+
export declare function linePastePlugin(): Plugin;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// A one-line leaf holds one paragraph, so a paste of several lines lands as that one line:
|
|
2
|
+
// the lines joined by a space, as a browser's single-line input joins them, where the
|
|
3
|
+
// schema would keep the first and drop the rest. An element of an array of them is the
|
|
4
|
+
// exception the array asks for (`visual/ProseValue.svelte`): there each line is a row.
|
|
5
|
+
import { Fragment, Slice } from 'prosemirror-model';
|
|
6
|
+
import { Plugin } from 'prosemirror-state';
|
|
7
|
+
/** The lines a slice holds that hold anything, in order. */
|
|
8
|
+
export function pastedLines(slice) {
|
|
9
|
+
const lines = [];
|
|
10
|
+
slice.content.descendants((node) => {
|
|
11
|
+
if (node.isTextblock && node.content.size)
|
|
12
|
+
lines.push(node);
|
|
13
|
+
return !node.isTextblock;
|
|
14
|
+
});
|
|
15
|
+
return lines;
|
|
16
|
+
}
|
|
17
|
+
/** `lines` as the content of one line, a space between each two. */
|
|
18
|
+
export function joinLines(lines, schema) {
|
|
19
|
+
const parts = [];
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
if (parts.length)
|
|
22
|
+
parts.push(schema.text(' '));
|
|
23
|
+
line.content.forEach((node) => parts.push(node));
|
|
24
|
+
}
|
|
25
|
+
return Fragment.from(parts);
|
|
26
|
+
}
|
|
27
|
+
export function linePastePlugin() {
|
|
28
|
+
return new Plugin({
|
|
29
|
+
props: {
|
|
30
|
+
transformPasted(slice, view) {
|
|
31
|
+
const lines = pastedLines(slice);
|
|
32
|
+
return lines.length > 1 ? new Slice(joinLines(lines, view.state.schema), 0, 0) : slice;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Schema } from 'prosemirror-model';
|
|
2
|
+
import type { Command } from 'prosemirror-state';
|
|
3
|
+
/** The items the selection stands in, in the innermost list holding all of them, moved
|
|
4
|
+
* one place up (`-1`) or down (`1`). */
|
|
5
|
+
export declare function moveItems(dir: -1 | 1): Command;
|
|
6
|
+
export declare function listMoveKeymap(schema: Schema): Record<string, Command>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const isList = (node) => node.type.name === 'ordered_list' || node.type.name === 'bullet_list';
|
|
2
|
+
/** The items the selection stands in, in the innermost list holding all of them, moved
|
|
3
|
+
* one place up (`-1`) or down (`1`). */
|
|
4
|
+
export function moveItems(dir) {
|
|
5
|
+
return (state, dispatch) => {
|
|
6
|
+
const { $from, $to } = state.selection;
|
|
7
|
+
const range = $from.blockRange($to, isList);
|
|
8
|
+
if (!range)
|
|
9
|
+
return false;
|
|
10
|
+
const index = dir < 0 ? range.startIndex - 1 : range.endIndex;
|
|
11
|
+
if (index < 0 || index >= range.parent.childCount)
|
|
12
|
+
return false;
|
|
13
|
+
if (!dispatch)
|
|
14
|
+
return true;
|
|
15
|
+
const sibling = range.parent.child(index);
|
|
16
|
+
const size = sibling.nodeSize;
|
|
17
|
+
const tr = state.tr;
|
|
18
|
+
if (dir < 0)
|
|
19
|
+
tr.delete(range.start - size, range.start).insert(range.end - size, sibling);
|
|
20
|
+
else
|
|
21
|
+
tr.delete(range.end, range.end + size).insert(range.start, sibling);
|
|
22
|
+
dispatch(tr.scrollIntoView());
|
|
23
|
+
return true;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function listMoveKeymap(schema) {
|
|
27
|
+
if (!schema.nodes.list_item)
|
|
28
|
+
return {};
|
|
29
|
+
return { 'Alt-ArrowUp': moveItems(-1), 'Alt-ArrowDown': moveItems(1) };
|
|
30
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// A pasted list takes the numbering of where it lands (CODEC §"Markdown at the edges").
|
|
2
|
+
// An item's number is its position, so a paste counts where it stands: items pasted into
|
|
3
|
+
// a list are that list's next items, of its kind and in its count, and items pasted on
|
|
4
|
+
// the empty line a list was just left by are the same list's. A list pasted on a line of
|
|
5
|
+
// its own keeps the `start` it was copied with, which a writer typed (`lists.ts` writes
|
|
6
|
+
// none) — but a run of items copied from inside a list is copied counting from 1: their
|
|
7
|
+
// numbers were positions in the list they came out of.
|
|
8
|
+
//
|
|
9
|
+
// A line's shape travels with its end, as a word processor's paragraph mark carries its
|
|
10
|
+
// style: text copied from inside one line pastes as text, whatever block it was copied
|
|
11
|
+
// out of, and paragraphs pasted into a numbered line are numbered lines.
|
|
12
|
+
import { Fragment, Slice } from 'prosemirror-model';
|
|
13
|
+
import { Plugin, Selection } from 'prosemirror-state';
|
|
14
|
+
const isList = (node) => !!node && (node.type.name === 'ordered_list' || node.type.name === 'bullet_list');
|
|
15
|
+
/** The one list a slice is, if it is one: whole, or open where a run of its items was
|
|
16
|
+
* copied. */
|
|
17
|
+
function soleList(slice) {
|
|
18
|
+
return slice.content.childCount === 1 && isList(slice.content.firstChild)
|
|
19
|
+
? slice.content.firstChild
|
|
20
|
+
: null;
|
|
21
|
+
}
|
|
22
|
+
/** The textblock a slice is the inside of: text copied from within one line, the slice
|
|
23
|
+
* open to that line at both ends down a chain of only children. */
|
|
24
|
+
function lineInside(slice) {
|
|
25
|
+
if (slice.openStart === 0 || slice.openStart !== slice.openEnd)
|
|
26
|
+
return null;
|
|
27
|
+
let node = null;
|
|
28
|
+
for (let d = 0, content = slice.content; d < slice.openStart; d++, content = node.content) {
|
|
29
|
+
if (content.childCount !== 1)
|
|
30
|
+
return null;
|
|
31
|
+
node = content.firstChild;
|
|
32
|
+
}
|
|
33
|
+
return node.isTextblock ? node : null;
|
|
34
|
+
}
|
|
35
|
+
/** `content` with the node `depth` levels down its leading edge counting from 1. */
|
|
36
|
+
function fromOne(content, depth) {
|
|
37
|
+
const first = content.firstChild;
|
|
38
|
+
const node = depth === 1
|
|
39
|
+
? first.type.create({ ...first.attrs, start: 1 }, first.content, first.marks)
|
|
40
|
+
: first.copy(fromOne(first.content, depth - 1));
|
|
41
|
+
return content.replaceChild(0, node);
|
|
42
|
+
}
|
|
43
|
+
/** A new item beside `item`: a task's sibling is a task, and unchecked. */
|
|
44
|
+
const itemAttrs = (item) => ({ checked: item.attrs.checked === null ? null : false });
|
|
45
|
+
/** HTML a ProseMirror view wrote, which states how deep the copy was open at each end. */
|
|
46
|
+
const PM_SLICE = /\sdata-pm-slice="\d+ \d+/;
|
|
47
|
+
export function listPastePlugin() {
|
|
48
|
+
// Whether the paste in flight states its own open depths: a ProseMirror copy does, and
|
|
49
|
+
// a drag inside the view carries its slice whole. Any other source is parsed open as
|
|
50
|
+
// deep as it goes, so a whole item copied off a web page reads as the inside of one
|
|
51
|
+
// line, and only a slice that says so is taken for one. Read once, by the transform.
|
|
52
|
+
let exact = false;
|
|
53
|
+
return new Plugin({
|
|
54
|
+
props: {
|
|
55
|
+
handleDOMEvents: {
|
|
56
|
+
drop: (view) => {
|
|
57
|
+
exact = !!view.dragging;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
transformPastedHTML(html) {
|
|
62
|
+
exact = PM_SLICE.test(html);
|
|
63
|
+
return html;
|
|
64
|
+
},
|
|
65
|
+
// A copy whose leading edge stands past an ordered list's first item is a run
|
|
66
|
+
// out of it, and goes out counting from 1; one from the first item keeps the
|
|
67
|
+
// list's `start`, however the selection ends. The copy is the selection's own
|
|
68
|
+
// slice, its leading edge the caret's ancestors from the top, so the list at
|
|
69
|
+
// depth `d` stands `d` levels down it.
|
|
70
|
+
transformCopied(slice, view) {
|
|
71
|
+
const { $from } = view.state.selection;
|
|
72
|
+
let content = slice.content;
|
|
73
|
+
for (let d = 1; d < $from.depth; d++) {
|
|
74
|
+
const list = $from.node(d);
|
|
75
|
+
if (list.type.name === 'ordered_list' && $from.index(d) > 0 && list.attrs.start !== 1)
|
|
76
|
+
content = fromOne(content, d);
|
|
77
|
+
}
|
|
78
|
+
return content === slice.content
|
|
79
|
+
? slice
|
|
80
|
+
: new Slice(content, slice.openStart, slice.openEnd);
|
|
81
|
+
},
|
|
82
|
+
// The caret's line keeps its shape under text from inside another line: no
|
|
83
|
+
// list, heading or fence comes along to wrap it. Into a line of an item, a list
|
|
84
|
+
// is its items, which join the list the caret is in where a whole list would
|
|
85
|
+
// nest under that line's item. Paragraphs into an item's first line are items
|
|
86
|
+
// beside it, as a word processor numbers each pasted paragraph; a later line of
|
|
87
|
+
// an item is unnumbered, and so are the paragraphs pasted into it.
|
|
88
|
+
transformPasted(slice, view) {
|
|
89
|
+
const line = exact && lineInside(slice);
|
|
90
|
+
exact = false;
|
|
91
|
+
if (line)
|
|
92
|
+
return new Slice(line.content, 0, 0);
|
|
93
|
+
const { $from, $to } = view.state.selection;
|
|
94
|
+
if (!$from.sameParent($to) || !$from.parent.isTextblock || $from.depth < 2)
|
|
95
|
+
return slice;
|
|
96
|
+
if ($from.parent.type.spec.code)
|
|
97
|
+
return slice;
|
|
98
|
+
const item = $from.node(-1);
|
|
99
|
+
if (item.type.name !== 'list_item')
|
|
100
|
+
return slice;
|
|
101
|
+
const list = soleList(slice);
|
|
102
|
+
if (list)
|
|
103
|
+
return new Slice(list.content, Math.max(slice.openStart - 1, 0), Math.max(slice.openEnd - 1, 0));
|
|
104
|
+
const { content } = slice;
|
|
105
|
+
if ($from.index(-1) !== 0 || content.childCount < 2)
|
|
106
|
+
return slice;
|
|
107
|
+
const items = [];
|
|
108
|
+
for (let i = 0; i < content.childCount; i++) {
|
|
109
|
+
const block = content.child(i);
|
|
110
|
+
if (block.type.name !== 'paragraph')
|
|
111
|
+
return slice;
|
|
112
|
+
items.push(item.type.create(itemAttrs(item), block));
|
|
113
|
+
}
|
|
114
|
+
return new Slice(Fragment.from(items), slice.openStart && slice.openStart + 1, slice.openEnd && slice.openEnd + 1);
|
|
115
|
+
},
|
|
116
|
+
// On the empty line right under a list of the same kind, the line Enter on an
|
|
117
|
+
// empty last item leaves, the items are that list's next ones.
|
|
118
|
+
handlePaste(view, _event, slice) {
|
|
119
|
+
const list = soleList(slice);
|
|
120
|
+
const { $from, empty } = view.state.selection;
|
|
121
|
+
if (!list || !empty || !$from.parent.isTextblock || $from.parent.content.size !== 0)
|
|
122
|
+
return false;
|
|
123
|
+
if ($from.parent.type.spec.code)
|
|
124
|
+
return false;
|
|
125
|
+
const above = view.state.doc.resolve($from.before()).nodeBefore;
|
|
126
|
+
if (!isList(above) || above.type !== list.type)
|
|
127
|
+
return false;
|
|
128
|
+
const end = $from.before() - 1;
|
|
129
|
+
const tr = view.state.tr.delete($from.before(), $from.after()).insert(end, list.content);
|
|
130
|
+
try {
|
|
131
|
+
tr.doc.check();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
tr.setSelection(Selection.near(tr.doc.resolve(end + list.content.size), -1));
|
|
137
|
+
view.dispatch(tr.scrollIntoView().setMeta('paste', true).setMeta('uiEvent', 'paste'));
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Command } from 'prosemirror-state';
|
|
2
|
+
/** Number (`ordered`) or bullet every line of the selection, or, where every one already
|
|
3
|
+
* carries the kind, take those markers off. Declines where the selection holds no line. */
|
|
4
|
+
export declare function toggleList(ordered: boolean): Command;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The list toggle: every line of a selection numbered or bulleted as one gesture, or
|
|
2
|
+
// every one's marker taken off. Mod-Shift-7 and Mod-Shift-8 bind it, the Docs chords
|
|
3
|
+
// (`lists.ts`), and the selection toolbar offers it (`visual/FormatPopover.svelte`).
|
|
4
|
+
//
|
|
5
|
+
// A line is a textblock the selection touches; a fence is no line here, a list item
|
|
6
|
+
// being no place for one to land unasked. A line carries a marker where it opens its
|
|
7
|
+
// item. An item's later line, a continuation, goes with its item where the item's
|
|
8
|
+
// opening line is selected too, and otherwise carries no marker of its own. Where every
|
|
9
|
+
// line carries one of the kind, the toggle takes those markers off, and only those.
|
|
10
|
+
// Otherwise each line becomes the kind: a run of plain lines is wrapped, one item a
|
|
11
|
+
// line, and a run of continuation lines the same way where it stands, the first items
|
|
12
|
+
// of a sub-list under their item, as `- ` or `1. ` at a continuation's head opens one
|
|
13
|
+
// (`blocks.ts` §`openingAnItem`); a list of the other kind is retyped whole, a list
|
|
14
|
+
// being one kind (`blocks.ts` §`listAtItem`); a line already in the kind stays. What it
|
|
15
|
+
// wraps or retypes joins the lists of its kind it leaves touching (`touching.ts`), so
|
|
16
|
+
// the lines of one selection count as one list.
|
|
17
|
+
import { Fragment, NodeRange, Slice } from 'prosemirror-model';
|
|
18
|
+
import { wrapRangeInList } from 'prosemirror-schema-list';
|
|
19
|
+
import { ReplaceAroundStep } from 'prosemirror-transform';
|
|
20
|
+
import { withinNesting } from './nesting.js';
|
|
21
|
+
import { joinAll } from './touching.js';
|
|
22
|
+
/** The textblocks `[from, to]` touches, by position, fences left out. A selection that
|
|
23
|
+
* ends at the very head of a block has selected nothing in it. */
|
|
24
|
+
function linesIn(doc, from, to) {
|
|
25
|
+
const lines = [];
|
|
26
|
+
doc.nodesBetween(from, to, (node, pos) => {
|
|
27
|
+
if (!node.isTextblock)
|
|
28
|
+
return true;
|
|
29
|
+
if (!node.type.spec.code && (from === to || pos + 1 < to))
|
|
30
|
+
lines.push(pos);
|
|
31
|
+
return false;
|
|
32
|
+
});
|
|
33
|
+
return lines;
|
|
34
|
+
}
|
|
35
|
+
function lineAt(doc, pos, itemType) {
|
|
36
|
+
const $at = doc.resolve(pos + 1);
|
|
37
|
+
let item = -1;
|
|
38
|
+
for (let depth = $at.depth - 1; depth > 0 && item < 0; depth--)
|
|
39
|
+
if ($at.node(depth).type === itemType)
|
|
40
|
+
item = depth;
|
|
41
|
+
let opens = item > 0;
|
|
42
|
+
for (let depth = item; opens && depth < $at.depth; depth++)
|
|
43
|
+
opens = $at.index(depth) === 0;
|
|
44
|
+
return { pos, $at, item, opens };
|
|
45
|
+
}
|
|
46
|
+
/** The selection's lines, each with whether its item's opening line is among them. */
|
|
47
|
+
function linesOf(doc, from, to, itemType) {
|
|
48
|
+
const lines = linesIn(doc, from, to).map((pos) => lineAt(doc, pos, itemType));
|
|
49
|
+
const opened = new Set(lines.filter((l) => l.opens).map((l) => l.$at.before(l.item)));
|
|
50
|
+
return lines.map((line) => ({
|
|
51
|
+
...line,
|
|
52
|
+
covered: line.item > 0 && !line.opens && opened.has(line.$at.before(line.item))
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* `prosemirror-schema-list`'s `liftOutOfList`, over a transaction, which upstream does
|
|
57
|
+
* not export: the items of `range` out of their list, their blocks standing where the
|
|
58
|
+
* list stood, the list closed around them. In a sub-list the blocks land in the parent
|
|
59
|
+
* item, the continuation lines a marker coming off leaves (`lists.ts` §`markerOff`).
|
|
60
|
+
* Returns where the list's tail stands, `null` where the items were its last, and
|
|
61
|
+
* `false` where the parent cannot hold the items' blocks.
|
|
62
|
+
*/
|
|
63
|
+
function liftOut(tr, range) {
|
|
64
|
+
const list = range.parent;
|
|
65
|
+
for (let pos = range.end, i = range.endIndex - 1; i > range.startIndex; i--) {
|
|
66
|
+
pos -= list.child(i).nodeSize;
|
|
67
|
+
tr.delete(pos - 1, pos + 1);
|
|
68
|
+
}
|
|
69
|
+
const $start = tr.doc.resolve(range.start);
|
|
70
|
+
const item = $start.nodeAfter;
|
|
71
|
+
const atStart = range.startIndex === 0;
|
|
72
|
+
const atEnd = range.endIndex === list.childCount;
|
|
73
|
+
const start = $start.pos;
|
|
74
|
+
const end = start + item.nodeSize;
|
|
75
|
+
const shell = Fragment.from(list.copy(Fragment.empty));
|
|
76
|
+
const step = new ReplaceAroundStep(start - (atStart ? 1 : 0), end + (atEnd ? 1 : 0), start + 1, end - 1, new Slice((atStart ? Fragment.empty : shell).append(atEnd ? Fragment.empty : shell), atStart ? 0 : 1, atEnd ? 0 : 1), atStart ? 0 : 1);
|
|
77
|
+
if (tr.maybeStep(step).failed)
|
|
78
|
+
return false;
|
|
79
|
+
if (atEnd)
|
|
80
|
+
return null;
|
|
81
|
+
const $list = tr.doc.resolve(range.$from.before(range.depth));
|
|
82
|
+
return $list.posAtIndex($list.index() + (atStart ? 0 : 1) + item.childCount);
|
|
83
|
+
}
|
|
84
|
+
/** Take every marker the selection's lines carry off, the shallowest list first:
|
|
85
|
+
* lifting an item brings its sub-lists up a level, where the next pass reaches them. A
|
|
86
|
+
* list's tail counts from 1 again, a number that names its position in the list it now
|
|
87
|
+
* is (`lists.ts` §`lineOut`). */
|
|
88
|
+
function unlist(tr, type, itemType) {
|
|
89
|
+
for (;;) {
|
|
90
|
+
const { from, to } = tr.selection;
|
|
91
|
+
const marked = linesIn(tr.doc, from, to)
|
|
92
|
+
.map((pos) => lineAt(tr.doc, pos, itemType))
|
|
93
|
+
.filter((l) => l.opens && l.$at.node(l.item - 1).type === type);
|
|
94
|
+
if (!marked.length)
|
|
95
|
+
return true;
|
|
96
|
+
const depth = Math.min(...marked.map((l) => l.item - 1));
|
|
97
|
+
const listPos = marked.find((l) => l.item - 1 === depth).$at.before(depth);
|
|
98
|
+
const indices = marked
|
|
99
|
+
.filter((l) => l.item - 1 === depth && l.$at.before(depth) === listPos)
|
|
100
|
+
.map((l) => l.$at.index(depth));
|
|
101
|
+
const $list = tr.doc.resolve(listPos + 1);
|
|
102
|
+
const range = new NodeRange(tr.doc.resolve($list.posAtIndex(Math.min(...indices))), tr.doc.resolve($list.posAtIndex(Math.max(...indices) + 1)), depth);
|
|
103
|
+
const tail = liftOut(tr, range);
|
|
104
|
+
if (tail === false)
|
|
105
|
+
return false;
|
|
106
|
+
const rest = tail === null ? null : tr.doc.nodeAt(tail);
|
|
107
|
+
if (tail !== null && rest?.attrs.start != null && rest.attrs.start !== 1)
|
|
108
|
+
tr.setNodeMarkup(tail, null, { ...rest.attrs, start: 1 });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Make every line the kind, and join what that leaves touching. */
|
|
112
|
+
function relist(tr, lines, type, paragraph) {
|
|
113
|
+
const changed = [];
|
|
114
|
+
const runs = [];
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
const { $at, item } = line;
|
|
117
|
+
if (line.covered)
|
|
118
|
+
continue;
|
|
119
|
+
if (line.opens) {
|
|
120
|
+
const pos = $at.before(item - 1);
|
|
121
|
+
if ($at.node(item - 1).type !== type && !changed.some((c) => c.pos === pos)) {
|
|
122
|
+
tr.setNodeMarkup(pos, type, type.name === 'ordered_list' ? { start: 1 } : null);
|
|
123
|
+
changed.push({ pos, step: tr.steps.length });
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const end = line.pos + $at.parent.nodeSize;
|
|
128
|
+
const run = runs[runs.length - 1];
|
|
129
|
+
if (run && run.to === line.pos && run.depth === $at.depth - 1)
|
|
130
|
+
run.to = end;
|
|
131
|
+
else
|
|
132
|
+
runs.push({ from: line.pos, to: end, depth: $at.depth - 1 });
|
|
133
|
+
}
|
|
134
|
+
for (const run of runs.reverse()) {
|
|
135
|
+
tr.setBlockType(run.from, run.to, paragraph);
|
|
136
|
+
const range = new NodeRange(tr.doc.resolve(run.from), tr.doc.resolve(run.to), run.depth);
|
|
137
|
+
if (!wrapRangeInList(tr, range, type))
|
|
138
|
+
return false;
|
|
139
|
+
changed.push({ pos: run.from, step: tr.steps.length });
|
|
140
|
+
}
|
|
141
|
+
const seams = [];
|
|
142
|
+
for (const { pos, step } of changed) {
|
|
143
|
+
const at = tr.mapping.slice(step).map(pos);
|
|
144
|
+
const node = tr.doc.nodeAt(at);
|
|
145
|
+
if (node)
|
|
146
|
+
seams.push(at, at + node.nodeSize);
|
|
147
|
+
}
|
|
148
|
+
joinAll(tr, seams);
|
|
149
|
+
return withinNesting(tr.doc);
|
|
150
|
+
}
|
|
151
|
+
/** Number (`ordered`) or bullet every line of the selection, or, where every one already
|
|
152
|
+
* carries the kind, take those markers off. Declines where the selection holds no line. */
|
|
153
|
+
export function toggleList(ordered) {
|
|
154
|
+
return (state, dispatch) => {
|
|
155
|
+
const { list_item: item, paragraph } = state.schema.nodes;
|
|
156
|
+
const type = state.schema.nodes[ordered ? 'ordered_list' : 'bullet_list'];
|
|
157
|
+
if (!item || !paragraph || !type)
|
|
158
|
+
return false;
|
|
159
|
+
const { from, to } = state.selection;
|
|
160
|
+
const lines = linesOf(state.doc, from, to, item);
|
|
161
|
+
if (!lines.length)
|
|
162
|
+
return false;
|
|
163
|
+
const tr = state.tr;
|
|
164
|
+
const off = lines.every((l) => l.covered || (l.opens && l.$at.node(l.item - 1).type === type));
|
|
165
|
+
if (!(off ? unlist(tr, type, item) : relist(tr, lines, type, paragraph)))
|
|
166
|
+
return false;
|
|
167
|
+
dispatch?.(tr.scrollIntoView());
|
|
168
|
+
return true;
|
|
169
|
+
};
|
|
170
|
+
}
|