@quillmark/svelte 0.20.1 → 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/THEMING.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 +26 -11
- package/dist/core/codec/blocks.d.ts +5 -5
- package/dist/core/codec/blocks.js +27 -8
- package/dist/core/codec/code.d.ts +4 -4
- package/dist/core/codec/code.js +52 -22
- 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 -15
- package/dist/core/codec/field.js +66 -26
- package/dist/core/codec/index.d.ts +1 -1
- package/dist/core/codec/index.js +2 -2
- package/dist/core/codec/inputrules.d.ts +4 -0
- package/dist/core/codec/inputrules.js +44 -6
- package/dist/core/codec/link-paste.d.ts +11 -0
- package/dist/core/codec/link-paste.js +32 -0
- package/dist/core/codec/lists.d.ts +5 -4
- package/dist/core/codec/lists.js +87 -31
- package/dist/core/codec/nesting.d.ts +20 -0
- package/dist/core/codec/nesting.js +62 -0
- package/dist/core/codec/prose.css +145 -63
- package/dist/core/codec/schema.d.ts +2 -2
- package/dist/core/codec/schema.js +71 -18
- package/dist/core/codec/slash.js +0 -0
- 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 +6 -7
- package/dist/core/codec/table-view.js +281 -201
- 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/core/rungs.d.ts +6 -0
- package/dist/core/rungs.js +11 -0
- package/dist/core/theme.css +24 -12
- package/dist/visual/ArrayField.svelte +236 -78
- package/dist/visual/ArrayField.svelte.d.ts +0 -2
- package/dist/visual/BooleanField.svelte +12 -1
- package/dist/visual/Card.svelte +41 -23
- package/dist/visual/Card.svelte.d.ts +3 -2
- package/dist/visual/CardControls.svelte +15 -15
- package/dist/visual/DateField.svelte +93 -64
- package/dist/visual/EnumField.svelte +9 -4
- package/dist/visual/Field.svelte +20 -16
- package/dist/visual/Field.svelte.d.ts +4 -2
- package/dist/visual/FieldHint.svelte +22 -5
- package/dist/visual/FieldLabel.svelte +5 -33
- package/dist/visual/FieldLabel.svelte.d.ts +0 -2
- package/dist/visual/FormatPopover.svelte +78 -81
- package/dist/visual/MatrixField.svelte +28 -19
- package/dist/visual/NumberField.svelte +32 -3
- package/dist/visual/ObjectField.svelte +40 -24
- 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 +7 -7
- package/dist/visual/VariantField.svelte +1 -1
- package/dist/visual/VariantField.svelte.d.ts +1 -1
- package/dist/visual/VisualEditorInner.svelte +121 -39
- package/dist/visual/controls.css +21 -2
- package/dist/visual/icons/Icon.svelte +3 -2
- package/dist/visual/icons/Icon.svelte.d.ts +3 -2
- package/dist/visual/icons/nodes.d.ts +25 -21
- package/dist/visual/icons/nodes.js +9 -7
- package/dist/visual/leaves.d.ts +12 -9
- package/dist/visual/leaves.js +19 -0
- package/dist/visual/motion.d.ts +25 -3
- package/dist/visual/motion.js +58 -9
- package/dist/visual/strings.d.ts +15 -9
- package/dist/visual/strings.js +19 -12
- package/dist/visual/structure.d.ts +21 -31
- package/dist/visual/structure.js +54 -51
- package/package.json +3 -3
|
@@ -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,10 @@
|
|
|
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;
|
|
57
43
|
}
|
|
58
|
-
let { label, controlId, id, descriptionId, onActivate,
|
|
44
|
+
let { label, controlId, id, descriptionId, onActivate, description }: Props = $props();
|
|
59
45
|
</script>
|
|
60
46
|
|
|
61
47
|
<div class="qm-field-label-row">
|
|
@@ -71,9 +57,6 @@
|
|
|
71
57
|
onclick={onActivate}
|
|
72
58
|
>
|
|
73
59
|
<span>{label}</span>
|
|
74
|
-
{#if required}
|
|
75
|
-
<span class="qm-field-required" aria-label={t.strings.fieldRequired}>*</span>
|
|
76
|
-
{/if}
|
|
77
60
|
</label>
|
|
78
61
|
{#if description}
|
|
79
62
|
<!-- Outside the `<label>`, both of them: a trigger inside it would put its own
|
|
@@ -91,10 +74,8 @@
|
|
|
91
74
|
/* The row, not the label, is the field's grid child: `Field`'s subgrid counts
|
|
92
75
|
two tracks and the hint sits beside the label rather than under it.
|
|
93
76
|
|
|
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. */
|
|
77
|
+
The marker's box is its glyph (`FieldHint`), so this gap is the whole of what
|
|
78
|
+
stands between it and the label. */
|
|
98
79
|
.qm-field-label-row {
|
|
99
80
|
display: flex;
|
|
100
81
|
align-items: center;
|
|
@@ -104,7 +85,6 @@
|
|
|
104
85
|
.qm-field-label {
|
|
105
86
|
display: inline-flex;
|
|
106
87
|
align-items: center;
|
|
107
|
-
gap: var(--_qm-space-half);
|
|
108
88
|
font-size: var(--_qm-text-label);
|
|
109
89
|
font-weight: var(--_qm-weight-mid);
|
|
110
90
|
line-height: var(--_qm-leading-tight);
|
|
@@ -116,14 +96,6 @@
|
|
|
116
96
|
.qm-field-label.targeted {
|
|
117
97
|
cursor: pointer;
|
|
118
98
|
}
|
|
119
|
-
/* Required marker: a quiet accent glyph, not an alarm; required-ness is guidance,
|
|
120
|
-
the document still edits and renders unmet. */
|
|
121
|
-
.qm-field-required {
|
|
122
|
-
display: inline-flex;
|
|
123
|
-
align-items: center;
|
|
124
|
-
color: var(--_qm-danger);
|
|
125
|
-
line-height: 1;
|
|
126
|
-
}
|
|
127
99
|
/* The parked description: in the accessibility tree, out of the layout, and never
|
|
128
100
|
a focus stop. Clipped rather than `display: none` / `hidden`, which take a node
|
|
129
101
|
out of the tree entirely and would make the `aria-describedby` dangle. Out of
|
|
@@ -9,8 +9,6 @@ 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;
|
|
16
14
|
}
|
|
@@ -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.
|
|
@@ -24,7 +25,10 @@
|
|
|
24
25
|
A press holds it down, which is what makes the surface answer a selection rather
|
|
25
26
|
than a pointer: a drag fires `selectionchange` on every frame, so a surface driven
|
|
26
27
|
by that alone rises on the first character and rides the cursor across the text it
|
|
27
|
-
is covering. Keyboard selection holds nothing, so
|
|
28
|
+
is covering. Keyboard selection holds nothing, so it waits instead: a rise with no
|
|
29
|
+
release behind it comes once the selection has stood still for `SETTLE_MS`, so a
|
|
30
|
+
Shift-arrow run, or a select-all on its way to a cut, raises nothing until it stops.
|
|
31
|
+
A lowering never waits, and an open surface follows each step.
|
|
28
32
|
|
|
29
33
|
Nothing here watches scroll. `sync` decides whether the surface is up, which a
|
|
30
34
|
scroll does not change; where it sits is the anchor's own question, and the anchor
|
|
@@ -61,6 +65,7 @@
|
|
|
61
65
|
// off-tree, so this component renders standalone too.
|
|
62
66
|
const t = wording();
|
|
63
67
|
import { toggleMark } from 'prosemirror-commands';
|
|
68
|
+
import { keydownHandler } from 'prosemirror-keymap';
|
|
64
69
|
import { TextSelection, type Command } from 'prosemirror-state';
|
|
65
70
|
import type { EditorView } from 'prosemirror-view';
|
|
66
71
|
import type { MarkType } from 'prosemirror-model';
|
|
@@ -70,6 +75,7 @@
|
|
|
70
75
|
import { hasMarks, rangeAnchor, type RangeAnchor } from '../core/codec/index.js';
|
|
71
76
|
import type { ActiveProse } from './leaves.js';
|
|
72
77
|
import { clearLink, hrefInSelection, normalizeHref, setLink } from './links.js';
|
|
78
|
+
import { spacePx } from '../core/rungs.js';
|
|
73
79
|
import './controls.css';
|
|
74
80
|
|
|
75
81
|
interface Props {
|
|
@@ -78,10 +84,9 @@
|
|
|
78
84
|
}
|
|
79
85
|
let { getActiveLeaf }: Props = $props();
|
|
80
86
|
|
|
81
|
-
const
|
|
82
|
-
/** The six content formatting marks. `anchor` is
|
|
83
|
-
*
|
|
84
|
-
* the tooltip and the accessible name; the anchor button spells the two separately. */
|
|
87
|
+
const SETTLE_MS = 450;
|
|
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. */
|
|
85
90
|
const MARKS: { name: string; icon: IconName; title: string }[] = $derived([
|
|
86
91
|
{ name: 'strong', icon: 'bold', title: t.strings.formatBold },
|
|
87
92
|
{ name: 'em', icon: 'italic', title: t.strings.formatEmphasis },
|
|
@@ -94,13 +99,14 @@
|
|
|
94
99
|
let open = $state(false);
|
|
95
100
|
/** A pointer is down outside this surface: the selection is still being made. */
|
|
96
101
|
let pressed = false;
|
|
97
|
-
/** Whether an anchor can be minted over the selection: in a field's own view, never
|
|
98
|
-
* in a table cell or a by-value leaf (see `sync`). */
|
|
99
|
-
let anchorAvailable = $state(true);
|
|
100
102
|
let linkPromptOpen = $state(false);
|
|
101
103
|
/** Whether the selection carried a link when the prompt was raised. */
|
|
102
104
|
let linkPresent = $state(false);
|
|
103
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);
|
|
104
110
|
let contentEl = $state<HTMLElement | undefined>(undefined);
|
|
105
111
|
/** The root to portal into: `document.body` escapes the editor's subtree and
|
|
106
112
|
* the consumer's dials with it, so a pane-scoped palette misses this surface.
|
|
@@ -115,12 +121,13 @@
|
|
|
115
121
|
/** The view the caret is actually in: the leaf's own, or the nested cell view of a
|
|
116
122
|
* table island (`codec/table-view.ts`). The six marks are the inline schema's
|
|
117
123
|
* too, so a selection in a cell raises the same popover and toggles the same
|
|
118
|
-
* commands
|
|
124
|
+
* commands. */
|
|
119
125
|
function activeLeafView(): EditorView | undefined {
|
|
120
126
|
return getActiveLeaf()?.views.focusedView();
|
|
121
127
|
}
|
|
122
128
|
|
|
123
|
-
function sync(): void {
|
|
129
|
+
function sync(wait = false): void {
|
|
130
|
+
clearTimeout(settling);
|
|
124
131
|
const insidePopover =
|
|
125
132
|
!!contentEl && !!document.activeElement && contentEl.contains(document.activeElement);
|
|
126
133
|
// Interacting with the popover itself (e.g. the link input): leave state as-is.
|
|
@@ -128,18 +135,16 @@
|
|
|
128
135
|
// returns early, so a surface frozen here still tracks the selection it covers.
|
|
129
136
|
if (insidePopover) return;
|
|
130
137
|
if (pressed) return;
|
|
131
|
-
const
|
|
132
|
-
const view = leaf?.views.focusedView();
|
|
138
|
+
const view = activeLeafView();
|
|
133
139
|
// A non-empty text selection. A node selection is non-empty too and has nothing
|
|
134
140
|
// to format: it covers a leaf block (an island, a rule), which is exactly where
|
|
135
141
|
// Escape out of a table cell lands.
|
|
136
142
|
const selection = view?.state.selection;
|
|
137
143
|
const formattable = selection instanceof TextSelection && !selection.empty;
|
|
138
|
-
// …in a leaf that has formatting at all. A `
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// 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.
|
|
143
148
|
if (
|
|
144
149
|
!view ||
|
|
145
150
|
!view.hasFocus() ||
|
|
@@ -151,16 +156,13 @@
|
|
|
151
156
|
linkPromptOpen = false;
|
|
152
157
|
return;
|
|
153
158
|
}
|
|
159
|
+
if (wait && !open) {
|
|
160
|
+
settling = setTimeout(() => sync(), SETTLE_MS);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
154
163
|
portalTarget = view.dom.closest<HTMLElement>('[data-qm-root]') ?? undefined;
|
|
155
164
|
const { from, to } = view.state.selection;
|
|
156
165
|
anchor = rangeAnchor(view, from, to);
|
|
157
|
-
// An anchor is minted through a field's controller, which a by-value leaf has
|
|
158
|
-
// none of: its anchors are dropped on the parent's value write. A selection inside
|
|
159
|
-
// a table cell is not in the field's coordinate space at all: the field's position
|
|
160
|
-
// map holds one `atom` run for the whole island, so there is no USV offset to mint
|
|
161
|
-
// an anchor at. The button is withheld rather than disabled, because what it would
|
|
162
|
-
// toggle does not exist there (CODEC §Islands).
|
|
163
|
-
anchorAvailable = !!leaf?.controller && view === leaf.views.view;
|
|
164
166
|
open = true;
|
|
165
167
|
}
|
|
166
168
|
|
|
@@ -186,12 +188,17 @@
|
|
|
186
188
|
// selection. `pending` collapses a burst to a single scheduled callback.
|
|
187
189
|
let pending = false;
|
|
188
190
|
let rafHandle = 0;
|
|
191
|
+
/** The check in flight answers a pointer's release, which raises at once. */
|
|
192
|
+
let released = false;
|
|
193
|
+
let settling: ReturnType<typeof setTimeout> | undefined;
|
|
189
194
|
function deferredSync(): void {
|
|
190
195
|
if (pending) return;
|
|
191
196
|
pending = true;
|
|
192
197
|
rafHandle = requestAnimationFrame(() => {
|
|
193
198
|
pending = false;
|
|
194
|
-
|
|
199
|
+
const wait = !released;
|
|
200
|
+
released = false;
|
|
201
|
+
sync(wait);
|
|
195
202
|
});
|
|
196
203
|
}
|
|
197
204
|
|
|
@@ -205,14 +212,16 @@
|
|
|
205
212
|
function onPointerUp(): void {
|
|
206
213
|
if (!pressed) return;
|
|
207
214
|
pressed = false;
|
|
215
|
+
released = true;
|
|
208
216
|
deferredSync();
|
|
209
217
|
}
|
|
210
218
|
|
|
211
|
-
// A burst-coalescing rAF may still be in flight at teardown;
|
|
212
|
-
// `sync()` never runs against the destroyed component.
|
|
219
|
+
// A burst-coalescing rAF or a settling rise may still be in flight at teardown;
|
|
220
|
+
// cancel both so `sync()` never runs against the destroyed component.
|
|
213
221
|
onDestroy(() => {
|
|
214
222
|
if (pending) cancelAnimationFrame(rafHandle);
|
|
215
223
|
pending = false;
|
|
224
|
+
clearTimeout(settling);
|
|
216
225
|
});
|
|
217
226
|
|
|
218
227
|
// Any close path that bypasses `sync` (e.g. Escape while the link input holds
|
|
@@ -250,6 +259,7 @@
|
|
|
250
259
|
if (name === 'link') {
|
|
251
260
|
linkValue = hrefInSelection(view.state);
|
|
252
261
|
linkPresent = linkValue.length > 0;
|
|
262
|
+
promptView = view;
|
|
253
263
|
linkPromptOpen = true;
|
|
254
264
|
return;
|
|
255
265
|
}
|
|
@@ -258,43 +268,12 @@
|
|
|
258
268
|
sync();
|
|
259
269
|
}
|
|
260
270
|
|
|
261
|
-
/**
|
|
262
|
-
* A fresh anchor id: 128 random bits as hex, the id being opaque to the
|
|
263
|
-
* boundary and needing only to be unique. `crypto.randomUUID` is
|
|
264
|
-
* secure-context-only, and absent on a page served over plain `http` to
|
|
265
|
-
* something other than localhost; `getRandomValues` is there either way.
|
|
266
|
-
*/
|
|
267
|
-
function mintAnchorId(): string {
|
|
268
|
-
return [...crypto.getRandomValues(new Uint8Array(16))]
|
|
269
|
-
.map((b) => b.toString(16).padStart(2, '0'))
|
|
270
|
-
.join('');
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Toggle an identity anchor over the selection: the `anchor`
|
|
275
|
-
* button's answer to a formatting toggle. If the selection already covers
|
|
276
|
-
* anchors, remove them; else insert one at its start with a freshly-minted
|
|
277
|
-
* unique id (the caller-supplied, invariant id the 0.97 policy settles).
|
|
278
|
-
* Zero-width and glyph-less (CODEC §Marks): the identity handle persists in
|
|
279
|
-
* the content, its chrome awaiting comment-thread UX.
|
|
280
|
-
*/
|
|
281
|
-
function toggleAnchor(): void {
|
|
282
|
-
const leaf = getActiveLeaf()?.controller;
|
|
283
|
-
const view = activeLeafView();
|
|
284
|
-
if (!leaf || !view || view.state.selection.empty) return;
|
|
285
|
-
const { from, to } = leaf.selectionRange();
|
|
286
|
-
const covered = leaf.anchorsInRange(from, to);
|
|
287
|
-
if (covered.length) covered.forEach((id) => leaf.removeAnchor(id));
|
|
288
|
-
else leaf.insertAnchor(mintAnchorId(), from);
|
|
289
|
-
view.focus();
|
|
290
|
-
sync();
|
|
291
|
-
}
|
|
292
|
-
|
|
293
271
|
/** Every exit from the prompt: lower it, run `cmd` if there is one, and hand the
|
|
294
272
|
* leaf back its focus and the surface its buttons. */
|
|
295
273
|
function closePrompt(cmd?: Command): void {
|
|
296
274
|
linkPromptOpen = false;
|
|
297
|
-
const view = activeLeafView();
|
|
275
|
+
const view = promptView && !promptView.isDestroyed ? promptView : activeLeafView();
|
|
276
|
+
promptView = undefined;
|
|
298
277
|
if (view) {
|
|
299
278
|
cmd?.(view.state, view.dispatch);
|
|
300
279
|
view.focus();
|
|
@@ -309,6 +288,30 @@
|
|
|
309
288
|
closePrompt(setLink(normalizeHref(linkValue)));
|
|
310
289
|
}
|
|
311
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
|
+
|
|
312
315
|
function removeLink(): void {
|
|
313
316
|
closePrompt(clearLink);
|
|
314
317
|
}
|
|
@@ -322,6 +325,7 @@
|
|
|
322
325
|
it does with the event. `pointercancel` is the release a gesture the browser takes
|
|
323
326
|
over gets. -->
|
|
324
327
|
<svelte:document
|
|
328
|
+
onkeydown={onKeyDown}
|
|
325
329
|
onselectionchange={deferredSync}
|
|
326
330
|
onfocusout={deferredSync}
|
|
327
331
|
onpointerdowncapture={onPointerDown}
|
|
@@ -342,7 +346,7 @@
|
|
|
342
346
|
side="top"
|
|
343
347
|
align="center"
|
|
344
348
|
trapFocus={false}
|
|
345
|
-
sideOffset={
|
|
349
|
+
sideOffset={spacePx(portalTarget, 2)}
|
|
346
350
|
onOpenAutoFocus={(e: Event) => e.preventDefault()}
|
|
347
351
|
onCloseAutoFocus={(e: Event) => e.preventDefault()}
|
|
348
352
|
>
|
|
@@ -375,26 +379,29 @@
|
|
|
375
379
|
>
|
|
376
380
|
<input
|
|
377
381
|
{@attach focusPrompt}
|
|
378
|
-
|
|
382
|
+
bind:this={promptInput}
|
|
383
|
+
class="qm-link-input qm-focus-ring"
|
|
379
384
|
type="text"
|
|
380
385
|
placeholder={t.strings.linkPlaceholder}
|
|
381
386
|
aria-label={t.strings.formatLink}
|
|
382
387
|
bind:value={linkValue}
|
|
383
388
|
/>
|
|
384
|
-
<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
|
+
>
|
|
385
392
|
<!-- Only where there is a link to remove: an arm that does nothing is
|
|
386
393
|
an arm to read past on every other visit to the prompt. -->
|
|
387
394
|
{#if linkPresent}
|
|
388
395
|
<button
|
|
389
396
|
type="button"
|
|
390
|
-
class="qm-icon-btn qm-mark-btn"
|
|
397
|
+
class="qm-icon-btn qm-mark-btn qm-focus-ring"
|
|
391
398
|
onmousedown={keepFocus}
|
|
392
399
|
onclick={removeLink}>{t.strings.linkRemove}</button
|
|
393
400
|
>
|
|
394
401
|
{/if}
|
|
395
402
|
<button
|
|
396
403
|
type="button"
|
|
397
|
-
class="qm-icon-btn qm-mark-btn"
|
|
404
|
+
class="qm-icon-btn qm-mark-btn qm-focus-ring"
|
|
398
405
|
onmousedown={keepFocus}
|
|
399
406
|
onclick={cancelLink}>{t.strings.linkCancel}</button
|
|
400
407
|
>
|
|
@@ -407,23 +414,13 @@
|
|
|
407
414
|
{#each MARKS as m (m.name)}
|
|
408
415
|
<button
|
|
409
416
|
type="button"
|
|
410
|
-
class="qm-icon-btn qm-mark-btn"
|
|
417
|
+
class="qm-icon-btn qm-mark-btn qm-focus-ring"
|
|
411
418
|
title={m.title}
|
|
412
419
|
aria-label={m.title}
|
|
413
420
|
onmousedown={keepFocus}
|
|
414
|
-
onclick={() => toggle(m.name)}><Icon name={m.icon}
|
|
421
|
+
onclick={() => toggle(m.name)}><Icon name={m.icon} /></button
|
|
415
422
|
>
|
|
416
423
|
{/each}
|
|
417
|
-
{#if anchorAvailable}
|
|
418
|
-
<button
|
|
419
|
-
type="button"
|
|
420
|
-
class="qm-icon-btn qm-mark-btn"
|
|
421
|
-
title={t.strings.formatAnchorTitle}
|
|
422
|
-
aria-label={t.strings.formatAnchor}
|
|
423
|
-
onmousedown={keepFocus}
|
|
424
|
-
onclick={toggleAnchor}><Icon name="hash" size={GLYPH} /></button
|
|
425
|
-
>
|
|
426
|
-
{/if}
|
|
427
424
|
</div>
|
|
428
425
|
{/if}
|
|
429
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);
|
|
@@ -525,16 +531,19 @@
|
|
|
525
531
|
background: var(--_qm-danger-tint);
|
|
526
532
|
color: var(--_qm-danger);
|
|
527
533
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
}
|
|
532
|
-
/* The default's set is what prints and nothing written: its titles and every value
|
|
533
|
-
its columns draw take the default rung (theme.css), and the first gesture, writing
|
|
534
|
-
it, takes it off. */
|
|
534
|
+
/* The default's set is what prints and nothing written: its titles, its ticks and
|
|
535
|
+
every value its columns draw take the default rung (theme.css), and the first
|
|
536
|
+
gesture, writing it, takes it off. */
|
|
535
537
|
.qm-matrix-roster[data-default] :global(:is(.qm-input, .ProseMirror, .qm-date)),
|
|
536
538
|
.qm-matrix-roster[data-default] :global(.qm-select:not([data-ghosted])),
|
|
537
539
|
.qm-matrix-roster[data-default] .qm-member.held .qm-member-title {
|
|
538
540
|
color: var(--_qm-ink-default);
|
|
539
541
|
}
|
|
542
|
+
.qm-matrix-roster[data-default] :is(.qm-tick-input:checked, .qm-tick-fixed) {
|
|
543
|
+
background: var(--_qm-accent-default);
|
|
544
|
+
border-color: var(--_qm-accent-default);
|
|
545
|
+
}
|
|
546
|
+
.qm-matrix-roster[data-default] :global(.qm-toggle[data-state='checked']) {
|
|
547
|
+
background: var(--_qm-accent-default);
|
|
548
|
+
}
|
|
540
549
|
</style>
|
|
@@ -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
|
/>
|