@quillmark/svelte 0.5.1 → 0.7.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 (66) hide show
  1. package/README.md +6 -0
  2. package/THEMING.md +13 -17
  3. package/dist/core/address.d.ts +17 -3
  4. package/dist/core/address.js +20 -3
  5. package/dist/core/codec/blocks.d.ts +37 -0
  6. package/dist/core/codec/blocks.js +163 -0
  7. package/dist/core/codec/field.js +5 -6
  8. package/dist/core/codec/index.d.ts +1 -1
  9. package/dist/core/codec/index.js +3 -2
  10. package/dist/core/codec/inputrules.js +24 -144
  11. package/dist/core/codec/prose.css +14 -25
  12. package/dist/core/codec/schema.d.ts +8 -0
  13. package/dist/core/codec/schema.js +33 -1
  14. package/dist/core/codec/slash.d.ts +18 -16
  15. package/dist/core/codec/slash.js +0 -0
  16. package/dist/core/codec/table-view.d.ts +4 -0
  17. package/dist/core/codec/table-view.js +137 -158
  18. package/dist/core/codec/table.d.ts +0 -10
  19. package/dist/core/codec/table.js +0 -15
  20. package/dist/core/theme.css +357 -346
  21. package/dist/preset/recipes.css +25 -21
  22. package/dist/preview/Preview.svelte +4 -2
  23. package/dist/preview/bridge.d.ts +23 -0
  24. package/dist/preview/bridge.js +92 -35
  25. package/dist/preview/controller.d.ts +6 -5
  26. package/dist/preview/controller.js +13 -7
  27. package/dist/preview/geometry.d.ts +10 -9
  28. package/dist/preview/geometry.js +11 -13
  29. package/dist/preview/index.js +3 -2
  30. package/dist/preview/paint.js +9 -1
  31. package/dist/visual/ArrayField.svelte +246 -63
  32. package/dist/visual/ArrayField.svelte.d.ts +5 -1
  33. package/dist/visual/Card.svelte +84 -50
  34. package/dist/visual/EnumField.svelte +37 -3
  35. package/dist/visual/Field.svelte +28 -4
  36. package/dist/visual/FieldLabel.svelte +2 -2
  37. package/dist/visual/FormatPopover.svelte +13 -1
  38. package/dist/visual/ObjectField.svelte +128 -35
  39. package/dist/visual/ObjectField.svelte.d.ts +11 -4
  40. package/dist/visual/ProseArrayElement.svelte +22 -2
  41. package/dist/visual/ProseArrayElement.svelte.d.ts +1 -0
  42. package/dist/visual/SlashMenu.svelte +7 -0
  43. package/dist/visual/TipsCard.svelte +33 -13
  44. package/dist/visual/VariantField.svelte +95 -0
  45. package/dist/visual/VariantField.svelte.d.ts +21 -0
  46. package/dist/visual/VisualEditorInner.svelte +44 -21
  47. package/dist/visual/controls.css +282 -283
  48. package/dist/visual/diagnostics.d.ts +13 -11
  49. package/dist/visual/diagnostics.js +11 -9
  50. package/dist/visual/domid.d.ts +13 -0
  51. package/dist/visual/domid.js +16 -0
  52. package/dist/visual/hold.d.ts +17 -0
  53. package/dist/visual/hold.js +39 -0
  54. package/dist/visual/index.js +1 -1
  55. package/dist/visual/leaves.d.ts +9 -7
  56. package/dist/visual/links.d.ts +5 -2
  57. package/dist/visual/links.js +9 -3
  58. package/dist/visual/motion.d.ts +9 -0
  59. package/dist/visual/motion.js +10 -2
  60. package/dist/visual/strings.d.ts +15 -0
  61. package/dist/visual/strings.js +3 -0
  62. package/dist/visual/structure.d.ts +32 -6
  63. package/dist/visual/structure.js +49 -3
  64. package/package.json +2 -2
  65. package/dist/core/codec/clipboard.d.ts +0 -59
  66. package/dist/core/codec/clipboard.js +0 -202
package/README.md CHANGED
@@ -60,6 +60,12 @@ const session = await new Engine().open(quill, doc);
60
60
 
61
61
  Opening a document that names a **different** quill is the same sequence, in order: resolve the new ref, `engine.open(quill, next)`, swap the props, then free the replaced handles that were yours to free. `<VisualEditor>` re-keys itself on the new `doc` (see below); `<Preview>` swaps by remount (`{#key session}`).
62
62
 
63
+ ## What a document is trusted to be
64
+
65
+ **A `Document` may be one its reader did not write** — an import, a shared file, a row from a multi-tenant store — and two properties hold over one regardless. Markdown becomes typed nodes and then DOM, never a markup string: this package has no `{@html}`, no `innerHTML` and no `eval`, so a document's text cannot become tags. And a link renders as one only for `http`, `https`, `mailto`, `tel`, `ftp`, or a value naming no scheme at all; every other scheme draws as an inert span, on the editable surface and in the tips card that paints `$ext.editor.tips` outside any `contenteditable`. That gate is the render's rather than the model's, so a refused href stays on the mark and round-trips: opening a document never edits it.
66
+
67
+ What stays yours is the text edge and the ref. The markdown is the host's at both ends, and `doc.quillRef` is the **document's** own — a stored file names the quill that opens it, so the ref → `Quill` mapping above is what bounds which templates it can reach.
68
+
63
69
  ## Preview
64
70
 
65
71
  `createPreview` supplies the layer the session omits: viewport, DOM, DPR, click mapping. It is a pure view: it never calls `session.apply`; you drive the edit and hand it the resulting `ChangeSet`.
package/THEMING.md CHANGED
@@ -24,7 +24,7 @@ Nothing to import and nothing to set. The package pulls its own stylesheet, and
24
24
 
25
25
  **Placing and sizing**: each surface carries a stable root class (`.qm-editor`, `.qm-preview`), so you can place it from your own stylesheet without a wrapper. Each also takes a `class` prop that merges onto the same element, which is the better handle when you have one to give.
26
26
 
27
- **Taking the column back**: your CSS is unlayered and ours is not (below), so `padding: 0` or a `background` of your own on `.qm-editor` wins outright. Nothing is locked.
27
+ **Taking the column back**: our selectors compute at specificity zero (below), so `padding: 0` or a `background` of your own on `.qm-editor` wins outright. Nothing is locked.
28
28
 
29
29
  ## Make it yours
30
30
 
@@ -43,7 +43,7 @@ The whole contract is **nine CSS custom properties**. They are dials, not a pale
43
43
 
44
44
  `--qm-bg` and `--qm-fg` are the two a themed host sets before any brand decision. Their defaults are a self-consistent neutral for a bare mounting site, not a guess at your page.
45
45
 
46
- `--qm-bg` is the **card**, not the column behind it: the plane a document is written on, which is also the sheet the preview paints. Everything else is a step off it, and the column the cards sit in is a step down, so a mounted editor draws its own ground and the cards read as islands on it. Give it the tone you want a card to be — white, in most light themes — and let the column fall out. If you want the column to meet your page instead, set a `background` of your own on `.qm-editor`; your CSS is unlayered and wins.
46
+ `--qm-bg` is the **card**, not the column behind it: the plane a document is written on, which is also the sheet the preview paints. Everything else is a step off it, and the column the cards sit in is a step down, so a mounted editor draws its own ground and the cards read as islands on it. Give it the tone you want a card to be — white, in most light themes — and let the column fall out. If you want the column to meet your page instead, set a `background` of your own on `.qm-editor`; any rule of yours wins.
47
47
 
48
48
  The poles take a colour, not `transparent` or `inherit`: the raised surfaces, the borders and the muted inks are each a `color-mix` against `--qm-bg`, so a transparent pole makes every one of them translucent instead of letting your page through. Hand over the value your page is painted with and the derivation steps off it.
49
49
 
@@ -65,15 +65,12 @@ Give a length dial a length: `--qm-space: 4` is a valid custom property and an i
65
65
 
66
66
  ### Your CSS beats ours
67
67
 
68
- The surfaces' own rules live in the `qm` cascade layer, so **unlayered CSS of yours beats them outright**, at any specificity and without `!important`. Nothing to declare; this is the case most apps are in.
68
+ The rules behind every class the surfaces promise you — `.qm-editor`, `.qm-preview`, `.qm-pane` — are wrapped in `:where()` and sit in no cascade layer, as are the derivation and the shared control recipes under them. So **each computes at specificity zero**, and two things follow:
69
69
 
70
- If your app uses cascade layers of its own, layer order decides it, and layer order is first-declaration order across the document — which makes it the bundler's, not yours. Name our layer first in one statement of your own, anywhere in your CSS, and it stops mattering:
70
+ - **Any rule of yours wins**, at any specificity and without `!important`. A bare `.qm-editor { … }` does it; so does a bare `input`.
71
+ - **No layer of yours can reach them**, because unlayered rules outrank every layer. A reset in a layer — Tailwind v4's preflight in `@layer base` is the common one — cannot strip the controls however your bundler orders the sheets. There is no layer statement to write and no sheet order to get right.
71
72
 
72
- ```css
73
- @layer qm, app;
74
- ```
75
-
76
- Declared before our sheet it fixes the order outright; declared after, `qm` is already first and `app` appends behind it. Either way your layer lands after ours and wins. `qm` is the contract; the sub-layers under it (`qm.scale`, `qm.chrome`) are ours to re-cut.
73
+ One thing still outranks us: an **unlayered** blanket reset of your own (`* { padding: 0 }` in no layer) ties at specificity zero and wins on source order. Put it in a layer, where Tailwind, Bootstrap and Open Props already put theirs, and it stops reaching us.
77
74
 
78
75
  ### The surface follows your colour scheme
79
76
 
@@ -176,22 +173,21 @@ An app that mounts an editor beside a preview over one session needs a screen to
176
173
  | `.qm-switch` | The band that says which track is showing, and nothing at the widths where both are. Its controls read as even halves and stand at the touch floor (`--qmh-tap`, 44px); depth past that is yours. |
177
174
  | `.qm-frame` | The mounting site: an edge, a corner, and the clip that holds a surface to them. What is inside it is the surface's own. |
178
175
 
179
- The narrow shape is one mount at a time, not two of them stacked: half a narrow viewport is under the width either surface reads at. The track you are not showing is `display: none`, so it leaves the tab order, hit-testing and the accessibility tree together: you need no `inert` on it, and no breakpoint in your JS, since the only state you hold is which of the two you last asked for. Keep both mounted: the hidden pane keeps its pixels, its caret and its scroll, and repaints itself when it comes back. A click that lands in the hidden pane is the one thing the shape adds to your wiring, so send the caret and reveal the track together, or the hit goes somewhere the reader cannot see.
176
+ The narrow shape is one mount at a time, not two of them stacked: half a narrow viewport is under the width either surface reads at. The track you are not showing is `display: none`, so it leaves the tab order, hit-testing and the accessibility tree together: you need no `inert` on it, and no breakpoint in your JS, since the only state you hold is which of the two you last asked for. Keep both mounted: the hidden pane keeps its pixels, its caret and its scroll, and comes back to them — it rasters nothing at a box it has none of, and repaints only where it returns to a box of another width. A preview scroll asked for while it was hidden is held and run on the way back, so it opens at the caret rather than several edits behind it. A click that lands in the hidden pane is the one thing the shape adds to your wiring, so send the caret and reveal the track together, or the hit goes somewhere the reader cannot see.
180
177
 
181
178
  The shape is shared; the look of a band is not. Your wordmark, your nav and the depth of your head are the most app-specific things on the screen, and a preset that drew them would hand every tool built on this the same face, so they read `--qmh-*` like everything else and you write the rules. `.qm-split`'s gap is a default rather than a fact about splitting: close it to `0` and carry a hairline on one pane if you want the two mounts to meet instead of stand apart.
182
179
 
183
- It is **unlayered**, so your own rules beat it by ordinary precedence, exactly as they beat the surfaces. It is also opt-in and side-effect free until imported: nothing that styles your document arrives with a component.
180
+ It is **unlayered** and at ordinary specificity, so your own rules beat it by ordinary precedence. It is also opt-in and side-effect free until imported: nothing that styles your document arrives with a component.
184
181
 
185
- One rule in it earns its place regardless of whether you take the rest, and you want it if you draw a focus ring of your own:
182
+ One rule in it earns its place regardless of whether you take the rest, and you want it if you draw a focus ring of your own — cut the mounted surfaces out of the blanket:
186
183
 
187
184
  ```css
188
- [data-qm-root] :focus-visible {
189
- outline: revert-layer;
190
- outline-offset: revert-layer;
185
+ :focus-visible:not(:where([data-qm-root], [data-qm-root] *)) {
186
+ outline: 2px solid; /* your ring, on your page */
191
187
  }
192
188
  ```
193
189
 
194
- A blanket `:focus-visible` of yours is unlayered, so it beats `@layer qm.chrome`, including on the controls that already draw a ring. `revert-layer` hands those back to ours and leaves yours everywhere else.
190
+ A blanket `:focus-visible` of yours outranks ours, including on the controls that already draw one, so a ring written for the controls that draw none repaints the ones that do. The cut-out leaves yours everywhere else, and `:not(:where(…))` contributes no specificity, so your rule ranks exactly as it would without it.
195
191
 
196
192
  ## What is deliberately not public
197
193
 
@@ -201,4 +197,4 @@ The derived surface scale (surface / border / ink rungs, the blur radius, the po
201
197
 
202
198
  ## Requirements
203
199
 
204
- `@layer`, `color-mix()`, `light-dark()` and relative colour syntax (`oklab(from …)`): Baseline since 2022, 2023, 2024 and 2024 respectively — the last is what lets a plane step by the card's own lightness. `@property` is the one feature the surface does not require: where it is missing, an invalid length dial collapses the surface rather than falling back.
200
+ `:where()`, `color-mix()`, `light-dark()` and relative colour syntax (`oklab(from …)`): Baseline since 2021, 2023, 2024 and 2024 respectively — the last is what lets a plane step by the card's own lightness. `@property` is the one feature the surface does not require: where it is missing, an invalid length dial collapses the surface rather than falling back.
@@ -63,14 +63,28 @@ 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 elementAddrForFieldPath}
67
- * takes instead), a field-rooted one, or a malformed one. A bare card and a `.body`
68
- * terminal both land on the field-less `{card: i}` the body leaf answers to.
66
+ * array-element path (`main.keywords[0]`, which {@link elementAddrForFieldPath} and
67
+ * {@link nearestAddrForFieldPath} take instead), a field-rooted one, or a malformed
68
+ * one. A bare card and a `.body` terminal both land on the field-less `{card: i}`
69
+ * the body leaf answers to.
69
70
  *
70
71
  * Needs no `kinds`: the path carries the absolute index, and the kind in it is
71
72
  * decoration the `Addr` has no room for.
72
73
  */
73
74
  export declare function addrForFieldPath(path: DocPath): Addr | undefined;
75
+ /**
76
+ * The nearest ancestor a commit address can name: {@link addrForFieldPath} where the
77
+ * path has one, else the root and its first field. `main.contact.email` and
78
+ * `main.keywords[0]` land on their field, any deeper nesting on the top-level field
79
+ * it hangs off. `undefined` where those two segments name nothing — a field-rooted
80
+ * (config-space) or malformed path.
81
+ *
82
+ * An `Addr` reaches a root and one field, so two segments is the whole search: a
83
+ * deeper prefix is unaddressable by arity alone. Addressability being `Addr`'s reach
84
+ * rather than the grammar's is what makes the truncation a consumer's to make
85
+ * (VISUAL_EDITOR §Diagnostics).
86
+ */
87
+ export declare function nearestAddrForFieldPath(path: DocPath): Addr | undefined;
74
88
  /**
75
89
  * An array element's address, split: `main.keywords[0]` lands here, and anything
76
90
  * {@link addrForFieldPath} can name — or whose trailing segment is not an index —
@@ -37,9 +37,10 @@ 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 elementAddrForFieldPath}
41
- * takes instead), a field-rooted one, or a malformed one. A bare card and a `.body`
42
- * terminal both land on the field-less `{card: i}` the body leaf answers to.
40
+ * array-element path (`main.keywords[0]`, which {@link elementAddrForFieldPath} and
41
+ * {@link nearestAddrForFieldPath} take instead), a field-rooted one, or a malformed
42
+ * one. A bare card and a `.body` terminal both land on the field-less `{card: i}`
43
+ * the body leaf answers to.
43
44
  *
44
45
  * Needs no `kinds`: the path carries the absolute index, and the kind in it is
45
46
  * decoration the `Addr` has no room for.
@@ -48,6 +49,22 @@ export function addrForFieldPath(path) {
48
49
  const segs = segsOf(path);
49
50
  return segs && addrForSegs(segs);
50
51
  }
52
+ /**
53
+ * The nearest ancestor a commit address can name: {@link addrForFieldPath} where the
54
+ * path has one, else the root and its first field. `main.contact.email` and
55
+ * `main.keywords[0]` land on their field, any deeper nesting on the top-level field
56
+ * it hangs off. `undefined` where those two segments name nothing — a field-rooted
57
+ * (config-space) or malformed path.
58
+ *
59
+ * An `Addr` reaches a root and one field, so two segments is the whole search: a
60
+ * deeper prefix is unaddressable by arity alone. Addressability being `Addr`'s reach
61
+ * rather than the grammar's is what makes the truncation a consumer's to make
62
+ * (VISUAL_EDITOR §Diagnostics).
63
+ */
64
+ export function nearestAddrForFieldPath(path) {
65
+ const segs = segsOf(path);
66
+ return segs && addrForSegs(segs.slice(0, 2));
67
+ }
51
68
  /**
52
69
  * An array element's address, split: `main.keywords[0]` lands here, and anything
53
70
  * {@link addrForFieldPath} can name — or whose trailing segment is not an index —
@@ -0,0 +1,37 @@
1
+ import type { Attrs, Node as PMNode } from 'prosemirror-model';
2
+ import type { Command, EditorState, Transaction } from 'prosemirror-state';
3
+ /** `# `: the heading `level` deep. `list_item` is `block+`, so one inside an item is a
4
+ * shape the content holds and `importMarkdown` produces from `- # title`. */
5
+ export declare function toHeading(level: number): Command;
6
+ /** A fence. The exit paragraph after it is the one a block island mints for the same
7
+ * reason, sharpened: a code block is the one block a gap cursor will not sit beside
8
+ * either, so a fence at the end of a body leaves the caret nowhere to go. The caret
9
+ * stays in the fence, not in the exit it minted. */
10
+ export declare function toCodeBlock(): Command;
11
+ /** `---`. A divider is a whole block, so this replaces its textblock rather than
12
+ * retyping one: `horizontal_rule` holds no content to retype into, and an empty block
13
+ * is the whole of what it may replace — anything else in one is a paragraph being
14
+ * edited, which a replace would eat. */
15
+ export declare function insertDivider(): Command;
16
+ /** `> `. No item guard: what this wraps an item's first block in is a container the
17
+ * content holds, where a list wrapping one mints an item whose only content is an
18
+ * item. */
19
+ export declare function wrapInQuote(): Command;
20
+ /** `- ` / `1. `. `joinBefore` is the shorthand's ordinal test: `2. ` continues the list
21
+ * above where `7. ` opens one. A pick passes none and continues whatever list is
22
+ * above it. */
23
+ export declare function wrapInList(ordered: boolean, attrs?: Attrs | null, joinBefore?: (node: PMNode) => boolean): Command;
24
+ /**
25
+ * Run `command` in place of the text at `[from, to)`: one transaction carrying the
26
+ * delete and the command's own steps. One rather than two because two would be two
27
+ * commits, two undo steps and two recompiles for one gesture; the command is built
28
+ * against the state the delete leaves, which is exactly what its steps were computed
29
+ * for.
30
+ *
31
+ * `null` is a command that declined, and nothing is consumed: the shorthand stays the
32
+ * text it was typed as.
33
+ */
34
+ export declare function consuming(state: EditorState, from: number, to: number, command: Command): {
35
+ tr: Transaction;
36
+ produced: Transaction;
37
+ } | null;
@@ -0,0 +1,163 @@
1
+ import { Selection } from 'prosemirror-state';
2
+ import { canJoin, findWrapping } from 'prosemirror-transform';
3
+ /** The head of a textblock: where a shorthand fires, and where a pick lands once its
4
+ * run is consumed. */
5
+ function atHead(state) {
6
+ const { $from, empty } = state.selection;
7
+ return empty && $from.parent.isTextblock && $from.parentOffset === 0;
8
+ }
9
+ /** Whether the caret's parent holds `type` in its block's place. `setBlockType` and
10
+ * `replaceWith` both mint the shape without asking. */
11
+ function fits($from, type) {
12
+ return $from.node(-1).canReplaceWith($from.index(-1), $from.indexAfter(-1), type);
13
+ }
14
+ /**
15
+ * The head of an item that already exists: where a list shorthand is the text an author
16
+ * typed rather than a shorthand at all.
17
+ *
18
+ * Firing there wraps the item's own paragraph in a fresh list, minting an item whose
19
+ * only content is another item — a level with nothing on it. Tab is the indent gesture
20
+ * and lands the shape a nesting is supposed to have, under the previous sibling. A
21
+ * later block of the item is not this case: wrapping a continuation paragraph is how a
22
+ * sub-list opens under text.
23
+ */
24
+ function openingAnItem($from, item) {
25
+ if (!item || $from.depth < 2)
26
+ return false;
27
+ return $from.node(-1).type === item && $from.index(-1) === 0;
28
+ }
29
+ function retype(name, attrs) {
30
+ return (state, dispatch) => {
31
+ const type = state.schema.nodes[name];
32
+ const { $from } = state.selection;
33
+ if (!type || !atHead(state) || !fits($from, type))
34
+ return false;
35
+ dispatch?.(state.tr.setBlockType($from.pos, $from.pos, type, attrs));
36
+ return true;
37
+ };
38
+ }
39
+ /** `wrappingInputRule`'s body: wrap the block range at `pos`, then join a preceding
40
+ * sibling of the same type — the one boundary the wrap itself opens (`lists.ts`
41
+ * §cleanup: command-local, never a global pass). */
42
+ function wrapAt(tr, pos, type, attrs, joinBefore) {
43
+ const range = tr.doc.resolve(pos).blockRange();
44
+ const wrapping = range && findWrapping(range, type, attrs);
45
+ if (!range || !wrapping)
46
+ return false;
47
+ tr.wrap(range, wrapping);
48
+ const before = tr.doc.resolve(pos - 1).nodeBefore;
49
+ if (before &&
50
+ before.type === type &&
51
+ canJoin(tr.doc, pos - 1) &&
52
+ (!joinBefore || joinBefore(before))) {
53
+ tr.join(pos - 1);
54
+ }
55
+ return true;
56
+ }
57
+ /** `# `: the heading `level` deep. `list_item` is `block+`, so one inside an item is a
58
+ * shape the content holds and `importMarkdown` produces from `- # title`. */
59
+ export function toHeading(level) {
60
+ return retype('heading', { level });
61
+ }
62
+ /** A fence. The exit paragraph after it is the one a block island mints for the same
63
+ * reason, sharpened: a code block is the one block a gap cursor will not sit beside
64
+ * either, so a fence at the end of a body leaves the caret nowhere to go. The caret
65
+ * stays in the fence, not in the exit it minted. */
66
+ export function toCodeBlock() {
67
+ return (state, dispatch) => {
68
+ const { code_block: code, paragraph } = state.schema.nodes;
69
+ const { $from } = state.selection;
70
+ if (!code || !paragraph || !atHead(state) || !fits($from, code))
71
+ return false;
72
+ const tr = state.tr.setBlockType($from.pos, $from.pos, code);
73
+ const at = tr.doc.resolve($from.pos).after();
74
+ if (!tr.doc.nodeAt(at))
75
+ tr.insert(at, paragraph.create());
76
+ dispatch?.(tr);
77
+ return true;
78
+ };
79
+ }
80
+ /** `---`. A divider is a whole block, so this replaces its textblock rather than
81
+ * retyping one: `horizontal_rule` holds no content to retype into, and an empty block
82
+ * is the whole of what it may replace — anything else in one is a paragraph being
83
+ * edited, which a replace would eat. */
84
+ export function insertDivider() {
85
+ return (state, dispatch) => {
86
+ const { horizontal_rule: rule, paragraph } = state.schema.nodes;
87
+ const { $from } = state.selection;
88
+ if (!rule || !paragraph || !atHead(state) || !fits($from, rule))
89
+ return false;
90
+ if ($from.parent.content.size !== 0)
91
+ return false;
92
+ const from = $from.before();
93
+ const tr = state.tr.replaceWith(from, $from.after(), rule.create());
94
+ const at = from + 1;
95
+ if (!tr.doc.nodeAt(at))
96
+ tr.insert(at, paragraph.create());
97
+ tr.setSelection(Selection.near(tr.doc.resolve(at), 1));
98
+ dispatch?.(tr);
99
+ return true;
100
+ };
101
+ }
102
+ /** `> `. No item guard: what this wraps an item's first block in is a container the
103
+ * content holds, where a list wrapping one mints an item whose only content is an
104
+ * item. */
105
+ export function wrapInQuote() {
106
+ return (state, dispatch) => {
107
+ const quote = state.schema.nodes.blockquote;
108
+ const { $from } = state.selection;
109
+ if (!quote || !atHead(state))
110
+ return false;
111
+ const tr = state.tr;
112
+ if (!wrapAt(tr, $from.pos, quote, null))
113
+ return false;
114
+ dispatch?.(tr);
115
+ return true;
116
+ };
117
+ }
118
+ /** `- ` / `1. `. `joinBefore` is the shorthand's ordinal test: `2. ` continues the list
119
+ * above where `7. ` opens one. A pick passes none and continues whatever list is
120
+ * above it. */
121
+ export function wrapInList(ordered, attrs = null, joinBefore) {
122
+ return (state, dispatch) => {
123
+ const { paragraph, list_item: item } = state.schema.nodes;
124
+ const list = state.schema.nodes[ordered ? 'ordered_list' : 'bullet_list'];
125
+ const { $from } = state.selection;
126
+ if (!list || !paragraph || !atHead(state) || openingAnItem($from, item))
127
+ return false;
128
+ const pos = $from.pos;
129
+ const tr = state.tr;
130
+ // A paragraph item wherever this fires: `# ` inside the item is the gesture that
131
+ // mints `list_item > heading`, and a wrap keeping the heading is a second door onto
132
+ // that shape. Positions survive the retype — same content, same size.
133
+ if ($from.parent.type !== paragraph)
134
+ tr.setBlockType(pos, pos, paragraph);
135
+ if (!wrapAt(tr, pos, list, attrs, joinBefore))
136
+ return false;
137
+ dispatch?.(tr);
138
+ return true;
139
+ };
140
+ }
141
+ /**
142
+ * Run `command` in place of the text at `[from, to)`: one transaction carrying the
143
+ * delete and the command's own steps. One rather than two because two would be two
144
+ * commits, two undo steps and two recompiles for one gesture; the command is built
145
+ * against the state the delete leaves, which is exactly what its steps were computed
146
+ * for.
147
+ *
148
+ * `null` is a command that declined, and nothing is consumed: the shorthand stays the
149
+ * text it was typed as.
150
+ */
151
+ export function consuming(state, from, to, command) {
152
+ const tr = state.tr.delete(from, to);
153
+ let produced;
154
+ command(state.apply(tr), (t) => {
155
+ produced = t;
156
+ });
157
+ if (!produced)
158
+ return null;
159
+ for (const step of produced.steps)
160
+ tr.step(step);
161
+ tr.setSelection(Selection.fromJSON(tr.doc, produced.selection.toJSON()));
162
+ return { tr, produced };
163
+ }
@@ -19,7 +19,6 @@ import { keymap } from 'prosemirror-keymap';
19
19
  import { EditorState, NodeSelection, Plugin, PluginKey, Selection, TextSelection } from 'prosemirror-state';
20
20
  import { Decoration, DecorationSet, EditorView } from 'prosemirror-view';
21
21
  import { reportError, errorMessage } from '../errors.js';
22
- import { tableClipboardPlugin } from './clipboard.js';
23
22
  import { decode } from './decode.js';
24
23
  import { usvToPM, pmToUsv, buildLineIndex } from './positions.js';
25
24
  import { lower, pmToContent, contentEdit } from './encode.js';
@@ -162,7 +161,8 @@ export function createField(opts) {
162
161
  // A focus event does not bubble, so the leaf's own `focus` handler
163
162
  // never fires for a cell: without this the active address would not
164
163
  // follow a caret clicked straight into a table.
165
- onCellFocus: () => opts.onFocus?.(addr)
164
+ onCellFocus: () => opts.onFocus?.(addr),
165
+ clearance
166
166
  })
167
167
  },
168
168
  dispatchTransaction: (tr) => {
@@ -407,11 +407,10 @@ export function proseLeafPlugins(schema, opts) {
407
407
  list.push(inputRulesPlugin(schema));
408
408
  list.push(keymap(editorKeymap(schema, opts.inline, !!opts.slash)));
409
409
  list.push(keymap(baseKeymap));
410
- // All three answer to something only a block leaf holds: an inline leaf is one
411
- // textblock with no island, no gap to put a cursor in, and nowhere for a pasted
412
- // table to land.
410
+ // Both answer to something only a block leaf holds: an inline leaf is one textblock
411
+ // with no island and no gap to put a cursor in.
413
412
  if (!opts.inline)
414
- list.push(gapCursor(), pastAtomPlugin(), tableClipboardPlugin(schema));
413
+ list.push(gapCursor(), pastAtomPlugin());
415
414
  if (opts.placeholder)
416
415
  list.push(placeholderPlugin(opts.placeholder));
417
416
  return list;
@@ -9,7 +9,7 @@ export { DEFAULT_TABLE_STRINGS } from './table-view.js';
9
9
  export type { TableChromeStrings } from './table-view.js';
10
10
  export { DEFAULT_SLASH_STRINGS } from './slash.js';
11
11
  export type { SlashState, SlashStrings } from './slash.js';
12
- export { blockSchema, inlineSchema, plaintextSchema, hasMarks } from './schema.js';
12
+ export { blockSchema, inlineSchema, plaintextSchema, hasMarks, rendersHref } from './schema.js';
13
13
  export { decode, renderContent, usvLength } from './decode.js';
14
14
  export { pmToContent, contentEdit, lower } from './encode.js';
15
15
  export type { ContentEdit } from './encode.js';
@@ -21,8 +21,9 @@ export { DEFAULT_TABLE_STRINGS } from './table-view.js';
21
21
  // the pixels (`visual/SlashMenu.svelte` is the only off-barrel caller).
22
22
  export { DEFAULT_SLASH_STRINGS } from './slash.js';
23
23
  // Schemas (the decode/encode target; the VisualEditor mounts them). `hasMarks` is
24
- // what a mark surface asks before offering itself over a leaf.
25
- export { blockSchema, inlineSchema, plaintextSchema, hasMarks } from './schema.js';
24
+ // what a mark surface asks before offering itself over a leaf, and `rendersHref` the
25
+ // link gate the prompt re-asks on submit (`visual/links.ts`).
26
+ export { blockSchema, inlineSchema, plaintextSchema, hasMarks, rendersHref } from './schema.js';
26
27
  // Decode / encode / positions (tests + VisualEditor).
27
28
  //
28
29
  // The barrel carries what has an off-barrel caller, and nothing else. A symbol
@@ -1,10 +1,10 @@
1
1
  // Input rules: the markdown-shorthand plugin the codec owns (`**`→strong, `*`→em,
2
2
  // `~~`→strike, `` ` ``→code, `# `→heading, `- `/`1. `→lists, `> `→blockquote,
3
- // `---`→divider).
4
- // Mark rules are hand-rolled (prosemirror-inputrules ships only node rules); the one
5
- // block rule with no guard of its own reuses `wrappingInputRule`, and the rest mirror
6
- // an upstream body around one. These produce transactions the codec already lowers:
7
- // they add no new lowering surface.
3
+ // `---`→divider, a fence→code block).
4
+ // Mark rules are hand-rolled (prosemirror-inputrules ships only node rules); every
5
+ // block rule is one regexp over a command in `blocks.ts`, which is the slash menu's
6
+ // implementation too. These produce transactions the codec already lowers: they add no
7
+ // new lowering surface.
8
8
  //
9
9
  // A rule declines only where what it would mint is unrepresentable, or is a shape
10
10
  // another gesture owns — never on how a quill renders it. What a quill typesets is not
@@ -12,9 +12,8 @@
12
12
  // sets"), and a construct that arrives through `importMarkdown` must stay authorable or
13
13
  // a document opens carrying a shape the editor refuses to make. So one guard survives:
14
14
  // `- ` / `1. ` at the head of an existing item, where Tab owns the nesting.
15
- import { InputRule, inputRules, wrappingInputRule } from 'prosemirror-inputrules';
16
- import { canJoin, findWrapping } from 'prosemirror-transform';
17
- import { Selection } from 'prosemirror-state';
15
+ import { InputRule, inputRules } from 'prosemirror-inputrules';
16
+ import { consuming, insertDivider, toCodeBlock, toHeading, wrapInList, wrapInQuote } from './blocks.js';
18
17
  /**
19
18
  * A mark input rule: when `regexp` (ending in the closing delimiter) matches
20
19
  * before the caret, wrap capture group 1 in `markType` and drop the delimiters.
@@ -46,69 +45,19 @@ function markInputRule(regexp, markType, delimLen) {
46
45
  return tr;
47
46
  });
48
47
  }
49
- /**
50
- * `wrappingInputRule` (whose body this mirrors), plus one normalization: a
51
- * `heading` being wrapped becomes a `paragraph` first.
52
- *
53
- * `list_item` is `block+`, so `list_item > heading` is *representable*, and `# `
54
- * inside the item is the gesture that mints it ({@link markdownInputRules}). A wrap
55
- * keeping the heading would be a second door onto that shape, so `- ` / `1. ` mint a
56
- * paragraph item wherever they fire.
57
- *
58
- * `item` is the second guard, and it is about the gesture rather than the shape: the
59
- * rule declines at the start of an item's own first block ({@link openingAnItem}).
60
- */
61
- function listWrappingRule(regexp, listType, paragraph, item, getAttrs, joinPredicate) {
62
- return new InputRule(regexp, (state, match, start, end) => {
63
- if (openingAnItem(state.doc.resolve(start), item))
64
- return null;
65
- const attrs = getAttrs ? getAttrs(match) : null;
66
- const tr = state.tr.delete(start, end);
67
- // Positions survive the retype: same content, same size.
68
- if (tr.doc.resolve(start).parent.type !== paragraph) {
69
- tr.setBlockType(start, start, paragraph);
70
- }
71
- const $start = tr.doc.resolve(start);
72
- const range = $start.blockRange();
73
- const wrapping = range && findWrapping(range, listType, attrs);
74
- if (!range || !wrapping)
75
- return null;
76
- tr.wrap(range, wrapping);
77
- // Join a preceding list of the same type: the one boundary this rule
78
- // itself opens (`lists.ts` §cleanup: command-local, never a global pass).
79
- const before = tr.doc.resolve(start - 1).nodeBefore;
80
- if (before &&
81
- before.type === listType &&
82
- canJoin(tr.doc, start - 1) &&
83
- (!joinPredicate || joinPredicate(match, before))) {
84
- tr.join(start - 1);
85
- }
86
- return tr;
87
- });
88
- }
89
- /**
90
- * Whether `$start` is the head of an item that already exists: offset 0 of a
91
- * `list_item`'s first block, which is where a list shorthand is the text an author
92
- * typed rather than a shorthand at all.
93
- *
94
- * Firing there wraps the item's own paragraph in a fresh list, minting an item whose
95
- * only content is another item — a level with nothing on it. Tab is the indent gesture
96
- * and lands the shape a nesting is supposed to have, under the previous sibling, so
97
- * what the rule adds here is a second door onto a worse answer. A later block of the
98
- * item is not this case: wrapping a continuation paragraph is how a sub-list opens
99
- * under text.
100
- */
101
- function openingAnItem($start, item) {
102
- if (!item || $start.depth < 2 || $start.parentOffset !== 0)
103
- return false;
104
- return $start.node(-1).type === item && $start.index(-1) === 0;
48
+ /** A block shorthand: the regexp that fires it, and the command it runs in the match's
49
+ * place. Every one is `^`-anchored, so the command's own head-of-a-textblock guard is
50
+ * the same position the regexp already stands at. */
51
+ function blockRule(regexp, command) {
52
+ return new InputRule(regexp, (state, match, start, end) => consuming(state, start, end, command(match))?.tr ?? null);
105
53
  }
106
54
  /** The full markdown-shorthand rule set for a block schema. */
107
55
  export function markdownInputRules(schema) {
108
56
  const rules = [];
109
57
  const m = schema.marks;
110
- const item = schema.nodes.list_item;
111
- // Inline marks. Non-greedy capture between matching delimiters.
58
+ // Inline marks. Non-greedy capture between matching delimiters. Built only where the
59
+ // schema has the mark: the inline schema omits them, and a plaintext one declares none
60
+ // at all.
112
61
  if (m.strong)
113
62
  rules.push(markInputRule(/\*\*([^*]+)\*\*$/, m.strong, 2));
114
63
  if (m.em)
@@ -117,85 +66,16 @@ export function markdownInputRules(schema) {
117
66
  rules.push(markInputRule(/~~([^~]+)~~$/, m.strike, 2));
118
67
  if (m.code)
119
68
  rules.push(markInputRule(/`([^`]+)`$/, m.code, 1));
120
- // Block shorthands (only where the schema has the node; the inline schema
121
- // omits them, so this stays a no-op there).
122
- if (schema.nodes.heading) {
123
- // `textblockTypeInputRule`'s body. It fires inside a list item like anywhere else:
124
- // `list_item > heading` is a shape the content holds and `importMarkdown` produces
125
- // from `- # title`, so a rule that declined there would refuse to author what a
126
- // document can arrive carrying.
127
- const heading = schema.nodes.heading;
128
- rules.push(new InputRule(/^(#{1,6})\s$/, (state, match, start, end) => {
129
- const $start = state.doc.resolve(start);
130
- if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), heading)) {
131
- return null;
132
- }
133
- return state.tr
134
- .delete(start, end)
135
- .setBlockType(start, start, heading, { level: match[1].length });
136
- }));
137
- }
138
- if (schema.nodes.code_block && schema.nodes.paragraph) {
139
- // `textblockTypeInputRule`'s body plus the exit the `---` rule mints below, for a
140
- // sharper version of its reason: a code block is the one block a GAP CURSOR will
141
- // not sit beside either (`closedBefore`/`closedAfter` both fail on a textblock), so
142
- // a fence at the end of a body leaves the caret with no way forward at all.
143
- const code = schema.nodes.code_block;
144
- const paragraph = schema.nodes.paragraph;
145
- rules.push(new InputRule(/^```$/, (state, _match, start, end) => {
146
- const $start = state.doc.resolve(start);
147
- if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), code)) {
148
- return null;
149
- }
150
- const tr = state.tr.delete(start, end).setBlockType(start, start, code);
151
- const at = tr.doc.resolve(start).after();
152
- if (!tr.doc.nodeAt(at))
153
- tr.insert(at, paragraph.create());
154
- // The caret stays in the fence, not in the exit it minted.
155
- return tr;
156
- }));
157
- }
158
- if (schema.nodes.horizontal_rule && schema.nodes.paragraph) {
159
- // A divider is a whole block, so this rule replaces its textblock rather than
160
- // retyping one: `horizontal_rule` holds no content to retype into.
161
- const rule = schema.nodes.horizontal_rule;
162
- const paragraph = schema.nodes.paragraph;
163
- rules.push(new InputRule(/^---$/, (state, _match, start, end) => {
164
- const $start = state.doc.resolve(start);
165
- // The block entirely, not a prefix of one. `[start, end)` is the match minus
166
- // the just-typed `-`, so the block holds `--` when this fires; anything else
167
- // in it is a paragraph being edited, which a replace would eat.
168
- if ($start.parentOffset !== 0 || $start.parent.content.size !== end - start) {
169
- return null;
170
- }
171
- if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), rule)) {
172
- return null;
173
- }
174
- const from = $start.before();
175
- const tr = state.tr.replaceWith(from, $start.after(), rule.create());
176
- // A divider at the end of a body leaves nowhere to type; the paragraph after
177
- // it is the exit, and it is where the caret lands (`slash.ts` §insertBlock
178
- // opens the same block for the same reason).
179
- const at = from + 1;
180
- if (!tr.doc.nodeAt(at))
181
- tr.insert(at, paragraph.create());
182
- tr.setSelection(Selection.near(tr.doc.resolve(at), 1));
183
- return tr;
184
- }));
185
- }
69
+ // Block shorthands. Each command answers for the schema it is run against, so a
70
+ // schema without the node declines rather than being guarded here.
71
+ rules.push(blockRule(/^(#{1,6})\s$/, (match) => toHeading(match[1].length)));
72
+ rules.push(blockRule(/^```$/, toCodeBlock));
73
+ rules.push(blockRule(/^---$/, insertDivider));
186
74
  // `- ` / `1. ` at the start of a block, here or nested inside an item — but not at
187
- // the start of an item's own first block, where {@link openingAnItem} declines.
188
- if (schema.nodes.bullet_list && schema.nodes.paragraph) {
189
- rules.push(listWrappingRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list, schema.nodes.paragraph, item));
190
- }
191
- if (schema.nodes.ordered_list && schema.nodes.paragraph) {
192
- rules.push(listWrappingRule(/^(\d+)\.\s$/, schema.nodes.ordered_list, schema.nodes.paragraph, item, (match) => ({ start: +match[1] }), (match, node) => node.childCount + node.attrs.start === +match[1]));
193
- }
194
- // No item guard: a quote wrapping an item's own first block is a container the
195
- // content holds, where a list wrapping one is an item whose only content is an item.
196
- if (schema.nodes.blockquote) {
197
- rules.push(wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote));
198
- }
75
+ // the start of an item's own first block, where the command declines.
76
+ rules.push(blockRule(/^\s*([-+*])\s$/, () => wrapInList(false)));
77
+ rules.push(blockRule(/^(\d+)\.\s$/, (match) => wrapInList(true, { start: +match[1] }, (node) => node.childCount + node.attrs.start === +match[1])));
78
+ rules.push(blockRule(/^\s*>\s$/, wrapInQuote));
199
79
  return rules;
200
80
  }
201
81
  /** The input-rules plugin the field mounts by default (opt-out via createField). */