chrome-devtools-frontend
Version:
Chrome DevTools UI
847 lines (775 loc) • 32.1 kB
text/typescript
// Copyright 2015 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 * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import type * as Protocol from '../../generated/protocol.js';
import * as EmulationModel from '../../models/emulation/emulation.js';
import * as Geometry from '../../models/geometry/geometry.js';
import * as UI from '../../ui/legacy/legacy.js';
import {Directives, html, nothing, render} from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import {DeviceModeToolbar} from './DeviceModeToolbar.js';
import deviceModeViewStyles from './deviceModeView.css.js';
import {MediaQueryInspector} from './MediaQueryInspector.js';
const {classMap, ref, styleMap} = Directives;
const {widget} = UI.Widget;
const UIStrings = {
/**
* @description Bottom resizer element title in Device Mode View of the Device Toolbar
*/
doubleclickForFullHeight: 'Double-click for full height',
/**
* @description Name of a device that the user can select to emulate. Small mobile device.
* Translation of this phrase should be limited to 10 characters.
*/
mobileS: 'Mobile S',
/**
* @description Name of a device that the user can select to emulate. Medium mobile device.
* Translation of this phrase should be limited to 10 characters.
*/
mobileM: 'Mobile M',
/**
* @description Name of a device that the user can select to emulate. Large mobile device.
* Translation of this phrase should be limited to 10 characters.
*/
mobileL: 'Mobile L',
/**
* @description Name of a device that the user can select to emulate. Tablet device.
* Translation of this phrase should be limited to 10 characters.
*/
tablet: 'Tablet',
/**
* @description Name of a device that the user can select to emulate. Laptop device.
* Translation of this phrase should be limited to 10 characters.
*/
laptop: 'Laptop',
/**
* @description Name of a device that the user can select to emulate. Large laptop device.
* Translation of this phrase should be limited to 10 characters.
*/
laptopL: 'Laptop L',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/emulation/DeviceModeView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export interface DeviceModeViewRefs {
toolbar: DeviceModeToolbar;
bottomRightResizerElement: HTMLElement;
bottomLeftResizerElement: HTMLElement;
rightResizerElement: HTMLElement;
leftResizerElement: HTMLElement;
bottomResizerElement: HTMLElement;
pageArea: HTMLElement;
}
export interface DeviceModeViewInput {
model: EmulationModel.DeviceModeModel.DeviceModeModel;
showMediaInspectorSetting: Common.Settings.Setting<boolean>;
showRulersSetting: Common.Settings.Setting<boolean>;
outlineImage: string;
outlineImageLoaded: boolean;
screenImage: string;
screenImageLoaded: boolean;
showRulers: boolean;
showMediaInspector: boolean;
scale: number;
cachedCssScreenRect?: EmulationModel.DeviceModeModel.Rect;
cachedOutlineRect?: EmulationModel.DeviceModeModel.Rect;
onApplyPresetSize: (size: number, e: Event) => void;
bottomRightResizer: UI.ResizerWidget.ResizerWidget;
bottomLeftResizer: UI.ResizerWidget.ResizerWidget;
rightResizer: UI.ResizerWidget.ResizerWidget;
leftResizer: UI.ResizerWidget.ResizerWidget;
bottomResizer: UI.ResizerWidget.ResizerWidget;
bottomRightResizerRef: (el?: Element) => void;
bottomLeftResizerRef: (el?: Element) => void;
rightResizerRef: (el?: Element) => void;
leftResizerRef: (el?: Element) => void;
bottomResizerRef: (el?: Element) => void;
onDoubleclickBottomResizer: () => void;
onOutlineImageLoaded: (success: boolean) => void;
onScreenImageLoaded: (success: boolean) => void;
}
export type DeviceModeViewView = (
input: DeviceModeViewInput,
output: DeviceModeViewRefs,
target: HTMLElement,
) => void;
function resizerRef(
resizer: UI.ResizerWidget.ResizerWidget,
onConnect?: (el: HTMLElement) => void,
): (el?: Element) => void {
let oldEl: HTMLElement|undefined;
return (el?: Element) => {
if (el instanceof HTMLElement) {
if (oldEl === el) {
return;
}
if (oldEl) {
resizer.removeElement(oldEl);
}
oldEl = el;
resizer.addElement(el);
onConnect?.(el);
} else if (oldEl) {
resizer.removeElement(oldEl);
oldEl = undefined;
}
};
}
export const DEFAULT_DEVICE_MODE_VIEW: DeviceModeViewView = (
input: DeviceModeViewInput,
output: DeviceModeViewRefs,
target: HTMLElement,
): void => {
const sizes = [320, 375, 425, 768, 1024, 1440, 2560];
const titles = [
i18nString(UIStrings.mobileS),
i18nString(UIStrings.mobileM),
i18nString(UIStrings.mobileL),
i18nString(UIStrings.tablet),
i18nString(UIStrings.laptop),
i18nString(UIStrings.laptopL),
'4K',
];
// clang-format off
render(html`
<devtools-widget ${UI.Widget.widget(DeviceModeToolbar, {model: input.model})}
${UI.Widget.widgetRef(DeviceModeToolbar, t => { output.toolbar = t; })}></devtools-widget>
<div class=${classMap({
'device-mode-content-clip': true,
vbox: true,
'device-mode-outline-visible': Boolean(input.outlineImage),
'device-mode-rulers-visible': input.showRulers,
})}>
<div class="device-mode-presets-container" jslog=${VisualLogging.responsivePresets()}>
<div class="device-mode-presets-container-inner">
${sizes.map((size, idx) => html`
<div class="fill device-mode-preset-bar-outer">
<div class="device-mode-preset-bar"
style="width: ${size * input.scale}px;"
jslog=${VisualLogging.action().track({click: true}).context(`device-mode-preset-${size}px`)}
=${(e: Event) => input.onApplyPresetSize(size, e)}>
<span>${titles[idx]} – ${size}px</span>
</div>
</div>
`).reverse()}
</div>
</div>
<div class="device-mode-media-container">
${input.showMediaInspector ? widget(MediaQueryInspector, {
scale: input.scale,
getWidthCallback: () => input.model.appliedDeviceSize().width,
setWidthCallback: input.model.setWidth.bind(input.model),
}) : nothing}
</div>
<div class="device-mode-content-area">
<img class="device-mode-outline-image fill"
?hidden=${!input.outlineImage || !input.outlineImageLoaded}
style=${styleMap(input.cachedOutlineRect ? {
left: `${input.cachedOutlineRect.left}px`,
top: `${input.cachedOutlineRect.top}px`,
width: `${input.cachedOutlineRect.width}px`,
height: `${input.cachedOutlineRect.height}px`,
} : {})}
srcset=${input.outlineImage || nothing}
=${(): void => input.onOutlineImageLoaded(true)}
=${(): void => input.onOutlineImageLoaded(false)}>
<div class="device-mode-screen-area"
style=${styleMap(input.cachedCssScreenRect ? {
left: `${input.cachedCssScreenRect.left}px`,
top: `${input.cachedCssScreenRect.top}px`,
width: `${input.cachedCssScreenRect.width}px`,
height: `${input.cachedCssScreenRect.height}px`,
} : {})}>
<img class="device-mode-screen-image"
?hidden=${!input.screenImage || !input.screenImageLoaded}
srcset=${input.screenImage || nothing}
=${(): void => input.onScreenImageLoaded(true)}
=${(): void => input.onScreenImageLoaded(false)}>
<div class="device-mode-resizer device-mode-bottom-right-resizer"
jslog=${VisualLogging.slider('device-mode-resizer').track({drag: true})}
${ref(input.bottomRightResizerRef)}>
<div></div>
</div>
<div class="device-mode-resizer device-mode-bottom-left-resizer"
jslog=${VisualLogging.slider('device-mode-resizer').track({drag: true})}
${ref(input.bottomLeftResizerRef)}>
<div></div>
</div>
<div class="device-mode-resizer device-mode-right-resizer"
jslog=${VisualLogging.slider('device-mode-resizer').track({drag: true})}
${ref(input.rightResizerRef)}>
<div></div>
</div>
<div class="device-mode-resizer device-mode-left-resizer"
jslog=${VisualLogging.slider('device-mode-resizer').track({drag: true})}
${ref(input.leftResizerRef)}>
<div></div>
</div>
<div class="device-mode-resizer device-mode-bottom-resizer"
jslog=${VisualLogging.slider('device-mode-resizer').track({drag: true})}
title=${i18nString(UIStrings.doubleclickForFullHeight)}
${ref(input.bottomResizerRef)}
=${input.onDoubleclickBottomResizer}>
<div></div>
</div>
<div class="device-mode-page-area" ${ref((el?: Element) => { if (el instanceof HTMLElement) { output.pageArea = el; } })}><slot></slot></div>
</div>
${input.showRulers ? html`
<devtools-widget class="device-mode-ruler-top device-mode-ruler"
style=${styleMap({left: `${input.cachedCssScreenRect?.left ?? 0}px`, top: `${input.cachedCssScreenRect?.top ?? 0}px`})}
${UI.Widget.widget(Ruler, {
scale: input.scale,
horizontal: true,
})}
-mode-ruler-marker-selected=${(e: CustomEvent<number>): void => input.model.setWidthAndScaleToFit(e.detail)}>
</devtools-widget>
<devtools-widget class="device-mode-ruler-left device-mode-ruler"
style=${styleMap({left: `${input.cachedCssScreenRect?.left ?? 0}px`, top: `${input.cachedCssScreenRect?.top ?? 0}px`})}
${UI.Widget.widget(Ruler, {
scale: input.scale,
horizontal: false,
})}
-mode-ruler-marker-selected=${(e: CustomEvent<number>): void => input.model.setHeightAndScaleToFit(e.detail)}>
</devtools-widget>
` : nothing}
</div>
</div>
`, target);
// clang-format on
};
export class DeviceModeView extends UI.Widget.VBox {
wrapperInstance!: UI.Widget.VBox|null;
private model: EmulationModel.DeviceModeModel.DeviceModeModel;
private showMediaInspectorSetting: Common.Settings.Setting<boolean>;
private showRulersSetting: Common.Settings.Setting<boolean>;
pageArea!: HTMLElement;
rightResizerElement!: HTMLElement;
leftResizerElement!: HTMLElement;
bottomResizerElement!: HTMLElement;
bottomRightResizerElement!: HTMLElement;
bottomLeftResizerElement!: HTMLElement;
private readonly bottomRightResizer = this.createResizer(2, 1);
private readonly bottomLeftResizer = this.createResizer(-2, 1);
private readonly rightResizer = this.createResizer(2, 0);
private readonly leftResizer = this.createResizer(-2, 0);
private readonly bottomResizer = this.createResizer(0, 1);
private readonly bottomRightResizerRef = resizerRef(this.bottomRightResizer, el => {
this.bottomRightResizerElement = el;
});
private readonly bottomLeftResizerRef = resizerRef(this.bottomLeftResizer, el => {
this.bottomLeftResizerElement = el;
});
private readonly rightResizerRef = resizerRef(this.rightResizer, el => {
this.rightResizerElement = el;
});
private readonly leftResizerRef = resizerRef(this.leftResizer, el => {
this.leftResizerElement = el;
});
private readonly bottomResizerRef = resizerRef(this.bottomResizer, el => {
this.bottomResizerElement = el;
});
private cachedResizable!: boolean|undefined;
toolbar!: DeviceModeToolbar;
private slowPositionStart?: {
x: number,
y: number,
}|null;
private resizeStart?: Geometry.Size;
private cachedCssScreenRect?: EmulationModel.DeviceModeModel.Rect;
private cachedCssVisiblePageRect?: EmulationModel.DeviceModeModel.Rect;
private cachedOutlineRect?: EmulationModel.DeviceModeModel.Rect;
private cachedMediaInspectorVisible?: boolean;
private cachedShowRulers?: boolean;
private cachedScale?: number;
private handleWidth?: number;
private handleHeight?: number;
#outlineImageLoaded = false;
#lastOutlineImageSrc?: string;
#screenImageLoaded = false;
#lastScreenImageSrc?: string;
#view: DeviceModeViewView;
constructor(view: DeviceModeViewView = DEFAULT_DEVICE_MODE_VIEW) {
super({useShadowDom: true});
this.#view = view;
this.setMinimumSize(150, 150);
this.element.classList.add('device-mode-view');
this.registerRequiredCSS(deviceModeViewStyles);
this.model = EmulationModel.DeviceModeModel.DeviceModeModel.instance();
this.model.addEventListener(EmulationModel.DeviceModeModel.Events.UPDATED, this.updateUI, this);
this.showMediaInspectorSetting = Common.Settings.Settings.instance().moduleSetting('show-media-query-inspector');
this.showMediaInspectorSetting.addChangeListener(this.updateUI, this);
this.showRulersSetting = Common.Settings.Settings.instance().moduleSetting('emulation.show-rulers');
this.showRulersSetting.addChangeListener(this.updateUI, this);
this.performUpdate();
UI.ZoomManager.ZoomManager.instance().addEventListener(UI.ZoomManager.Events.ZOOM_CHANGED, this.zoomChanged, this);
}
override performUpdate(): void {
if (this.#lastOutlineImageSrc !== this.model.outlineImage()) {
this.#lastOutlineImageSrc = this.model.outlineImage();
this.#outlineImageLoaded = false;
}
if (this.#lastScreenImageSrc !== this.model.screenImage()) {
this.#lastScreenImageSrc = this.model.screenImage();
this.#screenImageLoaded = false;
}
const input: DeviceModeViewInput = {
model: this.model,
showMediaInspectorSetting: this.showMediaInspectorSetting,
showRulersSetting: this.showRulersSetting,
outlineImage: this.model.outlineImage(),
outlineImageLoaded: this.#outlineImageLoaded,
screenImage: this.model.screenImage(),
screenImageLoaded: this.#screenImageLoaded,
showRulers: this.showRulersSetting.get() && this.model.type() !== EmulationModel.DeviceModeModel.Type.None,
showMediaInspector:
this.showMediaInspectorSetting.get() && this.model.type() !== EmulationModel.DeviceModeModel.Type.None,
scale: this.model.scale(),
cachedCssScreenRect: this.cachedCssScreenRect,
cachedOutlineRect: this.cachedOutlineRect,
onApplyPresetSize: (width: number, e: Event): void => {
this.model.emulate(EmulationModel.DeviceModeModel.Type.Responsive, null, null);
this.model.setWidthAndScaleToFit(width);
e.consume();
},
bottomRightResizer: this.bottomRightResizer,
bottomLeftResizer: this.bottomLeftResizer,
rightResizer: this.rightResizer,
leftResizer: this.leftResizer,
bottomResizer: this.bottomResizer,
bottomRightResizerRef: this.bottomRightResizerRef,
bottomLeftResizerRef: this.bottomLeftResizerRef,
rightResizerRef: this.rightResizerRef,
leftResizerRef: this.leftResizerRef,
bottomResizerRef: this.bottomResizerRef,
onDoubleclickBottomResizer: (): void => this.model.setHeight(0),
onOutlineImageLoaded: (success: boolean): void => this.onOutlineImageLoaded(success),
onScreenImageLoaded: (success: boolean): void => this.onScreenImageLoaded(success),
};
this.#view(input, this, this.contentElement);
}
private onOutlineImageLoaded(success: boolean): void {
if (this.#outlineImageLoaded !== success) {
this.#outlineImageLoaded = success;
this.requestUpdate();
}
}
private onScreenImageLoaded(success: boolean): void {
if (this.#screenImageLoaded !== success) {
this.#screenImageLoaded = success;
this.requestUpdate();
}
}
private createResizer(widthFactor: number, heightFactor: number): UI.ResizerWidget.ResizerWidget {
const resizer = new UI.ResizerWidget.ResizerWidget();
let cursor: 'nwse-resize'|'nesw-resize'|('ew-resize' | 'ns-resize') = widthFactor ? 'ew-resize' : 'ns-resize';
if (widthFactor * heightFactor > 0) {
cursor = 'nwse-resize';
}
if (widthFactor * heightFactor < 0) {
cursor = 'nesw-resize';
}
resizer.setCursor(cursor);
resizer.addEventListener(UI.ResizerWidget.Events.RESIZE_START, this.onResizeStart, this);
resizer.addEventListener(
UI.ResizerWidget.Events.RESIZE_UPDATE_XY, this.onResizeUpdate.bind(this, widthFactor, heightFactor));
resizer.addEventListener(UI.ResizerWidget.Events.RESIZE_END, this.onResizeEnd, this);
return resizer;
}
private onResizeStart(): void {
this.slowPositionStart = null;
const rect = this.model.screenRect();
this.resizeStart = new Geometry.Size(rect.width, rect.height);
}
private onResizeUpdate(widthFactor: number, heightFactor: number, event: {
data: UI.ResizerWidget.ResizeUpdateXYEvent,
}): void {
if (event.data.shiftKey !== Boolean(this.slowPositionStart)) {
this.slowPositionStart = event.data.shiftKey ? {x: event.data.currentX, y: event.data.currentY} : null;
}
let cssOffsetX: number = event.data.currentX - event.data.startX;
let cssOffsetY: number = event.data.currentY - event.data.startY;
if (this.slowPositionStart) {
cssOffsetX = (event.data.currentX - this.slowPositionStart.x) / 10 + this.slowPositionStart.x - event.data.startX;
cssOffsetY = (event.data.currentY - this.slowPositionStart.y) / 10 + this.slowPositionStart.y - event.data.startY;
}
if (widthFactor && this.resizeStart) {
const dipOffsetX = cssOffsetX * UI.ZoomManager.ZoomManager.instance().zoomFactor();
let newWidth: number = this.resizeStart.width + dipOffsetX * widthFactor;
newWidth = Math.round(newWidth / this.model.scale());
if (newWidth >= EmulationModel.DeviceModeModel.MinDeviceSize &&
newWidth <= EmulationModel.DeviceModeModel.MaxDeviceSize) {
this.model.setWidth(newWidth);
}
}
if (heightFactor && this.resizeStart) {
const dipOffsetY = cssOffsetY * UI.ZoomManager.ZoomManager.instance().zoomFactor();
let newHeight: number = this.resizeStart.height + dipOffsetY * heightFactor;
newHeight = Math.round(newHeight / this.model.scale());
if (newHeight >= EmulationModel.DeviceModeModel.MinDeviceSize &&
newHeight <= EmulationModel.DeviceModeModel.MaxDeviceSize) {
this.model.setHeight(newHeight);
}
}
}
exitHingeMode(): void {
if (this.model) {
this.model.exitHingeMode();
}
}
private onResizeEnd(): void {
delete this.resizeStart;
Host.userMetrics.actionTaken(Host.UserMetrics.Action.ResizedViewInResponsiveMode);
}
private updateUI(): void {
function applyRect(element: HTMLElement, rect: EmulationModel.DeviceModeModel.Rect): void {
element.style.left = rect.left + 'px';
element.style.top = rect.top + 'px';
element.style.width = rect.width + 'px';
element.style.height = rect.height + 'px';
}
if (!this.isShowing()) {
return;
}
const zoomFactor = UI.ZoomManager.ZoomManager.instance().zoomFactor();
let callDoResize = false;
const showRulers = this.showRulersSetting.get() && this.model.type() !== EmulationModel.DeviceModeModel.Type.None;
let contentAreaResized = false;
const cssScreenRect = this.model.screenRect().scale(1 / zoomFactor);
if (!this.cachedCssScreenRect || !cssScreenRect.isEqual(this.cachedCssScreenRect)) {
callDoResize = true;
this.cachedCssScreenRect = cssScreenRect;
}
const cssVisiblePageRect = this.model.visiblePageRect().scale(1 / zoomFactor);
if (!this.cachedCssVisiblePageRect || !cssVisiblePageRect.isEqual(this.cachedCssVisiblePageRect)) {
applyRect(this.pageArea, cssVisiblePageRect);
callDoResize = true;
this.cachedCssVisiblePageRect = cssVisiblePageRect;
}
const outlineRectFromModel = this.model.outlineRect();
if (outlineRectFromModel) {
const outlineRect = outlineRectFromModel.scale(1 / zoomFactor);
if (!this.cachedOutlineRect || !outlineRect.isEqual(this.cachedOutlineRect)) {
callDoResize = true;
this.cachedOutlineRect = outlineRect;
}
}
const resizable = this.model.type() === EmulationModel.DeviceModeModel.Type.Responsive;
if (resizable !== this.cachedResizable) {
this.rightResizerElement.classList.toggle('hidden', !resizable);
this.leftResizerElement.classList.toggle('hidden', !resizable);
this.bottomResizerElement.classList.toggle('hidden', !resizable);
this.bottomRightResizerElement.classList.toggle('hidden', !resizable);
this.bottomLeftResizerElement.classList.toggle('hidden', !resizable);
this.cachedResizable = resizable;
}
const mediaInspectorVisible =
this.showMediaInspectorSetting.get() && this.model.type() !== EmulationModel.DeviceModeModel.Type.None;
if (mediaInspectorVisible !== this.cachedMediaInspectorVisible) {
contentAreaResized = true;
callDoResize = true;
this.cachedMediaInspectorVisible = mediaInspectorVisible;
}
if (showRulers !== this.cachedShowRulers) {
contentAreaResized = true;
callDoResize = true;
this.cachedShowRulers = showRulers;
}
if (this.model.scale() !== this.cachedScale) {
callDoResize = true;
this.cachedScale = this.model.scale();
}
this.requestUpdate();
this.toolbar.requestUpdate();
if (callDoResize) {
this.doResize();
}
if (contentAreaResized) {
this.contentAreaResized();
}
}
setNonEmulatedAvailableSize(element: Element): void {
if (this.model.type() !== EmulationModel.DeviceModeModel.Type.None) {
return;
}
const zoomFactor = UI.ZoomManager.ZoomManager.instance().zoomFactor();
const rect = element.getBoundingClientRect();
const availableSize =
new Geometry.Size(Math.max(rect.width * zoomFactor, 1), Math.max(rect.height * zoomFactor, 1));
this.model.setAvailableSize(availableSize, availableSize);
}
private contentAreaResized(): void {
const contentArea = this.contentElement.querySelector<HTMLElement>('.device-mode-content-area');
if (!contentArea) {
return;
}
const zoomFactor = UI.ZoomManager.ZoomManager.instance().zoomFactor();
const rect = contentArea.getBoundingClientRect();
const availableSize =
new Geometry.Size(Math.max(rect.width * zoomFactor, 1), Math.max(rect.height * zoomFactor, 1));
const preferredSize = new Geometry.Size(
Math.max((rect.width - 2 * (this.handleWidth || 0)) * zoomFactor, 1),
Math.max((rect.height - (this.handleHeight || 0)) * zoomFactor, 1));
this.model.setAvailableSize(availableSize, preferredSize);
}
private measureHandles(): void {
const hidden = this.rightResizerElement.classList.contains('hidden');
this.rightResizerElement.classList.toggle('hidden', false);
this.bottomResizerElement.classList.toggle('hidden', false);
this.handleWidth = this.rightResizerElement.offsetWidth;
this.handleHeight = this.bottomResizerElement.offsetHeight;
this.rightResizerElement.classList.toggle('hidden', hidden);
this.bottomResizerElement.classList.toggle('hidden', hidden);
}
private zoomChanged(): void {
delete this.handleWidth;
delete this.handleHeight;
if (this.isShowing()) {
this.measureHandles();
this.contentAreaResized();
}
}
override onResize(): void {
if (this.isShowing()) {
this.contentAreaResized();
}
}
override wasShown(): void {
super.wasShown();
this.measureHandles();
this.toolbar.restore();
}
override willHide(): void {
super.willHide();
this.model.emulate(EmulationModel.DeviceModeModel.Type.None, null, null);
}
async captureScreenshot(): Promise<void> {
const screenshot = await this.model.captureScreenshot(false);
if (screenshot === null) {
return;
}
const pageImage = new Image();
pageImage.src = 'data:image/png;base64,' + screenshot;
pageImage.onload = async () => {
const scale = pageImage.naturalWidth / this.model.screenRect().width;
const outlineRectFromModel = this.model.outlineRect();
if (!outlineRectFromModel) {
throw new Error('Unable to take screenshot: no outlineRect available.');
}
const outlineRect = outlineRectFromModel.scale(scale);
const screenRect = this.model.screenRect().scale(scale);
const visiblePageRect = this.model.visiblePageRect().scale(scale);
const contentLeft = screenRect.left + visiblePageRect.left - outlineRect.left;
const contentTop = screenRect.top + visiblePageRect.top - outlineRect.top;
const canvas = document.createElement('canvas');
canvas.width = Math.floor(outlineRect.width);
// Cap the height to not hit the GPU limit.
// https://crbug.com/1260828
canvas.height = Math.min((1 << 14), Math.floor(outlineRect.height));
const ctx = canvas.getContext('2d', {willReadFrequently: true});
if (!ctx) {
throw new Error('Could not get 2d context from canvas.');
}
ctx.imageSmoothingEnabled = false;
if (this.model.outlineImage()) {
await this.paintImage(ctx, this.model.outlineImage(), outlineRect.relativeTo(outlineRect));
}
if (this.model.screenImage()) {
await this.paintImage(ctx, this.model.screenImage(), screenRect.relativeTo(outlineRect));
}
ctx.drawImage(pageImage, Math.floor(contentLeft), Math.floor(contentTop));
this.saveScreenshot((canvas));
};
}
async captureFullSizeScreenshot(): Promise<void> {
const screenshot = await this.model.captureScreenshot(true);
if (screenshot === null) {
return;
}
return this.saveScreenshotBase64(screenshot);
}
async captureAreaScreenshot(clip?: Protocol.Page.Viewport): Promise<void> {
const screenshot = await this.model.captureScreenshot(false, clip);
if (screenshot === null) {
return;
}
return this.saveScreenshotBase64(screenshot);
}
private saveScreenshotBase64(screenshot: string): void {
const pageImage = new Image();
pageImage.src = 'data:image/png;base64,' + screenshot;
pageImage.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = pageImage.naturalWidth;
// Cap the height to not hit the GPU limit.
// https://crbug.com/1260828
canvas.height = Math.min((1 << 14), Math.floor(pageImage.naturalHeight));
const ctx = canvas.getContext('2d', {willReadFrequently: true});
if (!ctx) {
throw new Error('Could not get 2d context for base64 screenshot.');
}
ctx.imageSmoothingEnabled = false;
ctx.drawImage(pageImage, 0, 0);
this.saveScreenshot((canvas));
};
}
private paintImage(ctx: CanvasRenderingContext2D, src: string, rect: EmulationModel.DeviceModeModel.Rect):
Promise<void> {
return new Promise(resolve => {
const image = new Image();
image.crossOrigin = 'Anonymous';
image.srcset = src;
image.onerror = () => resolve();
image.onload = () => {
ctx.drawImage(image, rect.left, rect.top, rect.width, rect.height);
resolve();
};
});
}
private saveScreenshot(canvas: HTMLCanvasElement): void {
const url = this.model.inspectedURL();
let fileName = '';
if (url) {
const withoutFragment = Platform.StringUtilities.removeURLFragment(url);
fileName = Platform.StringUtilities.trimURL(withoutFragment);
}
const device = this.model.device();
if (device && this.model.type() === EmulationModel.DeviceModeModel.Type.Device) {
fileName += `(${device.title})`;
}
const link = document.createElement('a');
link.download = fileName + '.png';
canvas.toBlob(blob => {
if (blob === null) {
return;
}
link.href = URL.createObjectURL(blob);
link.click();
});
}
}
export interface RulerViewInput {
horizontal: boolean;
scale: number;
onMarkerClick: (size: number) => void;
}
export type RulerView = (input: RulerViewInput, output: undefined, target: HTMLElement) => void;
export const DEFAULT_RULER_VIEW: RulerView = (input: RulerViewInput, output: undefined, target: HTMLElement): void => {
const zoomFactor = UI.ZoomManager.ZoomManager.instance().zoomFactor();
const size = input.horizontal ? target.offsetWidth : target.offsetHeight;
const dipSize = size * zoomFactor / input.scale;
const count = Math.ceil(dipSize / 5);
let step = 1;
if (input.scale < 0.8) {
step = 2;
}
if (input.scale < 0.6) {
step = 4;
}
if (input.scale < 0.4) {
step = 8;
}
if (input.scale < 0.2) {
step = 16;
}
if (input.scale < 0.1) {
step = 32;
}
const markers = [];
for (let i = 0; i < count; i++) {
if (i % step) {
continue;
}
const isLarge = !(i % 10);
const isMedium = !(i % 5);
const offset = i ? `${(5 * i) * input.scale / zoomFactor}px` : undefined;
// clang-format off
markers.push(html`
<div
class=${classMap({
'device-mode-ruler-marker': true,
'device-mode-ruler-marker-large': isLarge,
'device-mode-ruler-marker-medium': isMedium && !isLarge,
})}
style=${styleMap(input.horizontal ? {left: offset} : {top: offset})}>
${i && !(i % 20) ?
html`<div class="device-mode-ruler-text" =${() => input.onMarkerClick(i * 5)}>${i * 5}</div>` :
nothing}
</div>
`);
// clang-format on
}
render(html`
<div class="device-mode-ruler-content">
<div class="device-mode-ruler-inner">
${markers}
</div>
</div>
`,
target, {
container: {
classes: ['device-mode-ruler'],
attributes: {jslog: VisualLogging.deviceModeRuler().track({click: true})},
},
});
};
export const enum RulerEvents {
MARKER_SELECTED = 'MarkerSelected',
}
export interface RulerEventTypes {
[RulerEvents.MARKER_SELECTED]: number;
}
export class Ruler extends Common.ObjectWrapper.eventMixin<RulerEventTypes, typeof UI.Widget.Widget>(UI.Widget.Widget) {
#view: RulerView;
#horizontal = true;
#scale = 1;
constructor(element?: HTMLElement, view: RulerView = DEFAULT_RULER_VIEW) {
super(element);
this.#view = view;
}
get horizontal(): boolean {
return this.#horizontal;
}
set horizontal(horizontal: boolean) {
if (this.#horizontal === horizontal) {
return;
}
this.#horizontal = horizontal;
this.requestUpdate();
}
get scale(): number {
return this.#scale;
}
set scale(scale: number) {
if (this.#scale === scale) {
return;
}
this.#scale = scale;
this.requestUpdate();
}
override wasShown(): void {
super.wasShown();
this.requestUpdate();
}
override onResize(): void {
super.onResize();
this.requestUpdate();
}
#onMarkerClick = (size: number): void => {
this.dispatchEventToListeners(RulerEvents.MARKER_SELECTED, size);
};
override performUpdate(): void {
if (!this.isShowing()) {
return;
}
const viewInput: RulerViewInput = {
horizontal: this.#horizontal,
scale: this.#scale,
onMarkerClick: this.#onMarkerClick,
};
this.#view(viewInput, undefined, this.contentElement);
}
}