@quillmark/svelte 0.12.0 → 0.13.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/dist/core/codec/decode.d.ts +17 -0
- package/dist/core/codec/decode.js +57 -4
- package/dist/core/codec/field.d.ts +64 -15
- package/dist/core/codec/field.js +201 -65
- package/dist/core/codec/index.d.ts +3 -3
- package/dist/core/codec/index.js +3 -3
- package/dist/core/codec/keymap.d.ts +5 -4
- package/dist/core/codec/keymap.js +5 -4
- package/dist/core/codec/prose.css +42 -0
- package/dist/core/codec/schema.d.ts +24 -5
- package/dist/core/codec/schema.js +40 -9
- package/dist/core/errors.d.ts +3 -2
- package/dist/core/theme.css +9 -6
- package/dist/preview/paint.js +21 -17
- package/dist/visual/ArrayField.svelte +66 -22
- package/dist/visual/ArrayField.svelte.d.ts +2 -0
- package/dist/visual/BooleanField.svelte +28 -6
- package/dist/visual/BooleanField.svelte.d.ts +2 -0
- package/dist/visual/Card.svelte +3 -3
- package/dist/visual/DateField.svelte +66 -31
- package/dist/visual/EnumField.svelte +27 -9
- package/dist/visual/EnumField.svelte.d.ts +5 -0
- package/dist/visual/Field.svelte +45 -10
- package/dist/visual/FormatPopover.svelte +4 -3
- package/dist/visual/MatrixField.svelte +3 -2
- package/dist/visual/NumberField.svelte +31 -14
- package/dist/visual/NumberField.svelte.d.ts +5 -1
- package/dist/visual/ObjectField.svelte +62 -15
- package/dist/visual/ProseField.svelte +53 -23
- package/dist/visual/ProseField.svelte.d.ts +15 -3
- package/dist/visual/ProseValue.svelte +102 -46
- package/dist/visual/ProseValue.svelte.d.ts +17 -5
- package/dist/visual/TextField.svelte +35 -18
- package/dist/visual/TextField.svelte.d.ts +7 -0
- package/dist/visual/VariantField.svelte +1 -0
- package/dist/visual/VisualEditorInner.svelte +24 -24
- package/dist/visual/controls.css +16 -5
- package/dist/visual/strings.d.ts +8 -1
- package/dist/visual/strings.js +2 -0
- package/dist/visual/structure.d.ts +77 -43
- package/dist/visual/structure.js +132 -57
- package/package.json +2 -2
|
@@ -7,8 +7,8 @@ export declare const blockSchema: Schema<string, string>;
|
|
|
7
7
|
* a table cell, which is the same content unit). */
|
|
8
8
|
export declare const inlineSchema: Schema<string, string>;
|
|
9
9
|
/**
|
|
10
|
-
* The inline schema with no mark types at all: a `plaintext` field, whose
|
|
11
|
-
* literal text the boundary refuses to coerce back once it carries a mark.
|
|
10
|
+
* The inline schema with no mark types at all: a `plaintext(inline)` field, whose
|
|
11
|
+
* value is literal text the boundary refuses to coerce back once it carries a mark.
|
|
12
12
|
*
|
|
13
13
|
* Declaring none is what makes that structural rather than a rule each path
|
|
14
14
|
* restates: `toggleMark` has no type to apply, the mark input rules build nothing,
|
|
@@ -16,11 +16,30 @@ export declare const inlineSchema: Schema<string, string>;
|
|
|
16
16
|
* carrying (CODEC §Inline mode).
|
|
17
17
|
*/
|
|
18
18
|
export declare const plaintextSchema: Schema<string, never>;
|
|
19
|
-
/**
|
|
20
|
-
*
|
|
19
|
+
/**
|
|
20
|
+
* A `plaintext` field without `inline`: paragraphs and hard breaks, no marks, no
|
|
21
|
+
* islands, no containers, which is the shape `Content::is_plain` admits. Mark-free
|
|
22
|
+
* for the reason {@link plaintextSchema} is.
|
|
23
|
+
*
|
|
24
|
+
* A paragraph and a break both store one `\n`, so a paragraph here is a line and
|
|
25
|
+
* says so (`data-qm-line`): `prose.css` draws a run of them at the leading, and a
|
|
26
|
+
* boundary reads as the one line down that a break does.
|
|
27
|
+
*/
|
|
28
|
+
export declare const plainSchema: Schema<"text" | "doc" | "paragraph" | "hard_break", never>;
|
|
29
|
+
/** The schema a leaf of the declared type mounts: `inline` narrows either type to one
|
|
30
|
+
* textblock, and `plaintext` takes its marks away. */
|
|
31
|
+
export declare function leafSchema(opts: {
|
|
32
|
+
plaintext?: boolean;
|
|
33
|
+
inline?: boolean;
|
|
34
|
+
}): Schema;
|
|
35
|
+
/** Whether `schema` can carry formatting at all: false for the two plaintext schemas.
|
|
36
|
+
* What a mark command asks before it offers itself. */
|
|
21
37
|
export declare function hasMarks(schema: Schema): boolean;
|
|
22
|
-
/** True for the
|
|
38
|
+
/** True for the one-line schemas, which declare no `hard_break`; decode branches on it. */
|
|
23
39
|
export declare function isInlineSchema(schema: Schema): boolean;
|
|
40
|
+
/** True for {@link blockSchema}, the one holding blocks past the paragraph: what a
|
|
41
|
+
* leaf's island, gap-cursor and slash machinery mounts on. */
|
|
42
|
+
export declare function isBlockSchema(schema: Schema): boolean;
|
|
24
43
|
/** Whether `type` can hold a within-block line break, which a heading cannot (§`heading`).
|
|
25
44
|
* What a `\n` landing in a textblock is rewritten to turns on it (`breaks.ts`), and so
|
|
26
45
|
* does what decode puts between a block's continued segments. */
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// Each set is closed upstream, so the schema names its whole vocabulary and carries
|
|
5
5
|
// nothing inert. `blockSchema` is the full field; `inlineSchema` is the constrained
|
|
6
6
|
// single-textblock form for `richtext(inline)` (one paragraph, no block split, no
|
|
7
|
-
// containers, no islands) and `plaintextSchema` is that one without marks
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// containers, no islands) and `plaintextSchema` is that one without marks, for
|
|
8
|
+
// `plaintext(inline)`; `plainSchema` is a `plaintext` field without `inline`,
|
|
9
|
+
// paragraphs and hard breaks and nothing else. Same decode/lower/position machinery,
|
|
10
|
+
// narrower shapes. Anchors are not marks here (decorations).
|
|
10
11
|
//
|
|
11
12
|
// `toDOM` and `parseDOM` are one tier, not two halves of a rendering: a copy and a
|
|
12
13
|
// paste inside one body run the whole document through them (CODEC §"Markdown at the
|
|
@@ -188,8 +189,8 @@ const inlineNodes = {
|
|
|
188
189
|
* a table cell, which is the same content unit). */
|
|
189
190
|
export const inlineSchema = new Schema({ nodes: inlineNodes, marks });
|
|
190
191
|
/**
|
|
191
|
-
* The inline schema with no mark types at all: a `plaintext` field, whose
|
|
192
|
-
* literal text the boundary refuses to coerce back once it carries a mark.
|
|
192
|
+
* The inline schema with no mark types at all: a `plaintext(inline)` field, whose
|
|
193
|
+
* value is literal text the boundary refuses to coerce back once it carries a mark.
|
|
193
194
|
*
|
|
194
195
|
* Declaring none is what makes that structural rather than a rule each path
|
|
195
196
|
* restates: `toggleMark` has no type to apply, the mark input rules build nothing,
|
|
@@ -197,14 +198,44 @@ export const inlineSchema = new Schema({ nodes: inlineNodes, marks });
|
|
|
197
198
|
* carrying (CODEC §Inline mode).
|
|
198
199
|
*/
|
|
199
200
|
export const plaintextSchema = new Schema({ nodes: inlineNodes, marks: {} });
|
|
200
|
-
/**
|
|
201
|
-
*
|
|
201
|
+
/**
|
|
202
|
+
* A `plaintext` field without `inline`: paragraphs and hard breaks, no marks, no
|
|
203
|
+
* islands, no containers, which is the shape `Content::is_plain` admits. Mark-free
|
|
204
|
+
* for the reason {@link plaintextSchema} is.
|
|
205
|
+
*
|
|
206
|
+
* A paragraph and a break both store one `\n`, so a paragraph here is a line and
|
|
207
|
+
* says so (`data-qm-line`): `prose.css` draws a run of them at the leading, and a
|
|
208
|
+
* boundary reads as the one line down that a break does.
|
|
209
|
+
*/
|
|
210
|
+
export const plainSchema = new Schema({
|
|
211
|
+
nodes: {
|
|
212
|
+
doc: { content: 'paragraph+' },
|
|
213
|
+
paragraph: { ...blockNodes.paragraph, toDOM: () => ['p', { 'data-qm-line': '' }, 0] },
|
|
214
|
+
text: inlineLeafNodes.text,
|
|
215
|
+
hard_break: inlineLeafNodes.hard_break
|
|
216
|
+
},
|
|
217
|
+
marks: {}
|
|
218
|
+
});
|
|
219
|
+
/** The schema a leaf of the declared type mounts: `inline` narrows either type to one
|
|
220
|
+
* textblock, and `plaintext` takes its marks away. */
|
|
221
|
+
export function leafSchema(opts) {
|
|
222
|
+
if (opts.plaintext)
|
|
223
|
+
return opts.inline ? plaintextSchema : plainSchema;
|
|
224
|
+
return opts.inline ? inlineSchema : blockSchema;
|
|
225
|
+
}
|
|
226
|
+
/** Whether `schema` can carry formatting at all: false for the two plaintext schemas.
|
|
227
|
+
* What a mark command asks before it offers itself. */
|
|
202
228
|
export function hasMarks(schema) {
|
|
203
229
|
return Object.keys(schema.marks).length > 0;
|
|
204
230
|
}
|
|
205
|
-
/** True for the
|
|
231
|
+
/** True for the one-line schemas, which declare no `hard_break`; decode branches on it. */
|
|
206
232
|
export function isInlineSchema(schema) {
|
|
207
|
-
return !schema.nodes.
|
|
233
|
+
return !schema.nodes.hard_break;
|
|
234
|
+
}
|
|
235
|
+
/** True for {@link blockSchema}, the one holding blocks past the paragraph: what a
|
|
236
|
+
* leaf's island, gap-cursor and slash machinery mounts on. */
|
|
237
|
+
export function isBlockSchema(schema) {
|
|
238
|
+
return !!schema.nodes.blockquote;
|
|
208
239
|
}
|
|
209
240
|
/** Whether `type` can hold a within-block line break, which a heading cannot (§`heading`).
|
|
210
241
|
* What a `\n` landing in a textblock is rewritten to turns on it (`breaks.ts`), and so
|
package/dist/core/errors.d.ts
CHANGED
|
@@ -11,8 +11,9 @@ export type EditorErrorCode =
|
|
|
11
11
|
* the whole projection instead, so the store is correct and this field's
|
|
12
12
|
* identity anchors were paid. */
|
|
13
13
|
| 'commit-fallback'
|
|
14
|
-
/** The overwrite fallback also failed
|
|
15
|
-
*
|
|
14
|
+
/** The overwrite fallback also failed, or the typed writer refused a `plaintext`
|
|
15
|
+
* leaf's text: the optimistic PM state stands and the store is stale for this
|
|
16
|
+
* field. The one code here that leaves damage. */
|
|
16
17
|
| 'commit-lost'
|
|
17
18
|
/** A card operation (add, move, remove, retype) that threw. The document is
|
|
18
19
|
* unchanged; the boundary's mutators are transactional. */
|
package/dist/core/theme.css
CHANGED
|
@@ -163,13 +163,16 @@
|
|
|
163
163
|
relationship at any mix: a stroke against the surface behind it, not a fill
|
|
164
164
|
stacked on one. */
|
|
165
165
|
--_qm-border-faint: color-mix(in oklab, var(--_qm-bg), var(--_qm-border) 50%);
|
|
166
|
-
/*
|
|
167
|
-
well a field draws it against, and what would take it —
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
166
|
+
/* Three rungs, and nothing under the last: a step below `label` lands under 3:1 on
|
|
167
|
+
the well a field draws it against, and what would take it — an example, an unset
|
|
168
|
+
value's word — is content a reader has to read (WCAG 1.4.3), not decoration.
|
|
169
|
+
`ink` is written. `default` is the resolved `default:` drawn as the value it
|
|
170
|
+
prints: near enough to `ink` to read as an answer, a step off it so it never
|
|
171
|
+
reads as a written one. `label` is words about a value rather than the value — an
|
|
172
|
+
`example:`, `None`, a format hint — and the chrome that names fields; in italic
|
|
173
|
+
where it is a leaf's ghost, which has no written neighbour to be read against. */
|
|
172
174
|
--_qm-ink: var(--_qm-fg);
|
|
175
|
+
--_qm-ink-default: color-mix(in oklab, var(--_qm-fg), var(--_qm-bg) 20%);
|
|
173
176
|
--_qm-ink-label: color-mix(in oklab, var(--_qm-fg), var(--_qm-bg) 35%);
|
|
174
177
|
/* The arrival wash (`core/bloom.ts`). Its alpha is a peak, not a tint: the
|
|
175
178
|
wash animates from and back to zero, never held, so it reads over the leaf it
|
package/dist/preview/paint.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// Vertical breathing room between stacked pages; cosmetic only, and a rung like
|
|
2
2
|
// any other gap: the stack breathes on the same rhythm the cards do.
|
|
3
3
|
const PAGE_GAP = 'var(--_qm-space-4)';
|
|
4
|
+
// The least raster density, in device pixels per CSS pixel. Glyphs rasterize
|
|
5
|
+
// unhinted, so a raster at a 1× or fractional ratio (1.25, 1.5; browser zoom)
|
|
6
|
+
// is resampled nearly 1:1 and small text smears; a raster at least twice the
|
|
7
|
+
// box is downsampled instead, which keeps body text legible.
|
|
8
|
+
const MIN_DENSITY = 2;
|
|
4
9
|
export function createPaintLoop(session, container, margin,
|
|
5
10
|
// Notified when a slot's `session.paint` throws: the loop swallows the throw
|
|
6
11
|
// (see `paintSlot`) so one bad page never aborts the band sweep; the caller
|
|
@@ -65,16 +70,19 @@ onPaintError) {
|
|
|
65
70
|
return { page, size, el };
|
|
66
71
|
}
|
|
67
72
|
// Rasterize `slot` into its canvas (creating the canvas if this is its first
|
|
68
|
-
// paint since mounting)
|
|
69
|
-
// CSS width, so layout width tracks the container per the zoom settled
|
|
70
|
-
// decision; `canvas.style.*` is then set from the authoritative PaintResult,
|
|
71
|
-
// never guessed.
|
|
73
|
+
// paint since mounting), at the slot's own current CSS width.
|
|
72
74
|
function paintSlot(slot) {
|
|
73
75
|
let canvas = canvases.get(slot.page);
|
|
74
76
|
if (!canvas) {
|
|
75
77
|
canvas = document.createElement('canvas');
|
|
76
78
|
canvas.className = 'qm-page-canvas';
|
|
77
|
-
Object.assign(canvas.style, {
|
|
79
|
+
Object.assign(canvas.style, {
|
|
80
|
+
position: 'absolute',
|
|
81
|
+
inset: '0',
|
|
82
|
+
display: 'block',
|
|
83
|
+
width: '100%',
|
|
84
|
+
height: '100%'
|
|
85
|
+
});
|
|
78
86
|
}
|
|
79
87
|
// Contextless canvas: bail before registering, so `updateBand` still sees
|
|
80
88
|
// the page as unmounted and retries instead of keeping a blank page.
|
|
@@ -86,11 +94,9 @@ onPaintError) {
|
|
|
86
94
|
canvases.set(slot.page, canvas);
|
|
87
95
|
}
|
|
88
96
|
const layoutScale = (slot.el.clientWidth || slot.size.widthPt) / slot.size.widthPt;
|
|
89
|
-
const densityScale = (window.devicePixelRatio || 1) * zoom;
|
|
97
|
+
const densityScale = Math.max(window.devicePixelRatio || 1, MIN_DENSITY) * zoom;
|
|
90
98
|
try {
|
|
91
|
-
|
|
92
|
-
canvas.style.width = `${result.layoutWidth}px`;
|
|
93
|
-
canvas.style.height = `${result.layoutHeight}px`;
|
|
99
|
+
session.paint(ctx, slot.page, layoutScale * densityScale);
|
|
94
100
|
}
|
|
95
101
|
catch (err) {
|
|
96
102
|
// A paint that throws must not abort the band loop; `updateBand` runs in
|
|
@@ -162,14 +168,12 @@ onPaintError) {
|
|
|
162
168
|
// Slots are built on the first `refresh` (the controller calls it immediately),
|
|
163
169
|
// not here: reconcile → `session.pageSize` must run after the controller's
|
|
164
170
|
// `pageCount` gate, since `pageSize` throws for a page the count excludes.
|
|
165
|
-
// ── Keep mounted rasters
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
// their current box; canvases are `position:absolute`, so a repaint can't feed
|
|
172
|
-
// back into layout (and thus can't re-trigger the resize observer).
|
|
171
|
+
// ── Keep mounted rasters sharp ──────────────────────────────────────────────
|
|
172
|
+
// A canvas stretches with its slot, so a page that stays mounted while the
|
|
173
|
+
// container's CSS width or `devicePixelRatio` shifts keeps its place and goes
|
|
174
|
+
// soft. Two observers repaint mounted pages from their current box; canvases
|
|
175
|
+
// are `position:absolute`, so a repaint can't feed back into layout (and thus
|
|
176
|
+
// can't re-trigger the resize observer).
|
|
173
177
|
// Repaint mounted pages (all, or a given subset) from their live geometry: the
|
|
174
178
|
// one path resize, DPR, and zoom share; each `paintSlot` re-reads the slot's
|
|
175
179
|
// current `clientWidth`. A no-op for an unmounted page.
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
neither a screen reader nor a touch user, and the count is the carrier.
|
|
13
13
|
|
|
14
14
|
An `object` element collapses: the row is its own summary — a box, titled by the
|
|
15
|
-
row's
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
row's first short text cell ({@link rowSummary}) — and opens onto {@link ObjectField},
|
|
16
|
+
one at a time. Stacking the subforms instead would nest a field one level past the
|
|
17
|
+
depth the subform's own vertical draws, once per row.
|
|
18
18
|
Opening is therefore part of a landing rather than something the user does first:
|
|
19
19
|
`focusPath` opens the row it is aimed at before it focuses, and hands the rest of the
|
|
20
20
|
walk to the subform it opened, which is how a row two closed boxes down is reached.
|
|
@@ -45,6 +45,13 @@
|
|
|
45
45
|
and of the values together, the mechanism insert and remove use, so an element keeps
|
|
46
46
|
its id for life and no prose leaf inside it remounts; the open row stays open across
|
|
47
47
|
its own move, and `animate:reorder` holds the moving row as it holds a card.
|
|
48
|
+
|
|
49
|
+
An unset array whose `default:` holds elements draws them as its rows, at the default
|
|
50
|
+
rung (`data-default` on the rows' box), since they are what prints. The rows are the
|
|
51
|
+
declared literal rather than resolve's form, which completes a record against `items`:
|
|
52
|
+
every gesture — an edit in a row, Enter, a remove, a move, the add chip — commits the
|
|
53
|
+
whole array with the change in it, and a take writes only what the author declared.
|
|
54
|
+
Removing the last row commits `[]`, the empty answer.
|
|
48
55
|
-->
|
|
49
56
|
<script lang="ts">
|
|
50
57
|
import { wording } from './strings.js';
|
|
@@ -58,10 +65,13 @@
|
|
|
58
65
|
import { createLifespan } from '../core/teardown.js';
|
|
59
66
|
import {
|
|
60
67
|
IdSeq,
|
|
68
|
+
baseType,
|
|
61
69
|
controlKind,
|
|
70
|
+
declaredContent,
|
|
62
71
|
humanize,
|
|
63
72
|
obliged,
|
|
64
73
|
rowSummary,
|
|
74
|
+
schemaAt,
|
|
65
75
|
type ArrayLayout
|
|
66
76
|
} from './structure.js';
|
|
67
77
|
import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
|
|
@@ -82,6 +92,8 @@
|
|
|
82
92
|
|
|
83
93
|
interface Props {
|
|
84
94
|
value: unknown[] | undefined;
|
|
95
|
+
/** The declared `default:`: the rows an unset array draws, which a gesture takes. */
|
|
96
|
+
fallback?: unknown[];
|
|
85
97
|
items: QuillFieldSchema | undefined;
|
|
86
98
|
/** How the elements draw: the record list, or the table an `array<object>` of
|
|
87
99
|
* short cells asks for (`arrayLayout`). */
|
|
@@ -121,6 +133,7 @@
|
|
|
121
133
|
}
|
|
122
134
|
let {
|
|
123
135
|
value,
|
|
136
|
+
fallback,
|
|
124
137
|
items,
|
|
125
138
|
layout = 'list',
|
|
126
139
|
max,
|
|
@@ -142,7 +155,17 @@
|
|
|
142
155
|
const control = $derived(items ? controlKind(items) : 'text');
|
|
143
156
|
const table = $derived(layout === 'table' && control === 'object');
|
|
144
157
|
const columns = $derived(Object.entries(items?.properties ?? {}));
|
|
145
|
-
const
|
|
158
|
+
const defaulted = $derived(value == null && (fallback?.length ?? 0) > 0);
|
|
159
|
+
const arr = $derived((value ?? fallback ?? []) as unknown[]);
|
|
160
|
+
/** The rows' content read: the boundary's, or, while the rows are the default, the
|
|
161
|
+
* literal's own leaf at the codec its declared type names. */
|
|
162
|
+
function readAt(path: PathStep[]): Content | undefined {
|
|
163
|
+
if (!defaulted) return contentAt(path);
|
|
164
|
+
const leaf = schemaAt({ type: 'array', items }, path);
|
|
165
|
+
let v: unknown = arr;
|
|
166
|
+
for (const step of path) v = (v as Record<string | number, unknown> | undefined)?.[step];
|
|
167
|
+
return leaf ? declaredContent(v, baseType(leaf) === 'richtext') : undefined;
|
|
168
|
+
}
|
|
146
169
|
const atCap = $derived(max != null && arr.length >= max);
|
|
147
170
|
const routed = $derived(splitDeep(diagnostics));
|
|
148
171
|
const foot = $derived(
|
|
@@ -154,7 +177,7 @@
|
|
|
154
177
|
// an effect-only seed mounts every element editor in a second render.
|
|
155
178
|
const seq = new IdSeq();
|
|
156
179
|
// svelte-ignore state_referenced_locally
|
|
157
|
-
let ids = $state<string[]>(seq.take((value ?? []).length));
|
|
180
|
+
let ids = $state<string[]>(seq.take((value ?? fallback ?? []).length));
|
|
158
181
|
// Length reconcile (defend against an out-of-band length change);
|
|
159
182
|
// order is maintained by the mutators, not here.
|
|
160
183
|
$effect(() => {
|
|
@@ -239,8 +262,7 @@
|
|
|
239
262
|
// one, else this instance's own.
|
|
240
263
|
const uid = $props.id();
|
|
241
264
|
const countId = $derived(`${idBase ?? `qm-${uid}`}-count`);
|
|
242
|
-
const columnTitle = (key: string, sub: QuillFieldSchema): string =>
|
|
243
|
-
sub.ui?.title ?? humanize(key);
|
|
265
|
+
const columnTitle = (key: string, sub: QuillFieldSchema): string => sub.title ?? humanize(key);
|
|
244
266
|
|
|
245
267
|
// The awaited flush below is the only work that outlives a gesture here, so the
|
|
246
268
|
// span carries the reorder's frame and nothing else: it is the liveness
|
|
@@ -444,14 +466,14 @@
|
|
|
444
466
|
if (v === undefined || v === '') return true;
|
|
445
467
|
return typeof v === 'object' && v !== null && 'text' in v && (v as Content).text === '';
|
|
446
468
|
}
|
|
447
|
-
/** Whether element `k` reads empty to the user. A text element's
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
469
|
+
/** Whether element `k` reads empty to the user. A text element's input is the truth,
|
|
470
|
+
* being the control the keystroke lands in. A prose element commits every edit, so
|
|
471
|
+
* the committed `Content` is; an authored string, the transport-door rest, is empty
|
|
472
|
+
* when it has no characters. A table's row is empty when the cell under the caret is
|
|
473
|
+
* and so is every cell beside it, read off the row: a cell showing only its schema
|
|
474
|
+
* default holds nothing written, as in a row just added. The caret's cell is read off
|
|
475
|
+
* its control, which a default the cell holds unwritten leaves full while the
|
|
476
|
+
* committed row has no key. */
|
|
455
477
|
function elementEmpty(k: number, target: EventTarget | null, column?: string): boolean {
|
|
456
478
|
if (control === 'prose') {
|
|
457
479
|
const el = arr[k];
|
|
@@ -556,7 +578,12 @@
|
|
|
556
578
|
<!-- Three lists, one per row shape, because `animate:` is granted only to a keyed
|
|
557
579
|
each block's one child: a branch inside the block would stand between them. -->
|
|
558
580
|
{#if table}
|
|
559
|
-
<div
|
|
581
|
+
<div
|
|
582
|
+
class="qm-array-rows qm-array-table"
|
|
583
|
+
class:empty={ids.length === 0}
|
|
584
|
+
data-default={defaulted ? '' : undefined}
|
|
585
|
+
bind:this={rowsEl}
|
|
586
|
+
>
|
|
560
587
|
<div class="qm-array-table-scroller" style:--table-cols={columns.length}>
|
|
561
588
|
<!-- The header names the columns, so a cell carries no label of its own: its
|
|
562
589
|
accessible name is the row's and the column's composed (`ObjectField`). The
|
|
@@ -582,7 +609,7 @@
|
|
|
582
609
|
value={(arr[k] ?? {}) as Record<string, unknown>}
|
|
583
610
|
properties={items?.properties}
|
|
584
611
|
label={rowName(k)}
|
|
585
|
-
contentAt={(path) =>
|
|
612
|
+
contentAt={(path) => readAt([k, ...path])}
|
|
586
613
|
onCommit={(obj) => commitElement(k, obj)}
|
|
587
614
|
onCellKey={(e, column) => onElementKey(e, k, column)}
|
|
588
615
|
diagnostics={routed.below.get(k)}
|
|
@@ -593,7 +620,12 @@
|
|
|
593
620
|
</div>
|
|
594
621
|
</div>
|
|
595
622
|
{:else if control === 'object'}
|
|
596
|
-
<div
|
|
623
|
+
<div
|
|
624
|
+
class="qm-array-rows"
|
|
625
|
+
class:empty={ids.length === 0}
|
|
626
|
+
data-default={defaulted ? '' : undefined}
|
|
627
|
+
bind:this={rowsEl}
|
|
628
|
+
>
|
|
597
629
|
{#each ids as id, k (id)}
|
|
598
630
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
599
631
|
<!-- The handler catches a key from the controls inside the row and adds no
|
|
@@ -637,7 +669,7 @@
|
|
|
637
669
|
properties={items?.properties}
|
|
638
670
|
label={rowName(k)}
|
|
639
671
|
idBase={idBase != null ? `${idBase}-e-${id}` : undefined}
|
|
640
|
-
contentAt={(path) =>
|
|
672
|
+
contentAt={(path) => readAt([k, ...path])}
|
|
641
673
|
onCommit={(obj) => commitElement(k, obj)}
|
|
642
674
|
diagnostics={routed.below.get(k)}
|
|
643
675
|
/>
|
|
@@ -650,14 +682,19 @@
|
|
|
650
682
|
{/each}
|
|
651
683
|
</div>
|
|
652
684
|
{:else}
|
|
653
|
-
<div
|
|
685
|
+
<div
|
|
686
|
+
class="qm-array-rows"
|
|
687
|
+
class:empty={ids.length === 0}
|
|
688
|
+
data-default={defaulted ? '' : undefined}
|
|
689
|
+
bind:this={rowsEl}
|
|
690
|
+
>
|
|
654
691
|
{#each ids as id, k (id)}
|
|
655
692
|
<div class="qm-array-row" bind:this={rowEls[id]}>
|
|
656
693
|
{#if control === 'prose'}
|
|
657
694
|
<ProseValue
|
|
658
695
|
bind:this={els[id]}
|
|
659
|
-
content={() =>
|
|
660
|
-
plaintext={items
|
|
696
|
+
content={() => readAt([k])}
|
|
697
|
+
plaintext={items != null && baseType(items) === 'plaintext'}
|
|
661
698
|
label={rowName(k)}
|
|
662
699
|
onChange={(rt) => commitElement(k, rt)}
|
|
663
700
|
onKey={(e) => onElementKey(e, k)}
|
|
@@ -762,6 +799,13 @@
|
|
|
762
799
|
.qm-array-rows.empty {
|
|
763
800
|
display: none;
|
|
764
801
|
}
|
|
802
|
+
/* The default's rows are what prints and nothing written: every value they draw takes
|
|
803
|
+
the default rung (theme.css), and the first gesture, writing them, takes it off. */
|
|
804
|
+
.qm-array-rows[data-default] :global(:is(.qm-input, .ProseMirror, .qm-date)),
|
|
805
|
+
.qm-array-rows[data-default] :global(.qm-select:not([data-ghosted])),
|
|
806
|
+
.qm-array-rows[data-default] .qm-element-title:not(.untitled) {
|
|
807
|
+
color: var(--_qm-ink-default);
|
|
808
|
+
}
|
|
765
809
|
/* A grid rather than a block: an `<input>` is inline-level and would sit on a
|
|
766
810
|
baseline, standing the row a descender taller than the box the slab measures itself
|
|
767
811
|
against. `minmax(0, …)` because a long unbroken value grows an `auto` track, and the
|
|
@@ -5,6 +5,8 @@ import type { LandingBox } from './leaves.js';
|
|
|
5
5
|
import './controls.css';
|
|
6
6
|
interface Props {
|
|
7
7
|
value: unknown[] | undefined;
|
|
8
|
+
/** The declared `default:`: the rows an unset array draws, which a gesture takes. */
|
|
9
|
+
fallback?: unknown[];
|
|
8
10
|
items: QuillFieldSchema | undefined;
|
|
9
11
|
/** How the elements draw: the record list, or the table an `array<object>` of
|
|
10
12
|
* short cells asks for (`arrayLayout`). */
|
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
Styled rather than a native checkbox: the native box's face is UA-owned shadow
|
|
5
5
|
DOM, so no dial reaches it. The a11y comes with the primitive:
|
|
6
6
|
Switch.Root renders `role="switch"` with its checked state and keyboard handling.
|
|
7
|
+
|
|
8
|
+
An unset `boolean?` prints `none`, which neither face of a switch says, so it draws a
|
|
9
|
+
third: the thumb at the track's centre. `role="switch"` admits no third state (a
|
|
10
|
+
`mixed` there reads as false), so an optional boolean's control is a `checkbox`, the
|
|
11
|
+
toggle role that does, announcing `mixed` while unset. A press from unset writes
|
|
12
|
+
`true`; nothing here returns the cell to unset.
|
|
7
13
|
-->
|
|
8
14
|
<script lang="ts">
|
|
9
15
|
import { Switch } from 'bits-ui';
|
|
@@ -13,6 +19,8 @@
|
|
|
13
19
|
interface Props {
|
|
14
20
|
value: boolean | undefined;
|
|
15
21
|
fallback?: boolean;
|
|
22
|
+
/** A `boolean?` cell, which has a third state: unset, printing `none`. */
|
|
23
|
+
optional?: boolean;
|
|
16
24
|
/** Accessible name for a switch nothing else names: an object property, whose
|
|
17
25
|
* name is the field label plus the property's. A field's own switch takes `id`
|
|
18
26
|
* instead and is named by the `<label for>` beside it. */
|
|
@@ -26,32 +34,42 @@
|
|
|
26
34
|
describedBy?: string;
|
|
27
35
|
onCommit: (v: boolean) => void;
|
|
28
36
|
}
|
|
29
|
-
let { value, fallback, label, id, describedBy, onCommit }: Props = $props();
|
|
37
|
+
let { value, fallback, optional = false, label, id, describedBy, onCommit }: Props = $props();
|
|
30
38
|
|
|
31
39
|
// Local toggle state synced to `value`; own-toggles stay local, only an external
|
|
32
40
|
// change reconciles back in (see `syncedLocal`). The primitive is driven
|
|
33
41
|
// controlled (`checked` + `onCheckedChange`, never `bind:`) so reconciliation
|
|
34
42
|
// stays the package's: a two-way bind hands the primitive a lane around it,
|
|
35
|
-
// which repeats the reconciliation hazard in miniature.
|
|
36
|
-
|
|
43
|
+
// which repeats the reconciliation hazard in miniature. `undefined` is the unset
|
|
44
|
+
// optional cell, the one state with no default to stand in for it.
|
|
45
|
+
const local = syncedLocal<boolean | undefined>(
|
|
46
|
+
() => value ?? fallback ?? (optional ? undefined : false)
|
|
47
|
+
);
|
|
48
|
+
const unset = $derived(local.value === undefined);
|
|
49
|
+
const tristate = $derived(
|
|
50
|
+
optional ? { role: 'checkbox', 'aria-checked': unset ? ('mixed' as const) : !!local.value } : {}
|
|
51
|
+
);
|
|
37
52
|
</script>
|
|
38
53
|
|
|
39
54
|
<!-- Not `.qm-switch`: that name is the preset's pane band, whose narrow-viewport rule
|
|
40
55
|
stands every child of it at `--qmh-tap`, so a track wearing it grows a thumb three times
|
|
41
56
|
its own height over the labels around it (`preset/recipes.css`, held by `check:style`). -->
|
|
42
|
-
<span class="qm-toggle-wrap">
|
|
57
|
+
<span class="qm-toggle-wrap" data-unset={unset ? '' : undefined}>
|
|
43
58
|
<Switch.Root
|
|
44
59
|
class="qm-toggle qm-focus-ring qm-tap-floor"
|
|
45
|
-
checked={local.value}
|
|
60
|
+
checked={!!local.value}
|
|
46
61
|
{id}
|
|
47
62
|
aria-label={id ? undefined : label}
|
|
48
63
|
aria-describedby={describedBy}
|
|
49
64
|
onCheckedChange={(v) => {
|
|
65
|
+
// From unset the primitive reads the unchecked face, so its toggle is `true`.
|
|
50
66
|
local.value = v;
|
|
51
67
|
onCommit(v);
|
|
52
68
|
}}
|
|
53
69
|
>
|
|
54
|
-
|
|
70
|
+
{#snippet child({ props })}
|
|
71
|
+
<button {...props} {...tristate}><Switch.Thumb class="qm-toggle-thumb" /></button>
|
|
72
|
+
{/snippet}
|
|
55
73
|
</Switch.Root>
|
|
56
74
|
</span>
|
|
57
75
|
|
|
@@ -95,4 +113,8 @@
|
|
|
95
113
|
.qm-toggle-wrap :global(.qm-toggle-thumb[data-state='checked']) {
|
|
96
114
|
translate: 0.75rem;
|
|
97
115
|
}
|
|
116
|
+
/* Unset, the thumb stands at neither end: halfway along its travel. */
|
|
117
|
+
.qm-toggle-wrap[data-unset] :global(.qm-toggle-thumb) {
|
|
118
|
+
translate: 0.375rem;
|
|
119
|
+
}
|
|
98
120
|
</style>
|
|
@@ -2,6 +2,8 @@ import './controls.css';
|
|
|
2
2
|
interface Props {
|
|
3
3
|
value: boolean | undefined;
|
|
4
4
|
fallback?: boolean;
|
|
5
|
+
/** A `boolean?` cell, which has a third state: unset, printing `none`. */
|
|
6
|
+
optional?: boolean;
|
|
5
7
|
/** Accessible name for a switch nothing else names: an object property, whose
|
|
6
8
|
* name is the field label plus the property's. A field's own switch takes `id`
|
|
7
9
|
* instead and is named by the `<label for>` beside it. */
|
package/dist/visual/Card.svelte
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import type { EditorErrorHandler } from '../core/errors.js';
|
|
16
16
|
import type { LeafRegistry } from './leaves.js';
|
|
17
17
|
import type { CardModel, FieldModel } from './structure.js';
|
|
18
|
-
import { placeFields, humanize, initialExpandedGroup } from './structure.js';
|
|
18
|
+
import { placeFields, humanize, initialExpandedGroup, kindTitle, mountKey } from './structure.js';
|
|
19
19
|
import { holdInView } from './hold.js';
|
|
20
20
|
import type { FieldDomIds } from './domid.js';
|
|
21
21
|
import Field from './Field.svelte';
|
|
@@ -333,7 +333,7 @@
|
|
|
333
333
|
|
|
334
334
|
{#snippet sectionFields(fields: FieldModel[])}
|
|
335
335
|
<div class="qm-fields qm-tracks">
|
|
336
|
-
{#each placeFields(fields) as { field: f, span } (f.name)}
|
|
336
|
+
{#each placeFields(fields) as { field: f, span } (mountKey(f.name, f.schema))}
|
|
337
337
|
<Field
|
|
338
338
|
field={f}
|
|
339
339
|
{span}
|
|
@@ -402,7 +402,7 @@
|
|
|
402
402
|
|
|
403
403
|
{#snippet kindOptions()}
|
|
404
404
|
{#each kinds as k (k)}
|
|
405
|
-
<option value={k}>{
|
|
405
|
+
<option value={k}>{kindTitle(quill.schema.card_kinds?.[k], k)}</option>
|
|
406
406
|
{/each}
|
|
407
407
|
{/snippet}
|
|
408
408
|
|