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} />