• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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