summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/helpers/indent_helper.js
blob: a8815fac04eba7c893b70c758ca84ad24e886a77 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
const INDENT_SEQUENCE = '    ';

function countLeftSpaces(text) {
  const i = text.split('').findIndex(c => c !== ' ');
  return i === -1 ? text.length : i;
}

/**
 * IndentHelper provides methods that allow manual and smart indentation in
 * textareas. It supports line indent/unindent, selection indent/unindent,
 * auto indentation on newlines, and smart deletion of indents with backspace.
 */
export default class IndentHelper {
  /**
   * Creates a new IndentHelper and binds it to the given `textarea`. You can provide a custom indent sequence in the second parameter, but the `newline` and `backspace` operations may work funny if the indent sequence isn't spaces only.
   */
  constructor(textarea, indentSequence = INDENT_SEQUENCE) {
    this.element = textarea;
    this.seq = indentSequence;
  }

  getSelection() {
    return { start: this.element.selectionStart, end: this.element.selectionEnd };
  }

  isRangeSelection() {
    return this.element.selectionStart !== this.element.selectionEnd;
  }

  /**
   * Re-implementation of textarea's setRangeText method, because IE/Edge don't support it.
   *
   * @see https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#dom-textarea%2Finput-setrangetext
   */
  setRangeText(replacement, start, end, selectMode) {
    // Disable eslint to remain as faithful as possible to the above linked spec
    /* eslint-disable no-param-reassign, no-case-declarations */
    const text = this.element.value;

    if (start > end) {
      throw new RangeError('setRangeText: start index must be less than or equal to end index');
    }

    // Clamp to [0, len]
    start = Math.max(0, Math.min(start, text.length));
    end = Math.max(0, Math.min(end, text.length));

    let selection = { start: this.element.selectionStart, end: this.element.selectionEnd };

    this.element.value = text.slice(0, start) + replacement + text.slice(end);

    const newLength = replacement.length;
    const newEnd = start + newLength;

    switch (selectMode) {
      case 'select':
        selection = { start, newEnd };
        break;
      case 'start':
        selection = { start, end: start };
        break;
      case 'end':
        selection = { start: newEnd, end: newEnd };
        break;
      case 'preserve':
      default:
        const oldLength = end - start;
        const delta = newLength - oldLength;
        if (selection.start > end) {
          selection.start += delta;
        } else if (selection.start > start) {
          selection.start = start;
        }
        if (selection.end > end) {
          selection.end += delta;
        } else if (selection.end > start) {
          selection.end = newEnd;
        }
    }

    this.element.setSelectionRange(selection.start, selection.end);

    /* eslint-enable no-param-reassign, no-case-declarations */
  }

  /**
   * Returns an array of lines in the textarea, with information about their
   * start/end offsets and whether they are included in the current selection.
   */
  splitLines() {
    const { start, end } = this.getSelection();

    const lines = this.element.value.split('\n');
    let textStart = 0;
    const lineObjects = [];
    lines.forEach(line => {
      const lineObj = {
        text: line,
        start: textStart,
        end: textStart + line.length,
      };
      lineObj.inSelection = lineObj.start <= end && lineObj.end >= start;
      lineObjects.push(lineObj);
      textStart += line.length + 1;
    });
    return lineObjects;
  }

  /**
   * Indents selected lines by one level.
   */
  indent() {
    const { start } = this.getSelection();

    const selectedLines = this.splitLines().filter(line => line.inSelection);
    if (!this.isRangeSelection() && start === selectedLines[0].start) {
      // Special case: if cursor is at the beginning of the line, move it one
      // indent right.
      const line = selectedLines[0];
      this.setRangeText(this.seq, line.start, line.start, 'end');
    } else {
      selectedLines.reverse();
      selectedLines.forEach(line => {
        this.setRangeText(INDENT_SEQUENCE, line.start, line.start, 'preserve');
      });
    }
  }

  /**
   * Unindents selected lines by one level.
   */
  unindent() {
    const lines = this.splitLines().filter(line => line.inSelection);
    lines.reverse();
    lines
      .filter(line => line.text.startsWith(this.seq))
      .forEach(line => {
        this.setRangeText('', line.start, line.start + this.seq.length, 'preserve');
      });
  }

  /**
   * Emulates a newline keypress, automatically indenting the new line.
   */
  newline() {
    const { start, end } = this.getSelection();

    if (this.isRangeSelection()) {
      // Manually kill the selection before calculating the indent
      this.setRangeText('', start, end, 'start');
    }

    // Auto-indent the next line
    const currentLine = this.splitLines().find(line => line.end >= start);
    const spaces = countLeftSpaces(currentLine.text);
    this.setRangeText(`\n${' '.repeat(spaces)}`, start, start, 'end');
  }

  /**
   * If the cursor is positioned at the end of a line's leading indents,
   * emulates a backspace keypress by deleting a single level of indents.
   * @param event The DOM KeyboardEvent that triggers this action, or null.
   */
  backspace(event) {
    const { start } = this.getSelection();

    // If the cursor is at the end of leading indents, delete an indent.
    if (!this.isRangeSelection()) {
      const currentLine = this.splitLines().find(line => line.end >= start);
      const cursorPosition = start - currentLine.start;
      if (countLeftSpaces(currentLine.text) === cursorPosition && cursorPosition > 0) {
        if (event) event.preventDefault();

        let spacesToDelete = cursorPosition % this.seq.length;
        if (spacesToDelete === 0) {
          spacesToDelete = this.seq.length;
        }
        this.setRangeText('', start - spacesToDelete, start, 'start');
      }
    }
  }
}