chrome-devtools-frontend
Version:
Chrome DevTools UI
223 lines (191 loc) • 10.2 kB
text/typescript
// 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});
}
}