@happyvertical/smrt-content 0.51.38 → 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.
- package/AGENTS.md +6 -0
- package/README.md +76 -0
- package/dist/body-format.d.ts +104 -0
- package/dist/body-format.d.ts.map +1 -1
- package/dist/body-format.js +439 -48
- package/dist/body-format.js.map +1 -1
- package/dist/chunks/content-query-BqLHbZYv.js +4131 -0
- package/dist/chunks/content-query-BqLHbZYv.js.map +1 -0
- package/dist/chunks/{content-versions-CiA4oUsl.js → content-versions-DatN0G28.js} +2 -2
- package/dist/chunks/{content-versions-CiA4oUsl.js.map → content-versions-DatN0G28.js.map} +1 -1
- package/dist/chunks/{contents-BlHrLlpl.js → contents-xas83N8M.js} +4 -6
- package/dist/chunks/contents-xas83N8M.js.map +1 -0
- package/dist/content-query.d.ts.map +1 -1
- package/dist/content.d.ts +13 -1
- package/dist/content.d.ts.map +1 -1
- package/dist/contents.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -6
- package/dist/manifest.json +113 -23
- package/dist/server/content-list-data-surface.d.ts +1 -1
- package/dist/server/content-list-data-surface.d.ts.map +1 -1
- package/dist/server.js +3 -3
- package/dist/server.js.map +1 -1
- package/dist/smrt-knowledge.json +92 -10
- package/dist/svelte/components/ContentBodyEditor.svelte +898 -38
- package/dist/svelte/components/ContentBodyEditor.svelte.d.ts +67 -0
- package/dist/svelte/components/ContentBodyEditor.svelte.d.ts.map +1 -1
- package/dist/svelte/components/ContentBodyRenderer.svelte +36 -11
- package/dist/svelte/components/ContentBodyRenderer.svelte.d.ts +2 -0
- package/dist/svelte/components/ContentBodyRenderer.svelte.d.ts.map +1 -1
- package/dist/svelte/components/ContentClaimAuditTool.svelte +45 -2
- package/dist/svelte/components/ContentCorrectionsTool.svelte +24 -2
- package/dist/svelte/components/ContentEditor.svelte +67 -17
- package/dist/svelte/components/ContentEditor.svelte.d.ts +12 -0
- package/dist/svelte/components/ContentEditor.svelte.d.ts.map +1 -1
- package/dist/svelte/components/ContentGovernancePanel.svelte +4 -2
- package/dist/svelte/components/ContentMetadataFields.svelte +141 -51
- package/dist/svelte/components/ContentMetadataFields.svelte.d.ts +20 -0
- package/dist/svelte/components/ContentMetadataFields.svelte.d.ts.map +1 -1
- package/dist/svelte/components/ContentPictureDrawer.svelte +611 -0
- package/dist/svelte/components/ContentPictureDrawer.svelte.d.ts +68 -0
- package/dist/svelte/components/ContentPictureDrawer.svelte.d.ts.map +1 -0
- package/dist/svelte/components/ContentReviewStatusTray.svelte +2 -2
- package/dist/svelte/components/ContentStatusFields.svelte +85 -38
- package/dist/svelte/components/ContentStatusFields.svelte.d.ts +10 -0
- package/dist/svelte/components/ContentStatusFields.svelte.d.ts.map +1 -1
- package/dist/svelte/components/ContentTitleField.svelte +109 -12
- package/dist/svelte/components/ContentTitleField.svelte.d.ts +12 -0
- package/dist/svelte/components/ContentTitleField.svelte.d.ts.map +1 -1
- package/dist/svelte/components/ContentTransparencyReport.svelte +3 -1
- package/dist/svelte/components/ContentTransparencyTool.svelte +18 -3
- package/dist/svelte/components/ContentVersionsTool.svelte +26 -2
- package/dist/svelte/components/content-field-mode.d.ts +20 -0
- package/dist/svelte/components/content-field-mode.d.ts.map +1 -0
- package/dist/svelte/components/content-field-mode.js +26 -0
- package/dist/svelte/content-editor-media.d.ts +14 -0
- package/dist/svelte/content-editor-media.d.ts.map +1 -1
- package/dist/svelte/content-editor-media.js +41 -0
- package/dist/svelte/content-editor-state.svelte.d.ts +17 -0
- package/dist/svelte/content-editor-state.svelte.d.ts.map +1 -1
- package/dist/svelte/content-editor-state.svelte.js +46 -0
- package/dist/svelte/i18n.editor.d.ts +49 -0
- package/dist/svelte/i18n.editor.d.ts.map +1 -1
- package/dist/svelte/i18n.editor.js +53 -1
- package/dist/svelte/index.d.ts +10 -5
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +5 -3
- package/package.json +27 -25
- package/dist/chunks/content-query-BTmxK8AQ.js +0 -4092
- package/dist/chunks/content-query-BTmxK8AQ.js.map +0 -1
- package/dist/chunks/contents-BlHrLlpl.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 {
|
|
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 &&
|
|
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 =
|
|
796
|
+
const rawHtml = editorInnerHtml();
|
|
594
797
|
const normalizedHtml = normalizeEditorHtml(rawHtml);
|
|
595
|
-
if (options.syncDom &&
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
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
|
|
1367
|
+
return [];
|
|
890
1368
|
}
|
|
891
1369
|
|
|
892
1370
|
try {
|
|
893
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
941
|
-
if (
|
|
1439
|
+
const draggedImages = parseDraggedImages(event.dataTransfer);
|
|
1440
|
+
if (draggedImages.length > 0) {
|
|
942
1441
|
if (dropRange) {
|
|
943
1442
|
savedRange = dropRange.cloneRange();
|
|
944
1443
|
}
|
|
945
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
<
|
|
1035
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
<
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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=
|
|
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
|
|
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:
|
|
1241
|
-
height:
|
|
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:
|
|
1443
|
-
height:
|
|
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);
|