| // Copyright 2021 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| /* eslint-disable @devtools/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 Geometry from '../../models/geometry/geometry.js'; |
| import * as Workspace from '../../models/workspace/workspace.js'; |
| import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js'; |
| import {createIcon, type Icon} from '../../ui/kit/kit.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 * as VisualLogging from '../../ui/visual_logging/visual_logging.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 doNotCompleteIn = new Set(['ColorLiteral', 'NumberLiteral', 'StringLiteral', 'Comment', 'Important']); |
| |
| function findPropertyAt(node: CodeMirror.SyntaxNode, pos: number): CodeMirror.SyntaxNode|null { |
| if (doNotCompleteIn.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.DOM.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: 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 = 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.color as Common.Color.Color; |
| }); |
| swatch.addEventListener(InlineEditor.ColorSwatch.ColorFormatChangedEvent.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.color 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: 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 container = document.createElement('span'); |
| const bezierText = container.createChild('span'); |
| const icon = createIcon('bezier-curve-filled', 'bezier-swatch-icon'); |
| icon.setAttribute('jslog', `${VisualLogging.showStyleEditor('bezier')}`); |
| bezierText.append(this.text); |
| UI.Tooltip.Tooltip.install(icon, i18nString(UIStrings.openCubicBezierEditor)); |
| icon.addEventListener('click', (event: MouseEvent) => { |
| event.consume(true); |
| view.dispatch({ |
| effects: setTooltip.of({ |
| type: TooltipType.CURVE, |
| pos: view.posAtDOM(icon), |
| text: this.text, |
| swatch: icon, |
| curve: this.curve, |
| }), |
| }); |
| }, false); |
| return icon; |
| } |
| |
| 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: Geometry.CubicBezier, |
| swatch: Icon, |
| }; |
| |
| 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?.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 && |
| !Workspace.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)', |
| }, |
| }); |