editable_text_area_shadow.js revision cedac228d2dd51db4b79ea1e72c7f249408ee061
1// Copyright 2014 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/**
6 * @fileoverview Defines the EditableTextAreaShadow class.
7 */
8
9goog.provide('cvox.EditableTextAreaShadow');
10
11/**
12 * Creates a shadow element for an editable text area used to compute line
13 * numbers.
14 * @constructor
15 */
16cvox.EditableTextAreaShadow = function() {
17  /**
18   * @type {Element}
19   * @private
20   */
21  this.shadowElement_ = document.createElement('div');
22
23  /**
24   * Map from line index to a data structure containing the start
25   * and end index within the line.
26   * @type {Object.<number, {startIndex: number, endIndex: number}>}
27   * @private
28   */
29  this.lines_ = {};
30
31  /**
32   * Map from 0-based character index to 0-based line index.
33   * @type {Array.<number>}
34   * @private
35   */
36  this.characterToLineMap_ = [];
37};
38
39/**
40 * Update the shadow element.
41 * @param {Element} element The textarea element.
42 */
43cvox.EditableTextAreaShadow.prototype.update = function(element) {
44  document.body.appendChild(this.shadowElement_);
45
46  while (this.shadowElement_.childNodes.length) {
47    this.shadowElement_.removeChild(this.shadowElement_.childNodes[0]);
48  }
49  this.shadowElement_.style.cssText =
50      window.getComputedStyle(element, null).cssText;
51  this.shadowElement_.style.position = 'absolute';
52  this.shadowElement_.style.top = -9999;
53  this.shadowElement_.style.left = -9999;
54  this.shadowElement_.setAttribute('aria-hidden', 'true');
55
56  // Add the text to the shadow element, but with an extra character to the
57  // end so that we can get the bounding box of the last line - we can't
58  // measure blank lines otherwise.
59  var text = element.value;
60  var textNode = document.createTextNode(text + '.');
61  this.shadowElement_.appendChild(textNode);
62
63  /**
64   * For extra speed, try to skip this many characters at a time - if
65   * none of the characters are newlines and they're all at the same
66   * vertical position, we don't have to examine each one. If not,
67   * fall back to moving by one character at a time.
68   * @const
69   */
70  var SKIP = 8;
71
72  /**
73   * Map from line index to a data structure containing the start
74   * and end index within the line.
75   * @type {Object.<number, {startIndex: number, endIndex: number}>}
76   */
77  var lines = {0: {startIndex: 0, endIndex: 0}};
78
79  var range = document.createRange();
80  var offset = 0;
81  var lastGoodOffset = 0;
82  var lineIndex = 0;
83  var lastBottom = null;
84  var nearNewline = false;
85  var rect;
86  while (offset <= text.length) {
87    range.setStart(textNode, offset);
88
89    // If we're near the end or if there's an explicit newline character,
90    // don't even try to skip.
91    if (offset + SKIP > text.length ||
92        text.substr(offset, SKIP).indexOf('\n') >= 0) {
93      nearNewline = true;
94    }
95
96    if (nearNewline) {
97      // Move by one character.
98      offset++;
99      range.setEnd(textNode, offset);
100      rect = range.getBoundingClientRect();
101    } else {
102      // Try to move by |SKIP| characters.
103      range.setEnd(textNode, offset + SKIP);
104      rect = range.getBoundingClientRect();
105      if (rect.bottom == lastBottom) {
106        // Great, they all seem to be on the same line.
107        offset += SKIP;
108      } else {
109        // Nope, there might be a newline, better go one at a time to be safe.
110        if (rect && lastBottom !== null) {
111          nearNewline = true;
112        }
113        offset++;
114        range.setEnd(textNode, offset);
115        rect = range.getBoundingClientRect();
116      }
117    }
118
119    if (offset > 0 && text[offset - 1] == '\n') {
120      // Handle an explicit newline character - that always results in
121      // a new line.
122      lines[lineIndex].endIndex = offset - 1;
123      lineIndex++;
124      lines[lineIndex] = {startIndex: offset, endIndex: offset};
125      lastBottom = null;
126      nearNewline = false;
127      lastGoodOffset = offset;
128    } else if (rect && (lastBottom === null)) {
129      // This is the first character we've successfully measured on this
130      // line. Save the vertical position but don't do anything else.
131      lastBottom = rect.bottom;
132    } else if (rect && rect.bottom != lastBottom) {
133      // This character is at a different vertical position, so place an
134      // implicit newline immediately after the *previous* good character
135      // we found (which we now know was the last character of the previous
136      // line).
137      lines[lineIndex].endIndex = lastGoodOffset;
138      lineIndex++;
139      lines[lineIndex] = {startIndex: lastGoodOffset, endIndex: lastGoodOffset};
140      lastBottom = rect ? rect.bottom : null;
141      nearNewline = false;
142    }
143
144    if (rect) {
145      lastGoodOffset = offset;
146    }
147  }
148  // Finish up the last line.
149  lines[lineIndex].endIndex = text.length;
150
151  // Create a map from character index to line number.
152  var characterToLineMap = [];
153  for (var i = 0; i <= lineIndex; i++) {
154    for (var j = lines[i].startIndex; j <= lines[i].endIndex; j++) {
155      characterToLineMap[j] = i;
156    }
157  }
158
159  // Finish updating fields and remove the shadow element.
160  this.characterToLineMap_ = characterToLineMap;
161  this.lines_ = lines;
162  document.body.removeChild(this.shadowElement_);
163};
164
165/**
166 * Get the line number corresponding to a particular index.
167 * @param {number} index The 0-based character index.
168 * @return {number} The 0-based line number corresponding to that character.
169 */
170cvox.EditableTextAreaShadow.prototype.getLineIndex = function(index) {
171  return this.characterToLineMap_[index];
172};
173
174/**
175 * Get the start character index of a line.
176 * @param {number} index The 0-based line index.
177 * @return {number} The 0-based index of the first character in this line.
178 */
179cvox.EditableTextAreaShadow.prototype.getLineStart = function(index) {
180  return this.lines_[index].startIndex;
181};
182
183/**
184 * Get the end character index of a line.
185 * @param {number} index The 0-based line index.
186 * @return {number} The 0-based index of the end of this line.
187 */
188cvox.EditableTextAreaShadow.prototype.getLineEnd = function(index) {
189  return this.lines_[index].endIndex;
190};
191