UNPKG

lighthouse

Version:

Automated auditing, performance metrics, and best practices for the web.

288 lines (252 loc) • 10.9 kB
/** * @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; } }