@quillmark/svelte 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/THEMING.md +11 -7
- package/dist/core/address.d.ts +7 -7
- package/dist/core/address.js +6 -6
- package/dist/core/bloom.d.ts +5 -5
- package/dist/core/bloom.js +8 -8
- package/dist/core/codec/anchor.d.ts +1 -1
- package/dist/core/codec/anchor.js +1 -1
- package/dist/core/codec/atoms.d.ts +10 -0
- package/dist/core/codec/atoms.js +78 -0
- package/dist/core/codec/clipboard.d.ts +59 -0
- package/dist/core/codec/clipboard.js +202 -0
- package/dist/core/codec/code.d.ts +1 -1
- package/dist/core/codec/code.js +4 -4
- package/dist/core/codec/decode.d.ts +1 -1
- package/dist/core/codec/decode.js +8 -8
- package/dist/core/codec/encode.d.ts +4 -4
- package/dist/core/codec/encode.js +10 -10
- package/dist/core/codec/field.d.ts +21 -26
- package/dist/core/codec/field.js +33 -32
- package/dist/core/codec/index.js +5 -8
- package/dist/core/codec/inputrules.js +56 -18
- package/dist/core/codec/islands.d.ts +11 -7
- package/dist/core/codec/islands.js +16 -10
- package/dist/core/codec/keymap.d.ts +5 -6
- package/dist/core/codec/keymap.js +8 -7
- package/dist/core/codec/lists.d.ts +3 -3
- package/dist/core/codec/lists.js +32 -13
- package/dist/core/codec/marks.d.ts +1 -1
- package/dist/core/codec/marks.js +1 -1
- package/dist/core/codec/positions.js +1 -1
- package/dist/core/codec/prose.css +230 -215
- package/dist/core/codec/schema.js +4 -4
- package/dist/core/codec/slash.d.ts +39 -41
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-view.d.ts +12 -16
- package/dist/core/codec/table-view.js +488 -292
- package/dist/core/codec/table.d.ts +42 -15
- package/dist/core/codec/table.js +70 -26
- package/dist/core/errors.d.ts +5 -5
- package/dist/core/index.js +2 -2
- package/dist/core/lifecycle.js +1 -1
- package/dist/core/rebind.svelte.d.ts +2 -2
- package/dist/core/rebind.svelte.js +4 -4
- package/dist/core/teardown.d.ts +1 -1
- package/dist/core/teardown.js +1 -1
- package/dist/core/theme.css +144 -54
- package/dist/preset/index.css +1 -1
- package/dist/preset/recipes.css +31 -29
- package/dist/preset/scale.css +36 -25
- package/dist/preview/Preview.svelte +12 -10
- package/dist/preview/Preview.svelte.d.ts +3 -3
- package/dist/preview/bridge.js +4 -4
- package/dist/preview/controller.js +3 -3
- package/dist/preview/geometry.d.ts +2 -2
- package/dist/preview/geometry.js +2 -2
- package/dist/preview/index.js +3 -3
- package/dist/preview/overlay.d.ts +1 -1
- package/dist/preview/overlay.js +3 -3
- package/dist/preview/paint.js +15 -17
- package/dist/visual/ArrayField.svelte +80 -66
- package/dist/visual/ArrayField.svelte.d.ts +1 -1
- package/dist/visual/BooleanField.svelte +8 -6
- package/dist/visual/BooleanField.svelte.d.ts +2 -2
- package/dist/visual/Card.svelte +151 -325
- package/dist/visual/Card.svelte.d.ts +2 -6
- package/dist/visual/CardControls.svelte +1 -1
- package/dist/visual/DateField.svelte +19 -19
- package/dist/visual/DateField.svelte.d.ts +3 -3
- package/dist/visual/DiagnosticList.svelte +1 -1
- package/dist/visual/EnumField.svelte +16 -16
- package/dist/visual/EnumField.svelte.d.ts +2 -2
- package/dist/visual/Field.svelte +128 -115
- package/dist/visual/Field.svelte.d.ts +1 -1
- package/dist/visual/FieldHint.svelte +24 -42
- package/dist/visual/FieldHint.svelte.d.ts +1 -1
- package/dist/visual/FieldLabel.svelte +8 -8
- package/dist/visual/FormatPopover.svelte +80 -35
- package/dist/visual/NumberField.svelte +5 -5
- package/dist/visual/NumberField.svelte.d.ts +1 -1
- package/dist/visual/ObjectField.svelte +9 -9
- package/dist/visual/ObjectField.svelte.d.ts +1 -1
- package/dist/visual/ProseArrayElement.svelte +11 -13
- package/dist/visual/ProseArrayElement.svelte.d.ts +1 -1
- package/dist/visual/ProseField.svelte +53 -84
- package/dist/visual/ProseField.svelte.d.ts +12 -17
- package/dist/visual/SlashMenu.svelte +42 -16
- package/dist/visual/TextField.svelte +5 -5
- package/dist/visual/TextField.svelte.d.ts +1 -1
- package/dist/visual/TipsCard.svelte +43 -28
- package/dist/visual/VisualEditor.svelte +6 -6
- package/dist/visual/VisualEditorInner.svelte +122 -129
- package/dist/visual/controls.css +74 -30
- package/dist/visual/diagnostics.d.ts +5 -5
- package/dist/visual/diagnostics.js +3 -3
- package/dist/visual/domid.js +3 -3
- package/dist/visual/ext.d.ts +1 -1
- package/dist/visual/ext.js +1 -1
- package/dist/visual/icons/nodes.d.ts +3 -0
- package/dist/visual/icons/nodes.js +2 -1
- package/dist/visual/index.js +4 -4
- package/dist/visual/leaves.d.ts +3 -3
- package/dist/visual/leaves.js +1 -1
- package/dist/visual/links.d.ts +24 -0
- package/dist/visual/links.js +67 -0
- package/dist/visual/props.d.ts +8 -8
- package/dist/visual/signals.d.ts +6 -6
- package/dist/visual/strings.d.ts +14 -19
- package/dist/visual/strings.js +5 -7
- package/dist/visual/structure.d.ts +24 -17
- package/dist/visual/structure.js +8 -8
- package/dist/visual/synced.svelte.d.ts +1 -1
- package/dist/visual/synced.svelte.js +4 -4
- package/dist/visual/tips.d.ts +2 -2
- package/dist/visual/tips.js +3 -3
- package/package.json +1 -1
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
The schema `description` as a themed floating surface: the second that is
|
|
2
|
+
The schema `description` as a themed floating surface: the second that is read
|
|
3
3
|
rather than picked from, so it shares {@link FormatPopover}'s translucent recipe
|
|
4
4
|
and portals into the nearest `[data-qm-root]` like every
|
|
5
5
|
other floating surface, so a consumer's dials still reach it.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
Not the native `title` tooltip: that reaches no dial, waits ~1s, cannot be
|
|
8
|
+
dismissed, and (the part that makes it a hole rather than a blemish) does not exist
|
|
9
|
+
on touch, which leaves guidance the reference quill puts on nearly every field
|
|
10
|
+
reachable on a tablet only through a screen reader.
|
|
11
11
|
|
|
12
12
|
Floating rather than inline under the label: the reference quill's descriptions run
|
|
13
13
|
to a median of ~110 characters and past 250 at the tail: permanently in flow that
|
|
@@ -15,14 +15,14 @@
|
|
|
15
15
|
to avoid. Measured, not assumed; a quill whose descriptions are all one short line
|
|
16
16
|
would be better served inline.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
The popover is chrome, not the announcement. Its content is `aria-hidden`: the
|
|
19
19
|
description reaches assistive tech through the always-present parked node
|
|
20
20
|
{@link FieldLabel} renders and the `aria-describedby` the control carries, which
|
|
21
21
|
holds whether the surface is open or not. So no reader depends on a hover.
|
|
22
22
|
|
|
23
23
|
Three ways in, one per input modality: hover (pointer), focus (keyboard), tap
|
|
24
24
|
(touch); wired here rather than taken from `Tooltip`, whose trigger returns early
|
|
25
|
-
on `pointerType === 'touch'` and closes on click
|
|
25
|
+
on `pointerType === 'touch'` and closes on click.
|
|
26
26
|
-->
|
|
27
27
|
<script lang="ts">
|
|
28
28
|
import { Popover } from 'bits-ui';
|
|
@@ -35,14 +35,14 @@
|
|
|
35
35
|
* do not all announce alike. */
|
|
36
36
|
label: string;
|
|
37
37
|
/** The parked description node; the trigger points at it too, so a reader
|
|
38
|
-
* that lands
|
|
38
|
+
* that lands here announces the guidance rather than only the field's own. */
|
|
39
39
|
describedBy?: string;
|
|
40
40
|
}
|
|
41
41
|
let { description, label, describedBy }: Props = $props();
|
|
42
42
|
|
|
43
43
|
let open = $state(false);
|
|
44
44
|
let triggerEl = $state<HTMLButtonElement | undefined>(undefined);
|
|
45
|
-
/** The root to portal
|
|
45
|
+
/** The root to portal into: `document.body` would escape the consumer's dials
|
|
46
46
|
* along with the editor's subtree. `undefined` falls back to bits-ui's default. */
|
|
47
47
|
const portalTarget = $derived(triggerEl?.closest<HTMLElement>('[data-qm-root]') ?? undefined);
|
|
48
48
|
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
let beside = $state(false);
|
|
52
52
|
|
|
53
53
|
/** Whether this description sets one line. Off a probe rather than the surface
|
|
54
|
-
* itself, since the side is a prop the surface is positioned
|
|
54
|
+
* itself, since the side is a prop the surface is positioned from, and a side chosen
|
|
55
55
|
* after the fact moves a surface the reader is already looking at. The probe wears
|
|
56
56
|
* the surface's own class inside the portal target, so it wraps to the same measure
|
|
57
57
|
* under the same dials; `width: max-content` keeps that measure the class's rather
|
|
@@ -80,15 +80,15 @@
|
|
|
80
80
|
}
|
|
81
81
|
</script>
|
|
82
82
|
|
|
83
|
-
<!-- The trigger is
|
|
83
|
+
<!-- The trigger is ours, not `Popover.Trigger`, and the surface anchors to it:
|
|
84
84
|
the same shape {@link FormatPopover} uses. The primitive's trigger owns a click
|
|
85
|
-
|
|
85
|
+
toggle, which fights every one of the three openings below: a mouse click after
|
|
86
86
|
a hover-open would close, and on touch the focus that precedes the click opens
|
|
87
87
|
it just in time for the click to close it again. -->
|
|
88
88
|
<button
|
|
89
89
|
bind:this={triggerEl}
|
|
90
90
|
type="button"
|
|
91
|
-
class="qm-field-hint qm-icon-btn qm-focus-ring"
|
|
91
|
+
class="qm-field-hint qm-icon-btn qm-focus-ring qm-tap-floor"
|
|
92
92
|
aria-label="{label} guidance"
|
|
93
93
|
aria-expanded={open}
|
|
94
94
|
aria-describedby={describedBy}
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
if (e.pointerType !== 'touch') open = false;
|
|
100
100
|
}}
|
|
101
101
|
onpointerup={(e) => {
|
|
102
|
-
// Touch only, and a
|
|
102
|
+
// Touch only, and a toggle: the one modality with no hover to leave and no
|
|
103
103
|
// blur on the way out. A mouse press falls through to hover, which already
|
|
104
104
|
// has it open and will close it on the way off.
|
|
105
105
|
if (e.pointerType === 'touch') {
|
|
@@ -116,18 +116,18 @@
|
|
|
116
116
|
onblur={() => (open = false)}
|
|
117
117
|
onkeydown={(e) => {
|
|
118
118
|
if (e.key === 'Escape' && open) {
|
|
119
|
-
//
|
|
120
|
-
//
|
|
119
|
+
// Stopped here so the key does not travel on to a card- or editor-level
|
|
120
|
+
// Escape.
|
|
121
121
|
e.stopPropagation();
|
|
122
122
|
open = false;
|
|
123
123
|
}
|
|
124
124
|
}}><Icon name="info" size={13} /></button
|
|
125
125
|
>
|
|
126
126
|
|
|
127
|
-
<!--
|
|
127
|
+
<!-- Placement is the description'S height. One line rides the label rung beside its own
|
|
128
128
|
glyph, where the overhang past the rung's line box falls in the label-to-control gaps.
|
|
129
|
-
Anything taller stands over the field: beside the glyph it would hang onto
|
|
130
|
-
|
|
129
|
+
Anything taller stands over the field: beside the glyph it would hang onto the control
|
|
130
|
+
it describes, the one overlap a reader cannot clear by reaching for what is hidden,
|
|
131
131
|
since a pointer leaving the trigger closes the surface. Over the field it covers a
|
|
132
132
|
neighbour's control instead, which reaching for it dismisses. -->
|
|
133
133
|
<!-- `align="center"` for the rung, `"start"` over the field: centred, a surface taller
|
|
@@ -173,18 +173,15 @@
|
|
|
173
173
|
<style>
|
|
174
174
|
/* Chrome, type and ring are the glyph-button family's (`.qm-icon-btn` +
|
|
175
175
|
`.qm-focus-ring`, controls.css): this trigger is one more glyph button and
|
|
176
|
-
assembles none of that itself. What it does
|
|
176
|
+
assembles none of that itself. What it does not take is the family's tap floor
|
|
177
177
|
and the fill that floor sizes, because this is the one glyph button that sits in
|
|
178
|
-
a
|
|
178
|
+
a line of text rather than in a row of its own: the floor is half again the label
|
|
179
179
|
rung's line box, so a target-sized paint box overhangs the line at each end by
|
|
180
180
|
most of the field's label-to-control gap: the hover fill landing on the input's
|
|
181
181
|
top border, the focus ring crossing it.
|
|
182
182
|
|
|
183
|
-
So the box is the
|
|
184
|
-
|
|
185
|
-
correct at once: the row keeps the line box's height and the press keeps the
|
|
186
|
-
floor. Every other glyph button has a row to itself and keeps the floor as its
|
|
187
|
-
box.
|
|
183
|
+
So the box is the glyph and the target is `.qm-tap-floor`'s (controls.css), which
|
|
184
|
+
carries that split and the giving-up of the family floor together.
|
|
188
185
|
|
|
189
186
|
It takes no fill either: the ink step below says hover, and the surface itself
|
|
190
187
|
opens on it. */
|
|
@@ -193,30 +190,15 @@
|
|
|
193
190
|
color: var(--_qm-ink-ghost);
|
|
194
191
|
/* Not the family's `pointer`: the glyph raises guidance, it does not act. */
|
|
195
192
|
cursor: help;
|
|
196
|
-
position: relative;
|
|
197
|
-
min-width: 0;
|
|
198
|
-
min-height: 0;
|
|
199
193
|
padding: 0;
|
|
200
194
|
background: none;
|
|
201
195
|
}
|
|
202
|
-
/* The tap floor, out of flow and centred on the glyph: WCAG 2.5.8 measures the
|
|
203
|
-
target, and this is it. Unpainted, so it enlarges the press and not the mark, and
|
|
204
|
-
out of the accessibility tree by construction: a pseudo-element has no node. */
|
|
205
|
-
.qm-field-hint::after {
|
|
206
|
-
content: '';
|
|
207
|
-
position: absolute;
|
|
208
|
-
top: 50%;
|
|
209
|
-
left: 50%;
|
|
210
|
-
width: var(--_qm-tap-min);
|
|
211
|
-
height: var(--_qm-tap-min);
|
|
212
|
-
transform: translate(-50%, -50%);
|
|
213
|
-
}
|
|
214
196
|
.qm-field-hint:hover,
|
|
215
197
|
.qm-field-hint:focus-visible {
|
|
216
198
|
color: var(--_qm-ink-label);
|
|
217
199
|
}
|
|
218
200
|
/* The lift, the translucency, the blur and the scale-in come from
|
|
219
|
-
`.qm-popover-surface` (controls.css). This surface's own is a
|
|
201
|
+
`.qm-popover-surface` (controls.css). This surface's own is a measure: guidance
|
|
220
202
|
is prose, and prose past ~40 characters a line stops being scannable; plus the
|
|
221
203
|
inset and the meta type rung that keep it a note about a field rather than a
|
|
222
204
|
second field.
|
|
@@ -5,7 +5,7 @@ interface Props {
|
|
|
5
5
|
* do not all announce alike. */
|
|
6
6
|
label: string;
|
|
7
7
|
/** The parked description node; the trigger points at it too, so a reader
|
|
8
|
-
* that lands
|
|
8
|
+
* that lands here announces the guidance rather than only the field's own. */
|
|
9
9
|
describedBy?: string;
|
|
10
10
|
}
|
|
11
11
|
declare const FieldHint: import("svelte").Component<Props, {}, "">;
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
A field's label plus its guidance chrome.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
It names the control. A real `<label>`, so a click lands the caret in the field:
|
|
5
5
|
the widest, easiest target in a dense row, and the most conventional affordance a
|
|
6
|
-
form has. `for` reaches a
|
|
6
|
+
form has. `for` reaches a labelable control (the inputs, the enum trigger, the
|
|
7
7
|
switch) and the browser does the rest; a control `for` cannot reach (the prose
|
|
8
8
|
leaf's `contenteditable`, the date field's segments, an array's N inputs) leaves
|
|
9
9
|
`controlId` unset, points `aria-labelledby` back at this label's own `id`, and
|
|
10
10
|
hands the click to `onActivate`. Both halves are needed: `for` on a non-labelable
|
|
11
|
-
element is inert
|
|
11
|
+
element is inert and invalid.
|
|
12
12
|
|
|
13
13
|
Two non-gating label decorations:
|
|
14
14
|
• a persistent required `*` when the field has no `default:`: the
|
|
15
15
|
"Unendorsed"/must_fill set (DOCUMENT_MODEL: no separate `required` axis). Its
|
|
16
16
|
accessible name is "required", so a screen reader announces the word, not the
|
|
17
|
-
glyph;
|
|
17
|
+
glyph; inside the `<label>`, so it names the control along with the text
|
|
18
18
|
("Subject required") rather than announcing as a node adjacent to it;
|
|
19
19
|
|
|
20
20
|
• the `description` as an info marker raising a themed popover
|
|
21
21
|
({@link FieldHint}).
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
The description describes, it does not name. It is parked in an always-present
|
|
24
24
|
visually-hidden node here and the control carries `aria-describedby`: name then
|
|
25
25
|
role then description, which is the announcement the markup visually implies. In
|
|
26
26
|
the name it would instead prepend ~110 characters of guidance (the reference
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
{/if}
|
|
78
78
|
</label>
|
|
79
79
|
{#if description}
|
|
80
|
-
<!--
|
|
80
|
+
<!-- Outside the `<label>`, both of them: a trigger inside it would put its own
|
|
81
81
|
name in the control's, and the parked copy would put all ~110 characters
|
|
82
82
|
there. A `<button>` in a label is at least safe from the other hazard:
|
|
83
83
|
the spec makes a label's activation behaviour do nothing for a click
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
|
|
91
91
|
<style>
|
|
92
92
|
/* The row, not the label, is the field's grid child: `Field`'s subgrid counts
|
|
93
|
-
|
|
93
|
+
two tracks and the hint sits beside the label rather than under it.
|
|
94
94
|
|
|
95
95
|
A rung wider than the label's own gap below, so the two separations rank: the
|
|
96
96
|
required glyph is part of the name and sits at the tighter one, the guidance
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
align-items: center;
|
|
108
108
|
gap: var(--_qm-space-half);
|
|
109
109
|
font-size: var(--_qm-text-label);
|
|
110
|
-
font-weight: var(--_qm-weight-
|
|
110
|
+
font-weight: var(--_qm-weight-mid);
|
|
111
111
|
line-height: var(--_qm-leading-tight);
|
|
112
112
|
color: var(--_qm-ink-label);
|
|
113
113
|
min-width: 0;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
The formatting selection popover (VISUAL_EDITOR §Chrome).
|
|
3
|
-
shell-owned, observing the
|
|
2
|
+
The formatting selection popover (VISUAL_EDITOR §Chrome). One popover,
|
|
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. A non-empty selection in
|
|
6
6
|
the active leaf raises it over
|
|
@@ -11,20 +11,20 @@
|
|
|
11
11
|
content. The keymap mirror (Mod-b/i/u) already lives in the codec's keymap
|
|
12
12
|
(field.ts); this is the pointer affordance for all six marks.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Selection observation. The view's `dispatchTransaction` is the codec's
|
|
15
15
|
(read-only), so there is no hook to observe transactions from outside.
|
|
16
16
|
Fallback: a document-level `selectionchange` listener, coalesced to one
|
|
17
17
|
`requestAnimationFrame`-deferred check (see `deferredSync` below: a bare
|
|
18
|
-
microtask is
|
|
18
|
+
microtask is not late enough; verified a "select all" gesture fires several
|
|
19
19
|
transient `selectionchange` events first). `focusout` keeps the popover honest
|
|
20
20
|
when focus leaves the leaf.
|
|
21
21
|
|
|
22
|
-
Nothing here watches
|
|
22
|
+
Nothing here watches scroll. `sync` decides whether the surface is up and what
|
|
23
23
|
its buttons read, which a scroll changes neither of; where it sits is the
|
|
24
24
|
anchor's own question, and the anchor answers it by measuring when floating-ui
|
|
25
25
|
asks rather than by being handed a rect (`codec/anchor.ts`).
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Focus discipline. Three failure modes a naive Popover.Content invites:
|
|
28
28
|
(1) its default `trapFocus` (true) redirects any focus landing outside its
|
|
29
29
|
DOM back inside; which would make it impossible to click back into
|
|
30
30
|
the editor to change the selection while the popover is showing. Fixed
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
the viewport off the clicked field. Fixed by `onCloseAutoFocus`
|
|
42
42
|
calling `preventDefault`: the intentional returns after mark toggle
|
|
43
43
|
/ link submit go through `view.focus` in `toggle`/`submitLink`.
|
|
44
|
-
A mark button
|
|
44
|
+
A mark button also swallows its own `mousedown` (prosemirror-menu's own
|
|
45
45
|
trick): without it, the browser's default mousedown action focuses the
|
|
46
46
|
button before `click` fires, blurring the editor and collapsing the
|
|
47
47
|
selection the command is supposed to act on.
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
// off-tree, so this component renders standalone too.
|
|
54
54
|
const t = wording();
|
|
55
55
|
import { toggleMark } from 'prosemirror-commands';
|
|
56
|
-
import { TextSelection } from 'prosemirror-state';
|
|
56
|
+
import { TextSelection, type Command } from 'prosemirror-state';
|
|
57
57
|
import type { EditorView } from 'prosemirror-view';
|
|
58
58
|
import type { MarkType } from 'prosemirror-model';
|
|
59
59
|
import { Popover } from 'bits-ui';
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
type LeafViews,
|
|
66
66
|
type RangeAnchor
|
|
67
67
|
} from '../core/codec/index.js';
|
|
68
|
+
import { clearLink, hrefInSelection, normalizeHref, setLink } from './links.js';
|
|
68
69
|
import './controls.css';
|
|
69
70
|
|
|
70
71
|
type LeafWithView = FieldController & Partial<LeafViews>;
|
|
@@ -77,7 +78,9 @@
|
|
|
77
78
|
|
|
78
79
|
/** Mark size: the shared control-glyph rule for the popover icons. */
|
|
79
80
|
const GLYPH = 15;
|
|
80
|
-
/** The six content formatting marks
|
|
81
|
+
/** The six content formatting marks. `anchor` is a seventh, drawn separately below:
|
|
82
|
+
* a decoration toggle rather than a PM `toggleMark`. One string per mark carries both
|
|
83
|
+
* the tooltip and the accessible name; the anchor button spells the two separately. */
|
|
81
84
|
const MARKS: { name: string; icon: IconName; title: string }[] = $derived([
|
|
82
85
|
{ name: 'strong', icon: 'bold', title: t.strings.formatBold },
|
|
83
86
|
{ name: 'em', icon: 'italic', title: t.strings.formatEmphasis },
|
|
@@ -89,23 +92,24 @@
|
|
|
89
92
|
|
|
90
93
|
let open = $state(false);
|
|
91
94
|
let activeMarks = $state<Record<string, boolean>>({});
|
|
92
|
-
/** Whether the selection is in the
|
|
95
|
+
/** Whether the selection is in the field's coordinate space, which an anchor needs
|
|
93
96
|
* and a table cell is not (see `sync`). */
|
|
94
97
|
let anchorAvailable = $state(true);
|
|
95
98
|
let linkPromptOpen = $state(false);
|
|
96
99
|
let linkValue = $state('');
|
|
100
|
+
let linkInputEl = $state<HTMLInputElement | undefined>(undefined);
|
|
97
101
|
let contentEl = $state<HTMLElement | undefined>(undefined);
|
|
98
|
-
/** The root to portal
|
|
102
|
+
/** The root to portal into: `document.body` escapes the editor's subtree and
|
|
99
103
|
* the consumer's dials with it, so a pane-scoped palette misses this surface.
|
|
100
104
|
* Resolved from the active leaf's own DOM, so the popover lands inside whichever
|
|
101
105
|
* root raised it; `undefined` falls back to bits-ui's `document.body` for a leaf
|
|
102
106
|
* mounted outside any root. */
|
|
103
107
|
let portalTarget = $state<HTMLElement | undefined>(undefined);
|
|
104
108
|
|
|
105
|
-
/** The selection the surface hangs off, live rather than measured (`codec/anchor.ts`): floating-ui re-reads it through every scroll and reflow, so `sync` mints one only when the
|
|
109
|
+
/** The selection the surface hangs off, live rather than measured (`codec/anchor.ts`): floating-ui re-reads it through every scroll and reflow, so `sync` mints one only when the range moves. A new object each time it does, so bits-ui's `watch( => opts.customAnchor.current, …)` sees the change and repositions (a mutated-in-place object would not). */
|
|
106
110
|
let anchor = $state<RangeAnchor | undefined>(undefined);
|
|
107
111
|
|
|
108
|
-
/** The view the caret is actually in: the leaf's own, or the
|
|
112
|
+
/** The view the caret is actually in: the leaf's own, or the nested cell view of a
|
|
109
113
|
* table island (`codec/table-view.ts`). The six marks are the inline schema's
|
|
110
114
|
* too, so a selection in a cell raises the same popover and toggles the same
|
|
111
115
|
* commands; only `anchor` is withheld (below). */
|
|
@@ -114,7 +118,7 @@
|
|
|
114
118
|
return leaf?.focusedView?.() ?? leaf?.view;
|
|
115
119
|
}
|
|
116
120
|
|
|
117
|
-
/** Recompute open/anchor/active-marks from the active leaf's
|
|
121
|
+
/** Recompute open/anchor/active-marks from the active leaf's current PM selection. */
|
|
118
122
|
function sync(): void {
|
|
119
123
|
const insidePopover =
|
|
120
124
|
!!contentEl && !!document.activeElement && contentEl.contains(document.activeElement);
|
|
@@ -124,7 +128,7 @@
|
|
|
124
128
|
if (insidePopover) return;
|
|
125
129
|
const leaf = getActiveLeaf() as LeafWithView | undefined;
|
|
126
130
|
const view = leaf?.focusedView?.() ?? leaf?.view;
|
|
127
|
-
// A non-empty
|
|
131
|
+
// A non-empty text selection. A node selection is non-empty too and has nothing
|
|
128
132
|
// to format: it covers a leaf block (an island, a rule), which is exactly where
|
|
129
133
|
// Escape out of a table cell lands.
|
|
130
134
|
const selection = view?.state.selection;
|
|
@@ -145,7 +149,7 @@
|
|
|
145
149
|
// `anchor` is a decoration, not a PM mark (CODEC §Marks): its active state is
|
|
146
150
|
// whether the selection covers an identity anchor, read off the leaf in USV.
|
|
147
151
|
//
|
|
148
|
-
// A selection inside a table
|
|
152
|
+
// A selection inside a table cell is not in that coordinate space at all: the
|
|
149
153
|
// field's position map holds one `atom` run for the whole island, so there is
|
|
150
154
|
// no USV offset to mint an anchor at. The button is withheld rather than
|
|
151
155
|
// disabled, because what it would toggle does not exist there (CODEC §Islands).
|
|
@@ -159,8 +163,8 @@
|
|
|
159
163
|
}
|
|
160
164
|
|
|
161
165
|
// A burst of DOM events (verified: browser "select all" processing fires
|
|
162
|
-
// `selectionchange` several times, transiently
|
|
163
|
-
// final range) must coalesce to
|
|
166
|
+
// `selectionchange` several times, transiently empty before landing on the
|
|
167
|
+
// final range) must coalesce to one settled check, not one `sync()` per
|
|
164
168
|
// event: reacting to each intermediate state mounts/unmounts
|
|
165
169
|
// `Popover.Content` repeatedly within a few event-loop turns, which is both
|
|
166
170
|
// visibly unstable (a moving, momentarily detached click target) and races
|
|
@@ -169,7 +173,7 @@
|
|
|
169
173
|
// microtask is not late enough: those transient events are themselves
|
|
170
174
|
// separated by microtasks. `requestAnimationFrame` is: it runs once
|
|
171
175
|
// per-frame, after the browser has finished dispatching every synchronous
|
|
172
|
-
// consequence of the user gesture, so it always reads the
|
|
176
|
+
// consequence of the user gesture, so it always reads the final, settled
|
|
173
177
|
// selection. `pending` collapses a burst to a single scheduled callback.
|
|
174
178
|
let pending = false;
|
|
175
179
|
let rafHandle = 0;
|
|
@@ -202,19 +206,36 @@
|
|
|
202
206
|
if (!open) linkPromptOpen = false;
|
|
203
207
|
});
|
|
204
208
|
|
|
209
|
+
// The prompt takes its own focus. Nothing else moves it: open-auto-focus is
|
|
210
|
+
// prevented (focus discipline above), and the prompt replaces the buttons on a
|
|
211
|
+
// surface already open, so without this the URL is typed into the document.
|
|
212
|
+
// Selected rather than merely focused, the input arriving seeded and replacing
|
|
213
|
+
// that value being what the prompt is usually raised for.
|
|
214
|
+
$effect(() => {
|
|
215
|
+
if (linkPromptOpen && linkInputEl) {
|
|
216
|
+
linkInputEl.focus();
|
|
217
|
+
linkInputEl.select();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
205
221
|
/** Swallow the button's mousedown so focus/selection never leave the leaf. */
|
|
206
222
|
function keepFocus(e: MouseEvent): void {
|
|
207
223
|
e.preventDefault();
|
|
208
224
|
}
|
|
209
225
|
|
|
226
|
+
// A link is a value, so its button raises the prompt rather than toggling — one
|
|
227
|
+
// meaning whether or not the selection carries one, and the seeded prompt is the
|
|
228
|
+
// only place the document's href is legible. Removal is an arm inside the
|
|
229
|
+
// prompt, not a second meaning for the button. The five value-less marks keep
|
|
230
|
+
// the toggle.
|
|
210
231
|
function toggle(name: string): void {
|
|
211
232
|
const view = activeLeafView();
|
|
212
233
|
if (!view) return;
|
|
213
234
|
const type: MarkType | undefined = view.state.schema.marks[name];
|
|
214
235
|
if (!type) return;
|
|
215
|
-
if (name === 'link'
|
|
236
|
+
if (name === 'link') {
|
|
237
|
+
linkValue = hrefInSelection(view.state);
|
|
216
238
|
linkPromptOpen = true;
|
|
217
|
-
linkValue = '';
|
|
218
239
|
return;
|
|
219
240
|
}
|
|
220
241
|
toggleMark(type)(view.state, view.dispatch);
|
|
@@ -242,21 +263,31 @@
|
|
|
242
263
|
sync();
|
|
243
264
|
}
|
|
244
265
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
266
|
+
/** Every exit from the prompt: lower it, run `cmd` if there is one, and hand the
|
|
267
|
+
* leaf back its focus and the surface its buttons. */
|
|
268
|
+
function closePrompt(cmd?: Command): void {
|
|
248
269
|
linkPromptOpen = false;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
270
|
+
const view = activeLeafView();
|
|
271
|
+
if (view) {
|
|
272
|
+
cmd?.(view.state, view.dispatch);
|
|
273
|
+
view.focus();
|
|
274
|
+
}
|
|
253
275
|
sync();
|
|
254
276
|
}
|
|
255
277
|
|
|
278
|
+
/** Apply the typed value, normalized (`links.ts`). A blank value is nothing to
|
|
279
|
+
* apply and only closes the prompt: removal has its own arm, and a second door
|
|
280
|
+
* onto it would be a second thing to keep true. */
|
|
281
|
+
function submitLink(): void {
|
|
282
|
+
closePrompt(setLink(normalizeHref(linkValue)));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function removeLink(): void {
|
|
286
|
+
closePrompt(clearLink);
|
|
287
|
+
}
|
|
288
|
+
|
|
256
289
|
function cancelLink(): void {
|
|
257
|
-
|
|
258
|
-
activeLeafView()?.focus();
|
|
259
|
-
sync();
|
|
290
|
+
closePrompt();
|
|
260
291
|
}
|
|
261
292
|
</script>
|
|
262
293
|
|
|
@@ -271,7 +302,7 @@
|
|
|
271
302
|
onOpenAutoFocus={(e: Event) => e.preventDefault()}
|
|
272
303
|
onCloseAutoFocus={(e: Event) => e.preventDefault()}
|
|
273
304
|
>
|
|
274
|
-
<!-- The `child` snippet, so the surface
|
|
305
|
+
<!-- The `child` snippet, so the surface is the primitive's content node
|
|
275
306
|
rather than a pill inside it: the dismissal is a CSS animation and the
|
|
276
307
|
primitive unmounts on the content node's own animations finishing. It
|
|
277
308
|
also keeps the recipe reachable from this
|
|
@@ -299,12 +330,24 @@
|
|
|
299
330
|
}}
|
|
300
331
|
>
|
|
301
332
|
<input
|
|
333
|
+
bind:this={linkInputEl}
|
|
302
334
|
class="qm-link-input"
|
|
303
335
|
type="text"
|
|
304
336
|
placeholder={t.strings.linkPlaceholder}
|
|
337
|
+
aria-label={t.strings.formatLink}
|
|
305
338
|
bind:value={linkValue}
|
|
306
339
|
/>
|
|
307
340
|
<button type="submit" class="qm-icon-btn qm-mark-btn">{t.strings.linkApply}</button>
|
|
341
|
+
<!-- Only where there is a link to remove: an arm that does nothing is
|
|
342
|
+
an arm to read past on every other visit to the prompt. -->
|
|
343
|
+
{#if activeMarks.link}
|
|
344
|
+
<button
|
|
345
|
+
type="button"
|
|
346
|
+
class="qm-icon-btn qm-mark-btn"
|
|
347
|
+
onmousedown={keepFocus}
|
|
348
|
+
onclick={removeLink}>{t.strings.linkRemove}</button
|
|
349
|
+
>
|
|
350
|
+
{/if}
|
|
308
351
|
<button
|
|
309
352
|
type="button"
|
|
310
353
|
class="qm-icon-btn qm-mark-btn"
|
|
@@ -349,7 +392,7 @@
|
|
|
349
392
|
</Popover.Root>
|
|
350
393
|
|
|
351
394
|
<style>
|
|
352
|
-
/* This surface
|
|
395
|
+
/* This surface portals out of the leaf's DOM but into the nearest `[data-qm-root]`
|
|
353
396
|
(the target is resolved in `sync`), so it inherits the consumer's dials like
|
|
354
397
|
every other surface. It carries the marker itself too: floating over content is
|
|
355
398
|
still a detached subtree for the derivation's purposes, and the marker is what
|
|
@@ -386,13 +429,15 @@
|
|
|
386
429
|
align-items: center;
|
|
387
430
|
}
|
|
388
431
|
/* The box's recipe without `.qm-input`, which adds the `width: 100%` that would
|
|
389
|
-
stretch this to the popover.
|
|
432
|
+
stretch this to the popover. The fill is the box's own rung and there is no
|
|
433
|
+
edge, as everywhere else a value is typed (controls.css). */
|
|
390
434
|
.qm-link-input {
|
|
391
435
|
font: inherit;
|
|
392
436
|
font-size: var(--_qm-text-body);
|
|
393
437
|
padding: var(--_qm-inset-control);
|
|
394
|
-
border:
|
|
438
|
+
border: none;
|
|
395
439
|
border-radius: var(--_qm-radius-inner);
|
|
440
|
+
background: var(--_qm-surface-well);
|
|
396
441
|
min-width: 12rem;
|
|
397
442
|
}
|
|
398
443
|
</style>
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
A `number` / `integer` field → numeric input (fixture `font_size` = 11.5).
|
|
3
|
-
Commits at `change` (blur/Enter),
|
|
3
|
+
Commits at `change` (blur/Enter), not per keystroke: a partial numeric entry
|
|
4
4
|
(`-`, `1.`, `1e`) 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. A blank entry commits `undefined`: the
|
|
7
|
+
region. A blank entry commits `undefined`: the unset rung of the
|
|
8
8
|
commitment ladder: the parent removes the field and the engine renders the
|
|
9
9
|
ghosted `default:`. Settling at `change` also keeps
|
|
10
10
|
select-all-and-retype from flashing the preview through the default mid-keystroke.
|
|
11
11
|
|
|
12
|
-
`type="text"`, not `type="number"`: a native number input
|
|
12
|
+
`type="text"`, not `type="number"`: a native number input sanitizes an
|
|
13
13
|
invalid string to `""` before the DOM `value` setter even runs (verified:
|
|
14
14
|
`.value = "abc"` on `type="number"` never lands), which would make a
|
|
15
15
|
genuinely bad entry untypeable. The commit-time coercion diagnostic
|
|
16
16
|
(VISUAL_EDITOR §Diagnostics) needs exactly that path reachable through the
|
|
17
|
-
UI, so a non-blank entry that fails to parse forwards the
|
|
17
|
+
UI, so a non-blank entry that fails to parse forwards the raw string to
|
|
18
18
|
`onCommit` unchanged: the boundary's own `writer.set` coercion is the judge
|
|
19
19
|
(throws a `QuillmarkError` the parent turns into a field diagnostic), not a
|
|
20
20
|
client-side guess. `inputmode` keeps the numeric mobile keyboard.
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
value: number | undefined;
|
|
28
28
|
integer?: boolean;
|
|
29
29
|
fallback?: number;
|
|
30
|
-
/** Accessible name for an input
|
|
30
|
+
/** Accessible name for an input nothing else names: an object property, whose
|
|
31
31
|
* name is the field label plus the property's. A field's own input takes `id`
|
|
32
32
|
* instead and is named by the `<label for>` beside it. */
|
|
33
33
|
label?: string;
|
|
@@ -3,7 +3,7 @@ interface Props {
|
|
|
3
3
|
value: number | undefined;
|
|
4
4
|
integer?: boolean;
|
|
5
5
|
fallback?: number;
|
|
6
|
-
/** Accessible name for an input
|
|
6
|
+
/** Accessible name for an input nothing else names: an object property, whose
|
|
7
7
|
* name is the field label plus the property's. A field's own input takes `id`
|
|
8
8
|
* instead and is named by the `<label for>` beside it. */
|
|
9
9
|
label?: string;
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
An `object` field → a nested subform over `properties` (declaration order),
|
|
3
|
-
committing the
|
|
3
|
+
committing the whole object by value on any nested change. Scalar properties
|
|
4
4
|
only: a nested prose/array/object property renders a placeholder instead of
|
|
5
5
|
recursing.
|
|
6
6
|
|
|
7
7
|
A property's ghosted `default:` is the static schema `sub.default`, not the
|
|
8
8
|
resolved provenance the top-level ghosts read (FIELD_PROVENANCE): `resolve`
|
|
9
9
|
carries no per-property row (an object field resolves as one row whose value is
|
|
10
|
-
the whole object).
|
|
11
|
-
per-property provenance.
|
|
10
|
+
the whole object).
|
|
12
11
|
-->
|
|
13
12
|
<script lang="ts">
|
|
14
13
|
import type { QuillFieldSchema } from '@quillmark/wasm';
|
|
@@ -24,7 +23,7 @@
|
|
|
24
23
|
properties: Record<string, QuillFieldSchema> | undefined;
|
|
25
24
|
/** Accessible-name prefix for the property controls. */
|
|
26
25
|
label?: string;
|
|
27
|
-
/** The field label's own id. A subform is a
|
|
26
|
+
/** The field label's own id. A subform is a group of controls, not one control
|
|
28
27
|
* `for` could reach, so the field's label names the set and each property
|
|
29
28
|
* control keeps its own composed `aria-label`. */
|
|
30
29
|
labelledBy?: string;
|
|
@@ -37,7 +36,7 @@
|
|
|
37
36
|
const entries = $derived(Object.entries(properties ?? {}));
|
|
38
37
|
const obj = $derived((value ?? {}) as Record<string, unknown>);
|
|
39
38
|
|
|
40
|
-
/** Take the caret: the
|
|
39
|
+
/** Take the caret: the first property's control, a subform having no single control
|
|
41
40
|
* of its own to land on. Resolved off the DOM rather than a ref per property: every
|
|
42
41
|
* property is a scalar control the DOM already knows how to focus (the date field's
|
|
43
42
|
* segments carry `tabindex`, the `literal` separators between them do not), so a
|
|
@@ -53,7 +52,7 @@
|
|
|
53
52
|
function commitProp(key: string, v: unknown): void {
|
|
54
53
|
if (v === undefined) {
|
|
55
54
|
// A nested control cleared (the unset rung): drop the key so the property
|
|
56
|
-
// is
|
|
55
|
+
// is absent in the committed object (resolving to its own `default:`)
|
|
57
56
|
// rather than an `undefined` hole carried through `writer.set`.
|
|
58
57
|
const rest = { ...obj };
|
|
59
58
|
delete rest[key];
|
|
@@ -134,9 +133,10 @@
|
|
|
134
133
|
}
|
|
135
134
|
.qm-object-label {
|
|
136
135
|
font-size: var(--_qm-text-label);
|
|
137
|
-
/*
|
|
138
|
-
|
|
139
|
-
|
|
136
|
+
/* The chrome weight, the same one the field label above it takes: what says
|
|
137
|
+
this name is nested is the subform's rule and its inset, so a weight step
|
|
138
|
+
here would state it twice and rank two names the ramp does not separate. */
|
|
139
|
+
font-weight: var(--_qm-weight-mid);
|
|
140
140
|
/* A label is a line, not a passage, so it overrides the root's reading
|
|
141
141
|
rhythm the way every other label rung does. */
|
|
142
142
|
line-height: var(--_qm-leading-tight);
|
|
@@ -4,7 +4,7 @@ interface Props {
|
|
|
4
4
|
properties: Record<string, QuillFieldSchema> | undefined;
|
|
5
5
|
/** Accessible-name prefix for the property controls. */
|
|
6
6
|
label?: string;
|
|
7
|
-
/** The field label's own id. A subform is a
|
|
7
|
+
/** The field label's own id. A subform is a group of controls, not one control
|
|
8
8
|
* `for` could reach, so the field's label names the set and each property
|
|
9
9
|
* control keeps its own composed `aria-label`. */
|
|
10
10
|
labelledBy?: string;
|