@quillmark/svelte 0.19.0 → 0.20.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/NOTICE +4 -2
- package/dist/core/codec/blocks.d.ts +11 -2
- package/dist/core/codec/blocks.js +45 -14
- package/dist/core/codec/breaks.d.ts +3 -2
- package/dist/core/codec/breaks.js +3 -2
- package/dist/core/codec/decode.d.ts +7 -3
- package/dist/core/codec/decode.js +34 -8
- package/dist/core/codec/field.d.ts +11 -5
- package/dist/core/codec/field.js +11 -8
- package/dist/core/codec/index.d.ts +1 -1
- package/dist/core/codec/index.js +1 -1
- package/dist/core/codec/islands.d.ts +3 -1
- package/dist/core/codec/islands.js +8 -5
- package/dist/core/codec/markdown-paste.d.ts +9 -0
- package/dist/core/codec/markdown-paste.js +49 -0
- package/dist/core/codec/prose.css +109 -1
- package/dist/core/codec/schema.d.ts +8 -2
- package/dist/core/codec/schema.js +16 -5
- package/dist/core/codec/slash.d.ts +9 -6
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-paste.d.ts +7 -0
- package/dist/core/codec/table-paste.js +132 -0
- package/dist/core/codec/table-view.d.ts +12 -0
- package/dist/core/codec/table-view.js +401 -57
- package/dist/core/codec/table.d.ts +38 -16
- package/dist/core/codec/table.js +106 -35
- package/dist/core/theme.css +1 -1
- package/dist/visual/ArrayField.svelte +4 -0
- package/dist/visual/Field.svelte +8 -2
- package/dist/visual/Field.svelte.d.ts +1 -1
- package/dist/visual/FieldHint.svelte +62 -10
- package/dist/visual/FormatPopover.svelte +27 -31
- package/dist/visual/FormatPopover.svelte.d.ts +2 -2
- package/dist/visual/MatrixAdd.svelte +13 -132
- package/dist/visual/MatrixAdd.svelte.d.ts +2 -5
- package/dist/visual/MatrixField.svelte +5 -63
- package/dist/visual/MatrixField.svelte.d.ts +0 -3
- package/dist/visual/ObjectField.svelte +7 -3
- package/dist/visual/ObjectField.svelte.d.ts +4 -2
- package/dist/visual/ProseField.svelte +2 -1
- package/dist/visual/ProseValue.svelte +113 -40
- package/dist/visual/ProseValue.svelte.d.ts +8 -4
- package/dist/visual/VisualEditorInner.svelte +22 -22
- package/dist/visual/VisualEditorInner.svelte.d.ts +2 -2
- package/dist/visual/checklists.d.ts +0 -79
- package/dist/visual/checklists.js +0 -164
- package/dist/visual/guidance.d.ts +15 -0
- package/dist/visual/guidance.js +37 -0
- package/dist/visual/leaves.d.ts +27 -1
- package/dist/visual/leaves.js +46 -0
- package/dist/visual/strings.d.ts +3 -8
- package/dist/visual/strings.js +0 -3
- package/dist/visual/structure.js +1 -1
- package/dist/visual/tips.js +1 -1
- package/package.json +8 -8
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ActiveProse } from './leaves.js';
|
|
2
2
|
interface Props {
|
|
3
3
|
/** The observation seam VisualEditor exposes over its `leaves` registry. */
|
|
4
|
-
getActiveLeaf: () =>
|
|
4
|
+
getActiveLeaf: () => ActiveProse | undefined;
|
|
5
5
|
}
|
|
6
6
|
declare const FormatPopover: import("svelte").Component<Props, {}, "">;
|
|
7
7
|
type FormatPopover = ReturnType<typeof FormatPopover>;
|
|
@@ -1,76 +1,38 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
An open matrix's add box (VISUAL_EDITOR §"The matrix"): a
|
|
3
|
-
what
|
|
4
|
-
item they would add to this one. The list stands in flow under the input rather than
|
|
5
|
-
floating, so a closing group panel clips nothing of it, and it closes while the focus
|
|
6
|
-
is elsewhere. The input keeps the caret throughout and the active option is
|
|
7
|
-
`aria-activedescendant`, the combobox pattern: ↑/↓ walk, Enter picks, Escape clears.
|
|
8
|
-
Unwalked, the active option is the first result the words name outright, else the add
|
|
9
|
-
option: a result named only by abbreviation, which may stand in another card, is
|
|
10
|
-
taken by an arrow or a press and never by Enter alone.
|
|
2
|
+
An open matrix's add box (VISUAL_EDITOR §"The matrix"): a plain text input. Enter adds
|
|
3
|
+
what was typed, trimmed, and clears the box; Escape clears it.
|
|
11
4
|
-->
|
|
12
5
|
<script lang="ts">
|
|
13
6
|
import Icon from './icons/Icon.svelte';
|
|
14
|
-
import type { AddOption } from './checklists.js';
|
|
15
7
|
import './controls.css';
|
|
16
8
|
|
|
17
9
|
interface Props {
|
|
18
|
-
/** The input's id, and the base its listbox and options derive theirs from. */
|
|
19
10
|
id: string;
|
|
20
11
|
/** The input's accessible name and its words at rest. */
|
|
21
12
|
placeholder: string;
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
onChoose: (option: AddOption, query: string) => void;
|
|
13
|
+
/** Add an item titled `title`, never empty. */
|
|
14
|
+
onAdd: (title: string) => void;
|
|
25
15
|
}
|
|
26
|
-
let { id, placeholder,
|
|
16
|
+
let { id, placeholder, onAdd }: Props = $props();
|
|
27
17
|
|
|
28
|
-
let
|
|
29
|
-
/** The walked option, `undefined` until an arrow or the pointer moves it. */
|
|
30
|
-
let walked = $state<number | undefined>();
|
|
31
|
-
let focused = $state(false);
|
|
18
|
+
let typed = $state('');
|
|
32
19
|
let inputEl = $state<HTMLInputElement | undefined>();
|
|
33
|
-
const shown = $derived(query.trim() ? options(query.trim()) : []);
|
|
34
|
-
const open = $derived(focused && shown.length > 0);
|
|
35
|
-
const active = $derived.by(() => {
|
|
36
|
-
if (walked !== undefined) return Math.min(walked, shown.length - 1);
|
|
37
|
-
const strong = shown.findIndex((o) => o.strong);
|
|
38
|
-
if (strong >= 0) return strong;
|
|
39
|
-
const add = shown.findIndex((o) => o.add);
|
|
40
|
-
return add >= 0 ? add : 0;
|
|
41
|
-
});
|
|
42
|
-
const listId = $derived(`${id}-list`);
|
|
43
|
-
const optionId = (k: number): string => `${id}-opt-${k}`;
|
|
44
20
|
|
|
45
21
|
export function focus(): void {
|
|
46
22
|
inputEl?.focus();
|
|
47
23
|
}
|
|
48
24
|
|
|
49
|
-
function choose(k: number): void {
|
|
50
|
-
const option = shown[k];
|
|
51
|
-
if (!option) return;
|
|
52
|
-
const typed = query.trim();
|
|
53
|
-
query = '';
|
|
54
|
-
walked = undefined;
|
|
55
|
-
onChoose(option, typed);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
25
|
function onkeydown(e: KeyboardEvent): void {
|
|
59
26
|
if (e.isComposing) return;
|
|
60
|
-
if (e.key === '
|
|
61
|
-
e.preventDefault();
|
|
62
|
-
walked = (active + 1) % shown.length;
|
|
63
|
-
} else if (e.key === 'ArrowUp' && open) {
|
|
64
|
-
e.preventDefault();
|
|
65
|
-
walked = (active - 1 + shown.length) % shown.length;
|
|
66
|
-
} else if (e.key === 'Enter' && open) {
|
|
27
|
+
if (e.key === 'Enter' && typed.trim()) {
|
|
67
28
|
e.preventDefault();
|
|
68
|
-
|
|
69
|
-
|
|
29
|
+
const title = typed.trim();
|
|
30
|
+
typed = '';
|
|
31
|
+
onAdd(title);
|
|
32
|
+
} else if (e.key === 'Escape' && typed) {
|
|
70
33
|
e.preventDefault();
|
|
71
34
|
e.stopPropagation();
|
|
72
|
-
|
|
73
|
-
walked = undefined;
|
|
35
|
+
typed = '';
|
|
74
36
|
}
|
|
75
37
|
}
|
|
76
38
|
</script>
|
|
@@ -80,67 +42,21 @@
|
|
|
80
42
|
<Icon name="plus" class="qm-matrix-add-glyph" />
|
|
81
43
|
<input
|
|
82
44
|
bind:this={inputEl}
|
|
45
|
+
bind:value={typed}
|
|
83
46
|
{id}
|
|
84
47
|
class="qm-input qm-focus-ring"
|
|
85
48
|
type="text"
|
|
86
|
-
role="combobox"
|
|
87
49
|
autocomplete="off"
|
|
88
50
|
aria-label={placeholder}
|
|
89
|
-
aria-autocomplete="list"
|
|
90
|
-
aria-expanded={open}
|
|
91
|
-
aria-controls={listId}
|
|
92
|
-
aria-activedescendant={open ? optionId(active) : undefined}
|
|
93
51
|
{placeholder}
|
|
94
|
-
value={query}
|
|
95
|
-
oninput={(e) => {
|
|
96
|
-
query = e.currentTarget.value;
|
|
97
|
-
walked = undefined;
|
|
98
|
-
}}
|
|
99
|
-
onfocus={() => (focused = true)}
|
|
100
|
-
onblur={() => (focused = false)}
|
|
101
52
|
{onkeydown}
|
|
102
53
|
/>
|
|
103
54
|
</span>
|
|
104
|
-
<!-- Always mounted, so `aria-controls` names an element at rest too; empty, it draws
|
|
105
|
-
nothing. -->
|
|
106
|
-
<div
|
|
107
|
-
class="qm-menu-surface qm-matrix-add-list"
|
|
108
|
-
role="listbox"
|
|
109
|
-
id={listId}
|
|
110
|
-
aria-label={placeholder}
|
|
111
|
-
hidden={!open}
|
|
112
|
-
>
|
|
113
|
-
{#each shown as option, k (option.key)}
|
|
114
|
-
<!-- The input keeps focus and owns the keys; a press picks without taking it. -->
|
|
115
|
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
116
|
-
<div
|
|
117
|
-
class={['qm-menu-item qm-matrix-add-option', { add: option.add }]}
|
|
118
|
-
role="option"
|
|
119
|
-
id={optionId(k)}
|
|
120
|
-
tabindex="-1"
|
|
121
|
-
aria-selected={k === active}
|
|
122
|
-
data-highlighted={k === active ? '' : undefined}
|
|
123
|
-
onpointerdown={(e) => e.preventDefault()}
|
|
124
|
-
onpointermove={() => (walked = k)}
|
|
125
|
-
onclick={() => choose(k)}
|
|
126
|
-
>
|
|
127
|
-
{#if option.add}<Icon name="plus" class="qm-matrix-add-glyph" />{/if}
|
|
128
|
-
<span class="qm-matrix-add-title">{option.title}</span>
|
|
129
|
-
{#if option.note}<span class="qm-matrix-add-note">{option.note}</span>{/if}
|
|
130
|
-
</div>
|
|
131
|
-
{/each}
|
|
132
|
-
</div>
|
|
133
55
|
</div>
|
|
134
56
|
|
|
135
57
|
<style>
|
|
136
|
-
/* The box's text, and every option's, starts past the glyph slot at its leading edge,
|
|
137
|
-
so a result reads in the column the typed words stand in and the add option's `+`
|
|
138
|
-
stands under the box's. */
|
|
139
58
|
.qm-matrix-add {
|
|
140
59
|
--_slot: calc(var(--_qm-space-2) * 2 + var(--_qm-glyph-control));
|
|
141
|
-
display: flex;
|
|
142
|
-
flex-direction: column;
|
|
143
|
-
gap: var(--_qm-space-half);
|
|
144
60
|
min-width: 0;
|
|
145
61
|
}
|
|
146
62
|
.qm-matrix-add-field {
|
|
@@ -172,39 +88,4 @@
|
|
|
172
88
|
.qm-matrix-add-field .qm-input:placeholder-shown:not(:focus):hover {
|
|
173
89
|
background: var(--_qm-surface-hover);
|
|
174
90
|
}
|
|
175
|
-
/* The list is a menu, raised over the card rather than cut into it, so its options
|
|
176
|
-
read as choices and not as a second field. Its edge and inset come off each
|
|
177
|
-
option's, which keeps the column. */
|
|
178
|
-
.qm-matrix-add-list {
|
|
179
|
-
display: flex;
|
|
180
|
-
flex-direction: column;
|
|
181
|
-
}
|
|
182
|
-
.qm-matrix-add-list[hidden] {
|
|
183
|
-
display: none;
|
|
184
|
-
}
|
|
185
|
-
.qm-matrix-add-option {
|
|
186
|
-
position: relative;
|
|
187
|
-
display: flex;
|
|
188
|
-
align-items: baseline;
|
|
189
|
-
justify-content: space-between;
|
|
190
|
-
gap: var(--_qm-space-2);
|
|
191
|
-
padding-inline-start: calc(var(--_slot) - var(--_qm-space-half) - var(--_qm-border-width));
|
|
192
|
-
font-size: var(--_qm-text-body);
|
|
193
|
-
color: var(--_qm-ink);
|
|
194
|
-
}
|
|
195
|
-
.qm-matrix-add-option :global(.qm-matrix-add-glyph) {
|
|
196
|
-
inset-inline-start: calc(var(--_qm-space-2) - var(--_qm-space-half) - var(--_qm-border-width));
|
|
197
|
-
inset-block-start: 50%;
|
|
198
|
-
translate: 0 -50%;
|
|
199
|
-
}
|
|
200
|
-
/* The add option reads at the label ink: it offers a new item, where a result names
|
|
201
|
-
one the document already knows. */
|
|
202
|
-
.qm-matrix-add-option.add {
|
|
203
|
-
color: var(--_qm-ink-label);
|
|
204
|
-
}
|
|
205
|
-
.qm-matrix-add-note {
|
|
206
|
-
flex-shrink: 0;
|
|
207
|
-
font-size: var(--_qm-text-label);
|
|
208
|
-
color: var(--_qm-ink-label);
|
|
209
|
-
}
|
|
210
91
|
</style>
|
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import type { AddOption } from './checklists.js';
|
|
2
1
|
interface Props {
|
|
3
|
-
/** The input's id, and the base its listbox and options derive theirs from. */
|
|
4
2
|
id: string;
|
|
5
3
|
/** The input's accessible name and its words at rest. */
|
|
6
4
|
placeholder: string;
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
onChoose: (option: AddOption, query: string) => void;
|
|
5
|
+
/** Add an item titled `title`, never empty. */
|
|
6
|
+
onAdd: (title: string) => void;
|
|
10
7
|
}
|
|
11
8
|
declare const MatrixAdd: import("svelte").Component<Props, {
|
|
12
9
|
focus: () => void;
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
An open matrix's added items follow the roster in id order, as the plate prints them:
|
|
12
12
|
held by being present, so each draws a fixed tick, its `title` as an input, a remove,
|
|
13
|
-
and the same columns. The add box closes the list ({@link MatrixAdd}):
|
|
14
|
-
|
|
13
|
+
and the same columns. The add box closes the list ({@link MatrixAdd}): what is typed
|
|
14
|
+
there is added under an id minted from it past every id the list holds, so a title the
|
|
15
|
+
list already carries is a second item.
|
|
15
16
|
|
|
16
17
|
The control owns its label row, as an array does: the count of held items sits
|
|
17
18
|
where an array's add chip sits, so {@link Field} skips its own label for it.
|
|
@@ -48,15 +49,7 @@
|
|
|
48
49
|
onRoster,
|
|
49
50
|
schemaAt
|
|
50
51
|
} from './structure.js';
|
|
51
|
-
import {
|
|
52
|
-
checklists,
|
|
53
|
-
matrixPrinted,
|
|
54
|
-
searchChecklists,
|
|
55
|
-
type AddOption,
|
|
56
|
-
type Checklist,
|
|
57
|
-
type ChecklistHit,
|
|
58
|
-
type Searchable
|
|
59
|
-
} from './checklists.js';
|
|
52
|
+
import { matrixPrinted } from './checklists.js';
|
|
60
53
|
import { splitDeep, unrouted, type DeepDiagnostic } from './diagnostics.js';
|
|
61
54
|
import type { LandingBox } from './leaves.js';
|
|
62
55
|
import { propertyDomIds } from './domid.js';
|
|
@@ -85,9 +78,6 @@
|
|
|
85
78
|
/** The field's control id: the base a member's tick and columns derive their own
|
|
86
79
|
* names from, one `-m-<member id>` segment down. */
|
|
87
80
|
idBase?: string;
|
|
88
|
-
/** The field's leaf key, where it is a card's own field: how the add box tells
|
|
89
|
-
* this list from the rest of the document's (`checklists.ts`). */
|
|
90
|
-
listKey?: string;
|
|
91
81
|
/** The boundary's nested content read, rooted at this field: a member's content
|
|
92
82
|
* column reads at `[id, column]` (`reader.getContentAt`). */
|
|
93
83
|
contentAt: (path: PathStep[]) => Content | undefined;
|
|
@@ -104,16 +94,11 @@
|
|
|
104
94
|
labelId,
|
|
105
95
|
descriptionId,
|
|
106
96
|
idBase,
|
|
107
|
-
listKey,
|
|
108
97
|
contentAt,
|
|
109
98
|
onCommit,
|
|
110
99
|
diagnostics
|
|
111
100
|
}: Props = $props();
|
|
112
101
|
|
|
113
|
-
// A card's own matrix searches the document's lists; one nested in a container is
|
|
114
|
-
// not indexed, and searches its own items alone.
|
|
115
|
-
const index = checklists();
|
|
116
|
-
const others = (): readonly Checklist[] => (listKey != null ? (index?.list() ?? []) : []);
|
|
117
102
|
const members = $derived(matrixMembers(schema.members));
|
|
118
103
|
const compact = $derived(!!schema.ui?.compact);
|
|
119
104
|
const hasColumns = $derived(Object.keys(schema.properties ?? {}).length > 0);
|
|
@@ -220,48 +205,6 @@
|
|
|
220
205
|
}
|
|
221
206
|
|
|
222
207
|
// ── The add box ────────────────────────────────────────────────────────────
|
|
223
|
-
/** A result's list: this one, or another of the document's. */
|
|
224
|
-
type Hit = ChecklistHit<Checklist | undefined>;
|
|
225
|
-
function options(query: string): AddOption[] {
|
|
226
|
-
const own: Searchable<Checklist | undefined> = { list: undefined, schema, value: map };
|
|
227
|
-
const lists: Searchable<Checklist | undefined>[] = others()
|
|
228
|
-
.filter((l) => l.key !== listKey)
|
|
229
|
-
.map((l) => ({ list: l, schema: l.schema, value: l.value }));
|
|
230
|
-
const hits = searchChecklists(query, [own, ...lists]);
|
|
231
|
-
const out: AddOption[] = hits.map((h, k) => ({
|
|
232
|
-
key: `hit-${k}`,
|
|
233
|
-
title: h.title,
|
|
234
|
-
note:
|
|
235
|
-
[
|
|
236
|
-
h.list ? t.strings.matrixIn(h.list.label) : undefined,
|
|
237
|
-
h.held ? t.strings.matrixHeldTag : undefined
|
|
238
|
-
]
|
|
239
|
-
.filter(Boolean)
|
|
240
|
-
.join(' · ') || undefined,
|
|
241
|
-
strong: h.strong,
|
|
242
|
-
payload: h
|
|
243
|
-
}));
|
|
244
|
-
// Offered last, and not where an item of this list already carries the words.
|
|
245
|
-
const fold = (s: string) => s.trim().toLowerCase();
|
|
246
|
-
const dup = hits.some((h) => h.list === undefined && fold(h.title) === fold(query));
|
|
247
|
-
if (!dup) out.push({ key: 'add', title: t.strings.matrixAddNew(query), add: true });
|
|
248
|
-
return out;
|
|
249
|
-
}
|
|
250
|
-
async function choose(option: AddOption, query: string): Promise<void> {
|
|
251
|
-
if (option.add) return add(query);
|
|
252
|
-
const hit = option.payload as Hit;
|
|
253
|
-
const other = hit.list;
|
|
254
|
-
if (other) {
|
|
255
|
-
if (!hit.held) other.commit(commitMember(other.value, hit.id, memberWrite({})));
|
|
256
|
-
index?.land(`${other.path}.${hit.id}`);
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
if (!hit.held) commit(commitMember(map, hit.id, ticked(hit.id)));
|
|
260
|
-
await flush();
|
|
261
|
-
focusPath([hit.id]);
|
|
262
|
-
const box = ref(memberEls, hit.id);
|
|
263
|
-
if (box) bloomInside(box);
|
|
264
|
-
}
|
|
265
208
|
async function add(title: string): Promise<void> {
|
|
266
209
|
const taken = (id: string) => onRoster(schema, id) || (map != null && Object.hasOwn(map, id));
|
|
267
210
|
const id = addedId(title, taken);
|
|
@@ -397,8 +340,7 @@
|
|
|
397
340
|
bind:this={addEl}
|
|
398
341
|
id={`${base}-add`}
|
|
399
342
|
placeholder={t.strings.matrixAdd(label ?? '')}
|
|
400
|
-
{
|
|
401
|
-
onChoose={choose}
|
|
343
|
+
onAdd={add}
|
|
402
344
|
/>
|
|
403
345
|
{/if}
|
|
404
346
|
<DiagnosticList diagnostics={foot} />
|
|
@@ -18,9 +18,6 @@ interface Props {
|
|
|
18
18
|
/** The field's control id: the base a member's tick and columns derive their own
|
|
19
19
|
* names from, one `-m-<member id>` segment down. */
|
|
20
20
|
idBase?: string;
|
|
21
|
-
/** The field's leaf key, where it is a card's own field: how the add box tells
|
|
22
|
-
* this list from the rest of the document's (`checklists.ts`). */
|
|
23
|
-
listKey?: string;
|
|
24
21
|
/** The boundary's nested content read, rooted at this field: a member's content
|
|
25
22
|
* column reads at `[id, column]` (`reader.getContentAt`). */
|
|
26
23
|
contentAt: (path: PathStep[]) => Content | undefined;
|
|
@@ -81,8 +81,10 @@
|
|
|
81
81
|
/**
|
|
82
82
|
* The parent control's DOM id: the base each property's own three names derive
|
|
83
83
|
* from ({@link propertyDomIds}). Absent — a subform mounted with no field around
|
|
84
|
-
* it
|
|
85
|
-
*
|
|
84
|
+
* it — the properties fall back to `aria-label`, which names them without a
|
|
85
|
+
* `for` target to click. A table's row takes the table's own and names its
|
|
86
|
+
* cells the same fallback way, reading only the description off it: the
|
|
87
|
+
* header parks one per column, which every row's cell in it points at.
|
|
86
88
|
*/
|
|
87
89
|
idBase?: string;
|
|
88
90
|
/** Accessible-name prefix used only on the `aria-label` fallback above. */
|
|
@@ -268,7 +270,8 @@
|
|
|
268
270
|
{@const kind = controlKind(sub)}
|
|
269
271
|
{@const ids = idBase && !bare ? propertyDomIds(idBase, key) : undefined}
|
|
270
272
|
{@const named = ids ? undefined : fallbackName(key, sub)}
|
|
271
|
-
{@const describes =
|
|
273
|
+
{@const describes =
|
|
274
|
+
sub.description && idBase ? propertyDomIds(idBase, key).description : undefined}
|
|
272
275
|
{@const deep = routed.below.get(key)}
|
|
273
276
|
{@const own = isContainer(kind) ? undefined : deep?.map((d) => d.diagnostic)}
|
|
274
277
|
<!-- `for` reaches the labelable controls, a variant through its discriminant; the
|
|
@@ -359,6 +362,7 @@
|
|
|
359
362
|
<ProseValue
|
|
360
363
|
bind:this={proseEls[key]}
|
|
361
364
|
content={() => contentAt([key])}
|
|
365
|
+
value={obj[key]}
|
|
362
366
|
fallback={contentDefault(sub)}
|
|
363
367
|
plaintext={baseType(sub) === 'plaintext'}
|
|
364
368
|
placeholder={noneOf(key, sub)}
|
|
@@ -14,8 +14,10 @@ interface Props {
|
|
|
14
14
|
/**
|
|
15
15
|
* The parent control's DOM id: the base each property's own three names derive
|
|
16
16
|
* from ({@link propertyDomIds}). Absent — a subform mounted with no field around
|
|
17
|
-
* it
|
|
18
|
-
*
|
|
17
|
+
* it — the properties fall back to `aria-label`, which names them without a
|
|
18
|
+
* `for` target to click. A table's row takes the table's own and names its
|
|
19
|
+
* cells the same fallback way, reading only the description off it: the
|
|
20
|
+
* header parks one per column, which every row's cell in it points at.
|
|
19
21
|
*/
|
|
20
22
|
idBase?: string;
|
|
21
23
|
/** Accessible-name prefix used only on the `aria-label` fallback above. */
|
|
@@ -123,6 +123,7 @@
|
|
|
123
123
|
placeholder,
|
|
124
124
|
placeholderUntilEdit,
|
|
125
125
|
tableStrings: () => t.strings,
|
|
126
|
+
slashStrings: () => t.strings,
|
|
126
127
|
onSlash: (next) => {
|
|
127
128
|
slash = next;
|
|
128
129
|
},
|
|
@@ -155,7 +156,7 @@
|
|
|
155
156
|
controller?.setFallback(fallback);
|
|
156
157
|
});
|
|
157
158
|
|
|
158
|
-
// Only a narrowed or a plain leaf
|
|
159
|
+
// Only a narrowed or a plain leaf holds by what a re-validation says, so only one of them asks.
|
|
159
160
|
$effect(() => {
|
|
160
161
|
void diagnostics;
|
|
161
162
|
if (inline || plaintext) controller?.applyExternal();
|
|
@@ -11,24 +11,35 @@
|
|
|
11
11
|
Mounts once per stable key (an element's session id, a property's name), so the
|
|
12
12
|
parent's re-derive leaves the caret where it is.
|
|
13
13
|
|
|
14
|
+
Having no address, it registers no controller: it announces its views into the
|
|
15
|
+
registry's by-value lane under the field it stands in (`leaves.ts`), the formatting
|
|
16
|
+
popover's door to it. A focus on an editable leaf announces them, and so does a write
|
|
17
|
+
releasing the leaf under the caret; a hold and an unmount withdraw them, and a blur
|
|
18
|
+
does not, the popover's link prompt taking the focus from the view it writes to.
|
|
19
|
+
|
|
14
20
|
The content is read, not passed: `reader.getContentAt(addr, path)` decodes through
|
|
15
21
|
the codec the leaf's own declared type names, so what it rests as — the content
|
|
16
22
|
object, a `plaintext` literal, the authored string a transport door left — stops
|
|
17
|
-
being the row's or the cell's business.
|
|
18
|
-
|
|
23
|
+
being the row's or the cell's business. It is read at mount and again wherever the
|
|
24
|
+
parent's re-derive hands down a `value` that moved, and a read differing from the
|
|
25
|
+
content the leaf last committed or took replaces the view, the gate `createField`
|
|
26
|
+
re-hydrates through (`reconcile.ts`): a write that lands in the store under a mounted
|
|
27
|
+
leaf is what its next keystroke edits. Its own commit is read back as the store took
|
|
28
|
+
it, so the echo replaces nothing and the caret and the history stay.
|
|
19
29
|
|
|
20
30
|
The schema is the declared type's where the parent has room for it: a subform cell
|
|
21
31
|
declaring no `inline` takes the full row ({@link ObjectField}), a `richtext` one on the
|
|
22
32
|
block schema and a `plaintext` one on the plain schema, paragraphs and hard breaks.
|
|
23
33
|
An array element is one textblock whatever it declares, since Enter there is the
|
|
24
|
-
repeater's. There is no slash menu at either width, and
|
|
25
|
-
placeholder: an atom a keystroke can delete and no view edits.
|
|
34
|
+
repeater's. There is no slash menu at either width, and no island view.
|
|
26
35
|
|
|
27
|
-
A leaf over content
|
|
28
|
-
one plain paragraph
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
schema, read-only, with a note
|
|
36
|
+
A leaf over content it cannot edit is held (`fitsLeaf`): a narrowed one over anything
|
|
37
|
+
but one plain paragraph, a plain one over anything but plain lines, where the decode
|
|
38
|
+
would join or drop what is left over for the first keystroke to write back, and a block
|
|
39
|
+
one over an island, which draws as its placeholder tag, an atom a keystroke deletes
|
|
40
|
+
unseen. A held leaf draws its content on the block schema, read-only, with a note
|
|
41
|
+
inside its box, and commits nothing. The hold is judged of what each mount and each
|
|
42
|
+
replacement reads, so a write moving the value takes it or releases it.
|
|
32
43
|
-->
|
|
33
44
|
<script lang="ts">
|
|
34
45
|
import { untrack } from 'svelte';
|
|
@@ -36,6 +47,7 @@
|
|
|
36
47
|
import { EditorView } from 'prosemirror-view';
|
|
37
48
|
import {
|
|
38
49
|
blockSchema,
|
|
50
|
+
createReconciler,
|
|
39
51
|
decode,
|
|
40
52
|
emptyContent,
|
|
41
53
|
fitsLeaf,
|
|
@@ -45,18 +57,26 @@
|
|
|
45
57
|
proseAttributes,
|
|
46
58
|
proseLeafPlugins,
|
|
47
59
|
buildLineIndex,
|
|
48
|
-
|
|
60
|
+
pmToUsv,
|
|
61
|
+
usvToPM,
|
|
62
|
+
valueEqual,
|
|
63
|
+
type LeafViews
|
|
49
64
|
} from '../core/codec/index.js';
|
|
65
|
+
import { valueLeafSeat } from './leaves.js';
|
|
50
66
|
import { wording } from './strings.js';
|
|
51
67
|
import './controls.css';
|
|
52
68
|
import type { Content } from '@quillmark/wasm';
|
|
53
69
|
|
|
54
70
|
interface Props {
|
|
55
|
-
/** This leaf's content
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* boundary read per leaf per render, all but
|
|
71
|
+
/** This leaf's content (the parent's boundary read), `undefined` where nothing is
|
|
72
|
+
* stored: read at mount and wherever {@link value} moves. A thunk rather than a
|
|
73
|
+
* value: the parent re-derives per revision, and a value prop would be a
|
|
74
|
+
* boundary read per leaf per render, all but the moved one discarded. */
|
|
59
75
|
content: () => Content | undefined;
|
|
76
|
+
/** This leaf's value as the parent's re-derive delivers it, stored form and all:
|
|
77
|
+
* compared with the last one delivered, never drawn, so a move is what re-reads
|
|
78
|
+
* {@link content}. */
|
|
79
|
+
value?: unknown;
|
|
60
80
|
/** The default an unset leaf holds, where it prints: drawn at the default rung,
|
|
61
81
|
* and handed up whole with the first edit in it, as `createField` takes one. */
|
|
62
82
|
fallback?: Content;
|
|
@@ -87,6 +107,7 @@
|
|
|
87
107
|
}
|
|
88
108
|
let {
|
|
89
109
|
content,
|
|
110
|
+
value,
|
|
90
111
|
fallback,
|
|
91
112
|
plaintext = false,
|
|
92
113
|
block = false,
|
|
@@ -99,10 +120,13 @@
|
|
|
99
120
|
}: Props = $props();
|
|
100
121
|
|
|
101
122
|
const t = wording();
|
|
123
|
+
const seat = valueLeafSeat();
|
|
102
124
|
const uid = $props.id();
|
|
103
125
|
const heldId = `${uid}-held`;
|
|
104
126
|
let held = $state(false);
|
|
105
127
|
let view: EditorView | undefined;
|
|
128
|
+
/** What the mounted view does with a value the parent's re-derive delivers. */
|
|
129
|
+
let follow: ((next: unknown) => void) | undefined;
|
|
106
130
|
/** Take the caret: what a parent placing focus on this leaf calls. The view's
|
|
107
131
|
* focus, not the element's: a PM view restores its selection, where a bare DOM
|
|
108
132
|
* focus on a contenteditable leaves the caret unplaced. And the reveal beside it,
|
|
@@ -135,46 +159,61 @@
|
|
|
135
159
|
|
|
136
160
|
function mountView(mount: HTMLDivElement): () => void {
|
|
137
161
|
return untrack(() => {
|
|
138
|
-
// The same keymap and plugin stack a `createField` leaf mounts (shared
|
|
139
|
-
// `proseLeafPlugins`), minus the anchor-position plugin: anchors are dropped on
|
|
140
|
-
// the parent's value write, per the header.
|
|
141
162
|
const inline = !block;
|
|
163
|
+
const leaf = { inline, plaintext, islands: false };
|
|
164
|
+
const names = { label, labelledBy, describedBy };
|
|
165
|
+
const shown = (stored: Content | undefined): Content => stored ?? fallback ?? emptyContent();
|
|
142
166
|
const stored = content();
|
|
143
|
-
|
|
167
|
+
let seen = value;
|
|
144
168
|
let defaulted = stored === undefined && !!fallback;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
169
|
+
const reconciler = createReconciler(shown(stored));
|
|
170
|
+
held = !fitsLeaf(reconciler.last, leaf);
|
|
171
|
+
// Which of `aria-label` / `aria-labelledby` wins is the codec's one answer
|
|
172
|
+
// (`proseAttributes`), so a cell carrying a label element and a row carrying
|
|
173
|
+
// none cannot name their regions by different rules.
|
|
174
|
+
const plainNamed = proseAttributes(names) ?? {};
|
|
175
|
+
const heldNamed = heldAttributes(names, heldId);
|
|
176
|
+
const build = (rt: Content): EditorState => {
|
|
177
|
+
// The same keymap and plugin stack a `createField` leaf mounts (shared
|
|
178
|
+
// `proseLeafPlugins`), minus the anchor-position plugin: anchors are dropped on
|
|
179
|
+
// the parent's value write, per the header.
|
|
180
|
+
const schema = held ? blockSchema : leafSchema({ plaintext, inline });
|
|
181
|
+
return EditorState.create({
|
|
182
|
+
doc: decode(rt, schema),
|
|
183
|
+
plugins: held
|
|
184
|
+
? []
|
|
185
|
+
: proseLeafPlugins(schema, {
|
|
186
|
+
inline,
|
|
187
|
+
placeholder: () => placeholder,
|
|
188
|
+
placeholderUntilEdit: true
|
|
189
|
+
})
|
|
190
|
+
});
|
|
191
|
+
};
|
|
161
192
|
const mounted = new EditorView(
|
|
162
193
|
{ mount },
|
|
163
194
|
{
|
|
164
|
-
state,
|
|
195
|
+
state: build(reconciler.last),
|
|
165
196
|
editable: () => !held,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
197
|
+
attributes: () => {
|
|
198
|
+
const named = held ? heldNamed : plainNamed;
|
|
199
|
+
return defaulted ? { ...named, 'data-default': '' } : named;
|
|
200
|
+
},
|
|
170
201
|
dispatchTransaction(tr) {
|
|
171
202
|
const next = mounted.state.apply(tr);
|
|
172
203
|
const edit = tr.docChanged && !held;
|
|
173
204
|
if (edit) defaulted = false;
|
|
174
205
|
mounted.updateState(next);
|
|
175
|
-
if (edit)
|
|
206
|
+
if (!edit) return;
|
|
207
|
+
onChange(pmToContent(next.doc));
|
|
208
|
+
// The commit as the store took it, so its echo through the re-derive is no move.
|
|
209
|
+
seen = value;
|
|
210
|
+
reconciler.commit(shown(content()));
|
|
176
211
|
},
|
|
177
212
|
handleDOMEvents: {
|
|
213
|
+
focus: () => {
|
|
214
|
+
if (!held) seat?.focus(views);
|
|
215
|
+
return false;
|
|
216
|
+
},
|
|
178
217
|
keydown: (_v, e) => {
|
|
179
218
|
onKey?.(e);
|
|
180
219
|
return false;
|
|
@@ -182,13 +221,47 @@
|
|
|
182
221
|
}
|
|
183
222
|
}
|
|
184
223
|
);
|
|
224
|
+
const views: LeafViews = {
|
|
225
|
+
view: mounted,
|
|
226
|
+
focusedView: () => mounted,
|
|
227
|
+
nestedViews: () => []
|
|
228
|
+
};
|
|
185
229
|
view = mounted;
|
|
230
|
+
follow = (next) => {
|
|
231
|
+
if (valueEqual(next, seen)) return;
|
|
232
|
+
seen = next;
|
|
233
|
+
const read = content();
|
|
234
|
+
const current = shown(read);
|
|
235
|
+
const holds = !fitsLeaf(current, leaf);
|
|
236
|
+
const wasDefaulted = defaulted;
|
|
237
|
+
defaulted = read === undefined && !!fallback;
|
|
238
|
+
if (holds === held && !reconciler.shouldRehydrate(current)) {
|
|
239
|
+
if (defaulted !== wasDefaulted) mounted.setProps({});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
held = holds;
|
|
243
|
+
if (held) seat?.release(views);
|
|
244
|
+
else if (mounted.hasFocus()) seat?.focus(views);
|
|
245
|
+
// The caret keeps its offset, clamped, as a field's re-hydrate keeps it.
|
|
246
|
+
const caret = pmToUsv(buildLineIndex(mounted.state.doc), mounted.state.selection.head);
|
|
247
|
+
const fresh = build(current);
|
|
248
|
+
const sel = Selection.near(fresh.doc.resolve(usvToPM(buildLineIndex(fresh.doc), caret)));
|
|
249
|
+
mounted.updateState(fresh.apply(fresh.tr.setSelection(sel)));
|
|
250
|
+
reconciler.commit(current);
|
|
251
|
+
};
|
|
186
252
|
return () => {
|
|
253
|
+
seat?.release(views);
|
|
254
|
+
follow = undefined;
|
|
187
255
|
view = undefined;
|
|
188
256
|
mounted.destroy();
|
|
189
257
|
};
|
|
190
258
|
});
|
|
191
259
|
}
|
|
260
|
+
|
|
261
|
+
$effect(() => {
|
|
262
|
+
const next = value;
|
|
263
|
+
untrack(() => follow?.(next));
|
|
264
|
+
});
|
|
192
265
|
</script>
|
|
193
266
|
|
|
194
267
|
<!-- `.qm-control-box` (controls.css) is the whole box, so an array of `richtext` and
|