From 8deb887e95b4524fe4a730e1c6245e702b5e433c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:58:10 +0200 Subject: [PATCH] feat: let plugins see and act on text selected in a message, with a right-click menu and the side panel's selection --- .../__tests__/reader-plugin-surfaces.test.tsx | 62 +++++- .../__tests__/reader-selection-menu.test.ts | 132 ++++++++++++ components/email/email-viewer.tsx | 34 ++- components/email/reader-selection-menu.tsx | 198 ++++++++++++++++++ components/email/thread-conversation-view.tsx | 14 +- lib/plugin-slot-catalog.ts | 3 +- locales/en/common.json | 1 + 7 files changed, 439 insertions(+), 5 deletions(-) create mode 100644 components/email/__tests__/reader-selection-menu.test.ts create mode 100644 components/email/reader-selection-menu.tsx diff --git a/components/email/__tests__/reader-plugin-surfaces.test.tsx b/components/email/__tests__/reader-plugin-surfaces.test.tsx index 36c7f96f3..37d0649d3 100644 --- a/components/email/__tests__/reader-plugin-surfaces.test.tsx +++ b/components/email/__tests__/reader-plugin-surfaces.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { act, createEvent, fireEvent, render, screen, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import React from 'react'; import type { Email, Mailbox, ThreadGroup } from '@/lib/jmap/types'; @@ -136,6 +136,66 @@ describe('email-detail-sidebar', () => { }); }); +describe('reader-selection-menu and the side panel\'s selection', () => { + const SURFACE = 'reader-selection-menu'; + const plainBody = () => document.querySelector('[data-plugin-source="email-body"]')!; + const selectBody = () => { + const range = document.createRange(); + range.selectNodeContents(plainBody()); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + }; + const rightClick = () => { + const event = createEvent.contextMenu(plainBody(), { clientX: 30, clientY: 40 }); + fireEvent(plainBody(), event); + return event; + }; + + afterEach(() => { + vi.useRealTimers(); + window.getSelection()?.removeAllRanges(); + }); + + it('leaves the browser menu alone without plugin entries, and without a selection', () => { + viewer(textMail('Hi there')); + selectBody(); + expect(rightClick().defaultPrevented).toBe(false); + expect(screen.queryByRole('menu')).toBeNull(); + + act(() => { setPluginActions('p1', 'P1', SURFACE, [{ id: 'explain', label: 'Explain' }]); }); + window.getSelection()!.removeAllRanges(); + expect(rightClick().defaultPrevented).toBe(false); + expect(screen.queryByRole('menu')).toBeNull(); + }); + + it('offers Copy and the plugin entries on selected text, and hands the click the message and the text', async () => { + act(() => { setPluginActions('p1', 'P1', SURFACE, [{ id: 'explain', label: 'Explain', icon: 'sparkles' }]); }); + viewer(textMail('Hi there')); + selectBody(); + + expect(rightClick().defaultPrevented).toBe(true); + expect(screen.getByRole('menu')).toHaveTextContent('copy'); + fireEvent.click(screen.getByRole('menuitem', { name: 'Explain' })); + await act(async () => {}); + expect(dispatch).toHaveBeenCalledWith('p1', SURFACE, 'explain', { emailId: 'e1', selection: { text: 'Hi there' } }); + expect(screen.queryByRole('menu')).toBeNull(); + }); + + it('sends the side panel the selected text a moment after it changes', () => { + offerSlots('p1', ['email-detail-sidebar']); + viewer(textMail('Hi there')); + expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).selection).toEqual({ text: '' }); + + vi.useFakeTimers(); + selectBody(); + act(() => { document.dispatchEvent(new Event('selectionchange')); }); + expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).selection).toEqual({ text: '' }); + act(() => { vi.advanceTimersByTime(200); }); + expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).selection).toEqual({ text: 'Hi there' }); + }); +}); + describe('reader-header and reader-below-subject', () => { it('put the frames above the subject and under the sender, with the message', () => { offerSlots('p1', ['reader-header', 'reader-below-subject']); diff --git a/components/email/__tests__/reader-selection-menu.test.ts b/components/email/__tests__/reader-selection-menu.test.ts new file mode 100644 index 000000000..735df4641 --- /dev/null +++ b/components/email/__tests__/reader-selection-menu.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { uiHooks } from '@/lib/plugin-hooks'; +import { bodyMenuPoint, selectedTextIn, watchBodySelection, watchElementSelection } from '../reader-selection-menu'; + +// Selected text inside a message body, for plugins. An HTML body is a +// sandboxed iframe: its selection never reaches the page's selectionchange, +// so the body document itself is watched. + +function bodyFrame(html: string) { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + const doc = iframe.contentDocument!; + doc.body.innerHTML = html; + return { iframe, doc }; +} + +function selectAll(doc: Document, el: Element) { + const range = doc.createRange(); + range.selectNodeContents(el); + const sel = doc.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); +} + +beforeEach(() => { vi.useFakeTimers(); }); +afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ''; +}); + +describe('watchBodySelection', () => { + it('reports the selected text a moment after it changes, once per change, and tells the plugins', async () => { + const { iframe, doc } = bodyFrame('

Quoted words

'); + const onSelection = vi.fn(); + const heard = vi.fn(); + const handler = uiHooks.onTextSelectionChange.register('p1', heard); + try { + watchBodySelection(doc, iframe, { emailId: 'e1', onSelection }); + + selectAll(doc, doc.querySelector('p')!); + doc.dispatchEvent(new Event('selectionchange')); + doc.dispatchEvent(new Event('selectionchange')); + expect(onSelection).not.toHaveBeenCalled(); + vi.advanceTimersByTime(200); + expect(onSelection).toHaveBeenCalledTimes(1); + expect(onSelection).toHaveBeenCalledWith('Quoted words'); + await Promise.resolve(); + expect(heard).toHaveBeenCalledWith({ text: 'Quoted words', source: 'email-body', emailId: 'e1' }); + + // The same selection again is not reported again. + doc.dispatchEvent(new Event('selectionchange')); + vi.advanceTimersByTime(200); + expect(onSelection).toHaveBeenCalledTimes(1); + + // Gone: reported as empty, but plugins are not told about nothing. + doc.getSelection()!.removeAllRanges(); + doc.dispatchEvent(new Event('selectionchange')); + vi.advanceTimersByTime(200); + expect(onSelection).toHaveBeenLastCalledWith(''); + await Promise.resolve(); + expect(heard).toHaveBeenCalledTimes(1); + } finally { + handler.dispose(); + } + }); + + it('opens the menu on a right-click on selected text, at the frame offset, and keeps the browser menu otherwise', () => { + const { iframe, doc } = bodyFrame('

Quoted words

'); + iframe.getBoundingClientRect = () => ({ left: 100, top: 50, width: 600, height: 400, right: 700, bottom: 450, x: 100, y: 50, toJSON: () => ({}) }); + Object.defineProperty(doc.documentElement, 'clientWidth', { value: 600, configurable: true }); + const onContextMenu = vi.fn().mockReturnValue(true); + watchBodySelection(doc, iframe, { emailId: 'e1', onContextMenu }); + + // No selection: nothing happens. + const idle = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, cancelable: true, bubbles: true }); + doc.dispatchEvent(idle); + expect(onContextMenu).not.toHaveBeenCalled(); + expect(idle.defaultPrevented).toBe(false); + + selectAll(doc, doc.querySelector('p')!); + const click = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, cancelable: true, bubbles: true }); + doc.dispatchEvent(click); + expect(onContextMenu).toHaveBeenCalledWith({ x: 110, y: 70 }, 'Quoted words'); + expect(click.defaultPrevented).toBe(true); + + // The menu declined (no plugin entries): the browser's own menu stays. + onContextMenu.mockReturnValue(false); + const declined = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, cancelable: true, bubbles: true }); + doc.dispatchEvent(declined); + expect(declined.defaultPrevented).toBe(false); + }); + + it('scales the point when the body was shrunk to fit a narrow screen', () => { + const { iframe, doc } = bodyFrame('

x

'); + iframe.getBoundingClientRect = () => ({ left: 0, top: 100, width: 300, height: 400, right: 300, bottom: 500, x: 0, y: 100, toJSON: () => ({}) }); + Object.defineProperty(doc.documentElement, 'clientWidth', { value: 600, configurable: true }); + expect(bodyMenuPoint({ clientX: 200, clientY: 40 }, doc, iframe)).toEqual({ x: 100, y: 120 }); + }); + + it('stops when told to', () => { + const { iframe, doc } = bodyFrame('

Quoted words

'); + const onSelection = vi.fn(); + const stop = watchBodySelection(doc, iframe, { emailId: 'e1', onSelection }); + stop(); + selectAll(doc, doc.querySelector('p')!); + doc.dispatchEvent(new Event('selectionchange')); + vi.advanceTimersByTime(200); + expect(onSelection).not.toHaveBeenCalled(); + }); +}); + +describe('a plain-text body in the page', () => { + it('counts only a selection inside the body', () => { + document.body.innerHTML = '
elsewhere

Plain words

'; + const body = document.getElementById('body')!; + selectAll(document, document.getElementById('other')!); + expect(selectedTextIn(body)).toBe(''); + selectAll(document, body.querySelector('p')!); + expect(selectedTextIn(body)).toBe('Plain words'); + }); + + it('reports changes of the page selection inside the body', () => { + document.body.innerHTML = '

Plain words

'; + const body = document.getElementById('body')!; + const onSelection = vi.fn(); + watchElementSelection(body, { emailId: 'e1', onSelection }); + selectAll(document, body.querySelector('p')!); + document.dispatchEvent(new Event('selectionchange')); + vi.advanceTimersByTime(200); + expect(onSelection).toHaveBeenCalledWith('Plain words'); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 46ba234d9..78bbf7673 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -18,6 +18,7 @@ import { useCopyLink } from "@/hooks/use-copy-link"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { SubMenuPanel } from "@/components/ui/context-menu"; +import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection, watchElementSelection } from "./reader-selection-menu"; import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils"; import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone"; import { itemsToHide } from "@/lib/toolbar-overflow"; @@ -994,6 +995,7 @@ function DetailsCategorySlot({ email, category }: { email: EmailReadView | null; /** Narrowest the message may get next to a plugin panel before the panel collapses. */ const MIN_READER_WIDTH = 480; +const NO_BODY_SELECTION = { text: '' }; function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) { return ( @@ -1510,6 +1512,18 @@ export function EmailViewer({ [email, wantsPluginEmailView], ); const pluginEmailProps = useMemo(() => ({ email: pluginEmailView }), [pluginEmailView]); + // The text selected in the body, for the side panel (a plugin working on + // what the user highlights) and the right-click menu on it. Reset per + // message; the body documents report changes a moment after they happen. + const [bodySelection, setBodySelection] = useState<{ text: string }>(NO_BODY_SELECTION); + useEffect(() => { setBodySelection(NO_BODY_SELECTION); }, [email?.id]); + const onBodySelection = useCallback((text: string) => { + setBodySelection((prev) => (prev.text === text ? prev : (text ? { text } : NO_BODY_SELECTION))); + }, []); + const pluginDetailProps = useMemo(() => ({ email: pluginEmailView, selection: bodySelection }), [pluginEmailView, bodySelection]); + const selectionMenu = useReaderSelectionMenu(); + const emailIdRef = useRef(email?.id); + emailIdRef.current = email?.id; const readerActionContext = useMemo(() => ({ emailId: email?.id }), [email?.id]); const pluginFooterProps = useMemo(() => ({ emailId: email?.id }), [email?.id]); const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(() => { @@ -2947,6 +2961,11 @@ export function EmailViewer({ const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc; // The plain-text body, where reader-above-quote mounts between text and quote. const [plainBodyEl, setPlainBodyEl] = useState(null); + // A plain-text body sits in the page: watch the page's selection for it. + useEffect(() => { + if (!plainBodyEl || !hasDetailSidebar) return; + return watchElementSelection(plainBodyEl, { emailId: email?.id, onSelection: onBodySelection }); + }, [plainBodyEl, hasDetailSidebar, email?.id, onBodySelection]); // One object per body: React writes innerHTML again whenever this object // changes, which would drop what was mounted in the body and fold the quote // back up on every render. @@ -3194,12 +3213,19 @@ export function EmailViewer({ // A press in the body closes a mention's card. The page never sees // it: the focus is already inside the iframe, so there is no blur. doc.addEventListener('mousedown', () => setBodyMention(null), true); + // Selected text, for the side panel, the onTextSelectionChange hook + // and the right-click menu plugins may add entries to. + watchBodySelection(doc, iframe, { + emailId: email?.id, + onSelection: (text) => { if (iframe.contentDocument === doc) onBodySelection(text); }, + onContextMenu: (point, text) => selectionMenu.openRef.current(point, { emailId: emailIdRef.current, text }), + }); } } catch { // Cross-origin restrictions - iframe will still display content } - }, [isDark, effectiveEmailContent.externalBlocked, email?.id, t, openCidPartLink]); + }, [isDark, effectiveEmailContent.externalBlocked, email?.id, t, openCidPartLink, onBodySelection, selectionMenu.openRef]); // Wire up the iframe as soon as its sandboxed document has parsed, rather than // waiting for the iframe 'load' event. 'load' also waits on every subresource, @@ -6074,6 +6100,9 @@ export function EmailViewer({ ref={setPlainBodyEl} className="email-content-text text-foreground" dir="auto" + data-plugin-source="email-body" + data-email-id={email?.id} + onContextMenu={(e) => selectionMenu.onElementContextMenu(e, email?.id)} dangerouslySetInnerHTML={plainBodyHtml} style={{ ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), @@ -6349,6 +6378,7 @@ export function EmailViewer({ )} + {/* Plugin Detail Sidebar - resizable, collapsible */} {hasDetailSidebar && !isMobile && ( <> @@ -6407,7 +6437,7 @@ export function EmailViewer({
- +
diff --git a/components/email/reader-selection-menu.tsx b/components/email/reader-selection-menu.tsx new file mode 100644 index 000000000..bde053dbc --- /dev/null +++ b/components/email/reader-selection-menu.tsx @@ -0,0 +1,198 @@ +"use client"; + +// Selected text in a message body, for plugins: the right-click menu on it +// (the reader-selection-menu surface, api.ui.setActions), the selection the +// reader's side panel gets as a prop, and the onTextSelectionChange hook. +// An HTML body lives in a sandboxed iframe whose selection the page never +// sees, so the body's own document is watched; a plain-text body sits in the +// page and is watched through the document's selection. +// +// The menu only replaces the browser's own when a plugin put entries in it. +// Copy is kept, since the menu replaces the one that had it. + +import { useCallback, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; +import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions"; +import { useContextMenu } from "@/hooks/use-context-menu"; +import { Copy } from "@/components/icons"; +import { uiHooks } from "@/lib/plugin-hooks"; + +export const READER_SELECTION_SURFACE = "reader-selection-menu"; +/** How long after the last change the selection goes to plugins. */ +export const BODY_SELECTION_DELAY_MS = 150; + +export interface ReaderSelectionTarget { + emailId: string | undefined; + text: string; +} + +export interface Point { x: number; y: number } + +/** + * Where a contextmenu event inside a body iframe is on the page: the frame's + * offset plus the event's position, scaled when the body was shrunk to fit a + * narrow screen. The keyboard (Shift+F10, the Menu key) fires the event at + * 0,0: then the point is under the selection. + */ +export function bodyMenuPoint( + e: { clientX: number; clientY: number }, + doc: Document, + frame: HTMLIFrameElement | null, +): Point { + let x = e.clientX; + let y = e.clientY; + if (!x && !y) { + const sel = doc.getSelection(); + const rect = sel && sel.rangeCount > 0 ? sel.getRangeAt(0).getBoundingClientRect() : null; + if (rect && (rect.width || rect.height)) { + x = rect.left; + y = rect.bottom; + } + } + if (!frame) return { x, y }; + const frameRect = frame.getBoundingClientRect(); + const laidOut = doc.documentElement.clientWidth; + const scale = laidOut > 0 && frameRect.width > 0 ? frameRect.width / laidOut : 1; + return { x: frameRect.left + x * scale, y: frameRect.top + y * scale }; +} + +export interface BodySelectionWatch { + emailId: string | undefined; + /** The selected text as it changes; '' when the selection is gone. */ + onSelection?: (text: string) => void; + /** A right-click on selected text. Return true when a menu was opened (the browser's is then suppressed). */ + onContextMenu?: (point: Point, text: string) => boolean; + delayMs?: number; +} + +function announce(text: string, emailId: string | undefined): void { + if (!text || uiHooks.onTextSelectionChange.size === 0) return; + void uiHooks.onTextSelectionChange.emit({ text, source: "email-body", emailId }); +} + +/** + * Watch the document of a body iframe. Returns a function that stops + * watching; the listeners also die with the document. + */ +export function watchBodySelection(doc: Document, frame: HTMLIFrameElement | null, opts: BodySelectionWatch): () => void { + const delay = opts.delayMs ?? BODY_SELECTION_DELAY_MS; + let timer: ReturnType | null = null; + let last = ""; + const read = () => doc.getSelection()?.toString() ?? ""; + const publish = () => { + timer = null; + const text = read(); + if (text === last) return; + last = text; + opts.onSelection?.(text); + announce(text, opts.emailId); + }; + const onSelectionChange = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(publish, delay); + }; + const onContextMenu = (e: MouseEvent) => { + const text = read(); + if (!text.trim() || !opts.onContextMenu) return; + if (opts.onContextMenu(bodyMenuPoint(e, doc, frame), text)) e.preventDefault(); + }; + doc.addEventListener("selectionchange", onSelectionChange); + doc.addEventListener("contextmenu", onContextMenu); + return () => { + if (timer) clearTimeout(timer); + doc.removeEventListener("selectionchange", onSelectionChange); + doc.removeEventListener("contextmenu", onContextMenu); + }; +} + +/** The text selected inside `el`, from the page's selection; '' when the selection is elsewhere. */ +export function selectedTextIn(el: Element): string { + const sel = el.ownerDocument.getSelection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return ""; + const anchor = sel.anchorNode; + const focus = sel.focusNode; + if (!anchor || !focus || !el.contains(anchor) || !el.contains(focus)) return ""; + return sel.toString(); +} + +/** Watch a plain-text body that sits in the page itself (no iframe). */ +export function watchElementSelection(el: HTMLElement, opts: Pick): () => void { + const delay = opts.delayMs ?? BODY_SELECTION_DELAY_MS; + const doc = el.ownerDocument; + let timer: ReturnType | null = null; + let last = ""; + const publish = () => { + timer = null; + const text = selectedTextIn(el); + if (text === last) return; + last = text; + opts.onSelection?.(text); + // The page-wide listener (PluginAppEvents) tells plugins about this one. + }; + const onSelectionChange = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(publish, delay); + }; + doc.addEventListener("selectionchange", onSelectionChange); + return () => { + if (timer) clearTimeout(timer); + doc.removeEventListener("selectionchange", onSelectionChange); + }; +} + +/** The menu's state, and `open` for a right-click on selected text. */ +export function useReaderSelectionMenu() { + const actions = usePluginActions(READER_SELECTION_SURFACE); + const menu = useContextMenu(); + const { openContextMenu } = menu; + const enabled = actions.length > 0; + // Body documents are wired once, when they load; they read the latest + // state through this ref. + const enabledRef = useRef(enabled); + enabledRef.current = enabled; + const open = useCallback((point: Point, target: ReaderSelectionTarget, e?: React.MouseEvent): boolean => { + if (!enabledRef.current || !target.text.trim()) return false; + openContextMenu({ + preventDefault: () => e?.preventDefault(), + stopPropagation: () => e?.stopPropagation(), + clientX: point.x, + clientY: point.y, + } as React.MouseEvent, target); + return true; + }, [openContextMenu]); + const openRef = useRef(open); + openRef.current = open; + /** For a plain-text body in the page: the React contextmenu handler. */ + const onElementContextMenu = useCallback((e: React.MouseEvent, emailId: string | undefined) => { + const el = e.currentTarget as HTMLElement; + const text = selectedTextIn(el); + if (!text.trim()) return; + const point = e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : bodyMenuPoint(e, el.ownerDocument, null); + openRef.current(point, { emailId, text }, e); + }, []); + return { ...menu, open, openRef, onElementContextMenu, enabled }; +} + +export function ReaderSelectionMenu({ menu }: { menu: ReturnType }) { + const t = useTranslations("email_viewer"); + const { contextMenu, closeContextMenu, menuRef } = menu; + if (!contextMenu.isOpen) return null; + const target = contextMenu.data; + const copy = () => { + closeContextMenu(); + void navigator.clipboard?.writeText(target?.text ?? "").catch(() => {}); + }; + return ( + + + + + + ); +} diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 44c5c590f..4920015da 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -46,6 +46,7 @@ import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status"; import { useOwnDomainAddress } from "@/hooks/use-own-domain-address"; import { emitThreadExpand } from "@/lib/plugin-thread-expand"; import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection } from "./reader-selection-menu"; import { PluginActions } from "@/components/plugins/plugin-actions"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body"; @@ -548,6 +549,7 @@ export function ConversationEmailBody({ // reader-above-quote: the body the quote was collapsed in, once it is on screen. const [htmlBodyEl, setHtmlBodyEl] = useState(null); const [plainBodyEl, setPlainBodyEl] = useState(null); + const selectionMenu = useReaderSelectionMenu(); const wantsQuoteSlot = usePluginSlotOffers('reader-above-quote').length > 0; const quoteSlotProps = useMemo( () => ({ email: wantsQuoteSlot ? emailToReadView(email) : null }), @@ -796,11 +798,17 @@ export function ConversationEmailBody({ a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener noreferrer'); }); + // Selected text: the onTextSelectionChange hook and the right-click + // menu plugins may add entries to. + watchBodySelection(doc, iframe, { + emailId: email.id, + onContextMenu: (point, text) => selectionMenu.openRef.current(point, { emailId: email.id, text }), + }); setHtmlBodyEl(doc.body); } catch { // contentDocument may be inaccessible under stricter sandboxes; ignore. } - }, [t, isDark, allowExternal]); + }, [t, isDark, allowExternal, email.id, selectionMenu.openRef]); return (
@@ -868,9 +876,13 @@ export function ConversationEmailBody({ "[&_img]:max-w-full [&_img]:h-auto" )} style={{ whiteSpace: 'pre-wrap', ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), fontSize: '13px' }} + data-plugin-source="email-body" + data-email-id={email.id} + onContextMenu={(e) => selectionMenu.onElementContextMenu(e, email.id)} dangerouslySetInnerHTML={plainBodyHtml} /> )} +