1// Copyright (C) 2020 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 15 16import * as m from 'mithril'; 17 18import {Actions} from '../common/actions'; 19 20import {globals} from './globals'; 21import {createPage} from './pages'; 22import {QueryTable} from './query_table'; 23 24const INPUT_PLACEHOLDER = 'Enter query and press Cmd/Ctrl + Enter'; 25const INPUT_MIN_LINES = 2; 26const INPUT_MAX_LINES = 10; 27const INPUT_LINE_HEIGHT_EM = 1.2; 28const TAB_SPACES = 2; 29const TAB_SPACES_STRING = ' '.repeat(TAB_SPACES); 30const QUERY_ID = 'analyze-page-query'; 31 32class QueryInput implements m.ClassComponent { 33 // How many lines to display if the user hasn't resized the input box. 34 displayLines = INPUT_MIN_LINES; 35 36 static onKeyDown(e: Event) { 37 const event = e as KeyboardEvent; 38 const target = e.target as HTMLTextAreaElement; 39 const {selectionStart, selectionEnd} = target; 40 41 if (event.code === 'Enter' && (event.metaKey || event.ctrlKey)) { 42 event.preventDefault(); 43 let query = target.value; 44 if (selectionEnd > selectionStart) { 45 query = query.substring(selectionStart, selectionEnd); 46 } 47 if (!query) return; 48 globals.dispatch( 49 Actions.executeQuery({engineId: '0', queryId: QUERY_ID, query})); 50 } 51 52 if (event.code === 'Tab') { 53 // Handle tabs to insert spaces. 54 event.preventDefault(); 55 const lastLineBreak = target.value.lastIndexOf('\n', selectionEnd); 56 57 if (selectionStart === selectionEnd || lastLineBreak < selectionStart) { 58 // Selection does not contain line breaks, therefore is on a single 59 // line. In this case, replace the selection with spaces. Replacement is 60 // done via document.execCommand as opposed to direct manipulation of 61 // element's value attribute because modifying latter programmatically 62 // drops the edit history which breaks undo/redo functionality. 63 document.execCommand('insertText', false, TAB_SPACES_STRING); 64 } else { 65 this.handleMultilineTab(target, event); 66 } 67 } 68 } 69 70 // Handle Tab press when the current selection is multiline: find all the 71 // lines intersecting with the selection, and either indent or dedent (if 72 // Shift key is held) them. 73 private static handleMultilineTab( 74 target: HTMLTextAreaElement, event: KeyboardEvent) { 75 const {selectionStart, selectionEnd} = target; 76 const firstLineBreak = target.value.lastIndexOf('\n', selectionStart - 1); 77 78 // If no line break is found (selection begins at the first line), 79 // replacementStart would have the correct value of 0. 80 const replacementStart = firstLineBreak + 1; 81 const replacement = target.value.substring(replacementStart, selectionEnd) 82 .split('\n') 83 .map((line) => { 84 if (event.shiftKey) { 85 // When Shift is held, remove whitespace at the 86 // beginning 87 return this.dedent(line); 88 } else { 89 return TAB_SPACES_STRING + line; 90 } 91 }) 92 .join('\n'); 93 // Select the range to be replaced. 94 target.setSelectionRange(replacementStart, selectionEnd); 95 document.execCommand('insertText', false, replacement); 96 // Restore the selection to match the previous selection, allowing to chain 97 // indent operations by just pressing Tab several times. 98 target.setSelectionRange( 99 replacementStart, replacementStart + replacement.length); 100 } 101 102 // Chop off up to TAB_SPACES leading spaces from a string. 103 private static dedent(line: string): string { 104 let i = 0; 105 while (i < line.length && i < TAB_SPACES && line[i] === ' ') { 106 i++; 107 } 108 return line.substring(i); 109 } 110 111 onInput(textareaValue: string) { 112 const textareaLines = textareaValue.split('\n').length; 113 const clampedNumLines = 114 Math.min(Math.max(textareaLines, INPUT_MIN_LINES), INPUT_MAX_LINES); 115 this.displayLines = clampedNumLines; 116 globals.dispatch(Actions.setAnalyzePageQuery({query: textareaValue})); 117 globals.rafScheduler.scheduleFullRedraw(); 118 } 119 120 // This method exists because unfortunatley setting custom properties on an 121 // element's inline style attribue doesn't seem to work in mithril, even 122 // though the docs claim so. 123 setHeightBeforeResize(node: HTMLElement) { 124 // +2em for some extra breathing space to account for padding. 125 const heightEm = this.displayLines * INPUT_LINE_HEIGHT_EM + 2; 126 // We set a height based on the number of lines that we want to display by 127 // default. If the user resizes the textbox using the resize handle in the 128 // bottom-right corner, this height is overridden. 129 node.style.setProperty('--height-before-resize', `${heightEm}em`); 130 // TODO(dproy): The resized height is lost if user navigates away from the 131 // page and comes back. 132 } 133 134 oncreate(vnode: m.VnodeDOM) { 135 // This makes sure query persists if user navigates to other pages and comes 136 // back to analyze page. 137 const existingQuery = globals.state.analyzePageQuery; 138 const textarea = vnode.dom as HTMLTextAreaElement; 139 if (existingQuery) { 140 textarea.value = existingQuery; 141 this.onInput(existingQuery); 142 } 143 144 this.setHeightBeforeResize(textarea); 145 } 146 147 onupdate(vnode: m.VnodeDOM) { 148 this.setHeightBeforeResize(vnode.dom as HTMLElement); 149 } 150 151 view() { 152 return m('textarea.query-input', { 153 placeholder: INPUT_PLACEHOLDER, 154 onkeydown: (e: Event) => QueryInput.onKeyDown(e), 155 oninput: (e: Event) => 156 this.onInput((e.target as HTMLTextAreaElement).value), 157 }); 158 } 159} 160 161 162export const AnalyzePage = createPage({ 163 view() { 164 return m( 165 '.analyze-page', 166 m(QueryInput), 167 m(QueryTable, {queryId: QUERY_ID}), 168 ); 169 } 170}); 171