@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.
Files changed (68) hide show
  1. package/README.md +7 -8
  2. package/THEMING.md +1 -1
  3. package/dist/core/codec/blocks.d.ts +23 -6
  4. package/dist/core/codec/blocks.js +163 -38
  5. package/dist/core/codec/code.js +3 -8
  6. package/dist/core/codec/decode.d.ts +2 -1
  7. package/dist/core/codec/decode.js +19 -11
  8. package/dist/core/codec/field.d.ts +6 -3
  9. package/dist/core/codec/field.js +12 -4
  10. package/dist/core/codec/index.d.ts +2 -0
  11. package/dist/core/codec/index.js +4 -0
  12. package/dist/core/codec/inputrules.js +40 -10
  13. package/dist/core/codec/islands.d.ts +4 -1
  14. package/dist/core/codec/islands.js +43 -16
  15. package/dist/core/codec/keymap.js +19 -1
  16. package/dist/core/codec/line-paste.d.ts +7 -0
  17. package/dist/core/codec/line-paste.js +36 -0
  18. package/dist/core/codec/list-move.d.ts +6 -0
  19. package/dist/core/codec/list-move.js +30 -0
  20. package/dist/core/codec/list-paste.d.ts +2 -0
  21. package/dist/core/codec/list-paste.js +142 -0
  22. package/dist/core/codec/list-toggle.d.ts +4 -0
  23. package/dist/core/codec/list-toggle.js +170 -0
  24. package/dist/core/codec/lists.d.ts +22 -15
  25. package/dist/core/codec/lists.js +404 -97
  26. package/dist/core/codec/markdown-paste.d.ts +13 -3
  27. package/dist/core/codec/markdown-paste.js +70 -6
  28. package/dist/core/codec/nesting.d.ts +5 -1
  29. package/dist/core/codec/nesting.js +1 -1
  30. package/dist/core/codec/prose.css +36 -20
  31. package/dist/core/codec/slash.d.ts +2 -1
  32. package/dist/core/codec/slash.js +0 -0
  33. package/dist/core/codec/touching.d.ts +12 -0
  34. package/dist/core/codec/touching.js +33 -0
  35. package/dist/core/codec/undo.d.ts +5 -0
  36. package/dist/core/codec/undo.js +21 -0
  37. package/dist/index.js +1 -1
  38. package/dist/preview/Preview.svelte +88 -13
  39. package/dist/preview/Preview.svelte.d.ts +2 -2
  40. package/dist/preview/bridge.js +8 -2
  41. package/dist/preview/controller.d.ts +8 -4
  42. package/dist/preview/controller.js +10 -2
  43. package/dist/preview/index.js +1 -1
  44. package/dist/preview/paint.d.ts +6 -2
  45. package/dist/preview/paint.js +67 -56
  46. package/dist/preview/strings.d.ts +2 -0
  47. package/dist/preview/strings.js +4 -2
  48. package/dist/preview/viewport.d.ts +10 -0
  49. package/dist/preview/viewport.js +271 -0
  50. package/dist/preview/zoom.d.ts +41 -0
  51. package/dist/preview/zoom.js +52 -0
  52. package/dist/visual/ArrayField.svelte +24 -4
  53. package/dist/visual/Card.svelte +5 -4
  54. package/dist/visual/DateField.svelte +26 -13
  55. package/dist/visual/Field.svelte +2 -2
  56. package/dist/visual/FormatPopover.svelte +41 -2
  57. package/dist/visual/ObjectField.svelte +4 -7
  58. package/dist/visual/ObjectField.svelte.d.ts +2 -2
  59. package/dist/visual/ProseValue.svelte +35 -3
  60. package/dist/visual/ProseValue.svelte.d.ts +7 -0
  61. package/dist/visual/VisualEditorInner.svelte +5 -11
  62. package/dist/visual/icons/nodes.d.ts +26 -0
  63. package/dist/visual/icons/nodes.js +16 -0
  64. package/dist/visual/strings.d.ts +6 -4
  65. package/dist/visual/strings.js +9 -3
  66. package/dist/visual/structure.d.ts +22 -10
  67. package/dist/visual/structure.js +35 -11
  68. package/package.json +2 -2
@@ -1,6 +1,7 @@
1
- // Input rules: the markdown-shorthand plugin the codec owns (`**`→strong, `*`→em,
2
- // `~~`→strike, `` ` ``→code, `# `→heading, `- `/`1. `→lists, `> `→blockquote,
3
- // `---`→divider), and the fence, which fires on Enter rather than as typed.
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 guard survives:
14
- // `- ` / `1. ` at the head of an existing item, where Tab owns the nesting.
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 { consuming, insertDivider, toCodeBlock, toHeading, wrapInList, wrapInQuote } from './blocks.js';
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 — but not at
76
- // the start of an item's own first block, where the command declines.
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+)\.\s$/, (match) => wrapInList(true, { start: +match[1] }, (node) => node.childCount + node.attrs.start === +match[1])));
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 `para` line. */
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 an `image` inline. The node carries `id`, `islandType` and `props` verbatim.
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
- // `ImageProps` for `image`; DOCUMENT_MODEL §Stability seams), so the codec
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` over the boundary's own union,
42
- * so a member added upstream is a missing key here rather than a silent refusal at the
43
- * one door with no decode behind it. */
44
- const BLOCK_ISLAND = { table: true, image: false };
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 BLOCK_ISLAND[type];
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 content vocabulary does not name — a name no read produces and no
51
- * write takes, so the element's text arrives as text. A `props` that is not the JSON
52
- * written above reads as `null`, and one of the wrong shape is `undefined` at its
53
- * reader (§`tablePropsOfNode`): the entry survives as an island of its type rather
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(BLOCK_ISLAND, type))
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 `para` line. */
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: (node) => ['span', islandDOM(node), `[${node.attrs.islandType || 'island'}]`]
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,2 @@
1
+ import { Plugin } from 'prosemirror-state';
2
+ export declare function listPastePlugin(): Plugin;
@@ -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
+ }