UNPKG

chrome-devtools-frontend

Version:
223 lines (191 loc) • 10.2 kB
// Copyright 2021 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. /* eslint-disable rulesdir/no-imperative-dom-api */ /* * Copyright (C) 2011 Google Inc. All rights reserved. * Copyright (C) 2006, 2007, 2008 Apple Inc. All rights reserved. * Copyright (C) 2007 Matt Lilek (pewtermoose@gmail.com). * Copyright (C) 2009 Joseph Pecoraro * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of * its contributors may be used to endorse or promote products derived * from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ import * as Common from '../../../core/common/common.js'; import * as Host from '../../../core/host/host.js'; import * as Root from '../../../core/root/root.js'; let themeSupportInstance: ThemeSupport; const themeValueByTargetByName = new Map<Element|null, Map<string, string>>(); export class ThemeSupport extends EventTarget { private themeNameInternal = 'default'; private computedStyleOfHTML = Common.Lazy.lazy(() => window.getComputedStyle(document.documentElement)); readonly #documentsToTheme = new Set<Document>([document]); readonly #darkThemeMediaQuery: MediaQueryList; readonly #highContrastMediaQuery: MediaQueryList; readonly #onThemeChangeListener = (): void => this.#applyTheme(); readonly #onHostThemeChangeListener = (): void => this.fetchColorsAndApplyHostTheme(); private constructor(private setting: Common.Settings.Setting<string>) { super(); // When the theme changes we instantiate a new theme support and reapply. // Equally if the user has set to match the system and the OS preference changes // we perform the same change. this.#darkThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); this.#highContrastMediaQuery = window.matchMedia('(forced-colors: active)'); this.#darkThemeMediaQuery.addEventListener('change', this.#onThemeChangeListener); this.#highContrastMediaQuery.addEventListener('change', this.#onThemeChangeListener); setting.addChangeListener(this.#onThemeChangeListener); Host.InspectorFrontendHost.InspectorFrontendHostInstance.events.addEventListener( Host.InspectorFrontendHostAPI.Events.ColorThemeChanged, this.#onHostThemeChangeListener); } #dispose(): void { this.#darkThemeMediaQuery.removeEventListener('change', this.#onThemeChangeListener); this.#highContrastMediaQuery.removeEventListener('change', this.#onThemeChangeListener); this.setting.removeChangeListener(this.#onThemeChangeListener); Host.InspectorFrontendHost.InspectorFrontendHostInstance.events.removeEventListener( Host.InspectorFrontendHostAPI.Events.ColorThemeChanged, this.#onHostThemeChangeListener); } static hasInstance(): boolean { return typeof themeSupportInstance !== 'undefined'; } static instance(opts: { forceNew: boolean|null, setting: Common.Settings.Setting<string>|null, } = {forceNew: null, setting: null}): ThemeSupport { const {forceNew, setting} = opts; if (!themeSupportInstance || forceNew) { if (!setting) { throw new Error(`Unable to create theme support: setting must be provided: ${new Error().stack}`); } if (themeSupportInstance) { themeSupportInstance.#dispose(); } themeSupportInstance = new ThemeSupport(setting); } return themeSupportInstance; } /** * Adds additional `Document` instances that should be themed besides the default * `window.document` in which this ThemeSupport instance was created. */ addDocumentToTheme(document: Document): void { this.#documentsToTheme.add(document); this.#fetchColorsAndApplyHostTheme(document); } getComputedValue(propertyName: string, target: Element|null = null): string { // Since we might query the same property name from various targets we need to support // per-target caching of computed values. Here we attempt to locate the particular computed // value cache for the target element. If no target was specified we use the default computed root, // which belongs to the documentElement. let themeValueByName = themeValueByTargetByName.get(target); if (!themeValueByName) { themeValueByName = new Map<string, string>(); themeValueByTargetByName.set(target, themeValueByName); } // Since theme changes trigger a reload, we can avoid repeatedly looking up color values // dynamically. Instead we can look up the first time and cache them for future use, // knowing that the cache will be invalidated by virtue of a reload when the theme changes. let themeValue = themeValueByName.get(propertyName); if (!themeValue) { const styleDeclaration = target ? window.getComputedStyle(target) : this.computedStyleOfHTML(); if (typeof styleDeclaration === 'symbol') { throw new Error(`Computed value for property (${propertyName}) could not be found on documentElement.`); } themeValue = styleDeclaration.getPropertyValue(propertyName).trim(); // If we receive back an empty value (nothing has been set) we don't store it for the future. // This means that subsequent requests will continue to query the styles in case the value // has been set. if (themeValue) { themeValueByName.set(propertyName, themeValue); } } return themeValue; } themeName(): string { return this.themeNameInternal; } #applyTheme(): void { for (const document of this.#documentsToTheme) { this.#applyThemeToDocument(document); } } #applyThemeToDocument(document: Document): void { const isForcedColorsMode = window.matchMedia('(forced-colors: active)').matches; const systemPreferredTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default'; const useSystemPreferred = this.setting.get() === 'systemPreferred' || isForcedColorsMode; this.themeNameInternal = useSystemPreferred ? systemPreferredTheme : this.setting.get(); document.documentElement.classList.toggle('theme-with-dark-background', this.themeNameInternal === 'dark'); const useChromeTheme = Common.Settings.moduleSetting('chrome-theme-colors').get(); const isIncognito = Root.Runtime.hostConfig.isOffTheRecord === true; // Baseline is the name of Chrome's default color theme and there are two of these: default and grayscale. // The collective name for the rest of the color themes is dynamic. // In the baseline themes Chrome uses custom values for surface colors, whereas for dynamic themes these are color-mixed. // To match Chrome we need to know if any of the baseline themes is currently active and assign specific values to surface colors. if (isIncognito) { document.documentElement.classList.toggle('baseline-grayscale', true); } else if (useChromeTheme) { const selectedTheme = getComputedStyle(document.body).getPropertyValue('--user-color-source'); document.documentElement.classList.toggle('baseline-default', selectedTheme === 'baseline-default'); document.documentElement.classList.toggle('baseline-grayscale', selectedTheme === 'baseline-grayscale'); } else { document.documentElement.classList.toggle('baseline-grayscale', true); } // In the event the theme changes we need to clear caches and notify subscribers. themeValueByTargetByName.clear(); this.dispatchEvent(new ThemeChangeEvent()); } static clearThemeCache(): void { themeValueByTargetByName.clear(); } fetchColorsAndApplyHostTheme(): void { for (const document of this.#documentsToTheme) { this.#fetchColorsAndApplyHostTheme(document); } } #fetchColorsAndApplyHostTheme(document: Document): void { const useChromeTheme = Common.Settings.moduleSetting('chrome-theme-colors').get(); if (Host.InspectorFrontendHost.InspectorFrontendHostInstance.isHostedMode() || !useChromeTheme) { this.#applyThemeToDocument(document); return; } const oldColorsCssLink = document.querySelector('link[href*=\'//theme/colors.css\']'); const newColorsCssLink = document.createElement('link'); newColorsCssLink.setAttribute( 'href', `devtools://theme/colors.css?sets=ui,chrome&version=${(new Date()).getTime().toString()}`); newColorsCssLink.setAttribute('rel', 'stylesheet'); newColorsCssLink.setAttribute('type', 'text/css'); newColorsCssLink.onload = () => { if (oldColorsCssLink) { oldColorsCssLink.remove(); } this.#applyThemeToDocument(document); }; document.body.appendChild(newColorsCssLink); } } export class ThemeChangeEvent extends Event { static readonly eventName = 'themechange'; constructor() { super(ThemeChangeEvent.eventName, {bubbles: true, composed: true}); } }