@quillmark/svelte 0.1.1 → 0.2.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 (64) hide show
  1. package/README.md +15 -11
  2. package/THEMING.md +42 -23
  3. package/dist/core/address.d.ts +39 -4
  4. package/dist/core/address.js +47 -19
  5. package/dist/core/codec/encode.js +4 -1
  6. package/dist/core/codec/field.d.ts +1 -1
  7. package/dist/core/codec/prose.css +41 -18
  8. package/dist/core/codec/table.js +3 -1
  9. package/dist/core/errors.d.ts +15 -5
  10. package/dist/core/index.d.ts +1 -1
  11. package/dist/core/index.js +5 -4
  12. package/dist/core/lifecycle.d.ts +21 -5
  13. package/dist/core/lifecycle.js +34 -8
  14. package/dist/core/rebind.svelte.d.ts +16 -2
  15. package/dist/core/rebind.svelte.js +26 -8
  16. package/dist/core/theme.css +43 -18
  17. package/dist/preset/recipes.css +55 -8
  18. package/dist/preset/scale.css +5 -4
  19. package/dist/preview/Preview.svelte +10 -9
  20. package/dist/preview/Preview.svelte.d.ts +5 -5
  21. package/dist/preview/bridge.d.ts +6 -4
  22. package/dist/preview/bridge.js +17 -7
  23. package/dist/preview/controller.d.ts +13 -6
  24. package/dist/preview/controller.js +5 -2
  25. package/dist/preview/geometry.d.ts +16 -1
  26. package/dist/preview/geometry.js +20 -0
  27. package/dist/preview/index.d.ts +1 -0
  28. package/dist/preview/index.js +7 -0
  29. package/dist/preview/overlay.d.ts +2 -1
  30. package/dist/preview/overlay.js +24 -9
  31. package/dist/preview/paint.js +4 -0
  32. package/dist/visual/ArrayField.svelte +26 -20
  33. package/dist/visual/ArrayField.svelte.d.ts +4 -3
  34. package/dist/visual/Card.svelte +21 -19
  35. package/dist/visual/Card.svelte.d.ts +8 -3
  36. package/dist/visual/DiagnosticList.svelte +1 -1
  37. package/dist/visual/EnumField.svelte +34 -14
  38. package/dist/visual/EnumField.svelte.d.ts +4 -3
  39. package/dist/visual/Field.svelte +87 -25
  40. package/dist/visual/Field.svelte.d.ts +11 -7
  41. package/dist/visual/FieldHint.svelte +59 -10
  42. package/dist/visual/FormatPopover.svelte +3 -1
  43. package/dist/visual/ObjectField.svelte +21 -2
  44. package/dist/visual/ObjectField.svelte.d.ts +3 -1
  45. package/dist/visual/ProseArrayElement.svelte +1 -6
  46. package/dist/visual/ProseArrayElement.svelte.d.ts +0 -1
  47. package/dist/visual/ProseField.svelte +20 -12
  48. package/dist/visual/ProseField.svelte.d.ts +5 -3
  49. package/dist/visual/TipsCard.svelte +1 -1
  50. package/dist/visual/VisualEditor.svelte +12 -13
  51. package/dist/visual/VisualEditor.svelte.d.ts +2 -3
  52. package/dist/visual/VisualEditorInner.svelte +140 -66
  53. package/dist/visual/VisualEditorInner.svelte.d.ts +2 -3
  54. package/dist/visual/controls.css +14 -11
  55. package/dist/visual/diagnostics.d.ts +12 -20
  56. package/dist/visual/diagnostics.js +6 -18
  57. package/dist/visual/index.d.ts +1 -0
  58. package/dist/visual/index.js +7 -0
  59. package/dist/visual/leaves.d.ts +47 -0
  60. package/dist/visual/leaves.js +31 -0
  61. package/dist/visual/props.d.ts +27 -6
  62. package/dist/visual/tips.d.ts +3 -1
  63. package/dist/visual/tips.js +5 -2
  64. package/package.json +2 -2
@@ -1,6 +1,6 @@
1
1
  import type { Document, Quill, Addr, Diagnostic, ResolvedField } from '@quillmark/wasm';
2
2
  import type { EditorErrorHandler } from '../core/errors.js';
3
- import type { FieldController } from '../core/codec/index.js';
3
+ import type { LeafRegistry } from './leaves.js';
4
4
  import type { FieldModel, FieldSpan } from './structure.js';
5
5
  import type { FieldDomIds } from './domid.js';
6
6
  interface Props {
@@ -14,22 +14,26 @@ interface Props {
14
14
  doc: Document;
15
15
  /** The schema a prose leaf reads its content through (`ProseField`). */
16
16
  quill: Quill;
17
- /** LIVE prose address (getter-`card`); used only when control === 'prose'. */
18
- proseAddr: Addr;
17
+ /** This field's LIVE address (getter-`card`, so a card reorder re-targets in
18
+ * place): the prose leaf commits to it, and a focus reports it. */
19
+ addr: Addr;
19
20
  leafKey: string;
20
21
  /** This field's three DOM names, derived from `leafKey` (see `domid.ts`): how
21
22
  * the label and the control find each other. */
22
23
  domIds: FieldDomIds;
23
24
  onCommitScalar: (value: unknown) => void;
24
- /** Enum-option policy: `false` disables that option. Only the enum
25
- * control reads it; other controls ignore it. */
25
+ /** Enum-option policy: `false` marks that option unavailable. Only the enum
26
+ * control reads this pair. */
26
27
  optionAllowed?: (value: string) => boolean;
28
+ /** How a refused option draws: greyed (default) or left out. */
29
+ enumDisallowed?: 'hide' | 'disable';
27
30
  onFocus?: (addr: Addr) => void;
28
31
  onCaretMove?: (addr: Addr, pos: number) => void;
29
32
  onChange?: (addr: Addr) => void;
30
33
  onError?: EditorErrorHandler;
31
- register?: (key: string, controller: FieldController) => void;
32
- unregister?: (key: string) => void;
34
+ /** The editor's leaf registry (`leaves.ts`): a form control registers its
35
+ * landing handle here, a prose leaf its controller from inside `ProseField`. */
36
+ leaves?: LeafRegistry;
33
37
  diagnostics?: Diagnostic[];
34
38
  }
35
39
  declare const Field: import("svelte").Component<Props, {}, "">;
@@ -45,6 +45,39 @@
45
45
  /** The root to portal INTO: `document.body` would escape the consumer's dials
46
46
  * along with the editor's subtree. `undefined` falls back to bits-ui's default. */
47
47
  const portalTarget = $derived(triggerEl?.closest<HTMLElement>('[data-qm-root]') ?? undefined);
48
+
49
+ /** Placement, re-measured on each opening: the surface rides the label rung beside
50
+ * the glyph, or stands over the field. */
51
+ let beside = $state(false);
52
+
53
+ /** Whether this description sets one line. Off a probe rather than the surface
54
+ * itself, since the side is a prop the surface is positioned FROM, and a side chosen
55
+ * after the fact moves a surface the reader is already looking at. The probe wears
56
+ * the surface's own class inside the portal target, so it wraps to the same measure
57
+ * under the same dials; `width: max-content` keeps that measure the class's rather
58
+ * than the containing block's. Line boxes are counted off a range over the text
59
+ * rather than derived from a height, which would need the line rung and the inset
60
+ * restated here as numbers. A DOM with no layout yields none, which stands the
61
+ * surface over the field. */
62
+ function ridesTheRung(): boolean {
63
+ const host = portalTarget ?? document.body;
64
+ const probe = document.createElement('div');
65
+ probe.className = 'qm-hint-popover';
66
+ probe.style.cssText =
67
+ 'position:absolute;width:max-content;visibility:hidden;pointer-events:none';
68
+ probe.textContent = description;
69
+ host.appendChild(probe);
70
+ const range = document.createRange();
71
+ range.selectNodeContents(probe);
72
+ const lines = range.getClientRects().length;
73
+ probe.remove();
74
+ return lines === 1;
75
+ }
76
+
77
+ function raise(): void {
78
+ beside = ridesTheRung();
79
+ open = true;
80
+ }
48
81
  </script>
49
82
 
50
83
  <!-- The trigger is OURS, not `Popover.Trigger`, and the surface anchors to it:
@@ -60,7 +93,7 @@
60
93
  aria-expanded={open}
61
94
  aria-describedby={describedBy}
62
95
  onpointerenter={(e) => {
63
- if (e.pointerType !== 'touch') open = true;
96
+ if (e.pointerType !== 'touch') raise();
64
97
  }}
65
98
  onpointerleave={(e) => {
66
99
  if (e.pointerType !== 'touch') open = false;
@@ -69,13 +102,16 @@
69
102
  // Touch only, and a TOGGLE: the one modality with no hover to leave and no
70
103
  // blur on the way out. A mouse press falls through to hover, which already
71
104
  // has it open and will close it on the way off.
72
- if (e.pointerType === 'touch') open = !open;
105
+ if (e.pointerType === 'touch') {
106
+ if (open) open = false;
107
+ else raise();
108
+ }
73
109
  }}
74
110
  onfocus={(e) => {
75
111
  // Keyboard arrival only. A pointer press focuses the button too, and opening
76
112
  // on that would re-open what the tap just toggled shut; `:focus-visible` is
77
113
  // the UA's own answer to which arrival this was.
78
- if (e.currentTarget.matches(':focus-visible')) open = true;
114
+ if (e.currentTarget.matches(':focus-visible')) raise();
79
115
  }}
80
116
  onblur={() => (open = false)}
81
117
  onkeydown={(e) => {
@@ -88,12 +124,21 @@
88
124
  }}><Icon name="info" size={13} /></button
89
125
  >
90
126
 
127
+ <!-- PLACEMENT IS THE DESCRIPTION'S HEIGHT. One line rides the label rung beside its own
128
+ glyph, where the overhang past the rung's line box falls in the label-to-control gaps.
129
+ Anything taller stands over the field: beside the glyph it would hang onto THE CONTROL
130
+ IT DESCRIBES, the one overlap a reader cannot clear by reaching for what is hidden,
131
+ since a pointer leaving the trigger closes the surface. Over the field it covers a
132
+ neighbour's control instead, which reaching for it dismisses. -->
133
+ <!-- `align="center"` for the rung, `"start"` over the field: centred, a surface taller
134
+ than the glyph's line box would hang half its height onto the field's own control. -->
135
+
91
136
  <Popover.Root bind:open>
92
137
  <Popover.Portal to={portalTarget}>
93
138
  <Popover.Content
94
139
  customAnchor={triggerEl ?? null}
95
- side="top"
96
- align="start"
140
+ side={beside ? 'right' : 'top'}
141
+ align={beside ? 'center' : 'start'}
97
142
  sideOffset={6}
98
143
  trapFocus={false}
99
144
  onOpenAutoFocus={(e: Event) => e.preventDefault()}
@@ -168,18 +213,22 @@
168
213
  }
169
214
  .qm-field-hint:hover,
170
215
  .qm-field-hint:focus-visible {
171
- color: var(--_qm-ink-meta);
216
+ color: var(--_qm-ink-label);
172
217
  }
173
218
  /* The lift, the translucency, the blur and the scale-in come from
174
219
  `.qm-popover-surface` (controls.css). This surface's own is a MEASURE: guidance
175
220
  is prose, and prose past ~40 characters a line stops being scannable; plus the
176
221
  inset and the meta type rung that keep it a note about a field rather than a
177
- second field. */
178
- .qm-hint-popover {
222
+ second field.
223
+
224
+ Unscoped, because the placement probe wears this class on a node Svelte never
225
+ compiled: a scoped rule would leave the probe wrapping to a different measure
226
+ than the surface it stands in for, which is the one thing the probe must not do. */
227
+ :global(.qm-hint-popover) {
179
228
  max-width: 22rem;
180
229
  padding: var(--_qm-space) var(--_qm-space-2);
181
- font-size: var(--_qm-text-meta);
230
+ font-size: var(--_qm-text-label);
182
231
  line-height: var(--_qm-leading-body);
183
- color: var(--_qm-ink-meta);
232
+ color: var(--_qm-ink-label);
184
233
  }
185
234
  </style>
@@ -385,10 +385,12 @@
385
385
  gap: var(--_qm-space);
386
386
  align-items: center;
387
387
  }
388
+ /* The box's recipe without `.qm-input`, which adds the `width: 100%` that would
389
+ stretch this to the popover. */
388
390
  .qm-link-input {
389
391
  font: inherit;
390
392
  font-size: var(--_qm-text-body);
391
- padding: var(--_qm-space) var(--_qm-space-2);
393
+ padding: var(--_qm-inset-control);
392
394
  border: var(--_qm-border-width) solid var(--_qm-border);
393
395
  border-radius: var(--_qm-radius-inner);
394
396
  min-width: 12rem;
@@ -37,6 +37,19 @@
37
37
  const entries = $derived(Object.entries(properties ?? {}));
38
38
  const obj = $derived((value ?? {}) as Record<string, unknown>);
39
39
 
40
+ /** Take the caret: the FIRST property's control, a subform having no single control
41
+ * of its own to land on. Resolved off the DOM rather than a ref per property: every
42
+ * property is a scalar control the DOM already knows how to focus (the date field's
43
+ * segments carry `tabindex`, the `literal` separators between them do not), so a
44
+ * handle each would be five refs restating document order. An empty or wholly
45
+ * unsupported subform lands nothing. */
46
+ let rootEl = $state<HTMLElement | undefined>();
47
+ export function focus(): void {
48
+ rootEl
49
+ ?.querySelector<HTMLElement>('input, select, button, [tabindex]:not([tabindex="-1"])')
50
+ ?.focus();
51
+ }
52
+
40
53
  function commitProp(key: string, v: unknown): void {
41
54
  if (v === undefined) {
42
55
  // A nested control cleared (the unset rung): drop the key so the property
@@ -51,7 +64,13 @@
51
64
  }
52
65
  </script>
53
66
 
54
- <div class="qm-object" role="group" aria-labelledby={labelledBy} aria-describedby={describedBy}>
67
+ <div
68
+ bind:this={rootEl}
69
+ class="qm-object"
70
+ role="group"
71
+ aria-labelledby={labelledBy}
72
+ aria-describedby={describedBy}
73
+ >
55
74
  {#each entries as [key, sub] (key)}
56
75
  {@const kind = controlKind(sub)}
57
76
  {@const propLabel = `${label != null ? `${label} ` : ''}${sub.ui?.title ?? humanize(key)}`}
@@ -117,7 +136,7 @@
117
136
  font-size: var(--_qm-text-label);
118
137
  /* Soft (500), a rung under the top-level field label's 600: a nested object
119
138
  prop reads as secondary. Resolves the label-weight disagreement. */
120
- font-weight: var(--_qm-weight-soft);
139
+ font-weight: var(--_qm-weight-label);
121
140
  /* A label is a line, not a passage, so it overrides the root's reading
122
141
  rhythm the way every other label rung does. */
123
142
  line-height: var(--_qm-leading-tight);
@@ -12,6 +12,8 @@ interface Props {
12
12
  describedBy?: string;
13
13
  onCommit: (obj: Record<string, unknown>) => void;
14
14
  }
15
- declare const ObjectField: import("svelte").Component<Props, {}, "">;
15
+ declare const ObjectField: import("svelte").Component<Props, {
16
+ focus: () => void;
17
+ }, "">;
16
18
  type ObjectField = ReturnType<typeof ObjectField>;
17
19
  export default ObjectField;
@@ -27,9 +27,8 @@
27
27
  * array repeater's Enter/Backspace (`ArrayField`). Fires BEFORE the view's own
28
28
  * keymap, so the state it reads is the one this keystroke has yet to change. */
29
29
  onKey?: (e: KeyboardEvent) => void;
30
- onFocusEl?: () => void;
31
30
  }
32
- let { value, plaintext, label, onChange, onKey, onFocusEl }: Props = $props();
31
+ let { value, plaintext, label, onChange, onKey }: Props = $props();
33
32
 
34
33
  let containerEl: HTMLDivElement | undefined = $state();
35
34
  let view: EditorView | undefined;
@@ -62,10 +61,6 @@
62
61
  if (tr.docChanged) onChange(pmToContent(next.doc));
63
62
  },
64
63
  handleDOMEvents: {
65
- focus: () => {
66
- onFocusEl?.();
67
- return false;
68
- },
69
64
  keydown: (_v, e) => {
70
65
  onKey?.(e);
71
66
  return false;
@@ -10,7 +10,6 @@ interface Props {
10
10
  * array repeater's Enter/Backspace (`ArrayField`). Fires BEFORE the view's own
11
11
  * keymap, so the state it reads is the one this keystroke has yet to change. */
12
12
  onKey?: (e: KeyboardEvent) => void;
13
- onFocusEl?: () => void;
14
13
  }
15
14
  declare const ProseArrayElement: import("svelte").Component<Props, {
16
15
  focus: () => void;
@@ -10,6 +10,7 @@
10
10
  <script lang="ts">
11
11
  import { onMount } from 'svelte';
12
12
  import { createField, type FieldController, type SlashState } from '../core/codec/index.js';
13
+ import type { LeafRegistry } from './leaves.js';
13
14
  import SlashMenu from './SlashMenu.svelte';
14
15
  import { wording } from './strings.js';
15
16
  import './controls.css';
@@ -54,8 +55,10 @@
54
55
  /** A commit landed on this leaf: the prose change lane. */
55
56
  onChange?: (addr: Addr) => void;
56
57
  onError?: EditorErrorHandler;
57
- register?: (key: string, controller: FieldController) => void;
58
- unregister?: (key: string) => void;
58
+ /** The editor's leaf registry (`leaves.ts`). A prose leaf registers its
59
+ * CONTROLLER: the landing handle plus the codec seam a form control has no half
60
+ * of, which is what makes this leaf a caret target and not merely a focus one. */
61
+ leaves?: LeafRegistry;
59
62
  }
60
63
 
61
64
  let {
@@ -74,8 +77,7 @@
74
77
  onCaretMove,
75
78
  onChange,
76
79
  onError,
77
- register,
78
- unregister
80
+ leaves
79
81
  }: Props = $props();
80
82
 
81
83
  let containerEl: HTMLDivElement | undefined = $state();
@@ -134,9 +136,9 @@
134
136
  onChange,
135
137
  onError
136
138
  });
137
- register?.(leafKey, controller);
139
+ leaves?.registerProse(leafKey, controller);
138
140
  return () => {
139
- unregister?.(leafKey);
141
+ leaves?.unregisterProse(leafKey);
140
142
  controller?.destroy();
141
143
  controller = undefined;
142
144
  };
@@ -172,16 +174,22 @@
172
174
  own surface. */
173
175
  position: relative;
174
176
  }
175
- /* A leaf the inline schema does NOT constrain (the body) is paper rather than a
176
- cell in a row of controls, so it opens at a few lines and grows. The floor is
177
- three LINE BOXES, not a length: size times leading is what one line measures, so
178
- both factors are named or the expression stops meaning the three lines it claims.
179
- It moves with the type ramp and with nothing else. Three is what an EMPTY body
177
+ /* A leaf the inline schema does NOT constrain is prose rather than a cell in a row
178
+ of controls, so it opens at a few lines and grows. The floor is three LINE BOXES,
179
+ not a length: `1em` rather than a size rung because which rung this leaf reads is
180
+ the rule below's, and a second naming of it drifts. Three is what an EMPTY body
180
181
  costs, which is the only state the floor is ever the height: the ghost takes the
181
182
  first line, so the opening reads as an invitation with room under it rather than
182
183
  as a drop. */
183
184
  .qm-prose-block {
184
- min-height: calc(var(--_qm-text-body) * var(--_qm-leading-body) * 3);
185
+ min-height: calc(1em * var(--_qm-leading-body) * 3);
186
+ }
187
+ /* PAPER, and the box is what says so. Not `block` alone: `inline` is the quill's to
188
+ declare, so a schema omitting it on a richtext field puts a block leaf in a field
189
+ row, where the paper rung would break the height agreement `controls.css` builds by
190
+ construction. */
191
+ .qm-prose-block:not(.qm-control-box) {
192
+ font-size: var(--_qm-text-paper);
185
193
  }
186
194
  /* The caret is the prose leaf's focus indicator, not a ring: a ring around a
187
195
  contenteditable reads as form chrome rather than as paper. So the outline is
@@ -1,4 +1,4 @@
1
- import { type FieldController } from '../core/codec/index.js';
1
+ import type { LeafRegistry } from './leaves.js';
2
2
  import './controls.css';
3
3
  import type { Document, Quill, Addr } from '@quillmark/wasm';
4
4
  import type { EditorErrorHandler } from '../core/errors.js';
@@ -40,8 +40,10 @@ interface Props {
40
40
  /** A commit landed on this leaf: the prose change lane. */
41
41
  onChange?: (addr: Addr) => void;
42
42
  onError?: EditorErrorHandler;
43
- register?: (key: string, controller: FieldController) => void;
44
- unregister?: (key: string) => void;
43
+ /** The editor's leaf registry (`leaves.ts`). A prose leaf registers its
44
+ * CONTROLLER: the landing handle plus the codec seam a form control has no half
45
+ * of, which is what makes this leaf a caret target and not merely a focus one. */
46
+ leaves?: LeafRegistry;
45
47
  }
46
48
  declare const ProseField: import("svelte").Component<Props, {
47
49
  focus: () => void;
@@ -185,7 +185,7 @@
185
185
  button carries a label and so widens its inset and takes the meta type rung. */
186
186
  .qm-tips-action {
187
187
  padding: var(--_qm-space-half) var(--_qm-space-2);
188
- font-size: var(--_qm-text-meta);
188
+ font-size: var(--_qm-text-label);
189
189
  color: var(--_qm-ink-label);
190
190
  }
191
191
  </style>
@@ -24,9 +24,8 @@
24
24
  -->
25
25
  <script lang="ts">
26
26
  import { untrack } from 'svelte';
27
- import { reportError } from '../core/errors.js';
28
- import type { ContentHit } from '@quillmark/wasm';
29
- import type { DocPath } from '../core/address.js';
27
+ import { reportRebindIgnored } from '../core/rebind.svelte.js';
28
+ import type { DocPath, Landing } from '../core/address.js';
30
29
  import type { CardId } from './signals.js';
31
30
  import type { VisualEditorProps } from './props.js';
32
31
  import VisualEditorInner from './VisualEditorInner.svelte';
@@ -54,12 +53,10 @@
54
53
  }
55
54
  if (quill === boundQuill || reported) return;
56
55
  reported = true;
57
- reportError(props.onError, {
58
- code: 'rebind-ignored',
59
- severity: 'dev',
60
- message:
61
- 'quill swapped in place; the mounted leaves still commit against the quill this doc mounted with. Swap the doc with it, or remount the editor, to rebind.'
62
- });
56
+ reportRebindIgnored(
57
+ props.onError,
58
+ 'quill swapped in place; the mounted leaves still commit against the quill this doc mounted with. Swap the doc with it, or remount the editor, to rebind.'
59
+ );
63
60
  });
64
61
  });
65
62
 
@@ -67,11 +64,13 @@
67
64
  // landing between a swap and the incoming mount is a no-op rather than a throw,
68
65
  // which is what lets a host hold ONE `bind:this` across two documents.
69
66
 
70
- /** Place the caret at a preview hit. */
71
- export async function setCaret(hit: ContentHit): Promise<void> {
72
- await inner?.setCaret(hit);
67
+ /** Land a preview pick: a caret where the payload carries one and the leaf can
68
+ * spend it, an array element where the address names one, a focus otherwise. */
69
+ export async function setCaret(at: Landing): Promise<void> {
70
+ await inner?.setCaret(at);
73
71
  }
74
- /** Reveal and focus the leaf at `field`, without placing a caret inside it. */
72
+ /** Reveal and focus the field at `field` — any mounted one — without placing a
73
+ * caret inside it. */
75
74
  export async function focusField(field: DocPath): Promise<void> {
76
75
  await inner?.focusField(field);
77
76
  }
@@ -1,9 +1,8 @@
1
- import type { ContentHit } from '@quillmark/wasm';
2
- import type { DocPath } from '../core/address.js';
1
+ import type { DocPath, Landing } from '../core/address.js';
3
2
  import type { CardId } from './signals.js';
4
3
  import type { VisualEditorProps } from './props.js';
5
4
  declare const VisualEditor: import("svelte").Component<VisualEditorProps, {
6
- setCaret: (hit: ContentHit) => Promise<void>;
5
+ setCaret: (at: Landing) => Promise<void>;
7
6
  focusField: (field: DocPath) => Promise<void>;
8
7
  insertCard: (kind: string, at?: number) => CardId | undefined;
9
8
  removeCard: (cardId: CardId) => void;