@glw907/cairn-cms 0.56.0 → 0.56.2
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/CHANGELOG.md +61 -0
- package/README.md +10 -3
- package/dist/components/ComponentForm.svelte +175 -46
- package/dist/components/ComponentForm.svelte.d.ts +22 -8
- package/dist/components/ComponentInsertDialog.svelte +379 -26
- package/dist/components/ComponentInsertDialog.svelte.d.ts +31 -2
- package/dist/components/EditPage.svelte +130 -8
- package/dist/components/MarkdownEditor.svelte +75 -0
- package/dist/components/MarkdownEditor.svelte.d.ts +14 -0
- package/dist/components/cairn-admin.css +125 -0
- package/dist/components/markdown-directives.d.ts +7 -0
- package/dist/components/markdown-directives.js +10 -0
- package/dist/render/component-grammar.d.ts +20 -0
- package/dist/render/component-grammar.js +47 -3
- package/dist/render/component-validate.js +22 -0
- package/dist/render/registry.d.ts +28 -0
- package/dist/render/registry.js +12 -0
- package/package.json +2 -1
- package/src/lib/components/ComponentForm.svelte +175 -46
- package/src/lib/components/ComponentInsertDialog.svelte +379 -26
- package/src/lib/components/EditPage.svelte +130 -8
- package/src/lib/components/MarkdownEditor.svelte +75 -0
- package/src/lib/components/markdown-directives.ts +11 -0
- package/src/lib/render/component-grammar.ts +59 -3
- package/src/lib/render/component-validate.ts +22 -1
- package/src/lib/render/registry.ts +33 -0
|
@@ -8,19 +8,59 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
8
8
|
<script module lang="ts">
|
|
9
9
|
import type { ComponentRegistry, ComponentDef } from '../render/registry.js';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/** Past this many actionable components, the catalog grows a search input. Below it search is
|
|
12
|
+
* noise over a list short enough to scan. */
|
|
13
|
+
const SEARCH_THRESHOLD = 8;
|
|
14
|
+
|
|
15
|
+
/** Whether a def carries a schema (any attribute or slot), so it opens the guided form rather
|
|
16
|
+
* than inserting a bare template. Exported so a host deciding on its own guided-edit affordance
|
|
17
|
+
* (the edit page's Edit-block control) reads the same notion the dialog lists and chooses by. */
|
|
18
|
+
export function hasSchema(def: ComponentDef): boolean {
|
|
12
19
|
return (def.attributes?.length ?? 0) > 0 || (def.slots?.length ?? 0) > 0;
|
|
13
20
|
}
|
|
14
|
-
/** The registry's
|
|
15
|
-
* directly
|
|
21
|
+
/** The registry's pickable components. A def is actionable when a schema opens the guided form or
|
|
22
|
+
* a template inserts directly; a def with neither is not listed. A `hidden` def is then dropped,
|
|
23
|
+
* so the hidden filter applies after the actionable one. Exported so a host rendering its own
|
|
16
24
|
* trigger (the edit page's toolbar) can hide it under the same condition the dialog uses. */
|
|
17
25
|
export function insertableDefs(registry?: ComponentRegistry): ComponentDef[] {
|
|
18
|
-
return (registry?.defs ?? []).filter(
|
|
26
|
+
return (registry?.defs ?? []).filter(
|
|
27
|
+
(def) => (hasSchema(def) || Boolean(def.insertTemplate)) && !def.hidden,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A heading-bearing group of rows. A group with an empty heading renders without an eyebrow.
|
|
32
|
+
* Groups appear in first-declared order, and rows keep declaration order within each group. */
|
|
33
|
+
interface CatalogGroup {
|
|
34
|
+
heading: string;
|
|
35
|
+
defs: ComponentDef[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Partition the defs into groups by `def.group`, preserving declaration order of both the
|
|
39
|
+
* groups (first time a group name is seen) and the rows within a group. A def with no `group`
|
|
40
|
+
* collects into one leading default group with no heading. */
|
|
41
|
+
function groupDefs(defs: ComponentDef[]): CatalogGroup[] {
|
|
42
|
+
const order: string[] = [];
|
|
43
|
+
const byHeading = new Map<string, ComponentDef[]>();
|
|
44
|
+
for (const def of defs) {
|
|
45
|
+
const heading = def.group ?? '';
|
|
46
|
+
if (!byHeading.has(heading)) {
|
|
47
|
+
byHeading.set(heading, []);
|
|
48
|
+
order.push(heading);
|
|
49
|
+
}
|
|
50
|
+
byHeading.get(heading)!.push(def);
|
|
51
|
+
}
|
|
52
|
+
return order.map((heading) => ({ heading, defs: byHeading.get(heading)! }));
|
|
19
53
|
}
|
|
20
54
|
</script>
|
|
21
55
|
|
|
22
56
|
<script lang="ts">
|
|
57
|
+
import { tick } from 'svelte';
|
|
23
58
|
import type { IconSet } from '../render/glyph.js';
|
|
59
|
+
import type { ComponentValues } from '../render/registry.js';
|
|
60
|
+
import type { ResolvedPreview } from '../content/types.js';
|
|
61
|
+
import type { LinkResolve } from '../content/links.js';
|
|
62
|
+
import { serializeComponent } from '../render/component-grammar.js';
|
|
63
|
+
import { buildPreviewDoc } from './preview-doc.js';
|
|
24
64
|
import ComponentForm from './ComponentForm.svelte';
|
|
25
65
|
|
|
26
66
|
interface Props {
|
|
@@ -28,8 +68,19 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
28
68
|
registry?: ComponentRegistry;
|
|
29
69
|
/** Insert markdown at the editor cursor. */
|
|
30
70
|
insert: (text: string) => void;
|
|
71
|
+
/** Replace the placed component's source span with new markdown. Edit mode routes submit here
|
|
72
|
+
* instead of insert. Optional: a host that never opens edit mode passes none. */
|
|
73
|
+
update?: (range: { from: number; to: number }, markdown: string) => void;
|
|
31
74
|
/** The site's icon set, for icon fields. */
|
|
32
75
|
icons?: IconSet;
|
|
76
|
+
/** The site's design-accurate render pipeline. When present and the picked component declares a
|
|
77
|
+
* `preview`, the configure step splits to two panes and renders the configured directive
|
|
78
|
+
* through this into a sandboxed iframe (the same path EditPage's preview uses). Optional: a
|
|
79
|
+
* host that passes none simply gets no preview pane. */
|
|
80
|
+
render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
|
|
81
|
+
/** The adapter's resolved preview knob (stylesheets and container class), threaded to
|
|
82
|
+
* buildPreviewDoc so the preview frame links the site's own CSS, the same as EditPage. */
|
|
83
|
+
preview?: ResolvedPreview | null;
|
|
33
84
|
/** Disable the trigger; the host sets it while Preview shows. */
|
|
34
85
|
disabled?: boolean;
|
|
35
86
|
/** Render the built-in Insert block trigger. False mounts only the dialog, for a host that
|
|
@@ -37,66 +88,368 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
37
88
|
trigger?: boolean;
|
|
38
89
|
}
|
|
39
90
|
|
|
40
|
-
let { registry, insert, icons, disabled = false, trigger = true }: Props = $props();
|
|
91
|
+
let { registry, insert, update, icons, render, preview = null, disabled = false, trigger = true }: Props = $props();
|
|
41
92
|
|
|
42
93
|
let dialog = $state<HTMLDialogElement | null>(null);
|
|
43
94
|
let picked = $state<ComponentDef | null>(null);
|
|
95
|
+
let query = $state('');
|
|
96
|
+
let searchInput = $state<HTMLInputElement | null>(null);
|
|
97
|
+
|
|
98
|
+
// Edit mode re-opens a placed component into the same guided form. editValues seeds the form from
|
|
99
|
+
// the parsed block (not the catalog pick path), and editRange is the source span Update replaces.
|
|
100
|
+
// Both are null in the catalog insert flow. resetPreview clears them so a fresh open is clean.
|
|
101
|
+
let editValues = $state<ComponentValues | null>(null);
|
|
102
|
+
let editRange = $state<{ from: number; to: number } | null>(null);
|
|
103
|
+
const editing = $derived(editValues !== null);
|
|
104
|
+
|
|
105
|
+
// The form's live values and its required-empty state, bound out of ComponentForm so the preview
|
|
106
|
+
// pane can render from them and mirror the incomplete state.
|
|
107
|
+
let formValues = $state<ComponentValues | undefined>(undefined);
|
|
108
|
+
let formIncomplete = $state(false);
|
|
109
|
+
|
|
110
|
+
// Two-pane configure is opt-in: it appears only when the picked component declares a `preview`
|
|
111
|
+
// AND a render function is threaded. Otherwise the configure step stays single column.
|
|
112
|
+
const twoPane = $derived(Boolean(picked?.preview) && Boolean(render));
|
|
113
|
+
|
|
114
|
+
// The preview pane's settle state, the honest chip the mockup names. The empty/incomplete state
|
|
115
|
+
// wins over settling so the pane never claims to settle a fabricated block.
|
|
116
|
+
type PreviewState = 'settling' | 'settled' | 'failed';
|
|
117
|
+
let previewState = $state<PreviewState>('settled');
|
|
118
|
+
let previewDoc = $state('');
|
|
119
|
+
|
|
120
|
+
// The required regions left empty, named for the incomplete-state callout. A boolean attribute is
|
|
121
|
+
// always met; a text/select/icon attribute or a slot is unmet when empty.
|
|
122
|
+
const emptyRequired = $derived.by(() => {
|
|
123
|
+
if (!picked || !formValues) return [] as string[];
|
|
124
|
+
const out: string[] = [];
|
|
125
|
+
for (const field of picked.attributes ?? []) {
|
|
126
|
+
if (!field.required || field.type === 'boolean') continue;
|
|
127
|
+
const v = formValues.attributes[field.key];
|
|
128
|
+
if (typeof v !== 'string' || v === '') out.push(field.label);
|
|
129
|
+
}
|
|
130
|
+
for (const slot of picked.slots ?? []) {
|
|
131
|
+
if (!slot.required) continue;
|
|
132
|
+
const v = formValues.slots[slot.name];
|
|
133
|
+
const filled = Array.isArray(v) ? v.some((i) => i !== '') : typeof v === 'string' && v !== '';
|
|
134
|
+
if (!filled) out.push(slot.label);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// The debounced, latest-wins preview render, the same shape EditPage's preview effect uses: a
|
|
140
|
+
// setTimeout debounce (~200ms) guarded by a plain counter so a slow earlier render that resolves
|
|
141
|
+
// after a newer one started is discarded, one persistent iframe whose srcdoc is replaced. The
|
|
142
|
+
// incomplete state short-circuits the render (the skeleton renders from the template, not the
|
|
143
|
+
// pipeline), so a required-empty block never reaches the site render as a fabricated finish.
|
|
144
|
+
let previewRun = 0;
|
|
145
|
+
$effect(() => {
|
|
146
|
+
if (!twoPane || !render || !picked || !formValues) return;
|
|
147
|
+
if (formIncomplete) {
|
|
148
|
+
previewState = 'settled';
|
|
149
|
+
previewRun++;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const md = serializeComponent(picked, formValues);
|
|
153
|
+
const run = ++previewRun;
|
|
154
|
+
previewState = 'settling';
|
|
155
|
+
const handle = setTimeout(async () => {
|
|
156
|
+
try {
|
|
157
|
+
const html = await render(md);
|
|
158
|
+
if (run === previewRun) {
|
|
159
|
+
previewDoc = buildPreviewDoc(html, preview);
|
|
160
|
+
previewState = 'settled';
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
if (run === previewRun) {
|
|
164
|
+
previewState = 'failed';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}, 200);
|
|
168
|
+
return () => {
|
|
169
|
+
clearTimeout(handle);
|
|
170
|
+
previewRun++;
|
|
171
|
+
};
|
|
172
|
+
});
|
|
44
173
|
|
|
45
174
|
const defs = $derived(insertableDefs(registry));
|
|
175
|
+
/** The catalog grows a search input only once the actionable count crosses the threshold. */
|
|
176
|
+
const showSearch = $derived(defs.length > SEARCH_THRESHOLD);
|
|
177
|
+
/** The defs matching the live query, by label or description (case-insensitive). With no query
|
|
178
|
+
* the whole catalog shows. */
|
|
179
|
+
const filtered = $derived.by(() => {
|
|
180
|
+
const q = query.trim().toLowerCase();
|
|
181
|
+
if (!q) return defs;
|
|
182
|
+
return defs.filter(
|
|
183
|
+
(def) =>
|
|
184
|
+
def.label.toLowerCase().includes(q) || (def.description ?? '').toLowerCase().includes(q),
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
const groups = $derived(groupDefs(filtered));
|
|
188
|
+
|
|
189
|
+
/** Clear the four preview-coupled state cells so no stale preview HTML or settle state survives a
|
|
190
|
+
* re-open, a step back, or a fresh pick. Called from every transition that leaves the configure
|
|
191
|
+
* step or starts a new one. */
|
|
192
|
+
function resetPreview() {
|
|
193
|
+
formValues = undefined;
|
|
194
|
+
formIncomplete = false;
|
|
195
|
+
previewState = 'settled';
|
|
196
|
+
previewDoc = '';
|
|
197
|
+
editValues = null;
|
|
198
|
+
editRange = null;
|
|
199
|
+
}
|
|
46
200
|
|
|
47
201
|
/** Open the picker. Exported so a trigger={false} host can drive the dialog itself. */
|
|
48
202
|
export function open() {
|
|
49
203
|
picked = null;
|
|
204
|
+
query = '';
|
|
205
|
+
resetPreview();
|
|
206
|
+
dialog?.showModal();
|
|
207
|
+
// Focus the search box on open when it shows, so an editor with a large catalog types straight
|
|
208
|
+
// into the filter. The dialog's own focus trap already lands focus on the first row otherwise.
|
|
209
|
+
if (showSearch) {
|
|
210
|
+
void tick().then(() => searchInput?.focus());
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Re-open a placed component into the same guided form. Skips the catalog: seeds the form from
|
|
214
|
+
* the parsed values, stores the source range for Update, and shows the configure step straight
|
|
215
|
+
* away. Exported so the host (the edit page's Edit-block control) drives it. */
|
|
216
|
+
export function editComponent(
|
|
217
|
+
def: ComponentDef,
|
|
218
|
+
values: ComponentValues,
|
|
219
|
+
range: { from: number; to: number },
|
|
220
|
+
) {
|
|
221
|
+
resetPreview();
|
|
222
|
+
query = '';
|
|
223
|
+
editValues = values;
|
|
224
|
+
editRange = range;
|
|
225
|
+
picked = def;
|
|
50
226
|
dialog?.showModal();
|
|
51
227
|
}
|
|
228
|
+
|
|
229
|
+
function back() {
|
|
230
|
+
picked = null;
|
|
231
|
+
resetPreview();
|
|
232
|
+
}
|
|
52
233
|
function close() {
|
|
53
234
|
picked = null;
|
|
54
235
|
dialog?.close();
|
|
55
236
|
}
|
|
237
|
+
function onClose() {
|
|
238
|
+
picked = null;
|
|
239
|
+
resetPreview();
|
|
240
|
+
}
|
|
56
241
|
function choose(def: ComponentDef) {
|
|
57
242
|
if (hasSchema(def)) {
|
|
243
|
+
resetPreview();
|
|
58
244
|
picked = def;
|
|
245
|
+
// ComponentForm focuses its own first field on mount.
|
|
59
246
|
} else {
|
|
60
247
|
insert(def.insertTemplate ?? '');
|
|
61
248
|
close();
|
|
62
249
|
}
|
|
63
250
|
}
|
|
64
|
-
|
|
65
|
-
|
|
251
|
+
// The form's submit routes here. In edit mode it replaces the stored source span through update;
|
|
252
|
+
// otherwise it inserts at the cursor. Either way the dialog closes.
|
|
253
|
+
function onSubmit(markdown: string) {
|
|
254
|
+
if (editing && editRange) {
|
|
255
|
+
update?.(editRange, markdown);
|
|
256
|
+
} else {
|
|
257
|
+
insert(markdown);
|
|
258
|
+
}
|
|
66
259
|
close();
|
|
67
260
|
}
|
|
261
|
+
|
|
262
|
+
// The native <dialog> turns Escape into a close. At the configure step Escape should step back to
|
|
263
|
+
// the catalog instead (one level), matching the catalog's own Back control; from the catalog it
|
|
264
|
+
// closes. Handling cancel (the event the dialog fires on Escape) lets us preventDefault and
|
|
265
|
+
// intercept the first level.
|
|
266
|
+
function onCancel(e: Event) {
|
|
267
|
+
// In edit mode there is no catalog to step back to, so Escape closes (the default). At the
|
|
268
|
+
// configure step of the insert flow, intercept the first Escape and step back to the catalog.
|
|
269
|
+
if (picked && !editing) {
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
back();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Arrow keys roam the rows: each row is a real <button>, so Enter chooses for free and Escape
|
|
276
|
+
// closes through the native <dialog>. Up/Down (and Left/Right) move focus between the row buttons
|
|
277
|
+
// in DOM order across every group, wrapping at the ends, the list-navigation model an editor
|
|
278
|
+
// expects. The handler sits on each row button (an interactive element), and finds its siblings
|
|
279
|
+
// through the shared scroll region so navigation crosses group boundaries.
|
|
280
|
+
function onRowKeydown(e: KeyboardEvent) {
|
|
281
|
+
const isNext = e.key === 'ArrowDown' || e.key === 'ArrowRight';
|
|
282
|
+
const isPrev = e.key === 'ArrowUp' || e.key === 'ArrowLeft';
|
|
283
|
+
if (!isNext && !isPrev) return;
|
|
284
|
+
const region = (e.currentTarget as HTMLElement).closest('[data-cairn-pk-list]');
|
|
285
|
+
if (!region) return;
|
|
286
|
+
const rows = [...region.querySelectorAll<HTMLButtonElement>('[data-testid="cairn-pk-row"]')];
|
|
287
|
+
const from = rows.indexOf(e.currentTarget as HTMLButtonElement);
|
|
288
|
+
if (from < 0) return;
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
const next = (from + (isNext ? 1 : -1) + rows.length) % rows.length;
|
|
291
|
+
rows[next].focus();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ArrowDown from the search input enters the list at the first row, ArrowUp at the last, so the
|
|
295
|
+
// keyboard model is whole: type to filter, then arrow into the results without reaching for the
|
|
296
|
+
// mouse. From a row, onRowKeydown takes over.
|
|
297
|
+
function onSearchKeydown(e: KeyboardEvent) {
|
|
298
|
+
const isNext = e.key === 'ArrowDown';
|
|
299
|
+
const isPrev = e.key === 'ArrowUp';
|
|
300
|
+
if (!isNext && !isPrev) return;
|
|
301
|
+
const rows = dialog?.querySelectorAll<HTMLButtonElement>('[data-testid="cairn-pk-row"]');
|
|
302
|
+
if (!rows || rows.length === 0) return;
|
|
303
|
+
e.preventDefault();
|
|
304
|
+
rows[isNext ? 0 : rows.length - 1].focus();
|
|
305
|
+
}
|
|
68
306
|
</script>
|
|
69
307
|
|
|
308
|
+
<!-- The guided form, identical in either layout: the two-pane case wraps it in the left column, the
|
|
309
|
+
single-column case renders it bare. One snippet keeps the prop wiring in one place. -->
|
|
310
|
+
{#snippet configureForm(def: ComponentDef)}
|
|
311
|
+
<ComponentForm {def} {icons} onInsert={onSubmit} initial={editValues ?? undefined} submitLabel={editing ? 'Update' : 'Insert'} bind:values={formValues} bind:incomplete={formIncomplete} />
|
|
312
|
+
{/snippet}
|
|
313
|
+
|
|
70
314
|
{#if trigger && defs.length > 0}
|
|
71
315
|
<button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label="Insert block" {disabled} onclick={open}>Insert block</button>
|
|
72
316
|
{/if}
|
|
73
317
|
|
|
74
318
|
{#if defs.length > 0}
|
|
75
|
-
<dialog class="modal" aria-labelledby="cairn-insert-dialog-title" bind:this={dialog} onclose={
|
|
76
|
-
<div class="modal-box">
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
319
|
+
<dialog class="modal" aria-labelledby="cairn-insert-dialog-title" bind:this={dialog} onclose={onClose} oncancel={onCancel}>
|
|
320
|
+
<div class="modal-box {twoPane ? 'max-w-3xl' : ''}">
|
|
321
|
+
<!-- The shared header: at the configure step it carries the Back control and the
|
|
322
|
+
"Insert > group" eyebrow breadcrumb above the component label; while browsing it is the
|
|
323
|
+
plain "Insert a component" title. -->
|
|
324
|
+
<div class="mb-3 flex items-center gap-3">
|
|
325
|
+
{#if picked && !editing}
|
|
326
|
+
<button type="button" class="btn btn-ghost btn-sm btn-square" aria-label="Back to components" onclick={back}>
|
|
327
|
+
<svg class="h-4 w-4" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M165.7 202.3a8 8 0 0 1-11.4 11.4l-80-80a8 8 0 0 1 0-11.4l80-80a8 8 0 0 1 11.4 11.4L91.3 128Z" /></svg>
|
|
328
|
+
</button>
|
|
329
|
+
{/if}
|
|
330
|
+
<div class="min-w-0 flex-1">
|
|
331
|
+
{#if picked}
|
|
332
|
+
<div class="text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">{editing ? 'Edit' : 'Insert'}{#if picked.group} › {picked.group}{/if}</div>
|
|
333
|
+
<h2 id="cairn-insert-dialog-title" class="font-[family-name:var(--font-display)] text-lg font-bold tracking-tight">{picked.label}</h2>
|
|
334
|
+
{:else}
|
|
335
|
+
<h2 id="cairn-insert-dialog-title" class="text-base font-semibold">Insert a component</h2>
|
|
336
|
+
{/if}
|
|
337
|
+
</div>
|
|
338
|
+
<button type="button" class="btn btn-ghost btn-sm btn-square" aria-label="Close" onclick={close}>✕</button>
|
|
80
339
|
</div>
|
|
81
340
|
|
|
82
341
|
{#if picked}
|
|
83
342
|
{#key picked}
|
|
84
|
-
|
|
343
|
+
{#if twoPane}
|
|
344
|
+
<!-- Two panes: the form on the left, the live preview on the right. Below the breakpoint
|
|
345
|
+
the preview stacks beneath the form. -->
|
|
346
|
+
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
347
|
+
<div class="overflow-auto">
|
|
348
|
+
{@render configureForm(picked)}
|
|
349
|
+
</div>
|
|
350
|
+
<div data-testid="cairn-pk-preview" class="flex flex-col gap-2 rounded-box border border-[var(--cairn-card-border)] bg-base-200 p-3">
|
|
351
|
+
<div class="flex items-baseline justify-between gap-2">
|
|
352
|
+
<span class="text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">Preview</span>
|
|
353
|
+
<!-- A silent visual cue, never an announcement: it re-rendered on every debounced
|
|
354
|
+
keystroke, so a screen reader read "Settling"/"Settled" aloud on each edit. The
|
|
355
|
+
incomplete and render-failed conditions reach assistive tech through the
|
|
356
|
+
per-field role="alert" errors and the failed panel text, so nothing is lost. -->
|
|
357
|
+
<span data-testid="cairn-pk-settle" class="inline-flex items-center gap-1.5 text-[0.7rem] text-[var(--color-muted)]">
|
|
358
|
+
{#if formIncomplete}
|
|
359
|
+
Incomplete
|
|
360
|
+
{:else if previewState === 'failed'}
|
|
361
|
+
Could not render
|
|
362
|
+
{:else if previewState === 'settling'}
|
|
363
|
+
<span class="inline-block h-2.5 w-2.5 animate-spin rounded-full border-[1.5px] border-current border-t-transparent motion-reduce:animate-none" aria-hidden="true"></span>
|
|
364
|
+
Settling
|
|
365
|
+
{:else}
|
|
366
|
+
Settled
|
|
367
|
+
{/if}
|
|
368
|
+
</span>
|
|
369
|
+
</div>
|
|
370
|
+
{#if formIncomplete}
|
|
371
|
+
<!-- The skeleton: never a fabricated finished block. The empty required regions are
|
|
372
|
+
called out by name so the editor knows exactly what the preview still needs. -->
|
|
373
|
+
<div class="flex flex-1 flex-col items-center justify-center gap-2 rounded-box border border-dashed border-[var(--cairn-card-border)] p-6 text-center">
|
|
374
|
+
<p class="text-sm font-medium">Fill the required fields to preview this.</p>
|
|
375
|
+
<p class="flex flex-wrap justify-center gap-1.5 text-xs">
|
|
376
|
+
{#each emptyRequired as label (label)}
|
|
377
|
+
<span class="rounded border border-dashed border-[color-mix(in_oklab,var(--color-error)_55%,var(--cairn-card-border))] px-2 py-0.5 font-medium text-error">{label} needed</span>
|
|
378
|
+
{/each}
|
|
379
|
+
</p>
|
|
380
|
+
</div>
|
|
381
|
+
{:else if previewState === 'failed'}
|
|
382
|
+
<!-- The render threw. Say so and keep the form intact; the editor can still insert. -->
|
|
383
|
+
<div data-testid="cairn-pk-preview-failed" class="flex flex-1 flex-col items-center justify-center gap-1.5 rounded-box border border-[color-mix(in_oklab,var(--color-error)_35%,var(--cairn-card-border))] bg-[color-mix(in_oklab,var(--color-error)_5%,transparent)] p-5 text-center text-error">
|
|
384
|
+
<p class="text-sm font-semibold">Preview could not render</p>
|
|
385
|
+
<p class="text-xs text-[var(--color-muted)]">Your settings are kept. You can still insert and check it on the page.</p>
|
|
386
|
+
</div>
|
|
387
|
+
{:else}
|
|
388
|
+
<div class="flex min-h-64 flex-1 overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100 shadow-[var(--cairn-shadow)]">
|
|
389
|
+
<iframe sandbox="" title="Component preview" srcdoc={previewDoc} class="block w-full flex-1"></iframe>
|
|
390
|
+
</div>
|
|
391
|
+
{/if}
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
{:else}
|
|
395
|
+
{@render configureForm(picked)}
|
|
396
|
+
{/if}
|
|
85
397
|
{/key}
|
|
86
398
|
{:else}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
399
|
+
{#if showSearch}
|
|
400
|
+
<div class="mb-3 flex items-center gap-2 rounded-field border border-[var(--cairn-card-border)] bg-base-100 px-3 py-2">
|
|
401
|
+
<svg class="ec-glyph h-4 w-4 text-[var(--color-muted)]" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M229.7 218.3 179.6 168.2A92.2 92.2 0 1 0 168.2 179.6l50.1 50.1a8 8 0 0 0 11.4-11.4ZM40 112a72 72 0 1 1 72 72 72.1 72.1 0 0 1-72-72Z" /></svg>
|
|
402
|
+
<input
|
|
403
|
+
type="search"
|
|
404
|
+
class="w-full border-0 bg-transparent p-0 text-sm outline-hidden placeholder:text-[var(--color-muted)]"
|
|
405
|
+
placeholder="Search components"
|
|
406
|
+
aria-label="Search components"
|
|
407
|
+
bind:value={query}
|
|
408
|
+
bind:this={searchInput}
|
|
409
|
+
onkeydown={onSearchKeydown}
|
|
410
|
+
/>
|
|
411
|
+
</div>
|
|
412
|
+
<p class="sr-only" role="status" aria-live="polite">
|
|
413
|
+
{filtered.length} {filtered.length === 1 ? 'component' : 'components'} match
|
|
414
|
+
</p>
|
|
415
|
+
{/if}
|
|
416
|
+
|
|
417
|
+
{#if filtered.length === 0}
|
|
418
|
+
<!-- The query matched nothing. The components exist; none match. Offer the way back. -->
|
|
419
|
+
<div class="flex flex-col items-center gap-3 px-6 py-12 text-center">
|
|
420
|
+
<p class="text-sm text-[var(--color-muted)]">No components match <span class="font-medium text-base-content">"{query.trim()}"</span>.</p>
|
|
421
|
+
<button type="button" class="text-[0.8125rem] font-medium text-primary underline [text-underline-offset:2px]" onclick={() => (query = '')}>Clear search</button>
|
|
422
|
+
</div>
|
|
423
|
+
{:else}
|
|
424
|
+
<!-- One scroll region holds every group, so the arrow keys roam the whole catalog. -->
|
|
425
|
+
<div data-cairn-pk-list>
|
|
426
|
+
{#each groups as group (group.heading)}
|
|
427
|
+
<div class="mt-3 first:mt-0">
|
|
428
|
+
{#if group.heading}
|
|
429
|
+
<div data-testid="cairn-pk-group-heading" class="px-2 pb-1.5 text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">{group.heading}</div>
|
|
430
|
+
{/if}
|
|
431
|
+
<ul class="menu w-full p-0">
|
|
432
|
+
{#each group.defs as def (def.name)}
|
|
433
|
+
<li>
|
|
434
|
+
<button type="button" data-testid="cairn-pk-row" class="flex items-start gap-3 py-2" onclick={() => choose(def)} onkeydown={onRowKeydown}>
|
|
435
|
+
{#if def.icon && icons?.[def.icon]}
|
|
436
|
+
<span class="flex h-8 w-8 flex-none items-center justify-center rounded-lg bg-base-200 text-base-content">
|
|
437
|
+
<svg class="ec-glyph h-4 w-4" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d={icons[def.icon]} /></svg>
|
|
438
|
+
</span>
|
|
439
|
+
{/if}
|
|
440
|
+
<span class="flex flex-col items-start gap-0.5">
|
|
441
|
+
<span data-testid="cairn-pk-row-label" class="text-sm font-medium">{def.label}</span>
|
|
442
|
+
{#if def.description}<span class="text-xs text-[var(--color-muted)]">{def.description}</span>{/if}
|
|
443
|
+
{#if def.use}<span class="text-xs text-[var(--color-subtle)]">{def.use}</span>{/if}
|
|
444
|
+
</span>
|
|
445
|
+
</button>
|
|
446
|
+
</li>
|
|
447
|
+
{/each}
|
|
448
|
+
</ul>
|
|
449
|
+
</div>
|
|
450
|
+
{/each}
|
|
451
|
+
</div>
|
|
452
|
+
{/if}
|
|
100
453
|
{/if}
|
|
101
454
|
</div>
|
|
102
455
|
<form method="dialog" class="modal-backdrop">
|
|
@@ -1,16 +1,41 @@
|
|
|
1
1
|
import type { ComponentRegistry, ComponentDef } from '../render/registry.js';
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
2
|
+
/** Whether a def carries a schema (any attribute or slot), so it opens the guided form rather
|
|
3
|
+
* than inserting a bare template. Exported so a host deciding on its own guided-edit affordance
|
|
4
|
+
* (the edit page's Edit-block control) reads the same notion the dialog lists and chooses by. */
|
|
5
|
+
export declare function hasSchema(def: ComponentDef): boolean;
|
|
6
|
+
/** The registry's pickable components. A def is actionable when a schema opens the guided form or
|
|
7
|
+
* a template inserts directly; a def with neither is not listed. A `hidden` def is then dropped,
|
|
8
|
+
* so the hidden filter applies after the actionable one. Exported so a host rendering its own
|
|
4
9
|
* trigger (the edit page's toolbar) can hide it under the same condition the dialog uses. */
|
|
5
10
|
export declare function insertableDefs(registry?: ComponentRegistry): ComponentDef[];
|
|
6
11
|
import type { IconSet } from '../render/glyph.js';
|
|
12
|
+
import type { ComponentValues } from '../render/registry.js';
|
|
13
|
+
import type { ResolvedPreview } from '../content/types.js';
|
|
14
|
+
import type { LinkResolve } from '../content/links.js';
|
|
7
15
|
interface Props {
|
|
8
16
|
/** The site's component registry. */
|
|
9
17
|
registry?: ComponentRegistry;
|
|
10
18
|
/** Insert markdown at the editor cursor. */
|
|
11
19
|
insert: (text: string) => void;
|
|
20
|
+
/** Replace the placed component's source span with new markdown. Edit mode routes submit here
|
|
21
|
+
* instead of insert. Optional: a host that never opens edit mode passes none. */
|
|
22
|
+
update?: (range: {
|
|
23
|
+
from: number;
|
|
24
|
+
to: number;
|
|
25
|
+
}, markdown: string) => void;
|
|
12
26
|
/** The site's icon set, for icon fields. */
|
|
13
27
|
icons?: IconSet;
|
|
28
|
+
/** The site's design-accurate render pipeline. When present and the picked component declares a
|
|
29
|
+
* `preview`, the configure step splits to two panes and renders the configured directive
|
|
30
|
+
* through this into a sandboxed iframe (the same path EditPage's preview uses). Optional: a
|
|
31
|
+
* host that passes none simply gets no preview pane. */
|
|
32
|
+
render?: (md: string, opts?: {
|
|
33
|
+
stagger?: boolean;
|
|
34
|
+
resolve?: LinkResolve;
|
|
35
|
+
}) => string | Promise<string>;
|
|
36
|
+
/** The adapter's resolved preview knob (stylesheets and container class), threaded to
|
|
37
|
+
* buildPreviewDoc so the preview frame links the site's own CSS, the same as EditPage. */
|
|
38
|
+
preview?: ResolvedPreview | null;
|
|
14
39
|
/** Disable the trigger; the host sets it while Preview shows. */
|
|
15
40
|
disabled?: boolean;
|
|
16
41
|
/** Render the built-in Insert block trigger. False mounts only the dialog, for a host that
|
|
@@ -25,6 +50,10 @@ interface Props {
|
|
|
25
50
|
*/
|
|
26
51
|
declare const ComponentInsertDialog: import("svelte").Component<Props, {
|
|
27
52
|
open: () => void;
|
|
53
|
+
editComponent: (def: ComponentDef, values: ComponentValues, range: {
|
|
54
|
+
from: number;
|
|
55
|
+
to: number;
|
|
56
|
+
}) => void;
|
|
28
57
|
}, "">;
|
|
29
58
|
type ComponentInsertDialog = ReturnType<typeof ComponentInsertDialog>;
|
|
30
59
|
export default ComponentInsertDialog;
|