// Copyright (C) 2020 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 m from 'mithril'; import {EngineProxy} from '../common/engine'; import {QueryResponse, runQuery} from '../common/queries'; import {addTab} from './bottom_tab'; import {globals} from './globals'; import {createPage} from './pages'; import {QueryHistoryComponent, queryHistoryStorage} from './query_history'; import {QueryResultTab} from './query_result_tab'; import {QueryTable} from './query_table'; const INPUT_PLACEHOLDER = 'Enter query and press Cmd/Ctrl + Enter'; const INPUT_MIN_LINES = 2; const INPUT_MAX_LINES = 10; const INPUT_LINE_HEIGHT_EM = 1.2; const TAB_SPACES = 2; const TAB_SPACES_STRING = ' '.repeat(TAB_SPACES); interface AnalyzePageState { enteredText: string; executedQuery?: string; queryResult?: QueryResponse; } const state: AnalyzePageState = { enteredText: '', }; export function runAnalyzeQuery(query: string) { state.executedQuery = query; state.queryResult = undefined; const engine = getEngine(); if (engine) { runQuery(query, engine).then((resp: QueryResponse) => { addTab({ kind: QueryResultTab.kind, tag: 'analyze_page_query', config: { query: query, title: 'Standalone Query', prefetchedResponse: resp, }, }); // We might have started to execute another query. Ignore it in that case. if (state.executedQuery !== query) { return; } state.queryResult = resp; globals.rafScheduler.scheduleFullRedraw(); }); } } function getEngine(): EngineProxy|undefined { const engineId = globals.getCurrentEngine()?.id; if (engineId === undefined) { return undefined; } const engine = globals.engines.get(engineId)?.getProxy('AnalyzePage'); return engine; } class QueryInput implements m.ClassComponent { // How many lines to display if the user hasn't resized the input box. displayLines = INPUT_MIN_LINES; static onKeyDown(e: Event) { const event = e as KeyboardEvent; const target = e.target as HTMLTextAreaElement; const {selectionStart, selectionEnd} = target; if (event.code === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); let query = target.value; if (selectionEnd > selectionStart) { query = query.substring(selectionStart, selectionEnd); } if (!query) return; queryHistoryStorage.saveQuery(query); runAnalyzeQuery(query); } if (event.code === 'Tab') { // Handle tabs to insert spaces. event.preventDefault(); const lastLineBreak = target.value.lastIndexOf('\n', selectionEnd); if (selectionStart === selectionEnd || lastLineBreak < selectionStart) { // Selection does not contain line breaks, therefore is on a single // line. In this case, replace the selection with spaces. Replacement is // done via document.execCommand as opposed to direct manipulation of // element's value attribute because modifying latter programmatically // drops the edit history which breaks undo/redo functionality. document.execCommand('insertText', false, TAB_SPACES_STRING); } else { this.handleMultilineTab(target, event); } } } // Handle Tab press when the current selection is multiline: find all the // lines intersecting with the selection, and either indent or dedent (if // Shift key is held) them. private static handleMultilineTab( target: HTMLTextAreaElement, event: KeyboardEvent) { const {selectionStart, selectionEnd} = target; const firstLineBreak = target.value.lastIndexOf('\n', selectionStart - 1); // If no line break is found (selection begins at the first line), // replacementStart would have the correct value of 0. const replacementStart = firstLineBreak + 1; const replacement = target.value.substring(replacementStart, selectionEnd) .split('\n') .map((line) => { if (event.shiftKey) { // When Shift is held, remove whitespace at the // beginning return this.dedent(line); } else { return TAB_SPACES_STRING + line; } }) .join('\n'); // Select the range to be replaced. target.setSelectionRange(replacementStart, selectionEnd); document.execCommand('insertText', false, replacement); // Restore the selection to match the previous selection, allowing to chain // indent operations by just pressing Tab several times. target.setSelectionRange( replacementStart, replacementStart + replacement.length); } // Chop off up to TAB_SPACES leading spaces from a string. private static dedent(line: string): string { let i = 0; while (i < line.length && i < TAB_SPACES && line[i] === ' ') { i++; } return line.substring(i); } onInput(textareaValue: string) { const textareaLines = textareaValue.split('\n').length; const clampedNumLines = Math.min(Math.max(textareaLines, INPUT_MIN_LINES), INPUT_MAX_LINES); this.displayLines = clampedNumLines; state.enteredText = textareaValue; globals.rafScheduler.scheduleFullRedraw(); } // This method exists because unfortunatley setting custom properties on an // element's inline style attribue doesn't seem to work in mithril, even // though the docs claim so. setHeightBeforeResize(node: HTMLElement) { // +2em for some extra breathing space to account for padding. const heightEm = this.displayLines * INPUT_LINE_HEIGHT_EM + 2; // We set a height based on the number of lines that we want to display by // default. If the user resizes the textbox using the resize handle in the // bottom-right corner, this height is overridden. node.style.setProperty('--height-before-resize', `${heightEm}em`); // TODO(dproy): The resized height is lost if user navigates away from the // page and comes back. } oncreate(vnode: m.VnodeDOM) { // This makes sure query persists if user navigates to other pages and comes // back to analyze page. const existingQuery = state.enteredText; const textarea = vnode.dom as HTMLTextAreaElement; if (existingQuery) { textarea.value = existingQuery; this.onInput(existingQuery); } this.setHeightBeforeResize(textarea); } onupdate(vnode: m.VnodeDOM) { this.setHeightBeforeResize(vnode.dom as HTMLElement); } view() { return m('textarea.query-input', { placeholder: INPUT_PLACEHOLDER, onkeydown: (e: Event) => QueryInput.onKeyDown(e), oninput: (e: Event) => this.onInput((e.target as HTMLTextAreaElement).value), }); } } export const AnalyzePage = createPage({ view() { return m( '.analyze-page', m(QueryInput), state.executedQuery === undefined ? null : m(QueryTable, { query: state.executedQuery, resp: state.queryResult, onClose: () => { state.executedQuery = undefined; state.queryResult = undefined; globals.rafScheduler.scheduleFullRedraw(); }, }), m(QueryHistoryComponent)); }, });