chrome-devtools-frontend
Version:
Chrome DevTools UI
329 lines (292 loc) • 12.4 kB
text/typescript
// Copyright 2016 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 TextUtils from '../../models/text_utils/text_utils.js';
import * as Common from '../common/common.js';
import * as HostModule from '../host/host.js';
import * as Platform from '../platform/platform.js';
import type * as Protocol from '../../generated/protocol.js';
import {cssMetadata, GridAreaRowRegex} from './CSSMetadata.js';
import {type Edit} from './CSSModel.js';
import {type CSSStyleDeclaration} from './CSSStyleDeclaration.js';
export class CSSProperty {
ownerStyle: CSSStyleDeclaration;
index: number;
name: string;
value: string;
important: boolean;
disabled: boolean;
parsedOk: boolean;
implicit: boolean;
text: string|null|undefined;
range: TextUtils.TextRange.TextRange|null;
#active: boolean;
#nameRangeInternal: TextUtils.TextRange.TextRange|null;
#valueRangeInternal: TextUtils.TextRange.TextRange|null;
#invalidString?: Common.UIString.LocalizedString;
#longhandProperties: CSSProperty[] = [];
constructor(
ownerStyle: CSSStyleDeclaration, index: number, name: string, value: string, important: boolean,
disabled: boolean, parsedOk: boolean, implicit: boolean, text?: string|null, range?: Protocol.CSS.SourceRange,
longhandProperties?: Protocol.CSS.CSSProperty[]) {
this.ownerStyle = ownerStyle;
this.index = index;
this.name = name;
this.value = value;
this.important = important;
this.disabled = disabled;
this.parsedOk = parsedOk;
this.implicit = implicit; // A longhand, implicitly set by missing values of shorthand.
this.text = text;
this.range = range ? TextUtils.TextRange.TextRange.fromObject(range) : null;
this.#active = true;
this.#nameRangeInternal = null;
this.#valueRangeInternal = null;
if (longhandProperties && longhandProperties.length > 0) {
for (const property of longhandProperties) {
this.#longhandProperties.push(
new CSSProperty(ownerStyle, ++index, property.name, property.value, important, disabled, parsedOk, true));
}
} else {
// Blink would not parse shorthands containing 'var()' functions:
// https://drafts.csswg.org/css-variables/#variables-in-shorthands).
// Therefore we manually check if the current property is a shorthand,
// and fills its longhand components with empty values.
const longhandNames = cssMetadata().getLonghands(name);
for (const longhandName of longhandNames || []) {
this.#longhandProperties.push(
new CSSProperty(ownerStyle, ++index, longhandName, '', important, disabled, parsedOk, true));
}
}
}
static parsePayload(ownerStyle: CSSStyleDeclaration, index: number, payload: Protocol.CSS.CSSProperty): CSSProperty {
// The following default field values are used in the payload:
// important: false
// parsedOk: true
// implicit: false
// disabled: false
const result = new CSSProperty(
ownerStyle, index, payload.name, payload.value, payload.important || false, payload.disabled || false,
('parsedOk' in payload) ? Boolean(payload.parsedOk) : true, Boolean(payload.implicit), payload.text,
payload.range, payload.longhandProperties);
return result;
}
private ensureRanges(): void {
if (this.#nameRangeInternal && this.#valueRangeInternal) {
return;
}
const range = this.range;
const text = this.text ? new TextUtils.Text.Text(this.text) : null;
if (!range || !text) {
return;
}
const nameIndex = text.value().indexOf(this.name);
const valueIndex = text.value().lastIndexOf(this.value);
if (nameIndex === -1 || valueIndex === -1 || nameIndex > valueIndex) {
return;
}
const nameSourceRange = new TextUtils.TextRange.SourceRange(nameIndex, this.name.length);
const valueSourceRange = new TextUtils.TextRange.SourceRange(valueIndex, this.value.length);
this.#nameRangeInternal = rebase(text.toTextRange(nameSourceRange), range.startLine, range.startColumn);
this.#valueRangeInternal = rebase(text.toTextRange(valueSourceRange), range.startLine, range.startColumn);
function rebase(oneLineRange: TextUtils.TextRange.TextRange, lineOffset: number, columnOffset: number):
TextUtils.TextRange.TextRange {
if (oneLineRange.startLine === 0) {
oneLineRange.startColumn += columnOffset;
oneLineRange.endColumn += columnOffset;
}
oneLineRange.startLine += lineOffset;
oneLineRange.endLine += lineOffset;
return oneLineRange;
}
}
nameRange(): TextUtils.TextRange.TextRange|null {
this.ensureRanges();
return this.#nameRangeInternal;
}
valueRange(): TextUtils.TextRange.TextRange|null {
this.ensureRanges();
return this.#valueRangeInternal;
}
rebase(edit: Edit): void {
if (this.ownerStyle.styleSheetId !== edit.styleSheetId) {
return;
}
if (this.range) {
this.range = this.range.rebaseAfterTextEdit(edit.oldRange, edit.newRange);
}
}
setActive(active: boolean): void {
this.#active = active;
}
get propertyText(): string|null {
if (this.text !== undefined) {
return this.text;
}
if (this.name === '') {
return '';
}
return this.name + ': ' + this.value + (this.important ? ' !important' : '') + ';';
}
activeInStyle(): boolean {
return this.#active;
}
trimmedValueWithoutImportant(): string {
const important = '!important';
return this.value.endsWith(important) ? this.value.slice(0, -important.length).trim() : this.value.trim();
}
async setText(propertyText: string, majorChange: boolean, overwrite?: boolean): Promise<boolean> {
if (!this.ownerStyle) {
throw new Error('No ownerStyle for property');
}
if (!this.ownerStyle.styleSheetId) {
throw new Error('No owner style id');
}
if (!this.range || !this.ownerStyle.range) {
throw new Error('Style not editable');
}
if (majorChange) {
HostModule.userMetrics.actionTaken(HostModule.UserMetrics.Action.StyleRuleEdited);
if (this.name.startsWith('--')) {
HostModule.userMetrics.actionTaken(HostModule.UserMetrics.Action.CustomPropertyEdited);
}
}
if (overwrite && propertyText === this.propertyText) {
this.ownerStyle.cssModel().domModel().markUndoableState(!majorChange);
return true;
}
const range = this.range.relativeTo(this.ownerStyle.range.startLine, this.ownerStyle.range.startColumn);
const indentation = this.ownerStyle.cssText ?
this.detectIndentation(this.ownerStyle.cssText) :
Common.Settings.Settings.instance().moduleSetting('textEditorIndent').get();
const endIndentation = this.ownerStyle.cssText ? indentation.substring(0, this.ownerStyle.range.endColumn) : '';
const text = new TextUtils.Text.Text(this.ownerStyle.cssText || '');
const newStyleText = text.replaceRange(range, Platform.StringUtilities.sprintf(';%s;', propertyText));
const styleText = await CSSProperty.formatStyle(newStyleText, indentation, endIndentation);
return this.ownerStyle.setText(styleText, majorChange);
}
static async formatStyle(styleText: string, indentation: string, endIndentation: string): Promise<string> {
const doubleIndent = indentation.substring(endIndentation.length) + indentation;
if (indentation) {
indentation = '\n' + indentation;
}
let result = '';
let propertyName = '';
let propertyText = '';
let insideProperty = false;
let needsSemi = false;
const tokenize = TextUtils.CodeMirrorUtils.createCssTokenizer();
await tokenize('*{' + styleText + '}', processToken);
if (insideProperty) {
result += propertyText;
}
result = result.substring(2, result.length - 1).trimEnd();
return result + (indentation ? '\n' + endIndentation : '');
function processToken(token: string, tokenType: string|null): void {
if (!insideProperty) {
const disabledProperty = tokenType?.includes('comment') && isDisabledProperty(token);
const isPropertyStart =
(tokenType?.includes('string') || tokenType?.includes('meta') || tokenType?.includes('property') ||
(tokenType?.includes('variableName') && tokenType !== ('variableName.function')));
if (disabledProperty) {
result = result.trimEnd() + indentation + token;
} else if (isPropertyStart) {
insideProperty = true;
propertyText = token;
} else if (token !== ';' || needsSemi) {
result += token;
if (token.trim() && !(tokenType?.includes('comment'))) {
needsSemi = token !== ';';
}
}
if (token === '{' && !tokenType) {
needsSemi = false;
}
return;
}
if (token === '}' || token === ';') {
// While `propertyText` can generally be trimmed, doing so
// breaks valid CSS declarations such as `--foo: ;` which would
// then produce invalid CSS of the form `--foo:;`. This
// implementation takes special care to restore a single
// whitespace token in this edge case. https://crbug.com/1071296
const trimmedPropertyText = propertyText.trim();
result = result.trimEnd() + indentation + trimmedPropertyText + (trimmedPropertyText.endsWith(':') ? ' ' : '') +
token;
needsSemi = false;
insideProperty = false;
propertyName = '';
return;
}
if (cssMetadata().isGridAreaDefiningProperty(propertyName)) {
const rowResult = GridAreaRowRegex.exec(token);
if (rowResult && rowResult.index === 0 && !propertyText.trimEnd().endsWith(']')) {
propertyText = propertyText.trimEnd() + '\n' + doubleIndent;
}
}
if (!propertyName && token === ':') {
propertyName = propertyText;
}
propertyText += token;
}
function isDisabledProperty(text: string): boolean {
const colon = text.indexOf(':');
if (colon === -1) {
return false;
}
const propertyName = text.substring(2, colon).trim();
return cssMetadata().isCSSPropertyName(propertyName);
}
}
private detectIndentation(text: string): string {
const lines = text.split('\n');
if (lines.length < 2) {
return '';
}
return TextUtils.TextUtils.Utils.lineIndent(lines[1]);
}
setValue(newValue: string, majorChange: boolean, overwrite: boolean, userCallback?: ((arg0: boolean) => void)): void {
const text = this.name + ': ' + newValue + (this.important ? ' !important' : '') + ';';
void this.setText(text, majorChange, overwrite).then(userCallback);
}
async setDisabled(disabled: boolean): Promise<boolean> {
if (!this.ownerStyle) {
return false;
}
if (disabled === this.disabled) {
return true;
}
if (!this.text) {
return true;
}
const propertyText = this.text.trim();
// Ensure that if we try to enable/disable a property that has no semicolon (which is only legal
// in the last position of a css rule), we add it. This ensures that if we then later try
// to re-enable/-disable the rule, we end up with legal syntax (if the user adds more properties
// after the disabled rule).
const appendSemicolonIfMissing = (propertyText: string): string =>
propertyText + (propertyText.endsWith(';') ? '' : ';');
let text: string;
if (disabled) {
text = '/* ' + appendSemicolonIfMissing(propertyText) + ' */';
} else {
text = appendSemicolonIfMissing(this.text.substring(2, propertyText.length - 2).trim());
}
return this.setText(text, true, true);
}
/**
* This stores the warning string when a CSS Property is improperly parsed.
*/
setDisplayedStringForInvalidProperty(invalidString: Common.UIString.LocalizedString): void {
this.#invalidString = invalidString;
}
/**
* Retrieve the warning string for a screen reader to announce when editing the property.
*/
getInvalidStringForInvalidProperty(): Common.UIString.LocalizedString|undefined {
return this.#invalidString;
}
getLonghandProperties(): CSSProperty[] {
return this.#longhandProperties;
}
}