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:
@@ -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, "&").replace(/</g, "<").replace(/"/g, """);
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user