@happyvertical/smrt-content 0.51.39 → 0.52.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.
Files changed (72) hide show
  1. package/AGENTS.md +6 -0
  2. package/README.md +76 -0
  3. package/dist/body-format.d.ts +104 -0
  4. package/dist/body-format.d.ts.map +1 -1
  5. package/dist/body-format.js +439 -48
  6. package/dist/body-format.js.map +1 -1
  7. package/dist/chunks/content-query-BqLHbZYv.js +4131 -0
  8. package/dist/chunks/content-query-BqLHbZYv.js.map +1 -0
  9. package/dist/chunks/{content-versions-CvOoDLSK.js → content-versions-DatN0G28.js} +2 -2
  10. package/dist/chunks/{content-versions-CvOoDLSK.js.map → content-versions-DatN0G28.js.map} +1 -1
  11. package/dist/chunks/{contents-BB7RIBx2.js → contents-xas83N8M.js} +4 -6
  12. package/dist/chunks/contents-xas83N8M.js.map +1 -0
  13. package/dist/content-query.d.ts.map +1 -1
  14. package/dist/content.d.ts +13 -1
  15. package/dist/content.d.ts.map +1 -1
  16. package/dist/contents.d.ts.map +1 -1
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +6 -6
  20. package/dist/manifest.json +113 -23
  21. package/dist/server/content-list-data-surface.d.ts +1 -1
  22. package/dist/server/content-list-data-surface.d.ts.map +1 -1
  23. package/dist/server.js +3 -3
  24. package/dist/server.js.map +1 -1
  25. package/dist/smrt-knowledge.json +92 -10
  26. package/dist/svelte/components/ContentBodyEditor.svelte +898 -38
  27. package/dist/svelte/components/ContentBodyEditor.svelte.d.ts +67 -0
  28. package/dist/svelte/components/ContentBodyEditor.svelte.d.ts.map +1 -1
  29. package/dist/svelte/components/ContentBodyRenderer.svelte +36 -11
  30. package/dist/svelte/components/ContentBodyRenderer.svelte.d.ts +2 -0
  31. package/dist/svelte/components/ContentBodyRenderer.svelte.d.ts.map +1 -1
  32. package/dist/svelte/components/ContentClaimAuditTool.svelte +45 -2
  33. package/dist/svelte/components/ContentCorrectionsTool.svelte +24 -2
  34. package/dist/svelte/components/ContentEditor.svelte +67 -17
  35. package/dist/svelte/components/ContentEditor.svelte.d.ts +12 -0
  36. package/dist/svelte/components/ContentEditor.svelte.d.ts.map +1 -1
  37. package/dist/svelte/components/ContentGovernancePanel.svelte +4 -2
  38. package/dist/svelte/components/ContentMetadataFields.svelte +141 -51
  39. package/dist/svelte/components/ContentMetadataFields.svelte.d.ts +20 -0
  40. package/dist/svelte/components/ContentMetadataFields.svelte.d.ts.map +1 -1
  41. package/dist/svelte/components/ContentPictureDrawer.svelte +611 -0
  42. package/dist/svelte/components/ContentPictureDrawer.svelte.d.ts +68 -0
  43. package/dist/svelte/components/ContentPictureDrawer.svelte.d.ts.map +1 -0
  44. package/dist/svelte/components/ContentReviewStatusTray.svelte +2 -2
  45. package/dist/svelte/components/ContentStatusFields.svelte +85 -38
  46. package/dist/svelte/components/ContentStatusFields.svelte.d.ts +10 -0
  47. package/dist/svelte/components/ContentStatusFields.svelte.d.ts.map +1 -1
  48. package/dist/svelte/components/ContentTitleField.svelte +109 -12
  49. package/dist/svelte/components/ContentTitleField.svelte.d.ts +12 -0
  50. package/dist/svelte/components/ContentTitleField.svelte.d.ts.map +1 -1
  51. package/dist/svelte/components/ContentTransparencyReport.svelte +3 -1
  52. package/dist/svelte/components/ContentTransparencyTool.svelte +18 -3
  53. package/dist/svelte/components/ContentVersionsTool.svelte +26 -2
  54. package/dist/svelte/components/content-field-mode.d.ts +20 -0
  55. package/dist/svelte/components/content-field-mode.d.ts.map +1 -0
  56. package/dist/svelte/components/content-field-mode.js +26 -0
  57. package/dist/svelte/content-editor-media.d.ts +14 -0
  58. package/dist/svelte/content-editor-media.d.ts.map +1 -1
  59. package/dist/svelte/content-editor-media.js +41 -0
  60. package/dist/svelte/content-editor-state.svelte.d.ts +17 -0
  61. package/dist/svelte/content-editor-state.svelte.d.ts.map +1 -1
  62. package/dist/svelte/content-editor-state.svelte.js +46 -0
  63. package/dist/svelte/i18n.editor.d.ts +49 -0
  64. package/dist/svelte/i18n.editor.d.ts.map +1 -1
  65. package/dist/svelte/i18n.editor.js +53 -1
  66. package/dist/svelte/index.d.ts +10 -5
  67. package/dist/svelte/index.d.ts.map +1 -1
  68. package/dist/svelte/index.js +5 -3
  69. package/package.json +27 -25
  70. package/dist/chunks/content-query-DPJCcD1V.js +0 -4092
  71. package/dist/chunks/content-query-DPJCcD1V.js.map +0 -1
  72. package/dist/chunks/contents-BB7RIBx2.js.map +0 -1
@@ -1,8 +1,18 @@
1
1
  <script lang="ts">
2
2
  import type { ImageLike } from '@happyvertical/smrt-images/svelte';
3
- import { Select } from '@happyvertical/smrt-ui/forms';
3
+ import {
4
+ createLongPress,
5
+ highlightControl,
6
+ primeReadyBeep,
7
+ revealControl,
8
+ Select,
9
+ tryGetControlInteractionContext,
10
+ useControlRegistration,
11
+ } from '@happyvertical/smrt-ui/forms';
4
12
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
5
13
  import { Button } from '@happyvertical/smrt-ui/ui';
14
+ import { onDestroy, type Snippet } from 'svelte';
15
+ import { slide } from 'svelte/transition';
6
16
  import {
7
17
  bodyToEditorHtml,
8
18
  type ContentBodyFormat,
@@ -25,13 +35,44 @@ export interface ContentBodyEditorChange {
25
35
  images: ContentBodyImage[];
26
36
  }
27
37
 
38
+ /** The person asked to change a picture in the story (see `onRequestImageChange`). */
39
+ export interface ContentBodyImageChangeRequest {
40
+ /** The picture's index among the body's images. */
41
+ index: number;
42
+ /** Its stored asset id, when it has one. */
43
+ assetId: string | null;
44
+ /** Its alt text. */
45
+ alt: string;
46
+ /** Its source URL as shown in the editor. */
47
+ src: string;
48
+ /**
49
+ * True when it came from pressing and holding the picture: the host opens
50
+ * its request box already listening for speech.
51
+ */
52
+ listen: boolean;
53
+ /** Where the picture is on screen (viewport pixels), to anchor a popover. */
54
+ rect: { top: number; left: number; width: number; height: number };
55
+ }
56
+
28
57
  export interface Props {
29
58
  /** The body content string being edited, in HTML or the configured format. */
30
59
  value: string;
31
60
  /** Format of the body content: HTML, Markdown, or auto-detected. */
32
61
  format?: ContentBodyFormat | null;
62
+ /**
63
+ * Show the "Save as" HTML/Markdown picker in the toolbar. Off by default:
64
+ * the storage format is a technical choice most editors should not face, so
65
+ * hosts opt in for technical users. The body keeps its `format` either way.
66
+ */
67
+ showFormatPicker?: boolean;
33
68
  /** Placeholder text shown when the editor is empty. */
34
69
  placeholder?: string;
70
+ /** DOM id of the editable surface (default `content-body-input`). */
71
+ id?: string;
72
+ /** Field name; also the control id agents address (default `body`). */
73
+ name?: string;
74
+ /** Accessible and agent-facing label (default "Story"). */
75
+ label?: string;
35
76
  /** Index of the currently selected image in the body, or -1 for none. */
36
77
  selectedImageIndex?: number;
37
78
  /** Fired when the body content or embedded images change. */
@@ -42,24 +83,111 @@ export interface Props {
42
83
  onSelectImage?: (index: number) => void;
43
84
  /** Fired when the user sets an image as the content thumbnail. */
44
85
  onUseImageAsThumbnail?: (assetId: string) => void;
86
+ /**
87
+ * Asset id of the current main picture (thumbnail). The selected image's
88
+ * "Use as main picture" button shows as pressed when it is this one.
89
+ */
90
+ mainImageAssetId?: string | null;
45
91
  /** Resolves an image file, asset, or ID to a persisted image URL or object. */
46
92
  onResolveImage?: (
47
93
  selected: ImageLike | File | string,
48
94
  ) => Promise<unknown> | unknown;
95
+ /**
96
+ * The image panel (chooser/editor) the toolbar's image button opens. When
97
+ * provided it renders between the toolbar and the writing surface, sliding
98
+ * open above the text, and the image button reports `aria-expanded` /
99
+ * `aria-controls`. Without it the button only fires `onOpenImageChooser`.
100
+ */
101
+ imagePanel?: Snippet;
102
+ /** Whether the image panel is open (controlled by the parent). */
103
+ imagePanelOpen?: boolean;
104
+ /** Accessible name of the image panel region (default "Pictures"). */
105
+ imagePanelLabel?: string;
106
+ /** Fired when the panel asks to close (Escape inside it). */
107
+ onCloseImagePanel?: () => void;
108
+ /**
109
+ * The person wants a picture changed. With it, a selected or hovered
110
+ * picture shows a "Change this picture" button, the picture toolbar gets
111
+ * one too, and pressing and holding a picture (about half a second, without
112
+ * moving; moving is still a drag) asks with `listen: true`. Without it
113
+ * none of these show.
114
+ */
115
+ onRequestImageChange?: (request: ContentBodyImageChangeRequest) => void;
49
116
  }
50
117
 
51
118
  let {
52
119
  value,
53
120
  format = null,
121
+ showFormatPicker = false,
54
122
  placeholder = 'Start writing...',
123
+ id = 'content-body-input',
124
+ name = 'body',
125
+ label = undefined,
55
126
  selectedImageIndex = -1,
56
127
  onChange = undefined,
57
128
  onOpenImageChooser = undefined,
58
129
  onSelectImage = undefined,
59
130
  onUseImageAsThumbnail = undefined,
131
+ mainImageAssetId = null,
60
132
  onResolveImage = undefined,
133
+ imagePanel = undefined,
134
+ imagePanelOpen = false,
135
+ imagePanelLabel = undefined,
136
+ onCloseImagePanel = undefined,
137
+ onRequestImageChange = undefined,
61
138
  }: Props = $props();
62
139
 
140
+ const imagePanelId = $derived(`${id}-image-panel`);
141
+ const resolvedImagePanelLabel = $derived(
142
+ imagePanelLabel ?? t(M['content.content_body_editor.image_panel']),
143
+ );
144
+ let imageButtonElement = $state<HTMLElement | null>(null);
145
+ let imagePanelElement = $state<HTMLElement | null>(null);
146
+ let imagePanelWasOpen = false;
147
+
148
+ function prefersReducedMotion(): boolean {
149
+ return (
150
+ typeof window !== 'undefined' &&
151
+ typeof window.matchMedia === 'function' &&
152
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
153
+ );
154
+ }
155
+
156
+ // Opening moves focus into the panel (its region is focusable) so keyboard and
157
+ // screen-reader users land on what they opened; closing returns focus to the
158
+ // toolbar button that opened it.
159
+ $effect(() => {
160
+ const open = Boolean(imagePanel && imagePanelOpen);
161
+ if (open === imagePanelWasOpen) {
162
+ return;
163
+ }
164
+ imagePanelWasOpen = open;
165
+ if (open) {
166
+ queueMicrotask(() => imagePanelElement?.focus({ preventScroll: false }));
167
+ } else if (typeof document !== 'undefined') {
168
+ const active = document.activeElement;
169
+ if (
170
+ !active ||
171
+ active === document.body ||
172
+ (active !== editorElement && rootElement?.contains(active))
173
+ ) {
174
+ imageButtonElement?.querySelector('button')?.focus();
175
+ }
176
+ }
177
+ });
178
+
179
+ function handleImagePanelKeydown(event: KeyboardEvent) {
180
+ if (event.key === 'Escape' && onCloseImagePanel) {
181
+ event.stopPropagation();
182
+ onCloseImagePanel();
183
+ }
184
+ }
185
+
186
+ const resolvedLabel = $derived(label ?? t(M['content.content_fields.body']));
187
+ const interactionContext = tryGetControlInteractionContext();
188
+ /** The body as last received from the parent or emitted by this editor. */
189
+ let currentBody = '';
190
+
63
191
  const MIN_IMAGE_WIDTH = 120;
64
192
  const IMAGE_WIDTH_STEP = 80;
65
193
  const INPUT_CHANGE_DEBOUNCE_MS = 120;
@@ -93,6 +221,8 @@ let editorHtml = $state('');
93
221
  let lastExternalKey = $state('');
94
222
  let isFocused = $state(false);
95
223
  let isDragging = $state(false);
224
+ /** Where a moved picture would land (px from the editor's top), while dragging. */
225
+ let dropIndicatorTop = $state<number | null>(null);
96
226
  let selectedImageIndexState = $state(-1);
97
227
  let selectedImageBox = $state<ImageBox | null>(null);
98
228
  let selectedImagePlacement = $state<ContentBodyImagePlacement>('block');
@@ -125,8 +255,9 @@ $effect(() => {
125
255
  currentFormat = resolvedFormat;
126
256
  editorHtml = bodyToEditorHtml(value || '', resolvedFormat);
127
257
  lastExternalKey = externalKey;
258
+ currentBody = value || '';
128
259
 
129
- if (editorElement && !isFocused && editorElement.innerHTML !== editorHtml) {
260
+ if (editorElement && !isFocused && editorInnerHtml() !== editorHtml) {
130
261
  editorElement.innerHTML = editorHtml;
131
262
  }
132
263
  });
@@ -337,6 +468,66 @@ function setCaretAfterNode(node: Node) {
337
468
  savedRange = range.cloneRange();
338
469
  }
339
470
 
471
+ // Pictures shown only as a preview (see `previewImage`): picture -> the `src`
472
+ // and `srcset` it really has. A preview is never part of the body:
473
+ // `editorInnerHtml` reads the surface with every picture back at its real
474
+ // `src`/`srcset`, so no change event can carry a previewed picture out of the
475
+ // editor, and a copy or cut puts the real pictures on the clipboard.
476
+ interface ImageOriginal {
477
+ src: string | null;
478
+ srcset: string | null;
479
+ }
480
+ const imagePreviews = new Map<HTMLImageElement, ImageOriginal>();
481
+
482
+ function setOrRemove(image: Element, name: string, value: string | null) {
483
+ if (value === null) image.removeAttribute(name);
484
+ else image.setAttribute(name, value);
485
+ }
486
+
487
+ function restoreImage(image: Element, original: ImageOriginal) {
488
+ setOrRemove(image, 'src', original.src);
489
+ setOrRemove(image, 'srcset', original.srcset);
490
+ }
491
+
492
+ // Copy and cut read the live surface: put the real pictures back for the
493
+ // clipboard, then show the previews again.
494
+ function handleClipboardWithPreviews() {
495
+ if (imagePreviews.size === 0) return;
496
+ const shown = [...imagePreviews].map(([image]) => ({
497
+ image,
498
+ src: image.getAttribute('src'),
499
+ }));
500
+ for (const [image, original] of imagePreviews) restoreImage(image, original);
501
+ setTimeout(() => {
502
+ for (const { image, src } of shown) {
503
+ if (!image.isConnected || !imagePreviews.has(image)) continue;
504
+ setOrRemove(image, 'src', src);
505
+ image.removeAttribute('srcset');
506
+ }
507
+ }, 0);
508
+ }
509
+
510
+ /** The surface's HTML as the body has it: previews put back. */
511
+ function editorInnerHtml(): string {
512
+ if (!editorElement) return '';
513
+ for (const image of [...imagePreviews.keys()]) {
514
+ if (!image.isConnected) imagePreviews.delete(image);
515
+ }
516
+ if (imagePreviews.size === 0) return editorElement.innerHTML;
517
+ const originals = editorElement.querySelectorAll('img');
518
+ const inert = editorElement.ownerDocument.implementation
519
+ .createHTMLDocument('')
520
+ .importNode(editorElement, true) as HTMLElement;
521
+ const copies = inert.querySelectorAll('img');
522
+ originals.forEach((image, index) => {
523
+ const original = imagePreviews.get(image);
524
+ const copy = copies[index];
525
+ if (!original || !copy) return;
526
+ restoreImage(copy, original);
527
+ });
528
+ return inert.innerHTML;
529
+ }
530
+
340
531
  function getEditorImages(): HTMLImageElement[] {
341
532
  if (!editorElement) {
342
533
  return [];
@@ -496,6 +687,18 @@ function refreshSelectedImageChrome() {
496
687
  };
497
688
  selectedImagePlacement = getImagePlacement(frame);
498
689
  selectedImageAssetId = image.getAttribute('data-smrt-asset-id');
690
+ refreshHoveredImageBox();
691
+ }
692
+
693
+ /** Keep the change badge on the hovered picture after it moved or resized. */
694
+ function refreshHoveredImageBox() {
695
+ if (!hoveredImage) return;
696
+ if (!editorElement?.contains(hoveredImage)) {
697
+ hoveredImage = null;
698
+ hoveredImageBox = null;
699
+ return;
700
+ }
701
+ hoveredImageBox = imageBoxFor(hoveredImage);
499
702
  }
500
703
 
501
704
  function placeCaretAfterImage(image: HTMLImageElement) {
@@ -590,15 +793,16 @@ function emitChange(options: { syncDom?: boolean } = {}) {
590
793
  }
591
794
 
592
795
  clearPendingInputChange();
593
- const rawHtml = editorElement.innerHTML;
796
+ const rawHtml = editorInnerHtml();
594
797
  const normalizedHtml = normalizeEditorHtml(rawHtml);
595
- if (options.syncDom && editorElement.innerHTML !== normalizedHtml) {
798
+ if (options.syncDom && editorInnerHtml() !== normalizedHtml) {
596
799
  editorElement.innerHTML = normalizedHtml;
597
800
  editorHtml = normalizedHtml;
598
801
  }
599
802
 
600
803
  const body = editorHtmlToBody(normalizedHtml, currentFormat);
601
804
  lastExternalKey = makeExternalKey(body, currentFormat);
805
+ currentBody = body;
602
806
  onChange?.({
603
807
  body,
604
808
  bodyFormat: currentFormat,
@@ -607,6 +811,48 @@ function emitChange(options: { syncDom?: boolean } = {}) {
607
811
  refreshSelectedImageChrome();
608
812
  }
609
813
 
814
+ /** Replace the whole body (an applied agent proposal) in the current format. */
815
+ function replaceBody(next: unknown) {
816
+ const body = typeof next === 'string' ? next : String(next ?? '');
817
+ clearPendingInputChange();
818
+ editorHtml = bodyToEditorHtml(body, currentFormat);
819
+ if (editorElement) editorElement.innerHTML = editorHtml;
820
+ lastExternalKey = makeExternalKey(body, currentFormat);
821
+ currentBody = body;
822
+ onChange?.({
823
+ body,
824
+ bodyFormat: currentFormat,
825
+ images: extractBodyImages(body, currentFormat),
826
+ });
827
+ }
828
+
829
+ // One agent-addressable control for the whole story, in the body's own
830
+ // format (HTML or Markdown). Proposals are staged; a person applies them.
831
+ useControlRegistration(() => {
832
+ const surface = editorElement;
833
+ if (!surface || !name) return false;
834
+ return {
835
+ controlId: name,
836
+ metadata: {
837
+ kind: 'textarea',
838
+ label: resolvedLabel,
839
+ description:
840
+ currentFormat === 'markdown'
841
+ ? 'The story, in Markdown.'
842
+ : 'The story, in HTML.',
843
+ },
844
+ getValue: () => currentBody,
845
+ setValue: replaceBody,
846
+ clear: () => {
847
+ replaceBody('');
848
+ return true;
849
+ },
850
+ focus: () => surface.focus(),
851
+ reveal: () => revealControl(surface),
852
+ highlight: (durationMs) => highlightControl(surface, durationMs),
853
+ };
854
+ });
855
+
610
856
  function runCommand(command: string, value?: string) {
611
857
  restoreSelection();
612
858
  document.execCommand(command, false, value);
@@ -681,6 +927,77 @@ export function insertImageAsset(asset: ImageAssetLike | null | undefined) {
681
927
  insertImageHtml(imageAssetToHtml(asset));
682
928
  }
683
929
 
930
+ /**
931
+ * Replace the picture at `index` (in story order) with `asset`, keeping its
932
+ * place, size, and layout: for example a cropped or edited version of the
933
+ * same picture. Returns false when there is no picture at `index` or the
934
+ * asset has no usable source.
935
+ */
936
+ export function replaceImage(
937
+ index: number,
938
+ asset: (ImageAssetLike & { height?: unknown }) | null | undefined,
939
+ ): boolean {
940
+ const image = getEditorImages()[index];
941
+ const template = document.createElement('template');
942
+ template.innerHTML = imageAssetToHtml(asset).trim();
943
+ const next = template.content.querySelector('img');
944
+ const src = next?.getAttribute('src');
945
+ if (!image || !src) {
946
+ return false;
947
+ }
948
+ // Accepting a picture replaces any preview of it, and the old picture's
949
+ // responsive sources go with it.
950
+ imagePreviews.delete(image);
951
+ image.setAttribute('src', src);
952
+ setOrRemove(image, 'srcset', next?.getAttribute('srcset') ?? null);
953
+ setOrRemove(image, 'sizes', next?.getAttribute('sizes') ?? null);
954
+ const assetId = next?.getAttribute('data-smrt-asset-id');
955
+ if (assetId) {
956
+ image.setAttribute('data-smrt-asset-id', assetId);
957
+ }
958
+ refreshSelectedImageChrome();
959
+ emitChange();
960
+ return true;
961
+ }
962
+
963
+ /**
964
+ * Show `src` on the picture at `index` WITHOUT changing the story: a preview
965
+ * the person can accept (`replaceImage`) or put back (`previewImage(index,
966
+ * null)`). It is never written to the body, never reported by `onChange`, and
967
+ * goes away with the editor. Only same-origin paths (`/…`) are shown. Returns
968
+ * false when there is no picture at `index` or `src` is not allowed.
969
+ */
970
+ export function previewImage(index: number, src: string | null): boolean {
971
+ const image = getEditorImages()[index];
972
+ if (!image) return false;
973
+ if (src === null) {
974
+ const original = imagePreviews.get(image);
975
+ if (original) {
976
+ restoreImage(image, original);
977
+ imagePreviews.delete(image);
978
+ refreshSelectedImageChrome();
979
+ }
980
+ return true;
981
+ }
982
+ if (!/^\/(?![/\\])/.test(src) || /[\s\\]/.test(src)) return false;
983
+ if (!imagePreviews.has(image))
984
+ imagePreviews.set(image, {
985
+ src: image.getAttribute('src'),
986
+ srcset: image.getAttribute('srcset'),
987
+ });
988
+ // A srcset would keep showing the real picture: the preview is `src` only.
989
+ image.removeAttribute('srcset');
990
+ image.setAttribute('src', src);
991
+ refreshSelectedImageChrome();
992
+ return true;
993
+ }
994
+
995
+ /** Put every previewed picture back (see `previewImage`). */
996
+ export function clearImagePreviews() {
997
+ for (const [image, original] of imagePreviews) restoreImage(image, original);
998
+ imagePreviews.clear();
999
+ }
1000
+
684
1001
  export function focusImage(index: number) {
685
1002
  if (!editorElement || index < 0) {
686
1003
  return;
@@ -801,6 +1118,149 @@ function startImageResize(event: PointerEvent) {
801
1118
  window.addEventListener('pointerup', handleResizePointerUp);
802
1119
  }
803
1120
 
1121
+ /** A top-level node that shows something (text or a picture). */
1122
+ function isSignificantBlock(node: Node): boolean {
1123
+ if (node.nodeType === TEXT_NODE) {
1124
+ return Boolean(node.textContent?.trim());
1125
+ }
1126
+ if (node.nodeType !== ELEMENT_NODE) {
1127
+ return false;
1128
+ }
1129
+ const element = node as HTMLElement;
1130
+ return (
1131
+ Boolean(element.textContent?.trim()) ||
1132
+ Boolean(element.tagName === 'IMG' || element.querySelector('img'))
1133
+ );
1134
+ }
1135
+
1136
+ function significantBlocks(exclude?: Node | null): Node[] {
1137
+ if (!editorElement) {
1138
+ return [];
1139
+ }
1140
+ return Array.from(editorElement.childNodes).filter(
1141
+ (node) => node !== exclude && isSignificantBlock(node),
1142
+ );
1143
+ }
1144
+
1145
+ function nodeRect(node: Node): DOMRect | null {
1146
+ if (node.nodeType === ELEMENT_NODE) {
1147
+ return (node as Element).getBoundingClientRect();
1148
+ }
1149
+ const range = document.createRange();
1150
+ range.selectNodeContents(node);
1151
+ return range.getBoundingClientRect();
1152
+ }
1153
+
1154
+ /** The picture's movable unit: its top-level block, or the picture itself when it sits inside text. */
1155
+ function imageMoveUnit(image: HTMLImageElement): HTMLElement {
1156
+ const root = getImageLayoutRoot(image);
1157
+ return root.parentElement === editorElement ? root : getImageFrame(image);
1158
+ }
1159
+
1160
+ /** The top-level block holding `node`. */
1161
+ function topLevelBlockOf(node: Node): Node | null {
1162
+ let current: Node | null = node;
1163
+ while (current && current.parentNode !== editorElement) {
1164
+ current = current.parentNode;
1165
+ }
1166
+ return current;
1167
+ }
1168
+
1169
+ /**
1170
+ * The gap between top-level blocks nearest to `clientY`: the block to go in
1171
+ * front of (null: the end), and where to draw the drop line.
1172
+ */
1173
+ function dropSlotFromPoint(
1174
+ clientY: number,
1175
+ exclude: Node | null,
1176
+ ): { before: Node | null; top: number } | null {
1177
+ if (!editorElement || !rootElement) {
1178
+ return null;
1179
+ }
1180
+ const rootTop = rootElement.getBoundingClientRect().top;
1181
+ const blocks = significantBlocks(exclude);
1182
+ for (const block of blocks) {
1183
+ const rect = nodeRect(block);
1184
+ if (rect && clientY < rect.top + rect.height / 2) {
1185
+ return { before: block, top: rect.top - rootTop - 2 };
1186
+ }
1187
+ }
1188
+ const last = blocks.at(-1);
1189
+ const lastRect = last ? nodeRect(last) : null;
1190
+ const editorRect = editorElement.getBoundingClientRect();
1191
+ return {
1192
+ before: null,
1193
+ top: (lastRect ? lastRect.bottom : editorRect.top + 8) - rootTop + 2,
1194
+ };
1195
+ }
1196
+
1197
+ /** Put the picture in front of `before` (null: at the end of the story). */
1198
+ function moveImageBefore(image: HTMLImageElement, before: Node | null) {
1199
+ if (!editorElement) {
1200
+ return;
1201
+ }
1202
+ const unit = imageMoveUnit(image);
1203
+ if (before === unit) {
1204
+ return;
1205
+ }
1206
+ editorElement.insertBefore(unit, before);
1207
+ selectImageElement(image);
1208
+ emitChange();
1209
+ }
1210
+
1211
+ function imageMoveTargets(image: HTMLImageElement): {
1212
+ up: Node | null | undefined;
1213
+ down: Node | null | undefined;
1214
+ } {
1215
+ const unit = imageMoveUnit(image);
1216
+ if (unit.parentElement !== editorElement) {
1217
+ // Inside a paragraph: up puts it just above that paragraph, down just below.
1218
+ const block = topLevelBlockOf(unit);
1219
+ return {
1220
+ up: block,
1221
+ down: block ? (block.nextSibling ?? null) : undefined,
1222
+ };
1223
+ }
1224
+ const blocks = significantBlocks();
1225
+ const index = blocks.indexOf(unit);
1226
+ const previous = index > 0 ? blocks[index - 1] : undefined;
1227
+ const next = index >= 0 ? blocks[index + 1] : undefined;
1228
+ return {
1229
+ up: previous,
1230
+ down: next ? (next.nextSibling ?? null) : undefined,
1231
+ };
1232
+ }
1233
+
1234
+ /** Move the selected picture one block up or down (the keyboard and touch path). */
1235
+ function moveSelectedImage(direction: 'up' | 'down') {
1236
+ const image = getSelectedImage();
1237
+ if (!image) {
1238
+ return;
1239
+ }
1240
+ const target = imageMoveTargets(image)[direction];
1241
+ if (target === undefined) {
1242
+ return;
1243
+ }
1244
+ moveImageBefore(image, target);
1245
+ image.scrollIntoView?.({ block: 'nearest' });
1246
+ }
1247
+
1248
+ const selectedImageMoves = $derived.by(() => {
1249
+ // Recomputed whenever the selection chrome refreshes.
1250
+ void selectedImageBox;
1251
+ const image = selectedImageIndexState >= 0 ? getSelectedImage() : null;
1252
+ if (!image) {
1253
+ return { up: false, down: false };
1254
+ }
1255
+ const targets = imageMoveTargets(image);
1256
+ return { up: targets.up !== undefined, down: targets.down !== undefined };
1257
+ });
1258
+
1259
+ function showDropIndicator(clientY: number, exclude: Node | null) {
1260
+ const slot = dropSlotFromPoint(clientY, exclude);
1261
+ dropIndicatorTop = slot ? slot.top : null;
1262
+ }
1263
+
804
1264
  function moveImageToRange(
805
1265
  imageIndex: number,
806
1266
  range: Range | null,
@@ -847,11 +1307,24 @@ function handleMovePointerUp(event: PointerEvent) {
847
1307
  const imageIndex = moveState.imageIndex;
848
1308
  moveState = null;
849
1309
  window.removeEventListener('pointerup', handleMovePointerUp);
850
- moveImageToRange(
851
- imageIndex,
852
- getRangeFromPoint(event.clientX, event.clientY),
853
- event.clientX,
854
- );
1310
+ window.removeEventListener('pointermove', handleMovePointerMove);
1311
+ dropIndicatorTop = null;
1312
+ const image = getEditorImages()[imageIndex];
1313
+ if (!image) {
1314
+ return;
1315
+ }
1316
+ const slot = dropSlotFromPoint(event.clientY, imageMoveUnit(image));
1317
+ if (slot) {
1318
+ moveImageBefore(image, slot.before);
1319
+ }
1320
+ }
1321
+
1322
+ function handleMovePointerMove(event: PointerEvent) {
1323
+ if (!moveState) {
1324
+ return;
1325
+ }
1326
+ const image = getEditorImages()[moveState.imageIndex];
1327
+ showDropIndicator(event.clientY, image ? imageMoveUnit(image) : null);
855
1328
  }
856
1329
 
857
1330
  function startImageMove(event: PointerEvent) {
@@ -870,6 +1343,7 @@ function startImageMove(event: PointerEvent) {
870
1343
  frame,
871
1344
  };
872
1345
  window.addEventListener('pointerup', handleMovePointerUp);
1346
+ window.addEventListener('pointermove', handleMovePointerMove);
873
1347
  }
874
1348
 
875
1349
  async function resolveAndInsertImage(selected: ImageLike | File | string) {
@@ -883,22 +1357,34 @@ async function resolveAndInsertImage(selected: ImageLike | File | string) {
883
1357
  }
884
1358
  }
885
1359
 
886
- function parseDraggedImage(dataTransfer: DataTransfer): ImageLike | null {
1360
+ /**
1361
+ * Images dragged in from a picker: `application/x-smrt-image` carries one
1362
+ * image object, or an array of them when several are dragged together.
1363
+ */
1364
+ function parseDraggedImages(dataTransfer: DataTransfer): ImageLike[] {
887
1365
  const payload = dataTransfer.getData('application/x-smrt-image');
888
1366
  if (!payload) {
889
- return null;
1367
+ return [];
890
1368
  }
891
1369
 
892
1370
  try {
893
- return JSON.parse(payload) as ImageLike;
1371
+ const parsed = JSON.parse(payload) as unknown;
1372
+ const list = Array.isArray(parsed) ? parsed : [parsed];
1373
+ return list.filter(
1374
+ (item): item is ImageLike => Boolean(item) && typeof item === 'object',
1375
+ );
894
1376
  } catch {
895
- return null;
1377
+ return [];
896
1378
  }
897
1379
  }
898
1380
 
899
1381
  function handleDragOver(event: DragEvent) {
900
1382
  event.preventDefault();
901
1383
  isDragging = true;
1384
+ if (movingImageIndex !== null) {
1385
+ const image = getEditorImages()[movingImageIndex];
1386
+ showDropIndicator(event.clientY, image ? imageMoveUnit(image) : null);
1387
+ }
902
1388
  if (event.dataTransfer) {
903
1389
  event.dataTransfer.dropEffect = movingImageIndex === null ? 'copy' : 'move';
904
1390
  }
@@ -912,11 +1398,13 @@ function handleDragLeave(event: DragEvent) {
912
1398
  }
913
1399
 
914
1400
  isDragging = false;
1401
+ dropIndicatorTop = null;
915
1402
  }
916
1403
 
917
1404
  async function handleDrop(event: DragEvent) {
918
1405
  event.preventDefault();
919
1406
  isDragging = false;
1407
+ dropIndicatorTop = null;
920
1408
  if (!event.dataTransfer) {
921
1409
  return;
922
1410
  }
@@ -932,17 +1420,30 @@ async function handleDrop(event: DragEvent) {
932
1420
  Number.isInteger(parsedBodyImageIndex) &&
933
1421
  parsedBodyImageIndex >= 0
934
1422
  ) {
935
- moveImageToRange(parsedBodyImageIndex, dropRange, event.clientX);
1423
+ // A picture already in the story goes into the gap between blocks
1424
+ // nearest the pointer: above the first paragraph, between any two, or
1425
+ // at the end.
1426
+ const image = getEditorImages()[parsedBodyImageIndex];
1427
+ const slot = image
1428
+ ? dropSlotFromPoint(event.clientY, imageMoveUnit(image))
1429
+ : null;
1430
+ if (image && slot) {
1431
+ moveImageBefore(image, slot.before);
1432
+ } else {
1433
+ moveImageToRange(parsedBodyImageIndex, dropRange, event.clientX);
1434
+ }
936
1435
  movingImageIndex = null;
937
1436
  return;
938
1437
  }
939
1438
 
940
- const draggedImage = parseDraggedImage(event.dataTransfer);
941
- if (draggedImage) {
1439
+ const draggedImages = parseDraggedImages(event.dataTransfer);
1440
+ if (draggedImages.length > 0) {
942
1441
  if (dropRange) {
943
1442
  savedRange = dropRange.cloneRange();
944
1443
  }
945
- await resolveAndInsertImage(draggedImage);
1444
+ for (const draggedImage of draggedImages) {
1445
+ await resolveAndInsertImage(draggedImage);
1446
+ }
946
1447
  return;
947
1448
  }
948
1449
 
@@ -970,6 +1471,124 @@ async function handleDrop(event: DragEvent) {
970
1471
  }
971
1472
  }
972
1473
 
1474
+ // ---- Change this picture ---------------------------------------------
1475
+
1476
+ let hoveredImageBox = $state<ImageBox | null>(null);
1477
+ let hoveredImage: HTMLImageElement | null = null;
1478
+
1479
+ function imageBoxFor(image: HTMLImageElement): ImageBox | null {
1480
+ if (!rootElement) return null;
1481
+ const frameRect = getImageFrame(image).getBoundingClientRect();
1482
+ const rootRect = rootElement.getBoundingClientRect();
1483
+ return {
1484
+ top: frameRect.top - rootRect.top,
1485
+ left: frameRect.left - rootRect.left,
1486
+ width: frameRect.width,
1487
+ height: frameRect.height,
1488
+ };
1489
+ }
1490
+
1491
+ function requestImageChange(image: HTMLImageElement | null, listen: boolean) {
1492
+ if (!onRequestImageChange || !image) return;
1493
+ const index = getEditorImages().indexOf(image);
1494
+ if (index < 0) return;
1495
+ const rect = getImageFrame(image).getBoundingClientRect();
1496
+ onRequestImageChange({
1497
+ index,
1498
+ assetId: image.getAttribute('data-smrt-asset-id'),
1499
+ alt: image.getAttribute('alt') ?? '',
1500
+ src: image.getAttribute('src') ?? '',
1501
+ listen,
1502
+ rect: {
1503
+ top: rect.top,
1504
+ left: rect.left,
1505
+ width: rect.width,
1506
+ height: rect.height,
1507
+ },
1508
+ });
1509
+ }
1510
+
1511
+ /** The picture the "change" badge is on: the hovered one, else the selected one. */
1512
+ const changeBadgeBox = $derived(
1513
+ onRequestImageChange ? (hoveredImageBox ?? selectedImageBox) : null,
1514
+ );
1515
+
1516
+ function handleSurfacePointerOver(event: PointerEvent) {
1517
+ if (!onRequestImageChange || event.pointerType !== 'mouse') return;
1518
+ const image = (event.target as Element | null)?.closest(
1519
+ 'img',
1520
+ ) as HTMLImageElement | null;
1521
+ if (!image || !editorElement?.contains(image)) return;
1522
+ hoveredImage = image;
1523
+ hoveredImageBox = imageBoxFor(image);
1524
+ }
1525
+
1526
+ function handleRootPointerLeave() {
1527
+ hoveredImage = null;
1528
+ hoveredImageBox = null;
1529
+ }
1530
+
1531
+ function handleSurfacePointerOut(event: PointerEvent) {
1532
+ if (!hoveredImage) return;
1533
+ const next = event.relatedTarget as Element | null;
1534
+ // Moving onto the badge keeps it; moving elsewhere in the text hides it.
1535
+ if (next?.closest?.('.image-change-badge')) return;
1536
+ if (next && next === hoveredImage) return;
1537
+ hoveredImage = null;
1538
+ hoveredImageBox = null;
1539
+ }
1540
+
1541
+ function handleChangeBadgeClick() {
1542
+ const image = hoveredImage ?? getSelectedImage();
1543
+ requestImageChange(image, false);
1544
+ }
1545
+
1546
+ // Press and hold a picture: ask for a change, listening for speech. Moving
1547
+ // past the tolerance first is a drag (native picture drag and Move keep
1548
+ // working); the context menu is held back only during a press on a picture.
1549
+ const pictureLongPress = createLongPress({
1550
+ filter: (event) => {
1551
+ if (!onRequestImageChange) return null;
1552
+ const image = (event.target as Element | null)?.closest('img');
1553
+ return image && editorElement?.contains(image) ? image : null;
1554
+ },
1555
+ onPressStart: () => primeReadyBeep(),
1556
+ onLongPress: ({ target }) => {
1557
+ const image = target as HTMLImageElement;
1558
+ selectImageElement(image);
1559
+ navigator.vibrate?.(15);
1560
+ requestImageChange(image, true);
1561
+ },
1562
+ preventContextMenu: true,
1563
+ });
1564
+
1565
+ function handleWindowPointerMove(event: PointerEvent) {
1566
+ pictureLongPress.handlePointerMove(event);
1567
+ }
1568
+ function handleWindowPointerUp(event: PointerEvent) {
1569
+ pictureLongPress.handlePointerUp(event);
1570
+ }
1571
+ function handleWindowPointerCancel(event: PointerEvent) {
1572
+ pictureLongPress.handlePointerCancel(event);
1573
+ }
1574
+
1575
+ $effect(() => {
1576
+ if (!onRequestImageChange || typeof window === 'undefined') return;
1577
+ window.addEventListener('pointermove', handleWindowPointerMove);
1578
+ window.addEventListener('pointerup', handleWindowPointerUp);
1579
+ window.addEventListener('pointercancel', handleWindowPointerCancel);
1580
+ return () => {
1581
+ window.removeEventListener('pointermove', handleWindowPointerMove);
1582
+ window.removeEventListener('pointerup', handleWindowPointerUp);
1583
+ window.removeEventListener('pointercancel', handleWindowPointerCancel);
1584
+ };
1585
+ });
1586
+
1587
+ onDestroy(() => {
1588
+ pictureLongPress.cancel();
1589
+ imagePreviews.clear();
1590
+ });
1591
+
973
1592
  function handleSurfaceClick(event: MouseEvent) {
974
1593
  const target = event.target as Element | null;
975
1594
  const image = target?.closest('img') as HTMLImageElement | null;
@@ -984,6 +1603,8 @@ function handleSurfaceClick(event: MouseEvent) {
984
1603
  }
985
1604
 
986
1605
  function handleEditorDragStart(event: DragEvent) {
1606
+ pictureLongPress.handleDragStart();
1607
+ hoveredImageBox = null;
987
1608
  const target = event.target as Element | null;
988
1609
  const image =
989
1610
  (target?.closest('img') as HTMLImageElement | null) ||
@@ -1006,11 +1627,13 @@ function handleEditorDragStart(event: DragEvent) {
1006
1627
  function handleEditorDragEnd() {
1007
1628
  movingImageIndex = null;
1008
1629
  isDragging = false;
1630
+ dropIndicatorTop = null;
1009
1631
  refreshSelectedImageChrome();
1010
1632
  }
1011
1633
  </script>
1012
1634
 
1013
- <div bind:this={rootElement} class="content-body-editor">
1635
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1636
+ <div bind:this={rootElement} class="content-body-editor" onpointerleave={handleRootPointerLeave}>
1014
1637
  <div class="body-editor-toolbar" aria-label={t(M['content.content_body_editor.toolbar'])}>
1015
1638
  <Button variant="ghost" size="sm" class="editor-toolbar-button" type="button" title={t(M['content.content_body_editor.bold'])} aria-label={t(M['content.content_body_editor.bold'])} onclick={() => runCommand('bold')}>
1016
1639
  <strong>B</strong>
@@ -1022,7 +1645,7 @@ function handleEditorDragEnd() {
1022
1645
  H2
1023
1646
  </Button>
1024
1647
  <Button variant="ghost" size="sm" class="editor-toolbar-button" type="button" title={t(M['content.content_body_editor.bulleted_list'])} aria-label={t(M['content.content_body_editor.bulleted_list'])} onclick={() => runCommand('insertUnorderedList')}>
1025
- <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
1648
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
1026
1649
  <line x1="9" y1="6" x2="21" y2="6"></line>
1027
1650
  <line x1="9" y1="12" x2="21" y2="12"></line>
1028
1651
  <line x1="9" y1="18" x2="21" y2="18"></line>
@@ -1031,32 +1654,81 @@ function handleEditorDragEnd() {
1031
1654
  <circle cx="4" cy="18" r="1"></circle>
1032
1655
  </svg>
1033
1656
  </Button>
1034
- <Button variant="ghost" size="sm" class="editor-toolbar-button" type="button" title={t(M['content.content_body_editor.insert_image'])} aria-label={t(M['content.content_body_editor.insert_image'])} onclick={() => onOpenImageChooser?.()}>
1035
- <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1657
+ <span class="editor-toolbar-slot" bind:this={imageButtonElement}>
1658
+ <Button
1659
+ variant="ghost"
1660
+ size="sm"
1661
+ class="editor-toolbar-button"
1662
+ type="button"
1663
+ title={t(M['content.content_body_editor.insert_image'])}
1664
+ aria-label={t(M['content.content_body_editor.insert_image'])}
1665
+ aria-expanded={imagePanel ? imagePanelOpen : undefined}
1666
+ aria-controls={imagePanel ? imagePanelId : undefined}
1667
+ onclick={() => onOpenImageChooser?.()}
1668
+ >
1669
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1036
1670
  <rect x="3" y="3" width="18" height="18" rx="2"></rect>
1037
1671
  <circle cx="8.5" cy="8.5" r="1.5"></circle>
1038
1672
  <polyline points="21 15 16 10 5 21"></polyline>
1039
1673
  </svg>
1040
1674
  </Button>
1041
-
1042
- <label class="format-select">
1043
- <span>{t(M['content.content_body_editor.save_as'])}</span>
1044
- <Select
1045
- value={currentFormat}
1046
- onchange={(event) => setBodyFormat((event.currentTarget as HTMLSelectElement).value as ContentBodyFormat)}
1047
- >
1048
- <option value="html">HTML</option>
1049
- <option value="markdown">Markdown</option>
1050
- </Select>
1051
- </label>
1675
+ </span>
1676
+
1677
+ {#if showFormatPicker}
1678
+ <label class="format-select">
1679
+ <span>{t(M['content.content_body_editor.save_as'])}</span>
1680
+ <Select
1681
+ value={currentFormat}
1682
+ onchange={(event) => setBodyFormat((event.currentTarget as HTMLSelectElement).value as ContentBodyFormat)}
1683
+ >
1684
+ <option value="html">HTML</option>
1685
+ <option value="markdown">Markdown</option>
1686
+ </Select>
1687
+ </label>
1688
+ {/if}
1052
1689
  </div>
1053
1690
 
1691
+ {#if imagePanel && imagePanelOpen}
1692
+ <!-- Escape bubbling up from any control inside the panel closes it. -->
1693
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
1694
+ <section
1695
+ bind:this={imagePanelElement}
1696
+ id={imagePanelId}
1697
+ class="body-editor-image-panel"
1698
+ aria-label={resolvedImagePanelLabel}
1699
+ tabindex="-1"
1700
+ onkeydown={handleImagePanelKeydown}
1701
+ transition:slide={{ duration: prefersReducedMotion() ? 0 : 180 }}
1702
+ >
1703
+ {@render imagePanel()}
1704
+ </section>
1705
+ {/if}
1706
+
1707
+ {#if dropIndicatorTop !== null}
1708
+ <div
1709
+ class="body-drop-indicator"
1710
+ style={`top: ${dropIndicatorTop}px;`}
1711
+ aria-hidden="true"
1712
+ data-testid="body-drop-indicator"
1713
+ ></div>
1714
+ {/if}
1715
+
1054
1716
  {#if selectedImageBox}
1055
1717
  <div
1056
1718
  class="image-control-popover"
1057
1719
  style={`top: ${Math.max(44, selectedImageBox.top + 8)}px; left: ${selectedImageBox.left + selectedImageBox.width / 2}px;`}
1058
1720
  aria-label={t(M['content.content_body_editor.selected_image_controls'])}
1059
1721
  >
1722
+ {#if onRequestImageChange}
1723
+ <Button variant="ghost" size="sm" class="editor-popover-button editor-popover-button--change" type="button" title={t(M['content.content_body_editor.change_image_hint'])} aria-label={t(M['content.content_body_editor.change_image'])} onclick={() => requestImageChange(getSelectedImage(), false)}>
1724
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1725
+ <path d="m12 3 1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Z"></path>
1726
+ <path d="M19 15v4"></path>
1727
+ <path d="M17 17h4"></path>
1728
+ </svg>
1729
+ <span class="editor-popover-button-text">{t(M['content.content_body_editor.change_image_short'])}</span>
1730
+ </Button>
1731
+ {/if}
1060
1732
  <Button variant="ghost" size="sm" class="editor-popover-button" type="button" title={t(M['content.content_body_editor.move_image'])} aria-label={t(M['content.content_body_editor.move_image'])} onpointerdown={startImageMove}>
1061
1733
  <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1062
1734
  <path d="M12 2v20"></path>
@@ -1067,6 +1739,18 @@ function handleEditorDragEnd() {
1067
1739
  <path d="m9 19 3 3 3-3"></path>
1068
1740
  </svg>
1069
1741
  </Button>
1742
+ <Button variant="ghost" size="sm" class="editor-popover-button editor-popover-button--move" type="button" title={t(M['content.content_body_editor.move_image_up'])} aria-label={t(M['content.content_body_editor.move_image_up'])} disabled={!selectedImageMoves.up} onclick={() => moveSelectedImage('up')}>
1743
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1744
+ <path d="M12 19V5"></path>
1745
+ <path d="m5 12 7-7 7 7"></path>
1746
+ </svg>
1747
+ </Button>
1748
+ <Button variant="ghost" size="sm" class="editor-popover-button editor-popover-button--move" type="button" title={t(M['content.content_body_editor.move_image_down'])} aria-label={t(M['content.content_body_editor.move_image_down'])} disabled={!selectedImageMoves.down} onclick={() => moveSelectedImage('down')}>
1749
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1750
+ <path d="M12 5v14"></path>
1751
+ <path d="m19 12-7 7-7-7"></path>
1752
+ </svg>
1753
+ </Button>
1070
1754
  <span class="image-control-divider"></span>
1071
1755
  <Button
1072
1756
  variant="ghost"
@@ -1152,7 +1836,7 @@ function handleEditorDragEnd() {
1152
1836
  </svg>
1153
1837
  </Button>
1154
1838
  {#if selectedImageAssetId && onUseImageAsThumbnail}
1155
- <Button variant="ghost" size="sm" class="editor-popover-button" type="button" title={t(M['content.content_body_editor.use_as_primary_image'])} aria-label={t(M['content.content_body_editor.use_as_primary_image'])} onclick={useSelectedImageAsThumbnail}>
1839
+ <Button variant="ghost" size="sm" class={`editor-popover-button${mainImageAssetId && selectedImageAssetId === mainImageAssetId ? ' editor-popover-button--active' : ''}`} type="button" title={t(M['content.content_body_editor.use_as_primary_image'])} aria-label={t(M['content.content_body_editor.use_as_primary_image'])} aria-pressed={Boolean(mainImageAssetId) && selectedImageAssetId === mainImageAssetId} onclick={useSelectedImageAsThumbnail}>
1156
1840
  <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
1157
1841
  <path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2L12 17.3 6.4 20.2 7.5 14 3 9.6l6.2-.9L12 3Z"></path>
1158
1842
  </svg>
@@ -1183,10 +1867,31 @@ function handleEditorDragEnd() {
1183
1867
  </button>
1184
1868
  {/if}
1185
1869
 
1870
+ {#if changeBadgeBox}
1871
+ <!-- raw-primitive-allow: a round 44px icon button pinned to a picture's corner (absolute position over the story); Button's padding and layout do not fit an overlay badge -->
1872
+ <button
1873
+ type="button"
1874
+ class="image-change-badge"
1875
+ title={t(M['content.content_body_editor.change_image_hint'])}
1876
+ aria-label={t(M['content.content_body_editor.change_image'])}
1877
+ style={`top: ${changeBadgeBox.top + changeBadgeBox.height - 52}px; left: ${changeBadgeBox.left + 8}px;`}
1878
+ onclick={handleChangeBadgeClick}
1879
+ >
1880
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1881
+ <path d="m12 3 1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Z"></path>
1882
+ <path d="M19 15v4"></path>
1883
+ <path d="M17 17h4"></path>
1884
+ </svg>
1885
+ </button>
1886
+ {/if}
1887
+
1186
1888
  <!-- svelte-ignore a11y_no_static_element_interactions -->
1187
1889
  <div
1188
1890
  bind:this={editorElement}
1189
- id="content-body-input"
1891
+ {id}
1892
+ aria-label={resolvedLabel}
1893
+ data-smrt-control={name || undefined}
1894
+ data-smrt-form={interactionContext?.formId}
1190
1895
  class="body-editor-surface"
1191
1896
  class:body-editor-surface--dragging={isDragging}
1192
1897
  contenteditable="true"
@@ -1205,12 +1910,19 @@ function handleEditorDragEnd() {
1205
1910
  saveSelection();
1206
1911
  }}
1207
1912
  onclick={handleSurfaceClick}
1913
+ onpointerdown={(event) => pictureLongPress.handlePointerDown(event)}
1914
+ onpointerover={handleSurfacePointerOver}
1915
+ onpointerout={handleSurfacePointerOut}
1916
+ oncontextmenu={(event) => pictureLongPress.handleContextMenu(event)}
1917
+ onclickcapture={(event) => pictureLongPress.handleClick(event)}
1208
1918
  onkeyup={saveSelection}
1209
1919
  ondragover={handleDragOver}
1210
1920
  ondragleave={handleDragLeave}
1211
1921
  ondragstart={handleEditorDragStart}
1212
1922
  ondragend={handleEditorDragEnd}
1213
1923
  ondrop={(event) => void handleDrop(event)}
1924
+ oncopy={handleClipboardWithPreviews}
1925
+ oncut={handleClipboardWithPreviews}
1214
1926
  >
1215
1927
  {@html editorHtml}
1216
1928
  </div>
@@ -1236,9 +1948,16 @@ function handleEditorDragEnd() {
1236
1948
  background: var(--smrt-color-surface-container-low, var(--smrt-color-surface-container));
1237
1949
  }
1238
1950
 
1951
+ /* Icon buttons are a fixed square. smrt-ui's `size="sm"` padding
1952
+ (0.5rem 0.75rem) would leave a 2rem button ~6px of content width, which
1953
+ squeezed the SVG icons (list, image) far below the text glyphs (B, I, H2).
1954
+ Zero the padding and never let an icon shrink: every toolbar glyph renders
1955
+ at the same --smrt-content-editor-icon-size. */
1239
1956
  .body-editor-toolbar :global(.editor-toolbar-button) {
1240
- width: 2rem;
1241
- height: 2rem;
1957
+ width: 2.25rem;
1958
+ height: 2.25rem;
1959
+ min-width: 2.25rem;
1960
+ padding: 0;
1242
1961
  display: inline-grid;
1243
1962
  place-items: center;
1244
1963
  border: 1px solid transparent;
@@ -1248,11 +1967,59 @@ function handleEditorDragEnd() {
1248
1967
  cursor: pointer;
1249
1968
  }
1250
1969
 
1970
+ .body-editor-toolbar :global(.editor-toolbar-button svg),
1971
+ .image-control-popover :global(.editor-popover-button svg) {
1972
+ flex-shrink: 0;
1973
+ width: var(--smrt-content-editor-icon-size, 1.125rem);
1974
+ height: var(--smrt-content-editor-icon-size, 1.125rem);
1975
+ }
1976
+
1977
+ /* Touch screens: 44px targets (WCAG 2.5.5), same icon size. The toolbar
1978
+ wraps rather than scrolling off a phone. */
1979
+ @media (pointer: coarse) {
1980
+ .body-editor-toolbar {
1981
+ flex-wrap: wrap;
1982
+ }
1983
+
1984
+ .body-editor-toolbar :global(.editor-toolbar-button) {
1985
+ width: 2.75rem;
1986
+ height: 2.75rem;
1987
+ min-width: 2.75rem;
1988
+ }
1989
+ }
1990
+
1991
+ .body-editor-toolbar :global(.editor-toolbar-button[aria-expanded='true']) {
1992
+ border-color: var(--smrt-color-primary);
1993
+ background: var(--smrt-color-primary-container, var(--smrt-color-surface-container));
1994
+ color: var(--smrt-color-on-primary-container, var(--smrt-color-primary));
1995
+ }
1996
+
1251
1997
  .body-editor-toolbar :global(.editor-toolbar-button:hover) {
1252
1998
  border-color: var(--smrt-color-outline-variant);
1253
1999
  background: var(--smrt-color-surface-container);
1254
2000
  }
1255
2001
 
2002
+ .editor-toolbar-slot {
2003
+ display: inline-flex;
2004
+ }
2005
+
2006
+ /* The image panel sits between the toolbar and the text so it opens above
2007
+ the story rather than below it. Full width, in the page flow (no modal),
2008
+ so it works the same on a phone. */
2009
+ .body-editor-image-panel {
2010
+ border-bottom: 1px solid var(--smrt-color-outline-variant);
2011
+ background: var(--smrt-color-surface-container-lowest, var(--smrt-color-surface));
2012
+ padding: 0.75rem;
2013
+ max-height: min(70vh, 40rem);
2014
+ overflow: auto;
2015
+ overscroll-behavior: contain;
2016
+ }
2017
+
2018
+ .body-editor-image-panel:focus-visible {
2019
+ outline: 2px solid var(--smrt-color-primary);
2020
+ outline-offset: -2px;
2021
+ }
2022
+
1256
2023
  .format-select {
1257
2024
  margin-left: auto;
1258
2025
  display: inline-flex;
@@ -1371,6 +2138,8 @@ function handleEditorDragEnd() {
1371
2138
  }
1372
2139
 
1373
2140
  .body-editor-surface :global(img) {
2141
+ /* iOS: no callout on a long press, which asks for a change instead. */
2142
+ -webkit-touch-callout: none;
1374
2143
  display: block;
1375
2144
  max-width: min(100%, 44rem);
1376
2145
  height: auto;
@@ -1402,6 +2171,26 @@ function handleEditorDragEnd() {
1402
2171
  max-width: 100%;
1403
2172
  }
1404
2173
 
2174
+ .body-editor-surface :global(img[data-smrt-thumbnail][data-smrt-placement='full']) {
2175
+ margin-top: 0;
2176
+ }
2177
+
2178
+ .body-editor-surface :global(img[data-smrt-thumbnail][data-smrt-placement='right']) {
2179
+ margin-top: 0.25rem;
2180
+ }
2181
+
2182
+ /* Phones: wrapped images stack full width, as the published page shows them. */
2183
+ @media (max-width: 36rem) {
2184
+ .body-editor-surface :global(figure[data-smrt-placement='left']),
2185
+ .body-editor-surface :global(figure[data-smrt-placement='right']),
2186
+ .body-editor-surface :global(img[data-smrt-placement='left']),
2187
+ .body-editor-surface :global(img[data-smrt-placement='right']) {
2188
+ float: none;
2189
+ width: 100% !important;
2190
+ margin: 0.75rem 0;
2191
+ }
2192
+ }
2193
+
1405
2194
  .body-editor-surface :global(img[data-smrt-selected='true']) {
1406
2195
  outline: 3px solid var(--smrt-color-primary);
1407
2196
  outline-offset: 3px;
@@ -1412,6 +2201,51 @@ function handleEditorDragEnd() {
1412
2201
  opacity: 0.78;
1413
2202
  }
1414
2203
 
2204
+ .image-change-badge {
2205
+ position: absolute;
2206
+ z-index: 19;
2207
+ display: inline-grid;
2208
+ place-items: center;
2209
+ inline-size: 44px;
2210
+ block-size: 44px;
2211
+ padding: 0;
2212
+ border: 1px solid var(--smrt-color-outline-variant);
2213
+ border-radius: var(--smrt-radius-full, 9999px);
2214
+ background: color-mix(in srgb, var(--smrt-color-surface) 94%, transparent);
2215
+ color: var(--smrt-color-primary, #3558d6);
2216
+ box-shadow: var(--smrt-elevation-3, 0 0.4rem 1rem color-mix(in srgb, var(--smrt-color-shadow) 20%, transparent));
2217
+ cursor: pointer;
2218
+ }
2219
+
2220
+ .image-change-badge:hover,
2221
+ .image-change-badge:focus-visible {
2222
+ background: var(--smrt-color-primary-container, #dde3ff);
2223
+ }
2224
+
2225
+ .image-control-popover :global(.editor-popover-button.editor-popover-button--change) {
2226
+ inline-size: auto;
2227
+ min-inline-size: 44px;
2228
+ block-size: 44px;
2229
+ padding: 0 0.7rem 0 0.55rem;
2230
+ gap: 0.3rem;
2231
+ display: inline-flex;
2232
+ color: var(--smrt-color-primary, #3558d6);
2233
+ font-weight: var(--smrt-typography-weight-semibold, 600);
2234
+ }
2235
+
2236
+ /* Phones: the picture toolbar is already wide; Change is icon-only there
2237
+ (still 44px and named "Change this picture"). */
2238
+ @media (max-width: 48rem) {
2239
+ .image-control-popover :global(.editor-popover-button-text) {
2240
+ display: none;
2241
+ }
2242
+
2243
+ .image-control-popover :global(.editor-popover-button.editor-popover-button--change) {
2244
+ padding: 0;
2245
+ inline-size: 44px;
2246
+ }
2247
+ }
2248
+
1415
2249
  .image-control-popover {
1416
2250
  position: absolute;
1417
2251
  z-index: 20;
@@ -1439,11 +2273,37 @@ function handleEditorDragEnd() {
1439
2273
  }
1440
2274
 
1441
2275
  .image-control-popover :global(.editor-popover-button) {
1442
- width: 1.85rem;
1443
- height: 1.85rem;
2276
+ width: 2rem;
2277
+ height: 2rem;
2278
+ min-width: 2rem;
2279
+ padding: 0;
1444
2280
  border-radius: var(--smrt-radius-full, 9999px);
1445
2281
  }
1446
2282
 
2283
+ /* Move up / Move down: full 44px touch targets. */
2284
+ .image-control-popover :global(.editor-popover-button.editor-popover-button--move) {
2285
+ width: 2.75rem;
2286
+ height: 2.75rem;
2287
+ min-width: 2.75rem;
2288
+ }
2289
+
2290
+ .image-control-popover :global(.editor-popover-button:disabled) {
2291
+ opacity: 0.4;
2292
+ cursor: default;
2293
+ }
2294
+
2295
+ .body-drop-indicator {
2296
+ position: absolute;
2297
+ z-index: 19;
2298
+ left: 1rem;
2299
+ right: 1rem;
2300
+ height: 4px;
2301
+ border-radius: var(--smrt-radius-sm, 2px);
2302
+ background: var(--smrt-color-primary);
2303
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--smrt-color-primary) 25%, transparent);
2304
+ pointer-events: none;
2305
+ }
2306
+
1447
2307
  .image-control-popover :global(.editor-popover-button:hover),
1448
2308
  .image-control-popover :global(.editor-popover-button.editor-popover-button--active) {
1449
2309
  background: var(--smrt-color-surface-container);