From b02ce23aceced26906f6293d0143c9e062591063 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:29:01 +0100 Subject: [PATCH] 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. --- .../rich-text-editor-link-dialog.test.tsx | 45 ++++++- components/email/link-dialog.tsx | 120 ++++++++++++++++++ components/email/rich-text-editor.tsx | 60 +++++---- locales/ar/common.json | 1 + locales/ca/common.json | 1 + locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/et/common.json | 1 + locales/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/mn/common.json | 1 + locales/nb/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/th/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/vi/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + 33 files changed, 229 insertions(+), 26 deletions(-) create mode 100644 components/email/link-dialog.tsx diff --git a/components/email/__tests__/rich-text-editor-link-dialog.test.tsx b/components/email/__tests__/rich-text-editor-link-dialog.test.tsx index c15a51ce4..23d7ac5a2 100644 --- a/components/email/__tests__/rich-text-editor-link-dialog.test.tsx +++ b/components/email/__tests__/rich-text-editor-link-dialog.test.tsx @@ -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('docs 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('

See it

'); + 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]*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('

See it

'); + 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
]*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('

docs here

'); + 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(/]*href="https:\/\/example\.com"[^>]*>the docs<\/a> here/); }); it('opens on Ctrl+K in the text, as in Gmail, Outlook and Word', async () => { diff --git a/components/email/link-dialog.tsx b/components/email/link-dialog.tsx new file mode 100644 index 000000000..d9a0f0ed2 --- /dev/null +++ b/components/email/link-dialog.tsx @@ -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(null); + const hrefRef = useRef(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 ( + + {onRemove && ( + + )} + + + + } + > +
+
+ + setText(e.target.value)} /> +
+
+ + 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 && ( + + )} +
+
+
+ ); +} diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 952a9da30..e0a9720a3 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -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(null); const [colorMenuOpen, setColorMenuOpen] = useState(false); @@ -1465,16 +1482,13 @@ export function RichTextEditor({ document.body )} - 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} />