@denarolabs/email-template 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/components/ai-email-editor/AIEmailEditor.d.ts +3 -0
  2. package/dist/components/ai-email-editor/AIEmailEditor.js +207 -0
  3. package/dist/components/ai-email-editor/chat-message.d.ts +36 -0
  4. package/dist/components/ai-email-editor/chat-message.js +49 -0
  5. package/dist/components/ai-email-editor/images.d.ts +2 -0
  6. package/dist/components/ai-email-editor/images.js +14 -0
  7. package/dist/components/ai-email-editor/model-picker.d.ts +7 -0
  8. package/dist/components/ai-email-editor/model-picker.js +9 -0
  9. package/dist/components/ai-email-editor/preview.d.ts +35 -0
  10. package/dist/components/ai-email-editor/preview.js +728 -0
  11. package/dist/components/ai-email-editor/send-test-email-modal.d.ts +13 -0
  12. package/dist/components/ai-email-editor/send-test-email-modal.js +70 -0
  13. package/dist/components/ai-email-editor/stream.d.ts +20 -0
  14. package/dist/components/ai-email-editor/stream.js +57 -0
  15. package/dist/components/ai-email-editor/use-ai-email-editor.d.ts +117 -0
  16. package/dist/components/ai-email-editor/use-ai-email-editor.js +1308 -0
  17. package/dist/components/ai-email-editor/view-html-modal.d.ts +9 -0
  18. package/dist/components/ai-email-editor/view-html-modal.js +37 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.js +7 -0
  21. package/dist/lib/ai-stream-contract.d.ts +99 -0
  22. package/dist/lib/ai-stream-contract.js +35 -0
  23. package/dist/lib/build-default-system-prompt.d.ts +2 -0
  24. package/dist/lib/build-default-system-prompt.js +37 -0
  25. package/dist/lib/capture-email-preview.d.ts +5 -0
  26. package/dist/lib/capture-email-preview.js +73 -0
  27. package/dist/lib/cn.d.ts +2 -0
  28. package/dist/lib/cn.js +5 -0
  29. package/dist/lib/merge-tag-validation.d.ts +3 -0
  30. package/dist/lib/merge-tag-validation.js +45 -0
  31. package/dist/lib/rasterize-image-client.d.ts +4 -0
  32. package/dist/lib/rasterize-image-client.js +47 -0
  33. package/dist/lib/strip-html-code-fences.d.ts +1 -0
  34. package/dist/lib/strip-html-code-fences.js +6 -0
  35. package/dist/schemas/aiEmail.d.ts +224 -0
  36. package/dist/schemas/aiEmail.js +29 -0
  37. package/dist/schemas/aiEmailResponse.d.ts +15 -0
  38. package/dist/schemas/aiEmailResponse.js +15 -0
  39. package/dist/types.d.ts +57 -0
  40. package/dist/types.js +1 -0
  41. package/dist/ui/button.d.ts +11 -0
  42. package/dist/ui/button.js +34 -0
  43. package/dist/ui/dialog.d.ts +33 -0
  44. package/dist/ui/dialog.js +39 -0
  45. package/dist/ui/dropdown-menu.d.ts +8 -0
  46. package/dist/ui/dropdown-menu.js +23 -0
  47. package/dist/ui/input.d.ts +2 -0
  48. package/dist/ui/input.js +6 -0
  49. package/dist/ui/textarea.d.ts +2 -0
  50. package/dist/ui/textarea.js +7 -0
  51. package/package.json +74 -0
  52. package/src/styles.css +197 -0
@@ -0,0 +1,728 @@
1
+ export const AI_IMAGE_HANDLE_ID = "ai-email-image-handle";
2
+ /** Visible size of the corner resize control (must match CSS). */
3
+ export const AI_IMAGE_HANDLE_SIZE = 24;
4
+ const MIN_WIDTH = 96;
5
+ const MAX_WIDTH = 640;
6
+ const DRAG_THRESHOLD = 8;
7
+ const TEXT_HOST_SELECTOR = "[data-edit-id],p,td,th,li,blockquote";
8
+ export const AI_IMAGE_EDITOR_STYLES = `
9
+ img[data-ai-image] { cursor: grab; touch-action: none; }
10
+ img[data-ai-image].ai-img-selected { cursor: grab; }
11
+ img[data-ai-image].ai-img-dragging { cursor: grabbing; opacity: 0.25; }
12
+ img[data-ai-image].ai-img-selected {
13
+ outline: 2px solid rgb(6, 182, 212);
14
+ outline-offset: 2px;
15
+ }
16
+ .ai-img-drag-ghost {
17
+ position: fixed;
18
+ margin: 0;
19
+ padding: 0;
20
+ pointer-events: none;
21
+ z-index: 9998;
22
+ opacity: 0.92;
23
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.22);
24
+ touch-action: none;
25
+ }
26
+ #${AI_IMAGE_HANDLE_ID} {
27
+ position: fixed;
28
+ width: ${AI_IMAGE_HANDLE_SIZE}px;
29
+ height: ${AI_IMAGE_HANDLE_SIZE}px;
30
+ margin: 0;
31
+ padding: 0;
32
+ background-color: #fff;
33
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M12.5 3.5L3.5 12.5' stroke='%2306b6d4' stroke-width='1.75' stroke-linecap='round'/%3E%3Cpath d='M12.5 3.5H9' stroke='%2306b6d4' stroke-width='1.75' stroke-linecap='round'/%3E%3Cpath d='M12.5 3.5V7' stroke='%2306b6d4' stroke-width='1.75' stroke-linecap='round'/%3E%3Cpath d='M3.5 12.5H7' stroke='%2306b6d4' stroke-width='1.75' stroke-linecap='round'/%3E%3Cpath d='M3.5 12.5V9' stroke='%2306b6d4' stroke-width='1.75' stroke-linecap='round'/%3E%3C/svg%3E");
34
+ background-repeat: no-repeat;
35
+ background-position: center;
36
+ background-size: 16px 16px;
37
+ border: 2px solid rgb(6, 182, 212);
38
+ border-radius: 4px;
39
+ cursor: nwse-resize;
40
+ z-index: 10000;
41
+ touch-action: none;
42
+ box-shadow: 0 2px 8px rgba(15, 23, 42, 0.22);
43
+ }
44
+ #${AI_IMAGE_HANDLE_ID}:hover {
45
+ background-color: rgb(6, 182, 212);
46
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M12.5 3.5L3.5 12.5' stroke='%23ffffff' stroke-width='1.75' stroke-linecap='round'/%3E%3Cpath d='M12.5 3.5H9' stroke='%23ffffff' stroke-width='1.75' stroke-linecap='round'/%3E%3Cpath d='M12.5 3.5V7' stroke='%23ffffff' stroke-width='1.75' stroke-linecap='round'/%3E%3Cpath d='M3.5 12.5H7' stroke='%23ffffff' stroke-width='1.75' stroke-linecap='round'/%3E%3Cpath d='M3.5 12.5V9' stroke='%23ffffff' stroke-width='1.75' stroke-linecap='round'/%3E%3C/svg%3E");
47
+ }
48
+ `;
49
+ /** Tag images and ensure they can be resized in the preview. */
50
+ export function prepareImagesForEditing(doc) {
51
+ for (const img of doc.querySelectorAll("img")) {
52
+ img.setAttribute("data-ai-image", "true");
53
+ if (!img.getAttribute("alt"))
54
+ img.setAttribute("alt", "");
55
+ img.addEventListener("error", () => {
56
+ img.remove();
57
+ }, { once: true });
58
+ }
59
+ }
60
+ const PLACEHOLDER_IMAGE_SRC = /placeholder|placehold\.co|dummyimage|via\.placeholder|example\.com|yourdomain|your-logo|logo-placeholder|placeimg|fakeimg|lorempixel|picsum\.photos/i;
61
+ function isPlaceholderImage(img) {
62
+ const src = (img.getAttribute("src") ?? "").trim();
63
+ if (!src || src === "#")
64
+ return true;
65
+ if (PLACEHOLDER_IMAGE_SRC.test(src))
66
+ return true;
67
+ const alt = (img.getAttribute("alt") ?? "").toLowerCase();
68
+ if (alt.includes("logo") &&
69
+ (!src.startsWith("http") || PLACEHOLDER_IMAGE_SRC.test(src) || src.includes("example"))) {
70
+ return true;
71
+ }
72
+ return false;
73
+ }
74
+ function stripPlaceholderImages(doc) {
75
+ for (const img of [...doc.querySelectorAll("img")]) {
76
+ if (isPlaceholderImage(img)) {
77
+ img.remove();
78
+ }
79
+ }
80
+ }
81
+ export function sanitizeEmailHtml(html) {
82
+ const parser = new DOMParser();
83
+ const doc = parser.parseFromString(html, "text/html");
84
+ stripPlaceholderImages(doc);
85
+ return serializeHtmlDocument(doc);
86
+ }
87
+ export function stripImageEditorChrome(doc) {
88
+ doc.getElementById(AI_IMAGE_HANDLE_ID)?.remove();
89
+ doc.querySelectorAll(".ai-img-drag-ghost").forEach((node) => node.remove());
90
+ for (const img of doc.querySelectorAll("img[data-ai-image]")) {
91
+ img.classList.remove("ai-img-selected", "ai-img-dragging");
92
+ img.removeAttribute("data-ai-image");
93
+ }
94
+ }
95
+ function parseWidth(img) {
96
+ const inline = img.style.width;
97
+ if (inline.endsWith("px"))
98
+ return Number.parseFloat(inline);
99
+ const attr = img.getAttribute("width");
100
+ if (attr)
101
+ return Number.parseFloat(attr);
102
+ return img.getBoundingClientRect().width || 280;
103
+ }
104
+ function clampWidth(width) {
105
+ return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.round(width)));
106
+ }
107
+ function applyWidth(img, width) {
108
+ const next = clampWidth(width);
109
+ img.style.width = `${next}px`;
110
+ img.style.maxWidth = "100%";
111
+ img.style.height = "auto";
112
+ img.setAttribute("width", String(next));
113
+ img.removeAttribute("height");
114
+ }
115
+ function applyFloatStyles(img, side) {
116
+ img.style.display = "block";
117
+ img.style.height = "auto";
118
+ img.style.maxWidth = side === "none" ? "100%" : "45%";
119
+ img.style.border = "0";
120
+ img.style.outline = "none";
121
+ if (side === "left") {
122
+ img.style.cssFloat = "left";
123
+ img.style.margin = "0 16px 12px 0";
124
+ }
125
+ else if (side === "right") {
126
+ img.style.cssFloat = "right";
127
+ img.style.margin = "0 0 12px 16px";
128
+ }
129
+ else {
130
+ img.style.cssFloat = "none";
131
+ img.style.margin = "16px auto";
132
+ img.style.display = "block";
133
+ }
134
+ }
135
+ function currentFloatSide(img) {
136
+ const float = img.style.cssFloat || img.style.float;
137
+ if (float === "right")
138
+ return "right";
139
+ if (float === "left")
140
+ return "left";
141
+ return "none";
142
+ }
143
+ /** Pick wrap side from where the image was dropped inside its host block. */
144
+ function inferFloatSide(clientX, host) {
145
+ const rect = host.getBoundingClientRect();
146
+ if (rect.width <= 0)
147
+ return "left";
148
+ const relativeX = (clientX - rect.left) / rect.width;
149
+ if (relativeX <= 0.38)
150
+ return "left";
151
+ if (relativeX >= 0.62)
152
+ return "right";
153
+ return "none";
154
+ }
155
+ /** Unwrap centered-only wrapper divs so text can flow around the image. */
156
+ export function unwrapCenteredImage(img) {
157
+ const parent = img.parentElement;
158
+ if (!parent || parent.tagName !== "DIV" || parent.childElementCount !== 1)
159
+ return;
160
+ const style = (parent.getAttribute("style") ?? "").replace(/\s/g, "").toLowerCase();
161
+ if (!style.includes("text-align:center"))
162
+ return;
163
+ const width = parseWidth(img);
164
+ applyFloatStyles(img, "left");
165
+ applyWidth(img, width);
166
+ parent.replaceWith(img);
167
+ }
168
+ function findDropHost(doc, clientX, clientY, exclude) {
169
+ const target = doc.elementFromPoint(clientX, clientY);
170
+ if (!target)
171
+ return doc.body;
172
+ if (target.id === AI_IMAGE_HANDLE_ID ||
173
+ target.classList.contains("ai-img-drag-ghost")) {
174
+ return exclude.parentElement ?? doc.body;
175
+ }
176
+ const host = target.closest(TEXT_HOST_SELECTOR);
177
+ if (host && host !== exclude)
178
+ return host;
179
+ if (exclude.parentElement)
180
+ return exclude.parentElement;
181
+ return doc.body;
182
+ }
183
+ function resolveDirectChild(host, node) {
184
+ if (node === host)
185
+ return host.firstChild;
186
+ let current = node;
187
+ while (current && current.parentNode !== host) {
188
+ current = current.parentNode;
189
+ }
190
+ return current;
191
+ }
192
+ function safeInsertInHost(host, img, before) {
193
+ if (before && before.parentNode === host) {
194
+ host.insertBefore(img, before);
195
+ return;
196
+ }
197
+ host.appendChild(img);
198
+ }
199
+ function insertImageInHost(doc, img, host, clientX, clientY, floatSide) {
200
+ if (floatSide === "left") {
201
+ host.insertBefore(img, host.firstChild);
202
+ return;
203
+ }
204
+ if (floatSide === "right") {
205
+ host.appendChild(img);
206
+ return;
207
+ }
208
+ const range = doc.caretRangeFromPoint?.(clientX, clientY);
209
+ if (range && host.contains(range.startContainer)) {
210
+ const node = range.startContainer;
211
+ if (node.nodeType === Node.TEXT_NODE) {
212
+ const textNode = node;
213
+ if (range.startOffset > 0 && range.startOffset < textNode.length) {
214
+ textNode.splitText(range.startOffset);
215
+ }
216
+ safeInsertInHost(host, img, resolveDirectChild(host, textNode));
217
+ return;
218
+ }
219
+ if (node.nodeType === Node.ELEMENT_NODE) {
220
+ safeInsertInHost(host, img, resolveDirectChild(host, node));
221
+ return;
222
+ }
223
+ }
224
+ host.appendChild(img);
225
+ }
226
+ function repositionImageAtPoint(doc, img, clientX, clientY) {
227
+ const fallbackParent = img.parentElement ?? doc.body;
228
+ const fallbackNext = img.nextSibling;
229
+ try {
230
+ unwrapCenteredImage(img);
231
+ const width = parseWidth(img);
232
+ const host = findDropHost(doc, clientX, clientY, img);
233
+ const floatSide = inferFloatSide(clientX, host);
234
+ img.remove();
235
+ applyFloatStyles(img, floatSide);
236
+ applyWidth(img, width);
237
+ insertImageInHost(doc, img, host, clientX, clientY, floatSide);
238
+ }
239
+ catch {
240
+ if (!img.isConnected) {
241
+ if (fallbackNext && fallbackNext.parentNode === fallbackParent) {
242
+ fallbackParent.insertBefore(img, fallbackNext);
243
+ }
244
+ else {
245
+ fallbackParent.appendChild(img);
246
+ }
247
+ }
248
+ }
249
+ }
250
+ function ensureHandle(doc) {
251
+ let handle = doc.getElementById(AI_IMAGE_HANDLE_ID);
252
+ if (!handle) {
253
+ handle = doc.createElement("div");
254
+ handle.id = AI_IMAGE_HANDLE_ID;
255
+ handle.title = "Drag to resize image";
256
+ handle.setAttribute("role", "button");
257
+ handle.setAttribute("aria-label", "Resize image");
258
+ doc.body.appendChild(handle);
259
+ }
260
+ return handle;
261
+ }
262
+ function hideHandle(doc) {
263
+ doc.getElementById(AI_IMAGE_HANDLE_ID)?.style.setProperty("display", "none");
264
+ }
265
+ function positionHandle(doc, img, handle) {
266
+ const rect = img.getBoundingClientRect();
267
+ const offset = AI_IMAGE_HANDLE_SIZE / 2;
268
+ handle.style.display = "block";
269
+ handle.style.left = `${rect.right - offset}px`;
270
+ handle.style.top = `${rect.bottom - offset}px`;
271
+ }
272
+ export function attachImageResize(doc, options) {
273
+ let selected = null;
274
+ let raf = 0;
275
+ let resizeStartX = 0;
276
+ let resizeStartWidth = 0;
277
+ let resizeUndoPushed = false;
278
+ let pointerDownX = 0;
279
+ let pointerDownY = 0;
280
+ let dragOffsetX = 0;
281
+ let dragOffsetY = 0;
282
+ let dragging = false;
283
+ let dragGhost = null;
284
+ let activePointerId = null;
285
+ const handle = ensureHandle(doc);
286
+ hideHandle(doc);
287
+ const deselect = () => {
288
+ if (selected) {
289
+ selected.classList.remove("ai-img-selected", "ai-img-dragging");
290
+ }
291
+ selected = null;
292
+ hideHandle(doc);
293
+ };
294
+ const refreshSelection = (img) => {
295
+ selected = img;
296
+ img.classList.add("ai-img-selected");
297
+ positionHandle(doc, img, handle);
298
+ };
299
+ const select = (img) => {
300
+ if (selected === img) {
301
+ positionHandle(doc, img, handle);
302
+ return;
303
+ }
304
+ deselect();
305
+ unwrapCenteredImage(img);
306
+ if (currentFloatSide(img) === "none" && !img.style.width) {
307
+ applyFloatStyles(img, "left");
308
+ applyWidth(img, Math.min(280, img.naturalWidth || 280));
309
+ }
310
+ refreshSelection(img);
311
+ };
312
+ const scheduleReposition = () => {
313
+ if (!selected || dragging)
314
+ return;
315
+ cancelAnimationFrame(raf);
316
+ raf = requestAnimationFrame(() => {
317
+ if (selected && !dragging) {
318
+ positionHandle(doc, selected, handle);
319
+ }
320
+ });
321
+ };
322
+ const clearDragGhost = () => {
323
+ dragGhost?.remove();
324
+ dragGhost = null;
325
+ if (selected)
326
+ selected.classList.remove("ai-img-dragging");
327
+ };
328
+ const onResizePointerMove = (event) => {
329
+ if (!selected)
330
+ return;
331
+ if (!resizeUndoPushed) {
332
+ options.onBeforeEdit?.();
333
+ resizeUndoPushed = true;
334
+ }
335
+ cancelAnimationFrame(raf);
336
+ raf = requestAnimationFrame(() => {
337
+ if (!selected)
338
+ return;
339
+ const delta = event.clientX - resizeStartX;
340
+ applyWidth(selected, resizeStartWidth + delta);
341
+ positionHandle(doc, selected, handle);
342
+ });
343
+ };
344
+ const onResizePointerUp = () => {
345
+ doc.removeEventListener("pointermove", onResizePointerMove);
346
+ doc.removeEventListener("pointerup", onResizePointerUp);
347
+ cancelAnimationFrame(raf);
348
+ if (selected)
349
+ options.onResizeEnd();
350
+ };
351
+ const onHandlePointerDown = (event) => {
352
+ if (!selected)
353
+ return;
354
+ event.preventDefault();
355
+ event.stopPropagation();
356
+ resizeUndoPushed = false;
357
+ resizeStartX = event.clientX;
358
+ resizeStartWidth = parseWidth(selected);
359
+ doc.addEventListener("pointermove", onResizePointerMove);
360
+ doc.addEventListener("pointerup", onResizePointerUp);
361
+ };
362
+ const onImagePointerMove = (event) => {
363
+ if (!selected || activePointerId !== event.pointerId)
364
+ return;
365
+ const deltaX = event.clientX - pointerDownX;
366
+ const deltaY = event.clientY - pointerDownY;
367
+ if (!dragging && Math.hypot(deltaX, deltaY) < DRAG_THRESHOLD)
368
+ return;
369
+ if (!dragging) {
370
+ options.onBeforeEdit?.();
371
+ dragging = true;
372
+ hideHandle(doc);
373
+ selected.classList.add("ai-img-dragging");
374
+ dragGhost = selected.cloneNode(true);
375
+ dragGhost.className = "ai-img-drag-ghost";
376
+ dragGhost.removeAttribute("data-ai-image");
377
+ dragGhost.style.width = `${parseWidth(selected)}px`;
378
+ dragGhost.style.height = "auto";
379
+ doc.body.appendChild(dragGhost);
380
+ }
381
+ cancelAnimationFrame(raf);
382
+ raf = requestAnimationFrame(() => {
383
+ if (!dragGhost)
384
+ return;
385
+ dragGhost.style.left = `${event.clientX - dragOffsetX}px`;
386
+ dragGhost.style.top = `${event.clientY - dragOffsetY}px`;
387
+ });
388
+ };
389
+ const onImagePointerUp = (event) => {
390
+ if (activePointerId !== event.pointerId)
391
+ return;
392
+ doc.removeEventListener("pointermove", onImagePointerMove);
393
+ doc.removeEventListener("pointerup", onImagePointerUp);
394
+ doc.removeEventListener("pointercancel", onImagePointerUp);
395
+ cancelAnimationFrame(raf);
396
+ const didDrag = dragging;
397
+ dragging = false;
398
+ activePointerId = null;
399
+ if (didDrag && selected) {
400
+ try {
401
+ repositionImageAtPoint(doc, selected, event.clientX, event.clientY);
402
+ }
403
+ finally {
404
+ selected.classList.remove("ai-img-dragging");
405
+ selected.style.removeProperty("opacity");
406
+ clearDragGhost();
407
+ if (selected.isConnected) {
408
+ refreshSelection(selected);
409
+ }
410
+ else {
411
+ deselect();
412
+ }
413
+ }
414
+ options.onResizeEnd();
415
+ return;
416
+ }
417
+ clearDragGhost();
418
+ };
419
+ const onImagePointerDown = (event) => {
420
+ if (options.isSelectMode())
421
+ return;
422
+ const target = event.target;
423
+ if (target === handle || handle.contains(target)) {
424
+ return;
425
+ }
426
+ const img = target?.closest("img[data-ai-image]");
427
+ if (!img)
428
+ return;
429
+ event.preventDefault();
430
+ event.stopPropagation();
431
+ select(img);
432
+ activePointerId = event.pointerId;
433
+ pointerDownX = event.clientX;
434
+ pointerDownY = event.clientY;
435
+ const rect = img.getBoundingClientRect();
436
+ dragOffsetX = event.clientX - rect.left;
437
+ dragOffsetY = event.clientY - rect.top;
438
+ doc.addEventListener("pointermove", onImagePointerMove);
439
+ doc.addEventListener("pointerup", onImagePointerUp);
440
+ doc.addEventListener("pointercancel", onImagePointerUp);
441
+ };
442
+ const onBodyClick = (event) => {
443
+ if (options.isSelectMode())
444
+ return;
445
+ const target = event.target;
446
+ if (target === handle || handle.contains(target))
447
+ return;
448
+ if (target?.closest("img[data-ai-image]"))
449
+ return;
450
+ deselect();
451
+ };
452
+ const onScroll = () => scheduleReposition();
453
+ const onResize = () => scheduleReposition();
454
+ handle.addEventListener("pointerdown", onHandlePointerDown);
455
+ doc.body.addEventListener("pointerdown", onImagePointerDown, true);
456
+ doc.body.addEventListener("click", onBodyClick, true);
457
+ doc.defaultView?.addEventListener("scroll", onScroll, true);
458
+ doc.defaultView?.addEventListener("resize", onResize);
459
+ return () => {
460
+ cancelAnimationFrame(raf);
461
+ clearDragGhost();
462
+ deselect();
463
+ handle.removeEventListener("pointerdown", onHandlePointerDown);
464
+ doc.body.removeEventListener("pointerdown", onImagePointerDown, true);
465
+ doc.body.removeEventListener("click", onBodyClick, true);
466
+ doc.defaultView?.removeEventListener("scroll", onScroll, true);
467
+ doc.defaultView?.removeEventListener("resize", onResize);
468
+ handle.remove();
469
+ };
470
+ }
471
+ /** Float image for inline insert — text wraps in supporting clients and in preview. */
472
+ export function createFloatImage(doc, url, label, width = 280) {
473
+ const img = doc.createElement("img");
474
+ img.setAttribute("src", url);
475
+ img.setAttribute("alt", label || "");
476
+ img.setAttribute("data-ai-image", "true");
477
+ applyFloatStyles(img, "left");
478
+ applyWidth(img, width);
479
+ return img;
480
+ }
481
+ // --- Preview HTML editing (blocks, inline edit, save-ready serialize) --------
482
+ const TEXT_BLOCK_TAGS = new Set([
483
+ "P",
484
+ "H1",
485
+ "H2",
486
+ "H3",
487
+ "H4",
488
+ "H5",
489
+ "H6",
490
+ "TD",
491
+ "TH",
492
+ "A",
493
+ "LI",
494
+ "BUTTON",
495
+ "DIV",
496
+ "SPAN",
497
+ "STRONG",
498
+ "EM",
499
+ "B",
500
+ "I",
501
+ "LABEL",
502
+ ]);
503
+ export const AI_EDITOR_STYLE_ID = "ai-email-editor-styles";
504
+ export const AI_EDITOR_STYLES = `
505
+ [data-edit-id] { transition: outline 0.15s ease, background 0.15s ease; }
506
+ [data-edit-id].ai-selected {
507
+ outline: 2px solid rgb(6, 182, 212);
508
+ background: rgba(6, 182, 212, 0.07);
509
+ }
510
+ [data-edit-id][contenteditable="true"] {
511
+ outline: 2px solid rgb(34, 197, 94);
512
+ outline-offset: 1px;
513
+ cursor: text;
514
+ }
515
+ [data-edit-id][contenteditable="true"]:not(button):not(a) {
516
+ background: rgba(34, 197, 94, 0.06);
517
+ }
518
+ button[data-edit-id][contenteditable="true"],
519
+ a[data-edit-id][contenteditable="true"] {
520
+ outline: 2px dashed rgba(34, 197, 94, 0.85);
521
+ outline-offset: 3px;
522
+ cursor: text;
523
+ }
524
+ [data-edit-id][contenteditable="true"]::selection,
525
+ [data-edit-id][contenteditable="true"] *::selection {
526
+ background-color: rgba(6, 182, 212, 0.45);
527
+ color: inherit;
528
+ -webkit-text-fill-color: currentColor;
529
+ text-shadow: none;
530
+ }
531
+ button[data-edit-id][contenteditable="true"]::selection,
532
+ button[data-edit-id][contenteditable="true"] *::selection,
533
+ a[data-edit-id][contenteditable="true"]::selection,
534
+ a[data-edit-id][contenteditable="true"] *::selection {
535
+ background-color: rgba(15, 23, 42, 0.82);
536
+ color: #ffffff;
537
+ -webkit-text-fill-color: #ffffff;
538
+ text-shadow: none;
539
+ }
540
+ body.ai-select-mode [data-edit-id] { cursor: crosshair; }
541
+ body.ai-select-mode [data-edit-id].ai-flagged { cursor: text; }
542
+ body.ai-select-mode [data-edit-id]:hover:not(button):not(a) {
543
+ outline: 2px dashed rgba(250, 204, 21, 0.85);
544
+ }
545
+ body.ai-select-mode button[data-edit-id]:hover,
546
+ body.ai-select-mode a[data-edit-id]:hover {
547
+ outline: 2px dashed rgba(250, 204, 21, 0.85);
548
+ outline-offset: 2px;
549
+ }
550
+ body.ai-select-mode [data-edit-id].ai-flagged:not(button):not(a) {
551
+ outline: 2px solid rgb(250, 204, 21);
552
+ background: rgba(250, 204, 21, 0.14);
553
+ box-shadow: inset 0 0 0 1px rgba(250, 204, 21, 0.35);
554
+ }
555
+ body.ai-select-mode button[data-edit-id].ai-flagged,
556
+ body.ai-select-mode a[data-edit-id].ai-flagged {
557
+ outline: 2px solid rgb(250, 204, 21);
558
+ outline-offset: 2px;
559
+ }
560
+ body.ai-select-mode [data-edit-id].ai-flagged[contenteditable="true"]:not(button):not(a) {
561
+ outline: 2px solid rgb(34, 197, 94);
562
+ background: rgba(34, 197, 94, 0.06);
563
+ box-shadow: none;
564
+ }
565
+ body.ai-select-mode button[data-edit-id].ai-flagged[contenteditable="true"],
566
+ body.ai-select-mode a[data-edit-id].ai-flagged[contenteditable="true"] {
567
+ outline: 2px dashed rgba(34, 197, 94, 0.85);
568
+ outline-offset: 3px;
569
+ }
570
+ `;
571
+ /** Tag text-bearing blocks so the editor can target them for click / AI edits. */
572
+ export function annotateHtmlForEditing(html) {
573
+ const parser = new DOMParser();
574
+ const doc = parser.parseFromString(html, "text/html");
575
+ stripPlaceholderImages(doc);
576
+ let counter = 0;
577
+ for (const el of doc.body.querySelectorAll("*")) {
578
+ if (el.hasAttribute("data-edit-id"))
579
+ continue;
580
+ const text = el.textContent?.trim();
581
+ if (!text)
582
+ continue;
583
+ if (el.querySelector("[data-edit-id]"))
584
+ continue;
585
+ const tag = el.tagName;
586
+ if (!TEXT_BLOCK_TAGS.has(tag))
587
+ continue;
588
+ if (tag === "DIV") {
589
+ const blockChildren = [...el.children].filter((child) => TEXT_BLOCK_TAGS.has(child.tagName) &&
590
+ !!child.textContent?.trim());
591
+ if (blockChildren.length > 0)
592
+ continue;
593
+ }
594
+ if (tag === "SPAN" &&
595
+ el.parentElement?.hasAttribute("data-edit-id")) {
596
+ continue;
597
+ }
598
+ el.setAttribute("data-edit-id", `edit-${counter++}`);
599
+ }
600
+ prepareImagesForEditing(doc);
601
+ injectEditorStyles(doc);
602
+ return serializeHtmlDocument(doc);
603
+ }
604
+ function injectEditorStyles(doc) {
605
+ let styleEl = doc.getElementById(AI_EDITOR_STYLE_ID);
606
+ if (!styleEl) {
607
+ styleEl = doc.createElement("style");
608
+ styleEl.id = AI_EDITOR_STYLE_ID;
609
+ doc.head.appendChild(styleEl);
610
+ }
611
+ styleEl.textContent = AI_EDITOR_STYLES + AI_IMAGE_EDITOR_STYLES;
612
+ }
613
+ export function serializeHtmlDocument(doc) {
614
+ const doctype = doc.doctype
615
+ ? `<!DOCTYPE ${doc.doctype.name}${doc.doctype.publicId ? ` PUBLIC "${doc.doctype.publicId}"` : ""}${doc.doctype.systemId ? ` "${doc.doctype.systemId}"` : ""}>`
616
+ : "<!DOCTYPE html>";
617
+ return `${doctype}\n${doc.documentElement.outerHTML}`;
618
+ }
619
+ export function stripEditorChrome(html) {
620
+ const parser = new DOMParser();
621
+ const doc = parser.parseFromString(html, "text/html");
622
+ doc.getElementById(AI_EDITOR_STYLE_ID)?.remove();
623
+ doc.querySelectorAll("[data-edit-id]").forEach((el) => {
624
+ el.removeAttribute("data-edit-id");
625
+ el.classList.remove("ai-selected", "ai-flagged");
626
+ el.removeAttribute("contenteditable");
627
+ });
628
+ stripImageEditorChrome(doc);
629
+ return serializeHtmlDocument(doc);
630
+ }
631
+ /** Click-to-type: focus a block and place the caret where the user clicked. */
632
+ export function focusEditableElementAtPoint(element, clientX, clientY) {
633
+ element.setAttribute("contenteditable", "true");
634
+ element.focus();
635
+ const doc = element.ownerDocument;
636
+ const selection = doc.getSelection();
637
+ if (!selection)
638
+ return;
639
+ let range = null;
640
+ if (doc.caretRangeFromPoint) {
641
+ range = doc.caretRangeFromPoint(clientX, clientY);
642
+ }
643
+ else {
644
+ const caretPositionFromPoint = doc.caretPositionFromPoint;
645
+ const pos = caretPositionFromPoint?.call(doc, clientX, clientY);
646
+ if (pos) {
647
+ range = doc.createRange();
648
+ range.setStart(pos.offsetNode, pos.offset);
649
+ range.collapse(true);
650
+ }
651
+ }
652
+ if (range && element.contains(range.startContainer)) {
653
+ selection.removeAllRanges();
654
+ selection.addRange(range);
655
+ return;
656
+ }
657
+ range = doc.createRange();
658
+ range.selectNodeContents(element);
659
+ range.collapse(false);
660
+ selection.removeAllRanges();
661
+ selection.addRange(range);
662
+ }
663
+ export function finishInlineEdits(doc, except) {
664
+ doc.querySelectorAll('[contenteditable="true"]').forEach((node) => {
665
+ if (node !== except) {
666
+ node.removeAttribute("contenteditable");
667
+ }
668
+ });
669
+ }
670
+ export function syncFlaggedElements(doc, flaggedIds) {
671
+ const flagged = new Set(flaggedIds);
672
+ doc.querySelectorAll("[data-edit-id]").forEach((el) => {
673
+ const id = el.getAttribute("data-edit-id");
674
+ if (id && flagged.has(id)) {
675
+ el.classList.add("ai-flagged");
676
+ }
677
+ else {
678
+ el.classList.remove("ai-flagged");
679
+ }
680
+ });
681
+ }
682
+ export function getBlockHtmlById(doc, editId) {
683
+ const el = doc.querySelector(`[data-edit-id="${editId}"]`);
684
+ return el?.outerHTML ?? null;
685
+ }
686
+ // --- Image assets ---------------------------------------------------------
687
+ /** Builds a centered, responsive image block ready to drop into an email. */
688
+ function buildImageWrapper(doc, url, label) {
689
+ const wrapper = doc.createElement("div");
690
+ wrapper.setAttribute("style", "text-align:center;margin:16px 0;");
691
+ const img = doc.createElement("img");
692
+ img.setAttribute("src", url);
693
+ img.setAttribute("alt", label || "");
694
+ img.setAttribute("data-ai-image", "true");
695
+ img.setAttribute("style", "max-width:100%;height:auto;display:inline-block;border:0;outline:none;");
696
+ wrapper.appendChild(img);
697
+ return wrapper;
698
+ }
699
+ /** Appends an image block to the end of the email's main content. */
700
+ export function insertImageBlock(doc, url, label) {
701
+ if (!doc.body)
702
+ return;
703
+ doc.body.appendChild(buildImageWrapper(doc, url, label));
704
+ }
705
+ /**
706
+ * Inserts an image block right after the block under the given viewport point.
707
+ * Falls back to appending at the end when the drop position is unclear.
708
+ */
709
+ export function insertImageBlockAtPoint(doc, url, label, clientX, clientY) {
710
+ const target = doc.elementFromPoint(clientX, clientY);
711
+ let block = target?.closest("[data-edit-id],tr,td,table,p,div,section,header,footer");
712
+ // Never insert a DIV directly under table row structures.
713
+ if (block && (block.tagName === "TR" || block.tagName === "TD")) {
714
+ block = block.closest("table") ?? block;
715
+ }
716
+ const textHost = target?.closest("[data-edit-id],p,td,th,li,blockquote");
717
+ if (textHost) {
718
+ const img = createFloatImage(doc, url, label);
719
+ textHost.insertBefore(img, textHost.firstChild);
720
+ return;
721
+ }
722
+ if (!block?.parentElement || block === doc.body) {
723
+ insertImageBlock(doc, url, label);
724
+ return;
725
+ }
726
+ const wrapper = buildImageWrapper(doc, url, label);
727
+ block.parentElement.insertBefore(wrapper, block.nextSibling);
728
+ }