| // 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/enforce-custom-element-definitions-location */ |
| |
| import * as Common from '../../../core/common/common.js'; |
| import * as CodeMirror from '../../../third_party/codemirror.next/codemirror.next.js'; |
| import * as UI from '../../legacy/legacy.js'; |
| import * as ThemeSupport from '../../legacy/theme_support/theme_support.js'; |
| import * as CodeHighlighter from '../code_highlighter/code_highlighter.js'; |
| |
| import {baseConfiguration, dummyDarkTheme, dynamicSetting, DynamicSetting, themeSelection} from './config.js'; |
| import {toLineColumn, toOffset} from './position.js'; |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'devtools-text-editor': TextEditor; |
| } |
| } |
| |
| export class TextEditor extends HTMLElement { |
| readonly #shadow = this.attachShadow({mode: 'open'}); |
| #activeEditor: CodeMirror.EditorView|undefined = undefined; |
| #dynamicSettings: ReadonlyArray<DynamicSetting<unknown>> = DynamicSetting.none; |
| #activeSettingListeners: Array<[Common.Settings.Setting<unknown>, (event: {data: unknown}) => void]> = []; |
| #pendingState: CodeMirror.EditorState|undefined; |
| #lastScrollSnapshot: CodeMirror.StateEffect<unknown>|undefined; |
| #resizeTimeout = -1; |
| #resizeListener = (): void => { |
| if (this.#resizeTimeout < 0) { |
| this.#resizeTimeout = window.setTimeout(() => { |
| this.#resizeTimeout = -1; |
| if (this.#activeEditor) { |
| CodeMirror.repositionTooltips(this.#activeEditor); |
| } |
| }, 50); |
| } |
| }; |
| #devtoolsResizeObserver = new ResizeObserver(this.#resizeListener); |
| |
| constructor(pendingState?: CodeMirror.EditorState) { |
| super(); |
| this.#pendingState = pendingState; |
| this.#shadow.createChild('style').textContent = CodeHighlighter.codeHighlighterStyles; |
| } |
| |
| #createEditor(): CodeMirror.EditorView { |
| this.#activeEditor = new CodeMirror.EditorView({ |
| state: this.state, |
| parent: this.#shadow, |
| root: this.#shadow, |
| dispatch: (tr: CodeMirror.Transaction, view: CodeMirror.EditorView) => { |
| view.update([tr]); |
| this.#maybeDispatchInput(tr); |
| if (tr.reconfigured) { |
| this.#ensureSettingListeners(); |
| } |
| }, |
| scrollTo: this.#lastScrollSnapshot, |
| }); |
| |
| this.#activeEditor.scrollDOM.addEventListener('scroll', () => { |
| if (!this.#activeEditor) { |
| return; |
| } |
| |
| this.#lastScrollSnapshot = this.#activeEditor.scrollSnapshot(); |
| this.scrollEventHandledToSaveScrollPositionForTest(); |
| }); |
| this.#activeEditor.scrollDOM.addEventListener('scrollend', () => { |
| this.dispatchEvent(new Event('scrollend')); |
| }); |
| |
| this.#ensureSettingListeners(); |
| this.#startObservingResize(); |
| ThemeSupport.ThemeSupport.instance().addEventListener(ThemeSupport.ThemeChangeEvent.eventName, () => { |
| const currentTheme = ThemeSupport.ThemeSupport.instance().themeName() === 'dark' ? dummyDarkTheme : []; |
| this.editor.dispatch({ |
| effects: themeSelection.reconfigure(currentTheme), |
| }); |
| }); |
| return this.#activeEditor; |
| } |
| |
| get editor(): CodeMirror.EditorView { |
| return this.#activeEditor || this.#createEditor(); |
| } |
| |
| dispatch(spec: CodeMirror.TransactionSpec): void { |
| return this.editor.dispatch(spec); |
| } |
| |
| get state(): CodeMirror.EditorState { |
| if (this.#activeEditor) { |
| return this.#activeEditor.state; |
| } |
| if (!this.#pendingState) { |
| this.#pendingState = CodeMirror.EditorState.create({extensions: baseConfiguration('')}); |
| } |
| return this.#pendingState; |
| } |
| |
| set state(state: CodeMirror.EditorState) { |
| if (this.#pendingState === state) { |
| return; |
| } |
| |
| this.#pendingState = state; |
| |
| if (this.#activeEditor) { |
| this.#activeEditor.setState(state); |
| this.#ensureSettingListeners(); |
| } |
| } |
| |
| scrollEventHandledToSaveScrollPositionForTest(): void { |
| } |
| |
| connectedCallback(): void { |
| if (!this.#activeEditor) { |
| this.#createEditor(); |
| } else { |
| this.#activeEditor.dispatch({effects: this.#lastScrollSnapshot}); |
| } |
| } |
| |
| disconnectedCallback(): void { |
| if (this.#activeEditor) { |
| this.#activeEditor.dispatch({effects: clearHighlightedLine.of(null)}); |
| this.#pendingState = this.#activeEditor.state; |
| this.#devtoolsResizeObserver.disconnect(); |
| window.removeEventListener('resize', this.#resizeListener); |
| this.#activeEditor.destroy(); |
| this.#activeEditor = undefined; |
| this.#ensureSettingListeners(); |
| } |
| } |
| |
| override focus(): void { |
| if (this.#activeEditor) { |
| this.#activeEditor.focus(); |
| } |
| } |
| |
| #ensureSettingListeners(): void { |
| const dynamicSettings = this.#activeEditor ? |
| this.#activeEditor.state.facet<ReadonlyArray<DynamicSetting<unknown>>>(dynamicSetting) : |
| DynamicSetting.none; |
| if (dynamicSettings === this.#dynamicSettings) { |
| return; |
| } |
| this.#dynamicSettings = dynamicSettings; |
| |
| for (const [setting, listener] of this.#activeSettingListeners) { |
| setting.removeChangeListener(listener); |
| } |
| this.#activeSettingListeners = []; |
| |
| for (const dynamicSetting of dynamicSettings) { |
| const handler = ({data}: {data: unknown}): void => { |
| const change = dynamicSetting.sync(this.state, data); |
| if (change && this.#activeEditor) { |
| this.#activeEditor.dispatch({effects: change}); |
| } |
| }; |
| const setting = Common.Settings.Settings.instance().moduleSetting(dynamicSetting.settingName); |
| setting.addChangeListener(handler); |
| this.#activeSettingListeners.push([setting, handler]); |
| } |
| } |
| |
| #startObservingResize(): void { |
| const devtoolsElement = UI.UIUtils.getDevToolsBoundingElement(); |
| if (devtoolsElement) { |
| this.#devtoolsResizeObserver.observe(devtoolsElement); |
| } |
| window.addEventListener('resize', this.#resizeListener); |
| } |
| |
| #maybeDispatchInput(transaction: CodeMirror.Transaction): void { |
| const userEvent = transaction.annotation(CodeMirror.Transaction.userEvent); |
| const inputType = userEvent ? CODE_MIRROR_USER_EVENT_TO_INPUT_EVENT_TYPE.get(userEvent) : null; |
| if (inputType) { |
| this.dispatchEvent(new InputEvent('input', {inputType})); |
| } |
| } |
| |
| revealPosition(selection: CodeMirror.EditorSelection, highlight = true): void { |
| const view = this.#activeEditor; |
| if (!view) { |
| return; |
| } |
| |
| const line = view.state.doc.lineAt(selection.main.head); |
| const effects: Array<CodeMirror.StateEffect<unknown>> = []; |
| if (highlight) { |
| // Lazily register the highlight line state. |
| if (!view.state.field(highlightedLineState, false)) { |
| view.dispatch({effects: CodeMirror.StateEffect.appendConfig.of(highlightedLineState)}); |
| } else { |
| // Always clear the previous highlight line first. This cannot be done |
| // in combination with the other effects, as it wouldn't restart the CSS |
| // highlight line animation. |
| view.dispatch({effects: clearHighlightedLine.of(null)}); |
| } |
| |
| // Here we finally start the actual highlight line effects. |
| effects.push(setHighlightedLine.of(line.from)); |
| } |
| |
| const editorRect = view.scrollDOM.getBoundingClientRect(); |
| const targetPos = view.coordsAtPos(selection.main.head); |
| if (!selection.main.empty) { |
| // If the caller provided an actual range, we use the default 'nearest' on both axis. |
| // Otherwise we 'center' on an axis to provide more context around the single point. |
| effects.push(CodeMirror.EditorView.scrollIntoView(selection.main)); |
| } else if (!targetPos || targetPos.top < editorRect.top || targetPos.bottom > editorRect.bottom) { |
| effects.push(CodeMirror.EditorView.scrollIntoView(selection.main, {y: 'center'})); |
| } else if (targetPos.left < editorRect.left || targetPos.right > editorRect.right) { |
| effects.push(CodeMirror.EditorView.scrollIntoView(selection.main, {x: 'center'})); |
| } |
| |
| view.dispatch({ |
| selection, |
| effects, |
| userEvent: 'select.reveal', |
| }); |
| } |
| |
| createSelection(head: {lineNumber: number, columnNumber: number}, anchor?: { |
| lineNumber: number, |
| columnNumber: number, |
| }): CodeMirror.EditorSelection { |
| const {doc} = this.state; |
| const headPos = toOffset(doc, head); |
| return CodeMirror.EditorSelection.single(anchor ? toOffset(doc, anchor) : headPos, headPos); |
| } |
| |
| toLineColumn(pos: number): {lineNumber: number, columnNumber: number} { |
| return toLineColumn(this.state.doc, pos); |
| } |
| |
| toOffset(pos: {lineNumber: number, columnNumber: number}): number { |
| return toOffset(this.state.doc, pos); |
| } |
| } |
| |
| customElements.define('devtools-text-editor', TextEditor); |
| |
| // Line highlighting |
| |
| const clearHighlightedLine = CodeMirror.StateEffect.define<null>(); |
| const setHighlightedLine = CodeMirror.StateEffect.define<number>(); |
| |
| const highlightedLineState = CodeMirror.StateField.define<CodeMirror.DecorationSet>({ |
| create: () => CodeMirror.Decoration.none, |
| update(value, tr) { |
| if (!tr.changes.empty && value.size) { |
| value = value.map(tr.changes); |
| } |
| for (const effect of tr.effects) { |
| if (effect.is(clearHighlightedLine)) { |
| value = CodeMirror.Decoration.none; |
| } else if (effect.is(setHighlightedLine)) { |
| value = CodeMirror.Decoration.set([ |
| CodeMirror.Decoration.line({attributes: {class: 'cm-highlightedLine'}}).range(effect.value), |
| ]); |
| } |
| } |
| return value; |
| }, |
| provide: field => CodeMirror.EditorView.decorations.from(field, value => value), |
| }); |
| |
| const CODE_MIRROR_USER_EVENT_TO_INPUT_EVENT_TYPE = new Map([ |
| ['input.type', 'insertText'], |
| ['input.type.compose', 'insertCompositionText'], |
| ['input.paste', 'insertFromPaste'], |
| ['input.drop', 'insertFromDrop'], |
| ['input.complete', 'insertReplacementText'], |
| ['delete.selection', 'deleteContent'], |
| ['delete.forward', 'deleteContentForward'], |
| ['delete.backward', 'deleteContentBackward'], |
| ['delete.cut', 'deleteByCut'], |
| ['move.drop', 'deleteByDrag'], |
| ['undo', 'historyUndo'], |
| ['redo', 'historyRedo'], |
| ]); |