@glw907/cairn-cms 0.56.2 → 0.57.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +134 -0
- 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 +949 -0
- package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
- package/dist/components/EditPage.svelte +348 -7
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/MarkdownEditor.svelte +283 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +37 -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 +578 -0
- package/dist/components/MediaHeroField.svelte.d.ts +75 -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 +901 -9
- 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 +12 -0
- package/dist/components/markdown-directives.js +42 -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 +22 -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 +64 -11
- package/dist/content/validate.js +31 -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/pipeline.d.ts +2 -0
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.js +3 -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 +77 -2
- package/dist/sveltekit/content-routes.js +470 -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 +7 -1
- package/src/lib/components/AdminLayout.svelte +3 -0
- package/src/lib/components/CairnAdmin.svelte +8 -1
- package/src/lib/components/CairnMediaLibrary.svelte +949 -0
- package/src/lib/components/EditPage.svelte +348 -7
- package/src/lib/components/MarkdownEditor.svelte +283 -1
- package/src/lib/components/MediaCaptureCard.svelte +135 -0
- package/src/lib/components/MediaFigureControl.svelte +247 -0
- package/src/lib/components/MediaHeroField.svelte +578 -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 +46 -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 +20 -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 +80 -13
- package/src/lib/content/validate.ts +29 -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/pipeline.ts +17 -3
- package/src/lib/render/registry.ts +5 -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 +589 -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
|
@@ -9,8 +9,10 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
9
9
|
-->
|
|
10
10
|
<script lang="ts">
|
|
11
11
|
import { onMount, onDestroy } from 'svelte';
|
|
12
|
-
import { applyMarkdownFormat, insertInlineLink, type FormatKind, type FormatResult } from './markdown-format.js';
|
|
12
|
+
import { applyMarkdownFormat, figureAtImage, insertImage as insertImageFormat, insertInlineLink, type FigureAtImage, type FormatKind, type FormatResult } from './markdown-format.js';
|
|
13
13
|
import { fenceScan, caretContainerRange, directiveOpenerName } from './markdown-directives.js';
|
|
14
|
+
import { firstImageFile, guardDropTarget } from './client-ingest.js';
|
|
15
|
+
import type { MediaLibrary } from '../media/library-entry.js';
|
|
14
16
|
|
|
15
17
|
/** The directive container at the caret: the opener's name, the block's markdown, and the
|
|
16
18
|
* document character offsets of its inclusive line range. */
|
|
@@ -30,6 +32,30 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
30
32
|
registerInsert?: (insert: (text: string) => void) => void;
|
|
31
33
|
/** Receives a `(href, title) => void` that inserts an inline link; the link picker calls it. */
|
|
32
34
|
registerInsertLink?: (insert: (href: string, title: string) => void) => void;
|
|
35
|
+
/** Receives an `(alt, ref) => void` that inserts an inline image at the caret; the media picker
|
|
36
|
+
* and the capture card call it with the chosen alt and the full `media:slug.hash` reference. */
|
|
37
|
+
registerInsertImage?: (insert: (alt: string, ref: string) => void) => void;
|
|
38
|
+
/** Called with the first image File of a paste or drop onto the surface; the host opens the
|
|
39
|
+
* capture card with the bytes. A paste or drop carrying no image falls through untouched. */
|
|
40
|
+
onImageIngest?: (file: File) => void;
|
|
41
|
+
/** The picker's human layer per stored asset, keyed by the 16-hex content hash (EditData's
|
|
42
|
+
* `mediaLibrary`). The source decoration reads it to render a `media:` token as a thumbnail chip;
|
|
43
|
+
* reactive, so a just-uploaded image decorates once it joins the library. Empty by default. */
|
|
44
|
+
mediaLibrary?: MediaLibrary;
|
|
45
|
+
/** Receives a `() => { left; right; top; bottom } | null` returning the caret's viewport
|
|
46
|
+
* coordinates; the insert popover anchors itself to the cursor from this. Null before mount or
|
|
47
|
+
* when the caret has no measurable position. */
|
|
48
|
+
registerCaretCoords?: (
|
|
49
|
+
get: () => { left: number; right: number; top: number; bottom: number } | null,
|
|
50
|
+
) => void;
|
|
51
|
+
/** Receives a `() => void` that returns focus to the editor; the insert popover calls it on close
|
|
52
|
+
* or Escape. The selection is preserved automatically, since opening the popover only blurs the
|
|
53
|
+
* editor and never edits the doc. */
|
|
54
|
+
registerFocusEditor?: (focus: () => void) => void;
|
|
55
|
+
/** Receives the optimistic-placeholder api; the insert popover drives the upload loop through it
|
|
56
|
+
* (begin lands a placeholder at the caret, progress moves its bar, resolveTo swaps it for the
|
|
57
|
+
* committed image text, cancel removes it leaving the source untouched). */
|
|
58
|
+
registerImagePlaceholders?: (api: import('./editor-placeholder.js').ImagePlaceholderApi) => void;
|
|
33
59
|
/** Receives a `() => string` returning the selected text; the web link dialog reads it. */
|
|
34
60
|
registerGetSelection?: (get: () => string) => void;
|
|
35
61
|
/** Receives a `(kind) => void` that transforms the current selection; the host's toolbar calls it. */
|
|
@@ -37,9 +63,17 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
37
63
|
/** Reports the directive container at the caret (or null when outside any container) whenever
|
|
38
64
|
* the reported value changes; the host resolves it against the registry to offer Edit-block. */
|
|
39
65
|
onComponentAtCaret?: (info: ComponentAtCaret | null) => void;
|
|
66
|
+
/** Reports the media image at the caret (or null when the caret is not on one) whenever the
|
|
67
|
+
* reported value changes; the host opens the figure control over it to wrap, edit, or unwrap a
|
|
68
|
+
* `:::figure`. The figure transforms write source through registerReplaceRange. */
|
|
69
|
+
onMediaImageAtCaret?: (info: FigureAtImage | null) => void;
|
|
40
70
|
/** Receives a `(from, to, text) => void` that overwrites a document span; the dialog's Update
|
|
41
71
|
* calls it to write an edited block back over its original range. */
|
|
42
72
|
registerReplaceRange?: (replace: (from: number, to: number, text: string) => void) => void;
|
|
73
|
+
/** Receives a `(from, to) => void` that selects a document span, focuses the editor, and scrolls
|
|
74
|
+
* the range into view; the needs-alt notice's jump control calls it to land the author on an
|
|
75
|
+
* image that lacks alt text. */
|
|
76
|
+
registerSelectRange?: (select: (from: number, to: number) => void) => void;
|
|
43
77
|
/** Generic CodeMirror completion sources wired into the editor; the link autocomplete is one. The
|
|
44
78
|
* type is referenced inline so no static `@codemirror/*` import sits in this client-only file. */
|
|
45
79
|
completionSources?: import('@codemirror/autocomplete').CompletionSource[];
|
|
@@ -57,10 +91,18 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
57
91
|
name,
|
|
58
92
|
registerInsert,
|
|
59
93
|
registerInsertLink,
|
|
94
|
+
registerInsertImage,
|
|
95
|
+
onImageIngest,
|
|
96
|
+
mediaLibrary = {},
|
|
97
|
+
registerCaretCoords,
|
|
98
|
+
registerFocusEditor,
|
|
99
|
+
registerImagePlaceholders,
|
|
60
100
|
registerGetSelection,
|
|
61
101
|
registerFormat,
|
|
62
102
|
onComponentAtCaret,
|
|
103
|
+
onMediaImageAtCaret,
|
|
63
104
|
registerReplaceRange,
|
|
105
|
+
registerSelectRange,
|
|
64
106
|
completionSources = [],
|
|
65
107
|
focusMode = false,
|
|
66
108
|
typewriter = false,
|
|
@@ -80,6 +122,11 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
80
122
|
let focusCompartment: import('@codemirror/state').Compartment | null = null;
|
|
81
123
|
let typewriterCompartment: import('@codemirror/state').Compartment | null = null;
|
|
82
124
|
let surfaceCompartment: import('@codemirror/state').Compartment | null = null;
|
|
125
|
+
// The media: source decoration lives in its own compartment, reconfigured when the mediaLibrary
|
|
126
|
+
// prop changes so a just-uploaded image decorates the moment it joins the library. The media
|
|
127
|
+
// module loads with the other dynamic editor modules in onMount.
|
|
128
|
+
let mediaCompartment: import('@codemirror/state').Compartment | null = null;
|
|
129
|
+
let mediaMod: typeof import('./editor-media.js') | null = null;
|
|
83
130
|
// The posture themes, swapped through the surface compartment. Each owns its type step and
|
|
84
131
|
// leading (the base theme deliberately sets neither on the content node, so the postures never
|
|
85
132
|
// contest it on adoption order). Built in onMount beside the base theme.
|
|
@@ -96,6 +143,8 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
96
143
|
const highlightMod = await import('./editor-highlight.js');
|
|
97
144
|
const modesMod = await import('./editor-modes.js');
|
|
98
145
|
const foldingMod = await import('./editor-folding.js');
|
|
146
|
+
const placeholderMod = await import('./editor-placeholder.js');
|
|
147
|
+
mediaMod = await import('./editor-media.js');
|
|
99
148
|
|
|
100
149
|
if (!host) return;
|
|
101
150
|
|
|
@@ -206,6 +255,120 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
206
255
|
},
|
|
207
256
|
'.cm-cairn-directive-leaf': directiveInk,
|
|
208
257
|
'.cm-cairn-directive-inline': directiveInk,
|
|
258
|
+
// The media: source chip: the inline widget that stands in for a media reference token, in the
|
|
259
|
+
// directive accent language (the 8% accent chip, the accent ink that holds AA on it). An
|
|
260
|
+
// inline-flex pill carrying a small thumbnail and the asset's display name, so a reference
|
|
261
|
+
// reads as the image it points at without leaving the source view.
|
|
262
|
+
'.cm-cairn-media-chip': {
|
|
263
|
+
display: 'inline-flex',
|
|
264
|
+
alignItems: 'center',
|
|
265
|
+
gap: '0.3em',
|
|
266
|
+
verticalAlign: 'baseline',
|
|
267
|
+
padding: '0.05em 0.4em 0.05em 0.25em',
|
|
268
|
+
borderRadius: '0.375rem',
|
|
269
|
+
backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
|
|
270
|
+
color: 'var(--color-accent)',
|
|
271
|
+
fontFamily: 'var(--font-body, ui-sans-serif, sans-serif)',
|
|
272
|
+
fontSize: '0.8125rem',
|
|
273
|
+
lineHeight: '1.4',
|
|
274
|
+
},
|
|
275
|
+
// The thumbnail: a small square crop, rounded to match the chip. object-fit keeps a
|
|
276
|
+
// non-square source from distorting. A faint border lifts a light image off the chip tint.
|
|
277
|
+
'.cm-cairn-media-thumb': {
|
|
278
|
+
width: '1.4em',
|
|
279
|
+
height: '1.4em',
|
|
280
|
+
objectFit: 'cover',
|
|
281
|
+
borderRadius: '0.25rem',
|
|
282
|
+
border: '1px solid color-mix(in oklab, var(--color-accent) 20%, transparent)',
|
|
283
|
+
flex: '0 0 auto',
|
|
284
|
+
},
|
|
285
|
+
'.cm-cairn-media-name': {
|
|
286
|
+
fontWeight: '500',
|
|
287
|
+
// Keep a long name from stretching the line; the title and the picker carry the full text.
|
|
288
|
+
maxWidth: '18ch',
|
|
289
|
+
overflow: 'hidden',
|
|
290
|
+
textOverflow: 'ellipsis',
|
|
291
|
+
whiteSpace: 'nowrap',
|
|
292
|
+
},
|
|
293
|
+
// The needs-alt marker: a glyph plus a label, never hue alone (the spec accessibility rule).
|
|
294
|
+
// It rides the warning tone so it reads as a caution, with the label spelling out the state.
|
|
295
|
+
// The text uses --cairn-warning-ink, the on-surface warning text token, not --color-warning
|
|
296
|
+
// (a fill tone that fails small-text contrast on the light chip tint, WCAG 1.4.3). The ink
|
|
297
|
+
// holds AA on the chip's tint on both themes and stands apart from the accent name.
|
|
298
|
+
'.cm-cairn-media-needs-alt': {
|
|
299
|
+
display: 'inline-flex',
|
|
300
|
+
alignItems: 'center',
|
|
301
|
+
gap: '0.2em',
|
|
302
|
+
color: 'var(--cairn-warning-ink, oklch(50% 0.13 70))',
|
|
303
|
+
fontSize: '0.6875rem',
|
|
304
|
+
fontWeight: '600',
|
|
305
|
+
textTransform: 'uppercase',
|
|
306
|
+
letterSpacing: '0.02em',
|
|
307
|
+
},
|
|
308
|
+
'.cm-cairn-media-needs-alt-glyph': { fontSize: '0.85em', lineHeight: '1' },
|
|
309
|
+
// The figure/role pill: a small bordered pill carrying the placement role (or "figure" for
|
|
310
|
+
// the measure default) when a media token sits inside a :::figure, in the directive accent
|
|
311
|
+
// language. The accent ink and a color-mix accent border on the base-100 surface read as a
|
|
312
|
+
// quiet tag beside the name. The ink is theme-defined, so it holds contrast in both themes
|
|
313
|
+
// (Task 8's polish confirms it visually). A bare token renders no pill at all.
|
|
314
|
+
'.cm-cairn-media-role': {
|
|
315
|
+
fontFamily: 'var(--font-body, ui-sans-serif, sans-serif)',
|
|
316
|
+
fontSize: '0.625rem',
|
|
317
|
+
fontWeight: '600',
|
|
318
|
+
letterSpacing: '0.01em',
|
|
319
|
+
color: 'var(--color-accent)',
|
|
320
|
+
backgroundColor: 'var(--color-base-100)',
|
|
321
|
+
border: '1px solid color-mix(in oklab, var(--color-accent) 35%, transparent)',
|
|
322
|
+
borderRadius: '0.3rem',
|
|
323
|
+
padding: '0.04rem 0.34rem',
|
|
324
|
+
flex: '0 0 auto',
|
|
325
|
+
},
|
|
326
|
+
// The optimistic upload placeholder: an inline pill in the accent language, carrying a small
|
|
327
|
+
// thumbnail of the image the author is placing and a determinate progress bar beneath it. It
|
|
328
|
+
// stands in for the committed image text only while the upload runs; on resolve the seam
|
|
329
|
+
// swaps it for the real reference, and on failure the seam removes it (the source untouched).
|
|
330
|
+
// The accent tint matches the media chip so the two read as one visual family.
|
|
331
|
+
'.cm-cairn-media-placeholder': {
|
|
332
|
+
display: 'inline-flex',
|
|
333
|
+
flexDirection: 'column',
|
|
334
|
+
gap: '0.2em',
|
|
335
|
+
verticalAlign: 'baseline',
|
|
336
|
+
padding: '0.2em 0.35em',
|
|
337
|
+
borderRadius: '0.375rem',
|
|
338
|
+
backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
|
|
339
|
+
border: '1px solid color-mix(in oklab, var(--color-accent) 20%, transparent)',
|
|
340
|
+
},
|
|
341
|
+
'.cm-cairn-media-placeholder-thumb': {
|
|
342
|
+
width: '2.4em',
|
|
343
|
+
height: '2.4em',
|
|
344
|
+
objectFit: 'cover',
|
|
345
|
+
borderRadius: '0.25rem',
|
|
346
|
+
// A gentle pulse marks the placeholder as in-flight; reduced-motion drops it below.
|
|
347
|
+
opacity: '0.85',
|
|
348
|
+
},
|
|
349
|
+
// The determinate bar: native <progress> restyled to the accent ink so the fill reads as the
|
|
350
|
+
// upload's progress. Sized to the thumbnail width so the pill stays compact.
|
|
351
|
+
'.cm-cairn-media-placeholder-bar': {
|
|
352
|
+
width: '2.4em',
|
|
353
|
+
height: '0.3em',
|
|
354
|
+
appearance: 'none',
|
|
355
|
+
border: '0',
|
|
356
|
+
borderRadius: '0.15em',
|
|
357
|
+
backgroundColor: 'color-mix(in oklab, var(--color-accent) 18%, transparent)',
|
|
358
|
+
overflow: 'hidden',
|
|
359
|
+
},
|
|
360
|
+
'.cm-cairn-media-placeholder-bar::-webkit-progress-bar': {
|
|
361
|
+
backgroundColor: 'transparent',
|
|
362
|
+
},
|
|
363
|
+
'.cm-cairn-media-placeholder-bar::-webkit-progress-value': {
|
|
364
|
+
backgroundColor: 'var(--color-accent)',
|
|
365
|
+
borderRadius: '0.15em',
|
|
366
|
+
transition: 'width 200ms ease',
|
|
367
|
+
},
|
|
368
|
+
'.cm-cairn-media-placeholder-bar::-moz-progress-bar': {
|
|
369
|
+
backgroundColor: 'var(--color-accent)',
|
|
370
|
+
borderRadius: '0.15em',
|
|
371
|
+
},
|
|
209
372
|
// Container folding lives in a real gutter column now, not an in-text band. The gutter is a
|
|
210
373
|
// fixed-x column left of the content; the chevron is empty at rest and reveals on hovering
|
|
211
374
|
// the gutter cell (the VS Code / Zed / Obsidian standard), forced on when folded or when the
|
|
@@ -266,6 +429,7 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
266
429
|
'@media (prefers-reduced-motion: reduce)': {
|
|
267
430
|
'.cm-cairn-fold-btn svg': { transition: 'none' },
|
|
268
431
|
'.cm-cairn-fold-flash': { transition: 'none' },
|
|
432
|
+
'.cm-cairn-media-placeholder-bar::-webkit-progress-value': { transition: 'none' },
|
|
269
433
|
},
|
|
270
434
|
// The folded-row wash: a soft accent tint, square and full-row, returning as a STATE signal
|
|
271
435
|
// so folded spots read in a scan. The rails are inset box-shadows on the same line element
|
|
@@ -352,6 +516,7 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
352
516
|
focusCompartment = new stateMod.Compartment();
|
|
353
517
|
typewriterCompartment = new stateMod.Compartment();
|
|
354
518
|
surfaceCompartment = new stateMod.Compartment();
|
|
519
|
+
mediaCompartment = new stateMod.Compartment();
|
|
355
520
|
|
|
356
521
|
view = new EditorView({
|
|
357
522
|
parent: host,
|
|
@@ -378,6 +543,44 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
378
543
|
// invariant. Placed after the directive plugin so its chevron widget on an opener row
|
|
379
544
|
// composes with the row's rail and gutter; its keymap is internal to the extension.
|
|
380
545
|
foldingMod.cairnFolding(),
|
|
546
|
+
// The optimistic image placeholder field: a widget-only decoration the insert popover
|
|
547
|
+
// drives through the registerImagePlaceholders api. It never writes doc text, so a failed
|
|
548
|
+
// upload leaves the source untouched (open risk 2). Placed after folding so a placeholder
|
|
549
|
+
// landing inside a directive composes with the rails.
|
|
550
|
+
placeholderMod.cairnImagePlaceholders(),
|
|
551
|
+
// The media: source decoration, in its own compartment so a mediaLibrary prop change
|
|
552
|
+
// reconfigures it without rebuilding the editor. The chip and the atomic ranges read the
|
|
553
|
+
// library; an empty library decorates nothing.
|
|
554
|
+
mediaCompartment.of(mediaMod.cairnMediaDecorations(mediaLibrary)),
|
|
555
|
+
// Paste and drop ingest: an image carried by either gesture is preventDefault'd and handed
|
|
556
|
+
// to onImageIngest (the host opens the capture card with the bytes); a gesture carrying no
|
|
557
|
+
// image falls through to CodeMirror's default. 2b is single-file per gesture (open risk 3),
|
|
558
|
+
// so only the first image routes.
|
|
559
|
+
EditorView.domEventHandlers({
|
|
560
|
+
dragover(event) {
|
|
561
|
+
// Allow the drop only when the drag carries image files; otherwise let it pass so a
|
|
562
|
+
// non-image drag (text, a link) keeps its native behavior.
|
|
563
|
+
if (event.dataTransfer && firstImageFile(event.dataTransfer)) {
|
|
564
|
+
guardDropTarget(event);
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
return false;
|
|
568
|
+
},
|
|
569
|
+
drop(event) {
|
|
570
|
+
const file = event.dataTransfer ? firstImageFile(event.dataTransfer) : null;
|
|
571
|
+
if (!file) return false;
|
|
572
|
+
guardDropTarget(event);
|
|
573
|
+
onImageIngest?.(file);
|
|
574
|
+
return true;
|
|
575
|
+
},
|
|
576
|
+
paste(event) {
|
|
577
|
+
const file = event.clipboardData ? firstImageFile(event.clipboardData) : null;
|
|
578
|
+
if (!file) return false; // a text or markdown paste falls through untouched
|
|
579
|
+
event.preventDefault();
|
|
580
|
+
onImageIngest?.(file);
|
|
581
|
+
return true;
|
|
582
|
+
},
|
|
583
|
+
}),
|
|
381
584
|
EditorView.contentAttributes.of({ spellcheck: 'true', autocorrect: 'on', autocapitalize: 'sentences' }),
|
|
382
585
|
theme,
|
|
383
586
|
surfaceCompartment.of(surface === 'prose' ? proseTheme : markupTheme),
|
|
@@ -387,6 +590,10 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
387
590
|
// caret sits in, so the reporter runs on either; the dedupe below absorbs the no-ops.
|
|
388
591
|
if (onComponentAtCaret && (update.docChanged || update.selectionSet))
|
|
389
592
|
reportComponentAtCaret(update.state);
|
|
593
|
+
// The media-image reporter rides the same two triggers: a caret move lands on or off an
|
|
594
|
+
// image, an edit shifts the figure's span. The dedupe absorbs the keystroke no-ops.
|
|
595
|
+
if (onMediaImageAtCaret && (update.docChanged || update.selectionSet))
|
|
596
|
+
reportMediaImageAtCaret(update.state);
|
|
390
597
|
}),
|
|
391
598
|
],
|
|
392
599
|
}),
|
|
@@ -394,12 +601,18 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
394
601
|
|
|
395
602
|
registerInsert?.(insertAtCursor);
|
|
396
603
|
registerInsertLink?.(insertLink);
|
|
604
|
+
registerInsertImage?.(insertImage);
|
|
605
|
+
registerCaretCoords?.(caretCoords);
|
|
606
|
+
registerFocusEditor?.(focusEditor);
|
|
607
|
+
registerImagePlaceholders?.(placeholderMod.imagePlaceholderApi(view));
|
|
397
608
|
registerGetSelection?.(selectedText);
|
|
398
609
|
registerFormat?.(applyFormat);
|
|
399
610
|
registerReplaceRange?.(replaceRange);
|
|
611
|
+
registerSelectRange?.(selectRange);
|
|
400
612
|
// Report the caret's starting container once the editor exists, so a caret that mounts inside
|
|
401
613
|
// a block is known without waiting for the first move.
|
|
402
614
|
if (onComponentAtCaret) reportComponentAtCaret(view.state);
|
|
615
|
+
if (onMediaImageAtCaret) reportMediaImageAtCaret(view.state);
|
|
403
616
|
mounted = true;
|
|
404
617
|
});
|
|
405
618
|
|
|
@@ -432,6 +645,15 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
432
645
|
});
|
|
433
646
|
});
|
|
434
647
|
|
|
648
|
+
// Reconfigure the media decoration when the mediaLibrary prop changes, so a just-uploaded image
|
|
649
|
+
// (added to the library by the host) decorates without rebuilding the editor. Reading the prop
|
|
650
|
+
// tracks it; the guard waits for the mounted editor and its media module.
|
|
651
|
+
$effect(() => {
|
|
652
|
+
const library = mediaLibrary;
|
|
653
|
+
if (!mounted || !view || !mediaMod || !mediaCompartment) return;
|
|
654
|
+
view.dispatch({ effects: mediaCompartment.reconfigure(mediaMod.cairnMediaDecorations(library)) });
|
|
655
|
+
});
|
|
656
|
+
|
|
435
657
|
// The last value handed to onComponentAtCaret, so the reporter fires only on a change. The
|
|
436
658
|
// identity compared is name + markdown + from + to. A pure caret move within one block leaves all
|
|
437
659
|
// four unchanged, so it does not refire; an edit inside the block changes the markdown even when
|
|
@@ -473,6 +695,32 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
473
695
|
onComponentAtCaret?.(next);
|
|
474
696
|
}
|
|
475
697
|
|
|
698
|
+
// The last media-image report, so the reporter fires only on a change. The compared identity is
|
|
699
|
+
// the image span plus the figure's range/caption/role; a pure caret move within one image (or one
|
|
700
|
+
// figure) leaves them unchanged, so it does not refire, while an edit that shifts the figure span
|
|
701
|
+
// or rewrites the caption does. A null transitions to or from null on entering/leaving an image.
|
|
702
|
+
let lastMediaReport: FigureAtImage | null = null;
|
|
703
|
+
|
|
704
|
+
// Compute the media image at the caret from a CodeMirror state and report it through
|
|
705
|
+
// onMediaImageAtCaret, deduped so a caret move that stays on the same image does not refire.
|
|
706
|
+
function reportMediaImageAtCaret(state: import('@codemirror/state').EditorState) {
|
|
707
|
+
const next = figureAtImage(state.doc.toString(), state.selection.main.head);
|
|
708
|
+
const prev = lastMediaReport;
|
|
709
|
+
const same =
|
|
710
|
+
prev === next ||
|
|
711
|
+
(prev !== null &&
|
|
712
|
+
next !== null &&
|
|
713
|
+
prev.imageFrom === next.imageFrom &&
|
|
714
|
+
prev.imageTo === next.imageTo &&
|
|
715
|
+
(prev.figure?.from ?? null) === (next.figure?.from ?? null) &&
|
|
716
|
+
(prev.figure?.to ?? null) === (next.figure?.to ?? null) &&
|
|
717
|
+
(prev.figure?.caption ?? null) === (next.figure?.caption ?? null) &&
|
|
718
|
+
(prev.figure?.role ?? null) === (next.figure?.role ?? null));
|
|
719
|
+
if (same) return;
|
|
720
|
+
lastMediaReport = next;
|
|
721
|
+
onMediaImageAtCaret?.(next);
|
|
722
|
+
}
|
|
723
|
+
|
|
476
724
|
// Overwrite a document span with new text and drop the caret after it, mirroring insertAtCursor's
|
|
477
725
|
// dispatch shape. A no-op before the editor mounts, the same guard the other seams carry.
|
|
478
726
|
function replaceRange(from: number, to: number, text: string) {
|
|
@@ -481,6 +729,15 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
481
729
|
view.focus();
|
|
482
730
|
}
|
|
483
731
|
|
|
732
|
+
// Select a document span, focus the surface, and scroll the range into view. The needs-alt notice's
|
|
733
|
+
// jump control calls it to land the author on an image that lacks alt text. A no-op before the
|
|
734
|
+
// editor mounts, the same guard the other seams carry.
|
|
735
|
+
function selectRange(from: number, to: number) {
|
|
736
|
+
if (!view) return;
|
|
737
|
+
view.dispatch({ selection: { anchor: from, head: to }, scrollIntoView: true });
|
|
738
|
+
view.focus();
|
|
739
|
+
}
|
|
740
|
+
|
|
484
741
|
function insertAtCursor(text: string) {
|
|
485
742
|
if (!view) {
|
|
486
743
|
value = value ? `${value}\n\n${text}` : text;
|
|
@@ -518,12 +775,37 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
518
775
|
transformSelection((doc, from, to) => insertInlineLink(doc, from, to, href, title));
|
|
519
776
|
}
|
|
520
777
|
|
|
778
|
+
function insertImage(alt: string, ref: string) {
|
|
779
|
+
if (!view) {
|
|
780
|
+
// The editor has not mounted yet; append the image to the raw value so a pick is never lost,
|
|
781
|
+
// mirroring insertLink's pre-mount fallback.
|
|
782
|
+
const image = insertImageFormat('', 0, 0, alt, ref).doc;
|
|
783
|
+
value = value ? `${value} ${image}` : image;
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
transformSelection((doc, from, to) => insertImageFormat(doc, from, to, alt, ref));
|
|
787
|
+
}
|
|
788
|
+
|
|
521
789
|
function selectedText(): string {
|
|
522
790
|
if (!view) return '';
|
|
523
791
|
const { from, to } = view.state.selection.main;
|
|
524
792
|
return view.state.sliceDoc(from, to);
|
|
525
793
|
}
|
|
526
794
|
|
|
795
|
+
// The caret's viewport coordinates, for the insert popover to anchor itself to the cursor. Null
|
|
796
|
+
// before the editor mounts or when the caret has no measurable position (an unrendered line).
|
|
797
|
+
function caretCoords(): { left: number; right: number; top: number; bottom: number } | null {
|
|
798
|
+
if (!view) return null;
|
|
799
|
+
const rect = view.coordsAtPos(view.state.selection.main.head);
|
|
800
|
+
return rect ? { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom } : null;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Return focus to the editor surface; the popover calls it on close or Escape. The selection is
|
|
804
|
+
// intact because opening the popover only blurred the editor, it never edited the doc.
|
|
805
|
+
function focusEditor() {
|
|
806
|
+
view?.focus();
|
|
807
|
+
}
|
|
808
|
+
|
|
527
809
|
function applyFormat(kind: FormatKind) {
|
|
528
810
|
transformSelection((doc, from, to) => applyMarkdownFormat(doc, from, to, kind));
|
|
529
811
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The one-step capture card for a media insert. Shown when an author already has bytes in hand (a
|
|
4
|
+
paste, a drop, or a chosen file), it captures three things and emits them to its host: the file, an
|
|
5
|
+
editable display name, and the alt text. It is a presentational form card, not a dialog of its own;
|
|
6
|
+
Task 6's insert popover hosts it.
|
|
7
|
+
|
|
8
|
+
The display name pre-fills from proposedNameFor(file.name): a real specific stem (blue-shoes.png)
|
|
9
|
+
arrives as a Suggested value, while a generic camera stem (IMG_4821.jpg) leaves the field empty and
|
|
10
|
+
required with no tag, so the author never accepts machine noise.
|
|
11
|
+
|
|
12
|
+
Alt is a real role="radiogroup" of two radios: write a description, or mark decorative. The
|
|
13
|
+
requirement is surfaced through aria-describedby, never by disabling the submit. Insert is never
|
|
14
|
+
disabled (the locked no-skipped-disabled-reason rule); an author may proceed with alt unset and the
|
|
15
|
+
emitted record carries an empty alt, which the host treats as needs-alt debt. A decorative choice
|
|
16
|
+
also resolves alt to the empty string. The committed reference keys off an empty alt as the
|
|
17
|
+
needs-alt signal, so the emitted record uses an empty alt string for both the decorative and the
|
|
18
|
+
left-blank cases, and a separate decorative flag distinguishes them for the host.
|
|
19
|
+
-->
|
|
20
|
+
<script lang="ts">
|
|
21
|
+
import { untrack } from 'svelte';
|
|
22
|
+
import { proposedNameFor } from './client-ingest.js';
|
|
23
|
+
|
|
24
|
+
/** The record the card emits to its host on insert. */
|
|
25
|
+
interface CaptureRecord {
|
|
26
|
+
/** The image bytes the author is placing. */
|
|
27
|
+
file: File;
|
|
28
|
+
/** The editable display name, the proposed stem or the author's edit. */
|
|
29
|
+
displayName: string;
|
|
30
|
+
/** The alt text. Empty for a decorative image or for an author who proceeded without alt; the
|
|
31
|
+
* host commits an empty alt as the needs-alt signal in the `` reference. */
|
|
32
|
+
alt: string;
|
|
33
|
+
/** True when the author marked the image decorative, distinguishing a deliberate empty alt from
|
|
34
|
+
* a left-blank one. The committed alt is empty either way. */
|
|
35
|
+
decorative: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Props {
|
|
39
|
+
/** The image to capture; the card previews it from a local object URL. */
|
|
40
|
+
file: File;
|
|
41
|
+
/** Emit the captured record to the host on insert. */
|
|
42
|
+
oncapture: (record: CaptureRecord) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let { file, oncapture }: Props = $props();
|
|
46
|
+
|
|
47
|
+
// The proposed display name for this file, computed once. A real stem pre-fills the field and shows
|
|
48
|
+
// the Suggested tag; a generic stem yields null, leaving the field empty, required, and untagged.
|
|
49
|
+
const proposed = $derived(proposedNameFor(file.name));
|
|
50
|
+
// Seeded once from the file the card opened with; untrack marks it a deliberate one-time read, not
|
|
51
|
+
// a reactive miss. The card lives for one file, so a later file swap is out of scope.
|
|
52
|
+
let displayName = $state(untrack(() => proposedNameFor(file.name) ?? ''));
|
|
53
|
+
|
|
54
|
+
// The alt mode: unset until the author picks one, then 'describe' or 'decorative'. Unset emits an
|
|
55
|
+
// empty alt (needs-alt debt); insert never blocks on it.
|
|
56
|
+
let altMode = $state<'describe' | 'decorative' | null>(null);
|
|
57
|
+
let altText = $state('');
|
|
58
|
+
|
|
59
|
+
// A local object URL for the preview. Allocation and revoke live in ONE $effect keyed on the file,
|
|
60
|
+
// never in a $derived: a derivation that calls createObjectURL allocates a resource as a side
|
|
61
|
+
// effect, which can desync from the revoke (a re-derive leaks the prior URL). The matched effect
|
|
62
|
+
// revokes on teardown and on any file change, so the blob never leaks.
|
|
63
|
+
let previewUrl = $state('');
|
|
64
|
+
$effect(() => {
|
|
65
|
+
const url = URL.createObjectURL(file);
|
|
66
|
+
previewUrl = url;
|
|
67
|
+
return () => URL.revokeObjectURL(url);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
function submit(e: SubmitEvent) {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
// Decorative and write-but-blank both commit an empty alt; the decorative flag distinguishes
|
|
73
|
+
// them for the host. A described image carries the trimmed alt text.
|
|
74
|
+
const alt = altMode === 'describe' ? altText.trim() : '';
|
|
75
|
+
oncapture({ file, displayName: displayName.trim(), alt, decorative: altMode === 'decorative' });
|
|
76
|
+
}
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<form class="flex flex-col gap-4" onsubmit={submit}>
|
|
80
|
+
<div class="flex items-start gap-3">
|
|
81
|
+
<img
|
|
82
|
+
src={previewUrl}
|
|
83
|
+
alt=""
|
|
84
|
+
class="h-16 w-16 flex-none rounded-box border border-[var(--cairn-card-border)] object-cover"
|
|
85
|
+
/>
|
|
86
|
+
<label class="flex flex-1 flex-col gap-1">
|
|
87
|
+
<span class="flex items-center gap-2 text-sm font-medium">
|
|
88
|
+
Name
|
|
89
|
+
{#if proposed !== null}
|
|
90
|
+
<span class="badge badge-ghost badge-sm">Suggested</span>
|
|
91
|
+
{/if}
|
|
92
|
+
</span>
|
|
93
|
+
<input
|
|
94
|
+
class="input w-full"
|
|
95
|
+
aria-required={proposed === null ? 'true' : undefined}
|
|
96
|
+
placeholder="What is this image?"
|
|
97
|
+
bind:value={displayName}
|
|
98
|
+
/>
|
|
99
|
+
</label>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<fieldset
|
|
103
|
+
class="flex flex-col gap-2"
|
|
104
|
+
role="radiogroup"
|
|
105
|
+
aria-label="Alt text"
|
|
106
|
+
aria-required="true"
|
|
107
|
+
aria-describedby="cairn-capture-alt-note"
|
|
108
|
+
>
|
|
109
|
+
<legend class="text-sm font-medium">Alt text</legend>
|
|
110
|
+
<p id="cairn-capture-alt-note" class="text-xs text-[var(--color-muted)]">
|
|
111
|
+
Describe the image for screen readers, or mark it decorative. You can insert without alt text
|
|
112
|
+
and add it later.
|
|
113
|
+
</p>
|
|
114
|
+
<label class="flex cursor-pointer items-center gap-2">
|
|
115
|
+
<input type="radio" class="radio radio-sm" name="cairn-capture-alt" value="describe" bind:group={altMode} />
|
|
116
|
+
<span class="text-sm">Write a description</span>
|
|
117
|
+
</label>
|
|
118
|
+
{#if altMode === 'describe'}
|
|
119
|
+
<input
|
|
120
|
+
class="input input-sm ml-6 w-[calc(100%-1.5rem)]"
|
|
121
|
+
aria-label="Alt text description"
|
|
122
|
+
placeholder="A short description"
|
|
123
|
+
bind:value={altText}
|
|
124
|
+
/>
|
|
125
|
+
{/if}
|
|
126
|
+
<label class="flex cursor-pointer items-center gap-2">
|
|
127
|
+
<input type="radio" class="radio radio-sm" name="cairn-capture-alt" value="decorative" bind:group={altMode} />
|
|
128
|
+
<span class="text-sm">Mark as decorative</span>
|
|
129
|
+
</label>
|
|
130
|
+
</fieldset>
|
|
131
|
+
|
|
132
|
+
<div class="flex justify-end">
|
|
133
|
+
<button type="submit" class="btn btn-sm btn-primary">Insert image</button>
|
|
134
|
+
</div>
|
|
135
|
+
</form>
|