@quillmark/svelte 0.4.3 → 0.5.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
@@ -81,7 +81,7 @@ const preview = createPreview(session, {
81
81
  preview.refresh(session.update(doc)); // repaint dirtyPages ∩ visible
82
82
  ```
83
83
 
84
- In Svelte, `<Preview {session} onPick={…} />` exposes the same verbs (`refresh`, `scrollToField`, `focusPosition`, `setZoom`) via `bind:this`.
84
+ In Svelte, `<Preview {session} onPick={…} />` exposes the same verbs (`refresh`, `scrollToField`, `focusPosition`, `endFollow`, `setZoom`) via `bind:this`.
85
85
 
86
86
  `<Preview>` binds **once**, at mount: swapping `session`, `margin`, `onPick`, `onError` or `strings` in place changes nothing on screen and reports `rebind-ignored` through `onError` at `dev` severity, naming the prop. Swap by remounting (`{#key session}`); drive in-place edits through `refresh(change)`. `class` and `style` are the exceptions, landing on the root element and staying live.
87
87
 
@@ -196,10 +196,14 @@ The bridge lives at the **consumer** layer and is opt-in; the editor is unaware
196
196
  // preview → editor: a click resolved to an address lands in the field it names.
197
197
  onPick: (at) => visualEditor.setCaret(at);
198
198
 
199
- // editor → preview: a caret move scrolls the preview to follow it.
199
+ // editor → preview: a caret move scrolls the preview to follow it,
200
+ // and a focus into a leaf with no caret to report ends the follow.
200
201
  onCaretMove: preview.focusPosition;
202
+ onActiveLeafChange: preview.endFollow;
201
203
  ```
202
204
 
205
+ Both editor→preview hops or neither. A control reports its focus and no caret — there is no offset a date field could name — so without the second the preview keeps following the leaf the focus left, and every recompile pulls the pane back to it. A prose leaf restarts the follow with its next caret. A host that reads the active leaf for its own chrome calls `endFollow` from its handler.
206
+
203
207
  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
208
 
205
209
  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.
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` / `#ff9c94` | Error diagnostics, the required marker, the delete glyph. |
58
- | `--qm-warning` | `#b25000` / `#e0b44c` | Warning diagnostics. |
57
+ | `--qm-danger` | `#9e1518` / `#f9786c` | Error diagnostics, the required marker, the delete glyph. |
58
+ | `--qm-warning` | `#926510` / `#fdc776` | 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. |
@@ -132,7 +132,7 @@ export interface LeafViews {
132
132
  /** Every view nested inside this leaf, in mount order: one per table cell. */
133
133
  nestedViews(): EditorView[];
134
134
  }
135
- /** The canonical empty `Content`: one empty `para` line. The zero value a prose
135
+ /** The canonical empty `Content`: one empty `para` line. The empty a prose
136
136
  * leaf (or an array's prose element) seeds from. */
137
137
  export declare function emptyContent(): Content;
138
138
  export declare function createField(opts: CreateFieldOpts): FieldController;
@@ -70,7 +70,7 @@ function anchorPlugin(seed) {
70
70
  function readLeaf(reader, addr) {
71
71
  return reader.getContent(addr) ?? emptyContent();
72
72
  }
73
- /** The canonical empty `Content`: one empty `para` line. The zero value a prose
73
+ /** The canonical empty `Content`: one empty `para` line. The empty a prose
74
74
  * leaf (or an array's prose element) seeds from. */
75
75
  export function emptyContent() {
76
76
  return { text: '', lines: [{ containers: [], kind: 'para' }], marks: [], islands: [] };
@@ -133,9 +133,22 @@ export function createField(opts) {
133
133
  const slash = inline ? undefined : opts.onSlash;
134
134
  const state = buildState(reconciler.last);
135
135
  index = buildLineIndex(state.doc);
136
+ // The clearance PM's reveal keeps around the caret, in this leaf's own line box.
137
+ // Its default is 5px, which is a caret visible and unusable: the next line typed
138
+ // lands past the fold, the reason the preview's own scroll clears a target by the
139
+ // target's height (PREVIEW.md §"Follow-the-caret scroll"). Measured off the
140
+ // mounted box rather than restated from the rung, and dropped where the
141
+ // derivation is out of reach (jsdom), a fallback there being the scale written
142
+ // where `check:style` cannot read it.
143
+ const lineBox = Number.parseFloat(getComputedStyle(container).lineHeight);
144
+ const clearance = Number.isFinite(lineBox) ? lineBox : undefined;
136
145
  view = new EditorView(container, {
137
146
  state,
138
147
  attributes: proseAttributes(opts),
148
+ // Both terms, one line each: when a flagged dispatch moves the scrollport
149
+ // (`scrollThreshold`) and what it leaves at the edge (`scrollMargin`).
150
+ scrollThreshold: clearance,
151
+ scrollMargin: clearance,
139
152
  // Islands are block-schema only, so an inline leaf mounts no node view at all.
140
153
  nodeViews: inline
141
154
  ? undefined
@@ -270,8 +283,15 @@ export function createField(opts) {
270
283
  // be non-inline (before an island/rule block, or doc-level in an empty
271
284
  // nested textblock), where a raw TextSelection is invalid.
272
285
  const sel = Selection.near(view.state.doc.resolve(pm));
273
- view.dispatch(view.state.tr.setSelection(sel));
286
+ // Focus first, then the caret: the order a click's own pair arrives in, and the
287
+ // one a consumer that ends its caret follow on the arrival needs — the other
288
+ // way round, the arrival ends the follow this call just started.
289
+ //
290
+ // Flagged, because PM focuses with `preventScroll` and a landing is a caret
291
+ // move like any other: unflagged, the caret an arrow key would have revealed
292
+ // is left off screen by the click that placed it.
274
293
  view.focus();
294
+ view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
275
295
  },
276
296
  applyExternal() {
277
297
  const current = readLeaf(reader, addr);
@@ -296,6 +316,10 @@ export function createField(opts) {
296
316
  },
297
317
  focus() {
298
318
  view.focus();
319
+ // The placement rung's reveal: no caret to flag a transaction with, so the
320
+ // trip is the element's, `nearest` and no clearance — the one the control
321
+ // lane's own focus takes, for the landing that means the same thing there.
322
+ view.dom.scrollIntoView({ block: 'nearest' });
299
323
  },
300
324
  getContent() {
301
325
  return readLeaf(reader, addr);
@@ -93,12 +93,14 @@
93
93
  is its own figure rather than one ink shown twice: a hue calibrated against
94
94
  white reads as a stain on a dark card. All four clear WCAG 1.4.3's 4.5:1
95
95
  against the card, which is the bar the label rung asks for and the one the
96
- glyphs drawn in these hues clear on the way past 1.4.11's 3:1 — 5.8:1 and
97
- 5.2:1 light, 7.4:1 and 7.7:1 dark. The figures are the host scale's
98
- (`preset/scale.css`), so a page's status line and a card's diagnostic carry
99
- one colour meaning error and one meaning warning. */
100
- --_qm-danger: var(--qm-danger, light-dark(#c5221f, #ff9c94));
101
- --_qm-warning: var(--qm-warning, light-dark(#b25000, #e0b44c));
96
+ glyphs drawn in these hues clear on the way past 1.4.11's 3:1 — 8.2:1 and
97
+ 5.1:1 light, 5.6:1 and 9.7:1 dark. Each is also dark enough at its own pole
98
+ to carry a label at 4.5:1, so a filled chip needs no third figure. The
99
+ figures are the host scale's (`preset/scale.css`), so a page's status line
100
+ and a card's diagnostic carry one colour meaning error and one meaning
101
+ warning. */
102
+ --_qm-danger: var(--qm-danger, light-dark(#9e1518, #f9786c));
103
+ --_qm-warning: var(--qm-warning, light-dark(#926510, #fdc776));
102
104
  --_qm-font: var(--qm-font, ui-sans-serif, system-ui, sans-serif);
103
105
  --_qm-font-mono: var(--qm-font-mono, ui-monospace, monospace);
104
106
 
@@ -50,8 +50,8 @@
50
50
  rather than a second pair beside the surfaces'. Each pole is its own figure: a hue
51
51
  calibrated against white reads as a stain on a dark page. The dial sits in front
52
52
  of the pair, as every pole's does, so a host that turns one owns both schemes. */
53
- --qmh-alert: var(--qm-danger, light-dark(#c5221f, #ff9c94));
54
- --qmh-warn: var(--qm-warning, light-dark(#b25000, #e0b44c));
53
+ --qmh-alert: var(--qm-danger, light-dark(#9e1518, #f9786c));
54
+ --qmh-warn: var(--qm-warning, light-dark(#926510, #fdc776));
55
55
 
56
56
  /* ── Rhythm ──────────────────────────────────────────────────────────────── */
57
57
  /* One base and its multiples, the same four steps the surfaces derive. A host
@@ -3,8 +3,8 @@
3
3
  container div on mount and tears it down on unmount. No logic beyond wiring;
4
4
  paint.ts/bridge.ts/controller.ts own the behavior. Exposes the
5
5
  `PreviewController` verbs as instance methods (`bind:this`) for a consumer
6
- that drives `refresh`/`scrollToField`/`focusPosition`/`setZoom` imperatively
7
- (e.g. after `session.apply` elsewhere).
6
+ that drives `refresh`/`scrollToField`/`focusPosition`/`endFollow`/`setZoom`
7
+ imperatively (e.g. after `session.apply` elsewhere).
8
8
  -->
9
9
  <script lang="ts">
10
10
  import { onMount } from 'svelte';
@@ -78,6 +78,11 @@
78
78
  export function focusPosition(at: Place): void {
79
79
  controller?.focusPosition(at);
80
80
  }
81
+ /** The follow ends on a focus change ({@link PreviewController}), and the editor's
82
+ * own signal is one: `onActiveLeafChange={preview.endFollow}`. */
83
+ export function endFollow(): void {
84
+ controller?.endFollow();
85
+ }
81
86
  export function setZoom(scale: number): void {
82
87
  controller?.setZoom(scale);
83
88
  }
@@ -32,6 +32,7 @@ declare const Preview: import("svelte").Component<Props, {
32
32
  refresh: (change: ChangeSet) => void;
33
33
  scrollToField: (field: DocPath) => boolean;
34
34
  focusPosition: (at: Place) => void;
35
+ endFollow: () => void;
35
36
  setZoom: (scale: number) => void;
36
37
  }, "">;
37
38
  type Preview = ReturnType<typeof Preview>;
@@ -38,6 +38,17 @@ export interface PreviewController {
38
38
  * `ContentHit` carries both members and fits here too.
39
39
  */
40
40
  focusPosition(at: Place): void;
41
+ /**
42
+ * End the follow: the pane stays where the user left it until the next
43
+ * `focusPosition`.
44
+ *
45
+ * The editor's `onActiveLeafChange` is the signal, firing for a form control as for
46
+ * a prose leaf: `onActiveLeafChange={preview.endFollow}`. A control has no caret
47
+ * coordinate to report, so unwired the follow keeps naming the leaf the focus left
48
+ * and every recompile pulls the pane back there. A prose leaf restarts it with its
49
+ * own next caret.
50
+ */
51
+ endFollow(): void;
41
52
  /** Fold a density multiplier into every future paint (crispness, not layout). */
42
53
  setZoom(scale: number): void;
43
54
  destroy(): void;
@@ -83,13 +83,13 @@ export function createPreview(session, opts) {
83
83
  }
84
84
  }
85
85
  render(session.pageCount, []);
86
- // The last place the editor put the caret, re-located after every recompile.
87
- // `session.locate` answers against the last compiled layout and a consumer
88
- // debounces `update`, so a caret typed past that layout's content is off-content
89
- // for the whole burst: `focusPosition` no-ops and the pane sits still, and a
90
- // caret event is otherwise the only thing that asks again. The re-locate is the
91
- // preview's, not the consumer's: the staleness sits between two session queries
92
- // this module owns both ends of.
86
+ // The last place the editor put the caret, re-located after every recompile and
87
+ // dropped on a focus change. `session.locate` answers against the last compiled
88
+ // layout and a consumer debounces `update`, so a caret typed past that layout's
89
+ // content is off-content for the whole burst: `focusPosition` no-ops and the pane
90
+ // sits still, and a caret event is otherwise the only thing that asks again. The
91
+ // re-locate is the preview's, not the consumer's: the staleness sits between two
92
+ // session queries this module owns both ends of.
93
93
  let followed;
94
94
  return {
95
95
  refresh(change) {
@@ -106,6 +106,9 @@ export function createPreview(session, opts) {
106
106
  followed = at;
107
107
  bridge?.focusPosition(at.field, at.pos);
108
108
  },
109
+ endFollow() {
110
+ followed = undefined;
111
+ },
109
112
  setZoom(scale) {
110
113
  paintLoop.setDensityZoom(scale);
111
114
  },
@@ -2,11 +2,12 @@
2
2
  An `array` field → an add/remove repeater. Elements commit by value: every
3
3
  edit / add / remove rebuilds the whole array and hands it to the parent's typed
4
4
  `writer.set(field, wholeArray)` (arrays are not op-addressed). Element control
5
- by `items.type`: `string` / `plaintext` → text input, `richtext` → a prose element
5
+ by `items.type`: `richtext` / `plaintext` → a prose element
6
6
  ({@link ProseArrayElement}), `object` → a minimal JSON editor, keeping the prior
7
- element value on an entry that does not parse. The add affordance sits in the
8
- label header row (space-between with the field label); {@link Field} skips its
9
- own label for array controls and hands this component the label track with it.
7
+ element value on an entry that does not parse, everything else → a text input. The
8
+ add affordance sits in the label header row (space-between with the field label);
9
+ {@link Field} skips its own label for array controls and hands this component the
10
+ label track with it.
10
11
 
11
12
  The remove is inside the element: a slab over the end of the element's own box, taking
12
13
  its two end-side corners. So a row's box is the element's box, and an array's rows end
@@ -54,22 +55,31 @@
54
55
  labelId?: string;
55
56
  /** Where the description parks, for the group's `aria-describedby`. */
56
57
  descriptionId?: string;
58
+ /** Element `i`'s content, decoded by the boundary through the codec `items`
59
+ * names (`Field`, `reader.getContentAt`). Asked only for a content-typed
60
+ * element: on any other the read is not content and throws. `undefined` for a
61
+ * row the stored value does not reach — a slot this control has spliced in and
62
+ * whose commit has yet to land, or was refused. */
63
+ elementContent: (i: number) => Content | undefined;
57
64
  onCommit: (arr: unknown[]) => void;
58
65
  }
59
- let { value, items, label, required, description, labelId, descriptionId, onCommit }: Props =
60
- $props();
66
+ let {
67
+ value,
68
+ items,
69
+ label,
70
+ required,
71
+ description,
72
+ labelId,
73
+ descriptionId,
74
+ elementContent,
75
+ onCommit
76
+ }: Props = $props();
61
77
 
62
- // The element control is the item schema's own, with one departure: a `plaintext`
63
- // element is a text input where the scalar field of that type is a prose leaf. A
64
- // `plaintext` value rests as its literal string (canon SCHEMAS.md §"Content fields
65
- // rest per codec"), and the read that decodes a resting value to `Content` addresses
66
- // a field, never an element (`getContent`; borb-sh/quillmark#1243), so a string is
67
- // what the row is handed and a string is what it commits. The prose element stays
68
- // the `richtext` arm: `Content` after a bound load or a commit, the authored
69
- // string through the transport door, lowered in the row for the same reason.
70
- //
71
- // An array declaring no `items` has text elements.
72
- const control = $derived(items && items.type !== 'plaintext' ? controlKind(items) : 'text');
78
+ // The element control is the item schema's own, with no departure: a content-typed
79
+ // element mounts the prose leaf its scalar field mounts, reading through
80
+ // `elementContent` whatever the element rests as. An array declaring no `items`
81
+ // has text elements.
82
+ const control = $derived(items ? controlKind(items) : 'text');
73
83
  const arr = $derived((value ?? []) as unknown[]);
74
84
 
75
85
  // Parallel stable ids, one per element, kept in lockstep with the data below.
@@ -264,7 +274,8 @@
264
274
  {#if control === 'prose'}
265
275
  <ProseArrayElement
266
276
  bind:this={els[id]}
267
- value={(arr[k] ?? emptyElement()) as Content | string}
277
+ content={() => elementContent(k) ?? emptyContent()}
278
+ plaintext={items?.type === 'plaintext'}
268
279
  label={label != null ? `${label} ${k + 1}` : undefined}
269
280
  onChange={(rt) => commitElement(k, rt)}
270
281
  onKey={(e) => onElementKey(e, k)}
@@ -1,4 +1,4 @@
1
- import type { QuillFieldSchema } from '@quillmark/wasm';
1
+ import type { Content, QuillFieldSchema } from '@quillmark/wasm';
2
2
  import './controls.css';
3
3
  interface Props {
4
4
  value: unknown[] | undefined;
@@ -15,6 +15,12 @@ interface Props {
15
15
  labelId?: string;
16
16
  /** Where the description parks, for the group's `aria-describedby`. */
17
17
  descriptionId?: string;
18
+ /** Element `i`'s content, decoded by the boundary through the codec `items`
19
+ * names (`Field`, `reader.getContentAt`). Asked only for a content-typed
20
+ * element: on any other the read is not content and throws. `undefined` for a
21
+ * row the stored value does not reach — a slot this control has spliced in and
22
+ * whose commit has yet to land, or was refused. */
23
+ elementContent: (i: number) => Content | undefined;
18
24
  onCommit: (arr: unknown[]) => void;
19
25
  }
20
26
  declare const ArrayField: import("svelte").Component<Props, {
@@ -80,8 +80,8 @@
80
80
  <span class="qm-select-wrap" bind:this={wrapEl}>
81
81
  <!-- `allowDeselect={false}`: the unset sentinel is the clear-to-default
82
82
  affordance, so the primitive's own deselect must stay off. It reports a
83
- deselect as `''`: indistinguishable from picking the empty-string enum
84
- member the reference quill actually declares. -->
83
+ deselect as `''`, the boundary's blank: a written value, where the sentinel's
84
+ clear is an absence. -->
85
85
  <Select.Root
86
86
  type="single"
87
87
  allowDeselect={false}
@@ -265,6 +265,7 @@
265
265
  description={field.description}
266
266
  labelId={domIds.label}
267
267
  descriptionId={domIds.description}
268
+ elementContent={(i) => quill.reader(doc).getContentAt(addr, [i])}
268
269
  onCommit={onCommitScalar}
269
270
  />
270
271
  {:else if field.control === 'object'}
@@ -11,8 +11,8 @@
11
11
  element is inert and invalid.
12
12
 
13
13
  Two non-gating label decorations:
14
- • a persistent required `*` when the field has no `default:`: the
15
- "Unendorsed"/must_fill set (DOCUMENT_MODEL: no separate `required` axis). Its
14
+ • a persistent required `*` for a field the schema obliges (`must_fill`, which
15
+ derives from a missing `default:`; DOCUMENT_MODEL). Its
16
16
  accessible name is "required", so a screen reader announces the word, not the
17
17
  glyph; inside the `<label>`, so it names the control along with the text
18
18
  ("Subject required") rather than announcing as a node adjacent to it;
@@ -1,7 +1,7 @@
1
1
  <!--
2
- One element of an array-of-`richtext` field (fixture `keywords`). Array
3
- elements are not `applyChange`-addressable: `Addr.field` is a flat name, so
4
- `keywords[0]` has no op address. So
2
+ One element of an array of `richtext` (fixture `keywords`) or `plaintext`
3
+ (`errata`). Array elements are not `applyChange`-addressable: `Addr.field` is a
4
+ flat name, so `keywords[0]` has no op address. So
5
5
  this is not a `createField` leaf: it mounts a minimal PM view over the codec's
6
6
  decode/encode + inline schema, and on every edit hands the re-encoded
7
7
  `Content` up to the parent {@link ArrayField}, which commits the whole array
@@ -9,24 +9,35 @@
9
9
  element are dropped on that value write: acceptable for inline refs. Mounts
10
10
  once per stable element id (no reset on the parent's re-derive).
11
11
 
12
- A transport-door document rests each element as the authored string. A scalar
13
- leaf reads that through `getContent`; `getContent` names a field, never an
14
- element, so this row calls the boundary's `importMarkdown` itself. That is
15
- the richtext codec, which is this row's `items` type; `decode` is the PM
16
- mapping and assumes `Content`. A string here is the missing element read,
17
- not a codec failure.
12
+ The content is read, not passed: `reader.getContentAt(addr, [k])` decodes an
13
+ element through the codec its `items` type declares, so what the element rests
14
+ as — the content object, a `plaintext` literal, the authored string a transport
15
+ door left — stops being this row's business. Read once, at mount, since that is
16
+ when this leaf takes its state.
18
17
  -->
19
18
  <script lang="ts">
20
19
  import { onMount } from 'svelte';
21
20
  import { EditorState } from 'prosemirror-state';
22
21
  import { EditorView } from 'prosemirror-view';
23
- import { decode, pmToContent, inlineSchema, proseLeafPlugins } from '../core/codec/index.js';
24
- import { core } from '../core/lifecycle.js';
22
+ import {
23
+ decode,
24
+ pmToContent,
25
+ inlineSchema,
26
+ plaintextSchema,
27
+ proseLeafPlugins
28
+ } from '../core/codec/index.js';
25
29
  import './controls.css';
26
30
  import type { Content } from '@quillmark/wasm';
27
31
 
28
32
  interface Props {
29
- value: Content | string;
33
+ /** This element's content, read at mount (`ArrayField`, through the boundary's
34
+ * element read). A thunk rather than a value: the parent re-derives per
35
+ * revision and this leaf takes its state once, so a value prop would be a
36
+ * boundary read per row per render, all but one of them discarded. */
37
+ content: () => Content;
38
+ /** The mark-free schema (a `plaintext` item): literal text, no formatting,
39
+ * exactly as the scalar field of that type mounts. */
40
+ plaintext?: boolean;
30
41
  /** Accessible name for the editable region (the array has no per-element label). */
31
42
  label?: string;
32
43
  onChange: (rt: Content) => void;
@@ -35,15 +46,20 @@
35
46
  * keymap, so the state it reads is the one this keystroke has yet to change. */
36
47
  onKey?: (e: KeyboardEvent) => void;
37
48
  }
38
- let { value, label, onChange, onKey }: Props = $props();
49
+ let { content, plaintext = false, label, onChange, onKey }: Props = $props();
39
50
 
40
51
  let containerEl: HTMLDivElement | undefined = $state();
41
52
  let view: EditorView | undefined;
42
53
  /** Take the caret: what a parent placing focus on this element calls. The view's
43
54
  * focus, not the element's: a PM view restores its selection, where a bare DOM
44
- * focus on a contenteditable leaves the caret unplaced. */
55
+ * focus on a contenteditable leaves the caret unplaced. And the reveal beside it,
56
+ * since PM's focus prevents the scroll a `string` element's input takes by
57
+ * default — an array whose two item types revealed differently is one row of it
58
+ * landing off screen. */
45
59
  export function focus(): void {
46
- view?.focus();
60
+ if (!view) return;
61
+ view.focus();
62
+ view.dom.scrollIntoView({ block: 'nearest' });
47
63
  }
48
64
 
49
65
  onMount(() => {
@@ -52,11 +68,15 @@
52
68
  // plugin stack a `createField` leaf mounts (shared `proseLeafPlugins`), minus
53
69
  // the anchor-position plugin (element anchors are dropped on the array's value
54
70
  // write, per the header). Its own `dispatchTransaction` hands `Content` up.
55
- const rt = typeof value === 'string' ? core().importMarkdown(value) : value;
56
- const pmDoc = decode(rt, inlineSchema);
71
+ //
72
+ // The schema is the declared type's, as `createField` picks it: `plaintext`
73
+ // declares no mark types, so there is nothing to toggle, to paste in, or to
74
+ // mint a shorthand with.
75
+ const schema = plaintext ? plaintextSchema : inlineSchema;
76
+ const pmDoc = decode(content(), schema);
57
77
  const state = EditorState.create({
58
78
  doc: pmDoc,
59
- plugins: proseLeafPlugins(inlineSchema, { inline: true })
79
+ plugins: proseLeafPlugins(schema, { inline: true })
60
80
  });
61
81
  const mounted = new EditorView(containerEl, {
62
82
  state,
@@ -1,7 +1,14 @@
1
1
  import './controls.css';
2
2
  import type { Content } from '@quillmark/wasm';
3
3
  interface Props {
4
- value: Content | string;
4
+ /** This element's content, read at mount (`ArrayField`, through the boundary's
5
+ * element read). A thunk rather than a value: the parent re-derives per
6
+ * revision and this leaf takes its state once, so a value prop would be a
7
+ * boundary read per row per render, all but one of them discarded. */
8
+ content: () => Content;
9
+ /** The mark-free schema (a `plaintext` item): literal text, no formatting,
10
+ * exactly as the scalar field of that type mounts. */
11
+ plaintext?: boolean;
5
12
  /** Accessible name for the editable region (the array has no per-element label). */
6
13
  label?: string;
7
14
  onChange: (rt: Content) => void;
@@ -58,6 +58,7 @@
58
58
  } from './structure.js';
59
59
  import {
60
60
  fieldKeyToString,
61
+ inlineShown,
61
62
  resolveCardKey,
62
63
  routeAndResolve,
63
64
  mergeDiagnostics,
@@ -206,6 +207,10 @@
206
207
  const plain = normalize(addr);
207
208
  activeAddr = plain;
208
209
  activeCardId = cardIdOf(plain);
210
+ // The arrival ends the caret memo's run (`lastCaretField` below): a leaf returned
211
+ // to at the offset it still names has to report again.
212
+ lastCaretField = undefined;
213
+ lastCaretPos = undefined;
209
214
  const field = pathFor(plain);
210
215
  if (field != null) onActiveLeafChange?.({ field, cardId: activeCardId });
211
216
  }
@@ -236,6 +241,10 @@
236
241
  // that reflows the page under a caret holding its offset is the case this drops,
237
242
  // and a preview does not need the signal to answer it: the place is unchanged, so
238
243
  // the one it already followed is the one its recompile re-locates.
244
+ //
245
+ // A focus clears it, and must: a focus into a leaf with no caret to report ends a
246
+ // consumer's follow (PREVIEW §"Follow-the-caret scroll"), and the return to the
247
+ // offset the memo still names is the only thing that would restart it.
239
248
  let lastCaretField: DocPath | undefined;
240
249
  let lastCaretPos: number | undefined;
241
250
  function handleCaret(addr: Addr, pos: number): void {
@@ -272,7 +281,7 @@
272
281
  if (value === undefined) {
273
282
  // The unset rung of the commitment ladder (VISUAL_EDITOR §"Structure mirrors
274
283
  // the schema"): removing the field leaves the engine's authored › `default:`
275
- // › zero-fill resolve to render the default, where writing one bakes a
284
+ // › blank-fill resolve to render the default, where writing one bakes a
276
285
  // snapshot the schema cannot track (canon SCHEMAS.md: the engine never
277
286
  // persists a default; nor do we). Removal writes no value, so there is
278
287
  // nothing for a schema to conform and the lane is the quill-free one.
@@ -477,10 +486,12 @@
477
486
  });
478
487
 
479
488
  // All three producers. The two positional ones resolve their `.path` to the live
480
- // stable-id keying; commit errors are id-keyed already. Nothing is dropped:
481
- // diagnostics never gate, so nothing here hides one either.
489
+ // stable-id keying; commit errors are id-keyed already. Nothing is dropped for
490
+ // being unwelcome — diagnostics never gate — but the obligation warning is
491
+ // withheld from the boundary's lane, the label's `*` being where the surface
492
+ // states that (`inlineShown`).
482
493
  const diagByKey = $derived.by(() => {
483
- const fromValidate = routeAndResolve(validation, cardIds);
494
+ const fromValidate = routeAndResolve(validation.filter(inlineShown), cardIds);
484
495
  const fromExternal = routeAndResolve(diagnostics, cardIds);
485
496
  return mergeDiagnostics(fromValidate, fromExternal, [...commitErrors.values()]);
486
497
  });
@@ -27,6 +27,20 @@ export interface RoutedDiagnostic {
27
27
  key: FieldKey;
28
28
  diagnostic: Diagnostic;
29
29
  }
30
+ /**
31
+ * Whether a validation diagnostic is the surface's to show under a control
32
+ * (VISUAL_EDITOR §Diagnostics). Obligation is not: the label's `*` already states
33
+ * it, from the same schema, for the same cells, and it states it whether or not the
34
+ * cell has been answered — so the warning is a second copy of a mark on screen, once
35
+ * per obliged field, on every document nobody has finished. That is every obliged
36
+ * field of a fresh seed, which is what a consumer's first mount is.
37
+ *
38
+ * Withheld from the boundary's own validation lane only. A consumer's `diagnostics`
39
+ * prop passes whole: what a host hands the surface, the host asked to see. And
40
+ * nothing is suppressed at the source — `quill.validate(doc)` is the completeness
41
+ * read, unchanged and still the host's to make.
42
+ */
43
+ export declare function inlineShown(d: Diagnostic): boolean;
30
44
  /**
31
45
  * Route a path-keyed producer's raw `Diagnostic[]` (validate / external) to the
32
46
  * editor's stable-id keying: the one door producers #1/#3 take, `addrForFieldPath`
@@ -18,6 +18,22 @@ export function resolveCardKey(key, cardIds) {
18
18
  const id = key.card >= 0 && key.card < cardIds.length ? cardIds[key.card] : undefined;
19
19
  return id != null ? { card: id, field: key.field } : undefined;
20
20
  }
21
+ /**
22
+ * Whether a validation diagnostic is the surface's to show under a control
23
+ * (VISUAL_EDITOR §Diagnostics). Obligation is not: the label's `*` already states
24
+ * it, from the same schema, for the same cells, and it states it whether or not the
25
+ * cell has been answered — so the warning is a second copy of a mark on screen, once
26
+ * per obliged field, on every document nobody has finished. That is every obliged
27
+ * field of a fresh seed, which is what a consumer's first mount is.
28
+ *
29
+ * Withheld from the boundary's own validation lane only. A consumer's `diagnostics`
30
+ * prop passes whole: what a host hands the surface, the host asked to see. And
31
+ * nothing is suppressed at the source — `quill.validate(doc)` is the completeness
32
+ * read, unchanged and still the host's to make.
33
+ */
34
+ export function inlineShown(d) {
35
+ return d.code !== 'validation::must_fill';
36
+ }
21
37
  /**
22
38
  * Route a path-keyed producer's raw `Diagnostic[]` (validate / external) to the
23
39
  * editor's stable-id keying: the one door producers #1/#3 take, `addrForFieldPath`
@@ -17,6 +17,11 @@ export interface VisualEditorProps {
17
17
  * The active leaf: its canonical `DocPath` and the session key of the card holding
18
18
  * it, which is what a host tracking the active card keeps across a reorder
19
19
  * (`CardId`).
20
+ *
21
+ * It fires for a form control as for a prose leaf, which makes it the other half of
22
+ * the preview bridge: a control has no caret coordinate to report, so
23
+ * `onActiveLeafChange={preview.endFollow}` is what stops the pane following the leaf
24
+ * the focus left.
20
25
  */
21
26
  onActiveLeafChange?: (active: ActiveLeaf) => void;
22
27
  /**
@@ -17,12 +17,12 @@ export interface FieldModel {
17
17
  * undefined when the field declares none. Chrome-only; never gates. */
18
18
  description: string | undefined;
19
19
  /**
20
- * Required-ness: a field with no `default:` is "Unendorsed": its
21
- * seed carries a `!must_fill` marker (DOCUMENT_MODEL: there is no separate
22
- * `required` axis). Drives a persistent label `*`, complementary to the ghosted
23
- * `default:` (a required field has no default to ghost). Persistent (schema-
24
- * derived, survives filling); the on-commit `must_fill` `validate` warning
25
- * remains the unmet-ness signal. Label chrome only; never gates.
20
+ * Required-ness: the schema's `must_fill` obligation, which derives from
21
+ * `default:`'s absence when the field declares none (DOCUMENT_MODEL). Drives a
22
+ * persistent label `*`; a field can carry both it and a ghosted `default:`,
23
+ * since the two axes are independent. Persistent (schema-derived, survives
24
+ * filling); the `must_fill` `validate` warning remains the unmet-ness signal.
25
+ * Label chrome only; never gates.
26
26
  */
27
27
  required: boolean;
28
28
  /** Prose-leaf flags (only meaningful when `control === 'prose'` / array items). */
@@ -89,7 +89,7 @@ export function fieldModels(cardSchema) {
89
89
  compact: !!schema.ui?.compact,
90
90
  label: schema.ui?.title ?? humanize(name),
91
91
  description: schema.description,
92
- required: schema.default === undefined,
92
+ required: schema.must_fill ?? schema.default === undefined,
93
93
  inline: !!schema.inline,
94
94
  plaintext: schema.type === 'plaintext'
95
95
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/svelte",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Editor, live-preview and source surfaces over a Quillmark WASM session.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -66,7 +66,7 @@
66
66
  "check": "svelte-check --tsconfig ./tsconfig.json"
67
67
  },
68
68
  "peerDependencies": {
69
- "@quillmark/wasm": ">=0.104.0-0",
69
+ "@quillmark/wasm": ">=0.105.0-0",
70
70
  "svelte": "^5.0.0"
71
71
  },
72
72
  "dependencies": {