@quillmark/svelte 0.0.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/LICENSE +201 -0
- package/NOTICE +58 -0
- package/README.md +241 -0
- package/THEMING.md +153 -0
- package/dist/core/address.d.ts +56 -0
- package/dist/core/address.js +95 -0
- package/dist/core/bloom.d.ts +37 -0
- package/dist/core/bloom.js +116 -0
- package/dist/core/codec/anchor.d.ts +19 -0
- package/dist/core/codec/anchor.js +36 -0
- package/dist/core/codec/code.d.ts +12 -0
- package/dist/core/codec/code.js +118 -0
- package/dist/core/codec/decode.d.ts +24 -0
- package/dist/core/codec/decode.js +270 -0
- package/dist/core/codec/encode.d.ts +71 -0
- package/dist/core/codec/encode.js +480 -0
- package/dist/core/codec/field.d.ts +171 -0
- package/dist/core/codec/field.js +493 -0
- package/dist/core/codec/index.d.ts +22 -0
- package/dist/core/codec/index.js +43 -0
- package/dist/core/codec/inputrules.d.ts +7 -0
- package/dist/core/codec/inputrules.js +168 -0
- package/dist/core/codec/islands.d.ts +36 -0
- package/dist/core/codec/islands.js +82 -0
- package/dist/core/codec/keymap.d.ts +13 -0
- package/dist/core/codec/keymap.js +29 -0
- package/dist/core/codec/lists.d.ts +22 -0
- package/dist/core/codec/lists.js +104 -0
- package/dist/core/codec/marks.d.ts +37 -0
- package/dist/core/codec/marks.js +74 -0
- package/dist/core/codec/positions.d.ts +17 -0
- package/dist/core/codec/positions.js +108 -0
- package/dist/core/codec/prose.css +458 -0
- package/dist/core/codec/reconcile.d.ts +26 -0
- package/dist/core/codec/reconcile.js +64 -0
- package/dist/core/codec/schema.d.ts +13 -0
- package/dist/core/codec/schema.js +155 -0
- package/dist/core/codec/slash.d.ts +79 -0
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-view.d.ts +50 -0
- package/dist/core/codec/table-view.js +928 -0
- package/dist/core/codec/table.d.ts +85 -0
- package/dist/core/codec/table.js +224 -0
- package/dist/core/errors.d.ts +73 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +19 -0
- package/dist/core/lifecycle.d.ts +7 -0
- package/dist/core/lifecycle.js +21 -0
- package/dist/core/rebind.svelte.d.ts +18 -0
- package/dist/core/rebind.svelte.js +44 -0
- package/dist/core/teardown.d.ts +20 -0
- package/dist/core/teardown.js +37 -0
- package/dist/core/theme.css +276 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +9 -0
- package/dist/preset/index.css +10 -0
- package/dist/preset/recipes.css +188 -0
- package/dist/preset/scale.css +97 -0
- package/dist/preview/Preview.svelte +118 -0
- package/dist/preview/Preview.svelte.d.ts +39 -0
- package/dist/preview/bridge.d.ts +13 -0
- package/dist/preview/bridge.js +88 -0
- package/dist/preview/controller.d.ts +40 -0
- package/dist/preview/controller.js +141 -0
- package/dist/preview/geometry.d.ts +37 -0
- package/dist/preview/geometry.js +45 -0
- package/dist/preview/index.d.ts +5 -0
- package/dist/preview/index.js +14 -0
- package/dist/preview/overlay.d.ts +10 -0
- package/dist/preview/overlay.js +82 -0
- package/dist/preview/paint.d.ts +17 -0
- package/dist/preview/paint.js +262 -0
- package/dist/preview/strings.d.ts +14 -0
- package/dist/preview/strings.js +22 -0
- package/dist/visual/ArrayField.svelte +362 -0
- package/dist/visual/ArrayField.svelte.d.ts +24 -0
- package/dist/visual/BooleanField.svelte +88 -0
- package/dist/visual/BooleanField.svelte.d.ts +20 -0
- package/dist/visual/Card.svelte +849 -0
- package/dist/visual/Card.svelte.d.ts +43 -0
- package/dist/visual/CardControls.svelte +92 -0
- package/dist/visual/CardControls.svelte.d.ts +11 -0
- package/dist/visual/DateField.svelte +203 -0
- package/dist/visual/DateField.svelte.d.ts +28 -0
- package/dist/visual/DiagnosticList.svelte +50 -0
- package/dist/visual/DiagnosticList.svelte.d.ts +7 -0
- package/dist/visual/EnumField.svelte +169 -0
- package/dist/visual/EnumField.svelte.d.ts +25 -0
- package/dist/visual/Field.svelte +270 -0
- package/dist/visual/Field.svelte.d.ts +37 -0
- package/dist/visual/FieldHint.svelte +185 -0
- package/dist/visual/FieldHint.svelte.d.ts +13 -0
- package/dist/visual/FieldLabel.svelte +141 -0
- package/dist/visual/FieldLabel.svelte.d.ts +19 -0
- package/dist/visual/FormatPopover.svelte +396 -0
- package/dist/visual/FormatPopover.svelte.d.ts +9 -0
- package/dist/visual/NumberField.svelte +72 -0
- package/dist/visual/NumberField.svelte.d.ts +19 -0
- package/dist/visual/ObjectField.svelte +131 -0
- package/dist/visual/ObjectField.svelte.d.ts +17 -0
- package/dist/visual/ProseArrayElement.svelte +98 -0
- package/dist/visual/ProseArrayElement.svelte.d.ts +19 -0
- package/dist/visual/ProseField.svelte +213 -0
- package/dist/visual/ProseField.svelte.d.ts +50 -0
- package/dist/visual/SlashMenu.svelte +126 -0
- package/dist/visual/SlashMenu.svelte.d.ts +14 -0
- package/dist/visual/TextField.svelte +63 -0
- package/dist/visual/TextField.svelte.d.ts +23 -0
- package/dist/visual/TipsCard.svelte +191 -0
- package/dist/visual/TipsCard.svelte.d.ts +13 -0
- package/dist/visual/VisualEditor.svelte +96 -0
- package/dist/visual/VisualEditor.svelte.d.ts +14 -0
- package/dist/visual/VisualEditorInner.svelte +1032 -0
- package/dist/visual/VisualEditorInner.svelte.d.ts +17 -0
- package/dist/visual/controls.css +272 -0
- package/dist/visual/diagnostics.d.ts +52 -0
- package/dist/visual/diagnostics.js +83 -0
- package/dist/visual/domid.d.ts +21 -0
- package/dist/visual/domid.js +36 -0
- package/dist/visual/ext.d.ts +19 -0
- package/dist/visual/ext.js +29 -0
- package/dist/visual/icons/Icon.svelte +39 -0
- package/dist/visual/icons/Icon.svelte.d.ts +12 -0
- package/dist/visual/icons/nodes.d.ts +103 -0
- package/dist/visual/icons/nodes.js +55 -0
- package/dist/visual/index.d.ts +9 -0
- package/dist/visual/index.js +24 -0
- package/dist/visual/motion.d.ts +18 -0
- package/dist/visual/motion.js +53 -0
- package/dist/visual/props.d.ts +77 -0
- package/dist/visual/props.js +1 -0
- package/dist/visual/signals.d.ts +59 -0
- package/dist/visual/signals.js +1 -0
- package/dist/visual/strings.d.ts +123 -0
- package/dist/visual/strings.js +80 -0
- package/dist/visual/structure.d.ts +224 -0
- package/dist/visual/structure.js +270 -0
- package/dist/visual/synced.svelte.d.ts +15 -0
- package/dist/visual/synced.svelte.js +34 -0
- package/dist/visual/tips.d.ts +24 -0
- package/dist/visual/tips.js +50 -0
- package/package.json +102 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The formatting selection popover (VISUAL_EDITOR §Chrome). ONE popover,
|
|
3
|
+
shell-owned, observing the ACTIVE leaf through
|
|
4
|
+
`getActiveLeaf`: the VisualEditor's accessor over its `leaves` registry, and
|
|
5
|
+
the whole of what this surface knows about the editor. A non-empty selection in
|
|
6
|
+
the active leaf raises it over
|
|
7
|
+
that leaf's selection rect; each button dispatches a PM `toggleMark` command
|
|
8
|
+
straight at the leaf's `EditorView`. The codec's own `dispatchTransaction`
|
|
9
|
+
(field.ts, read-only from here) lowers the resulting transaction to
|
|
10
|
+
`markOps` and commits via `applyChange`: this component never touches the
|
|
11
|
+
content. The keymap mirror (Mod-b/i/u) already lives in the codec's keymap
|
|
12
|
+
(field.ts); this is the pointer affordance for all six marks.
|
|
13
|
+
|
|
14
|
+
SELECTION OBSERVATION. The view's `dispatchTransaction` is the codec's
|
|
15
|
+
(read-only), so there is no hook to observe transactions from outside.
|
|
16
|
+
Fallback: a document-level `selectionchange` listener, coalesced to one
|
|
17
|
+
`requestAnimationFrame`-deferred check (see `deferredSync` below: a bare
|
|
18
|
+
microtask is NOT late enough; verified a "select all" gesture fires several
|
|
19
|
+
transient `selectionchange` events first). `focusout` keeps the popover honest
|
|
20
|
+
when focus leaves the leaf.
|
|
21
|
+
|
|
22
|
+
Nothing here watches SCROLL. `sync` decides whether the surface is up and what
|
|
23
|
+
its buttons read, which a scroll changes neither of; where it sits is the
|
|
24
|
+
anchor's own question, and the anchor answers it by measuring when floating-ui
|
|
25
|
+
asks rather than by being handed a rect (`codec/anchor.ts`).
|
|
26
|
+
|
|
27
|
+
FOCUS DISCIPLINE. Three failure modes a naive Popover.Content invites:
|
|
28
|
+
(1) its default `trapFocus` (true) redirects any focus landing outside its
|
|
29
|
+
DOM back inside; which would make it impossible to click back into
|
|
30
|
+
the editor to change the selection while the popover is showing. Fixed
|
|
31
|
+
by `trapFocus={false}`.
|
|
32
|
+
(2) its open-auto-focus (unconditional on mount, regardless of trapFocus;
|
|
33
|
+
verified against bits-ui's FocusScope) would steal focus from the
|
|
34
|
+
editor onto the first button the instant the popover appears. Fixed by
|
|
35
|
+
`onOpenAutoFocus` calling `preventDefault`.
|
|
36
|
+
(3) its default `onCloseAutoFocus` returns focus to the element focused
|
|
37
|
+
before open (the prose leaf holding the selection). Clicking a native
|
|
38
|
+
input (card-title, TextField, NumberField) while the popover is open
|
|
39
|
+
bounces focus off that input back to the leaf; ProseMirror re-asserts
|
|
40
|
+
the retained selection and the browser scrolls it into view, yanking
|
|
41
|
+
the viewport off the clicked field. Fixed by `onCloseAutoFocus`
|
|
42
|
+
calling `preventDefault`: the intentional returns after mark toggle
|
|
43
|
+
/ link submit go through `view.focus` in `toggle`/`submitLink`.
|
|
44
|
+
A mark button ALSO swallows its own `mousedown` (prosemirror-menu's own
|
|
45
|
+
trick): without it, the browser's default mousedown action focuses the
|
|
46
|
+
button before `click` fires, blurring the editor and collapsing the
|
|
47
|
+
selection the command is supposed to act on.
|
|
48
|
+
-->
|
|
49
|
+
<script lang="ts">
|
|
50
|
+
import { wording } from './strings.js';
|
|
51
|
+
|
|
52
|
+
// The surface's words, ambient from the editor root; the package's English
|
|
53
|
+
// off-tree, so this component renders standalone too.
|
|
54
|
+
const t = wording();
|
|
55
|
+
import { toggleMark } from 'prosemirror-commands';
|
|
56
|
+
import { TextSelection } from 'prosemirror-state';
|
|
57
|
+
import type { EditorView } from 'prosemirror-view';
|
|
58
|
+
import type { MarkType } from 'prosemirror-model';
|
|
59
|
+
import { Popover } from 'bits-ui';
|
|
60
|
+
import Icon from './icons/Icon.svelte';
|
|
61
|
+
import type { IconName } from './icons/nodes.js';
|
|
62
|
+
import {
|
|
63
|
+
rangeAnchor,
|
|
64
|
+
type FieldController,
|
|
65
|
+
type LeafViews,
|
|
66
|
+
type RangeAnchor
|
|
67
|
+
} from '../core/codec/index.js';
|
|
68
|
+
import './controls.css';
|
|
69
|
+
|
|
70
|
+
type LeafWithView = FieldController & Partial<LeafViews>;
|
|
71
|
+
|
|
72
|
+
interface Props {
|
|
73
|
+
/** The observation seam VisualEditor exposes over its `leaves` registry. */
|
|
74
|
+
getActiveLeaf: () => FieldController | undefined;
|
|
75
|
+
}
|
|
76
|
+
let { getActiveLeaf }: Props = $props();
|
|
77
|
+
|
|
78
|
+
/** Mark size: the shared control-glyph rule for the popover icons. */
|
|
79
|
+
const GLYPH = 15;
|
|
80
|
+
/** The six content formatting marks; `anchor` is a 7th, rendered separately (a decoration toggle, not a PM `toggleMark` (see the button below). Each carries a glyph) the icon *is* the label. */
|
|
81
|
+
const MARKS: { name: string; icon: IconName; title: string }[] = $derived([
|
|
82
|
+
{ name: 'strong', icon: 'bold', title: t.strings.formatBold },
|
|
83
|
+
{ name: 'em', icon: 'italic', title: t.strings.formatEmphasis },
|
|
84
|
+
{ name: 'underline', icon: 'underline', title: t.strings.formatUnderline },
|
|
85
|
+
{ name: 'strike', icon: 'strikethrough', title: t.strings.formatStrikethrough },
|
|
86
|
+
{ name: 'code', icon: 'code', title: t.strings.formatCode },
|
|
87
|
+
{ name: 'link', icon: 'link', title: t.strings.formatLink }
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
let open = $state(false);
|
|
91
|
+
let activeMarks = $state<Record<string, boolean>>({});
|
|
92
|
+
/** Whether the selection is in the FIELD's coordinate space, which an anchor needs
|
|
93
|
+
* and a table cell is not (see `sync`). */
|
|
94
|
+
let anchorAvailable = $state(true);
|
|
95
|
+
let linkPromptOpen = $state(false);
|
|
96
|
+
let linkValue = $state('');
|
|
97
|
+
let contentEl = $state<HTMLElement | undefined>(undefined);
|
|
98
|
+
/** The root to portal INTO: `document.body` escapes the editor's subtree and
|
|
99
|
+
* the consumer's dials with it, so a pane-scoped palette misses this surface.
|
|
100
|
+
* Resolved from the active leaf's own DOM, so the popover lands inside whichever
|
|
101
|
+
* root raised it; `undefined` falls back to bits-ui's `document.body` for a leaf
|
|
102
|
+
* mounted outside any root. */
|
|
103
|
+
let portalTarget = $state<HTMLElement | undefined>(undefined);
|
|
104
|
+
|
|
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 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
|
+
let anchor = $state<RangeAnchor | undefined>(undefined);
|
|
107
|
+
|
|
108
|
+
/** The view the caret is actually in: the leaf's own, or the NESTED cell view of a
|
|
109
|
+
* table island (`codec/table-view.ts`). The six marks are the inline schema's
|
|
110
|
+
* too, so a selection in a cell raises the same popover and toggles the same
|
|
111
|
+
* commands; only `anchor` is withheld (below). */
|
|
112
|
+
function activeLeafView(): EditorView | undefined {
|
|
113
|
+
const leaf = getActiveLeaf() as LeafWithView | undefined;
|
|
114
|
+
return leaf?.focusedView?.() ?? leaf?.view;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Recompute open/anchor/active-marks from the active leaf's CURRENT PM selection. */
|
|
118
|
+
function sync(): void {
|
|
119
|
+
const insidePopover =
|
|
120
|
+
!!contentEl && !!document.activeElement && contentEl.contains(document.activeElement);
|
|
121
|
+
// Interacting with the popover itself (e.g. the link input): leave state as-is.
|
|
122
|
+
// Costs nothing to hold: the anchor minted below goes on measuring while this
|
|
123
|
+
// returns early, so a surface frozen here still tracks the selection it covers.
|
|
124
|
+
if (insidePopover) return;
|
|
125
|
+
const leaf = getActiveLeaf() as LeafWithView | undefined;
|
|
126
|
+
const view = leaf?.focusedView?.() ?? leaf?.view;
|
|
127
|
+
// A non-empty TEXT selection. A NODE selection is non-empty too and has nothing
|
|
128
|
+
// to format: it covers a leaf block (an island, a rule), which is exactly where
|
|
129
|
+
// Escape out of a table cell lands.
|
|
130
|
+
const selection = view?.state.selection;
|
|
131
|
+
const formattable = selection instanceof TextSelection && !selection.empty;
|
|
132
|
+
if (!view || !view.hasFocus() || !formattable) {
|
|
133
|
+
open = false;
|
|
134
|
+
linkPromptOpen = false;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
portalTarget = view.dom.closest<HTMLElement>('[data-qm-root]') ?? undefined;
|
|
138
|
+
const { from, to } = view.state.selection;
|
|
139
|
+
anchor = rangeAnchor(view, from, to);
|
|
140
|
+
const marks: Record<string, boolean> = {};
|
|
141
|
+
for (const m of MARKS) {
|
|
142
|
+
const type = view.state.schema.marks[m.name];
|
|
143
|
+
marks[m.name] = !!type && view.state.doc.rangeHasMark(from, to, type);
|
|
144
|
+
}
|
|
145
|
+
// `anchor` is a decoration, not a PM mark (CODEC §Marks): its active state is
|
|
146
|
+
// whether the selection covers an identity anchor, read off the leaf in USV.
|
|
147
|
+
//
|
|
148
|
+
// A selection inside a table CELL is not in that coordinate space at all: the
|
|
149
|
+
// field's position map holds one `atom` run for the whole island, so there is
|
|
150
|
+
// no USV offset to mint an anchor at. The button is withheld rather than
|
|
151
|
+
// disabled, because what it would toggle does not exist there (CODEC §Islands).
|
|
152
|
+
anchorAvailable = !!leaf && view === leaf.view;
|
|
153
|
+
if (leaf && anchorAvailable) {
|
|
154
|
+
const sel = leaf.selectionRange();
|
|
155
|
+
marks.anchor = leaf.anchorsInRange(sel.from, sel.to).length > 0;
|
|
156
|
+
}
|
|
157
|
+
activeMarks = marks;
|
|
158
|
+
open = true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// A burst of DOM events (verified: browser "select all" processing fires
|
|
162
|
+
// `selectionchange` several times, transiently EMPTY before landing on the
|
|
163
|
+
// final range) must coalesce to ONE settled check, not one `sync()` per
|
|
164
|
+
// event: reacting to each intermediate state mounts/unmounts
|
|
165
|
+
// `Popover.Content` repeatedly within a few event-loop turns, which is both
|
|
166
|
+
// visibly unstable (a moving, momentarily detached click target) and races
|
|
167
|
+
// bits-ui's own internal effects (observed: Svelte's
|
|
168
|
+
// `derived_inert` warning from a stale read after a too-fast unmount). A
|
|
169
|
+
// microtask is not late enough: those transient events are themselves
|
|
170
|
+
// separated by microtasks. `requestAnimationFrame` is: it runs once
|
|
171
|
+
// per-frame, after the browser has finished dispatching every synchronous
|
|
172
|
+
// consequence of the user gesture, so it always reads the FINAL, settled
|
|
173
|
+
// selection. `pending` collapses a burst to a single scheduled callback.
|
|
174
|
+
let pending = false;
|
|
175
|
+
let rafHandle = 0;
|
|
176
|
+
function deferredSync(): void {
|
|
177
|
+
if (pending) return;
|
|
178
|
+
pending = true;
|
|
179
|
+
rafHandle = requestAnimationFrame(() => {
|
|
180
|
+
pending = false;
|
|
181
|
+
sync();
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
$effect(() => {
|
|
186
|
+
document.addEventListener('selectionchange', deferredSync);
|
|
187
|
+
document.addEventListener('focusout', deferredSync);
|
|
188
|
+
return () => {
|
|
189
|
+
document.removeEventListener('selectionchange', deferredSync);
|
|
190
|
+
document.removeEventListener('focusout', deferredSync);
|
|
191
|
+
// A burst-coalescing rAF may still be in flight at teardown; cancel it so
|
|
192
|
+
// `sync()` never runs against the destroyed component.
|
|
193
|
+
if (pending) cancelAnimationFrame(rafHandle);
|
|
194
|
+
pending = false;
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Any close path that bypasses `sync` (e.g. Escape while the link input holds
|
|
199
|
+
// focus: bits-ui flips `open` itself) must not leave a stale prompt for the
|
|
200
|
+
// next open.
|
|
201
|
+
$effect(() => {
|
|
202
|
+
if (!open) linkPromptOpen = false;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/** Swallow the button's mousedown so focus/selection never leave the leaf. */
|
|
206
|
+
function keepFocus(e: MouseEvent): void {
|
|
207
|
+
e.preventDefault();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function toggle(name: string): void {
|
|
211
|
+
const view = activeLeafView();
|
|
212
|
+
if (!view) return;
|
|
213
|
+
const type: MarkType | undefined = view.state.schema.marks[name];
|
|
214
|
+
if (!type) return;
|
|
215
|
+
if (name === 'link' && !activeMarks.link) {
|
|
216
|
+
linkPromptOpen = true;
|
|
217
|
+
linkValue = '';
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
toggleMark(type)(view.state, view.dispatch);
|
|
221
|
+
view.focus();
|
|
222
|
+
sync();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Toggle an identity anchor over the selection: the `anchor`
|
|
227
|
+
* button's answer to a formatting toggle. If the selection already covers
|
|
228
|
+
* anchors, remove them; else insert one at its start with a freshly-minted
|
|
229
|
+
* unique id (the caller-supplied, invariant id the 0.97 policy settles).
|
|
230
|
+
* Zero-width and glyph-less (CODEC §Marks): the identity handle persists in
|
|
231
|
+
* the content, its chrome awaiting comment-thread UX.
|
|
232
|
+
*/
|
|
233
|
+
function toggleAnchor(): void {
|
|
234
|
+
const leaf = getActiveLeaf();
|
|
235
|
+
const view = activeLeafView();
|
|
236
|
+
if (!leaf || !view || view.state.selection.empty) return;
|
|
237
|
+
const { from, to } = leaf.selectionRange();
|
|
238
|
+
const covered = leaf.anchorsInRange(from, to);
|
|
239
|
+
if (covered.length) covered.forEach((id) => leaf.removeAnchor(id));
|
|
240
|
+
else leaf.insertAnchor(crypto.randomUUID(), from);
|
|
241
|
+
view.focus();
|
|
242
|
+
sync();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function submitLink(): void {
|
|
246
|
+
const view = activeLeafView();
|
|
247
|
+
const type = view?.state.schema.marks.link;
|
|
248
|
+
linkPromptOpen = false;
|
|
249
|
+
if (!view || !type) return;
|
|
250
|
+
const href = linkValue.trim();
|
|
251
|
+
if (href) toggleMark(type, { href })(view.state, view.dispatch);
|
|
252
|
+
view.focus();
|
|
253
|
+
sync();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function cancelLink(): void {
|
|
257
|
+
linkPromptOpen = false;
|
|
258
|
+
activeLeafView()?.focus();
|
|
259
|
+
sync();
|
|
260
|
+
}
|
|
261
|
+
</script>
|
|
262
|
+
|
|
263
|
+
<Popover.Root bind:open>
|
|
264
|
+
<Popover.Portal to={portalTarget}>
|
|
265
|
+
<Popover.Content
|
|
266
|
+
customAnchor={anchor ?? null}
|
|
267
|
+
side="top"
|
|
268
|
+
align="center"
|
|
269
|
+
trapFocus={false}
|
|
270
|
+
sideOffset={8}
|
|
271
|
+
onOpenAutoFocus={(e: Event) => e.preventDefault()}
|
|
272
|
+
onCloseAutoFocus={(e: Event) => e.preventDefault()}
|
|
273
|
+
>
|
|
274
|
+
<!-- The `child` snippet, so the surface IS the primitive's content node
|
|
275
|
+
rather than a pill inside it: the dismissal is a CSS animation and the
|
|
276
|
+
primitive unmounts on the content node's own animations finishing. It
|
|
277
|
+
also keeps the recipe reachable from this
|
|
278
|
+
component's scoped styles, which a class handed to a primitive as a
|
|
279
|
+
string never picks up the scoping hash for. `wrapperProps` is
|
|
280
|
+
floating-ui's positioning box: spread, never styled. `inert` is the half
|
|
281
|
+
of the dismissal the recipe cannot carry: the surface is still on screen
|
|
282
|
+
for the length of the fade, and a surface on its way out is not a thing
|
|
283
|
+
to click, tab into, or read. -->
|
|
284
|
+
{#snippet child({ props, wrapperProps, open: raised })}
|
|
285
|
+
<div {...wrapperProps}>
|
|
286
|
+
<div
|
|
287
|
+
bind:this={contentEl}
|
|
288
|
+
{...props}
|
|
289
|
+
class="qm-format-popover qm-popover-surface"
|
|
290
|
+
data-qm-root
|
|
291
|
+
inert={!raised}
|
|
292
|
+
>
|
|
293
|
+
{#if linkPromptOpen}
|
|
294
|
+
<form
|
|
295
|
+
class="qm-link-prompt"
|
|
296
|
+
onsubmit={(e) => {
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
submitLink();
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
<input
|
|
302
|
+
class="qm-link-input"
|
|
303
|
+
type="text"
|
|
304
|
+
placeholder={t.strings.linkPlaceholder}
|
|
305
|
+
bind:value={linkValue}
|
|
306
|
+
/>
|
|
307
|
+
<button type="submit" class="qm-icon-btn qm-mark-btn">{t.strings.linkApply}</button>
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
class="qm-icon-btn qm-mark-btn"
|
|
311
|
+
onmousedown={keepFocus}
|
|
312
|
+
onclick={cancelLink}>{t.strings.linkCancel}</button
|
|
313
|
+
>
|
|
314
|
+
</form>
|
|
315
|
+
{:else}
|
|
316
|
+
<!-- `role="group"`, not `toolbar`: these buttons carry no roving-tabindex /
|
|
317
|
+
arrow-key navigation, so the ARIA toolbar contract would be a lie;
|
|
318
|
+
a labelled group is the honest description. -->
|
|
319
|
+
<div class="qm-format-buttons" role="group" aria-label={t.strings.formatGroup}>
|
|
320
|
+
{#each MARKS as m (m.name)}
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
class="qm-icon-btn qm-mark-btn"
|
|
324
|
+
class:active={activeMarks[m.name]}
|
|
325
|
+
title={m.title}
|
|
326
|
+
aria-label={m.title}
|
|
327
|
+
onmousedown={keepFocus}
|
|
328
|
+
onclick={() => toggle(m.name)}><Icon name={m.icon} size={GLYPH} /></button
|
|
329
|
+
>
|
|
330
|
+
{/each}
|
|
331
|
+
{#if anchorAvailable}
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
class="qm-icon-btn qm-mark-btn"
|
|
335
|
+
class:active={activeMarks.anchor}
|
|
336
|
+
title={t.strings.formatAnchorTitle}
|
|
337
|
+
aria-label={t.strings.formatAnchor}
|
|
338
|
+
onmousedown={keepFocus}
|
|
339
|
+
onclick={toggleAnchor}><Icon name="hash" size={GLYPH} /></button
|
|
340
|
+
>
|
|
341
|
+
{/if}
|
|
342
|
+
</div>
|
|
343
|
+
{/if}
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
{/snippet}
|
|
347
|
+
</Popover.Content>
|
|
348
|
+
</Popover.Portal>
|
|
349
|
+
</Popover.Root>
|
|
350
|
+
|
|
351
|
+
<style>
|
|
352
|
+
/* This surface PORTALS out of the leaf's DOM but INTO the nearest `[data-qm-root]`
|
|
353
|
+
(the target is resolved in `sync`), so it inherits the consumer's dials like
|
|
354
|
+
every other surface. It carries the marker itself too: floating over content is
|
|
355
|
+
still a detached subtree for the derivation's purposes, and the marker is what
|
|
356
|
+
applies it (core/theme.css). */
|
|
357
|
+
/* The lift, the translucency, the blur and the scale-in come from
|
|
358
|
+
`.qm-popover-surface` (controls.css); this surface's own is a row and an inset. */
|
|
359
|
+
.qm-format-popover {
|
|
360
|
+
display: flex;
|
|
361
|
+
padding: var(--_qm-space);
|
|
362
|
+
}
|
|
363
|
+
.qm-format-buttons {
|
|
364
|
+
display: flex;
|
|
365
|
+
gap: var(--_qm-space-half);
|
|
366
|
+
}
|
|
367
|
+
/* Chrome, type, target floor, hover fill and disabled recede come from
|
|
368
|
+
`.qm-icon-btn` (controls.css); what is here is this surface's own. The border is
|
|
369
|
+
drawn transparent rather than absent so the active inversion adds no width and
|
|
370
|
+
shifts no glyph, and the ink is stated because the family deliberately declares
|
|
371
|
+
none: its callers disagree, and a popover over content reads at the card's. */
|
|
372
|
+
.qm-mark-btn {
|
|
373
|
+
border: var(--_qm-border-width) solid transparent;
|
|
374
|
+
padding: var(--_qm-space) var(--_qm-space-2);
|
|
375
|
+
color: var(--_qm-ink);
|
|
376
|
+
}
|
|
377
|
+
/* Active is an ink inversion, the one state on this surface a hover fill cannot
|
|
378
|
+
say: a mark already carried is not a mark under the pointer. */
|
|
379
|
+
.qm-mark-btn.active {
|
|
380
|
+
background: var(--_qm-ink);
|
|
381
|
+
color: var(--_qm-surface);
|
|
382
|
+
}
|
|
383
|
+
.qm-link-prompt {
|
|
384
|
+
display: flex;
|
|
385
|
+
gap: var(--_qm-space);
|
|
386
|
+
align-items: center;
|
|
387
|
+
}
|
|
388
|
+
.qm-link-input {
|
|
389
|
+
font: inherit;
|
|
390
|
+
font-size: var(--_qm-text-body);
|
|
391
|
+
padding: var(--_qm-space) var(--_qm-space-2);
|
|
392
|
+
border: var(--_qm-border-width) solid var(--_qm-border);
|
|
393
|
+
border-radius: var(--_qm-radius-inner);
|
|
394
|
+
min-width: 12rem;
|
|
395
|
+
}
|
|
396
|
+
</style>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type FieldController } from '../core/codec/index.js';
|
|
2
|
+
import './controls.css';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** The observation seam VisualEditor exposes over its `leaves` registry. */
|
|
5
|
+
getActiveLeaf: () => FieldController | undefined;
|
|
6
|
+
}
|
|
7
|
+
declare const FormatPopover: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type FormatPopover = ReturnType<typeof FormatPopover>;
|
|
9
|
+
export default FormatPopover;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A `number` / `integer` field → numeric input (fixture `font_size` = 11.5).
|
|
3
|
+
Commits at `change` (blur/Enter), NOT per keystroke: a partial numeric entry
|
|
4
|
+
(`-`, `1.`, `1e`) is never a document state worth a boundary round-trip, and
|
|
5
|
+
committing it live flashes a coercion diagnostic + `console.error` on every
|
|
6
|
+
intermediate prefix, announced by `DiagnosticList`'s `role="status"` live
|
|
7
|
+
region. A blank entry commits `undefined`: the UNSET rung of the
|
|
8
|
+
commitment ladder: the parent removes the field and the engine renders the
|
|
9
|
+
ghosted `default:`. Settling at `change` also keeps
|
|
10
|
+
select-all-and-retype from flashing the preview through the default mid-keystroke.
|
|
11
|
+
|
|
12
|
+
`type="text"`, not `type="number"`: a native number input SANITIZES an
|
|
13
|
+
invalid string to `""` before the DOM `value` setter even runs (verified:
|
|
14
|
+
`.value = "abc"` on `type="number"` never lands), which would make a
|
|
15
|
+
genuinely bad entry untypeable. The commit-time coercion diagnostic
|
|
16
|
+
(VISUAL_EDITOR §Diagnostics) needs exactly that path reachable through the
|
|
17
|
+
UI, so a non-blank entry that fails to parse forwards the RAW STRING to
|
|
18
|
+
`onCommit` unchanged: the boundary's own `writer.set` coercion is the judge
|
|
19
|
+
(throws a `QuillmarkError` the parent turns into a field diagnostic), not a
|
|
20
|
+
client-side guess. `inputmode` keeps the numeric mobile keyboard.
|
|
21
|
+
-->
|
|
22
|
+
<script lang="ts">
|
|
23
|
+
import { syncedLocal } from './synced.svelte.js';
|
|
24
|
+
import './controls.css';
|
|
25
|
+
|
|
26
|
+
interface Props {
|
|
27
|
+
value: number | undefined;
|
|
28
|
+
integer?: boolean;
|
|
29
|
+
fallback?: number;
|
|
30
|
+
/** Accessible name for an input NOTHING else names: an object property, whose
|
|
31
|
+
* name is the field label plus the property's. A field's own input takes `id`
|
|
32
|
+
* instead and is named by the `<label for>` beside it. */
|
|
33
|
+
label?: string;
|
|
34
|
+
/** `<label for>` target. Set → the label names this input, so `aria-label`
|
|
35
|
+
* comes off: two names is where implementations disagree about which wins. */
|
|
36
|
+
id?: string;
|
|
37
|
+
/** The parked `description` (FieldLabel): announced after the name. */
|
|
38
|
+
describedBy?: string;
|
|
39
|
+
onCommit: (v: number | string | undefined) => void;
|
|
40
|
+
}
|
|
41
|
+
let { value, integer, fallback, label, id, describedBy, onCommit }: Props = $props();
|
|
42
|
+
|
|
43
|
+
// Local input state synced to `value` (as a string projection); own-typing
|
|
44
|
+
// stays local, only an external change reconciles back in (see `syncedLocal`).
|
|
45
|
+
const local = syncedLocal(() => (value != null ? String(value) : ''));
|
|
46
|
+
|
|
47
|
+
// Parse a settled entry and emit it; `local` is owned by `oninput`. Blank →
|
|
48
|
+
// `undefined` (the unset rung: parent removes the field, default renders).
|
|
49
|
+
function commit(raw: string): void {
|
|
50
|
+
if (raw.trim() === '') return void onCommit(undefined);
|
|
51
|
+
// Number(), not parseFloat/parseInt: a prefix parse would silently commit
|
|
52
|
+
// `14.5` for `14.5x` (and truncate `11.9` → 11 on integer fields) instead
|
|
53
|
+
// of letting the boundary judge the full entry.
|
|
54
|
+
const n = Number(raw);
|
|
55
|
+
onCommit(Number.isNaN(n) ? raw : n);
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<input
|
|
60
|
+
class="qm-input qm-focus-ring"
|
|
61
|
+
type="text"
|
|
62
|
+
inputmode={integer ? 'numeric' : 'decimal'}
|
|
63
|
+
value={local.value}
|
|
64
|
+
{id}
|
|
65
|
+
placeholder={fallback != null ? String(fallback) : ''}
|
|
66
|
+
aria-label={id ? undefined : label}
|
|
67
|
+
aria-describedby={describedBy}
|
|
68
|
+
oninput={(e) => {
|
|
69
|
+
local.value = (e.currentTarget as HTMLInputElement).value;
|
|
70
|
+
}}
|
|
71
|
+
onchange={(e) => commit((e.currentTarget as HTMLInputElement).value)}
|
|
72
|
+
/>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import './controls.css';
|
|
2
|
+
interface Props {
|
|
3
|
+
value: number | undefined;
|
|
4
|
+
integer?: boolean;
|
|
5
|
+
fallback?: number;
|
|
6
|
+
/** Accessible name for an input NOTHING else names: an object property, whose
|
|
7
|
+
* name is the field label plus the property's. A field's own input takes `id`
|
|
8
|
+
* instead and is named by the `<label for>` beside it. */
|
|
9
|
+
label?: string;
|
|
10
|
+
/** `<label for>` target. Set → the label names this input, so `aria-label`
|
|
11
|
+
* comes off: two names is where implementations disagree about which wins. */
|
|
12
|
+
id?: string;
|
|
13
|
+
/** The parked `description` (FieldLabel): announced after the name. */
|
|
14
|
+
describedBy?: string;
|
|
15
|
+
onCommit: (v: number | string | undefined) => void;
|
|
16
|
+
}
|
|
17
|
+
declare const NumberField: import("svelte").Component<Props, {}, "">;
|
|
18
|
+
type NumberField = ReturnType<typeof NumberField>;
|
|
19
|
+
export default NumberField;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
An `object` field → a nested subform over `properties` (declaration order),
|
|
3
|
+
committing the WHOLE object by value on any nested change. SCALAR properties
|
|
4
|
+
only: a nested prose/array/object property renders a placeholder instead of
|
|
5
|
+
recursing.
|
|
6
|
+
|
|
7
|
+
A property's ghosted `default:` is the static schema `sub.default`, not the
|
|
8
|
+
resolved provenance the top-level ghosts read (FIELD_PROVENANCE): `resolve`
|
|
9
|
+
carries no per-property row (an object field resolves as one row whose value is
|
|
10
|
+
the whole object). Thread it through `ghostDefault` once resolve exposes
|
|
11
|
+
per-property provenance.
|
|
12
|
+
-->
|
|
13
|
+
<script lang="ts">
|
|
14
|
+
import type { QuillFieldSchema } from '@quillmark/wasm';
|
|
15
|
+
import { controlKind, enumValues, humanize } from './structure.js';
|
|
16
|
+
import TextField from './TextField.svelte';
|
|
17
|
+
import EnumField from './EnumField.svelte';
|
|
18
|
+
import NumberField from './NumberField.svelte';
|
|
19
|
+
import BooleanField from './BooleanField.svelte';
|
|
20
|
+
import DateField from './DateField.svelte';
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
value: Record<string, unknown> | undefined;
|
|
24
|
+
properties: Record<string, QuillFieldSchema> | undefined;
|
|
25
|
+
/** Accessible-name prefix for the property controls. */
|
|
26
|
+
label?: string;
|
|
27
|
+
/** The field label's own id. A subform is a GROUP of controls, not one control
|
|
28
|
+
* `for` could reach, so the field's label names the set and each property
|
|
29
|
+
* control keeps its own composed `aria-label`. */
|
|
30
|
+
labelledBy?: string;
|
|
31
|
+
/** The parked `description` (FieldLabel): announced on entering the group. */
|
|
32
|
+
describedBy?: string;
|
|
33
|
+
onCommit: (obj: Record<string, unknown>) => void;
|
|
34
|
+
}
|
|
35
|
+
let { value, properties, label, labelledBy, describedBy, onCommit }: Props = $props();
|
|
36
|
+
|
|
37
|
+
const entries = $derived(Object.entries(properties ?? {}));
|
|
38
|
+
const obj = $derived((value ?? {}) as Record<string, unknown>);
|
|
39
|
+
|
|
40
|
+
function commitProp(key: string, v: unknown): void {
|
|
41
|
+
if (v === undefined) {
|
|
42
|
+
// A nested control cleared (the unset rung): drop the key so the property
|
|
43
|
+
// is ABSENT in the committed object (resolving to its own `default:`)
|
|
44
|
+
// rather than an `undefined` hole carried through `writer.set`.
|
|
45
|
+
const rest = { ...obj };
|
|
46
|
+
delete rest[key];
|
|
47
|
+
onCommit(rest);
|
|
48
|
+
} else {
|
|
49
|
+
onCommit({ ...obj, [key]: v });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<div class="qm-object" role="group" aria-labelledby={labelledBy} aria-describedby={describedBy}>
|
|
55
|
+
{#each entries as [key, sub] (key)}
|
|
56
|
+
{@const kind = controlKind(sub)}
|
|
57
|
+
{@const propLabel = `${label != null ? `${label} ` : ''}${sub.ui?.title ?? humanize(key)}`}
|
|
58
|
+
<div class="qm-object-prop">
|
|
59
|
+
<span class="qm-object-label">{sub.ui?.title ?? humanize(key)}</span>
|
|
60
|
+
{#if kind === 'enum'}
|
|
61
|
+
<EnumField
|
|
62
|
+
label={propLabel}
|
|
63
|
+
value={obj[key] as string | undefined}
|
|
64
|
+
values={enumValues(sub) ?? []}
|
|
65
|
+
fallback={sub.default as string | undefined}
|
|
66
|
+
onCommit={(v) => commitProp(key, v)}
|
|
67
|
+
/>
|
|
68
|
+
{:else if kind === 'number'}
|
|
69
|
+
<NumberField
|
|
70
|
+
label={propLabel}
|
|
71
|
+
value={obj[key] as number | undefined}
|
|
72
|
+
integer={sub.type === 'integer'}
|
|
73
|
+
fallback={sub.default as number | undefined}
|
|
74
|
+
onCommit={(v) => commitProp(key, v)}
|
|
75
|
+
/>
|
|
76
|
+
{:else if kind === 'boolean'}
|
|
77
|
+
<BooleanField
|
|
78
|
+
label={propLabel}
|
|
79
|
+
value={obj[key] as boolean | undefined}
|
|
80
|
+
fallback={sub.default as boolean | undefined}
|
|
81
|
+
onCommit={(v) => commitProp(key, v)}
|
|
82
|
+
/>
|
|
83
|
+
{:else if kind === 'date'}
|
|
84
|
+
<DateField
|
|
85
|
+
label={propLabel}
|
|
86
|
+
value={obj[key] as string | undefined}
|
|
87
|
+
onCommit={(v) => commitProp(key, v)}
|
|
88
|
+
/>
|
|
89
|
+
{:else if kind === 'text'}
|
|
90
|
+
<TextField
|
|
91
|
+
label={propLabel}
|
|
92
|
+
value={obj[key] as string | undefined}
|
|
93
|
+
placeholder={sub.default != null ? String(sub.default) : undefined}
|
|
94
|
+
onCommit={(v) => commitProp(key, v)}
|
|
95
|
+
/>
|
|
96
|
+
{:else}
|
|
97
|
+
<span class="qm-unsupported">({kind} property — not editable in V1)</span>
|
|
98
|
+
{/if}
|
|
99
|
+
</div>
|
|
100
|
+
{/each}
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<style>
|
|
104
|
+
.qm-object {
|
|
105
|
+
display: flex;
|
|
106
|
+
flex-direction: column;
|
|
107
|
+
gap: var(--_qm-space-2);
|
|
108
|
+
border-left: var(--_qm-border-width) solid var(--_qm-border);
|
|
109
|
+
padding-left: var(--_qm-space-2);
|
|
110
|
+
}
|
|
111
|
+
.qm-object-prop {
|
|
112
|
+
display: flex;
|
|
113
|
+
flex-direction: column;
|
|
114
|
+
gap: var(--_qm-space-half);
|
|
115
|
+
}
|
|
116
|
+
.qm-object-label {
|
|
117
|
+
font-size: var(--_qm-text-label);
|
|
118
|
+
/* Soft (500), a rung under the top-level field label's 600: a nested object
|
|
119
|
+
prop reads as secondary. Resolves the label-weight disagreement. */
|
|
120
|
+
font-weight: var(--_qm-weight-soft);
|
|
121
|
+
/* A label is a line, not a passage, so it overrides the root's reading
|
|
122
|
+
rhythm the way every other label rung does. */
|
|
123
|
+
line-height: var(--_qm-leading-tight);
|
|
124
|
+
color: var(--_qm-ink-label);
|
|
125
|
+
}
|
|
126
|
+
.qm-unsupported {
|
|
127
|
+
font-size: var(--_qm-text-body);
|
|
128
|
+
color: var(--_qm-ink-ghost);
|
|
129
|
+
font-style: italic;
|
|
130
|
+
}
|
|
131
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { QuillFieldSchema } from '@quillmark/wasm';
|
|
2
|
+
interface Props {
|
|
3
|
+
value: Record<string, unknown> | undefined;
|
|
4
|
+
properties: Record<string, QuillFieldSchema> | undefined;
|
|
5
|
+
/** Accessible-name prefix for the property controls. */
|
|
6
|
+
label?: string;
|
|
7
|
+
/** The field label's own id. A subform is a GROUP of controls, not one control
|
|
8
|
+
* `for` could reach, so the field's label names the set and each property
|
|
9
|
+
* control keeps its own composed `aria-label`. */
|
|
10
|
+
labelledBy?: string;
|
|
11
|
+
/** The parked `description` (FieldLabel): announced on entering the group. */
|
|
12
|
+
describedBy?: string;
|
|
13
|
+
onCommit: (obj: Record<string, unknown>) => void;
|
|
14
|
+
}
|
|
15
|
+
declare const ObjectField: import("svelte").Component<Props, {}, "">;
|
|
16
|
+
type ObjectField = ReturnType<typeof ObjectField>;
|
|
17
|
+
export default ObjectField;
|