@quillmark/svelte 0.6.0 → 0.7.1
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/THEMING.md +1 -1
- package/dist/core/codec/field.d.ts +15 -4
- package/dist/core/codec/field.js +13 -10
- package/dist/core/codec/index.d.ts +1 -1
- package/dist/core/codec/index.js +1 -1
- package/dist/core/codec/table-view.d.ts +4 -0
- package/dist/core/codec/table-view.js +10 -1
- package/dist/core/theme.css +14 -9
- package/dist/preset/recipes.css +15 -4
- package/dist/preview/bridge.d.ts +23 -0
- package/dist/preview/bridge.js +92 -35
- package/dist/preview/controller.d.ts +6 -5
- package/dist/preview/controller.js +13 -7
- package/dist/preview/index.js +3 -2
- package/dist/preview/paint.js +9 -1
- package/dist/visual/ArrayField.svelte +30 -23
- package/dist/visual/ArrayField.svelte.d.ts +9 -7
- package/dist/visual/Card.svelte +88 -45
- package/dist/visual/EnumField.svelte +4 -2
- package/dist/visual/Field.svelte +22 -2
- package/dist/visual/ObjectField.svelte +95 -75
- package/dist/visual/ObjectField.svelte.d.ts +7 -3
- package/dist/visual/ProseValue.svelte +165 -0
- package/dist/visual/ProseValue.svelte.d.ts +33 -0
- package/dist/visual/SlashMenu.svelte +7 -0
- package/dist/visual/VariantField.svelte +11 -6
- package/dist/visual/VariantField.svelte.d.ts +5 -1
- package/dist/visual/VisualEditorInner.svelte +29 -11
- package/dist/visual/hold.d.ts +17 -0
- package/dist/visual/hold.js +39 -0
- package/dist/visual/index.js +1 -1
- package/dist/visual/motion.d.ts +9 -0
- package/dist/visual/motion.js +10 -2
- package/dist/visual/strings.d.ts +4 -3
- package/package.json +2 -2
- package/dist/visual/ProseArrayElement.svelte +0 -139
- package/dist/visual/ProseArrayElement.svelte.d.ts +0 -25
|
@@ -1,33 +1,31 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
An `object` field → a nested subform over `properties` (declaration order),
|
|
3
|
-
committing the whole object by value on any nested change.
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
committing the whole object by value on any nested change. Leaf properties: a
|
|
4
|
+
scalar draws its own control, a content property mounts the by-value prose leaf
|
|
5
|
+
({@link ProseValue}) over the boundary's nested read, and a nested `array`/`object`
|
|
6
|
+
property renders a placeholder instead of recursing.
|
|
6
7
|
|
|
7
|
-
The nesting is a
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
exactly as a field's does, and the field's own label caps the band from outside it —
|
|
11
|
-
name, rule under the name, content, closing rule.
|
|
8
|
+
The nesting is a vertical at `--_qm-border`, the ladder's last stroke: the card's edge,
|
|
9
|
+
an open section's vertical one `--_qm-nest` in where the field is in one, this one a rung
|
|
10
|
+
further, the properties a rung inside it (ARCHITECTURE §"A plane is a tone").
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
Which edges draw is {@link Props.edges}, and the rule is that a boundary is stated
|
|
18
|
-
once: where a box already sits against the subform, that box IS the boundary and a
|
|
19
|
-
stroke under it would say so twice. So a field-level subform takes the band, and a
|
|
20
|
-
subform hanging off a control — a variant's cells under their discriminant, an
|
|
21
|
-
array element's properties under its summary row — takes the closing rule alone.
|
|
22
|
-
Sometimes two strokes and sometimes one: the open figure the card's bracket is.
|
|
12
|
+
One figure wherever it mounts: a variant's cells under their discriminant and an array
|
|
13
|
+
element's properties under its summary row sit at the depth a field-level subform sits
|
|
14
|
+
at, and the box above them states position rather than depth.
|
|
23
15
|
|
|
24
16
|
A property's ghosted `default:` is the static schema `sub.default`, not the
|
|
25
17
|
resolved provenance the top-level ghosts read (FIELD_PROVENANCE): `resolve`
|
|
26
18
|
carries no per-property row (an object field resolves as one row whose value is
|
|
27
19
|
the whole object).
|
|
20
|
+
|
|
21
|
+
Every caller hands down `contentAt`, which is `reader.getContentAt` with the field
|
|
22
|
+
already bound: a field-level subform passes the property key through untouched, a
|
|
23
|
+
variant passes its cell's, and an array's open row prefixes its index. So a cell
|
|
24
|
+
reads at whatever depth it sits, which nothing here knows.
|
|
28
25
|
-->
|
|
29
26
|
<script lang="ts">
|
|
30
|
-
import type { QuillFieldSchema } from '@quillmark/wasm';
|
|
27
|
+
import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
|
|
28
|
+
import { emptyContent } from '../core/codec/index.js';
|
|
31
29
|
import { controlKind, humanize, obliged } from './structure.js';
|
|
32
30
|
import { wording } from './strings.js';
|
|
33
31
|
import { propertyDomIds } from './domid.js';
|
|
@@ -37,6 +35,7 @@
|
|
|
37
35
|
import NumberField from './NumberField.svelte';
|
|
38
36
|
import BooleanField from './BooleanField.svelte';
|
|
39
37
|
import DateField from './DateField.svelte';
|
|
38
|
+
import ProseValue from './ProseValue.svelte';
|
|
40
39
|
|
|
41
40
|
interface Props {
|
|
42
41
|
value: Record<string, unknown> | undefined;
|
|
@@ -56,20 +55,16 @@
|
|
|
56
55
|
idBase?: string;
|
|
57
56
|
/** Accessible-name prefix used only on the `aria-label` fallback above. */
|
|
58
57
|
label?: string;
|
|
59
|
-
/**
|
|
60
|
-
|
|
58
|
+
/** The boundary's nested content read, rooted at this subform: `path` is a
|
|
59
|
+
* `PathStep[]` from here to the leaf, which the caller prefixes with whatever
|
|
60
|
+
* stands between this subform and the field (`reader.getContentAt`). Asked only
|
|
61
|
+
* for a content-typed property: on any other the read is not content and throws.
|
|
62
|
+
* `undefined` for a property the stored value does not reach. */
|
|
63
|
+
contentAt: (path: PathStep[]) => Content | undefined;
|
|
61
64
|
onCommit: (obj: Record<string, unknown>) => void;
|
|
62
65
|
}
|
|
63
|
-
let {
|
|
64
|
-
|
|
65
|
-
properties,
|
|
66
|
-
labelledBy,
|
|
67
|
-
describedBy,
|
|
68
|
-
idBase,
|
|
69
|
-
label,
|
|
70
|
-
edges = 'band',
|
|
71
|
-
onCommit
|
|
72
|
-
}: Props = $props();
|
|
66
|
+
let { value, properties, labelledBy, describedBy, idBase, label, contentAt, onCommit }: Props =
|
|
67
|
+
$props();
|
|
73
68
|
|
|
74
69
|
const t = wording();
|
|
75
70
|
const entries = $derived(Object.entries(properties ?? {}));
|
|
@@ -88,26 +83,48 @@
|
|
|
88
83
|
`${label != null ? `${label} ` : ''}${title(key, sub)}` +
|
|
89
84
|
(required(sub) ? ` ${t.strings.fieldRequired}` : '');
|
|
90
85
|
|
|
91
|
-
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
86
|
+
/** The prose cells' handles, keyed by property name — which is stable, a subform
|
|
87
|
+
* drawing its properties by declaration and never by position. A ref only where
|
|
88
|
+
* the DOM cannot answer: a `contenteditable` matches no focusable selector, and
|
|
89
|
+
* focusing the node is not focusing the view ({@link ProseValue.focus}). A scalar
|
|
90
|
+
* control is resolved off the markup, so a handle each would be five refs
|
|
91
|
+
* restating document order.
|
|
92
|
+
*
|
|
93
|
+
* `$state` for the binding's sake: `bind:this` into a property of a plain object
|
|
94
|
+
* is a write Svelte cannot track, and it says so once per cell per render
|
|
95
|
+
* (`ArrayField` keeps its element refs the same way). */
|
|
96
|
+
const proseEls: Record<string, { focus: () => void } | undefined> = $state({});
|
|
97
97
|
let rootEl = $state<HTMLElement | undefined>();
|
|
98
98
|
const FOCUSABLE = 'input, select, button, [tabindex]:not([tabindex="-1"])';
|
|
99
|
+
/** Take the caret: the first property that has somewhere to put it, in document
|
|
100
|
+
* order — a subform has no single control of its own to land on, and a property
|
|
101
|
+
* standing the unsupported line has none either, so the walk steps past it. An
|
|
102
|
+
* empty or wholly unsupported subform lands nothing. */
|
|
99
103
|
export function focus(): void {
|
|
100
|
-
|
|
104
|
+
for (const cell of propCells()) if (landOn(cell)) return;
|
|
101
105
|
}
|
|
102
|
-
/** A label click for the
|
|
103
|
-
* focus lives on a segment
|
|
104
|
-
* where the label is rather than on
|
|
106
|
+
/** A label click for the properties `for` cannot reach: the date control, whose
|
|
107
|
+
* focus lives on a segment, and a prose cell, which is a `contenteditable`. Scoped
|
|
108
|
+
* to that property's own cell, so the click lands where the label is rather than on
|
|
109
|
+
* the subform's first control. */
|
|
105
110
|
function focusProp(key: string): void {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
111
|
+
for (const cell of propCells()) if (cell.dataset.qmProp === key) return void landOn(cell);
|
|
112
|
+
}
|
|
113
|
+
// Matched on the dataset: a key is the schema's own string, and spelling one into a
|
|
114
|
+
// selector needs `CSS.escape`, which the test DOM does not carry.
|
|
115
|
+
function propCells(): HTMLElement[] {
|
|
116
|
+
return [...(rootEl?.querySelectorAll<HTMLElement>('[data-qm-prop]') ?? [])];
|
|
117
|
+
}
|
|
118
|
+
/** Land in one property's cell, answering whether it took the caret. */
|
|
119
|
+
function landOn(cell: HTMLElement): boolean {
|
|
120
|
+
const prose = proseEls[cell.dataset.qmProp ?? ''];
|
|
121
|
+
if (prose) {
|
|
122
|
+
prose.focus();
|
|
123
|
+
return true;
|
|
110
124
|
}
|
|
125
|
+
const el = cell.querySelector<HTMLElement>(FOCUSABLE);
|
|
126
|
+
el?.focus();
|
|
127
|
+
return !!el;
|
|
111
128
|
}
|
|
112
129
|
|
|
113
130
|
function commitProp(key: string, v: unknown): void {
|
|
@@ -127,7 +144,6 @@
|
|
|
127
144
|
<div
|
|
128
145
|
bind:this={rootEl}
|
|
129
146
|
class="qm-object"
|
|
130
|
-
class:close={edges === 'close'}
|
|
131
147
|
role="group"
|
|
132
148
|
aria-labelledby={labelledBy}
|
|
133
149
|
aria-describedby={describedBy}
|
|
@@ -138,8 +154,8 @@
|
|
|
138
154
|
{@const named = ids ? undefined : fallbackName(key, sub)}
|
|
139
155
|
{@const describes = sub.description && ids ? ids.description : undefined}
|
|
140
156
|
<!-- `for` reaches the four labelable controls; the date field's focus lives on a
|
|
141
|
-
segment
|
|
142
|
-
level up. -->
|
|
157
|
+
segment and a prose cell is a `contenteditable`, so those take the click
|
|
158
|
+
handoff instead, exactly as `Field` does one level up. -->
|
|
143
159
|
{@const labelable =
|
|
144
160
|
kind === 'text' || kind === 'enum' || kind === 'number' || kind === 'boolean'}
|
|
145
161
|
<div class="qm-object-prop" data-qm-prop={key}>
|
|
@@ -200,6 +216,18 @@
|
|
|
200
216
|
placeholder={sub.default != null ? String(sub.default) : undefined}
|
|
201
217
|
onCommit={(v) => commitProp(key, v)}
|
|
202
218
|
/>
|
|
219
|
+
{:else if kind === 'prose'}
|
|
220
|
+
<!-- Keyed by name, so a re-derive of the container this cell commits whole
|
|
221
|
+
leaves the leaf mounted and the caret in it. -->
|
|
222
|
+
<ProseValue
|
|
223
|
+
bind:this={proseEls[key]}
|
|
224
|
+
content={() => contentAt([key]) ?? emptyContent()}
|
|
225
|
+
plaintext={sub.type === 'plaintext'}
|
|
226
|
+
label={named}
|
|
227
|
+
labelledBy={ids?.label}
|
|
228
|
+
describedBy={describes}
|
|
229
|
+
onChange={(rt) => commitProp(key, rt)}
|
|
230
|
+
/>
|
|
203
231
|
{:else}
|
|
204
232
|
<!-- A property the subform does not recurse into. What it says is what the
|
|
205
233
|
user can do about it, which is edit the field from the source surface;
|
|
@@ -211,46 +239,38 @@
|
|
|
211
239
|
</div>
|
|
212
240
|
|
|
213
241
|
<style>
|
|
214
|
-
/* The
|
|
215
|
-
|
|
216
|
-
`padding-block` at the same rung on both sides is the other half of that.
|
|
242
|
+
/* The vertical and the rung between it and the properties, `--_qm-nest` being the step
|
|
243
|
+
the card and the section hold too.
|
|
217
244
|
|
|
218
245
|
`--_qm-border` and not `--_qm-border-faint`: this is the stroke that structures
|
|
219
|
-
(ARCHITECTURE §"A plane is a tone"), the one the
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
job.
|
|
246
|
+
(ARCHITECTURE §"A plane is a tone"), the one the card's edge and the section's
|
|
247
|
+
vertical read. `faint` separates without structuring — a table's interior lines
|
|
248
|
+
under a frame that is doing the structuring — which is the other job.
|
|
223
249
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
250
|
+
`padding-block` is the stroke's end caps, the same rung and the same reason as a
|
|
251
|
+
section's (`Card.svelte`). A cap and not a leading gap: what stands the subform off
|
|
252
|
+
the label or the box above it is still that stacker's own.
|
|
253
|
+
|
|
254
|
+
The tracks are the section's count over the subform's own width — `--cols` inherits,
|
|
255
|
+
so the container query stepping the section's capacity steps this with it, and there
|
|
256
|
+
is no second query container and nothing measured. The edges are not the section's:
|
|
257
|
+
the inset is the nesting, and a cell lining up with the field above it would claim a
|
|
258
|
+
depth it is not at. */
|
|
232
259
|
.qm-object {
|
|
233
260
|
display: grid;
|
|
234
261
|
grid-template-columns: repeat(var(--cols), 1fr);
|
|
235
262
|
gap: var(--_qm-space-2);
|
|
236
|
-
border-
|
|
237
|
-
padding-
|
|
238
|
-
|
|
239
|
-
/* Hanging off a control (a variant's discriminant, an array element's summary row):
|
|
240
|
-
that box is the top boundary already, and a stroke under it would state the
|
|
241
|
-
boundary twice. The closing rule is not optional in the same breath — without it
|
|
242
|
-
the properties float between the box above and whatever follows. */
|
|
243
|
-
.qm-object.close {
|
|
244
|
-
border-block-start: none;
|
|
245
|
-
padding-block-start: 0;
|
|
263
|
+
border-inline-start: var(--_qm-border-width) solid var(--_qm-border);
|
|
264
|
+
padding-inline-start: var(--_qm-nest);
|
|
265
|
+
padding-block: var(--_qm-space);
|
|
246
266
|
}
|
|
247
267
|
/* A property measures like a field: two tracks over the subform's own rows, so a
|
|
248
268
|
label wrapping in one column leaves its control on the row's baseline rather than a
|
|
249
269
|
line below it — `Field.svelte`'s rule for a row-sharing field, one level in. The
|
|
250
270
|
tracks are this grid's own; the section's belong to the fields.
|
|
251
271
|
|
|
252
|
-
`min-width: 0`
|
|
253
|
-
|
|
272
|
+
`min-width: 0` because a property overflowing its `1fr` grows the track, and the
|
|
273
|
+
subform with it, past the field that holds it. */
|
|
254
274
|
.qm-object-prop {
|
|
255
275
|
display: grid;
|
|
256
276
|
grid-row: span 2;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QuillFieldSchema } from '@quillmark/wasm';
|
|
1
|
+
import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
|
|
2
2
|
interface Props {
|
|
3
3
|
value: Record<string, unknown> | undefined;
|
|
4
4
|
properties: Record<string, QuillFieldSchema> | undefined;
|
|
@@ -17,8 +17,12 @@ interface Props {
|
|
|
17
17
|
idBase?: string;
|
|
18
18
|
/** Accessible-name prefix used only on the `aria-label` fallback above. */
|
|
19
19
|
label?: string;
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/** The boundary's nested content read, rooted at this subform: `path` is a
|
|
21
|
+
* `PathStep[]` from here to the leaf, which the caller prefixes with whatever
|
|
22
|
+
* stands between this subform and the field (`reader.getContentAt`). Asked only
|
|
23
|
+
* for a content-typed property: on any other the read is not content and throws.
|
|
24
|
+
* `undefined` for a property the stored value does not reach. */
|
|
25
|
+
contentAt: (path: PathStep[]) => Content | undefined;
|
|
22
26
|
onCommit: (obj: Record<string, unknown>) => void;
|
|
23
27
|
}
|
|
24
28
|
declare const ObjectField: import("svelte").Component<Props, {
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A prose leaf the parent commits by value: an element of a `richtext` (fixture
|
|
3
|
+
`keywords`) or `plaintext` (`errata`) array, and a content cell of a subform — an
|
|
4
|
+
`object`'s property, a variant's cell ({@link ObjectField}). None of them is
|
|
5
|
+
`applyChange`-addressable: `Addr.field` is a flat name, so `keywords[0]` and
|
|
6
|
+
`contact.note` have no op address. So this is not a `createField` leaf: it mounts a
|
|
7
|
+
minimal PM view over the codec's decode/encode + inline schema, and on every edit
|
|
8
|
+
hands the re-encoded `Content` up to the parent — {@link ArrayField}, which commits
|
|
9
|
+
the whole array, or {@link ObjectField}, which commits the whole container
|
|
10
|
+
(`writer.set(field, next)`). Anchors inside a value are dropped on that write:
|
|
11
|
+
acceptable for inline refs. Mounts once per stable key (an element's session id, a
|
|
12
|
+
property's name), so the parent's re-derive leaves the caret where it is.
|
|
13
|
+
|
|
14
|
+
The content is read, not passed: `reader.getContentAt(addr, path)` decodes through
|
|
15
|
+
the codec the leaf's own declared type names, so what it rests as — the content
|
|
16
|
+
object, a `plaintext` literal, the authored string a transport door left — stops
|
|
17
|
+
being the row's or the cell's business. Read once, at mount, since that is when this
|
|
18
|
+
leaf takes its state.
|
|
19
|
+
|
|
20
|
+
One textblock wherever it mounts, no islands and no slash menu. `plaintext` is
|
|
21
|
+
inline at the codec, so that is the declared shape for every plaintext leaf; a block
|
|
22
|
+
`richtext` is held one rung narrower than it declares, having no room for a second
|
|
23
|
+
paragraph in the figure a repeater row and a subform cell draw.
|
|
24
|
+
-->
|
|
25
|
+
<script lang="ts">
|
|
26
|
+
import { onMount } from 'svelte';
|
|
27
|
+
import { EditorState, Selection } from 'prosemirror-state';
|
|
28
|
+
import { EditorView } from 'prosemirror-view';
|
|
29
|
+
import {
|
|
30
|
+
decode,
|
|
31
|
+
pmToContent,
|
|
32
|
+
inlineSchema,
|
|
33
|
+
plaintextSchema,
|
|
34
|
+
proseAttributes,
|
|
35
|
+
proseLeafPlugins,
|
|
36
|
+
buildLineIndex,
|
|
37
|
+
usvToPM
|
|
38
|
+
} from '../core/codec/index.js';
|
|
39
|
+
import './controls.css';
|
|
40
|
+
import type { Content } from '@quillmark/wasm';
|
|
41
|
+
|
|
42
|
+
interface Props {
|
|
43
|
+
/** This leaf's content, read at mount (the parent's boundary read). A thunk
|
|
44
|
+
* rather than a value: the parent re-derives per revision and this leaf takes
|
|
45
|
+
* its state once, so a value prop would be a boundary read per leaf per render,
|
|
46
|
+
* all but one of them discarded. */
|
|
47
|
+
content: () => Content;
|
|
48
|
+
/** The mark-free schema (a `plaintext` leaf): literal text, no formatting,
|
|
49
|
+
* exactly as the scalar field of that type mounts. */
|
|
50
|
+
plaintext?: boolean;
|
|
51
|
+
/** Accessible name for the editable region, where nothing else names it: an
|
|
52
|
+
* array has no per-element label. A cell with a label element takes
|
|
53
|
+
* `labelledBy`. */
|
|
54
|
+
label?: string;
|
|
55
|
+
/** The label's own id → `aria-labelledby`. `for` cannot reach a
|
|
56
|
+
* `contenteditable`, so a subform cell's label associates the other way and
|
|
57
|
+
* hands its click back through {@link focus}. */
|
|
58
|
+
labelledBy?: string;
|
|
59
|
+
/** The parked `description` (FieldLabel) → `aria-describedby`. */
|
|
60
|
+
describedBy?: string;
|
|
61
|
+
onChange: (rt: Content) => void;
|
|
62
|
+
/** Raw keydown, for a container whose own keys run through this leaf: the
|
|
63
|
+
* array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
|
|
64
|
+
* keymap, so the state it reads is the one this keystroke has yet to change. */
|
|
65
|
+
onKey?: (e: KeyboardEvent) => void;
|
|
66
|
+
}
|
|
67
|
+
let {
|
|
68
|
+
content,
|
|
69
|
+
plaintext = false,
|
|
70
|
+
label,
|
|
71
|
+
labelledBy,
|
|
72
|
+
describedBy,
|
|
73
|
+
onChange,
|
|
74
|
+
onKey
|
|
75
|
+
}: Props = $props();
|
|
76
|
+
|
|
77
|
+
let containerEl: HTMLDivElement | undefined = $state();
|
|
78
|
+
let view: EditorView | undefined;
|
|
79
|
+
/** Take the caret: what a parent placing focus on this leaf calls. The view's
|
|
80
|
+
* focus, not the element's: a PM view restores its selection, where a bare DOM
|
|
81
|
+
* focus on a contenteditable leaves the caret unplaced. And the reveal beside it,
|
|
82
|
+
* since PM's focus prevents the scroll a `string` element's input takes by
|
|
83
|
+
* default — an array whose two item types revealed differently is one row of it
|
|
84
|
+
* landing off screen. */
|
|
85
|
+
export function focus(): void {
|
|
86
|
+
if (!view) return;
|
|
87
|
+
view.focus();
|
|
88
|
+
view.dom.scrollIntoView({ block: 'nearest' });
|
|
89
|
+
}
|
|
90
|
+
/** Take the caret to USV `pos` in this leaf's own content: what a landing on an
|
|
91
|
+
* element address resolves to when the compile answered a cluster-exact offset
|
|
92
|
+
* (`leaves.ts`). `createField`'s body over the view this component owns.
|
|
93
|
+
*
|
|
94
|
+
* The index is built here rather than held: a landing is a discrete act, and one
|
|
95
|
+
* doc walk per click is cheaper than a cache to invalidate per keystroke. `usvToPM`
|
|
96
|
+
* clamps, so an offset past a value the compile has moved on from lands the end.
|
|
97
|
+
*
|
|
98
|
+
* `Selection.near`, not `TextSelection.create`: the mapped position can be
|
|
99
|
+
* non-inline. Focus first, then the caret, flagged for scroll — the order and the
|
|
100
|
+
* reasons are `FieldController.setCaret`'s (`codec/field.ts`). */
|
|
101
|
+
export function setCaret(pos: number): void {
|
|
102
|
+
if (!view) return;
|
|
103
|
+
const pm = usvToPM(buildLineIndex(view.state.doc), pos);
|
|
104
|
+
const sel = Selection.near(view.state.doc.resolve(pm));
|
|
105
|
+
view.focus();
|
|
106
|
+
view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
onMount(() => {
|
|
110
|
+
if (!containerEl) return;
|
|
111
|
+
// An inline, value-by-value leaf: the same keymap and plugin stack a
|
|
112
|
+
// `createField` leaf mounts (shared `proseLeafPlugins`), minus the
|
|
113
|
+
// anchor-position plugin (anchors are dropped on the parent's value write, per
|
|
114
|
+
// the header). Its own `dispatchTransaction` hands `Content` up.
|
|
115
|
+
//
|
|
116
|
+
// The schema is the declared type's, as `createField` picks it: `plaintext`
|
|
117
|
+
// declares no mark types, so there is nothing to toggle, to paste in, or to
|
|
118
|
+
// mint a shorthand with.
|
|
119
|
+
const schema = plaintext ? plaintextSchema : inlineSchema;
|
|
120
|
+
const pmDoc = decode(content(), schema);
|
|
121
|
+
const state = EditorState.create({
|
|
122
|
+
doc: pmDoc,
|
|
123
|
+
plugins: proseLeafPlugins(schema, { inline: true })
|
|
124
|
+
});
|
|
125
|
+
const mounted = new EditorView(containerEl, {
|
|
126
|
+
state,
|
|
127
|
+
// Which of `aria-label` / `aria-labelledby` wins is the codec's one answer
|
|
128
|
+
// (`proseAttributes`), so a cell carrying a label element and a row carrying
|
|
129
|
+
// none cannot name their regions by different rules.
|
|
130
|
+
attributes: proseAttributes({ label, labelledBy, describedBy }),
|
|
131
|
+
dispatchTransaction(tr) {
|
|
132
|
+
const next = mounted.state.apply(tr);
|
|
133
|
+
mounted.updateState(next);
|
|
134
|
+
if (tr.docChanged) onChange(pmToContent(next.doc));
|
|
135
|
+
},
|
|
136
|
+
handleDOMEvents: {
|
|
137
|
+
keydown: (_v, e) => {
|
|
138
|
+
onKey?.(e);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
view = mounted;
|
|
144
|
+
return () => {
|
|
145
|
+
view = undefined;
|
|
146
|
+
mounted.destroy();
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
</script>
|
|
150
|
+
|
|
151
|
+
<!-- `.qm-control-box` (controls.css) is the whole box, so an array of `richtext` and
|
|
152
|
+
an array of `string` render rows of equal height, and a subform's prose cell measures
|
|
153
|
+
like the text cell beside it. No floor: the reset in `core/codec/prose.css` makes one
|
|
154
|
+
line of prose measure one line. Width is the row's or the cell's to give: the leaf
|
|
155
|
+
fills the track it is placed in. -->
|
|
156
|
+
<div bind:this={containerEl} class="qm-prose-value qm-control-box qm-focus-ring-within"></div>
|
|
157
|
+
|
|
158
|
+
<style>
|
|
159
|
+
/* Caret-primary, matching ProseField: the contenteditable's own outline is
|
|
160
|
+
dropped and the ring rides the wrapper (`qm-focus-ring-within`,
|
|
161
|
+
controls.css), which is where the box is. */
|
|
162
|
+
.qm-prose-value :global(.ProseMirror) {
|
|
163
|
+
outline: none;
|
|
164
|
+
}
|
|
165
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import './controls.css';
|
|
2
|
+
import type { Content } from '@quillmark/wasm';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** This leaf's content, read at mount (the parent's boundary read). A thunk
|
|
5
|
+
* rather than a value: the parent re-derives per revision and this leaf takes
|
|
6
|
+
* its state once, so a value prop would be a boundary read per leaf per render,
|
|
7
|
+
* all but one of them discarded. */
|
|
8
|
+
content: () => Content;
|
|
9
|
+
/** The mark-free schema (a `plaintext` leaf): literal text, no formatting,
|
|
10
|
+
* exactly as the scalar field of that type mounts. */
|
|
11
|
+
plaintext?: boolean;
|
|
12
|
+
/** Accessible name for the editable region, where nothing else names it: an
|
|
13
|
+
* array has no per-element label. A cell with a label element takes
|
|
14
|
+
* `labelledBy`. */
|
|
15
|
+
label?: string;
|
|
16
|
+
/** The label's own id → `aria-labelledby`. `for` cannot reach a
|
|
17
|
+
* `contenteditable`, so a subform cell's label associates the other way and
|
|
18
|
+
* hands its click back through {@link focus}. */
|
|
19
|
+
labelledBy?: string;
|
|
20
|
+
/** The parked `description` (FieldLabel) → `aria-describedby`. */
|
|
21
|
+
describedBy?: string;
|
|
22
|
+
onChange: (rt: Content) => void;
|
|
23
|
+
/** Raw keydown, for a container whose own keys run through this leaf: the
|
|
24
|
+
* array repeater's Enter/Backspace (`ArrayField`). Fires before the view's own
|
|
25
|
+
* keymap, so the state it reads is the one this keystroke has yet to change. */
|
|
26
|
+
onKey?: (e: KeyboardEvent) => void;
|
|
27
|
+
}
|
|
28
|
+
declare const ProseValue: import("svelte").Component<Props, {
|
|
29
|
+
focus: () => void;
|
|
30
|
+
setCaret: (pos: number) => void;
|
|
31
|
+
}, "">;
|
|
32
|
+
type ProseValue = ReturnType<typeof ProseValue>;
|
|
33
|
+
export default ProseValue;
|
|
@@ -72,8 +72,15 @@
|
|
|
72
72
|
edge under the character that opened the menu, and stays there as the query is
|
|
73
73
|
typed. Zero offset, because a gap reads as a surface floating near the caret
|
|
74
74
|
rather than one growing out of it. -->
|
|
75
|
+
<!-- The boundary is the root the surface portals into, the box that clips it where
|
|
76
|
+
the consumer scrolls one ({@link FormatPopover} states it in full). It is also
|
|
77
|
+
what keeps the row scroll above inside the list: `nearest` scrolls any ancestor
|
|
78
|
+
the row is not visible in, and a surface raised into room the pane clips leaves
|
|
79
|
+
the pane one of them — so the scroll moves the pane, and with it the caret this
|
|
80
|
+
menu is anchored to. -->
|
|
75
81
|
<Popover.Content
|
|
76
82
|
customAnchor={menu?.anchor ?? null}
|
|
83
|
+
collisionBoundary={portalTarget ?? []}
|
|
77
84
|
side="bottom"
|
|
78
85
|
align="start"
|
|
79
86
|
trapFocus={false}
|
|
@@ -4,14 +4,16 @@
|
|
|
4
4
|
a container, `{value: <member>, …that member's fields}`, and commits whole: it is one
|
|
5
5
|
cell to `Addr`, so there is no per-cell write address.
|
|
6
6
|
|
|
7
|
-
The cells are {@link ObjectField}, which already draws a
|
|
7
|
+
The cells are {@link ObjectField}, which already draws a leaf field set keyed by
|
|
8
8
|
name over a container its parent commits by value. It takes the whole container as its
|
|
9
9
|
`value` and the world's declaration as its `properties`, so what it hands back is the
|
|
10
10
|
container with one cell written — and cells outside the drawn world ride through
|
|
11
|
-
untouched rather than needing to be merged back.
|
|
11
|
+
untouched rather than needing to be merged back. A content cell reads at the key it
|
|
12
|
+
declares: the boundary's schema walk unions the worlds, so a dormant cell reads absent
|
|
13
|
+
rather than raising.
|
|
12
14
|
-->
|
|
13
15
|
<script lang="ts">
|
|
14
|
-
import type { QuillFieldSchema } from '@quillmark/wasm';
|
|
16
|
+
import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
|
|
15
17
|
import EnumField from './EnumField.svelte';
|
|
16
18
|
import ObjectField from './ObjectField.svelte';
|
|
17
19
|
import {
|
|
@@ -34,6 +36,10 @@
|
|
|
34
36
|
id?: string;
|
|
35
37
|
labelledBy?: string;
|
|
36
38
|
describedBy?: string;
|
|
39
|
+
/** The boundary's nested content read for this field, which the cells take
|
|
40
|
+
* unprefixed: a variant's cell is one key in from the container it rests as, so
|
|
41
|
+
* a `plaintext` cell's path is its own name ({@link ObjectField}). */
|
|
42
|
+
contentAt: (path: PathStep[]) => Content | undefined;
|
|
37
43
|
onCommit: (v: Record<string, unknown> | undefined) => void;
|
|
38
44
|
optionAllowed?: (value: string) => boolean;
|
|
39
45
|
enumDisallowed?: 'hide' | 'disable';
|
|
@@ -46,6 +52,7 @@
|
|
|
46
52
|
id,
|
|
47
53
|
labelledBy,
|
|
48
54
|
describedBy,
|
|
55
|
+
contentAt,
|
|
49
56
|
onCommit,
|
|
50
57
|
optionAllowed,
|
|
51
58
|
enumDisallowed
|
|
@@ -73,8 +80,6 @@
|
|
|
73
80
|
flip would carry it between them. -->
|
|
74
81
|
{#if cells}
|
|
75
82
|
{#key member}
|
|
76
|
-
<!-- `close`: the discriminant's box directly above IS the cells' top boundary,
|
|
77
|
-
so the band's opening stroke would state it twice. -->
|
|
78
83
|
<ObjectField
|
|
79
84
|
value={value ?? {}}
|
|
80
85
|
properties={cells}
|
|
@@ -82,7 +87,7 @@
|
|
|
82
87
|
idBase={id}
|
|
83
88
|
{labelledBy}
|
|
84
89
|
{describedBy}
|
|
85
|
-
|
|
90
|
+
{contentAt}
|
|
86
91
|
onCommit={(obj) => onCommit(obj)}
|
|
87
92
|
/>
|
|
88
93
|
{/key}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QuillFieldSchema } from '@quillmark/wasm';
|
|
1
|
+
import type { Content, PathStep, QuillFieldSchema } from '@quillmark/wasm';
|
|
2
2
|
interface Props {
|
|
3
3
|
/** The stored container, or undefined while the field is unset. */
|
|
4
4
|
value: Record<string, unknown> | undefined;
|
|
@@ -12,6 +12,10 @@ interface Props {
|
|
|
12
12
|
id?: string;
|
|
13
13
|
labelledBy?: string;
|
|
14
14
|
describedBy?: string;
|
|
15
|
+
/** The boundary's nested content read for this field, which the cells take
|
|
16
|
+
* unprefixed: a variant's cell is one key in from the container it rests as, so
|
|
17
|
+
* a `plaintext` cell's path is its own name ({@link ObjectField}). */
|
|
18
|
+
contentAt: (path: PathStep[]) => Content | undefined;
|
|
15
19
|
onCommit: (v: Record<string, unknown> | undefined) => void;
|
|
16
20
|
optionAllowed?: (value: string) => boolean;
|
|
17
21
|
enumDisallowed?: 'hide' | 'disable';
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
} from './diagnostics.js';
|
|
67
67
|
import { fieldDomIds, groupPanelId } from './domid.js';
|
|
68
68
|
import { createLeafRegistry, type FieldControl } from './leaves.js';
|
|
69
|
-
import { reorder } from './motion.js';
|
|
69
|
+
import { reorder, reorderTrips } from './motion.js';
|
|
70
70
|
import { tipsChannel } from './tips.js';
|
|
71
71
|
import { patchEditorExt } from './ext.js';
|
|
72
72
|
import Card from './Card.svelte';
|
|
@@ -185,6 +185,9 @@
|
|
|
185
185
|
};
|
|
186
186
|
let mainCard = $state<CardHandle | undefined>(undefined);
|
|
187
187
|
let cardRefs = $state<(CardHandle | undefined)[]>([]);
|
|
188
|
+
/** The slot each card sits in: what a reorder animates, and so what the post-mutation
|
|
189
|
+
* scroll waits on before it measures anything. */
|
|
190
|
+
let slotEls = $state<(HTMLElement | undefined)[]>([]);
|
|
188
191
|
/** The stack's own element: it carries `data-qm-root`, so it is what the kind
|
|
189
192
|
* menu portals into, the way each leaf's surface resolves its nearest root. */
|
|
190
193
|
let rootEl = $state<HTMLElement | undefined>(undefined);
|
|
@@ -327,16 +330,29 @@
|
|
|
327
330
|
* on a document of any length an insert lands off-screen, and a
|
|
328
331
|
* viewport that does not follow reads as nothing happening.
|
|
329
332
|
*
|
|
330
|
-
* Coalesced on
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
+
* Coalesced on the last call: each takes a ticket and a continuation holding a stale
|
|
334
|
+
* one drops out, so two mutations inside a tick run one smooth scroll to the last of
|
|
335
|
+
* them rather than two fighting over the same scroller. A ticket rather than the id
|
|
336
|
+
* being scrolled to, because an insert and a move of the *same* card are two calls
|
|
337
|
+
* naming one id, on different terms — `center` against `nearest`.
|
|
333
338
|
*/
|
|
334
|
-
let
|
|
339
|
+
let scrollTicket = 0;
|
|
335
340
|
async function scrollCardIntoView(id: string, block: ScrollLogicalPosition): Promise<void> {
|
|
336
|
-
|
|
341
|
+
const ticket = ++scrollTicket;
|
|
337
342
|
if (!(await span.resumes(tick()))) return;
|
|
338
|
-
if (
|
|
339
|
-
|
|
343
|
+
if (ticket !== scrollTicket) return;
|
|
344
|
+
// A reorder is running by this flush and holds the slot at its pre-move position
|
|
345
|
+
// for the length of the trip (`motion.ts`), so asking now measures the box the
|
|
346
|
+
// card is leaving — where a `nearest` would take the stack back to for the card
|
|
347
|
+
// that moved in from off screen. The trip says the card moved; the scroll follows
|
|
348
|
+
// it to where it landed. `allSettled`, because a second reorder cancels the first
|
|
349
|
+
// and a cancelled run rejects.
|
|
350
|
+
const slot = slotEls[cardIndexOf(id)];
|
|
351
|
+
const trips = slot ? reorderTrips(slot) : [];
|
|
352
|
+
if (trips.length) {
|
|
353
|
+
if (!(await span.resumes(Promise.allSettled(trips.map((trip) => trip.finished))))) return;
|
|
354
|
+
if (ticket !== scrollTicket) return;
|
|
355
|
+
}
|
|
340
356
|
const i = cardIndexOf(id);
|
|
341
357
|
if (i >= 0) cardRefs[i]?.scrollIntoViewCard(block);
|
|
342
358
|
}
|
|
@@ -813,7 +829,7 @@
|
|
|
813
829
|
rather than being slid across. It is also the shape `animate:` asks for, being the
|
|
814
830
|
keyed block's only child. -->
|
|
815
831
|
{#each model.cards as c, i (c.id)}
|
|
816
|
-
<div class="qm-card-slot" animate:reorder={isReordering}>
|
|
832
|
+
<div class="qm-card-slot" bind:this={slotEls[i]} animate:reorder={isReordering}>
|
|
817
833
|
<Card
|
|
818
834
|
bind:this={cardRefs[i]}
|
|
819
835
|
card={c}
|
|
@@ -874,10 +890,12 @@
|
|
|
874
890
|
>{marked ? merged.addCard : ''}</DropdownMenu.Trigger
|
|
875
891
|
>
|
|
876
892
|
<DropdownMenu.Portal to={rootEl}>
|
|
877
|
-
<DropdownMenu.Content sideOffset={4}>
|
|
893
|
+
<DropdownMenu.Content collisionBoundary={rootEl ?? []} sideOffset={4}>
|
|
878
894
|
<!-- Portalled out of the row but into the stack's root, and carrying the
|
|
879
895
|
marker itself: floating is still a detached subtree to the
|
|
880
|
-
derivation, like FormatPopover and the enum listbox.
|
|
896
|
+
derivation, like FormatPopover and the enum listbox. The root is the
|
|
897
|
+
flip's boundary for the reason it is theirs: it is the box that clips
|
|
898
|
+
the surface where the consumer scrolls one. -->
|
|
881
899
|
<div class="qm-menu-surface" data-qm-root>
|
|
882
900
|
{#each kinds as k (k)}
|
|
883
901
|
<DropdownMenu.Item class="qm-menu-item" onSelect={() => addCard(atIndex, k)}
|