mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: let plugins see and act on text selected in a message, with a right-click menu and the side panel's selection
This commit is contained in:
@@ -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<HTMLElement>('[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']);
|
||||
|
||||
@@ -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('<p>Quoted words</p>');
|
||||
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('<p>Quoted words</p>');
|
||||
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('<p>x</p>');
|
||||
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('<p>Quoted words</p>');
|
||||
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 = '<div id="other">elsewhere</div><div id="body"><p>Plain words</p></div>';
|
||||
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 = '<div id="body"><p>Plain words</p></div>';
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<boolean>(() => {
|
||||
@@ -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<HTMLDivElement | null>(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({
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<ReaderSelectionMenu menu={selectionMenu} />
|
||||
{/* Plugin Detail Sidebar - resizable, collapsible */}
|
||||
{hasDetailSidebar && !isMobile && (
|
||||
<>
|
||||
@@ -6407,7 +6437,7 @@ export function EmailViewer({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<PluginSlot name="email-detail-sidebar" extraProps={pluginEmailProps} />
|
||||
<PluginSlot name="email-detail-sidebar" extraProps={pluginDetailProps} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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<typeof setTimeout> | 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<BodySelectionWatch, "emailId" | "onSelection" | "delayMs">): () => void {
|
||||
const delay = opts.delayMs ?? BODY_SELECTION_DELAY_MS;
|
||||
const doc = el.ownerDocument;
|
||||
let timer: ReturnType<typeof setTimeout> | 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<ReaderSelectionTarget>();
|
||||
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<typeof useReaderSelectionMenu> }) {
|
||||
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 (
|
||||
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||
<ContextMenuItem icon={Copy} label={t("copy")} testId="reader-selection-copy" onClick={copy} />
|
||||
<ContextMenuSeparator />
|
||||
<PluginActions
|
||||
surface="reader-selection-menu"
|
||||
variant="menu"
|
||||
context={{ emailId: target?.emailId, selection: { text: target?.text ?? "" } }}
|
||||
onRan={closeContextMenu}
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLElement | null>(null);
|
||||
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(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 (
|
||||
<div className={className}>
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
<ReaderSelectionMenu menu={selectionMenu} />
|
||||
<ReaderAboveQuoteSlot
|
||||
root={emailContent.isHtml ? htmlBodyEl : plainBodyEl}
|
||||
frame={emailContent.isHtml ? iframeRef.current : null}
|
||||
|
||||
@@ -70,9 +70,10 @@ export const SLOT_CATALOG = {
|
||||
'email-footer': S('reader', 'frame', ['ui:email-footer'], { emailId: 'email:read' }, 'below the message body'),
|
||||
'thread-summary': S('reader', 'frame', ['ui:email-banner'], { threadId: 'email:read', emailIds: 'email:read' }, 'above the messages of an open conversation (the phone conversation view and Mountain View; the default desktop layout opens one message at a time and has no conversation view)'),
|
||||
'reader-actions': S('reader', 'action', ['ui:toolbar'], { emailId: 'email:read' }, 'buttons in the open message\'s toolbar', true),
|
||||
'email-detail-sidebar': S('reader', 'frame', ['ui:email-details'], { email: 'email:read' }, 'a side panel next to the open message'),
|
||||
'email-detail-sidebar': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', selection: 'email:read' }, 'a side panel next to the open message; `selection` is the text selected in the message as the user changes it ({ text })'),
|
||||
'email-details-section': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', category: null }, 'a section in the message details'),
|
||||
'attachment-actions': S('reader', 'frame', ['email:read'], { attachment: null }, 'buttons on each attachment', true),
|
||||
'reader-selection-menu': S('reader', 'action', ['ui:context-menu', 'ui:toolbar'], { emailId: 'email:read', selection: 'email:read' }, 'entries in the right-click menu on selected text in a message (without plugin entries the browser\'s own menu stays); the click\'s selection is { text }'),
|
||||
'attachment-menu': S('reader', 'action', ['email:read'], { emailId: 'email:read', blobId: 'email:read', name: 'email:read', type: 'email:read', size: 'email:read' }, 'entries in each attachment\'s menu (for example "Save to Nextcloud")'),
|
||||
|
||||
// ─── Composer ──────────────────────────────────────────────
|
||||
|
||||
@@ -639,6 +639,7 @@
|
||||
"copy_failed": "Could not copy the code"
|
||||
},
|
||||
"mv_position": "{index} of {total}",
|
||||
"copy": "Copy",
|
||||
"plugin_panel_show": "Show plugin panel",
|
||||
"plugin_panel_hide": "Hide plugin panel"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user