@glw907/cairn-cms 0.56.1 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +148 -0
- package/README.md +10 -4
- package/dist/components/AdminLayout.svelte +3 -0
- package/dist/components/CairnAdmin.svelte +8 -1
- package/dist/components/CairnAdmin.svelte.d.ts +2 -0
- package/dist/components/CairnMediaLibrary.svelte +929 -0
- package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
- 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 +477 -15
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/MarkdownEditor.svelte +358 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +51 -1
- package/dist/components/MediaCaptureCard.svelte +135 -0
- package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
- package/dist/components/MediaFigureControl.svelte +247 -0
- package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
- package/dist/components/MediaHeroField.svelte +569 -0
- package/dist/components/MediaHeroField.svelte.d.ts +67 -0
- package/dist/components/MediaInsertPopover.svelte +449 -0
- package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
- package/dist/components/MediaPicker.svelte +257 -0
- package/dist/components/MediaPicker.svelte.d.ts +41 -0
- package/dist/components/admin-icons.d.ts +12 -0
- package/dist/components/admin-icons.js +12 -0
- package/dist/components/cairn-admin.css +1045 -28
- package/dist/components/client-ingest.d.ts +142 -0
- package/dist/components/client-ingest.js +297 -0
- package/dist/components/editor-media.d.ts +11 -0
- package/dist/components/editor-media.js +206 -0
- package/dist/components/editor-placeholder.d.ts +26 -0
- package/dist/components/editor-placeholder.js +166 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +19 -0
- package/dist/components/markdown-directives.js +52 -0
- package/dist/components/markdown-format.d.ts +89 -0
- package/dist/components/markdown-format.js +255 -0
- package/dist/components/media-upload-outcome.d.ts +52 -0
- package/dist/components/media-upload-outcome.js +48 -0
- package/dist/content/compose.js +3 -0
- package/dist/content/frontmatter.js +17 -0
- package/dist/content/manifest.d.ts +4 -0
- package/dist/content/manifest.js +41 -1
- package/dist/content/media-refs.d.ts +7 -0
- package/dist/content/media-refs.js +52 -0
- package/dist/content/schema.d.ts +5 -2
- package/dist/content/schema.js +17 -0
- package/dist/content/types.d.ts +62 -11
- package/dist/content/validate.js +27 -0
- package/dist/delivery/public-routes.d.ts +16 -0
- package/dist/delivery/public-routes.js +46 -3
- package/dist/delivery/seo-fields.js +7 -1
- package/dist/delivery/seo.d.ts +2 -0
- package/dist/delivery/seo.js +3 -0
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +21 -0
- package/dist/doctor/index.d.ts +3 -1
- package/dist/doctor/index.js +11 -2
- package/dist/doctor/types.d.ts +3 -0
- package/dist/doctor/wrangler-config.d.ts +3 -0
- package/dist/doctor/wrangler-config.js +20 -0
- package/dist/env.d.ts +19 -0
- package/dist/env.js +26 -0
- package/dist/index.d.ts +1 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/media/config.d.ts +24 -0
- package/dist/media/config.js +69 -0
- package/dist/media/delivery-bucket.d.ts +34 -0
- package/dist/media/delivery-bucket.js +10 -0
- package/dist/media/index.d.ts +6 -0
- package/dist/media/index.js +13 -0
- package/dist/media/library-entry.d.ts +30 -0
- package/dist/media/library-entry.js +17 -0
- package/dist/media/manifest.d.ts +44 -0
- package/dist/media/manifest.js +105 -0
- package/dist/media/naming.d.ts +18 -0
- package/dist/media/naming.js +112 -0
- package/dist/media/reconcile.d.ts +36 -0
- package/dist/media/reconcile.js +45 -0
- package/dist/media/reference.d.ts +12 -0
- package/dist/media/reference.js +33 -0
- package/dist/media/sniff.d.ts +18 -0
- package/dist/media/sniff.js +106 -0
- package/dist/media/store.d.ts +25 -0
- package/dist/media/store.js +16 -0
- package/dist/media/transform-url.d.ts +26 -0
- package/dist/media/transform-url.js +38 -0
- package/dist/media/usage.d.ts +48 -0
- package/dist/media/usage.js +90 -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/pipeline.d.ts +2 -0
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.d.ts +28 -0
- package/dist/render/registry.js +15 -0
- package/dist/render/remark-figure.d.ts +4 -0
- package/dist/render/remark-figure.js +103 -0
- package/dist/render/resolve-media.d.ts +34 -0
- package/dist/render/resolve-media.js +78 -0
- package/dist/render/sanitize-schema.d.ts +4 -2
- package/dist/render/sanitize-schema.js +5 -3
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +5 -0
- package/dist/sveltekit/cairn-admin.d.ts +8 -1
- package/dist/sveltekit/cairn-admin.js +10 -2
- package/dist/sveltekit/content-routes.d.ts +68 -2
- package/dist/sveltekit/content-routes.js +461 -10
- package/dist/sveltekit/csrf.d.ts +16 -0
- package/dist/sveltekit/csrf.js +18 -0
- package/dist/sveltekit/guard.js +10 -3
- package/dist/sveltekit/index.d.ts +2 -1
- package/dist/sveltekit/index.js +1 -0
- package/dist/sveltekit/media-route.d.ts +12 -0
- package/dist/sveltekit/media-route.js +137 -0
- package/dist/vite/index.d.ts +3 -0
- package/dist/vite/index.js +7 -2
- package/package.json +8 -1
- package/src/lib/components/AdminLayout.svelte +3 -0
- package/src/lib/components/CairnAdmin.svelte +8 -1
- package/src/lib/components/CairnMediaLibrary.svelte +929 -0
- package/src/lib/components/ComponentForm.svelte +175 -46
- package/src/lib/components/ComponentInsertDialog.svelte +379 -26
- package/src/lib/components/EditPage.svelte +477 -15
- package/src/lib/components/MarkdownEditor.svelte +358 -1
- package/src/lib/components/MediaCaptureCard.svelte +135 -0
- package/src/lib/components/MediaFigureControl.svelte +247 -0
- package/src/lib/components/MediaHeroField.svelte +569 -0
- package/src/lib/components/MediaInsertPopover.svelte +449 -0
- package/src/lib/components/MediaPicker.svelte +257 -0
- package/src/lib/components/admin-icons.ts +12 -0
- package/src/lib/components/cairn-admin.css +37 -0
- package/src/lib/components/client-ingest.ts +380 -0
- package/src/lib/components/editor-media.ts +248 -0
- package/src/lib/components/editor-placeholder.ts +213 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +57 -0
- package/src/lib/components/markdown-format.ts +307 -1
- package/src/lib/components/media-upload-outcome.ts +83 -0
- package/src/lib/content/compose.ts +3 -0
- package/src/lib/content/frontmatter.ts +16 -1
- package/src/lib/content/manifest.ts +44 -1
- package/src/lib/content/media-refs.ts +58 -0
- package/src/lib/content/schema.ts +31 -7
- package/src/lib/content/types.ts +78 -13
- package/src/lib/content/validate.ts +26 -1
- package/src/lib/delivery/public-routes.ts +52 -3
- package/src/lib/delivery/seo-fields.ts +6 -1
- package/src/lib/delivery/seo.ts +5 -0
- package/src/lib/doctor/checks-local.ts +22 -0
- package/src/lib/doctor/index.ts +21 -3
- package/src/lib/doctor/types.ts +3 -0
- package/src/lib/doctor/wrangler-config.ts +23 -0
- package/src/lib/env.ts +28 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/log/events.ts +8 -1
- package/src/lib/media/config.ts +103 -0
- package/src/lib/media/delivery-bucket.ts +41 -0
- package/src/lib/media/index.ts +22 -0
- package/src/lib/media/library-entry.ts +58 -0
- package/src/lib/media/manifest.ts +122 -0
- package/src/lib/media/naming.ts +130 -0
- package/src/lib/media/reconcile.ts +79 -0
- package/src/lib/media/reference.ts +40 -0
- package/src/lib/media/sniff.ts +114 -0
- package/src/lib/media/store.ts +57 -0
- package/src/lib/media/transform-url.ts +58 -0
- package/src/lib/media/usage.ts +152 -0
- package/src/lib/render/component-grammar.ts +59 -3
- package/src/lib/render/component-validate.ts +22 -1
- package/src/lib/render/pipeline.ts +17 -3
- package/src/lib/render/registry.ts +38 -0
- package/src/lib/render/remark-figure.ts +132 -0
- package/src/lib/render/resolve-media.ts +96 -0
- package/src/lib/render/sanitize-schema.ts +5 -3
- package/src/lib/sveltekit/admin-dispatch.ts +6 -1
- package/src/lib/sveltekit/cairn-admin.ts +13 -3
- package/src/lib/sveltekit/content-routes.ts +573 -12
- package/src/lib/sveltekit/csrf.ts +18 -0
- package/src/lib/sveltekit/guard.ts +12 -3
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/media-route.ts +158 -0
- package/src/lib/vite/index.ts +9 -2
|
@@ -21,30 +21,49 @@ 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
|
+
import ImageIcon from '@lucide/svelte/icons/image';
|
|
27
29
|
import { useTopbar } from './topbar-context.js';
|
|
28
30
|
import CsrfField from './CsrfField.svelte';
|
|
29
31
|
import MarkdownEditor from './MarkdownEditor.svelte';
|
|
30
32
|
import EditorToolbar from './EditorToolbar.svelte';
|
|
31
|
-
import ComponentInsertDialog, { insertableDefs } from './ComponentInsertDialog.svelte';
|
|
33
|
+
import ComponentInsertDialog, { insertableDefs, hasSchema } from './ComponentInsertDialog.svelte';
|
|
32
34
|
import LinkPicker from './LinkPicker.svelte';
|
|
33
35
|
import WebLinkDialog from './WebLinkDialog.svelte';
|
|
36
|
+
import MediaInsertPopover from './MediaInsertPopover.svelte';
|
|
37
|
+
import MediaHeroField from './MediaHeroField.svelte';
|
|
38
|
+
import MediaFigureControl from './MediaFigureControl.svelte';
|
|
34
39
|
import DeleteDialog from './DeleteDialog.svelte';
|
|
35
40
|
import RenameDialog from './RenameDialog.svelte';
|
|
36
41
|
import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
|
|
37
42
|
import ShortcutsDialog from './ShortcutsDialog.svelte';
|
|
38
43
|
import { cairnLinkCompletionSource } from './link-completion.js';
|
|
39
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
findMediaImagesNeedingAlt,
|
|
46
|
+
unwrapCairnLink,
|
|
47
|
+
unwrapFigure,
|
|
48
|
+
updateFigure,
|
|
49
|
+
wrapImageInFigure,
|
|
50
|
+
type FigureAtImage,
|
|
51
|
+
type FigureRole,
|
|
52
|
+
type FormatKind,
|
|
53
|
+
} from './markdown-format.js';
|
|
40
54
|
import { buildPreviewDoc, deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
|
|
41
55
|
import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
|
|
42
|
-
import type { ComponentRegistry } from '../render/registry.js';
|
|
56
|
+
import type { ComponentRegistry, ComponentDef } from '../render/registry.js';
|
|
57
|
+
import { parseComponent, componentRoundTripSafety } from '../render/component-grammar.js';
|
|
43
58
|
import type { IconSet } from '../render/glyph.js';
|
|
44
59
|
import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
|
|
45
|
-
import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
|
|
60
|
+
import type { TextareaField, TagsField, FreeTagsField, ImageValue } from '../content/types.js';
|
|
46
61
|
import type { LinkResolve } from '../content/links.js';
|
|
47
62
|
import { manifestLinkResolver } from '../content/manifest.js';
|
|
63
|
+
import type { MediaResolve } from '../render/resolve-media.js';
|
|
64
|
+
import { manifestMediaResolver } from '../render/resolve-media.js';
|
|
65
|
+
import type { MediaEntry } from '../media/manifest.js';
|
|
66
|
+
import { mediaLibraryEntry } from '../media/library-entry.js';
|
|
48
67
|
|
|
49
68
|
interface Props {
|
|
50
69
|
/** The edit load's data, plus the site name for the heading. */
|
|
@@ -52,7 +71,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
52
71
|
/** The site's component registry, for the insert palette. */
|
|
53
72
|
registry?: ComponentRegistry;
|
|
54
73
|
/** The site's design-accurate render pipeline; the preview pane renders its output, which the floored pipeline already sanitized. */
|
|
55
|
-
render?: (
|
|
74
|
+
render?: (
|
|
75
|
+
md: string,
|
|
76
|
+
opts?: { stagger?: boolean; resolve?: LinkResolve; resolveMedia?: MediaResolve },
|
|
77
|
+
) => string | Promise<string>;
|
|
56
78
|
/** The site's icon set, for the guided form's icon fields. */
|
|
57
79
|
icons?: IconSet;
|
|
58
80
|
/** The last content action's failure: the save guard's broken links, the delete guard's
|
|
@@ -124,6 +146,12 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
124
146
|
if (target?.closest('dialog, #cairn-pane-write')) return;
|
|
125
147
|
fieldsDirty = true;
|
|
126
148
|
}
|
|
149
|
+
// Mark the details fields dirty without a form input event. The hero field writes its value to
|
|
150
|
+
// hidden inputs, whose programmatic value changes do not fire the form's oninput, so it signals
|
|
151
|
+
// dirty through this helper instead.
|
|
152
|
+
function markFieldsDirty() {
|
|
153
|
+
fieldsDirty = true;
|
|
154
|
+
}
|
|
127
155
|
|
|
128
156
|
// The edit form element, for the Ctrl/Cmd+S shortcut's requestSubmit.
|
|
129
157
|
let editForm = $state<HTMLFormElement | null>(null);
|
|
@@ -263,6 +291,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
263
291
|
// Which pane the editor card shows. The toolbar's tablist drives it; Write is always the
|
|
264
292
|
// landing tab.
|
|
265
293
|
let mode = $state<'write' | 'preview'>('write');
|
|
294
|
+
// Preview is read-only, so the insert controls the page renders into the toolbar disable with the
|
|
295
|
+
// strip's own format buttons. Declared here (above the Edit-block derivations that read it) so it
|
|
296
|
+
// is in scope before its first use.
|
|
297
|
+
const insertDisabled = $derived(mode === 'preview');
|
|
266
298
|
let previewHtml = $state('');
|
|
267
299
|
// True after a render call threw, so the preview pane can say so instead of going blank.
|
|
268
300
|
let previewFailed = $state(false);
|
|
@@ -353,12 +385,65 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
353
385
|
// preview knob, or a styleless document (behind the hint below) when the site sets none.
|
|
354
386
|
const previewDoc = $derived(buildPreviewDoc(previewHtml, data.preview));
|
|
355
387
|
let insert = $state.raw<(text: string) => void>(() => {});
|
|
388
|
+
// The editor's range-replace seam, registered by MarkdownEditor on mount; the dialog's Update
|
|
389
|
+
// routes through it to overwrite an edited block's source span. A no-op until then.
|
|
390
|
+
let replaceRange = $state.raw<(from: number, to: number, text: string) => void>(() => {});
|
|
391
|
+
// The editor's select-range seam, registered by MarkdownEditor on mount; the needs-alt notice's
|
|
392
|
+
// jump control routes through it to land the author on an image that lacks alt. A no-op until then.
|
|
393
|
+
let selectRange = $state.raw<(from: number, to: number) => void>(() => {});
|
|
356
394
|
let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
|
|
357
395
|
// The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
|
|
358
396
|
// reads it for the Text field's default.
|
|
359
397
|
let getSelection = $state.raw<() => string>(() => '');
|
|
360
398
|
// The editor's selection transform, registered by MarkdownEditor on mount; a no-op until then.
|
|
361
399
|
let format = $state.raw<(kind: FormatKind) => void>(() => {});
|
|
400
|
+
|
|
401
|
+
// The media insert seams, registered by MarkdownEditor on mount, mirroring the range holders
|
|
402
|
+
// above. The popover drives the optimistic upload loop through them: the caret anchor, the focus
|
|
403
|
+
// restore, the placeholder api, and the direct-insert path for a picked image. The placeholder
|
|
404
|
+
// api type is referenced inline (import('...').Type), never a static `import type ... from`, so
|
|
405
|
+
// no static edge to the dynamically-imported editor-placeholder module sits in this component
|
|
406
|
+
// (the editor-boundary test bars that edge by a textual `from` scan).
|
|
407
|
+
let caretCoords = $state.raw<() => { left: number; right: number; top: number; bottom: number } | null>(
|
|
408
|
+
() => null,
|
|
409
|
+
);
|
|
410
|
+
let focusEditor = $state.raw<() => void>(() => {});
|
|
411
|
+
let placeholders = $state.raw<import('./editor-placeholder.js').ImagePlaceholderApi | null>(null);
|
|
412
|
+
let insertImageFn = $state.raw<(alt: string, ref: string) => void>(() => {});
|
|
413
|
+
|
|
414
|
+
// A no-op placeholder api so the editor object handed to the popover is never null before the
|
|
415
|
+
// editor registers its real one on mount.
|
|
416
|
+
const noopPlaceholders: import('./editor-placeholder.js').ImagePlaceholderApi = {
|
|
417
|
+
begin: () => 0,
|
|
418
|
+
progress: () => {},
|
|
419
|
+
resolveTo: () => {},
|
|
420
|
+
cancel: () => {},
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// The editor object the popover drives, delegating through the holders so the latest registered
|
|
424
|
+
// function is always used (the holders start as no-ops and are replaced on mount).
|
|
425
|
+
const editorApi = $derived({
|
|
426
|
+
caretCoords: () => caretCoords(),
|
|
427
|
+
focusEditor: () => focusEditor(),
|
|
428
|
+
placeholders: placeholders ?? noopPlaceholders,
|
|
429
|
+
insertImage: (alt: string, ref: string) => insertImageFn(alt, ref),
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// The headless media insert popover, opened from the toolbar control, paste, or drop.
|
|
433
|
+
let mediaPopover = $state<MediaInsertPopover | null>(null);
|
|
434
|
+
|
|
435
|
+
// The rendered hero fields' refs (for the needs-alt notice's "Add alt text" action, which focuses
|
|
436
|
+
// the field's own alt input) and their reported needs-alt signals, keyed by field name. A hero is
|
|
437
|
+
// a frontmatter value with no body offset, so its needs-alt signal comes from the field, not the
|
|
438
|
+
// body scanner (findMediaImagesNeedingAlt), and its remediation focuses the alt input, never a
|
|
439
|
+
// source range (selectRange). The records are keyed by field name; `data.fields` is static for the
|
|
440
|
+
// page's lifetime, so a key never goes stale (no per-key cleanup on unmount is needed).
|
|
441
|
+
let heroFieldRefs = $state<Record<string, MediaHeroField>>({});
|
|
442
|
+
let heroNeedsAlt = $state<Record<string, boolean>>({});
|
|
443
|
+
|
|
444
|
+
// The server-owned records from each successful upload this session. They ride the save form as
|
|
445
|
+
// the hidden `media` field, so the save action merges them into media.json.
|
|
446
|
+
let uploadedRecords = $state<MediaEntry[]>([]);
|
|
362
447
|
// A headless dialog instance, typed structurally over its exported open() (the linkPicker idiom).
|
|
363
448
|
type DialogHandle = { open: () => void };
|
|
364
449
|
// The toolbar's insert dialogs. Each holds its own <form>, so they mount outside the edit form
|
|
@@ -366,7 +451,9 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
366
451
|
// breaks SSR and hydration); the toolbar snippet renders plain triggers that open them here.
|
|
367
452
|
let webLinkDialog = $state<DialogHandle | null>(null);
|
|
368
453
|
let linkPicker = $state<DialogHandle | null>(null);
|
|
369
|
-
|
|
454
|
+
// The insert dialog binds the full instance, not the bare DialogHandle: the Edit-block control
|
|
455
|
+
// drives editComponent(def, values, range) on it, beyond the shared open().
|
|
456
|
+
let insertDialog = $state<ComponentInsertDialog | null>(null);
|
|
370
457
|
// The lifecycle dialogs, opened from the header's overflow menu.
|
|
371
458
|
let deleteDialog = $state<DialogHandle | null>(null);
|
|
372
459
|
let renameDialog = $state<DialogHandle | null>(null);
|
|
@@ -379,6 +466,183 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
379
466
|
// by, so the toolbar trigger and the dialog appear and disappear together.
|
|
380
467
|
const hasComponents = $derived(insertableDefs(registry).length > 0);
|
|
381
468
|
|
|
469
|
+
// The directive container at the editor caret, reported by MarkdownEditor whenever it changes
|
|
470
|
+
// (null outside any container). The Edit-block control resolves it against the registry and the
|
|
471
|
+
// round-trip safety gate below; its identity is the key the async gate guards against a stale
|
|
472
|
+
// result. The reporter's name+markdown+from+to shape; declared locally because MarkdownEditor's
|
|
473
|
+
// matching interface is not exported.
|
|
474
|
+
type CaretComponent = { name: string | null; markdown: string; from: number; to: number };
|
|
475
|
+
let caretComponent = $state<CaretComponent | null>(null);
|
|
476
|
+
|
|
477
|
+
// The media image at the editor caret, reported by MarkdownEditor whenever it changes (null off any
|
|
478
|
+
// media image). The Figure control reads it to wrap a bare image or edit an existing figure; it
|
|
479
|
+
// writes source through the replaceRange seam. The figure dialog is mounted headless below.
|
|
480
|
+
let mediaAtCaret = $state<FigureAtImage | null>(null);
|
|
481
|
+
// The figure control's host <dialog>, opened by the toolbar control. Mounted outside the edit form
|
|
482
|
+
// (a form nested in a form is invalid HTML), the Edit-block dialog pattern.
|
|
483
|
+
let figureDialog = $state<HTMLDialogElement | null>(null);
|
|
484
|
+
// Whether the Figure control is available: a media image sits at the caret and Preview is not
|
|
485
|
+
// showing (the insert controls disable together with the Write surface). The control is always
|
|
486
|
+
// rendered (it never mounts on caret move); only its enabled state changes.
|
|
487
|
+
const figureAvailable = $derived(mediaAtCaret != null && !insertDisabled);
|
|
488
|
+
const figureLabel = $derived(
|
|
489
|
+
figureAvailable
|
|
490
|
+
? mediaAtCaret?.figure
|
|
491
|
+
? 'Edit the figure at the cursor'
|
|
492
|
+
: 'Wrap the image at the cursor in a figure'
|
|
493
|
+
: 'Place the cursor on an image to add a figure',
|
|
494
|
+
);
|
|
495
|
+
// Whether the image at the caret is decorative (empty or whitespace-only alt). The token came from
|
|
496
|
+
// a parsed image node, so the alt is the source between `![` and the closing `]` before `](`. An
|
|
497
|
+
// empty alt is the needs-alt signal; the figure control surfaces it and the decorative-plus-caption
|
|
498
|
+
// warning. Derived from the reported token so it tracks the caret.
|
|
499
|
+
const figureDecorative = $derived.by(() => {
|
|
500
|
+
if (!mediaAtCaret) return false;
|
|
501
|
+
const token = body.slice(mediaAtCaret.imageFrom, mediaAtCaret.imageTo);
|
|
502
|
+
const match = /^!\[([\s\S]*?)\]\(/.exec(token);
|
|
503
|
+
return (match?.[1] ?? '').trim() === '';
|
|
504
|
+
});
|
|
505
|
+
// A def is actionable for guided edit when it has a schema (the same notion the insert catalog
|
|
506
|
+
// lists by): a template-only def has no form to re-open into. Reuses the dialog's exported
|
|
507
|
+
// hasSchema so the two surfaces can never drift on what counts as editable.
|
|
508
|
+
function editableDef(name: string | null): ComponentDef | undefined {
|
|
509
|
+
if (!name) return undefined;
|
|
510
|
+
const def = registry?.get(name);
|
|
511
|
+
if (!def) return undefined;
|
|
512
|
+
return hasSchema(def) ? def : undefined;
|
|
513
|
+
}
|
|
514
|
+
// The resolved editability: the def, the source range, and the validated markdown when the caret
|
|
515
|
+
// sits on a known, schema-bearing component whose round-trip safety check passed for the CURRENT
|
|
516
|
+
// caret; null otherwise. The def, range, and markdown are captured from one caretComponent
|
|
517
|
+
// snapshot, so editBlock() never mixes a newer markdown with an older range. The Edit-block
|
|
518
|
+
// control enables only when this is set.
|
|
519
|
+
let editable = $state<{ def: ComponentDef; range: { from: number; to: number }; markdown: string } | null>(null);
|
|
520
|
+
// Why edit is unavailable, distinguishing "not on a component" from "on an unsafe one" so the
|
|
521
|
+
// disabled tooltip is honest. 'none' covers both no-caret-component and an unknown/template-only
|
|
522
|
+
// one (no guided form either way); 'unsafe' is a known component the safety gate refused.
|
|
523
|
+
let editReason = $state<'none' | 'unsafe'>('none');
|
|
524
|
+
// Resolve editability when the caret-component changes, async-safe. componentRoundTripSafety is
|
|
525
|
+
// async, so a slow check could resolve after a newer caret move; guard latest-wins on the
|
|
526
|
+
// caretComponent identity (the EditPage preview-debounce pattern), only applying a result when
|
|
527
|
+
// the caret has not moved since the check started. A block whose check did not pass for the
|
|
528
|
+
// current caret never enables edit.
|
|
529
|
+
$effect(() => {
|
|
530
|
+
const current = caretComponent;
|
|
531
|
+
const def = editableDef(current?.name ?? null);
|
|
532
|
+
if (!current || !def) {
|
|
533
|
+
editable = null;
|
|
534
|
+
editReason = 'none';
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
let stale = false;
|
|
538
|
+
void componentRoundTripSafety(current.markdown, def)
|
|
539
|
+
.then((result) => {
|
|
540
|
+
if (stale || caretComponent !== current) return;
|
|
541
|
+
if (result.safe) {
|
|
542
|
+
editable = { def, range: { from: current.from, to: current.to }, markdown: current.markdown };
|
|
543
|
+
editReason = 'none';
|
|
544
|
+
} else {
|
|
545
|
+
editable = null;
|
|
546
|
+
editReason = 'unsafe';
|
|
547
|
+
}
|
|
548
|
+
})
|
|
549
|
+
.catch(() => {
|
|
550
|
+
// A parse throw during the safety check must never leave a stale block enabled. Guarded by
|
|
551
|
+
// the same latest-wins identity, fall back to the safe default of no editable block.
|
|
552
|
+
if (stale || caretComponent !== current) return;
|
|
553
|
+
editable = null;
|
|
554
|
+
editReason = 'none';
|
|
555
|
+
});
|
|
556
|
+
return () => {
|
|
557
|
+
stale = true;
|
|
558
|
+
};
|
|
559
|
+
});
|
|
560
|
+
// The Edit-block control's accessible label and tooltip: a plain reason in each state. Enabled
|
|
561
|
+
// names the action; the two disabled reasons are honest about why.
|
|
562
|
+
const editBlockLabel = $derived(
|
|
563
|
+
editable
|
|
564
|
+
? 'Edit the component at the cursor'
|
|
565
|
+
: editReason === 'unsafe'
|
|
566
|
+
? "This block can't be edited in the form. Edit it as markdown."
|
|
567
|
+
: 'Place the cursor in a component to edit it',
|
|
568
|
+
);
|
|
569
|
+
// Whether the Edit-block control is unavailable: either Preview hides the Write surface, or the
|
|
570
|
+
// caret is not on a safe, schema-bearing component. The control stays focusable and announced in
|
|
571
|
+
// this state (aria-disabled, not the native disabled attribute), so its reason reaches assistive
|
|
572
|
+
// technology; the dead click is made inert in editBlock().
|
|
573
|
+
const editBlockUnavailable = $derived(insertDisabled || !editable);
|
|
574
|
+
// Activate edit: parse the block into form values, then open the dialog in edit mode over the
|
|
575
|
+
// stored source range. Guarded by editable AND the preview-mode disable, so the control is inert
|
|
576
|
+
// unless the gate passed and the editor is on the Write tab. The def, range, and markdown all come
|
|
577
|
+
// from the one editable snapshot, so a newer caret markdown is never paired with an older range.
|
|
578
|
+
async function editBlock() {
|
|
579
|
+
if (insertDisabled || !editable) return;
|
|
580
|
+
const values = await parseComponent(editable.markdown, editable.def);
|
|
581
|
+
insertDialog?.editComponent(editable.def, values, editable.range);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// The figure dialog's pre-fill, snapshotted when the control opens so the form never mixes a newer
|
|
585
|
+
// caret with the values it opened on. Captured from mediaAtCaret at open time: edit mode with the
|
|
586
|
+
// figure's caption/role when a figure wraps the image, else wrap mode with empty caption and the
|
|
587
|
+
// measure default. decorative rides the snapshot too. Null while the dialog is closed.
|
|
588
|
+
let figurePrefill = $state<{
|
|
589
|
+
mode: 'wrap' | 'edit';
|
|
590
|
+
caption: string;
|
|
591
|
+
role: FigureRole | null;
|
|
592
|
+
decorative: boolean;
|
|
593
|
+
image: { from: number; to: number };
|
|
594
|
+
figureRange: { from: number; to: number } | null;
|
|
595
|
+
} | null>(null);
|
|
596
|
+
|
|
597
|
+
// Open the figure control over the media image at the caret. Inert unless a media image sits there
|
|
598
|
+
// and the Write surface is up, the same gate the toolbar control shows. The snapshot is the source
|
|
599
|
+
// of truth for the apply handlers, so a caret move while the dialog is open never re-targets it.
|
|
600
|
+
function openFigure() {
|
|
601
|
+
if (!figureAvailable || !mediaAtCaret) return;
|
|
602
|
+
const at = mediaAtCaret;
|
|
603
|
+
figurePrefill = {
|
|
604
|
+
mode: at.figure ? 'edit' : 'wrap',
|
|
605
|
+
caption: at.figure?.caption ?? '',
|
|
606
|
+
role: at.figure?.role ?? null,
|
|
607
|
+
decorative: figureDecorative,
|
|
608
|
+
image: { from: at.imageFrom, to: at.imageTo },
|
|
609
|
+
figureRange: at.figure ? { from: at.figure.from, to: at.figure.to } : null,
|
|
610
|
+
};
|
|
611
|
+
figureDialog?.showModal();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Apply the control's choice through the replaceRange seam, then close. Wrap a bare image or update
|
|
615
|
+
// an existing figure, off the snapshot the dialog opened on. The pure transform owns the source
|
|
616
|
+
// shape and keeps the media token byte-intact; the preview stays read-only.
|
|
617
|
+
function applyFigure(choice: { caption: string; role: FigureRole | null }) {
|
|
618
|
+
const pre = figurePrefill;
|
|
619
|
+
if (!pre) return;
|
|
620
|
+
const result =
|
|
621
|
+
pre.mode === 'edit' && pre.figureRange
|
|
622
|
+
? updateFigure(body, pre.figureRange, choice.caption, choice.role)
|
|
623
|
+
: wrapImageInFigure(body, pre.image.from, pre.image.to, choice.caption, choice.role);
|
|
624
|
+
writeFigureResult(result);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Unwrap the figure back to its bare image, then close. Edit mode only (the snapshot carries the
|
|
628
|
+
// figure range). The bare image token is restored verbatim by the pure transform.
|
|
629
|
+
function unwrapFigureAction() {
|
|
630
|
+
const pre = figurePrefill;
|
|
631
|
+
if (!pre || !pre.figureRange) return;
|
|
632
|
+
writeFigureResult(unwrapFigure(body, pre.figureRange));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Write a figure transform's result back to the editor: overwrite the whole doc through the
|
|
636
|
+
// replaceRange seam, then place the selection the transform chose (the seam alone drops the caret
|
|
637
|
+
// at the end). replaceRange dispatches the doc change and focuses the surface; selectRange then
|
|
638
|
+
// dispatches a selection-only transaction, which CodeMirror's history does not record as its own
|
|
639
|
+
// undoable event, so one undo reverts the whole figure write. Close the dialog last.
|
|
640
|
+
function writeFigureResult(result: { doc: string; from: number; to: number }) {
|
|
641
|
+
replaceRange(0, body.length, result.doc);
|
|
642
|
+
selectRange(result.from, result.to);
|
|
643
|
+
figureDialog?.close();
|
|
644
|
+
}
|
|
645
|
+
|
|
382
646
|
// The header's status badge, in ConceptList's vocabulary: a pending entry reads Edited (or New
|
|
383
647
|
// when it has never been published); otherwise the live site matches and it reads Published.
|
|
384
648
|
const status = $derived.by(() => {
|
|
@@ -444,6 +708,21 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
444
708
|
}
|
|
445
709
|
}
|
|
446
710
|
|
|
711
|
+
// The media images in the live body that carry no alt text, recomputed as the author types. Alt is
|
|
712
|
+
// accessibility debt, never a render or publish failure, so this drives a non-blocking warning the
|
|
713
|
+
// author can act on or leave; the count drops and the notice clears as each alt is filled.
|
|
714
|
+
const needsAlt = $derived(findMediaImagesNeedingAlt(body));
|
|
715
|
+
|
|
716
|
+
// The declared image (hero) fields, for labelling the needs-alt notice's frontmatter rows.
|
|
717
|
+
const imageFields = $derived(
|
|
718
|
+
data.fields.filter((f) => f.type === 'image').map((f) => ({ name: f.name, label: f.label })),
|
|
719
|
+
);
|
|
720
|
+
// The frontmatter-hero needs-alt rows: each image field whose hero reports a needs-alt signal. The
|
|
721
|
+
// row's action focuses the field's alt input (the body scanner and its source-range jump cannot
|
|
722
|
+
// reach a frontmatter value). The headline count sums these with the body scanner's hits.
|
|
723
|
+
const heroRows = $derived(imageFields.filter((f) => heroNeedsAlt[f.name]));
|
|
724
|
+
const needsAltCount = $derived(needsAlt.length + heroRows.length);
|
|
725
|
+
|
|
447
726
|
// The delete guard's inbound linkers, from a refused delete (fail 409). Empty when the delete was
|
|
448
727
|
// not refused. When set, a delete was blocked by a link that appeared since the page loaded.
|
|
449
728
|
const deleteRefusedLinks = $derived(form?.inboundLinks ?? []);
|
|
@@ -556,6 +835,28 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
556
835
|
// returns undefined for a missing target so the render step marks it cairn-broken-link.
|
|
557
836
|
const resolveLink = $derived(manifestLinkResolver(data.linkTargets));
|
|
558
837
|
|
|
838
|
+
// The media analog: it turns a media: reference into its /media delivery path in the preview, and
|
|
839
|
+
// returns undefined for a missing target so the render step marks it cairn-broken-media. The
|
|
840
|
+
// committed mediaTargets projection is merged with this session's uploaded records (the same
|
|
841
|
+
// override the picker's library does), so a just-uploaded image renders its thumbnail in the live
|
|
842
|
+
// preview before the next save commits it, rather than reading as a broken reference.
|
|
843
|
+
const resolveMediaTargets = $derived({
|
|
844
|
+
...data.mediaTargets,
|
|
845
|
+
...Object.fromEntries(
|
|
846
|
+
uploadedRecords.map((r) => [r.hash, { slug: r.slug, ext: r.ext, contentType: r.contentType }]),
|
|
847
|
+
),
|
|
848
|
+
});
|
|
849
|
+
const resolveMedia = $derived(manifestMediaResolver(resolveMediaTargets));
|
|
850
|
+
|
|
851
|
+
// The picker's library, the committed projection merged with this session's uploaded records,
|
|
852
|
+
// keyed by content hash. An uploaded record overrides a committed entry on a hash match (the same
|
|
853
|
+
// hash is the same bytes, so the override is harmless). This is what the editor decorates with, so
|
|
854
|
+
// a just-uploaded image carries its source chip before the next save commits it.
|
|
855
|
+
const mediaLibrary = $derived({
|
|
856
|
+
...data.mediaLibrary,
|
|
857
|
+
...Object.fromEntries(uploadedRecords.map((r) => [r.hash, mediaLibraryEntry(r)])),
|
|
858
|
+
});
|
|
859
|
+
|
|
559
860
|
// The [[ autocomplete source over the same link targets, handed to the editor's generic seam.
|
|
560
861
|
const completionSources = $derived([cairnLinkCompletionSource(data.linkTargets)]);
|
|
561
862
|
|
|
@@ -563,10 +864,6 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
563
864
|
mode = m;
|
|
564
865
|
}
|
|
565
866
|
|
|
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
867
|
// The editor card's keyboard shortcuts. Bound to the card so they fire wherever focus sits in the
|
|
571
868
|
// strip or the surface, without claiming the keys page-wide. The listener attaches
|
|
572
869
|
// programmatically: it is event delegation, not an interaction affordance, which Svelte's a11y
|
|
@@ -626,10 +923,11 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
626
923
|
if (mode !== 'preview' || !render) return;
|
|
627
924
|
const md = body;
|
|
628
925
|
const resolve = resolveLink; // tracked read in the effect body
|
|
926
|
+
const resolveMediaRef = resolveMedia; // tracked read in the effect body
|
|
629
927
|
const run = ++previewRun;
|
|
630
928
|
const handle = setTimeout(async () => {
|
|
631
929
|
try {
|
|
632
|
-
const html = await render(md, { resolve });
|
|
930
|
+
const html = await render(md, { resolve, resolveMedia: resolveMediaRef });
|
|
633
931
|
if (run === previewRun) {
|
|
634
932
|
previewHtml = html;
|
|
635
933
|
previewFailed = false;
|
|
@@ -828,6 +1126,45 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
828
1126
|
</ul>
|
|
829
1127
|
</div>
|
|
830
1128
|
{/if}
|
|
1129
|
+
<!-- The publish-time needs-alt notice: a non-blocking warning, never a block. Alt text is
|
|
1130
|
+
accessibility debt, so the author can add it now or save without it; the count drops live as
|
|
1131
|
+
each alt is filled and the notice clears at zero. The leading glyph carries the state alongside
|
|
1132
|
+
the count, so the caution reads without relying on hue. Each row's jump control selects the
|
|
1133
|
+
image in the source through the editor's select-range seam, landing the author on it to type
|
|
1134
|
+
the alt. The role="status" live region renders unconditionally (present and empty at load), so
|
|
1135
|
+
when the first debt appears its count change announces; a region conditionally mounted with its
|
|
1136
|
+
first content may not be observed by assistive tech (WCAG 4.1.3). The visible alert chrome and
|
|
1137
|
+
content gate on the count, so an empty region shows nothing. A plain wrapper (not display:contents)
|
|
1138
|
+
carries the role, since some assistive tech drops a role off a display:contents box. -->
|
|
1139
|
+
<div role="status">
|
|
1140
|
+
{#if needsAltCount}
|
|
1141
|
+
<div class="alert alert-warning mb-4 flex-col items-start text-sm">
|
|
1142
|
+
<p class="flex items-center gap-2 font-medium">
|
|
1143
|
+
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
|
1144
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
|
1145
|
+
</svg>
|
|
1146
|
+
<span>{needsAltCount} {needsAltCount === 1 ? 'image needs' : 'images need'} alt text</span>
|
|
1147
|
+
</p>
|
|
1148
|
+
<p>Alt text describes an image for readers who cannot see it. Add it now, or save and come back to it.</p>
|
|
1149
|
+
<ul class="mt-1 w-full">
|
|
1150
|
+
{#each needsAlt as item (item.from)}
|
|
1151
|
+
<li class="flex items-center justify-between gap-2">
|
|
1152
|
+
<code class="text-xs">{item.ref}</code>
|
|
1153
|
+
<button type="button" class="btn btn-xs" onclick={() => selectRange(item.from, item.to)}>Add alt text</button>
|
|
1154
|
+
</li>
|
|
1155
|
+
{/each}
|
|
1156
|
+
<!-- The frontmatter-hero rows: a hero has no body offset, so its action focuses the field's
|
|
1157
|
+
own alt input rather than a source range. -->
|
|
1158
|
+
{#each heroRows as hero (hero.name)}
|
|
1159
|
+
<li class="flex items-center justify-between gap-2">
|
|
1160
|
+
<span class="text-xs font-medium">{hero.label}</span>
|
|
1161
|
+
<button type="button" class="btn btn-xs" onclick={() => heroFieldRefs[hero.name]?.focusAlt()}>Add alt text</button>
|
|
1162
|
+
</li>
|
|
1163
|
+
{/each}
|
|
1164
|
+
</ul>
|
|
1165
|
+
</div>
|
|
1166
|
+
{/if}
|
|
1167
|
+
</div>
|
|
831
1168
|
{#if draftWarning}
|
|
832
1169
|
<div class="alert alert-warning mb-4 text-sm">
|
|
833
1170
|
Saved. Note: this page links to unpublished {draftWarning.includes(',') ? 'pages' : 'a page'} ({draftWarning}), which will 404 until published.
|
|
@@ -903,6 +1240,24 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
903
1240
|
>
|
|
904
1241
|
<BlocksIcon class="h-4 w-4" aria-hidden="true" />
|
|
905
1242
|
</button>
|
|
1243
|
+
<!-- Edit block re-opens the component at the caret into the guided form. It is
|
|
1244
|
+
unavailable while Preview shows (like the insert controls) and whenever the caret is
|
|
1245
|
+
not on a safe, schema-bearing component; the tooltip names the reason in each state.
|
|
1246
|
+
The unavailable state uses aria-disabled, not the native disabled attribute, so the
|
|
1247
|
+
control stays focusable and its reason reaches assistive technology; the disabled
|
|
1248
|
+
look rides a class and editBlock() early-returns so the dead click is inert. -->
|
|
1249
|
+
<button
|
|
1250
|
+
type="button"
|
|
1251
|
+
class="btn btn-sm btn-ghost btn-square"
|
|
1252
|
+
class:btn-disabled={editBlockUnavailable}
|
|
1253
|
+
aria-haspopup="dialog"
|
|
1254
|
+
aria-label={editBlockLabel}
|
|
1255
|
+
title={editBlockLabel}
|
|
1256
|
+
aria-disabled={editBlockUnavailable}
|
|
1257
|
+
onclick={editBlock}
|
|
1258
|
+
>
|
|
1259
|
+
<SquarePenIcon class="h-4 w-4" aria-hidden="true" />
|
|
1260
|
+
</button>
|
|
906
1261
|
{/if}
|
|
907
1262
|
<button
|
|
908
1263
|
type="button"
|
|
@@ -929,9 +1284,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
929
1284
|
<button
|
|
930
1285
|
type="button"
|
|
931
1286
|
class="btn btn-ghost btn-sm btn-square"
|
|
932
|
-
disabled
|
|
933
|
-
aria-label="
|
|
934
|
-
title="
|
|
1287
|
+
disabled={insertDisabled}
|
|
1288
|
+
aria-label="Insert image"
|
|
1289
|
+
title="Insert image"
|
|
1290
|
+
onclick={() => mediaPopover?.open('chooser')}
|
|
935
1291
|
>
|
|
936
1292
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
937
1293
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
@@ -939,6 +1295,26 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
939
1295
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
940
1296
|
</svg>
|
|
941
1297
|
</button>
|
|
1298
|
+
<!-- The Figure control: always rendered, enabled only when the caret sits on a media image
|
|
1299
|
+
(and the Write surface is up). It never mounts or unmounts on caret movement; only its
|
|
1300
|
+
enabled state changes (the Edit-block pattern). The unavailable state uses aria-disabled,
|
|
1301
|
+
not the native disabled attribute, so the control stays focusable and its reason reaches
|
|
1302
|
+
assistive technology; openFigure() early-returns so the dead click is inert. The dimming
|
|
1303
|
+
uses opacity and cursor utilities, never .btn-disabled, because that sets
|
|
1304
|
+
pointer-events: none and would suppress the title tooltip a mouse user reads for the why. -->
|
|
1305
|
+
<button
|
|
1306
|
+
type="button"
|
|
1307
|
+
class="btn btn-sm btn-ghost btn-square"
|
|
1308
|
+
class:opacity-50={!figureAvailable}
|
|
1309
|
+
class:cursor-not-allowed={!figureAvailable}
|
|
1310
|
+
aria-haspopup="dialog"
|
|
1311
|
+
aria-label={figureLabel}
|
|
1312
|
+
title={figureLabel}
|
|
1313
|
+
aria-disabled={!figureAvailable}
|
|
1314
|
+
onclick={openFigure}
|
|
1315
|
+
>
|
|
1316
|
+
<ImageIcon class="h-4 w-4" aria-hidden="true" />
|
|
1317
|
+
</button>
|
|
942
1318
|
{/snippet}
|
|
943
1319
|
</EditorToolbar>
|
|
944
1320
|
{/if}
|
|
@@ -950,13 +1326,26 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
950
1326
|
name="body"
|
|
951
1327
|
{surface}
|
|
952
1328
|
registerInsert={(fn) => (insert = fn)}
|
|
1329
|
+
onComponentAtCaret={(info) => (caretComponent = info)}
|
|
1330
|
+
onMediaImageAtCaret={(info) => (mediaAtCaret = info)}
|
|
1331
|
+
registerReplaceRange={(fn) => (replaceRange = fn)}
|
|
1332
|
+
registerSelectRange={(fn) => (selectRange = fn)}
|
|
953
1333
|
registerInsertLink={(fn) => (insertLink = fn)}
|
|
954
1334
|
registerGetSelection={(fn) => (getSelection = fn)}
|
|
955
1335
|
registerFormat={(fn) => (format = fn)}
|
|
1336
|
+
registerCaretCoords={(fn) => (caretCoords = fn)}
|
|
1337
|
+
registerFocusEditor={(fn) => (focusEditor = fn)}
|
|
1338
|
+
registerImagePlaceholders={(api) => (placeholders = api)}
|
|
1339
|
+
registerInsertImage={(fn) => (insertImageFn = fn)}
|
|
1340
|
+
onImageIngest={(file) => mediaPopover?.open('capture', file)}
|
|
956
1341
|
{completionSources}
|
|
1342
|
+
{mediaLibrary}
|
|
957
1343
|
{focusMode}
|
|
958
1344
|
{typewriter}
|
|
959
1345
|
/>
|
|
1346
|
+
<!-- The accumulated uploaded records ride the save form alongside the body. The save action
|
|
1347
|
+
reads `media` and merges these records into media.json (publish submits the same form). -->
|
|
1348
|
+
<input type="hidden" name="media" value={JSON.stringify(uploadedRecords)} />
|
|
960
1349
|
</div>
|
|
961
1350
|
{#if mode === 'preview'}
|
|
962
1351
|
<!-- The preview ground: recessed under the floating frame card so the page reads as a
|
|
@@ -1177,6 +1566,19 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1177
1566
|
value={tagValue}
|
|
1178
1567
|
/>
|
|
1179
1568
|
</label>
|
|
1569
|
+
{:else if field.type === 'image'}
|
|
1570
|
+
{@const heroValue = data.frontmatter[field.name] as ImageValue | undefined}
|
|
1571
|
+
<MediaHeroField
|
|
1572
|
+
bind:this={heroFieldRefs[field.name]}
|
|
1573
|
+
field={{ name: field.name, label: field.label }}
|
|
1574
|
+
value={heroValue}
|
|
1575
|
+
mediaLibrary={mediaLibrary}
|
|
1576
|
+
conceptId={data.conceptId}
|
|
1577
|
+
id={data.id}
|
|
1578
|
+
onuploaded={(record) => (uploadedRecords = [...uploadedRecords, record])}
|
|
1579
|
+
ondirty={markFieldsDirty}
|
|
1580
|
+
onneedsaltchange={(n) => (heroNeedsAlt = { ...heroNeedsAlt, [field.name]: n })}
|
|
1581
|
+
/>
|
|
1180
1582
|
{:else}
|
|
1181
1583
|
<label class="flex flex-col gap-1">
|
|
1182
1584
|
<span class="text-sm font-medium">{field.label}</span>
|
|
@@ -1239,10 +1641,70 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1239
1641
|
<form>, and a form nested in a form is invalid HTML the parser repairs by dropping the outer
|
|
1240
1642
|
tag, which breaks the SSR'd document and hydration. The toolbar snippet's triggers drive them
|
|
1241
1643
|
through their exported open(). -->
|
|
1242
|
-
<ComponentInsertDialog
|
|
1644
|
+
<ComponentInsertDialog
|
|
1645
|
+
bind:this={insertDialog}
|
|
1646
|
+
trigger={false}
|
|
1647
|
+
{registry}
|
|
1648
|
+
{insert}
|
|
1649
|
+
update={(range, md) => replaceRange(range.from, range.to, md)}
|
|
1650
|
+
{icons}
|
|
1651
|
+
{render}
|
|
1652
|
+
preview={data.preview}
|
|
1653
|
+
/>
|
|
1243
1654
|
<WebLinkDialog bind:this={webLinkDialog} trigger={false} insert={insertLink} selection={getSelection} />
|
|
1244
1655
|
<LinkPicker bind:this={linkPicker} trigger={false} linkTargets={data.linkTargets} insert={insertLink} />
|
|
1245
1656
|
|
|
1657
|
+
<!-- The media insert popover, mounted headless: the toolbar control, a paste, or a drop drives it
|
|
1658
|
+
through its exported open(). On a successful upload it hands the server-owned record up; the
|
|
1659
|
+
record joins uploadedRecords (the hidden save field) and the merged library (the source chip). -->
|
|
1660
|
+
<MediaInsertPopover
|
|
1661
|
+
bind:this={mediaPopover}
|
|
1662
|
+
trigger={false}
|
|
1663
|
+
conceptId={data.conceptId}
|
|
1664
|
+
id={data.id}
|
|
1665
|
+
library={mediaLibrary}
|
|
1666
|
+
editor={editorApi}
|
|
1667
|
+
onuploaded={(record) => (uploadedRecords = [...uploadedRecords, record])}
|
|
1668
|
+
/>
|
|
1669
|
+
|
|
1670
|
+
<!-- The figure control's host dialog, mounted headless outside the edit form (the control holds its
|
|
1671
|
+
own <form>). The toolbar Figure control opens it through openFigure(), pre-filled from the caret
|
|
1672
|
+
snapshot. The control is keyed on figurePrefill so it remounts fresh per open, seeding its fields
|
|
1673
|
+
from the new caption/role. The native <dialog> gives the focus trap and Escape for free, and the
|
|
1674
|
+
close event (the X, the backdrop, Escape, and the apply path all fire it) clears the snapshot so
|
|
1675
|
+
the host state matches the closed dialog. -->
|
|
1676
|
+
<dialog
|
|
1677
|
+
class="modal"
|
|
1678
|
+
aria-labelledby="cairn-figure-dialog-title"
|
|
1679
|
+
bind:this={figureDialog}
|
|
1680
|
+
onclose={() => (figurePrefill = null)}
|
|
1681
|
+
>
|
|
1682
|
+
<div class="modal-box max-w-sm">
|
|
1683
|
+
<div class="mb-3 flex items-center justify-between">
|
|
1684
|
+
<h2 id="cairn-figure-dialog-title" class="flex items-center gap-2 text-base font-semibold">
|
|
1685
|
+
<ImageIcon class="h-4 w-4 text-[var(--color-accent)]" aria-hidden="true" />
|
|
1686
|
+
{figurePrefill?.mode === 'edit' ? 'Edit figure' : 'Wrap in a figure'}
|
|
1687
|
+
</h2>
|
|
1688
|
+
<button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={() => figureDialog?.close()}>✕</button>
|
|
1689
|
+
</div>
|
|
1690
|
+
{#if figurePrefill}
|
|
1691
|
+
{#key figurePrefill}
|
|
1692
|
+
<MediaFigureControl
|
|
1693
|
+
caption={figurePrefill.caption}
|
|
1694
|
+
role={figurePrefill.role}
|
|
1695
|
+
mode={figurePrefill.mode}
|
|
1696
|
+
decorative={figurePrefill.decorative}
|
|
1697
|
+
onapply={applyFigure}
|
|
1698
|
+
onunwrap={unwrapFigureAction}
|
|
1699
|
+
/>
|
|
1700
|
+
{/key}
|
|
1701
|
+
{/if}
|
|
1702
|
+
</div>
|
|
1703
|
+
<form method="dialog" class="modal-backdrop">
|
|
1704
|
+
<button tabindex="-1" aria-label="Close">close</button>
|
|
1705
|
+
</form>
|
|
1706
|
+
</dialog>
|
|
1707
|
+
|
|
1246
1708
|
<!-- The lifecycle dialogs, mounted headless: the header's overflow menu drives them through their
|
|
1247
1709
|
exported open(). Their POST forms flip the leaving flag so the leave guard stands down. -->
|
|
1248
1710
|
<RenameDialog
|