feat: ask for the link's text and address in Insert link, also with nothing selected

The first link dialog test now finds the address by its "Link" label and
checks the linked text, since the dialog has two fields.
This commit is contained in:
Linus Rath
2026-11-04 12:29:01 +01:00
parent 801aa51b24
commit b02ce23ace
33 changed files with 229 additions and 26 deletions
@@ -29,11 +29,50 @@ describe('Insert link', () => {
fireEvent.click(screen.getByTitle('link'));
expect(prompt).not.toHaveBeenCalled();
const dialog = screen.getByRole('dialog', { name: 'insert_link' });
fireEvent.change(dialog.querySelector('input')!, { target: { value: 'example.com/docs' } });
screen.getByRole('dialog', { name: 'insert_link' });
expect(screen.getByRole('textbox', { name: 'link_text' })).toHaveValue('docs');
fireEvent.change(screen.getByRole('textbox', { name: 'link' }), { target: { value: 'example.com/docs' } });
fireEvent.click(screen.getByRole('button', { name: 'save' }));
expect(editor.getHTML()).toContain('href="https://example.com/docs"');
expect(editor.getHTML()).toContain('<a target="_blank" rel="noopener noreferrer nofollow" href="https://example.com/docs">docs</a> here');
});
// compose-p2-7: with nothing selected Save used to add nothing.
it('inserts the text as a link when nothing is selected', async () => {
const editor = await setup('<p>See it</p>');
act(() => { editor.commands.setTextSelection(4); });
fireEvent.click(screen.getByTitle('link'));
expect(screen.getByRole('textbox', { name: 'link_text' })).toHaveValue('');
fireEvent.change(screen.getByRole('textbox', { name: 'link_text' }), { target: { value: 'our docs' } });
fireEvent.change(screen.getByRole('textbox', { name: 'link' }), { target: { value: 'example.com/docs' } });
fireEvent.click(screen.getByRole('button', { name: 'save' }));
expect(editor.getHTML()).toMatch(/See<a [^>]*href="https:\/\/example\.com\/docs"[^>]*>our docs<\/a> it/);
});
it('shows the address as the link when the text is left empty', async () => {
const editor = await setup('<p>See it</p>');
act(() => { editor.commands.setTextSelection(4); });
fireEvent.click(screen.getByTitle('link'));
fireEvent.change(screen.getByRole('textbox', { name: 'link' }), { target: { value: 'https://example.com' } });
fireEvent.click(screen.getByRole('button', { name: 'save' }));
expect(editor.getHTML()).toMatch(/See<a [^>]*href="https:\/\/example\.com"[^>]*>https:\/\/example\.com<\/a> it/);
});
it('edits the text of a link the caret is in', async () => {
const editor = await setup('<p><a href="https://example.com">docs</a> here</p>');
act(() => { editor.commands.setTextSelection(2); });
fireEvent.click(screen.getByTitle('link'));
expect(screen.getByRole('textbox', { name: 'link_text' })).toHaveValue('docs');
expect(screen.getByRole('textbox', { name: 'link' })).toHaveValue('https://example.com');
fireEvent.change(screen.getByRole('textbox', { name: 'link_text' }), { target: { value: 'the docs' } });
fireEvent.click(screen.getByRole('button', { name: 'save' }));
expect(editor.getHTML()).toMatch(/<a [^>]*href="https:\/\/example\.com"[^>]*>the docs<\/a> here/);
});
it('opens on Ctrl+K in the text, as in Gmail, Outlook and Word', async () => {
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
export interface LinkDialogProps {
isOpen: boolean;
onClose: () => void;
/** `text` is what the link shows; empty means the address itself. */
onSubmit: (link: { text: string; href: string }) => void;
/** The selected text, or the link's own text when editing one. */
defaultText: string;
defaultHref: string;
/** Offers "Remove link" when editing a link. */
onRemove?: () => void;
}
/**
* Insert link (compose-p2-7): "Text" and "Link", as Gmail and Outlook ask. The
* selection fills Text; with nothing selected the text (or the address) goes
* in as a new link.
*/
export function LinkDialog({ isOpen, onClose, onSubmit, defaultText, defaultHref, onRemove }: LinkDialogProps) {
const t = useTranslations("email_composer.toolbar");
const tCommon = useTranslations("common");
const id = useId();
const [text, setText] = useState(defaultText);
const [href, setHref] = useState(defaultHref);
const textRef = useRef<HTMLInputElement>(null);
const hrefRef = useRef<HTMLInputElement>(null);
// With text already there, the address is what is left to give.
const firstField = defaultText ? hrefRef : textRef;
useEffect(() => {
if (!isOpen) return;
setText(defaultText);
setHref(defaultHref);
}, [isOpen, defaultText, defaultHref]);
const address = href.trim();
const error = /\s/.test(address) ? t("link_invalid") : null;
const canSubmit = address.length > 0 && !error;
const errorId = `${id}-error`;
const submit = (event?: React.FormEvent) => {
event?.preventDefault();
if (!canSubmit) return;
try {
onSubmit({ text, href: address });
} finally {
onClose();
}
};
return (
<Dialog
isOpen={isOpen}
onClose={onClose}
title={defaultHref ? t("edit_link") : t("insert_link")}
showClose={false}
initialFocus={firstField}
onSubmit={submit}
footer={
<>
{onRemove && (
<Button
type="button"
variant="ghost"
className="me-auto"
onClick={() => {
try {
onRemove();
} finally {
onClose();
}
}}
>
{t("remove_link")}
</Button>
)}
<Button type="button" variant="outline" onClick={onClose}>
{tCommon("cancel")}
</Button>
<Button type="submit" variant="default" disabled={!canSubmit}>
{tCommon("save")}
</Button>
</>
}
>
<div className="mt-4 space-y-3">
<div className="space-y-1.5">
<label htmlFor={`${id}-text`} className="block text-sm font-medium text-foreground">{t("link_text")}</label>
<Input id={`${id}-text`} ref={textRef} type="text" value={text} onChange={(e) => setText(e.target.value)} />
</div>
<div className="space-y-1.5">
<label htmlFor={`${id}-href`} className="block text-sm font-medium text-foreground">{t("link")}</label>
<Input
id={`${id}-href`}
ref={hrefRef}
type="text"
value={href}
onChange={(e) => setHref(e.target.value)}
placeholder={t("link_address")}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
className={error ? "border-destructive focus-visible:border-destructive" : undefined}
/>
{error && (
<p id={errorId} role="alert" className="text-xs text-destructive">
{error}
</p>
)}
</div>
</div>
</Dialog>
);
}
+37 -23
View File
@@ -32,7 +32,8 @@ import { RecipientMention, RecipientMentionList, type RecipientMentionListHandle
import { MentionChipCard, setMentionRecipients } from "@/components/email/mention-chip";
import { mentionSuggestions, type MentionCandidate, type MentionContact } from "@/lib/recipient-mentions";
import { cn } from "@/lib/utils";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { getMarkRange } from "@tiptap/core";
import { LinkDialog } from "@/components/email/link-dialog";
import { ImageAltDialog } from "@/components/email/image-alt-dialog";
/** A typed address without a scheme ("example.com") becomes a web link. */
@@ -741,26 +742,43 @@ export function RichTextEditor({
if (editor) setMentionRecipients(editor, mentionCandidates ? mentionCandidates.map((c) => c.email) : null);
}, [editor, mentionCandidates]);
// Insert link asks for the address in the app's own dialog; on a link it
// starts from the current address and offers "Remove link".
// The selection is kept from when the dialog opened: the dialog takes the
// focus, and with it the editor's selection.
const [linkDialog, setLinkDialog] = useState<{ href: string; from: number; to: number } | null>(null);
// Insert link asks for the text and the address in the app's own dialog
// (compose-p2-7); on a link it starts from the link's text and address and
// offers "Remove link". The range is kept from when the dialog opened: the
// dialog takes the focus, and with it the editor's selection.
const [linkDialog, setLinkDialog] = useState<{ href: string; text: string; from: number; to: number } | null>(null);
const addLink = useCallback(() => {
if (!editor) return;
const previousUrl = editor.getAttributes("link").href as string | undefined;
const { from, to } = editor.state.selection;
setLinkDialog({ href: previousUrl ?? "", from, to });
let { from, to } = editor.state.selection;
// The caret in a link edits the whole link.
const linkType = editor.schema.marks.link;
if (previousUrl && from === to && linkType) {
const range = getMarkRange(editor.state.doc.resolve(from), linkType);
if (range) ({ from, to } = range);
}
const text = editor.state.doc.textBetween(from, to, " ");
setLinkDialog({ href: previousUrl ?? "", text, from, to });
}, [editor]);
addLinkRef.current = addLink;
const applyLink = useCallback((url: string) => {
const applyLink = useCallback(({ text, href }: { text: string; href: string }) => {
if (!editor || !linkDialog) return;
editor
.chain()
.focus()
.setTextSelection({ from: linkDialog.from, to: linkDialog.to })
const label = text.trim() ? text : href;
const { from, to } = linkDialog;
const chain = editor.chain().focus();
if (from === to || label !== linkDialog.text) {
// Nothing selected, or the text changed: the text goes in as the link.
chain
.insertContentAt({ from, to }, { type: "text", text: label, marks: [{ type: "link", attrs: { href } }] })
.setTextSelection(from + label.length)
.unsetMark("link")
.run();
return;
}
chain
.setTextSelection({ from, to })
.extendMarkRange("link")
.setLink({ href: url })
.setLink({ href })
.run();
}, [editor, linkDialog]);
const removeLink = useCallback(() => {
@@ -776,7 +794,6 @@ export function RichTextEditor({
const tToolbar = useTranslations("email_composer.toolbar");
const tWindows = useTranslations("compose_windows");
const tCommon = useTranslations("common");
const [tableMenuOpen, setTableMenuOpen] = useState(false);
const tableWrapperRef = useRef<HTMLDivElement>(null);
const [colorMenuOpen, setColorMenuOpen] = useState(false);
@@ -1465,16 +1482,13 @@ export function RichTextEditor({
document.body
)}
<PromptDialog
<LinkDialog
isOpen={!!linkDialog}
onClose={() => setLinkDialog(null)}
title={linkDialog?.href ? tToolbar("edit_link") : tToolbar("insert_link")}
placeholder={tToolbar("link_address")}
defaultValue={linkDialog?.href ?? ""}
confirmText={tCommon("save")}
validate={(value) => (/\s/.test(value) ? tToolbar("link_invalid") : null)}
onSubmit={(value) => applyLink(withLinkScheme(value))}
extraAction={linkDialog?.href ? { label: tToolbar("remove_link"), onClick: removeLink } : undefined}
defaultText={linkDialog?.text ?? ""}
defaultHref={linkDialog?.href ?? ""}
onSubmit={({ text, href }) => applyLink({ text, href: withLinkScheme(href) })}
onRemove={linkDialog?.href ? removeLink : undefined}
/>
<ImageAltDialog
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "تعديل الرابط",
"remove_link": "إزالة الرابط",
"link_address": "عنوان الرابط",
"link_text": "النص",
"link_invalid": "يجب ألا يحتوي العنوان على مسافات",
"table": "جدول",
"clear_formatting": "مسح التنسيق",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Edita l'enllaç",
"remove_link": "Treu l'enllaç",
"link_address": "Adreça de l'enllaç",
"link_text": "Text",
"link_invalid": "L'adreça no pot tenir espais",
"table": "Taula",
"clear_formatting": "Neteja el format",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Upravit odkaz",
"remove_link": "Odebrat odkaz",
"link_address": "Adresa odkazu",
"link_text": "Text",
"link_invalid": "Adresa nesmí obsahovat mezery",
"table": "Tabulka",
"clear_formatting": "Vymazat formátování",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Rediger link",
"remove_link": "Fjern link",
"link_address": "Linkadresse",
"link_text": "Tekst",
"link_invalid": "Adressen må ikke indeholde mellemrum",
"table": "Tabel",
"clear_formatting": "Ryd formatering",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Link bearbeiten",
"remove_link": "Link entfernen",
"link_address": "Linkadresse",
"link_text": "Text",
"link_invalid": "Die Adresse darf keine Leerzeichen enthalten",
"table": "Tabelle",
"clear_formatting": "Formatierung löschen",
+1
View File
@@ -1165,6 +1165,7 @@
"edit_link": "Edit link",
"remove_link": "Remove link",
"link_address": "Link address",
"link_text": "Text",
"link_invalid": "The address can't contain spaces",
"table": "Table",
"clear_formatting": "Clear formatting",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Editar enlace",
"remove_link": "Quitar enlace",
"link_address": "Dirección del enlace",
"link_text": "Texto",
"link_invalid": "La dirección no puede contener espacios",
"table": "Tabla",
"clear_formatting": "Borrar formato",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Muuda linki",
"remove_link": "Eemalda link",
"link_address": "Lingi aadress",
"link_text": "Tekst",
"link_invalid": "Aadressis ei tohi olla tühikuid",
"table": "Tabel",
"clear_formatting": "Eemalda vormindus",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "ویرایش پیوند",
"remove_link": "حذف پیوند",
"link_address": "نشانی پیوند",
"link_text": "متن",
"link_invalid": "نشانی نباید فاصله داشته باشد",
"table": "جدول",
"clear_formatting": "پاک کردن قالب‌بندی",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Modifier le lien",
"remove_link": "Retirer le lien",
"link_address": "Adresse du lien",
"link_text": "Texte",
"link_invalid": "L'adresse ne peut pas contenir d'espaces",
"table": "Tableau",
"clear_formatting": "Effacer la mise en forme",
+1
View File
@@ -1106,6 +1106,7 @@
"edit_link": "עריכת קישור",
"remove_link": "הסרת קישור",
"link_address": "כתובת הקישור",
"link_text": "טקסט",
"link_invalid": "הכתובת לא יכולה להכיל רווחים",
"table": "טבלה",
"clear_formatting": "ניקוי עיצוב",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Hivatkozás szerkesztése",
"remove_link": "Hivatkozás eltávolítása",
"link_address": "Hivatkozás címe",
"link_text": "Szöveg",
"link_invalid": "A cím nem tartalmazhat szóközt",
"table": "Táblázat",
"clear_formatting": "Formázás törlése",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Modifica link",
"remove_link": "Rimuovi link",
"link_address": "Indirizzo del link",
"link_text": "Testo",
"link_invalid": "L'indirizzo non può contenere spazi",
"table": "Tabella",
"clear_formatting": "Cancella formattazione",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "リンクを編集",
"remove_link": "リンクを解除",
"link_address": "リンク先のアドレス",
"link_text": "テキスト",
"link_invalid": "アドレスに空白は使えません",
"table": "表",
"clear_formatting": "書式をクリア",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "링크 편집",
"remove_link": "링크 제거",
"link_address": "링크 주소",
"link_text": "텍스트",
"link_invalid": "주소에 공백을 넣을 수 없어요",
"table": "표",
"clear_formatting": "서식 지우기",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Rediģēt saiti",
"remove_link": "Noņemt saiti",
"link_address": "Saites adrese",
"link_text": "Teksts",
"link_invalid": "Adresē nedrīkst būt atstarpju",
"table": "Tabula",
"clear_formatting": "Notīrīt formatējumu",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Холбоос засах",
"remove_link": "Холбоос хасах",
"link_address": "Холбоосын хаяг",
"link_text": "Текст",
"link_invalid": "Хаяг хоосон зай агуулж болохгүй",
"table": "Хүснэгт",
"clear_formatting": "Форматыг арилгах",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Rediger lenke",
"remove_link": "Fjern lenke",
"link_address": "Lenkeadresse",
"link_text": "Tekst",
"link_invalid": "Adressen kan ikke inneholde mellomrom",
"table": "Tabell",
"clear_formatting": "Fjern formatering",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Link bewerken",
"remove_link": "Link verwijderen",
"link_address": "Linkadres",
"link_text": "Tekst",
"link_invalid": "Het adres mag geen spaties bevatten",
"table": "Tabel",
"clear_formatting": "Opmaak wissen",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Edytuj link",
"remove_link": "Usuń link",
"link_address": "Adres linku",
"link_text": "Tekst",
"link_invalid": "Adres nie może zawierać spacji",
"table": "Tabela",
"clear_formatting": "Wyczyść formatowanie",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Editar link",
"remove_link": "Remover link",
"link_address": "Endereço do link",
"link_text": "Texto",
"link_invalid": "O endereço não pode conter espaços",
"table": "Tabela",
"clear_formatting": "Limpar formatação",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Editează linkul",
"remove_link": "Elimină linkul",
"link_address": "Adresa linkului",
"link_text": "Text",
"link_invalid": "Adresa nu poate conține spații",
"table": "Tabel",
"clear_formatting": "Șterge formatarea",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Изменить ссылку",
"remove_link": "Убрать ссылку",
"link_address": "Адрес ссылки",
"link_text": "Текст",
"link_invalid": "В адресе не должно быть пробелов",
"table": "Таблица",
"clear_formatting": "Очистить форматирование",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Upraviť odkaz",
"remove_link": "Odstrániť odkaz",
"link_address": "Adresa odkazu",
"link_text": "Text",
"link_invalid": "Adresa nesmie obsahovať medzery",
"table": "Tabuľka",
"clear_formatting": "Vymazať formátovanie",
+1
View File
@@ -1552,6 +1552,7 @@
"edit_link": "แก้ไขลิงก์",
"remove_link": "นำลิงก์ออก",
"link_address": "ที่อยู่ลิงก์",
"link_text": "ข้อความ",
"link_invalid": "ที่อยู่ต้องไม่มีช่องว่าง",
"table": "ตาราง",
"clear_formatting": "ล้างการจัดรูปแบบ",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Bağlantıyı düzenle",
"remove_link": "Bağlantıyı kaldır",
"link_address": "Bağlantı adresi",
"link_text": "Metin",
"link_invalid": "Adres boşluk içeremez",
"table": "Tablo",
"clear_formatting": "Biçimlendirmeyi temizle",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "Змінити посилання",
"remove_link": "Прибрати посилання",
"link_address": "Адреса посилання",
"link_text": "Текст",
"link_invalid": "Адреса не може містити пробілів",
"table": "Таблиця",
"clear_formatting": "Очистити форматування",
+1
View File
@@ -1177,6 +1177,7 @@
"edit_link": "Chỉnh sửa liên kết",
"remove_link": "Xóa liên kết",
"link_address": "Địa chỉ liên kết",
"link_text": "Văn bản",
"link_invalid": "Địa chỉ không được chứa dấu cách",
"table": "Bảng",
"clear_formatting": "Xóa định dạng",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "編輯連結",
"remove_link": "移除連結",
"link_address": "連結網址",
"link_text": "文字",
"link_invalid": "網址中不能包含空格",
"table": "表格",
"clear_formatting": "清除格式",
+1
View File
@@ -1163,6 +1163,7 @@
"edit_link": "编辑链接",
"remove_link": "移除链接",
"link_address": "链接地址",
"link_text": "文本",
"link_invalid": "地址中不能包含空格",
"table": "表格",
"clear_formatting": "清除格式",