feat: let plugins add entries to the right-click menu on selected text in the composer

This commit is contained in:
Linus Rath
2026-10-20 17:21:12 +02:00
parent a2136319fb
commit 00142540f6
5 changed files with 192 additions and 2 deletions
@@ -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(<EmailComposer initialData={{ ...DRAFT, body: 'Hello there' }} onClose={vi.fn()} />);
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(<EmailComposer initialData={{ ...DRAFT, body: 'Hello there' }} onClose={vi.fn()} />);
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 {
@@ -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<SelectionMenuTarget>();
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<typeof useComposerSelectionMenu>;
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 (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<ContextMenuItem icon={Scissors} label={t("cut")} testId="composer-selection-cut" onClick={() => { closeContextMenu(); onClipboard("cut"); }} />
<ContextMenuItem icon={Copy} label={t("copy")} testId="composer-selection-copy" onClick={() => { closeContextMenu(); onClipboard("copy"); }} />
<ContextMenuSeparator />
<PluginActions
surface="composer-selection-menu"
variant="menu"
context={{ composerId, selection: { text } }}
onRan={closeContextMenu}
/>
</ContextMenu>
);
}
+38 -1
View File
@@ -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}
/>
) : (
<div ref={editorContainerRef}>
<div ref={editorContainerRef} onContextMenu={onBodyContextMenu}>
<RichTextEditor
content={body}
onChange={(html) => {
@@ -4727,6 +4763,7 @@ export function EmailComposer({
)}
</div>
<ComposerPluginSidebar side="right" mode={pluginSidebars.modes.right} onOpen={() => pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} extraProps={pluginSidebarProps} />
<ComposerSelectionMenu menu={selectionMenu} composerId={pluginComposerId} onClipboard={clipboardFromMenu} />
</div>
);
}
+1
View File
@@ -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),
+2
View File
@@ -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",