diff --git a/apps/studio/src/constants.ts b/apps/studio/src/constants.ts index b860b7b0df..c74ca35cd0 100644 --- a/apps/studio/src/constants.ts +++ b/apps/studio/src/constants.ts @@ -61,6 +61,7 @@ export const IPC_VOID_HANDLERS = [ 'setWindowButtonVisibility', 'showErrorMessageBox', 'showSiteContextMenu', + 'setPreviewInspectorReady', 'showItemInFolder', 'showNotification', 'authenticate', diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index 7d9485fa94..9a14fbf811 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -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, @@ -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, @@ -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; diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 59aa476a8e..47b29c1025 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -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 { @@ -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 ); +} diff --git a/apps/studio/src/ipc-utils.ts b/apps/studio/src/ipc-utils.ts index 2c17fa75ea..14eec7d7e0 100644 --- a/apps/studio/src/ipc-utils.ts +++ b/apps/studio/src/ipc-utils.ts @@ -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 ]; diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index c0da6112f7..b303f1a899 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -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 ) => diff --git a/apps/studio/src/preview-context-menu.ts b/apps/studio/src/preview-context-menu.ts new file mode 100644 index 0000000000..3838e2736c --- /dev/null +++ b/apps/studio/src/preview-context-menu.ts @@ -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 — 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 . + */ +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 '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(); + } ); +} diff --git a/apps/studio/src/tests/preview-context-menu.test.ts b/apps/studio/src/tests/preview-context-menu.test.ts new file mode 100644 index 0000000000..c855ffa8dc --- /dev/null +++ b/apps/studio/src/tests/preview-context-menu.test.ts @@ -0,0 +1,266 @@ +/** + * @vitest-environment node + */ +import { vi } from 'vitest'; +import { + buildPreviewContextMenuTemplate, + type PreviewContextMenuActions, + type PreviewContextMenuEnvironment, + type PreviewContextMenuState, +} from 'src/preview-context-menu'; +import type { ContextMenuParams } from 'electron'; + +vi.mock( 'electron', () => ( { + Menu: { buildFromTemplate: vi.fn() }, + clipboard: { writeText: vi.fn() }, +} ) ); + +type Params = Parameters< typeof buildPreviewContextMenuTemplate >[ 0 ]; + +function makeParams( overrides: Partial< Params > = {} ): Params { + return { + selectionText: '', + isEditable: false, + editFlags: { + canCut: false, + canCopy: false, + canPaste: false, + canSelectAll: true, + } as ContextMenuParams[ 'editFlags' ], + linkURL: '', + srcURL: '', + mediaType: 'none', + hasImageContents: false, + ...overrides, + }; +} + +function makeState( overrides: Partial< PreviewContextMenuState > = {} ): PreviewContextMenuState { + return { inspectorReady: true, ...overrides }; +} + +function makeEnvironment( + overrides: Partial< PreviewContextMenuEnvironment > = {} +): PreviewContextMenuEnvironment { + return { platform: 'darwin', ...overrides }; +} + +function makeActions(): PreviewContextMenuActions { + return { + annotateElement: vi.fn(), + openExternally: vi.fn(), + copyToClipboard: vi.fn(), + copyImage: vi.fn(), + lookUpSelection: vi.fn(), + inspectElement: vi.fn(), + }; +} + +function labelsOf( template: ReturnType< typeof buildPreviewContextMenuTemplate > ) { + return template.map( ( item ) => item.role ?? item.label ?? item.type ); +} + +describe( 'buildPreviewContextMenuTemplate', () => { + it( 'offers only Annotate and Inspect on plain page text', () => { + const template = buildPreviewContextMenuTemplate( + makeParams(), + makeState(), + makeActions(), + makeEnvironment() + ); + + expect( labelsOf( template ) ).toEqual( [ + 'Annotate Element', + 'separator', + 'Inspect Element', + ] ); + } ); + + it( 'leaves Annotate out while the inspector is not attached', () => { + const template = buildPreviewContextMenuTemplate( + makeParams(), + makeState( { inspectorReady: false } ), + makeActions(), + makeEnvironment() + ); + + expect( labelsOf( template ) ).toEqual( [ 'Inspect Element' ] ); + } ); + + it( 'hands the annotation request straight to the guest page', () => { + const actions = makeActions(); + const template = buildPreviewContextMenuTemplate( + makeParams(), + makeState(), + actions, + makeEnvironment() + ); + + ( template[ 0 ].click as () => void )(); + + expect( actions.annotateElement ).toHaveBeenCalledOnce(); + } ); + + it( 'offers link actions that leave the preview', () => { + const actions = makeActions(); + const template = buildPreviewContextMenuTemplate( + makeParams( { linkURL: 'https://example.com/about' } ), + makeState( { inspectorReady: false } ), + actions, + makeEnvironment() + ); + + expect( labelsOf( template ) ).toEqual( [ + 'Open Link in Browser', + 'Copy Link Address', + 'separator', + 'Inspect Element', + ] ); + + ( template[ 0 ].click as () => void )(); + ( template[ 1 ].click as () => void )(); + expect( actions.openExternally ).toHaveBeenCalledWith( 'https://example.com/about' ); + expect( actions.copyToClipboard ).toHaveBeenCalledWith( 'https://example.com/about' ); + } ); + + it( 'offers image actions only for an image with real contents', () => { + const actions = makeActions(); + const withImage = buildPreviewContextMenuTemplate( + makeParams( { + mediaType: 'image', + hasImageContents: true, + srcURL: 'https://example.com/logo.png', + } ), + makeState( { inspectorReady: false } ), + actions, + makeEnvironment() + ); + expect( labelsOf( withImage ) ).toEqual( [ + 'Copy Image', + 'Copy Image Address', + 'Open Image in Browser', + 'separator', + 'Inspect Element', + ] ); + + ( withImage[ 2 ].click as () => void )(); + expect( actions.openExternally ).toHaveBeenCalledWith( 'https://example.com/logo.png' ); + + // A broken image reports the type but has nothing to copy or open. + const brokenImage = buildPreviewContextMenuTemplate( + makeParams( { mediaType: 'image', hasImageContents: false } ), + makeState( { inspectorReady: false } ), + makeActions(), + makeEnvironment() + ); + expect( labelsOf( brokenImage ) ).not.toContain( 'Copy Image' ); + } ); + + it( 'offers Look Up for a selection on macOS only', () => { + const params = makeParams( { + selectionText: 'permalink', + editFlags: { canCopy: true, canSelectAll: true } as ContextMenuParams[ 'editFlags' ], + } ); + const state = makeState( { inspectorReady: false } ); + + expect( + labelsOf( buildPreviewContextMenuTemplate( params, state, makeActions(), makeEnvironment() ) ) + ).toEqual( [ 'Look Up “permalink”', 'separator', 'copy', 'separator', 'Inspect Element' ] ); + + expect( + labelsOf( + buildPreviewContextMenuTemplate( + params, + state, + makeActions(), + makeEnvironment( { platform: 'win32' } ) + ) + ) + ).toEqual( [ 'copy', 'separator', 'Inspect Element' ] ); + } ); + + it( 'keeps Cut, Paste and Select All to fields the user can type in', () => { + const editFlags = { + canCut: true, + canCopy: true, + canPaste: true, + canSelectAll: true, + } as ContextMenuParams[ 'editFlags' ]; + const state = makeState( { inspectorReady: false } ); + const onWindows = makeEnvironment( { platform: 'win32' } ); + + expect( + labelsOf( + buildPreviewContextMenuTemplate( + makeParams( { isEditable: true, selectionText: 'permalink', editFlags } ), + state, + makeActions(), + onWindows + ) + ) + ).toEqual( [ 'cut', 'copy', 'paste', 'selectAll', 'separator', 'Inspect Element' ] ); + + // Page text gets Copy and nothing else — Select All would highlight the + // whole document, which is never what was wanted here. + expect( + labelsOf( + buildPreviewContextMenuTemplate( + makeParams( { isEditable: false, selectionText: 'permalink', editFlags } ), + state, + makeActions(), + onWindows + ) + ) + ).toEqual( [ 'copy', 'separator', 'Inspect Element' ] ); + } ); + + it( 'offers Inspect Element in production too', () => { + const template = buildPreviewContextMenuTemplate( + makeParams(), + makeState( { inspectorReady: false } ), + makeActions(), + makeEnvironment() + ); + + expect( labelsOf( template ) ).toContain( 'Inspect Element' ); + } ); + + it( 'collapses and truncates a long selection in the Look Up label', () => { + const template = buildPreviewContextMenuTemplate( + makeParams( { selectionText: ' the quick\n brown fox jumps over the dog ' } ), + makeState( { inspectorReady: false } ), + makeActions(), + makeEnvironment() + ); + + expect( template[ 0 ].label ).toBe( 'Look Up “the quick brown fox jum…”' ); + } ); + + it( 'never leads or trails with a separator', () => { + const template = buildPreviewContextMenuTemplate( + makeParams( { + linkURL: 'https://example.com', + selectionText: 'about', + editFlags: { canCopy: true, canSelectAll: true } as ContextMenuParams[ 'editFlags' ], + } ), + makeState(), + makeActions(), + makeEnvironment() + ); + + expect( template[ 0 ].type ).not.toBe( 'separator' ); + expect( template.at( -1 )?.type ).not.toBe( 'separator' ); + expect( labelsOf( template ) ).toEqual( [ + 'Annotate Element', + 'separator', + 'Open Link in Browser', + 'Copy Link Address', + 'separator', + 'Look Up “about”', + 'separator', + 'copy', + 'separator', + 'Inspect Element', + ] ); + } ); +} ); diff --git a/apps/ui/src/components/site-preview/index.tsx b/apps/ui/src/components/site-preview/index.tsx index 52aa6f4158..31174131b6 100644 --- a/apps/ui/src/components/site-preview/index.tsx +++ b/apps/ui/src/components/site-preview/index.tsx @@ -57,7 +57,7 @@ interface InspectorState { interface InspectorCommand { id: number; - type: 'toggle-picking' | 'submit'; + type: 'toggle-picking' | 'submit' | 'annotate-context-target'; } interface BrowserNavigationState { @@ -292,6 +292,20 @@ export function SitePreview( { setInspectorCommand( { id: commandIdRef.current, type } ); }, [] ); + // Keep the host's preview context menu in step with the inspector, which is + // re-injected on every page load — otherwise it would offer element actions + // on pages where nothing is listening. + useEffect( () => { + connector.setPreviewInspectorReady?.( canAnnotate ); + return () => connector.setPreviewInspectorReady?.( false ); + }, [ canAnnotate, connector ] ); + + useEffect( () => { + return connector.onPreviewAnnotateElement?.( () => + sendInspectorCommand( 'annotate-context-target' ) + ); + }, [ connector, sendInspectorCommand ] ); + const browserShortcuts = useMemo( () => ( { back: getNavigationShortcutDescriptor( 'back' ), diff --git a/apps/ui/src/components/site-preview/inspector-script.ts b/apps/ui/src/components/site-preview/inspector-script.ts index 59e18e878d..27a2abdb3c 100644 --- a/apps/ui/src/components/site-preview/inspector-script.ts +++ b/apps/ui/src/components/site-preview/inspector-script.ts @@ -17,7 +17,8 @@ * The annotation controls live in the host toolbar (not in the page), and * drive the inspector by dispatching `INSPECTOR_COMMAND_EVENT` custom events * on the guest `window` via `webview.executeJavaScript()`: - * host -> guest: `{ "type": "toggle-picking" | "submit" | "report-state" }` + * host -> guest: `{ "type": "toggle-picking" | "submit" | "report-state" + * | "annotate-context-target" }` * * Layout strategy: markers and the picking highlight use `position: absolute` * anchored at *document* coordinates (viewport rect + scroll offset). They @@ -236,6 +237,7 @@ export const INSPECTOR_PAGE_SCRIPT = * State + DOM * ---------------------------------------------------------------- */ let isPicking = false; + let contextTarget = null; let hoveredEl = null; let activePopup = null; /* { id?, target, comment, fromPicker? } */ let annotations = Array.isArray( window.__studioInspectorState ) @@ -353,6 +355,15 @@ export const INSPECTOR_PAGE_SCRIPT = togglePicking(); return; } + if ( command.type === 'annotate-context-target' ) { + /* The page can navigate or re-render between the right-click and + * the menu choice, so only annotate a target still in the document. */ + if ( contextTarget && contextTarget.isConnected ) { + openPopupForElement( contextTarget ); + } + contextTarget = null; + return; + } if ( command.type === 'submit' ) { submitAnnotations(); return; @@ -566,6 +577,18 @@ export const INSPECTOR_PAGE_SCRIPT = true ); + /* The host's native context menu can annotate whatever was right-clicked. + * Remembering the target here means no coordinates have to survive the trip + * out to the main process and back — this listener always runs before the + * menu is even requested. */ + document.addEventListener( + 'contextmenu', + ( e ) => { + contextTarget = isOurElement( e.target ) ? null : e.target; + }, + true + ); + document.addEventListener( 'keydown', ( e ) => { diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts index 0acb72cc58..94acb462e0 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -814,6 +814,14 @@ export function createIpcConnector(): Connector { await ipcApi.copyText( text ); }, + setPreviewInspectorReady( ready: boolean ): void { + ipcApi.setPreviewInspectorReady( ready ); + }, + + onPreviewAnnotateElement( listener: () => void ): () => void { + return ipcListener.subscribe( 'preview-annotate-element', () => listener() ); + }, + 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 2c2ec1c2a3..0a313a88e7 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -398,6 +398,14 @@ 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 >; + + // Site preview annotation, driven from the host's native context menu. + // Absent in the browser builds, which have no such menu to hang it off. + // The host needs to know whether the inspector is currently attached so it + // can leave the item out rather than offer one that does nothing. + setPreviewInspectorReady?( ready: boolean ): void; + onPreviewAnnotateElement?( listener: () => void ): () => void; + openSiteUrl( siteId: string, relativeUrl?: string,