@quillmark/svelte 0.21.0 → 0.23.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/cap.d.ts +21 -0
- package/dist/core/codec/cap.js +63 -0
- 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 +22 -10
- package/dist/core/codec/field.js +54 -18
- package/dist/core/codec/index.d.ts +3 -1
- package/dist/core/codec/index.js +3 -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 +103 -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 +33 -17
- package/dist/visual/Field.svelte.d.ts +4 -2
- package/dist/visual/FieldLabel.svelte +19 -33
- package/dist/visual/FieldLabel.svelte.d.ts +4 -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 +50 -22
- package/dist/visual/ObjectField.svelte.d.ts +6 -0
- package/dist/visual/ProseField.svelte +20 -14
- package/dist/visual/ProseField.svelte.d.ts +8 -2
- package/dist/visual/ProseValue.svelte +30 -19
- package/dist/visual/ProseValue.svelte.d.ts +7 -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 +16 -5
- package/dist/visual/strings.js +17 -9
- package/dist/visual/structure.d.ts +26 -32
- package/dist/visual/structure.js +71 -53
- package/package.json +3 -3
package/dist/visual/Field.svelte
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
ResolvedField
|
|
28
28
|
} from '@quillmark/wasm';
|
|
29
29
|
import type { EditorErrorHandler } from '../core/errors.js';
|
|
30
|
-
import { seatValueLeaves, type LandingBox, type LeafRegistry } from './leaves.js';
|
|
30
|
+
import { seatValueLeaves, stepsTo, type LandingBox, type LeafRegistry } from './leaves.js';
|
|
31
31
|
import type { FieldModel, FieldSpan } from './structure.js';
|
|
32
32
|
import {
|
|
33
33
|
VARIANT_DISCRIMINANT,
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
printedText,
|
|
41
41
|
renderedBoolean,
|
|
42
42
|
stringifyGhost,
|
|
43
|
+
textCap,
|
|
43
44
|
titleText
|
|
44
45
|
} from './structure.js';
|
|
45
46
|
import { wording } from './strings.js';
|
|
@@ -82,7 +83,9 @@
|
|
|
82
83
|
optionAllowed?: (value: string) => boolean;
|
|
83
84
|
/** How a refused option draws: greyed (default) or left out. */
|
|
84
85
|
enumDisallowed?: 'hide' | 'disable';
|
|
85
|
-
|
|
86
|
+
/** A focus inside the field: its address, and the steps down to the cell holding
|
|
87
|
+
* the focus ({@link stepsTo}), `[]` on the field's own control. */
|
|
88
|
+
onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
|
|
86
89
|
onCaretMove?: (addr: Addr, pos: number) => void;
|
|
87
90
|
onChange?: (addr: Addr) => void;
|
|
88
91
|
onError?: EditorErrorHandler;
|
|
@@ -161,6 +164,14 @@
|
|
|
161
164
|
// reference must vanish with it: `aria-describedby` pointing at nothing describes
|
|
162
165
|
// nothing, and silently.
|
|
163
166
|
const describedBy = $derived(field.description ? domIds.description : undefined);
|
|
167
|
+
// A capped prose leaf reports its count, and the count describes it too: a keystroke
|
|
168
|
+
// the cap refuses changes nothing else a reader can perceive.
|
|
169
|
+
let chars = $state<number>();
|
|
170
|
+
const cap = $derived(field.control === 'prose' ? textCap(field.schema) : undefined);
|
|
171
|
+
const countId = $derived(`${domIds.control}-count`);
|
|
172
|
+
const proseDescribedBy = $derived(
|
|
173
|
+
cap ? [describedBy, countId].filter(Boolean).join(' ') : describedBy
|
|
174
|
+
);
|
|
164
175
|
// Two controls own their label row, the add chip and the held count sharing it.
|
|
165
176
|
const ownsLabel = $derived(field.control === 'array' || field.control === 'matrix');
|
|
166
177
|
const flat = $derived(flatObject(field.schema));
|
|
@@ -221,7 +232,7 @@
|
|
|
221
232
|
|
|
222
233
|
/**
|
|
223
234
|
* This field's landing handle. The wrapper is the bloom host rather than the
|
|
224
|
-
* control:
|
|
235
|
+
* control: the wash is a box's `::after`, which an `<input>` draws none of. The
|
|
225
236
|
* label is outside it, and stays out of the wash: an arrival marks where the caret
|
|
226
237
|
* landed, which is the control. A control that owns its label (`ownsLabel`: the array
|
|
227
238
|
* and the matrix) holds that row inside itself, so it names its own box
|
|
@@ -251,7 +262,8 @@
|
|
|
251
262
|
}
|
|
252
263
|
|
|
253
264
|
// A by-value prose leaf anywhere inside this field's containers is reached under
|
|
254
|
-
// this field's key: the active address
|
|
265
|
+
// this field's key: the registry and the active address name the field, never the
|
|
266
|
+
// element or cell.
|
|
255
267
|
seatValueLeaves({
|
|
256
268
|
focus: (leaf) => leaves?.focusValue(leafKey, leaf),
|
|
257
269
|
release: (leaf) => leaves?.releaseValue(leafKey, leaf)
|
|
@@ -261,11 +273,16 @@
|
|
|
261
273
|
* A form control has no controller to report its focus through, so the wrapper
|
|
262
274
|
* reports: `focusin` bubbles, so one handler covers a plain input, an array's N
|
|
263
275
|
* elements and an object's properties alike, and the active leaf names a scalar
|
|
264
|
-
* field the way it names a prose one. A prose leaf reports through its own
|
|
276
|
+
* field the way it names a prose one, down to the element or cell the focus is in. A prose leaf reports through its own
|
|
265
277
|
* controller — the source that drives its caret signals too — and is excluded here
|
|
266
278
|
* rather than counted twice.
|
|
267
279
|
*/
|
|
268
|
-
const reportFocus = $derived(
|
|
280
|
+
const reportFocus = $derived(
|
|
281
|
+
field.control === 'prose'
|
|
282
|
+
? undefined
|
|
283
|
+
: (e: FocusEvent & { currentTarget: HTMLElement }) =>
|
|
284
|
+
onFocus?.(addr, stepsTo(e.target, e.currentTarget))
|
|
285
|
+
);
|
|
269
286
|
</script>
|
|
270
287
|
|
|
271
288
|
<div class={['qm-field', { cell: span === 'cell' }]}>
|
|
@@ -276,8 +293,9 @@
|
|
|
276
293
|
id={domIds.label}
|
|
277
294
|
descriptionId={domIds.description}
|
|
278
295
|
{onActivate}
|
|
279
|
-
required={field.required}
|
|
280
296
|
description={field.description}
|
|
297
|
+
count={cap && chars != null ? t.strings.textCount(chars, cap.max) : undefined}
|
|
298
|
+
{countId}
|
|
281
299
|
/>
|
|
282
300
|
{/if}
|
|
283
301
|
<!-- The control and what hangs under it, in one grid cell: the diagnostics stack
|
|
@@ -285,7 +303,7 @@
|
|
|
285
303
|
rule below holds why). One markup for both spans; a field owning its row nests a
|
|
286
304
|
level and measures the same. -->
|
|
287
305
|
<div class="qm-field-stack">
|
|
288
|
-
<div class="qm-field-control" {@attach registerControl} onfocusin={reportFocus}>
|
|
306
|
+
<div class="qm-field-control qm-wash" {@attach registerControl} onfocusin={reportFocus}>
|
|
289
307
|
{#if field.control === 'prose'}
|
|
290
308
|
<ProseField
|
|
291
309
|
{quill}
|
|
@@ -296,9 +314,10 @@
|
|
|
296
314
|
plaintext={field.plaintext}
|
|
297
315
|
fallback={printed ? (ghost as Content) : undefined}
|
|
298
316
|
placeholder={none}
|
|
299
|
-
|
|
317
|
+
emptyUnsets={optional}
|
|
300
318
|
labelledBy={domIds.label}
|
|
301
|
-
{
|
|
319
|
+
describedBy={proseDescribedBy}
|
|
320
|
+
cap={cap && { ...cap, onCount: (n) => (chars = n) }}
|
|
302
321
|
{leafKey}
|
|
303
322
|
{onFocus}
|
|
304
323
|
{onCaretMove}
|
|
@@ -374,7 +393,6 @@
|
|
|
374
393
|
layout={arrayLayout(field.schema)}
|
|
375
394
|
max={field.schema.max}
|
|
376
395
|
label={field.label}
|
|
377
|
-
required={field.required}
|
|
378
396
|
description={field.description}
|
|
379
397
|
labelId={domIds.label}
|
|
380
398
|
descriptionId={domIds.description}
|
|
@@ -389,6 +407,7 @@
|
|
|
389
407
|
value={value as Record<string, unknown> | undefined}
|
|
390
408
|
properties={field.schema.properties}
|
|
391
409
|
{flat}
|
|
410
|
+
placed
|
|
392
411
|
label={field.label}
|
|
393
412
|
idBase={domIds.control}
|
|
394
413
|
labelledBy={domIds.label}
|
|
@@ -476,13 +495,10 @@
|
|
|
476
495
|
gap: var(--_qm-space);
|
|
477
496
|
min-width: 0;
|
|
478
497
|
}
|
|
479
|
-
/*
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
square over a rounded one; on an array or a subform it bounds a group, where
|
|
483
|
-
there is no single box for it to disagree with. */
|
|
498
|
+
/* The arrival wash's host (`.qm-wash`). The radius is the control's own, so the
|
|
499
|
+
wash's corners are the box's rather than square over a rounded one; on an array or
|
|
500
|
+
a subform it bounds a group, where there is no single box for it to disagree with. */
|
|
484
501
|
.qm-field-control {
|
|
485
|
-
position: relative;
|
|
486
502
|
border-radius: var(--_qm-radius-inner);
|
|
487
503
|
}
|
|
488
504
|
</style>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Document, Quill, Addr, Diagnostic, ResolvedField } from '@quillmark/wasm';
|
|
1
|
+
import type { Document, Quill, Addr, Diagnostic, PathStep, ResolvedField } from '@quillmark/wasm';
|
|
2
2
|
import type { EditorErrorHandler } from '../core/errors.js';
|
|
3
3
|
import { type LeafRegistry } from './leaves.js';
|
|
4
4
|
import type { FieldModel, FieldSpan } from './structure.js';
|
|
@@ -27,7 +27,9 @@ interface Props {
|
|
|
27
27
|
optionAllowed?: (value: string) => boolean;
|
|
28
28
|
/** How a refused option draws: greyed (default) or left out. */
|
|
29
29
|
enumDisallowed?: 'hide' | 'disable';
|
|
30
|
-
|
|
30
|
+
/** A focus inside the field: its address, and the steps down to the cell holding
|
|
31
|
+
* the focus ({@link stepsTo}), `[]` on the field's own control. */
|
|
32
|
+
onFocus?: (addr: Addr, steps?: readonly PathStep[]) => void;
|
|
31
33
|
onCaretMove?: (addr: Addr, pos: number) => void;
|
|
32
34
|
onChange?: (addr: Addr) => void;
|
|
33
35
|
onError?: EditorErrorHandler;
|
|
@@ -10,15 +10,8 @@
|
|
|
10
10
|
hands the click to `onActivate`. Both halves are needed: `for` on a non-labelable
|
|
11
11
|
element is inert and invalid.
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
`default:` (DOCUMENT_MODEL). Its
|
|
16
|
-
accessible name is "required", so a screen reader announces the word, not the
|
|
17
|
-
glyph; inside the `<label>`, so it names the control along with the text
|
|
18
|
-
("Subject required") rather than announcing as a node adjacent to it;
|
|
19
|
-
|
|
20
|
-
• the `description` as an info marker raising a themed popover
|
|
21
|
-
({@link FieldHint}).
|
|
13
|
+
One non-gating label decoration: the `description` as an info marker raising a
|
|
14
|
+
themed popover ({@link FieldHint}).
|
|
22
15
|
|
|
23
16
|
The description describes, it does not name. It is parked in an always-present
|
|
24
17
|
visually-hidden node here and the control carries `aria-describedby`: name then
|
|
@@ -32,11 +25,6 @@
|
|
|
32
25
|
control's label decorates the same way.
|
|
33
26
|
-->
|
|
34
27
|
<script lang="ts">
|
|
35
|
-
import { wording } from './strings.js';
|
|
36
|
-
|
|
37
|
-
// The surface's words, ambient from the editor root; the package's English
|
|
38
|
-
// off-tree, so this component renders standalone too.
|
|
39
|
-
const t = wording();
|
|
40
28
|
import FieldHint from './FieldHint.svelte';
|
|
41
29
|
|
|
42
30
|
interface Props {
|
|
@@ -50,12 +38,15 @@
|
|
|
50
38
|
descriptionId?: string;
|
|
51
39
|
/** Focus handoff for a control `for` cannot reach; a label click calls it. */
|
|
52
40
|
onActivate?: () => void;
|
|
53
|
-
/** No-default field → a persistent required `*`. */
|
|
54
|
-
required?: boolean;
|
|
55
41
|
/** Schema `description`, or undefined: the affordance renders only when set. */
|
|
56
42
|
description?: string;
|
|
43
|
+
/** A capped field's character count, drawn at the row's end. */
|
|
44
|
+
count?: string;
|
|
45
|
+
/** The count's own id, for the control's `aria-describedby`. */
|
|
46
|
+
countId?: string;
|
|
57
47
|
}
|
|
58
|
-
let { label, controlId, id, descriptionId, onActivate,
|
|
48
|
+
let { label, controlId, id, descriptionId, onActivate, description, count, countId }: Props =
|
|
49
|
+
$props();
|
|
59
50
|
</script>
|
|
60
51
|
|
|
61
52
|
<div class="qm-field-label-row">
|
|
@@ -71,9 +62,6 @@
|
|
|
71
62
|
onclick={onActivate}
|
|
72
63
|
>
|
|
73
64
|
<span>{label}</span>
|
|
74
|
-
{#if required}
|
|
75
|
-
<span class="qm-field-required" aria-label={t.strings.fieldRequired}>*</span>
|
|
76
|
-
{/if}
|
|
77
65
|
</label>
|
|
78
66
|
{#if description}
|
|
79
67
|
<!-- Outside the `<label>`, both of them: a trigger inside it would put its own
|
|
@@ -85,16 +73,17 @@
|
|
|
85
73
|
<FieldHint {description} {label} describedBy={descriptionId} />
|
|
86
74
|
<span class="qm-visually-hidden" id={descriptionId}>{description}</span>
|
|
87
75
|
{/if}
|
|
76
|
+
{#if count != null}
|
|
77
|
+
<span class="qm-field-count" id={countId}>{count}</span>
|
|
78
|
+
{/if}
|
|
88
79
|
</div>
|
|
89
80
|
|
|
90
81
|
<style>
|
|
91
82
|
/* The row, not the label, is the field's grid child: `Field`'s subgrid counts
|
|
92
83
|
two tracks and the hint sits beside the label rather than under it.
|
|
93
84
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
marker is a second affordance beside it. The marker's box is its glyph
|
|
97
|
-
(`FieldHint`), so this gap is the whole of what stands between them. */
|
|
85
|
+
The marker's box is its glyph (`FieldHint`), so this gap is the whole of what
|
|
86
|
+
stands between it and the label. */
|
|
98
87
|
.qm-field-label-row {
|
|
99
88
|
display: flex;
|
|
100
89
|
align-items: center;
|
|
@@ -104,26 +93,23 @@
|
|
|
104
93
|
.qm-field-label {
|
|
105
94
|
display: inline-flex;
|
|
106
95
|
align-items: center;
|
|
107
|
-
gap: var(--_qm-space-half);
|
|
108
96
|
font-size: var(--_qm-text-label);
|
|
109
97
|
font-weight: var(--_qm-weight-mid);
|
|
110
98
|
line-height: var(--_qm-leading-tight);
|
|
111
99
|
color: var(--_qm-ink-label);
|
|
112
100
|
min-width: 0;
|
|
113
101
|
}
|
|
102
|
+
.qm-field-count {
|
|
103
|
+
margin-inline-start: auto;
|
|
104
|
+
font-size: var(--_qm-text-label);
|
|
105
|
+
color: var(--_qm-ink-label);
|
|
106
|
+
font-variant-numeric: tabular-nums;
|
|
107
|
+
}
|
|
114
108
|
/* A label that lands focus says so. Only when it has somewhere to land: an
|
|
115
109
|
`ObjectField` property's label names a subform, not one control. */
|
|
116
110
|
.qm-field-label.targeted {
|
|
117
111
|
cursor: pointer;
|
|
118
112
|
}
|
|
119
|
-
/* Required marker: the label's own ink, not an alarm; required-ness is guidance,
|
|
120
|
-
the document still edits and renders unmet, and the danger hue is a diagnostic's. */
|
|
121
|
-
.qm-field-required {
|
|
122
|
-
display: inline-flex;
|
|
123
|
-
align-items: center;
|
|
124
|
-
color: var(--_qm-ink-label);
|
|
125
|
-
line-height: 1;
|
|
126
|
-
}
|
|
127
113
|
/* The parked description: in the accessibility tree, out of the layout, and never
|
|
128
114
|
a focus stop. Clipped rather than `display: none` / `hidden`, which take a node
|
|
129
115
|
out of the tree entirely and would make the `aria-describedby` dangle. Out of
|
|
@@ -9,10 +9,12 @@ interface Props {
|
|
|
9
9
|
descriptionId?: string;
|
|
10
10
|
/** Focus handoff for a control `for` cannot reach; a label click calls it. */
|
|
11
11
|
onActivate?: () => void;
|
|
12
|
-
/** No-default field → a persistent required `*`. */
|
|
13
|
-
required?: boolean;
|
|
14
12
|
/** Schema `description`, or undefined: the affordance renders only when set. */
|
|
15
13
|
description?: string;
|
|
14
|
+
/** A capped field's character count, drawn at the row's end. */
|
|
15
|
+
count?: string;
|
|
16
|
+
/** The count's own id, for the control's `aria-describedby`. */
|
|
17
|
+
countId?: string;
|
|
16
18
|
}
|
|
17
19
|
declare const FieldLabel: import("svelte").Component<Props, {}, "">;
|
|
18
20
|
type FieldLabel = ReturnType<typeof FieldLabel>;
|
|
@@ -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);
|