UNPKG

chrome-devtools-frontend

Version:
382 lines (354 loc) • 14.5 kB
// Copyright 2023 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as Common from '../../core/common/common.js'; import * as Host from '../../core/host/host.js'; import * as RenderCoordinator from '../components/render_coordinator/render_coordinator.js'; import {processForDebugging, processStartLoggingForDebugging} from './Debugging.js'; import {getDomState, visibleOverlap} from './DomState.js'; import type {Loggable} from './Loggable.js'; import {getLoggingConfig} from './LoggingConfig.js'; import {logChange, logClick, logDrag, logHover, logImpressions, logKeyDown, logResize} from './LoggingEvents.js'; import {getLoggingState, getOrCreateLoggingState, type LoggingState} from './LoggingState.js'; import {getNonDomLoggables, hasNonDomLoggables, unregisterAllLoggables, unregisterLoggables} from './NonDomState.js'; const PROCESS_DOM_INTERVAL = 500; const KEYBOARD_LOG_INTERVAL = 3000; const HOVER_LOG_INTERVAL = 1000; const DRAG_LOG_INTERVAL = 1250; const DRAG_REPORT_THRESHOLD = 50; const CLICK_LOG_INTERVAL = 500; const RESIZE_LOG_INTERVAL = 200; const RESIZE_REPORT_THRESHOLD = 50; const noOpThrottler = { schedule: async () => {}, } as unknown as Common.Throttler.Throttler; let processingThrottler = noOpThrottler; export let keyboardLogThrottler = noOpThrottler; let hoverLogThrottler = noOpThrottler; let dragLogThrottler = noOpThrottler; export let clickLogThrottler = noOpThrottler; export let resizeLogThrottler = noOpThrottler; const mutationObserver = new MutationObserver(scheduleProcessing); const resizeObserver = new ResizeObserver(onResizeOrIntersection); const intersectionObserver = new IntersectionObserver(onResizeOrIntersection); const documents: Document[] = []; const pendingResize = new Map<Element, DOMRect>(); const pendingChange = new Set<Element>(); function observeMutations(roots: Array<HTMLElement|ShadowRoot>): void { for (const root of roots) { mutationObserver.observe(root, {attributes: true, childList: true, subtree: true}); root.querySelectorAll('[popover]')?.forEach(e => e.addEventListener('toggle', scheduleProcessing)); } } let logging = false; export function isLogging(): boolean { return logging; } export async function startLogging(options?: { processingThrottler?: Common.Throttler.Throttler, keyboardLogThrottler?: Common.Throttler.Throttler, hoverLogThrottler?: Common.Throttler.Throttler, dragLogThrottler?: Common.Throttler.Throttler, clickLogThrottler?: Common.Throttler.Throttler, resizeLogThrottler?: Common.Throttler.Throttler, }): Promise<void> { logging = true; processingThrottler = options?.processingThrottler || new Common.Throttler.Throttler(PROCESS_DOM_INTERVAL); keyboardLogThrottler = options?.keyboardLogThrottler || new Common.Throttler.Throttler(KEYBOARD_LOG_INTERVAL); hoverLogThrottler = options?.hoverLogThrottler || new Common.Throttler.Throttler(HOVER_LOG_INTERVAL); dragLogThrottler = options?.dragLogThrottler || new Common.Throttler.Throttler(DRAG_LOG_INTERVAL); clickLogThrottler = options?.clickLogThrottler || new Common.Throttler.Throttler(CLICK_LOG_INTERVAL); resizeLogThrottler = options?.resizeLogThrottler || new Common.Throttler.Throttler(RESIZE_LOG_INTERVAL); processStartLoggingForDebugging(); await addDocument(document); } export async function addDocument(document: Document): Promise<void> { documents.push(document); if (['interactive', 'complete'].includes(document.readyState)) { await process(); } document.addEventListener('visibilitychange', scheduleProcessing); document.addEventListener('scroll', scheduleProcessing); observeMutations([document.body]); } export async function stopLogging(): Promise<void> { await keyboardLogThrottler.schedule(async () => {}, Common.Throttler.Scheduling.AS_SOON_AS_POSSIBLE); logging = false; unregisterAllLoggables(); for (const document of documents) { document.removeEventListener('visibilitychange', scheduleProcessing); document.removeEventListener('scroll', scheduleProcessing); } mutationObserver.disconnect(); resizeObserver.disconnect(); intersectionObserver.disconnect(); documents.length = 0; viewportRects.clear(); processingThrottler = noOpThrottler; pendingResize.clear(); pendingChange.clear(); } async function yieldToResize(): Promise<void> { while (resizeLogThrottler.process) { await resizeLogThrottler.processCompleted; } } async function yieldToInteractions(): Promise<void> { while (clickLogThrottler.process) { await clickLogThrottler.processCompleted; } while (keyboardLogThrottler.process) { await keyboardLogThrottler.processCompleted; } } function flushPendingChangeEvents(): void { for (const element of pendingChange) { logPendingChange(element); } } export function scheduleProcessing(): void { if (!processingThrottler) { return; } void processingThrottler.schedule(() => RenderCoordinator.read('processForLogging', process)); } const viewportRects = new Map<Document, DOMRect>(); const viewportRectFor = (element: Element): DOMRect => { const ownerDocument = element.ownerDocument; const viewportRect = viewportRects.get(ownerDocument) || new DOMRect(0, 0, ownerDocument.defaultView?.innerWidth || 0, ownerDocument.defaultView?.innerHeight || 0); viewportRects.set(ownerDocument, viewportRect); return viewportRect; }; async function process(): Promise<void> { if (document.hidden) { return; } const startTime = performance.now(); const {loggables, shadowRoots} = getDomState(documents); const visibleLoggables: Loggable[] = []; observeMutations(shadowRoots); const nonDomRoots: Array<Loggable|undefined> = [undefined]; for (const {element, parent} of loggables) { const loggingState = getOrCreateLoggingState(element, getLoggingConfig(element), parent); if (!loggingState.impressionLogged) { const overlap = visibleOverlap(element, viewportRectFor(element)); const visibleSelectOption = element.tagName === 'OPTION' && loggingState.parent?.selectOpen; const visible = overlap && (!parent || loggingState.parent?.impressionLogged); if (visible || visibleSelectOption) { if (overlap) { loggingState.size = overlap; } visibleLoggables.push(element); loggingState.impressionLogged = true; } } if (loggingState.impressionLogged && hasNonDomLoggables(element)) { nonDomRoots.push(element); } if (!loggingState.processed) { const clickLikeHandler = (doubleClick: boolean) => (e: Event) => { const loggable = e.currentTarget as Element; maybeCancelDrag(e); logClick(clickLogThrottler)(loggable, e, {doubleClick}); }; if (loggingState.config.track?.click) { element.addEventListener('click', clickLikeHandler(false), {capture: true}); element.addEventListener('auxclick', clickLikeHandler(false), {capture: true}); element.addEventListener('contextmenu', clickLikeHandler(false), {capture: true}); } if (loggingState.config.track?.dblclick) { element.addEventListener('dblclick', clickLikeHandler(true), {capture: true}); } const trackHover = loggingState.config.track?.hover; if (trackHover) { element.addEventListener('mouseover', logHover(hoverLogThrottler), {capture: true}); element.addEventListener( 'mouseout', () => hoverLogThrottler.schedule(cancelLogging, Common.Throttler.Scheduling.AS_SOON_AS_POSSIBLE), {capture: true}); } const trackDrag = loggingState.config.track?.drag; if (trackDrag) { element.addEventListener('pointerdown', onDragStart, {capture: true}); document.addEventListener('pointerup', maybeCancelDrag, {capture: true}); document.addEventListener('dragend', maybeCancelDrag, {capture: true}); } if (loggingState.config.track?.change) { element.addEventListener('input', (event: Event) => { if (!(event instanceof InputEvent)) { return; } if (loggingState.pendingChangeContext && loggingState.pendingChangeContext !== event.inputType) { void logPendingChange(element); } loggingState.pendingChangeContext = event.inputType; pendingChange.add(element); }, {capture: true}); element.addEventListener('change', (event: Event) => { const target = event?.target ?? element; if (['checkbox', 'radio'].includes((target as HTMLInputElement).type)) { loggingState.pendingChangeContext = (target as HTMLInputElement).checked ? 'on' : 'off'; } logPendingChange(element); }, {capture: true}); element.addEventListener('focusout', () => { if (loggingState.pendingChangeContext) { void logPendingChange(element); } }, {capture: true}); } const trackKeyDown = loggingState.config.track?.keydown; if (trackKeyDown) { element.addEventListener('keydown', e => logKeyDown(keyboardLogThrottler)(e.currentTarget, e), {capture: true}); } if (loggingState.config.track?.resize) { resizeObserver.observe(element); intersectionObserver.observe(element); } if (element.tagName === 'SELECT') { const onSelectOpen = (e: Event): void => { void logClick(clickLogThrottler)(element, e); if (loggingState.selectOpen) { return; } loggingState.selectOpen = true; void scheduleProcessing(); }; element.addEventListener('click', onSelectOpen, {capture: true}); // Based on MenuListSelectType::ShouldOpenPopupForKey{Down,Press}Event element.addEventListener('keydown', event => { const e = event as KeyboardEvent; if ((Host.Platform.isMac() || e.altKey) && (e.code === 'ArrowDown' || e.code === 'ArrowUp') || (!e.altKey && !e.ctrlKey && e.code === 'F4')) { onSelectOpen(event); } }, {capture: true}); element.addEventListener('keypress', event => { const e = event as KeyboardEvent; if (e.key === ' ' || !Host.Platform.isMac() && e.key === '\r') { onSelectOpen(event); } }, {capture: true}); element.addEventListener('change', e => { for (const option of (element as HTMLSelectElement).selectedOptions) { if (getLoggingState(option)?.config.track?.click) { void logClick(clickLogThrottler)(option, e); } } }, {capture: true}); } loggingState.processed = true; } processForDebugging(element); } for (let i = 0; i < nonDomRoots.length; ++i) { const root = nonDomRoots[i]; for (const {loggable, config, parent, size} of getNonDomLoggables(root)) { const loggingState = getOrCreateLoggingState(loggable, config, parent); if (size) { loggingState.size = size; } processForDebugging(loggable); visibleLoggables.push(loggable); loggingState.impressionLogged = true; if (hasNonDomLoggables(loggable)) { nonDomRoots.push(loggable); } } // No need to track loggable as soon as we've logged the impression // We can still log interaction events with a handle to a loggable unregisterLoggables(root); } if (visibleLoggables.length) { await yieldToInteractions(); await yieldToResize(); flushPendingChangeEvents(); await logImpressions(visibleLoggables); } Host.userMetrics.visualLoggingProcessingDone(performance.now() - startTime); } function logPendingChange(element: Element): void { const loggingState = getLoggingState(element); if (!loggingState) { return; } void logChange(element); delete loggingState.pendingChangeContext; pendingChange.delete(element); } async function cancelLogging(): Promise<void> { } let dragStartX = 0, dragStartY = 0; function onDragStart(event: Event): void { if (!(event instanceof MouseEvent)) { return; } dragStartX = event.screenX; dragStartY = event.screenY; void logDrag(dragLogThrottler)(event); } function maybeCancelDrag(event: Event): void { if (!(event instanceof MouseEvent)) { return; } if (Math.abs(event.screenX - dragStartX) >= DRAG_REPORT_THRESHOLD || Math.abs(event.screenY - dragStartY) >= DRAG_REPORT_THRESHOLD) { return; } void dragLogThrottler.schedule(cancelLogging, Common.Throttler.Scheduling.AS_SOON_AS_POSSIBLE); } function isAncestorOf(state1: LoggingState|null, state2: LoggingState|null): boolean { while (state2) { if (state2 === state1) { return true; } state2 = state2.parent; } return false; } async function onResizeOrIntersection(entries: ResizeObserverEntry[]|IntersectionObserverEntry[]): Promise<void> { for (const entry of entries) { const element = entry.target; const loggingState = getLoggingState(element); const overlap = visibleOverlap(element, viewportRectFor(element)) || new DOMRect(0, 0, 0, 0); if (!loggingState?.size) { continue; } let hasPendingParent = false; for (const pendingElement of pendingResize.keys()) { if (pendingElement === element) { continue; } const pendingState = getLoggingState(pendingElement); if (isAncestorOf(pendingState, loggingState)) { hasPendingParent = true; break; } if (isAncestorOf(loggingState, pendingState)) { pendingResize.delete(pendingElement); } } if (hasPendingParent) { continue; } pendingResize.set(element, overlap); void resizeLogThrottler.schedule(async () => { if (pendingResize.size) { await yieldToInteractions(); flushPendingChangeEvents(); } for (const [element, overlap] of pendingResize.entries()) { const loggingState = getLoggingState(element); if (!loggingState) { continue; } if (Math.abs(overlap.width - loggingState.size.width) >= RESIZE_REPORT_THRESHOLD || Math.abs(overlap.height - loggingState.size.height) >= RESIZE_REPORT_THRESHOLD) { logResize(element, overlap); } } pendingResize.clear(); }, Common.Throttler.Scheduling.DELAYED); } }