@quillmark/svelte 0.10.1 → 0.12.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 +1 -1
- package/THEMING.md +2 -2
- package/dist/core/address.d.ts +17 -10
- package/dist/core/address.js +26 -12
- package/dist/core/bloom.js +4 -3
- package/dist/core/codec/blocks.js +21 -1
- package/dist/core/codec/breaks.d.ts +10 -2
- package/dist/core/codec/breaks.js +65 -9
- package/dist/core/codec/code.d.ts +6 -4
- package/dist/core/codec/code.js +7 -4
- package/dist/core/codec/decode.js +103 -68
- package/dist/core/codec/encode.d.ts +5 -8
- package/dist/core/codec/encode.js +56 -86
- package/dist/core/codec/field.js +43 -24
- package/dist/core/codec/index.d.ts +2 -1
- package/dist/core/codec/index.js +6 -3
- package/dist/core/codec/islands.d.ts +16 -11
- package/dist/core/codec/islands.js +70 -59
- package/dist/core/codec/keymap.js +2 -1
- package/dist/core/codec/marks.d.ts +17 -13
- package/dist/core/codec/marks.js +39 -42
- package/dist/core/codec/positions.d.ts +4 -1
- package/dist/core/codec/positions.js +5 -1
- package/dist/core/codec/reconcile.d.ts +6 -0
- package/dist/core/codec/reconcile.js +19 -0
- package/dist/core/codec/schema.d.ts +5 -8
- package/dist/core/codec/schema.js +30 -156
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-view.js +77 -26
- package/dist/core/codec/table.d.ts +6 -5
- package/dist/core/codec/table.js +14 -31
- package/dist/core/codec/urls.d.ts +20 -0
- package/dist/core/codec/urls.js +45 -0
- package/dist/core/rungs.d.ts +8 -0
- package/dist/core/rungs.js +24 -0
- package/dist/core/theme.css +10 -0
- package/dist/preset/recipes.css +1 -1
- package/dist/preset/scale.css +7 -4
- package/dist/preview/bridge.d.ts +8 -1
- package/dist/preview/bridge.js +31 -25
- package/dist/preview/controller.js +13 -13
- package/dist/preview/geometry.d.ts +12 -1
- package/dist/preview/geometry.js +14 -1
- package/dist/preview/paint.js +6 -4
- package/dist/preview/strings.d.ts +0 -2
- package/dist/preview/strings.js +5 -6
- package/dist/visual/ArrayField.svelte +600 -173
- package/dist/visual/ArrayField.svelte.d.ts +14 -1
- package/dist/visual/BooleanField.svelte +10 -7
- package/dist/visual/Card.svelte +8 -30
- package/dist/visual/DateField.svelte +2 -2
- package/dist/visual/Field.svelte +76 -32
- package/dist/visual/FieldHint.svelte +1 -1
- package/dist/visual/MatrixField.svelte +352 -0
- package/dist/visual/MatrixField.svelte.d.ts +36 -0
- package/dist/visual/ObjectField.svelte +356 -133
- package/dist/visual/ObjectField.svelte.d.ts +17 -2
- package/dist/visual/VariantField.svelte +55 -2
- package/dist/visual/VariantField.svelte.d.ts +10 -1
- package/dist/visual/VisualEditor.svelte +1 -1
- package/dist/visual/VisualEditorInner.svelte +118 -67
- package/dist/visual/controls.css +39 -9
- package/dist/visual/diagnostics.d.ts +26 -2
- package/dist/visual/diagnostics.js +52 -5
- package/dist/visual/ext.d.ts +12 -13
- package/dist/visual/ext.js +22 -19
- package/dist/visual/hold.js +2 -2
- package/dist/visual/icons/nodes.d.ts +3 -0
- package/dist/visual/icons/nodes.js +2 -1
- package/dist/visual/index.js +2 -3
- package/dist/visual/leaves.d.ts +25 -12
- package/dist/visual/links.d.ts +4 -0
- package/dist/visual/links.js +15 -4
- package/dist/visual/motion.d.ts +14 -5
- package/dist/visual/motion.js +13 -11
- package/dist/visual/strings.d.ts +26 -13
- package/dist/visual/strings.js +7 -2
- package/dist/visual/structure.d.ts +101 -8
- package/dist/visual/structure.js +252 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ const doc = quill.seedDocument();
|
|
|
40
40
|
|
|
41
41
|
// An EXISTING document: parsed back from what you stored.
|
|
42
42
|
const doc = Document.fromMarkdown(markdown); // canonical Quillmark markdown
|
|
43
|
-
const doc = Document.
|
|
43
|
+
const doc = Document.fromStored(json); // the versioned storage DTO (`doc.toStored()`)
|
|
44
44
|
|
|
45
45
|
const session = await new Engine().open(quill, doc);
|
|
46
46
|
// free() on teardown the handles you MINTED: this quill, this doc, this session.
|
package/THEMING.md
CHANGED
|
@@ -123,7 +123,7 @@ import '@quillmark/svelte/preset';
|
|
|
123
123
|
```html
|
|
124
124
|
<body class="qm-page">
|
|
125
125
|
<p class="qm-label">quill</p>
|
|
126
|
-
<pre class="qm-readout">
|
|
126
|
+
<pre class="qm-readout">showcase@1.0.0</pre>
|
|
127
127
|
</body>
|
|
128
128
|
```
|
|
129
129
|
|
|
@@ -193,7 +193,7 @@ A blanket `:focus-visible` of yours outranks ours, including on the controls tha
|
|
|
193
193
|
|
|
194
194
|
## What is deliberately not public
|
|
195
195
|
|
|
196
|
-
The
|
|
196
|
+
The two root classes and `.qm-pane` are the whole class contract on the surfaces. Every other `qm-*` class inside them is internal and renames without notice: a class contract over the interior would freeze the DOM shape the surfaces are free to re-cut, and the dials already reach the values a restyle wants. The preset's classes are a contract of their own: they land on _your_ DOM, so they freeze nothing of ours.
|
|
197
197
|
|
|
198
198
|
The derived surface scale (surface / border / ink rungs, the blur radius, the popover's translucency ratio, the recede-opacity ladder, the two leading rungs, the overlay ring widths, the scroll tail) is internal on the same terms, and declared **on** each root element, so setting a rung from an ancestor does nothing. A dial appears when a real consumer needs one; every dial is one more thing a reader has to hold.
|
|
199
199
|
|
package/dist/core/address.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Addr, HitGranularity } from '@quillmark/wasm';
|
|
1
|
+
import type { Addr, HitGranularity, PathStep } from '@quillmark/wasm';
|
|
2
2
|
/**
|
|
3
3
|
* A canonical field address: `main.<field>` / `main.body` /
|
|
4
4
|
* `cards.<kind>[<i>].<field>`, cards keyed by absolute document index.
|
|
@@ -63,7 +63,7 @@ export declare function cardPath(index: number, kinds: readonly string[]): DocPa
|
|
|
63
63
|
/**
|
|
64
64
|
* The inverse: a canonical `DocPath` back to the `Addr` the document verbs take, or
|
|
65
65
|
* `undefined` for a path that names no single commit address — a nested or
|
|
66
|
-
* array-element path (`main.keywords[0]`, which {@link
|
|
66
|
+
* array-element path (`main.keywords[0]`, which {@link nestedAddrForFieldPath} and
|
|
67
67
|
* {@link nearestAddrForFieldPath} take instead), a field-rooted one, or a malformed
|
|
68
68
|
* one. A bare card and a `.body` terminal both land on the field-less `{card: i}`
|
|
69
69
|
* the body leaf answers to.
|
|
@@ -86,14 +86,21 @@ export declare function addrForFieldPath(path: DocPath): Addr | undefined;
|
|
|
86
86
|
*/
|
|
87
87
|
export declare function nearestAddrForFieldPath(path: DocPath): Addr | undefined;
|
|
88
88
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
89
|
+
* A nested address, split: the field's `Addr`, and the steps from it to the leaf.
|
|
90
|
+
* `main.keywords[0]` is `{field: keywords, steps: [0]}`,
|
|
91
|
+
* `main.vectors[0].tours[2].title` is `[0, 'tours', 2, 'title']`, and
|
|
92
|
+
* `main.contact.email` is `['email']`: an index segment reads as a number and a key as
|
|
93
|
+
* a string, the `PathStep` grammar `reader.getContentAt` walks. Anything
|
|
94
|
+
* {@link addrForFieldPath} can name — a field, a body, a card — has no steps and lands
|
|
95
|
+
* here as `undefined`; so does a body with something after it, a body holding no
|
|
96
|
+
* cells. Whether the schema declares each step is the caller's to check: this module
|
|
97
|
+
* holds the grammar and no schema. `regions()`, `positionAt` and `formatDocPath` all
|
|
98
|
+
* spell the index segment bracketed, so there is one spelling to read and none to
|
|
99
|
+
* bridge.
|
|
93
100
|
*/
|
|
94
|
-
export declare function
|
|
95
|
-
/**
|
|
96
|
-
export interface
|
|
101
|
+
export declare function nestedAddrForFieldPath(path: DocPath): NestedAddr | undefined;
|
|
102
|
+
/** A nested address: the field's `Addr`, and the steps from it to the leaf. */
|
|
103
|
+
export interface NestedAddr {
|
|
97
104
|
field: Addr;
|
|
98
|
-
|
|
105
|
+
steps: PathStep[];
|
|
99
106
|
}
|
package/dist/core/address.js
CHANGED
|
@@ -37,7 +37,7 @@ export function cardPath(index, kinds) {
|
|
|
37
37
|
/**
|
|
38
38
|
* The inverse: a canonical `DocPath` back to the `Addr` the document verbs take, or
|
|
39
39
|
* `undefined` for a path that names no single commit address — a nested or
|
|
40
|
-
* array-element path (`main.keywords[0]`, which {@link
|
|
40
|
+
* array-element path (`main.keywords[0]`, which {@link nestedAddrForFieldPath} and
|
|
41
41
|
* {@link nearestAddrForFieldPath} take instead), a field-rooted one, or a malformed
|
|
42
42
|
* one. A bare card and a `.body` terminal both land on the field-less `{card: i}`
|
|
43
43
|
* the body leaf answers to.
|
|
@@ -66,21 +66,35 @@ export function nearestAddrForFieldPath(path) {
|
|
|
66
66
|
return segs && addrForSegs(segs.slice(0, 2));
|
|
67
67
|
}
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
69
|
+
* A nested address, split: the field's `Addr`, and the steps from it to the leaf.
|
|
70
|
+
* `main.keywords[0]` is `{field: keywords, steps: [0]}`,
|
|
71
|
+
* `main.vectors[0].tours[2].title` is `[0, 'tours', 2, 'title']`, and
|
|
72
|
+
* `main.contact.email` is `['email']`: an index segment reads as a number and a key as
|
|
73
|
+
* a string, the `PathStep` grammar `reader.getContentAt` walks. Anything
|
|
74
|
+
* {@link addrForFieldPath} can name — a field, a body, a card — has no steps and lands
|
|
75
|
+
* here as `undefined`; so does a body with something after it, a body holding no
|
|
76
|
+
* cells. Whether the schema declares each step is the caller's to check: this module
|
|
77
|
+
* holds the grammar and no schema. `regions()`, `positionAt` and `formatDocPath` all
|
|
78
|
+
* spell the index segment bracketed, so there is one spelling to read and none to
|
|
79
|
+
* bridge.
|
|
73
80
|
*/
|
|
74
|
-
export function
|
|
81
|
+
export function nestedAddrForFieldPath(path) {
|
|
75
82
|
const segs = segsOf(path);
|
|
76
|
-
if (!segs)
|
|
83
|
+
if (!segs || segs.length < 3)
|
|
77
84
|
return undefined;
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
85
|
+
const field = addrForSegs(segs.slice(0, 2));
|
|
86
|
+
if (field?.field == null)
|
|
80
87
|
return undefined;
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
const steps = [];
|
|
89
|
+
for (const seg of segs.slice(2)) {
|
|
90
|
+
if (seg.seg === 'index')
|
|
91
|
+
steps.push(seg.index);
|
|
92
|
+
else if (seg.seg === 'field')
|
|
93
|
+
steps.push(seg.name);
|
|
94
|
+
else
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
return { field, steps };
|
|
84
98
|
}
|
|
85
99
|
/** `parseDocPath`, with a malformed path as `undefined` rather than a throw. The gate
|
|
86
100
|
* is read outside the try: an uninitialized core is not a malformed path, and would
|
package/dist/core/bloom.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// fixed by `--_qm-accent-wash`, so interpolating 0→1→0 keeps the scale in CSS where
|
|
7
7
|
// `color-mix` resolves, instead of in keyframe values a script would have to
|
|
8
8
|
// pre-resolve and re-resolve on a theme change.
|
|
9
|
+
import { rungStyle } from './rungs.js';
|
|
9
10
|
const BLOOM_CLASS = 'qm-bloom';
|
|
10
11
|
const WASH = 'var(--_qm-accent-wash)';
|
|
11
12
|
/** The dwell used when the derivation is out of reach (an unstyled root, or jsdom,
|
|
@@ -24,8 +25,8 @@ const FRAMES = [
|
|
|
24
25
|
];
|
|
25
26
|
const FRAMES_REDUCED = [{ opacity: 1 }, { opacity: 1 }];
|
|
26
27
|
/**
|
|
27
|
-
* Run one wash on `el`, resolving the dwell off
|
|
28
|
-
* in CSS.
|
|
28
|
+
* Run one wash on `el`, resolving the dwell off its root so the number is
|
|
29
|
+
* single-sourced in CSS (`core/rungs.ts`).
|
|
29
30
|
*
|
|
30
31
|
* One property, both motion preferences: `--_qm-bloom-dwell` already shortens under
|
|
31
32
|
* `prefers-reduced-motion` in the derivation, so what is left to `matchMedia` here is
|
|
@@ -36,7 +37,7 @@ function bloom(el) {
|
|
|
36
37
|
if (typeof el.animate !== 'function')
|
|
37
38
|
return undefined;
|
|
38
39
|
const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
39
|
-
const raw =
|
|
40
|
+
const raw = rungStyle(el).getPropertyValue('--_qm-bloom-dwell').trim();
|
|
40
41
|
const n = Number.parseFloat(raw);
|
|
41
42
|
const duration = !Number.isFinite(n) ? FALLBACK_MS : raw.endsWith('ms') ? n : n * 1000;
|
|
42
43
|
return el.animate(reduced ? FRAMES_REDUCED : FRAMES, { duration });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Selection } from 'prosemirror-state';
|
|
2
2
|
import { chainCommands, splitBlock } from 'prosemirror-commands';
|
|
3
3
|
import { canJoin, findWrapping } from 'prosemirror-transform';
|
|
4
|
+
import { takesLineBreak } from './schema.js';
|
|
4
5
|
/** The head of a textblock: where a shorthand fires, and where a pick lands once its
|
|
5
6
|
* run is consumed. */
|
|
6
7
|
function atHead(state) {
|
|
@@ -33,10 +34,29 @@ function retype(name, attrs) {
|
|
|
33
34
|
const { $from } = state.selection;
|
|
34
35
|
if (!type || !atHead(state) || !fits($from, type))
|
|
35
36
|
return false;
|
|
36
|
-
|
|
37
|
+
const tr = state.tr;
|
|
38
|
+
spaceBreaks(tr, $from, type);
|
|
39
|
+
dispatch?.(tr.setBlockType($from.pos, $from.pos, type, attrs));
|
|
37
40
|
return true;
|
|
38
41
|
};
|
|
39
42
|
}
|
|
43
|
+
/** Replace every `hard_break` in the caret's block with a space, ahead of a retype into
|
|
44
|
+
* a type that holds none. `setBlockType` clears what the new type cannot hold by
|
|
45
|
+
* deleting it, which joins the text either side with nothing and glues the words; a
|
|
46
|
+
* space is what the break becomes at every other door (`breaks.ts`, `decode.ts`). A
|
|
47
|
+
* break and a space are both one position wide, so an earlier replacement leaves every
|
|
48
|
+
* later one addressed. */
|
|
49
|
+
function spaceBreaks(tr, $from, type) {
|
|
50
|
+
const br = tr.doc.type.schema.nodes.hard_break;
|
|
51
|
+
if (!br || takesLineBreak(type))
|
|
52
|
+
return;
|
|
53
|
+
let at = $from.start();
|
|
54
|
+
$from.parent.forEach((child) => {
|
|
55
|
+
if (child.type === br)
|
|
56
|
+
tr.replaceWith(at, at + child.nodeSize, type.schema.text(' ', type.allowedMarks(child.marks)));
|
|
57
|
+
at += child.nodeSize;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
40
60
|
/** `wrappingInputRule`'s body: wrap the block range at `pos`, then join a preceding
|
|
41
61
|
* sibling of the same type — the one boundary the wrap itself opens (`lists.ts`
|
|
42
62
|
* §cleanup: command-local, never a global pass). */
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import type { Schema } from 'prosemirror-model';
|
|
2
|
-
import { Plugin } from 'prosemirror-state';
|
|
2
|
+
import { Plugin, type Command } from 'prosemirror-state';
|
|
3
3
|
/**
|
|
4
|
-
* Rewrite every `\n` a transaction leaves in a non-`code` textblock to a `hard_break
|
|
4
|
+
* Rewrite every `\n` a transaction leaves in a non-`code` textblock: to a `hard_break`
|
|
5
|
+
* where the block takes one, to a space where it does not.
|
|
5
6
|
*
|
|
6
7
|
* Reads the schema rather than a flag: the inline schemas declare neither node, and
|
|
7
8
|
* nothing puts a `\n` in one — a paste parses its whitespace away and the keys type
|
|
8
9
|
* characters.
|
|
9
10
|
*/
|
|
10
11
|
export declare function linebreakPlugin(schema: Schema): Plugin;
|
|
12
|
+
/**
|
|
13
|
+
* The line-break link of the body's key chains: `{}` for the inline schemas, which
|
|
14
|
+
* declare no break node. A `richtext(inline)` field is one line by construction
|
|
15
|
+
* (`Content::is_inline`), so there the key is swallowed beside Enter (`field.ts`)
|
|
16
|
+
* rather than answered here.
|
|
17
|
+
*/
|
|
18
|
+
export declare function breakKeymap(schema: Schema): Record<string, Command>;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { Plugin } from 'prosemirror-state';
|
|
1
|
+
import { Plugin, TextSelection } from 'prosemirror-state';
|
|
2
|
+
import { takesLineBreak } from './schema.js';
|
|
2
3
|
/**
|
|
3
|
-
* Rewrite every `\n` a transaction leaves in a non-`code` textblock to a `hard_break
|
|
4
|
+
* Rewrite every `\n` a transaction leaves in a non-`code` textblock: to a `hard_break`
|
|
5
|
+
* where the block takes one, to a space where it does not.
|
|
4
6
|
*
|
|
5
7
|
* Reads the schema rather than a flag: the inline schemas declare neither node, and
|
|
6
8
|
* nothing puts a `\n` in one — a paste parses its whitespace away and the keys type
|
|
@@ -13,25 +15,79 @@ export function linebreakPlugin(schema) {
|
|
|
13
15
|
if (!br || !trs.some((tr) => tr.docChanged))
|
|
14
16
|
return null;
|
|
15
17
|
const at = [];
|
|
16
|
-
state.doc.descendants((node, pos) => {
|
|
18
|
+
state.doc.descendants((node, pos, parent) => {
|
|
17
19
|
// A code block's newlines are its content (`whitespace: 'pre'`).
|
|
18
20
|
if (node.type.spec.code)
|
|
19
21
|
return false;
|
|
20
|
-
if (!node.isText)
|
|
22
|
+
if (!node.isText || !parent)
|
|
21
23
|
return true;
|
|
24
|
+
// A space carries the marks the `\n` it stands in for was under, the way
|
|
25
|
+
// PM's own `clearIncompatible` fills one: without them a bolded break
|
|
26
|
+
// leaves an unbolded space, and one mark becomes two in the store.
|
|
27
|
+
const fill = takesLineBreak(parent.type)
|
|
28
|
+
? br.create()
|
|
29
|
+
: schema.text(' ', parent.type.allowedMarks(node.marks));
|
|
22
30
|
const text = node.text ?? '';
|
|
23
31
|
for (let i = text.indexOf('\n'); i >= 0; i = text.indexOf('\n', i + 1))
|
|
24
|
-
at.push(pos + i);
|
|
32
|
+
at.push({ pos: pos + i, fill });
|
|
25
33
|
return true;
|
|
26
34
|
});
|
|
27
35
|
if (!at.length)
|
|
28
36
|
return null;
|
|
29
|
-
// A break and the `\n`
|
|
30
|
-
// replacement leaves every later one addressed.
|
|
37
|
+
// A break, a space and the `\n` either replaces are all one position wide, so
|
|
38
|
+
// an earlier replacement leaves every later one addressed.
|
|
31
39
|
const tr = state.tr;
|
|
32
|
-
for (const
|
|
33
|
-
tr.replaceWith(pos, pos + 1,
|
|
40
|
+
for (const it of at)
|
|
41
|
+
tr.replaceWith(it.pos, it.pos + 1, it.fill);
|
|
34
42
|
return tr;
|
|
35
43
|
}
|
|
36
44
|
});
|
|
37
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Shift-Enter: a line break inside the caret's own block — the `continues` line a hard
|
|
48
|
+
* break is, never a second block.
|
|
49
|
+
*
|
|
50
|
+
* A selection that is not a text range is swallowed outright: an island is the subject of
|
|
51
|
+
* the next command, never a thing armed for replacement (`atoms.ts`). Otherwise the
|
|
52
|
+
* caret's own block decides. It **declines** in a code block, where the newline Enter
|
|
53
|
+
* takes already *is* that line and the code link binds the key one surface in
|
|
54
|
+
* (`code.ts`); it **swallows** the key where the block holds no break (`takesLineBreak`),
|
|
55
|
+
* a heading being the one of those; everywhere else it inserts the break.
|
|
56
|
+
*
|
|
57
|
+
* **Swallowing is the guard, not a spelling of declining.** A key the leaf declines is
|
|
58
|
+
* the browser's, a contenteditable answers this one with a `<br>`, and a heading that
|
|
59
|
+
* merely declined would take the space a `<br>` parses to.
|
|
60
|
+
*
|
|
61
|
+
* The break takes no marks and the caret keeps them: `decode` mints an unmarked break,
|
|
62
|
+
* so an inherited mark would leave the leaf holding a shape a re-hydrate swaps out
|
|
63
|
+
* (CODEC §Encode, one spelling of each shape), while dropping the marks from the
|
|
64
|
+
* *caret* would end a bold run at a line break the author only meant to wrap at.
|
|
65
|
+
*/
|
|
66
|
+
function insertBreak(schema) {
|
|
67
|
+
const br = schema.nodes.hard_break;
|
|
68
|
+
return (state, dispatch) => {
|
|
69
|
+
if (!(state.selection instanceof TextSelection))
|
|
70
|
+
return true;
|
|
71
|
+
const { parent } = state.selection.$from;
|
|
72
|
+
if (parent.type.spec.code)
|
|
73
|
+
return false;
|
|
74
|
+
if (!takesLineBreak(parent.type))
|
|
75
|
+
return true;
|
|
76
|
+
if (dispatch) {
|
|
77
|
+
const marks = state.storedMarks ?? state.selection.$from.marks();
|
|
78
|
+
dispatch(state.tr.replaceSelectionWith(br.create(), false).setStoredMarks(marks).scrollIntoView());
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The line-break link of the body's key chains: `{}` for the inline schemas, which
|
|
85
|
+
* declare no break node. A `richtext(inline)` field is one line by construction
|
|
86
|
+
* (`Content::is_inline`), so there the key is swallowed beside Enter (`field.ts`)
|
|
87
|
+
* rather than answered here.
|
|
88
|
+
*/
|
|
89
|
+
export function breakKeymap(schema) {
|
|
90
|
+
if (!schema.nodes.hard_break)
|
|
91
|
+
return {};
|
|
92
|
+
return { 'Shift-Enter': insertBreak(schema) };
|
|
93
|
+
}
|
|
@@ -6,9 +6,11 @@ import { type Command } from 'prosemirror-state';
|
|
|
6
6
|
*
|
|
7
7
|
* `Enter` is upstream's `newlineInCode`, and it is load-bearing rather than
|
|
8
8
|
* cosmetic: inside a `list_item > code_block` the list link's `splitListItem`
|
|
9
|
-
* otherwise splits the item in two, each half holding a code block.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* otherwise splits the item in two, each half holding a code block. `Shift-Enter` is
|
|
10
|
+
* the same command because it is the same edit: the line break the key means
|
|
11
|
+
* everywhere else (`breaks.ts`) is, inside a fence, the newline Enter already takes.
|
|
12
|
+
* Every command here declines away from a code block — inside one for the four keys
|
|
13
|
+
* about its text, against one for the two about its edge — so the list links keep the
|
|
14
|
+
* keys elsewhere.
|
|
13
15
|
*/
|
|
14
16
|
export declare function codeKeymap(schema: Schema): Record<string, Command>;
|
package/dist/core/codec/code.js
CHANGED
|
@@ -158,10 +158,12 @@ function retypeAtProseEdge(side) {
|
|
|
158
158
|
*
|
|
159
159
|
* `Enter` is upstream's `newlineInCode`, and it is load-bearing rather than
|
|
160
160
|
* cosmetic: inside a `list_item > code_block` the list link's `splitListItem`
|
|
161
|
-
* otherwise splits the item in two, each half holding a code block.
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
161
|
+
* otherwise splits the item in two, each half holding a code block. `Shift-Enter` is
|
|
162
|
+
* the same command because it is the same edit: the line break the key means
|
|
163
|
+
* everywhere else (`breaks.ts`) is, inside a fence, the newline Enter already takes.
|
|
164
|
+
* Every command here declines away from a code block — inside one for the four keys
|
|
165
|
+
* about its text, against one for the two about its edge — so the list links keep the
|
|
166
|
+
* keys elsewhere.
|
|
165
167
|
*/
|
|
166
168
|
export function codeKeymap(schema) {
|
|
167
169
|
if (!schema.nodes.code_block)
|
|
@@ -170,6 +172,7 @@ export function codeKeymap(schema) {
|
|
|
170
172
|
Tab: indentInCode,
|
|
171
173
|
'Shift-Tab': outdentInCode,
|
|
172
174
|
Enter: newlineInCode,
|
|
175
|
+
'Shift-Enter': newlineInCode,
|
|
173
176
|
Backspace: retypeAtProseEdge(-1),
|
|
174
177
|
Delete: retypeAtProseEdge(1)
|
|
175
178
|
};
|
|
@@ -3,15 +3,14 @@
|
|
|
3
3
|
// nodes; a code fence's lines → one `code_block`), nest by shared `containers`
|
|
4
4
|
// prefix (`list_item`/`quote` → lists/blockquote), select the block node by
|
|
5
5
|
// `kind`, apply marks over their `[start,end)` USV ranges (PM splits inline nodes
|
|
6
|
-
// at mark boundaries), and lower island slots to leaf nodes (
|
|
7
|
-
//
|
|
8
|
-
// decorations (field.ts). Positions throughout are USV; `Array.from` iterates by
|
|
6
|
+
// at mark boundaries), and lower island slots to leaf nodes (block where a block
|
|
7
|
+
// island's slot stands alone on its line, inline otherwise). Anchors are not
|
|
8
|
+
// applied here; they are decorations (field.ts). Positions throughout are USV; `Array.from` iterates by
|
|
9
9
|
// code point so an astral char is one unit, never a surrogate half.
|
|
10
10
|
import { DOMSerializer } from 'prosemirror-model';
|
|
11
|
-
import { ISLAND_SLOT } from './islands.js';
|
|
11
|
+
import { ISLAND_SLOT, isBlockIsland } from './islands.js';
|
|
12
12
|
import { descriptorOf, markKey, pmMarkFromContent } from './marks.js';
|
|
13
|
-
import { hasMarks, isInlineSchema } from './schema.js';
|
|
14
|
-
import { isAnchorMark, isCodeLine, isHeadingLine, isListItemContainer } from '@quillmark/wasm';
|
|
13
|
+
import { hasMarks, isInlineSchema, takesLineBreak } from './schema.js';
|
|
15
14
|
/** Code points of `s` (USV units): the iteration granularity the content speaks. */
|
|
16
15
|
export function codePoints(s) {
|
|
17
16
|
return Array.from(s);
|
|
@@ -50,7 +49,7 @@ export function decode(rt, schema) {
|
|
|
50
49
|
// plaintext field that acquired one anyway — an older build's popover, a hand-
|
|
51
50
|
// written document — is exactly the content that must open and then heal on the
|
|
52
51
|
// next commit, not throw here.
|
|
53
|
-
const marks = hasMarks(schema) ? rt.marks.filter((m) =>
|
|
52
|
+
const marks = hasMarks(schema) ? rt.marks.filter((m) => m.type !== 'anchor') : [];
|
|
54
53
|
const cursor = { i: 0, rt };
|
|
55
54
|
if (isInlineSchema(schema)) {
|
|
56
55
|
return decodeInline(rt, schema, lineTexts, starts, marks, cursor);
|
|
@@ -96,22 +95,21 @@ function groupBlocks(schema, leaves, depth, marks, cursor) {
|
|
|
96
95
|
continue;
|
|
97
96
|
}
|
|
98
97
|
const here = path[depth];
|
|
99
|
-
|
|
100
|
-
// narrow, so the two arms this schema builds are claimed by the boundary's
|
|
101
|
-
// guard and a literal check, and everything else is an unknown the inert
|
|
102
|
-
// wrapper carries (CODEC §Open sets); no branch degrades silently.
|
|
103
|
-
if (isListItemContainer(here)) {
|
|
98
|
+
if (here.container === 'list_item') {
|
|
104
99
|
// Gather the maximal run of sibling `list_item` leaves at this depth
|
|
105
100
|
// (same ordered/start), then split it into items by `ordinal`.
|
|
106
|
-
const { ordered, start
|
|
101
|
+
const { ordered, start } = here.attrs;
|
|
102
|
+
const instance = instanceOf(here);
|
|
107
103
|
let j = i + 1;
|
|
108
104
|
while (j < leaves.length) {
|
|
109
105
|
const c = atDepth(leaves[j], depth);
|
|
110
|
-
if (!c ||
|
|
106
|
+
if (!c || c.container !== 'list_item')
|
|
107
|
+
break;
|
|
108
|
+
if (c.attrs.ordered !== ordered || c.attrs.start !== start)
|
|
111
109
|
break;
|
|
112
110
|
// `instance` is the boundary between two adjacent lists; the normalizer
|
|
113
111
|
// numbers a run's `ordinal`s gaplessly from 0, so a reset carries none.
|
|
114
|
-
if (c
|
|
112
|
+
if (instanceOf(c) !== instance)
|
|
115
113
|
break;
|
|
116
114
|
j++;
|
|
117
115
|
}
|
|
@@ -131,9 +129,11 @@ function groupBlocks(schema, leaves, depth, marks, cursor) {
|
|
|
131
129
|
i = j;
|
|
132
130
|
continue;
|
|
133
131
|
}
|
|
134
|
-
// quote and
|
|
135
|
-
//
|
|
136
|
-
//
|
|
132
|
+
// The quote, and the whole of what is left: `satisfies` is the exhaustiveness
|
|
133
|
+
// check, so a container added upstream is a compile error here rather than a
|
|
134
|
+
// blockquote it is not. A wrapper over the run of leaves carrying the identical
|
|
135
|
+
// container here, identity by `containerKey`.
|
|
136
|
+
here.container;
|
|
137
137
|
const key = containerKey(here);
|
|
138
138
|
let j = i + 1;
|
|
139
139
|
while (j < leaves.length) {
|
|
@@ -143,9 +143,7 @@ function groupBlocks(schema, leaves, depth, marks, cursor) {
|
|
|
143
143
|
j++;
|
|
144
144
|
}
|
|
145
145
|
const inner = groupBlocks(schema, leaves.slice(i, j), depth + 1, marks, cursor);
|
|
146
|
-
out.push(
|
|
147
|
-
? schema.nodes.blockquote.create(null, inner)
|
|
148
|
-
: schema.nodes.unknown_container.create({ container: here.container, attrs: 'attrs' in here ? here.attrs : null }, inner));
|
|
146
|
+
out.push(schema.nodes.blockquote.create(null, inner));
|
|
149
147
|
i = j;
|
|
150
148
|
}
|
|
151
149
|
return out;
|
|
@@ -154,61 +152,79 @@ function atDepth(leaf, depth) {
|
|
|
154
152
|
return leaf.containers[depth];
|
|
155
153
|
}
|
|
156
154
|
/** The `ordinal` of a leaf's `list_item` at `depth`: only called inside a run the
|
|
157
|
-
*
|
|
155
|
+
* discriminant has already established, so a miss is unreachable. */
|
|
158
156
|
function ordinalAt(leaf, depth) {
|
|
159
157
|
const c = atDepth(leaf, depth);
|
|
160
|
-
return c
|
|
158
|
+
return c?.container === 'list_item' ? c.attrs.ordinal : -1;
|
|
159
|
+
}
|
|
160
|
+
/** A container's `instance`, absent meaning the zero a canonical read omits. It is what
|
|
161
|
+
* tells one container from an adjacent sibling of identical shape, which contiguity
|
|
162
|
+
* alone reads as one, so absent and `0` have to answer alike. */
|
|
163
|
+
function instanceOf(c) {
|
|
164
|
+
return c.instance ?? 0;
|
|
161
165
|
}
|
|
162
|
-
/** Identity of a container for run gathering: its name
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* shape, which contiguity alone reads as one. Every arm but `list_item` (which has its
|
|
166
|
-
* own run rule) keys here. */
|
|
166
|
+
/** Identity of a container for run gathering: its name and its `instance`, NUL-joined
|
|
167
|
+
* as `markKey` joins a mark's. `list_item` carries its own run rule, so the quote is
|
|
168
|
+
* what keys here. */
|
|
167
169
|
function containerKey(c) {
|
|
168
|
-
|
|
169
|
-
return 'attrs' in c ? `${id}\u0000${JSON.stringify(c.attrs)}` : id;
|
|
170
|
+
return `${c.container}\u0000${instanceOf(c)}`;
|
|
170
171
|
}
|
|
171
|
-
/** A single leaf block node
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* compare literally, and anything left is unknown. */
|
|
172
|
+
/** A single leaf block node from its segments. Exhaustive over the closed line
|
|
173
|
+
* vocabulary, so a kind added upstream is a compile error rather than a silent
|
|
174
|
+
* flattening to `para` that the next commit would store. */
|
|
175
175
|
function makeLeaf(schema, leaf, marks, cursor) {
|
|
176
176
|
const line = leaf.line;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
177
|
+
switch (line.kind) {
|
|
178
|
+
case 'rule':
|
|
179
|
+
return schema.nodes.horizontal_rule.create();
|
|
180
|
+
case 'code': {
|
|
181
|
+
// One code_block: the segments' texts joined by literal `\n`, no marks.
|
|
182
|
+
const text = leaf.segments.map((s) => s.text).join('\n');
|
|
183
|
+
const content = text.length ? [schema.text(text)] : [];
|
|
184
|
+
return schema.nodes.code_block.create({ lang: line.attrs?.lang ?? null }, content);
|
|
185
|
+
}
|
|
186
|
+
case 'para':
|
|
187
|
+
case 'heading': {
|
|
188
|
+
if (line.kind === 'para' && blockIslandLine(leaf, cursor))
|
|
189
|
+
return schema.nodes.island_block.create(islandAttrs(cursor));
|
|
190
|
+
// Inline content, the segment boundary a `hard_break` in a paragraph and a space
|
|
191
|
+
// in a heading, which takes none (`schema.ts` §`takesLineBreak`). A stored heading
|
|
192
|
+
// carries no continuation — the store clears one after a block of a single line —
|
|
193
|
+
// so the space is what keeps a hand-built content decodable.
|
|
194
|
+
const type = line.kind === 'heading' ? schema.nodes.heading : schema.nodes.paragraph;
|
|
195
|
+
const inline = [];
|
|
196
|
+
leaf.segments.forEach((seg, idx) => {
|
|
197
|
+
if (idx > 0)
|
|
198
|
+
inline.push(takesLineBreak(type) ? schema.nodes.hard_break.create() : schema.text(' '));
|
|
199
|
+
inline.push(...buildInline(schema, seg.text, seg.startUSV, marks, cursor, false));
|
|
200
|
+
});
|
|
201
|
+
return line.kind === 'heading'
|
|
202
|
+
? type.create({ level: line.attrs.level }, inline)
|
|
203
|
+
: type.create(null, inline);
|
|
204
|
+
}
|
|
205
|
+
default: {
|
|
206
|
+
const unreached = line;
|
|
207
|
+
return unreached;
|
|
208
|
+
}
|
|
197
209
|
}
|
|
198
|
-
// An unknown kind renders as a paragraph and rides on it, so it re-encodes
|
|
199
|
-
// verbatim rather than flattening to `para` on the field's first edit.
|
|
200
|
-
const unknown = line.kind === 'para' ? null : { kind: line.kind, attrs: 'attrs' in line ? line.attrs : null };
|
|
201
|
-
return schema.nodes.paragraph.create({ unknown }, inline);
|
|
202
210
|
}
|
|
203
|
-
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
|
|
211
|
+
/** Whether a `para` leaf is one block island: a single line holding its slot alone,
|
|
212
|
+
* backed by an island whose markup is a block. The island is the one fact; the line
|
|
213
|
+
* carries no kind of its own for it. */
|
|
214
|
+
function blockIslandLine(leaf, cursor) {
|
|
215
|
+
if (leaf.segments.length !== 1 || leaf.segments[0].text !== ISLAND_SLOT)
|
|
216
|
+
return false;
|
|
217
|
+
const isl = cursor.rt.islands[cursor.i];
|
|
218
|
+
return isl != null && isBlockIsland(isl.type);
|
|
219
|
+
}
|
|
220
|
+
/** Consume the next island entry as PM node attrs (text-order matched to slots): the
|
|
221
|
+
* whole entry, which an island edit has to write back (`islands.ts`). `null` where a
|
|
222
|
+
* slot has no entry behind it, which is a malformed content: the slot draws nothing
|
|
223
|
+
* and its line an empty paragraph, so the decode stays total without minting a node
|
|
224
|
+
* whose `type` the vocabulary would refuse on the way back. */
|
|
207
225
|
function islandAttrs(cursor) {
|
|
208
226
|
const isl = cursor.rt.islands[cursor.i++];
|
|
209
|
-
|
|
210
|
-
return { id: '', islandType: '', props: null, loss: 'unrepresentable' };
|
|
211
|
-
return { id: isl.id, islandType: isl.type, props: isl.props, loss: isl.loss };
|
|
227
|
+
return isl ? { id: isl.id, islandType: isl.type, props: isl.props } : null;
|
|
212
228
|
}
|
|
213
229
|
/**
|
|
214
230
|
* Inline nodes for one segment: split text into maximal runs of a constant mark
|
|
@@ -227,20 +243,39 @@ function buildInline(schema, segText, segStartUSV, marks, cursor, stripIslands)
|
|
|
227
243
|
out.push(schema.text(runText, runMarks));
|
|
228
244
|
runText = '';
|
|
229
245
|
};
|
|
246
|
+
// A mark set changes only where a mark starts or ends. `marks` is the field's whole
|
|
247
|
+
// list, so resolving one per code point costs the text against every mark in the
|
|
248
|
+
// document.
|
|
249
|
+
const segEndUSV = segStartUSV + cps.length;
|
|
250
|
+
const spanning = marks.filter((m) => m.start < segEndUSV && m.end > segStartUSV);
|
|
251
|
+
const edges = new Set();
|
|
252
|
+
for (const m of spanning) {
|
|
253
|
+
if (m.start > segStartUSV)
|
|
254
|
+
edges.add(m.start);
|
|
255
|
+
if (m.end < segEndUSV)
|
|
256
|
+
edges.add(m.end);
|
|
257
|
+
}
|
|
258
|
+
let active = [];
|
|
259
|
+
let key = '';
|
|
260
|
+
let unresolved = true;
|
|
230
261
|
for (let k = 0; k < cps.length; k++) {
|
|
231
262
|
const cp = cps[k];
|
|
232
263
|
const pos = segStartUSV + k;
|
|
233
264
|
if (cp === ISLAND_SLOT) {
|
|
234
265
|
flush();
|
|
235
266
|
const attrs = islandAttrs(cursor);
|
|
236
|
-
if (!stripIslands && schema.nodes.island_inline) {
|
|
267
|
+
if (attrs && !stripIslands && schema.nodes.island_inline) {
|
|
237
268
|
out.push(schema.nodes.island_inline.create(attrs));
|
|
238
269
|
}
|
|
239
270
|
runKey = '\0slot'; // force a fresh run after a slot
|
|
271
|
+
unresolved = true; // the slot's own position is skipped, edge or not
|
|
240
272
|
continue;
|
|
241
273
|
}
|
|
242
|
-
|
|
243
|
-
|
|
274
|
+
if (unresolved || edges.has(pos)) {
|
|
275
|
+
active = spanning.filter((m) => m.start <= pos && pos < m.end);
|
|
276
|
+
key = markSetKey(active);
|
|
277
|
+
unresolved = false;
|
|
278
|
+
}
|
|
244
279
|
if (key !== runKey && runText.length)
|
|
245
280
|
flush();
|
|
246
281
|
if (key !== runKey) {
|