@mjml-agent-editor/editor 0.1.12 → 0.1.14

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/dist/canvas.js CHANGED
@@ -1,10 +1,24 @@
1
1
  "use client";
2
2
  import {
3
3
  CanvasComments,
4
+ Dialog,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ Popover,
10
+ PopoverAnchor,
11
+ PopoverContent,
12
+ PopoverTrigger,
13
+ refusal,
14
+ saveToastId,
15
+ useAgentTurn,
4
16
  useDocumentStore,
17
+ useImageLibrary,
5
18
  useLabels
6
- } from "./chunk-4N2OJT22.js";
19
+ } from "./chunk-SR3NM6VC.js";
7
20
  import {
21
+ Button,
8
22
  Input,
9
23
  Spinner,
10
24
  cn
@@ -14,10 +28,41 @@ import {
14
28
  import grapesjs from "grapesjs";
15
29
  import GjsEditor, { Canvas, useEditorMaybe } from "@grapesjs/react";
16
30
  import grapesjsMJML from "grapesjs-mjml";
17
- import { useRef, useState } from "react";
31
+ import { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
18
32
  import { STARTER_MJML } from "@mjml-agent-editor/core";
19
- import { toast } from "sonner";
20
- import { ChevronRight } from "lucide-react";
33
+
34
+ // src/lib/save-queue.ts
35
+ function createSaveQueue(save, delayMs) {
36
+ let timer = null;
37
+ let tail = Promise.resolve();
38
+ const cancel = () => {
39
+ if (timer) clearTimeout(timer);
40
+ timer = null;
41
+ };
42
+ const run = (task) => {
43
+ const next = tail.then(task);
44
+ tail = next.catch(() => {
45
+ });
46
+ return next;
47
+ };
48
+ return {
49
+ schedule() {
50
+ cancel();
51
+ timer = setTimeout(() => void run(save).catch(() => {
52
+ }), delayMs);
53
+ },
54
+ cancel,
55
+ flush() {
56
+ cancel();
57
+ return run(save);
58
+ },
59
+ run
60
+ };
61
+ }
62
+
63
+ // src/components/editor/EmailEditor.tsx
64
+ import { toast as toast2 } from "sonner";
65
+ import { ChevronRight, ImageIcon as ImageIcon2, ImageUp as ImageUp2 } from "lucide-react";
21
66
 
22
67
  // src/components/ui/switch.tsx
23
68
  import "react";
@@ -51,7 +96,582 @@ function Switch({
51
96
 
52
97
  // src/components/editor/EmailEditor.tsx
53
98
  import { Search } from "lucide-react";
99
+
100
+ // src/components/editor/ImagePicker.tsx
101
+ import { useCallback, useEffect, useRef, useState } from "react";
102
+ import { Check, ImageUp, Link2, Trash2 } from "lucide-react";
103
+ import { toast } from "sonner";
54
104
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
105
+ function ImagePicker({
106
+ open,
107
+ currentUrl,
108
+ onSelect,
109
+ onClose
110
+ }) {
111
+ const library = useImageLibrary();
112
+ const labels = useLabels();
113
+ const [images, setImages] = useState([]);
114
+ const [quota, setQuota] = useState();
115
+ const [loading, setLoading] = useState(false);
116
+ const [pending, setPending] = useState(0);
117
+ const [dragging, setDragging] = useState(false);
118
+ const [url, setUrl] = useState("");
119
+ const [confirmId, setConfirmId] = useState(null);
120
+ const fileInput = useRef(null);
121
+ const session = useRef(0);
122
+ const latest = useRef({ labels, onSelect });
123
+ latest.current = { labels, onSelect };
124
+ const lists = useRef(0);
125
+ const refresh = useCallback(async () => {
126
+ if (!library) return;
127
+ const mine = ++lists.current;
128
+ setLoading(true);
129
+ try {
130
+ const result = await library.list();
131
+ if (mine !== lists.current) return;
132
+ setImages(result.images);
133
+ setQuota(result.quota);
134
+ } catch (err) {
135
+ console.error(err);
136
+ if (mine === lists.current) toast.error(latest.current.labels.imagePickerLoadFailed);
137
+ } finally {
138
+ if (mine === lists.current) setLoading(false);
139
+ }
140
+ }, [library]);
141
+ useEffect(() => {
142
+ session.current += 1;
143
+ if (!open) return;
144
+ setUrl("");
145
+ setConfirmId(null);
146
+ void refresh();
147
+ }, [open]);
148
+ useEffect(() => () => void (session.current += 1), []);
149
+ const accepts = (file) => matchesAccept(file, library?.accept);
150
+ const full = quota !== void 0 && quota.used >= quota.limit;
151
+ const upload = async (files) => {
152
+ if (!library || !files?.length) return;
153
+ const mine = session.current;
154
+ const list = Array.from(files).filter((f) => {
155
+ if (accepts(f)) return true;
156
+ toast.error(labels.imagePickerUnsupported(f.name));
157
+ return false;
158
+ });
159
+ if (!list.length) return;
160
+ setPending((n) => n + list.length);
161
+ const results = await Promise.allSettled(list.map(async (f) => library.upload(f)));
162
+ setPending((n) => n - list.length);
163
+ for (const r of results) {
164
+ if (r.status === "rejected") {
165
+ console.error(r.reason);
166
+ toast.error(r.reason instanceof Error ? r.reason.message : labels.imageUploadFailed);
167
+ }
168
+ }
169
+ if (session.current !== mine) return;
170
+ await refresh();
171
+ const [only] = results;
172
+ if (results.length === 1 && only?.status === "fulfilled" && session.current === mine) {
173
+ latest.current.onSelect(only.value.url);
174
+ }
175
+ };
176
+ const remove = async (image) => {
177
+ if (!library?.remove) return;
178
+ if (confirmId !== image.id) {
179
+ setConfirmId(image.id);
180
+ return;
181
+ }
182
+ setConfirmId(null);
183
+ try {
184
+ await library.remove(image.id);
185
+ await refresh();
186
+ } catch (err) {
187
+ console.error(err);
188
+ toast.error(err instanceof Error ? err.message : labels.imageDeleteFailed);
189
+ }
190
+ };
191
+ const onDrop = (e) => {
192
+ e.preventDefault();
193
+ setDragging(false);
194
+ if (!full) void upload(e.dataTransfer.files);
195
+ };
196
+ return /* @__PURE__ */ jsx2(Dialog, { open, onOpenChange: (next) => !next && onClose(), children: /* @__PURE__ */ jsxs(DialogContent, { className: "editor-dark gap-0 overflow-hidden border border-panel-border bg-panel p-0 text-panel-fg ring-0 sm:max-w-3xl", children: [
197
+ /* @__PURE__ */ jsxs(DialogHeader, { className: "border-b border-panel-border px-5 py-4", children: [
198
+ /* @__PURE__ */ jsx2(DialogTitle, { children: labels.imagePickerTitle }),
199
+ quota && /* @__PURE__ */ jsx2(DialogDescription, { className: "text-panel-muted-fg", children: labels.imagePickerQuota(quota.used, quota.limit) })
200
+ ] }),
201
+ /* @__PURE__ */ jsxs("div", { className: "flex max-h-[70vh] flex-col gap-4 overflow-y-auto p-5", children: [
202
+ library && (full ? /* @__PURE__ */ jsx2("p", { className: "rounded-lg border border-panel-border bg-panel-elevated px-4 py-3 text-sm text-panel-muted-fg", children: labels.imagePickerQuotaFull }) : /* @__PURE__ */ jsxs(
203
+ "div",
204
+ {
205
+ onDragOver: (e) => {
206
+ e.preventDefault();
207
+ setDragging(true);
208
+ },
209
+ onDragLeave: () => setDragging(false),
210
+ onDrop,
211
+ className: cn(
212
+ "flex flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed px-6 py-8 text-center transition-colors",
213
+ dragging ? "border-brand bg-brand-soft" : "border-panel-border bg-panel-elevated"
214
+ ),
215
+ children: [
216
+ /* @__PURE__ */ jsx2(ImageUp, { className: "size-8 text-panel-muted-fg" }),
217
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2 text-sm text-panel-muted-fg", children: [
218
+ /* @__PURE__ */ jsx2("span", { children: labels.imagePickerDrop }),
219
+ /* @__PURE__ */ jsx2(
220
+ Button,
221
+ {
222
+ type: "button",
223
+ size: "sm",
224
+ className: "bg-brand text-brand-fg hover:bg-brand/90",
225
+ onClick: () => fileInput.current?.click(),
226
+ children: labels.imagePickerBrowse
227
+ }
228
+ )
229
+ ] }),
230
+ /* @__PURE__ */ jsx2(
231
+ "input",
232
+ {
233
+ ref: fileInput,
234
+ type: "file",
235
+ accept: library.accept ?? "image/*",
236
+ multiple: true,
237
+ hidden: true,
238
+ onChange: (e) => {
239
+ void upload(e.target.files);
240
+ e.target.value = "";
241
+ }
242
+ }
243
+ )
244
+ ]
245
+ }
246
+ )),
247
+ /* @__PURE__ */ jsxs(
248
+ "form",
249
+ {
250
+ className: "flex gap-2",
251
+ onSubmit: (e) => {
252
+ e.preventDefault();
253
+ const value = url.trim();
254
+ if (value) onSelect(value);
255
+ },
256
+ children: [
257
+ /* @__PURE__ */ jsxs("div", { className: "relative flex-1", children: [
258
+ /* @__PURE__ */ jsx2(Link2, { className: "pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-panel-muted-fg" }),
259
+ /* @__PURE__ */ jsx2(
260
+ Input,
261
+ {
262
+ value: url,
263
+ onChange: (e) => setUrl(e.target.value),
264
+ placeholder: labels.imagePickerUrlPlaceholder,
265
+ className: "h-9 border-panel-border bg-panel-elevated pl-8 text-panel-fg placeholder:text-panel-muted-fg"
266
+ }
267
+ )
268
+ ] }),
269
+ /* @__PURE__ */ jsx2(
270
+ Button,
271
+ {
272
+ type: "submit",
273
+ size: "lg",
274
+ variant: "outline",
275
+ disabled: !url.trim(),
276
+ className: "border-panel-border bg-panel-elevated text-panel-fg hover:bg-panel-hover hover:text-panel-fg",
277
+ children: labels.imagePickerUseUrl
278
+ }
279
+ )
280
+ ]
281
+ }
282
+ ),
283
+ library && /* @__PURE__ */ jsxs("section", { children: [
284
+ /* @__PURE__ */ jsx2("h3", { className: "mb-2 text-xs font-medium tracking-wide text-panel-muted-fg uppercase", children: labels.imagePickerGallery }),
285
+ loading && images.length === 0 && pending === 0 ? /* @__PURE__ */ jsx2("div", { className: "flex justify-center py-8", children: /* @__PURE__ */ jsx2(Spinner, {}) }) : images.length === 0 && pending === 0 ? /* @__PURE__ */ jsx2("p", { className: "py-6 text-center text-sm text-panel-muted-fg", children: labels.imagePickerEmpty }) : /* @__PURE__ */ jsxs("ul", { className: "grid grid-cols-3 gap-3 sm:grid-cols-4", children: [
286
+ Array.from({ length: pending }, (_, i) => /* @__PURE__ */ jsx2(
287
+ "li",
288
+ {
289
+ className: "flex aspect-[4/3] items-center justify-center rounded-lg border border-panel-border bg-panel-elevated",
290
+ children: /* @__PURE__ */ jsx2(Spinner, {})
291
+ },
292
+ `pending-${i}`
293
+ )),
294
+ images.map((image) => {
295
+ const selected = image.url === currentUrl;
296
+ const confirming = confirmId === image.id;
297
+ return /* @__PURE__ */ jsxs("li", { className: "group relative", children: [
298
+ /* @__PURE__ */ jsx2(
299
+ "button",
300
+ {
301
+ type: "button",
302
+ onClick: () => onSelect(image.url),
303
+ title: image.name,
304
+ className: cn(
305
+ "block aspect-[4/3] w-full overflow-hidden rounded-lg border bg-black/25 transition outline-none focus-visible:ring-2 focus-visible:ring-brand",
306
+ selected ? "border-brand ring-2 ring-brand" : "border-panel-border hover:border-panel-muted-fg"
307
+ ),
308
+ children: /* @__PURE__ */ jsx2(
309
+ "img",
310
+ {
311
+ src: image.url,
312
+ alt: image.name ?? "",
313
+ loading: "lazy",
314
+ className: "size-full object-contain"
315
+ }
316
+ )
317
+ }
318
+ ),
319
+ selected && /* @__PURE__ */ jsx2("span", { className: "pointer-events-none absolute top-1.5 left-1.5 flex size-5 items-center justify-center rounded-full bg-brand text-brand-fg", children: /* @__PURE__ */ jsx2(Check, { className: "size-3" }) }),
320
+ image.name && /* @__PURE__ */ jsx2("span", { className: "pointer-events-none absolute inset-x-0 bottom-0 truncate rounded-b-lg bg-black/60 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover:opacity-100", children: image.name }),
321
+ library.remove && /* @__PURE__ */ jsxs(
322
+ "button",
323
+ {
324
+ type: "button",
325
+ onClick: () => void remove(image),
326
+ onBlur: () => confirming && setConfirmId(null),
327
+ title: confirming ? labels.imagePickerConfirmDelete : labels.imagePickerDelete,
328
+ "aria-label": labels.imagePickerDelete,
329
+ className: cn(
330
+ "absolute top-1.5 right-1.5 flex h-6 items-center gap-1 rounded-md px-1.5 text-xs text-white transition focus-visible:opacity-100",
331
+ confirming ? "bg-red-600 opacity-100" : "bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80"
332
+ ),
333
+ children: [
334
+ /* @__PURE__ */ jsx2(Trash2, { className: "size-3.5" }),
335
+ confirming && labels.imagePickerConfirmDelete
336
+ ]
337
+ }
338
+ )
339
+ ] }, image.id);
340
+ })
341
+ ] })
342
+ ] })
343
+ ] })
344
+ ] }) });
345
+ }
346
+ function matchesAccept(file, accept) {
347
+ if (!accept) return file.type.startsWith("image/");
348
+ return accept.split(",").some((raw) => {
349
+ const rule = raw.trim().toLowerCase();
350
+ if (rule.startsWith(".")) return file.name.toLowerCase().endsWith(rule);
351
+ if (rule.endsWith("/*")) return file.type.startsWith(rule.slice(0, -1));
352
+ return file.type === rule;
353
+ });
354
+ }
355
+
356
+ // src/components/editor/ColorField.tsx
357
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
358
+ import { HexAlphaColorPicker, HexColorPicker } from "react-colorful";
359
+ import { Pipette } from "lucide-react";
360
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
361
+ var PRESETS = [
362
+ "#000000",
363
+ "#374151",
364
+ "#6b7280",
365
+ "#d1d5db",
366
+ "#f3f4f6",
367
+ "#ffffff",
368
+ "#dc2626",
369
+ "#ea580c",
370
+ "#ca8a04",
371
+ "#16a34a",
372
+ "#0284c7",
373
+ "#4f46e5",
374
+ "#9333ea",
375
+ "#db2777"
376
+ ];
377
+ var eyeDropper = () => globalThis.EyeDropper;
378
+ function ColorField({
379
+ value,
380
+ placeholder,
381
+ documentColors,
382
+ labels,
383
+ onChange
384
+ }) {
385
+ const [text, setText] = useState2(value);
386
+ const [open, setOpen] = useState2(false);
387
+ const [used, setUsed] = useState2([]);
388
+ const pressed = useRef2(false);
389
+ const dragged = useRef2(null);
390
+ const fieldRef = useRef2(null);
391
+ const anchor = useRef2({
392
+ getBoundingClientRect: () => {
393
+ const field = fieldRef.current?.getBoundingClientRect() ?? new DOMRect();
394
+ const panel = fieldRef.current?.closest(".editor-dark")?.getBoundingClientRect();
395
+ return new DOMRect(panel?.right ?? field.right, field.top, 0, field.height);
396
+ }
397
+ });
398
+ useEffect2(() => setText(value), [value]);
399
+ const latestOnChange = useRef2(onChange);
400
+ latestOnChange.current = onChange;
401
+ const release = useCallback2(() => {
402
+ pressed.current = false;
403
+ if (dragged.current !== null) latestOnChange.current(dragged.current, false);
404
+ dragged.current = null;
405
+ }, []);
406
+ useEffect2(() => {
407
+ document.addEventListener("lostpointercapture", release);
408
+ return () => {
409
+ document.removeEventListener("lostpointercapture", release);
410
+ release();
411
+ };
412
+ }, [release]);
413
+ const hex = toHex(value) ?? toHex(placeholder ?? "") ?? "#000000";
414
+ const translucent = hex.length === 9;
415
+ const empty = !value;
416
+ const commit = (next) => {
417
+ dragged.current = null;
418
+ onChange(next, false);
419
+ };
420
+ const drag = (next) => {
421
+ setText(next);
422
+ if (!pressed.current) return commit(next);
423
+ dragged.current = next;
424
+ onChange(next, true);
425
+ };
426
+ const pickFromScreen = async () => {
427
+ const Ctor = eyeDropper();
428
+ if (!Ctor) return;
429
+ try {
430
+ const { sRGBHex } = await new Ctor().open();
431
+ commit(sRGBHex);
432
+ } catch {
433
+ }
434
+ };
435
+ return /* @__PURE__ */ jsxs2(
436
+ Popover,
437
+ {
438
+ open,
439
+ onOpenChange: (next) => {
440
+ if (next) setUsed(documentColors());
441
+ else if (text.trim() !== value) commit(text.trim());
442
+ setOpen(next);
443
+ },
444
+ children: [
445
+ /* @__PURE__ */ jsx3(PopoverAnchor, { virtualRef: anchor }),
446
+ /* @__PURE__ */ jsxs2(
447
+ "div",
448
+ {
449
+ ref: fieldRef,
450
+ className: "flex h-8 w-full items-center gap-1.5 rounded-md border border-panel-border bg-panel-elevated pr-1.5 pl-2 focus-within:border-brand",
451
+ children: [
452
+ /* @__PURE__ */ jsx3(
453
+ "input",
454
+ {
455
+ value: text,
456
+ placeholder,
457
+ spellCheck: false,
458
+ onChange: (e) => setText(e.target.value),
459
+ onBlur: () => text !== value && commit(text.trim()),
460
+ onKeyDown: (e) => {
461
+ if (e.key === "Enter") commit(text.trim());
462
+ if (e.key === "Escape") setText(value);
463
+ },
464
+ className: "min-w-0 flex-1 bg-transparent font-mono text-xs text-panel-fg outline-none placeholder:text-panel-muted-fg"
465
+ }
466
+ ),
467
+ /* @__PURE__ */ jsx3(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsxs2(
468
+ "button",
469
+ {
470
+ type: "button",
471
+ title: labels.colorPickerOpen,
472
+ "aria-label": labels.colorPickerOpen,
473
+ className: "relative size-5 shrink-0 overflow-hidden rounded border border-white/20 outline-none focus-visible:ring-2 focus-visible:ring-brand",
474
+ children: [
475
+ /* @__PURE__ */ jsx3(Checkerboard, {}),
476
+ !empty && /* @__PURE__ */ jsx3("span", { className: "absolute inset-0", style: { background: value } })
477
+ ]
478
+ }
479
+ ) })
480
+ ]
481
+ }
482
+ ),
483
+ /* @__PURE__ */ jsxs2(
484
+ PopoverContent,
485
+ {
486
+ side: "right",
487
+ align: "start",
488
+ sideOffset: 8,
489
+ className: "editor-dark flex w-52 flex-col gap-2.5 border border-panel-border bg-panel p-2.5 text-panel-fg",
490
+ children: [
491
+ /* @__PURE__ */ jsx3(
492
+ "div",
493
+ {
494
+ onPointerDownCapture: (e) => {
495
+ pressed.current = true;
496
+ e.target.setPointerCapture(e.pointerId);
497
+ },
498
+ children: translucent ? /* @__PURE__ */ jsx3(HexAlphaColorPicker, { color: hex, onChange: drag, className: "color-field-picker" }) : /* @__PURE__ */ jsx3(HexColorPicker, { color: hex, onChange: drag, className: "color-field-picker" })
499
+ }
500
+ ),
501
+ /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-1.5", children: [
502
+ /* @__PURE__ */ jsx3(
503
+ "span",
504
+ {
505
+ className: "size-6 shrink-0 rounded border border-white/20",
506
+ style: { background: hex }
507
+ }
508
+ ),
509
+ /* @__PURE__ */ jsx3(
510
+ "input",
511
+ {
512
+ value: text,
513
+ spellCheck: false,
514
+ onChange: (e) => setText(e.target.value),
515
+ onKeyDown: (e) => e.key === "Enter" && commit(text.trim()),
516
+ onBlur: () => text !== value && commit(text.trim()),
517
+ className: "h-6 min-w-0 flex-1 rounded border border-panel-border bg-panel-elevated px-1.5 font-mono text-[11px] outline-none focus:border-brand"
518
+ }
519
+ ),
520
+ eyeDropper() && /* @__PURE__ */ jsx3(
521
+ "button",
522
+ {
523
+ type: "button",
524
+ onClick: () => void pickFromScreen(),
525
+ title: labels.colorPickerEyedropper,
526
+ "aria-label": labels.colorPickerEyedropper,
527
+ className: "flex size-6 shrink-0 items-center justify-center rounded border border-panel-border bg-panel-elevated text-panel-muted-fg hover:text-panel-fg",
528
+ children: /* @__PURE__ */ jsx3(Pipette, { className: "size-3" })
529
+ }
530
+ )
531
+ ] }),
532
+ used.length > 0 && /* @__PURE__ */ jsx3(
533
+ Swatches,
534
+ {
535
+ title: labels.colorPickerDocument,
536
+ colors: used,
537
+ current: hex,
538
+ onPick: commit
539
+ }
540
+ ),
541
+ /* @__PURE__ */ jsx3(
542
+ Swatches,
543
+ {
544
+ title: labels.colorPickerPresets,
545
+ colors: PRESETS,
546
+ current: hex,
547
+ onPick: commit,
548
+ transparentLabel: labels.colorPickerTransparent
549
+ }
550
+ )
551
+ ]
552
+ }
553
+ )
554
+ ]
555
+ }
556
+ );
557
+ }
558
+ function Swatches({
559
+ title,
560
+ colors,
561
+ current,
562
+ onPick,
563
+ transparentLabel
564
+ }) {
565
+ return /* @__PURE__ */ jsxs2("div", { children: [
566
+ /* @__PURE__ */ jsx3("p", { className: "mb-1 text-[10px] font-medium tracking-wide text-panel-muted-fg uppercase", children: title }),
567
+ /* @__PURE__ */ jsxs2("div", { className: "grid grid-cols-8 gap-1", children: [
568
+ colors.map((color) => /* @__PURE__ */ jsx3(
569
+ "button",
570
+ {
571
+ type: "button",
572
+ title: color,
573
+ onClick: () => onPick(color),
574
+ className: cn(
575
+ "aspect-square rounded border border-white/15 outline-none hover:scale-110 focus-visible:ring-2 focus-visible:ring-brand",
576
+ color.toLowerCase() === current.toLowerCase() && "ring-2 ring-brand"
577
+ ),
578
+ style: { background: color }
579
+ },
580
+ color
581
+ )),
582
+ transparentLabel && /* @__PURE__ */ jsx3(
583
+ "button",
584
+ {
585
+ type: "button",
586
+ title: transparentLabel,
587
+ onClick: () => onPick("transparent"),
588
+ className: "relative aspect-square overflow-hidden rounded border border-white/15 outline-none hover:scale-110 focus-visible:ring-2 focus-visible:ring-brand",
589
+ children: /* @__PURE__ */ jsx3(Checkerboard, {})
590
+ }
591
+ )
592
+ ] })
593
+ ] });
594
+ }
595
+ function Checkerboard() {
596
+ return /* @__PURE__ */ jsx3(
597
+ "span",
598
+ {
599
+ className: "absolute inset-0",
600
+ style: {
601
+ backgroundImage: "repeating-conic-gradient(#9ca3af 0% 25%, #e5e7eb 0% 50%)",
602
+ backgroundSize: "8px 8px"
603
+ }
604
+ }
605
+ );
606
+ }
607
+ function toHex(value, { opaque = false } = {}) {
608
+ const v = value.trim().toLowerCase();
609
+ const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(v);
610
+ if (short) return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
611
+ if (/^#[0-9a-f]{6}$/.test(v)) return v;
612
+ if (/^#[0-9a-f]{8}$/.test(v)) return opaque || v.endsWith("ff") ? v.slice(0, 7) : v;
613
+ const rgb = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)(?:[\s,/]+([\d.]+%?))?/.exec(v);
614
+ if (rgb) {
615
+ const channels = [rgb[1], rgb[2], rgb[3]].map((n) => Math.min(255, Number(n)));
616
+ const alphaText = rgb[4];
617
+ const alpha = alphaText?.endsWith("%") ? parseFloat(alphaText) / 100 : Number(alphaText ?? 1);
618
+ if (!opaque && alpha < 1) channels.push(Math.round(Math.max(0, alpha) * 255));
619
+ return `#${channels.map((n) => n.toString(16).padStart(2, "0")).join("")}`;
620
+ }
621
+ return null;
622
+ }
623
+
624
+ // src/components/editor/ImageField.tsx
625
+ import { ImageIcon, X } from "lucide-react";
626
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
627
+ function ImageField({
628
+ value,
629
+ labels,
630
+ onChoose,
631
+ onClear
632
+ }) {
633
+ const url = imageUrl(value);
634
+ return /* @__PURE__ */ jsxs3("div", { className: "relative", children: [
635
+ /* @__PURE__ */ jsxs3(
636
+ "button",
637
+ {
638
+ type: "button",
639
+ onClick: onChoose,
640
+ title: labels.chooseImage,
641
+ className: "group relative block w-full overflow-hidden rounded-md border border-panel-border bg-panel-elevated outline-none focus-visible:ring-2 focus-visible:ring-brand",
642
+ children: [
643
+ url ? /* @__PURE__ */ jsx4("img", { src: url, alt: "", className: "h-24 w-full bg-black/25 object-contain" }) : /* @__PURE__ */ jsxs3("span", { className: "flex h-16 flex-col items-center justify-center gap-1 text-xs text-panel-muted-fg", children: [
644
+ /* @__PURE__ */ jsx4(ImageIcon, { className: "size-5" }),
645
+ labels.chooseImage
646
+ ] }),
647
+ url && /* @__PURE__ */ jsx4("span", { className: "absolute inset-0 flex items-center justify-center bg-black/50 text-xs font-medium text-white opacity-0 transition group-hover:opacity-100", children: labels.chooseImage })
648
+ ]
649
+ }
650
+ ),
651
+ value && /* @__PURE__ */ jsx4(
652
+ "button",
653
+ {
654
+ type: "button",
655
+ onClick: onClear,
656
+ title: labels.imageFieldRemove,
657
+ "aria-label": labels.imageFieldRemove,
658
+ className: "absolute top-1 right-1 flex size-5 items-center justify-center rounded bg-black/60 text-white hover:bg-black/80",
659
+ children: /* @__PURE__ */ jsx4(X, { className: "size-3" })
660
+ }
661
+ )
662
+ ] });
663
+ }
664
+ function imageUrl(value) {
665
+ const v = value.trim();
666
+ if (!v || v === "none") return null;
667
+ const css = /^url\(\s*(['"]?)(.*?)\1\s*\)$/i.exec(v);
668
+ if (css) return css[2] || null;
669
+ return /^[a-z-]+\(/i.test(v) ? null : v;
670
+ }
671
+
672
+ // src/components/editor/EmailEditor.tsx
673
+ import { createRoot } from "react-dom/client";
674
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
55
675
  var SEC_ID_RE = /\bsec-([A-Za-z0-9_-]+)\b/;
56
676
  var OBJ_ID_RE = /\bobj-([A-Za-z0-9_-]+)\b/;
57
677
  var TYPE_LABEL = {
@@ -64,6 +684,7 @@ function tagOf(c) {
64
684
  return String(c.get("tagName") || c.get("type") || "");
65
685
  }
66
686
  var SPARKLES_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>';
687
+ var IMAGE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21"/><path d="m14 19.5 3-3 3 3"/><path d="M17 22v-5.5"/><circle cx="9" cy="9" r="2"/></svg>';
67
688
  function isSection(c) {
68
689
  return tagOf(c) === "mj-section";
69
690
  }
@@ -146,6 +767,75 @@ function wrapSelectionStyle(el, style) {
146
767
  } catch {
147
768
  }
148
769
  }
770
+ function setupStyleFields(editor, labels) {
771
+ const sm = editor.StyleManager;
772
+ const base = sm.getType("base").view;
773
+ const documentColors = () => {
774
+ const source = `${editor.getHtml()} ${editor.getCss() ?? ""}`;
775
+ const counts = /* @__PURE__ */ new Map();
776
+ const colorValue = /(?:color|background|border)[\w-]*\s*[:=]\s*["']?[^"';>{}]*?(#[0-9a-f]{3,8}\b|rgba?\([^)]*\))/gi;
777
+ for (const match of source.matchAll(colorValue)) {
778
+ const hex = toHex(match[1], { opaque: true });
779
+ if (hex) counts.set(hex, (counts.get(hex) ?? 0) + 1);
780
+ }
781
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 14).map(([hex]) => hex);
782
+ };
783
+ const ownValue = (property) => property.hasValue?.({ noParent: true }) ? String(property.getValue?.() ?? "") : "";
784
+ const reactView = (field) => base.extend({
785
+ create({ property }) {
786
+ const el = document.createElement("div");
787
+ el.addEventListener("change", (event) => event.stopPropagation());
788
+ const root = createRoot(el);
789
+ this.__root = root;
790
+ this.__render = () => root.render(field(property, ownValue(property)));
791
+ this.__render();
792
+ return el;
793
+ },
794
+ update() {
795
+ this.__render?.();
796
+ },
797
+ destroy() {
798
+ const root = this.__root;
799
+ this.__root = void 0;
800
+ this.__render = void 0;
801
+ setTimeout(() => root?.unmount());
802
+ }
803
+ });
804
+ sm.addType("color", {
805
+ view: reactView((property, value) => /* @__PURE__ */ jsx5(
806
+ ColorField,
807
+ {
808
+ value,
809
+ placeholder: String(property.getDefaultValue?.() ?? ""),
810
+ documentColors,
811
+ labels,
812
+ onChange: (next, partial) => property.upValue(next, { partial })
813
+ }
814
+ ))
815
+ });
816
+ sm.addType("file", {
817
+ view: reactView((property, value) => /* @__PURE__ */ jsx5(
818
+ ImageField,
819
+ {
820
+ value,
821
+ labels,
822
+ onClear: () => property.upValue(""),
823
+ onChoose: () => {
824
+ const am = editor.AssetManager;
825
+ am.open({
826
+ types: ["image"],
827
+ current: imageUrl(value) ?? void 0,
828
+ select(asset, complete) {
829
+ const url = typeof asset === "string" ? asset : asset.getSrc();
830
+ property.upValue(url, { partial: !complete });
831
+ if (complete) am.close();
832
+ }
833
+ });
834
+ }
835
+ }
836
+ ))
837
+ });
838
+ }
149
839
  function setupRichText(editor, labels) {
150
840
  const rte = editor.RichTextEditor;
151
841
  if (!rte || rte.__customActions) return;
@@ -185,16 +875,25 @@ function setupRichText(editor, labels) {
185
875
  function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
186
876
  const documents = useDocumentStore();
187
877
  const labels = useLabels();
188
- const loadingRef = useRef(false);
189
- const saveTimer = useRef(null);
190
- const saveStatusRef = useRef("idle");
191
- const stateListeners = useRef(/* @__PURE__ */ new Set());
192
- const [loading, setLoading] = useState(true);
193
- const [sidebarView, setSidebarView] = useState("blocks");
194
- const [composeTarget, setComposeTarget] = useState(null);
195
- const selectionSource = useRef("other");
196
- const editorRef = useRef(null);
197
- const [crumbs, setCrumbs] = useState([]);
878
+ const loadingRef = useRef3(false);
879
+ const saveStatusRef = useRef3("idle");
880
+ const stateListeners = useRef3(/* @__PURE__ */ new Set());
881
+ const [loading, setLoading] = useState3(true);
882
+ const [sidebarView, setSidebarView] = useState3("blocks");
883
+ const [composeTarget, setComposeTarget] = useState3(null);
884
+ const selectionSource = useRef3("other");
885
+ const editorRef = useRef3(null);
886
+ const { running: turnRunning, locked: readOnly, setLocked } = useAgentTurn();
887
+ const readOnlyRef = useRef3(false);
888
+ const applyReadOnly = useRef3(async () => {
889
+ });
890
+ useEffect3(() => void applyReadOnly.current(turnRunning), [turnRunning]);
891
+ useEffect3(() => () => setLocked(false), [setLocked]);
892
+ const [crumbs, setCrumbs] = useState3([]);
893
+ const [selectedImageSrc, setSelectedImageSrc] = useState3(null);
894
+ const [picker, setPicker] = useState3(
895
+ null
896
+ );
198
897
  const onEditor = async (editor) => {
199
898
  editorRef.current = editor;
200
899
  const buildCrumbs = (c) => {
@@ -221,11 +920,12 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
221
920
  };
222
921
  const snapshot = () => ({
223
922
  device: editor.getDevice(),
224
- canUndo: editor.UndoManager.hasUndo(),
225
- canRedo: editor.UndoManager.hasRedo(),
923
+ canUndo: !readOnlyRef.current && editor.UndoManager.hasUndo(),
924
+ canRedo: !readOnlyRef.current && editor.UndoManager.hasRedo(),
226
925
  saveStatus: saveStatusRef.current,
227
926
  zoom: Math.round(editor.Canvas.getZoom?.() ?? 100),
228
- contentWidth: bodyWidth()
927
+ contentWidth: bodyWidth(),
928
+ readOnly: readOnlyRef.current
229
929
  });
230
930
  const notifyState = () => {
231
931
  const s = snapshot();
@@ -249,6 +949,14 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
249
949
  if (isSection(c)) addIdClass(c, "sec");
250
950
  if (!isCommentable(c)) return;
251
951
  const toolbar = [...c.get("toolbar") ?? []];
952
+ if (tagOf(c) === "mj-image" && !toolbar.some((t) => t.command === "open-image-picker")) {
953
+ toolbar.push({
954
+ command: "open-image-picker",
955
+ label: IMAGE_SVG,
956
+ attributes: { title: labels.chooseImage }
957
+ });
958
+ c.set("toolbar", toolbar);
959
+ }
252
960
  if (!toolbar.some((t) => t.command === "open-comments")) {
253
961
  toolbar.push({
254
962
  command: "open-comments",
@@ -258,6 +966,21 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
258
966
  c.set("toolbar", toolbar);
259
967
  }
260
968
  };
969
+ editor.Commands.add("open-image-picker", {
970
+ run(ed) {
971
+ const target = ed.getSelected();
972
+ if (!target || tagOf(target) !== "mj-image") return;
973
+ const am = ed.AssetManager;
974
+ am.open({
975
+ target,
976
+ types: ["image"],
977
+ select(asset, complete) {
978
+ target.set({ src: asset.getSrc() });
979
+ if (complete) am.close();
980
+ }
981
+ });
982
+ }
983
+ });
261
984
  editor.Commands.add("open-comments", {
262
985
  run(ed) {
263
986
  const target = targetOf(ed.getSelected() ?? void 0);
@@ -265,6 +988,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
265
988
  }
266
989
  });
267
990
  setupRichText(editor, labels);
991
+ setupStyleFields(editor, labels);
268
992
  const blockCategory = (label) => {
269
993
  const l = label.toLowerCase();
270
994
  if (/(column|section)/.test(l)) return "Layout";
@@ -293,6 +1017,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
293
1017
  if (selectionSource.current === "canvas") setSidebarView("settings");
294
1018
  selectionSource.current = "other";
295
1019
  buildCrumbs(c);
1020
+ setSelectedImageSrc(c && tagOf(c) === "mj-image" ? String(c.get("src") ?? "") : null);
296
1021
  if (c?.get("type") === "mj-image" && !c.getTrait("src")) {
297
1022
  c.addTrait(
298
1023
  {
@@ -306,40 +1031,99 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
306
1031
  );
307
1032
  }
308
1033
  });
309
- editor.on("component:deselected", () => setCrumbs([]));
1034
+ editor.on("component:deselected", () => {
1035
+ const rest = editor.getSelected();
1036
+ buildCrumbs(rest);
1037
+ setSelectedImageSrc(
1038
+ rest && tagOf(rest) === "mj-image" ? String(rest.get("src") ?? "") : null
1039
+ );
1040
+ });
1041
+ editor.on("component:update:src", (c) => {
1042
+ if (c === editor.getSelected()) setSelectedImageSrc(String(c.get("src") ?? ""));
1043
+ });
1044
+ let stored = "";
310
1045
  const save = async () => {
311
- if (loadingRef.current) return;
1046
+ const view = editor.getEditing()?.getView();
1047
+ await view?.syncContent?.();
1048
+ const mjml = editor.getHtml();
1049
+ if (mjml === stored) return;
312
1050
  setSave("saving");
313
1051
  try {
314
- await documents.save(docId, {
315
- mjml: editor.getHtml(),
316
- projectData: editor.getProjectData()
317
- });
1052
+ await documents.save(docId, { mjml, projectData: editor.getProjectData() });
1053
+ stored = mjml;
318
1054
  setSave("saved");
319
1055
  } catch (e) {
320
1056
  setSave("error");
321
- toast.error(labels.documentSaveFailed);
1057
+ toast2.error(refusal(e) ?? labels.documentSaveFailed, { id: saveToastId(docId) });
322
1058
  throw e;
323
1059
  }
324
1060
  };
1061
+ const queue = createSaveQueue(save, 1200);
325
1062
  editor.on("update", () => {
326
1063
  notifyState();
327
- if (loadingRef.current) return;
328
- if (saveTimer.current) clearTimeout(saveTimer.current);
329
- saveTimer.current = setTimeout(() => void save().catch(console.error), 1200);
1064
+ if (!loadingRef.current && !readOnlyRef.current) queue.schedule();
330
1065
  });
331
1066
  editor.on("change:device", notifyState);
1067
+ const blocked = [
1068
+ "core:undo",
1069
+ "core:redo",
1070
+ "core:paste",
1071
+ "core:component-delete",
1072
+ "tlb-delete",
1073
+ "tlb-clone",
1074
+ "tlb-move",
1075
+ "open-assets",
1076
+ "open-image-picker"
1077
+ ];
1078
+ for (const id of blocked) {
1079
+ editor.on(`command:run:before:${id}`, ({ options }) => {
1080
+ if (readOnlyRef.current) options.abort = true;
1081
+ });
1082
+ }
1083
+ const lock = (on) => {
1084
+ readOnlyRef.current = on;
1085
+ setLocked(on);
1086
+ notifyState();
1087
+ };
1088
+ applyReadOnly.current = async (on) => {
1089
+ if (on) {
1090
+ lock(true);
1091
+ queue.cancel();
1092
+ editor.select([]);
1093
+ editor.AssetManager.close();
1094
+ setPicker(null);
1095
+ return;
1096
+ }
1097
+ try {
1098
+ await reload();
1099
+ } catch (e) {
1100
+ console.error(e);
1101
+ toast2.error(labels.documentLoadFailed, {
1102
+ id: `reload-${docId}`,
1103
+ duration: Infinity,
1104
+ action: { label: labels.retry, onClick: () => void applyReadOnly.current(false) }
1105
+ });
1106
+ return;
1107
+ }
1108
+ lock(false);
1109
+ };
1110
+ const reload = () => queue.run(async () => {
1111
+ const fresh = await documents.get(docId);
1112
+ if (fresh.mjml !== stored) loadMjml(fresh.mjml);
1113
+ });
332
1114
  const loadMjml = (mjml) => {
333
1115
  loadingRef.current = true;
334
- if (saveTimer.current) clearTimeout(saveTimer.current);
335
- editor.setComponents(mjml || STARTER_MJML);
336
- editor.getWrapper()?.find("mj-section").forEach(decorate);
337
- for (const t of Object.keys(TYPE_LABEL)) {
338
- editor.getWrapper()?.find(t).forEach(decorate);
339
- }
340
- setTimeout(() => {
1116
+ try {
1117
+ queue.cancel();
1118
+ stored = mjml;
1119
+ editor.setComponents(mjml || STARTER_MJML);
1120
+ editor.getWrapper()?.find("mj-section").forEach(decorate);
1121
+ for (const t of Object.keys(TYPE_LABEL)) {
1122
+ editor.getWrapper()?.find(t).forEach(decorate);
1123
+ }
1124
+ } finally {
341
1125
  loadingRef.current = false;
342
- }, 400);
1126
+ }
343
1127
  };
344
1128
  const ensureHighlightStyles = () => {
345
1129
  const cdoc = editor.Canvas.getDocument();
@@ -376,7 +1160,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
376
1160
  const doc = await documents.get(docId);
377
1161
  loadMjml(doc.mjml);
378
1162
  } catch (e) {
379
- toast.error(labels.documentLoadFailed);
1163
+ toast2.error(labels.documentLoadFailed);
380
1164
  console.error(e);
381
1165
  } finally {
382
1166
  setLoading(false);
@@ -384,14 +1168,8 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
384
1168
  onReady({
385
1169
  getMjml: () => editor.getHtml(),
386
1170
  getCompiledHtml: compiledHtml,
387
- flushSave: async () => {
388
- if (saveTimer.current) clearTimeout(saveTimer.current);
389
- await save();
390
- },
391
- reloadFromDb: async () => {
392
- const fresh = await documents.get(docId);
393
- if (fresh.mjml !== editor.getHtml()) loadMjml(fresh.mjml);
394
- },
1171
+ flushSave: () => queue.flush(),
1172
+ reloadFromDb: reload,
395
1173
  highlightSection: (sectionId, on) => {
396
1174
  ensureHighlightStyles();
397
1175
  const el = sectionEl(sectionId);
@@ -407,10 +1185,12 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
407
1185
  notifyState();
408
1186
  },
409
1187
  undo: () => {
1188
+ if (readOnlyRef.current) return;
410
1189
  editor.UndoManager.undo();
411
1190
  notifyState();
412
1191
  },
413
1192
  redo: () => {
1193
+ if (readOnlyRef.current) return;
414
1194
  editor.UndoManager.redo();
415
1195
  notifyState();
416
1196
  },
@@ -419,6 +1199,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
419
1199
  notifyState();
420
1200
  },
421
1201
  setContentWidth: (w) => {
1202
+ if (readOnlyRef.current) return;
422
1203
  findByTag("mj-body")?.addAttributes({ width: w });
423
1204
  notifyState();
424
1205
  },
@@ -429,7 +1210,7 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
429
1210
  }
430
1211
  });
431
1212
  };
432
- return /* @__PURE__ */ jsx2(
1213
+ return /* @__PURE__ */ jsx5(
433
1214
  GjsEditor,
434
1215
  {
435
1216
  grapesjs,
@@ -443,6 +1224,19 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
443
1224
  styleManager: { appendTo: "#gjs-styles" },
444
1225
  traitManager: { appendTo: "#gjs-traits" },
445
1226
  layerManager: { appendTo: "#gjs-layers" },
1227
+ assetManager: {
1228
+ // Never inline files: mail clients block `data:` images (see ImageLibrary).
1229
+ embedAsBase64: false,
1230
+ // Our own dialog instead of GrapesJS's modal. GrapesJS still decides when it opens
1231
+ // and what a choice applies to; `select(url, true)` sets the image and closes.
1232
+ custom: {
1233
+ open: (props) => setPicker({
1234
+ current: props.options?.current ?? (String(props.options?.target?.get("src") ?? "") || void 0),
1235
+ select: (url) => props.select(url, true)
1236
+ }),
1237
+ close: () => setPicker(null)
1238
+ }
1239
+ },
446
1240
  deviceManager: {
447
1241
  devices: [
448
1242
  { id: "desktop", name: "Desktop", width: "" },
@@ -452,19 +1246,26 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
452
1246
  plugins: [grapesjsMJML]
453
1247
  },
454
1248
  onEditor,
455
- children: /* @__PURE__ */ jsxs("div", { className: "flex h-full min-h-0 flex-1", children: [
456
- /* @__PURE__ */ jsx2(
1249
+ children: /* @__PURE__ */ jsxs4("div", { className: "relative flex h-full min-h-0 flex-1", inert: readOnly, children: [
1250
+ readOnly && /* @__PURE__ */ jsx5("div", { className: "absolute inset-0 z-30 flex cursor-not-allowed items-start justify-center bg-white/20 pt-3", children: /* @__PURE__ */ jsxs4("span", { className: "flex items-center gap-2 rounded-full bg-panel px-3 py-1 text-xs text-panel-fg shadow", children: [
1251
+ /* @__PURE__ */ jsx5(Spinner, {}),
1252
+ " ",
1253
+ labels.agentWorking
1254
+ ] }) }),
1255
+ /* @__PURE__ */ jsx5(
457
1256
  LeftSidebar,
458
1257
  {
459
1258
  view: sidebarView,
460
1259
  onViewChange: setSidebarView,
461
- markLayersSource: () => selectionSource.current = "layers"
1260
+ markLayersSource: () => selectionSource.current = "layers",
1261
+ selectedImageSrc,
1262
+ onChooseImage: () => editorRef.current?.runCommand("open-image-picker")
462
1263
  }
463
1264
  ),
464
- /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-1 flex-col bg-surface-muted", children: [
465
- /* @__PURE__ */ jsxs("div", { className: "relative min-h-0 flex-1", children: [
466
- /* @__PURE__ */ jsx2(Canvas, { className: "h-full" }),
467
- /* @__PURE__ */ jsx2(
1265
+ /* @__PURE__ */ jsxs4("div", { className: "flex min-w-0 flex-1 flex-col bg-surface-muted", children: [
1266
+ /* @__PURE__ */ jsxs4("div", { className: "relative min-h-0 flex-1", children: [
1267
+ /* @__PURE__ */ jsx5(Canvas, { className: "h-full" }),
1268
+ /* @__PURE__ */ jsx5(
468
1269
  CanvasComments,
469
1270
  {
470
1271
  docId,
@@ -474,12 +1275,27 @@ function EmailEditor({ docId, onReady, commentsRefresh, onOpenCountChange }) {
474
1275
  onOpenCountChange
475
1276
  }
476
1277
  ),
477
- loading && /* @__PURE__ */ jsxs("div", { className: "absolute inset-0 z-10 flex items-center justify-center gap-2 bg-white/70 text-sm text-zinc-500", children: [
478
- /* @__PURE__ */ jsx2(Spinner, {}),
1278
+ /* @__PURE__ */ jsx5(
1279
+ ImagePicker,
1280
+ {
1281
+ open: picker !== null,
1282
+ currentUrl: picker?.current,
1283
+ onSelect: (url) => {
1284
+ if (!readOnlyRef.current) picker?.select(url);
1285
+ setPicker(null);
1286
+ },
1287
+ onClose: () => {
1288
+ setPicker(null);
1289
+ editorRef.current?.AssetManager.close();
1290
+ }
1291
+ }
1292
+ ),
1293
+ loading && /* @__PURE__ */ jsxs4("div", { className: "absolute inset-0 z-10 flex items-center justify-center gap-2 bg-white/70 text-sm text-zinc-500", children: [
1294
+ /* @__PURE__ */ jsx5(Spinner, {}),
479
1295
  " Loading editor\u2026"
480
1296
  ] })
481
1297
  ] }),
482
- /* @__PURE__ */ jsx2(Breadcrumb, { crumbs, onSelect: (c) => editorRef.current?.select(c) })
1298
+ /* @__PURE__ */ jsx5(Breadcrumb, { crumbs, onSelect: (c) => editorRef.current?.select(c) })
483
1299
  ] })
484
1300
  ] })
485
1301
  }
@@ -490,32 +1306,34 @@ function CollapseSection({
490
1306
  defaultOpen = true,
491
1307
  children
492
1308
  }) {
493
- const [open, setOpen] = useState(defaultOpen);
494
- return /* @__PURE__ */ jsxs("div", { className: "border-b border-panel-border", children: [
495
- /* @__PURE__ */ jsxs(
1309
+ const [open, setOpen] = useState3(defaultOpen);
1310
+ return /* @__PURE__ */ jsxs4("div", { className: "border-b border-panel-border", children: [
1311
+ /* @__PURE__ */ jsxs4(
496
1312
  "button",
497
1313
  {
498
1314
  type: "button",
499
1315
  onClick: () => setOpen((o) => !o),
500
1316
  className: "flex w-full items-center gap-1.5 px-3 py-2 text-xs font-semibold tracking-wide text-panel-muted-fg uppercase hover:text-panel-fg",
501
1317
  children: [
502
- /* @__PURE__ */ jsx2(ChevronRight, { className: cn("size-3.5 transition-transform", open && "rotate-90") }),
1318
+ /* @__PURE__ */ jsx5(ChevronRight, { className: cn("size-3.5 transition-transform", open && "rotate-90") }),
503
1319
  title
504
1320
  ]
505
1321
  }
506
1322
  ),
507
- /* @__PURE__ */ jsx2("div", { className: cn(!open && "hidden"), children })
1323
+ /* @__PURE__ */ jsx5("div", { className: cn(!open && "hidden"), children })
508
1324
  ] });
509
1325
  }
510
1326
  function LeftSidebar({
511
1327
  view,
512
1328
  onViewChange,
513
- markLayersSource
1329
+ markLayersSource,
1330
+ selectedImageSrc,
1331
+ onChooseImage
514
1332
  }) {
515
1333
  const editor = useEditorMaybe();
516
1334
  const labels = useLabels();
517
- const [layersExpanded, setLayersExpanded] = useState(false);
518
- const [blockSearch, setBlockSearch] = useState("");
1335
+ const [layersExpanded, setLayersExpanded] = useState3(false);
1336
+ const [blockSearch, setBlockSearch] = useState3("");
519
1337
  const changeView = (v) => {
520
1338
  if (v === "settings" && editor && !editor.getSelected()) {
521
1339
  const wrapper = editor.getWrapper();
@@ -544,12 +1362,12 @@ function LeftSidebar({
544
1362
  cat.style.display = anyVisible ? "" : "none";
545
1363
  });
546
1364
  };
547
- return /* @__PURE__ */ jsxs("div", { className: "editor-dark flex w-64 shrink-0 flex-col border-r border-panel-border bg-panel text-panel-fg", children: [
548
- /* @__PURE__ */ jsx2("div", { className: "flex border-b border-panel-border", children: [
1365
+ return /* @__PURE__ */ jsxs4("div", { className: "editor-dark flex w-64 shrink-0 flex-col border-r border-panel-border bg-panel text-panel-fg", children: [
1366
+ /* @__PURE__ */ jsx5("div", { className: "flex border-b border-panel-border", children: [
549
1367
  ["blocks", "Blocks"],
550
1368
  ["settings", "Settings"],
551
1369
  ["layers", "Layers"]
552
- ].map(([value, label]) => /* @__PURE__ */ jsx2(
1370
+ ].map(([value, label]) => /* @__PURE__ */ jsx5(
553
1371
  "button",
554
1372
  {
555
1373
  type: "button",
@@ -563,10 +1381,10 @@ function LeftSidebar({
563
1381
  },
564
1382
  value
565
1383
  )) }),
566
- /* @__PURE__ */ jsxs("div", { className: cn("flex min-h-0 flex-1 flex-col", view !== "blocks" && "hidden"), children: [
567
- /* @__PURE__ */ jsxs("div", { className: "relative border-b border-panel-border p-2", children: [
568
- /* @__PURE__ */ jsx2(Search, { className: "pointer-events-none absolute top-1/2 left-4 size-3.5 -translate-y-1/2 text-panel-muted-fg" }),
569
- /* @__PURE__ */ jsx2(
1384
+ /* @__PURE__ */ jsxs4("div", { className: cn("flex min-h-0 flex-1 flex-col", view !== "blocks" && "hidden"), children: [
1385
+ /* @__PURE__ */ jsxs4("div", { className: "relative border-b border-panel-border p-2", children: [
1386
+ /* @__PURE__ */ jsx5(Search, { className: "pointer-events-none absolute top-1/2 left-4 size-3.5 -translate-y-1/2 text-panel-muted-fg" }),
1387
+ /* @__PURE__ */ jsx5(
570
1388
  Input,
571
1389
  {
572
1390
  value: blockSearch,
@@ -575,23 +1393,58 @@ function LeftSidebar({
575
1393
  className: "h-8 border-panel-border bg-panel-elevated pr-10 pl-7 text-sm text-panel-fg placeholder:text-panel-muted-fg"
576
1394
  }
577
1395
  ),
578
- /* @__PURE__ */ jsx2("kbd", { className: "pointer-events-none absolute top-1/2 right-4 -translate-y-1/2 rounded border border-panel-border bg-panel px-1 py-0.5 font-mono text-[10px] text-panel-muted-fg", children: "\u2318K" })
1396
+ /* @__PURE__ */ jsx5("kbd", { className: "pointer-events-none absolute top-1/2 right-4 -translate-y-1/2 rounded border border-panel-border bg-panel px-1 py-0.5 font-mono text-[10px] text-panel-muted-fg", children: "\u2318K" })
579
1397
  ] }),
580
- /* @__PURE__ */ jsx2("div", { id: "gjs-blocks", className: "min-h-0 flex-1 overflow-y-auto" })
1398
+ /* @__PURE__ */ jsx5("div", { id: "gjs-blocks", className: "min-h-0 flex-1 overflow-y-auto" })
581
1399
  ] }),
582
- /* @__PURE__ */ jsxs("div", { className: cn("min-h-0 flex-1 overflow-y-auto", view !== "settings" && "hidden"), children: [
583
- /* @__PURE__ */ jsx2(CollapseSection, { title: labels.attributes, children: /* @__PURE__ */ jsx2("div", { id: "gjs-traits" }) }),
584
- /* @__PURE__ */ jsx2(CollapseSection, { title: labels.style, children: /* @__PURE__ */ jsx2("div", { id: "gjs-styles" }) })
1400
+ /* @__PURE__ */ jsxs4("div", { className: cn("min-h-0 flex-1 overflow-y-auto", view !== "settings" && "hidden"), children: [
1401
+ selectedImageSrc !== null && /* @__PURE__ */ jsxs4("div", { className: "border-b border-panel-border p-3", children: [
1402
+ /* @__PURE__ */ jsxs4(
1403
+ "button",
1404
+ {
1405
+ type: "button",
1406
+ onClick: onChooseImage,
1407
+ title: labels.chooseImage,
1408
+ className: "group relative block w-full overflow-hidden rounded-lg border border-panel-border bg-panel-elevated",
1409
+ children: [
1410
+ selectedImageSrc ? /* @__PURE__ */ jsx5(
1411
+ "img",
1412
+ {
1413
+ src: selectedImageSrc,
1414
+ alt: "",
1415
+ className: "h-32 w-full bg-black/25 object-contain"
1416
+ }
1417
+ ) : /* @__PURE__ */ jsx5("span", { className: "flex h-32 items-center justify-center text-panel-muted-fg", children: /* @__PURE__ */ jsx5(ImageIcon2, { className: "size-8" }) }),
1418
+ /* @__PURE__ */ jsx5("span", { className: "absolute inset-0 flex items-center justify-center bg-black/50 text-sm font-medium text-white opacity-0 transition group-hover:opacity-100", children: labels.chooseImage })
1419
+ ]
1420
+ }
1421
+ ),
1422
+ /* @__PURE__ */ jsxs4(
1423
+ Button,
1424
+ {
1425
+ type: "button",
1426
+ size: "sm",
1427
+ className: "mt-2 w-full bg-brand text-brand-fg hover:bg-brand/90",
1428
+ onClick: onChooseImage,
1429
+ children: [
1430
+ /* @__PURE__ */ jsx5(ImageUp2, {}),
1431
+ labels.chooseImage
1432
+ ]
1433
+ }
1434
+ )
1435
+ ] }),
1436
+ /* @__PURE__ */ jsx5(CollapseSection, { title: labels.attributes, children: /* @__PURE__ */ jsx5("div", { id: "gjs-traits" }) }),
1437
+ /* @__PURE__ */ jsx5(CollapseSection, { title: labels.style, children: /* @__PURE__ */ jsx5("div", { id: "gjs-styles" }) })
585
1438
  ] }),
586
- /* @__PURE__ */ jsxs(
1439
+ /* @__PURE__ */ jsxs4(
587
1440
  "div",
588
1441
  {
589
1442
  className: cn("flex min-h-0 flex-1 flex-col", view !== "layers" && "hidden"),
590
1443
  onMouseDownCapture: markLayersSource,
591
1444
  children: [
592
- /* @__PURE__ */ jsxs("label", { className: "flex items-center justify-between gap-2 border-b border-panel-border px-3 py-2 text-xs text-panel-muted-fg", children: [
593
- /* @__PURE__ */ jsx2("span", { children: labels.expandAll }),
594
- /* @__PURE__ */ jsx2(
1445
+ /* @__PURE__ */ jsxs4("label", { className: "flex items-center justify-between gap-2 border-b border-panel-border px-3 py-2 text-xs text-panel-muted-fg", children: [
1446
+ /* @__PURE__ */ jsx5("span", { children: labels.expandAll }),
1447
+ /* @__PURE__ */ jsx5(
595
1448
  Switch,
596
1449
  {
597
1450
  checked: layersExpanded,
@@ -600,7 +1453,7 @@ function LeftSidebar({
600
1453
  }
601
1454
  )
602
1455
  ] }),
603
- /* @__PURE__ */ jsx2("div", { id: "gjs-layers", className: "min-h-0 flex-1 overflow-y-auto" })
1456
+ /* @__PURE__ */ jsx5("div", { id: "gjs-layers", className: "min-h-0 flex-1 overflow-y-auto" })
604
1457
  ]
605
1458
  }
606
1459
  )
@@ -611,9 +1464,9 @@ function Breadcrumb({
611
1464
  onSelect
612
1465
  }) {
613
1466
  const labels = useLabels();
614
- return /* @__PURE__ */ jsx2("div", { className: "flex h-9 shrink-0 items-center gap-0.5 border-t border-border bg-surface px-3 text-xs text-muted-foreground", children: crumbs.length === 0 ? /* @__PURE__ */ jsx2("span", { className: "text-muted-foreground/60", children: labels.selectAnElement }) : crumbs.map((c, i) => /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-0.5", children: [
615
- i > 0 && /* @__PURE__ */ jsx2(ChevronRight, { className: "size-3 opacity-40" }),
616
- /* @__PURE__ */ jsx2(
1467
+ return /* @__PURE__ */ jsx5("div", { className: "flex h-9 shrink-0 items-center gap-0.5 border-t border-border bg-surface px-3 text-xs text-muted-foreground", children: crumbs.length === 0 ? /* @__PURE__ */ jsx5("span", { className: "text-muted-foreground/60", children: labels.selectAnElement }) : crumbs.map((c, i) => /* @__PURE__ */ jsxs4("span", { className: "flex items-center gap-0.5", children: [
1468
+ i > 0 && /* @__PURE__ */ jsx5(ChevronRight, { className: "size-3 opacity-40" }),
1469
+ /* @__PURE__ */ jsx5(
617
1470
  "button",
618
1471
  {
619
1472
  type: "button",