@quillmark/svelte 0.21.0 → 0.22.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/NOTICE +6 -7
- package/README.md +1 -1
- package/dist/core/address.d.ts +6 -1
- package/dist/core/address.js +12 -3
- package/dist/core/bloom.d.ts +9 -4
- package/dist/core/bloom.js +18 -49
- package/dist/core/codec/atoms.d.ts +1 -1
- package/dist/core/codec/atoms.js +1 -1
- package/dist/core/codec/blocks.js +4 -2
- package/dist/core/codec/code.d.ts +1 -1
- package/dist/core/codec/code.js +1 -1
- package/dist/core/codec/decode.d.ts +5 -5
- package/dist/core/codec/decode.js +18 -12
- package/dist/core/codec/encode.js +6 -1
- package/dist/core/codec/field.d.ts +15 -10
- package/dist/core/codec/field.js +46 -17
- package/dist/core/codec/index.d.ts +1 -1
- package/dist/core/codec/index.js +2 -2
- package/dist/core/codec/inputrules.js +1 -1
- package/dist/core/codec/lists.d.ts +3 -2
- package/dist/core/codec/lists.js +21 -5
- package/dist/core/codec/nesting.d.ts +20 -0
- package/dist/core/codec/nesting.js +62 -0
- package/dist/core/codec/prose.css +74 -20
- package/dist/core/codec/schema.d.ts +2 -2
- package/dist/core/codec/schema.js +16 -7
- package/dist/core/codec/table-copy.d.ts +17 -0
- package/dist/core/codec/table-copy.js +84 -0
- package/dist/core/codec/table-view.d.ts +1 -7
- package/dist/core/codec/table-view.js +70 -119
- package/dist/core/codec/table.d.ts +2 -9
- package/dist/core/codec/table.js +10 -19
- package/dist/core/codec/tasks.d.ts +19 -0
- package/dist/core/codec/tasks.js +140 -0
- package/dist/core/errors.d.ts +2 -2
- package/dist/visual/ArrayField.svelte +101 -63
- package/dist/visual/ArrayField.svelte.d.ts +0 -2
- package/dist/visual/Card.svelte +9 -3
- package/dist/visual/Card.svelte.d.ts +2 -2
- package/dist/visual/Field.svelte +20 -16
- package/dist/visual/Field.svelte.d.ts +4 -2
- package/dist/visual/FieldLabel.svelte +5 -33
- package/dist/visual/FieldLabel.svelte.d.ts +0 -2
- package/dist/visual/FormatPopover.svelte +54 -73
- package/dist/visual/MatrixField.svelte +18 -12
- package/dist/visual/NumberField.svelte +32 -3
- package/dist/visual/ObjectField.svelte +38 -20
- package/dist/visual/ObjectField.svelte.d.ts +6 -0
- package/dist/visual/ProseField.svelte +10 -13
- package/dist/visual/ProseField.svelte.d.ts +5 -2
- package/dist/visual/ProseValue.svelte +25 -19
- package/dist/visual/ProseValue.svelte.d.ts +4 -4
- package/dist/visual/TextField.svelte +3 -2
- package/dist/visual/TipsCard.svelte +5 -2
- package/dist/visual/VariantField.svelte +1 -1
- package/dist/visual/VariantField.svelte.d.ts +1 -1
- package/dist/visual/VisualEditorInner.svelte +13 -13
- package/dist/visual/controls.css +19 -1
- package/dist/visual/icons/nodes.d.ts +0 -21
- package/dist/visual/icons/nodes.js +1 -7
- package/dist/visual/leaves.d.ts +12 -9
- package/dist/visual/leaves.js +19 -0
- package/dist/visual/strings.d.ts +10 -5
- package/dist/visual/strings.js +16 -9
- package/dist/visual/structure.d.ts +21 -31
- package/dist/visual/structure.js +54 -51
- package/package.json +3 -3
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
shell-owned, observing the active leaf through
|
|
4
4
|
`getActiveLeaf`: the VisualEditor's accessor over its `leaves` registry, and
|
|
5
5
|
the whole of what this surface knows about the editor. The active leaf is a
|
|
6
|
-
field's or a by-value one (an array's element, a subform's cell)
|
|
7
|
-
|
|
6
|
+
field's or a by-value one (an array's element, a subform's cell), reached through
|
|
7
|
+
its views. A non-empty selection in the active leaf
|
|
8
8
|
raises it over that leaf's selection rect; each button dispatches a PM `toggleMark`
|
|
9
9
|
command straight at the leaf's `EditorView`, whose own `dispatchTransaction` commits
|
|
10
10
|
the result as it commits a keystroke: a field's lowers it to `markOps` through
|
|
11
11
|
`applyChange` (field.ts), a by-value leaf's hands the re-encoded value to its parent
|
|
12
12
|
(`ProseValue`). This component never touches the content. The keymap mirror
|
|
13
|
-
(Mod-b/i/u)
|
|
14
|
-
affordance for all six marks.
|
|
13
|
+
(Mod-b/i/u) lives in the codec's keymap (field.ts); this is the pointer
|
|
14
|
+
affordance for all six marks. Mod-K is this surface's own key, the prompt it raises
|
|
15
|
+
being this surface's: a document `keydown` the leaf's keymap left unclaimed.
|
|
15
16
|
|
|
16
17
|
Selection observation. The view's `dispatchTransaction` is the leaf's
|
|
17
18
|
(read-only), so there is no hook to observe transactions from outside.
|
|
@@ -64,6 +65,7 @@
|
|
|
64
65
|
// off-tree, so this component renders standalone too.
|
|
65
66
|
const t = wording();
|
|
66
67
|
import { toggleMark } from 'prosemirror-commands';
|
|
68
|
+
import { keydownHandler } from 'prosemirror-keymap';
|
|
67
69
|
import { TextSelection, type Command } from 'prosemirror-state';
|
|
68
70
|
import type { EditorView } from 'prosemirror-view';
|
|
69
71
|
import type { MarkType } from 'prosemirror-model';
|
|
@@ -83,9 +85,8 @@
|
|
|
83
85
|
let { getActiveLeaf }: Props = $props();
|
|
84
86
|
|
|
85
87
|
const SETTLE_MS = 450;
|
|
86
|
-
/** The six content formatting marks. `anchor` is
|
|
87
|
-
*
|
|
88
|
-
* the tooltip and the accessible name; the anchor button spells the two separately. */
|
|
88
|
+
/** The six content formatting marks. `anchor` is withheld: it draws nothing until
|
|
89
|
+
* comments do. One string per mark carries both the tooltip and the accessible name. */
|
|
89
90
|
const MARKS: { name: string; icon: IconName; title: string }[] = $derived([
|
|
90
91
|
{ name: 'strong', icon: 'bold', title: t.strings.formatBold },
|
|
91
92
|
{ name: 'em', icon: 'italic', title: t.strings.formatEmphasis },
|
|
@@ -98,13 +99,14 @@
|
|
|
98
99
|
let open = $state(false);
|
|
99
100
|
/** A pointer is down outside this surface: the selection is still being made. */
|
|
100
101
|
let pressed = false;
|
|
101
|
-
/** Whether an anchor can be minted over the selection: in a field's own view, never
|
|
102
|
-
* in a table cell or a by-value leaf (see `sync`). */
|
|
103
|
-
let anchorAvailable = $state(true);
|
|
104
102
|
let linkPromptOpen = $state(false);
|
|
105
103
|
/** Whether the selection carried a link when the prompt was raised. */
|
|
106
104
|
let linkPresent = $state(false);
|
|
107
105
|
let linkValue = $state('');
|
|
106
|
+
/** The view the prompt was raised over. The prompt takes the focus, and a cell view
|
|
107
|
+
* without it is no longer the leaf's `focusedView`. */
|
|
108
|
+
let promptView: EditorView | undefined;
|
|
109
|
+
let promptInput = $state<HTMLInputElement | undefined>(undefined);
|
|
108
110
|
let contentEl = $state<HTMLElement | undefined>(undefined);
|
|
109
111
|
/** The root to portal into: `document.body` escapes the editor's subtree and
|
|
110
112
|
* the consumer's dials with it, so a pane-scoped palette misses this surface.
|
|
@@ -119,7 +121,7 @@
|
|
|
119
121
|
/** The view the caret is actually in: the leaf's own, or the nested cell view of a
|
|
120
122
|
* table island (`codec/table-view.ts`). The six marks are the inline schema's
|
|
121
123
|
* too, so a selection in a cell raises the same popover and toggles the same
|
|
122
|
-
* commands
|
|
124
|
+
* commands. */
|
|
123
125
|
function activeLeafView(): EditorView | undefined {
|
|
124
126
|
return getActiveLeaf()?.views.focusedView();
|
|
125
127
|
}
|
|
@@ -133,18 +135,16 @@
|
|
|
133
135
|
// returns early, so a surface frozen here still tracks the selection it covers.
|
|
134
136
|
if (insidePopover) return;
|
|
135
137
|
if (pressed) return;
|
|
136
|
-
const
|
|
137
|
-
const view = leaf?.views.focusedView();
|
|
138
|
+
const view = activeLeafView();
|
|
138
139
|
// A non-empty text selection. A node selection is non-empty too and has nothing
|
|
139
140
|
// to format: it covers a leaf block (an island, a rule), which is exactly where
|
|
140
141
|
// Escape out of a table cell lands.
|
|
141
142
|
const selection = view?.state.selection;
|
|
142
143
|
const formattable = selection instanceof TextSelection && !selection.empty;
|
|
143
|
-
// …in a leaf that has formatting at all. A `
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// coerce back.
|
|
144
|
+
// …in a leaf that has formatting at all. A `text` leaf declares no mark types
|
|
145
|
+
// (`plaintextSchema`, `plainSchema`), so this surface withholds itself over one
|
|
146
|
+
// entirely, the way it never rises over a form control: the value is literal
|
|
147
|
+
// text, and a mark on it is a value the boundary refuses to coerce back.
|
|
148
148
|
if (
|
|
149
149
|
!view ||
|
|
150
150
|
!view.hasFocus() ||
|
|
@@ -163,13 +163,6 @@
|
|
|
163
163
|
portalTarget = view.dom.closest<HTMLElement>('[data-qm-root]') ?? undefined;
|
|
164
164
|
const { from, to } = view.state.selection;
|
|
165
165
|
anchor = rangeAnchor(view, from, to);
|
|
166
|
-
// An anchor is minted through a field's controller, which a by-value leaf has
|
|
167
|
-
// none of: its anchors are dropped on the parent's value write. A selection inside
|
|
168
|
-
// a table cell is not in the field's coordinate space at all: the field's position
|
|
169
|
-
// map holds one `atom` run for the whole island, so there is no USV offset to mint
|
|
170
|
-
// an anchor at. The button is withheld rather than disabled, because what it would
|
|
171
|
-
// toggle does not exist there (CODEC §Islands).
|
|
172
|
-
anchorAvailable = !!leaf?.controller && view === leaf.views.view;
|
|
173
166
|
open = true;
|
|
174
167
|
}
|
|
175
168
|
|
|
@@ -266,6 +259,7 @@
|
|
|
266
259
|
if (name === 'link') {
|
|
267
260
|
linkValue = hrefInSelection(view.state);
|
|
268
261
|
linkPresent = linkValue.length > 0;
|
|
262
|
+
promptView = view;
|
|
269
263
|
linkPromptOpen = true;
|
|
270
264
|
return;
|
|
271
265
|
}
|
|
@@ -274,43 +268,12 @@
|
|
|
274
268
|
sync();
|
|
275
269
|
}
|
|
276
270
|
|
|
277
|
-
/**
|
|
278
|
-
* A fresh anchor id: 128 random bits as hex, the id being opaque to the
|
|
279
|
-
* boundary and needing only to be unique. `crypto.randomUUID` is
|
|
280
|
-
* secure-context-only, and absent on a page served over plain `http` to
|
|
281
|
-
* something other than localhost; `getRandomValues` is there either way.
|
|
282
|
-
*/
|
|
283
|
-
function mintAnchorId(): string {
|
|
284
|
-
return [...crypto.getRandomValues(new Uint8Array(16))]
|
|
285
|
-
.map((b) => b.toString(16).padStart(2, '0'))
|
|
286
|
-
.join('');
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Toggle an identity anchor over the selection: the `anchor`
|
|
291
|
-
* button's answer to a formatting toggle. If the selection already covers
|
|
292
|
-
* anchors, remove them; else insert one at its start with a freshly-minted
|
|
293
|
-
* unique id (the caller-supplied, invariant id the 0.97 policy settles).
|
|
294
|
-
* Zero-width and glyph-less (CODEC §Marks): the identity handle persists in
|
|
295
|
-
* the content, its chrome awaiting comment-thread UX.
|
|
296
|
-
*/
|
|
297
|
-
function toggleAnchor(): void {
|
|
298
|
-
const leaf = getActiveLeaf()?.controller;
|
|
299
|
-
const view = activeLeafView();
|
|
300
|
-
if (!leaf || !view || view.state.selection.empty) return;
|
|
301
|
-
const { from, to } = leaf.selectionRange();
|
|
302
|
-
const covered = leaf.anchorsInRange(from, to);
|
|
303
|
-
if (covered.length) covered.forEach((id) => leaf.removeAnchor(id));
|
|
304
|
-
else leaf.insertAnchor(mintAnchorId(), from);
|
|
305
|
-
view.focus();
|
|
306
|
-
sync();
|
|
307
|
-
}
|
|
308
|
-
|
|
309
271
|
/** Every exit from the prompt: lower it, run `cmd` if there is one, and hand the
|
|
310
272
|
* leaf back its focus and the surface its buttons. */
|
|
311
273
|
function closePrompt(cmd?: Command): void {
|
|
312
274
|
linkPromptOpen = false;
|
|
313
|
-
const view = activeLeafView();
|
|
275
|
+
const view = promptView && !promptView.isDestroyed ? promptView : activeLeafView();
|
|
276
|
+
promptView = undefined;
|
|
314
277
|
if (view) {
|
|
315
278
|
cmd?.(view.state, view.dispatch);
|
|
316
279
|
view.focus();
|
|
@@ -325,6 +288,30 @@
|
|
|
325
288
|
closePrompt(setLink(normalizeHref(linkValue)));
|
|
326
289
|
}
|
|
327
290
|
|
|
291
|
+
/** Mod-K is a press of the Link button: claimed only where the surface would stand
|
|
292
|
+
* over the selection, and a second press while the prompt is up hands it the focus. */
|
|
293
|
+
function raiseLink(view: EditorView): boolean {
|
|
294
|
+
const inside = !!contentEl?.contains(document.activeElement);
|
|
295
|
+
if (inside && linkPromptOpen) return true;
|
|
296
|
+
if (!inside && !view.hasFocus()) return false;
|
|
297
|
+
sync();
|
|
298
|
+
if (!open) return false;
|
|
299
|
+
const reraised = linkPromptOpen;
|
|
300
|
+
toggle('link');
|
|
301
|
+
if (reraised && promptInput) focusPrompt(promptInput);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const linkKey = keydownHandler({
|
|
306
|
+
'Mod-k': (_state, _dispatch, view) => !!view && raiseLink(view)
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
function onKeyDown(e: KeyboardEvent): void {
|
|
310
|
+
if (e.defaultPrevented) return;
|
|
311
|
+
const view = activeLeafView();
|
|
312
|
+
if (view && linkKey(view, e)) e.preventDefault();
|
|
313
|
+
}
|
|
314
|
+
|
|
328
315
|
function removeLink(): void {
|
|
329
316
|
closePrompt(clearLink);
|
|
330
317
|
}
|
|
@@ -338,6 +325,7 @@
|
|
|
338
325
|
it does with the event. `pointercancel` is the release a gesture the browser takes
|
|
339
326
|
over gets. -->
|
|
340
327
|
<svelte:document
|
|
328
|
+
onkeydown={onKeyDown}
|
|
341
329
|
onselectionchange={deferredSync}
|
|
342
330
|
onfocusout={deferredSync}
|
|
343
331
|
onpointerdowncapture={onPointerDown}
|
|
@@ -391,26 +379,29 @@
|
|
|
391
379
|
>
|
|
392
380
|
<input
|
|
393
381
|
{@attach focusPrompt}
|
|
394
|
-
|
|
382
|
+
bind:this={promptInput}
|
|
383
|
+
class="qm-link-input qm-focus-ring"
|
|
395
384
|
type="text"
|
|
396
385
|
placeholder={t.strings.linkPlaceholder}
|
|
397
386
|
aria-label={t.strings.formatLink}
|
|
398
387
|
bind:value={linkValue}
|
|
399
388
|
/>
|
|
400
|
-
<button type="submit" class="qm-icon-btn qm-mark-btn"
|
|
389
|
+
<button type="submit" class="qm-icon-btn qm-mark-btn qm-focus-ring"
|
|
390
|
+
>{t.strings.linkApply}</button
|
|
391
|
+
>
|
|
401
392
|
<!-- Only where there is a link to remove: an arm that does nothing is
|
|
402
393
|
an arm to read past on every other visit to the prompt. -->
|
|
403
394
|
{#if linkPresent}
|
|
404
395
|
<button
|
|
405
396
|
type="button"
|
|
406
|
-
class="qm-icon-btn qm-mark-btn"
|
|
397
|
+
class="qm-icon-btn qm-mark-btn qm-focus-ring"
|
|
407
398
|
onmousedown={keepFocus}
|
|
408
399
|
onclick={removeLink}>{t.strings.linkRemove}</button
|
|
409
400
|
>
|
|
410
401
|
{/if}
|
|
411
402
|
<button
|
|
412
403
|
type="button"
|
|
413
|
-
class="qm-icon-btn qm-mark-btn"
|
|
404
|
+
class="qm-icon-btn qm-mark-btn qm-focus-ring"
|
|
414
405
|
onmousedown={keepFocus}
|
|
415
406
|
onclick={cancelLink}>{t.strings.linkCancel}</button
|
|
416
407
|
>
|
|
@@ -423,23 +414,13 @@
|
|
|
423
414
|
{#each MARKS as m (m.name)}
|
|
424
415
|
<button
|
|
425
416
|
type="button"
|
|
426
|
-
class="qm-icon-btn qm-mark-btn"
|
|
417
|
+
class="qm-icon-btn qm-mark-btn qm-focus-ring"
|
|
427
418
|
title={m.title}
|
|
428
419
|
aria-label={m.title}
|
|
429
420
|
onmousedown={keepFocus}
|
|
430
421
|
onclick={() => toggle(m.name)}><Icon name={m.icon} /></button
|
|
431
422
|
>
|
|
432
423
|
{/each}
|
|
433
|
-
{#if anchorAvailable}
|
|
434
|
-
<button
|
|
435
|
-
type="button"
|
|
436
|
-
class="qm-icon-btn qm-mark-btn"
|
|
437
|
-
title={t.strings.formatAnchorTitle}
|
|
438
|
-
aria-label={t.strings.formatAnchor}
|
|
439
|
-
onmousedown={keepFocus}
|
|
440
|
-
onclick={toggleAnchor}><Icon name="hash" /></button
|
|
441
|
-
>
|
|
442
|
-
{/if}
|
|
443
424
|
</div>
|
|
444
425
|
{/if}
|
|
445
426
|
</div>
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
|
|
54
54
|
import type { LandingBox } from './leaves.js';
|
|
55
55
|
import { propertyDomIds } from './domid.js';
|
|
56
|
-
import {
|
|
56
|
+
import { bloom } from '../core/bloom.js';
|
|
57
57
|
import Icon from './icons/Icon.svelte';
|
|
58
58
|
import FieldLabel from './FieldLabel.svelte';
|
|
59
59
|
import ObjectField from './ObjectField.svelte';
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
// the box stays where the next item is typed.
|
|
216
216
|
const first = Object.keys(schema.properties ?? {})[0];
|
|
217
217
|
if (first !== undefined) ref(colEls, id)?.focusPath([first]);
|
|
218
|
-
if (box)
|
|
218
|
+
if (box) bloom(box);
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
/** Take the caret: the first item's control, else the add box. */
|
|
@@ -276,13 +276,17 @@
|
|
|
276
276
|
</span>
|
|
277
277
|
</div>
|
|
278
278
|
<div
|
|
279
|
-
class={['qm-matrix-roster', { 'qm-tracks': compact, compact }]}
|
|
279
|
+
class={['qm-matrix-roster qm-wash', { 'qm-tracks': compact, compact }]}
|
|
280
280
|
data-default={defaulted ? '' : undefined}
|
|
281
281
|
bind:this={rosterEl}
|
|
282
282
|
>
|
|
283
283
|
{#each members as m (m.id)}
|
|
284
284
|
{@const on = held(m.id)}
|
|
285
|
-
<div
|
|
285
|
+
<div
|
|
286
|
+
class={['qm-member qm-wash', { held: on }]}
|
|
287
|
+
data-qm-prop={m.id}
|
|
288
|
+
bind:this={memberEls[m.id]}
|
|
289
|
+
>
|
|
286
290
|
<div class="qm-member-head">
|
|
287
291
|
<!-- A real checkbox with its face drawn here: the UA's face is shadow DOM no
|
|
288
292
|
dial reaches, so the input is `appearance: none` and the box and the
|
|
@@ -306,7 +310,11 @@
|
|
|
306
310
|
{/each}
|
|
307
311
|
{#each added as item, k (item.id)}
|
|
308
312
|
{@const name = item.title || t.strings.matrixItemTitle(label ?? '', k + 1)}
|
|
309
|
-
<div
|
|
313
|
+
<div
|
|
314
|
+
class="qm-member qm-wash held added"
|
|
315
|
+
data-qm-prop={item.id}
|
|
316
|
+
bind:this={memberEls[item.id]}
|
|
317
|
+
>
|
|
310
318
|
<div class="qm-member-head">
|
|
311
319
|
<!-- Held by being present: the tick is fixed, and the remove is its untick. -->
|
|
312
320
|
<span class="qm-tick" aria-hidden="true">
|
|
@@ -317,6 +325,7 @@
|
|
|
317
325
|
class="qm-input qm-focus-ring qm-member-title-input"
|
|
318
326
|
type="text"
|
|
319
327
|
id={itemTitleId(item.id)}
|
|
328
|
+
data-qm-prop={MATRIX_TITLE}
|
|
320
329
|
aria-label={t.strings.matrixItemTitle(label ?? '', k + 1)}
|
|
321
330
|
value={item.title}
|
|
322
331
|
bind:this={titleEls[item.id]}
|
|
@@ -391,11 +400,9 @@
|
|
|
391
400
|
color: var(--_qm-ink-label);
|
|
392
401
|
font-variant-numeric: tabular-nums;
|
|
393
402
|
}
|
|
394
|
-
/* The roster, one member to a row in declaration order
|
|
395
|
-
|
|
396
|
-
rows are. */
|
|
403
|
+
/* The roster, one member to a row in declaration order, rounded for the wash
|
|
404
|
+
(`washBox`) to the rung a row's box draws, as an array's rows are. */
|
|
397
405
|
.qm-matrix-roster {
|
|
398
|
-
position: relative;
|
|
399
406
|
display: flex;
|
|
400
407
|
flex-direction: column;
|
|
401
408
|
gap: var(--_qm-space);
|
|
@@ -411,10 +418,9 @@
|
|
|
411
418
|
align-items: start;
|
|
412
419
|
column-gap: var(--_qm-space-2);
|
|
413
420
|
}
|
|
414
|
-
/* A member is a row
|
|
415
|
-
|
|
421
|
+
/* A member is a row, rounded for the wash a landing on it blooms to the rung the
|
|
422
|
+
rows around it draw. */
|
|
416
423
|
.qm-member {
|
|
417
|
-
position: relative;
|
|
418
424
|
display: flex;
|
|
419
425
|
flex-direction: column;
|
|
420
426
|
gap: var(--_qm-space);
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
(`-`, `1.`) is never a document state worth a boundary round-trip, and
|
|
5
5
|
committing it live flashes a coercion diagnostic + `console.error` on every
|
|
6
6
|
intermediate prefix, announced by `DiagnosticList`'s `role="status"` live
|
|
7
|
-
region.
|
|
7
|
+
region. Escape takes a pending entry back to what the field holds, as it takes a
|
|
8
|
+
card rename back, and the focus stays; with nothing pending the key is not claimed.
|
|
8
9
|
|
|
9
10
|
An unset field holds its resolved `default:` as its text, at the default rung
|
|
10
11
|
(theme.css), and an edit that settles writes it as authored, as `TextField` takes
|
|
@@ -52,7 +53,8 @@
|
|
|
52
53
|
}
|
|
53
54
|
let { value, integer, fallback, placeholder, label, id, describedBy, onCommit }: Props = $props();
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
const resting = $derived(value != null ? String(value) : (fallback ?? ''));
|
|
57
|
+
let local = $derived(resting);
|
|
56
58
|
const defaulted = $derived(value == null && !!fallback && local === fallback);
|
|
57
59
|
|
|
58
60
|
const partial = $derived(
|
|
@@ -76,13 +78,37 @@
|
|
|
76
78
|
local = el.value;
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
// Where the reverted text differs from the last settled one, a browser fires `change`
|
|
82
|
+
// on leaving; that one commits nothing. An entry that settled outside the grammar was
|
|
83
|
+
// handed up as a string and refused, so taking it back settles the held value again,
|
|
84
|
+
// which clears the refusal drawn on the field.
|
|
85
|
+
let reverted = false;
|
|
86
|
+
let offered = false;
|
|
87
|
+
function revert(e: KeyboardEvent): void {
|
|
88
|
+
if (e.key !== 'Escape' || e.isComposing) return;
|
|
89
|
+
const el = e.currentTarget as HTMLInputElement;
|
|
90
|
+
if (el.value === resting) return;
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
local = el.value = resting;
|
|
93
|
+
reverted = true;
|
|
94
|
+
if (offered) {
|
|
95
|
+
offered = false;
|
|
96
|
+
onCommit(value);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
79
100
|
// Parse a settled entry and emit it; `local` is owned by `oninput`. Blank →
|
|
80
101
|
// `undefined`, and the input takes the default back: nothing else reconciles it, the
|
|
81
102
|
// projection having read the default throughout. Onto the element as well, since the
|
|
82
103
|
// attribute Svelte last wrote may already be the default, and it writes no repeat.
|
|
83
104
|
function commit(el: HTMLInputElement): void {
|
|
105
|
+
if (reverted) {
|
|
106
|
+
reverted = false;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
84
109
|
const raw = el.value;
|
|
85
110
|
const entry = raw.trim();
|
|
111
|
+
offered = false;
|
|
86
112
|
if (entry === '') {
|
|
87
113
|
onCommit(undefined);
|
|
88
114
|
local = el.value = fallback ?? '';
|
|
@@ -90,7 +116,8 @@
|
|
|
90
116
|
}
|
|
91
117
|
// Finite as well: a digit run past `Number.MAX_VALUE` reads as `Infinity`.
|
|
92
118
|
const n = whole.test(entry) ? Number(entry) : NaN;
|
|
93
|
-
|
|
119
|
+
offered = !Number.isFinite(n);
|
|
120
|
+
onCommit(offered ? raw : n);
|
|
94
121
|
}
|
|
95
122
|
</script>
|
|
96
123
|
|
|
@@ -107,6 +134,8 @@
|
|
|
107
134
|
onbeforeinput={filter}
|
|
108
135
|
oninput={(e) => {
|
|
109
136
|
local = (e.currentTarget as HTMLInputElement).value;
|
|
137
|
+
reverted = false;
|
|
110
138
|
}}
|
|
139
|
+
onkeydown={revert}
|
|
111
140
|
onchange={(e) => commit(e.currentTarget as HTMLInputElement)}
|
|
112
141
|
/>
|
|
@@ -41,18 +41,21 @@
|
|
|
41
41
|
baseType,
|
|
42
42
|
controlKind,
|
|
43
43
|
declaredContent,
|
|
44
|
+
fieldModels,
|
|
44
45
|
flatObject,
|
|
45
46
|
humanize,
|
|
46
47
|
isContainer,
|
|
48
|
+
literalText,
|
|
47
49
|
mountKey,
|
|
48
|
-
obliged,
|
|
49
50
|
optionalCell,
|
|
51
|
+
placeFields,
|
|
50
52
|
printedText,
|
|
51
53
|
renderedBoolean,
|
|
52
54
|
shortCell
|
|
53
55
|
} from './structure.js';
|
|
54
56
|
import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
|
|
55
57
|
import type { LandingBox } from './leaves.js';
|
|
58
|
+
import { isEmptyContent } from '../core/codec/index.js';
|
|
56
59
|
import { wording } from './strings.js';
|
|
57
60
|
import { propertyDomIds } from './domid.js';
|
|
58
61
|
import FieldLabel from './FieldLabel.svelte';
|
|
@@ -102,6 +105,12 @@
|
|
|
102
105
|
/** `ui.layout: flat`: no vertical and no rung, the properties at the depth of the
|
|
103
106
|
* list the subform stands in. */
|
|
104
107
|
flat?: boolean;
|
|
108
|
+
/** A card-level object, or one nested in it: its properties are placed as a
|
|
109
|
+
* card's fields are (`placeFields`), so moving a field into a group leaves its row
|
|
110
|
+
* as it was. Unset,
|
|
111
|
+
* every short cell packs: an array's record, a variant's world and a matrix
|
|
112
|
+
* member's columns are one record's cells, not a card's fields. */
|
|
113
|
+
placed?: boolean;
|
|
105
114
|
/** Raw keydown off a text or prose cell, for a row whose own keys run through its
|
|
106
115
|
* cells: the table's Enter and Backspace ({@link ArrayField}). */
|
|
107
116
|
onCellKey?: (e: KeyboardEvent, key: string) => void;
|
|
@@ -121,6 +130,7 @@
|
|
|
121
130
|
onCommit,
|
|
122
131
|
bare = false,
|
|
123
132
|
flat = false,
|
|
133
|
+
placed = false,
|
|
124
134
|
onCellKey,
|
|
125
135
|
diagnostics
|
|
126
136
|
}: Props = $props();
|
|
@@ -129,15 +139,16 @@
|
|
|
129
139
|
const entries = $derived(Object.entries(properties ?? {}));
|
|
130
140
|
const obj = $derived((value ?? {}) as Record<string, unknown>);
|
|
131
141
|
const has = (key: string): boolean => Object.hasOwn(properties ?? {}, key);
|
|
142
|
+
const spans = $derived(
|
|
143
|
+
placed
|
|
144
|
+
? new Map(
|
|
145
|
+
placeFields(fieldModels({ fields: properties ?? {} })).map((p) => [p.field.name, p.span])
|
|
146
|
+
)
|
|
147
|
+
: undefined
|
|
148
|
+
);
|
|
132
149
|
const routed = $derived(splitDeep(diagnostics));
|
|
133
150
|
const foot = $derived(unrouted(routed, (step) => typeof step === 'string' && has(step)));
|
|
134
151
|
|
|
135
|
-
/** Obligation, read off the cell exactly as `fieldModels` reads it off a field
|
|
136
|
-
* ({@link obliged}). `validate` anchors it per leaf, so a property with no
|
|
137
|
-
* `default:` is obliged in its own right and the container around it holds none
|
|
138
|
-
* (VISUAL_EDITOR §"Enum variants"). */
|
|
139
|
-
const required = obliged;
|
|
140
|
-
|
|
141
152
|
const title = (key: string, sub: QuillFieldSchema): string => sub.title ?? humanize(key);
|
|
142
153
|
/** What an unset optional cell ghosts, which is what it prints: `none`. An answered
|
|
143
154
|
* cell ghosts nothing, an empty answer printing empty. */
|
|
@@ -151,8 +162,7 @@
|
|
|
151
162
|
/** The `aria-label` fallback, for a subform mounted without a field's id space:
|
|
152
163
|
* the field's name and the property's, since nothing else names the control. */
|
|
153
164
|
const fallbackName = (key: string, sub: QuillFieldSchema): string =>
|
|
154
|
-
`${label != null ? `${label} ` : ''}${title(key, sub)}
|
|
155
|
-
(required(sub) ? ` ${t.strings.fieldRequired}` : '');
|
|
165
|
+
`${label != null ? `${label} ` : ''}${title(key, sub)}`;
|
|
156
166
|
|
|
157
167
|
/** The handles of the cells the DOM cannot answer for, keyed by property name —
|
|
158
168
|
* which is stable, a subform drawing its properties by declaration and never by
|
|
@@ -285,10 +295,17 @@
|
|
|
285
295
|
kind === 'boolean'}
|
|
286
296
|
<!-- A container takes the full span at every depth: its rows are the document's
|
|
287
297
|
to count, and a track beside it would stand in a column of whitespace
|
|
288
|
-
(`packable`). A block prose cell spans it too, and keeps its label row
|
|
298
|
+
(`packable`). A block prose cell spans it too, and keeps its label row, as
|
|
299
|
+
does a placed cell `placeFields` gives the whole row. -->
|
|
289
300
|
{@const block = kind === 'prose' && !shortCell(sub)}
|
|
290
301
|
<div
|
|
291
|
-
class={[
|
|
302
|
+
class={[
|
|
303
|
+
'qm-object-prop',
|
|
304
|
+
{
|
|
305
|
+
'qm-prop-full': isContainer(kind),
|
|
306
|
+
'qm-prop-wide': block || (!isContainer(kind) && spans?.get(key) === 'full')
|
|
307
|
+
}
|
|
308
|
+
]}
|
|
292
309
|
data-qm-prop={key}
|
|
293
310
|
>
|
|
294
311
|
{#if ids && kind !== 'array' && kind !== 'matrix'}
|
|
@@ -298,7 +315,6 @@
|
|
|
298
315
|
id={ids.label}
|
|
299
316
|
descriptionId={describes}
|
|
300
317
|
onActivate={labelable ? undefined : () => focusProp(key)}
|
|
301
|
-
required={required(sub)}
|
|
302
318
|
description={sub.description}
|
|
303
319
|
/>
|
|
304
320
|
{/if}
|
|
@@ -364,13 +380,14 @@
|
|
|
364
380
|
content={() => contentAt([key])}
|
|
365
381
|
value={obj[key]}
|
|
366
382
|
fallback={contentDefault(sub)}
|
|
367
|
-
plaintext={
|
|
383
|
+
plaintext={literalText(sub)}
|
|
368
384
|
placeholder={noneOf(key, sub)}
|
|
369
385
|
{block}
|
|
370
386
|
label={named}
|
|
371
387
|
labelledBy={ids?.label}
|
|
372
388
|
describedBy={describes}
|
|
373
|
-
onChange={(rt) =>
|
|
389
|
+
onChange={(rt) =>
|
|
390
|
+
commitProp(key, optionalCell(sub) && isEmptyContent(rt) ? undefined : rt)}
|
|
374
391
|
onKey={onCellKey ? (e) => onCellKey(e, key) : undefined}
|
|
375
392
|
/>
|
|
376
393
|
{:else if kind === 'array'}
|
|
@@ -386,7 +403,6 @@
|
|
|
386
403
|
layout={arrayLayout(sub)}
|
|
387
404
|
max={sub.max}
|
|
388
405
|
label={title(key, sub)}
|
|
389
|
-
required={required(sub)}
|
|
390
406
|
description={sub.description}
|
|
391
407
|
labelId={ids?.label}
|
|
392
408
|
descriptionId={describes}
|
|
@@ -401,6 +417,7 @@
|
|
|
401
417
|
value={obj[key] as Record<string, unknown> | undefined}
|
|
402
418
|
properties={sub.properties}
|
|
403
419
|
flat={flatObject(sub)}
|
|
420
|
+
{placed}
|
|
404
421
|
label={ids ? undefined : fallbackName(key, sub)}
|
|
405
422
|
idBase={ids?.control}
|
|
406
423
|
labelledBy={ids?.label}
|
|
@@ -514,15 +531,16 @@
|
|
|
514
531
|
align-items: start;
|
|
515
532
|
min-width: 0;
|
|
516
533
|
}
|
|
517
|
-
/* A
|
|
518
|
-
truncated, and there are no sibling properties for it to line up with. A
|
|
519
|
-
|
|
520
|
-
|
|
534
|
+
/* A record of one cell takes half the capacity: at full capacity a single track reads
|
|
535
|
+
as truncated, and there are no sibling properties for it to line up with. A placed
|
|
536
|
+
property's span is `placeFields`', which strands a lone one to the whole row as it
|
|
537
|
+
does a card's field. */
|
|
521
538
|
.qm-object-prop:only-child {
|
|
522
539
|
grid-column: span var(--cols-half);
|
|
523
540
|
}
|
|
524
541
|
/* A block prose property holds paragraphs, so a track beside it stands in whitespace
|
|
525
|
-
once it grows (`packable`); its label still rides the row above it.
|
|
542
|
+
once it grows (`packable`); its label still rides the row above it. A placed
|
|
543
|
+
property `placeFields` gives the whole row spans it the same way. */
|
|
526
544
|
.qm-object-prop.qm-prop-wide,
|
|
527
545
|
.qm-object-prop.qm-prop-wide:only-child {
|
|
528
546
|
grid-column: 1 / -1;
|
|
@@ -35,6 +35,12 @@ interface Props {
|
|
|
35
35
|
/** `ui.layout: flat`: no vertical and no rung, the properties at the depth of the
|
|
36
36
|
* list the subform stands in. */
|
|
37
37
|
flat?: boolean;
|
|
38
|
+
/** A card-level object, or one nested in it: its properties are placed as a
|
|
39
|
+
* card's fields are (`placeFields`), so moving a field into a group leaves its row
|
|
40
|
+
* as it was. Unset,
|
|
41
|
+
* every short cell packs: an array's record, a variant's world and a matrix
|
|
42
|
+
* member's columns are one record's cells, not a card's fields. */
|
|
43
|
+
placed?: boolean;
|
|
38
44
|
/** Raw keydown off a text or prose cell, for a row whose own keys run through its
|
|
39
45
|
* cells: the table's Enter and Backspace ({@link ArrayField}). */
|
|
40
46
|
onCellKey?: (e: KeyboardEvent, key: string) => void;
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
plaintext?: boolean;
|
|
25
25
|
/**
|
|
26
26
|
* Draw no box: the card body's leaf, and nothing else. Not `block`, which is a
|
|
27
|
-
* different predicate: a `richtext` field
|
|
28
|
-
* still a control in a row of controls, so only the caller knows what
|
|
27
|
+
* different predicate: a `richtext` field declaring `multiline: true` is block
|
|
28
|
+
* prose and still a control in a row of controls, so only the caller knows what
|
|
29
|
+
* is paper.
|
|
29
30
|
*/
|
|
30
31
|
unframed?: boolean;
|
|
31
32
|
/** Accessible name for a leaf nothing else names: an array element, or the
|
|
@@ -45,6 +46,8 @@
|
|
|
45
46
|
placeholder?: string;
|
|
46
47
|
/** The placeholder goes at the leaf's first edit (`createField`). */
|
|
47
48
|
placeholderUntilEdit?: boolean;
|
|
49
|
+
/** An emptied leaf removes the field (`createField`). */
|
|
50
|
+
emptyUnsets?: boolean;
|
|
48
51
|
/** Registry identity, stamped on the DOM node so a remount is visible as one. */
|
|
49
52
|
leafKey: string;
|
|
50
53
|
onFocus?: (addr: Addr) => void;
|
|
@@ -73,6 +76,7 @@
|
|
|
73
76
|
fallback,
|
|
74
77
|
placeholder,
|
|
75
78
|
placeholderUntilEdit,
|
|
79
|
+
emptyUnsets,
|
|
76
80
|
leafKey,
|
|
77
81
|
onFocus,
|
|
78
82
|
onCaretMove,
|
|
@@ -122,6 +126,7 @@
|
|
|
122
126
|
fallback,
|
|
123
127
|
placeholder,
|
|
124
128
|
placeholderUntilEdit,
|
|
129
|
+
emptyUnsets,
|
|
125
130
|
tableStrings: () => t.strings,
|
|
126
131
|
slashStrings: () => t.strings,
|
|
127
132
|
onSlash: (next) => {
|
|
@@ -166,7 +171,7 @@
|
|
|
166
171
|
<div
|
|
167
172
|
{@attach mountField}
|
|
168
173
|
class={[
|
|
169
|
-
'qm-prose',
|
|
174
|
+
'qm-prose qm-wash',
|
|
170
175
|
{ 'qm-control-box': !unframed, 'qm-focus-ring-within': !unframed, 'qm-prose-block': block }
|
|
171
176
|
]}
|
|
172
177
|
data-leaf-key={leafKey}
|
|
@@ -178,14 +183,6 @@
|
|
|
178
183
|
<SlashMenu menu={slash} leaf={() => controller} label={t.strings.slashLabel} />
|
|
179
184
|
|
|
180
185
|
<style>
|
|
181
|
-
/* The box is `.qm-control-box` (controls.css), the same rule the input beside it
|
|
182
|
-
draws, so the two agree on height by construction. No `min-height` with it:
|
|
183
|
-
`core/codec/prose.css` resets the paragraph box, so one line of prose in this box
|
|
184
|
-
measures one line of text in that one. */
|
|
185
|
-
.qm-prose {
|
|
186
|
-
/* Containing block for the arrival wash's inset child (`core/bloom.ts`). */
|
|
187
|
-
position: relative;
|
|
188
|
-
}
|
|
189
186
|
/* A leaf the inline schema does not constrain grows, and opens at one line box: the
|
|
190
187
|
empty leaf is the height its first line will take, so the first keystroke displaces
|
|
191
188
|
nothing below it. `1em`, not a size rung, which is the rule below's to name.
|
|
@@ -196,8 +193,8 @@
|
|
|
196
193
|
.qm-prose-block :global(.ProseMirror) {
|
|
197
194
|
min-height: calc(1em * var(--_qm-leading-body));
|
|
198
195
|
}
|
|
199
|
-
/* Paper, and the withheld box is what says so. Not `block` alone: `
|
|
200
|
-
quill's to declare, so a schema
|
|
196
|
+
/* Paper, and the withheld box is what says so. Not `block` alone: `multiline` is the
|
|
197
|
+
quill's to declare, so a schema spelling it on a richtext field puts a block leaf in
|
|
201
198
|
a field row, where this rung would break the height agreement above. */
|
|
202
199
|
.qm-prose-block:not(.qm-control-box) {
|
|
203
200
|
font-size: var(--_qm-text-paper);
|
|
@@ -11,8 +11,9 @@ interface Props {
|
|
|
11
11
|
plaintext?: boolean;
|
|
12
12
|
/**
|
|
13
13
|
* Draw no box: the card body's leaf, and nothing else. Not `block`, which is a
|
|
14
|
-
* different predicate: a `richtext` field
|
|
15
|
-
* still a control in a row of controls, so only the caller knows what
|
|
14
|
+
* different predicate: a `richtext` field declaring `multiline: true` is block
|
|
15
|
+
* prose and still a control in a row of controls, so only the caller knows what
|
|
16
|
+
* is paper.
|
|
16
17
|
*/
|
|
17
18
|
unframed?: boolean;
|
|
18
19
|
/** Accessible name for a leaf nothing else names: an array element, or the
|
|
@@ -32,6 +33,8 @@ interface Props {
|
|
|
32
33
|
placeholder?: string;
|
|
33
34
|
/** The placeholder goes at the leaf's first edit (`createField`). */
|
|
34
35
|
placeholderUntilEdit?: boolean;
|
|
36
|
+
/** An emptied leaf removes the field (`createField`). */
|
|
37
|
+
emptyUnsets?: boolean;
|
|
35
38
|
/** Registry identity, stamped on the DOM node so a remount is visible as one. */
|
|
36
39
|
leafKey: string;
|
|
37
40
|
onFocus?: (addr: Addr) => void;
|