aboutsummaryrefslogtreecommitdiff
path: root/contexts/data/lib/closure-library/closure/goog/editor/range.js
blob: 26d8636e4a3bacb05e6342f14b9f2f7baade6088 (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
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// 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.

/**
 * @fileoverview Utilties for working with ranges.
 *
 * @author nicksantos@google.com (Nick Santos)
 */

goog.provide('goog.editor.range');
goog.provide('goog.editor.range.Point');

goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
goog.require('goog.dom.RangeEndpoint');
goog.require('goog.dom.SavedCaretRange');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.node');
goog.require('goog.editor.style');
goog.require('goog.iter');


/**
 * Given a range and an element, create a narrower range that is limited to the
 * boundaries of the element. If the range starts (or ends) outside the
 * element, the narrowed range's start point (or end point) will be the
 * leftmost (or rightmost) leaf of the element.
 * @param {goog.dom.AbstractRange} range The range.
 * @param {Element} el The element to limit the range to.
 * @return {goog.dom.AbstractRange} A new narrowed range, or null if the
 *     element does not contain any part of the given range.
 */
goog.editor.range.narrow = function(range, el) {
  var startContainer = range.getStartNode();
  var endContainer = range.getEndNode();

  if (startContainer && endContainer) {
    var isElement = function(node) {
      return node == el;
    };
    var hasStart = goog.dom.getAncestor(startContainer, isElement, true);
    var hasEnd = goog.dom.getAncestor(endContainer, isElement, true);

    if (hasStart && hasEnd) {
      // The range is contained entirely within this element.
      return range.clone();
    } else if (hasStart) {
      // The range starts inside the element, but ends outside it.
      var leaf = goog.editor.node.getRightMostLeaf(el);
      return goog.dom.Range.createFromNodes(
          range.getStartNode(), range.getStartOffset(),
          leaf, goog.editor.node.getLength(leaf));
    } else if (hasEnd) {
      // The range starts outside the element, but ends inside it.
      return goog.dom.Range.createFromNodes(
          goog.editor.node.getLeftMostLeaf(el), 0,
          range.getEndNode(), range.getEndOffset());
    }
  }

  // The selection starts and ends outside the element.
  return null;
};


/**
 * Given a range, expand the range to include outer tags if the full contents of
 * those tags are entirely selected.  This essentially changes the dom position,
 * but not the visible position of the range.
 * Ex. <li>foo</li> if "foo" is selected, instead of returning start and end
 * nodes as the foo text node, return the li.
 * @param {goog.dom.AbstractRange} range The range.
 * @param {Node=} opt_stopNode Optional node to stop expanding past.
 * @return {goog.dom.AbstractRange} The expanded range.
 */
goog.editor.range.expand = function(range, opt_stopNode) {
  // Expand the start out to the common container.
  var expandedRange = goog.editor.range.expandEndPointToContainer_(
      range, goog.dom.RangeEndpoint.START, opt_stopNode);
  // Expand the end out to the common container.
  expandedRange = goog.editor.range.expandEndPointToContainer_(
      expandedRange, goog.dom.RangeEndpoint.END, opt_stopNode);

  var startNode = expandedRange.getStartNode();
  var endNode = expandedRange.getEndNode();
  var startOffset = expandedRange.getStartOffset();
  var endOffset = expandedRange.getEndOffset();

  // If we have reached a common container, now expand out.
  if (startNode == endNode) {
    while (endNode != opt_stopNode &&
           startOffset == 0 &&
           endOffset == goog.editor.node.getLength(endNode)) {
      // Select the parent instead.
      var parentNode = endNode.parentNode;
      startOffset = goog.array.indexOf(parentNode.childNodes, endNode);
      endOffset = startOffset + 1;
      endNode = parentNode;
    }
    startNode = endNode;
  }

  return goog.dom.Range.createFromNodes(startNode, startOffset,
      endNode, endOffset);
};


/**
 * Given a range, expands the start or end points as far out towards the
 * range's common container (or stopNode, if provided) as possible, while
 * perserving the same visible position.
 *
 * @param {goog.dom.AbstractRange} range The range to expand.
 * @param {goog.dom.RangeEndpoint} endpoint The endpoint to expand.
 * @param {Node=} opt_stopNode Optional node to stop expanding past.
 * @return {goog.dom.AbstractRange} The expanded range.
 * @private
 */
goog.editor.range.expandEndPointToContainer_ = function(range, endpoint,
                                                        opt_stopNode) {
  var expandStart = endpoint == goog.dom.RangeEndpoint.START;
  var node = expandStart ? range.getStartNode() : range.getEndNode();
  var offset = expandStart ? range.getStartOffset() : range.getEndOffset();
  var container = range.getContainerElement();

  // Expand the node out until we reach the container or the stop node.
  while (node != container && node != opt_stopNode) {
    // It is only valid to expand the start if we are at the start of a node
    // (offset 0) or expand the end if we are at the end of a node
    // (offset length).
    if (expandStart && offset != 0 ||
        !expandStart && offset != goog.editor.node.getLength(node)) {
      break;
    }

    var parentNode = node.parentNode;
    var index = goog.array.indexOf(parentNode.childNodes, node);
    offset = expandStart ? index : index + 1;
    node = parentNode;
  }

  return goog.dom.Range.createFromNodes(
      expandStart ? node : range.getStartNode(),
      expandStart ? offset : range.getStartOffset(),
      expandStart ? range.getEndNode() : node,
      expandStart ? range.getEndOffset() : offset);
};


/**
 * Cause the window's selection to be the start of this node.
 * @param {Node} node The node to select the start of.
 */
goog.editor.range.selectNodeStart = function(node) {
  goog.dom.Range.createCaret(goog.editor.node.getLeftMostLeaf(node), 0).
      select();
};


/**
 * Position the cursor immediately to the left or right of "node".
 * In Firefox, the selection parent is outside of "node", so the cursor can
 * effectively be moved to the end of a link node, without being considered
 * inside of it.
 * Note: This does not always work in WebKit. In particular, if you try to
 * place a cursor to the right of a link, typing still puts you in the link.
 * Bug: http://bugs.webkit.org/show_bug.cgi?id=17697
 * @param {Node} node The node to position the cursor relative to.
 * @param {boolean} toLeft True to place it to the left, false to the right.
 * @return {goog.dom.AbstractRange} The newly selected range.
 */
goog.editor.range.placeCursorNextTo = function(node, toLeft) {
  var parent = node.parentNode;
  var offset = goog.array.indexOf(parent.childNodes, node) +
      (toLeft ? 0 : 1);
  var point = goog.editor.range.Point.createDeepestPoint(
      parent, offset, toLeft);
  // NOTE: It's for fixing bug that selecting HR tag breaks
  // the cursor position In IE9. See http://b/6040468.
  if (goog.userAgent.IE && goog.userAgent.isVersion('9') &&
      point.node.nodeType == goog.dom.NodeType.ELEMENT &&
      point.node.tagName == goog.dom.TagName.HR) {
    var hr = point.node;
    point.node = hr.parentNode;
    point.offset = goog.array.indexOf(point.node.childNodes, hr) +
        (toLeft ? 0 : 1);
  }
  var range = goog.dom.Range.createCaret(point.node, point.offset);
  range.select();
  return range;
};


/**
 * Normalizes the node, preserving the selection of the document.
 *
 * May also normalize things outside the node, if it is more efficient to do so.
 *
 * @param {Node} node The node to normalize.
 */
goog.editor.range.selectionPreservingNormalize = function(node) {
  var doc = goog.dom.getOwnerDocument(node);
  var selection = goog.dom.Range.createFromWindow(goog.dom.getWindow(doc));
  var normalizedRange =
      goog.editor.range.rangePreservingNormalize(node, selection);
  if (normalizedRange) {
    normalizedRange.select();
  }
};


/**
 * Manually normalizes the node in IE, since native normalize in IE causes
 * transient problems.
 * @param {Node} node The node to normalize.
 * @private
 */
goog.editor.range.normalizeNodeIe_ = function(node) {
  var lastText = null;
  var child = node.firstChild;
  while (child) {
    var next = child.nextSibling;
    if (child.nodeType == goog.dom.NodeType.TEXT) {
      if (child.nodeValue == '') {
        node.removeChild(child);
      } else if (lastText) {
        lastText.nodeValue += child.nodeValue;
        node.removeChild(child);
      } else {
        lastText = child;
      }
    } else {
      goog.editor.range.normalizeNodeIe_(child);
      lastText = null;
    }
    child = next;
  }
};


/**
 * Normalizes the given node.
 * @param {Node} node The node to normalize.
 */
goog.editor.range.normalizeNode = function(node) {
  if (goog.userAgent.IE) {
    goog.editor.range.normalizeNodeIe_(node);
  } else {
    node.normalize();
  }
};


/**
 * Normalizes the node, preserving a range of the document.
 *
 * May also normalize things outside the node, if it is more efficient to do so.
 *
 * @param {Node} node The node to normalize.
 * @param {goog.dom.AbstractRange?} range The range to normalize.
 * @return {goog.dom.AbstractRange?} The range, adjusted for normalization.
 */
goog.editor.range.rangePreservingNormalize = function(node, range) {
  if (range) {
    var rangeFactory = goog.editor.range.normalize(range);
    // WebKit has broken selection affinity, so carets tend to jump out of the
    // beginning of inline elements. This means that if we're doing the
    // normalize as the result of a range that will later become the selection,
    // we might not normalize something in the range after it is read back from
    // the selection. We can't just normalize the parentNode here because WebKit
    // can move the selection range out of multiple inline parents.
    var container = goog.editor.style.getContainer(range.getContainerElement());
  }

  if (container) {
    goog.editor.range.normalizeNode(
        goog.dom.findCommonAncestor(container, node));
  } else if (node) {
    goog.editor.range.normalizeNode(node);
  }

  if (rangeFactory) {
    return rangeFactory();
  } else {
    return null;
  }
};


/**
 * Get the deepest point in the DOM that's equivalent to the endpoint of the
 * given range.
 *
 * @param {goog.dom.AbstractRange} range A range.
 * @param {boolean} atStart True for the start point, false for the end point.
 * @return {goog.editor.range.Point} The end point, expressed as a node
 *    and an offset.
 */
goog.editor.range.getDeepEndPoint = function(range, atStart) {
  return atStart ?
      goog.editor.range.Point.createDeepestPoint(
          range.getStartNode(), range.getStartOffset()) :
      goog.editor.range.Point.createDeepestPoint(
          range.getEndNode(), range.getEndOffset());
};


/**
 * Given a range in the current DOM, create a factory for a range that
 * represents the same selection in a normalized DOM. The factory function
 * should be invoked after the DOM is normalized.
 *
 * All browsers do a bad job preserving ranges across DOM normalization.
 * The issue is best described in this 5-year-old bug report:
 * https://bugzilla.mozilla.org/show_bug.cgi?id=191864
 * For most applications, this isn't a problem. The browsers do a good job
 * handling un-normalized text, so there's usually no reason to normalize.
 *
 * The exception to this rule is the rich text editing commands
 * execCommand and queryCommandValue, which will fail often if there are
 * un-normalized text nodes.
 *
 * The factory function creates new ranges so that we can normalize the DOM
 * without problems. It must be created before any normalization happens,
 * and invoked after normalization happens.
 *
 * @param {goog.dom.AbstractRange} range The range to normalize. It may
 *    become invalid after body.normalize() is called.
 * @return {function(): goog.dom.AbstractRange} A factory for a normalized
 *    range. Should be called after body.normalize() is called.
 */
goog.editor.range.normalize = function(range) {
  var startPoint = goog.editor.range.normalizePoint_(
      goog.editor.range.getDeepEndPoint(range, true));
  var startParent = startPoint.getParentPoint();
  var startPreviousSibling = startPoint.node.previousSibling;
  if (startPoint.node.nodeType == goog.dom.NodeType.TEXT) {
    startPoint.node = null;
  }

  var endPoint = goog.editor.range.normalizePoint_(
      goog.editor.range.getDeepEndPoint(range, false));
  var endParent = endPoint.getParentPoint();
  var endPreviousSibling = endPoint.node.previousSibling;
  if (endPoint.node.nodeType == goog.dom.NodeType.TEXT) {
    endPoint.node = null;
  }

  /** @return {goog.dom.AbstractRange} The normalized range. */
  return function() {
    if (!startPoint.node && startPreviousSibling) {
      // If startPoint.node was previously an empty text node with no siblings,
      // startPreviousSibling may not have a nextSibling since that node will no
      // longer exist.  Do our best and point to the end of the previous
      // element.
      startPoint.node = startPreviousSibling.nextSibling;
      if (!startPoint.node) {
        startPoint = goog.editor.range.Point.getPointAtEndOfNode(
            startPreviousSibling);
      }
    }

    if (!endPoint.node && endPreviousSibling) {
      // If endPoint.node was previously an empty text node with no siblings,
      // endPreviousSibling may not have a nextSibling since that node will no
      // longer exist.  Do our best and point to the end of the previous
      // element.
      endPoint.node = endPreviousSibling.nextSibling;
      if (!endPoint.node) {
        endPoint = goog.editor.range.Point.getPointAtEndOfNode(
            endPreviousSibling);
      }
    }

    return goog.dom.Range.createFromNodes(
        startPoint.node || startParent.node.firstChild || startParent.node,
        startPoint.offset,
        endPoint.node || endParent.node.firstChild || endParent.node,
        endPoint.offset);
  };
};


/**
 * Given a point in the current DOM, adjust it to represent the same point in
 * a normalized DOM.
 *
 * See the comments on goog.editor.range.normalize for more context.
 *
 * @param {goog.editor.range.Point} point A point in the document.
 * @return {goog.editor.range.Point} The same point, for easy chaining.
 * @private
 */
goog.editor.range.normalizePoint_ = function(point) {
  var previous;
  if (point.node.nodeType == goog.dom.NodeType.TEXT) {
    // If the cursor position is in a text node,
    // look at all the previous text siblings of the text node,
    // and set the offset relative to the earliest text sibling.
    for (var current = point.node.previousSibling;
         current && current.nodeType == goog.dom.NodeType.TEXT;
         current = current.previousSibling) {
      point.offset += goog.editor.node.getLength(current);
    }

    previous = current;
  } else {
    previous = point.node.previousSibling;
  }

  var parent = point.node.parentNode;
  point.node = previous ? previous.nextSibling : parent.firstChild;
  return point;
};


/**
 * Checks if a range is completely inside an editable region.
 * @param {goog.dom.AbstractRange} range The range to test.
 * @return {boolean} Whether the range is completely inside an editable region.
 */
goog.editor.range.isEditable = function(range) {
  var rangeContainer = range.getContainerElement();

  // Closure's implementation of getContainerElement() is a little too
  // smart in IE when exactly one element is contained in the range.
  // It assumes that there's a user whose intent was actually to select
  // all that element's children, so it returns the element itself as its
  // own containing element.
  // This little sanity check detects this condition so we can account for it.
  var rangeContainerIsOutsideRange =
      range.getStartNode() != rangeContainer.parentElement;

  return (rangeContainerIsOutsideRange &&
          goog.editor.node.isEditableContainer(rangeContainer)) ||
      goog.editor.node.isEditable(rangeContainer);
};


/**
 * Returns whether the given range intersects with any instance of the given
 * tag.
 * @param {goog.dom.AbstractRange} range The range to check.
 * @param {goog.dom.TagName} tagName The name of the tag.
 * @return {boolean} Whether the given range intersects with any instance of
 *     the given tag.
 */
goog.editor.range.intersectsTag = function(range, tagName) {
  if (goog.dom.getAncestorByTagNameAndClass(range.getContainerElement(),
                                            tagName)) {
    return true;
  }

  return goog.iter.some(range, function(node) {
    return node.tagName == tagName;
  });
};



/**
 * One endpoint of a range, represented as a Node and and offset.
 * @param {Node} node The node containing the point.
 * @param {number} offset The offset of the point into the node.
 * @constructor
 */
goog.editor.range.Point = function(node, offset) {
  /**
   * The node containing the point.
   * @type {Node}
   */
  this.node = node;

  /**
   * The offset of the point into the node.
   * @type {number}
   */
  this.offset = offset;
};


/**
 * Gets the point of this point's node in the DOM.
 * @return {goog.editor.range.Point} The node's point.
 */
goog.editor.range.Point.prototype.getParentPoint = function() {
  var parent = this.node.parentNode;
  return new goog.editor.range.Point(
      parent, goog.array.indexOf(parent.childNodes, this.node));
};


/**
 * Construct the deepest possible point in the DOM that's equivalent
 * to the given point, expressed as a node and an offset.
 * @param {Node} node The node containing the point.
 * @param {number} offset The offset of the point from the node.
 * @param {boolean=} opt_trendLeft Notice that a (node, offset) pair may be
 *     equivalent to more than one descendent (node, offset) pair in the DOM.
 *     By default, we trend rightward. If this parameter is true, then we
 *     trend leftward. The tendency to fall rightward by default is for
 *     consistency with other range APIs (like placeCursorNextTo).
 * @return {goog.editor.range.Point} A new point.
 */
goog.editor.range.Point.createDeepestPoint =
    function(node, offset, opt_trendLeft) {
  while (node.nodeType == goog.dom.NodeType.ELEMENT) {
    var child = node.childNodes[offset];
    if (!child && !node.lastChild) {
      break;
    }
    if (child) {
      var prevSibling = child.previousSibling;
      if (opt_trendLeft && prevSibling) {
        node = prevSibling;
        offset = goog.editor.node.getLength(node);
      } else {
        node = child;
        offset = 0;
      }
    } else {
      node = node.lastChild;
      offset = goog.editor.node.getLength(node);
    }
  }

  return new goog.editor.range.Point(node, offset);
};


/**
 * Construct a point at the very end of the given node.
 * @param {Node} node The node to create a point for.
 * @return {goog.editor.range.Point} A new point.
 */
goog.editor.range.Point.getPointAtEndOfNode = function(node) {
  return new goog.editor.range.Point(node, goog.editor.node.getLength(node));
};


/**
 * Saves the range by inserting carets into the HTML.
 *
 * Unlike the regular saveUsingCarets, this SavedRange normalizes text nodes.
 * Browsers have other bugs where they don't handle split text nodes in
 * contentEditable regions right.
 *
 * @param {goog.dom.AbstractRange} range The abstract range object.
 * @return {goog.dom.SavedCaretRange} A saved caret range that normalizes
 *     text nodes.
 */
goog.editor.range.saveUsingNormalizedCarets = function(range) {
  return new goog.editor.range.NormalizedCaretRange_(range);
};



/**
 * Saves the range using carets, but normalizes text nodes when carets
 * are removed.
 * @see goog.editor.range.saveUsingNormalizedCarets
 * @param {goog.dom.AbstractRange} range The range being saved.
 * @constructor
 * @extends {goog.dom.SavedCaretRange}
 * @private
 */
goog.editor.range.NormalizedCaretRange_ = function(range) {
  goog.dom.SavedCaretRange.call(this, range);
};
goog.inherits(goog.editor.range.NormalizedCaretRange_,
    goog.dom.SavedCaretRange);


/**
 * Normalizes text nodes whenever carets are removed from the document.
 * @param {goog.dom.AbstractRange=} opt_range A range whose offsets have already
 *     been adjusted for caret removal; it will be adjusted and returned if it
 *     is also affected by post-removal operations, such as text node
 *     normalization.
 * @return {goog.dom.AbstractRange|undefined} The adjusted range, if opt_range
 *     was provided.
 * @override
 */
goog.editor.range.NormalizedCaretRange_.prototype.removeCarets =
    function(opt_range) {
  var startCaret = this.getCaret(true);
  var endCaret = this.getCaret(false);
  var node = startCaret && endCaret ?
      goog.dom.findCommonAncestor(startCaret, endCaret) :
      startCaret || endCaret;

  goog.editor.range.NormalizedCaretRange_.superClass_.removeCarets.call(this);

  if (opt_range) {
    return goog.editor.range.rangePreservingNormalize(node, opt_range);
  } else if (node) {
    goog.editor.range.selectionPreservingNormalize(node);
  }
};