Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/studio/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const IPC_VOID_HANDLERS = [
'setWindowButtonVisibility',
'showErrorMessageBox',
'showSiteContextMenu',
'setPreviewInspectorReady',
'showItemInFolder',
'showNotification',
'authenticate',
Expand Down
12 changes: 11 additions & 1 deletion apps/studio/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
} from 'electron-devtools-installer';
import { IPC_VOID_HANDLERS } from 'src/constants';
import * as ipcHandlers from 'src/ipc-handlers';
import { markAppQuitting } from 'src/ipc-utils';
import { markAppQuitting, sendIpcEventToRenderer } from 'src/ipc-utils';
import {
hasActiveSyncOperations,
hasUploadingPushOperations,
Expand Down Expand Up @@ -59,6 +59,7 @@ import { autoInstallLinuxCliIfNeeded } from 'src/modules/cli/lib/linux-installat
import { autoInstallMacOSCliIfNeeded } from 'src/modules/cli/lib/macos-installation-manager';
import { autoInstallWindowsCliIfNeeded } from 'src/modules/cli/lib/windows-installation-manager';
import { startRemoteSessionStatusPolling } from 'src/modules/remote-session/daemon-status-poller';
import { registerPreviewContextMenu } from 'src/preview-context-menu';
import {
getRunningSiteCount,
persistAutoStartForRunningSites,
Expand Down Expand Up @@ -210,6 +211,15 @@ async function appBoot() {
app.on( 'web-contents-created', ( _event, contents ) => {
const isSitePreviewWebview = contents.getType() === 'webview';

if ( isSitePreviewWebview ) {
registerPreviewContextMenu( contents, {
openExternally: openExternalWebUrl,
// The guest page remembers which element was right-clicked, so
// the renderer only needs the go-ahead.
annotateElement: () => void sendIpcEventToRenderer( 'preview-annotate-element' ),
} );
}

contents.on( 'will-navigate', ( event, navigationUrl ) => {
if ( isSitePreviewWebview ) {
return;
Expand Down
9 changes: 9 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ import { linuxFindEditorPath } from 'src/modules/user-settings/lib/linux-editor-
import { linuxFindTerminalPath } from 'src/modules/user-settings/lib/linux-terminal-path';
import { SupportedTerminal } from 'src/modules/user-settings/lib/terminal';
import { winFindEditorPath } from 'src/modules/user-settings/lib/win-editor-path';
import { setPreviewInspectorReady as storePreviewInspectorReady } from 'src/preview-context-menu';
import { SiteServer, stopAllServers as triggerStopAllServers } from 'src/site-server';
import { getSiteThumbnailPath } from 'src/storage/paths';
import {
Expand Down Expand Up @@ -2456,3 +2457,11 @@ export async function stopRemoteSessionDaemon(
emitter.on( 'error', ( { error } ) => reject( error ) );
} );
}

// The site preview's annotation inspector lives in the guest page and is
// re-injected on every load, so only the renderer knows whether it is
// currently attached. It pushes that state here for the preview's context
// menu, which would otherwise have to offer its element actions blind.
export function setPreviewInspectorReady( _event: IpcMainInvokeEvent, ready: boolean ) {
storePreviewInspectorReady( ready );
}
4 changes: 4 additions & 0 deletions apps/studio/src/ipc-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export interface IpcEvents {
'on-export': [ ExportIpcEvent[ 'event' ], string ];
'on-import': [ ImportEventTuple, string ];
'on-site-create-progress': [ { siteId: string; message: string } ];
// The user picked "Annotate Element" in the site preview's context menu.
// Only the guest page knows which element that was, so the renderer hands
// the request straight to the inspector rather than carrying a target.
'preview-annotate-element': [ void ];
'site-context-menu-action': [ { action: string; siteId: string } ];
'site-event': [ SiteEvent ];
'snapshot-event': [ SnapshotEvent ];
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ const api: IpcApi = {
ipcRendererInvoke( 'extractBlueprintBundle', zipFilePath ),
cleanupBlueprintTempDir: ( tempDir ) => ipcRendererInvoke( 'cleanupBlueprintTempDir', tempDir ),
showSiteContextMenu: ( context ) => ipcRendererSend( 'showSiteContextMenu', context ),
setPreviewInspectorReady: ( ready ) => ipcRendererSend( 'setPreviewInspectorReady', ready ),
setWindowControlVisibility: ( visible ) =>
ipcRendererInvoke( 'setWindowControlVisibility', visible ),
setTitleBarBackdropEffect: ( enabled ) =>
Expand Down
183 changes: 183 additions & 0 deletions apps/studio/src/preview-context-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import {
clipboard,
Menu,
type ContextMenuParams,
type MenuItemConstructorOptions,
type WebContents,
} from 'electron';
import { __, sprintf } from '@wordpress/i18n';

// The site preview is a <webview> — its own webContents in its own process, so
// the agentic UI's right-click handling never sees these events. Here the main
// process can own the menu outright: unlike the chat panel, everything the menu
// needs is already in `params`, so nothing has to be asked of a renderer.

// 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;

// Pushed by the renderer as the inspector attaches and detaches. It is plain
// state rather than something asked for at right-click time, so reading it
// while building the menu can't race the click that opened it.
let previewInspectorReady = false;

export function setPreviewInspectorReady( ready: boolean ): void {
previewInspectorReady = ready;
}

export function isPreviewInspectorReady(): boolean {
return previewInspectorReady;
}

export interface PreviewContextMenuState {
// False while the annotation inspector isn't injected into the guest page
// (during a load, or on a page it couldn't attach to). Only it knows which
// element was clicked, so Annotate is left out rather than offered and
// doing nothing.
inspectorReady: boolean;
}

export interface PreviewContextMenuActions {
annotateElement: () => void;
openExternally: ( url: string ) => void;
copyToClipboard: ( text: string ) => void;
copyImage: () => void;
lookUpSelection: () => void;
inspectElement: () => void;
}

export interface PreviewContextMenuEnvironment {
platform: NodeJS.Platform;
}

type PreviewContextMenuParams = Pick<
ContextMenuParams,
| 'selectionText'
| 'isEditable'
| 'editFlags'
| 'linkURL'
| 'srcURL'
| 'mediaType'
| 'hasImageContents'
>;

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 );
}

/**
* Context menu for the previewed site.
*
* Annotating comes first — it's why you'd right-click here — followed by
* whatever the pointer is actually on. Every item is conditional on doing
* something, so nothing is offered greyed out or inert.
*/
export function buildPreviewContextMenuTemplate(
params: PreviewContextMenuParams,
state: PreviewContextMenuState,
actions: PreviewContextMenuActions,
environment: PreviewContextMenuEnvironment
): MenuItemConstructorOptions[] {
const selection = params.selectionText.trim();
const linkUrl = params.linkURL;
const imageUrl = params.mediaType === 'image' && params.hasImageContents ? params.srcURL : '';

// Built as sections and joined with separators, so an inapplicable section
// can't leave a stray divider behind.
const sections: MenuItemConstructorOptions[][] = [];

if ( state.inspectorReady ) {
sections.push( [ { label: __( 'Annotate Element' ), click: actions.annotateElement } ] );
}

if ( linkUrl ) {
sections.push( [
{ label: __( 'Open Link in Browser' ), click: () => actions.openExternally( linkUrl ) },
{ label: __( 'Copy Link Address' ), click: () => actions.copyToClipboard( linkUrl ) },
] );
}

if ( imageUrl ) {
sections.push( [
{ label: __( 'Copy Image' ), click: actions.copyImage },
{ label: __( 'Copy Image Address' ), click: () => actions.copyToClipboard( imageUrl ) },
{ label: __( 'Open Image in Browser' ), click: () => actions.openExternally( imageUrl ) },
] );
}

if ( environment.platform === 'darwin' && selection ) {
sections.push( [ { label: toLookUpLabel( selection ), click: actions.lookUpSelection } ] );
}

// Cut, Paste and Select All only mean anything in a field the user can type
// in; on page text, Copy is the only one worth offering.
const editItems: MenuItemConstructorOptions[] = [];
if ( params.isEditable && params.editFlags.canCut ) {
editItems.push( { role: 'cut' } );
}
if ( params.editFlags.canCopy ) {
editItems.push( { role: 'copy' } );
}
if ( params.isEditable && params.editFlags.canPaste ) {
editItems.push( { role: 'paste' } );
}
if ( params.isEditable && params.editFlags.canSelectAll ) {
editItems.push( { role: 'selectAll' } );
}
if ( editItems.length > 0 ) {
sections.push( editItems );
}

// Not gated to development builds: inspecting the page you're building is
// the point of the preview, and Electron keeps DevTools available when
// packaged.
sections.push( [ { label: __( 'Inspect Element' ), click: actions.inspectElement } ] );

return sections.flatMap( ( section, index ) =>
index === 0 ? section : [ { type: 'separator' }, ...section ]
);
}

/**
* Attaches the context menu to a site-preview <webview>.
*/
export function registerPreviewContextMenu(
contents: WebContents,
{
openExternally,
annotateElement,
}: {
openExternally: ( url: string ) => void;
annotateElement: () => void;
}
): void {
contents.on( 'context-menu', ( _event, params ) => {
const template = buildPreviewContextMenuTemplate(
params,
{ inspectorReady: isPreviewInspectorReady() },
{
annotateElement,
openExternally,
copyToClipboard: ( text ) => clipboard.writeText( text ),
copyImage: () => contents.copyImageAt( params.x, params.y ),
// Known cosmetic quirk: macOS anchors the dictionary panel to a
// selection rect the guest reports in its own coordinate space,
// and nothing translates it by the <webview>'s offset in the
// window — so the panel and its floating text land together in
// the wrong place. The definition itself is correct, and the
// placement isn't ours to fix (the API takes no position).
lookUpSelection: () => contents.showDefinitionForSelection(),
inspectElement: () => contents.inspectElement( params.x, params.y ),
},
{ platform: process.platform }
);

Menu.buildFromTemplate( template ).popup();
} );
}
Loading
Loading