@quillmark/svelte 0.19.0 → 0.20.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.
Files changed (55) hide show
  1. package/NOTICE +4 -2
  2. package/dist/core/codec/blocks.d.ts +11 -2
  3. package/dist/core/codec/blocks.js +45 -14
  4. package/dist/core/codec/breaks.d.ts +3 -2
  5. package/dist/core/codec/breaks.js +3 -2
  6. package/dist/core/codec/decode.d.ts +7 -3
  7. package/dist/core/codec/decode.js +34 -8
  8. package/dist/core/codec/field.d.ts +11 -5
  9. package/dist/core/codec/field.js +11 -8
  10. package/dist/core/codec/index.d.ts +1 -1
  11. package/dist/core/codec/index.js +1 -1
  12. package/dist/core/codec/islands.d.ts +3 -1
  13. package/dist/core/codec/islands.js +8 -5
  14. package/dist/core/codec/markdown-paste.d.ts +9 -0
  15. package/dist/core/codec/markdown-paste.js +49 -0
  16. package/dist/core/codec/prose.css +109 -1
  17. package/dist/core/codec/schema.d.ts +8 -2
  18. package/dist/core/codec/schema.js +16 -5
  19. package/dist/core/codec/slash.d.ts +9 -6
  20. package/dist/core/codec/slash.js +0 -0
  21. package/dist/core/codec/table-paste.d.ts +7 -0
  22. package/dist/core/codec/table-paste.js +132 -0
  23. package/dist/core/codec/table-view.d.ts +12 -0
  24. package/dist/core/codec/table-view.js +401 -57
  25. package/dist/core/codec/table.d.ts +38 -16
  26. package/dist/core/codec/table.js +106 -35
  27. package/dist/core/theme.css +1 -1
  28. package/dist/visual/ArrayField.svelte +4 -0
  29. package/dist/visual/Field.svelte +8 -2
  30. package/dist/visual/Field.svelte.d.ts +1 -1
  31. package/dist/visual/FieldHint.svelte +62 -10
  32. package/dist/visual/FormatPopover.svelte +27 -31
  33. package/dist/visual/FormatPopover.svelte.d.ts +2 -2
  34. package/dist/visual/MatrixAdd.svelte +13 -132
  35. package/dist/visual/MatrixAdd.svelte.d.ts +2 -5
  36. package/dist/visual/MatrixField.svelte +5 -63
  37. package/dist/visual/MatrixField.svelte.d.ts +0 -3
  38. package/dist/visual/ObjectField.svelte +7 -3
  39. package/dist/visual/ObjectField.svelte.d.ts +4 -2
  40. package/dist/visual/ProseField.svelte +2 -1
  41. package/dist/visual/ProseValue.svelte +113 -40
  42. package/dist/visual/ProseValue.svelte.d.ts +8 -4
  43. package/dist/visual/VisualEditorInner.svelte +22 -22
  44. package/dist/visual/VisualEditorInner.svelte.d.ts +2 -2
  45. package/dist/visual/checklists.d.ts +0 -79
  46. package/dist/visual/checklists.js +0 -164
  47. package/dist/visual/guidance.d.ts +15 -0
  48. package/dist/visual/guidance.js +37 -0
  49. package/dist/visual/leaves.d.ts +27 -1
  50. package/dist/visual/leaves.js +46 -0
  51. package/dist/visual/strings.d.ts +3 -8
  52. package/dist/visual/strings.js +0 -3
  53. package/dist/visual/structure.js +1 -1
  54. package/dist/visual/tips.js +1 -1
  55. package/package.json +8 -8
@@ -1,7 +1,7 @@
1
- import { type FieldController } from '../core/codec/index.js';
1
+ import type { ActiveProse } from './leaves.js';
2
2
  interface Props {
3
3
  /** The observation seam VisualEditor exposes over its `leaves` registry. */
4
- getActiveLeaf: () => FieldController | undefined;
4
+ getActiveLeaf: () => ActiveProse | undefined;
5
5
  }
6
6
  declare const FormatPopover: import("svelte").Component<Props, {}, "">;
7
7
  type FormatPopover = ReturnType<typeof FormatPopover>;
@@ -1,76 +1,38 @@
1
1
  <!--
2
- An open matrix's add box (VISUAL_EDITOR §"The matrix"): a combobox whose options are
3
- what the typed words name across the document's checklists, best first, and last the
4
- item they would add to this one. The list stands in flow under the input rather than
5
- floating, so a closing group panel clips nothing of it, and it closes while the focus
6
- is elsewhere. The input keeps the caret throughout and the active option is
7
- `aria-activedescendant`, the combobox pattern: ↑/↓ walk, Enter picks, Escape clears.
8
- Unwalked, the active option is the first result the words name outright, else the add
9
- option: a result named only by abbreviation, which may stand in another card, is
10
- taken by an arrow or a press and never by Enter alone.
2
+ An open matrix's add box (VISUAL_EDITOR §"The matrix"): a plain text input. Enter adds
3
+ what was typed, trimmed, and clears the box; Escape clears it.
11
4
  -->
12
5
  <script lang="ts">
13
6
  import Icon from './icons/Icon.svelte';
14
- import type { AddOption } from './checklists.js';
15
7
  import './controls.css';
16
8
 
17
9
  interface Props {
18
- /** The input's id, and the base its listbox and options derive theirs from. */
19
10
  id: string;
20
11
  /** The input's accessible name and its words at rest. */
21
12
  placeholder: string;
22
- /** The options `query` offers, best first; the add option, where there is one, last. */
23
- options: (query: string) => AddOption[];
24
- onChoose: (option: AddOption, query: string) => void;
13
+ /** Add an item titled `title`, never empty. */
14
+ onAdd: (title: string) => void;
25
15
  }
26
- let { id, placeholder, options, onChoose }: Props = $props();
16
+ let { id, placeholder, onAdd }: Props = $props();
27
17
 
28
- let query = $state('');
29
- /** The walked option, `undefined` until an arrow or the pointer moves it. */
30
- let walked = $state<number | undefined>();
31
- let focused = $state(false);
18
+ let typed = $state('');
32
19
  let inputEl = $state<HTMLInputElement | undefined>();
33
- const shown = $derived(query.trim() ? options(query.trim()) : []);
34
- const open = $derived(focused && shown.length > 0);
35
- const active = $derived.by(() => {
36
- if (walked !== undefined) return Math.min(walked, shown.length - 1);
37
- const strong = shown.findIndex((o) => o.strong);
38
- if (strong >= 0) return strong;
39
- const add = shown.findIndex((o) => o.add);
40
- return add >= 0 ? add : 0;
41
- });
42
- const listId = $derived(`${id}-list`);
43
- const optionId = (k: number): string => `${id}-opt-${k}`;
44
20
 
45
21
  export function focus(): void {
46
22
  inputEl?.focus();
47
23
  }
48
24
 
49
- function choose(k: number): void {
50
- const option = shown[k];
51
- if (!option) return;
52
- const typed = query.trim();
53
- query = '';
54
- walked = undefined;
55
- onChoose(option, typed);
56
- }
57
-
58
25
  function onkeydown(e: KeyboardEvent): void {
59
26
  if (e.isComposing) return;
60
- if (e.key === 'ArrowDown' && open) {
61
- e.preventDefault();
62
- walked = (active + 1) % shown.length;
63
- } else if (e.key === 'ArrowUp' && open) {
64
- e.preventDefault();
65
- walked = (active - 1 + shown.length) % shown.length;
66
- } else if (e.key === 'Enter' && open) {
27
+ if (e.key === 'Enter' && typed.trim()) {
67
28
  e.preventDefault();
68
- choose(active);
69
- } else if (e.key === 'Escape' && query) {
29
+ const title = typed.trim();
30
+ typed = '';
31
+ onAdd(title);
32
+ } else if (e.key === 'Escape' && typed) {
70
33
  e.preventDefault();
71
34
  e.stopPropagation();
72
- query = '';
73
- walked = undefined;
35
+ typed = '';
74
36
  }
75
37
  }
76
38
  </script>
@@ -80,67 +42,21 @@
80
42
  <Icon name="plus" class="qm-matrix-add-glyph" />
81
43
  <input
82
44
  bind:this={inputEl}
45
+ bind:value={typed}
83
46
  {id}
84
47
  class="qm-input qm-focus-ring"
85
48
  type="text"
86
- role="combobox"
87
49
  autocomplete="off"
88
50
  aria-label={placeholder}
89
- aria-autocomplete="list"
90
- aria-expanded={open}
91
- aria-controls={listId}
92
- aria-activedescendant={open ? optionId(active) : undefined}
93
51
  {placeholder}
94
- value={query}
95
- oninput={(e) => {
96
- query = e.currentTarget.value;
97
- walked = undefined;
98
- }}
99
- onfocus={() => (focused = true)}
100
- onblur={() => (focused = false)}
101
52
  {onkeydown}
102
53
  />
103
54
  </span>
104
- <!-- Always mounted, so `aria-controls` names an element at rest too; empty, it draws
105
- nothing. -->
106
- <div
107
- class="qm-menu-surface qm-matrix-add-list"
108
- role="listbox"
109
- id={listId}
110
- aria-label={placeholder}
111
- hidden={!open}
112
- >
113
- {#each shown as option, k (option.key)}
114
- <!-- The input keeps focus and owns the keys; a press picks without taking it. -->
115
- <!-- svelte-ignore a11y_click_events_have_key_events -->
116
- <div
117
- class={['qm-menu-item qm-matrix-add-option', { add: option.add }]}
118
- role="option"
119
- id={optionId(k)}
120
- tabindex="-1"
121
- aria-selected={k === active}
122
- data-highlighted={k === active ? '' : undefined}
123
- onpointerdown={(e) => e.preventDefault()}
124
- onpointermove={() => (walked = k)}
125
- onclick={() => choose(k)}
126
- >
127
- {#if option.add}<Icon name="plus" class="qm-matrix-add-glyph" />{/if}
128
- <span class="qm-matrix-add-title">{option.title}</span>
129
- {#if option.note}<span class="qm-matrix-add-note">{option.note}</span>{/if}
130
- </div>
131
- {/each}
132
- </div>
133
55
  </div>
134
56
 
135
57
  <style>
136
- /* The box's text, and every option's, starts past the glyph slot at its leading edge,
137
- so a result reads in the column the typed words stand in and the add option's `+`
138
- stands under the box's. */
139
58
  .qm-matrix-add {
140
59
  --_slot: calc(var(--_qm-space-2) * 2 + var(--_qm-glyph-control));
141
- display: flex;
142
- flex-direction: column;
143
- gap: var(--_qm-space-half);
144
60
  min-width: 0;
145
61
  }
146
62
  .qm-matrix-add-field {
@@ -172,39 +88,4 @@
172
88
  .qm-matrix-add-field .qm-input:placeholder-shown:not(:focus):hover {
173
89
  background: var(--_qm-surface-hover);
174
90
  }
175
- /* The list is a menu, raised over the card rather than cut into it, so its options
176
- read as choices and not as a second field. Its edge and inset come off each
177
- option's, which keeps the column. */
178
- .qm-matrix-add-list {
179
- display: flex;
180
- flex-direction: column;
181
- }
182
- .qm-matrix-add-list[hidden] {
183
- display: none;
184
- }
185
- .qm-matrix-add-option {
186
- position: relative;
187
- display: flex;
188
- align-items: baseline;
189
- justify-content: space-between;
190
- gap: var(--_qm-space-2);
191
- padding-inline-start: calc(var(--_slot) - var(--_qm-space-half) - var(--_qm-border-width));
192
- font-size: var(--_qm-text-body);
193
- color: var(--_qm-ink);
194
- }
195
- .qm-matrix-add-option :global(.qm-matrix-add-glyph) {
196
- inset-inline-start: calc(var(--_qm-space-2) - var(--_qm-space-half) - var(--_qm-border-width));
197
- inset-block-start: 50%;
198
- translate: 0 -50%;
199
- }
200
- /* The add option reads at the label ink: it offers a new item, where a result names
201
- one the document already knows. */
202
- .qm-matrix-add-option.add {
203
- color: var(--_qm-ink-label);
204
- }
205
- .qm-matrix-add-note {
206
- flex-shrink: 0;
207
- font-size: var(--_qm-text-label);
208
- color: var(--_qm-ink-label);
209
- }
210
91
  </style>
@@ -1,12 +1,9 @@
1
- import type { AddOption } from './checklists.js';
2
1
  interface Props {
3
- /** The input's id, and the base its listbox and options derive theirs from. */
4
2
  id: string;
5
3
  /** The input's accessible name and its words at rest. */
6
4
  placeholder: string;
7
- /** The options `query` offers, best first; the add option, where there is one, last. */
8
- options: (query: string) => AddOption[];
9
- onChoose: (option: AddOption, query: string) => void;
5
+ /** Add an item titled `title`, never empty. */
6
+ onAdd: (title: string) => void;
10
7
  }
11
8
  declare const MatrixAdd: import("svelte").Component<Props, {
12
9
  focus: () => void;
@@ -10,8 +10,9 @@
10
10
 
11
11
  An open matrix's added items follow the roster in id order, as the plate prints them:
12
12
  held by being present, so each draws a fixed tick, its `title` as an input, a remove,
13
- and the same columns. The add box closes the list ({@link MatrixAdd}): it searches
14
- every checklist of the document before it offers to add what was typed.
13
+ and the same columns. The add box closes the list ({@link MatrixAdd}): what is typed
14
+ there is added under an id minted from it past every id the list holds, so a title the
15
+ list already carries is a second item.
15
16
 
16
17
  The control owns its label row, as an array does: the count of held items sits
17
18
  where an array's add chip sits, so {@link Field} skips its own label for it.
@@ -48,15 +49,7 @@
48
49
  onRoster,
49
50
  schemaAt
50
51
  } from './structure.js';
51
- import {
52
- checklists,
53
- matrixPrinted,
54
- searchChecklists,
55
- type AddOption,
56
- type Checklist,
57
- type ChecklistHit,
58
- type Searchable
59
- } from './checklists.js';
52
+ import { matrixPrinted } from './checklists.js';
60
53
  import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
61
54
  import type { LandingBox } from './leaves.js';
62
55
  import { propertyDomIds } from './domid.js';
@@ -85,9 +78,6 @@
85
78
  /** The field's control id: the base a member's tick and columns derive their own
86
79
  * names from, one `-m-<member id>` segment down. */
87
80
  idBase?: string;
88
- /** The field's leaf key, where it is a card's own field: how the add box tells
89
- * this list from the rest of the document's (`checklists.ts`). */
90
- listKey?: string;
91
81
  /** The boundary's nested content read, rooted at this field: a member's content
92
82
  * column reads at `[id, column]` (`reader.getContentAt`). */
93
83
  contentAt: (path: PathStep[]) => Content | undefined;
@@ -104,16 +94,11 @@
104
94
  labelId,
105
95
  descriptionId,
106
96
  idBase,
107
- listKey,
108
97
  contentAt,
109
98
  onCommit,
110
99
  diagnostics
111
100
  }: Props = $props();
112
101
 
113
- // A card's own matrix searches the document's lists; one nested in a container is
114
- // not indexed, and searches its own items alone.
115
- const index = checklists();
116
- const others = (): readonly Checklist[] => (listKey != null ? (index?.list() ?? []) : []);
117
102
  const members = $derived(matrixMembers(schema.members));
118
103
  const compact = $derived(!!schema.ui?.compact);
119
104
  const hasColumns = $derived(Object.keys(schema.properties ?? {}).length > 0);
@@ -220,48 +205,6 @@
220
205
  }
221
206
 
222
207
  // ── The add box ────────────────────────────────────────────────────────────
223
- /** A result's list: this one, or another of the document's. */
224
- type Hit = ChecklistHit<Checklist | undefined>;
225
- function options(query: string): AddOption[] {
226
- const own: Searchable<Checklist | undefined> = { list: undefined, schema, value: map };
227
- const lists: Searchable<Checklist | undefined>[] = others()
228
- .filter((l) => l.key !== listKey)
229
- .map((l) => ({ list: l, schema: l.schema, value: l.value }));
230
- const hits = searchChecklists(query, [own, ...lists]);
231
- const out: AddOption[] = hits.map((h, k) => ({
232
- key: `hit-${k}`,
233
- title: h.title,
234
- note:
235
- [
236
- h.list ? t.strings.matrixIn(h.list.label) : undefined,
237
- h.held ? t.strings.matrixHeldTag : undefined
238
- ]
239
- .filter(Boolean)
240
- .join(' · ') || undefined,
241
- strong: h.strong,
242
- payload: h
243
- }));
244
- // Offered last, and not where an item of this list already carries the words.
245
- const fold = (s: string) => s.trim().toLowerCase();
246
- const dup = hits.some((h) => h.list === undefined && fold(h.title) === fold(query));
247
- if (!dup) out.push({ key: 'add', title: t.strings.matrixAddNew(query), add: true });
248
- return out;
249
- }
250
- async function choose(option: AddOption, query: string): Promise<void> {
251
- if (option.add) return add(query);
252
- const hit = option.payload as Hit;
253
- const other = hit.list;
254
- if (other) {
255
- if (!hit.held) other.commit(commitMember(other.value, hit.id, memberWrite({})));
256
- index?.land(`${other.path}.${hit.id}`);
257
- return;
258
- }
259
- if (!hit.held) commit(commitMember(map, hit.id, ticked(hit.id)));
260
- await flush();
261
- focusPath([hit.id]);
262
- const box = ref(memberEls, hit.id);
263
- if (box) bloomInside(box);
264
- }
265
208
  async function add(title: string): Promise<void> {
266
209
  const taken = (id: string) => onRoster(schema, id) || (map != null && Object.hasOwn(map, id));
267
210
  const id = addedId(title, taken);
@@ -397,8 +340,7 @@
397
340
  bind:this={addEl}
398
341
  id={`${base}-add`}
399
342
  placeholder={t.strings.matrixAdd(label ?? '')}
400
- {options}
401
- onChoose={choose}
343
+ onAdd={add}
402
344
  />
403
345
  {/if}
404
346
  <DiagnosticList diagnostics={foot} />
@@ -18,9 +18,6 @@ interface Props {
18
18
  /** The field's control id: the base a member's tick and columns derive their own
19
19
  * names from, one `-m-<member id>` segment down. */
20
20
  idBase?: string;
21
- /** The field's leaf key, where it is a card's own field: how the add box tells
22
- * this list from the rest of the document's (`checklists.ts`). */
23
- listKey?: string;
24
21
  /** The boundary's nested content read, rooted at this field: a member's content
25
22
  * column reads at `[id, column]` (`reader.getContentAt`). */
26
23
  contentAt: (path: PathStep[]) => Content | undefined;
@@ -81,8 +81,10 @@
81
81
  /**
82
82
  * The parent control's DOM id: the base each property's own three names derive
83
83
  * from ({@link propertyDomIds}). Absent — a subform mounted with no field around
84
- * it, or a table's row — the properties fall back to `aria-label`, which names
85
- * them without a `for` target to click.
84
+ * it — the properties fall back to `aria-label`, which names them without a
85
+ * `for` target to click. A table's row takes the table's own and names its
86
+ * cells the same fallback way, reading only the description off it: the
87
+ * header parks one per column, which every row's cell in it points at.
86
88
  */
87
89
  idBase?: string;
88
90
  /** Accessible-name prefix used only on the `aria-label` fallback above. */
@@ -268,7 +270,8 @@
268
270
  {@const kind = controlKind(sub)}
269
271
  {@const ids = idBase && !bare ? propertyDomIds(idBase, key) : undefined}
270
272
  {@const named = ids ? undefined : fallbackName(key, sub)}
271
- {@const describes = sub.description && ids ? ids.description : undefined}
273
+ {@const describes =
274
+ sub.description && idBase ? propertyDomIds(idBase, key).description : undefined}
272
275
  {@const deep = routed.below.get(key)}
273
276
  {@const own = isContainer(kind) ? undefined : deep?.map((d) => d.diagnostic)}
274
277
  <!-- `for` reaches the labelable controls, a variant through its discriminant; the
@@ -359,6 +362,7 @@
359
362
  <ProseValue
360
363
  bind:this={proseEls[key]}
361
364
  content={() => contentAt([key])}
365
+ value={obj[key]}
362
366
  fallback={contentDefault(sub)}
363
367
  plaintext={baseType(sub) === 'plaintext'}
364
368
  placeholder={noneOf(key, sub)}
@@ -14,8 +14,10 @@ interface Props {
14
14
  /**
15
15
  * The parent control's DOM id: the base each property's own three names derive
16
16
  * from ({@link propertyDomIds}). Absent — a subform mounted with no field around
17
- * it, or a table's row — the properties fall back to `aria-label`, which names
18
- * them without a `for` target to click.
17
+ * it — the properties fall back to `aria-label`, which names them without a
18
+ * `for` target to click. A table's row takes the table's own and names its
19
+ * cells the same fallback way, reading only the description off it: the
20
+ * header parks one per column, which every row's cell in it points at.
19
21
  */
20
22
  idBase?: string;
21
23
  /** Accessible-name prefix used only on the `aria-label` fallback above. */
@@ -123,6 +123,7 @@
123
123
  placeholder,
124
124
  placeholderUntilEdit,
125
125
  tableStrings: () => t.strings,
126
+ slashStrings: () => t.strings,
126
127
  onSlash: (next) => {
127
128
  slash = next;
128
129
  },
@@ -155,7 +156,7 @@
155
156
  controller?.setFallback(fallback);
156
157
  });
157
158
 
158
- // Only a narrowed or a plain leaf can hold, so only one of them asks.
159
+ // Only a narrowed or a plain leaf holds by what a re-validation says, so only one of them asks.
159
160
  $effect(() => {
160
161
  void diagnostics;
161
162
  if (inline || plaintext) controller?.applyExternal();
@@ -11,24 +11,35 @@
11
11
  Mounts once per stable key (an element's session id, a property's name), so the
12
12
  parent's re-derive leaves the caret where it is.
13
13
 
14
+ Having no address, it registers no controller: it announces its views into the
15
+ registry's by-value lane under the field it stands in (`leaves.ts`), the formatting
16
+ popover's door to it. A focus on an editable leaf announces them, and so does a write
17
+ releasing the leaf under the caret; a hold and an unmount withdraw them, and a blur
18
+ does not, the popover's link prompt taking the focus from the view it writes to.
19
+
14
20
  The content is read, not passed: `reader.getContentAt(addr, path)` decodes through
15
21
  the codec the leaf's own declared type names, so what it rests as — the content
16
22
  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.
23
+ being the row's or the cell's business. It is read at mount and again wherever the
24
+ parent's re-derive hands down a `value` that moved, and a read differing from the
25
+ content the leaf last committed or took replaces the view, the gate `createField`
26
+ re-hydrates through (`reconcile.ts`): a write that lands in the store under a mounted
27
+ leaf is what its next keystroke edits. Its own commit is read back as the store took
28
+ it, so the echo replaces nothing and the caret and the history stay.
19
29
 
20
30
  The schema is the declared type's where the parent has room for it: a subform cell
21
31
  declaring no `inline` takes the full row ({@link ObjectField}), a `richtext` one on the
22
32
  block schema and a `plaintext` one on the plain schema, paragraphs and hard breaks.
23
33
  An array element is one textblock whatever it declares, since Enter there is the
24
- repeater's. There is no slash menu at either width, and an island draws as its
25
- placeholder: an atom a keystroke can delete and no view edits.
34
+ repeater's. There is no slash menu at either width, and no island view.
26
35
 
27
- A leaf over content its schema cannot hold is held: a narrowed one over anything but
28
- one plain paragraph (`fitsInline`), a plain one over anything but plain lines
29
- (`fitsPlain`). Its decode would join or drop what is left over, and the first
30
- keystroke would write that loss back, so a held leaf draws its content on the block
31
- schema, read-only, with a note inside its box, and commits nothing.
36
+ A leaf over content it cannot edit is held (`fitsLeaf`): a narrowed one over anything
37
+ but one plain paragraph, a plain one over anything but plain lines, where the decode
38
+ would join or drop what is left over for the first keystroke to write back, and a block
39
+ one over an island, which draws as its placeholder tag, an atom a keystroke deletes
40
+ unseen. A held leaf draws its content on the block schema, read-only, with a note
41
+ inside its box, and commits nothing. The hold is judged of what each mount and each
42
+ replacement reads, so a write moving the value takes it or releases it.
32
43
  -->
33
44
  <script lang="ts">
34
45
  import { untrack } from 'svelte';
@@ -36,6 +47,7 @@
36
47
  import { EditorView } from 'prosemirror-view';
37
48
  import {
38
49
  blockSchema,
50
+ createReconciler,
39
51
  decode,
40
52
  emptyContent,
41
53
  fitsLeaf,
@@ -45,18 +57,26 @@
45
57
  proseAttributes,
46
58
  proseLeafPlugins,
47
59
  buildLineIndex,
48
- usvToPM
60
+ pmToUsv,
61
+ usvToPM,
62
+ valueEqual,
63
+ type LeafViews
49
64
  } from '../core/codec/index.js';
65
+ import { valueLeafSeat } from './leaves.js';
50
66
  import { wording } from './strings.js';
51
67
  import './controls.css';
52
68
  import type { Content } from '@quillmark/wasm';
53
69
 
54
70
  interface Props {
55
- /** This leaf's content, read at mount (the parent's boundary read), `undefined`
56
- * where nothing is stored. A thunk rather than a value: the parent re-derives
57
- * per revision and this leaf takes its state once, so a value prop would be a
58
- * boundary read per leaf per render, all but one of them discarded. */
71
+ /** This leaf's content (the parent's boundary read), `undefined` where nothing is
72
+ * stored: read at mount and wherever {@link value} moves. A thunk rather than a
73
+ * value: the parent re-derives per revision, and a value prop would be a
74
+ * boundary read per leaf per render, all but the moved one discarded. */
59
75
  content: () => Content | undefined;
76
+ /** This leaf's value as the parent's re-derive delivers it, stored form and all:
77
+ * compared with the last one delivered, never drawn, so a move is what re-reads
78
+ * {@link content}. */
79
+ value?: unknown;
60
80
  /** The default an unset leaf holds, where it prints: drawn at the default rung,
61
81
  * and handed up whole with the first edit in it, as `createField` takes one. */
62
82
  fallback?: Content;
@@ -87,6 +107,7 @@
87
107
  }
88
108
  let {
89
109
  content,
110
+ value,
90
111
  fallback,
91
112
  plaintext = false,
92
113
  block = false,
@@ -99,10 +120,13 @@
99
120
  }: Props = $props();
100
121
 
101
122
  const t = wording();
123
+ const seat = valueLeafSeat();
102
124
  const uid = $props.id();
103
125
  const heldId = `${uid}-held`;
104
126
  let held = $state(false);
105
127
  let view: EditorView | undefined;
128
+ /** What the mounted view does with a value the parent's re-derive delivers. */
129
+ let follow: ((next: unknown) => void) | undefined;
106
130
  /** Take the caret: what a parent placing focus on this leaf calls. The view's
107
131
  * focus, not the element's: a PM view restores its selection, where a bare DOM
108
132
  * focus on a contenteditable leaves the caret unplaced. And the reveal beside it,
@@ -135,46 +159,61 @@
135
159
 
136
160
  function mountView(mount: HTMLDivElement): () => void {
137
161
  return untrack(() => {
138
- // The same keymap and plugin stack a `createField` leaf mounts (shared
139
- // `proseLeafPlugins`), minus the anchor-position plugin: anchors are dropped on
140
- // the parent's value write, per the header.
141
162
  const inline = !block;
163
+ const leaf = { inline, plaintext, islands: false };
164
+ const names = { label, labelledBy, describedBy };
165
+ const shown = (stored: Content | undefined): Content => stored ?? fallback ?? emptyContent();
142
166
  const stored = content();
143
- const rt = stored ?? fallback ?? emptyContent();
167
+ let seen = value;
144
168
  let defaulted = stored === undefined && !!fallback;
145
- held = !fitsLeaf(rt, { inline, plaintext });
146
- const named = held
147
- ? heldAttributes({ label, labelledBy, describedBy }, heldId)
148
- : (proseAttributes({ label, labelledBy, describedBy }) ?? {});
149
- const defaultNamed = { ...named, 'data-default': '' };
150
- const schema = held ? blockSchema : leafSchema({ plaintext, inline });
151
- const state = EditorState.create({
152
- doc: decode(rt, schema),
153
- plugins: held
154
- ? []
155
- : proseLeafPlugins(schema, {
156
- inline,
157
- placeholder: () => placeholder,
158
- placeholderUntilEdit: true
159
- })
160
- });
169
+ const reconciler = createReconciler(shown(stored));
170
+ held = !fitsLeaf(reconciler.last, leaf);
171
+ // Which of `aria-label` / `aria-labelledby` wins is the codec's one answer
172
+ // (`proseAttributes`), so a cell carrying a label element and a row carrying
173
+ // none cannot name their regions by different rules.
174
+ const plainNamed = proseAttributes(names) ?? {};
175
+ const heldNamed = heldAttributes(names, heldId);
176
+ const build = (rt: Content): EditorState => {
177
+ // The same keymap and plugin stack a `createField` leaf mounts (shared
178
+ // `proseLeafPlugins`), minus the anchor-position plugin: anchors are dropped on
179
+ // the parent's value write, per the header.
180
+ const schema = held ? blockSchema : leafSchema({ plaintext, inline });
181
+ return EditorState.create({
182
+ doc: decode(rt, schema),
183
+ plugins: held
184
+ ? []
185
+ : proseLeafPlugins(schema, {
186
+ inline,
187
+ placeholder: () => placeholder,
188
+ placeholderUntilEdit: true
189
+ })
190
+ });
191
+ };
161
192
  const mounted = new EditorView(
162
193
  { mount },
163
194
  {
164
- state,
195
+ state: build(reconciler.last),
165
196
  editable: () => !held,
166
- // Which of `aria-label` / `aria-labelledby` wins is the codec's one answer
167
- // (`proseAttributes`), so a cell carrying a label element and a row carrying
168
- // none cannot name their regions by different rules.
169
- attributes: () => (defaulted ? defaultNamed : named),
197
+ attributes: () => {
198
+ const named = held ? heldNamed : plainNamed;
199
+ return defaulted ? { ...named, 'data-default': '' } : named;
200
+ },
170
201
  dispatchTransaction(tr) {
171
202
  const next = mounted.state.apply(tr);
172
203
  const edit = tr.docChanged && !held;
173
204
  if (edit) defaulted = false;
174
205
  mounted.updateState(next);
175
- if (edit) onChange(pmToContent(next.doc));
206
+ if (!edit) return;
207
+ onChange(pmToContent(next.doc));
208
+ // The commit as the store took it, so its echo through the re-derive is no move.
209
+ seen = value;
210
+ reconciler.commit(shown(content()));
176
211
  },
177
212
  handleDOMEvents: {
213
+ focus: () => {
214
+ if (!held) seat?.focus(views);
215
+ return false;
216
+ },
178
217
  keydown: (_v, e) => {
179
218
  onKey?.(e);
180
219
  return false;
@@ -182,13 +221,47 @@
182
221
  }
183
222
  }
184
223
  );
224
+ const views: LeafViews = {
225
+ view: mounted,
226
+ focusedView: () => mounted,
227
+ nestedViews: () => []
228
+ };
185
229
  view = mounted;
230
+ follow = (next) => {
231
+ if (valueEqual(next, seen)) return;
232
+ seen = next;
233
+ const read = content();
234
+ const current = shown(read);
235
+ const holds = !fitsLeaf(current, leaf);
236
+ const wasDefaulted = defaulted;
237
+ defaulted = read === undefined && !!fallback;
238
+ if (holds === held && !reconciler.shouldRehydrate(current)) {
239
+ if (defaulted !== wasDefaulted) mounted.setProps({});
240
+ return;
241
+ }
242
+ held = holds;
243
+ if (held) seat?.release(views);
244
+ else if (mounted.hasFocus()) seat?.focus(views);
245
+ // The caret keeps its offset, clamped, as a field's re-hydrate keeps it.
246
+ const caret = pmToUsv(buildLineIndex(mounted.state.doc), mounted.state.selection.head);
247
+ const fresh = build(current);
248
+ const sel = Selection.near(fresh.doc.resolve(usvToPM(buildLineIndex(fresh.doc), caret)));
249
+ mounted.updateState(fresh.apply(fresh.tr.setSelection(sel)));
250
+ reconciler.commit(current);
251
+ };
186
252
  return () => {
253
+ seat?.release(views);
254
+ follow = undefined;
187
255
  view = undefined;
188
256
  mounted.destroy();
189
257
  };
190
258
  });
191
259
  }
260
+
261
+ $effect(() => {
262
+ const next = value;
263
+ untrack(() => follow?.(next));
264
+ });
192
265
  </script>
193
266
 
194
267
  <!-- `.qm-control-box` (controls.css) is the whole box, so an array of `richtext` and