| // Copyright 2010 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js'; |
| import type * as Protocol from '../../generated/protocol.js'; |
| import * as TextUtils from '../../models/text_utils/text_utils.js'; |
| import * as Common from '../common/common.js'; |
| import * as Host from '../host/host.js'; |
| import * as Platform from '../platform/platform.js'; |
| import * as Root from '../root/root.js'; |
| |
| import {CSSFontFace} from './CSSFontFace.js'; |
| import {CSSMatchedStyles} from './CSSMatchedStyles.js'; |
| import {CSSMedia} from './CSSMedia.js'; |
| import {cssMetadata} from './CSSMetadata.js'; |
| import {CSSStyleRule} from './CSSRule.js'; |
| import {CSSStyleDeclaration, Type} from './CSSStyleDeclaration.js'; |
| import {CSSStyleSheetHeader} from './CSSStyleSheetHeader.js'; |
| import {DOMModel, type DOMNode} from './DOMModel.js'; |
| import { |
| Events as ResourceTreeModelEvents, |
| PrimaryPageChangeType, |
| type ResourceTreeFrame, |
| ResourceTreeModel, |
| } from './ResourceTreeModel.js'; |
| import {SDKModel} from './SDKModel.js'; |
| import {SourceMapManager} from './SourceMapManager.js'; |
| import {Capability, type Target} from './Target.js'; |
| |
| export const enum ColorScheme { |
| LIGHT = 'light', |
| DARK = 'dark', |
| } |
| |
| export interface LayoutProperties { |
| isFlex: boolean; |
| isGrid: boolean; |
| isSubgrid: boolean; |
| isGridLanes: boolean; |
| containerType?: string; |
| hasScroll: boolean; |
| } |
| |
| export class CSSModel extends SDKModel<EventTypes> { |
| readonly agent: ProtocolProxyApi.CSSApi; |
| readonly #domModel: DOMModel; |
| readonly #fontFaces = new Map<string, CSSFontFace>(); |
| readonly #originalStyleSheetText = new Map<CSSStyleSheetHeader, Promise<string|null>>(); |
| readonly #resourceTreeModel: ResourceTreeModel|null; |
| readonly #sourceMapManager: SourceMapManager<CSSStyleSheetHeader>; |
| readonly #styleLoader: ComputedStyleLoader; |
| readonly #stylePollingThrottler = new Common.Throttler.Throttler(StylePollingInterval); |
| readonly #styleSheetIdsForURL = |
| new Map<Platform.DevToolsPath.UrlString, Map<string, Set<Protocol.DOM.StyleSheetId>>>(); |
| readonly #styleSheetIdToHeader = new Map<Protocol.DOM.StyleSheetId, CSSStyleSheetHeader>(); |
| #cachedMatchedCascadeNode: DOMNode|null = null; |
| #cachedMatchedCascadePromise: Promise<CSSMatchedStyles|null>|null = null; |
| #cssPropertyTracker: CSSPropertyTracker|null = null; |
| #isCSSPropertyTrackingEnabled = false; |
| #isEnabled = false; |
| #isRuleUsageTrackingEnabled = false; |
| #isTrackingRequestPending = false; |
| #colorScheme: ColorScheme|undefined; |
| |
| constructor(target: Target) { |
| super(target); |
| this.#domModel = (target.model(DOMModel) as DOMModel); |
| this.#sourceMapManager = new SourceMapManager(target); |
| this.agent = target.cssAgent(); |
| this.#styleLoader = new ComputedStyleLoader(this); |
| this.#resourceTreeModel = target.model(ResourceTreeModel); |
| if (this.#resourceTreeModel) { |
| this.#resourceTreeModel.addEventListener( |
| ResourceTreeModelEvents.PrimaryPageChanged, this.onPrimaryPageChanged, this); |
| } |
| target.registerCSSDispatcher(new CSSDispatcher(this)); |
| if (!target.suspended()) { |
| void this.enable(); |
| } |
| |
| this.#sourceMapManager.setEnabled( |
| Common.Settings.Settings.instance().moduleSetting<boolean>('css-source-maps-enabled').get()); |
| Common.Settings.Settings.instance() |
| .moduleSetting<boolean>('css-source-maps-enabled') |
| .addChangeListener(event => this.#sourceMapManager.setEnabled(event.data)); |
| } |
| |
| async colorScheme(): Promise<ColorScheme|undefined> { |
| if (!this.#colorScheme) { |
| const colorSchemeResponse = await this.domModel()?.target().runtimeAgent().invoke_evaluate( |
| {expression: 'window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches'}); |
| if (colorSchemeResponse && !colorSchemeResponse.exceptionDetails && !colorSchemeResponse.getError()) { |
| this.#colorScheme = colorSchemeResponse.result.value ? ColorScheme.DARK : ColorScheme.LIGHT; |
| } |
| } |
| return this.#colorScheme; |
| } |
| |
| async resolveValues(propertyName: string|undefined, nodeId: Protocol.DOM.NodeId, ...values: string[]): |
| Promise<string[]|null> { |
| if (propertyName && cssMetadata().getLonghands(propertyName)?.length) { |
| return null; |
| } |
| const response = await this.agent.invoke_resolveValues({values, nodeId, propertyName}); |
| if (response.getError()) { |
| return null; |
| } |
| return response.results; |
| } |
| |
| headersForSourceURL(sourceURL: Platform.DevToolsPath.UrlString): CSSStyleSheetHeader[] { |
| const headers = []; |
| for (const headerId of this.getStyleSheetIdsForURL(sourceURL)) { |
| const header = this.styleSheetHeaderForId(headerId); |
| if (header) { |
| headers.push(header); |
| } |
| } |
| return headers; |
| } |
| |
| createRawLocationsByURL( |
| sourceURL: Platform.DevToolsPath.UrlString, lineNumber: number, |
| columnNumber: number|undefined = 0): CSSLocation[] { |
| const headers = this.headersForSourceURL(sourceURL); |
| headers.sort(stylesheetComparator); |
| const endIndex = Platform.ArrayUtilities.upperBound( |
| headers, undefined, (_, header) => lineNumber - header.startLine || columnNumber - header.startColumn); |
| if (!endIndex) { |
| return []; |
| } |
| const locations = []; |
| const last = headers[endIndex - 1]; |
| for (let index = endIndex - 1; |
| index >= 0 && headers[index].startLine === last.startLine && headers[index].startColumn === last.startColumn; |
| --index) { |
| if (headers[index].containsLocation(lineNumber, columnNumber)) { |
| locations.push(new CSSLocation(headers[index], lineNumber, columnNumber)); |
| } |
| } |
| |
| return locations; |
| function stylesheetComparator(a: CSSStyleSheetHeader, b: CSSStyleSheetHeader): number { |
| return a.startLine - b.startLine || a.startColumn - b.startColumn || a.id.localeCompare(b.id); |
| } |
| } |
| |
| sourceMapManager(): SourceMapManager<CSSStyleSheetHeader> { |
| return this.#sourceMapManager; |
| } |
| |
| static readableLayerName(text: string): string { |
| return text || '<anonymous>'; |
| } |
| |
| static trimSourceURL(text: string): string { |
| let sourceURLIndex = text.lastIndexOf('/*# sourceURL='); |
| if (sourceURLIndex === -1) { |
| sourceURLIndex = text.lastIndexOf('/*@ sourceURL='); |
| if (sourceURLIndex === -1) { |
| return text; |
| } |
| } |
| const sourceURLLineIndex = text.lastIndexOf('\n', sourceURLIndex); |
| if (sourceURLLineIndex === -1) { |
| return text; |
| } |
| const sourceURLLine = text.substr(sourceURLLineIndex + 1).split('\n', 1)[0]; |
| const sourceURLRegex = /[\x20\t]*\/\*[#@] sourceURL=[\x20\t]*([^\s]*)[\x20\t]*\*\/[\x20\t]*$/; |
| if (sourceURLLine.search(sourceURLRegex) === -1) { |
| return text; |
| } |
| return text.substr(0, sourceURLLineIndex) + text.substr(sourceURLLineIndex + sourceURLLine.length + 1); |
| } |
| |
| domModel(): DOMModel { |
| return this.#domModel; |
| } |
| |
| async trackComputedStyleUpdatesForNode(nodeId: Protocol.DOM.NodeId|undefined): Promise<void> { |
| await this.agent.invoke_trackComputedStyleUpdatesForNode({nodeId}); |
| } |
| |
| async setStyleText( |
| styleSheetId: Protocol.DOM.StyleSheetId, range: TextUtils.TextRange.TextRange, text: string, |
| majorChange: boolean): Promise<boolean> { |
| try { |
| await this.ensureOriginalStyleSheetText(styleSheetId); |
| |
| const {styles} = |
| await this.agent.invoke_setStyleTexts({edits: [{styleSheetId, range: range.serializeToObject(), text}]}); |
| if (styles?.length !== 1) { |
| return false; |
| } |
| |
| this.#domModel.markUndoableState(!majorChange); |
| const edit = new Edit(styleSheetId, range, text, styles[0]); |
| this.fireStyleSheetChanged(styleSheetId, edit); |
| return true; |
| } catch (e) { |
| console.error(e); |
| return false; |
| } |
| } |
| |
| async setSelectorText(styleSheetId: Protocol.DOM.StyleSheetId, range: TextUtils.TextRange.TextRange, text: string): |
| Promise<boolean> { |
| Host.userMetrics.actionTaken(Host.UserMetrics.Action.StyleRuleEdited); |
| |
| try { |
| await this.ensureOriginalStyleSheetText(styleSheetId); |
| const {selectorList} = await this.agent.invoke_setRuleSelector({styleSheetId, range, selector: text}); |
| |
| if (!selectorList) { |
| return false; |
| } |
| this.#domModel.markUndoableState(); |
| const edit = new Edit(styleSheetId, range, text, selectorList); |
| this.fireStyleSheetChanged(styleSheetId, edit); |
| return true; |
| } catch (e) { |
| console.error(e); |
| return false; |
| } |
| } |
| |
| async setPropertyRulePropertyName( |
| styleSheetId: Protocol.DOM.StyleSheetId, range: TextUtils.TextRange.TextRange, text: string): Promise<boolean> { |
| Host.userMetrics.actionTaken(Host.UserMetrics.Action.StyleRuleEdited); |
| |
| try { |
| await this.ensureOriginalStyleSheetText(styleSheetId); |
| const {propertyName} = |
| await this.agent.invoke_setPropertyRulePropertyName({styleSheetId, range, propertyName: text}); |
| |
| if (!propertyName) { |
| return false; |
| } |
| this.#domModel.markUndoableState(); |
| const edit = new Edit(styleSheetId, range, text, propertyName); |
| this.fireStyleSheetChanged(styleSheetId, edit); |
| return true; |
| } catch (e) { |
| console.error(e); |
| return false; |
| } |
| } |
| |
| async setKeyframeKey(styleSheetId: Protocol.DOM.StyleSheetId, range: TextUtils.TextRange.TextRange, text: string): |
| Promise<boolean> { |
| Host.userMetrics.actionTaken(Host.UserMetrics.Action.StyleRuleEdited); |
| |
| try { |
| await this.ensureOriginalStyleSheetText(styleSheetId); |
| const {keyText} = await this.agent.invoke_setKeyframeKey({styleSheetId, range, keyText: text}); |
| |
| if (!keyText) { |
| return false; |
| } |
| this.#domModel.markUndoableState(); |
| const edit = new Edit(styleSheetId, range, text, keyText); |
| this.fireStyleSheetChanged(styleSheetId, edit); |
| return true; |
| } catch (e) { |
| console.error(e); |
| return false; |
| } |
| } |
| |
| startCoverage(): Promise<Protocol.ProtocolResponseWithError> { |
| this.#isRuleUsageTrackingEnabled = true; |
| return this.agent.invoke_startRuleUsageTracking(); |
| } |
| |
| async takeCoverageDelta(): Promise<{ |
| timestamp: number, |
| coverage: Protocol.CSS.RuleUsage[], |
| }> { |
| const r = await this.agent.invoke_takeCoverageDelta(); |
| const timestamp = (r?.timestamp) || 0; |
| const coverage = (r?.coverage) || []; |
| return {timestamp, coverage}; |
| } |
| |
| setLocalFontsEnabled(enabled: boolean): Promise<Protocol.ProtocolResponseWithError> { |
| return this.agent.invoke_setLocalFontsEnabled({ |
| enabled, |
| }); |
| } |
| |
| async stopCoverage(): Promise<void> { |
| this.#isRuleUsageTrackingEnabled = false; |
| await this.agent.invoke_stopRuleUsageTracking(); |
| } |
| |
| async getMediaQueries(): Promise<CSSMedia[]> { |
| const {medias} = await this.agent.invoke_getMediaQueries(); |
| return medias ? CSSMedia.parseMediaArrayPayload(this, medias) : []; |
| } |
| |
| async getRootLayer(nodeId: Protocol.DOM.NodeId): Promise<Protocol.CSS.CSSLayerData> { |
| const {rootLayer} = await this.agent.invoke_getLayersForNode({nodeId}); |
| return rootLayer; |
| } |
| |
| isEnabled(): boolean { |
| return this.#isEnabled; |
| } |
| |
| private async enable(): Promise<void> { |
| await this.agent.invoke_enable(); |
| this.#isEnabled = true; |
| if (this.#isRuleUsageTrackingEnabled) { |
| await this.startCoverage(); |
| } |
| this.dispatchEventToListeners(Events.ModelWasEnabled); |
| } |
| |
| async getAnimatedStylesForNode(nodeId: Protocol.DOM.NodeId): |
| Promise<Protocol.CSS.GetAnimatedStylesForNodeResponse|null> { |
| const response = await this.agent.invoke_getAnimatedStylesForNode({nodeId}); |
| if (response.getError()) { |
| return null; |
| } |
| |
| return response; |
| } |
| |
| async getMatchedStyles(nodeId: Protocol.DOM.NodeId): Promise<CSSMatchedStyles|null> { |
| const node = this.#domModel.nodeForId(nodeId); |
| if (!node) { |
| return null; |
| } |
| |
| const shouldGetAnimatedStyles = Root.Runtime.hostConfig.devToolsAnimationStylesInStylesTab?.enabled; |
| const [matchedStylesResponse, animatedStylesResponse] = await Promise.all([ |
| this.agent.invoke_getMatchedStylesForNode({nodeId}), |
| shouldGetAnimatedStyles ? this.agent.invoke_getAnimatedStylesForNode({nodeId}) : undefined, |
| ]); |
| |
| if (matchedStylesResponse.getError()) { |
| return null; |
| } |
| |
| const payload = { |
| cssModel: this, |
| node, |
| inlinePayload: matchedStylesResponse.inlineStyle || null, |
| attributesPayload: matchedStylesResponse.attributesStyle || null, |
| matchedPayload: matchedStylesResponse.matchedCSSRules || [], |
| pseudoPayload: matchedStylesResponse.pseudoElements || [], |
| inheritedPayload: matchedStylesResponse.inherited || [], |
| inheritedPseudoPayload: matchedStylesResponse.inheritedPseudoElements || [], |
| animationsPayload: matchedStylesResponse.cssKeyframesRules || [], |
| parentLayoutNodeId: matchedStylesResponse.parentLayoutNodeId, |
| positionTryRules: matchedStylesResponse.cssPositionTryRules || [], |
| propertyRules: matchedStylesResponse.cssPropertyRules ?? [], |
| functionRules: matchedStylesResponse.cssFunctionRules ?? [], |
| cssPropertyRegistrations: matchedStylesResponse.cssPropertyRegistrations ?? [], |
| atRules: matchedStylesResponse.cssAtRules ?? [], |
| activePositionFallbackIndex: matchedStylesResponse.activePositionFallbackIndex ?? -1, |
| animationStylesPayload: animatedStylesResponse?.animationStyles || [], |
| inheritedAnimatedPayload: animatedStylesResponse?.inherited || [], |
| transitionsStylePayload: animatedStylesResponse?.transitionsStyle || null, |
| }; |
| return await CSSMatchedStyles.create(payload); |
| } |
| |
| async getClassNames(styleSheetId: Protocol.DOM.StyleSheetId): Promise<string[]> { |
| const {classNames} = await this.agent.invoke_collectClassNames({styleSheetId}); |
| return classNames || []; |
| } |
| |
| async getComputedStyle(nodeId: Protocol.DOM.NodeId): Promise<Map<string, string>|null> { |
| if (!this.isEnabled()) { |
| await this.enable(); |
| } |
| return await this.#styleLoader.computedStylePromise(nodeId); |
| } |
| |
| async getLayoutPropertiesFromComputedStyle(nodeId: Protocol.DOM.NodeId): Promise<LayoutProperties|null> { |
| const styles = await this.getComputedStyle(nodeId); |
| if (!styles) { |
| return null; |
| } |
| |
| const display = styles.get('display'); |
| const isFlex = display === 'flex' || display === 'inline-flex'; |
| const isGrid = display === 'grid' || display === 'inline-grid'; |
| const isSubgrid = (isGrid && |
| (styles.get('grid-template-columns')?.startsWith('subgrid') || |
| styles.get('grid-template-rows')?.startsWith('subgrid'))) ?? |
| false; |
| const isGridLanes = display === 'grid-lanes' || display === 'inline-grid-lanes'; |
| const containerType = styles.get('container-type'); |
| const isContainer = Boolean(containerType) && containerType !== '' && containerType !== 'normal'; |
| const hasScroll = Boolean(styles.get('scroll-snap-type')) && styles.get('scroll-snap-type') !== 'none'; |
| |
| return { |
| isFlex, |
| isGrid, |
| isSubgrid, |
| isGridLanes, |
| containerType: isContainer ? containerType : undefined, |
| hasScroll, |
| }; |
| } |
| |
| async getEnvironmentVariables(): Promise<Record<string, string>> { |
| const response = await this.agent.invoke_getEnvironmentVariables(); |
| if (response.getError()) { |
| return {}; |
| } |
| return response.environmentVariables; |
| } |
| |
| async getBackgroundColors(nodeId: Protocol.DOM.NodeId): Promise<ContrastInfo|null> { |
| const response = await this.agent.invoke_getBackgroundColors({nodeId}); |
| if (response.getError()) { |
| return null; |
| } |
| |
| return { |
| backgroundColors: response.backgroundColors || null, |
| computedFontSize: response.computedFontSize || '', |
| computedFontWeight: response.computedFontWeight || '', |
| }; |
| } |
| |
| async getPlatformFonts(nodeId: Protocol.DOM.NodeId): Promise<Protocol.CSS.PlatformFontUsage[]|null> { |
| const {fonts} = await this.agent.invoke_getPlatformFontsForNode({nodeId}); |
| return fonts; |
| } |
| |
| allStyleSheets(): CSSStyleSheetHeader[] { |
| const values = [...this.#styleSheetIdToHeader.values()]; |
| function styleSheetComparator(a: CSSStyleSheetHeader, b: CSSStyleSheetHeader): number { |
| if (a.sourceURL < b.sourceURL) { |
| return -1; |
| } |
| if (a.sourceURL > b.sourceURL) { |
| return 1; |
| } |
| return a.startLine - b.startLine || a.startColumn - b.startColumn; |
| } |
| values.sort(styleSheetComparator); |
| |
| return values; |
| } |
| |
| async getInlineStyles(nodeId: Protocol.DOM.NodeId): Promise<InlineStyleResult|null> { |
| const response = await this.agent.invoke_getInlineStylesForNode({nodeId}); |
| |
| if (response.getError() || !response.inlineStyle) { |
| return null; |
| } |
| const inlineStyle = new CSSStyleDeclaration(this, null, response.inlineStyle, Type.Inline); |
| const attributesStyle = response.attributesStyle ? |
| new CSSStyleDeclaration(this, null, response.attributesStyle, Type.Attributes) : |
| null; |
| return new InlineStyleResult(inlineStyle, attributesStyle); |
| } |
| |
| forceStartingStyle(node: DOMNode, forced: boolean): boolean { |
| void this.agent.invoke_forceStartingStyle({nodeId: node.id, forced}); |
| this.dispatchEventToListeners(Events.StartingStylesStateForced, node); |
| return true; |
| } |
| |
| forcePseudoState(node: DOMNode, pseudoClass: string, enable: boolean): boolean { |
| const forcedPseudoClasses = node.marker<string[]>(PseudoStateMarker) || []; |
| const hasPseudoClass = forcedPseudoClasses.includes(pseudoClass); |
| if (enable) { |
| if (hasPseudoClass) { |
| return false; |
| } |
| forcedPseudoClasses.push(pseudoClass); |
| node.setMarker(PseudoStateMarker, forcedPseudoClasses); |
| } else { |
| if (!hasPseudoClass) { |
| return false; |
| } |
| Platform.ArrayUtilities.removeElement(forcedPseudoClasses, pseudoClass); |
| if (forcedPseudoClasses.length) { |
| node.setMarker(PseudoStateMarker, forcedPseudoClasses); |
| } else { |
| node.setMarker(PseudoStateMarker, null); |
| } |
| } |
| |
| if (node.id === undefined) { |
| return false; |
| } |
| void this.agent.invoke_forcePseudoState({nodeId: node.id, forcedPseudoClasses}); |
| this.dispatchEventToListeners(Events.PseudoStateForced, {node, pseudoClass, enable}); |
| return true; |
| } |
| |
| pseudoState(node: DOMNode): string[]|null { |
| return node.marker(PseudoStateMarker) || []; |
| } |
| |
| async setMediaText( |
| styleSheetId: Protocol.DOM.StyleSheetId, range: TextUtils.TextRange.TextRange, |
| newMediaText: string): Promise<boolean> { |
| Host.userMetrics.actionTaken(Host.UserMetrics.Action.StyleRuleEdited); |
| |
| try { |
| await this.ensureOriginalStyleSheetText(styleSheetId); |
| const {media} = await this.agent.invoke_setMediaText({styleSheetId, range, text: newMediaText}); |
| |
| if (!media) { |
| return false; |
| } |
| this.#domModel.markUndoableState(); |
| const edit = new Edit(styleSheetId, range, newMediaText, media); |
| this.fireStyleSheetChanged(styleSheetId, edit); |
| return true; |
| } catch (e) { |
| console.error(e); |
| return false; |
| } |
| } |
| |
| async setContainerQueryText( |
| styleSheetId: Protocol.DOM.StyleSheetId, range: TextUtils.TextRange.TextRange, |
| newContainerQueryText: string): Promise<boolean> { |
| Host.userMetrics.actionTaken(Host.UserMetrics.Action.StyleRuleEdited); |
| |
| try { |
| await this.ensureOriginalStyleSheetText(styleSheetId); |
| const {containerQuery} = |
| await this.agent.invoke_setContainerQueryText({styleSheetId, range, text: newContainerQueryText}); |
| |
| if (!containerQuery) { |
| return false; |
| } |
| this.#domModel.markUndoableState(); |
| const edit = new Edit(styleSheetId, range, newContainerQueryText, containerQuery); |
| this.fireStyleSheetChanged(styleSheetId, edit); |
| return true; |
| } catch (e) { |
| console.error(e); |
| return false; |
| } |
| } |
| |
| async setSupportsText( |
| styleSheetId: Protocol.DOM.StyleSheetId, range: TextUtils.TextRange.TextRange, |
| newSupportsText: string): Promise<boolean> { |
| Host.userMetrics.actionTaken(Host.UserMetrics.Action.StyleRuleEdited); |
| |
| try { |
| await this.ensureOriginalStyleSheetText(styleSheetId); |
| const {supports} = await this.agent.invoke_setSupportsText({styleSheetId, range, text: newSupportsText}); |
| |
| if (!supports) { |
| return false; |
| } |
| this.#domModel.markUndoableState(); |
| const edit = new Edit(styleSheetId, range, newSupportsText, supports); |
| this.fireStyleSheetChanged(styleSheetId, edit); |
| return true; |
| } catch (e) { |
| console.error(e); |
| return false; |
| } |
| } |
| |
| async setScopeText( |
| styleSheetId: Protocol.DOM.StyleSheetId, range: TextUtils.TextRange.TextRange, |
| newScopeText: string): Promise<boolean> { |
| Host.userMetrics.actionTaken(Host.UserMetrics.Action.StyleRuleEdited); |
| |
| try { |
| await this.ensureOriginalStyleSheetText(styleSheetId); |
| const {scope} = await this.agent.invoke_setScopeText({styleSheetId, range, text: newScopeText}); |
| |
| if (!scope) { |
| return false; |
| } |
| this.#domModel.markUndoableState(); |
| const edit = new Edit(styleSheetId, range, newScopeText, scope); |
| this.fireStyleSheetChanged(styleSheetId, edit); |
| return true; |
| } catch (e) { |
| console.error(e); |
| return false; |
| } |
| } |
| |
| async addRule(styleSheetId: Protocol.DOM.StyleSheetId, ruleText: string, ruleLocation: TextUtils.TextRange.TextRange): |
| Promise<CSSStyleRule|null> { |
| try { |
| await this.ensureOriginalStyleSheetText(styleSheetId); |
| const {rule} = await this.agent.invoke_addRule({styleSheetId, ruleText, location: ruleLocation}); |
| |
| if (!rule) { |
| return null; |
| } |
| this.#domModel.markUndoableState(); |
| const edit = new Edit(styleSheetId, ruleLocation, ruleText, rule); |
| this.fireStyleSheetChanged(styleSheetId, edit); |
| return new CSSStyleRule(this, rule); |
| } catch (e) { |
| console.error(e); |
| return null; |
| } |
| } |
| |
| async requestViaInspectorStylesheet(maybeFrameId?: Protocol.Page.FrameId|null): Promise<CSSStyleSheetHeader|null> { |
| const frameId = maybeFrameId || |
| (this.#resourceTreeModel && this.#resourceTreeModel.mainFrame ? this.#resourceTreeModel.mainFrame.id : null); |
| const headers = [...this.#styleSheetIdToHeader.values()]; |
| const styleSheetHeader = headers.find(header => header.frameId === frameId && header.isViaInspector()); |
| if (styleSheetHeader) { |
| return styleSheetHeader; |
| } |
| if (!frameId) { |
| return null; |
| } |
| |
| try { |
| return await this.createInspectorStylesheet(frameId); |
| } catch (e) { |
| console.error(e); |
| return null; |
| } |
| } |
| |
| async createInspectorStylesheet(frameId: Protocol.Page.FrameId, force = false): Promise<CSSStyleSheetHeader|null> { |
| const result = await this.agent.invoke_createStyleSheet({frameId, force}); |
| if (result.getError()) { |
| throw new Error(result.getError()); |
| } |
| return this.#styleSheetIdToHeader.get(result.styleSheetId) || null; |
| } |
| |
| mediaQueryResultChanged(): void { |
| this.#colorScheme = undefined; |
| this.dispatchEventToListeners(Events.MediaQueryResultChanged); |
| } |
| |
| fontsUpdated(fontFace?: Protocol.CSS.FontFace|null): void { |
| if (fontFace) { |
| this.#fontFaces.set(fontFace.src, new CSSFontFace(fontFace)); |
| } |
| this.dispatchEventToListeners(Events.FontsUpdated); |
| } |
| |
| fontFaces(): CSSFontFace[] { |
| return [...this.#fontFaces.values()]; |
| } |
| |
| fontFaceForSource(src: string): CSSFontFace|undefined { |
| return this.#fontFaces.get(src); |
| } |
| |
| styleSheetHeaderForId(id: Protocol.DOM.StyleSheetId): CSSStyleSheetHeader|null { |
| return this.#styleSheetIdToHeader.get(id) || null; |
| } |
| |
| styleSheetHeaders(): CSSStyleSheetHeader[] { |
| return [...this.#styleSheetIdToHeader.values()]; |
| } |
| |
| fireStyleSheetChanged(styleSheetId: Protocol.DOM.StyleSheetId, edit?: Edit): void { |
| this.dispatchEventToListeners(Events.StyleSheetChanged, {styleSheetId, edit}); |
| } |
| |
| private ensureOriginalStyleSheetText(styleSheetId: Protocol.DOM.StyleSheetId): Promise<string|null> { |
| const header = this.styleSheetHeaderForId(styleSheetId); |
| if (!header) { |
| return Promise.resolve(null); |
| } |
| let promise = this.#originalStyleSheetText.get(header); |
| if (!promise) { |
| promise = this.getStyleSheetText(header.id); |
| this.#originalStyleSheetText.set(header, promise); |
| this.originalContentRequestedForTest(header); |
| } |
| return promise; |
| } |
| |
| private originalContentRequestedForTest(_header: CSSStyleSheetHeader): void { |
| } |
| |
| originalStyleSheetText(header: CSSStyleSheetHeader): Promise<string|null> { |
| return this.ensureOriginalStyleSheetText(header.id); |
| } |
| |
| getAllStyleSheetHeaders(): Iterable<CSSStyleSheetHeader> { |
| return this.#styleSheetIdToHeader.values(); |
| } |
| |
| computedStyleUpdated(nodeId: Protocol.DOM.NodeId): void { |
| this.dispatchEventToListeners(Events.ComputedStyleUpdated, {nodeId}); |
| } |
| |
| styleSheetAdded(header: Protocol.CSS.CSSStyleSheetHeader): void { |
| console.assert(!this.#styleSheetIdToHeader.get(header.styleSheetId)); |
| if (header.loadingFailed) { |
| // When the stylesheet fails to load, treat it as a constructed stylesheet. Failed sheets can still be modified |
| // from JS, in which case CSS.styleSheetChanged events are sent. So as to not confuse CSSModel clients we don't |
| // just discard the failed sheet here. Treating the failed sheet as a constructed stylesheet lets us keep track |
| // of it cleanly. |
| header.hasSourceURL = false; |
| header.isConstructed = true; |
| header.isInline = false; |
| header.isMutable = false; |
| header.sourceURL = ''; |
| header.sourceMapURL = undefined; |
| } |
| const styleSheetHeader = new CSSStyleSheetHeader(this, header); |
| this.#styleSheetIdToHeader.set(header.styleSheetId, styleSheetHeader); |
| const url = styleSheetHeader.resourceURL(); |
| let frameIdToStyleSheetIds = this.#styleSheetIdsForURL.get(url); |
| if (!frameIdToStyleSheetIds) { |
| frameIdToStyleSheetIds = new Map(); |
| this.#styleSheetIdsForURL.set(url, frameIdToStyleSheetIds); |
| } |
| if (frameIdToStyleSheetIds) { |
| let styleSheetIds = frameIdToStyleSheetIds.get(styleSheetHeader.frameId); |
| if (!styleSheetIds) { |
| styleSheetIds = new Set(); |
| frameIdToStyleSheetIds.set(styleSheetHeader.frameId, styleSheetIds); |
| } |
| styleSheetIds.add(styleSheetHeader.id); |
| } |
| this.#sourceMapManager.attachSourceMap(styleSheetHeader, styleSheetHeader.sourceURL, styleSheetHeader.sourceMapURL); |
| this.dispatchEventToListeners(Events.StyleSheetAdded, styleSheetHeader); |
| } |
| |
| styleSheetRemoved(id: Protocol.DOM.StyleSheetId): void { |
| const header = this.#styleSheetIdToHeader.get(id); |
| console.assert(Boolean(header)); |
| if (!header) { |
| return; |
| } |
| this.#styleSheetIdToHeader.delete(id); |
| const url = header.resourceURL(); |
| const frameIdToStyleSheetIds = this.#styleSheetIdsForURL.get(url); |
| console.assert( |
| Boolean(frameIdToStyleSheetIds), 'No frameId to styleSheetId map is available for given style sheet URL.'); |
| if (frameIdToStyleSheetIds) { |
| const stylesheetIds = frameIdToStyleSheetIds.get(header.frameId); |
| if (stylesheetIds) { |
| stylesheetIds.delete(id); |
| if (!stylesheetIds.size) { |
| frameIdToStyleSheetIds.delete(header.frameId); |
| if (!frameIdToStyleSheetIds.size) { |
| this.#styleSheetIdsForURL.delete(url); |
| } |
| } |
| } |
| } |
| this.#originalStyleSheetText.delete(header); |
| this.#sourceMapManager.detachSourceMap(header); |
| this.dispatchEventToListeners(Events.StyleSheetRemoved, header); |
| } |
| |
| getStyleSheetIdsForURL(url: Platform.DevToolsPath.UrlString): Protocol.DOM.StyleSheetId[] { |
| const frameIdToStyleSheetIds = this.#styleSheetIdsForURL.get(url); |
| if (!frameIdToStyleSheetIds) { |
| return []; |
| } |
| |
| const result = []; |
| for (const styleSheetIds of frameIdToStyleSheetIds.values()) { |
| result.push(...styleSheetIds); |
| } |
| return result; |
| } |
| |
| async setStyleSheetText(styleSheetId: Protocol.DOM.StyleSheetId, newText: string, majorChange: boolean): |
| Promise<string|null> { |
| const header = this.#styleSheetIdToHeader.get(styleSheetId); |
| if (!header) { |
| return 'Unknown stylesheet in CSS.setStyleSheetText'; |
| } |
| newText = CSSModel.trimSourceURL(newText); |
| if (header.hasSourceURL) { |
| newText += '\n/*# sourceURL=' + header.sourceURL + ' */'; |
| } |
| |
| await this.ensureOriginalStyleSheetText(styleSheetId); |
| const response = await this.agent.invoke_setStyleSheetText({styleSheetId: header.id, text: newText}); |
| const sourceMapURL = response.sourceMapURL as Platform.DevToolsPath.UrlString; |
| |
| this.#sourceMapManager.detachSourceMap(header); |
| header.setSourceMapURL(sourceMapURL); |
| this.#sourceMapManager.attachSourceMap(header, header.sourceURL, header.sourceMapURL); |
| if (sourceMapURL === null) { |
| return 'Error in CSS.setStyleSheetText'; |
| } |
| this.#domModel.markUndoableState(!majorChange); |
| this.fireStyleSheetChanged(styleSheetId); |
| return null; |
| } |
| |
| async getStyleSheetText(styleSheetId: Protocol.DOM.StyleSheetId): Promise<string|null> { |
| const response = await this.agent.invoke_getStyleSheetText({styleSheetId}); |
| if (response.getError()) { |
| return null; |
| } |
| const {text} = response; |
| return text && CSSModel.trimSourceURL(text); |
| } |
| |
| private async onPrimaryPageChanged( |
| event: Common.EventTarget.EventTargetEvent<{frame: ResourceTreeFrame, type: PrimaryPageChangeType}>): |
| Promise<void> { |
| // If the main frame was restored from the back-forward cache, the order of CDP |
| // is different from the regular navigations. In this case, events about CSS |
| // stylesheet has already been received and they are mixed with the previous page |
| // stylesheets. Therefore, we re-enable the CSS agent to get fresh events. |
| // For the regular navigations, we can just clear the local data because events about |
| // stylesheets will arrive later. |
| if (event.data.frame.backForwardCacheDetails.restoredFromCache) { |
| await this.suspendModel(); |
| await this.resumeModel(); |
| } else if (event.data.type !== PrimaryPageChangeType.ACTIVATION) { |
| this.resetStyleSheets(); |
| this.resetFontFaces(); |
| } |
| } |
| |
| private resetStyleSheets(): void { |
| const headers = [...this.#styleSheetIdToHeader.values()]; |
| this.#styleSheetIdsForURL.clear(); |
| this.#styleSheetIdToHeader.clear(); |
| for (const header of headers) { |
| this.#sourceMapManager.detachSourceMap(header); |
| this.dispatchEventToListeners(Events.StyleSheetRemoved, header); |
| } |
| } |
| |
| private resetFontFaces(): void { |
| this.#fontFaces.clear(); |
| } |
| |
| override async suspendModel(): Promise<void> { |
| this.#isEnabled = false; |
| await this.agent.invoke_disable(); |
| this.resetStyleSheets(); |
| this.resetFontFaces(); |
| } |
| |
| override async resumeModel(): Promise<void> { |
| return await this.enable(); |
| } |
| |
| setEffectivePropertyValueForNode(nodeId: Protocol.DOM.NodeId, propertyName: string, value: string): void { |
| void this.agent.invoke_setEffectivePropertyValueForNode({nodeId, propertyName, value}); |
| } |
| |
| cachedMatchedCascadeForNode(node: DOMNode): Promise<CSSMatchedStyles|null> { |
| if (this.#cachedMatchedCascadeNode !== node) { |
| this.discardCachedMatchedCascade(); |
| } |
| this.#cachedMatchedCascadeNode = node; |
| if (!this.#cachedMatchedCascadePromise) { |
| if (node.id) { |
| this.#cachedMatchedCascadePromise = this.getMatchedStyles(node.id); |
| } else { |
| return Promise.resolve(null); |
| } |
| } |
| return this.#cachedMatchedCascadePromise; |
| } |
| |
| discardCachedMatchedCascade(): void { |
| this.#cachedMatchedCascadeNode = null; |
| this.#cachedMatchedCascadePromise = null; |
| } |
| |
| createCSSPropertyTracker(propertiesToTrack: Protocol.CSS.CSSComputedStyleProperty[]): CSSPropertyTracker { |
| const cssPropertyTracker = new CSSPropertyTracker(this, propertiesToTrack); |
| return cssPropertyTracker; |
| } |
| |
| enableCSSPropertyTracker(cssPropertyTracker: CSSPropertyTracker): void { |
| const propertiesToTrack = cssPropertyTracker.getTrackedProperties(); |
| if (propertiesToTrack.length === 0) { |
| return; |
| } |
| void this.agent.invoke_trackComputedStyleUpdates({propertiesToTrack}); |
| this.#isCSSPropertyTrackingEnabled = true; |
| this.#cssPropertyTracker = cssPropertyTracker; |
| void this.pollComputedStyleUpdates(); |
| } |
| |
| // Since we only support one tracker at a time, this call effectively disables |
| // style tracking. |
| disableCSSPropertyTracker(): void { |
| this.#isCSSPropertyTrackingEnabled = false; |
| this.#cssPropertyTracker = null; |
| // Sending an empty list to the backend signals the close of style tracking |
| void this.agent.invoke_trackComputedStyleUpdates({propertiesToTrack: []}); |
| } |
| |
| private async pollComputedStyleUpdates(): Promise<void> { |
| if (this.#isTrackingRequestPending) { |
| return; |
| } |
| |
| if (this.#isCSSPropertyTrackingEnabled) { |
| this.#isTrackingRequestPending = true; |
| const result = await this.agent.invoke_takeComputedStyleUpdates(); |
| this.#isTrackingRequestPending = false; |
| |
| if (result.getError() || !result.nodeIds || !this.#isCSSPropertyTrackingEnabled) { |
| return; |
| } |
| |
| if (this.#cssPropertyTracker) { |
| this.#cssPropertyTracker.dispatchEventToListeners( |
| CSSPropertyTrackerEvents.TRACKED_CSS_PROPERTIES_UPDATED, |
| result.nodeIds.map(nodeId => this.#domModel.nodeForId(nodeId))); |
| } |
| } |
| |
| if (this.#isCSSPropertyTrackingEnabled) { |
| void this.#stylePollingThrottler.schedule(this.pollComputedStyleUpdates.bind(this)); |
| } |
| } |
| |
| override dispose(): void { |
| this.disableCSSPropertyTracker(); |
| super.dispose(); |
| this.dispatchEventToListeners(Events.ModelDisposed, this); |
| } |
| |
| getAgent(): ProtocolProxyApi.CSSApi { |
| return this.agent; |
| } |
| } |
| |
| export enum Events { |
| /* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */ |
| FontsUpdated = 'FontsUpdated', |
| MediaQueryResultChanged = 'MediaQueryResultChanged', |
| ModelWasEnabled = 'ModelWasEnabled', |
| ModelDisposed = 'ModelDisposed', |
| PseudoStateForced = 'PseudoStateForced', |
| StartingStylesStateForced = 'StartingStylesStateForced', |
| StyleSheetAdded = 'StyleSheetAdded', |
| StyleSheetChanged = 'StyleSheetChanged', |
| StyleSheetRemoved = 'StyleSheetRemoved', |
| ComputedStyleUpdated = 'ComputedStyleUpdated', |
| /* eslint-enable @typescript-eslint/naming-convention */ |
| } |
| |
| export interface StyleSheetChangedEvent { |
| styleSheetId: Protocol.DOM.StyleSheetId; |
| edit?: Edit; |
| } |
| |
| export interface PseudoStateForcedEvent { |
| node: DOMNode; |
| pseudoClass: string; |
| enable: boolean; |
| } |
| |
| export interface ComputedStyleUpdatedEvent { |
| nodeId: Protocol.DOM.NodeId; |
| } |
| |
| export interface EventTypes { |
| [Events.FontsUpdated]: void; |
| [Events.MediaQueryResultChanged]: void; |
| [Events.ModelWasEnabled]: void; |
| [Events.ModelDisposed]: CSSModel; |
| [Events.PseudoStateForced]: PseudoStateForcedEvent; |
| [Events.StartingStylesStateForced]: DOMNode; |
| [Events.StyleSheetAdded]: CSSStyleSheetHeader; |
| [Events.StyleSheetChanged]: StyleSheetChangedEvent; |
| [Events.StyleSheetRemoved]: CSSStyleSheetHeader; |
| [Events.ComputedStyleUpdated]: ComputedStyleUpdatedEvent; |
| } |
| |
| const PseudoStateMarker = 'pseudo-state-marker'; |
| |
| export class Edit { |
| styleSheetId: string; |
| oldRange: TextUtils.TextRange.TextRange; |
| newRange: TextUtils.TextRange.TextRange; |
| newText: string; |
| payload: Object|null; |
| constructor(styleSheetId: string, oldRange: TextUtils.TextRange.TextRange, newText: string, payload: Object|null) { |
| this.styleSheetId = styleSheetId; |
| this.oldRange = oldRange; |
| this.newRange = TextUtils.TextRange.TextRange.fromEdit(oldRange, newText); |
| this.newText = newText; |
| this.payload = payload; |
| } |
| } |
| |
| export class CSSLocation { |
| readonly #cssModel: CSSModel; |
| styleSheetId: Protocol.DOM.StyleSheetId; |
| url: Platform.DevToolsPath.UrlString; |
| lineNumber: number; |
| columnNumber: number; |
| constructor(header: CSSStyleSheetHeader, lineNumber: number, columnNumber?: number) { |
| this.#cssModel = header.cssModel(); |
| this.styleSheetId = header.id; |
| this.url = header.resourceURL(); |
| this.lineNumber = lineNumber; |
| this.columnNumber = columnNumber || 0; |
| } |
| |
| cssModel(): CSSModel { |
| return this.#cssModel; |
| } |
| |
| header(): CSSStyleSheetHeader|null { |
| return this.#cssModel.styleSheetHeaderForId(this.styleSheetId); |
| } |
| } |
| |
| class CSSDispatcher implements ProtocolProxyApi.CSSDispatcher { |
| readonly #cssModel: CSSModel; |
| constructor(cssModel: CSSModel) { |
| this.#cssModel = cssModel; |
| } |
| |
| mediaQueryResultChanged(): void { |
| this.#cssModel.mediaQueryResultChanged(); |
| } |
| |
| fontsUpdated({font}: Protocol.CSS.FontsUpdatedEvent): void { |
| this.#cssModel.fontsUpdated(font); |
| } |
| |
| styleSheetChanged({styleSheetId}: Protocol.CSS.StyleSheetChangedEvent): void { |
| this.#cssModel.fireStyleSheetChanged(styleSheetId); |
| } |
| |
| styleSheetAdded({header}: Protocol.CSS.StyleSheetAddedEvent): void { |
| this.#cssModel.styleSheetAdded(header); |
| } |
| |
| styleSheetRemoved({styleSheetId}: Protocol.CSS.StyleSheetRemovedEvent): void { |
| this.#cssModel.styleSheetRemoved(styleSheetId); |
| } |
| |
| computedStyleUpdated({nodeId}: Protocol.CSS.ComputedStyleUpdatedEvent): void { |
| this.#cssModel.computedStyleUpdated(nodeId); |
| } |
| } |
| |
| class ComputedStyleLoader { |
| #cssModel: CSSModel; |
| #nodeIdToPromise = new Map<number, Promise<Map<string, string>|null>>(); |
| constructor(cssModel: CSSModel) { |
| this.#cssModel = cssModel; |
| } |
| |
| computedStylePromise(nodeId: Protocol.DOM.NodeId): Promise<Map<string, string>|null> { |
| let promise = this.#nodeIdToPromise.get(nodeId); |
| if (promise) { |
| return promise; |
| } |
| promise = this.#cssModel.getAgent().invoke_getComputedStyleForNode({nodeId}).then(({computedStyle}) => { |
| this.#nodeIdToPromise.delete(nodeId); |
| if (!computedStyle?.length) { |
| return null; |
| } |
| const result = new Map<string, string>(); |
| for (const property of computedStyle) { |
| result.set(property.name, property.value); |
| } |
| return result; |
| }); |
| this.#nodeIdToPromise.set(nodeId, promise); |
| return promise; |
| } |
| } |
| |
| export class InlineStyleResult { |
| inlineStyle: CSSStyleDeclaration|null; |
| attributesStyle: CSSStyleDeclaration|null; |
| constructor(inlineStyle: CSSStyleDeclaration|null, attributesStyle: CSSStyleDeclaration|null) { |
| this.inlineStyle = inlineStyle; |
| this.attributesStyle = attributesStyle; |
| } |
| } |
| |
| export class CSSPropertyTracker extends Common.ObjectWrapper.ObjectWrapper<CSSPropertyTrackerEventTypes> { |
| readonly #cssModel: CSSModel; |
| readonly #properties: Protocol.CSS.CSSComputedStyleProperty[]; |
| constructor(cssModel: CSSModel, propertiesToTrack: Protocol.CSS.CSSComputedStyleProperty[]) { |
| super(); |
| this.#cssModel = cssModel; |
| this.#properties = propertiesToTrack; |
| } |
| |
| start(): void { |
| this.#cssModel.enableCSSPropertyTracker(this); |
| } |
| |
| stop(): void { |
| this.#cssModel.disableCSSPropertyTracker(); |
| } |
| |
| getTrackedProperties(): Protocol.CSS.CSSComputedStyleProperty[] { |
| return this.#properties; |
| } |
| } |
| |
| const StylePollingInterval = 1000; // throttling interval for style polling, in milliseconds |
| |
| export const enum CSSPropertyTrackerEvents { |
| TRACKED_CSS_PROPERTIES_UPDATED = 'TrackedCSSPropertiesUpdated', |
| } |
| |
| export interface CSSPropertyTrackerEventTypes { |
| [CSSPropertyTrackerEvents.TRACKED_CSS_PROPERTIES_UPDATED]: Array<DOMNode|null>; |
| } |
| |
| SDKModel.register(CSSModel, {capabilities: Capability.DOM, autostart: true}); |
| export interface ContrastInfo { |
| backgroundColors: string[]|null; |
| computedFontSize: string; |
| computedFontWeight: string; |
| } |