@quillmark/svelte 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -135,13 +135,24 @@ export interface LeafViews {
135
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
+ /** The naming attributes for the `contenteditable`, shared by every prose leaf
139
+ * (`ProseValue` takes it too, so an addressed leaf and a by-value one name their
140
+ * regions by one rule). `aria-labelledby` supersedes `aria-label`: a leaf carrying
141
+ * both is the ambiguity where implementations disagree about which wins, and
142
+ * `undefined` is returned whole when a leaf takes neither, since ProseMirror reads
143
+ * the absence, not an empty object. */
144
+ export declare function proseAttributes(opts: {
145
+ label?: string;
146
+ labelledBy?: string;
147
+ describedBy?: string;
148
+ }): Record<string, string> | undefined;
138
149
  export declare function createField(opts: CreateFieldOpts): FieldController;
139
150
  /**
140
151
  * The prose-leaf plugin stack (VISUAL_EDITOR §Surface): shared by
141
- * {@link createField} and the array-element inline editor (`ProseArrayElement`),
142
- * so the two never fork the keymap/plugin ordering. History first, then any
143
- * leaf-specific plugins (`afterHistory`: the addressed leaf passes its
144
- * anchor-position plugin; a by-value array element passes none), the
152
+ * {@link createField} and the by-value inline editor (`ProseValue`), so the two
153
+ * never fork the keymap/plugin ordering. History first, then any leaf-specific
154
+ * plugins (`afterHistory`: the addressed leaf passes its anchor-position plugin;
155
+ * a by-value leaf passes none), the
145
156
  * markdown-shorthand input rules, then the field keymap over the base keymap, and
146
157
  * last the two that answer to a caret or a selection beside a block: the gap cursor
147
158
  * and {@link pastAtomPlugin}.
@@ -89,11 +89,13 @@ function opsCommittable(doc, addr) {
89
89
  const stored = doc.getStored(addr);
90
90
  return stored !== null && typeof stored === 'object';
91
91
  }
92
- /** The naming attributes for the `contenteditable`. `aria-labelledby` supersedes
93
- * `aria-label`: a leaf carrying both is the ambiguity where implementations
94
- * disagree about which wins, and `undefined` is returned whole when a leaf takes
95
- * neither, since ProseMirror reads the absence, not an empty object. */
96
- function proseAttributes(opts) {
92
+ /** The naming attributes for the `contenteditable`, shared by every prose leaf
93
+ * (`ProseValue` takes it too, so an addressed leaf and a by-value one name their
94
+ * regions by one rule). `aria-labelledby` supersedes `aria-label`: a leaf carrying
95
+ * both is the ambiguity where implementations disagree about which wins, and
96
+ * `undefined` is returned whole when a leaf takes neither, since ProseMirror reads
97
+ * the absence, not an empty object. */
98
+ export function proseAttributes(opts) {
97
99
  const attrs = {};
98
100
  if (opts.labelledBy)
99
101
  attrs['aria-labelledby'] = opts.labelledBy;
@@ -386,10 +388,10 @@ export function createField(opts) {
386
388
  }
387
389
  /**
388
390
  * The prose-leaf plugin stack (VISUAL_EDITOR §Surface): shared by
389
- * {@link createField} and the array-element inline editor (`ProseArrayElement`),
390
- * so the two never fork the keymap/plugin ordering. History first, then any
391
- * leaf-specific plugins (`afterHistory`: the addressed leaf passes its
392
- * anchor-position plugin; a by-value array element passes none), the
391
+ * {@link createField} and the by-value inline editor (`ProseValue`), so the two
392
+ * never fork the keymap/plugin ordering. History first, then any leaf-specific
393
+ * plugins (`afterHistory`: the addressed leaf passes its anchor-position plugin;
394
+ * a by-value leaf passes none), the
393
395
  * markdown-shorthand input rules, then the field keymap over the base keymap, and
394
396
  * last the two that answer to a caret or a selection beside a block: the gap cursor
395
397
  * and {@link pastAtomPlugin}.
@@ -3,7 +3,7 @@ import 'prosemirror-gapcursor/style/gapcursor.css';
3
3
  import './prose.css';
4
4
  export { rangeAnchor } from './anchor.js';
5
5
  export type { RangeAnchor } from './anchor.js';
6
- export { createField, emptyContent, proseLeafPlugins } from './field.js';
6
+ export { createField, emptyContent, proseAttributes, proseLeafPlugins } from './field.js';
7
7
  export type { CreateFieldOpts, FieldController, LeafViews } from './field.js';
8
8
  export { DEFAULT_TABLE_STRINGS } from './table-view.js';
9
9
  export type { TableChromeStrings } from './table-view.js';
@@ -13,7 +13,7 @@ import './prose.css';
13
13
  // selection (`visual/FormatPopover.svelte`), so it is reached off-barrel.
14
14
  export { rangeAnchor } from './anchor.js';
15
15
  // The prose leaf.
16
- export { createField, emptyContent, proseLeafPlugins } from './field.js';
16
+ export { createField, emptyContent, proseAttributes, proseLeafPlugins } from './field.js';
17
17
  // The table island's chrome vocabulary: the visual tier's `strings` set extends it,
18
18
  // so the island's wording is overridden beside every other key.
19
19
  export { DEFAULT_TABLE_STRINGS } from './table-view.js';
@@ -3,7 +3,7 @@
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
5
  by `items.type`: `richtext` / `plaintext` → a prose element
6
- ({@link ProseArrayElement}), `object` → a summary row that opens onto a subform,
6
+ ({@link ProseValue}), `object` → a summary row that opens onto a subform,
7
7
  everything else → a text input. The
8
8
  add affordance sits in the label header row (space-between with the field label);
9
9
  {@link Field} skips its own label for array controls and hands this component the
@@ -38,7 +38,7 @@
38
38
  // off-tree, so this component renders standalone too.
39
39
  const t = wording();
40
40
  import { onDestroy, tick } from 'svelte';
41
- import type { Content, QuillFieldSchema } from '@quillmark/wasm';
41
+ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
42
42
  import { emptyContent } from '../core/codec/index.js';
43
43
  import { createLifespan } from '../core/teardown.js';
44
44
  import { IdSeq, controlKind } from './structure.js';
@@ -46,7 +46,7 @@
46
46
  import Icon from './icons/Icon.svelte';
47
47
  import TextField from './TextField.svelte';
48
48
  import ObjectField from './ObjectField.svelte';
49
- import ProseArrayElement from './ProseArrayElement.svelte';
49
+ import ProseValue from './ProseValue.svelte';
50
50
  import FieldLabel from './FieldLabel.svelte';
51
51
  import './controls.css';
52
52
 
@@ -73,12 +73,14 @@
73
73
  * properties' own names from, one `-e-<element id>` segment down, so each open
74
74
  * record's cells carry real `<label for>` pairs like every other control. */
75
75
  idBase?: string;
76
- /** Element `i`'s content, decoded by the boundary through the codec `items`
77
- * names (`Field`, `reader.getContentAt`). Asked only for a content-typed
78
- * element: on any other the read is not content and throws. `undefined` for a
79
- * row the stored value does not reach — a slot this control has spliced in and
80
- * whose commit has yet to land, or was refused. */
81
- elementContent: (i: number) => Content | undefined;
76
+ /** The boundary's nested content read, rooted at this field: `path` is a
77
+ * `PathStep[]` from the field to the leaf, so `[i]` is element `i` and
78
+ * `[i, key]` a content cell of an `object` element's subform (`Field`,
79
+ * `reader.getContentAt`). The codec is the leaf's own declared type's. Asked
80
+ * only for a content-typed leaf: on any other the read is not content and
81
+ * throws. `undefined` for a leaf the stored value does not reach — a slot this
82
+ * control has spliced in and whose commit has yet to land, or was refused. */
83
+ contentAt: (path: PathStep[]) => Content | undefined;
82
84
  onCommit: (arr: unknown[]) => void;
83
85
  }
84
86
  let {
@@ -90,13 +92,13 @@
90
92
  labelId,
91
93
  descriptionId,
92
94
  idBase,
93
- elementContent,
95
+ contentAt,
94
96
  onCommit
95
97
  }: Props = $props();
96
98
 
97
99
  // The element control is the item schema's own, with no departure: a content-typed
98
100
  // element mounts the prose leaf its scalar field mounts, reading through
99
- // `elementContent` whatever the element rests as. An array declaring no `items`
101
+ // `contentAt` whatever the element rests as. An array declaring no `items`
100
102
  // has text elements.
101
103
  const control = $derived(items ? controlKind(items) : 'text');
102
104
  const arr = $derived((value ?? []) as unknown[]);
@@ -122,7 +124,7 @@
122
124
  // The focus targets, keyed by element ID rather than index: an index goes stale on
123
125
  // the splice that focus is chasing. An element control exposes `focus()` because a
124
126
  // text element and a prose element disagree on what focusing is: the difference
125
- // is stated on `ProseArrayElement.focus`, which owns it.
127
+ // is stated on `ProseValue.focus`, which owns it.
126
128
  //
127
129
  // Every path that drops an id deletes its entry: `bind:this` teardown nulls the
128
130
  // value on unmount and leaves the key, so a card that outlives its elements
@@ -396,6 +398,7 @@
396
398
  properties={items?.properties}
397
399
  label={label != null ? `${label} ${k + 1}` : undefined}
398
400
  idBase={idBase != null ? `${idBase}-e-${id}` : undefined}
401
+ contentAt={(path) => contentAt([k, ...path])}
399
402
  onCommit={(obj) => commitElement(k, obj)}
400
403
  />
401
404
  {/if}
@@ -403,9 +406,9 @@
403
406
  {:else}
404
407
  <div class="qm-array-row">
405
408
  {#if control === 'prose'}
406
- <ProseArrayElement
409
+ <ProseValue
407
410
  bind:this={els[id]}
408
- content={() => elementContent(k) ?? emptyContent()}
411
+ content={() => contentAt([k]) ?? emptyContent()}
409
412
  plaintext={items?.type === 'plaintext'}
410
413
  label={label != null ? `${label} ${k + 1}` : undefined}
411
414
  onChange={(rt) => commitElement(k, rt)}
@@ -1,4 +1,4 @@
1
- import type { Content, QuillFieldSchema } from '@quillmark/wasm';
1
+ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
2
2
  import './controls.css';
3
3
  interface Props {
4
4
  value: unknown[] | undefined;
@@ -19,12 +19,14 @@ interface Props {
19
19
  * properties' own names from, one `-e-<element id>` segment down, so each open
20
20
  * record's cells carry real `<label for>` pairs like every other control. */
21
21
  idBase?: string;
22
- /** Element `i`'s content, decoded by the boundary through the codec `items`
23
- * names (`Field`, `reader.getContentAt`). Asked only for a content-typed
24
- * element: on any other the read is not content and throws. `undefined` for a
25
- * row the stored value does not reach — a slot this control has spliced in and
26
- * whose commit has yet to land, or was refused. */
27
- elementContent: (i: number) => Content | undefined;
22
+ /** The boundary's nested content read, rooted at this field: `path` is a
23
+ * `PathStep[]` from the field to the leaf, so `[i]` is element `i` and
24
+ * `[i, key]` a content cell of an `object` element's subform (`Field`,
25
+ * `reader.getContentAt`). The codec is the leaf's own declared type's. Asked
26
+ * only for a content-typed leaf: on any other the read is not content and
27
+ * throws. `undefined` for a leaf the stored value does not reach — a slot this
28
+ * control has spliced in and whose commit has yet to land, or was refused. */
29
+ contentAt: (path: PathStep[]) => Content | undefined;
28
30
  onCommit: (arr: unknown[]) => void;
29
31
  }
30
32
  declare const ArrayField: import("svelte").Component<Props, {
@@ -128,9 +128,9 @@
128
128
  const ungrouped = $derived(card.sections.filter((s) => s.group == null));
129
129
  const grouped = $derived(card.sections.filter((s) => s.group != null));
130
130
 
131
- // A schema that declares no fields still gets a body, and an empty metadata block
132
- // draws nothing while the card's gap still counts it twice, standing a rung of dead
133
- // space over the body.
131
+ // A schema that declares no fields still gets a body, and an empty payload block is
132
+ // worse than none: its two rules land on one y and paint as a single stroke of twice
133
+ // the width, and the card's gap counts the block twice besides.
134
134
  const hasMeta = $derived(ungrouped.length > 0 || grouped.length > 0);
135
135
 
136
136
  // Seeded once from the card's shape. Card is keyed by stable id, so this survives the
@@ -251,7 +251,11 @@
251
251
 
252
252
  <div class="qm-card-body">
253
253
  {#if hasMeta}
254
- <div class="qm-card-meta" class:qm-meta-bottom={card.hasBody}>
254
+ <div
255
+ class="qm-card-meta"
256
+ class:qm-meta-top={!card.isMain}
257
+ class:qm-meta-bottom={card.hasBody}
258
+ >
255
259
  {#each ungrouped as section (section.group ?? '_ungrouped')}
256
260
  <div class="qm-section">
257
261
  {@render sectionFields(section.fields)}
@@ -511,17 +515,31 @@
511
515
  flex-direction: column;
512
516
  gap: var(--_qm-space);
513
517
  }
514
- /* The one horizontal the card draws, and it is the one thing a horizontal is for: it
515
- ranks the payload against the body rather than saying what is inside what, which is
516
- the verticals' job (ARCHITECTURE §"A plane is a tone"). Conditional on a body, since
517
- with none it would divide the payload from the card's own edge a rung below it.
518
+ /* One rung in two places: what a rule insets its own edge by, and what a vertical
519
+ reaches past the box it is drawn on. `--_qm-nest` is the distance a stroke stands off
520
+ the content it holds — the same one the fields keep off the vertical inline — and a
521
+ section's box already spends `--_qm-space` of it capping its own ends, so both spend
522
+ the difference. That is what squares the corner: the field stands the same rung off
523
+ the rule above it as off the vertical beside it. */
524
+ .qm-card-meta {
525
+ --stroke-reach: calc(var(--_qm-nest) - var(--_qm-space));
526
+ }
527
+ /* The card's two horizontals, and ranking is the one thing a horizontal is for: they
528
+ stand the payload against the title above it and the body below it, where saying what
529
+ sits inside what is the verticals' (ARCHITECTURE §"A plane is a tone"). Each is
530
+ conditional on what it ranks against: `main` is headerless, and a card with no body
531
+ has nothing under the payload to divide it from.
518
532
 
519
- The inset stands the rule off the last control at the rung the body sits under it by,
520
- taking the section's own end cap into account: `--_qm-space-2` here, plus that cap,
521
- is the `--_qm-space-3` the card spends between its blocks. */
533
+ The inset is the rule's own, so it goes with it: a card drawing one horizontal insets
534
+ that end and not the other. The air outside the pair is the `--_qm-space-3` the card
535
+ spends between its blocks. */
536
+ .qm-meta-top {
537
+ border-top: var(--_qm-border-width) solid var(--_qm-border);
538
+ padding-top: var(--stroke-reach);
539
+ }
522
540
  .qm-meta-bottom {
523
541
  border-bottom: var(--_qm-border-width) solid var(--_qm-border);
524
- padding-bottom: var(--_qm-space-2);
542
+ padding-bottom: var(--stroke-reach);
525
543
  }
526
544
  /* A section's share of the boundary between sections: one rung at each end, so two of
527
545
  them stand three of it apart whichever kind they are (above). The accordion spends the
@@ -577,12 +595,22 @@
577
595
  On the group rather than on its panel: what the stroke marks is the section, and a
578
596
  section is its header and what hangs under it. Drawn transparent when closed rather
579
597
  than absent, so a toggle moves no text — and closed, there is nothing under the header
580
- for it to gather. Its caps are the header's block padding above and the panel's below,
581
- at the rung the ungrouped section spends on both of its own ends. */
598
+ for it to gather.
599
+
600
+ The stroke caps at `--_qm-nest`, at both ends and on every section alike. Its box
601
+ already spends `--_qm-space` of that — the header's block padding above, the panel's
602
+ below, the rung the ungrouped section spends on its own two ends — so the rest is
603
+ grown here and handed straight back as margin: the stroke lengthens, and a section
604
+ boundary keeps the three rungs it stood at. What the stroke reaches into is the air of
605
+ that boundary, or, where the section is the payload's first or last, exactly the inset
606
+ its rule took — so it lands on the rule at a corner with nothing drawn longer for it.
607
+ One section open at a time, so no two strokes are ever visible to meet in between. */
582
608
  .qm-group {
583
609
  display: flex;
584
610
  flex-direction: column;
585
611
  border-left: var(--_qm-border-width) solid transparent;
612
+ padding-block: var(--stroke-reach);
613
+ margin-block: calc(-1 * var(--stroke-reach));
586
614
  transition: border-color var(--_qm-duration-slow) var(--_qm-ease-reverse);
587
615
  }
588
616
  .qm-group.qm-open {
@@ -590,13 +618,13 @@
590
618
  }
591
619
  /* Symmetric vertical padding at the tightest rung that still clears WCAG 2.5.8's
592
620
  24×24 floor: the header is the whole row, so adjacent labels share one rhythm with no
593
- dead strip outside the button. Horizontal is one rung left and zero right — left,
594
- because at zero the chevron stands on the section's vertical with only the icon box's
595
- own bearing between them, and the glyph's rotation swaps which bearing faces the
596
- stroke; right, because the row is the target and an inset there is target given back.
597
- The rung is the glyph's own and not the nesting step the fields under it take: what
598
- the heading names is the section, so it rides the vertical rather than the column its
599
- fields start on.
621
+ dead strip outside the button. Horizontal is the nesting step left and zero right —
622
+ left, because the chevron opening the heading starts where the fields under it start,
623
+ so everything the section holds stands off its vertical by one rung and the corner the
624
+ stroke closes against a rule reads square; right, because the row is the target and an
625
+ inset there is target given back. What the glyph gives up by taking the step is the
626
+ gutter: at a tighter rung it stands in the clearance itself, with only its own bearing
627
+ between ink and stroke, and the rotation swaps which bearing faces which.
600
628
 
601
629
  `font: inherit` because a UA button inherits no face, then the body rung, one step
602
630
  over the field labels beneath it: at the label rung the two read as one register and
@@ -609,7 +637,7 @@
609
637
  width: 100%;
610
638
  border: none;
611
639
  background: transparent;
612
- padding: var(--_qm-space) 0 var(--_qm-space) var(--_qm-space);
640
+ padding: var(--_qm-space) 0 var(--_qm-space) var(--_qm-nest);
613
641
  font: inherit;
614
642
  font-size: var(--_qm-text-body);
615
643
  font-weight: var(--_qm-weight-mid);
@@ -14,7 +14,15 @@
14
14
  `DiagnosticList`, non-gating.
15
15
  -->
16
16
  <script lang="ts">
17
- import type { Document, Quill, Addr, Diagnostic, ResolvedField } from '@quillmark/wasm';
17
+ import type {
18
+ Document,
19
+ Quill,
20
+ Addr,
21
+ Content,
22
+ Diagnostic,
23
+ PathStep,
24
+ ResolvedField
25
+ } from '@quillmark/wasm';
18
26
  import type { EditorErrorHandler } from '../core/errors.js';
19
27
  import type { LeafRegistry } from './leaves.js';
20
28
  import type { FieldModel, FieldSpan } from './structure.js';
@@ -116,6 +124,16 @@
116
124
  // nothing, and silently.
117
125
  const describedBy = $derived(field.description ? domIds.description : undefined);
118
126
 
127
+ /** The boundary's nested content read with this field's address already bound: an
128
+ * array's elements, an object's properties, a variant's cells, and a subform's cells
129
+ * under an open element. One door rather than one per depth, since `getContentAt`
130
+ * takes the whole path and the walk down is each container prefixing its own step.
131
+ * Read per call and never held: a leaf takes its state at mount, and a control asks
132
+ * once. */
133
+ function contentAt(path: PathStep[]): Content | undefined {
134
+ return quill.reader(doc).getContentAt(addr, path);
135
+ }
136
+
119
137
  // ── Focus: one answer, two callers (`leaves.ts`) ─────────────────────────────
120
138
  // A label click and the editor's `focusField`/`setCaret` ask the same question, so
121
139
  // they read the same function and cannot land in different places. The four
@@ -247,6 +265,7 @@
247
265
  id={domIds.control}
248
266
  labelledBy={domIds.label}
249
267
  {describedBy}
268
+ {contentAt}
250
269
  onCommit={onCommitScalar}
251
270
  {optionAllowed}
252
271
  {enumDisallowed}
@@ -288,7 +307,7 @@
288
307
  labelId={domIds.label}
289
308
  descriptionId={domIds.description}
290
309
  idBase={domIds.control}
291
- elementContent={(i) => quill.reader(doc).getContentAt(addr, [i])}
310
+ {contentAt}
292
311
  onCommit={onCommitScalar}
293
312
  />
294
313
  {:else if field.control === 'object'}
@@ -300,6 +319,7 @@
300
319
  idBase={domIds.control}
301
320
  labelledBy={domIds.label}
302
321
  {describedBy}
322
+ {contentAt}
303
323
  onCommit={onCommitScalar}
304
324
  />
305
325
  {:else}
@@ -1,8 +1,9 @@
1
1
  <!--
2
2
  An `object` field → a nested subform over `properties` (declaration order),
3
- committing the whole object by value on any nested change. Scalar properties
4
- only: a nested prose/array/object property renders a placeholder instead of
5
- recursing.
3
+ committing the whole object by value on any nested change. Leaf properties: a
4
+ scalar draws its own control, a content property mounts the by-value prose leaf
5
+ ({@link ProseValue}) over the boundary's nested read, and a nested `array`/`object`
6
+ property renders a placeholder instead of recursing.
6
7
 
7
8
  The nesting is a vertical at `--_qm-border`, the ladder's last stroke: the card's edge,
8
9
  an open section's vertical one `--_qm-nest` in where the field is in one, this one a rung
@@ -16,9 +17,15 @@
16
17
  resolved provenance the top-level ghosts read (FIELD_PROVENANCE): `resolve`
17
18
  carries no per-property row (an object field resolves as one row whose value is
18
19
  the whole object).
20
+
21
+ Every caller hands down `contentAt`, which is `reader.getContentAt` with the field
22
+ already bound: a field-level subform passes the property key through untouched, a
23
+ variant passes its cell's, and an array's open row prefixes its index. So a cell
24
+ reads at whatever depth it sits, which nothing here knows.
19
25
  -->
20
26
  <script lang="ts">
21
- import type { QuillFieldSchema } from '@quillmark/wasm';
27
+ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
28
+ import { emptyContent } from '../core/codec/index.js';
22
29
  import { controlKind, humanize, obliged } from './structure.js';
23
30
  import { wording } from './strings.js';
24
31
  import { propertyDomIds } from './domid.js';
@@ -28,6 +35,7 @@
28
35
  import NumberField from './NumberField.svelte';
29
36
  import BooleanField from './BooleanField.svelte';
30
37
  import DateField from './DateField.svelte';
38
+ import ProseValue from './ProseValue.svelte';
31
39
 
32
40
  interface Props {
33
41
  value: Record<string, unknown> | undefined;
@@ -47,9 +55,16 @@
47
55
  idBase?: string;
48
56
  /** Accessible-name prefix used only on the `aria-label` fallback above. */
49
57
  label?: string;
58
+ /** The boundary's nested content read, rooted at this subform: `path` is a
59
+ * `PathStep[]` from here to the leaf, which the caller prefixes with whatever
60
+ * stands between this subform and the field (`reader.getContentAt`). Asked only
61
+ * for a content-typed property: on any other the read is not content and throws.
62
+ * `undefined` for a property the stored value does not reach. */
63
+ contentAt: (path: PathStep[]) => Content | undefined;
50
64
  onCommit: (obj: Record<string, unknown>) => void;
51
65
  }
52
- let { value, properties, labelledBy, describedBy, idBase, label, onCommit }: Props = $props();
66
+ let { value, properties, labelledBy, describedBy, idBase, label, contentAt, onCommit }: Props =
67
+ $props();
53
68
 
54
69
  const t = wording();
55
70
  const entries = $derived(Object.entries(properties ?? {}));
@@ -68,26 +83,48 @@
68
83
  `${label != null ? `${label} ` : ''}${title(key, sub)}` +
69
84
  (required(sub) ? ` ${t.strings.fieldRequired}` : '');
70
85
 
71
- /** Take the caret: the first property's control, a subform having no single control
72
- * of its own to land on. Resolved off the DOM rather than a ref per property: every
73
- * property is a scalar control the DOM already knows how to focus (the date field's
74
- * segments carry `tabindex`, the `literal` separators between them do not), so a
75
- * handle each would be five refs restating document order. An empty or wholly
76
- * unsupported subform lands nothing. */
86
+ /** The prose cells' handles, keyed by property name — which is stable, a subform
87
+ * drawing its properties by declaration and never by position. A ref only where
88
+ * the DOM cannot answer: a `contenteditable` matches no focusable selector, and
89
+ * focusing the node is not focusing the view ({@link ProseValue.focus}). A scalar
90
+ * control is resolved off the markup, so a handle each would be five refs
91
+ * restating document order.
92
+ *
93
+ * `$state` for the binding's sake: `bind:this` into a property of a plain object
94
+ * is a write Svelte cannot track, and it says so once per cell per render
95
+ * (`ArrayField` keeps its element refs the same way). */
96
+ const proseEls: Record<string, { focus: () => void } | undefined> = $state({});
77
97
  let rootEl = $state<HTMLElement | undefined>();
78
98
  const FOCUSABLE = 'input, select, button, [tabindex]:not([tabindex="-1"])';
99
+ /** Take the caret: the first property that has somewhere to put it, in document
100
+ * order — a subform has no single control of its own to land on, and a property
101
+ * standing the unsupported line has none either, so the walk steps past it. An
102
+ * empty or wholly unsupported subform lands nothing. */
79
103
  export function focus(): void {
80
- rootEl?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
104
+ for (const cell of propCells()) if (landOn(cell)) return;
81
105
  }
82
- /** A label click for the one property `for` cannot reach: the date control, whose
83
- * focus lives on a segment. Scoped to that property's own cell, so the click lands
84
- * where the label is rather than on the subform's first control. */
106
+ /** A label click for the properties `for` cannot reach: the date control, whose
107
+ * focus lives on a segment, and a prose cell, which is a `contenteditable`. Scoped
108
+ * to that property's own cell, so the click lands where the label is rather than on
109
+ * the subform's first control. */
85
110
  function focusProp(key: string): void {
86
- // Matched on the dataset: a key is the schema's own string, and spelling one into
87
- // a selector needs `CSS.escape`, which the test DOM does not carry.
88
- for (const cell of rootEl?.querySelectorAll<HTMLElement>('[data-qm-prop]') ?? []) {
89
- if (cell.dataset.qmProp === key) return cell.querySelector<HTMLElement>(FOCUSABLE)?.focus();
111
+ for (const cell of propCells()) if (cell.dataset.qmProp === key) return void landOn(cell);
112
+ }
113
+ // Matched on the dataset: a key is the schema's own string, and spelling one into a
114
+ // selector needs `CSS.escape`, which the test DOM does not carry.
115
+ function propCells(): HTMLElement[] {
116
+ return [...(rootEl?.querySelectorAll<HTMLElement>('[data-qm-prop]') ?? [])];
117
+ }
118
+ /** Land in one property's cell, answering whether it took the caret. */
119
+ function landOn(cell: HTMLElement): boolean {
120
+ const prose = proseEls[cell.dataset.qmProp ?? ''];
121
+ if (prose) {
122
+ prose.focus();
123
+ return true;
90
124
  }
125
+ const el = cell.querySelector<HTMLElement>(FOCUSABLE);
126
+ el?.focus();
127
+ return !!el;
91
128
  }
92
129
 
93
130
  function commitProp(key: string, v: unknown): void {
@@ -117,8 +154,8 @@
117
154
  {@const named = ids ? undefined : fallbackName(key, sub)}
118
155
  {@const describes = sub.description && ids ? ids.description : undefined}
119
156
  <!-- `for` reaches the four labelable controls; the date field's focus lives on a
120
- segment, so it takes the click handoff instead, exactly as `Field` does one
121
- level up. -->
157
+ segment and a prose cell is a `contenteditable`, so those take the click
158
+ handoff instead, exactly as `Field` does one level up. -->
122
159
  {@const labelable =
123
160
  kind === 'text' || kind === 'enum' || kind === 'number' || kind === 'boolean'}
124
161
  <div class="qm-object-prop" data-qm-prop={key}>
@@ -179,6 +216,18 @@
179
216
  placeholder={sub.default != null ? String(sub.default) : undefined}
180
217
  onCommit={(v) => commitProp(key, v)}
181
218
  />
219
+ {:else if kind === 'prose'}
220
+ <!-- Keyed by name, so a re-derive of the container this cell commits whole
221
+ leaves the leaf mounted and the caret in it. -->
222
+ <ProseValue
223
+ bind:this={proseEls[key]}
224
+ content={() => contentAt([key]) ?? emptyContent()}
225
+ plaintext={sub.type === 'plaintext'}
226
+ label={named}
227
+ labelledBy={ids?.label}
228
+ describedBy={describes}
229
+ onChange={(rt) => commitProp(key, rt)}
230
+ />
182
231
  {:else}
183
232
  <!-- A property the subform does not recurse into. What it says is what the
184
233
  user can do about it, which is edit the field from the source surface;
@@ -1,4 +1,4 @@
1
- import type { QuillFieldSchema } from '@quillmark/wasm';
1
+ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
2
2
  interface Props {
3
3
  value: Record<string, unknown> | undefined;
4
4
  properties: Record<string, QuillFieldSchema> | undefined;
@@ -17,6 +17,12 @@ interface Props {
17
17
  idBase?: string;
18
18
  /** Accessible-name prefix used only on the `aria-label` fallback above. */
19
19
  label?: string;
20
+ /** The boundary's nested content read, rooted at this subform: `path` is a
21
+ * `PathStep[]` from here to the leaf, which the caller prefixes with whatever
22
+ * stands between this subform and the field (`reader.getContentAt`). Asked only
23
+ * for a content-typed property: on any other the read is not content and throws.
24
+ * `undefined` for a property the stored value does not reach. */
25
+ contentAt: (path: PathStep[]) => Content | undefined;
20
26
  onCommit: (obj: Record<string, unknown>) => void;
21
27
  }
22
28
  declare const ObjectField: import("svelte").Component<Props, {
@@ -0,0 +1,165 @@
1
+ <!--
2
+ A prose leaf the parent commits by value: an element of a `richtext` (fixture
3
+ `keywords`) or `plaintext` (`errata`) array, and a content cell of a subform — an
4
+ `object`'s property, a variant's cell ({@link ObjectField}). None of them is
5
+ `applyChange`-addressable: `Addr.field` is a flat name, so `keywords[0]` and
6
+ `contact.note` have no op address. So this is not a `createField` leaf: it mounts a
7
+ minimal PM view over the codec's decode/encode + inline schema, and on every edit
8
+ hands the re-encoded `Content` up to the parent — {@link ArrayField}, which commits
9
+ the whole array, or {@link ObjectField}, which commits the whole container
10
+ (`writer.set(field, next)`). Anchors inside a value are dropped on that write:
11
+ acceptable for inline refs. Mounts once per stable key (an element's session id, a
12
+ property's name), so the parent's re-derive leaves the caret where it is.
13
+
14
+ The content is read, not passed: `reader.getContentAt(addr, path)` decodes through
15
+ the codec the leaf's own declared type names, so what it rests as — the content
16
+ object, a `plaintext` literal, the authored string a transport door left — stops
17
+ being the row's or the cell's business. Read once, at mount, since that is when this
18
+ leaf takes its state.
19
+
20
+ One textblock wherever it mounts, no islands and no slash menu. `plaintext` is
21
+ inline at the codec, so that is the declared shape for every plaintext leaf; a block
22
+ `richtext` is held one rung narrower than it declares, having no room for a second
23
+ paragraph in the figure a repeater row and a subform cell draw.
24
+ -->
25
+ <script lang="ts">
26
+ import { onMount } from 'svelte';
27
+ import { EditorState, Selection } from 'prosemirror-state';
28
+ import { EditorView } from 'prosemirror-view';
29
+ import {
30
+ decode,
31
+ pmToContent,
32
+ inlineSchema,
33
+ plaintextSchema,
34
+ proseAttributes,
35
+ proseLeafPlugins,
36
+ buildLineIndex,
37
+ usvToPM
38
+ } from '../core/codec/index.js';
39
+ import './controls.css';
40
+ import type { Content } from '@quillmark/wasm';
41
+
42
+ interface Props {
43
+ /** This leaf's content, read at mount (the parent's boundary read). A thunk
44
+ * rather than a value: the parent re-derives per revision and this leaf takes
45
+ * its state once, so a value prop would be a boundary read per leaf per render,
46
+ * all but one of them discarded. */
47
+ content: () => Content;
48
+ /** The mark-free schema (a `plaintext` leaf): literal text, no formatting,
49
+ * exactly as the scalar field of that type mounts. */
50
+ plaintext?: boolean;
51
+ /** Accessible name for the editable region, where nothing else names it: an
52
+ * array has no per-element label. A cell with a label element takes
53
+ * `labelledBy`. */
54
+ label?: string;
55
+ /** The label's own id → `aria-labelledby`. `for` cannot reach a
56
+ * `contenteditable`, so a subform cell's label associates the other way and
57
+ * hands its click back through {@link focus}. */
58
+ labelledBy?: string;
59
+ /** The parked `description` (FieldLabel) → `aria-describedby`. */
60
+ describedBy?: string;
61
+ onChange: (rt: Content) => void;
62
+ /** Raw keydown, for a container whose own keys run through this leaf: the
63
+ * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
64
+ * keymap, so the state it reads is the one this keystroke has yet to change. */
65
+ onKey?: (e: KeyboardEvent) => void;
66
+ }
67
+ let {
68
+ content,
69
+ plaintext = false,
70
+ label,
71
+ labelledBy,
72
+ describedBy,
73
+ onChange,
74
+ onKey
75
+ }: Props = $props();
76
+
77
+ let containerEl: HTMLDivElement | undefined = $state();
78
+ let view: EditorView | undefined;
79
+ /** Take the caret: what a parent placing focus on this leaf calls. The view's
80
+ * focus, not the element's: a PM view restores its selection, where a bare DOM
81
+ * focus on a contenteditable leaves the caret unplaced. And the reveal beside it,
82
+ * since PM's focus prevents the scroll a `string` element's input takes by
83
+ * default — an array whose two item types revealed differently is one row of it
84
+ * landing off screen. */
85
+ export function focus(): void {
86
+ if (!view) return;
87
+ view.focus();
88
+ view.dom.scrollIntoView({ block: 'nearest' });
89
+ }
90
+ /** Take the caret to USV `pos` in this leaf's own content: what a landing on an
91
+ * element address resolves to when the compile answered a cluster-exact offset
92
+ * (`leaves.ts`). `createField`'s body over the view this component owns.
93
+ *
94
+ * The index is built here rather than held: a landing is a discrete act, and one
95
+ * doc walk per click is cheaper than a cache to invalidate per keystroke. `usvToPM`
96
+ * clamps, so an offset past a value the compile has moved on from lands the end.
97
+ *
98
+ * `Selection.near`, not `TextSelection.create`: the mapped position can be
99
+ * non-inline. Focus first, then the caret, flagged for scroll — the order and the
100
+ * reasons are `FieldController.setCaret`'s (`codec/field.ts`). */
101
+ export function setCaret(pos: number): void {
102
+ if (!view) return;
103
+ const pm = usvToPM(buildLineIndex(view.state.doc), pos);
104
+ const sel = Selection.near(view.state.doc.resolve(pm));
105
+ view.focus();
106
+ view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
107
+ }
108
+
109
+ onMount(() => {
110
+ if (!containerEl) return;
111
+ // An inline, value-by-value leaf: the same keymap and plugin stack a
112
+ // `createField` leaf mounts (shared `proseLeafPlugins`), minus the
113
+ // anchor-position plugin (anchors are dropped on the parent's value write, per
114
+ // the header). Its own `dispatchTransaction` hands `Content` up.
115
+ //
116
+ // The schema is the declared type's, as `createField` picks it: `plaintext`
117
+ // declares no mark types, so there is nothing to toggle, to paste in, or to
118
+ // mint a shorthand with.
119
+ const schema = plaintext ? plaintextSchema : inlineSchema;
120
+ const pmDoc = decode(content(), schema);
121
+ const state = EditorState.create({
122
+ doc: pmDoc,
123
+ plugins: proseLeafPlugins(schema, { inline: true })
124
+ });
125
+ const mounted = new EditorView(containerEl, {
126
+ state,
127
+ // Which of `aria-label` / `aria-labelledby` wins is the codec's one answer
128
+ // (`proseAttributes`), so a cell carrying a label element and a row carrying
129
+ // none cannot name their regions by different rules.
130
+ attributes: proseAttributes({ label, labelledBy, describedBy }),
131
+ dispatchTransaction(tr) {
132
+ const next = mounted.state.apply(tr);
133
+ mounted.updateState(next);
134
+ if (tr.docChanged) onChange(pmToContent(next.doc));
135
+ },
136
+ handleDOMEvents: {
137
+ keydown: (_v, e) => {
138
+ onKey?.(e);
139
+ return false;
140
+ }
141
+ }
142
+ });
143
+ view = mounted;
144
+ return () => {
145
+ view = undefined;
146
+ mounted.destroy();
147
+ };
148
+ });
149
+ </script>
150
+
151
+ <!-- `.qm-control-box` (controls.css) is the whole box, so an array of `richtext` and
152
+ an array of `string` render rows of equal height, and a subform's prose cell measures
153
+ like the text cell beside it. No floor: the reset in `core/codec/prose.css` makes one
154
+ line of prose measure one line. Width is the row's or the cell's to give: the leaf
155
+ fills the track it is placed in. -->
156
+ <div bind:this={containerEl} class="qm-prose-value qm-control-box qm-focus-ring-within"></div>
157
+
158
+ <style>
159
+ /* Caret-primary, matching ProseField: the contenteditable's own outline is
160
+ dropped and the ring rides the wrapper (`qm-focus-ring-within`,
161
+ controls.css), which is where the box is. */
162
+ .qm-prose-value :global(.ProseMirror) {
163
+ outline: none;
164
+ }
165
+ </style>
@@ -0,0 +1,33 @@
1
+ import './controls.css';
2
+ import type { Content } from '@quillmark/wasm';
3
+ interface Props {
4
+ /** This leaf's content, read at mount (the parent's boundary read). A thunk
5
+ * rather than a value: the parent re-derives per revision and this leaf takes
6
+ * its state once, so a value prop would be a boundary read per leaf per render,
7
+ * all but one of them discarded. */
8
+ content: () => Content;
9
+ /** The mark-free schema (a `plaintext` leaf): literal text, no formatting,
10
+ * exactly as the scalar field of that type mounts. */
11
+ plaintext?: boolean;
12
+ /** Accessible name for the editable region, where nothing else names it: an
13
+ * array has no per-element label. A cell with a label element takes
14
+ * `labelledBy`. */
15
+ label?: string;
16
+ /** The label's own id → `aria-labelledby`. `for` cannot reach a
17
+ * `contenteditable`, so a subform cell's label associates the other way and
18
+ * hands its click back through {@link focus}. */
19
+ labelledBy?: string;
20
+ /** The parked `description` (FieldLabel) → `aria-describedby`. */
21
+ describedBy?: string;
22
+ onChange: (rt: Content) => void;
23
+ /** Raw keydown, for a container whose own keys run through this leaf: the
24
+ * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
25
+ * keymap, so the state it reads is the one this keystroke has yet to change. */
26
+ onKey?: (e: KeyboardEvent) => void;
27
+ }
28
+ declare const ProseValue: import("svelte").Component<Props, {
29
+ focus: () => void;
30
+ setCaret: (pos: number) => void;
31
+ }, "">;
32
+ type ProseValue = ReturnType<typeof ProseValue>;
33
+ export default ProseValue;
@@ -4,14 +4,16 @@
4
4
  a container, `{value: <member>, …that member's fields}`, and commits whole: it is one
5
5
  cell to `Addr`, so there is no per-cell write address.
6
6
 
7
- The cells are {@link ObjectField}, which already draws a scalar field set keyed by
7
+ The cells are {@link ObjectField}, which already draws a leaf field set keyed by
8
8
  name over a container its parent commits by value. It takes the whole container as its
9
9
  `value` and the world's declaration as its `properties`, so what it hands back is the
10
10
  container with one cell written — and cells outside the drawn world ride through
11
- untouched rather than needing to be merged back.
11
+ untouched rather than needing to be merged back. A content cell reads at the key it
12
+ declares: the boundary's schema walk unions the worlds, so a dormant cell reads absent
13
+ rather than raising.
12
14
  -->
13
15
  <script lang="ts">
14
- import type { QuillFieldSchema } from '@quillmark/wasm';
16
+ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
15
17
  import EnumField from './EnumField.svelte';
16
18
  import ObjectField from './ObjectField.svelte';
17
19
  import {
@@ -34,6 +36,10 @@
34
36
  id?: string;
35
37
  labelledBy?: string;
36
38
  describedBy?: string;
39
+ /** The boundary's nested content read for this field, which the cells take
40
+ * unprefixed: a variant's cell is one key in from the container it rests as, so
41
+ * a `plaintext` cell's path is its own name ({@link ObjectField}). */
42
+ contentAt: (path: PathStep[]) => Content | undefined;
37
43
  onCommit: (v: Record<string, unknown> | undefined) => void;
38
44
  optionAllowed?: (value: string) => boolean;
39
45
  enumDisallowed?: 'hide' | 'disable';
@@ -46,6 +52,7 @@
46
52
  id,
47
53
  labelledBy,
48
54
  describedBy,
55
+ contentAt,
49
56
  onCommit,
50
57
  optionAllowed,
51
58
  enumDisallowed
@@ -80,6 +87,7 @@
80
87
  idBase={id}
81
88
  {labelledBy}
82
89
  {describedBy}
90
+ {contentAt}
83
91
  onCommit={(obj) => onCommit(obj)}
84
92
  />
85
93
  {/key}
@@ -1,4 +1,4 @@
1
- import type { QuillFieldSchema } from '@quillmark/wasm';
1
+ import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
2
2
  interface Props {
3
3
  /** The stored container, or undefined while the field is unset. */
4
4
  value: Record<string, unknown> | undefined;
@@ -12,6 +12,10 @@ interface Props {
12
12
  id?: string;
13
13
  labelledBy?: string;
14
14
  describedBy?: string;
15
+ /** The boundary's nested content read for this field, which the cells take
16
+ * unprefixed: a variant's cell is one key in from the container it rests as, so
17
+ * a `plaintext` cell's path is its own name ({@link ObjectField}). */
18
+ contentAt: (path: PathStep[]) => Content | undefined;
15
19
  onCommit: (v: Record<string, unknown> | undefined) => void;
16
20
  optionAllowed?: (value: string) => boolean;
17
21
  enumDisallowed?: 'hide' | 'disable';
@@ -25,9 +25,10 @@ export interface VisualStrings extends TableChromeStrings, SlashStrings {
25
25
  * ghosts ({@link EnumField}). */
26
26
  enumUnsetTag: string;
27
27
  /**
28
- * What stands where a subform declines to recurse (a nested prose / array /
29
- * object property), and where to edit that property instead. The whole sentence,
30
- * since a translator orders the kind within it.
28
+ * What stands where a subform declines to recurse — a nested `array` or `object`
29
+ * property, whose own figure would nest a field past the depth this one draws —
30
+ * and where to edit that property instead. The whole sentence, since a translator
31
+ * orders the kind within it.
31
32
  */
32
33
  nestedUnsupported: (kind: string) => string;
33
34
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/svelte",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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.108.0-0",
69
+ "@quillmark/wasm": ">=0.108.1-0",
70
70
  "svelte": "^5.0.0"
71
71
  },
72
72
  "dependencies": {
@@ -1,139 +0,0 @@
1
- <!--
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
- this is not a `createField` leaf: it mounts a minimal PM view over the codec's
6
- decode/encode + inline schema, and on every edit hands the re-encoded
7
- `Content` up to the parent {@link ArrayField}, which commits the whole array
8
- by value (`writer.set(field, arrayWithElementReplaced)`). Anchors within an
9
- element are dropped on that value write: acceptable for inline refs. Mounts
10
- once per stable element id (no reset on the parent's re-derive).
11
-
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.
17
- -->
18
- <script lang="ts">
19
- import { onMount } from 'svelte';
20
- import { EditorState, Selection } from 'prosemirror-state';
21
- import { EditorView } from 'prosemirror-view';
22
- import {
23
- decode,
24
- pmToContent,
25
- inlineSchema,
26
- plaintextSchema,
27
- proseLeafPlugins,
28
- buildLineIndex,
29
- usvToPM
30
- } from '../core/codec/index.js';
31
- import './controls.css';
32
- import type { Content } from '@quillmark/wasm';
33
-
34
- interface Props {
35
- /** This element's content, read at mount (`ArrayField`, through the boundary's
36
- * element read). A thunk rather than a value: the parent re-derives per
37
- * revision and this leaf takes its state once, so a value prop would be a
38
- * boundary read per row per render, all but one of them discarded. */
39
- content: () => Content;
40
- /** The mark-free schema (a `plaintext` item): literal text, no formatting,
41
- * exactly as the scalar field of that type mounts. */
42
- plaintext?: boolean;
43
- /** Accessible name for the editable region (the array has no per-element label). */
44
- label?: string;
45
- onChange: (rt: Content) => void;
46
- /** Raw keydown, for a container whose own keys run through this element: the
47
- * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
48
- * keymap, so the state it reads is the one this keystroke has yet to change. */
49
- onKey?: (e: KeyboardEvent) => void;
50
- }
51
- let { content, plaintext = false, label, onChange, onKey }: Props = $props();
52
-
53
- let containerEl: HTMLDivElement | undefined = $state();
54
- let view: EditorView | undefined;
55
- /** Take the caret: what a parent placing focus on this element calls. The view's
56
- * focus, not the element's: a PM view restores its selection, where a bare DOM
57
- * focus on a contenteditable leaves the caret unplaced. And the reveal beside it,
58
- * since PM's focus prevents the scroll a `string` element's input takes by
59
- * default — an array whose two item types revealed differently is one row of it
60
- * landing off screen. */
61
- export function focus(): void {
62
- if (!view) return;
63
- view.focus();
64
- view.dom.scrollIntoView({ block: 'nearest' });
65
- }
66
- /** Take the caret to USV `pos` in this element's own content: what a landing on an
67
- * element address resolves to when the compile answered a cluster-exact offset
68
- * (`leaves.ts`). `createField`'s body over the view this component owns.
69
- *
70
- * The index is built here rather than held: a landing is a discrete act, and one
71
- * doc walk per click is cheaper than a cache to invalidate per keystroke. `usvToPM`
72
- * clamps, so an offset past a value the compile has moved on from lands the end.
73
- *
74
- * `Selection.near`, not `TextSelection.create`: the mapped position can be
75
- * non-inline. Focus first, then the caret, flagged for scroll — the order and the
76
- * reasons are `FieldController.setCaret`'s (`codec/field.ts`). */
77
- export function setCaret(pos: number): void {
78
- if (!view) return;
79
- const pm = usvToPM(buildLineIndex(view.state.doc), pos);
80
- const sel = Selection.near(view.state.doc.resolve(pm));
81
- view.focus();
82
- view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
83
- }
84
-
85
- onMount(() => {
86
- if (!containerEl) return;
87
- // An array element is an inline, value-by-value leaf: the same keymap and
88
- // plugin stack a `createField` leaf mounts (shared `proseLeafPlugins`), minus
89
- // the anchor-position plugin (element anchors are dropped on the array's value
90
- // write, per the header). Its own `dispatchTransaction` hands `Content` up.
91
- //
92
- // The schema is the declared type's, as `createField` picks it: `plaintext`
93
- // declares no mark types, so there is nothing to toggle, to paste in, or to
94
- // mint a shorthand with.
95
- const schema = plaintext ? plaintextSchema : inlineSchema;
96
- const pmDoc = decode(content(), schema);
97
- const state = EditorState.create({
98
- doc: pmDoc,
99
- plugins: proseLeafPlugins(schema, { inline: true })
100
- });
101
- const mounted = new EditorView(containerEl, {
102
- state,
103
- // Names the `contenteditable` for assistive tech (the array row has no
104
- // label element to associate).
105
- attributes: label ? { 'aria-label': label } : undefined,
106
- dispatchTransaction(tr) {
107
- const next = mounted.state.apply(tr);
108
- mounted.updateState(next);
109
- if (tr.docChanged) onChange(pmToContent(next.doc));
110
- },
111
- handleDOMEvents: {
112
- keydown: (_v, e) => {
113
- onKey?.(e);
114
- return false;
115
- }
116
- }
117
- });
118
- view = mounted;
119
- return () => {
120
- view = undefined;
121
- mounted.destroy();
122
- };
123
- });
124
- </script>
125
-
126
- <!-- `.qm-control-box` (controls.css) is the whole box, so an array of `richtext` and
127
- an array of `string` render rows of equal height. No floor: the reset in
128
- `core/codec/prose.css` makes one line of prose measure one line. Width is the
129
- row's to give: the element fills the track it is placed in (`ArrayField`). -->
130
- <div bind:this={containerEl} class="qm-array-prose qm-control-box qm-focus-ring-within"></div>
131
-
132
- <style>
133
- /* Caret-primary, matching ProseField: the contenteditable's own outline is
134
- dropped and the ring rides the wrapper (`qm-focus-ring-within`,
135
- controls.css), which is where the box is. */
136
- .qm-array-prose :global(.ProseMirror) {
137
- outline: none;
138
- }
139
- </style>
@@ -1,25 +0,0 @@
1
- import './controls.css';
2
- import type { Content } from '@quillmark/wasm';
3
- interface Props {
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;
12
- /** Accessible name for the editable region (the array has no per-element label). */
13
- label?: string;
14
- onChange: (rt: Content) => void;
15
- /** Raw keydown, for a container whose own keys run through this element: the
16
- * array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
17
- * keymap, so the state it reads is the one this keystroke has yet to change. */
18
- onKey?: (e: KeyboardEvent) => void;
19
- }
20
- declare const ProseArrayElement: import("svelte").Component<Props, {
21
- focus: () => void;
22
- setCaret: (pos: number) => void;
23
- }, "">;
24
- type ProseArrayElement = ReturnType<typeof ProseArrayElement>;
25
- export default ProseArrayElement;