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.ArrayList;
18  import java.util.List;
19  
20  import org.apache.commons.lang3.StringUtils;
21  import org.htmlunit.WebClient;
22  import org.htmlunit.corejs.javascript.Context;
23  import org.htmlunit.corejs.javascript.ContextAction;
24  import org.htmlunit.corejs.javascript.Function;
25  import org.htmlunit.corejs.javascript.Scriptable;
26  import org.htmlunit.corejs.javascript.VarScope;
27  import org.htmlunit.html.DomAttr;
28  import org.htmlunit.html.DomElement;
29  import org.htmlunit.html.DomNode;
30  import org.htmlunit.javascript.HtmlUnitContextFactory;
31  import org.htmlunit.javascript.HtmlUnitScriptable;
32  import org.htmlunit.javascript.JavaScriptEngine;
33  import org.htmlunit.javascript.configuration.JsxClass;
34  import org.htmlunit.javascript.configuration.JsxConstructor;
35  import org.htmlunit.javascript.configuration.JsxFunction;
36  import org.htmlunit.javascript.configuration.JsxGetter;
37  import org.htmlunit.javascript.configuration.JsxSetter;
38  import org.htmlunit.javascript.configuration.JsxSymbol;
39  
40  /**
41   * A JavaScript object for {@code DOMTokenList}.
42   *
43   * @author Ahmed Ashour
44   * @author Ronald Brill
45   * @author Marek Gawlicki
46   * @author Markus Winter
47   *
48   * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/DOMTokenList">MDN Documentation</a>
49   */
50  @JsxClass
51  public class DOMTokenList extends HtmlUnitScriptable {
52  
53      private static final String WHITESPACE_CHARS = " \t\r\n\u000C";
54  
55      private String attributeName_;
56  
57      /**
58       * Creates an instance.
59       */
60      public DOMTokenList() {
61          super();
62      }
63  
64      /**
65       * JavaScript constructor.
66       */
67      @JsxConstructor
68      public void jsConstructor() {
69          // nothing to do
70      }
71  
72      /**
73       * Creates an instance.
74       * @param node the node which contains the underlying string
75       * @param attributeName the attribute name of the DomElement of the specified node
76       */
77      public DOMTokenList(final Node node, final String attributeName) {
78          super();
79          setDomNode(node.getDomNodeOrDie(), false);
80          setParentScope(node.getParentScope());
81          setPrototype(getPrototype(getClass()));
82          attributeName_ = attributeName;
83      }
84  
85      /**
86       * Returns the {@code value} property.
87       * @return the value
88       */
89      @JsxGetter
90      public String getValue() {
91          final DomNode node = getDomNodeOrNull();
92          if (node != null) {
93              final DomAttr attr = (DomAttr) node.getAttributes().getNamedItem(attributeName_);
94              if (attr != null) {
95                  return attr.getValue();
96              }
97          }
98          return null;
99      }
100 
101     /**
102      * Sets the {@code value} property.
103      * @param value the new value
104      */
105     @JsxSetter
106     public void setValue(final String value) {
107         final DomNode node = getDomNodeOrNull();
108         if (node != null) {
109             updateAttribute(value);
110         }
111     }
112 
113     /**
114      * Returns the length property.
115      * @return the length
116      */
117     @JsxGetter
118     public int getLength() {
119         final String value = getValue();
120         if (org.htmlunit.util.StringUtils.isBlank(value)) {
121             return 0;
122         }
123 
124         return split(value).size();
125     }
126 
127     /**
128      * {@inheritDoc}
129      */
130     @Override
131     public String getDefaultValue(final Class<?> hint) {
132         if (getPrototype() == null) {
133             return (String) super.getDefaultValue(hint);
134         }
135 
136         final String value = getValue();
137         if (value != null) {
138             return String.join(" ", StringUtils.split(value, WHITESPACE_CHARS));
139         }
140         return "";
141     }
142 
143     /**
144      * Adds the given tokens to the list, omitting any that are already present.
145      *
146      * @param context the JavaScript context
147      * @param scope the scope
148      * @param thisObj the scriptable
149      * @param args the arguments passed into the method
150      * @param function the function
151      */
152     @JsxFunction
153     public static void add(final Context context, final VarScope scope,
154             final Scriptable thisObj, final Object[] args, final Function function) {
155         if (args.length > 0) {
156             final DOMTokenList list = (DOMTokenList) thisObj;
157             final List<String> parts = split(list.getValue());
158 
159             for (final Object arg : args) {
160                 final String token = JavaScriptEngine.toString(arg);
161 
162                 if (org.htmlunit.util.StringUtils.isEmptyOrNull(token)) {
163                     throw JavaScriptEngine.asJavaScriptException(
164                             (HtmlUnitScriptable) getTopLevelScope(scope).getGlobalThis(),
165                             "DOMTokenList: add() does not support empty tokens",
166                             DOMException.SYNTAX_ERR);
167                 }
168                 if (StringUtils.containsAny(token, WHITESPACE_CHARS)) {
169                     throw JavaScriptEngine.asJavaScriptException(
170                             (HtmlUnitScriptable) getTopLevelScope(scope).getGlobalThis(),
171                             "DOMTokenList: add() does not support whitespace chars in tokens",
172                             DOMException.INVALID_CHARACTER_ERR);
173                 }
174 
175                 if (!parts.contains(token)) {
176                     parts.add(token);
177                 }
178             }
179             list.updateAttribute(String.join(" ", parts));
180         }
181     }
182 
183     /**
184      * Removes the specified tokens from the underlying string.
185      *
186      * @param context the JavaScript context
187      * @param scope the scope
188      * @param thisObj the scriptable
189      * @param args the arguments passed into the method
190      * @param function the function
191      */
192     @JsxFunction
193     public static void remove(final Context context, final VarScope scope,
194             final Scriptable thisObj, final Object[] args, final Function function) {
195         final DOMTokenList list = (DOMTokenList) thisObj;
196 
197         final String value = list.getValue();
198         if (value == null) {
199             return;
200         }
201 
202         if (args.length > 0) {
203             final List<String> parts = split(list.getValue());
204 
205             for (final Object arg : args) {
206                 final String token = JavaScriptEngine.toString(arg);
207 
208                 if (org.htmlunit.util.StringUtils.isEmptyOrNull(token)) {
209                     throw JavaScriptEngine.asJavaScriptException(
210                             (HtmlUnitScriptable) getTopLevelScope(scope).getGlobalThis(),
211                             "DOMTokenList: remove() does not support empty tokens",
212                             DOMException.SYNTAX_ERR);
213                 }
214                 if (StringUtils.containsAny(token, WHITESPACE_CHARS)) {
215                     throw JavaScriptEngine.asJavaScriptException(
216                             (HtmlUnitScriptable) getTopLevelScope(scope).getGlobalThis(),
217                             "DOMTokenList: remove() does not support whitespace chars in tokens",
218                             DOMException.INVALID_CHARACTER_ERR);
219                 }
220 
221                 parts.remove(token);
222             }
223             list.updateAttribute(String.join(" ", parts));
224         }
225     }
226 
227     /**
228      * Replaces an existing token with a new token. If the first token doesn't exist, replace()
229      * returns false immediately, without adding the new token to the token list.
230      * @param oldToken a string representing the token you want to replace
231      * @param newToken a string representing the token you want to replace oldToken with
232      * @return true if oldToken was successfully replaced, or false if not
233      */
234     @JsxFunction
235     public boolean replace(final String oldToken, final String newToken) {
236         if (org.htmlunit.util.StringUtils.isEmptyOrNull(oldToken)) {
237             throw JavaScriptEngine.asJavaScriptException(
238                     getWindow(),
239                     "Empty oldToken not allowed",
240                     DOMException.SYNTAX_ERR);
241         }
242         if (StringUtils.containsAny(oldToken, WHITESPACE_CHARS)) {
243             throw JavaScriptEngine.asJavaScriptException(
244                     getWindow(),
245                     "DOMTokenList: replace() oldToken contains whitespace",
246                     DOMException.INVALID_CHARACTER_ERR);
247         }
248 
249         if (org.htmlunit.util.StringUtils.isEmptyOrNull(newToken)) {
250             throw JavaScriptEngine.asJavaScriptException(
251                     getWindow(),
252                     "Empty newToken not allowed",
253                     DOMException.SYNTAX_ERR);
254         }
255         if (StringUtils.containsAny(newToken, WHITESPACE_CHARS)) {
256             throw JavaScriptEngine.asJavaScriptException(
257                     getWindow(),
258                     "DOMTokenList: replace() newToken contains whitespace",
259                     DOMException.INVALID_CHARACTER_ERR);
260         }
261 
262         final String value = getValue();
263         if (value == null) {
264             return false;
265         }
266         final List<String> parts = split(value);
267         final int pos = parts.indexOf(oldToken);
268         if (pos == -1) {
269             return false;
270         }
271 
272         parts.set(pos, newToken);
273         updateAttribute(String.join(" ", parts));
274         return true;
275     }
276 
277     /**
278      * Toggles the token, adding or removing it.
279      * @param token the token to add or remove
280      * @return whether the string now contains the token or not
281      */
282     @JsxFunction
283     public boolean toggle(final String token) {
284         if (org.htmlunit.util.StringUtils.isEmptyOrNull(token)) {
285             throw JavaScriptEngine.asJavaScriptException(
286                     getWindow(),
287                     "DOMTokenList: toggle() does not support empty tokens",
288                     DOMException.SYNTAX_ERR);
289         }
290         if (StringUtils.containsAny(token, WHITESPACE_CHARS)) {
291             throw JavaScriptEngine.asJavaScriptException(
292                     getWindow(),
293                     "DOMTokenList: toggle() does not support whitespace chars in tokens",
294                     DOMException.INVALID_CHARACTER_ERR);
295         }
296 
297         final List<String> parts = split(getValue());
298         if (parts.contains(token)) {
299             parts.remove(token);
300             updateAttribute(String.join(" ", parts));
301             return false;
302         }
303 
304         parts.add(token);
305         updateAttribute(String.join(" ", parts));
306         return true;
307     }
308 
309     /**
310      * Checks if the specified token is contained in the underlying string.
311      * @param token the token to add
312      * @return true if the underlying string contains token, otherwise false
313      */
314     @JsxFunction
315     public boolean contains(final String token) {
316         if (org.htmlunit.util.StringUtils.isBlank(token)) {
317             return false;
318         }
319 
320         if (org.htmlunit.util.StringUtils.isEmptyOrNull(token)) {
321             throw JavaScriptEngine.reportRuntimeError("DOMTokenList: contains() does not support empty tokens");
322         }
323         if (StringUtils.containsAny(token, WHITESPACE_CHARS)) {
324             throw JavaScriptEngine.reportRuntimeError(
325                     "DOMTokenList: contains() does not support whitespace chars in tokens");
326         }
327 
328         final List<String> parts = split(getValue());
329         return parts.contains(token);
330     }
331 
332     /**
333      * Returns the item at the specified index.
334      * @param index the index of the item
335      * @return the item
336      */
337     @JsxFunction
338     public String item(final int index) {
339         if (index < 0) {
340             return null;
341         }
342 
343         final String value = getValue();
344         if (org.htmlunit.util.StringUtils.isEmptyOrNull(value)) {
345             return null;
346         }
347 
348         final List<String> parts = split(value);
349         if (index < parts.size()) {
350             return parts.get(index);
351         }
352 
353         return null;
354     }
355 
356     /**
357      * Returns an Iterator allowing to go through all keys contained in this object.
358      * @return a NativeArrayIterator
359      */
360     @JsxFunction
361     public Scriptable keys() {
362         return JavaScriptEngine.newArrayIteratorTypeKeys(getParentScope(), this);
363     }
364 
365     /**
366      * {@inheritDoc}.
367      */
368     @Override
369     public Object[] getIds() {
370         final Object[] normalIds = super.getIds();
371 
372         final String value = getValue();
373         if (org.htmlunit.util.StringUtils.isEmptyOrNull(value)) {
374             return normalIds;
375         }
376 
377         final List<String> parts = split(getValue());
378         final Object[] ids = new Object[parts.size() + normalIds.length];
379         final int size = parts.size();
380         for (int i = 0; i < size; i++) {
381             ids[i] = i;
382         }
383         System.arraycopy(normalIds, 0, ids, parts.size(), normalIds.length);
384 
385         return ids;
386     }
387 
388     /**
389      * Returns an Iterator allowing to go through all keys contained in this object.
390      * @return a NativeArrayIterator
391      */
392     @JsxFunction
393     @JsxSymbol(symbolName = "iterator")
394     public Scriptable values() {
395         return JavaScriptEngine.newArrayIteratorTypeValues(getParentScope(), this);
396     }
397 
398     /**
399      * Returns an Iterator allowing to go through all key/value pairs contained in this object.
400      * @return a NativeArrayIterator
401      */
402     @JsxFunction
403     public Scriptable entries() {
404         return JavaScriptEngine.newArrayIteratorTypeEntries(getParentScope(), this);
405     }
406 
407     /**
408      * Calls the {@code callback} given in parameter once for each value in the list.
409      * @param callback function to execute for each element
410      */
411     @JsxFunction
412     public void forEach(final Object callback) {
413         if (!(callback instanceof Function function)) {
414             throw JavaScriptEngine.typeError(
415                     "Foreach callback '" + JavaScriptEngine.toString(callback) + "' is not a function");
416         }
417 
418         final String value = getValue();
419         if (org.htmlunit.util.StringUtils.isEmptyOrNull(value)) {
420             return;
421         }
422 
423         final WebClient client = getWindow().getWebWindow().getWebClient();
424         final HtmlUnitContextFactory cf = client.getJavaScriptEngine().getContextFactory();
425 
426         final ContextAction<Object> contextAction = cx -> {
427             final VarScope scope = getParentScope();
428 
429             List<String> parts = split(value);
430             final int size = parts.size();
431             int i = 0;
432             while (i < size && i < parts.size()) {
433                 function.call(cx, scope, this, new Object[] {parts.get(i), i, this});
434 
435                 // refresh
436                 parts = split(getValue());
437                 i++;
438             }
439             return null;
440         };
441         cf.call(contextAction);
442     }
443 
444     /**
445      * {@inheritDoc}
446      */
447     @Override
448     public Object get(final int index, final Scriptable start) {
449         final Object value = item(index);
450         if (value == null) {
451             return JavaScriptEngine.UNDEFINED;
452         }
453         return value;
454     }
455 
456     private void updateAttribute(final String value) {
457         final DomElement domNode = (DomElement) getDomNodeOrDie();
458 
459         // always create a new one because the old one is used later for the mutation observer
460         // to get the old value from
461         final DomAttr attr = domNode.getPage().createAttribute(attributeName_);
462         attr.setValue(value);
463         domNode.setAttributeNode(attr);
464     }
465 
466     private static List<String> split(final String value) {
467         if (org.htmlunit.util.StringUtils.isEmptyOrNull(value)) {
468             return new ArrayList<>();
469         }
470 
471         final String[] parts = StringUtils.split(value, WHITESPACE_CHARS);
472 
473         // usually a short list, no index needed
474         final List<String> result = new ArrayList<>();
475         for (final String part : parts) {
476             if (!result.contains(part)) {
477                 result.add(part);
478             }
479         }
480         return result;
481     }
482 }