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