@quillmark/svelte 0.18.0 → 0.20.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.
Files changed (56) 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 +17 -6
  29. package/dist/visual/ArrayField.svelte.d.ts +1 -1
  30. package/dist/visual/Field.svelte +9 -3
  31. package/dist/visual/Field.svelte.d.ts +1 -1
  32. package/dist/visual/FieldHint.svelte +62 -10
  33. package/dist/visual/FormatPopover.svelte +27 -31
  34. package/dist/visual/FormatPopover.svelte.d.ts +2 -2
  35. package/dist/visual/MatrixAdd.svelte +13 -132
  36. package/dist/visual/MatrixAdd.svelte.d.ts +2 -5
  37. package/dist/visual/MatrixField.svelte +5 -63
  38. package/dist/visual/MatrixField.svelte.d.ts +0 -3
  39. package/dist/visual/ObjectField.svelte +8 -4
  40. package/dist/visual/ObjectField.svelte.d.ts +4 -2
  41. package/dist/visual/ProseField.svelte +2 -1
  42. package/dist/visual/ProseValue.svelte +113 -40
  43. package/dist/visual/ProseValue.svelte.d.ts +8 -4
  44. package/dist/visual/VisualEditorInner.svelte +22 -22
  45. package/dist/visual/VisualEditorInner.svelte.d.ts +2 -2
  46. package/dist/visual/checklists.d.ts +0 -79
  47. package/dist/visual/checklists.js +0 -164
  48. package/dist/visual/guidance.d.ts +15 -0
  49. package/dist/visual/guidance.js +37 -0
  50. package/dist/visual/leaves.d.ts +27 -1
  51. package/dist/visual/leaves.js +46 -0
  52. package/dist/visual/strings.d.ts +3 -8
  53. package/dist/visual/strings.js +0 -3
  54. package/dist/visual/structure.js +1 -1
  55. package/dist/visual/tips.js +1 -1
  56. package/package.json +2 -2
@@ -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
@@ -1,10 +1,14 @@
1
1
  import type { Content } from '@quillmark/wasm';
2
2
  interface Props {
3
- /** This leaf's content, read at mount (the parent's boundary read), `undefined`
4
- * where nothing is stored. A thunk rather than a value: the parent re-derives
5
- * per revision and this leaf takes its state once, so a value prop would be a
6
- * boundary read per leaf per render, all but one of them discarded. */
3
+ /** This leaf's content (the parent's boundary read), `undefined` where nothing is
4
+ * stored: read at mount and wherever {@link value} moves. A thunk rather than a
5
+ * value: the parent re-derives per revision, and a value prop would be a
6
+ * boundary read per leaf per render, all but the moved one discarded. */
7
7
  content: () => Content | undefined;
8
+ /** This leaf's value as the parent's re-derive delivers it, stored form and all:
9
+ * compared with the last one delivered, never drawn, so a move is what re-reads
10
+ * {@link content}. */
11
+ value?: unknown;
8
12
  /** The default an unset leaf holds, where it prints: drawn at the default rung,
9
13
  * and handed up whole with the first edit in it, as `createField` takes one. */
10
14
  fallback?: Content;
@@ -46,7 +46,7 @@
46
46
  import type { VisualEditorProps } from './props.js';
47
47
  import { mergeStrings, setWording } from './strings.js';
48
48
  import type { CardId, ChangeSource } from './signals.js';
49
- import type { FieldController } from '../core/codec/index.js';
49
+ import type { FieldController, LeafViews } from '../core/codec/index.js';
50
50
  import {
51
51
  IdSeq,
52
52
  controlKind,
@@ -79,11 +79,11 @@
79
79
  type RoutedDiagnostic
80
80
  } from './diagnostics.js';
81
81
  import { fieldDomIds, groupPanelId } from './domid.js';
82
- import { createLeafRegistry, type FieldControl } from './leaves.js';
82
+ import { createLeafRegistry, type ActiveProse, type FieldControl } from './leaves.js';
83
83
  import { reorder, reorderArm, reorderTrips } from './motion.js';
84
84
  import { tipsChannel } from './tips.js';
85
- import { cardChecklists, setChecklists } from './checklists.js';
86
85
  import { patchEditorExt } from './ext.js';
86
+ import { raiseGuidance } from './guidance.js';
87
87
  import Card from './Card.svelte';
88
88
  import TipsCard from './TipsCard.svelte';
89
89
  import FormatPopover from './FormatPopover.svelte';
@@ -654,20 +654,6 @@
654
654
  };
655
655
  });
656
656
 
657
- // The document's checklists, which an open matrix's add box searches whole
658
- // (`checklists.ts`): off the model, so a list in a closed group or another card is
659
- // reached without being mounted.
660
- const lists = $derived(
661
- cardChecklists(
662
- [model.main, ...model.cards],
663
- (c) => c.titleOverride || c.titlePlaceholder,
664
- (c, field) => pathFor(makeAddr(c.id, c.isMain, field)),
665
- (c, field) => fieldKeyToString({ card: c.isMain ? undefined : c.id, field }),
666
- (c, field, next) => commitScalar(c.id, c.isMain, field, next)
667
- )
668
- );
669
- setChecklists({ list: () => lists, land: (path) => void focusField(path) });
670
-
671
657
  // ── Public entry points ─────────────────────────────────────────────────────
672
658
  /**
673
659
  * Resolve a preview {@link Landing} to a mounted leaf and land in it.
@@ -694,12 +680,18 @@
694
680
  const box = await land(found, at.granularity === 'segment' ? undefined : at.pos);
695
681
  if (span.alive) bloomInside(box);
696
682
  }
697
- /** The active leaf's controller: the formatting popover's observation seam.
698
- * `undefined` for a focused form control, which holds no marks to toggle. */
699
- export function getActiveLeaf(): FieldController | undefined {
683
+ /** The active prose leaf: the formatting popover's observation seam. The active
684
+ * field's controller where it registered one, else the by-value leaf focused last
685
+ * under it (`leaves.ts`); `undefined` for a form control, which holds no marks to
686
+ * toggle. */
687
+ export function getActiveLeaf(): ActiveProse | undefined {
700
688
  if (!activeAddr) return undefined;
701
689
  const card = activeAddr.card != null ? activeCardId : undefined;
702
- return leaves.prose(fieldKeyToString({ card, field: activeAddr.field }));
690
+ const key = fieldKeyToString({ card, field: activeAddr.field });
691
+ const controller = leaves.prose(key);
692
+ if (controller) return { views: controller as FieldController & LeafViews, controller };
693
+ const views = leaves.value(key);
694
+ return views && { views };
703
695
  }
704
696
 
705
697
  /**
@@ -856,7 +848,15 @@
856
848
  }
857
849
  </script>
858
850
 
859
- <div class="qm-editor {className ?? ''}" {style} data-qm-root bind:this={rootEl}>
851
+ <!-- The keydown is a control's, bubbled: the root resolves which description it raises. -->
852
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
853
+ <div
854
+ class="qm-editor {className ?? ''}"
855
+ {style}
856
+ data-qm-root
857
+ bind:this={rootEl}
858
+ onkeydown={raiseGuidance}
859
+ >
860
860
  <!-- `main` and the tips card are one block in the stack: the tips card tucks under
861
861
  `main`'s bottom corners, so the two share a seam rather than a gutter and the
862
862
  wrapper is what holds them to it. -->
@@ -1,10 +1,10 @@
1
1
  import { type DocPath, type Landing } from '../core/address.js';
2
2
  import type { VisualEditorProps } from './props.js';
3
3
  import type { CardId } from './signals.js';
4
- import type { FieldController } from '../core/codec/index.js';
4
+ import { type ActiveProse } from './leaves.js';
5
5
  declare const VisualEditorInner: import("svelte").Component<VisualEditorProps, {
6
6
  setCaret: (at: Landing) => Promise<void>;
7
- getActiveLeaf: () => FieldController | undefined;
7
+ getActiveLeaf: () => ActiveProse | undefined;
8
8
  focusField: (field: DocPath) => Promise<void>;
9
9
  insertCard: (kind: string, at?: number) => CardId | undefined;
10
10
  removeCard: (cardId: CardId) => void;
@@ -1,82 +1,3 @@
1
1
  import type { QuillFieldSchema } from '@quillmark/wasm';
2
- import type { DocPath } from '../core/address.js';
3
- import { type CardModel, type MatrixMember } from './structure.js';
4
- /** One matrix of the document, as the add box reaches it. */
5
- export interface Checklist {
6
- /** The field's leaf key: how a mounted matrix tells itself from the rest. */
7
- key: string;
8
- /** The field's path, the prefix a member's landing address extends. */
9
- path: DocPath;
10
- /** What a result from this list reads under: the field's label, and the card's
11
- * where the field is not the main card's. */
12
- label: string;
13
- schema: QuillFieldSchema;
14
- /** The held set as it prints: the stored map, else the declared `default:`. */
15
- value: Record<string, unknown> | undefined;
16
- /** Commit the whole map, as the field's own control does. */
17
- commit: (next: Record<string, unknown> | undefined) => void;
18
- }
19
- export interface ChecklistIndex {
20
- list: () => readonly Checklist[];
21
- /** Reveal and focus a member by its address. */
22
- land: (path: DocPath) => void;
23
- }
24
- export declare function setChecklists(index: ChecklistIndex): void;
25
- /** The index under a mounted editor; `undefined` off-tree. */
26
- export declare function checklists(): ChecklistIndex | undefined;
27
2
  /** The value a matrix prints: the stored map, else a mapping `default:`. */
28
3
  export declare function matrixPrinted(value: unknown, schema: QuillFieldSchema): Record<string, unknown> | undefined;
29
- /** Every card-level matrix of the cards, in document order. */
30
- export declare function cardChecklists(cards: readonly CardModel[], cardLabel: (card: CardModel) => string, path: (card: CardModel, field: string) => DocPath | undefined, leafKey: (card: CardModel, field: string) => string, commit: (card: CardModel, field: string, next: Record<string, unknown> | undefined) => void): Checklist[];
31
- /** Every item a matrix can show: the roster in declaration order, then the items its
32
- * map adds, in id order. */
33
- export declare function checklistItems(schema: QuillFieldSchema, value: Record<string, unknown> | undefined): MatrixMember[];
34
- /** One option of an add box. */
35
- export interface AddOption {
36
- /** Stable across one query's options: the option element's id suffix. */
37
- key: string;
38
- title: string;
39
- /** Words after the title: the list a result stands in, its held state. */
40
- note?: string;
41
- /** The option that adds what was typed, rather than a result. */
42
- add?: boolean;
43
- /** A result the typed words name outright, which Enter takes unarrowed: an add box
44
- * whose best result is weaker takes its add option instead. */
45
- strong?: boolean;
46
- /** What the owner reads back off a chosen option. */
47
- payload?: unknown;
48
- }
49
- /** One search result: an item of some checklist, `list` naming which. */
50
- export interface ChecklistHit<L> {
51
- list: L;
52
- id: string;
53
- title: string;
54
- held: boolean;
55
- score: number;
56
- /** Every query word claimed a title word whole or as its opening. */
57
- strong: boolean;
58
- }
59
- /**
60
- * How well `query` names `title`, or `undefined` for not at all. Each query word claims
61
- * a title word — whole, as its opening, or as an abbreviation of it (`flt cc` names
62
- * `Flight CC`) — the stronger claims taken first, so `c cc` names `CC Candidate`; a
63
- * word left over may claim a run of words whose openings it spells (`fcc`, `jqo`). The
64
- * title the query spells outright ranks first, then one it opens.
65
- */
66
- export declare function matchTitle(query: string, title: string): {
67
- score: number;
68
- strong: boolean;
69
- } | undefined;
70
- /** {@link matchTitle}'s score, `0` for no match. */
71
- export declare function matchScore(query: string, title: string): number;
72
- /** What a search reads of one list: its items and their ticks. */
73
- export interface Searchable<L> {
74
- list: L;
75
- schema: QuillFieldSchema;
76
- value: Record<string, unknown> | undefined;
77
- }
78
- /**
79
- * The items across `lists` that `query` names, best first; a tie keeps the order of
80
- * `lists`, then of each list's items.
81
- */
82
- export declare function searchChecklists<L>(query: string, lists: readonly Searchable<L>[], limit?: number): ChecklistHit<L>[];
@@ -1,20 +1,3 @@
1
- /**
2
- * The document's checklists, and the search an open matrix's add box runs over them
3
- * (VISUAL_EDITOR §"The matrix"). The editor root publishes every card-level matrix off
4
- * the model it derives, so a list in a collapsed group or another card is searched
5
- * as surely as the one the author is typing in. A matrix mounted off-tree, or nested
6
- * in a container, is not indexed and searches its own items alone.
7
- */
8
- import { getContext, setContext } from 'svelte';
9
- import { addedItems, matrixHeld, matrixMembers, memberValue } from './structure.js';
10
- const KEY = Symbol('qm-checklists');
11
- export function setChecklists(index) {
12
- setContext(KEY, index);
13
- }
14
- /** The index under a mounted editor; `undefined` off-tree. */
15
- export function checklists() {
16
- return getContext(KEY);
17
- }
18
1
  /** The value a matrix prints: the stored map, else a mapping `default:`. */
19
2
  export function matrixPrinted(value, schema) {
20
3
  const map = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
@@ -22,150 +5,3 @@ export function matrixPrinted(value, schema) {
22
5
  : undefined;
23
6
  return value == null ? map(schema.default) : map(value);
24
7
  }
25
- /** Every card-level matrix of the cards, in document order. */
26
- export function cardChecklists(cards, cardLabel, path, leafKey, commit) {
27
- const out = [];
28
- for (const card of cards) {
29
- if (card.unschemable)
30
- continue;
31
- for (const section of card.sections)
32
- for (const f of section.fields) {
33
- if (f.control !== 'matrix')
34
- continue;
35
- const at = path(card, f.name);
36
- if (at == null)
37
- continue;
38
- out.push({
39
- key: leafKey(card, f.name),
40
- path: at,
41
- label: card.isMain ? f.label : `${f.label} · ${cardLabel(card)}`,
42
- schema: f.schema,
43
- value: matrixPrinted(card.values[f.name], f.schema),
44
- commit: (next) => commit(card, f.name, next)
45
- });
46
- }
47
- }
48
- return out;
49
- }
50
- /** Every item a matrix can show: the roster in declaration order, then the items its
51
- * map adds, in id order. */
52
- export function checklistItems(schema, value) {
53
- return [...matrixMembers(schema.members), ...addedItems(schema, value)];
54
- }
55
- /** Lowercase, unaccented words, any script: `Exec / Aide / CAG` → `exec aide cag`. */
56
- function words(s) {
57
- return s
58
- .normalize('NFKD')
59
- .replace(/[\u0300-\u036f]/g, '')
60
- .toLowerCase()
61
- .split(/[^\p{L}\p{N}]+/u)
62
- .filter(Boolean);
63
- }
64
- /** `flt` in `flight`: every letter in order, the first letters equal. */
65
- function abbreviates(token, word) {
66
- if (token[0] !== word[0])
67
- return false;
68
- let i = 0;
69
- for (const c of word)
70
- if (c === token[i])
71
- i++;
72
- return i === token.length;
73
- }
74
- /** How a query word claims one title word: whole (3), as its opening (2), as an
75
- * abbreviation (1), or not (0). */
76
- function claim(token, word) {
77
- return word === token ? 3 : word.startsWith(token) ? 2 : abbreviates(token, word) ? 1 : 0;
78
- }
79
- /** The end of the run of free words from `i` whose openings spell `token` in order —
80
- * `fcc` over `flight cc`, `jqo` over `joint qualified officer` — or `-1`. */
81
- function runFrom(token, w, free, i) {
82
- if (i >= w.length || !free[i])
83
- return -1;
84
- for (let k = Math.min(token.length, w[i].length); k > 0; k--) {
85
- if (!w[i].startsWith(token.slice(0, k)))
86
- continue;
87
- if (k === token.length)
88
- return i + 1;
89
- const end = runFrom(token.slice(k), w, free, i + 1);
90
- if (end >= 0)
91
- return end;
92
- }
93
- return -1;
94
- }
95
- /**
96
- * How well `query` names `title`, or `undefined` for not at all. Each query word claims
97
- * a title word — whole, as its opening, or as an abbreviation of it (`flt cc` names
98
- * `Flight CC`) — the stronger claims taken first, so `c cc` names `CC Candidate`; a
99
- * word left over may claim a run of words whose openings it spells (`fcc`, `jqo`). The
100
- * title the query spells outright ranks first, then one it opens.
101
- */
102
- export function matchTitle(query, title) {
103
- const q = words(query);
104
- const w = words(title);
105
- if (!q.length || !w.length)
106
- return undefined;
107
- const free = w.map(() => true);
108
- const left = q.map(() => true);
109
- let score = 0;
110
- let strong = true;
111
- for (const level of [3, 2, 1])
112
- q.forEach((token, t) => {
113
- if (!left[t])
114
- return;
115
- const at = w.findIndex((word, i) => free[i] && claim(token, word) === level);
116
- if (at < 0)
117
- return;
118
- free[at] = left[t] = false;
119
- score += level;
120
- if (level === 1)
121
- strong = false;
122
- });
123
- for (let t = 0; t < q.length; t++) {
124
- if (!left[t])
125
- continue;
126
- let found = false;
127
- for (let i = 0; i < w.length && !found; i++) {
128
- const end = runFrom(q[t], w, free, i);
129
- if (end - i < 2)
130
- continue;
131
- for (let k = i; k < end; k++)
132
- free[k] = false;
133
- found = true;
134
- }
135
- if (!found)
136
- return undefined;
137
- score += 1;
138
- strong = false;
139
- }
140
- const typed = q.join(' ');
141
- const whole = w.join(' ');
142
- return { score: score + (whole === typed ? 3 : whole.startsWith(typed) ? 1 : 0), strong };
143
- }
144
- /** {@link matchTitle}'s score, `0` for no match. */
145
- export function matchScore(query, title) {
146
- return matchTitle(query, title)?.score ?? 0;
147
- }
148
- /**
149
- * The items across `lists` that `query` names, best first; a tie keeps the order of
150
- * `lists`, then of each list's items.
151
- */
152
- export function searchChecklists(query, lists, limit = 8) {
153
- const hits = [];
154
- for (const { list, schema, value } of lists)
155
- for (const item of checklistItems(schema, value)) {
156
- const match = matchTitle(query, item.title);
157
- if (match)
158
- hits.push({
159
- list,
160
- id: item.id,
161
- title: item.title,
162
- held: matrixHeld(memberValue(value, item.id)),
163
- ...match
164
- });
165
- }
166
- return hits
167
- .map((h, i) => [h, i])
168
- .sort(([a, i], [b, j]) => b.score - a.score || i - j)
169
- .slice(0, limit)
170
- .map(([h]) => h);
171
- }
@@ -0,0 +1,15 @@
1
+ /** `Mod-/`, read off `key` so a layout that shifts to reach `/` still matches; either
2
+ * modifier stands for `Mod`. */
3
+ export declare function isGuidanceKey(e: KeyboardEvent): boolean;
4
+ /**
5
+ * The guidance trigger for the description nearest `from`: the innermost element
6
+ * carrying `aria-describedby` whose ids name a parked description with a trigger
7
+ * beside it. A trigger points at its own parked node through that same attribute,
8
+ * which is the relation read here; ids that are not a description (an array's count,
9
+ * a prose leaf's note) are skipped, and an undescribed property falls through to the
10
+ * group around it.
11
+ */
12
+ export declare function guidanceTrigger(from: Element, root: Element): HTMLElement | undefined;
13
+ /** Root keydown: the key raises (or lowers) the nearest guidance. A trigger's `click()`
14
+ * is an activation with no pointer behind it, which `FieldHint` toggles on. */
15
+ export declare function raiseGuidance(e: KeyboardEvent): void;
@@ -0,0 +1,37 @@
1
+ // The guidance key: `Mod-/` on a control raises the description that control carries,
2
+ // the keyboard's way in to a `FieldHint` whose glyph is out of the tab order.
3
+ /** `Mod-/`, read off `key` so a layout that shifts to reach `/` still matches; either
4
+ * modifier stands for `Mod`. */
5
+ export function isGuidanceKey(e) {
6
+ return e.key === '/' && (e.ctrlKey || e.metaKey) && !e.altKey && !e.isComposing;
7
+ }
8
+ /**
9
+ * The guidance trigger for the description nearest `from`: the innermost element
10
+ * carrying `aria-describedby` whose ids name a parked description with a trigger
11
+ * beside it. A trigger points at its own parked node through that same attribute,
12
+ * which is the relation read here; ids that are not a description (an array's count,
13
+ * a prose leaf's note) are skipped, and an undescribed property falls through to the
14
+ * group around it.
15
+ */
16
+ export function guidanceTrigger(from, root) {
17
+ for (let el = from.closest('[aria-describedby]'); el;) {
18
+ for (const id of el.getAttribute('aria-describedby').split(/\s+/)) {
19
+ const trigger = root.querySelector(`.qm-field-hint[aria-describedby="${id}"]`);
20
+ if (trigger)
21
+ return trigger;
22
+ }
23
+ el = el.parentElement?.closest('[aria-describedby]') ?? null;
24
+ }
25
+ return undefined;
26
+ }
27
+ /** Root keydown: the key raises (or lowers) the nearest guidance. A trigger's `click()`
28
+ * is an activation with no pointer behind it, which `FieldHint` toggles on. */
29
+ export function raiseGuidance(e) {
30
+ if (!isGuidanceKey(e) || !(e.target instanceof Element))
31
+ return;
32
+ const trigger = guidanceTrigger(e.target, e.currentTarget);
33
+ if (!trigger)
34
+ return;
35
+ e.preventDefault();
36
+ trigger.click();
37
+ }