@quillmark/svelte 0.22.0 → 0.24.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 CHANGED
@@ -14,12 +14,12 @@ npm install @quillmark/svelte
14
14
 
15
15
  Each subpath is its own module root; a bundler pulls only what the entry you import reaches.
16
16
 
17
- | Import | Surface |
18
- | --------------------------- | --------------------------------------------------------------------------------------------------------------------- |
19
- | `@quillmark/svelte/core` | What the surfaces share: the `DocPath`/`Place` address vocabulary, the `EditorError` channel, `init`. Framework-free. |
20
- | `@quillmark/svelte/preview` | The live preview: `createPreview` + `<Preview>`. Reaches nothing editor-side. |
21
- | `@quillmark/svelte/visual` | The federated WYSIWYG: `<VisualEditor>`, the codec's `createField` prose leaf. |
22
- | `@quillmark/svelte` | Re-exports `/core`. |
17
+ | Import | Surface |
18
+ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
19
+ | `@quillmark/svelte/core` | What the surfaces share: the `DocPath`/`Place` address vocabulary, the `EditorError` channel, `init`. No UI framework. |
20
+ | `@quillmark/svelte/preview` | The live preview: `createPreview` + `<Preview>`. Reaches nothing editor-side. |
21
+ | `@quillmark/svelte/visual` | The federated WYSIWYG: `<VisualEditor>`, the codec's `createField` prose leaf. |
22
+ | `@quillmark/svelte` | Re-exports `/core`. |
23
23
 
24
24
  ## Open a session
25
25
 
@@ -29,8 +29,7 @@ The **consumer** owns the session and the handles, and drives every edit; the su
29
29
  import { Engine } from '@quillmark/wasm';
30
30
  import { init } from '@quillmark/svelte/core';
31
31
 
32
- // One-time, and the only door to Quill and Document: the artifact exports
33
- // neither statically.
32
+ // Quill and Document are exported only through init(), which loads the wasm once.
34
33
  const { Quill, Document } = await init();
35
34
 
36
35
  const quill = Quill.fromTree(tree); // tree: Map<string, Uint8Array> of the quill dir
@@ -0,0 +1,21 @@
1
+ import type { Node as PMNode } from 'prosemirror-model';
2
+ import { Plugin } from 'prosemirror-state';
3
+ /** A leaf's character cap: the schema's `max:`, and whether the count drops trailing
4
+ * whitespace, which a one-line `text` does and nothing else. */
5
+ export interface TextCap {
6
+ max: number;
7
+ trimEnd?: boolean;
8
+ /** The count, at mount and after every change to the document. */
9
+ onCount?: (count: number) => void;
10
+ }
11
+ /** The characters `doc` holds as `FieldSchema::char_count` counts them: Unicode scalar
12
+ * values of the plain projection, islands counting nothing and trailing line breaks
13
+ * none. */
14
+ export declare function capCount(doc: PMNode, trimEnd?: boolean): number;
15
+ /**
16
+ * Refuses a transaction leaving the leaf past `max`, so a keystroke at the cap lands
17
+ * nothing, and cuts a paste to what fits. Past the cap, a transaction passes where it
18
+ * leaves the count no higher, so a value stored over the cap still edits down; undo and
19
+ * redo pass, each restoring a state the leaf already held.
20
+ */
21
+ export declare function capPlugin(cap: TextCap): Plugin;
@@ -0,0 +1,63 @@
1
+ import { isHistoryTransaction } from 'prosemirror-history';
2
+ import { Plugin } from 'prosemirror-state';
3
+ import { usvLength } from './decode.js';
4
+ import { scanDoc } from './encode.js';
5
+ import { ISLAND_SLOT } from './islands.js';
6
+ /** The characters `doc` holds as `FieldSchema::char_count` counts them: Unicode scalar
7
+ * values of the plain projection, islands counting nothing and trailing line breaks
8
+ * none. */
9
+ export function capCount(doc, trimEnd = false) {
10
+ const text = scanDoc(doc).text.replaceAll(ISLAND_SLOT, '');
11
+ return usvLength(trimEnd ? text.trimEnd() : text.replace(/[\n\r]+$/, ''));
12
+ }
13
+ /**
14
+ * Refuses a transaction leaving the leaf past `max`, so a keystroke at the cap lands
15
+ * nothing, and cuts a paste to what fits. Past the cap, a transaction passes where it
16
+ * leaves the count no higher, so a value stored over the cap still edits down; undo and
17
+ * redo pass, each restoring a state the leaf already held.
18
+ */
19
+ export function capPlugin(cap) {
20
+ const count = (doc) => capCount(doc, cap.trimEnd);
21
+ return new Plugin({
22
+ filterTransaction: (tr) => {
23
+ if (!tr.docChanged || isHistoryTransaction(tr))
24
+ return true;
25
+ const after = count(tr.doc);
26
+ return after <= cap.max || after <= count(tr.before);
27
+ },
28
+ props: {
29
+ handlePaste: (view, _event, slice) => {
30
+ const { state } = view;
31
+ const pasted = () => state.tr.replaceSelection(slice);
32
+ if (count(pasted().doc) <= cap.max)
33
+ return false;
34
+ const tr = pasted();
35
+ const start = tr.mapping.map(state.selection.from, -1);
36
+ const end = tr.mapping.map(state.selection.to, 1);
37
+ const fits = (cut) => count(pasted().delete(cut, end).doc) <= cap.max;
38
+ if (!fits(start))
39
+ return true;
40
+ let lo = start;
41
+ let hi = end;
42
+ while (hi - lo > 1) {
43
+ const mid = (lo + hi) >> 1;
44
+ if (fits(mid))
45
+ lo = mid;
46
+ else
47
+ hi = mid;
48
+ }
49
+ view.dispatch(tr.delete(lo, end).scrollIntoView().setMeta('uiEvent', 'paste'));
50
+ return true;
51
+ }
52
+ },
53
+ view: (v) => {
54
+ cap.onCount?.(count(v.state.doc));
55
+ return {
56
+ update: (next, prev) => {
57
+ if (next.state.doc !== prev.doc)
58
+ cap.onCount?.(count(next.state.doc));
59
+ }
60
+ };
61
+ }
62
+ });
63
+ }
@@ -29,7 +29,8 @@ export declare function fitsInline(rt: Content): boolean;
29
29
  export declare function fitsPlain(rt: Content): boolean;
30
30
  /** Whether a leaf so declared edits `rt` losing nothing: {@link fitsInline} narrowed
31
31
  * and {@link fitsPlain} plain, past which the decode drops what the first commit would
32
- * store; on the block schema anything but an element ({@link editable}),
32
+ * store; on the block schema anything but a construct read and not edited — an element,
33
+ * a definition list ({@link editable}), a footnote or an equation (`isEditedIsland`) —
33
34
  * and an island where the leaf mounts no island view (`islands`), which draws as its
34
35
  * placeholder tag, an atom a keystroke deletes unseen. What a leaf held over the rest
35
36
  * reads. */
@@ -8,7 +8,7 @@
8
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, isBlockIsland } from './islands.js';
11
+ import { ISLAND_SLOT, isBlockIsland, isEditedIsland } from './islands.js';
12
12
  import { descriptorOf, markKey, pmMarkFromContent } from './marks.js';
13
13
  import { core } from '../lifecycle.js';
14
14
  import { hasMarks, isBlockSchema, isInlineSchema, takesLineBreak } from './schema.js';
@@ -96,7 +96,8 @@ export function fitsPlain(rt) {
96
96
  }
97
97
  /** Whether a leaf so declared edits `rt` losing nothing: {@link fitsInline} narrowed
98
98
  * and {@link fitsPlain} plain, past which the decode drops what the first commit would
99
- * store; on the block schema anything but an element ({@link editable}),
99
+ * store; on the block schema anything but a construct read and not edited — an element,
100
+ * a definition list ({@link editable}), a footnote or an equation (`isEditedIsland`) —
100
101
  * and an island where the leaf mounts no island view (`islands`), which draws as its
101
102
  * placeholder tag, an atom a keystroke deletes unseen. What a leaf held over the rest
102
103
  * reads. */
@@ -105,11 +106,16 @@ export function fitsLeaf(rt, leaf) {
105
106
  return fitsInline(rt);
106
107
  if (leaf.plaintext)
107
108
  return fitsPlain(rt);
108
- return (leaf.islands || rt.islands.length === 0) && rt.lines.every(editable);
109
+ if (!leaf.islands && rt.islands.length)
110
+ return false;
111
+ return rt.islands.every((isl) => isEditedIsland(isl.type)) && rt.lines.every(editable);
109
112
  }
110
- /** Whether every container on `line` is one the block schema edits: an element decodes
111
- * to no node, so the first commit would drop it. */
113
+ /** Whether the block schema edits `line`'s kind and every container on it: a term draws
114
+ * as a paragraph and an element and a description as the blocks they wrap, so the first
115
+ * commit would drop each. */
112
116
  function editable(line) {
117
+ if (line.kind === 'term')
118
+ return false;
113
119
  return line.containers.every((c) => c.container === 'quote' || c.container === 'list_item');
114
120
  }
115
121
  /** Inline decode: one paragraph, containers and islands stripped. */
@@ -192,9 +198,9 @@ function groupBlocks(schema, leaves, depth, marks, cursor) {
192
198
  i = j;
193
199
  continue;
194
200
  }
195
- if (here.container === 'element') {
196
- // No node holds an element, and a leaf holding one is held (`fitsLeaf`), so
197
- // what it wraps draws in place, read-only.
201
+ if (here.container === 'element' || here.container === 'description') {
202
+ // No node holds an element or a description, and a leaf holding one is held
203
+ // (`fitsLeaf`), so what it wraps draws in place, read-only.
198
204
  const key = containerKey(here);
199
205
  let j = i + 1;
200
206
  while (j < leaves.length) {
@@ -249,7 +255,7 @@ function instanceOf(c) {
249
255
  }
250
256
  /** Identity of a container for run gathering: its name, its `instance` and an element's
251
257
  * attributes, NUL-joined as `markKey` joins a mark's. `list_item` carries its own run
252
- * rule, so the quote and the element are what key here. */
258
+ * rule, so the rest are what key here. */
253
259
  function containerKey(c) {
254
260
  const attrs = c.container === 'element' ? JSON.stringify(c.attrs) : '';
255
261
  return `${c.container}\u0000${instanceOf(c)}\u0000${attrs}`;
@@ -269,13 +275,15 @@ function makeLeaf(schema, leaf, marks, cursor) {
269
275
  return schema.nodes.code_block.create({ lang: line.attrs?.lang ?? null }, content);
270
276
  }
271
277
  case 'para':
278
+ case 'term':
272
279
  case 'heading': {
273
- if (line.kind === 'para' && blockIslandLine(leaf, cursor))
280
+ if (line.kind !== 'heading' && blockIslandLine(leaf, cursor))
274
281
  return schema.nodes.island_block.create(islandAttrs(cursor));
275
282
  // Inline content, the segment boundary a `hard_break` in a paragraph and a space
276
283
  // in a heading, which takes none (`schema.ts` §`takesLineBreak`). A stored heading
277
284
  // carries no continuation — the store clears one after a block of a single line —
278
- // so the space is what keeps a hand-built content decodable.
285
+ // so the space is what keeps a hand-built content decodable. A term draws as a
286
+ // paragraph, read-only (`fitsLeaf`).
279
287
  const type = line.kind === 'heading' ? schema.nodes.heading : schema.nodes.paragraph;
280
288
  const inline = [];
281
289
  leaf.segments.forEach((seg, idx) => {
@@ -3,6 +3,7 @@ import { Plugin } from 'prosemirror-state';
3
3
  import { EditorView } from 'prosemirror-view';
4
4
  import type { Document, Content, Addr, Quill } from '@quillmark/wasm';
5
5
  import type { EditorErrorHandler } from '../errors.js';
6
+ import { type TextCap } from './cap.js';
6
7
  import { type TableChromeStrings } from './table-view.js';
7
8
  import { type SlashState, type SlashStrings } from './slash.js';
8
9
  /** Options for {@link createField}. */
@@ -27,7 +28,8 @@ export interface CreateFieldOpts {
27
28
  * leaf, or a `text` one, is held over a value its schema cannot
28
29
  * hold (`fitsLeaf`, upstream's `isInline` and `isPlain`): the decode would join lines
29
30
  * and drop containers and islands for the first commit to store. Any leaf is held
30
- * over an element, which no node holds. A held leaf draws
31
+ * over what is read and not edited: an element, a definition list, a footnote and an
32
+ * equation (CODEC §Decode). A held leaf draws
31
33
  * its content on the block schema, read-only, and commits nothing. Judged of the value
32
34
  * each mount and re-hydrate reads, so {@link FieldController.applyExternal} is what
33
35
  * re-evaluates it.
@@ -75,6 +77,9 @@ export interface CreateFieldOpts {
75
77
  * a body's invitation. The initial value; {@link FieldController.setPlaceholder}
76
78
  * moves it after mount. Empty/absent shows no ghost. */
77
79
  placeholder?: string;
80
+ /** The schema's `max:` on the field: a character count no edit takes the leaf past
81
+ * (`cap.ts`). */
82
+ cap?: TextCap;
78
83
  /** The placeholder goes at the leaf's first edit: the edit answers the leaf, and
79
84
  * emptied it holds an empty answer, which prints empty. Absent, the placeholder
80
85
  * returns whenever the leaf is empty. */
@@ -224,5 +229,8 @@ export declare function proseLeafPlugins(schema: Schema, opts: {
224
229
  placeholder?: () => string | undefined;
225
230
  /** {@link CreateFieldOpts.placeholderUntilEdit}. */
226
231
  placeholderUntilEdit?: boolean;
232
+ /** {@link CreateFieldOpts.cap}. Ahead of every other paste handler, which a paste
233
+ * it cuts never reaches. */
234
+ cap?: TextCap;
227
235
  afterHistory?: Plugin[];
228
236
  }): Plugin[];
@@ -24,6 +24,7 @@ import { reportError, errorMessage } from '../errors.js';
24
24
  import { decode, fitsLeaf } from './decode.js';
25
25
  import { usvToPM, pmToUsv, buildLineIndex, lineIndexOf } from './positions.js';
26
26
  import { lower, pmToContent, scanContent, scanDoc, contentEdit } from './encode.js';
27
+ import { capPlugin } from './cap.js';
27
28
  import { islandPastePlugin } from './islands.js';
28
29
  import { anchorsFromContent } from './marks.js';
29
30
  import { contentEqual, createReconciler } from './reconcile.js';
@@ -282,6 +283,7 @@ export function createField(opts) {
282
283
  // given one later; the plugin draws nothing while the text is empty.
283
284
  placeholder: () => placeholderText,
284
285
  placeholderUntilEdit: opts.placeholderUntilEdit,
286
+ cap: opts.cap,
285
287
  afterHistory: [anchorPlugin(seededAnchors)]
286
288
  })
287
289
  });
@@ -547,7 +549,12 @@ export function createField(opts) {
547
549
  * its author typed without a second rule saying so.
548
550
  */
549
551
  export function proseLeafPlugins(schema, opts) {
550
- const list = [history(), ...(opts.afterHistory ?? []), linebreakPlugin(schema)];
552
+ const list = [
553
+ history(),
554
+ ...(opts.cap ? [capPlugin(opts.cap)] : []),
555
+ ...(opts.afterHistory ?? []),
556
+ linebreakPlugin(schema)
557
+ ];
551
558
  if (opts.slash)
552
559
  list.push(slashPlugin(opts.slash, opts.slashHint));
553
560
  if (!opts.noInputRules)
@@ -2,6 +2,8 @@ export { rangeAnchor } from './anchor.js';
2
2
  export type { RangeAnchor } from './anchor.js';
3
3
  export { createField, emptyContent, heldAttributes, isEmptyContent, proseAttributes, proseLeafPlugins } from './field.js';
4
4
  export type { CreateFieldOpts, FieldController, LeafViews } from './field.js';
5
+ export { capCount } from './cap.js';
6
+ export type { TextCap } from './cap.js';
5
7
  export { DEFAULT_TABLE_STRINGS } from './table-view.js';
6
8
  export type { TableChromeStrings } from './table-view.js';
7
9
  export { DEFAULT_SLASH_STRINGS } from './slash.js';
@@ -14,6 +14,7 @@ import './prose.css';
14
14
  export { rangeAnchor } from './anchor.js';
15
15
  // The prose leaf.
16
16
  export { createField, emptyContent, heldAttributes, isEmptyContent, proseAttributes, proseLeafPlugins } from './field.js';
17
+ export { capCount } from './cap.js';
17
18
  // The table island's chrome vocabulary: the visual tier's `strings` set extends it,
18
19
  // so the island's wording is overridden beside every other key.
19
20
  export { DEFAULT_TABLE_STRINGS } from './table-view.js';
@@ -5,9 +5,12 @@ import type { ContentIsland, TableProps } from '@quillmark/wasm';
5
5
  export declare const ISLAND_SLOT = "\uFFFC";
6
6
  /** Whether an island of `type` is block markup: a `table` stands alone on its line. */
7
7
  export declare function isBlockIsland(type: ContentIsland['type']): boolean;
8
+ /** Whether a leaf edits an island of `type`; one holding any other is held (`fitsLeaf`). */
9
+ export declare function isEditedIsland(type: ContentIsland['type']): boolean;
8
10
  /** Block island node (a table): a `U+FFFC` slot alone on its `para` line. Atom, unselectable content. */
9
11
  export declare const islandBlockSpec: NodeSpec;
10
- /** Inline island node (an image): a `U+FFFC` slot inside a `para` line. */
12
+ /** Inline island node (an image, a footnote, an equation): a `U+FFFC` slot inside a
13
+ * `para` line. */
11
14
  export declare const islandInlineSpec: NodeSpec;
12
15
  /** A PM island node's attributes: the content entry, spelled the way a node spec
13
16
  * spells it (`islandType`, because `type` on a PM node is the node type). `islandType`
@@ -1,10 +1,11 @@
1
1
  // Islands: the `U+FFFC` slot + `ContentIsland` entry ↔ a PM leaf node. Every slot sits
2
2
  // in a `para` line; the island's type says which node it is, a `table` a block alone on
3
- // its line and an `image` inline. The node carries `id`, `islandType` and `props` verbatim.
3
+ // its line and every other type inline. The node carries `id`, `islandType` and `props`
4
+ // verbatim.
4
5
  //
5
6
  // A props-aware consumer reads the shape straight off the boundary:
6
- // `ContentIsland.props` is the typed union (`TableProps` for `table`,
7
- // `ImageProps` for `image`; DOCUMENT_MODEL §Stability seams), so the codec
7
+ // `ContentIsland.props` is the typed union (`TableProps` for `table`, `ImageProps` for
8
+ // `image`, …; DOCUMENT_MODEL §Stability seams), so the codec
8
9
  // reads it rather than hand-rolling a shape. `type` is a closed set, so a
9
10
  // discriminant check narrows it; `props` still crosses the DOM as opaque JSON,
10
11
  // which is what §`tablePropsOfNode` guards.
@@ -38,25 +39,35 @@ function islandDOM(node) {
38
39
  };
39
40
  }
40
41
  /** The island types the content model names, each with whether its markup is a block,
41
- * which decides the node its slot decodes to. A `Record` over the boundary's own union,
42
- * so a member added upstream is a missing key here rather than a silent refusal at the
43
- * one door with no decode behind it. */
44
- const BLOCK_ISLAND = { table: true, image: false };
42
+ * which decides the node its slot decodes to, and whether a leaf edits it. A `Record`
43
+ * over the boundary's own union, so a member added upstream is a missing key here
44
+ * rather than a silent refusal at the one door with no decode behind it. A footnote and
45
+ * an equation are read, not edited (CODEC §Decode): a leaf holding one is held. */
46
+ const ISLANDS = {
47
+ table: { block: true, edited: true },
48
+ image: { block: false, edited: true },
49
+ footnote: { block: false, edited: false },
50
+ math: { block: false, edited: false }
51
+ };
45
52
  /** Whether an island of `type` is block markup: a `table` stands alone on its line. */
46
53
  export function isBlockIsland(type) {
47
- return BLOCK_ISLAND[type];
54
+ return ISLANDS[type].block;
55
+ }
56
+ /** Whether a leaf edits an island of `type`; one holding any other is held (`fitsLeaf`). */
57
+ export function isEditedIsland(type) {
58
+ return ISLANDS[type].edited;
48
59
  }
49
60
  /** An island node's attributes off that DOM, or `false` where the id is absent or the
50
- * type is one the content vocabulary does not name — a name no read produces and no
51
- * write takes, so the element's text arrives as text. A `props` that is not the JSON
52
- * written above reads as `null`, and one of the wrong shape is `undefined` at its
53
- * reader (§`tablePropsOfNode`): the entry survives as an island of its type rather
54
- * than taking the paste down with it. */
61
+ * type is one no leaf edits — a name the vocabulary does not hold, or a footnote or an
62
+ * equation, which no gesture mints — so the element's text arrives as text. A `props`
63
+ * that is not the JSON written above reads as `null`, and one of the wrong shape is
64
+ * `undefined` at its reader (§`tablePropsOfNode`): the entry survives as an island of
65
+ * its type rather than taking the paste down with it. */
55
66
  function islandAttrsFromDOM(el) {
56
67
  const id = el.getAttribute('data-qm-island-id');
57
68
  // Asserted off an untrusted attribute and checked against the set on the next line.
58
69
  const type = el.getAttribute('data-qm-island');
59
- if (id == null || type == null || !Object.hasOwn(BLOCK_ISLAND, type))
70
+ if (id == null || type == null || !Object.hasOwn(ISLANDS, type) || !isEditedIsland(type))
60
71
  return false;
61
72
  const raw = el.getAttribute('data-qm-island-props');
62
73
  let props = null;
@@ -86,7 +97,8 @@ export const islandBlockSpec = {
86
97
  parseDOM: [{ tag: 'div[data-qm-island]', getAttrs: (el) => islandAttrsFromDOM(el) }],
87
98
  toDOM: (node) => ['div', islandDOM(node), `[${node.attrs.islandType || 'island'}]`]
88
99
  };
89
- /** Inline island node (an image): a `U+FFFC` slot inside a `para` line. */
100
+ /** Inline island node (an image, a footnote, an equation): a `U+FFFC` slot inside a
101
+ * `para` line. */
90
102
  export const islandInlineSpec = {
91
103
  group: 'inline',
92
104
  inline: true,
@@ -94,8 +106,23 @@ export const islandInlineSpec = {
94
106
  selectable: true,
95
107
  attrs: islandAttrs,
96
108
  parseDOM: [{ tag: 'span[data-qm-island]', getAttrs: (el) => islandAttrsFromDOM(el) }],
97
- toDOM: (node) => ['span', islandDOM(node), `[${node.attrs.islandType || 'island'}]`]
109
+ toDOM: inlineIslandDOM
98
110
  };
111
+ /** An inline island's DOM: a footnote its reference mark, whose number `prose.css` draws
112
+ * in reading order and whose note is its title, an equation its TeX between the dollars
113
+ * that write it, and an image its tag. */
114
+ function inlineIslandDOM(node) {
115
+ const attrs = node.attrs;
116
+ const dom = islandDOM(node);
117
+ const props = attrs.props;
118
+ if (attrs.islandType === 'footnote')
119
+ return ['sup', typeof props?.text === 'string' ? { ...dom, title: props.text } : dom];
120
+ if (attrs.islandType === 'math' && typeof props?.tex === 'string') {
121
+ const fence = props.display ? '$$' : '$';
122
+ return ['span', dom, `${fence}${props.tex}${fence}`];
123
+ }
124
+ return ['span', dom, `[${attrs.islandType || 'island'}]`];
125
+ }
99
126
  /** Build a content island entry from a PM island node's attrs (block or inline). The
100
127
  * cast pairs a closed `type` with a `props` that crossed the DOM as opaque JSON. The
101
128
  * store is lenient about a table's shape — it fills what it can and keeps the rest —
@@ -2,8 +2,9 @@ import { Plugin } from 'prosemirror-state';
2
2
  /**
3
3
  * A block leaf's markdown paste, consulted after the table paste: ProseMirror hands text
4
4
  * over only where the clipboard holds no HTML, whose DOM parse reads the source's own
5
- * structure, so HTML wins. A paste as plain text keeps the text. An island the parse
6
- * builds arrives under the id `importMarkdown` minted, which the paste's island pass
7
- * re-mints where the field holds it (`islands.ts`).
5
+ * structure, so HTML wins. A paste as plain text keeps the text, and so does a parse
6
+ * building a construct no leaf edits (`fitsLeaf`). An island the parse builds arrives
7
+ * under the id `importMarkdown` minted, which the paste's island pass re-mints where the
8
+ * field holds it (`islands.ts`).
8
9
  */
9
10
  export declare function markdownPastePlugin(): Plugin;
@@ -7,7 +7,7 @@
7
7
  import { Slice } from 'prosemirror-model';
8
8
  import { Plugin } from 'prosemirror-state';
9
9
  import { core } from '../lifecycle.js';
10
- import { decode } from './decode.js';
10
+ import { decode, fitsLeaf } from './decode.js';
11
11
  import { blockSchema } from './schema.js';
12
12
  /** A line opening a block markdown spells and prose does not: a bullet or an ordered item at
13
13
  * any indent, and within CommonMark's three spaces an ATX heading, a quote, a fence, or a
@@ -21,9 +21,10 @@ const BLOCK_HEAD = new RegExp([
21
21
  /**
22
22
  * A block leaf's markdown paste, consulted after the table paste: ProseMirror hands text
23
23
  * over only where the clipboard holds no HTML, whose DOM parse reads the source's own
24
- * structure, so HTML wins. A paste as plain text keeps the text. An island the parse
25
- * builds arrives under the id `importMarkdown` minted, which the paste's island pass
26
- * re-mints where the field holds it (`islands.ts`).
24
+ * structure, so HTML wins. A paste as plain text keeps the text, and so does a parse
25
+ * building a construct no leaf edits (`fitsLeaf`). An island the parse builds arrives
26
+ * under the id `importMarkdown` minted, which the paste's island pass re-mints where the
27
+ * field holds it (`islands.ts`).
27
28
  */
28
29
  export function markdownPastePlugin() {
29
30
  return new Plugin({
@@ -42,6 +43,8 @@ export function markdownPastePlugin() {
42
43
  // Nesting past the store's depth is refused; the literal text holds it.
43
44
  return null;
44
45
  }
46
+ if (!fitsLeaf(rt, { inline: false, plaintext: false, islands: true }))
47
+ return null;
45
48
  return new Slice(decode(rt, blockSchema).content, 0, 0);
46
49
  })
47
50
  }
@@ -162,6 +162,21 @@ li.ProseMirror-selectednode::after {
162
162
  pointer-events: none;
163
163
  }
164
164
 
165
+ /* A footnote and an equation are read, not edited (CODEC §Decode), and draw as what
166
+ their markdown writes: a footnote its reference mark, numbered in reading order off a
167
+ counter each leaf resets, its note the marker's title (`islands.ts`); an equation its
168
+ TeX between its dollars, in the code face. */
169
+ .ProseMirror {
170
+ counter-reset: qm-footnote;
171
+ }
172
+ .ProseMirror sup[data-qm-island='footnote']::after {
173
+ counter-increment: qm-footnote;
174
+ content: counter(qm-footnote);
175
+ }
176
+ .ProseMirror [data-qm-island='math'] {
177
+ font-family: var(--_qm-font-mono);
178
+ }
179
+
165
180
  /* A link takes the surface's ink (the UA's blue is the `#8cf` defect again) and a
166
181
  dotted underline. Dotted rather than solid because `underline` is a mark in the
167
182
  same schema, and a solid rule renders an authored `<u>` and a link identically in a
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // `@quillmark/svelte` root: the framework-free substrate.
1
+ // `@quillmark/svelte` root: the substrate, free of any UI framework.
2
2
  //
3
3
  // The headline surfaces mount from their own subpaths (`@quillmark/svelte/preview`,
4
4
  // `/visual`) so a bundler pulls only what an entry reaches; the root
@@ -74,6 +74,7 @@
74
74
  literalText,
75
75
  rowSummary,
76
76
  schemaAt,
77
+ textCap,
77
78
  type ArrayLayout
78
79
  } from './structure.js';
79
80
  import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
@@ -800,6 +801,7 @@
800
801
  content={() => readAt([k])}
801
802
  value={arr[k]}
802
803
  plaintext={items != null && literalText(items)}
804
+ cap={textCap(items)}
803
805
  label={rowName(k)}
804
806
  onChange={(rt) => commitElement(k, rt)}
805
807
  onKey={(e) => onElementKey(e, k)}
@@ -1,5 +1,6 @@
1
1
  <!--
2
- One card block, its fields grouped by `ui.group`/`ui.compact`. `main` is headerless
2
+ One card block, its top-level objects (or legacy `ui.group`s) drawn as panels and its
3
+ fields packed by `ui.compact`. `main` is headerless
3
4
  and takes no controls. Every prose leaf takes a parent-built live address, so a card
4
5
  reorder re-targets its commits without a remount.
5
6
  -->
@@ -273,9 +274,9 @@
273
274
  {@const headIds = section.head ? ops.domIds(section.head.name) : undefined}
274
275
  {@const headHint = section.head?.description}
275
276
  <div class={['qm-group', { 'qm-open': isOpen }]}>
276
- <!-- A flat field alone in its section is named by the header, which
277
- takes its label id and parks its description: the field draws no
278
- label of its own to say the section's name twice. -->
277
+ <!-- A panel's object, or an object alone in its section, is named by the
278
+ header, which takes its label id and parks its description: the field
279
+ draws no label of its own to say the section's name twice. -->
279
280
  <div class="qm-group-head">
280
281
  <button
281
282
  type="button"
@@ -33,13 +33,13 @@
33
33
  VARIANT_DISCRIMINANT,
34
34
  arrayLayout,
35
35
  baseType,
36
- flatObject,
37
36
  ghostDefault,
38
37
  isContainer,
39
38
  optionalCell,
40
39
  printedText,
41
40
  renderedBoolean,
42
41
  stringifyGhost,
42
+ textCap,
43
43
  titleText
44
44
  } from './structure.js';
45
45
  import { wording } from './strings.js';
@@ -163,9 +163,18 @@
163
163
  // reference must vanish with it: `aria-describedby` pointing at nothing describes
164
164
  // nothing, and silently.
165
165
  const describedBy = $derived(field.description ? domIds.description : undefined);
166
+ // A capped prose leaf reports its count, and the count describes it too: a keystroke
167
+ // the cap refuses changes nothing else a reader can perceive.
168
+ let chars = $state<number>();
169
+ const cap = $derived(field.control === 'prose' ? textCap(field.schema) : undefined);
170
+ const countId = $derived(`${domIds.control}-count`);
171
+ const proseDescribedBy = $derived(
172
+ cap ? [describedBy, countId].filter(Boolean).join(' ') : describedBy
173
+ );
166
174
  // Two controls own their label row, the add chip and the held count sharing it.
167
175
  const ownsLabel = $derived(field.control === 'array' || field.control === 'matrix');
168
- const flat = $derived(flatObject(field.schema));
176
+ // A headed object is a panel's, and the panel is its frame.
177
+ const flat = $derived(headed);
169
178
 
170
179
  // A container walks its diagnostics down to the cell each names and draws the rest
171
180
  // at its own foot (`diagnostics.ts`); every other control draws them here, under
@@ -285,6 +294,8 @@
285
294
  descriptionId={domIds.description}
286
295
  {onActivate}
287
296
  description={field.description}
297
+ count={cap && chars != null ? t.strings.textCount(chars, cap.max) : undefined}
298
+ {countId}
288
299
  />
289
300
  {/if}
290
301
  <!-- The control and what hangs under it, in one grid cell: the diagnostics stack
@@ -305,7 +316,8 @@
305
316
  placeholder={none}
306
317
  emptyUnsets={optional}
307
318
  labelledBy={domIds.label}
308
- {describedBy}
319
+ describedBy={proseDescribedBy}
320
+ cap={cap && { ...cap, onCount: (n) => (chars = n) }}
309
321
  {leafKey}
310
322
  {onFocus}
311
323
  {onCaretMove}
@@ -40,8 +40,13 @@
40
40
  onActivate?: () => void;
41
41
  /** Schema `description`, or undefined: the affordance renders only when set. */
42
42
  description?: string;
43
+ /** A capped field's character count, drawn at the row's end. */
44
+ count?: string;
45
+ /** The count's own id, for the control's `aria-describedby`. */
46
+ countId?: string;
43
47
  }
44
- let { label, controlId, id, descriptionId, onActivate, description }: Props = $props();
48
+ let { label, controlId, id, descriptionId, onActivate, description, count, countId }: Props =
49
+ $props();
45
50
  </script>
46
51
 
47
52
  <div class="qm-field-label-row">
@@ -68,6 +73,9 @@
68
73
  <FieldHint {description} {label} describedBy={descriptionId} />
69
74
  <span class="qm-visually-hidden" id={descriptionId}>{description}</span>
70
75
  {/if}
76
+ {#if count != null}
77
+ <span class="qm-field-count" id={countId}>{count}</span>
78
+ {/if}
71
79
  </div>
72
80
 
73
81
  <style>
@@ -91,6 +99,12 @@
91
99
  color: var(--_qm-ink-label);
92
100
  min-width: 0;
93
101
  }
102
+ .qm-field-count {
103
+ margin-inline-start: auto;
104
+ font-size: var(--_qm-text-label);
105
+ color: var(--_qm-ink-label);
106
+ font-variant-numeric: tabular-nums;
107
+ }
94
108
  /* A label that lands focus says so. Only when it has somewhere to land: an
95
109
  `ObjectField` property's label names a subform, not one control. */
96
110
  .qm-field-label.targeted {
@@ -11,6 +11,10 @@ interface Props {
11
11
  onActivate?: () => void;
12
12
  /** Schema `description`, or undefined: the affordance renders only when set. */
13
13
  description?: string;
14
+ /** A capped field's character count, drawn at the row's end. */
15
+ count?: string;
16
+ /** The count's own id, for the control's `aria-describedby`. */
17
+ countId?: string;
14
18
  }
15
19
  declare const FieldLabel: import("svelte").Component<Props, {}, "">;
16
20
  type FieldLabel = ReturnType<typeof FieldLabel>;
@@ -30,9 +30,8 @@
30
30
  member's id. So a cell reads at whatever depth it sits, which nothing here knows.
31
31
 
32
32
  `bare` is the table's row: the same cells with no labels and no figure of their own,
33
- laid onto the tracks the table's header names ({@link ArrayField}). `flat` is
34
- `ui.layout: flat`: the cells keep their labels and lose the figure, standing at the
35
- depth of whatever holds the subform.
33
+ laid onto the tracks the table's header names ({@link ArrayField}). `flat` is a
34
+ panel's object: the cells keep their labels and lose the figure, the panel being it.
36
35
  -->
37
36
  <script lang="ts">
38
37
  import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
@@ -42,7 +41,6 @@
42
41
  controlKind,
43
42
  declaredContent,
44
43
  fieldModels,
45
- flatObject,
46
44
  humanize,
47
45
  isContainer,
48
46
  literalText,
@@ -51,7 +49,8 @@
51
49
  placeFields,
52
50
  printedText,
53
51
  renderedBoolean,
54
- shortCell
52
+ shortCell,
53
+ textCap
55
54
  } from './structure.js';
56
55
  import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
57
56
  import type { LandingBox } from './leaves.js';
@@ -102,8 +101,8 @@
102
101
  /** A table's row: the cells alone, on the tracks the table's header names, with
103
102
  * no labels and no vertical of their own. */
104
103
  bare?: boolean;
105
- /** `ui.layout: flat`: no vertical and no rung, the properties at the depth of the
106
- * list the subform stands in. */
104
+ /** A panel's object: no vertical and no rung, the properties at the depth of the
105
+ * panel's field list. */
107
106
  flat?: boolean;
108
107
  /** A card-level object, or one nested in it: its properties are placed as a
109
108
  * card's fields are (`placeFields`), so moving a field into a group leaves its row
@@ -136,6 +135,8 @@
136
135
  }: Props = $props();
137
136
 
138
137
  const t = wording();
138
+ /** A capped prose cell's character count, by property. */
139
+ const counts: Record<string, number> = $state({});
139
140
  const entries = $derived(Object.entries(properties ?? {}));
140
141
  const obj = $derived((value ?? {}) as Record<string, unknown>);
141
142
  const has = (key: string): boolean => Object.hasOwn(properties ?? {}, key);
@@ -298,6 +299,8 @@
298
299
  (`packable`). A block prose cell spans it too, and keeps its label row, as
299
300
  does a placed cell `placeFields` gives the whole row. -->
300
301
  {@const block = kind === 'prose' && !shortCell(sub)}
302
+ {@const cap = kind === 'prose' ? textCap(sub) : undefined}
303
+ {@const countId = cap && ids ? `${ids.control}-count` : undefined}
301
304
  <div
302
305
  class={[
303
306
  'qm-object-prop',
@@ -316,6 +319,10 @@
316
319
  descriptionId={describes}
317
320
  onActivate={labelable ? undefined : () => focusProp(key)}
318
321
  description={sub.description}
322
+ count={cap && counts[key] != null
323
+ ? t.strings.textCount(counts[key], cap.max)
324
+ : undefined}
325
+ {countId}
319
326
  />
320
327
  {/if}
321
328
  {#if kind === 'enum'}
@@ -385,7 +392,8 @@
385
392
  {block}
386
393
  label={named}
387
394
  labelledBy={ids?.label}
388
- describedBy={describes}
395
+ describedBy={countId ? [describes, countId].filter(Boolean).join(' ') : describes}
396
+ cap={cap && { ...cap, onCount: (n) => (counts[key] = n) }}
389
397
  onChange={(rt) =>
390
398
  commitProp(key, optionalCell(sub) && isEmptyContent(rt) ? undefined : rt)}
391
399
  onKey={onCellKey ? (e) => onCellKey(e, key) : undefined}
@@ -416,7 +424,6 @@
416
424
  bind:this={nestedEls[key]}
417
425
  value={obj[key] as Record<string, unknown> | undefined}
418
426
  properties={sub.properties}
419
- flat={flatObject(sub)}
420
427
  {placed}
421
428
  label={ids ? undefined : fallbackName(key, sub)}
422
429
  idBase={ids?.control}
@@ -32,8 +32,8 @@ interface Props {
32
32
  /** A table's row: the cells alone, on the tracks the table's header names, with
33
33
  * no labels and no vertical of their own. */
34
34
  bare?: boolean;
35
- /** `ui.layout: flat`: no vertical and no rung, the properties at the depth of the
36
- * list the subform stands in. */
35
+ /** A panel's object: no vertical and no rung, the properties at the depth of the
36
+ * panel's field list. */
37
37
  flat?: boolean;
38
38
  /** A card-level object, or one nested in it: its properties are placed as a
39
39
  * card's fields are (`placeFields`), so moving a field into a group leaves its row
@@ -6,7 +6,12 @@
6
6
  -->
7
7
  <script lang="ts">
8
8
  import { untrack } from 'svelte';
9
- import { createField, type FieldController, type SlashState } from '../core/codec/index.js';
9
+ import {
10
+ createField,
11
+ type FieldController,
12
+ type SlashState,
13
+ type TextCap
14
+ } from '../core/codec/index.js';
10
15
  import type { LeafRegistry } from './leaves.js';
11
16
  import SlashMenu from './SlashMenu.svelte';
12
17
  import { wording } from './strings.js';
@@ -48,6 +53,8 @@
48
53
  placeholderUntilEdit?: boolean;
49
54
  /** An emptied leaf removes the field (`createField`). */
50
55
  emptyUnsets?: boolean;
56
+ /** The declared `max:`, a character count no edit takes the leaf past. */
57
+ cap?: TextCap;
51
58
  /** Registry identity, stamped on the DOM node so a remount is visible as one. */
52
59
  leafKey: string;
53
60
  onFocus?: (addr: Addr) => void;
@@ -77,6 +84,7 @@
77
84
  placeholder,
78
85
  placeholderUntilEdit,
79
86
  emptyUnsets,
87
+ cap,
80
88
  leafKey,
81
89
  onFocus,
82
90
  onCaretMove,
@@ -127,6 +135,7 @@
127
135
  placeholder,
128
136
  placeholderUntilEdit,
129
137
  emptyUnsets,
138
+ cap,
130
139
  tableStrings: () => t.strings,
131
140
  slashStrings: () => t.strings,
132
141
  onSlash: (next) => {
@@ -1,3 +1,4 @@
1
+ import { type TextCap } from '../core/codec/index.js';
1
2
  import type { LeafRegistry } from './leaves.js';
2
3
  import type { Document, Quill, Addr, Content, Diagnostic } from '@quillmark/wasm';
3
4
  import type { EditorErrorHandler } from '../core/errors.js';
@@ -35,6 +36,8 @@ interface Props {
35
36
  placeholderUntilEdit?: boolean;
36
37
  /** An emptied leaf removes the field (`createField`). */
37
38
  emptyUnsets?: boolean;
39
+ /** The declared `max:`, a character count no edit takes the leaf past. */
40
+ cap?: TextCap;
38
41
  /** Registry identity, stamped on the DOM node so a remount is visible as one. */
39
42
  leafKey: string;
40
43
  onFocus?: (addr: Addr) => void;
@@ -60,7 +60,8 @@
60
60
  pmToUsv,
61
61
  usvToPM,
62
62
  valueEqual,
63
- type LeafViews
63
+ type LeafViews,
64
+ type TextCap
64
65
  } from '../core/codec/index.js';
65
66
  import { valueLeafSeat } from './leaves.js';
66
67
  import { wording } from './strings.js';
@@ -99,6 +100,8 @@
99
100
  /** The empty leaf's ghost, a subform cell's: the `none` an optional cell prints
100
101
  * unset. Read per decoration pass. */
101
102
  placeholder?: string;
103
+ /** The declared `max:`, a character count no edit takes the leaf past. */
104
+ cap?: TextCap;
102
105
  onChange: (rt: Content) => void;
103
106
  /** Raw keydown, for a container whose own keys run through this leaf: the
104
107
  * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
@@ -115,6 +118,7 @@
115
118
  labelledBy,
116
119
  describedBy,
117
120
  placeholder,
121
+ cap,
118
122
  onChange,
119
123
  onKey
120
124
  }: Props = $props();
@@ -190,7 +194,8 @@
190
194
  ? []
191
195
  : proseLeafPlugins(schema, {
192
196
  inline,
193
- placeholder: () => placeholder
197
+ placeholder: () => placeholder,
198
+ cap
194
199
  })
195
200
  });
196
201
  };
@@ -1,3 +1,4 @@
1
+ import { type TextCap } from '../core/codec/index.js';
1
2
  import type { Content } from '@quillmark/wasm';
2
3
  interface Props {
3
4
  /** This leaf's content (the parent's boundary read), `undefined` where nothing is
@@ -31,6 +32,8 @@ interface Props {
31
32
  /** The empty leaf's ghost, a subform cell's: the `none` an optional cell prints
32
33
  * unset. Read per decoration pass. */
33
34
  placeholder?: string;
35
+ /** The declared `max:`, a character count no edit takes the leaf past. */
36
+ cap?: TextCap;
34
37
  onChange: (rt: Content) => void;
35
38
  /** Raw keydown, for a container whose own keys run through this leaf: the
36
39
  * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
@@ -54,10 +54,7 @@
54
54
  isContainer,
55
55
  shortCell,
56
56
  schemaAt,
57
- fieldModels,
58
- groupOrder,
59
- groupSections,
60
- groupLabel,
57
+ cardSections,
61
58
  cardTitle,
62
59
  kindTitle,
63
60
  fieldValues,
@@ -325,9 +322,9 @@
325
322
  try {
326
323
  if (value === undefined) {
327
324
  // The unset rung (VISUAL_EDITOR §"The commitment ladder"): removing the field
328
- // leaves the engine's authored › `default:` › blank-fill resolve to render the
325
+ // leaves Quillmark's authored › `default:` › blank-fill resolve to render the
329
326
  // default, where writing one bakes a snapshot the schema cannot track (canon
330
- // SCHEMAS.md: the engine never writes a default; the editor writes one only
327
+ // SCHEMAS.md: Quillmark never writes a default; the editor writes one only
331
328
  // as the edit that takes it). Removal writes no value, so there is
332
329
  // nothing for a schema to conform and the lane is the quill-free one.
333
330
  if (isMain) {
@@ -632,14 +629,11 @@
632
629
  payloadItems: readonly PayloadItem[];
633
630
  ext?: Record<string, unknown>;
634
631
  },
635
- cardSchema: Parameters<typeof fieldModels>[0] | undefined,
632
+ cardSchema: Parameters<typeof cardSections>[0] | undefined,
636
633
  rows: ResolvedCardRows
637
634
  ): CardModel {
638
635
  const values = fieldValues(card.payloadItems);
639
- const fields = cardSchema ? fieldModels(cardSchema) : [];
640
- const sections = cardSchema
641
- ? groupSections(fields, groupOrder(cardSchema), (g) => groupLabel(cardSchema, g))
642
- : [];
636
+ const sections = cardSchema ? cardSections(cardSchema) : [];
643
637
  const extEditor = card.ext?.editor as { title?: string } | undefined;
644
638
  const hasBody = bodyEnabled(cardSchema);
645
639
  return {
@@ -35,6 +35,12 @@ export interface VisualStrings extends TableChromeStrings, SlashStrings {
35
35
  * spells the separator.
36
36
  */
37
37
  arrayCount: (count: number, max: number) => string;
38
+ /**
39
+ * The count beside the label of a `text` or `richtext` field declaring `max:`, the
40
+ * carrier of a keystroke the cap refuses: `12 / 40`. Parametric, so a translator
41
+ * spells the separator.
42
+ */
43
+ textCount: (count: number, max: number) => string;
38
44
  /**
39
45
  * A collapsed array element with nothing to summarize itself by: the label and the
40
46
  * 1-based index, which is what its accessible name already says. Parametric, so
@@ -116,7 +122,7 @@ export interface VisualStrings extends TableChromeStrings, SlashStrings {
116
122
  *
117
123
  * - **`validation::enum_violation`, `type_mismatch`, `format_violation`**: the
118
124
  * constraint re-words from the quill's schema at `path`, the offending value from
119
- * `args` (`value` / `sourceToken` / `actual`, the engine testifying to what it
125
+ * `args` (`value` / `sourceToken` / `actual`, Quillmark testifying to what it
120
126
  * saw) and never from the document at `path`: validation runs post-coercion, so
121
127
  * the validator read a value the document does not hold and a sentence built from
122
128
  * `path` names a spelling the user never typed.
@@ -124,7 +130,7 @@ export interface VisualStrings extends TableChromeStrings, SlashStrings {
124
130
  * refused value being in neither document (unchanged on throw) nor schema.
125
131
  * - **`parse::yaml_error_with_location`, `invalid_structure`**: does not re-word. No
126
132
  * `path`; `args` names the block (`blockIndex`) and `location` places the failure in
127
- * the document's own coordinates, engine prose riding under no key: the parser's own
133
+ * the document's own coordinates, Quillmark prose riding under no key: the parser's own
128
134
  * text, its column and its caret snippet exist only inside `message`.
129
135
  * - **`LiveSession.warnings`**: does not re-word. Backend text, an external feed.
130
136
  *
@@ -41,6 +41,7 @@ export const DEFAULT_VISUAL_STRINGS = {
41
41
  arrayDrag: 'Drag to move',
42
42
  arrayRowAction: (action, row) => `${action} ${row}`,
43
43
  arrayCount: (count, max) => `${count} / ${max}`,
44
+ textCount: (count, max) => `${count} / ${max}`,
44
45
  elementUntitled: (label, index) => `${label} ${index}`,
45
46
  enumUnsetTag: 'Default',
46
47
  enumUnsetTagNone: 'Unset',
@@ -1,4 +1,5 @@
1
1
  import { type Content, type PathStep, type PayloadItem, type QuillCardSchema, type QuillFieldSchema, type QuillFieldType, type ResolvedField, type Resolved } from '@quillmark/wasm';
2
+ import type { TextCap } from '../core/codec/index.js';
2
3
  /** The control a field type maps to (VISUAL_EDITOR §"Structure mirrors the schema"). */
3
4
  export type ControlKind = 'prose' | 'text' | 'enum' | 'variant' | 'number' | 'boolean' | 'date' | 'array' | 'object' | 'matrix';
4
5
  /** The container controls: the ones a nested address walks into and a deep
@@ -25,18 +26,17 @@ export interface FieldModel {
25
26
  inline: boolean;
26
27
  plaintext: boolean;
27
28
  }
28
- /** A `ui.group` section: its label and the fields (declaration order) inside it. */
29
+ /** A panel section, a top-level `object`'s or a legacy `ui.group`'s: its label and the
30
+ * fields (declaration order) inside it. */
29
31
  export interface GroupSection {
30
32
  group: string | undefined;
31
33
  label: string;
32
34
  fields: FieldModel[];
33
- /** The field the section's header names in place of a label of its own: the one
34
- * field of a group section, where that field is {@link flatObject}. */
35
+ /** The object the section's header names in place of a label of its own: the
36
+ * object a panel is, or a group section's one field where that field is an
37
+ * `object`. Its properties draw flat, the panel being their frame. */
35
38
  head?: FieldModel;
36
39
  }
37
- /** `ui.layout: flat` on a typed dictionary: no frame of its own, its properties
38
- * standing at the depth of the fields around it rather than a rung in. */
39
- export declare function flatObject(schema: QuillFieldSchema): boolean;
40
40
  /**
41
41
  * One card instance projected for rendering: the schema × payload join result
42
42
  * the VisualEditor's `$derived` produces and hands to `<Card>`. Positional
@@ -111,7 +111,7 @@ export declare function resolvedByCardIndex(resolved: Resolved | undefined): Map
111
111
  export declare function ghostDefault(row: ResolvedField | undefined): unknown;
112
112
  /** A stored `boolean` cell as the render reads it: a boolean, `true` or `false` in any
113
113
  * case, or a number by whether it is non-zero; undefined where the render refuses the
114
- * value. The engine's coercion, restated, so the suite holds it to `reader.resolve()`. */
114
+ * value. Quillmark's coercion, restated, so the suite holds it to `reader.resolve()`. */
115
115
  export declare function renderedBoolean(value: unknown): boolean | undefined;
116
116
  /** A ghost value's string form, or undefined for null/object (only text ghosts
117
117
  * render a placeholder). A scalar's resolved default reads through it; a content
@@ -161,6 +161,8 @@ export declare function baseType(f: QuillFieldSchema): QuillFieldType;
161
161
  export declare function oneLine(f: QuillFieldSchema): boolean;
162
162
  /** Whether a content field's codec is the literal one, its value text with no marks: `text`. */
163
163
  export declare function literalText(f: QuillFieldSchema): boolean;
164
+ /** The character cap a content field's `max:` declares, `undefined` on any other. */
165
+ export declare function textCap(f: QuillFieldSchema | undefined): TextCap | undefined;
164
166
  /**
165
167
  * A scalar `default:` as the text an unset control holds, where it prints: as
166
168
  * declared, `undefined` for none, a blank or a non-scalar. A default of spaces prints
@@ -175,9 +177,10 @@ export declare function printedText(v: unknown): string | undefined;
175
177
  * a blank or a non-scalar.
176
178
  */
177
179
  export declare function declaredContent(v: unknown, markdown: boolean): Content | undefined;
178
- /** A field's `{#each}` key: its name and what a mounted leaf reads once, its type and
179
- * {@link oneLine} and its elements'. A retype or a quill swap that changes any of them
180
- * under a name remounts the field, where a kept leaf would commit on the old schema. */
180
+ /** A field's `{#each}` key: its name and what a mounted leaf reads once, its type,
181
+ * {@link oneLine} and `max:`, and its elements'. A retype or a quill swap that changes
182
+ * any of them under a name remounts the field, where a kept leaf would commit on the old
183
+ * schema. */
181
184
  export declare function mountKey(name: string, f: QuillFieldSchema): string;
182
185
  /** Map a field schema to its control. An array's element control is this over `items`;
183
186
  * a type this build does not know takes the text input. */
@@ -203,9 +206,9 @@ export declare function matrixMembers(roster: Record<string, string> | undefined
203
206
  export declare function memberValue(map: Record<string, unknown> | undefined, id: string): unknown;
204
207
  /**
205
208
  * Whether a stored member is held: by being present. A mapping is held whatever it
206
- * carries, `{}` included, and a bare scalar is the tick itself, read as the engine's
209
+ * carries, `{}` included, and a bare scalar is the tick itself, read as Quillmark's
207
210
  * coercion reads it: `false`, `0`, `"false"` and `null` unheld, every other present
208
- * scalar held, so a spelling the engine would refuse still draws the tick it claims.
211
+ * scalar held, so a spelling Quillmark would refuse still draws the tick it claims.
209
212
  */
210
213
  export declare function matrixHeld(stored: unknown): boolean;
211
214
  /** A member's columns: its mapping without the reserved keys, `{}` for a bare tick or an
@@ -241,7 +244,7 @@ export declare function matrixDeclares(matrix: QuillFieldSchema, id: string): bo
241
244
  /**
242
245
  * The added items a stored map holds, sorted by id as the plate orders them: on an
243
246
  * open matrix, each key off the roster spelled as an id whose mapping carries a
244
- * `title`. Any other key off the roster is the engine's refusal, drawn nowhere and
247
+ * `title`. Any other key off the roster is Quillmark's refusal, drawn nowhere and
245
248
  * ridden through.
246
249
  */
247
250
  export declare function addedItems(matrix: QuillFieldSchema, map: Record<string, unknown> | undefined): MatrixMember[];
@@ -325,6 +328,19 @@ export declare function groupLabel(cardSchema: QuillCardSchema, group: string):
325
328
  * the card schema, which falls back to {@link humanize} itself.
326
329
  */
327
330
  export declare function groupSections(fields: FieldModel[], order: string[], labelFor: (group: string) => string): GroupSection[];
331
+ /**
332
+ * Object-panel sections (VISUAL_EDITOR §"Structure mirrors the schema"): every
333
+ * top-level `object` is a panel of its own, headed by the object, in declaration
334
+ * order; everything else stands in one unframed section above them. The section id
335
+ * is the field name, which a card's keys hold unique.
336
+ */
337
+ export declare function objectSections(fields: FieldModel[]): GroupSection[];
338
+ /**
339
+ * A card's sections: its ungrouped fields' {@link objectSections}, then each legacy
340
+ * `ui.group` a panel in registry order, the order the loader settles the fields in.
341
+ * A panel named as a group is keyed off the group's id space, which `.` never joins.
342
+ */
343
+ export declare function cardSections(cardSchema: QuillCardSchema): GroupSection[];
328
344
  /**
329
345
  * The group section a card's accordion opens on first mount: the first in
330
346
  * order, or `null` when the card declares none. Ungrouped fields render outside
@@ -11,11 +11,6 @@ import { core } from '../core/lifecycle.js';
11
11
  export function isContainer(kind) {
12
12
  return kind === 'array' || kind === 'object' || kind === 'variant' || kind === 'matrix';
13
13
  }
14
- /** `ui.layout: flat` on a typed dictionary: no frame of its own, its properties
15
- * standing at the depth of the fields around it rather than a rung in. */
16
- export function flatObject(schema) {
17
- return controlKind(schema) === 'object' && schema.ui?.layout === 'flat';
18
- }
19
14
  /**
20
15
  * A card's resolved rows keyed by field name: the provenance channel parallel to
21
16
  * {@link CardModel.values}. `resolve` returns rows in declaration order; the
@@ -47,7 +42,7 @@ export function ghostDefault(row) {
47
42
  }
48
43
  /** A stored `boolean` cell as the render reads it: a boolean, `true` or `false` in any
49
44
  * case, or a number by whether it is non-zero; undefined where the render refuses the
50
- * value. The engine's coercion, restated, so the suite holds it to `reader.resolve()`. */
45
+ * value. Quillmark's coercion, restated, so the suite holds it to `reader.resolve()`. */
51
46
  export function renderedBoolean(value) {
52
47
  if (typeof value === 'boolean')
53
48
  return value;
@@ -93,6 +88,12 @@ export function oneLine(f) {
93
88
  export function literalText(f) {
94
89
  return baseType(f) === 'text';
95
90
  }
91
+ /** The character cap a content field's `max:` declares, `undefined` on any other. */
92
+ export function textCap(f) {
93
+ if (f?.max == null || controlKind(f) !== 'prose')
94
+ return undefined;
95
+ return { max: f.max, trimEnd: literalText(f) && oneLine(f) };
96
+ }
96
97
  /**
97
98
  * A scalar `default:` as the text an unset control holds, where it prints: as
98
99
  * declared, `undefined` for none, a blank or a non-scalar. A default of spaces prints
@@ -126,12 +127,21 @@ export function declaredContent(v, markdown) {
126
127
  islands: []
127
128
  };
128
129
  }
129
- /** A field's `{#each}` key: its name and what a mounted leaf reads once, its type and
130
- * {@link oneLine} and its elements'. A retype or a quill swap that changes any of them
131
- * under a name remounts the field, where a kept leaf would commit on the old schema. */
130
+ /** A field's `{#each}` key: its name and what a mounted leaf reads once, its type,
131
+ * {@link oneLine} and `max:`, and its elements'. A retype or a quill swap that changes
132
+ * any of them under a name remounts the field, where a kept leaf would commit on the old
133
+ * schema. */
132
134
  export function mountKey(name, f) {
133
135
  const items = f.items;
134
- return `${name}\u0000${f.type}\u0000${oneLine(f)}\u0000${items?.type ?? ''}\u0000${items ? oneLine(items) : ''}`;
136
+ return [
137
+ name,
138
+ f.type,
139
+ oneLine(f),
140
+ f.max ?? '',
141
+ items?.type ?? '',
142
+ items ? oneLine(items) : '',
143
+ items?.max ?? ''
144
+ ].join('\u0000');
135
145
  }
136
146
  /** Map a field schema to its control. An array's element control is this over `items`;
137
147
  * a type this build does not know takes the text input. */
@@ -182,16 +192,16 @@ export function memberValue(map, id) {
182
192
  }
183
193
  /**
184
194
  * Whether a stored member is held: by being present. A mapping is held whatever it
185
- * carries, `{}` included, and a bare scalar is the tick itself, read as the engine's
195
+ * carries, `{}` included, and a bare scalar is the tick itself, read as Quillmark's
186
196
  * coercion reads it: `false`, `0`, `"false"` and `null` unheld, every other present
187
- * scalar held, so a spelling the engine would refuse still draws the tick it claims.
197
+ * scalar held, so a spelling Quillmark would refuse still draws the tick it claims.
188
198
  */
189
199
  export function matrixHeld(stored) {
190
200
  if (typeof stored === 'object' && stored !== null)
191
201
  return true;
192
202
  return tickOf(stored);
193
203
  }
194
- /** A tick's scalar reading: absent and the engine's false spellings unheld, the rest held. */
204
+ /** A tick's scalar reading: absent and Quillmark's false spellings unheld, the rest held. */
195
205
  function tickOf(v) {
196
206
  if (v === undefined || v === null)
197
207
  return false;
@@ -265,7 +275,7 @@ export function matrixDeclares(matrix, id) {
265
275
  /**
266
276
  * The added items a stored map holds, sorted by id as the plate orders them: on an
267
277
  * open matrix, each key off the roster spelled as an id whose mapping carries a
268
- * `title`. Any other key off the roster is the engine's refusal, drawn nowhere and
278
+ * `title`. Any other key off the roster is Quillmark's refusal, drawn nowhere and
269
279
  * ridden through.
270
280
  */
271
281
  export function addedItems(matrix, map) {
@@ -511,7 +521,7 @@ export function groupSections(fields, order, labelFor) {
511
521
  for (const g of order) {
512
522
  const fs = fields.filter((f) => f.group === g);
513
523
  if (fs.length) {
514
- const head = fs.length === 1 && flatObject(fs[0].schema) ? fs[0] : undefined;
524
+ const head = fs.length === 1 && fs[0].control === 'object' ? fs[0] : undefined;
515
525
  sections.push({ group: g, label: labelFor(g), fields: fs, head });
516
526
  emitted.add(g);
517
527
  }
@@ -528,6 +538,35 @@ export function groupSections(fields, order, labelFor) {
528
538
  }
529
539
  return sections;
530
540
  }
541
+ /**
542
+ * Object-panel sections (VISUAL_EDITOR §"Structure mirrors the schema"): every
543
+ * top-level `object` is a panel of its own, headed by the object, in declaration
544
+ * order; everything else stands in one unframed section above them. The section id
545
+ * is the field name, which a card's keys hold unique.
546
+ */
547
+ export function objectSections(fields) {
548
+ const panel = (f) => f.control === 'object';
549
+ const loose = fields.filter((f) => !panel(f));
550
+ const sections = loose.length
551
+ ? [{ group: undefined, label: '', fields: loose }]
552
+ : [];
553
+ for (const f of fields)
554
+ if (panel(f))
555
+ sections.push({ group: f.name, label: f.label, fields: [f], head: f });
556
+ return sections;
557
+ }
558
+ /**
559
+ * A card's sections: its ungrouped fields' {@link objectSections}, then each legacy
560
+ * `ui.group` a panel in registry order, the order the loader settles the fields in.
561
+ * A panel named as a group is keyed off the group's id space, which `.` never joins.
562
+ */
563
+ export function cardSections(cardSchema) {
564
+ const fields = fieldModels(cardSchema);
565
+ const order = groupOrder(cardSchema);
566
+ const panels = objectSections(fields.filter((f) => f.group === undefined)).map((s) => s.head && order.includes(s.head.name) ? { ...s, group: `${s.head.name}.` } : s);
567
+ const grouped = fields.filter((f) => f.group !== undefined);
568
+ return [...panels, ...groupSections(grouped, order, (g) => groupLabel(cardSchema, g))];
569
+ }
531
570
  /**
532
571
  * The group section a card's accordion opens on first mount: the first in
533
572
  * order, or `null` when the card declares none. Ungrouped fields render outside
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/svelte",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Editor, live-preview and source surfaces over a Quillmark WASM session.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -69,7 +69,7 @@
69
69
  "check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings"
70
70
  },
71
71
  "peerDependencies": {
72
- "@quillmark/wasm": ">=0.126.0-0",
72
+ "@quillmark/wasm": ">=0.129.0-0",
73
73
  "svelte": "^5.33.0"
74
74
  },
75
75
  "dependencies": {