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