| // Copyright (C) 2023 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import {indentWithTab} from '@codemirror/commands'; |
| import {EditorState, Transaction} from '@codemirror/state'; |
| import {oneDark} from '@codemirror/theme-one-dark'; |
| import {keymap} from '@codemirror/view'; |
| import {basicSetup, EditorView} from 'codemirror'; |
| import {javascript} from '@codemirror/lang-javascript'; |
| import m from 'mithril'; |
| import {removeFalsyValues} from '../base/array_utils'; |
| import {assertUnreachable} from '../base/logging'; |
| import {perfettoSql} from '../base/perfetto_sql_lang/language'; |
| import {HTMLAttrs} from './common'; |
| import {classNames} from '../base/classnames'; |
| |
| type EditorLanguage = 'perfetto-sql' | 'javascript'; |
| |
| export interface EditorAttrs extends HTMLAttrs { |
| // Content of the editor. If defined, the editor operates in controlled mode, |
| // otherwise it operates in uncontrolled mode. |
| // - In controlled mode, the content of the editor is managed by the caller |
| // and should be used in conjunction with onUpdate to manage the state of |
| // the editor. |
| // - In uncontrolled mode, the content of the editor is managed internally by |
| // the editor itself. |
| readonly text?: string; |
| |
| // Which language use for syntax highlighting et al. Defaults to none. |
| readonly language?: EditorLanguage; |
| |
| // Whether the editor should be focused on creation. |
| readonly autofocus?: boolean; |
| |
| // Whether the editor should fill the height of its container. |
| readonly fillHeight?: boolean; |
| |
| // Whether the editor content is readonly. |
| readonly readonly?: boolean; |
| |
| // Callback for the Ctrl/Cmd + Enter key binding. |
| onExecute?: (text: string) => void; |
| |
| // Callback for the Ctrl/Cmd + S key binding. |
| onSave?: () => void; |
| |
| // Callback for every change to the editor's content. |
| onUpdate?: (text: string) => void; |
| } |
| |
| export class Editor implements m.ClassComponent<EditorAttrs> { |
| private editorView?: EditorView; |
| private latestText?: string; |
| |
| focus() { |
| this.editorView?.focus(); |
| } |
| |
| oncreate({dom, attrs}: m.CVnodeDOM<EditorAttrs>) { |
| const keymaps = [indentWithTab]; |
| const onExecute = attrs.onExecute; |
| const onSave = attrs.onSave; |
| const onUpdate = attrs.onUpdate; |
| |
| if (onExecute) { |
| keymaps.push({ |
| key: 'Mod-Enter', |
| run: (view: EditorView) => { |
| const state = view.state; |
| const selection = state.selection; |
| let text = state.doc.toString(); |
| if (!selection.main.empty) { |
| let selectedText = ''; |
| |
| for (const r of selection.ranges) { |
| selectedText += text.slice(r.from, r.to); |
| } |
| |
| text = selectedText; |
| } |
| onExecute(text); |
| m.redraw(); |
| return true; |
| }, |
| }); |
| } |
| |
| if (onSave) { |
| keymaps.push({ |
| key: 'Mod-s', |
| run: (_view: EditorView) => { |
| onSave(); |
| m.redraw(); |
| return true; |
| }, |
| }); |
| } |
| |
| const dispatch = (tr: Transaction, view: EditorView) => { |
| // Maybe don't bother doing this if onUpdate is not defined...? |
| view.update([tr]); |
| const text = view.state.doc.toString(); |
| // Cache the latest text so that we don't immediately have to overwrite |
| // this every time we make an edit to the doc if the caller just passes in |
| // the exact same string again on the next redraw. |
| this.latestText = text; |
| |
| if (onUpdate) { |
| onUpdate(text); |
| m.redraw(); |
| } |
| }; |
| |
| const lang = (() => { |
| switch (attrs.language) { |
| case undefined: |
| return undefined; |
| case 'perfetto-sql': |
| return perfettoSql(); |
| case 'javascript': |
| return javascript(); |
| default: |
| assertUnreachable(attrs.language); |
| } |
| })(); |
| |
| const readonly = (() => { |
| if (attrs.readonly) { |
| return [ |
| EditorState.readOnly.of(true), |
| EditorView.editable.of(false), |
| // Enable keyboard commands by allowing focus. |
| EditorView.contentAttributes.of({tabindex: '0'}), |
| ]; |
| } |
| return []; |
| })(); |
| |
| this.editorView = new EditorView({ |
| doc: attrs.text, |
| extensions: removeFalsyValues([ |
| keymap.of(keymaps), |
| ...readonly, |
| oneDark, |
| basicSetup, |
| lang, |
| ]), |
| parent: dom, |
| dispatch, |
| }); |
| |
| if (attrs.autofocus) { |
| this.focus(); |
| } |
| } |
| |
| onupdate({attrs}: m.CVnodeDOM<EditorAttrs>): void { |
| // Uncontrolled mode: no need to do anything. |
| if (attrs.text === undefined) { |
| return; |
| } |
| |
| const editorView = this.editorView; |
| if (editorView && attrs.text !== this.latestText) { |
| const state = editorView.state; |
| editorView.dispatch( |
| state.update({ |
| changes: {from: 0, to: state.doc.length, insert: attrs.text}, |
| }), |
| ); |
| this.latestText = attrs.text; |
| } |
| } |
| |
| onremove(): void { |
| if (this.editorView) { |
| this.editorView.destroy(); |
| this.editorView = undefined; |
| } |
| } |
| |
| view({attrs}: m.Vnode<EditorAttrs>): m.Children { |
| const className = classNames( |
| attrs.className, |
| attrs.fillHeight && 'pf-editor--fill-height', |
| ); |
| return m('.pf-editor', { |
| className: className, |
| ref: attrs.ref, |
| }); |
| } |
| } |