diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 96401ee5d1..63fc6f6ada 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -2466,3 +2466,5 @@ export async function stopRemoteSessionDaemon( emitter.on( 'error', ( { error } ) => reject( error ) ); } ); } + +export { showTextContextMenu } from 'src/text-context-menu'; diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index 5bab9cce7e..59bb89d441 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -190,6 +190,7 @@ const api: IpcApi = { ipcRendererInvoke( 'extractBlueprintBundle', zipFilePath ), cleanupBlueprintTempDir: ( tempDir ) => ipcRendererInvoke( 'cleanupBlueprintTempDir', tempDir ), showSiteContextMenu: ( context ) => ipcRendererSend( 'showSiteContextMenu', context ), + showTextContextMenu: ( context ) => ipcRendererInvoke( 'showTextContextMenu', context ), setWindowControlVisibility: ( visible ) => ipcRendererInvoke( 'setWindowControlVisibility', visible ), setTitleBarBackdropEffect: ( enabled ) => diff --git a/apps/studio/src/tests/text-context-menu.test.ts b/apps/studio/src/tests/text-context-menu.test.ts new file mode 100644 index 0000000000..f8ccbd1d60 --- /dev/null +++ b/apps/studio/src/tests/text-context-menu.test.ts @@ -0,0 +1,230 @@ +/** + * @vitest-environment node + */ +import { BrowserWindow, clipboard, Menu, type IpcMainInvokeEvent } from 'electron'; +import { vi } from 'vitest'; +import { + buildTextContextMenuTemplate, + showTextContextMenu, + type TextContextMenuContext, + type TextContextMenuEnvironment, +} from 'src/text-context-menu'; + +vi.mock( 'electron', () => ( { + BrowserWindow: { fromWebContents: vi.fn() }, + Menu: { buildFromTemplate: vi.fn() }, + clipboard: { readText: vi.fn(), writeText: vi.fn() }, +} ) ); + +function makeContext( overrides: Partial< TextContextMenuContext > = {} ): TextContextMenuContext { + return { selectionText: '', isEditable: false, ...overrides }; +} + +function makeEnvironment( + overrides: Partial< TextContextMenuEnvironment > = {} +): TextContextMenuEnvironment { + return { platform: 'darwin', canPaste: false, ...overrides }; +} + +const actions = { lookUpSelection: vi.fn(), copyMessage: vi.fn(), quoteSelection: vi.fn() }; + +function labelsOf( template: ReturnType< typeof buildTextContextMenuTemplate > ) { + return template.map( ( item ) => item.role ?? item.label ?? item.type ); +} + +describe( 'buildTextContextMenuTemplate', () => { + it( 'offers Look Up, Copy and Copy All for a selection on a message on macOS', () => { + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist', messageText: 'Dark mode and core coexist.' } ), + actions, + makeEnvironment() + ); + + expect( labelsOf( template ) ).toEqual( [ + 'Look Up “coexist”', + 'separator', + 'copy', + 'Copy All', + 'separator', + 'Quote in composer', + ] ); + } ); + + it( 'omits Look Up on Windows and Linux, which have no system dictionary', () => { + for ( const platform of [ 'win32', 'linux' ] as const ) { + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist', messageText: 'Dark mode and core coexist.' } ), + actions, + makeEnvironment( { platform } ) + ); + + expect( labelsOf( template ) ).toEqual( [ + 'copy', + 'Copy All', + 'separator', + 'Quote in composer', + ] ); + } + } ); + + it( 'runs showDefinitionForSelection when Look Up is chosen', () => { + const lookUpSelection = vi.fn(); + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist' } ), + { ...actions, lookUpSelection }, + makeEnvironment() + ); + + ( template[ 0 ].click as () => void )(); + + expect( lookUpSelection ).toHaveBeenCalledOnce(); + } ); + + it( 'copies the whole message, not the selection, from Copy All', () => { + const copyMessage = vi.fn(); + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist', messageText: 'Dark mode and core coexist.' } ), + { ...actions, copyMessage }, + makeEnvironment( { platform: 'linux' } ) + ); + const copyAll = template.find( ( item ) => item.label === 'Copy All' ); + + ( copyAll?.click as () => void )(); + + expect( copyMessage ).toHaveBeenCalledWith( 'Dark mode and core coexist.' ); + } ); + + it( 'offers a translated label for role-based clipboard actions', () => { + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist', isEditable: true } ), + actions, + makeEnvironment( { platform: 'linux', canPaste: true } ) + ); + + expect( template.find( ( item ) => item.role === 'copy' )?.label ).toBe( 'Copy' ); + expect( template.find( ( item ) => item.role === 'paste' )?.label ).toBe( 'Paste' ); + } ); + + it( 'offers quoting for a read-only selection and runs its action', () => { + const quoteSelection = vi.fn(); + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist' } ), + { ...actions, quoteSelection }, + makeEnvironment( { platform: 'linux' } ) + ); + const quote = template.find( ( item ) => item.label === 'Quote in composer' ); + + ( quote?.click as () => void )(); + + expect( quoteSelection ).toHaveBeenCalledOnce(); + } ); + + it( 'collapses and truncates a long selection in the Look Up label', () => { + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: ' core color\n schemes and dark mode now coexist ' } ), + actions, + makeEnvironment() + ); + + expect( template[ 0 ].label ).toBe( 'Look Up “core color schemes and…”' ); + } ); + + it( 'offers Paste only in an editable field with something on the clipboard', () => { + expect( + labelsOf( + buildTextContextMenuTemplate( + makeContext( { isEditable: true } ), + actions, + makeEnvironment( { canPaste: true } ) + ) + ) + ).toEqual( [ 'paste' ] ); + + expect( + labelsOf( + buildTextContextMenuTemplate( + makeContext( { isEditable: false } ), + actions, + makeEnvironment( { canPaste: true } ) + ) + ) + ).toEqual( [] ); + + expect( + labelsOf( + buildTextContextMenuTemplate( + makeContext( { isEditable: true } ), + actions, + makeEnvironment( { canPaste: false } ) + ) + ) + ).toEqual( [] ); + } ); + + it( 'leaves no stray separator when a section drops out', () => { + // Look Up applies but there is no message to copy, so the divider must + // still sit between two populated sections rather than trailing. + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist' } ), + actions, + makeEnvironment() + ); + + expect( labelsOf( template ) ).toEqual( [ + 'Look Up “coexist”', + 'separator', + 'copy', + 'separator', + 'Quote in composer', + ] ); + expect( template[ 0 ].type ).not.toBe( 'separator' ); + expect( template.at( -1 )?.type ).not.toBe( 'separator' ); + } ); + + it( 'drops the divider when only the clipboard section applies', () => { + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist', isEditable: true } ), + actions, + makeEnvironment( { platform: 'win32' } ) + ); + + expect( labelsOf( template ) ).toEqual( [ 'copy' ] ); + } ); + + it( 'returns nothing to show when no text action applies', () => { + const template = buildTextContextMenuTemplate( + makeContext(), + actions, + makeEnvironment( { platform: 'win32' } ) + ); + + expect( template ).toEqual( [] ); + } ); +} ); + +describe( 'showTextContextMenu', () => { + it( 'returns the selected text when Quote in composer is chosen', async () => { + const popup = vi.fn(); + vi.mocked( BrowserWindow.fromWebContents ).mockReturnValue( null ); + vi.mocked( clipboard.readText ).mockReturnValue( '' ); + vi.mocked( Menu.buildFromTemplate ).mockReturnValue( { popup } as unknown as Menu ); + const event = { + sender: { showDefinitionForSelection: vi.fn() }, + } as unknown as IpcMainInvokeEvent; + + const resultPromise = showTextContextMenu( + event, + makeContext( { selectionText: 'Selected reply' } ) + ); + const template = vi.mocked( Menu.buildFromTemplate ).mock.calls[ 0 ][ 0 ]; + const quote = template.find( ( item ) => item.label === 'Quote in composer' ); + ( quote?.click as () => void )(); + const popupOptions = popup.mock.calls[ 0 ][ 0 ]; + popupOptions.callback(); + + await expect( resultPromise ).resolves.toEqual( { + action: 'quote-selection', + selectionText: 'Selected reply', + } ); + } ); +} ); diff --git a/apps/studio/src/text-context-menu.ts b/apps/studio/src/text-context-menu.ts new file mode 100644 index 0000000000..2838a242d8 --- /dev/null +++ b/apps/studio/src/text-context-menu.ts @@ -0,0 +1,131 @@ +import { + BrowserWindow, + clipboard, + Menu, + type MenuItemConstructorOptions, + IpcMainInvokeEvent, +} from 'electron'; +import { __, sprintf } from '@wordpress/i18n'; + +// Electron ships no default context menu — the one Chrome shows is built by +// Chrome's browser layer, which isn't part of the embedded content layer. The +// items below are declared by us, but the menu itself is the real native +// widget on every platform (NSMenu, Win32, GTK). +// +// The renderer drives this rather than `webContents.on( 'context-menu' )`, +// matching `showSiteContextMenu`: only the renderer knows which message was +// clicked, and pushing that to the main process afterwards would race the +// browser's own context-menu request. + +// Long enough to recognise the phrase, short enough that the menu doesn't +// stretch across the screen. macOS truncates its own Look Up label similarly. +const LOOK_UP_LABEL_MAX_LENGTH = 24; + +export interface TextContextMenuContext { + selectionText: string; + isEditable: boolean; + // The full message the click landed on, when it landed on one at all. + messageText?: string; +} + +export interface TextContextMenuActions { + lookUpSelection: () => void; + copyMessage: ( text: string ) => void; + quoteSelection: () => void; +} + +export interface TextContextMenuEnvironment { + platform: NodeJS.Platform; + canPaste: boolean; +} + +export type TextContextMenuResult = + | { action: 'quote-selection'; selectionText: string } + | undefined; + +function toLookUpLabel( selection: string ): string { + const collapsed = selection.replace( /\s+/g, ' ' ).trim(); + const truncated = + collapsed.length > LOOK_UP_LABEL_MAX_LENGTH + ? `${ collapsed.slice( 0, LOOK_UP_LABEL_MAX_LENGTH - 1 ).trimEnd() }…` + : collapsed; + /* translators: %s: the text the user selected. */ + return sprintf( __( 'Look Up “%s”' ), truncated ); +} + +/** + * Text-only context menu: copy the selection, copy the whole message, and look + * a word up. Look Up is macOS-only because Windows and Linux expose no system + * dictionary to apps — their native text menus really are just the edit + * commands, so gating on platform yields what each OS would natively show. + */ +export function buildTextContextMenuTemplate( + context: TextContextMenuContext, + actions: TextContextMenuActions, + environment: TextContextMenuEnvironment +): MenuItemConstructorOptions[] { + const selection = context.selectionText.trim(); + const messageText = context.messageText; + + // Built as sections and joined with separators, so an inapplicable section + // can't leave a stray divider behind. + const sections: MenuItemConstructorOptions[][] = []; + + if ( environment.platform === 'darwin' && selection ) { + sections.push( [ { label: toLookUpLabel( selection ), click: actions.lookUpSelection } ] ); + } + + const clipboardItems: MenuItemConstructorOptions[] = []; + if ( selection ) { + clipboardItems.push( { label: __( 'Copy' ), role: 'copy' } ); + } + if ( messageText ) { + clipboardItems.push( { + label: __( 'Copy All' ), + click: () => actions.copyMessage( messageText ), + } ); + } + if ( context.isEditable && environment.canPaste ) { + clipboardItems.push( { label: __( 'Paste' ), role: 'paste' } ); + } + if ( clipboardItems.length > 0 ) { + sections.push( clipboardItems ); + } + if ( selection && ! context.isEditable ) { + sections.push( [ { label: __( 'Quote in composer' ), click: actions.quoteSelection } ] ); + } + + return sections.flatMap( ( section, index ) => + index === 0 ? section : [ { type: 'separator' }, ...section ] + ); +} + +export async function showTextContextMenu( + event: IpcMainInvokeEvent, + context: TextContextMenuContext +): Promise< TextContextMenuResult > { + let result: TextContextMenuResult; + const template = buildTextContextMenuTemplate( + context, + { + lookUpSelection: () => event.sender.showDefinitionForSelection(), + copyMessage: ( text ) => clipboard.writeText( text ), + quoteSelection: () => { + result = { action: 'quote-selection', selectionText: context.selectionText.trim() }; + }, + }, + { platform: process.platform, canPaste: clipboard.readText().length > 0 } + ); + + if ( template.length === 0 ) { + return undefined; + } + + const window = BrowserWindow.fromWebContents( event.sender ); + return new Promise( ( resolve ) => { + Menu.buildFromTemplate( template ).popup( { + ...( window ? { window } : {} ), + callback: () => resolve( result ), + } ); + } ); +} diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts index 53ec379208..6c886f7fa0 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -828,6 +828,10 @@ export function createIpcConnector(): Connector { await ipcApi.copyText( text ); }, + async showTextContextMenu( context ) { + return ipcApi.showTextContextMenu( context ); + }, + async confirmDeleteAllPreviewSites(): Promise< boolean > { const CANCEL_BUTTON_INDEX = 0; const DELETE_BUTTON_INDEX = 1; diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index 9d4ee6eff3..950f4bb30e 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -404,6 +404,15 @@ export interface Connector { // Clipboard — routed to the host so it works where the renderer's // `navigator.clipboard` is unavailable (e.g. Electron permission denial). copyText( text: string ): Promise< void >; + + // Pops the host's native text context menu. Absent in the browser builds, + // which already have a real one — there the right-click is left alone. + showTextContextMenu?( context: { + selectionText: string; + isEditable: boolean; + messageText?: string; + } ): Promise< { action: 'quote-selection'; selectionText: string } | undefined >; + openSiteUrl( siteId: string, relativeUrl?: string, diff --git a/apps/ui/src/hooks/use-text-context-menu.test.tsx b/apps/ui/src/hooks/use-text-context-menu.test.tsx new file mode 100644 index 0000000000..b819046e16 --- /dev/null +++ b/apps/ui/src/hooks/use-text-context-menu.test.tsx @@ -0,0 +1,129 @@ +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { watchComposerTextQuote } from '@/lib/composer-text-quote'; +import { MESSAGE_TEXT_ATTRIBUTE, useTextContextMenu } from './use-text-context-menu'; + +const showTextContextMenu = vi.fn().mockResolvedValue( undefined ); + +vi.mock( '@/data/core', () => ( { + useConnector: () => ( { showTextContextMenu } ), +} ) ); + +function Harness() { + useTextContextMenu(); + return ( +
The whole reply.
+wp plugin list+ + +