1// Copyright (C) 2023 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import {indentWithTab} from '@codemirror/commands'; 16import {Transaction} from '@codemirror/state'; 17import {oneDarkTheme} from '@codemirror/theme-one-dark'; 18import {keymap} from '@codemirror/view'; 19import {basicSetup, EditorView} from 'codemirror'; 20import m from 'mithril'; 21import {assertExists} from '../base/logging'; 22import {DragGestureHandler} from '../base/drag_gesture_handler'; 23import {DisposableStack} from '../base/disposable_stack'; 24import {scheduleFullRedraw} from './raf'; 25 26export interface EditorAttrs { 27 // Initial state for the editor. 28 initialText?: string; 29 30 // Changing generation is used to force resetting of the editor state 31 // to the current value of initialText. 32 generation?: number; 33 34 // Callback for the Ctrl/Cmd + Enter key binding. 35 onExecute?: (text: string) => void; 36 37 // Callback for every change to the text. 38 onUpdate?: (text: string) => void; 39} 40 41export class Editor implements m.ClassComponent<EditorAttrs> { 42 private editorView?: EditorView; 43 private generation?: number; 44 private trash = new DisposableStack(); 45 46 oncreate({dom, attrs}: m.CVnodeDOM<EditorAttrs>) { 47 const keymaps = [indentWithTab]; 48 const onExecute = attrs.onExecute; 49 const onUpdate = attrs.onUpdate; 50 51 if (onExecute) { 52 keymaps.push({ 53 key: 'Mod-Enter', 54 run: (view: EditorView) => { 55 const state = view.state; 56 const selection = state.selection; 57 let text = state.doc.toString(); 58 if (!selection.main.empty) { 59 let selectedText = ''; 60 61 for (const r of selection.ranges) { 62 selectedText += text.slice(r.from, r.to); 63 } 64 65 text = selectedText; 66 } 67 onExecute(text); 68 scheduleFullRedraw('force'); 69 return true; 70 }, 71 }); 72 } 73 74 let dispatch; 75 if (onUpdate) { 76 dispatch = (tr: Transaction, view: EditorView) => { 77 view.update([tr]); 78 const text = view.state.doc.toString(); 79 onUpdate(text); 80 scheduleFullRedraw('force'); 81 }; 82 } 83 84 this.generation = attrs.generation; 85 86 this.editorView = new EditorView({ 87 doc: attrs.initialText ?? '', 88 extensions: [keymap.of(keymaps), oneDarkTheme, basicSetup], 89 parent: dom, 90 dispatch, 91 }); 92 93 // Install the drag handler for the resize bar. 94 let initialH = 0; 95 this.trash.use( 96 new DragGestureHandler( 97 assertExists(dom.querySelector('.resize-handler')) as HTMLElement, 98 /* onDrag */ 99 (_x, y) => ((dom as HTMLElement).style.height = `${initialH + y}px`), 100 /* onDragStarted */ 101 () => (initialH = dom.clientHeight), 102 /* onDragFinished */ 103 () => {}, 104 ), 105 ); 106 } 107 108 onupdate({attrs}: m.CVnodeDOM<EditorAttrs>): void { 109 const {initialText, generation} = attrs; 110 const editorView = this.editorView; 111 if (editorView && this.generation !== generation) { 112 const state = editorView.state; 113 editorView.dispatch( 114 state.update({ 115 changes: {from: 0, to: state.doc.length, insert: initialText}, 116 }), 117 ); 118 this.generation = generation; 119 } 120 } 121 122 onremove(): void { 123 if (this.editorView) { 124 this.editorView.destroy(); 125 this.editorView = undefined; 126 } 127 this.trash.dispose(); 128 } 129 130 view({}: m.Vnode<EditorAttrs, this>): void | m.Children { 131 return m('.pf-editor', m('.resize-handler')); 132 } 133} 134