@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
|
@@ -21,6 +21,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
21
21
|
import { beforeNavigate } from '$app/navigation';
|
|
22
22
|
import { page } from '$app/state';
|
|
23
23
|
import BlocksIcon from '@lucide/svelte/icons/blocks';
|
|
24
|
+
import SquarePenIcon from '@lucide/svelte/icons/square-pen';
|
|
24
25
|
import LinkIcon from '@lucide/svelte/icons/link';
|
|
25
26
|
import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
|
|
26
27
|
import PanelRightIcon from '@lucide/svelte/icons/panel-right';
|
|
@@ -28,7 +29,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
28
29
|
import CsrfField from './CsrfField.svelte';
|
|
29
30
|
import MarkdownEditor from './MarkdownEditor.svelte';
|
|
30
31
|
import EditorToolbar from './EditorToolbar.svelte';
|
|
31
|
-
import ComponentInsertDialog, { insertableDefs } from './ComponentInsertDialog.svelte';
|
|
32
|
+
import ComponentInsertDialog, { insertableDefs, hasSchema } from './ComponentInsertDialog.svelte';
|
|
32
33
|
import LinkPicker from './LinkPicker.svelte';
|
|
33
34
|
import WebLinkDialog from './WebLinkDialog.svelte';
|
|
34
35
|
import DeleteDialog from './DeleteDialog.svelte';
|
|
@@ -39,7 +40,8 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
39
40
|
import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
|
|
40
41
|
import { buildPreviewDoc, deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
|
|
41
42
|
import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
|
|
42
|
-
import type { ComponentRegistry } from '../render/registry.js';
|
|
43
|
+
import type { ComponentRegistry, ComponentDef } from '../render/registry.js';
|
|
44
|
+
import { parseComponent, componentRoundTripSafety } from '../render/component-grammar.js';
|
|
43
45
|
import type { IconSet } from '../render/glyph.js';
|
|
44
46
|
import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
|
|
45
47
|
import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
|
|
@@ -263,6 +265,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
263
265
|
// Which pane the editor card shows. The toolbar's tablist drives it; Write is always the
|
|
264
266
|
// landing tab.
|
|
265
267
|
let mode = $state<'write' | 'preview'>('write');
|
|
268
|
+
// Preview is read-only, so the insert controls the page renders into the toolbar disable with the
|
|
269
|
+
// strip's own format buttons. Declared here (above the Edit-block derivations that read it) so it
|
|
270
|
+
// is in scope before its first use.
|
|
271
|
+
const insertDisabled = $derived(mode === 'preview');
|
|
266
272
|
let previewHtml = $state('');
|
|
267
273
|
// True after a render call threw, so the preview pane can say so instead of going blank.
|
|
268
274
|
let previewFailed = $state(false);
|
|
@@ -353,6 +359,9 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
353
359
|
// preview knob, or a styleless document (behind the hint below) when the site sets none.
|
|
354
360
|
const previewDoc = $derived(buildPreviewDoc(previewHtml, data.preview));
|
|
355
361
|
let insert = $state.raw<(text: string) => void>(() => {});
|
|
362
|
+
// The editor's range-replace seam, registered by MarkdownEditor on mount; the dialog's Update
|
|
363
|
+
// routes through it to overwrite an edited block's source span. A no-op until then.
|
|
364
|
+
let replaceRange = $state.raw<(from: number, to: number, text: string) => void>(() => {});
|
|
356
365
|
let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
|
|
357
366
|
// The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
|
|
358
367
|
// reads it for the Text field's default.
|
|
@@ -366,7 +375,9 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
366
375
|
// breaks SSR and hydration); the toolbar snippet renders plain triggers that open them here.
|
|
367
376
|
let webLinkDialog = $state<DialogHandle | null>(null);
|
|
368
377
|
let linkPicker = $state<DialogHandle | null>(null);
|
|
369
|
-
|
|
378
|
+
// The insert dialog binds the full instance, not the bare DialogHandle: the Edit-block control
|
|
379
|
+
// drives editComponent(def, values, range) on it, beyond the shared open().
|
|
380
|
+
let insertDialog = $state<ComponentInsertDialog | null>(null);
|
|
370
381
|
// The lifecycle dialogs, opened from the header's overflow menu.
|
|
371
382
|
let deleteDialog = $state<DialogHandle | null>(null);
|
|
372
383
|
let renameDialog = $state<DialogHandle | null>(null);
|
|
@@ -379,6 +390,92 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
379
390
|
// by, so the toolbar trigger and the dialog appear and disappear together.
|
|
380
391
|
const hasComponents = $derived(insertableDefs(registry).length > 0);
|
|
381
392
|
|
|
393
|
+
// The directive container at the editor caret, reported by MarkdownEditor whenever it changes
|
|
394
|
+
// (null outside any container). The Edit-block control resolves it against the registry and the
|
|
395
|
+
// round-trip safety gate below; its identity is the key the async gate guards against a stale
|
|
396
|
+
// result. The reporter's name+markdown+from+to shape; declared locally because MarkdownEditor's
|
|
397
|
+
// matching interface is not exported.
|
|
398
|
+
type CaretComponent = { name: string | null; markdown: string; from: number; to: number };
|
|
399
|
+
let caretComponent = $state<CaretComponent | null>(null);
|
|
400
|
+
// A def is actionable for guided edit when it has a schema (the same notion the insert catalog
|
|
401
|
+
// lists by): a template-only def has no form to re-open into. Reuses the dialog's exported
|
|
402
|
+
// hasSchema so the two surfaces can never drift on what counts as editable.
|
|
403
|
+
function editableDef(name: string | null): ComponentDef | undefined {
|
|
404
|
+
if (!name) return undefined;
|
|
405
|
+
const def = registry?.get(name);
|
|
406
|
+
if (!def) return undefined;
|
|
407
|
+
return hasSchema(def) ? def : undefined;
|
|
408
|
+
}
|
|
409
|
+
// The resolved editability: the def, the source range, and the validated markdown when the caret
|
|
410
|
+
// sits on a known, schema-bearing component whose round-trip safety check passed for the CURRENT
|
|
411
|
+
// caret; null otherwise. The def, range, and markdown are captured from one caretComponent
|
|
412
|
+
// snapshot, so editBlock() never mixes a newer markdown with an older range. The Edit-block
|
|
413
|
+
// control enables only when this is set.
|
|
414
|
+
let editable = $state<{ def: ComponentDef; range: { from: number; to: number }; markdown: string } | null>(null);
|
|
415
|
+
// Why edit is unavailable, distinguishing "not on a component" from "on an unsafe one" so the
|
|
416
|
+
// disabled tooltip is honest. 'none' covers both no-caret-component and an unknown/template-only
|
|
417
|
+
// one (no guided form either way); 'unsafe' is a known component the safety gate refused.
|
|
418
|
+
let editReason = $state<'none' | 'unsafe'>('none');
|
|
419
|
+
// Resolve editability when the caret-component changes, async-safe. componentRoundTripSafety is
|
|
420
|
+
// async, so a slow check could resolve after a newer caret move; guard latest-wins on the
|
|
421
|
+
// caretComponent identity (the EditPage preview-debounce pattern), only applying a result when
|
|
422
|
+
// the caret has not moved since the check started. A block whose check did not pass for the
|
|
423
|
+
// current caret never enables edit.
|
|
424
|
+
$effect(() => {
|
|
425
|
+
const current = caretComponent;
|
|
426
|
+
const def = editableDef(current?.name ?? null);
|
|
427
|
+
if (!current || !def) {
|
|
428
|
+
editable = null;
|
|
429
|
+
editReason = 'none';
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
let stale = false;
|
|
433
|
+
void componentRoundTripSafety(current.markdown, def)
|
|
434
|
+
.then((result) => {
|
|
435
|
+
if (stale || caretComponent !== current) return;
|
|
436
|
+
if (result.safe) {
|
|
437
|
+
editable = { def, range: { from: current.from, to: current.to }, markdown: current.markdown };
|
|
438
|
+
editReason = 'none';
|
|
439
|
+
} else {
|
|
440
|
+
editable = null;
|
|
441
|
+
editReason = 'unsafe';
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
.catch(() => {
|
|
445
|
+
// A parse throw during the safety check must never leave a stale block enabled. Guarded by
|
|
446
|
+
// the same latest-wins identity, fall back to the safe default of no editable block.
|
|
447
|
+
if (stale || caretComponent !== current) return;
|
|
448
|
+
editable = null;
|
|
449
|
+
editReason = 'none';
|
|
450
|
+
});
|
|
451
|
+
return () => {
|
|
452
|
+
stale = true;
|
|
453
|
+
};
|
|
454
|
+
});
|
|
455
|
+
// The Edit-block control's accessible label and tooltip: a plain reason in each state. Enabled
|
|
456
|
+
// names the action; the two disabled reasons are honest about why.
|
|
457
|
+
const editBlockLabel = $derived(
|
|
458
|
+
editable
|
|
459
|
+
? 'Edit the component at the cursor'
|
|
460
|
+
: editReason === 'unsafe'
|
|
461
|
+
? "This block can't be edited in the form. Edit it as markdown."
|
|
462
|
+
: 'Place the cursor in a component to edit it',
|
|
463
|
+
);
|
|
464
|
+
// Whether the Edit-block control is unavailable: either Preview hides the Write surface, or the
|
|
465
|
+
// caret is not on a safe, schema-bearing component. The control stays focusable and announced in
|
|
466
|
+
// this state (aria-disabled, not the native disabled attribute), so its reason reaches assistive
|
|
467
|
+
// technology; the dead click is made inert in editBlock().
|
|
468
|
+
const editBlockUnavailable = $derived(insertDisabled || !editable);
|
|
469
|
+
// Activate edit: parse the block into form values, then open the dialog in edit mode over the
|
|
470
|
+
// stored source range. Guarded by editable AND the preview-mode disable, so the control is inert
|
|
471
|
+
// unless the gate passed and the editor is on the Write tab. The def, range, and markdown all come
|
|
472
|
+
// from the one editable snapshot, so a newer caret markdown is never paired with an older range.
|
|
473
|
+
async function editBlock() {
|
|
474
|
+
if (insertDisabled || !editable) return;
|
|
475
|
+
const values = await parseComponent(editable.markdown, editable.def);
|
|
476
|
+
insertDialog?.editComponent(editable.def, values, editable.range);
|
|
477
|
+
}
|
|
478
|
+
|
|
382
479
|
// The header's status badge, in ConceptList's vocabulary: a pending entry reads Edited (or New
|
|
383
480
|
// when it has never been published); otherwise the live site matches and it reads Published.
|
|
384
481
|
const status = $derived.by(() => {
|
|
@@ -563,10 +660,6 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
563
660
|
mode = m;
|
|
564
661
|
}
|
|
565
662
|
|
|
566
|
-
// Preview is read-only, so the insert controls the page renders into the toolbar disable with
|
|
567
|
-
// the strip's own format buttons.
|
|
568
|
-
const insertDisabled = $derived(mode === 'preview');
|
|
569
|
-
|
|
570
663
|
// The editor card's keyboard shortcuts. Bound to the card so they fire wherever focus sits in the
|
|
571
664
|
// strip or the surface, without claiming the keys page-wide. The listener attaches
|
|
572
665
|
// programmatically: it is event delegation, not an interaction affordance, which Svelte's a11y
|
|
@@ -903,6 +996,24 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
903
996
|
>
|
|
904
997
|
<BlocksIcon class="h-4 w-4" aria-hidden="true" />
|
|
905
998
|
</button>
|
|
999
|
+
<!-- Edit block re-opens the component at the caret into the guided form. It is
|
|
1000
|
+
unavailable while Preview shows (like the insert controls) and whenever the caret is
|
|
1001
|
+
not on a safe, schema-bearing component; the tooltip names the reason in each state.
|
|
1002
|
+
The unavailable state uses aria-disabled, not the native disabled attribute, so the
|
|
1003
|
+
control stays focusable and its reason reaches assistive technology; the disabled
|
|
1004
|
+
look rides a class and editBlock() early-returns so the dead click is inert. -->
|
|
1005
|
+
<button
|
|
1006
|
+
type="button"
|
|
1007
|
+
class="btn btn-sm btn-ghost btn-square"
|
|
1008
|
+
class:btn-disabled={editBlockUnavailable}
|
|
1009
|
+
aria-haspopup="dialog"
|
|
1010
|
+
aria-label={editBlockLabel}
|
|
1011
|
+
title={editBlockLabel}
|
|
1012
|
+
aria-disabled={editBlockUnavailable}
|
|
1013
|
+
onclick={editBlock}
|
|
1014
|
+
>
|
|
1015
|
+
<SquarePenIcon class="h-4 w-4" aria-hidden="true" />
|
|
1016
|
+
</button>
|
|
906
1017
|
{/if}
|
|
907
1018
|
<button
|
|
908
1019
|
type="button"
|
|
@@ -950,6 +1061,8 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
950
1061
|
name="body"
|
|
951
1062
|
{surface}
|
|
952
1063
|
registerInsert={(fn) => (insert = fn)}
|
|
1064
|
+
onComponentAtCaret={(info) => (caretComponent = info)}
|
|
1065
|
+
registerReplaceRange={(fn) => (replaceRange = fn)}
|
|
953
1066
|
registerInsertLink={(fn) => (insertLink = fn)}
|
|
954
1067
|
registerGetSelection={(fn) => (getSelection = fn)}
|
|
955
1068
|
registerFormat={(fn) => (format = fn)}
|
|
@@ -1239,7 +1352,16 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1239
1352
|
<form>, and a form nested in a form is invalid HTML the parser repairs by dropping the outer
|
|
1240
1353
|
tag, which breaks the SSR'd document and hydration. The toolbar snippet's triggers drive them
|
|
1241
1354
|
through their exported open(). -->
|
|
1242
|
-
<ComponentInsertDialog
|
|
1355
|
+
<ComponentInsertDialog
|
|
1356
|
+
bind:this={insertDialog}
|
|
1357
|
+
trigger={false}
|
|
1358
|
+
{registry}
|
|
1359
|
+
{insert}
|
|
1360
|
+
update={(range, md) => replaceRange(range.from, range.to, md)}
|
|
1361
|
+
{icons}
|
|
1362
|
+
{render}
|
|
1363
|
+
preview={data.preview}
|
|
1364
|
+
/>
|
|
1243
1365
|
<WebLinkDialog bind:this={webLinkDialog} trigger={false} insert={insertLink} selection={getSelection} />
|
|
1244
1366
|
<LinkPicker bind:this={linkPicker} trigger={false} linkTargets={data.linkTargets} insert={insertLink} />
|
|
1245
1367
|
|
|
@@ -10,6 +10,16 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
10
10
|
<script lang="ts">
|
|
11
11
|
import { onMount, onDestroy } from 'svelte';
|
|
12
12
|
import { applyMarkdownFormat, insertInlineLink, type FormatKind, type FormatResult } from './markdown-format.js';
|
|
13
|
+
import { fenceScan, caretContainerRange, directiveOpenerName } from './markdown-directives.js';
|
|
14
|
+
|
|
15
|
+
/** The directive container at the caret: the opener's name, the block's markdown, and the
|
|
16
|
+
* document character offsets of its inclusive line range. */
|
|
17
|
+
interface ComponentAtCaret {
|
|
18
|
+
name: string | null;
|
|
19
|
+
markdown: string;
|
|
20
|
+
from: number;
|
|
21
|
+
to: number;
|
|
22
|
+
}
|
|
13
23
|
|
|
14
24
|
interface Props {
|
|
15
25
|
/** The markdown source; bindable so the parent reads edits back. */
|
|
@@ -24,6 +34,12 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
24
34
|
registerGetSelection?: (get: () => string) => void;
|
|
25
35
|
/** Receives a `(kind) => void` that transforms the current selection; the host's toolbar calls it. */
|
|
26
36
|
registerFormat?: (format: (kind: FormatKind) => void) => void;
|
|
37
|
+
/** Reports the directive container at the caret (or null when outside any container) whenever
|
|
38
|
+
* the reported value changes; the host resolves it against the registry to offer Edit-block. */
|
|
39
|
+
onComponentAtCaret?: (info: ComponentAtCaret | null) => void;
|
|
40
|
+
/** Receives a `(from, to, text) => void` that overwrites a document span; the dialog's Update
|
|
41
|
+
* calls it to write an edited block back over its original range. */
|
|
42
|
+
registerReplaceRange?: (replace: (from: number, to: number, text: string) => void) => void;
|
|
27
43
|
/** Generic CodeMirror completion sources wired into the editor; the link autocomplete is one. The
|
|
28
44
|
* type is referenced inline so no static `@codemirror/*` import sits in this client-only file. */
|
|
29
45
|
completionSources?: import('@codemirror/autocomplete').CompletionSource[];
|
|
@@ -43,6 +59,8 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
43
59
|
registerInsertLink,
|
|
44
60
|
registerGetSelection,
|
|
45
61
|
registerFormat,
|
|
62
|
+
onComponentAtCaret,
|
|
63
|
+
registerReplaceRange,
|
|
46
64
|
completionSources = [],
|
|
47
65
|
focusMode = false,
|
|
48
66
|
typewriter = false,
|
|
@@ -365,6 +383,10 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
365
383
|
surfaceCompartment.of(surface === 'prose' ? proseTheme : markupTheme),
|
|
366
384
|
EditorView.updateListener.of((update) => {
|
|
367
385
|
if (update.docChanged) value = update.state.doc.toString();
|
|
386
|
+
// A doc edit can change the block's span and a caret move can change which block the
|
|
387
|
+
// caret sits in, so the reporter runs on either; the dedupe below absorbs the no-ops.
|
|
388
|
+
if (onComponentAtCaret && (update.docChanged || update.selectionSet))
|
|
389
|
+
reportComponentAtCaret(update.state);
|
|
368
390
|
}),
|
|
369
391
|
],
|
|
370
392
|
}),
|
|
@@ -374,6 +396,10 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
374
396
|
registerInsertLink?.(insertLink);
|
|
375
397
|
registerGetSelection?.(selectedText);
|
|
376
398
|
registerFormat?.(applyFormat);
|
|
399
|
+
registerReplaceRange?.(replaceRange);
|
|
400
|
+
// Report the caret's starting container once the editor exists, so a caret that mounts inside
|
|
401
|
+
// a block is known without waiting for the first move.
|
|
402
|
+
if (onComponentAtCaret) reportComponentAtCaret(view.state);
|
|
377
403
|
mounted = true;
|
|
378
404
|
});
|
|
379
405
|
|
|
@@ -406,6 +432,55 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
406
432
|
});
|
|
407
433
|
});
|
|
408
434
|
|
|
435
|
+
// The last value handed to onComponentAtCaret, so the reporter fires only on a change. The
|
|
436
|
+
// identity compared is name + markdown + from + to. A pure caret move within one block leaves all
|
|
437
|
+
// four unchanged, so it does not refire; an edit inside the block changes the markdown even when
|
|
438
|
+
// it keeps the same length (an equal-length replacement leaves from and to unchanged), so the
|
|
439
|
+
// markdown must be part of the equality or such an edit would keep a stale report.
|
|
440
|
+
let lastCaretReport: ComponentAtCaret | null = null;
|
|
441
|
+
|
|
442
|
+
// Compute the directive container at the caret from a CodeMirror state and report it through
|
|
443
|
+
// onComponentAtCaret, deduped so a caret move within the same block does not refire. fenceScan
|
|
444
|
+
// lines are 0-based; doc.line(n) is 1-based, so the line range maps with a +1 on each bound.
|
|
445
|
+
function reportComponentAtCaret(state: import('@codemirror/state').EditorState) {
|
|
446
|
+
const doc = state.doc;
|
|
447
|
+
const lines: string[] = [];
|
|
448
|
+
for (let n = 1; n <= doc.lines; n++) lines.push(doc.line(n).text);
|
|
449
|
+
const caretLine = doc.lineAt(state.selection.main.head).number - 1;
|
|
450
|
+
const range = caretContainerRange(fenceScan(lines), caretLine);
|
|
451
|
+
let next: ComponentAtCaret | null = null;
|
|
452
|
+
if (range) {
|
|
453
|
+
const fromPos = doc.line(range.fromLine + 1).from;
|
|
454
|
+
const toPos = doc.line(range.toLine + 1).to;
|
|
455
|
+
next = {
|
|
456
|
+
name: directiveOpenerName(lines[range.fromLine] ?? ''),
|
|
457
|
+
markdown: doc.sliceString(fromPos, toPos),
|
|
458
|
+
from: fromPos,
|
|
459
|
+
to: toPos,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
const prev = lastCaretReport;
|
|
463
|
+
const same =
|
|
464
|
+
prev === next ||
|
|
465
|
+
(prev !== null &&
|
|
466
|
+
next !== null &&
|
|
467
|
+
prev.name === next.name &&
|
|
468
|
+
prev.markdown === next.markdown &&
|
|
469
|
+
prev.from === next.from &&
|
|
470
|
+
prev.to === next.to);
|
|
471
|
+
if (same) return;
|
|
472
|
+
lastCaretReport = next;
|
|
473
|
+
onComponentAtCaret?.(next);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Overwrite a document span with new text and drop the caret after it, mirroring insertAtCursor's
|
|
477
|
+
// dispatch shape. A no-op before the editor mounts, the same guard the other seams carry.
|
|
478
|
+
function replaceRange(from: number, to: number, text: string) {
|
|
479
|
+
if (!view) return;
|
|
480
|
+
view.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from + text.length } });
|
|
481
|
+
view.focus();
|
|
482
|
+
}
|
|
483
|
+
|
|
409
484
|
function insertAtCursor(text: string) {
|
|
410
485
|
if (!view) {
|
|
411
486
|
value = value ? `${value}\n\n${text}` : text;
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { type FormatKind } from './markdown-format.js';
|
|
2
|
+
/** The directive container at the caret: the opener's name, the block's markdown, and the
|
|
3
|
+
* document character offsets of its inclusive line range. */
|
|
4
|
+
interface ComponentAtCaret {
|
|
5
|
+
name: string | null;
|
|
6
|
+
markdown: string;
|
|
7
|
+
from: number;
|
|
8
|
+
to: number;
|
|
9
|
+
}
|
|
2
10
|
interface Props {
|
|
3
11
|
/** The markdown source; bindable so the parent reads edits back. */
|
|
4
12
|
value: string;
|
|
@@ -12,6 +20,12 @@ interface Props {
|
|
|
12
20
|
registerGetSelection?: (get: () => string) => void;
|
|
13
21
|
/** Receives a `(kind) => void` that transforms the current selection; the host's toolbar calls it. */
|
|
14
22
|
registerFormat?: (format: (kind: FormatKind) => void) => void;
|
|
23
|
+
/** Reports the directive container at the caret (or null when outside any container) whenever
|
|
24
|
+
* the reported value changes; the host resolves it against the registry to offer Edit-block. */
|
|
25
|
+
onComponentAtCaret?: (info: ComponentAtCaret | null) => void;
|
|
26
|
+
/** Receives a `(from, to, text) => void` that overwrites a document span; the dialog's Update
|
|
27
|
+
* calls it to write an edited block back over its original range. */
|
|
28
|
+
registerReplaceRange?: (replace: (from: number, to: number, text: string) => void) => void;
|
|
15
29
|
/** Generic CodeMirror completion sources wired into the editor; the link autocomplete is one. The
|
|
16
30
|
* type is referenced inline so no static `@codemirror/*` import sits in this client-only file. */
|
|
17
31
|
completionSources?: import('@codemirror/autocomplete').CompletionSource[];
|
|
@@ -1142,6 +1142,35 @@
|
|
|
1142
1142
|
}
|
|
1143
1143
|
}
|
|
1144
1144
|
|
|
1145
|
+
@layer daisyui.l1.l2 {
|
|
1146
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .btn-disabled:not(.btn-link, .btn-ghost) {
|
|
1147
|
+
background-color: var(--color-base-content);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
1151
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .btn-disabled:not(.btn-link, .btn-ghost) {
|
|
1152
|
+
background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .btn-disabled:not(.btn-link, .btn-ghost) {
|
|
1157
|
+
box-shadow: none;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .btn-disabled {
|
|
1161
|
+
pointer-events: none;
|
|
1162
|
+
--btn-border: #0000;
|
|
1163
|
+
--btn-noise: none;
|
|
1164
|
+
--btn-fg: var(--color-base-content);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
1168
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .btn-disabled {
|
|
1169
|
+
--btn-fg: color-mix(in oklch, var(--color-base-content) 20%, #0000);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1145
1174
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pointer-events-none {
|
|
1146
1175
|
pointer-events: none;
|
|
1147
1176
|
}
|
|
@@ -3929,6 +3958,10 @@
|
|
|
3929
3958
|
height: calc(var(--spacing) * 1.5);
|
|
3930
3959
|
}
|
|
3931
3960
|
|
|
3961
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-2\.5 {
|
|
3962
|
+
height: calc(var(--spacing) * 2.5);
|
|
3963
|
+
}
|
|
3964
|
+
|
|
3932
3965
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .h-3 {
|
|
3933
3966
|
height: calc(var(--spacing) * 3);
|
|
3934
3967
|
}
|
|
@@ -4023,6 +4056,10 @@
|
|
|
4023
4056
|
width: calc(var(--spacing) * 1.5);
|
|
4024
4057
|
}
|
|
4025
4058
|
|
|
4059
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-2\.5 {
|
|
4060
|
+
width: calc(var(--spacing) * 2.5);
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4026
4063
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .w-3 {
|
|
4027
4064
|
width: calc(var(--spacing) * 3);
|
|
4028
4065
|
}
|
|
@@ -4241,6 +4278,10 @@
|
|
|
4241
4278
|
cursor: pointer;
|
|
4242
4279
|
}
|
|
4243
4280
|
|
|
4281
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .resize {
|
|
4282
|
+
resize: both;
|
|
4283
|
+
}
|
|
4284
|
+
|
|
4244
4285
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .appearance-none {
|
|
4245
4286
|
appearance: none;
|
|
4246
4287
|
}
|
|
@@ -4455,6 +4496,11 @@
|
|
|
4455
4496
|
border-width: 0;
|
|
4456
4497
|
}
|
|
4457
4498
|
|
|
4499
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[1\.5px\] {
|
|
4500
|
+
border-style: var(--tw-border-style);
|
|
4501
|
+
border-width: 1.5px;
|
|
4502
|
+
}
|
|
4503
|
+
|
|
4458
4504
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-t {
|
|
4459
4505
|
border-top-style: var(--tw-border-style);
|
|
4460
4506
|
border-top-width: 1px;
|
|
@@ -4475,6 +4521,11 @@
|
|
|
4475
4521
|
border-left-width: 1px;
|
|
4476
4522
|
}
|
|
4477
4523
|
|
|
4524
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-dashed {
|
|
4525
|
+
--tw-border-style: dashed;
|
|
4526
|
+
border-style: dashed;
|
|
4527
|
+
}
|
|
4528
|
+
|
|
4478
4529
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-none {
|
|
4479
4530
|
--tw-border-style: none;
|
|
4480
4531
|
border-style: none;
|
|
@@ -4489,6 +4540,26 @@
|
|
|
4489
4540
|
}
|
|
4490
4541
|
}
|
|
4491
4542
|
|
|
4543
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[color-mix\(in_oklab\,var\(--color-error\)_35\%\,var\(--cairn-card-border\)\)\] {
|
|
4544
|
+
border-color: var(--color-error);
|
|
4545
|
+
}
|
|
4546
|
+
|
|
4547
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
4548
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[color-mix\(in_oklab\,var\(--color-error\)_35\%\,var\(--cairn-card-border\)\)\] {
|
|
4549
|
+
border-color: color-mix(in oklab,var(--color-error) 35%,var(--cairn-card-border));
|
|
4550
|
+
}
|
|
4551
|
+
}
|
|
4552
|
+
|
|
4553
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[color-mix\(in_oklab\,var\(--color-error\)_55\%\,var\(--cairn-card-border\)\)\] {
|
|
4554
|
+
border-color: var(--color-error);
|
|
4555
|
+
}
|
|
4556
|
+
|
|
4557
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
4558
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[color-mix\(in_oklab\,var\(--color-error\)_55\%\,var\(--cairn-card-border\)\)\] {
|
|
4559
|
+
border-color: color-mix(in oklab,var(--color-error) 55%,var(--cairn-card-border));
|
|
4560
|
+
}
|
|
4561
|
+
}
|
|
4562
|
+
|
|
4492
4563
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[var\(--cairn-card-border\)\] {
|
|
4493
4564
|
border-color: var(--cairn-card-border);
|
|
4494
4565
|
}
|
|
@@ -4497,10 +4568,18 @@
|
|
|
4497
4568
|
border-color: var(--color-base-300);
|
|
4498
4569
|
}
|
|
4499
4570
|
|
|
4571
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-current {
|
|
4572
|
+
border-color: currentColor;
|
|
4573
|
+
}
|
|
4574
|
+
|
|
4500
4575
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-transparent {
|
|
4501
4576
|
border-color: #0000;
|
|
4502
4577
|
}
|
|
4503
4578
|
|
|
4579
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-t-transparent {
|
|
4580
|
+
border-top-color: #0000;
|
|
4581
|
+
}
|
|
4582
|
+
|
|
4504
4583
|
@layer daisyui.l1.l2 {
|
|
4505
4584
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) :where(:not(ul, details, .menu-title, .btn)).menu-active {
|
|
4506
4585
|
--tw-outline-style: none;
|
|
@@ -4531,6 +4610,16 @@
|
|
|
4531
4610
|
border: none;
|
|
4532
4611
|
}
|
|
4533
4612
|
|
|
4613
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bg-\[color-mix\(in_oklab\,var\(--color-error\)_5\%\,transparent\)\] {
|
|
4614
|
+
background-color: var(--color-error);
|
|
4615
|
+
}
|
|
4616
|
+
|
|
4617
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
4618
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bg-\[color-mix\(in_oklab\,var\(--color-error\)_5\%\,transparent\)\] {
|
|
4619
|
+
background-color: color-mix(in oklab,var(--color-error) 5%,transparent);
|
|
4620
|
+
}
|
|
4621
|
+
}
|
|
4622
|
+
|
|
4534
4623
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bg-\[var\(--cairn-card-border\)\] {
|
|
4535
4624
|
background-color: var(--cairn-card-border);
|
|
4536
4625
|
}
|
|
@@ -4630,6 +4719,10 @@
|
|
|
4630
4719
|
padding: calc(var(--spacing) * 4);
|
|
4631
4720
|
}
|
|
4632
4721
|
|
|
4722
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .p-5 {
|
|
4723
|
+
padding: calc(var(--spacing) * 5);
|
|
4724
|
+
}
|
|
4725
|
+
|
|
4633
4726
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .p-6 {
|
|
4634
4727
|
padding: calc(var(--spacing) * 6);
|
|
4635
4728
|
}
|
|
@@ -4720,6 +4813,10 @@
|
|
|
4720
4813
|
padding-block: calc(var(--spacing) * 0);
|
|
4721
4814
|
}
|
|
4722
4815
|
|
|
4816
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-0\.5 {
|
|
4817
|
+
padding-block: calc(var(--spacing) * .5);
|
|
4818
|
+
}
|
|
4819
|
+
|
|
4723
4820
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-1 {
|
|
4724
4821
|
padding-block: calc(var(--spacing) * 1);
|
|
4725
4822
|
}
|
|
@@ -4756,6 +4853,10 @@
|
|
|
4756
4853
|
padding-block: calc(var(--spacing) * 8);
|
|
4757
4854
|
}
|
|
4758
4855
|
|
|
4856
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-12 {
|
|
4857
|
+
padding-block: calc(var(--spacing) * 12);
|
|
4858
|
+
}
|
|
4859
|
+
|
|
4759
4860
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .py-14 {
|
|
4760
4861
|
padding-block: calc(var(--spacing) * 14);
|
|
4761
4862
|
}
|
|
@@ -4780,6 +4881,10 @@
|
|
|
4780
4881
|
padding-right: calc(var(--spacing) * 3);
|
|
4781
4882
|
}
|
|
4782
4883
|
|
|
4884
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pb-1\.5 {
|
|
4885
|
+
padding-bottom: calc(var(--spacing) * 1.5);
|
|
4886
|
+
}
|
|
4887
|
+
|
|
4783
4888
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .pl-3 {
|
|
4784
4889
|
padding-left: calc(var(--spacing) * 3);
|
|
4785
4890
|
}
|
|
@@ -4854,6 +4959,10 @@
|
|
|
4854
4959
|
}
|
|
4855
4960
|
}
|
|
4856
4961
|
|
|
4962
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[0\.7rem\] {
|
|
4963
|
+
font-size: .7rem;
|
|
4964
|
+
}
|
|
4965
|
+
|
|
4857
4966
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[0\.75rem\] {
|
|
4858
4967
|
font-size: .75rem;
|
|
4859
4968
|
}
|
|
@@ -5290,6 +5399,10 @@
|
|
|
5290
5399
|
color: var(--color-muted);
|
|
5291
5400
|
}
|
|
5292
5401
|
|
|
5402
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .first\:mt-0:first-child {
|
|
5403
|
+
margin-top: calc(var(--spacing) * 0);
|
|
5404
|
+
}
|
|
5405
|
+
|
|
5293
5406
|
@media (hover: hover) {
|
|
5294
5407
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .hover\:bg-base-200:hover {
|
|
5295
5408
|
background-color: var(--color-base-200);
|
|
@@ -5380,6 +5493,12 @@
|
|
|
5380
5493
|
}
|
|
5381
5494
|
}
|
|
5382
5495
|
|
|
5496
|
+
@media (prefers-reduced-motion: reduce) {
|
|
5497
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .motion-reduce\:animate-none {
|
|
5498
|
+
animation: none;
|
|
5499
|
+
}
|
|
5500
|
+
}
|
|
5501
|
+
|
|
5383
5502
|
@media (width >= 40rem) {
|
|
5384
5503
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .sm\:mt-\[12vh\] {
|
|
5385
5504
|
margin-top: 12vh;
|
|
@@ -5552,6 +5671,12 @@
|
|
|
5552
5671
|
}
|
|
5553
5672
|
}
|
|
5554
5673
|
|
|
5674
|
+
@media (width >= 64rem) {
|
|
5675
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .lg\:grid-cols-2 {
|
|
5676
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
5677
|
+
}
|
|
5678
|
+
}
|
|
5679
|
+
|
|
5555
5680
|
@media (width >= 64rem) {
|
|
5556
5681
|
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .lg\:grid-cols-\[1fr_17rem\] {
|
|
5557
5682
|
grid-template-columns: 1fr 17rem;
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The directive name from a container opener line (`:::callout{...}` -> `callout`,
|
|
3
|
+
* `::::cta[Book]` -> `cta`), or null when the line is not a named container opener. Reads the
|
|
4
|
+
* same FENCE match the scan uses: group 2 is the name, empty on a bare closer, so a closer or a
|
|
5
|
+
* non-fence line returns null.
|
|
6
|
+
*/
|
|
7
|
+
export declare function directiveOpenerName(line: string): string | null;
|
|
1
8
|
/** Classify a whole line as a container fence, a leaf directive, or neither. */
|
|
2
9
|
export declare function directiveLineKind(line: string): 'fence' | 'leaf' | null;
|
|
3
10
|
/** One pass over the document: each line's container depth alongside its fence role. */
|
|
@@ -14,6 +14,16 @@ const INLINE = /(?<![:\w]):[\w-]+\[[^\]]*\](\{[^}]*\})?/g;
|
|
|
14
14
|
// directive forms. The depth scan tracks these so a documented ::: example inside a code block
|
|
15
15
|
// never opens a real container.
|
|
16
16
|
const CODE_FENCE = /^\s{0,3}(`{3,}|~{3,})/;
|
|
17
|
+
/**
|
|
18
|
+
* The directive name from a container opener line (`:::callout{...}` -> `callout`,
|
|
19
|
+
* `::::cta[Book]` -> `cta`), or null when the line is not a named container opener. Reads the
|
|
20
|
+
* same FENCE match the scan uses: group 2 is the name, empty on a bare closer, so a closer or a
|
|
21
|
+
* non-fence line returns null.
|
|
22
|
+
*/
|
|
23
|
+
export function directiveOpenerName(line) {
|
|
24
|
+
const m = FENCE.exec(line);
|
|
25
|
+
return m && m[2] ? m[2] : null;
|
|
26
|
+
}
|
|
17
27
|
/** Classify a whole line as a container fence, a leaf directive, or neither. */
|
|
18
28
|
export function directiveLineKind(line) {
|
|
19
29
|
if (FENCE.test(line))
|
|
@@ -7,6 +7,26 @@ export declare function parseComponent(markdown: string, def: ComponentDef): Pro
|
|
|
7
7
|
/** The raw attribute keys present on the component's opening directive, read from the parsed tree
|
|
8
8
|
* (quote-aware, unlike a regex over the source). Used by validation to flag unknown keys. */
|
|
9
9
|
export declare function parseRawAttributeKeys(markdown: string, def: ComponentDef): string[];
|
|
10
|
+
/** The result of {@link componentRoundTripSafety}: whether re-opening a placed block into the
|
|
11
|
+
* guided form and re-serializing it is provably lossless. */
|
|
12
|
+
export type RoundTripSafety = {
|
|
13
|
+
safe: true;
|
|
14
|
+
} | {
|
|
15
|
+
safe: false;
|
|
16
|
+
reason: 'unknown-attribute' | 'undeclared-child' | 'not-idempotent' | 'not-a-component';
|
|
17
|
+
};
|
|
18
|
+
/** Decide whether guided edit of this placed block is provably lossless. A block a person typed by
|
|
19
|
+
* hand can carry more than the schema models (an attribute the def does not list, a child container
|
|
20
|
+
* the def does not declare, slot content the form cannot represent stably), and parsing such a block
|
|
21
|
+
* into the form then re-serializing would silently drop it. The edit affordance is offered only when
|
|
22
|
+
* this returns `{ safe: true }`. Checks run in order and return the first failure:
|
|
23
|
+
*
|
|
24
|
+
* 1. `not-a-component`: the def's opening container is not present.
|
|
25
|
+
* 2. `unknown-attribute`: the block carries an attribute key the def does not declare.
|
|
26
|
+
* 3. `undeclared-child`: the root has a direct child container directive that is not a declared
|
|
27
|
+
* nested slot. Such a child would otherwise fold into the body slot and move on re-serialize.
|
|
28
|
+
* 4. `not-idempotent`: `parse -> serialize -> parse` does not recover the same values. */
|
|
29
|
+
export declare function componentRoundTripSafety(markdown: string, def: ComponentDef): Promise<RoundTripSafety>;
|
|
10
30
|
/** Parse the component once and derive both the guided-form values and the raw attribute keys.
|
|
11
31
|
* Validation needs both, so this seam spares it the double parse that calling
|
|
12
32
|
* {@link parseComponent} and {@link parseRawAttributeKeys} separately would cost. */
|