Compose: a popup for links — Open / Edit / Delete — and for creating them

Clicking a link in the message shows "Open the link: <URL> | Edit | Delete"
just below it. Open opens http(s)/mailto links in a new tab (a pasted
javascript: link is shown but never run); Edit swaps in an inline URL field;
Delete removes the link and keeps its words. Click elsewhere, Escape or
typing closes it — and Escape stops there instead of also minimising the
compose window.

The toolbar's link button uses the same floating field instead of the
browser's prompt: under the selected text (kept highlighted with a CSS
highlight while the field has focus), at the caret with the address as the
text, or — with the caret inside a link — editing that link rather than
nesting a new one. A bare "example.com" becomes https://example.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 23:21:44 +02:00
co-authored by Claude Opus 5.5
parent b1bdae6e59
commit b157c5c9bb
3 changed files with 240 additions and 3 deletions
@@ -185,6 +185,13 @@ export const RichTextEditor = forwardRef<
// Currently-selected inline image + its handle box (px, relative to the editor).
const [selImg, setSelImg] = useState<HTMLImageElement | null>(null);
const [box, setBox] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
// A clicked link and the popup under it: "Open the link: <URL> | Edit | Delete".
const [selLink, setSelLink] = useState<HTMLAnchorElement | null>(null);
const [linkPos, setLinkPos] = useState<{ left: number; top: number } | null>(null);
const [linkEdit, setLinkEdit] = useState<string | null>(null); // the URL being edited
// A link being created from the toolbar: the selection (or caret) it will wrap.
const [newLinkRange, setNewLinkRange] = useState<Range | null>(null);
const linkPopupRef = useRef<HTMLDivElement>(null);
// A reply's quoted original starts folded away behind the "•••" pill.
const [quoteOpen, setQuoteOpen] = useState(false);
@@ -310,9 +317,34 @@ export const RichTextEditor = forwardRef<
refreshActive();
};
// The toolbar's link button opens the same floating field as a link's Edit:
// under the selected text, or at the caret. A caret inside an existing link
// edits that link instead of nesting a new one.
const link = () => {
const url = window.prompt(t("compose.linkPrompt"), "https://");
if (url) exec("createLink", url);
const ed = ref.current;
if (!ed) return;
const sel = window.getSelection();
let range =
sel && sel.rangeCount > 0 && ed.contains(sel.anchorNode)
? sel.getRangeAt(0).cloneRange()
: (savedRange.current?.cloneRange() ?? null);
if (!range) {
// Never clicked into the message: the end of what was typed.
range = document.createRange();
range.selectNodeContents(ed);
range.collapse(false);
}
const start = range.startContainer;
const inLink = (start instanceof Element ? start : start.parentElement)?.closest("a");
if (inLink && ed.contains(inLink)) {
setNewLinkRange(null);
setSelLink(inLink);
setLinkEdit(inLink.getAttribute("href") ?? "");
return;
}
setSelLink(null);
setNewLinkRange(range);
setLinkEdit("https://");
};
const applyColor = (kind: "fore" | "hilite", color: string) => {
@@ -406,9 +438,131 @@ export const RichTextEditor = forwardRef<
return;
}
setSelImg(el.tagName === "IMG" ? (el as HTMLImageElement) : null);
const a = el.closest("a");
setSelLink(a && ref.current?.contains(a) ? a : null);
setLinkEdit(null);
};
// Where the link popup goes: just under the link (or the text becoming one),
// kept inside the editor.
const computeLinkPos = useCallback(() => {
const ed = ref.current;
if (!ed) return null;
let ar: DOMRect | null = null;
if (selLink?.isConnected) ar = selLink.getBoundingClientRect();
else if (newLinkRange) {
// A bare caret can measure as an empty box; its line box still has a place.
ar = newLinkRange.getBoundingClientRect();
if (!ar.width && !ar.height) ar = newLinkRange.getClientRects()[0] ?? null;
}
const er = ed.getBoundingClientRect();
if (!ar || (!ar.width && !ar.height && !ar.top)) return newLinkRange ? { left: 12, top: 12 } : null;
const left = Math.max(8, Math.min(ar.left - er.left, er.width - 320));
return { left, top: ar.bottom - er.top + 4 };
}, [selLink, newLinkRange]);
useEffect(() => setLinkPos(computeLinkPos()), [selLink, newLinkRange, computeLinkPos]);
// While the URL field has focus the browser hides the text selection, so the
// words about to become a link would look unselected. Keep them marked with a
// CSS highlight (styled in index.css; nothing is written into the message).
useEffect(() => {
const registry = (globalThis.CSS as unknown as { highlights?: Map<string, unknown> }).highlights;
const HighlightCtor = (globalThis as unknown as { Highlight?: new (r: Range) => unknown }).Highlight;
if (!registry || !HighlightCtor || !newLinkRange || newLinkRange.collapsed) return;
registry.set("wmc-link-target", new HighlightCtor(newLinkRange));
return () => {
registry.delete("wmc-link-target");
};
}, [newLinkRange]);
// Close on a click elsewhere; follow the link on resize.
useEffect(() => {
if (!selLink && !newLinkRange) return;
const onWin = () => setLinkPos(computeLinkPos());
const onDown = (e: MouseEvent) => {
const el = e.target as Node;
if (linkPopupRef.current?.contains(el) || selLink?.contains(el)) return;
setSelLink(null);
setNewLinkRange(null);
setLinkEdit(null);
};
window.addEventListener("resize", onWin);
document.addEventListener("mousedown", onDown);
return () => {
window.removeEventListener("resize", onWin);
document.removeEventListener("mousedown", onDown);
};
}, [selLink, newLinkRange, computeLinkPos]);
const closeLink = () => {
setSelLink(null);
setNewLinkRange(null);
setLinkEdit(null);
};
// Only web and mail links open from the popup: a pasted javascript: URL must
// never run in the app's own page.
const openableHref = (a: HTMLAnchorElement) =>
/^(https?:|mailto:)/i.test(a.getAttribute("href") ?? "");
// "Delete" removes the link, not its words: the text stays where it was.
const unlink = () => {
const a = selLink;
if (!a) return;
a.replaceWith(...Array.from(a.childNodes));
emit();
closeLink();
};
const saveLink = () => {
let url = (linkEdit ?? "").trim();
if (url === "https://" || url === "http://") url = ""; // the untouched prefill
// "example.com" means the web address, as the browser's own bar would read it.
if (url && !/^[a-z][a-z0-9+.-]*:/i.test(url)) url = `https://${url}`;
if (newLinkRange) {
const ed = ref.current;
if (url && ed) {
ed.focus();
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(newLinkRange);
if (newLinkRange.collapsed) {
// Nothing selected: the address itself becomes the linked text.
const safe = url.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
document.execCommand("insertHTML", false, `<a href="${safe}">${safe}</a>`);
} else {
document.execCommand("createLink", false, url);
}
emit();
refreshActive();
}
closeLink();
return;
}
const a = selLink;
if (!a) return;
if (!url) {
unlink();
return;
}
a.setAttribute("href", url);
emit();
closeLink();
};
const onEditorKeyDown = (e: React.KeyboardEvent) => {
// Typing (or Escape) in the message puts the link popup away. Escape stops
// here: the mail view's own Escape would otherwise minimise the whole window.
if (selLink || newLinkRange) {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
}
closeLink();
}
if (selImg && (e.key === "Delete" || e.key === "Backspace")) {
e.preventDefault();
selImg.remove();
@@ -588,9 +742,81 @@ export const RichTextEditor = forwardRef<
onMouseUp={refreshActive}
onClick={onEditorClick}
onKeyDown={onEditorKeyDown}
onScroll={() => setBox(computeBox())}
onScroll={() => {
setBox(computeBox());
setLinkPos(computeLinkPos());
}}
className={`wmc-editor ${quoteOpen ? "" : "wmc-quote-collapsed"} ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`}
/>
{(selLink || newLinkRange) && linkPos && (
<div
ref={linkPopupRef}
role="dialog"
aria-label={newLinkRange ? t("compose.linkPrompt") : t("compose.linkOpen")}
className="absolute z-20 flex max-w-[min(32rem,calc(100%-16px))] items-center gap-2 rounded-lg border border-app-border bg-app-elevated px-3 py-2 text-[13px] text-app-fg shadow-xl"
style={{ left: linkPos.left, top: linkPos.top }}
>
{linkEdit === null && selLink ? (
<>
<span className="shrink-0 text-app-muted">{t("compose.linkOpen")}</span>
{openableHref(selLink) ? (
<a
href={selLink.getAttribute("href") ?? ""}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 truncate text-app-accent hover:underline"
onClick={closeLink}
>
{selLink.getAttribute("href")}
</a>
) : (
<span className="min-w-0 truncate text-app-faint">{selLink.getAttribute("href")}</span>
)}
<span className="shrink-0 text-app-border">|</span>
<button
type="button"
className="shrink-0 text-app-accent hover:underline"
onClick={() => setLinkEdit(selLink.getAttribute("href") ?? "")}
>
{t("compose.linkEdit")}
</button>
<span className="shrink-0 text-app-border">|</span>
<button type="button" className="shrink-0 text-red-500 hover:underline" onClick={unlink}>
{t("compose.linkDelete")}
</button>
</>
) : (
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
saveLink();
}}
>
<input
className="w-72 rounded border border-app-border bg-app-surface px-2 py-1 text-[13px] focus:border-app-accent focus:outline-none"
value={linkEdit ?? ""}
aria-label={t("compose.linkPrompt")}
onChange={(e) => setLinkEdit(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation(); // close the popup, not the compose window
closeLink();
}
}}
autoFocus
/>
<button type="submit" className="rounded bg-app-accent px-2.5 py-1 font-medium text-app-accent-fg hover:opacity-90">
{t("compose.linkSave")}
</button>
<button type="button" className="px-1 text-app-muted hover:text-app-fg" onClick={closeLink}>
{t("common.cancel")}
</button>
</form>
)}
</div>
)}
{selImg && box && (
<>
<div
+4
View File
@@ -251,6 +251,10 @@ export const en = {
"compose.fmtImage": "Insert image",
"compose.insertSignature": "Insert signature",
"compose.linkPrompt": "Link URL:",
"compose.linkOpen": "Open the link:",
"compose.linkEdit": "Edit",
"compose.linkDelete": "Delete",
"compose.linkSave": "Save",
"compose.imageTooLarge": "Image is too large (max 5 MB).",
"compose.attachTooLarge": "File exceeds the 25 MB limit.",
"compose.attachments": "Attachments",
+7
View File
@@ -185,6 +185,13 @@ body {
display: none;
}
/* The text a new link will wrap, kept visible while the URL field has focus
(RichTextEditor registers the range; browsers without the Highlight API
simply show nothing extra). */
::highlight(wmc-link-target) {
background-color: rgb(var(--app-accent) / 0.25);
}
/* Thin, unobtrusive scrollbars that match the theme. */
* {
scrollbar-width: thin;