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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
clipboard,
dialog,
shell,
webContents,
type IpcMainInvokeEvent,
type WebContents,
Notification,
SaveDialogOptions,
} from 'electron';
Expand Down Expand Up @@ -2468,3 +2470,73 @@ export async function stopRemoteSessionDaemon(
emitter.on( 'error', ( { error } ) => reject( error ) );
} );
}

function getOwnedWebviewContents( event: IpcMainInvokeEvent, webContentsId: number ): WebContents {
if ( ! Number.isInteger( webContentsId ) || webContentsId <= 0 ) {
throw new Error( 'Invalid webview identifier.' );
}

const target = webContents.fromId( webContentsId );
if ( ! target || target.isDestroyed() ) {
throw new Error( 'Webview is no longer available.' );
}

if ( target.hostWebContents?.id !== event.sender.id ) {
throw new Error( 'Webview does not belong to the current window.' );
}

return target;
}

function attachDebuggerIfNeeded( target: WebContents ): boolean {
if ( target.debugger.isAttached() ) {
return false;
}

target.debugger.attach( '1.3' );
return true;
}

async function sendDebuggerCommand< T >(
target: WebContents,
method: string,
params?: Record< string, unknown >
): Promise< T > {
return ( await target.debugger.sendCommand( method, params ) ) as T;
}

// Simulates a viewport for the preview webview via the CDP device-metrics
// override that DevTools device mode is built on: the guest lays out at
// `width`×`height` CSS px and Chromium scales the rendered result by `scale`
// to fit the webview, remapping input coordinates to match. `null` returns
// the guest to the webview's natural size.
export async function setWebviewViewport(
event: IpcMainInvokeEvent,
webContentsId: number,
viewport: { width: number; height: number; scale: number; mobile?: boolean } | null
): Promise< void > {
const target = getOwnedWebviewContents( event, webContentsId );
attachDebuggerIfNeeded( target );
if ( ! viewport ) {
await sendDebuggerCommand( target, 'Emulation.clearDeviceMetricsOverride' );
return;
}
const { width, height, scale, mobile } = viewport;
const isValidDimension = ( value: number ) =>
Number.isInteger( value ) && value > 0 && value <= 10000;
const isValidScale =
typeof scale === 'number' && Number.isFinite( scale ) && scale > 0 && scale <= 1;
if ( ! isValidDimension( width ) || ! isValidDimension( height ) || ! isValidScale ) {
throw new Error( 'Unsupported webview viewport.' );
}
await sendDebuggerCommand( target, 'Emulation.setDeviceMetricsOverride', {
width,
height,
// 0 keeps the display's real device pixel ratio.
deviceScaleFactor: 0,
// Mobile presets emulate a phone (meta-viewport handling and mobile UA
// hints), not just a narrow desktop window.
mobile: mobile === true,
scale,
} );
}
2 changes: 2 additions & 0 deletions apps/studio/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ const api: IpcApi = {
getFileSize: ( id, filePath ) => ipcRendererInvoke( 'getFileSize', id, filePath ),
getPathForFile: ( file ) => webUtils.getPathForFile( file ),
readLocalMediaFile: ( path ) => ipcRendererInvoke( 'readLocalMediaFile', path ),
setWebviewViewport: ( webContentsId, viewport ) =>
ipcRendererInvoke( 'setWebviewViewport', webContentsId, viewport ),
isFullscreen: () => ipcRendererInvoke( 'isFullscreen' ),
getAllCustomDomains: () => ipcRendererInvoke( 'getAllCustomDomains' ),
saveUserTerminal: ( preferredTerminal ) =>
Expand Down
5 changes: 5 additions & 0 deletions apps/ui/src/components/menu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const { ThemeProvider } = unlock( privateApis );

export const Root = BaseMenu.Root;
export const Trigger = BaseMenu.Trigger;
export const Group = BaseMenu.Group;
export const RadioGroup = BaseMenu.RadioGroup;
export const SubmenuRoot = BaseMenu.SubmenuRoot;
export const ContextMenuRoot = BaseContextMenu.Root;
Expand Down Expand Up @@ -171,6 +172,10 @@ export const RadioItem = forwardRef< ElementRef< typeof BaseMenu.RadioItem >, Ra
}
);

export function GroupLabel( { children }: { children: ReactNode } ) {
return <BaseMenu.GroupLabel className={ styles.groupLabel }>{ children }</BaseMenu.GroupLabel>;
}

export function Separator( { className }: { className?: string } ) {
return <BaseMenu.Separator className={ `${ styles.separator } ${ className ?? '' }` } />;
}
7 changes: 7 additions & 0 deletions apps/ui/src/components/menu/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@
white-space: nowrap;
}

.groupLabel {
padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm) 0;
color: var(--wpds-color-fg-content-neutral-weak);
font-size: var(--wpds-typography-font-size-xs);
line-height: var(--wpds-typography-line-height-sm);
}

.separator {
height: 1px;
margin: var(--wpds-dimension-padding-xs) 0;
Expand Down
93 changes: 92 additions & 1 deletion apps/ui/src/components/site-preview/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { displayShortcut } from '@wordpress/keycodes';
import { Tooltip } from '@wordpress/ui';
import { describe, expect, it, vi } from 'vitest';
import { useConnector } from '@/data/core';
import { getBrowserShortcutCommand, getPathFromPreviewUrl, SitePreview } from './index';
import {
getBrowserShortcutCommand,
getPathFromPreviewUrl,
getSimulatedViewport,
SitePreview,
} from './index';
import type { SiteDetails } from '@/data/core';
import type { ReactNode } from 'react';

Expand Down Expand Up @@ -296,6 +301,50 @@ describe( 'SitePreview', () => {

expect( screen.getByRole( 'button', { name: 'Annotate' } ) ).toBeInTheDocument();
} );

it( 'offers responsive modes from the More options menu while running', async () => {
useConnectorMock.mockReturnValue( {
startSite: vi.fn().mockResolvedValue( undefined ),
capabilities: CAPABILITIES,
} as never );

renderPreview(
<SitePreview site={ createSite( { running: true } ) } path="/" reloadNonce={ 0 } />
);

fireEvent.click( screen.getByRole( 'button', { name: 'More options' } ) );

expect( await screen.findByText( 'Responsive mode' ) ).toBeVisible();
expect( screen.getByRole( 'menuitemradio', { name: 'Fit pane' } ) ).toBeChecked();
// The orientation group only accompanies the phone frame.
expect( screen.queryByText( 'Mobile orientation' ) ).not.toBeInTheDocument();

// Radio items keep the menu open, so the orientation group appears in place.
fireEvent.click( screen.getByRole( 'menuitemradio', { name: 'Mobile · 390×844' } ) );

expect( await screen.findByText( 'Mobile orientation' ) ).toBeVisible();
expect( screen.getByRole( 'menuitemradio', { name: 'Portrait' } ) ).toBeChecked();

// The menu is modal: its backdrop covers the webview, so clicks over
// the preview dismiss the menu instead of vanishing into the guest.
const backdrop = document.querySelector( '[role="presentation"][data-base-ui-inert]' );
expect( backdrop ).toBeInTheDocument();
fireEvent.pointerDown( backdrop as Element );
await waitFor( () =>
expect( screen.queryByText( 'Responsive mode' ) ).not.toBeInTheDocument()
);
} );

it( 'hides the More options menu when the site is not running', () => {
useConnectorMock.mockReturnValue( {
startSite: vi.fn().mockResolvedValue( undefined ),
capabilities: CAPABILITIES,
} as never );

renderPreview( <SitePreview site={ createSite() } path="/" reloadNonce={ 0 } /> );

expect( screen.queryByRole( 'button', { name: 'More options' } ) ).not.toBeInTheDocument();
} );
} );

describe( 'getBrowserShortcutCommand', () => {
Expand Down Expand Up @@ -353,6 +402,48 @@ describe( 'getBrowserShortcutCommand', () => {
} );
} );

describe( 'getSimulatedViewport', () => {
it( 'returns null without a preset or a measured pane', () => {
expect( getSimulatedViewport( null, { width: 520, height: 700 } ) ).toBe( null );
expect( getSimulatedViewport( { width: 390, height: 844 }, null ) ).toBe( null );
expect( getSimulatedViewport( { width: 390, height: 844 }, { width: 0, height: 700 } ) ).toBe(
null
);
} );

it( 'keeps presets at their exact dimensions, scaled down to fit both axes', () => {
// The height binds: 700 / 844 is smaller than 520 / 390.
expect(
getSimulatedViewport( { width: 390, height: 844, mobile: true }, { width: 520, height: 700 } )
).toEqual( {
width: 390,
height: 844,
scale: 700 / 844,
mobile: true,
} );
// The width binds for a desktop frame in a narrow pane.
expect(
getSimulatedViewport( { width: 1440, height: 900 }, { width: 720, height: 800 } )
).toEqual( {
width: 1440,
height: 900,
scale: 0.5,
mobile: false,
} );
} );

it( 'never scales up in a larger pane', () => {
expect(
getSimulatedViewport( { width: 390, height: 844 }, { width: 600, height: 1000 } )
).toEqual( {
width: 390,
height: 844,
scale: 1,
mobile: false,
} );
} );
} );

describe( 'getPathFromPreviewUrl', () => {
it( 'extracts the path, search, and hash for same-origin urls', () => {
expect(
Expand Down
Loading
Loading