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.javascript.host.dom;
16  
17  import java.util.HashSet;
18  
19  import org.apache.commons.logging.LogFactory;
20  import org.htmlunit.BrowserVersion;
21  import org.htmlunit.SgmlPage;
22  import org.htmlunit.WebClient;
23  import org.htmlunit.WebWindow;
24  import org.htmlunit.css.ComputedCssStyleDeclaration;
25  import org.htmlunit.css.StyleAttributes.Definition;
26  import org.htmlunit.html.DomDocumentFragment;
27  import org.htmlunit.html.DomElement;
28  import org.htmlunit.html.DomNode;
29  import org.htmlunit.html.DomText;
30  import org.htmlunit.html.HtmlElement;
31  import org.htmlunit.html.impl.SimpleRange;
32  import org.htmlunit.javascript.HtmlUnitScriptable;
33  import org.htmlunit.javascript.JavaScriptEngine;
34  import org.htmlunit.javascript.configuration.JsxClass;
35  import org.htmlunit.javascript.configuration.JsxConstant;
36  import org.htmlunit.javascript.configuration.JsxConstructor;
37  import org.htmlunit.javascript.configuration.JsxFunction;
38  import org.htmlunit.javascript.configuration.JsxGetter;
39  import org.htmlunit.javascript.host.DOMRect;
40  import org.htmlunit.javascript.host.DOMRectList;
41  import org.htmlunit.javascript.host.html.HTMLElement;
42  
43  /**
44   * The JavaScript object that represents a Range.
45   *
46   * @see <a href="http://www.xulplanet.com/references/objref/Range.html">XULPlanet</a>
47   * @see <a href="http://www.w3.org/TR/DOM-Level-2-Traversal-Range/ranges.html">DOM-Level-2-Traversal-Range</a>
48   * @author Marc Guillemot
49   * @author Ahmed Ashour
50   * @author Daniel Gredler
51   * @author James Phillpotts
52   * @author Ronald Brill
53   */
54  @JsxClass
55  public class Range extends AbstractRange {
56  
57      /** Comparison mode for compareBoundaryPoints. */
58      @JsxConstant
59      public static final int START_TO_START = 0;
60  
61      /** Comparison mode for compareBoundaryPoints. */
62      @JsxConstant
63      public static final int START_TO_END = 1;
64  
65      /** Comparison mode for compareBoundaryPoints. */
66      @JsxConstant
67      public static final int END_TO_END = 2;
68  
69      /** Comparison mode for compareBoundaryPoints. */
70      @JsxConstant
71      public static final int END_TO_START = 3;
72  
73      /**
74       * Creates an instance.
75       */
76      public Range() {
77          super();
78      }
79  
80      /**
81       * JavaScript constructor.
82       */
83      @Override
84      @JsxConstructor
85      public void jsConstructor() {
86          super.jsConstructor();
87      }
88  
89      /**
90       * Creates a new instance.
91       * @param document the HTML document creating the range
92       */
93      public Range(final Document document) {
94          super(document, document, 0, 0);
95      }
96  
97      Range(final SimpleRange simpleRange) {
98          super(simpleRange.getStartContainer().getScriptableObject(),
99                  simpleRange.getEndContainer().getScriptableObject(),
100                 simpleRange.getStartOffset(),
101                 simpleRange.getEndOffset());
102     }
103 
104     /**
105      * Sets the attributes describing the start of a Range.
106      * @param refNode the reference node
107      * @param offset the offset value within the node
108      */
109     @JsxFunction
110     public void setStart(final Node refNode, final int offset) {
111         if (refNode == null) {
112             throw JavaScriptEngine.reportRuntimeError("It is illegal to call Range.setStart() with a null node.");
113         }
114         internSetStartContainer(refNode);
115         internSetStartOffset(offset);
116     }
117 
118     /**
119      * Sets the start of the range to be after the node.
120      * @param refNode the reference node
121      */
122     @JsxFunction
123     public void setStartAfter(final Node refNode) {
124         if (refNode == null) {
125             throw JavaScriptEngine.reportRuntimeError("It is illegal to call Range.setStartAfter() with a null node.");
126         }
127         internSetStartContainer(refNode.getParent());
128         internSetStartOffset(getPositionInContainer(refNode) + 1);
129     }
130 
131     /**
132      * Sets the start of the range to be before the node.
133      * @param refNode the reference node
134      */
135     @JsxFunction
136     public void setStartBefore(final Node refNode) {
137         if (refNode == null) {
138             throw JavaScriptEngine.reportRuntimeError("It is illegal to call Range.setStartBefore() with a null node.");
139         }
140         internSetStartContainer(refNode.getParent());
141         internSetStartOffset(getPositionInContainer(refNode));
142     }
143 
144     private static int getPositionInContainer(final Node refNode) {
145         int i = 0;
146         Node node = refNode;
147         while (node.getPreviousSibling() != null) {
148             node = node.getPreviousSibling();
149             i++;
150         }
151         return i;
152     }
153 
154     /**
155      * Sets the attributes describing the end of a Range.
156      * @param refNode the reference node
157      * @param offset the offset value within the node
158      */
159     @JsxFunction
160     public void setEnd(final Node refNode, final int offset) {
161         if (refNode == null) {
162             throw JavaScriptEngine.reportRuntimeError("It is illegal to call Range.setEnd() with a null node.");
163         }
164         internSetEndContainer(refNode);
165         internSetEndOffset(offset);
166     }
167 
168     /**
169      * Sets the end of the range to be after the node.
170      * @param refNode the reference node
171      */
172     @JsxFunction
173     public void setEndAfter(final Node refNode) {
174         if (refNode == null) {
175             throw JavaScriptEngine.reportRuntimeError("It is illegal to call Range.setEndAfter() with a null node.");
176         }
177         internSetEndContainer(refNode.getParent());
178         internSetEndOffset(getPositionInContainer(refNode) + 1);
179     }
180 
181     /**
182      * Sets the end of the range to be before the node.
183      * @param refNode the reference node
184      */
185     @JsxFunction
186     public void setEndBefore(final Node refNode) {
187         if (refNode == null) {
188             throw JavaScriptEngine.reportRuntimeError("It is illegal to call Range.setEndBefore() with a null node.");
189         }
190         internSetEndContainer(refNode.getParent());
191         internSetEndOffset(getPositionInContainer(refNode));
192     }
193 
194     /**
195      * Select the contents within a node.
196      * @param refNode Node to select from
197      */
198     @JsxFunction
199     public void selectNodeContents(final Node refNode) {
200         internSetStartContainer(refNode);
201         internSetStartOffset(0);
202         internSetEndContainer(refNode);
203         internSetEndOffset(refNode.getChildNodes().getLength());
204     }
205 
206     /**
207      * Selects a node and its contents.
208      * @param refNode the node to select
209      */
210     @JsxFunction
211     public void selectNode(final Node refNode) {
212         setStartBefore(refNode);
213         setEndAfter(refNode);
214     }
215 
216     /**
217      * Collapse a Range onto one of its boundaries.
218      * @param toStart if {@code true}, collapses the Range onto its start; else collapses it onto its end
219      */
220     @JsxFunction
221     public void collapse(final boolean toStart) {
222         if (toStart) {
223             internSetEndContainer(internGetStartContainer());
224             internSetEndOffset(internGetStartOffset());
225         }
226         else {
227             internSetStartContainer(internGetEndContainer());
228             internSetStartOffset(internGetEndOffset());
229         }
230     }
231 
232     /**
233      * Returns the deepest common ancestor container of the Range's two boundary points.
234      * @return the deepest common ancestor container of the Range's two boundary points
235      */
236     @JsxGetter
237     public Object getCommonAncestorContainer() {
238         final HashSet<Node> startAncestors = new HashSet<>();
239         Node ancestor = internGetStartContainer();
240         while (ancestor != null) {
241             startAncestors.add(ancestor);
242             ancestor = ancestor.getParent();
243         }
244 
245         ancestor = internGetEndContainer();
246         while (ancestor != null) {
247             if (startAncestors.contains(ancestor)) {
248                 return ancestor;
249             }
250             ancestor = ancestor.getParent();
251         }
252 
253         return JavaScriptEngine.UNDEFINED;
254     }
255 
256     /**
257      * Parses an HTML snippet.
258      * @param valueAsString text that contains text and tags to be converted to a document fragment
259      * @return a document fragment
260      * @see <a href="https://developer.mozilla.org/en-US/docs/DOM/range.createContextualFragment">Mozilla
261      *     documentation</a>
262      */
263     @JsxFunction
264     public HtmlUnitScriptable createContextualFragment(final String valueAsString) {
265         final SgmlPage page = internGetStartContainer().getDomNodeOrDie().getPage();
266         final DomDocumentFragment fragment = new DomDocumentFragment(page);
267         try {
268             final WebClient webClient = page.getWebClient();
269             webClient.getPageCreator().getHtmlParser()
270                     .parseFragment(webClient, fragment,
271                             internGetStartContainer().getDomNodeOrDie(), valueAsString, false);
272         }
273         catch (final Exception e) {
274             LogFactory.getLog(Range.class).error("Unexpected exception occurred in createContextualFragment", e);
275             throw JavaScriptEngine.reportRuntimeError("Unexpected exception occurred in createContextualFragment: "
276                     + e.getMessage());
277         }
278 
279         return fragment.getScriptableObject();
280     }
281 
282     /**
283      * Moves this range's contents from the document tree into a document fragment.
284      * @return the new document fragment containing the range contents
285      */
286     @JsxFunction
287     public HtmlUnitScriptable extractContents() {
288         try {
289             return getSimpleRange().extractContents().getScriptableObject();
290         }
291         catch (final IllegalStateException e) {
292             throw JavaScriptEngine.reportRuntimeError(e.getMessage());
293         }
294     }
295 
296     /**
297      * Compares the boundary points of two Ranges.
298      * @param how a constant describing the comparison method
299      * @param sourceRange the Range to compare boundary points with this range
300      * @return -1, 0, or 1, indicating whether the corresponding boundary-point of range is respectively before,
301      *         equal to, or after the corresponding boundary-point of sourceRange.
302      */
303     @JsxFunction
304     public int compareBoundaryPoints(final int how, final Range sourceRange) {
305         final Node nodeForThis;
306         final int offsetForThis;
307         final int containingModifier;
308         if (START_TO_START == how || END_TO_START == how) {
309             nodeForThis = internGetStartContainer();
310             offsetForThis = internGetStartOffset();
311             containingModifier = 1;
312         }
313         else {
314             nodeForThis = internGetEndContainer();
315             offsetForThis = internGetEndOffset();
316             containingModifier = -1;
317         }
318 
319         final Node nodeForOther;
320         final int offsetForOther;
321         if (START_TO_END == how || START_TO_START == how) {
322             nodeForOther = sourceRange.internGetStartContainer();
323             offsetForOther = sourceRange.internGetStartOffset();
324         }
325         else {
326             nodeForOther = sourceRange.internGetEndContainer();
327             offsetForOther = sourceRange.internGetEndOffset();
328         }
329 
330         if (nodeForThis == nodeForOther) {
331             if (offsetForThis < offsetForOther) {
332                 return -1;
333             }
334             else if (offsetForThis > offsetForOther) {
335                 return 1;
336             }
337             return 0;
338         }
339 
340         final byte nodeComparision = (byte) nodeForThis.compareDocumentPosition(nodeForOther);
341         if ((nodeComparision & Node.DOCUMENT_POSITION_CONTAINED_BY) != 0) {
342             return -1 * containingModifier;
343         }
344         else if ((nodeComparision & Node.DOCUMENT_POSITION_PRECEDING) != 0) {
345             return -1;
346         }
347 
348         // TODO: handle other cases
349         return 1;
350     }
351 
352     /**
353      * Returns a clone of the range in a document fragment.
354      * @return a clone
355      */
356     @JsxFunction
357     public HtmlUnitScriptable cloneContents() {
358         try {
359             return getSimpleRange().cloneContents().getScriptableObject();
360         }
361         catch (final IllegalStateException e) {
362             throw JavaScriptEngine.reportRuntimeError(e.getMessage());
363         }
364     }
365 
366     /**
367      * Deletes the contents of the range.
368      */
369     @JsxFunction
370     public void deleteContents() {
371         try {
372             getSimpleRange().deleteContents();
373         }
374         catch (final IllegalStateException e) {
375             throw JavaScriptEngine.reportRuntimeError(e.getMessage());
376         }
377     }
378 
379     /**
380      * Inserts a new node at the beginning of the range. If the range begins at an offset, the node is split.
381      * @param newNode The node to insert
382      * @see <a href="https://developer.mozilla.org/en/DOM/range">https://developer.mozilla.org/en/DOM/range</a>
383      */
384     @JsxFunction
385     public void insertNode(final Node newNode) {
386         try {
387             getSimpleRange().insertNode(newNode.getDomNodeOrDie());
388         }
389         catch (final IllegalStateException e) {
390             throw JavaScriptEngine.reportRuntimeError(e.getMessage());
391         }
392     }
393 
394     /**
395      * Surrounds the contents of the range in a new node.
396      * @param newNode The node to surround the range in
397      */
398     @JsxFunction
399     public void surroundContents(final Node newNode) {
400         try {
401             getSimpleRange().surroundContents(newNode.getDomNodeOrDie());
402         }
403         catch (final IllegalStateException e) {
404             throw JavaScriptEngine.reportRuntimeError(e.getMessage());
405         }
406     }
407 
408     /**
409      * Returns a clone of the range.
410      * @return a clone of the range
411      */
412     @JsxFunction
413     public Range cloneRange() {
414         try {
415             return new Range(getSimpleRange().cloneRange());
416         }
417         catch (final IllegalStateException e) {
418             throw JavaScriptEngine.reportRuntimeError(e.getMessage());
419         }
420     }
421 
422     /**
423      * Releases Range from use to improve performance.
424      */
425     @JsxFunction
426     public void detach() {
427         // Java garbage collection should take care of this for us
428     }
429 
430     /**
431      * Returns the text of the Range.
432      * @return the text
433      */
434     @JsxFunction(functionName = "toString")
435     public String jsToString() {
436         try {
437             return getSimpleRange().toString();
438         }
439         catch (final IllegalStateException e) {
440             throw JavaScriptEngine.reportRuntimeError(e.getMessage());
441         }
442     }
443 
444     /**
445      * Retrieves a collection of rectangles that describes the layout of the contents of an object
446      * or range within the client. Each rectangle describes a single line.
447      * @return a collection of rectangles that describes the layout of the contents
448      */
449     @JsxFunction
450     public DOMRectList getClientRects() {
451         final DOMRectList rectList = new DOMRectList();
452         rectList.setParentScope(getParentScope());
453         rectList.setPrototype(getPrototype(rectList.getClass()));
454 
455         try {
456             // simple impl for now
457             for (final DomNode node : getSimpleRange().containedNodes()) {
458                 final HtmlUnitScriptable scriptable = node.getScriptableObject();
459                 if (scriptable instanceof HTMLElement element) {
460                     final DOMRect rect = element.getBoundingClientRect();
461                     rect.setParentScope(getParentScope());
462                     rect.setPrototype(getPrototype(rect.getClass()));
463                     rectList.add(rect);
464                 }
465                 else if (node instanceof DomText domText) {
466                     final DOMRect rect = getTextNodeRect(domText);
467                     if (rect != null) {
468                         rectList.add(rect);
469                     }
470                 }
471             }
472 
473             return rectList;
474         }
475         catch (final IllegalStateException e) {
476             throw JavaScriptEngine.reportRuntimeError(e.getMessage());
477         }
478     }
479 
480     /**
481      * Returns an object that bounds the contents of the range.
482      * this a rectangle enclosing the union of the bounding rectangles for all the elements in the range.
483      * @return an object the bounds the contents of the range
484      */
485     @JsxFunction
486     public DOMRect getBoundingClientRect() {
487         final DOMRect rect = new DOMRect(Integer.MAX_VALUE, Integer.MAX_VALUE, 0, 0);
488         rect.setParentScope(getParentScope());
489         rect.setPrototype(getPrototype(rect.getClass()));
490 
491         try {
492             // simple impl for now
493             for (final DomNode node : getSimpleRange().containedNodes()) {
494                 final HtmlUnitScriptable scriptable = node.getScriptableObject();
495 
496                 DOMRect childRect = null;
497                 if (scriptable instanceof HTMLElement element) {
498                     childRect = element.getBoundingClientRect();
499                 }
500                 else if (node instanceof DomText domText) {
501                     childRect = getTextNodeRect(domText);
502                 }
503 
504                 if (childRect != null) {
505                     rect.setX(Math.min(rect.getX(), childRect.getX()));
506                     rect.setY(Math.min(rect.getY(), childRect.getY()));
507                     rect.setWidth(Math.max(rect.getWidth(), childRect.getWidth()));
508                     rect.setHeight(Math.max(rect.getHeight(), childRect.getHeight()));
509                 }
510             }
511 
512             return rect;
513         }
514         catch (final IllegalStateException e) {
515             throw JavaScriptEngine.reportRuntimeError(e.getMessage());
516         }
517     }
518 
519     private DOMRect getTextNodeRect(final DomText node) {
520         // Text nodes have no scriptable; use to the parent element for some calculation
521         final DomNode parent = node.getParentNode();
522         if (!(parent instanceof HtmlElement parentHtml)) {
523             return null;
524         }
525 
526         final HTMLElement parentScriptable = parentHtml.getScriptableObject();
527         final DOMRect parentRect = parentScriptable.getBoundingClientRect();
528 
529         final SgmlPage page = parent.getPage();
530         final WebWindow webWindow = page.getEnclosingWindow();
531         final ComputedCssStyleDeclaration style = webWindow.getComputedStyle((DomElement) parent, null);
532         final BrowserVersion browserVersion = page.getWebClient().getBrowserVersion();
533 
534         final int fontHeight = browserVersion.getFontHeight(style.getStyleAttribute(Definition.FONT_SIZE, true));
535         final float pixelsPerChar = fontHeight / 1.8f;
536 
537         // Estimate the x offset of the text node's start within the parent.
538         // Sum the widths of all preceding text/inline siblings to get the base offset.
539         float siblingOffset = 0;
540         for (final DomNode sibling : parent.getChildren()) {
541             if (sibling == node) {
542                 break;
543             }
544             if (sibling instanceof DomText sibText) {
545                 // skip whitespace-only text nodes — they collapse to nothing in HTML
546                 final String visible = sibText.getVisibleText().trim();
547                 if (!visible.isEmpty()) {
548                     siblingOffset += visible.length() * pixelsPerChar;
549                 }
550             }
551             else if (sibling instanceof HtmlElement siblingElement) {
552                 final ComputedCssStyleDeclaration sibStyle =
553                         webWindow.getComputedStyle(siblingElement, null);
554                 siblingOffset += sibStyle.getCalculatedWidth(true, true, true);
555             }
556         }
557 
558         final boolean startIsThisNode = getSimpleRange().getStartContainer() == node;
559         final boolean endIsThisNode   = getSimpleRange().getEndContainer() == node;
560 
561         final int startChar = startIsThisNode ? getSimpleRange().getStartOffset() : 0;
562 
563         final String visibleText = node.getVisibleText().trim();
564         final int endChar = endIsThisNode ? getSimpleRange().getEndOffset() : visibleText.length();
565 
566         final double rectLeft  = parentRect.getX() + siblingOffset + startChar * pixelsPerChar;
567         final double rectWidth = (endChar - startChar) * pixelsPerChar;
568 
569         final DOMRect rect = new DOMRect(rectLeft, parentRect.getY(), rectWidth, fontHeight);
570         rect.setParentScope(getParentScope());
571         rect.setPrototype(getPrototype(rect.getClass()));
572         return rect;
573     }
574 }