Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2466,3 +2466,5 @@ export async function stopRemoteSessionDaemon(
emitter.on( 'error', ( { error } ) => reject( error ) );
} );
}

export { showTextContextMenu } from 'src/text-context-menu';
1 change: 1 addition & 0 deletions apps/studio/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) =>
Expand Down
230 changes: 230 additions & 0 deletions apps/studio/src/tests/text-context-menu.test.ts
Original file line number Diff line number Diff line change
@@ -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',
} );
} );
} );
131 changes: 131 additions & 0 deletions apps/studio/src/text-context-menu.ts
Original file line number Diff line number Diff line change
@@ -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 ),
} );
} );
}
4 changes: 4 additions & 0 deletions apps/ui/src/data/core/connectors/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading