chrome-devtools-frontend
Version:
Chrome DevTools UI
499 lines (446 loc) • 17.8 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 */
import * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import type * as Platform from '../../core/platform/platform.js';
import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import type * as Protocol from '../../generated/protocol.js';
import * as Bindings from '../../models/bindings/bindings.js';
import * as Workspace from '../../models/workspace/workspace.js';
import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
import * as ColorPicker from '../../ui/legacy/components/color_picker/color_picker.js';
import * as InlineEditor from '../../ui/legacy/components/inline_editor/inline_editor.js';
import type * as SourceFrame from '../../ui/legacy/components/source_frame/source_frame.js';
import * as UI from '../../ui/legacy/legacy.js';
import {AddDebugInfoURLDialog} from './AddSourceMapURLDialog.js';
import {Plugin} from './Plugin.js';
// Plugin to add CSS completion, shortcuts, and color/curve swatches
// to editors with CSS content.
const UIStrings = {
/**
*@description Swatch icon element title in CSSPlugin of the Sources panel
*/
openColorPicker: 'Open color picker.',
/**
*@description Text to open the cubic bezier editor
*/
openCubicBezierEditor: 'Open cubic bezier editor.',
/**
*@description Text for a context menu item for attaching a sourcemap to the currently open css file
*/
addSourceMap: 'Add source map…',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/sources/CSSPlugin.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const dontCompleteIn = new Set(['ColorLiteral', 'NumberLiteral', 'StringLiteral', 'Comment', 'Important']);
function findPropertyAt(node: CodeMirror.SyntaxNode, pos: number): CodeMirror.SyntaxNode|null {
if (dontCompleteIn.has(node.name)) {
return null;
}
for (let cur: CodeMirror.SyntaxNode|null = node; cur; cur = cur.parent) {
if (cur.name === 'StyleSheet' || cur.name === 'Styles' || cur.name === 'CallExpression') {
break;
} else if (cur.name === 'Declaration') {
const name = cur.getChild('PropertyName'), colon = cur.getChild(':');
return name && colon && colon.to <= pos ? name : null;
}
}
return null;
}
function getCurrentStyleSheet(
url: Platform.DevToolsPath.UrlString, cssModel: SDK.CSSModel.CSSModel): Protocol.CSS.StyleSheetId {
const currentStyleSheet = cssModel.getStyleSheetIdsForURL(url);
if (currentStyleSheet.length === 0) {
throw new Error('Can\'t find style sheet ID for current URL');
}
return currentStyleSheet[0];
}
async function specificCssCompletion(
cx: CodeMirror.CompletionContext, uiSourceCode: Workspace.UISourceCode.UISourceCode,
cssModel: SDK.CSSModel.CSSModel|undefined): Promise<CodeMirror.CompletionResult|null> {
const node = CodeMirror.syntaxTree(cx.state).resolveInner(cx.pos, -1);
if (node.name === 'ClassName') {
// Should never happen, but let's code defensively here
assertNotNullOrUndefined(cssModel);
const currentStyleSheet = getCurrentStyleSheet(uiSourceCode.url(), cssModel);
const existingClassNames = await cssModel.getClassNames(currentStyleSheet);
return {
from: node.from,
options: existingClassNames.map(value => ({type: 'constant', label: value})),
};
}
const property = findPropertyAt(node, cx.pos);
if (property) {
const propertyValues =
SDK.CSSMetadata.cssMetadata().getPropertyValues(cx.state.sliceDoc(property.from, property.to));
return {
from: node.name === 'ValueName' ? node.from : cx.pos,
options: propertyValues.map(value => ({type: 'constant', label: value})),
validFor: /^[\w\P{ASCII}\-]+$/u,
};
}
return null;
}
function findColorsAndCurves(
state: CodeMirror.EditorState,
from: number,
to: number,
onColor: (pos: number, parsedColor: Common.Color.Color, text: string) => void,
onCurve: (pos: number, curve: UI.Geometry.CubicBezier, text: string) => void,
): void {
let line = state.doc.lineAt(from);
function getToken(from: number, to: number): string {
if (from >= line.to) {
line = state.doc.lineAt(from);
}
return line.text.slice(from - line.from, to - line.from);
}
const tree = CodeMirror.ensureSyntaxTree(state, to, 100);
if (!tree) {
return;
}
tree.iterate({
from,
to,
enter: node => {
let content;
if (node.name === 'ValueName' || node.name === 'ColorLiteral') {
content = getToken(node.from, node.to);
} else if (
node.name === 'Callee' &&
/^(?:(?:rgba?|hsla?|hwba?|lch|oklch|lab|oklab|color)|cubic-bezier)$/.test(getToken(node.from, node.to))) {
content = state.sliceDoc(node.from, (node.node.parent as CodeMirror.SyntaxNode).to);
}
if (content) {
const parsedColor = Common.Color.parse(content);
if (parsedColor) {
onColor(node.from, parsedColor, content);
} else {
const parsedCurve = UI.Geometry.CubicBezier.parse(content);
if (parsedCurve) {
onCurve(node.from, parsedCurve, content);
}
}
}
},
});
}
class ColorSwatchWidget extends CodeMirror.WidgetType {
#text: string;
#color: Common.Color.Color;
readonly #from: number;
constructor(color: Common.Color.Color, text: string, from: number) {
super();
this.#color = color;
this.#text = text;
this.#from = from;
}
override eq(other: ColorSwatchWidget): boolean {
return this.#color.equal(other.#color) && this.#text === other.#text && this.#from === other.#from;
}
toDOM(view: CodeMirror.EditorView): HTMLElement {
const swatch = new InlineEditor.ColorSwatch.ColorSwatch(i18nString(UIStrings.openColorPicker));
swatch.renderColor(this.#color);
const value = swatch.createChild('span');
value.textContent = this.#text;
value.setAttribute('hidden', 'true');
swatch.addEventListener(InlineEditor.ColorSwatch.ColorChangedEvent.eventName, event => {
const insert = event.data.color.getAuthoredText() ?? event.data.color.asString();
view.dispatch({changes: {from: this.#from, to: this.#from + this.#text.length, insert}});
this.#text = insert;
this.#color = swatch.getColor() as Common.Color.Color;
});
swatch.addEventListener(InlineEditor.ColorSwatch.ClickEvent.eventName, event => {
event.consume(true);
view.dispatch({
effects: setTooltip.of({
type: TooltipType.COLOR,
pos: view.posAtDOM(swatch),
text: this.#text,
swatch,
color: this.#color,
}),
});
});
return swatch;
}
override ignoreEvent(): boolean {
return true;
}
}
class CurveSwatchWidget extends CodeMirror.WidgetType {
constructor(readonly curve: UI.Geometry.CubicBezier, readonly text: string) {
super();
}
override eq(other: CurveSwatchWidget): boolean {
return this.curve.asCSSText() === other.curve.asCSSText() && this.text === other.text;
}
toDOM(view: CodeMirror.EditorView): HTMLElement {
const swatch = InlineEditor.Swatches.BezierSwatch.create();
swatch.setBezierText(this.text);
UI.Tooltip.Tooltip.install(swatch.iconElement(), i18nString(UIStrings.openCubicBezierEditor));
swatch.iconElement().addEventListener('click', (event: MouseEvent) => {
event.consume(true);
view.dispatch({
effects: setTooltip.of({
type: TooltipType.CURVE,
pos: view.posAtDOM(swatch),
text: this.text,
swatch,
curve: this.curve,
}),
});
}, false);
swatch.hideText(true);
return swatch;
}
override ignoreEvent(): boolean {
return true;
}
}
const enum TooltipType {
COLOR = 0,
CURVE = 1,
}
type ActiveTooltip = {
type: TooltipType.COLOR,
pos: number,
text: string,
color: Common.Color.Color,
swatch: InlineEditor.ColorSwatch.ColorSwatch,
}|{
type: TooltipType.CURVE,
pos: number,
text: string,
curve: UI.Geometry.CubicBezier,
swatch: InlineEditor.Swatches.BezierSwatch,
};
function createCSSTooltip(active: ActiveTooltip): CodeMirror.Tooltip {
return {
pos: active.pos,
arrow: false,
create(view): CodeMirror.TooltipView {
let text = active.text;
let widget: UI.Widget.VBox, addListener: (handler: (event: {data: string}) => void) => void;
if (active.type === TooltipType.COLOR) {
const spectrum = new ColorPicker.Spectrum.Spectrum();
addListener = handler => {
spectrum.addEventListener(ColorPicker.Spectrum.Events.COLOR_CHANGED, handler);
};
spectrum.addEventListener(ColorPicker.Spectrum.Events.SIZE_CHANGED, () => view.requestMeasure());
spectrum.setColor(active.color);
widget = spectrum;
} else {
const spectrum = new InlineEditor.BezierEditor.BezierEditor(active.curve);
widget = spectrum;
addListener = handler => {
spectrum.addEventListener(InlineEditor.BezierEditor.Events.BEZIER_CHANGED, handler);
};
}
const dom = document.createElement('div');
dom.className = 'cm-tooltip-swatchEdit';
widget.markAsRoot();
widget.show(dom);
widget.showWidget();
widget.element.addEventListener('keydown', event => {
if (event.key === 'Escape') {
event.consume();
view.dispatch({
effects: setTooltip.of(null),
changes: text === active.text ? undefined :
{from: active.pos, to: active.pos + text.length, insert: active.text},
});
widget.hideWidget();
view.focus();
}
});
widget.element.addEventListener('focusout', event => {
if (event.relatedTarget && !widget.element.contains(event.relatedTarget as Node)) {
view.dispatch({effects: setTooltip.of(null)});
widget.hideWidget();
}
}, false);
widget.element.addEventListener('mousedown', event => event.consume());
return {
dom,
resize: false,
offset: {x: -8, y: 0},
mount: () => {
widget.focus();
widget.wasShown();
addListener((event: {data: string}) => {
view.dispatch({
changes: {from: active.pos, to: active.pos + text.length, insert: event.data},
annotations: isSwatchEdit.of(true),
});
text = event.data;
});
},
};
},
};
}
const setTooltip = CodeMirror.StateEffect.define<ActiveTooltip|null>();
const isSwatchEdit = CodeMirror.Annotation.define<boolean>();
const cssTooltipState = CodeMirror.StateField.define<ActiveTooltip|null>({
create() {
return null;
},
update(value: ActiveTooltip|null, tr: CodeMirror.Transaction): ActiveTooltip |
null {
if ((tr.docChanged || tr.selection) && !tr.annotation(isSwatchEdit)) {
value = null;
}
for (const effect of tr.effects) {
if (effect.is(setTooltip)) {
value = effect.value;
}
}
return value;
},
provide: field => CodeMirror.showTooltip.from(field, active => active && createCSSTooltip(active)),
});
function computeSwatchDeco(state: CodeMirror.EditorState, from: number, to: number): CodeMirror.DecorationSet {
const builder = new CodeMirror.RangeSetBuilder<CodeMirror.Decoration>();
findColorsAndCurves(
state, from, to,
(pos, parsedColor, colorText) => {
builder.add(
pos, pos, CodeMirror.Decoration.widget({widget: new ColorSwatchWidget(parsedColor, colorText, pos)}));
},
(pos, curve, text) => {
builder.add(pos, pos, CodeMirror.Decoration.widget({widget: new CurveSwatchWidget(curve, text)}));
});
return builder.finish();
}
const cssSwatchPlugin = CodeMirror.ViewPlugin.fromClass(class {
decorations:
CodeMirror.DecorationSet;
constructor(view: CodeMirror.EditorView) {
this.decorations = computeSwatchDeco(view.state, view.viewport.from, view.viewport.to);
}
update(update: CodeMirror.ViewUpdate): void {
if (update.viewportChanged || update.docChanged) {
this.decorations = computeSwatchDeco(update.state, update.view.viewport.from, update.view.viewport.to);
}
}
}, {
decorations: v => v.decorations,
});
function cssSwatches(): CodeMirror.Extension {
return [cssSwatchPlugin, cssTooltipState, theme];
}
function getNumberAt(node: CodeMirror.SyntaxNode): {from: number, to: number}|null {
if (node.name === 'Unit') {
node = node.parent as CodeMirror.SyntaxNode;
}
if (node.name === 'NumberLiteral') {
const lastChild = node.lastChild;
return {from: node.from, to: lastChild && lastChild.name === 'Unit' ? lastChild.from : node.to};
}
return null;
}
function modifyUnit(view: CodeMirror.EditorView, by: number): boolean {
const {head} = view.state.selection.main;
const context = CodeMirror.syntaxTree(view.state).resolveInner(head, -1);
const numberRange = getNumberAt(context) || getNumberAt(context.resolve(head, 1));
if (!numberRange) {
return false;
}
const currentNumber = Number(view.state.sliceDoc(numberRange.from, numberRange.to));
if (isNaN(currentNumber)) {
return false;
}
view.dispatch({
changes: {from: numberRange.from, to: numberRange.to, insert: String(currentNumber + by)},
scrollIntoView: true,
userEvent: 'insert.modifyUnit',
});
return true;
}
export function cssBindings(): CodeMirror.Extension {
// This is an awkward way to pass the argument given to the editor
// event handler through the ShortcutRegistry calling convention.
let currentView: CodeMirror.EditorView = null as unknown as CodeMirror.EditorView;
const listener = UI.ShortcutRegistry.ShortcutRegistry.instance().getShortcutListener({
'sources.increment-css': () => Promise.resolve(modifyUnit(currentView, 1)),
'sources.increment-css-by-ten': () => Promise.resolve(modifyUnit(currentView, 10)),
'sources.decrement-css': () => Promise.resolve(modifyUnit(currentView, -1)),
'sources.decrement-css-by-ten': () => Promise.resolve(modifyUnit(currentView, -10)),
});
return CodeMirror.EditorView.domEventHandlers({
keydown: (event, view) => {
const prevView = currentView;
currentView = view;
listener(event);
currentView = prevView;
return event.defaultPrevented;
},
});
}
export class CSSPlugin extends Plugin implements SDK.TargetManager.SDKModelObserver<SDK.CSSModel.CSSModel> {
#cssModel?: SDK.CSSModel.CSSModel;
constructor(uiSourceCode: Workspace.UISourceCode.UISourceCode, _transformer?: SourceFrame.SourceFrame.Transformer) {
super(uiSourceCode, _transformer);
SDK.TargetManager.TargetManager.instance().observeModels(SDK.CSSModel.CSSModel, this);
}
static override accepts(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
return uiSourceCode.contentType().hasStyleSheets();
}
modelAdded(cssModel: SDK.CSSModel.CSSModel): void {
if (cssModel.target() !== SDK.TargetManager.TargetManager.instance().primaryPageTarget()) {
return;
}
this.#cssModel = cssModel;
}
modelRemoved(cssModel: SDK.CSSModel.CSSModel): void {
if (this.#cssModel === cssModel) {
this.#cssModel = undefined;
}
}
override editorExtension(): CodeMirror.Extension {
return [cssBindings(), this.#cssCompletion(), cssSwatches()];
}
#cssCompletion(): CodeMirror.Extension {
const {cssCompletionSource} = CodeMirror.css;
// CodeMirror binds the function below to the state object.
// Therefore, we can't access `this` and retrieve the following properties.
// Instead, retrieve them up front to bind them to the correct closure.
const uiSourceCode = this.uiSourceCode;
const cssModel = this.#cssModel;
return CodeMirror.autocompletion({
override:
[async(cx: CodeMirror.CompletionContext):
Promise<CodeMirror.CompletionResult|null> => {
return await ((await specificCssCompletion(cx, uiSourceCode, cssModel)) || cssCompletionSource(cx));
}],
});
}
override populateTextAreaContextMenu(contextMenu: UI.ContextMenu.ContextMenu): void {
function addSourceMapURL(cssModel: SDK.CSSModel.CSSModel, sourceUrl: Platform.DevToolsPath.UrlString): void {
const dialog = AddDebugInfoURLDialog.createAddSourceMapURLDialog(sourceMapUrl => {
Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance().modelToInfo.get(cssModel)?.addSourceMap(
sourceUrl, sourceMapUrl);
});
dialog.show();
}
const cssModel = this.#cssModel;
const url = this.uiSourceCode.url();
if (this.uiSourceCode.project().type() === Workspace.Workspace.projectTypes.Network && cssModel &&
!Bindings.IgnoreListManager.IgnoreListManager.instance().isUserIgnoreListedURL(url)) {
const addSourceMapURLLabel = i18nString(UIStrings.addSourceMap);
contextMenu.debugSection().appendItem(
addSourceMapURLLabel, () => addSourceMapURL(cssModel, url), {jslogContext: 'add-source-map'});
}
}
}
const theme = CodeMirror.EditorView.baseTheme({
'.cm-tooltip.cm-tooltip-swatchEdit': {
'box-shadow': 'var(--sys-elevation-level2)',
'background-color': 'var(--sys-color-base-container-elevated)',
'border-radius': 'var(--sys-shape-corner-extra-small)',
},
});