From 00142540f66a0664208a261f0749be0b4ec00cda Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:50:43 +0200 Subject: [PATCH] feat: let plugins add entries to the right-click menu on selected text in the composer --- .../composer-plugin-surfaces.test.tsx | 62 ++++++++++++- components/email/composer-selection-menu.tsx | 90 +++++++++++++++++++ components/email/email-composer.tsx | 39 +++++++- lib/plugin-slot-catalog.ts | 1 + locales/en/common.json | 2 + 5 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 components/email/composer-selection-menu.tsx diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index 60f6691c9..c9351bc27 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { act, createEvent, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; @@ -413,6 +413,66 @@ describe('composer-attach-menu', () => { }); }); +describe('composer-selection-menu', () => { + const SURFACE = 'composer-selection-menu'; + const rightClick = (el: HTMLElement) => { + const event = createEvent.contextMenu(el, { clientX: 40, clientY: 50 }); + fireEvent(el, event); + return event; + }; + + beforeEach(() => { useSettingsStore.setState({ plainTextMode: true }); }); + afterEach(() => { useSettingsStore.setState({ plainTextMode: false }); }); + + it('leaves the browser menu alone without plugin entries, and without a selection', () => { + const { container } = render(); + const textarea = container.querySelector('textarea') as HTMLTextAreaElement; + textarea.setSelectionRange(0, 5); + expect(rightClick(textarea).defaultPrevented).toBe(false); + expect(screen.queryByRole('menu')).toBeNull(); + + act(() => { setPluginActions('p1', 'P1', SURFACE, checkActions([{ id: 'improve', label: 'Improve' }])); }); + textarea.setSelectionRange(3, 3); + expect(rightClick(textarea).defaultPrevented).toBe(false); + expect(screen.queryByRole('menu')).toBeNull(); + }); + + it('offers Cut, Copy and the plugin entries on selected text, and hands the click the selection', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } }); + try { + act(() => { setPluginActions('p1', 'P1', SURFACE, checkActions([{ id: 'improve', label: 'Improve', icon: 'sparkles' }])); }); + const { container } = render(); + const textarea = container.querySelector('textarea') as HTMLTextAreaElement; + textarea.setSelectionRange(0, 5); + + expect(rightClick(textarea).defaultPrevented).toBe(true); + const menu = screen.getByRole('menu'); + expect(menu).toHaveTextContent('cut'); + expect(menu).toHaveTextContent('copy'); + fireEvent.click(screen.getByRole('menuitem', { name: 'Improve' })); + await act(async () => {}); + expect(dispatch).toHaveBeenCalledWith('p1', SURFACE, 'improve', expect.objectContaining({ selection: { text: 'Hello' } })); + expect(typeof dispatch.mock.calls[0][3].composerId).toBe('string'); + expect(screen.queryByRole('menu')).toBeNull(); + + textarea.setSelectionRange(0, 5); + rightClick(textarea); + fireEvent.click(screen.getByTestId('composer-selection-copy')); + expect(writeText).toHaveBeenCalledWith('Hello'); + expect(textarea.value).toBe('Hello there'); + + textarea.setSelectionRange(0, 6); + rightClick(textarea); + fireEvent.click(screen.getByTestId('composer-selection-cut')); + expect(writeText).toHaveBeenLastCalledWith('Hello '); + expect(textarea.value).toBe('there'); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + describe('composer-sidebar and composer-sidebar-right', () => { function stubWidth(width: number) { class FixedWidthObserver { diff --git a/components/email/composer-selection-menu.tsx b/components/email/composer-selection-menu.tsx new file mode 100644 index 000000000..ea17d8e74 --- /dev/null +++ b/components/email/composer-selection-menu.tsx @@ -0,0 +1,90 @@ +"use client"; + +// The right-click menu on selected text in the composer. It only appears +// when a plugin declared entries for it (the composer-selection-menu +// surface, api.ui.setActions); otherwise the browser's own menu stays, with +// its spelling suggestions. Cut and Copy are kept, since the menu replaces +// the one that had them. A click on a plugin entry carries the composer id +// and the selected text. + +import { useCallback } 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, Scissors } from "@/components/icons"; + +export const COMPOSER_SELECTION_SURFACE = "composer-selection-menu"; + +export interface SelectionMenuTarget { + /** The selected text, as the plugin gets it. */ + text: string; +} + +/** + * Where the menu opens for a contextmenu event. The keyboard (Shift+F10, + * the Menu key) fires it at 0,0: then the menu opens under the selection. + */ +export function selectionMenuPoint( + e: { clientX: number; clientY: number; currentTarget?: EventTarget | null }, + selection: Selection | null = typeof window === "undefined" ? null : window.getSelection(), +): { x: number; y: number } { + if (e.clientX || e.clientY) return { x: e.clientX, y: e.clientY }; + if (selection && selection.rangeCount > 0) { + const rect = selection.getRangeAt(0).getBoundingClientRect(); + if (rect.width || rect.height) return { x: rect.left, y: rect.bottom }; + } + const el = e.currentTarget instanceof Element ? e.currentTarget : null; + const rect = el?.getBoundingClientRect(); + return rect ? { x: rect.left + 16, y: rect.top + 16 } : { x: 0, y: 0 }; +} + +/** The menu's state and the contextmenu handler for the message text. */ +export function useComposerSelectionMenu() { + const actions = usePluginActions(COMPOSER_SELECTION_SURFACE); + const menu = useContextMenu(); + const { openContextMenu } = menu; + const enabled = actions.length > 0; + const onContextMenu = useCallback((e: React.MouseEvent, read: () => string) => { + if (!enabled) return; + const text = read(); + if (!text.trim()) return; + const point = selectionMenuPoint(e); + openContextMenu({ + preventDefault: () => e.preventDefault(), + stopPropagation: () => e.stopPropagation(), + clientX: point.x, + clientY: point.y, + } as React.MouseEvent, { text }); + }, [enabled, openContextMenu]); + return { ...menu, onContextMenu, enabled }; +} + +export function ComposerSelectionMenu({ + menu, + composerId, + onClipboard, +}: { + menu: ReturnType; + composerId: string | null; + /** Cut or copy the selection the menu was opened on. */ + onClipboard: (command: "cut" | "copy") => void; +}) { + const t = useTranslations("email_composer"); + const { contextMenu, closeContextMenu, menuRef } = menu; + if (!contextMenu.isOpen) return null; + const text = contextMenu.data?.text ?? ""; + return ( + + { closeContextMenu(); onClipboard("cut"); }} /> + { closeContextMenu(); onClipboard("copy"); }} /> + + + + ); +} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index ddd33ec0d..af4a74f2e 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -71,6 +71,7 @@ import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@ import { useAccountStore } from "@/stores/account-store"; import { useSettingsStore } from "@/stores/settings-store"; import { ComposerPluginSidebar, useComposerSidebars } from "./composer-plugin-sidebars"; +import { ComposerSelectionMenu, useComposerSelectionMenu } from "./composer-selection-menu"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { usePluginActions } from "@/components/plugins/plugin-actions"; @@ -2796,6 +2797,40 @@ export function EmailComposer({ () => ({ draft: pluginDraft, selection: pluginSelection, composerId: pluginComposerId }), [pluginDraft, pluginSelection, pluginComposerId], ); + + // The right-click menu on selected text, when a plugin put entries in it. + const selectionMenu = useComposerSelectionMenu(); + const readSelectedText = useCallback((): string => { + if (plainTextModeRef.current) { + const textarea = bodyRef.current; + return textarea ? textarea.value.slice(textarea.selectionStart ?? 0, textarea.selectionEnd ?? 0) : ''; + } + const editor = editorRef.current; + return editor ? readEditorSelection(editor).text : ''; + }, []); + const onBodyContextMenu = useCallback((e: React.MouseEvent) => { + selectionMenu.onContextMenu(e, readSelectedText); + }, [selectionMenu, readSelectedText]); + // Cut and Copy from that menu: the browser's own command, run with the + // selection active again, keeps formatting on the clipboard; when the + // command is not available the plain text goes there. + const clipboardFromMenu = useCallback((command: 'cut' | 'copy') => { + const text = readSelectedText(); + const plain = plainTextModeRef.current; + const textarea = bodyRef.current; + const editor = editorRef.current; + if (plain) textarea?.focus(); else editor?.commands.focus(); + let done = false; + try { done = typeof document.execCommand === 'function' && document.execCommand(command); } catch { done = false; } + if (done) return; + void navigator.clipboard?.writeText(text).catch(() => {}); + if (command !== 'cut') return; + if (plain) { + if (textarea) replaceTextareaRange(textarea, textarea.selectionStart ?? 0, textarea.selectionEnd ?? 0, ''); + } else { + editor?.commands.deleteSelection(); + } + }, [readSelectedText]); const pluginToolbarProps = useMemo( () => ({ insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }), [pluginInsertText, pluginSelectedText, pluginComposerId], @@ -4103,9 +4138,10 @@ export function EmailComposer({ )} style={{ height: 'calc(100vh - 350px)' }} aria-invalid={validationErrors.body || undefined} + onContextMenu={onBodyContextMenu} /> ) : ( -
+
{ @@ -4727,6 +4763,7 @@ export function EmailComposer({ )}
pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} extraProps={pluginSidebarProps} /> +
); } diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts index 455202c0f..2e54dbdd3 100644 --- a/lib/plugin-slot-catalog.ts +++ b/lib/plugin-slot-catalog.ts @@ -86,6 +86,7 @@ export const SLOT_CATALOG = { 'composer-attach-menu': S('composer', 'action', ['ui:composer-toolbar'], { composerId: null }, 'entries in the Attach menu (for example "From Nextcloud"); the click\'s composerId lets api.composer.attach add files'), 'composer-options-menu': S('composer', 'action', ['ui:composer-toolbar'], { composerId: null }, 'entries in the composer\'s Options menu'), 'composer-toolbar-actions': S('composer', 'action', ['ui:composer-toolbar'], { composerId: null }, 'buttons in the composer\'s bottom bar, after Attach, Templates and Options; an action\'s shortcut works while the composer has focus', true), + 'composer-selection-menu': S('composer', 'action', ['ui:composer-toolbar'], { composerId: null, selection: 'compose:read' }, 'entries in the right-click menu on selected text in the message (without plugin entries the browser\'s own menu stays); the click\'s selection is { text }'), // ─── Calendar ────────────────────────────────────────────── 'calendar-event-actions': S('calendar', 'frame', ['ui:calendar-action'], { eventData: 'calendar:read', setVirtualLocation: null }, 'buttons in the event form', true), diff --git a/locales/en/common.json b/locales/en/common.json index 10cf32a0c..a10fa5503 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -752,6 +752,8 @@ "send_offline": "You seem to be offline. The message is still here, send it again once you are back online.", "send_timeout": "The server stopped responding. Check your Sent folder before sending again — the message may have gone out.", "send_recipients_rejected": "Not sent - the server rejected every recipient.", + "cut": "Cut", + "copy": "Copy", "send_plugin_failed": "Not sent - a plugin that handles sending failed or took too long. Check your Sent folder before sending again.", "send_some_recipients_rejected": "Sent, but not to these recipients - the server rejected them.", "continue_draft": "Continue draft",