blob: 993c0df2a8deed176fa73aca9ce0098e8bcac40d [file] [log] [blame]
rginda87b86462011-12-14 13:48:03 -08001// Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
rginda8ba33642011-12-14 12:31:31 -08002// 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 This class represents a single terminal screen full of text.
7 *
8 * It maintains the current cursor position and has basic methods for text
9 * insert and overwrite, and adding or removing rows from the screen.
10 *
11 * This class has no knowledge of the scrollback buffer.
12 *
13 * The number of rows on the screen is determined only by the number of rows
14 * that the caller inserts into the screen. If a caller wants to ensure a
15 * constant number of rows on the screen, it's their responsibility to remove a
16 * row for each row inserted.
17 *
18 * The screen width, in contrast, is enforced locally.
19 *
20 *
21 * In practice...
22 * - The hterm.Terminal class holds two hterm.Screen instances. One for the
23 * primary screen and one for the alternate screen.
24 *
25 * - The html.Screen class only cares that rows are HTMLElements. In the
26 * larger context of hterm, however, the rows happen to be displayed by an
27 * hterm.ScrollPort and have to follow a few rules as a result. Each
28 * row must be rooted by the custom HTML tag 'x-row', and each must have a
29 * rowIndex property that corresponds to the index of the row in the context
30 * of the scrollback buffer. These invariants are enforced by hterm.Terminal
31 * because that is the class using the hterm.Screen in the context of an
32 * hterm.ScrollPort.
33 */
34
35/**
36 * Create a new screen instance.
37 *
38 * The screen initially has no rows and a maximum column count of 0.
39 *
40 * @param {integer} opt_columnCount The maximum number of columns for this
41 * screen. See insertString() and overwriteString() for information about
42 * what happens when too many characters are added too a row. Defaults to
43 * 0 if not provided.
44 */
45hterm.Screen = function(opt_columnCount) {
46 /**
47 * Public, read-only access to the rows in this screen.
48 */
49 this.rowsArray = [];
50
51 // The max column width for this screen.
rginda87b86462011-12-14 13:48:03 -080052 this.columnCount_ = opt_columnCount || 80;
rginda8ba33642011-12-14 12:31:31 -080053
rgindaa19afe22012-01-25 15:40:22 -080054 // The current color, bold, underline and blink attributes.
55 this.textAttributes = new hterm.TextAttributes(window.document);
56
rginda87b86462011-12-14 13:48:03 -080057 // Current zero-based cursor coordinates.
58 this.cursorPosition = new hterm.RowCol(0, 0);
rginda8ba33642011-12-14 12:31:31 -080059
60 // The node containing the row that the cursor is positioned on.
61 this.cursorRowNode_ = null;
62
63 // The node containing the span of text that the cursor is positioned on.
64 this.cursorNode_ = null;
65
66 // The offset into cursorNode_ where the cursor is positioned.
67 this.cursorOffset_ = null;
68};
69
70/**
71 * Return the screen size as an hterm.Size object.
72 *
73 * @return {hterm.Size} hterm.Size object representing the current number
74 * of rows and columns in this screen.
75 */
76hterm.Screen.prototype.getSize = function() {
77 return new hterm.Size(this.columnCount_, this.rowsArray.length);
78};
79
80/**
81 * Return the current number of rows in this screen.
82 *
83 * @return {integer} The number of rows in this screen.
84 */
85hterm.Screen.prototype.getHeight = function() {
86 return this.rowsArray.length;
87};
88
89/**
90 * Return the current number of columns in this screen.
91 *
92 * @return {integer} The number of columns in this screen.
93 */
94hterm.Screen.prototype.getWidth = function() {
95 return this.columnCount_;
96};
97
98/**
99 * Set the maximum number of columns per row.
100 *
101 * TODO(rginda): This should probably clip existing rows if the count is
102 * decreased.
103 *
104 * @param {integer} count The maximum number of columns per row.
105 */
106hterm.Screen.prototype.setColumnCount = function(count) {
rginda2312fff2012-01-05 16:20:52 -0800107 this.columnCount_ = count;
108
rginda87b86462011-12-14 13:48:03 -0800109 if (this.rowsArray.length) {
110 var p = this.cursorPosition.clone();
111
112 for (var i = 0; i < this.rowsArray.length; i++) {
113 var overflow = this.rowsArray[i].textContent.length - count;
114 if (overflow > 0) {
115 this.setCursorPosition(i, count - 1);
116 this.deleteChars(overflow);
117 }
118 }
119
120 if (p.column >= count)
121 p.column = count - 1;
122
123 this.setCursorPosition(p.row, p.column);
124 }
rginda8ba33642011-12-14 12:31:31 -0800125};
126
127/**
128 * Remove the first row from the screen and return it.
129 *
130 * @return {HTMLElement} The first row in this screen.
131 */
132hterm.Screen.prototype.shiftRow = function() {
133 return this.shiftRows(1)[0];
rginda87b86462011-12-14 13:48:03 -0800134};
rginda8ba33642011-12-14 12:31:31 -0800135
136/**
137 * Remove rows from the top of the screen and return them as an array.
138 *
139 * @param {integer} count The number of rows to remove.
140 * @return {Array.<HTMLElement>} The selected rows.
141 */
142hterm.Screen.prototype.shiftRows = function(count) {
143 return this.rowsArray.splice(0, count);
144};
145
146/**
147 * Insert a row at the top of the screen.
148 *
149 * @param {HTMLElement} The row to insert.
150 */
151hterm.Screen.prototype.unshiftRow = function(row) {
152 this.rowsArray.splice(0, 0, row);
153};
154
155/**
156 * Insert rows at the top of the screen.
157 *
158 * @param {Array.<HTMLElement>} The rows to insert.
159 */
160hterm.Screen.prototype.unshiftRows = function(rows) {
161 this.rowsArray.unshift.apply(this.rowsArray, rows);
162};
163
164/**
165 * Remove the last row from the screen and return it.
166 *
167 * @return {HTMLElement} The last row in this screen.
168 */
169hterm.Screen.prototype.popRow = function() {
170 return this.popRows(1)[0];
171};
172
173/**
174 * Remove rows from the bottom of the screen and return them as an array.
175 *
176 * @param {integer} count The number of rows to remove.
177 * @return {Array.<HTMLElement>} The selected rows.
178 */
179hterm.Screen.prototype.popRows = function(count) {
180 return this.rowsArray.splice(this.rowsArray.length - count, count);
181};
182
183/**
184 * Insert a row at the bottom of the screen.
185 *
186 * @param {HTMLElement} The row to insert.
187 */
188hterm.Screen.prototype.pushRow = function(row) {
189 this.rowsArray.push(row);
190};
191
192/**
193 * Insert rows at the bottom of the screen.
194 *
195 * @param {Array.<HTMLElement>} The rows to insert.
196 */
197hterm.Screen.prototype.pushRows = function(rows) {
198 rows.push.apply(this.rowsArray, rows);
199};
200
201/**
202 * Insert a row at the specified column of the screen.
203 *
204 * @param {HTMLElement} The row to insert.
205 */
206hterm.Screen.prototype.insertRow = function(index, row) {
207 this.rowsArray.splice(index, 0, row);
208};
209
210/**
211 * Insert rows at the specified column of the screen.
212 *
213 * @param {Array.<HTMLElement>} The rows to insert.
214 */
215hterm.Screen.prototype.insertRows = function(index, rows) {
216 for (var i = 0; i < rows.length; i++) {
217 this.rowsArray.splice(index + i, 0, rows[i]);
218 }
219};
220
221/**
222 * Remove a last row from the specified column of the screen and return it.
223 *
224 * @return {HTMLElement} The selected row.
225 */
226hterm.Screen.prototype.removeRow = function(index) {
227 return this.rowsArray.splice(index, 1)[0];
228};
229
230/**
231 * Remove rows from the bottom of the screen and return them as an array.
232 *
233 * @param {integer} count The number of rows to remove.
234 * @return {Array.<HTMLElement>} The selected rows.
235 */
236hterm.Screen.prototype.removeRows = function(index, count) {
237 return this.rowsArray.splice(index, count);
238};
239
240/**
241 * Invalidate the current cursor position.
242 *
rginda87b86462011-12-14 13:48:03 -0800243 * This sets this.cursorPosition to (0, 0) and clears out some internal
rginda8ba33642011-12-14 12:31:31 -0800244 * data.
245 *
246 * Attempting to insert or overwrite text while the cursor position is invalid
247 * will raise an obscure exception.
248 */
249hterm.Screen.prototype.invalidateCursorPosition = function() {
rginda87b86462011-12-14 13:48:03 -0800250 this.cursorPosition.move(0, 0);
rginda8ba33642011-12-14 12:31:31 -0800251 this.cursorRowNode_ = null;
252 this.cursorNode_ = null;
253 this.cursorOffset_ = null;
254};
255
256/**
257 * Clear the contents of a selected row.
258 *
259 * TODO: Make this clear in the current style... somehow. We can't just
260 * fill the row with spaces, since they would have potential to mess up the
261 * terminal (for example, in insert mode, they might wrap around to the next
262 * line.
263 *
264 * @param {integer} index The zero-based index to clear.
265 */
266hterm.Screen.prototype.clearRow = function(index) {
267 if (index == this.cursorPosition.row) {
268 this.clearCursorRow();
269 } else {
270 var row = this.rowsArray[index];
271 row.innerHTML = '';
272 row.appendChild(row.ownerDocument.createTextNode(''));
273 }
274};
275
276/**
277 * Clear the contents of the cursor row.
278 *
279 * TODO: Same comment as clearRow().
280 */
281hterm.Screen.prototype.clearCursorRow = function() {
282 this.cursorRowNode_.innerHTML = '';
283 var text = this.cursorRowNode_.ownerDocument.createTextNode('');
284 this.cursorRowNode_.appendChild(text);
285 this.cursorOffset_ = 0;
286 this.cursorNode_ = text;
287 this.cursorPosition.column = 0;
rginda2312fff2012-01-05 16:20:52 -0800288 this.cursorPosition.overflow = false;
rginda8ba33642011-12-14 12:31:31 -0800289};
290
291/**
292 * Relocate the cursor to a give row and column.
293 *
294 * @param {integer} row The zero based row.
295 * @param {integer} column The zero based column.
296 */
297hterm.Screen.prototype.setCursorPosition = function(row, column) {
rginda87b86462011-12-14 13:48:03 -0800298 if (row >= this.rowsArray.length) {
299 console.log('Row out of bounds: ' + row, hterm.getStack(1));
300 row = this.rowsArray.length - 1;
301 } else if (row < 0) {
302 console.log('Row out of bounds: ' + row, hterm.getStack(1));
303 row = 0;
304 }
305
306 if (column >= this.columnCount_) {
307 console.log('Column out of bounds: ' + column, hterm.getStack(1));
308 column = this.columnCount_ - 1;
309 } else if (column < 0) {
310 console.log('Column out of bounds: ' + column, hterm.getStack(1));
311 column = 0;
312 }
rginda8ba33642011-12-14 12:31:31 -0800313
rginda2312fff2012-01-05 16:20:52 -0800314 this.cursorPosition.overflow = false;
315
rginda8ba33642011-12-14 12:31:31 -0800316 var rowNode = this.rowsArray[row];
317 var node = rowNode.firstChild;
318
319 if (!node) {
320 node = rowNode.ownerDocument.createTextNode('');
321 rowNode.appendChild(node);
322 }
323
rgindaa19afe22012-01-25 15:40:22 -0800324 var currentColumn = 0;
325
rginda8ba33642011-12-14 12:31:31 -0800326 if (rowNode == this.cursorRowNode_) {
327 if (column >= this.cursorPosition.column - this.cursorOffset_) {
328 node = this.cursorNode_;
329 currentColumn = this.cursorPosition.column - this.cursorOffset_;
330 }
331 } else {
332 this.cursorRowNode_ = rowNode;
333 }
334
335 this.cursorPosition.move(row, column);
336
337 while (node) {
338 var offset = column - currentColumn;
339 var textContent = node.textContent;
340 if (!node.nextSibling || textContent.length > offset) {
341 this.cursorNode_ = node;
342 this.cursorOffset_ = offset;
343 return;
344 }
345
346 currentColumn += textContent.length;
347 node = node.nextSibling;
348 }
349};
350
351/**
rginda87b86462011-12-14 13:48:03 -0800352 * Set the provided selection object to be a caret selection at the current
353 * cursor position.
354 */
355hterm.Screen.prototype.syncSelectionCaret = function(selection) {
356 selection.collapse(this.cursorNode_, this.cursorOffset_);
357};
358
359/**
rgindaa19afe22012-01-25 15:40:22 -0800360 * Split a single node into two nodes at the given offset.
rginda8ba33642011-12-14 12:31:31 -0800361 *
rgindaa19afe22012-01-25 15:40:22 -0800362 * For example:
363 * Given the DOM fragment '<div><span>Hello World</span></div>', call splitNode_
364 * passing the span and an offset of 6. This would modifiy the fragment to
365 * become: '<div><span>Hello </span><span>World</span></div>'. If the span
366 * had any attributes they would have been copied to the new span as well.
367 *
368 * The to-be-split node must have a container, so that the new node can be
369 * placed next to it.
370 *
371 * @param {HTMLNode} node The node to split.
372 * @param {integer} offset The offset into the node where the split should
373 * occur.
rginda8ba33642011-12-14 12:31:31 -0800374 */
rgindaa19afe22012-01-25 15:40:22 -0800375hterm.Screen.prototype.splitNode_ = function(node, offset) {
376 var afterNode = node.cloneNode(true);
377
378 node.textContent = node.textContent.substr(0, offset);
379 afterNode.textContent = afterNode.textContent.substr(offset);
380
381 node.parentNode.insertBefore(afterNode, node.nextSibling);
rginda8ba33642011-12-14 12:31:31 -0800382};
383
384/**
rgindaa19afe22012-01-25 15:40:22 -0800385 * Remove and return all content past the end of the current cursor position.
rginda8ba33642011-12-14 12:31:31 -0800386 *
rgindaa19afe22012-01-25 15:40:22 -0800387 * If necessary, the cursor's current node will be split. Everything past
388 * the end of the cursor will be returned in an array. Any empty nodes
389 * will be omitted from the result array. If the resulting array is empty,
390 * this function will return null.
rginda8ba33642011-12-14 12:31:31 -0800391 *
rgindaa19afe22012-01-25 15:40:22 -0800392 * @return {Array} An array of DOM nodes that used to appear after the cursor,
393 * or null if the cursor was already at the end of the line.
394 */
395hterm.Screen.prototype.clipAtCursor_ = function() {
396 if (this.cursorOffset_ < this.cursorNode_.textContent.length - 1)
397 this.splitNode_(this.cursorNode_, this.cursorOffset_ + 1);
398
399 var rv = null;
400 var rowNode = this.cursorRowNode_;
401 var node = this.cursorNode_.nextSibling;
402
403 while (node) {
404 var length = node.textContent.length;
405 if (length) {
406 if (rv) {
407 rv.push(node);
408 rv.characterLength += length;
409 } else {
410 rv = [node];
411 rv.characterLength = length;
412 }
413 }
414
415 rowNode.removeChild(node);
416 node = this.cursorNode_.nextSibling;
417 }
418
419 return rv;
420};
421
422/**
423 * Ensure that the current row does not overflow the current column count.
424 *
425 * If the current row is too long, it will be clipped and the overflow content
426 * will be returned as an array of DOM nodes. Otherwise this function returns
427 * null.
428 *
429 * @return {Array} An array of DOM nodes that overflowed in the current row,
430 * or null if the row did not overflow.
431 */
432hterm.Screen.prototype.maybeClipCurrentRow = function() {
433 var currentColumn = this.cursorPosition.column;
434
435 if (currentColumn >= this.columnCount_) {
436 this.setCursorPosition(this.cursorPosition.row, this.columnCount_ - 1);
437 this.cursorPosition.overflow = true;
438 return this.clipAtCursor_();
439 }
440
441 if (this.cursorRowNode_.textContent.length > this.columnCount_) {
442 this.setCursorPosition(this.cursorPosition.row, this.columnCount_ - 1);
443 var overflow = this.clipAtCursor_();
444 this.setCursorPosition(this.cursorPosition.row, currentColumn);
445 return overflow;
446 }
447
448 return null;
449};
450
451/**
452 * Insert a string at the current character position using the current
453 * text attributes.
454 *
455 * You must call maybeClipCurrentRow() after in order to check overflow.
rginda8ba33642011-12-14 12:31:31 -0800456 */
457hterm.Screen.prototype.insertString = function(str) {
rgindaa19afe22012-01-25 15:40:22 -0800458 var cursorNode = this.cursorNode_;
459 var cursorNodeText = cursorNode.textContent;
rginda8ba33642011-12-14 12:31:31 -0800460
rgindaa19afe22012-01-25 15:40:22 -0800461 // We may alter the length of the string by prepending some missing
462 // whitespace, so we need to record the string length ahead of time.
463 var strLength = str.length;
rginda8ba33642011-12-14 12:31:31 -0800464
rgindaa19afe22012-01-25 15:40:22 -0800465 // No matter what, before this function exits the cursor column will have
466 // moved this much.
467 this.cursorPosition.column += strLength;
rginda8ba33642011-12-14 12:31:31 -0800468
rgindaa19afe22012-01-25 15:40:22 -0800469 // Local cache of the cursor offset.
470 var offset = this.cursorOffset_;
rginda8ba33642011-12-14 12:31:31 -0800471
rgindaa19afe22012-01-25 15:40:22 -0800472 // Reverse offset is the offset measured from the end of the string.
473 // Zero implies that the cursor is at the end of the cursor node.
474 var reverseOffset = cursorNodeText.length - offset
475
476 if (reverseOffset < 0) {
477 // A negative reverse offset means the cursor is positioned past the end
478 // of the characters on this line. We'll need to insert the missing
479 // whitespace.
480 var ws = hterm.getWhitespace(-reverseOffset);
481
482 // This whitespace should be completely unstyled. Underline and background
483 // color would be visible on whitespace, so we can't use one of those
484 // spans to hold the text.
485 if (!(this.textAttributes.underline || this.textAttributes.background)) {
486 // Best case scenario, we can just pretend the spaces were part of the
487 // original string.
488 str = ws + str;
489 } else if (cursorNode.nodeType == 3 ||
490 !(cursorNode.style.textDecoration ||
491 cursorNode.style.backgroundColor)) {
492 // Second best case, the current node is able to hold the whitespace.
493 cursorNode.textContent = (cursorNodeText += ws);
494 } else {
495 // Worst case, we have to create a new node to hold the whitespace.
496 var wsNode = cursorNode.ownerDocument.createTextNode(ws);
497 this.cursorRowNode_.insertBefore(wsNode, cursorNode.nextSibling);
498 this.cursorNode_ = cursorNode = wsNode;
499 this.cursorOffset_ = offset = -reverseOffset;
500 cursorNodeText = ws;
501 }
502
503 // We now know for sure that we're at the last character of the cursor node.
504 reverseOffset = 0;
rginda8ba33642011-12-14 12:31:31 -0800505 }
506
rgindaa19afe22012-01-25 15:40:22 -0800507 if (this.textAttributes.matchesContainer(cursorNode)) {
508 // The new text can be placed directly in the cursor node.
509 if (reverseOffset == 0) {
510 cursorNode.textContent = cursorNodeText + str;
511 } else if (offset == 0) {
512 cursorNode.textContent = str + cursorNodeText;
513 } else {
514 cursorNode.textContent = cursorNodeText.substr(0, offset) + str +
515 cursorNodeText.substr(offset);
516 }
rginda8ba33642011-12-14 12:31:31 -0800517
rgindaa19afe22012-01-25 15:40:22 -0800518 this.cursorOffset_ += strLength;
519 return;
rginda87b86462011-12-14 13:48:03 -0800520 }
521
rgindaa19afe22012-01-25 15:40:22 -0800522 // The cursor node is the wrong style for the new text. If we're at the
523 // beginning or end of the cursor node, then the adjacent node is also a
524 // potential candidate.
rginda8ba33642011-12-14 12:31:31 -0800525
rgindaa19afe22012-01-25 15:40:22 -0800526 if (offset == 0) {
527 // At the beginning of the cursor node, the check the previous sibling.
528 var previousSibling = cursorNode.previousSibling;
529 if (previousSibling &&
530 this.textAttributes.matchesContainer(previousSibling)) {
531 previousSibling.textContent += str;
532 this.cursorNode_ = previousSibling;
533 this.cursorOffset_ = previousSibling.textContent.length;
534 return;
535 }
536
537 var newNode = this.textAttributes.createContainer(str);
538 this.cursorRowNode_.insertBefore(newNode, cursorNode);
539 this.cursorNode_ = newNode;
540 this.cursorOffset_ = strLength;
541 return;
542 }
543
544 if (reverseOffset == 0) {
545 // At the end of the cursor node, the check the next sibling.
546 var nextSibling = cursorNode.nextSibling;
547 if (nextSibling &&
548 this.textAttributes.matchesContainer(nextSibling)) {
549 nextSibling.textContent = str + nextSibling.textContent;
550 this.cursorNode_ = nextSibling;
551 this.cursorOffset_ = strLength;
552 return;
553 }
554
555 var newNode = this.textAttributes.createContainer(str);
556 this.cursorRowNode_.insertBefore(newNode, nextSibling);
557 this.cursorNode_ = newNode;
558 // We specifically need to include any missing whitespace here, since it's
559 // going in a new node.
560 this.cursorOffset_ = str.length;
561 return;
562 }
563
564 // Worst case, we're somewhere in the middle of the cursor node. We'll
565 // have to split it into two nodes and insert our new container in between.
566 this.splitNode_(cursorNode, offset);
567 var newNode = this.textAttributes.createContainer(str);
568 this.cursorRowNode_.insertBefore(newNode, cursorNode.nextSibling);
569 this.cursorNode_ = newNode;
570 this.cursorOffset_ = strLength;
571};
572
573/**
574 * Insert an array of DOM nodes at the beginning of the cursor row.
575 *
576 * This does not pay attention to the cursor column, it only prepends to the
577 * beginning of the current row.
578 *
579 * This method does not attempt to coalesce rows of the same style. It assumes
580 * that the rows being inserted have already been coalesced, and that there
581 * would be no gain in coalescing only the final node.
582 */
583hterm.Screen.prototype.prependNodes = function(ary) {
584 var parentNode = this.cursorRowNode_;
585
586 for (var i = ary.length - 1; i >= 0; i--) {
587 parentNode.insertBefore(ary[i], parentNode.firstChild);
588 }
rginda8ba33642011-12-14 12:31:31 -0800589};
590
591/**
592 * Overwrite the text at the current cursor position.
593 *
rgindaa19afe22012-01-25 15:40:22 -0800594 * You must call maybeClipCurrentRow() after in order to check overflow.
rginda8ba33642011-12-14 12:31:31 -0800595 */
596hterm.Screen.prototype.overwriteString = function(str) {
597 var maxLength = this.columnCount_ - this.cursorPosition.column;
598 if (!maxLength)
rgindaa19afe22012-01-25 15:40:22 -0800599 return [str];
600
601 if ((this.cursorNode_.textContent.substr(this.cursorOffset_) == str) &&
602 this.textAttributes.matchesContainer(this.cursorNode_)) {
603 // This overwrite would be a no-op, just move the cursor and return.
604 this.cursorOffset_ += str.length;
605 this.cursorPosition.column += str.length;
606 return;
607 }
rginda8ba33642011-12-14 12:31:31 -0800608
609 this.deleteChars(Math.min(str.length, maxLength));
rgindaa19afe22012-01-25 15:40:22 -0800610 this.insertString(str);
rginda8ba33642011-12-14 12:31:31 -0800611};
612
613/**
614 * Forward-delete one or more characters at the current cursor position.
615 *
616 * Text to the right of the deleted characters is shifted left. Only affects
617 * characters on the same row as the cursor.
618 *
619 * @param {integer} count The number of characters to delete. This is clamped
620 * to the column width minus the cursor column.
621 */
622hterm.Screen.prototype.deleteChars = function(count) {
623 var node = this.cursorNode_;
624 var offset = this.cursorOffset_;
625
rgindaa19afe22012-01-25 15:40:22 -0800626 if (node.textContent.length <= offset && !node.nextSibling) {
627 // There's nothing after this node/offset to delete, buh bye.
628 return;
629 }
630
rginda8ba33642011-12-14 12:31:31 -0800631 while (node && count) {
632 var startLength = node.textContent.length;
633
634 node.textContent = node.textContent.substr(0, offset) +
635 node.textContent.substr(offset + count);
636
637 var endLength = node.textContent.length;
638 count -= startLength - endLength;
639
640 if (endLength == 0 && node != this.cursorNode_) {
641 var nextNode = node.nextSibling;
642 node.parentNode.removeChild(node);
643 node = nextNode;
644 } else {
645 node = node.nextSibling;
646 }
647
648 offset = 0;
649 }
650};