mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-12 16:04:25 +00:00
Merge pull request #443 from warmbly/feature/editor-image-links-and-buttons
feat: clickable images and a call-to-action button in the campaign body editor
This commit is contained in:
@@ -22,7 +22,7 @@ A step has an internal **name** (never seen by recipients), a **subject** with a
|
||||
|
||||
### The body editor
|
||||
|
||||
The toolbar carries undo and redo (`Ctrl+Z` and `Ctrl+Shift+Z`, or `Cmd` on a Mac), bold, italic, underline and strikethrough, a heading, bullet and numbered lists, links, images, the font menu, text and highlight colour, alignment, tables, the personalization menu, an AI block, a condition, spintax, and a form link. `Shift+Enter` puts a line break inside a paragraph rather than starting a new one.
|
||||
The toolbar carries undo and redo (`Ctrl+Z` and `Ctrl+Shift+Z`, or `Cmd` on a Mac), bold, italic, underline and strikethrough, a heading, bullet and numbered lists, links, images, buttons, the font menu, text and highlight colour, alignment, tables, the personalization menu, an AI block, a condition, spintax, and a form link. `Shift+Enter` puts a line break inside a paragraph rather than starting a new one.
|
||||
|
||||
**Alignment** is left, centre, right and justify, applied to whichever blocks the selection covers. Pressing the alignment a block already has clears it again, so a body can go back to inheriting rather than pinning one on every paragraph.
|
||||
|
||||
@@ -50,12 +50,26 @@ Uploads go to a library shared by the whole workspace, so a logo uploaded once i
|
||||
|
||||
Select a placed image and a small bar appears over it: quarter, half or full column width (or the image's own size), left, center or right alignment, and the alt text. Alt text is worth filling in. Most clients block remote images until the reader allows them, so alt text is what the first look actually shows, and it is what the plain-text version of the email carries in the image's place.
|
||||
|
||||
The same bar is where an image becomes a link. Put an address in the link field and the image ships wrapped in an anchor, so the picture itself is clickable, and the unlink button next to the field takes it off again. An address typed without `https://` gets it added when you leave the field, because a mail client reads one without a scheme as a path inside itself and it goes nowhere. A linked image is tracked and counted like any other link in the body, the click report names it by its alt text, and the plain-text half carries the alt text with the destination after it.
|
||||
|
||||
Deleting an image from the library also deletes the file, so it stops loading in mail already sent. The dashboard asks before it does.
|
||||
|
||||
<Callout type="warn" title="Images cost you deliverability">
|
||||
Cold email from a real person rarely has images. An image-heavy body reads as a marketing blast to filters that cannot read the picture, and the content check flags it. One signature logo or one product shot is usually the most a first touch should carry.
|
||||
</Callout>
|
||||
|
||||
#### Buttons
|
||||
|
||||
The button beside the image button drops a call to action into the body and opens its bar: the text, the address it opens, a colour, three sizes, square, rounded or pill corners, left, centre or right, and a switch that stretches it to the full width of the column. The text colour follows the colour you pick, so a light button gets dark type rather than white on yellow. The label takes merge fields and spintax like any other copy, so `Book a call, {{.FirstName}}` works there too.
|
||||
|
||||
What it writes is a one-cell table, not a styled link. Outlook on Windows lays out with Word, which ignores `display: inline-block`, so a padded anchor arrives there as a coloured word with no box around it. Putting the colour and the padding on a table cell instead is the shape that holds everywhere, which is also why a button shows up as a small table in the HTML view.
|
||||
|
||||
A button with no address ships as the box it looks like and nothing else, rather than as a link to nowhere. Its link field stays amber until you fill it in, and fills in `https://` for you the same way the image's does.
|
||||
|
||||
<Callout type="warn" title="A button is a marketing signal">
|
||||
A first touch that looks like a newsletter gets read like one. In cold outreach a plain sentence with a link in it usually beats a button, and filters read heavy markup as a blast. Save the button for a later step, an event invitation, or a reply to someone already in a conversation.
|
||||
</Callout>
|
||||
|
||||
#### Writing the HTML yourself
|
||||
|
||||
The `</>` button on the right of the toolbar swaps the body for its HTML. What you type there is stored exactly as you wrote it, merge fields and conditions included, so a template built elsewhere keeps its markup. The only thing that changes on the way out is what the send path adds or resolves: merge fields fill in, a `<style>` block is inlined onto the elements it matches, and the signature, opt-out footer and tracking are appended.
|
||||
@@ -89,6 +103,8 @@ Every HTML email ships with a plain-text alternative beside it, and inbox provid
|
||||
|
||||
For a step written in HTML in the dashboard, Warmbly stores no plain-text half and renders one when the email sends: headings and paragraphs keep their line breaks, list items are bulleted, table rows become lines, each link keeps its destination in brackets after its text, and the stylesheet and any hidden preheader are left out. The mailbox's plain-text signature and the opt-out line follow it. The Preview tab shows the result, so you can read what a text-only client gets.
|
||||
|
||||
A step written in the visual editor gets its plain-text half as you type, and it keeps the same things: an image stands in as its alt text in brackets, and a link, a linked image and a button all keep their destination in brackets after the label. Without that a text-only reader gets a button with nowhere to go.
|
||||
|
||||
This only applies when there is no plain-text body to send. Set `body_plain` through the API and that text is sent as written; leave it empty and the send path renders one from the HTML.
|
||||
|
||||
### Preview and test
|
||||
|
||||
@@ -67,6 +67,7 @@ func newDisplayPolicy() *bluemonday.Policy {
|
||||
"border-color", "border-radius", "border-style", "border-width",
|
||||
"width", "min-width", "max-width", "height", "min-height", "max-height",
|
||||
"display", "float", "clear", "list-style-type", "opacity", "table-layout",
|
||||
"border-collapse", "border-spacing",
|
||||
).Globally()
|
||||
|
||||
// Images: remote (http/https) and inline data: URIs. cid: references point
|
||||
|
||||
@@ -19,10 +19,11 @@ func TestSanitizeDropsScriptAndStyleContent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizeKeepsLayoutMarkup(t *testing.T) {
|
||||
in := `<table width="600" cellpadding="0" bgcolor="#ffffff"><tr><td align="center" style="font-size:14px;color:#111">` +
|
||||
in := `<table width="600" cellpadding="0" bgcolor="#ffffff" style="border-collapse:separate"><tr>` +
|
||||
`<td align="center" style="font-size:14px;color:#111">` +
|
||||
`<a href="https://example.com">Link</a><img src="https://example.com/a.png" width="20"></td></tr></table>`
|
||||
out := Sanitize(in)
|
||||
for _, want := range []string{`width="600"`, `bgcolor="#ffffff"`, `align="center"`, "font-size", `href="https://example.com"`, `target="_blank"`, "<img"} {
|
||||
for _, want := range []string{`width="600"`, `bgcolor="#ffffff"`, `align="center"`, "font-size", "border-collapse", `href="https://example.com"`, `target="_blank"`, "<img"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("expected %q in output: %s", want, out)
|
||||
}
|
||||
|
||||
@@ -231,6 +231,21 @@ func TestToPlainTextLinkThatIsItsOwnLabel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A button and a linked image are nothing but their destination once the
|
||||
// markup is gone, so the text/plain half has to carry it (issue #433).
|
||||
func TestToPlainTextKeepsAButtonAndALinkedImage(t *testing.T) {
|
||||
button := ToPlainText(`<table data-warmbly-button=""><tbody><tr><td>` +
|
||||
`<a href="https://cal.test/me" style="display:inline-block">Book a call</a></td></tr></tbody></table>`)
|
||||
if button != "Book a call (https://cal.test/me)" {
|
||||
t.Errorf("a button is its label and where it goes: %q", button)
|
||||
}
|
||||
|
||||
image := ToPlainText(`<a href="https://x.test/demo"><img src="https://x.test/a.png" alt="Watch the demo"></a>`)
|
||||
if image != "[Watch the demo] (https://x.test/demo)" {
|
||||
t.Errorf("a linked image stands in as its alt text: %q", image)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPlainTextEntitiesAndBreaks(t *testing.T) {
|
||||
got := ToPlainText(`<p>Tom & Jerry<br>next line</p>`)
|
||||
if got != "Tom & Jerry\nnext line" {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Insert and edit for the campaign body's call-to-action button (issue #433).
|
||||
//
|
||||
// The toolbar places a default button and selects it, so the bar that edits it
|
||||
// is already open; everything after that happens there, the way an image's size
|
||||
// and alt text do. The markup it writes lives in nodes/EmailButtonNode.ts.
|
||||
|
||||
import {
|
||||
AlignCenterIcon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
CircleIcon,
|
||||
MousePointerClickIcon,
|
||||
SquareIcon,
|
||||
SquircleIcon,
|
||||
StretchHorizontalIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { BubbleBtn, BubbleDivider, NodeBubble, selectedNode, useNodeAnchor } from "./NodeBubble";
|
||||
import {
|
||||
BUTTON_RADII,
|
||||
BUTTON_SIZES,
|
||||
BUTTON_SWATCHES,
|
||||
BUTTON_DEFAULT_LABEL,
|
||||
readableTextColor,
|
||||
type ButtonAlign,
|
||||
} from "./nodes/EmailButtonNode";
|
||||
import { absoluteHref } from "./nodes/EmailImageNode";
|
||||
|
||||
const ALIGNMENTS: { value: ButtonAlign; title: string; Icon: typeof AlignLeftIcon }[] = [
|
||||
{ value: "left", title: "Align left", Icon: AlignLeftIcon },
|
||||
{ value: "center", title: "Centre", Icon: AlignCenterIcon },
|
||||
{ value: "right", title: "Align right", Icon: AlignRightIcon },
|
||||
];
|
||||
|
||||
const CORNER_ICONS = [SquareIcon, SquircleIcon, CircleIcon];
|
||||
|
||||
export function ButtonInsert({ editor }: { editor: Editor }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().insertEmailButton().run()}
|
||||
title="Insert a call-to-action button"
|
||||
className="size-7 inline-flex items-center justify-center rounded text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
<MousePointerClickIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ButtonBubble({ editor }: { editor: Editor }) {
|
||||
const selection = selectedNode(editor, "emailButton");
|
||||
const anchor = useNodeAnchor(editor, selection?.pos ?? null);
|
||||
|
||||
if (typeof document === "undefined" || !selection || !anchor) return null;
|
||||
|
||||
const label = (selection.attrs.label as string) ?? "";
|
||||
const href = (selection.attrs.href as string) ?? "";
|
||||
const background = (selection.attrs.background as string) ?? "";
|
||||
const padding = selection.attrs.padding as string;
|
||||
const radius = selection.attrs.radius as string;
|
||||
const align = (selection.attrs.align as ButtonAlign) ?? "center";
|
||||
const fullWidth = selection.attrs.width === "100%";
|
||||
|
||||
// No focus() here on purpose: the label and link fields are part of this
|
||||
// bar, and pulling focus back into the editor on every keystroke would make
|
||||
// them impossible to type in. ProseMirror keeps the node selected anyway.
|
||||
const set = (attrs: Record<string, unknown>) => editor.commands.updateAttributes("emailButton", attrs);
|
||||
|
||||
return (
|
||||
<NodeBubble anchor={anchor}>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
value={label}
|
||||
onChange={(e) => set({ label: e.target.value })}
|
||||
// An empty button is a coloured box nobody can read, and the
|
||||
// field is the only place to notice that.
|
||||
onBlur={(e) => !e.target.value.trim() && set({ label: BUTTON_DEFAULT_LABEL })}
|
||||
placeholder="Button text"
|
||||
title="Merge fields and spintax work here, the same as in the body"
|
||||
className="h-6 w-32 rounded border border-slate-200 px-1.5 text-[11px] text-slate-800 outline-none focus:border-sky-400"
|
||||
/>
|
||||
<input
|
||||
value={href}
|
||||
onChange={(e) => set({ href: e.target.value })}
|
||||
// A bare host is a relative path to a mail client, so it
|
||||
// goes nowhere and is never counted as a click.
|
||||
onBlur={(e) => set({ href: absoluteHref(e.target.value) })}
|
||||
placeholder="https://…"
|
||||
title={
|
||||
href.trim()
|
||||
? "Where the button goes"
|
||||
: "A button with no link does nothing when a reader presses it"
|
||||
}
|
||||
className={`h-6 w-48 max-w-[40vw] rounded border px-1.5 text-[11px] text-slate-800 outline-none focus:border-sky-400 ${
|
||||
href.trim() ? "border-slate-200" : "border-amber-300 bg-amber-50/50"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{BUTTON_SWATCHES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
title={s.label}
|
||||
aria-pressed={background.toLowerCase() === s.value}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => set({ background: s.value, color: readableTextColor(s.value) })}
|
||||
style={{ background: s.value }}
|
||||
className={`size-5 rounded-full border transition-transform hover:scale-110 ${
|
||||
background.toLowerCase() === s.value
|
||||
? "border-slate-900 ring-2 ring-sky-100"
|
||||
: "border-slate-200"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<BubbleDivider />
|
||||
{BUTTON_SIZES.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
title={s.title}
|
||||
aria-pressed={padding === s.padding}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => set({ padding: s.padding, fontSize: s.fontSize })}
|
||||
className={`h-6 px-1.5 rounded text-[11px] font-medium transition-colors ${
|
||||
padding === s.padding
|
||||
? "bg-sky-50 text-sky-700"
|
||||
: "text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
<BubbleDivider />
|
||||
{BUTTON_RADII.map((r, i) => {
|
||||
const Icon = CORNER_ICONS[i];
|
||||
return (
|
||||
<BubbleBtn
|
||||
key={r.value}
|
||||
title={r.title}
|
||||
active={radius === r.value}
|
||||
onClick={() => set({ radius: r.value })}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
</BubbleBtn>
|
||||
);
|
||||
})}
|
||||
<BubbleDivider />
|
||||
{ALIGNMENTS.map(({ value, title, Icon }) => (
|
||||
<BubbleBtn
|
||||
key={value}
|
||||
title={title}
|
||||
active={align === value}
|
||||
onClick={() => set({ align: value })}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
</BubbleBtn>
|
||||
))}
|
||||
<BubbleBtn
|
||||
title="Stretch to the full width of the column"
|
||||
active={fullWidth}
|
||||
onClick={() => set({ width: fullWidth ? null : "100%" })}
|
||||
>
|
||||
<StretchHorizontalIcon className="w-3 h-3" />
|
||||
</BubbleBtn>
|
||||
<BubbleDivider />
|
||||
<button
|
||||
type="button"
|
||||
title="Remove button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().deleteSelection().run()}
|
||||
className="size-6 inline-flex items-center justify-center rounded text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
|
||||
>
|
||||
<Trash2Icon className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</NodeBubble>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
// Image insertion and editing for the campaign body editor (issue #380).
|
||||
//
|
||||
// Two surfaces: a toolbar menu that uploads, takes a URL, or picks from the
|
||||
// workspace library, and a bubble over the selected image for size, alignment
|
||||
// and alt text. Uploads go to the library because a body image is fetched by
|
||||
// the recipient's mail client, which has no session and cannot read a
|
||||
// presigned attachment URL.
|
||||
// workspace library, and a bubble over the selected image for size, alignment,
|
||||
// alt text and the address it links to. Uploads go to the library because a
|
||||
// body image is fetched by the recipient's mail client, which has no session
|
||||
// and cannot read a presigned attachment URL.
|
||||
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -14,13 +14,14 @@ import {
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
ImageIcon,
|
||||
Link2Icon,
|
||||
Link2OffIcon,
|
||||
Loader2Icon,
|
||||
Trash2Icon,
|
||||
UploadCloudIcon,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
import { useAnchoredFloating } from "@/hooks/useAnchoredFloating";
|
||||
import { useConfirm } from "@/hooks/context/confirm";
|
||||
@@ -28,7 +29,14 @@ import { useEmailImages, useDeleteEmailImage } from "@/lib/api/hooks/app/campaig
|
||||
import type EmailImage from "@/lib/api/models/app/campaigns/EmailImage";
|
||||
import formatBytes from "@/lib/helper/formatBytes";
|
||||
import { ACCEPTED_IMAGE_TYPES, insertImage, useImageUpload } from "./imageUpload";
|
||||
import { IMAGE_SIZE_PRESETS, type ImageAlign } from "./nodes/EmailImageNode";
|
||||
import { absoluteHref, IMAGE_SIZE_PRESETS, type ImageAlign } from "./nodes/EmailImageNode";
|
||||
import { BubbleBtn, BubbleDivider, NodeBubble, selectedNode, useNodeAnchor } from "./NodeBubble";
|
||||
|
||||
const ALIGNMENTS: { value: ImageAlign; title: string; Icon: typeof AlignLeftIcon }[] = [
|
||||
{ value: "left", title: "Align left", Icon: AlignLeftIcon },
|
||||
{ value: "center", title: "Centre", Icon: AlignCenterIcon },
|
||||
{ value: "right", title: "Align right", Icon: AlignRightIcon },
|
||||
];
|
||||
|
||||
export function ImageMenu({ editor }: { editor: Editor }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@@ -272,125 +280,86 @@ export function ImageMenu({ editor }: { editor: Editor }) {
|
||||
);
|
||||
}
|
||||
|
||||
// selectedImage returns the image the caret has selected as a node, or null.
|
||||
// The bubble only exists for that selection, so clicking away dismisses it
|
||||
// without a listener of its own.
|
||||
function selectedImage(editor: Editor): { pos: number; attrs: Record<string, unknown> } | null {
|
||||
const sel = editor.state.selection;
|
||||
if (!(sel instanceof NodeSelection) || sel.node.type.name !== "image") return null;
|
||||
return { pos: sel.from, attrs: sel.node.attrs };
|
||||
}
|
||||
|
||||
// The editor re-renders its host on every transaction (shouldRerenderOnTransaction),
|
||||
// so this reads the live selection on each render rather than subscribing again.
|
||||
// The bar over the selected image: its size, alignment, alt text and the
|
||||
// address it links to.
|
||||
export function ImageBubble({ editor }: { editor: Editor }) {
|
||||
const [anchor, setAnchor] = React.useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
const selection = selectedImage(editor);
|
||||
const selectedPos = selection?.pos ?? null;
|
||||
|
||||
// Follow the image through scrolling and resizes, the same way the AI pill
|
||||
// does, so the bubble never detaches from what it edits.
|
||||
React.useEffect(() => {
|
||||
if (selectedPos === null) {
|
||||
setAnchor(null);
|
||||
return;
|
||||
}
|
||||
const sync = () => {
|
||||
try {
|
||||
const box = editor.view.coordsAtPos(selectedPos);
|
||||
setAnchor({ top: box.top, left: box.left });
|
||||
} catch {
|
||||
setAnchor(null);
|
||||
}
|
||||
};
|
||||
sync();
|
||||
window.addEventListener("scroll", sync, true);
|
||||
window.addEventListener("resize", sync);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", sync, true);
|
||||
window.removeEventListener("resize", sync);
|
||||
};
|
||||
}, [selectedPos, editor]);
|
||||
const selection = selectedNode(editor, "image");
|
||||
const anchor = useNodeAnchor(editor, selection?.pos ?? null);
|
||||
|
||||
if (typeof document === "undefined" || !selection || !anchor) return null;
|
||||
|
||||
const align = (selection.attrs.align as ImageAlign) ?? "left";
|
||||
const width = (selection.attrs.width as number | null) ?? null;
|
||||
const alt = (selection.attrs.alt as string | null) ?? "";
|
||||
// No focus() here on purpose: the alt-text field is part of this bar, and
|
||||
// pulling focus back into the editor on every keystroke would make it
|
||||
// impossible to type in. ProseMirror keeps the node selected regardless.
|
||||
const href = (selection.attrs.href as string | null) ?? "";
|
||||
// No focus() here on purpose: the alt-text and link fields are part of this
|
||||
// bar, and pulling focus back into the editor on every keystroke would make
|
||||
// it impossible to type in. ProseMirror keeps the node selected regardless.
|
||||
const set = (attrs: Record<string, unknown>) => editor.commands.updateAttributes("image", attrs);
|
||||
|
||||
const alignBtn = (value: ImageAlign, Icon: typeof AlignLeftIcon, title: string) => (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-pressed={align === value}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => set({ align: value })}
|
||||
className={`size-6 inline-flex items-center justify-center rounded transition-colors ${
|
||||
align === value ? "bg-sky-50 text-sky-700" : "text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
</button>
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<motion.div
|
||||
data-floating=""
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
style={{ position: "fixed", top: Math.max(8, anchor.top - 40), left: anchor.left, zIndex: 60 }}
|
||||
ref={(el) => {
|
||||
// Nothing in a floating bar may sit off-screen: on a narrow
|
||||
// viewport an image near the right edge would push it out.
|
||||
if (!el) return;
|
||||
const overflow = el.getBoundingClientRect().right - window.innerWidth + 8;
|
||||
if (overflow > 0) el.style.left = `${Math.max(8, anchor.left - overflow)}px`;
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-md border border-slate-200 bg-white p-1 shadow-[0_12px_32px_-8px_rgba(15,23,42,0.18)]"
|
||||
>
|
||||
{IMAGE_SIZE_PRESETS.map((p) => (
|
||||
return (
|
||||
<NodeBubble anchor={anchor}>
|
||||
<div className="flex items-center gap-1">
|
||||
{IMAGE_SIZE_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
title={p.title}
|
||||
aria-pressed={width === p.width}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => set({ width: p.width })}
|
||||
className={`h-6 px-1.5 rounded text-[11px] font-medium transition-colors ${
|
||||
width === p.width ? "bg-sky-50 text-sky-700" : "text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<BubbleDivider />
|
||||
{ALIGNMENTS.map(({ value, title, Icon }) => (
|
||||
<BubbleBtn key={value} title={title} active={align === value} onClick={() => set({ align: value })}>
|
||||
<Icon className="w-3 h-3" />
|
||||
</BubbleBtn>
|
||||
))}
|
||||
<BubbleDivider />
|
||||
<input
|
||||
value={alt}
|
||||
onChange={(e) => set({ alt: e.target.value })}
|
||||
placeholder="Alt text"
|
||||
title="Shown when the recipient's client blocks images, and read aloud by screen readers"
|
||||
className="h-6 w-32 max-w-[30vw] rounded border border-slate-200 px-1.5 text-[11px] text-slate-800 outline-none focus:border-sky-400"
|
||||
/>
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
title={p.title}
|
||||
aria-pressed={width === p.width}
|
||||
title="Remove image"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => set({ width: p.width })}
|
||||
className={`h-6 px-1.5 rounded text-[11px] font-medium transition-colors ${
|
||||
width === p.width ? "bg-sky-50 text-sky-700" : "text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
}`}
|
||||
onClick={() => editor.chain().focus().deleteSelection().run()}
|
||||
className="size-6 inline-flex items-center justify-center rounded text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
|
||||
>
|
||||
{p.label}
|
||||
<Trash2Icon className="w-3 h-3" />
|
||||
</button>
|
||||
))}
|
||||
<span className="mx-0.5 h-4 w-px bg-slate-200" />
|
||||
{alignBtn("left", AlignLeftIcon, "Align left")}
|
||||
{alignBtn("center", AlignCenterIcon, "Center")}
|
||||
{alignBtn("right", AlignRightIcon, "Align right")}
|
||||
<span className="mx-0.5 h-4 w-px bg-slate-200" />
|
||||
<input
|
||||
value={alt}
|
||||
onChange={(e) => set({ alt: e.target.value })}
|
||||
placeholder="Alt text"
|
||||
title="Shown when the recipient's client blocks images, and read aloud by screen readers"
|
||||
className="h-6 w-32 rounded border border-slate-200 px-1.5 text-[11px] text-slate-800 outline-none focus:border-sky-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title="Remove image"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => editor.chain().focus().deleteSelection().run()}
|
||||
className="size-6 inline-flex items-center justify-center rounded text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
|
||||
>
|
||||
<Trash2Icon className="w-3 h-3" />
|
||||
</button>
|
||||
</motion.div>,
|
||||
document.body,
|
||||
</div>
|
||||
{/* A picture is the most-clicked thing in an email that has one, so
|
||||
the link field is always on show rather than behind a toggle. */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="pl-1 text-slate-400" title="Open this address when the image is clicked">
|
||||
<Link2Icon className="w-3 h-3" />
|
||||
</span>
|
||||
<input
|
||||
value={href}
|
||||
onChange={(e) => set({ href: e.target.value })}
|
||||
// A bare host is a relative path to a mail client, so it
|
||||
// goes nowhere and is never counted as a click.
|
||||
onBlur={(e) => set({ href: absoluteHref(e.target.value) })}
|
||||
placeholder="Link the image to https://…"
|
||||
className="h-6 min-w-0 flex-1 rounded border border-slate-200 px-1.5 text-[11px] text-slate-800 outline-none focus:border-sky-400"
|
||||
/>
|
||||
{href !== "" && (
|
||||
<BubbleBtn title="Remove the link" onClick={() => set({ href: null })}>
|
||||
<Link2OffIcon className="w-3 h-3" />
|
||||
</BubbleBtn>
|
||||
)}
|
||||
</div>
|
||||
</NodeBubble>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// The floating bar over a selected block node: an image, a call-to-action
|
||||
// button. One implementation, because both have to do the same three things —
|
||||
// follow the node through a scroll or a resize, stay inside the viewport, and
|
||||
// vanish with the selection rather than with a listener of its own.
|
||||
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
|
||||
export type SelectedNode = { pos: number; attrs: Record<string, unknown> };
|
||||
export type NodeAnchor = { top: number; left: number; bottom: number };
|
||||
|
||||
// selectedNode returns the node of this type the caret has selected, or null.
|
||||
// The bubble only exists for that selection, so clicking away dismisses it.
|
||||
export function selectedNode(editor: Editor, typeName: string): SelectedNode | null {
|
||||
const sel = editor.state.selection;
|
||||
if (!(sel instanceof NodeSelection) || sel.node.type.name !== typeName) return null;
|
||||
return { pos: sel.from, attrs: sel.node.attrs };
|
||||
}
|
||||
|
||||
// useNodeAnchor tracks where the selected node sits on screen. The editor
|
||||
// re-renders its host on every transaction (shouldRerenderOnTransaction), so
|
||||
// the position is read on each render rather than subscribed to again.
|
||||
export function useNodeAnchor(editor: Editor, pos: number | null) {
|
||||
const [anchor, setAnchor] = React.useState<NodeAnchor | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (pos === null) {
|
||||
setAnchor(null);
|
||||
return;
|
||||
}
|
||||
const sync = () => {
|
||||
try {
|
||||
const box = editor.view.coordsAtPos(pos);
|
||||
setAnchor({ top: box.top, left: box.left, bottom: box.bottom });
|
||||
} catch {
|
||||
setAnchor(null);
|
||||
}
|
||||
};
|
||||
sync();
|
||||
window.addEventListener("scroll", sync, true);
|
||||
window.addEventListener("resize", sync);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", sync, true);
|
||||
window.removeEventListener("resize", sync);
|
||||
};
|
||||
}, [pos, editor]);
|
||||
|
||||
return anchor;
|
||||
}
|
||||
|
||||
export function NodeBubble({ anchor, children }: { anchor: NodeAnchor; children: React.ReactNode }) {
|
||||
return createPortal(
|
||||
<motion.div
|
||||
data-floating=""
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
style={{ position: "fixed", top: anchor.top, left: anchor.left, zIndex: 60 }}
|
||||
ref={(el) => {
|
||||
// Placed from the bar's own size rather than a guess at it:
|
||||
// these bars are one row or two depending on what they edit, so
|
||||
// a fixed offset either leaves a gap or covers the node. Above
|
||||
// the node when there is room and below it when there is not,
|
||||
// then clamped inside the viewport on every edge, because a
|
||||
// control that is off-screen is a control nobody has.
|
||||
if (!el) return;
|
||||
const box = el.getBoundingClientRect();
|
||||
const above = anchor.top - box.height - 6;
|
||||
const top = above >= 8 ? above : anchor.bottom + 6;
|
||||
el.style.top = `${Math.max(8, Math.min(top, window.innerHeight - box.height - 8))}px`;
|
||||
el.style.left = `${Math.max(8, Math.min(anchor.left, window.innerWidth - box.width - 8))}px`;
|
||||
}}
|
||||
className="flex max-w-[calc(100vw-16px)] flex-col gap-1 rounded-md border border-slate-200 bg-white p-1 shadow-[0_12px_32px_-8px_rgba(15,23,42,0.18)]"
|
||||
>
|
||||
{children}
|
||||
</motion.div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function BubbleDivider() {
|
||||
return <span className="mx-0.5 h-4 w-px bg-slate-200" />;
|
||||
}
|
||||
|
||||
// BubbleBtn is every square control in a floating bar: pressed state in sky,
|
||||
// nothing else.
|
||||
export function BubbleBtn({
|
||||
active,
|
||||
title,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean;
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-pressed={active}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
className={`size-6 inline-flex items-center justify-center rounded transition-colors ${
|
||||
active ? "bg-sky-50 text-sky-700" : "text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
// Rich email-body editor for campaign Steps, built on TipTap (no deprecated
|
||||
// execCommand). Controlled by an HTML string; emits HTML on change. Ships a
|
||||
// house-theme toolbar (undo/redo, headings, bold/italic/underline/strike,
|
||||
// lists, link, images), a one-click {{variable}} inserter, a spintax `{a|b}`
|
||||
// helper, and an HTML source view. Personalization tokens are just text, so
|
||||
// they survive serialization untouched.
|
||||
// lists, link, images, call-to-action buttons), a one-click {{variable}}
|
||||
// inserter, a spintax `{a|b}` helper, and an HTML source view. Personalization
|
||||
// tokens are just text, so they survive serialization untouched.
|
||||
//
|
||||
// Paste is normalised on the way in (pasteHtml.ts): a message copied out of
|
||||
// Gmail, Outlook or Word brings its own blank-line scaffolding, which our own
|
||||
@@ -62,7 +62,9 @@ import RichTextAIEdit from "@/components/app/ai/RichTextAIEdit";
|
||||
import RichTextAICaret from "@/components/app/ai/RichTextAICaret";
|
||||
import { useForms } from "@/lib/api/hooks/app/forms";
|
||||
import { EmailImage } from "./nodes/EmailImageNode";
|
||||
import { EmailButton } from "./nodes/EmailButtonNode";
|
||||
import { ImageBubble, ImageMenu } from "./ImageControls";
|
||||
import { ButtonBubble, ButtonInsert } from "./ButtonControls";
|
||||
import { AlignMenu, ColorMenu, TableMenu, TypeMenu } from "./DesignControls";
|
||||
import { insertImage, isSupportedImageFile, useImageUpload } from "./imageUpload";
|
||||
import { normalizePastedHTML } from "./pasteHtml";
|
||||
@@ -177,6 +179,7 @@ export default function RichTextEditor({
|
||||
ListItem,
|
||||
Link.configure({ openOnClick: false, autolink: true }),
|
||||
EmailImage,
|
||||
EmailButton,
|
||||
// Real email markup: table layout, <div> containers, colours,
|
||||
// fonts and alignment. Without these a pasted design keeps its
|
||||
// words and loses everything that made it a design.
|
||||
@@ -201,6 +204,20 @@ export default function RichTextEditor({
|
||||
} leading-relaxed text-slate-800 focus:outline-none`,
|
||||
},
|
||||
transformPastedHTML: (pasted) => normalizePastedHTML(pasted),
|
||||
handleDOMEvents: {
|
||||
// An <a> this editor renders itself — the wrapper around a
|
||||
// linked image, the anchor inside a button — sits in a node
|
||||
// that is not contenteditable, so a plain click follows it and
|
||||
// the dashboard navigates away mid-edit. Ctrl/Cmd still opens
|
||||
// it, which is how a link is opened from an editor anywhere.
|
||||
click: (_view, event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!event.metaKey && !event.ctrlKey && target?.closest?.("a[href]")) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
handlePaste: (_view, event) => {
|
||||
if (minimalRef.current) return false;
|
||||
const files = Array.from(event.clipboardData?.files ?? []).filter(isSupportedImageFile);
|
||||
@@ -334,8 +351,10 @@ export default function RichTextEditor({
|
||||
)}
|
||||
{!code && (
|
||||
<>
|
||||
{/* Select an image → size, alignment and alt text over it. */}
|
||||
{/* Select an image → size, alignment, alt text and link over it. */}
|
||||
<ImageBubble editor={editor} />
|
||||
{/* Select a button → its text, link, colour, size and shape. */}
|
||||
<ButtonBubble editor={editor} />
|
||||
{/* Select text → floating "Edit with AI" pill over the selection. */}
|
||||
<RichTextAIEdit editor={editor} />
|
||||
{/* Collapsed caret → sparkle companion + ⌘J to write with AI. */}
|
||||
@@ -508,6 +527,7 @@ function Toolbar({
|
||||
<Link2Icon className="w-3.5 h-3.5" />
|
||||
</Btn>
|
||||
<ImageMenu editor={editor} />
|
||||
<ButtonInsert editor={editor} />
|
||||
<Divider />
|
||||
<TypeMenu editor={editor} />
|
||||
<ColorMenu editor={editor} />
|
||||
|
||||
@@ -10,7 +10,12 @@ import { VARIABLES, SAMPLE, HTML_CHUNK_RE } from "@/lib/templateVars";
|
||||
export { VARIABLES, SAMPLE };
|
||||
|
||||
// Derive plain text from the editor HTML so both alternatives ship populated.
|
||||
export function htmlToPlain(html: string): string {
|
||||
//
|
||||
// `links` is on because the text/plain half of an email is unusable without the
|
||||
// destinations, and off for text that will be fed back into an editor: an
|
||||
// autolinked "x.test" whose href is "http://x.test" would gain the address on
|
||||
// every round trip and grow a tail (the AI-block prompt is stored that way).
|
||||
export function htmlToPlain(html: string, { links = true }: { links?: boolean } = {}): string {
|
||||
const withBreaks = html
|
||||
// An image has no text of its own, so the plain-text alternative would
|
||||
// silently lose whatever it carried. Its alt text stands in for it.
|
||||
@@ -33,6 +38,20 @@ export function htmlToPlain(html: string): string {
|
||||
doc.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
|
||||
if (/display\s*:\s*none/i.test(el.getAttribute("style") ?? "")) el.remove();
|
||||
});
|
||||
// A link's destination is the only way the text-only half of the email can
|
||||
// be followed, and a button or a linked image is nothing but its
|
||||
// destination. This is what the server renders for a body written in HTML
|
||||
// (internal/pkg/mailhtml), so the two halves agree.
|
||||
if (links) {
|
||||
doc.querySelectorAll("a[href]").forEach((a) => {
|
||||
const href = (a.getAttribute("href") ?? "").trim();
|
||||
// A merge token resolves per recipient and is not an address yet.
|
||||
if (!/^(https?:|mailto:)/i.test(href)) return;
|
||||
const label = (a.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
if (!label) a.textContent = href;
|
||||
else if (label !== href && `mailto:${label}` !== href) a.append(` (${href})`);
|
||||
});
|
||||
}
|
||||
return (doc.body.textContent || "").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -274,7 +274,10 @@ function AIVariableConfigBody({
|
||||
<RichTextEditor
|
||||
minimal
|
||||
html={initialHtml}
|
||||
onChange={(html) => patch({ prompt: htmlToPlain(html) })}
|
||||
// Without links: the prompt is read back into this
|
||||
// same editor next time, and an autolinked address
|
||||
// would gain a copy of itself on every round trip.
|
||||
onChange={(html) => patch({ prompt: htmlToPlain(html, { links: false }) })}
|
||||
variables={VARIABLES}
|
||||
placeholder="a warm one-line opener for {{.FirstName}} at {{.Company}}"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// The call-to-action button, through the editor that writes it (issue #433).
|
||||
//
|
||||
// A button is markup, not a class: what it renders IS what lands in the
|
||||
// recipient's client, and what it parses back is what an author sees when they
|
||||
// reopen the step. Both halves are asserted here, because a button that comes
|
||||
// back as a plain layout table is a design the editor quietly destroyed.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import Document from "@tiptap/extension-document";
|
||||
import Text from "@tiptap/extension-text";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import { emailDesignExtensions, EmailParagraph } from "./emailHtml";
|
||||
import { BUTTON_SWATCHES, EmailButton, readableTextColor } from "./EmailButtonNode";
|
||||
|
||||
const extensions = [
|
||||
Document,
|
||||
EmailParagraph,
|
||||
Text,
|
||||
Link.configure({ openOnClick: false, autolink: true }),
|
||||
EmailButton,
|
||||
// The table extensions are mounted here too, because the one thing that
|
||||
// could break parsing is the layout-table rule claiming this markup first.
|
||||
...emailDesignExtensions,
|
||||
];
|
||||
|
||||
function editorWith(html: string): Editor {
|
||||
return new Editor({ element: document.createElement("div"), extensions, content: html });
|
||||
}
|
||||
|
||||
function render(attrs: Record<string, unknown>): string {
|
||||
const editor = editorWith("<p>x</p>");
|
||||
editor.commands.insertEmailButton(attrs);
|
||||
const out = editor.getHTML();
|
||||
editor.destroy();
|
||||
return out;
|
||||
}
|
||||
|
||||
function roundTrip(html: string): string {
|
||||
const editor = editorWith(html);
|
||||
const out = editor.getHTML();
|
||||
editor.destroy();
|
||||
return out;
|
||||
}
|
||||
|
||||
function buttonAttrs(html: string): Record<string, unknown> | null {
|
||||
const editor = editorWith(html);
|
||||
let found: Record<string, unknown> | null = null;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "emailButton") found = node.attrs;
|
||||
});
|
||||
editor.destroy();
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("the call-to-action button", () => {
|
||||
it("renders a one-cell table carrying the colour and the padding", () => {
|
||||
const out = render({ label: "Book a call", href: "https://cal.test/me" });
|
||||
// The cell, not the anchor, holds both: Outlook lays out with Word,
|
||||
// which ignores display:inline-block and drops the anchor's padding.
|
||||
expect(out).toContain('bgcolor="#0369a1"');
|
||||
expect(out).toContain("padding: 12px 24px");
|
||||
expect(out).toContain("border-radius: 6px");
|
||||
expect(out).toContain('href="https://cal.test/me"');
|
||||
expect(out).toContain('target="_blank"');
|
||||
expect(out).toContain('rel="noopener noreferrer nofollow"');
|
||||
expect(out).toContain(">Book a call</a>");
|
||||
});
|
||||
|
||||
it("comes back exactly as it went out", () => {
|
||||
const html = render({
|
||||
label: "See the pricing",
|
||||
href: "https://warmbly.test/pricing",
|
||||
background: "#e11d48",
|
||||
color: "#ffffff",
|
||||
padding: "16px 32px",
|
||||
fontSize: 16,
|
||||
radius: "999px",
|
||||
align: "left",
|
||||
});
|
||||
expect(roundTrip(html)).toBe(html);
|
||||
|
||||
const attrs = buttonAttrs(html);
|
||||
expect(attrs).toMatchObject({
|
||||
label: "See the pricing",
|
||||
href: "https://warmbly.test/pricing",
|
||||
background: "#e11d48",
|
||||
padding: "16px 32px",
|
||||
fontSize: 16,
|
||||
radius: "999px",
|
||||
align: "left",
|
||||
width: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a size somebody tuned by hand in the HTML view", () => {
|
||||
// The presets set the padding and the type size; they are not the only
|
||||
// two values allowed, or editing the markup would silently undo itself.
|
||||
const attrs = buttonAttrs(
|
||||
'<table data-warmbly-button="" align="center"><tbody><tr>' +
|
||||
'<td bgcolor="#0369a1" style="border-radius:4px;padding:10px 30px">' +
|
||||
'<a href="https://x.test" style="color:#ffffff;font-size:15px">Talk to us</a>' +
|
||||
"</td></tr></tbody></table>",
|
||||
);
|
||||
expect(attrs).toMatchObject({ padding: "10px 30px", fontSize: 15, radius: "4px", label: "Talk to us" });
|
||||
});
|
||||
|
||||
it("is not claimed by the layout-table rule", () => {
|
||||
const editor = editorWith(render({ label: "Reply", href: "https://x.test" }));
|
||||
const names: string[] = [];
|
||||
editor.state.doc.descendants((node) => {
|
||||
names.push(node.type.name);
|
||||
});
|
||||
editor.destroy();
|
||||
expect(names).toContain("emailButton");
|
||||
expect(names).not.toContain("table");
|
||||
});
|
||||
|
||||
it("fills the column on request, and sizes to its label otherwise", () => {
|
||||
expect(render({ href: "https://x.test", width: "100%" })).toContain('width="100%"');
|
||||
expect(render({ href: "https://x.test" })).not.toContain('width="100%"');
|
||||
});
|
||||
|
||||
it("ships no anchor target when it has no link", () => {
|
||||
const out = render({ label: "Nowhere", href: "" });
|
||||
// An empty href is a dead link in every client; better a box that does
|
||||
// nothing than one that navigates to the message itself.
|
||||
expect(out).not.toContain("href=");
|
||||
expect(out).toContain(">Nowhere</a>");
|
||||
});
|
||||
|
||||
it("centres by attribute as well as by margin", () => {
|
||||
// Word reads align on the table and nothing else, so the margin alone
|
||||
// would leave the button hard left for every Outlook on Windows.
|
||||
const out = render({ href: "https://x.test", align: "center" });
|
||||
expect(out).toContain('align="center"');
|
||||
// jsdom collapses the four-value margin the node writes; a browser
|
||||
// keeps it as written, so the assertion allows both.
|
||||
expect(out).toMatch(/margin:\s*16px auto(?: 16px auto)?/);
|
||||
});
|
||||
|
||||
it("selects what it just inserted, so the bar that edits it is already open", () => {
|
||||
const editor = editorWith("<p>Hi</p>");
|
||||
editor.commands.insertEmailButton({ label: "Book a call" });
|
||||
const selection = editor.state.selection;
|
||||
expect(selection).toBeInstanceOf(NodeSelection);
|
||||
expect((selection as NodeSelection).node.type.name).toBe("emailButton");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("adds a second button rather than replacing the selected one", () => {
|
||||
// The toolbar is reachable while a button is selected, which is exactly
|
||||
// when its bar is open: pressing insert there must not throw away the
|
||||
// label, link and colour someone just set.
|
||||
const editor = editorWith("<p>Hi</p>");
|
||||
editor.commands.insertEmailButton({ label: "First", href: "https://a.test" });
|
||||
editor.commands.insertEmailButton({ label: "Second", href: "https://b.test" });
|
||||
const labels: string[] = [];
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "emailButton") labels.push(node.attrs.label as string);
|
||||
});
|
||||
editor.destroy();
|
||||
expect(labels).toEqual(["First", "Second"]);
|
||||
});
|
||||
|
||||
it("keeps a corner rounded on one side only", () => {
|
||||
const attrs = buttonAttrs(
|
||||
'<table data-warmbly-button=""><tbody><tr><td style="border-radius:12px 12px 0 0">' +
|
||||
'<a href="https://x.test">Top</a></td></tr></tbody></table>',
|
||||
);
|
||||
expect(attrs).toMatchObject({ radius: "12px 12px 0 0" });
|
||||
});
|
||||
|
||||
it("refuses an address no mail client would follow", () => {
|
||||
// The link mark blocks these, so the one place a URL is typed by hand
|
||||
// must not be the one that lets them through.
|
||||
for (const href of ["javascript:alert(1)", "java\tscript:alert(1)", "data:text/html,<b>x</b>"]) {
|
||||
expect(render({ label: "Go", href })).not.toContain("href=");
|
||||
}
|
||||
});
|
||||
|
||||
it("picks whichever label colour reads better on the background", () => {
|
||||
expect(readableTextColor("#0f172a")).toBe("#ffffff");
|
||||
expect(readableTextColor("#0369a1")).toBe("#ffffff");
|
||||
// A mid-tone is under any luminance threshold that keeps white off
|
||||
// yellow, and still reads better in dark type: 5.6:1 against 3.2:1.
|
||||
expect(readableTextColor("#d97706")).toBe("#0f172a");
|
||||
expect(readableTextColor("#fde047")).toBe("#0f172a");
|
||||
expect(readableTextColor("nonsense")).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("offers only backgrounds a white label passes AA on", () => {
|
||||
// The palette is picked so the colour someone chooses is legible, not
|
||||
// just so the function would cope if it were not.
|
||||
for (const swatch of BUTTON_SWATCHES) {
|
||||
expect(readableTextColor(swatch.value)).toBe("#ffffff");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
// The call-to-action button for the campaign body editor (issue #433).
|
||||
//
|
||||
// A button in email is not a <button>: no client renders one, and every client
|
||||
// strips it. It is an anchor a table is holding up. Outlook on Windows lays out
|
||||
// with Word, which ignores `display:inline-block` outright, so padding on the
|
||||
// anchor collapses and a styled <a> becomes a coloured word. Putting the colour
|
||||
// and the padding on a one-cell table instead is the shape that holds
|
||||
// everywhere, which is why this renders markup rather than a class.
|
||||
//
|
||||
// The node is an atom: the label is an attribute edited in the floating bar,
|
||||
// the same way an image's alt text is. Merge fields and spintax in the label
|
||||
// are plain text by the time the send path reads the body, so they still work.
|
||||
|
||||
import { Node } from "@tiptap/core";
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import { MAIL_LINK_ATTRS, mailHref } from "./EmailImageNode";
|
||||
|
||||
export type ButtonAlign = "left" | "center" | "right";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
// Padding on the cell and type size on the anchor, as one choice. Both are
|
||||
// stored as they were written rather than as the name of a preset, so a value
|
||||
// tuned by hand in the HTML view survives coming back to the visual editor.
|
||||
export const BUTTON_SIZES: { key: ButtonSize; label: string; title: string; padding: string; fontSize: number }[] = [
|
||||
{ key: "sm", label: "S", title: "Small", padding: "8px 16px", fontSize: 13 },
|
||||
{ key: "md", label: "M", title: "Medium", padding: "12px 24px", fontSize: 14 },
|
||||
{ key: "lg", label: "L", title: "Large", padding: "16px 32px", fontSize: 16 },
|
||||
];
|
||||
|
||||
// A pill is 999px rather than 50%: Outlook resolves a percentage radius
|
||||
// against the wrong box and squares the corners off. Stored as the CSS value
|
||||
// rather than a number, so a corner rounded on one side only in the HTML view
|
||||
// is not squared off on the way back.
|
||||
export const BUTTON_RADII: { label: string; title: string; value: string }[] = [
|
||||
{ label: "Square", title: "Square corners", value: "0" },
|
||||
{ label: "Rounded", title: "Rounded corners", value: "6px" },
|
||||
{ label: "Pill", title: "Fully rounded", value: "999px" },
|
||||
];
|
||||
|
||||
const BUTTON_DEFAULT_RADIUS = "6px";
|
||||
|
||||
// The house colours, at the shade that can carry a white label: sky, emerald
|
||||
// and amber are all under 4.5:1 against white one step lighter than this, and a
|
||||
// call to action nobody can read is the one thing a button may not be. Any
|
||||
// colour still gets whichever label reads better (readableTextColor); these are
|
||||
// the ones offered because they read well and look like the product.
|
||||
export const BUTTON_SWATCHES: { label: string; value: string }[] = [
|
||||
{ label: "Sky", value: "#0369a1" },
|
||||
{ label: "Indigo", value: "#4f46e5" },
|
||||
{ label: "Emerald", value: "#047857" },
|
||||
{ label: "Amber", value: "#b45309" },
|
||||
{ label: "Rose", value: "#e11d48" },
|
||||
{ label: "Slate", value: "#334155" },
|
||||
{ label: "Black", value: "#0f172a" },
|
||||
];
|
||||
|
||||
export const BUTTON_DEFAULT_LABEL = "Book a call";
|
||||
export const BUTTON_DEFAULT_BACKGROUND = "#0369a1";
|
||||
|
||||
// The two colours a label may be. Nothing in between: a button is one solid
|
||||
// block, and the only question is which of these two can be read on it.
|
||||
const LABEL_LIGHT = "#ffffff";
|
||||
const LABEL_DARK = "#0f172a";
|
||||
|
||||
// The font stack is written out because a button is the one place in a body
|
||||
// that must not inherit a template's decorative face and fall back at random.
|
||||
const BUTTON_FONT = "Arial, Helvetica, sans-serif";
|
||||
|
||||
// luminance is WCAG 2's relative luminance, or null for anything that is not a
|
||||
// hex colour (a named colour, a gradient, a value someone mistyped).
|
||||
function luminance(color: string): number | null {
|
||||
const hex = color.trim().replace(/^#/, "");
|
||||
const full = hex.length === 3 ? [...hex].map((c) => c + c).join("") : hex;
|
||||
if (!/^[0-9a-f]{6}$/i.test(full)) return null;
|
||||
const channel = (at: number) => {
|
||||
const c = Number.parseInt(full.slice(at, at + 2), 16) / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4);
|
||||
}
|
||||
|
||||
function contrast(a: number, b: number): number {
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
|
||||
const LIGHT_LUMINANCE = luminance(LABEL_LIGHT) as number;
|
||||
const DARK_LUMINANCE = luminance(LABEL_DARK) as number;
|
||||
|
||||
// readableTextColor picks the label colour the background can actually carry.
|
||||
// It compares the two contrast ratios rather than testing luminance against a
|
||||
// threshold: a mid-tone like amber is under any threshold that keeps white on
|
||||
// yellow, and still reads better in dark type than in white.
|
||||
export function readableTextColor(background: string): string {
|
||||
const l = luminance(background);
|
||||
if (l === null) return LABEL_LIGHT;
|
||||
return contrast(l, LIGHT_LUMINANCE) >= contrast(l, DARK_LUMINANCE) ? LABEL_LIGHT : LABEL_DARK;
|
||||
}
|
||||
|
||||
// decl reads one declaration off an element's own style attribute. The DOM
|
||||
// would answer too, but it normalises a colour to rgb() on the way, and the
|
||||
// hex the author picked is what has to come back.
|
||||
function decl(el: Element | null | undefined, prop: string): string {
|
||||
const raw = el?.getAttribute("style") ?? "";
|
||||
const match = new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`, "i").exec(raw);
|
||||
return match ? match[1].trim() : "";
|
||||
}
|
||||
|
||||
// marginFor is the button's placement and its breathing room, written out
|
||||
// because the editing surface has to show the same box the email will: a
|
||||
// stylesheet margin here would be one the recipient never gets.
|
||||
function marginFor(align: ButtonAlign): string {
|
||||
const left = align === "left" ? "0" : "auto";
|
||||
const right = align === "right" ? "0" : "auto";
|
||||
return `margin:16px ${right} 16px ${left}`;
|
||||
}
|
||||
|
||||
export const EmailButton = Node.create({
|
||||
name: "emailButton",
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
label: { default: BUTTON_DEFAULT_LABEL },
|
||||
href: { default: "" },
|
||||
background: { default: BUTTON_DEFAULT_BACKGROUND },
|
||||
color: { default: readableTextColor(BUTTON_DEFAULT_BACKGROUND) },
|
||||
padding: { default: BUTTON_SIZES[1].padding },
|
||||
fontSize: { default: BUTTON_SIZES[1].fontSize },
|
||||
radius: { default: BUTTON_DEFAULT_RADIUS },
|
||||
align: { default: "center" as ButtonAlign },
|
||||
// "100%" is the full-column button a phone gets pressed on. Any
|
||||
// other width is one somebody wrote themselves, and it is kept.
|
||||
width: { default: null as string | null },
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
// Above the layout-table rule, which would otherwise claim this
|
||||
// markup first and leave a two-cell table nobody can edit.
|
||||
priority: 100,
|
||||
tag: "table[data-warmbly-button]",
|
||||
getAttrs: (element) => {
|
||||
const table = element as HTMLElement;
|
||||
const cell = table.querySelector("td");
|
||||
const anchor = table.querySelector("a");
|
||||
const background =
|
||||
cell?.getAttribute("bgcolor") || decl(cell, "background-color") || BUTTON_DEFAULT_BACKGROUND;
|
||||
const fontSize = Number.parseInt(decl(anchor, "font-size"), 10);
|
||||
const declaredAlign = (table.getAttribute("align") ?? "").toLowerCase();
|
||||
return {
|
||||
label: anchor?.textContent?.trim() || BUTTON_DEFAULT_LABEL,
|
||||
href: anchor?.getAttribute("href") ?? "",
|
||||
background,
|
||||
color: decl(anchor, "color") || readableTextColor(background),
|
||||
padding: decl(cell, "padding") || BUTTON_SIZES[1].padding,
|
||||
fontSize: Number.isFinite(fontSize) ? fontSize : BUTTON_SIZES[1].fontSize,
|
||||
radius: decl(cell, "border-radius") || BUTTON_DEFAULT_RADIUS,
|
||||
align: declaredAlign === "left" || declaredAlign === "right" ? declaredAlign : "center",
|
||||
width: table.getAttribute("width"),
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ node }) {
|
||||
const { label, href, background, color, padding, fontSize, radius, width } = node.attrs as {
|
||||
label: string;
|
||||
href: string;
|
||||
background: string;
|
||||
color: string;
|
||||
padding: string;
|
||||
fontSize: number;
|
||||
radius: string;
|
||||
width: string | null;
|
||||
};
|
||||
const align = (node.attrs.align as ButtonAlign) ?? "center";
|
||||
|
||||
const table: Record<string, string> = {
|
||||
"data-warmbly-button": "",
|
||||
role: "presentation",
|
||||
cellpadding: "0",
|
||||
cellspacing: "0",
|
||||
border: "0",
|
||||
align,
|
||||
style: `border-collapse:separate;${width ? `width:${width};` : ""}${marginFor(align)}`,
|
||||
};
|
||||
if (width) table.width = width;
|
||||
|
||||
const cell = {
|
||||
align: "center",
|
||||
// bgcolor as well as the style: Word reads the attribute, and a
|
||||
// button that loses its colour there is just underlined text.
|
||||
bgcolor: background,
|
||||
style: `background-color:${background};border-radius:${radius};padding:${padding};text-align:center`,
|
||||
};
|
||||
|
||||
const anchor: Record<string, string> = {
|
||||
style:
|
||||
`display:inline-block;color:${color};font-family:${BUTTON_FONT};` +
|
||||
`font-size:${fontSize}px;font-weight:600;line-height:1.2;text-decoration:none`,
|
||||
};
|
||||
// An empty href is a dead link in every client, so a button without one
|
||||
// ships as the box it looks like and nothing more.
|
||||
const address = mailHref(href);
|
||||
if (address) Object.assign(anchor, { href: address, ...MAIL_LINK_ATTRS });
|
||||
|
||||
return ["table", table, ["tbody", ["tr", ["td", cell, ["a", anchor, label]]]]];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
const name = this.name;
|
||||
return {
|
||||
insertEmailButton:
|
||||
(attrs = {}) =>
|
||||
({ chain, state }) =>
|
||||
chain()
|
||||
// After the selection rather than over it: the toolbar
|
||||
// is reachable while a button is selected, which is
|
||||
// exactly when its bar is open, and pressing insert
|
||||
// there means "another one", not "throw this one away".
|
||||
.insertContentAt(state.selection.to, { type: name, attrs })
|
||||
// Select what was just placed, so the bar that edits it
|
||||
// opens without a second click. It is the last button
|
||||
// at or before the caret, which after an insert is the
|
||||
// new one.
|
||||
.command(({ tr, dispatch }) => {
|
||||
let at: number | null = null;
|
||||
tr.doc.nodesBetween(0, tr.selection.from, (node, pos) => {
|
||||
if (node.type.name === name) at = pos;
|
||||
});
|
||||
if (at !== null && dispatch) tr.setSelection(NodeSelection.create(tr.doc, at));
|
||||
return true;
|
||||
})
|
||||
.run(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
emailButton: {
|
||||
/** Place a call-to-action button at the caret. */
|
||||
insertEmailButton: (attrs?: Record<string, unknown>) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default EmailButton;
|
||||
@@ -0,0 +1,122 @@
|
||||
// A body image, and the anchor it can wear (issues #380, #433).
|
||||
//
|
||||
// ProseMirror cannot put the link mark on an image — a block leaf takes no
|
||||
// marks — so the anchor is part of what the node renders and is read back off
|
||||
// the parent <a>. That round trip is the whole feature: an href the editor
|
||||
// writes but cannot parse is a link that disappears when the step is reopened.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import Document from "@tiptap/extension-document";
|
||||
import Text from "@tiptap/extension-text";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import { emailDesignExtensions, EmailParagraph } from "./emailHtml";
|
||||
import { absoluteHref, EmailImage } from "./EmailImageNode";
|
||||
|
||||
const extensions = [
|
||||
Document,
|
||||
EmailParagraph,
|
||||
Text,
|
||||
Link.configure({ openOnClick: false, autolink: true }),
|
||||
EmailImage,
|
||||
...emailDesignExtensions,
|
||||
];
|
||||
|
||||
function editorWith(html: string): Editor {
|
||||
return new Editor({ element: document.createElement("div"), extensions, content: html });
|
||||
}
|
||||
|
||||
function roundTrip(html: string): string {
|
||||
const editor = editorWith(html);
|
||||
const out = editor.getHTML();
|
||||
editor.destroy();
|
||||
return out;
|
||||
}
|
||||
|
||||
function imageAttrs(html: string): Record<string, unknown> | null {
|
||||
const editor = editorWith(html);
|
||||
let found: Record<string, unknown> | null = null;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === "image") found = node.attrs;
|
||||
});
|
||||
editor.destroy();
|
||||
return found;
|
||||
}
|
||||
|
||||
const SRC = "https://cdn.test/logo.png";
|
||||
|
||||
// jsdom reflects the style attribute through the CSSOM and re-serializes it,
|
||||
// so a length written as `0` comes back as `0px`. A browser keeps what was
|
||||
// written, hence the tolerance rather than a literal.
|
||||
const margin = (sides: string) => new RegExp(`margin:\\s*${sides.replace(/ /g, "(?:px)?\\s+")}(?:px)?`);
|
||||
|
||||
describe("a body image", () => {
|
||||
it("renders bare when it links nowhere", () => {
|
||||
const out = roundTrip(`<img src="${SRC}" alt="Logo">`);
|
||||
expect(out).toContain("<img");
|
||||
expect(out).not.toContain("<a");
|
||||
expect(out).toMatch(margin("0 auto 0 0"));
|
||||
});
|
||||
|
||||
it("wraps itself in the anchor it was given, and reads it back", () => {
|
||||
const out = roundTrip(
|
||||
`<a href="https://warmbly.test/demo"><img src="${SRC}" alt="Logo" width="300" data-align="center"></a>`,
|
||||
);
|
||||
expect(out).toContain('<a href="https://warmbly.test/demo"');
|
||||
expect(out).toContain('target="_blank"');
|
||||
expect(out).toContain('rel="noopener noreferrer nofollow"');
|
||||
expect(roundTrip(out)).toBe(out);
|
||||
expect(imageAttrs(out)).toMatchObject({ href: "https://warmbly.test/demo", width: 300, align: "center" });
|
||||
});
|
||||
|
||||
it("gives the anchor the image's own box, so the click area is the picture", () => {
|
||||
const out = roundTrip(`<a href="https://x.test"><img src="${SRC}" width="300" data-align="center"></a>`);
|
||||
const anchor = out.slice(out.indexOf("<a"), out.indexOf("<img"));
|
||||
expect(anchor).toContain("width: 300px");
|
||||
expect(anchor).toMatch(/margin:\s*0(?:px)?\s+auto/);
|
||||
// And on the image as well: inside a box it already fills that is a
|
||||
// no-op, and it is the only centring left in a client that ignores
|
||||
// display:block on an anchor.
|
||||
expect(out.slice(out.indexOf("<img"))).toMatch(/margin:\s*0(?:px)?\s+auto/);
|
||||
});
|
||||
|
||||
it("keeps the margins on the image when it has no width to hand over", () => {
|
||||
const out = roundTrip(`<a href="https://x.test"><img src="${SRC}" data-align="right"></a>`);
|
||||
expect(out.slice(out.indexOf("<img"))).toMatch(margin("0 0 0 auto"));
|
||||
});
|
||||
|
||||
it("refuses an address no mail client would follow", () => {
|
||||
const out = roundTrip(`<a href="javascript:alert(1)"><img src="${SRC}" alt="Logo"></a>`);
|
||||
expect(out).not.toContain("<a");
|
||||
expect(out).not.toContain("javascript");
|
||||
});
|
||||
|
||||
it("takes an href only from the anchor wrapping it", () => {
|
||||
// A template that links a whole layout block must not hand its address
|
||||
// to every picture inside it.
|
||||
const attrs = imageAttrs(
|
||||
`<table><tr><td><a href="https://x.test"><p>Read on</p></a></td></tr></table><img src="${SRC}">`,
|
||||
);
|
||||
expect(attrs).toMatchObject({ href: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the address someone typed", () => {
|
||||
it("becomes the one they meant", () => {
|
||||
expect(absoluteHref("cal.test/me")).toBe("https://cal.test/me");
|
||||
expect(absoluteHref(" warmbly.com ")).toBe("https://warmbly.com");
|
||||
});
|
||||
|
||||
it("leaves alone anything that already says what it is", () => {
|
||||
expect(absoluteHref("https://x.test")).toBe("https://x.test");
|
||||
expect(absoluteHref("mailto:sam@x.test")).toBe("mailto:sam@x.test");
|
||||
expect(absoluteHref("//cdn.test/a")).toBe("//cdn.test/a");
|
||||
expect(absoluteHref("{{.UnsubscribeLink}}")).toBe("{{.UnsubscribeLink}}");
|
||||
expect(absoluteHref("/pricing")).toBe("/pricing");
|
||||
expect(absoluteHref("")).toBe("");
|
||||
});
|
||||
|
||||
it("does not guess at something that is not an address yet", () => {
|
||||
expect(absoluteHref("cal")).toBe("cal");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,10 @@
|
||||
// the tag itself. Width is written as both (Outlook reads the attribute), and
|
||||
// alignment as auto margins on a block image, which is the one centring trick
|
||||
// every client honours.
|
||||
//
|
||||
// An image can also be a link (issue #433). ProseMirror cannot put the link
|
||||
// mark on it — a block leaf takes no marks — so the anchor is part of what this
|
||||
// node renders, and read back off the parent <a> on the way in.
|
||||
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import Image from "@tiptap/extension-image";
|
||||
@@ -22,12 +26,58 @@ export const IMAGE_SIZE_PRESETS: { label: string; title: string; width: number |
|
||||
{ label: "Auto", title: "The image's own size", width: null },
|
||||
];
|
||||
|
||||
// What every anchor this editor writes carries, matching the link mark's own
|
||||
// defaults so a linked image and a linked word behave the same in a webmail.
|
||||
export const MAIL_LINK_ATTRS = { target: "_blank", rel: "noopener noreferrer nofollow" };
|
||||
|
||||
// Schemes that do nothing in a mail client and are an attack anywhere else the
|
||||
// body is rendered. The link mark refuses them, so an image and a button have
|
||||
// to as well, or the one place a URL is typed by hand is the one that does not.
|
||||
const UNSAFE_SCHEME = /^(javascript|data|vbscript|file):/i;
|
||||
|
||||
// mailHref is the address an anchor may actually carry: unsafe schemes become
|
||||
// nothing at all, and everything else is written as given.
|
||||
export function mailHref(raw: string): string {
|
||||
const href = raw.trim();
|
||||
// Characters a URL parser ignores come out before the scheme is read:
|
||||
// "java<tab>script:" is a javascript: URL to a browser, and is not one to
|
||||
// a regular expression.
|
||||
const bare = [...href].filter((c) => c.charCodeAt(0) > 0x20).join("");
|
||||
return UNSAFE_SCHEME.test(bare) ? "" : href;
|
||||
}
|
||||
|
||||
// absoluteHref upgrades what someone typed into what they meant: a bare host is
|
||||
// a relative path to every mail client, which then goes nowhere, is never
|
||||
// counted as a click, and is left out of the plain-text half. Anything already
|
||||
// carrying a scheme, a merge token, or a path is left exactly as written.
|
||||
export function absoluteHref(raw: string): string {
|
||||
const href = raw.trim();
|
||||
if (!href || /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//") || href.includes("{{")) return href;
|
||||
return /^[^\s/]+\.[^\s/]/.test(href) ? `https://${href}` : href;
|
||||
}
|
||||
|
||||
function readWidth(el: HTMLElement): number | null {
|
||||
const raw = el.getAttribute("width") || el.style.width || "";
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
// The anchor an image is wrapped in, and only the one wrapping it directly:
|
||||
// a template that links a whole layout block would otherwise hand its href to
|
||||
// every picture inside it.
|
||||
function readHref(el: HTMLElement): string | null {
|
||||
const parent = el.parentElement;
|
||||
if (!parent || parent.tagName !== "A") return null;
|
||||
return parent.getAttribute("href") || null;
|
||||
}
|
||||
|
||||
// marginFor is the alignment rule: auto margins on a block, which is the one
|
||||
// centring trick every mail client honours.
|
||||
function marginFor(align: ImageAlign): string {
|
||||
if (align === "center") return "margin:0 auto";
|
||||
return align === "right" ? "margin:0 0 0 auto" : "margin:0 auto 0 0";
|
||||
}
|
||||
|
||||
export const EmailImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
@@ -53,19 +103,42 @@ export const EmailImage = Image.extend({
|
||||
},
|
||||
renderHTML: (attrs) => ({ "data-align": (attrs.align as ImageAlign) ?? "left" }),
|
||||
},
|
||||
href: {
|
||||
default: null as string | null,
|
||||
parseHTML: (el) => readHref(el as HTMLElement),
|
||||
// Rendered as the wrapping anchor, never as an attribute.
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const width = typeof node.attrs.width === "number" ? node.attrs.width : null;
|
||||
const align = (node.attrs.align as ImageAlign) ?? "left";
|
||||
const href = typeof node.attrs.href === "string" ? mailHref(node.attrs.href) : "";
|
||||
const style = ["display:block", "max-width:100%", "height:auto", "border:0"];
|
||||
if (width) style.push(`width:${width}px`);
|
||||
style.push(align === "center" ? "margin:0 auto" : align === "right" ? "margin:0 0 0 auto" : "margin:0 auto 0 0");
|
||||
|
||||
const extra: Record<string, string> = { style: style.join(";") };
|
||||
const extra: Record<string, string> = {};
|
||||
if (width) extra.width = String(width);
|
||||
return ["img", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, extra)];
|
||||
if (!href) {
|
||||
extra.style = [...style, marginFor(align)].join(";");
|
||||
return ["img", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, extra)];
|
||||
}
|
||||
|
||||
// A known width lets the anchor take the image's own box, so the
|
||||
// clickable area is the picture rather than the whole row, and the
|
||||
// alignment goes there with it. It stays on the image too: inside a box
|
||||
// the image already fills that is a no-op, and in a client that ignores
|
||||
// display:block on an anchor it is the only centring left.
|
||||
const wrapper = ["display:block", "text-decoration:none", "border:0"];
|
||||
if (width) wrapper.push(`width:${width}px`, marginFor(align));
|
||||
extra.style = [...style, marginFor(align)].join(";");
|
||||
return [
|
||||
"a",
|
||||
{ href, ...MAIL_LINK_ATTRS, style: wrapper.join(";") },
|
||||
["img", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, extra)],
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -83,3 +83,46 @@ describe("htmlToPlain with images", () => {
|
||||
expect(htmlToPlain('<p>Hi</p><img src="https://x.test/a.png">')).toBe("Hi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("htmlToPlain with links", () => {
|
||||
it("keeps a link's destination after its text", () => {
|
||||
expect(htmlToPlain('<p>See our <a href="https://x.test/pricing">pricing</a></p>')).toBe(
|
||||
"See our pricing (https://x.test/pricing)",
|
||||
);
|
||||
});
|
||||
|
||||
it("gives a linked image its alt text and its destination", () => {
|
||||
expect(
|
||||
htmlToPlain('<a href="https://x.test/demo"><img src="https://x.test/a.png" alt="Watch the demo"></a>'),
|
||||
).toBe("[Watch the demo] (https://x.test/demo)");
|
||||
});
|
||||
|
||||
it("renders a button as its label and where it goes", () => {
|
||||
expect(
|
||||
htmlToPlain(
|
||||
'<table data-warmbly-button=""><tbody><tr><td><a href="https://cal.test/me">Book a call</a></td></tr></tbody></table>',
|
||||
),
|
||||
).toBe("Book a call (https://cal.test/me)");
|
||||
});
|
||||
|
||||
it("says an address once when the text already is the address", () => {
|
||||
expect(htmlToPlain('<p><a href="https://x.test">https://x.test</a></p>')).toBe("https://x.test");
|
||||
expect(htmlToPlain('<p><a href="mailto:sam@x.test">sam@x.test</a></p>')).toBe("sam@x.test");
|
||||
});
|
||||
|
||||
it("leaves a merge token alone, because it is not an address yet", () => {
|
||||
expect(htmlToPlain('<p><a href="{{.UnsubscribeLink}}">Unsubscribe</a></p>')).toBe("Unsubscribe");
|
||||
});
|
||||
|
||||
it("leaves them alone for text that goes back into an editor", () => {
|
||||
// An autolinked address would gain a copy of itself on every round
|
||||
// trip, which is how the AI-block prompt is stored.
|
||||
expect(htmlToPlain('<p>See <a href="https://x.test/a">our page</a></p>', { links: false })).toBe(
|
||||
"See our page",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the address when the link has no text", () => {
|
||||
expect(htmlToPlain('<p><a href="https://x.test/a"></a></p>')).toBe("https://x.test/a");
|
||||
});
|
||||
});
|
||||
|
||||
+16
-1
@@ -449,7 +449,10 @@ svg.loading circle {
|
||||
a sensible ceiling, and a visible ring while the image is the selection the
|
||||
floating size/alignment bar acts on. */
|
||||
.tiptap-body img { max-width: 100%; height: auto; border-radius: 2px; }
|
||||
.tiptap-body img.ProseMirror-selectednode {
|
||||
.tiptap-body img.ProseMirror-selectednode,
|
||||
/* A linked image is an <a> wrapping the <img>, so the selection lands on the
|
||||
anchor and the ring has to be able to live there too. */
|
||||
.tiptap-body a.ProseMirror-selectednode {
|
||||
outline: 2px solid #38bdf8; /* sky-400 */
|
||||
outline-offset: 1px;
|
||||
}
|
||||
@@ -473,6 +476,18 @@ svg.loading circle {
|
||||
background: rgba(2, 132, 199, 0.08); /* sky-600 */
|
||||
}
|
||||
|
||||
/* Call-to-action buttons. The sent markup is a one-cell table, which the
|
||||
layout-table rules above would otherwise stretch to the container and draw a
|
||||
dashed editing grid on. Everything else about a button (its colour, padding,
|
||||
corners and margins) is inline, so the editing surface shows the box the
|
||||
recipient gets rather than a dashboard-only approximation of it. */
|
||||
.tiptap-body table[data-warmbly-button]:not([width]) { width: auto; }
|
||||
.tiptap-body table[data-warmbly-button] td { border: 0; }
|
||||
.tiptap-body table[data-warmbly-button].ProseMirror-selectednode {
|
||||
outline: 2px solid #38bdf8; /* sky-400 */
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The signature is other people's markup mounted in the dashboard's own DOM,
|
||||
because a contentEditable is how the visual editor works. Paint containment
|
||||
makes this box the containing block for anything positioned inside it and
|
||||
|
||||
Reference in New Issue
Block a user