| // Copyright 2016 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| /* eslint-disable @devtools/no-imperative-dom-api */ |
| |
| import type * as Common from '../../core/common/common.js'; |
| import * as Root from '../../core/root/root.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import type * as Protocol from '../../generated/protocol.js'; |
| import * as EmulationModel from '../../models/emulation/emulation.js'; |
| import * as UI from '../../ui/legacy/legacy.js'; |
| |
| import {DeviceModeView} from './DeviceModeView.js'; |
| import type {InspectedPagePlaceholder} from './InspectedPagePlaceholder.js'; |
| |
| let deviceModeWrapperInstance: DeviceModeWrapper; |
| |
| export class DeviceModeWrapper extends UI.Widget.VBox { |
| private readonly inspectedPagePlaceholder: InspectedPagePlaceholder; |
| private deviceModeView: DeviceModeView|null; |
| private readonly toggleDeviceModeAction: UI.ActionRegistration.Action; |
| private showDeviceModeSetting: Common.Settings.Setting<boolean>; |
| |
| private constructor(inspectedPagePlaceholder: InspectedPagePlaceholder) { |
| super(); |
| this.inspectedPagePlaceholder = inspectedPagePlaceholder; |
| this.deviceModeView = null; |
| this.toggleDeviceModeAction = UI.ActionRegistry.ActionRegistry.instance().getAction('emulation.toggle-device-mode'); |
| const model = EmulationModel.DeviceModeModel.DeviceModeModel.instance(); |
| this.showDeviceModeSetting = model.enabledSetting(); |
| this.showDeviceModeSetting.setRequiresUserAction(Boolean(Root.Runtime.Runtime.queryParam('hasOtherClients'))); |
| this.showDeviceModeSetting.addChangeListener(this.update.bind(this, false)); |
| SDK.TargetManager.TargetManager.instance().addModelListener( |
| SDK.OverlayModel.OverlayModel, SDK.OverlayModel.Events.SCREENSHOT_REQUESTED, |
| this.screenshotRequestedFromOverlay, this); |
| this.update(true); |
| } |
| |
| static instance(opts: { |
| forceNew: boolean|null, |
| inspectedPagePlaceholder: InspectedPagePlaceholder|null, |
| } = {forceNew: null, inspectedPagePlaceholder: null}): DeviceModeWrapper { |
| const {forceNew, inspectedPagePlaceholder} = opts; |
| if (!deviceModeWrapperInstance || forceNew) { |
| if (!inspectedPagePlaceholder) { |
| throw new Error( |
| `Unable to create DeviceModeWrapper: inspectedPagePlaceholder must be provided: ${new Error().stack}`); |
| } |
| |
| deviceModeWrapperInstance = new DeviceModeWrapper(inspectedPagePlaceholder); |
| } |
| |
| return deviceModeWrapperInstance; |
| } |
| |
| toggleDeviceMode(): void { |
| this.showDeviceModeSetting.set(!this.showDeviceModeSetting.get()); |
| } |
| |
| isDeviceModeOn(): boolean { |
| return this.showDeviceModeSetting.get(); |
| } |
| |
| captureScreenshot(fullSize?: boolean, clip?: Protocol.Page.Viewport): boolean { |
| if (!this.deviceModeView) { |
| this.deviceModeView = new DeviceModeView(); |
| } |
| this.deviceModeView.setNonEmulatedAvailableSize(this.inspectedPagePlaceholder.element); |
| if (fullSize) { |
| void this.deviceModeView.captureFullSizeScreenshot(); |
| } else if (clip) { |
| void this.deviceModeView.captureAreaScreenshot(clip); |
| } else { |
| void this.deviceModeView.captureScreenshot(); |
| } |
| return true; |
| } |
| |
| private screenshotRequestedFromOverlay(event: Common.EventTarget.EventTargetEvent<Protocol.Page.Viewport>): void { |
| const clip = event.data; |
| this.captureScreenshot(false, clip); |
| } |
| |
| update(force?: boolean): void { |
| this.toggleDeviceModeAction.setToggled(this.showDeviceModeSetting.get()); |
| |
| const shouldShow = this.showDeviceModeSetting.get(); |
| if (!force && shouldShow === this.deviceModeView?.isShowing()) { |
| return; |
| } |
| |
| if (shouldShow) { |
| if (!this.deviceModeView) { |
| this.deviceModeView = new DeviceModeView(); |
| } |
| this.deviceModeView.show(this.element); |
| this.inspectedPagePlaceholder.clearMinimumSize(); |
| this.inspectedPagePlaceholder.show(this.deviceModeView.element); |
| } else { |
| if (this.deviceModeView) { |
| this.deviceModeView.exitHingeMode(); |
| this.deviceModeView.detach(); |
| } |
| this.inspectedPagePlaceholder.restoreMinimumSize(); |
| this.inspectedPagePlaceholder.show(this.element); |
| } |
| } |
| } |
| |
| export class ActionDelegate implements UI.ActionRegistration.ActionDelegate { |
| handleAction(context: UI.Context.Context, actionId: string): boolean { |
| switch (actionId) { |
| case 'emulation.capture-screenshot': |
| return DeviceModeWrapper.instance().captureScreenshot(); |
| |
| case 'emulation.capture-node-screenshot': { |
| const node = context.flavor(SDK.DOMModel.DOMNode); |
| if (!node) { |
| return true; |
| } |
| async function captureClip(): Promise<void> { |
| if (!node) { |
| return; |
| } |
| |
| // Resolve to a remote object to ensure the node is alive in the context. |
| const object = await node.resolveToObject(); |
| if (!object) { |
| return; |
| } |
| |
| // Get the Box Model via CDP. |
| // This returns the quads relative to the target's viewport. |
| // We use the 'border' quad to include the border and padding in the screenshot, |
| // matching the 'width' and 'height' properties which are also Border Box dimensions. |
| const nodeBoxModel = await node.boxModel(); |
| if (!nodeBoxModel) { |
| throw new Error(`Unable to get box model of the node: ${new Error().stack}`); |
| } |
| const nodeBorderQuad = nodeBoxModel.border; |
| |
| // Get Layout Metrics to account for the Visual Viewport scroll and zoom. |
| const metrics = await node.domModel().target().pageAgent().invoke_getLayoutMetrics(); |
| if (metrics.getError()) { |
| throw new Error(`Unable to get metrics: ${new Error().stack}`); |
| } |
| |
| const scrollX = metrics.cssVisualViewport.pageX; |
| const scrollY = metrics.cssVisualViewport.pageY; |
| |
| // Calculate the global offset for OOPiFs (Out-of-Process iframes). |
| // This accounts for the position of the target's frame within the main page. |
| const {x: oopifOffsetX, y: oopifOffsetY} = await getOopifOffset(node.domModel().target()); |
| |
| // Assemble the final Clip. |
| // The absolute coordinates are: Global (OOPiF) + Viewport Scroll + Local Node Position (Border Box). |
| const clip = { |
| x: oopifOffsetX + scrollX + nodeBorderQuad[0], |
| y: oopifOffsetY + scrollY + nodeBorderQuad[1], |
| width: nodeBoxModel.width, |
| height: nodeBoxModel.height, |
| scale: 1, |
| }; |
| |
| // Apply Zoom factor. |
| const zoom = metrics.cssVisualViewport.zoom ?? 1; |
| clip.x *= zoom; |
| clip.y *= zoom; |
| clip.width *= zoom; |
| clip.height *= zoom; |
| DeviceModeWrapper.instance().captureScreenshot(false, clip); |
| } |
| void captureClip(); |
| return true; |
| } |
| |
| case 'emulation.capture-full-height-screenshot': |
| return DeviceModeWrapper.instance().captureScreenshot(true); |
| |
| case 'emulation.toggle-device-mode': |
| DeviceModeWrapper.instance().toggleDeviceMode(); |
| return true; |
| } |
| return false; |
| } |
| } |
| |
| /** |
| * Calculate the offset of the "Local Root" frame relative to the "Global Root" (the main frame). |
| * This involves traversing the CDP Targets for OOPiFs. |
| */ |
| async function getOopifOffset(target: SDK.Target.Target|null): Promise<{x: number, y: number}> { |
| if (!target) { |
| return {x: 0, y: 0}; |
| } |
| |
| // Get the parent target. If there's no parent (we are at root) or it's not a frame, we are done. |
| const parentTarget = target.parentTarget(); |
| if (!parentTarget || parentTarget.type() !== SDK.Target.Type.FRAME) { |
| return {x: 0, y: 0}; |
| } |
| |
| // Identify the current frame's ID to find its owner in the parent. |
| const frameId = target.model(SDK.ResourceTreeModel.ResourceTreeModel)?.mainFrame?.id; |
| if (!frameId) { |
| return {x: 0, y: 0}; |
| } |
| |
| // Get the DOMModel of the parent to query the frame owner element. |
| const parentDOMModel = parentTarget.model(SDK.DOMModel.DOMModel); |
| if (!parentDOMModel) { |
| return {x: 0, y: 0}; |
| } |
| |
| // Retrieve the frame owner node (e.g. the <iframe> element) in the parent's document. |
| const frameOwnerDeferred = await parentDOMModel.getOwnerNodeForFrame(frameId); |
| const frameOwner = await frameOwnerDeferred?.resolvePromise(); |
| if (!frameOwner) { |
| return {x: 0, y: 0}; |
| } |
| |
| // Get the content box of the iframe element. |
| // This is relative to the parent target's viewport. |
| const boxModel = await frameOwner.boxModel(); |
| if (!boxModel) { |
| return {x: 0, y: 0}; |
| } |
| |
| // content is a Quad [x1, y1, x2, y2, x3, y3, x4, y4] |
| const contentQuad = boxModel.content; |
| const iframeContentX = contentQuad[0]; |
| const iframeContentY = contentQuad[1]; |
| |
| // Get the scroll position of the parent target to convert viewport-relative coordinates |
| // to document-relative coordinates. |
| const parentMetrics = await parentTarget.pageAgent().invoke_getLayoutMetrics(); |
| if (parentMetrics.getError()) { |
| return {x: 0, y: 0}; |
| } |
| |
| const scrollX = parentMetrics.cssVisualViewport.pageX; |
| const scrollY = parentMetrics.cssVisualViewport.pageY; |
| |
| // Recursively add the offset of the parent target itself (if it is also an OOPiF). |
| const parentOffset = await getOopifOffset(parentTarget); |
| |
| return { |
| x: iframeContentX + scrollX + parentOffset.x, |
| y: iframeContentY + scrollY + parentOffset.y, |
| }; |
| } |