lighthouse
Version:
Automated auditing, performance metrics, and best practices for the web.
288 lines (252 loc) • 10.9 kB
JavaScript
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview These functions define {Rect}s and {Size}s using two different coordinate spaces:
* 1. Screenshot coords (SC suffix): where 0,0 is the top left of the screenshot image
* 2. Display coords (DC suffix): that match the CSS pixel coordinate space of the LH report's page.
*/
import {Globals} from './report-globals.js';
/** @typedef {import('./dom.js').DOM} DOM */
/** @typedef {LH.Audit.Details.Rect} Rect */
/** @typedef {{width: number, height: number}} Size */
/**
* @typedef InstallOverlayFeatureParams
* @property {DOM} dom
* @property {Element} rootEl
* @property {Element} overlayContainerEl
* @property {LH.Result.FullPageScreenshot} fullPageScreenshot
*/
/**
* @param {LH.Result.FullPageScreenshot['screenshot']} screenshot
* @param {LH.Audit.Details.Rect} rect
* @return {boolean}
*/
function screenshotOverlapsRect(screenshot, rect) {
return rect.left <= screenshot.width &&
0 <= rect.right &&
rect.top <= screenshot.height &&
0 <= rect.bottom;
}
/**
* @param {number} value
* @param {number} min
* @param {number} max
*/
function clamp(value, min, max) {
if (value < min) return min;
if (value > max) return max;
return value;
}
/**
* @param {Rect} rect
*/
function getElementRectCenterPoint(rect) {
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
}
export class ElementScreenshotRenderer {
/**
* Given the location of an element and the sizes of the preview and screenshot,
* compute the absolute positions (in screenshot coordinate scale) of the screenshot content
* and the highlighted rect around the element.
* @param {Rect} elementRectSC
* @param {Size} elementPreviewSizeSC
* @param {Size} screenshotSize
*/
static getScreenshotPositions(elementRectSC, elementPreviewSizeSC, screenshotSize) {
const elementRectCenter = getElementRectCenterPoint(elementRectSC);
// Try to center clipped region.
const screenshotLeftVisibleEdge = clamp(
elementRectCenter.x - elementPreviewSizeSC.width / 2,
0, screenshotSize.width - elementPreviewSizeSC.width
);
const screenshotTopVisisbleEdge = clamp(
elementRectCenter.y - elementPreviewSizeSC.height / 2,
0, screenshotSize.height - elementPreviewSizeSC.height
);
return {
screenshot: {
left: screenshotLeftVisibleEdge,
top: screenshotTopVisisbleEdge,
},
clip: {
left: elementRectSC.left - screenshotLeftVisibleEdge,
top: elementRectSC.top - screenshotTopVisisbleEdge,
},
};
}
/**
* Render a clipPath SVG element to assist marking the element's rect.
* The elementRect and previewSize are in screenshot coordinate scale.
* @param {DOM} dom
* @param {HTMLElement} maskEl
* @param {{left: number, top: number}} positionClip
* @param {Rect} elementRect
* @param {Size} elementPreviewSize
*/
static renderClipPathInScreenshot(dom, maskEl, positionClip, elementRect, elementPreviewSize) {
const clipPathEl = dom.find('clipPath', maskEl);
const clipId = `clip-${Globals.getUniqueSuffix()}`;
clipPathEl.id = clipId;
maskEl.style.clipPath = `url(#${clipId})`;
// Normalize values between 0-1.
const top = positionClip.top / elementPreviewSize.height;
const bottom = top + elementRect.height / elementPreviewSize.height;
const left = positionClip.left / elementPreviewSize.width;
const right = left + elementRect.width / elementPreviewSize.width;
const polygonsPoints = [
`0,0 1,0 1,${top} 0,${top}`,
`0,${bottom} 1,${bottom} 1,1 0,1`,
`0,${top} ${left},${top} ${left},${bottom} 0,${bottom}`,
`${right},${top} 1,${top} 1,${bottom} ${right},${bottom}`,
];
for (const points of polygonsPoints) {
const pointEl = dom.createElementNS('http://www.w3.org/2000/svg', 'polygon');
pointEl.setAttribute('points', points);
clipPathEl.append(pointEl);
}
}
/**
* Called by report renderer. Defines a css variable used by any element screenshots
* in the provided report element.
* Allows for multiple Lighthouse reports to be rendered on the page, each with their
* own full page screenshot.
* @param {HTMLElement} el
* @param {LH.Result.FullPageScreenshot['screenshot']} screenshot
*/
static installFullPageScreenshot(el, screenshot) {
el.style.setProperty('--element-screenshot-url', `url('${screenshot.data}')`);
}
/**
* Installs the lightbox elements and wires up click listeners to all .lh-element-screenshot elements.
* @param {InstallOverlayFeatureParams} opts
*/
static installOverlayFeature(opts) {
const {dom, rootEl, overlayContainerEl, fullPageScreenshot} = opts;
const screenshotOverlayClass = 'lh-screenshot-overlay--enabled';
// Don't install the feature more than once.
if (rootEl.classList.contains(screenshotOverlayClass)) return;
rootEl.classList.add(screenshotOverlayClass);
// Add a single listener to the provided element to handle all clicks within (event delegation).
rootEl.addEventListener('click', e => {
const target = /** @type {?HTMLElement} */ (e.target);
if (!target) return;
// Only activate the overlay for clicks on the screenshot *preview* of an element, not the full-size too.
const el = /** @type {?HTMLElement} */ (target.closest('.lh-node > .lh-element-screenshot'));
if (!el) return;
const overlay = dom.createElement('div', 'lh-element-screenshot__overlay');
overlayContainerEl.append(overlay);
// The newly-added overlay has the dimensions we need.
const maxLightboxSize = {
width: overlay.clientWidth * 0.95,
height: overlay.clientHeight * 0.80,
};
const elementRectSC = {
width: Number(el.dataset['rectWidth']),
height: Number(el.dataset['rectHeight']),
left: Number(el.dataset['rectLeft']),
right: Number(el.dataset['rectLeft']) + Number(el.dataset['rectWidth']),
top: Number(el.dataset['rectTop']),
bottom: Number(el.dataset['rectTop']) + Number(el.dataset['rectHeight']),
};
const screenshotElement = ElementScreenshotRenderer.render(
dom,
fullPageScreenshot.screenshot,
elementRectSC,
maxLightboxSize
);
// This would be unexpected here.
// When `screenshotElement` is `null`, there is also no thumbnail element for the user to have clicked to make it this far.
if (!screenshotElement) {
overlay.remove();
return;
}
overlay.append(screenshotElement);
overlay.addEventListener('click', () => overlay.remove());
});
}
/**
* Given the size of the element in the screenshot and the total available size of our preview container,
* compute the factor by which we need to zoom out to view the entire element with context.
* @param {Rect} elementRectSC
* @param {Size} renderContainerSizeDC
* @return {number}
*/
static _computeZoomFactor(elementRectSC, renderContainerSizeDC) {
const targetClipToViewportRatio = 0.75;
const zoomRatioXY = {
x: renderContainerSizeDC.width / elementRectSC.width,
y: renderContainerSizeDC.height / elementRectSC.height,
};
const zoomFactor = targetClipToViewportRatio * Math.min(zoomRatioXY.x, zoomRatioXY.y);
return Math.min(1, zoomFactor);
}
/**
* Renders an element with surrounding context from the full page screenshot.
* Used to render both the thumbnail preview in details tables and the full-page screenshot in the lightbox.
* Returns null if element rect is outside screenshot bounds.
* @param {DOM} dom
* @param {LH.Result.FullPageScreenshot['screenshot']} screenshot
* @param {Rect} elementRectSC Region of screenshot to highlight.
* @param {Size} maxRenderSizeDC e.g. maxThumbnailSize or maxLightboxSize.
* @return {Element|null}
*/
static render(dom, screenshot, elementRectSC, maxRenderSizeDC) {
if (!screenshotOverlapsRect(screenshot, elementRectSC)) {
return null;
}
const tmpl = dom.createComponent('elementScreenshot');
const containerEl = dom.find('div.lh-element-screenshot', tmpl);
containerEl.dataset['rectWidth'] = elementRectSC.width.toString();
containerEl.dataset['rectHeight'] = elementRectSC.height.toString();
containerEl.dataset['rectLeft'] = elementRectSC.left.toString();
containerEl.dataset['rectTop'] = elementRectSC.top.toString();
// Zoom out when highlighted region takes up most of the viewport.
// This provides more context for where on the page this element is.
const zoomFactor = this._computeZoomFactor(elementRectSC, maxRenderSizeDC);
const elementPreviewSizeSC = {
width: maxRenderSizeDC.width / zoomFactor,
height: maxRenderSizeDC.height / zoomFactor,
};
elementPreviewSizeSC.width = Math.min(screenshot.width, elementPreviewSizeSC.width);
elementPreviewSizeSC.height = Math.min(screenshot.height, elementPreviewSizeSC.height);
/* This preview size is either the size of the thumbnail or size of the Lightbox */
const elementPreviewSizeDC = {
width: elementPreviewSizeSC.width * zoomFactor,
height: elementPreviewSizeSC.height * zoomFactor,
};
const positions = ElementScreenshotRenderer.getScreenshotPositions(
elementRectSC,
elementPreviewSizeSC,
{width: screenshot.width, height: screenshot.height}
);
const imageEl = dom.find('div.lh-element-screenshot__image', containerEl);
imageEl.style.width = elementPreviewSizeDC.width + 'px';
imageEl.style.height = elementPreviewSizeDC.height + 'px';
imageEl.style.backgroundPositionY = -(positions.screenshot.top * zoomFactor) + 'px';
imageEl.style.backgroundPositionX = -(positions.screenshot.left * zoomFactor) + 'px';
imageEl.style.backgroundSize =
`${screenshot.width * zoomFactor}px ${screenshot.height * zoomFactor}px`;
const markerEl = dom.find('div.lh-element-screenshot__element-marker', containerEl);
markerEl.style.width = elementRectSC.width * zoomFactor + 'px';
markerEl.style.height = elementRectSC.height * zoomFactor + 'px';
markerEl.style.left = positions.clip.left * zoomFactor + 'px';
markerEl.style.top = positions.clip.top * zoomFactor + 'px';
const maskEl = dom.find('div.lh-element-screenshot__mask', containerEl);
maskEl.style.width = elementPreviewSizeDC.width + 'px';
maskEl.style.height = elementPreviewSizeDC.height + 'px';
ElementScreenshotRenderer.renderClipPathInScreenshot(
dom,
maskEl,
positions.clip,
elementRectSC,
elementPreviewSizeSC
);
return containerEl;
}
}