@quillmark/svelte 0.1.0 → 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.
- package/README.md +15 -11
- package/THEMING.md +42 -23
- package/dist/core/address.d.ts +39 -4
- package/dist/core/address.js +47 -19
- package/dist/core/codec/encode.js +4 -1
- package/dist/core/codec/field.d.ts +1 -1
- package/dist/core/codec/prose.css +41 -18
- package/dist/core/codec/table.js +3 -1
- package/dist/core/errors.d.ts +15 -5
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +5 -4
- package/dist/core/lifecycle.d.ts +21 -5
- package/dist/core/lifecycle.js +34 -8
- package/dist/core/rebind.svelte.d.ts +16 -2
- package/dist/core/rebind.svelte.js +26 -8
- package/dist/core/theme.css +50 -18
- package/dist/preset/recipes.css +55 -8
- package/dist/preset/scale.css +5 -4
- package/dist/preview/Preview.svelte +10 -9
- package/dist/preview/Preview.svelte.d.ts +5 -5
- package/dist/preview/bridge.d.ts +6 -4
- package/dist/preview/bridge.js +17 -7
- package/dist/preview/controller.d.ts +13 -6
- package/dist/preview/controller.js +5 -2
- package/dist/preview/geometry.d.ts +16 -1
- package/dist/preview/geometry.js +20 -0
- package/dist/preview/index.d.ts +1 -0
- package/dist/preview/index.js +7 -0
- package/dist/preview/overlay.d.ts +2 -1
- package/dist/preview/overlay.js +24 -9
- package/dist/preview/paint.js +4 -0
- package/dist/visual/ArrayField.svelte +26 -20
- package/dist/visual/ArrayField.svelte.d.ts +4 -3
- package/dist/visual/Card.svelte +21 -19
- package/dist/visual/Card.svelte.d.ts +8 -3
- package/dist/visual/DiagnosticList.svelte +1 -1
- package/dist/visual/EnumField.svelte +34 -14
- package/dist/visual/EnumField.svelte.d.ts +4 -3
- package/dist/visual/Field.svelte +87 -25
- package/dist/visual/Field.svelte.d.ts +11 -7
- package/dist/visual/FieldHint.svelte +59 -10
- package/dist/visual/FormatPopover.svelte +3 -1
- package/dist/visual/ObjectField.svelte +21 -2
- package/dist/visual/ObjectField.svelte.d.ts +3 -1
- package/dist/visual/ProseArrayElement.svelte +1 -6
- package/dist/visual/ProseArrayElement.svelte.d.ts +0 -1
- package/dist/visual/ProseField.svelte +20 -12
- package/dist/visual/ProseField.svelte.d.ts +5 -3
- package/dist/visual/TipsCard.svelte +1 -1
- package/dist/visual/VisualEditor.svelte +12 -13
- package/dist/visual/VisualEditor.svelte.d.ts +2 -3
- package/dist/visual/VisualEditorInner.svelte +140 -66
- package/dist/visual/VisualEditorInner.svelte.d.ts +2 -3
- package/dist/visual/controls.css +14 -11
- package/dist/visual/diagnostics.d.ts +12 -20
- package/dist/visual/diagnostics.js +6 -18
- package/dist/visual/index.d.ts +1 -0
- package/dist/visual/index.js +7 -0
- package/dist/visual/leaves.d.ts +47 -0
- package/dist/visual/leaves.js +31 -0
- package/dist/visual/props.d.ts +27 -6
- package/dist/visual/tips.d.ts +3 -1
- package/dist/visual/tips.js +5 -2
- package/package.json +3 -5
package/dist/visual/Field.svelte
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
<script lang="ts">
|
|
17
17
|
import type { Document, Quill, Addr, Diagnostic, ResolvedField } from '@quillmark/wasm';
|
|
18
18
|
import type { EditorErrorHandler } from '../core/errors.js';
|
|
19
|
-
import type {
|
|
19
|
+
import type { LeafRegistry } from './leaves.js';
|
|
20
20
|
import type { FieldModel, FieldSpan } from './structure.js';
|
|
21
21
|
import { enumValues, ghostDefault, stringifyGhost } from './structure.js';
|
|
22
22
|
import type { FieldDomIds } from './domid.js';
|
|
@@ -42,22 +42,26 @@
|
|
|
42
42
|
doc: Document;
|
|
43
43
|
/** The schema a prose leaf reads its content through (`ProseField`). */
|
|
44
44
|
quill: Quill;
|
|
45
|
-
/** LIVE
|
|
46
|
-
|
|
45
|
+
/** This field's LIVE address (getter-`card`, so a card reorder re-targets in
|
|
46
|
+
* place): the prose leaf commits to it, and a focus reports it. */
|
|
47
|
+
addr: Addr;
|
|
47
48
|
leafKey: string;
|
|
48
49
|
/** This field's three DOM names, derived from `leafKey` (see `domid.ts`): how
|
|
49
50
|
* the label and the control find each other. */
|
|
50
51
|
domIds: FieldDomIds;
|
|
51
52
|
onCommitScalar: (value: unknown) => void;
|
|
52
|
-
/** Enum-option policy: `false`
|
|
53
|
-
* control reads
|
|
53
|
+
/** Enum-option policy: `false` marks that option unavailable. Only the enum
|
|
54
|
+
* control reads this pair. */
|
|
54
55
|
optionAllowed?: (value: string) => boolean;
|
|
56
|
+
/** How a refused option draws: greyed (default) or left out. */
|
|
57
|
+
enumDisallowed?: 'hide' | 'disable';
|
|
55
58
|
onFocus?: (addr: Addr) => void;
|
|
56
59
|
onCaretMove?: (addr: Addr, pos: number) => void;
|
|
57
60
|
onChange?: (addr: Addr) => void;
|
|
58
61
|
onError?: EditorErrorHandler;
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
/** The editor's leaf registry (`leaves.ts`): a form control registers its
|
|
63
|
+
* landing handle here, a prose leaf its controller from inside `ProseField`. */
|
|
64
|
+
leaves?: LeafRegistry;
|
|
61
65
|
diagnostics?: Diagnostic[];
|
|
62
66
|
}
|
|
63
67
|
let {
|
|
@@ -67,17 +71,17 @@
|
|
|
67
71
|
provenance,
|
|
68
72
|
doc,
|
|
69
73
|
quill,
|
|
70
|
-
|
|
74
|
+
addr,
|
|
71
75
|
leafKey,
|
|
72
76
|
domIds,
|
|
73
77
|
onCommitScalar,
|
|
74
78
|
optionAllowed,
|
|
79
|
+
enumDisallowed,
|
|
75
80
|
onFocus,
|
|
76
81
|
onCaretMove,
|
|
77
82
|
onChange,
|
|
78
83
|
onError,
|
|
79
|
-
|
|
80
|
-
unregister,
|
|
84
|
+
leaves,
|
|
81
85
|
diagnostics
|
|
82
86
|
}: Props = $props();
|
|
83
87
|
|
|
@@ -103,18 +107,66 @@
|
|
|
103
107
|
// nothing, and silently.
|
|
104
108
|
const describedBy = $derived(field.description ? domIds.description : undefined);
|
|
105
109
|
|
|
106
|
-
// Focus
|
|
107
|
-
//
|
|
108
|
-
//
|
|
110
|
+
// ── Focus: one answer, two callers (`leaves.ts`) ─────────────────────────────
|
|
111
|
+
// A label click and the editor's `focusField`/`setCaret` ask the same question, so
|
|
112
|
+
// they read the same function and cannot land in different places. The four
|
|
113
|
+
// labelable controls are reached through the DOM id `for` already points at; the
|
|
114
|
+
// rest own what focusing means and expose `focus()`, because it differs: a PM view
|
|
115
|
+
// restores a selection, a date field lands on its first segment, an array lands on
|
|
116
|
+
// its first element or on the add affordance that is all an empty one has, an
|
|
117
|
+
// object on its first property.
|
|
109
118
|
let proseEl = $state<{ focus: () => void } | undefined>();
|
|
110
119
|
let dateEl = $state<{ focus: () => void } | undefined>();
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
120
|
+
let arrayEl = $state<{ focus: () => void; focusElement: (k: number) => void } | undefined>();
|
|
121
|
+
let objectEl = $state<{ focus: () => void } | undefined>();
|
|
122
|
+
function focusControl(): void {
|
|
123
|
+
const owner = proseEl ?? dateEl ?? arrayEl ?? objectEl;
|
|
124
|
+
if (owner) return owner.focus();
|
|
125
|
+
document.getElementById(domIds.control)?.focus();
|
|
126
|
+
}
|
|
127
|
+
/** The array's per-ELEMENT landing, for the addresses the preview mints under an
|
|
128
|
+
* array field (`leaves.ts`); read at the call, so it tracks the mounted repeater. */
|
|
129
|
+
function focusElement(k: number): void {
|
|
130
|
+
arrayEl?.focusElement(k);
|
|
131
|
+
}
|
|
132
|
+
// Only where `for` cannot reach; the labelable four are the browser's own, and a
|
|
133
|
+
// second handler over them would be a focus the label already placed.
|
|
134
|
+
const onActivate = $derived(labelable ? undefined : focusControl);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* This field's landing handle. The wrapper is the bloom host rather than the
|
|
138
|
+
* control: `bloomInside` appends an inset child and an `<input>` holds none.
|
|
139
|
+
*
|
|
140
|
+
* A prose leaf is absent here — it registers its own controller from inside
|
|
141
|
+
* `ProseField`, carrying the codec seam this handle has no half of — and reactive
|
|
142
|
+
* rather than mount-once, because a retype can swap the control under a leaf key
|
|
143
|
+
* that does not remount.
|
|
144
|
+
*
|
|
145
|
+
* An array carries the per-element lane as well; no other control has elements for
|
|
146
|
+
* an address to name.
|
|
147
|
+
*/
|
|
148
|
+
let controlEl = $state<HTMLElement | undefined>();
|
|
149
|
+
$effect(() => {
|
|
150
|
+
if (field.control === 'prose' || !controlEl || !leaves) return;
|
|
151
|
+
const key = leafKey;
|
|
152
|
+
const registry = leaves;
|
|
153
|
+
registry.registerControl(key, {
|
|
154
|
+
focus: focusControl,
|
|
155
|
+
focusElement: field.control === 'array' ? focusElement : undefined,
|
|
156
|
+
el: controlEl
|
|
157
|
+
});
|
|
158
|
+
return () => registry.unregisterControl(key);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A form control has no controller to report its focus through, so the WRAPPER
|
|
163
|
+
* reports: `focusin` bubbles, so one handler covers a plain input, an array's N
|
|
164
|
+
* elements and an object's properties alike, and the active leaf names a scalar
|
|
165
|
+
* field the way it names a prose one. A prose leaf reports through its own
|
|
166
|
+
* controller — the source that drives its caret signals too — and is excluded here
|
|
167
|
+
* rather than counted twice.
|
|
168
|
+
*/
|
|
169
|
+
const reportFocus = $derived(field.control === 'prose' ? undefined : () => onFocus?.(addr));
|
|
118
170
|
</script>
|
|
119
171
|
|
|
120
172
|
<div class="qm-field" class:cell={span === 'cell'} class:lone={span === 'lone'}>
|
|
@@ -129,13 +181,13 @@
|
|
|
129
181
|
description={field.description}
|
|
130
182
|
/>
|
|
131
183
|
{/if}
|
|
132
|
-
<div class="qm-field-control">
|
|
184
|
+
<div class="qm-field-control" bind:this={controlEl} onfocusin={reportFocus}>
|
|
133
185
|
{#if field.control === 'prose'}
|
|
134
186
|
<ProseField
|
|
135
187
|
{quill}
|
|
136
188
|
bind:this={proseEl}
|
|
137
189
|
{doc}
|
|
138
|
-
addr
|
|
190
|
+
{addr}
|
|
139
191
|
inline={field.inline}
|
|
140
192
|
plaintext={field.plaintext}
|
|
141
193
|
labelledBy={domIds.label}
|
|
@@ -145,8 +197,7 @@
|
|
|
145
197
|
{onCaretMove}
|
|
146
198
|
{onChange}
|
|
147
199
|
{onError}
|
|
148
|
-
{
|
|
149
|
-
{unregister}
|
|
200
|
+
{leaves}
|
|
150
201
|
/>
|
|
151
202
|
{:else if field.control === 'enum'}
|
|
152
203
|
<EnumField
|
|
@@ -157,6 +208,7 @@
|
|
|
157
208
|
{describedBy}
|
|
158
209
|
onCommit={onCommitScalar}
|
|
159
210
|
{optionAllowed}
|
|
211
|
+
{enumDisallowed}
|
|
160
212
|
/>
|
|
161
213
|
{:else if field.control === 'number'}
|
|
162
214
|
<NumberField
|
|
@@ -186,6 +238,7 @@
|
|
|
186
238
|
/>
|
|
187
239
|
{:else if field.control === 'array'}
|
|
188
240
|
<ArrayField
|
|
241
|
+
bind:this={arrayEl}
|
|
189
242
|
value={value as unknown[] | undefined}
|
|
190
243
|
items={field.schema.items}
|
|
191
244
|
label={field.label}
|
|
@@ -194,10 +247,10 @@
|
|
|
194
247
|
labelId={domIds.label}
|
|
195
248
|
descriptionId={domIds.description}
|
|
196
249
|
onCommit={onCommitScalar}
|
|
197
|
-
onFocusEl={() => onFocus?.(proseAddr)}
|
|
198
250
|
/>
|
|
199
251
|
{:else if field.control === 'object'}
|
|
200
252
|
<ObjectField
|
|
253
|
+
bind:this={objectEl}
|
|
201
254
|
value={value as Record<string, unknown> | undefined}
|
|
202
255
|
properties={field.schema.properties}
|
|
203
256
|
label={field.label}
|
|
@@ -262,6 +315,15 @@
|
|
|
262
315
|
.qm-field.cell {
|
|
263
316
|
grid-column: span 1;
|
|
264
317
|
}
|
|
318
|
+
/* Positioned for the arrival wash a landing inserts (`core/bloom.ts`), the way
|
|
319
|
+
`.qm-prose` is: an inset child over the control, since an `<input>` takes none.
|
|
320
|
+
The radius is the control's own, so the wash's corners are the box's rather than
|
|
321
|
+
square over a rounded one; on an array or a subform it bounds a group, where
|
|
322
|
+
there is no single box for it to disagree with. */
|
|
323
|
+
.qm-field-control {
|
|
324
|
+
position: relative;
|
|
325
|
+
border-radius: var(--_qm-radius-inner);
|
|
326
|
+
}
|
|
265
327
|
/* A run of one takes half the capacity from column 1: `--cols-half` is the section's
|
|
266
328
|
capacity halved, so the edge lands on a track boundary at every capacity. */
|
|
267
329
|
.qm-field.lone {
|
|
@@ -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 {
|
|
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
|
|
18
|
-
|
|
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`
|
|
25
|
-
* control reads
|
|
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
|
-
|
|
32
|
-
|
|
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')
|
|
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')
|
|
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'))
|
|
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=
|
|
96
|
-
align=
|
|
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-
|
|
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
|
-
|
|
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-
|
|
230
|
+
font-size: var(--_qm-text-label);
|
|
182
231
|
line-height: var(--_qm-leading-body);
|
|
183
|
-
color: var(--_qm-ink-
|
|
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-
|
|
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
|
|
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-
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
+
leaves?.registerProse(leafKey, controller);
|
|
138
140
|
return () => {
|
|
139
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
44
|
-
|
|
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-
|
|
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 {
|
|
28
|
-
import type {
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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 {
|
|
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: (
|
|
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;
|