View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://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  package org.htmlunit.html.impl;
16  
17  import java.io.Serializable;
18  import java.util.ArrayList;
19  import java.util.Collections;
20  import java.util.Iterator;
21  import java.util.List;
22  import java.util.Objects;
23  
24  import org.apache.commons.lang3.builder.EqualsBuilder;
25  import org.htmlunit.SgmlPage;
26  import org.htmlunit.html.DomDocumentFragment;
27  import org.htmlunit.html.DomNode;
28  import org.htmlunit.html.DomNodeList;
29  import org.htmlunit.html.DomText;
30  import org.w3c.dom.DOMException;
31  import org.w3c.dom.DocumentFragment;
32  import org.w3c.dom.Node;
33  import org.w3c.dom.NodeList;
34  
35  /**
36   * Simple implementation of an Range.
37   *
38   * @author Marc Guillemot
39   * @author Daniel Gredler
40   * @author James Phillpotts
41   * @author Ahmed Ashour
42   * @author Ronald Brill
43   */
44  public class SimpleRange implements Serializable {
45  
46      /** The start (anchor) container. */
47      private DomNode startContainer_;
48  
49      /** The end (focus) container. */
50      private DomNode endContainer_;
51  
52      /**
53       * The start (anchor) offset; units are chars if the start container is a text node or an
54       * input element, DOM nodes otherwise.
55       */
56      private int startOffset_;
57  
58      /**
59       * The end (focus) offset; units are chars if the end container is a text node or an input
60       * element, DOM nodes otherwise.
61       */
62      private int endOffset_;
63  
64      /**
65       * Constructs a range without any content.
66       */
67      public SimpleRange() {
68          // Empty.
69      }
70  
71      /**
72       * Constructs a range for the specified element.
73       * @param node the node for the range
74       */
75      public SimpleRange(final DomNode node) {
76          startContainer_ = node;
77          endContainer_ = node;
78          startOffset_ = 0;
79          endOffset_ = getMaxOffset(node);
80      }
81  
82      /**
83       * Constructs a range for the provided element and start and end offset.
84       * @param node the node for the range
85       * @param offset the start and end offset
86       */
87      public SimpleRange(final DomNode node, final int offset) {
88          startContainer_ = node;
89          endContainer_ = node;
90          startOffset_ = offset;
91          endOffset_ = offset;
92      }
93  
94      /**
95       * Constructs a range for the provided elements and offsets.
96       * @param startNode the start node
97       * @param startOffset the start offset
98       * @param endNode the end node
99       * @param endOffset the end offset
100      */
101     public SimpleRange(final DomNode startNode, final int startOffset, final DomNode endNode, final int endOffset) {
102         startContainer_ = startNode;
103         endContainer_ = endNode;
104         startOffset_ = startOffset;
105         endOffset_ = endOffset;
106         if (startNode == endNode && startOffset > endOffset) {
107             endOffset_ = startOffset;
108         }
109     }
110 
111     /**
112      * Duplicates the contents of this.
113      * @return DocumentFragment that contains content equivalent to this
114      */
115     public DomDocumentFragment cloneContents() {
116         // Clone the common ancestor.
117         final DomNode ancestor = getCommonAncestorContainer();
118 
119         if (ancestor == null) {
120             return new DomDocumentFragment(null);
121         }
122         final DomNode ancestorClone = ancestor.cloneNode(true);
123 
124         // Find the start container and end container clones.
125         DomNode startClone = null;
126         DomNode endClone = null;
127         final DomNode start = startContainer_;
128         final DomNode end = endContainer_;
129         if (start == ancestor) {
130             startClone = ancestorClone;
131         }
132         if (end == ancestor) {
133             endClone = ancestorClone;
134         }
135         final Iterable<DomNode> descendants = ancestor.getDescendants();
136         if (startClone == null || endClone == null) {
137             final Iterator<DomNode> i = descendants.iterator();
138             final Iterator<DomNode> ci = ancestorClone.getDescendants().iterator();
139             while (i.hasNext()) {
140                 final DomNode e = i.next();
141                 final DomNode ce = ci.next();
142                 if (start == e) {
143                     startClone = ce;
144                 }
145                 else if (end == e) {
146                     endClone = ce;
147                     break;
148                 }
149             }
150         }
151 
152         // Do remove from end first so that it can't affect the offset values
153 
154         // Remove everything following the selection end from the clones.
155         if (endClone == null) {
156             throw new IllegalStateException("Unable to find end node clone.");
157         }
158         deleteAfter(endClone, endOffset_);
159         for (DomNode n = endClone; n != null; n = n.getParentNode()) {
160             while (n.getNextSibling() != null) {
161                 n.getNextSibling().remove();
162             }
163         }
164 
165         // Remove everything prior to the selection start from the clones.
166         if (startClone == null) {
167             throw new IllegalStateException("Unable to find start node clone.");
168         }
169         deleteBefore(startClone, startOffset_);
170         for (DomNode n = startClone; n != null; n = n.getParentNode()) {
171             while (n.getPreviousSibling() != null) {
172                 n.getPreviousSibling().remove();
173             }
174         }
175 
176         final SgmlPage page = ancestor.getPage();
177         final DomDocumentFragment fragment = new DomDocumentFragment(page);
178         if (start == end) {
179             fragment.appendChild(ancestorClone);
180         }
181         else {
182             for (final DomNode n : ancestorClone.getChildNodes()) {
183                 fragment.appendChild(n);
184             }
185         }
186         return fragment;
187     }
188 
189     /**
190      * Produces a new SimpleRange whose boundary-points are equal to the
191      * boundary-points of this.
192      * @return duplicated simple
193      */
194     public SimpleRange cloneRange() {
195         return new SimpleRange(startContainer_, startOffset_, endContainer_, endOffset_);
196     }
197 
198     /**
199      * Collapse this range onto one of its boundary-points.
200      * @param toStart if true, collapses the Range onto its start; else collapses it onto its end.
201      */
202     public void collapse(final boolean toStart) {
203         if (toStart) {
204             endContainer_ = startContainer_;
205             endOffset_ = startOffset_;
206         }
207         else {
208             startContainer_ = endContainer_;
209             startOffset_ = endOffset_;
210         }
211     }
212 
213     /**
214      * Removes the contents of this range from the containing document or
215      * document fragment without returning a reference to the removed
216      * content.
217      */
218     public void deleteContents() {
219         final DomNode ancestor = getCommonAncestorContainer();
220         if (ancestor != null) {
221             deleteContents(ancestor);
222         }
223     }
224 
225     private void deleteContents(final DomNode ancestor) {
226         final DomNode start;
227         final DomNode end;
228         if (isOffsetChars(startContainer_)) {
229             start = startContainer_;
230             String text = getText(start);
231             if (startOffset_ > -1 && startOffset_ < text.length()) {
232                 text = text.substring(0, startOffset_);
233             }
234             setText(start, text);
235         }
236         else if (startContainer_.getChildNodes().getLength() > startOffset_) {
237             start = (DomNode) startContainer_.getChildNodes().item(startOffset_);
238         }
239         else {
240             start = startContainer_.getNextSibling();
241         }
242         if (isOffsetChars(endContainer_)) {
243             end = endContainer_;
244             String text = getText(end);
245             if (endOffset_ > -1 && endOffset_ < text.length()) {
246                 text = text.substring(endOffset_);
247             }
248             setText(end, text);
249         }
250         else if (endContainer_.getChildNodes().getLength() > endOffset_) {
251             end = (DomNode) endContainer_.getChildNodes().item(endOffset_);
252         }
253         else {
254             end = endContainer_.getNextSibling();
255         }
256 
257         boolean foundStart = false;
258         boolean started = false;
259         final Iterator<DomNode> i = ancestor.getDescendants().iterator();
260         while (i.hasNext()) {
261             final DomNode n = i.next();
262             if (n == end) {
263                 break;
264             }
265             if (n == start) {
266                 foundStart = true;
267             }
268             if (foundStart && (n != start || !isOffsetChars(startContainer_))) {
269                 started = true;
270             }
271             if (started && !n.isAncestorOf(end)) {
272                 i.remove();
273             }
274         }
275     }
276 
277     /**
278      * Moves the contents of a Range from the containing document or document
279      * fragment to a new DocumentFragment.
280      * @return DocumentFragment containing the extracted contents
281      * @throws DOMException in case of error
282      */
283     public DomDocumentFragment extractContents() throws DOMException {
284         final DomDocumentFragment fragment = cloneContents();
285 
286         // Remove everything inside the range from the original nodes.
287         deleteContents();
288 
289         // Build the document fragment using the cloned nodes, and return it.
290         return fragment;
291     }
292 
293     /**
294      * @return true if startContainer equals endContainer and
295      *         startOffset equals endOffset
296      * @throws DOMException in case of error
297      */
298     public boolean isCollapsed() throws DOMException {
299         return startContainer_ == endContainer_ && startOffset_ == endOffset_;
300     }
301 
302     /**
303      * @return the deepest common ancestor container of this range's two
304      *         boundary-points.
305      * @throws DOMException in case of error
306      */
307     public DomNode getCommonAncestorContainer() throws DOMException {
308         if (startContainer_ != null && endContainer_ != null) {
309             for (DomNode p1 = startContainer_; p1 != null; p1 = p1.getParentNode()) {
310                 for (DomNode p2 = endContainer_; p2 != null; p2 = p2.getParentNode()) {
311                     if (p1 == p2) {
312                         return p1;
313                     }
314                 }
315             }
316         }
317         return null;
318     }
319 
320     /**
321      * @return the Node within which this range ends
322      */
323     public DomNode getEndContainer() {
324         return endContainer_;
325     }
326 
327     /**
328      * @return offset within the ending node of this
329      */
330     public int getEndOffset() {
331         return endOffset_;
332     }
333 
334     /**
335      * @return the Node within which this range begins
336      */
337     public DomNode getStartContainer() {
338         return startContainer_;
339     }
340 
341     /**
342      * @return offset within the starting node of this
343      */
344     public int getStartOffset() {
345         return startOffset_;
346     }
347 
348     /**
349      * Inserts a node into the Document or DocumentFragment at the start of
350      * the Range. If the container is a Text node, this will be split at the
351      * start of the Range (as if the Text node's splitText method was
352      * performed at the insertion point) and the insertion will occur
353      * between the two resulting Text nodes. Adjacent Text nodes will not be
354      * automatically merged. If the node to be inserted is a
355      * DocumentFragment node, the children will be inserted rather than the
356      * DocumentFragment node itself.
357      * @param newNode The node to insert at the start of the Range
358      */
359     public void insertNode(final DomNode newNode) {
360         if (isOffsetChars(startContainer_)) {
361             final DomNode split = startContainer_.cloneNode(false);
362             String text = getText(startContainer_);
363             if (startOffset_ > -1 && startOffset_ < text.length()) {
364                 text = text.substring(0, startOffset_);
365             }
366             setText(startContainer_, text);
367             text = getText(split);
368             if (startOffset_ > -1 && startOffset_ < text.length()) {
369                 text = text.substring(startOffset_);
370             }
371             setText(split, text);
372             insertNodeOrDocFragment(startContainer_.getParentNode(), split, startContainer_.getNextSibling());
373             insertNodeOrDocFragment(startContainer_.getParentNode(), newNode, split);
374         }
375         else {
376             insertNodeOrDocFragment(startContainer_, newNode,
377                     (DomNode) startContainer_.getChildNodes().item(startOffset_));
378         }
379 
380         setStart(newNode, 0);
381     }
382 
383     private static void insertNodeOrDocFragment(final DomNode parent, final DomNode newNode, final DomNode refNode) {
384         if (newNode instanceof DocumentFragment fragment) {
385 
386             final NodeList childNodes = fragment.getChildNodes();
387             while (childNodes.getLength() > 0) {
388                 final Node item = childNodes.item(0);
389                 parent.insertBefore(item, refNode);
390             }
391         }
392         else {
393             parent.insertBefore(newNode, refNode);
394         }
395     }
396 
397     /**
398      * Select a node and its contents.
399      * @param node The node to select.
400      */
401     public void selectNode(final DomNode node) {
402         startContainer_ = node;
403         startOffset_ = 0;
404         endContainer_ = node;
405         endOffset_ = getMaxOffset(node);
406     }
407 
408     /**
409      * Select the contents within a node.
410      * @param node Node to select from
411      */
412     public void selectNodeContents(final DomNode node) {
413         startContainer_ = node.getFirstChild();
414         startOffset_ = 0;
415         endContainer_ = node.getLastChild();
416         endOffset_ = getMaxOffset(node.getLastChild());
417     }
418 
419     /**
420      * Sets the attributes describing the end.
421      * @param refNode the refNode
422      * @param offset offset
423      */
424     public void setEnd(final DomNode refNode, final int offset) {
425         endContainer_ = refNode;
426         endOffset_ = offset;
427     }
428 
429     /**
430      * Sets the attributes describing the start.
431      * @param refNode the refNode
432      * @param offset offset
433      */
434     public void setStart(final DomNode refNode, final int offset) {
435         startContainer_ = refNode;
436         startOffset_ = offset;
437     }
438 
439     /**
440      * Reparents the contents of the Range to the given node and inserts the
441      * node at the position of the start of the Range.
442      * @param newParent The node to surround the contents with.
443      */
444     public void surroundContents(final DomNode newParent) {
445         newParent.appendChild(extractContents());
446         insertNode(newParent);
447         setStart(newParent, 0);
448         setEnd(newParent, getMaxOffset(newParent));
449     }
450 
451     /**
452      * {@inheritDoc}
453      */
454     @Override
455     public boolean equals(final Object obj) {
456         if (!(obj instanceof SimpleRange other)) {
457             return false;
458         }
459         return new EqualsBuilder()
460             .append(startContainer_, other.startContainer_)
461             .append(endContainer_, other.endContainer_)
462             .append(startOffset_, other.startOffset_)
463             .append(endOffset_, other.endOffset_).isEquals();
464     }
465 
466     /**
467      * {@inheritDoc}
468      */
469     @Override
470     public int hashCode() {
471         return Objects.hash(startContainer_, endContainer_, startOffset_, endOffset_);
472     }
473 
474     /**
475      * {@inheritDoc}
476      */
477     @Override
478     public String toString() {
479         final DomDocumentFragment fragment = cloneContents();
480         if (fragment.getPage() != null) {
481             return fragment.asNormalizedText();
482         }
483         return "";
484     }
485 
486     private static boolean isOffsetChars(final DomNode node) {
487         return node instanceof DomText || node instanceof SelectableTextInput;
488     }
489 
490     private static String getText(final DomNode node) {
491         if (node instanceof SelectableTextInput input) {
492             return input.getText();
493         }
494         return node.getTextContent();
495     }
496 
497     private static void setText(final DomNode node, final String text) {
498         if (node instanceof SelectableTextInput input) {
499             input.setText(text);
500         }
501         else {
502             node.setTextContent(text);
503         }
504     }
505 
506     private static void deleteBefore(final DomNode node, int offset) {
507         if (isOffsetChars(node)) {
508             String text = getText(node);
509             if (offset > -1 && offset < text.length()) {
510                 text = text.substring(offset);
511             }
512             else {
513                 text = "";
514             }
515             setText(node, text);
516         }
517         else {
518             final DomNodeList<DomNode> children = node.getChildNodes();
519             for (int i = 0; i < offset && i < children.getLength(); i++) {
520                 final DomNode child = children.get(i);
521                 child.remove();
522                 i--;
523                 offset--;
524             }
525         }
526     }
527 
528     private static void deleteAfter(final DomNode node, final int offset) {
529         if (isOffsetChars(node)) {
530             String text = getText(node);
531             if (offset > -1 && offset < text.length()) {
532                 text = text.substring(0, offset);
533                 setText(node, text);
534             }
535         }
536         else {
537             final DomNodeList<DomNode> children = node.getChildNodes();
538             for (int i = offset; i < children.getLength(); i++) {
539                 final DomNode child = children.get(i);
540                 child.remove();
541                 i--;
542             }
543         }
544     }
545 
546     private static int getMaxOffset(final DomNode node) {
547         return isOffsetChars(node) ? getText(node).length() : node.getChildNodes().getLength();
548     }
549 
550     /**
551      * @return a list with all nodes contained in this range
552      */
553     public List<DomNode> containedNodes() {
554         final DomNode ancestor = getCommonAncestorContainer();
555         if (ancestor == null) {
556             return Collections.emptyList();
557         }
558 
559         final DomNode start;
560         final DomNode end;
561         if (isOffsetChars(startContainer_)) {
562             start = startContainer_;
563             String text = getText(start);
564             if (startOffset_ > -1 && startOffset_ < text.length()) {
565                 text = text.substring(0, startOffset_);
566             }
567             setText(start, text);
568         }
569         else if (startContainer_.getChildNodes().getLength() > startOffset_) {
570             start = (DomNode) startContainer_.getChildNodes().item(startOffset_);
571         }
572         else {
573             start = startContainer_.getNextSibling();
574         }
575         if (isOffsetChars(endContainer_)) {
576             end = endContainer_;
577             String text = getText(end);
578             if (endOffset_ > -1 && endOffset_ < text.length()) {
579                 text = text.substring(endOffset_);
580             }
581             setText(end, text);
582         }
583         else if (endContainer_.getChildNodes().getLength() > endOffset_) {
584             end = (DomNode) endContainer_.getChildNodes().item(endOffset_);
585         }
586         else {
587             end = endContainer_.getNextSibling();
588         }
589 
590         boolean foundStart = false;
591         boolean started = false;
592         final List<DomNode> nodes = new ArrayList<>();
593         for (final DomNode n : ancestor.getDescendants()) {
594             if (n == end) {
595                 break;
596             }
597             if (n == start) {
598                 foundStart = true;
599             }
600             if (foundStart && (n != start || !isOffsetChars(startContainer_))) {
601                 started = true;
602             }
603             if (started && !n.isAncestorOf(end)) {
604                 nodes.add(n);
605             }
606         }
607         return nodes;
608     }
609 }