@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.
Files changed (173) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/dist/components/AdminLayout.svelte +3 -0
  3. package/dist/components/CairnAdmin.svelte +8 -1
  4. package/dist/components/CairnAdmin.svelte.d.ts +2 -0
  5. package/dist/components/CairnMediaLibrary.svelte +949 -0
  6. package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
  7. package/dist/components/EditPage.svelte +348 -7
  8. package/dist/components/EditPage.svelte.d.ts +2 -0
  9. package/dist/components/MarkdownEditor.svelte +283 -1
  10. package/dist/components/MarkdownEditor.svelte.d.ts +37 -1
  11. package/dist/components/MediaCaptureCard.svelte +135 -0
  12. package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
  13. package/dist/components/MediaFigureControl.svelte +247 -0
  14. package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
  15. package/dist/components/MediaHeroField.svelte +578 -0
  16. package/dist/components/MediaHeroField.svelte.d.ts +75 -0
  17. package/dist/components/MediaInsertPopover.svelte +449 -0
  18. package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
  19. package/dist/components/MediaPicker.svelte +257 -0
  20. package/dist/components/MediaPicker.svelte.d.ts +41 -0
  21. package/dist/components/admin-icons.d.ts +12 -0
  22. package/dist/components/admin-icons.js +12 -0
  23. package/dist/components/cairn-admin.css +901 -9
  24. package/dist/components/client-ingest.d.ts +142 -0
  25. package/dist/components/client-ingest.js +297 -0
  26. package/dist/components/editor-media.d.ts +11 -0
  27. package/dist/components/editor-media.js +206 -0
  28. package/dist/components/editor-placeholder.d.ts +26 -0
  29. package/dist/components/editor-placeholder.js +166 -0
  30. package/dist/components/index.d.ts +1 -0
  31. package/dist/components/index.js +1 -0
  32. package/dist/components/markdown-directives.d.ts +12 -0
  33. package/dist/components/markdown-directives.js +42 -0
  34. package/dist/components/markdown-format.d.ts +89 -0
  35. package/dist/components/markdown-format.js +255 -0
  36. package/dist/components/media-upload-outcome.d.ts +52 -0
  37. package/dist/components/media-upload-outcome.js +48 -0
  38. package/dist/content/compose.js +3 -0
  39. package/dist/content/frontmatter.js +22 -0
  40. package/dist/content/manifest.d.ts +4 -0
  41. package/dist/content/manifest.js +41 -1
  42. package/dist/content/media-refs.d.ts +7 -0
  43. package/dist/content/media-refs.js +52 -0
  44. package/dist/content/schema.d.ts +5 -2
  45. package/dist/content/schema.js +17 -0
  46. package/dist/content/types.d.ts +64 -11
  47. package/dist/content/validate.js +31 -0
  48. package/dist/delivery/public-routes.d.ts +16 -0
  49. package/dist/delivery/public-routes.js +46 -3
  50. package/dist/delivery/seo-fields.js +7 -1
  51. package/dist/delivery/seo.d.ts +2 -0
  52. package/dist/delivery/seo.js +3 -0
  53. package/dist/doctor/checks-local.d.ts +1 -0
  54. package/dist/doctor/checks-local.js +21 -0
  55. package/dist/doctor/index.d.ts +3 -1
  56. package/dist/doctor/index.js +11 -2
  57. package/dist/doctor/types.d.ts +3 -0
  58. package/dist/doctor/wrangler-config.d.ts +3 -0
  59. package/dist/doctor/wrangler-config.js +20 -0
  60. package/dist/env.d.ts +19 -0
  61. package/dist/env.js +26 -0
  62. package/dist/index.d.ts +1 -1
  63. package/dist/log/events.d.ts +1 -1
  64. package/dist/media/config.d.ts +24 -0
  65. package/dist/media/config.js +69 -0
  66. package/dist/media/delivery-bucket.d.ts +34 -0
  67. package/dist/media/delivery-bucket.js +10 -0
  68. package/dist/media/index.d.ts +6 -0
  69. package/dist/media/index.js +13 -0
  70. package/dist/media/library-entry.d.ts +30 -0
  71. package/dist/media/library-entry.js +17 -0
  72. package/dist/media/manifest.d.ts +44 -0
  73. package/dist/media/manifest.js +105 -0
  74. package/dist/media/naming.d.ts +18 -0
  75. package/dist/media/naming.js +112 -0
  76. package/dist/media/reconcile.d.ts +36 -0
  77. package/dist/media/reconcile.js +45 -0
  78. package/dist/media/reference.d.ts +12 -0
  79. package/dist/media/reference.js +33 -0
  80. package/dist/media/sniff.d.ts +18 -0
  81. package/dist/media/sniff.js +106 -0
  82. package/dist/media/store.d.ts +25 -0
  83. package/dist/media/store.js +16 -0
  84. package/dist/media/transform-url.d.ts +26 -0
  85. package/dist/media/transform-url.js +38 -0
  86. package/dist/media/usage.d.ts +48 -0
  87. package/dist/media/usage.js +90 -0
  88. package/dist/render/pipeline.d.ts +2 -0
  89. package/dist/render/pipeline.js +13 -2
  90. package/dist/render/registry.js +3 -0
  91. package/dist/render/remark-figure.d.ts +4 -0
  92. package/dist/render/remark-figure.js +103 -0
  93. package/dist/render/resolve-media.d.ts +34 -0
  94. package/dist/render/resolve-media.js +78 -0
  95. package/dist/render/sanitize-schema.d.ts +4 -2
  96. package/dist/render/sanitize-schema.js +5 -3
  97. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  98. package/dist/sveltekit/admin-dispatch.js +5 -0
  99. package/dist/sveltekit/cairn-admin.d.ts +8 -1
  100. package/dist/sveltekit/cairn-admin.js +10 -2
  101. package/dist/sveltekit/content-routes.d.ts +77 -2
  102. package/dist/sveltekit/content-routes.js +470 -10
  103. package/dist/sveltekit/csrf.d.ts +16 -0
  104. package/dist/sveltekit/csrf.js +18 -0
  105. package/dist/sveltekit/guard.js +10 -3
  106. package/dist/sveltekit/index.d.ts +2 -1
  107. package/dist/sveltekit/index.js +1 -0
  108. package/dist/sveltekit/media-route.d.ts +12 -0
  109. package/dist/sveltekit/media-route.js +137 -0
  110. package/dist/vite/index.d.ts +3 -0
  111. package/dist/vite/index.js +7 -2
  112. package/package.json +7 -1
  113. package/src/lib/components/AdminLayout.svelte +3 -0
  114. package/src/lib/components/CairnAdmin.svelte +8 -1
  115. package/src/lib/components/CairnMediaLibrary.svelte +949 -0
  116. package/src/lib/components/EditPage.svelte +348 -7
  117. package/src/lib/components/MarkdownEditor.svelte +283 -1
  118. package/src/lib/components/MediaCaptureCard.svelte +135 -0
  119. package/src/lib/components/MediaFigureControl.svelte +247 -0
  120. package/src/lib/components/MediaHeroField.svelte +578 -0
  121. package/src/lib/components/MediaInsertPopover.svelte +449 -0
  122. package/src/lib/components/MediaPicker.svelte +257 -0
  123. package/src/lib/components/admin-icons.ts +12 -0
  124. package/src/lib/components/cairn-admin.css +37 -0
  125. package/src/lib/components/client-ingest.ts +380 -0
  126. package/src/lib/components/editor-media.ts +248 -0
  127. package/src/lib/components/editor-placeholder.ts +213 -0
  128. package/src/lib/components/index.ts +1 -0
  129. package/src/lib/components/markdown-directives.ts +46 -0
  130. package/src/lib/components/markdown-format.ts +307 -1
  131. package/src/lib/components/media-upload-outcome.ts +83 -0
  132. package/src/lib/content/compose.ts +3 -0
  133. package/src/lib/content/frontmatter.ts +20 -1
  134. package/src/lib/content/manifest.ts +44 -1
  135. package/src/lib/content/media-refs.ts +58 -0
  136. package/src/lib/content/schema.ts +31 -7
  137. package/src/lib/content/types.ts +80 -13
  138. package/src/lib/content/validate.ts +29 -1
  139. package/src/lib/delivery/public-routes.ts +52 -3
  140. package/src/lib/delivery/seo-fields.ts +6 -1
  141. package/src/lib/delivery/seo.ts +5 -0
  142. package/src/lib/doctor/checks-local.ts +22 -0
  143. package/src/lib/doctor/index.ts +21 -3
  144. package/src/lib/doctor/types.ts +3 -0
  145. package/src/lib/doctor/wrangler-config.ts +23 -0
  146. package/src/lib/env.ts +28 -0
  147. package/src/lib/index.ts +2 -0
  148. package/src/lib/log/events.ts +8 -1
  149. package/src/lib/media/config.ts +103 -0
  150. package/src/lib/media/delivery-bucket.ts +41 -0
  151. package/src/lib/media/index.ts +22 -0
  152. package/src/lib/media/library-entry.ts +58 -0
  153. package/src/lib/media/manifest.ts +122 -0
  154. package/src/lib/media/naming.ts +130 -0
  155. package/src/lib/media/reconcile.ts +79 -0
  156. package/src/lib/media/reference.ts +40 -0
  157. package/src/lib/media/sniff.ts +114 -0
  158. package/src/lib/media/store.ts +57 -0
  159. package/src/lib/media/transform-url.ts +58 -0
  160. package/src/lib/media/usage.ts +152 -0
  161. package/src/lib/render/pipeline.ts +17 -3
  162. package/src/lib/render/registry.ts +5 -0
  163. package/src/lib/render/remark-figure.ts +132 -0
  164. package/src/lib/render/resolve-media.ts +96 -0
  165. package/src/lib/render/sanitize-schema.ts +5 -3
  166. package/src/lib/sveltekit/admin-dispatch.ts +6 -1
  167. package/src/lib/sveltekit/cairn-admin.ts +13 -3
  168. package/src/lib/sveltekit/content-routes.ts +589 -12
  169. package/src/lib/sveltekit/csrf.ts +18 -0
  170. package/src/lib/sveltekit/guard.ts +12 -3
  171. package/src/lib/sveltekit/index.ts +6 -0
  172. package/src/lib/sveltekit/media-route.ts +158 -0
  173. 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
  }
@@ -1,4 +1,5 @@
1
- import { type FormatKind } from './markdown-format.js';
1
+ import { type FigureAtImage, type FormatKind } from './markdown-format.js';
2
+ import type { MediaLibrary } from '../media/library-entry.js';
2
3
  /** The directive container at the caret: the opener's name, the block's markdown, and the
3
4
  * document character offsets of its inclusive line range. */
4
5
  interface ComponentAtCaret {
@@ -16,6 +17,33 @@ interface Props {
16
17
  registerInsert?: (insert: (text: string) => void) => void;
17
18
  /** Receives a `(href, title) => void` that inserts an inline link; the link picker calls it. */
18
19
  registerInsertLink?: (insert: (href: string, title: string) => void) => void;
20
+ /** Receives an `(alt, ref) => void` that inserts an inline image at the caret; the media picker
21
+ * and the capture card call it with the chosen alt and the full `media:slug.hash` reference. */
22
+ registerInsertImage?: (insert: (alt: string, ref: string) => void) => void;
23
+ /** Called with the first image File of a paste or drop onto the surface; the host opens the
24
+ * capture card with the bytes. A paste or drop carrying no image falls through untouched. */
25
+ onImageIngest?: (file: File) => void;
26
+ /** The picker's human layer per stored asset, keyed by the 16-hex content hash (EditData's
27
+ * `mediaLibrary`). The source decoration reads it to render a `media:` token as a thumbnail chip;
28
+ * reactive, so a just-uploaded image decorates once it joins the library. Empty by default. */
29
+ mediaLibrary?: MediaLibrary;
30
+ /** Receives a `() => { left; right; top; bottom } | null` returning the caret's viewport
31
+ * coordinates; the insert popover anchors itself to the cursor from this. Null before mount or
32
+ * when the caret has no measurable position. */
33
+ registerCaretCoords?: (get: () => {
34
+ left: number;
35
+ right: number;
36
+ top: number;
37
+ bottom: number;
38
+ } | null) => void;
39
+ /** Receives a `() => void` that returns focus to the editor; the insert popover calls it on close
40
+ * or Escape. The selection is preserved automatically, since opening the popover only blurs the
41
+ * editor and never edits the doc. */
42
+ registerFocusEditor?: (focus: () => void) => void;
43
+ /** Receives the optimistic-placeholder api; the insert popover drives the upload loop through it
44
+ * (begin lands a placeholder at the caret, progress moves its bar, resolveTo swaps it for the
45
+ * committed image text, cancel removes it leaving the source untouched). */
46
+ registerImagePlaceholders?: (api: import('./editor-placeholder.js').ImagePlaceholderApi) => void;
19
47
  /** Receives a `() => string` returning the selected text; the web link dialog reads it. */
20
48
  registerGetSelection?: (get: () => string) => void;
21
49
  /** Receives a `(kind) => void` that transforms the current selection; the host's toolbar calls it. */
@@ -23,9 +51,17 @@ interface Props {
23
51
  /** Reports the directive container at the caret (or null when outside any container) whenever
24
52
  * the reported value changes; the host resolves it against the registry to offer Edit-block. */
25
53
  onComponentAtCaret?: (info: ComponentAtCaret | null) => void;
54
+ /** Reports the media image at the caret (or null when the caret is not on one) whenever the
55
+ * reported value changes; the host opens the figure control over it to wrap, edit, or unwrap a
56
+ * `:::figure`. The figure transforms write source through registerReplaceRange. */
57
+ onMediaImageAtCaret?: (info: FigureAtImage | null) => void;
26
58
  /** Receives a `(from, to, text) => void` that overwrites a document span; the dialog's Update
27
59
  * calls it to write an edited block back over its original range. */
28
60
  registerReplaceRange?: (replace: (from: number, to: number, text: string) => void) => void;
61
+ /** Receives a `(from, to) => void` that selects a document span, focuses the editor, and scrolls
62
+ * the range into view; the needs-alt notice's jump control calls it to land the author on an
63
+ * image that lacks alt text. */
64
+ registerSelectRange?: (select: (from: number, to: number) => void) => void;
29
65
  /** Generic CodeMirror completion sources wired into the editor; the link autocomplete is one. The
30
66
  * type is referenced inline so no static `@codemirror/*` import sits in this client-only file. */
31
67
  completionSources?: import('@codemirror/autocomplete').CompletionSource[];
@@ -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 `![](media:...)` 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>
@@ -0,0 +1,40 @@
1
+ /** The record the card emits to its host on insert. */
2
+ interface CaptureRecord {
3
+ /** The image bytes the author is placing. */
4
+ file: File;
5
+ /** The editable display name, the proposed stem or the author's edit. */
6
+ displayName: string;
7
+ /** The alt text. Empty for a decorative image or for an author who proceeded without alt; the
8
+ * host commits an empty alt as the needs-alt signal in the `![](media:...)` reference. */
9
+ alt: string;
10
+ /** True when the author marked the image decorative, distinguishing a deliberate empty alt from
11
+ * a left-blank one. The committed alt is empty either way. */
12
+ decorative: boolean;
13
+ }
14
+ interface Props {
15
+ /** The image to capture; the card previews it from a local object URL. */
16
+ file: File;
17
+ /** Emit the captured record to the host on insert. */
18
+ oncapture: (record: CaptureRecord) => void;
19
+ }
20
+ /**
21
+ * The one-step capture card for a media insert. Shown when an author already has bytes in hand (a
22
+ * paste, a drop, or a chosen file), it captures three things and emits them to its host: the file, an
23
+ * editable display name, and the alt text. It is a presentational form card, not a dialog of its own;
24
+ * Task 6's insert popover hosts it.
25
+ *
26
+ * The display name pre-fills from proposedNameFor(file.name): a real specific stem (blue-shoes.png)
27
+ * arrives as a Suggested value, while a generic camera stem (IMG_4821.jpg) leaves the field empty and
28
+ * required with no tag, so the author never accepts machine noise.
29
+ *
30
+ * Alt is a real role="radiogroup" of two radios: write a description, or mark decorative. The
31
+ * requirement is surfaced through aria-describedby, never by disabling the submit. Insert is never
32
+ * disabled (the locked no-skipped-disabled-reason rule); an author may proceed with alt unset and the
33
+ * emitted record carries an empty alt, which the host treats as needs-alt debt. A decorative choice
34
+ * also resolves alt to the empty string. The committed reference keys off an empty alt as the
35
+ * needs-alt signal, so the emitted record uses an empty alt string for both the decorative and the
36
+ * left-blank cases, and a separate decorative flag distinguishes them for the host.
37
+ */
38
+ declare const MediaCaptureCard: import("svelte").Component<Props, {}, "">;
39
+ type MediaCaptureCard = ReturnType<typeof MediaCaptureCard>;
40
+ export default MediaCaptureCard;