@quillmark/svelte 0.3.2 → 0.4.1

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 CHANGED
@@ -200,7 +200,7 @@ onPick: (at) => visualEditor.setCaret(at);
200
200
  onCaretMove: preview.focusPosition;
201
201
  ```
202
202
 
203
- A pick carries a caret where the compile tracks the content under the point, and the field alone where it tracks only the placement — a scalar the plate prints without tracking. `setCaret` takes both: an absent `pos` reveals and focuses the field, which is the whole of what a click on plate-placed ink can mean. The address may name an array element (`main.references.0`), which lands on that row.
203
+ A pick carries a caret where the compile tracks the content under the point, and the field alone where it tracks only the placement — a scalar the plate prints without tracking. `setCaret` takes both: an absent `pos` reveals and focuses the field, which is the whole of what a click on plate-placed ink can mean. The address may name an array element (`main.keywords[0]`), which lands on that row.
204
204
 
205
205
  A consumer holding an `Addr` of its own maps it with `fieldPathForAddr` (from `@quillmark/svelte/core`). The playground app's split-pane route is the full reference shell: one session, both bridge directions, the preview following edits, and diagnostics routed inline.
206
206
 
package/THEMING.md CHANGED
@@ -54,8 +54,8 @@ The poles take a colour, not `transparent` or `inherit`: the raised surfaces, th
54
54
  | `--qm-bg` | `#fff` / `#272729` | Base surface: a card, and the preview's sheet. Fields, the popover and the column behind the cards each step off it. |
55
55
  | `--qm-fg` | `#1c1c1c` / `#d7dadc` | Base ink. Body text, labels, and borders step off it. |
56
56
  | `--qm-accent` | `#71717a` / `#a1a1aa` | Focus rings, active marks, the preview's active field box. The surface spends no hue of its own, so this is the one it wears. |
57
- | `--qm-danger` | `#c5221f` | Error diagnostics, the required marker, the delete glyph. |
58
- | `--qm-warning` | `#b25000` | Warning diagnostics. |
57
+ | `--qm-danger` | `#c5221f` / `#ff9c94` | Error diagnostics, the required marker, the delete glyph. |
58
+ | `--qm-warning` | `#b25000` / `#e0b44c` | Warning diagnostics. |
59
59
  | `--qm-font` | `ui-sans-serif, system-ui, sans-serif` | The editor surface's font family. Controls and buttons take it too, in place of the UA face. |
60
60
  | `--qm-font-mono` | `ui-monospace, monospace` | The monospace face: the JSON array control, the tips card. |
61
61
  | `--qm-font-size` | `0.875rem` | Chrome text: every control's size, the anchor the ramp derives up (title) and down (label/meta) from, and the anchor a document body sits one small step over. |
@@ -80,7 +80,7 @@ Declared before our sheet it fixes the order outright; declared after, `qm` is a
80
80
 
81
81
  Ink steps `fg → bg` and the borders and hover fills step `bg → fg`, mixed in **oklab**, so inverting the two poles inverts all of them together.
82
82
 
83
- **Planes are the exception**, and deliberately: the raised plane is the brighter one in both schemes, which is what makes a card read as a card rather than as a differently-tinted page. So the column under the cards sinks from `--qm-bg` toward shadow and a floating surface rises from it toward light, and neither reads the ink pole. Each direction takes a step sized to the scheme it is in, which is why pinning a dark palette while your `color-scheme` still says light leaves the column barely off its cards — the one case §"Make it yours" tells you to pin the scheme alongside the poles.
83
+ **Planes are the exception**, and deliberately: the raised plane is the brighter one in both schemes, which is what makes a card read as a card rather than as a differently-tinted page. So the column under the cards sinks from `--qm-bg` toward shadow and a floating surface rises from it toward light, and neither reads the ink pole. How far each steps is a function of the card's own lightness — a dark card has to spend more before the plane above it reads as lifted — so a palette pinned at neither pole takes a proportionate step without being told which scheme it is in.
84
84
 
85
85
  Which way the poles default is **your `color-scheme`**, not the operating system's. Declare the scheme your app is in and the surface lands on it, along with native chrome, which reads the same property:
86
86
 
@@ -106,7 +106,11 @@ Setting the poles yourself wins over the default in both schemes, so a fixed pal
106
106
  }
107
107
  ```
108
108
 
109
- A palette **pinned against** your own scheme is the one case needing a second line: the surfaces follow your dials, but native chrome follows `color-scheme`, so pin it with them (`.my-editor { color-scheme: dark }`). Skip that line and what the browser paints — the selection highlight, the scrollbar, the spinner — stays on whichever scheme the host inherits.
109
+ That cuts both ways for the status hues: `--qm-danger` and `--qm-warning` each default to a pair calibrated against the card at both poles, and one pinned value replaces both arms, so a red measured against white carries into the dark scheme unmeasured. Pin them under your own scheme rule (`.dark { --qm-danger: … }`) where the colour your app means by error is itself scheme-aware.
110
+
111
+ A palette **pinned against** your own scheme still wants a second line, for a smaller reason than it used to be: the planes read your card's lightness, so the surface itself lands right either way, but the poles' own defaults, the status hues and native chrome all still read `color-scheme`. Pin it with them (`.my-editor { color-scheme: dark }`). Skip that line and what the browser paints — the selection highlight, the scrollbar, the spinner — stays on whichever scheme the host inherits, and a status hue you left to us is measured against the wrong card.
112
+
113
+ Pinning **one mount** rather than a page is the `style` prop, which lands on the same root: `<Preview style="color-scheme: dark" />`. There is no `scheme` prop, and deliberately — an inline declaration of ours would be the one thing your own CSS could not beat.
110
114
 
111
115
  No JS runs and no media query: the derivation resolves through the cascade at paint time, so there is no `dark` prop and no mode toggle. The consumer's palette decides.
112
116
 
@@ -121,7 +125,7 @@ import '@quillmark/svelte/preset';
121
125
  ```html
122
126
  <body class="qm-page">
123
127
  <p class="qm-label">quill</p>
124
- <pre class="qm-readout">usaf_memo@0.2.0</pre>
128
+ <pre class="qm-readout">specimen@1.0.0</pre>
125
129
  </body>
126
130
  ```
127
131
 
@@ -198,4 +202,4 @@ The derived surface scale (surface / border / ink rungs, the blur radius, the po
198
202
 
199
203
  ## Requirements
200
204
 
201
- `@layer`, `color-mix()` and `light-dark()`: Baseline since 2022, 2023 and 2024 respectively. `@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.
205
+ `@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.
@@ -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.references.0`, which {@link elementAddrForFieldPath}
66
+ * array-element path (`main.keywords[0]`, which {@link elementAddrForFieldPath}
67
67
  * takes instead), a field-rooted one, or a malformed one. A bare card and a `.body`
68
68
  * terminal both land on the field-less `{card: i}` the body leaf answers to.
69
69
  *
@@ -72,16 +72,10 @@ export declare function cardPath(index: number, kinds: readonly string[]): DocPa
72
72
  */
73
73
  export declare function addrForFieldPath(path: DocPath): Addr | undefined;
74
74
  /**
75
- * An array element's address, split. `main.references.0` and `main.references[0]`
76
- * both land here; anything {@link addrForFieldPath} can name, and anything whose
77
- * trailing segment is not an index, does not.
78
- *
79
- * Both spellings, because the boundary mints one and formats the other: `regions()`
80
- * and `positionAt` emit `main.references.0`, which `parseDocPath` reads as a field
81
- * literally named `"0"`, while `formatDocPath` spells the index segment
82
- * `main.references[0]`. A trailing all-digits field name is therefore an index that
83
- * lost its brackets — but only under a field the schema declares an array, the
84
- * caller's guard to apply: this module holds the grammar and no schema.
75
+ * An array element's address, split: `main.keywords[0]` lands here, and anything
76
+ * {@link addrForFieldPath} can name — or whose trailing segment is not an index —
77
+ * does not. `regions()`, `positionAt` and `formatDocPath` all spell the index
78
+ * segment bracketed, so there is one spelling to read and none to bridge.
85
79
  */
86
80
  export declare function elementAddrForFieldPath(path: DocPath): ElementAddr | undefined;
87
81
  /** An array element's address: the array's `Addr`, and which element of it. */
@@ -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.references.0`, which {@link elementAddrForFieldPath}
40
+ * array-element path (`main.keywords[0]`, which {@link elementAddrForFieldPath}
41
41
  * takes instead), a field-rooted one, or a malformed one. A bare card and a `.body`
42
42
  * terminal both land on the field-less `{card: i}` the body leaf answers to.
43
43
  *
@@ -49,32 +49,21 @@ export function addrForFieldPath(path) {
49
49
  return segs && addrForSegs(segs);
50
50
  }
51
51
  /**
52
- * An array element's address, split. `main.references.0` and `main.references[0]`
53
- * both land here; anything {@link addrForFieldPath} can name, and anything whose
54
- * trailing segment is not an index, does not.
55
- *
56
- * Both spellings, because the boundary mints one and formats the other: `regions()`
57
- * and `positionAt` emit `main.references.0`, which `parseDocPath` reads as a field
58
- * literally named `"0"`, while `formatDocPath` spells the index segment
59
- * `main.references[0]`. A trailing all-digits field name is therefore an index that
60
- * lost its brackets — but only under a field the schema declares an array, the
61
- * caller's guard to apply: this module holds the grammar and no schema.
52
+ * An array element's address, split: `main.keywords[0]` lands here, and anything
53
+ * {@link addrForFieldPath} can name — or whose trailing segment is not an index —
54
+ * does not. `regions()`, `positionAt` and `formatDocPath` all spell the index
55
+ * segment bracketed, so there is one spelling to read and none to bridge.
62
56
  */
63
57
  export function elementAddrForFieldPath(path) {
64
58
  const segs = segsOf(path);
65
59
  if (!segs)
66
60
  return undefined;
67
61
  const last = segs[segs.length - 1];
68
- const index = last?.seg === 'index'
69
- ? last.index
70
- : last?.seg === 'field' && /^\d+$/.test(last.name)
71
- ? Number(last.name)
72
- : undefined;
73
- if (index == null)
62
+ if (last?.seg !== 'index')
74
63
  return undefined;
75
64
  const field = addrForSegs(segs.slice(0, -1));
76
65
  // A body has no elements: the parent must name a field.
77
- return field?.field != null ? { field, index } : undefined;
66
+ return field?.field != null ? { field, index: last.index } : undefined;
78
67
  }
79
68
  /** `parseDocPath`, with a malformed path as `undefined` rather than a throw. The gate
80
69
  * is read outside the try: an uninitialized core is not a malformed path, and would
@@ -4,10 +4,6 @@ import type { Content } from '@quillmark/wasm';
4
4
  export declare function codePoints(s: string): string[];
5
5
  /** USV length of `s` (code points, not UTF-16 units). */
6
6
  export declare function usvLength(s: string): number;
7
- interface DecodeOpts {
8
- /** Strip all marks (a `plaintext` field). */
9
- plaintext?: boolean;
10
- }
11
7
  /**
12
8
  * A `Content` as read-only DOM: decode under `schema`, then the nodes' own `toDOM`.
13
9
  * The rendering half of the codec's job with no editing attached (no PM view, no
@@ -18,7 +14,6 @@ interface DecodeOpts {
18
14
  * `DOMSerializer.fromSchema` memoizes on the schema, so repeat calls build no
19
15
  * serializer.
20
16
  */
21
- export declare function renderContent(rt: Content, schema: Schema, opts?: DecodeOpts): Node;
17
+ export declare function renderContent(rt: Content, schema: Schema): Node;
22
18
  /** Decode a `Content` to a PM document under `schema`. */
23
- export declare function decode(rt: Content, schema: Schema, opts?: DecodeOpts): PMNode;
24
- export {};
19
+ export declare function decode(rt: Content, schema: Schema): PMNode;
@@ -10,7 +10,7 @@
10
10
  import { DOMSerializer } from 'prosemirror-model';
11
11
  import { ISLAND_SLOT } from './islands.js';
12
12
  import { descriptorOf, markKey, pmMarkFromContent } from './marks.js';
13
- import { isInlineSchema } from './schema.js';
13
+ import { hasMarks, isInlineSchema } from './schema.js';
14
14
  import { isAnchorMark, isCodeLine, isHeadingLine, isListItemContainer } from '@quillmark/wasm';
15
15
  /** Code points of `s` (USV units): the iteration granularity the content speaks. */
16
16
  export function codePoints(s) {
@@ -33,11 +33,11 @@ export function usvLength(s) {
33
33
  * `DOMSerializer.fromSchema` memoizes on the schema, so repeat calls build no
34
34
  * serializer.
35
35
  */
36
- export function renderContent(rt, schema, opts = {}) {
37
- return DOMSerializer.fromSchema(schema).serializeFragment(decode(rt, schema, opts).content);
36
+ export function renderContent(rt, schema) {
37
+ return DOMSerializer.fromSchema(schema).serializeFragment(decode(rt, schema).content);
38
38
  }
39
39
  /** Decode a `Content` to a PM document under `schema`. */
40
- export function decode(rt, schema, opts = {}) {
40
+ export function decode(rt, schema) {
41
41
  const lineTexts = rt.text.split('\n');
42
42
  // Per-line USV start: line i begins after all earlier lines and their `\n`s.
43
43
  const starts = [];
@@ -46,7 +46,11 @@ export function decode(rt, schema, opts = {}) {
46
46
  starts.push(acc);
47
47
  acc += usvLength(lineTexts[i]) + 1; // +1 for the `\n` boundary
48
48
  }
49
- const marks = opts.plaintext ? [] : rt.marks.filter((m) => !isAnchorMark(m));
49
+ // A mark-free schema (`plaintextSchema`) has nothing to apply them with, and a
50
+ // plaintext field that acquired one anyway — an older build's popover, a hand-
51
+ // written document — is exactly the content that must open and then heal on the
52
+ // next commit, not throw here.
53
+ const marks = hasMarks(schema) ? rt.marks.filter((m) => !isAnchorMark(m)) : [];
50
54
  const cursor = { i: 0, rt };
51
55
  if (isInlineSchema(schema)) {
52
56
  return decodeInline(rt, schema, lineTexts, starts, marks, cursor);
@@ -16,7 +16,8 @@ export interface CreateFieldOpts {
16
16
  container: HTMLElement;
17
17
  /** Constrained single-textblock schema (a `richtext(inline)` field). */
18
18
  inline?: boolean;
19
- /** Inline + marks/islands stripped (a `plaintext` field). Implies `inline`. */
19
+ /** The mark-free inline schema (a `plaintext` field): literal text, no formatting
20
+ * and no anchors. Implies `inline`. */
20
21
  plaintext?: boolean;
21
22
  /** Suppress the markdown-shorthand input rules. */
22
23
  noInputRules?: boolean;
@@ -145,15 +146,13 @@ export declare function createField(opts: CreateFieldOpts): FieldController;
145
146
  * last the two that answer to a caret or a selection beside a block: the gap cursor
146
147
  * and {@link pastAtomPlugin}.
147
148
  *
148
- * A `plaintext` field carries no marks (decode strips them, the keymap suppresses
149
- * Mod-b/i/u), so it also skips the input rules: `inlineSchema` still declares the
150
- * mark types, so a `**bold**` rule would apply a strong mark and eat the literal
151
- * delimiters. The inline schema has no block nodes, so those rules are the only
152
- * ones it would add: skip all.
149
+ * Every mark-shaped plugin reads the schema rather than a flag: over
150
+ * `plaintextSchema` the shorthand rules build nothing (each is guarded on its mark
151
+ * type) and the toggles bind no key, so a `**bold**` keeps the delimiters its author
152
+ * typed without a second rule saying so.
153
153
  */
154
154
  export declare function proseLeafPlugins(schema: Schema, opts: {
155
155
  inline: boolean;
156
- plaintext: boolean;
157
156
  /** The slash menu's report channel; absent mounts no menu and leaves
158
157
  * Enter/Escape/the arrows to the links below (`keymap.ts`). */
159
158
  slash?: (state: SlashState | undefined) => void;
@@ -27,7 +27,7 @@ import { anchorsFromContent } from './marks.js';
27
27
  import { createReconciler } from './reconcile.js';
28
28
  import { inputRulesPlugin } from './inputrules.js';
29
29
  import { bodyKeymap } from './keymap.js';
30
- import { blockSchema, inlineSchema } from './schema.js';
30
+ import { blockSchema, inlineSchema, plaintextSchema } from './schema.js';
31
31
  import { DEFAULT_TABLE_STRINGS, tableNodeView } from './table-view.js';
32
32
  import { focusSlashItem, runSlashItem, slashPlugin } from './slash.js';
33
33
  const anchorKey = new PluginKey('quill-anchors');
@@ -108,7 +108,10 @@ export function createField(opts) {
108
108
  const { doc, addr, container } = opts;
109
109
  const inline = !!opts.inline || !!opts.plaintext;
110
110
  const plaintext = !!opts.plaintext;
111
- const schema = inline ? inlineSchema : blockSchema;
111
+ // The declared type picks the schema, and the schema is the whole of what
112
+ // `plaintext` suppresses: no mark types to toggle, to paste in, or to mint a
113
+ // shorthand with (`schema.ts`).
114
+ const schema = plaintext ? plaintextSchema : inline ? inlineSchema : blockSchema;
112
115
  // Bound once and held: the reader is a `{quill, doc}` pair that reads live, so
113
116
  // every read below sees the commit before it.
114
117
  const reader = opts.quill.reader(doc);
@@ -176,7 +179,7 @@ export function createField(opts) {
176
179
  }
177
180
  });
178
181
  function buildState(rt) {
179
- const pmDoc = decode(rt, schema, { plaintext });
182
+ const pmDoc = decode(rt, schema);
180
183
  const anchors = plaintext ? [] : anchorsFromContent(rt);
181
184
  // Seed anchor plugin positions in PM coords via a fresh index over pmDoc.
182
185
  const seedIndex = buildLineIndex(pmDoc);
@@ -188,7 +191,6 @@ export function createField(opts) {
188
191
  doc: pmDoc,
189
192
  plugins: proseLeafPlugins(schema, {
190
193
  inline,
191
- plaintext,
192
194
  slash,
193
195
  noInputRules: opts.noInputRules,
194
196
  // Always installed, so a leaf that mounts without a ghost can still be
@@ -368,19 +370,18 @@ export function createField(opts) {
368
370
  * last the two that answer to a caret or a selection beside a block: the gap cursor
369
371
  * and {@link pastAtomPlugin}.
370
372
  *
371
- * A `plaintext` field carries no marks (decode strips them, the keymap suppresses
372
- * Mod-b/i/u), so it also skips the input rules: `inlineSchema` still declares the
373
- * mark types, so a `**bold**` rule would apply a strong mark and eat the literal
374
- * delimiters. The inline schema has no block nodes, so those rules are the only
375
- * ones it would add: skip all.
373
+ * Every mark-shaped plugin reads the schema rather than a flag: over
374
+ * `plaintextSchema` the shorthand rules build nothing (each is guarded on its mark
375
+ * type) and the toggles bind no key, so a `**bold**` keeps the delimiters its author
376
+ * typed without a second rule saying so.
376
377
  */
377
378
  export function proseLeafPlugins(schema, opts) {
378
379
  const list = [history(), ...(opts.afterHistory ?? [])];
379
380
  if (opts.slash)
380
381
  list.push(slashPlugin(opts.slash));
381
- if (!opts.noInputRules && !opts.plaintext)
382
+ if (!opts.noInputRules)
382
383
  list.push(inputRulesPlugin(schema));
383
- list.push(keymap(editorKeymap(schema, opts.inline, opts.plaintext, !!opts.slash)));
384
+ list.push(keymap(editorKeymap(schema, opts.inline, !!opts.slash)));
384
385
  list.push(keymap(baseKeymap));
385
386
  // All three answer to something only a block leaf holds: an inline leaf is one
386
387
  // textblock with no island, no gap to put a cursor in, and nowhere for a pasted
@@ -460,7 +461,8 @@ function placeholderPlugin(read) {
460
461
  }
461
462
  /**
462
463
  * The field's keymap: history, mark toggles, and the body's structural keys; Enter
463
- * suppressed inline.
464
+ * suppressed inline. The toggles are bound per mark type the schema declares, so a
465
+ * plaintext leaf leaves Mod-b/i/u to the browser.
464
466
  *
465
467
  * Tab forks on the leaf's role, not on the caret's position. An inline/plaintext
466
468
  * leaf is a form field: Tab stays unbound, so field navigation is open for a shell
@@ -468,20 +470,18 @@ function placeholderPlugin(read) {
468
470
  * is structural, a chain each nested surface prepends to (`keymap.ts`). One key
469
471
  * never means two things within one surface.
470
472
  */
471
- function editorKeymap(schema, inline, plaintext, slash) {
473
+ function editorKeymap(schema, inline, slash) {
472
474
  const map = {
473
475
  'Mod-z': undo,
474
476
  'Mod-y': redo,
475
477
  'Shift-Mod-z': redo
476
478
  };
477
- if (!plaintext) {
478
- if (schema.marks.strong)
479
- map['Mod-b'] = toggleMark(schema.marks.strong);
480
- if (schema.marks.em)
481
- map['Mod-i'] = toggleMark(schema.marks.em);
482
- if (schema.marks.underline)
483
- map['Mod-u'] = toggleMark(schema.marks.underline);
484
- }
479
+ if (schema.marks.strong)
480
+ map['Mod-b'] = toggleMark(schema.marks.strong);
481
+ if (schema.marks.em)
482
+ map['Mod-i'] = toggleMark(schema.marks.em);
483
+ if (schema.marks.underline)
484
+ map['Mod-u'] = toggleMark(schema.marks.underline);
485
485
  if (inline) {
486
486
  // One textblock only: swallow Enter so no second block is attempted.
487
487
  map['Enter'] = () => true;
@@ -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 } from './schema.js';
12
+ export { blockSchema, inlineSchema, plaintextSchema, hasMarks } 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';
@@ -20,8 +20,9 @@ export { DEFAULT_TABLE_STRINGS } from './table-view.js';
20
20
  // The slash menu: the codec owns the model and the picks, the chrome the words and
21
21
  // the pixels (`visual/SlashMenu.svelte` is the only off-barrel caller).
22
22
  export { DEFAULT_SLASH_STRINGS } from './slash.js';
23
- // Schemas (the decode/encode target; the VisualEditor mounts them).
24
- export { blockSchema, inlineSchema } from './schema.js';
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';
25
26
  // Decode / encode / positions (tests + VisualEditor).
26
27
  //
27
28
  // The barrel carries what has an off-barrel caller, and nothing else. A symbol
@@ -5,6 +5,13 @@
5
5
  // block rule with no guard of its own reuses `wrappingInputRule`, and the rest mirror
6
6
  // an upstream body around one. These produce transactions the codec already lowers:
7
7
  // they add no new lowering surface.
8
+ //
9
+ // A rule declines only where what it would mint is unrepresentable, or is a shape
10
+ // another gesture owns — never on how a quill renders it. What a quill typesets is not
11
+ // knowable here (a quill this build has not seen is the open-sets case, CODEC §"Open
12
+ // 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.
8
15
  import { InputRule, inputRules, wrappingInputRule } from 'prosemirror-inputrules';
9
16
  import { canJoin, findWrapping } from 'prosemirror-transform';
10
17
  import { Selection } from 'prosemirror-state';
@@ -43,12 +50,10 @@ function markInputRule(regexp, markType, delimLen) {
43
50
  * `wrappingInputRule` (whose body this mirrors), plus one normalization: a
44
51
  * `heading` being wrapped becomes a `paragraph` first.
45
52
  *
46
- * `list_item` is `block+`, so `list_item > heading` is *representable* and a bare
47
- * wrap mints it (a shape no quill renders): the reference quill derives an item's
48
- * numbering and indent from the container path and typesets the item's blocks as
49
- * body paragraphs (`usaf_memo`'s `render-body`), where a heading resolves to
50
- * nothing. This is the wrap-side route into that shape; the `# ` rule guards the
51
- * other, in {@link markdownInputRules}.
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.
52
57
  *
53
58
  * `item` is the second guard, and it is about the gesture rather than the shape: the
54
59
  * rule declines at the start of an item's own first block ({@link openingAnItem}).
@@ -115,16 +120,13 @@ export function markdownInputRules(schema) {
115
120
  // Block shorthands (only where the schema has the node; the inline schema
116
121
  // omits them, so this stays a no-op there).
117
122
  if (schema.nodes.heading) {
118
- // `textblockTypeInputRule`'s body plus one guard: inside a list item, `# `
119
- // declines (null) and stays literal text, rather than minting the unrenderable
120
- // `list_item > heading` {@link listWrappingRule} normalizes away on the wrap side.
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.
121
127
  const heading = schema.nodes.heading;
122
128
  rules.push(new InputRule(/^(#{1,6})\s$/, (state, match, start, end) => {
123
129
  const $start = state.doc.resolve(start);
124
- for (let d = $start.depth; d > 0; d--) {
125
- if (item && $start.node(d).type === item)
126
- return null;
127
- }
128
130
  if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), heading)) {
129
131
  return null;
130
132
  }
@@ -166,12 +168,6 @@ export function markdownInputRules(schema) {
166
168
  if ($start.parentOffset !== 0 || $start.parent.content.size !== end - start) {
167
169
  return null;
168
170
  }
169
- // The `# ` rule's guard, for its reason: `list_item` is `block+`, so a
170
- // divider inside an item is representable and renders to nothing.
171
- for (let d = $start.depth; d > 0; d--) {
172
- if (item && $start.node(d).type === item)
173
- return null;
174
- }
175
171
  if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), rule)) {
176
172
  return null;
177
173
  }
@@ -195,6 +191,8 @@ export function markdownInputRules(schema) {
195
191
  if (schema.nodes.ordered_list && schema.nodes.paragraph) {
196
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]));
197
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.
198
196
  if (schema.nodes.blockquote) {
199
197
  rules.push(wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote));
200
198
  }
@@ -84,16 +84,17 @@ li.ProseMirror-selectednode::after {
84
84
  emits, minus the four the UA already renders correctly (`strong`, `em`, `u`, `s`)
85
85
  and the blockquote above.
86
86
 
87
- Each rule answers to the reference quill rather than to a browser default or to a
87
+ Each rule answers to the leaf's own job rather than to a browser default or to a
88
88
  web editor's chrome, because the leaf sits beside a live preview of the same
89
- content. `usaf_memo` sets `raw` in a monospace face and adds nothing; it renders a
90
- heading as a bold run-in prepended to the following paragraph, with the level
91
- never read; it gives a link no decoration at all. So a leaf shows that a span is
92
- code and a line is a heading: never a prediction of the size paper will set it
93
- at, which the package cannot know for a quill it has not seen.
89
+ content. A leaf shows *that* a span is code and a line is a heading: never a
90
+ prediction of the size paper will set it at, which the package cannot know for a
91
+ quill it has not seen. The reference quill is the case in point — it sets `raw` in
92
+ the face it bundles, scales a heading by its level and colours it, and gives a link
93
+ no decoration at all — and mirroring any of that here would be a guess about the
94
+ next quill.
94
95
 
95
96
  Scoping is by tag, and the schema does the rest: `h1`–`h6`, `hr` and the lists
96
- cannot exist under the inline schema, so no rule for them can land on a `subject`
97
+ cannot exist under the inline schema, so no rule for them can land on an inline
97
98
  field however that field is mounted. The two marks are the exception: `code` and
98
99
  `link` are in both schemas, and a `plaintext` leaf strips them at decode while
99
100
  mounting a schema that still carries them, so a paste can put either in one. The
@@ -1,13 +1,22 @@
1
1
  import { Schema } from 'prosemirror-model';
2
2
  /** The full field schema: every block kind, container, mark, and island. */
3
3
  export declare const blockSchema: Schema<string, string>;
4
+ /** The constrained inline schema: one paragraph, no block splitting, no
5
+ * containers, no islands, and the full mark set (a `richtext(inline)` field, and
6
+ * a table cell, which is the same content unit). */
7
+ export declare const inlineSchema: Schema<string, string>;
4
8
  /**
5
- * The constrained inline schema: one paragraph, no block splitting, no
6
- * containers, no islands. `doc: "paragraph"` (exactly one child) is what makes an
7
- * Enter a no-op at the model level: there is no second block for a split to land.
8
- * Marks are present; `plaintext` fields strip them at decode and mount no
9
- * mark input (a schema-level distinction the field draws, per CODEC §Inline mode).
9
+ * The inline schema with no mark types at all: a `plaintext` field, whose value is
10
+ * literal text the boundary refuses to coerce back once it carries a mark.
11
+ *
12
+ * Declaring none is what makes that structural rather than a rule each path
13
+ * restates: `toggleMark` has no type to apply, the mark input rules build nothing,
14
+ * a paste parses its marks away, and `decode` drops any a stray content arrives
15
+ * carrying (CODEC §Inline mode).
10
16
  */
11
- export declare const inlineSchema: Schema<"text" | "doc" | "paragraph", string>;
17
+ export declare const plaintextSchema: Schema<string, never>;
18
+ /** Whether `schema` can carry formatting at all: false for {@link plaintextSchema}
19
+ * alone. What a mark command asks before it offers itself. */
20
+ export declare function hasMarks(schema: Schema): boolean;
12
21
  /** True for the constrained inline schema (no block containers); decode branches on it. */
13
22
  export declare function isInlineSchema(schema: Schema): boolean;
@@ -4,12 +4,13 @@
4
4
  // Each of the three open sets gets an inert carrier so an unrecognized value
5
5
  // survives a round-trip: the `unknown` mark, the paragraph's `unknown` attribute,
6
6
  // and the `unknown_container` node. `blockSchema` is the full field; `inlineSchema`
7
- // is the constrained single-textblock form for `richtext(inline)` / `plaintext`
8
- // (one paragraph, no block split, no containers, no islands): same decode/lower/
9
- // position machinery, narrower shape. Anchors are not marks here (decorations).
7
+ // is the constrained single-textblock form for `richtext(inline)` (one paragraph,
8
+ // no block split, no containers, no islands) and `plaintextSchema` is that one
9
+ // without marks: same decode/lower/position machinery, narrower shape. Anchors are
10
+ // not marks here (decorations).
10
11
  import { Schema } from 'prosemirror-model';
11
12
  import { islandBlockSpec, islandInlineSpec } from './islands.js';
12
- // ── Marks (shared by both schemas; plaintext mounts none) ───────────────────
13
+ // ── Marks (the block and inline schemas share them; plaintext declares none) ─
13
14
  const marks = {
14
15
  // Order matters: it fixes mark-set sort order and parse precedence. `link`
15
16
  // last so it wraps outermost; `unknown` after it, non-exclusive so several
@@ -134,21 +135,33 @@ export const blockSchema = new Schema({
134
135
  nodes: { ...blockNodes, ...inlineLeafNodes },
135
136
  marks
136
137
  });
138
+ // ── The constrained inline nodes (both inline schemas) ──────────────────────
139
+ // `doc: "paragraph"` (exactly one child) is what makes an Enter a no-op at the
140
+ // model level: there is no second block for a split to land.
141
+ const inlineNodes = {
142
+ doc: { content: 'paragraph' },
143
+ paragraph: { content: 'inline*', toDOM: () => ['p', 0] },
144
+ text: { group: 'inline' }
145
+ };
146
+ /** The constrained inline schema: one paragraph, no block splitting, no
147
+ * containers, no islands, and the full mark set (a `richtext(inline)` field, and
148
+ * a table cell, which is the same content unit). */
149
+ export const inlineSchema = new Schema({ nodes: inlineNodes, marks });
137
150
  /**
138
- * The constrained inline schema: one paragraph, no block splitting, no
139
- * containers, no islands. `doc: "paragraph"` (exactly one child) is what makes an
140
- * Enter a no-op at the model level: there is no second block for a split to land.
141
- * Marks are present; `plaintext` fields strip them at decode and mount no
142
- * mark input (a schema-level distinction the field draws, per CODEC §Inline mode).
151
+ * The inline schema with no mark types at all: a `plaintext` field, whose value is
152
+ * literal text the boundary refuses to coerce back once it carries a mark.
153
+ *
154
+ * Declaring none is what makes that structural rather than a rule each path
155
+ * restates: `toggleMark` has no type to apply, the mark input rules build nothing,
156
+ * a paste parses its marks away, and `decode` drops any a stray content arrives
157
+ * carrying (CODEC §Inline mode).
143
158
  */
144
- export const inlineSchema = new Schema({
145
- nodes: {
146
- doc: { content: 'paragraph' },
147
- paragraph: { content: 'inline*', toDOM: () => ['p', 0] },
148
- text: { group: 'inline' }
149
- },
150
- marks
151
- });
159
+ export const plaintextSchema = new Schema({ nodes: inlineNodes, marks: {} });
160
+ /** Whether `schema` can carry formatting at all: false for {@link plaintextSchema}
161
+ * alone. What a mark command asks before it offers itself. */
162
+ export function hasMarks(schema) {
163
+ return Object.keys(schema.marks).length > 0;
164
+ }
152
165
  /** True for the constrained inline schema (no block containers); decode branches on it. */
153
166
  export function isInlineSchema(schema) {
154
167
  return !schema.nodes.blockquote;