@quillmark/svelte 0.1.1 → 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 +16 -12
- package/THEMING.md +48 -25
- package/dist/core/address.d.ts +42 -7
- package/dist/core/address.js +49 -21
- 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 +14 -11
- package/dist/core/codec/field.d.ts +22 -27
- 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 +266 -228
- 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 +73 -27
- package/dist/core/errors.d.ts +17 -7
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +6 -5
- package/dist/core/lifecycle.d.ts +21 -5
- package/dist/core/lifecycle.js +34 -8
- package/dist/core/rebind.svelte.d.ts +18 -4
- package/dist/core/rebind.svelte.js +30 -12
- package/dist/core/teardown.d.ts +1 -1
- package/dist/core/teardown.js +1 -1
- package/dist/core/theme.css +173 -58
- package/dist/preset/index.css +1 -1
- package/dist/preset/recipes.css +79 -30
- package/dist/preset/scale.css +40 -28
- package/dist/preview/Preview.svelte +22 -19
- package/dist/preview/Preview.svelte.d.ts +8 -8
- package/dist/preview/bridge.d.ts +6 -4
- package/dist/preview/bridge.js +19 -9
- package/dist/preview/controller.d.ts +13 -6
- package/dist/preview/controller.js +8 -5
- package/dist/preview/geometry.d.ts +17 -2
- package/dist/preview/geometry.js +21 -1
- package/dist/preview/index.d.ts +1 -0
- package/dist/preview/index.js +9 -2
- package/dist/preview/overlay.d.ts +2 -1
- package/dist/preview/overlay.js +26 -11
- package/dist/preview/paint.js +19 -17
- package/dist/visual/ArrayField.svelte +103 -83
- package/dist/visual/ArrayField.svelte.d.ts +5 -4
- package/dist/visual/BooleanField.svelte +8 -6
- package/dist/visual/BooleanField.svelte.d.ts +2 -2
- package/dist/visual/Card.svelte +161 -333
- package/dist/visual/Card.svelte.d.ts +5 -4
- 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 +2 -2
- package/dist/visual/EnumField.svelte +48 -28
- package/dist/visual/EnumField.svelte.d.ts +5 -4
- package/dist/visual/Field.svelte +207 -132
- package/dist/visual/Field.svelte.d.ts +11 -7
- package/dist/visual/FieldHint.svelte +79 -48
- package/dist/visual/FieldHint.svelte.d.ts +1 -1
- package/dist/visual/FieldLabel.svelte +8 -8
- package/dist/visual/FormatPopover.svelte +82 -35
- package/dist/visual/NumberField.svelte +5 -5
- package/dist/visual/NumberField.svelte.d.ts +1 -1
- package/dist/visual/ObjectField.svelte +28 -9
- package/dist/visual/ObjectField.svelte.d.ts +4 -2
- package/dist/visual/ProseArrayElement.svelte +12 -19
- package/dist/visual/ProseArrayElement.svelte.d.ts +1 -2
- package/dist/visual/ProseField.svelte +62 -85
- package/dist/visual/ProseField.svelte.d.ts +14 -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 +18 -19
- package/dist/visual/VisualEditor.svelte.d.ts +2 -3
- package/dist/visual/VisualEditorInner.svelte +251 -184
- package/dist/visual/VisualEditorInner.svelte.d.ts +2 -3
- package/dist/visual/controls.css +85 -38
- package/dist/visual/diagnostics.d.ts +13 -21
- package/dist/visual/diagnostics.js +7 -19
- 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.d.ts +1 -0
- package/dist/visual/index.js +10 -3
- package/dist/visual/leaves.d.ts +47 -0
- package/dist/visual/leaves.js +31 -0
- package/dist/visual/links.d.ts +24 -0
- package/dist/visual/links.js +67 -0
- package/dist/visual/props.d.ts +33 -12
- 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 +4 -2
- package/dist/visual/tips.js +7 -4
- package/package.json +2 -2
|
@@ -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,10 +36,23 @@
|
|
|
37
36
|
const entries = $derived(Object.entries(properties ?? {}));
|
|
38
37
|
const obj = $derived((value ?? {}) as Record<string, unknown>);
|
|
39
38
|
|
|
39
|
+
/** Take the caret: the first property's control, a subform having no single control
|
|
40
|
+
* of its own to land on. Resolved off the DOM rather than a ref per property: every
|
|
41
|
+
* property is a scalar control the DOM already knows how to focus (the date field's
|
|
42
|
+
* segments carry `tabindex`, the `literal` separators between them do not), so a
|
|
43
|
+
* handle each would be five refs restating document order. An empty or wholly
|
|
44
|
+
* unsupported subform lands nothing. */
|
|
45
|
+
let rootEl = $state<HTMLElement | undefined>();
|
|
46
|
+
export function focus(): void {
|
|
47
|
+
rootEl
|
|
48
|
+
?.querySelector<HTMLElement>('input, select, button, [tabindex]:not([tabindex="-1"])')
|
|
49
|
+
?.focus();
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
function commitProp(key: string, v: unknown): void {
|
|
41
53
|
if (v === undefined) {
|
|
42
54
|
// A nested control cleared (the unset rung): drop the key so the property
|
|
43
|
-
// is
|
|
55
|
+
// is absent in the committed object (resolving to its own `default:`)
|
|
44
56
|
// rather than an `undefined` hole carried through `writer.set`.
|
|
45
57
|
const rest = { ...obj };
|
|
46
58
|
delete rest[key];
|
|
@@ -51,7 +63,13 @@
|
|
|
51
63
|
}
|
|
52
64
|
</script>
|
|
53
65
|
|
|
54
|
-
<div
|
|
66
|
+
<div
|
|
67
|
+
bind:this={rootEl}
|
|
68
|
+
class="qm-object"
|
|
69
|
+
role="group"
|
|
70
|
+
aria-labelledby={labelledBy}
|
|
71
|
+
aria-describedby={describedBy}
|
|
72
|
+
>
|
|
55
73
|
{#each entries as [key, sub] (key)}
|
|
56
74
|
{@const kind = controlKind(sub)}
|
|
57
75
|
{@const propLabel = `${label != null ? `${label} ` : ''}${sub.ui?.title ?? humanize(key)}`}
|
|
@@ -115,9 +133,10 @@
|
|
|
115
133
|
}
|
|
116
134
|
.qm-object-label {
|
|
117
135
|
font-size: var(--_qm-text-label);
|
|
118
|
-
/*
|
|
119
|
-
|
|
120
|
-
|
|
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);
|
|
121
140
|
/* A label is a line, not a passage, so it overrides the root's reading
|
|
122
141
|
rhythm the way every other label rung does. */
|
|
123
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;
|
|
@@ -12,6 +12,8 @@ interface Props {
|
|
|
12
12
|
describedBy?: string;
|
|
13
13
|
onCommit: (obj: Record<string, unknown>) => void;
|
|
14
14
|
}
|
|
15
|
-
declare const ObjectField: import("svelte").Component<Props, {
|
|
15
|
+
declare const ObjectField: import("svelte").Component<Props, {
|
|
16
|
+
focus: () => void;
|
|
17
|
+
}, "">;
|
|
16
18
|
type ObjectField = ReturnType<typeof ObjectField>;
|
|
17
19
|
export default ObjectField;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
One element of an array-of-`richtext` field (fixture `references`). Array
|
|
3
|
-
elements are
|
|
3
|
+
elements are not `applyChange`-addressable: `Addr.field` is a flat name, so
|
|
4
4
|
`references.0` has no op address. So
|
|
5
|
-
this is
|
|
5
|
+
this is not a `createField` leaf: it mounts a minimal PM view over the codec's
|
|
6
6
|
decode/encode + inline schema, and on every edit hands the re-encoded
|
|
7
|
-
`Content`
|
|
7
|
+
`Content` up to the parent {@link ArrayField}, which commits the whole array
|
|
8
8
|
by value (`writer.set(field, arrayWithElementReplaced)`). Anchors within an
|
|
9
9
|
element are dropped on that value write: acceptable for inline refs. Mounts
|
|
10
|
-
|
|
10
|
+
once per stable element id (no reset on the parent's re-derive).
|
|
11
11
|
-->
|
|
12
12
|
<script lang="ts">
|
|
13
13
|
import { onMount } from 'svelte';
|
|
@@ -24,16 +24,15 @@
|
|
|
24
24
|
label?: string;
|
|
25
25
|
onChange: (rt: Content) => void;
|
|
26
26
|
/** Raw keydown, for a container whose own keys run through this element: the
|
|
27
|
-
* array repeater's Enter/Backspace (`ArrayField`). Fires
|
|
27
|
+
* array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
|
|
28
28
|
* keymap, so the state it reads is the one this keystroke has yet to change. */
|
|
29
29
|
onKey?: (e: KeyboardEvent) => void;
|
|
30
|
-
onFocusEl?: () => void;
|
|
31
30
|
}
|
|
32
|
-
let { value, plaintext, label, onChange, onKey
|
|
31
|
+
let { value, plaintext, label, onChange, onKey }: Props = $props();
|
|
33
32
|
|
|
34
33
|
let containerEl: HTMLDivElement | undefined = $state();
|
|
35
34
|
let view: EditorView | undefined;
|
|
36
|
-
/** Take the caret: what a parent placing focus on this element calls. The
|
|
35
|
+
/** Take the caret: what a parent placing focus on this element calls. The view's
|
|
37
36
|
* focus, not the element's: a PM view restores its selection, where a bare DOM
|
|
38
37
|
* focus on a contenteditable leaves the caret unplaced. */
|
|
39
38
|
export function focus(): void {
|
|
@@ -42,7 +41,7 @@
|
|
|
42
41
|
|
|
43
42
|
onMount(() => {
|
|
44
43
|
if (!containerEl) return;
|
|
45
|
-
// An array element is an inline, value-by-value leaf: the
|
|
44
|
+
// An array element is an inline, value-by-value leaf: the same keymap and
|
|
46
45
|
// plugin stack a `createField` leaf mounts (shared `proseLeafPlugins`), minus
|
|
47
46
|
// the anchor-position plugin (element anchors are dropped on the array's value
|
|
48
47
|
// write, per the header). Its own `dispatchTransaction` hands `Content` up.
|
|
@@ -62,10 +61,6 @@
|
|
|
62
61
|
if (tr.docChanged) onChange(pmToContent(next.doc));
|
|
63
62
|
},
|
|
64
63
|
handleDOMEvents: {
|
|
65
|
-
focus: () => {
|
|
66
|
-
onFocusEl?.();
|
|
67
|
-
return false;
|
|
68
|
-
},
|
|
69
64
|
keydown: (_v, e) => {
|
|
70
65
|
onKey?.(e);
|
|
71
66
|
return false;
|
|
@@ -84,15 +79,13 @@
|
|
|
84
79
|
an array of `string` render rows of equal height. No floor: the reset in
|
|
85
80
|
`core/codec/prose.css` makes one line of prose measure one line. Width is the
|
|
86
81
|
row's to give: the element fills the track it is placed in (`ArrayField`). -->
|
|
87
|
-
<div bind:this={containerEl} class="qm-array-prose qm-control-box"></div>
|
|
82
|
+
<div bind:this={containerEl} class="qm-array-prose qm-control-box qm-focus-ring-within"></div>
|
|
88
83
|
|
|
89
84
|
<style>
|
|
90
|
-
/* Caret-primary, matching ProseField: the contenteditable outline is
|
|
91
|
-
and the
|
|
85
|
+
/* Caret-primary, matching ProseField: the contenteditable's own outline is
|
|
86
|
+
dropped and the ring rides the wrapper (`qm-focus-ring-within`,
|
|
87
|
+
controls.css), which is where the box is. */
|
|
92
88
|
.qm-array-prose :global(.ProseMirror) {
|
|
93
89
|
outline: none;
|
|
94
90
|
}
|
|
95
|
-
.qm-array-prose:focus-within {
|
|
96
|
-
border-color: var(--_qm-accent);
|
|
97
|
-
}
|
|
98
91
|
</style>
|
|
@@ -7,10 +7,9 @@ interface Props {
|
|
|
7
7
|
label?: string;
|
|
8
8
|
onChange: (rt: Content) => void;
|
|
9
9
|
/** Raw keydown, for a container whose own keys run through this element: the
|
|
10
|
-
* array repeater's Enter/Backspace (`ArrayField`). Fires
|
|
10
|
+
* array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
|
|
11
11
|
* keymap, so the state it reads is the one this keystroke has yet to change. */
|
|
12
12
|
onKey?: (e: KeyboardEvent) => void;
|
|
13
|
-
onFocusEl?: () => void;
|
|
14
13
|
}
|
|
15
14
|
declare const ProseArrayElement: import("svelte").Component<Props, {
|
|
16
15
|
focus: () => void;
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
One content prose leaf: a
|
|
3
|
-
(VISUAL_EDITOR §Surface)
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
stable-id→index map), so a card reorder re-targets this leaf's commits without
|
|
8
|
-
a remount: the caret rides the untouched PM view.
|
|
2
|
+
One content prose leaf: a Svelte mount of the codec's `createField`
|
|
3
|
+
(VISUAL_EDITOR §Surface), which owns the PM state, the history and the
|
|
4
|
+
per-keystroke commit, so this wrapper is wiring and registration. `addr` is a
|
|
5
|
+
live object the parent builds, so a card reorder re-targets this leaf's commits
|
|
6
|
+
with the caret riding the untouched view.
|
|
9
7
|
-->
|
|
10
8
|
<script lang="ts">
|
|
11
9
|
import { onMount } from 'svelte';
|
|
12
10
|
import { createField, type FieldController, type SlashState } from '../core/codec/index.js';
|
|
11
|
+
import type { LeafRegistry } from './leaves.js';
|
|
13
12
|
import SlashMenu from './SlashMenu.svelte';
|
|
14
13
|
import { wording } from './strings.js';
|
|
15
14
|
import './controls.css';
|
|
@@ -19,32 +18,28 @@
|
|
|
19
18
|
interface Props {
|
|
20
19
|
doc: Document;
|
|
21
20
|
/** The schema this leaf reads its content through: `createField` binds a reader
|
|
22
|
-
* off it, so a stored string decodes by declared type
|
|
21
|
+
* off it, so a stored string decodes by its declared type. */
|
|
23
22
|
quill: Quill;
|
|
24
23
|
addr: Addr;
|
|
25
24
|
inline?: boolean;
|
|
26
25
|
plaintext?: boolean;
|
|
27
26
|
/**
|
|
28
|
-
* Draw no box: the card body's leaf, and nothing else.
|
|
29
|
-
*
|
|
30
|
-
* a
|
|
31
|
-
* row of controls. Only the body is paper, and only its caller knows it is.
|
|
27
|
+
* Draw no box: the card body's leaf, and nothing else. Not `block`, which is a
|
|
28
|
+
* different predicate: a `richtext` field without `inline` is block prose and
|
|
29
|
+
* still a control in a row of controls, so only the caller knows what is paper.
|
|
32
30
|
*/
|
|
33
31
|
unframed?: boolean;
|
|
34
|
-
/** Accessible name for a leaf
|
|
35
|
-
* card body. A leaf with a field label takes `labelledBy
|
|
32
|
+
/** Accessible name for a leaf nothing else names: an array element, or the
|
|
33
|
+
* card body. A leaf with a field label takes `labelledBy`. */
|
|
36
34
|
label?: string;
|
|
37
35
|
/** The field label's own id → `aria-labelledby` on the editable region. `for`
|
|
38
|
-
* cannot reach a `contenteditable
|
|
39
|
-
*
|
|
40
|
-
* the controller's `focus`. */
|
|
36
|
+
* cannot reach a `contenteditable`, so the association runs the other way and
|
|
37
|
+
* the label's click comes back through the controller's `focus`. */
|
|
41
38
|
labelledBy?: string;
|
|
42
39
|
/** The parked `description` (FieldLabel) → `aria-describedby`. */
|
|
43
40
|
describedBy?: string;
|
|
44
|
-
/** Ghost shown on the empty leaf
|
|
45
|
-
*
|
|
46
|
-
* (`resolveBodyGhost`). Reactive: a change is pushed into the mounted view,
|
|
47
|
-
* never a remount. */
|
|
41
|
+
/** Ghost shown on the empty leaf: an inline leaf's resolved `default:` or
|
|
42
|
+
* nothing, a body's always text (`resolveBodyGhost`). */
|
|
48
43
|
placeholder?: string;
|
|
49
44
|
/** Stable identity for the registry, stamped on the DOM node so a remount is
|
|
50
45
|
* visible as one. */
|
|
@@ -54,8 +49,9 @@
|
|
|
54
49
|
/** A commit landed on this leaf: the prose change lane. */
|
|
55
50
|
onChange?: (addr: Addr) => void;
|
|
56
51
|
onError?: EditorErrorHandler;
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
/** The editor's leaf registry (`leaves.ts`). Registering the controller is what
|
|
53
|
+
* makes this leaf a caret target and not merely a focus one. */
|
|
54
|
+
leaves?: LeafRegistry;
|
|
59
55
|
}
|
|
60
56
|
|
|
61
57
|
let {
|
|
@@ -74,39 +70,29 @@
|
|
|
74
70
|
onCaretMove,
|
|
75
71
|
onChange,
|
|
76
72
|
onError,
|
|
77
|
-
|
|
78
|
-
unregister
|
|
73
|
+
leaves
|
|
79
74
|
}: Props = $props();
|
|
80
75
|
|
|
81
76
|
let containerEl: HTMLDivElement | undefined = $state();
|
|
82
77
|
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
// mounted table without remounting the leaf and losing the caret.
|
|
78
|
+
// Handed to the codec as a getter: the island chrome redraws on each render, so a
|
|
79
|
+
// locale swap reaches a mounted table without remounting the leaf and losing the caret.
|
|
86
80
|
const t = wording();
|
|
87
81
|
|
|
88
|
-
/** The insert menu's live state, pushed by the leaf's trigger plugin
|
|
89
|
-
* rather than
|
|
90
|
-
*
|
|
91
|
-
* leaf is active. A constrained leaf never receives one (`createField` mounts the
|
|
92
|
-
* trigger on the block schema alone). */
|
|
82
|
+
/** The insert menu's live state, pushed by the leaf's trigger plugin: the menu is
|
|
83
|
+
* the leaf's surface rather than the shell's (VISUAL_EDITOR §Chrome). A constrained
|
|
84
|
+
* leaf never receives one, the trigger mounting on the block schema alone. */
|
|
93
85
|
let slash: SlashState | undefined = $state();
|
|
94
86
|
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// `plaintext`-only field would take the body's floor while holding one paragraph.
|
|
87
|
+
// The codec's own schema predicate (`createField`: `plaintext` implies `inline`), so
|
|
88
|
+
// the box a leaf draws and the schema it holds cannot disagree. On `inline` alone a
|
|
89
|
+
// `plaintext` field would take the block floor while holding one paragraph.
|
|
99
90
|
const block = $derived(!(inline || plaintext));
|
|
100
91
|
|
|
101
|
-
// An absent richtext field (a `default:`-only field like `tag_line`) is handled
|
|
102
|
-
// by the codec itself: `createField` decodes an empty content and overwrites on
|
|
103
|
-
// the first edit; so this wrapper adds no pre-seeding, and an untouched field
|
|
104
|
-
// stays absent (its default rendering intact) until actually edited.
|
|
105
92
|
let controller: FieldController | undefined;
|
|
106
93
|
|
|
107
|
-
/** Take the caret: what the label click calls. The
|
|
108
|
-
* container's
|
|
109
|
-
* the wrapper leaves unplaced. */
|
|
94
|
+
/** Take the caret: what the label's click calls. The controller's focus, not the
|
|
95
|
+
* container's, which leaves the PM selection unplaced. */
|
|
110
96
|
export function focus(): void {
|
|
111
97
|
controller?.focus();
|
|
112
98
|
}
|
|
@@ -125,7 +111,6 @@
|
|
|
125
111
|
describedBy,
|
|
126
112
|
placeholder,
|
|
127
113
|
tableStrings: () => t.strings,
|
|
128
|
-
slashStrings: () => t.strings,
|
|
129
114
|
onSlash: (next) => {
|
|
130
115
|
slash = next;
|
|
131
116
|
},
|
|
@@ -134,18 +119,16 @@
|
|
|
134
119
|
onChange,
|
|
135
120
|
onError
|
|
136
121
|
});
|
|
137
|
-
|
|
122
|
+
leaves?.registerProse(leafKey, controller);
|
|
138
123
|
return () => {
|
|
139
|
-
|
|
124
|
+
leaves?.unregisterProse(leafKey);
|
|
140
125
|
controller?.destroy();
|
|
141
126
|
controller = undefined;
|
|
142
127
|
};
|
|
143
128
|
});
|
|
144
129
|
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// the old kind's wording. Push it instead: the caret is worth more than the
|
|
148
|
-
// simplicity of rebuilding.
|
|
130
|
+
// A retype does not remount the leaf (its key is the card's session id), so the new
|
|
131
|
+
// kind's ghost is pushed into the live view rather than paid for with the caret.
|
|
149
132
|
$effect(() => {
|
|
150
133
|
controller?.setPlaceholder(placeholder);
|
|
151
134
|
});
|
|
@@ -155,53 +138,47 @@
|
|
|
155
138
|
bind:this={containerEl}
|
|
156
139
|
class="qm-prose"
|
|
157
140
|
class:qm-control-box={!unframed}
|
|
141
|
+
class:qm-focus-ring-within={!unframed}
|
|
158
142
|
class:qm-prose-block={block}
|
|
159
143
|
data-leaf-key={leafKey}
|
|
160
144
|
></div>
|
|
161
145
|
<SlashMenu menu={slash} leaf={() => controller} label={t.strings.slashLabel} />
|
|
162
146
|
|
|
163
147
|
<style>
|
|
164
|
-
/* The box is `.qm-control-box` (controls.css)
|
|
165
|
-
draws, so the two agree on height by construction
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
prose in this box measures one line of text in that one. */
|
|
148
|
+
/* The box is `.qm-control-box` (controls.css), the same rule the input beside it
|
|
149
|
+
draws, so the two agree on height by construction. No `min-height` with it:
|
|
150
|
+
`core/codec/prose.css` resets the paragraph box, so one line of prose in this box
|
|
151
|
+
measures one line of text in that one. */
|
|
169
152
|
.qm-prose {
|
|
170
|
-
/*
|
|
171
|
-
inset child, so it covers the text without fading it or tinting the leaf's
|
|
172
|
-
own surface. */
|
|
153
|
+
/* Containing block for the arrival wash's inset child (`core/bloom.ts`). */
|
|
173
154
|
position: relative;
|
|
174
155
|
}
|
|
175
|
-
/* A leaf the inline schema does
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
.qm-prose-block {
|
|
184
|
-
min-height: calc(
|
|
156
|
+
/* A leaf the inline schema does not constrain is prose rather than a cell in a row of
|
|
157
|
+
controls, so it opens at three line boxes and grows: the ghost takes the first,
|
|
158
|
+
leaving room under it. `1em`, not a size rung, which is the rule below's to name.
|
|
159
|
+
|
|
160
|
+
On the editable rather than on the wrapper, which is what makes those lines part of
|
|
161
|
+
the body: height the wrapper holds is height outside the `contenteditable`, where a
|
|
162
|
+
press lands no caret. Inside it, the browser answers a press past the last line at
|
|
163
|
+
the end of it. */
|
|
164
|
+
.qm-prose-block :global(.ProseMirror) {
|
|
165
|
+
min-height: calc(1em * var(--_qm-leading-body) * 3);
|
|
166
|
+
}
|
|
167
|
+
/* Paper, and the withheld box is what says so. Not `block` alone: `inline` is the
|
|
168
|
+
quill's to declare, so a schema omitting it on a richtext field puts a block leaf in
|
|
169
|
+
a field row, where this rung would break the height agreement above. */
|
|
170
|
+
.qm-prose-block:not(.qm-control-box) {
|
|
171
|
+
font-size: var(--_qm-text-paper);
|
|
185
172
|
}
|
|
186
|
-
/* The caret is the
|
|
187
|
-
|
|
188
|
-
|
|
173
|
+
/* The caret is the body's focus indicator: a ring around the one surface in a card
|
|
174
|
+
that is paper reads as form chrome. So the contenteditable's own outline is dropped
|
|
175
|
+
everywhere, and a boxed leaf takes the shared ring on its wrapper instead
|
|
176
|
+
(`qm-focus-ring-within`, controls.css). */
|
|
189
177
|
.qm-prose :global(.ProseMirror) {
|
|
190
178
|
outline: none;
|
|
191
179
|
}
|
|
192
|
-
/*
|
|
193
|
-
|
|
194
|
-
Scoped to the leaves that HAVE a hairline: the body has none, and the caret is
|
|
195
|
-
the whole indicator there: one surface per card, and the largest in it, so
|
|
196
|
-
"which leaf am I in" is not a question it raises. A rule drawn to answer it
|
|
197
|
-
would be a stroke bought back to replace the one unframing removed. */
|
|
198
|
-
.qm-prose.qm-control-box:focus-within {
|
|
199
|
-
border-color: var(--_qm-accent);
|
|
200
|
-
}
|
|
201
|
-
/* Empty-leaf ghost: the resolved `default:` as dim/italic ghost,
|
|
202
|
-
matching the scalar ghost rung. Rendered from a node decoration's data attr so
|
|
203
|
-
it stays out of the
|
|
204
|
-
document; `float`/`height:0` keep it from displacing the caret. */
|
|
180
|
+
/* Empty-leaf ghost, at the scalar ghost rung: a node decoration's data attr, so it
|
|
181
|
+
stays out of the document; `float`/`height: 0` keep it from displacing the caret. */
|
|
205
182
|
.qm-prose :global(.ProseMirror .qm-prose-placeholder::before) {
|
|
206
183
|
content: attr(data-placeholder);
|
|
207
184
|
color: var(--_qm-ink-ghost);
|
|
@@ -1,36 +1,32 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { LeafRegistry } from './leaves.js';
|
|
2
2
|
import './controls.css';
|
|
3
3
|
import type { Document, Quill, Addr } from '@quillmark/wasm';
|
|
4
4
|
import type { EditorErrorHandler } from '../core/errors.js';
|
|
5
5
|
interface Props {
|
|
6
6
|
doc: Document;
|
|
7
7
|
/** The schema this leaf reads its content through: `createField` binds a reader
|
|
8
|
-
* off it, so a stored string decodes by declared type
|
|
8
|
+
* off it, so a stored string decodes by its declared type. */
|
|
9
9
|
quill: Quill;
|
|
10
10
|
addr: Addr;
|
|
11
11
|
inline?: boolean;
|
|
12
12
|
plaintext?: boolean;
|
|
13
13
|
/**
|
|
14
|
-
* Draw no box: the card body's leaf, and nothing else.
|
|
15
|
-
*
|
|
16
|
-
* a
|
|
17
|
-
* row of controls. Only the body is paper, and only its caller knows it is.
|
|
14
|
+
* Draw no box: the card body's leaf, and nothing else. Not `block`, which is a
|
|
15
|
+
* different predicate: a `richtext` field without `inline` is block prose and
|
|
16
|
+
* still a control in a row of controls, so only the caller knows what is paper.
|
|
18
17
|
*/
|
|
19
18
|
unframed?: boolean;
|
|
20
|
-
/** Accessible name for a leaf
|
|
21
|
-
* card body. A leaf with a field label takes `labelledBy
|
|
19
|
+
/** Accessible name for a leaf nothing else names: an array element, or the
|
|
20
|
+
* card body. A leaf with a field label takes `labelledBy`. */
|
|
22
21
|
label?: string;
|
|
23
22
|
/** The field label's own id → `aria-labelledby` on the editable region. `for`
|
|
24
|
-
* cannot reach a `contenteditable
|
|
25
|
-
*
|
|
26
|
-
* the controller's `focus`. */
|
|
23
|
+
* cannot reach a `contenteditable`, so the association runs the other way and
|
|
24
|
+
* the label's click comes back through the controller's `focus`. */
|
|
27
25
|
labelledBy?: string;
|
|
28
26
|
/** The parked `description` (FieldLabel) → `aria-describedby`. */
|
|
29
27
|
describedBy?: string;
|
|
30
|
-
/** Ghost shown on the empty leaf
|
|
31
|
-
*
|
|
32
|
-
* (`resolveBodyGhost`). Reactive: a change is pushed into the mounted view,
|
|
33
|
-
* never a remount. */
|
|
28
|
+
/** Ghost shown on the empty leaf: an inline leaf's resolved `default:` or
|
|
29
|
+
* nothing, a body's always text (`resolveBodyGhost`). */
|
|
34
30
|
placeholder?: string;
|
|
35
31
|
/** Stable identity for the registry, stamped on the DOM node so a remount is
|
|
36
32
|
* visible as one. */
|
|
@@ -40,8 +36,9 @@ interface Props {
|
|
|
40
36
|
/** A commit landed on this leaf: the prose change lane. */
|
|
41
37
|
onChange?: (addr: Addr) => void;
|
|
42
38
|
onError?: EditorErrorHandler;
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
/** The editor's leaf registry (`leaves.ts`). Registering the controller is what
|
|
40
|
+
* makes this leaf a caret target and not merely a focus one. */
|
|
41
|
+
leaves?: LeafRegistry;
|
|
45
42
|
}
|
|
46
43
|
declare const ProseField: import("svelte").Component<Props, {
|
|
47
44
|
focus: () => void;
|
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
The slash menu. The leaf's own surface, not the
|
|
3
3
|
shell's: the trigger belongs to one caret in one leaf, so it is mounted by
|
|
4
|
-
`ProseField` beside the view it reads from, unlike the
|
|
4
|
+
`ProseField` beside the view it reads from, unlike the one shell-owned format
|
|
5
5
|
popover that follows whichever leaf is active.
|
|
6
6
|
|
|
7
|
-
The
|
|
7
|
+
The model is the codec's (`codec/slash.ts`: the trigger run, the query, the
|
|
8
8
|
highlighted index, and what each pick does). This component draws it and nothing
|
|
9
9
|
else. The keys are the leaf's keymap for the same reason: the caret stays in the
|
|
10
10
|
contenteditable while the menu is open, so a focus-taking listbox would move the
|
|
11
|
-
selection the insert is measured against. What is left here is the
|
|
12
|
-
buttons that pick, and that swallow their own mousedown so the caret stays put.
|
|
11
|
+
selection the insert is measured against. What is left here is mostly the pointer's
|
|
12
|
+
half: buttons that pick, and that swallow their own mousedown so the caret stays put.
|
|
13
13
|
|
|
14
|
-
The highlight has
|
|
15
|
-
index names, and a pointer entering an item
|
|
14
|
+
The highlight has one lane: `data-highlighted` marks whichever item the codec's
|
|
15
|
+
index names, and a pointer entering an item moves that index rather than painting a
|
|
16
16
|
second highlight the keyboard cannot reach (controls.css, the shared menu recipe).
|
|
17
|
+
The keyboard's one claim here is the scroll: the arrows move an index, and only the
|
|
18
|
+
chrome knows the port that index has to stay inside.
|
|
17
19
|
-->
|
|
18
20
|
<script lang="ts">
|
|
19
21
|
import { Popover } from 'bits-ui';
|
|
@@ -32,7 +34,7 @@
|
|
|
32
34
|
let { menu, leaf, label }: Props = $props();
|
|
33
35
|
|
|
34
36
|
/** Raised whenever the codec reports offers. Mirrored into local state rather than
|
|
35
|
-
* derived, because the primitive
|
|
37
|
+
* derived, because the primitive owns this prop: its own dismissal layer (an
|
|
36
38
|
* outside press) writes it, and a derived it cannot write to is an error rather
|
|
37
39
|
* than a dismissal. The next report re-syncs, so the codec stays the authority on
|
|
38
40
|
* whether a run is live. */
|
|
@@ -41,11 +43,21 @@
|
|
|
41
43
|
open = !!menu && menu.items.length > 0;
|
|
42
44
|
});
|
|
43
45
|
|
|
44
|
-
/** The root to portal
|
|
46
|
+
/** The root to portal into, resolved from the leaf's own DOM so the menu lands
|
|
45
47
|
* inside whichever `[data-qm-root]` raised it and inherits the consumer's dials;
|
|
46
48
|
* `undefined` falls back to bits-ui's `document.body`. */
|
|
47
49
|
const portalTarget = $derived(leaf()?.el.closest<HTMLElement>('[data-qm-root]') ?? undefined);
|
|
48
50
|
|
|
51
|
+
let surface = $state<HTMLElement | undefined>();
|
|
52
|
+
|
|
53
|
+
/** Keep the keyboard's cursor in the port: the arrows move an index rather than a
|
|
54
|
+
* focus, so a row the list scrolled past has nothing bringing it back. `nearest`
|
|
55
|
+
* scrolls the least that works, and no ancestor the row is already visible in. */
|
|
56
|
+
$effect(() => {
|
|
57
|
+
void menu?.index;
|
|
58
|
+
surface?.querySelector('[data-highlighted]')?.scrollIntoView({ block: 'nearest' });
|
|
59
|
+
});
|
|
60
|
+
|
|
49
61
|
/** Swallow the item's own mousedown: without it the browser focuses the item and
|
|
50
62
|
* blurs the leaf, taking the trigger run's caret with it. */
|
|
51
63
|
function keepFocus(e: MouseEvent): void {
|
|
@@ -55,12 +67,17 @@
|
|
|
55
67
|
|
|
56
68
|
<Popover.Root bind:open>
|
|
57
69
|
<Popover.Portal to={portalTarget}>
|
|
70
|
+
<!-- Hung off the trigger and flush against it: the anchor is the `/` itself
|
|
71
|
+
(`codec/slash.ts`), so the surface's top left corner sits at the line's bottom
|
|
72
|
+
edge under the character that opened the menu, and stays there as the query is
|
|
73
|
+
typed. Zero offset, because a gap reads as a surface floating near the caret
|
|
74
|
+
rather than one growing out of it. -->
|
|
58
75
|
<Popover.Content
|
|
59
76
|
customAnchor={menu?.anchor ?? null}
|
|
60
77
|
side="bottom"
|
|
61
78
|
align="start"
|
|
62
79
|
trapFocus={false}
|
|
63
|
-
sideOffset={
|
|
80
|
+
sideOffset={0}
|
|
64
81
|
onOpenAutoFocus={(e: Event) => e.preventDefault()}
|
|
65
82
|
onCloseAutoFocus={(e: Event) => e.preventDefault()}
|
|
66
83
|
>
|
|
@@ -74,22 +91,26 @@
|
|
|
74
91
|
a thing to click, tab into, or read. -->
|
|
75
92
|
<div
|
|
76
93
|
{...props}
|
|
94
|
+
bind:this={surface}
|
|
77
95
|
class="qm-slash-menu qm-menu-surface"
|
|
78
96
|
data-qm-root
|
|
79
97
|
role="group"
|
|
80
98
|
aria-label={label}
|
|
81
99
|
inert={!raised}
|
|
82
100
|
>
|
|
83
|
-
|
|
101
|
+
<!-- The row is the command as typed, leading `/` and all: the menu is a
|
|
102
|
+
completion of the run in the text, so the row and the run read the
|
|
103
|
+
same. -->
|
|
104
|
+
{#each menu?.items ?? [] as name, i (name)}
|
|
84
105
|
<button
|
|
85
106
|
type="button"
|
|
86
107
|
class="qm-menu-item qm-slash-item"
|
|
87
108
|
data-highlighted={i === menu?.index ? '' : undefined}
|
|
88
109
|
onmousedown={keepFocus}
|
|
89
|
-
onclick={() => leaf()?.slashPick(
|
|
90
|
-
onpointerenter={() => leaf()?.slashFocus(
|
|
110
|
+
onclick={() => leaf()?.slashPick(name)}
|
|
111
|
+
onpointerenter={() => leaf()?.slashFocus(name)}
|
|
91
112
|
>
|
|
92
|
-
{
|
|
113
|
+
/{name}
|
|
93
114
|
</button>
|
|
94
115
|
{/each}
|
|
95
116
|
</div>
|
|
@@ -104,20 +125,25 @@
|
|
|
104
125
|
`.qm-menu-surface` / `.qm-menu-item` (controls.css): the same recipe the enum
|
|
105
126
|
listbox and the card stack's kind menu draw, because all three are lists picked
|
|
106
127
|
from. What is here is this menu's own: a measure, so a long label does not stretch
|
|
107
|
-
the surface across the card,
|
|
128
|
+
the surface across the card, a scroll for a filtered list that stays long, and the
|
|
129
|
+
face. Monospace, where the sibling menus are not: they offer values of the document
|
|
130
|
+
and read as the prose around them, where these are commands, and the surface
|
|
131
|
+
already spells a literal in that face (the `code` mark, `codec/prose.css`). */
|
|
108
132
|
.qm-slash-menu {
|
|
109
133
|
min-width: 12rem;
|
|
110
134
|
max-height: 16rem;
|
|
111
135
|
overflow-y: auto;
|
|
136
|
+
font-family: var(--_qm-font-mono);
|
|
112
137
|
}
|
|
113
138
|
/* An item is a row of text, not a glyph: the whole row is the target, and the label
|
|
114
139
|
is the accessible name with nothing beside it. A button is a button, so the UA's
|
|
115
|
-
own box is taken back; what is left is the shared item recipe.
|
|
140
|
+
own box is taken back; what is left is the shared item recipe. The fill is not
|
|
141
|
+
taken back here: a scoped block is unlayered, so a reset would outrank the recipe's
|
|
142
|
+
highlight; the resting transparent is the recipe's own (controls.css). */
|
|
116
143
|
.qm-slash-item {
|
|
117
144
|
display: block;
|
|
118
145
|
width: 100%;
|
|
119
146
|
border: none;
|
|
120
|
-
background: none;
|
|
121
147
|
font: inherit;
|
|
122
148
|
color: inherit;
|
|
123
149
|
text-align: left;
|