diff --git a/frontend/src/components/compose/RichTextEditor.tsx b/frontend/src/components/compose/RichTextEditor.tsx index e5bccca..6dfcdcc 100644 --- a/frontend/src/components/compose/RichTextEditor.tsx +++ b/frontend/src/components/compose/RichTextEditor.tsx @@ -185,6 +185,13 @@ export const RichTextEditor = forwardRef< // Currently-selected inline image + its handle box (px, relative to the editor). const [selImg, setSelImg] = useState(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: | Edit | Delete". + const [selLink, setSelLink] = useState(null); + const [linkPos, setLinkPos] = useState<{ left: number; top: number } | null>(null); + const [linkEdit, setLinkEdit] = useState(null); // the URL being edited + // A link being created from the toolbar: the selection (or caret) it will wrap. + const [newLinkRange, setNewLinkRange] = useState(null); + const linkPopupRef = useRef(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 }).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(/${safe}`); + } 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 && ( +
+ {linkEdit === null && selLink ? ( + <> + {t("compose.linkOpen")} + {openableHref(selLink) ? ( + + {selLink.getAttribute("href")} + + ) : ( + {selLink.getAttribute("href")} + )} + | + + | + + + ) : ( +
{ + e.preventDefault(); + saveLink(); + }} + > + setLinkEdit(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); // close the popup, not the compose window + closeLink(); + } + }} + autoFocus + /> + + +
+ )} +
+ )} {selImg && box && ( <>