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.event;
16  
17  import java.io.Serializable;
18  import java.util.ArrayList;
19  import java.util.Collections;
20  import java.util.List;
21  import java.util.Locale;
22  import java.util.concurrent.ConcurrentHashMap;
23  import java.util.concurrent.ConcurrentMap;
24  
25  import org.apache.commons.logging.Log;
26  import org.apache.commons.logging.LogFactory;
27  import org.htmlunit.ScriptResult;
28  import org.htmlunit.corejs.javascript.Function;
29  import org.htmlunit.corejs.javascript.NativeObject;
30  import org.htmlunit.corejs.javascript.Scriptable;
31  import org.htmlunit.corejs.javascript.ScriptableObject;
32  import org.htmlunit.corejs.javascript.TopLevel;
33  import org.htmlunit.corejs.javascript.VarScope;
34  import org.htmlunit.corejs.javascript.WithScope;
35  import org.htmlunit.html.DomNode;
36  import org.htmlunit.html.HtmlPage;
37  import org.htmlunit.javascript.JavaScriptEngine;
38  import org.htmlunit.javascript.host.Window;
39  import org.htmlunit.javascript.host.html.HTMLDocument;
40  import org.htmlunit.javascript.host.html.HTMLElement;
41  
42  /**
43   * Container for event listeners.
44   *
45   * @author Marc Guillemot
46   * @author Daniel Gredler
47   * @author Ahmed Ashour
48   * @author Frank Danek
49   * @author Ronald Brill
50   * @author Atsushi Nakagawa
51   */
52  public class EventListenersContainer implements Serializable {
53  
54      private static final Log LOG = LogFactory.getLog(EventListenersContainer.class);
55  
56      // Refactoring note: This seems ad-hoc. Shouldn't synchronization be orchestrated between
57      // JS thread and main thread at a much higher layer?  Anyway, to preserve behaviour of prior
58      // coding where 'synchronized' was used more explicitly, we're using a ConcurrentHashMap here
59      // and using ConcurrentMap.compute() to mutate below so that mutations are atomic.  This for
60      // example avoids the case where two concurrent addListener()s can result in either being lost.
61      private final ConcurrentMap<String, TypeContainer> typeContainers_ = new ConcurrentHashMap<>();
62      private final EventTarget jsNode_;
63  
64      private record TypeContainer(List<Scriptable> capturingListeners_, List<Scriptable> bubblingListeners_,
65                                   List<Scriptable> atTargetListeners_, Function handler_) implements Serializable {
66          public static final TypeContainer EMPTY = new TypeContainer();
67  
68          // This sentinel value could be some singleton instance but null
69          // isn't used for anything else so why not.
70          private static final Scriptable EVENT_HANDLER_PLACEHOLDER = null;
71  
72          TypeContainer() {
73              this(Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), null);
74          }
75  
76          List<Scriptable> getListeners(final int eventPhase) {
77              return switch (eventPhase) {
78                  case Event.CAPTURING_PHASE -> capturingListeners_;
79                  case Event.AT_TARGET -> atTargetListeners_;
80                  case Event.BUBBLING_PHASE -> bubblingListeners_;
81                  default -> throw new UnsupportedOperationException("eventPhase: " + eventPhase);
82              };
83          }
84  
85          public TypeContainer setPropertyHandler(final Function propertyHandler) {
86              if (propertyHandler != null) {
87                  // If we already have a handler then the position of the existing
88                  // placeholder should not be changed so just change the handler
89                  if (handler_ != null) {
90                      if (propertyHandler == handler_) {
91                          return this;
92                      }
93                      return withPropertyHandler(propertyHandler);
94                  }
95  
96                  // Insert the placeholder and set the handler
97                  return withPropertyHandler(propertyHandler).addListener(EVENT_HANDLER_PLACEHOLDER, false);
98              }
99              if (handler_ == null) {
100                 return this;
101             }
102             return removeListener(EVENT_HANDLER_PLACEHOLDER, false).withPropertyHandler(null);
103         }
104 
105         private TypeContainer withPropertyHandler(final Function propertyHandler) {
106             return new TypeContainer(capturingListeners_, bubblingListeners_, atTargetListeners_, propertyHandler);
107         }
108 
109         public TypeContainer addListener(final Scriptable listener, final boolean useCapture) {
110             List<Scriptable> capturingListeners = capturingListeners_;
111             List<Scriptable> bubblingListeners = bubblingListeners_;
112             final List<Scriptable> listeners = useCapture ? capturingListeners : bubblingListeners;
113 
114             if (listeners.contains(listener)) {
115                 return this;
116             }
117 
118             List<Scriptable> newListeners = new ArrayList<>(listeners.size() + 1);
119             newListeners.addAll(listeners);
120             newListeners.add(listener);
121             newListeners = Collections.unmodifiableList(newListeners);
122 
123             if (useCapture) {
124                 capturingListeners = newListeners;
125             }
126             else {
127                 bubblingListeners = newListeners;
128             }
129 
130             List<Scriptable> atTargetListeners = new ArrayList<>(atTargetListeners_.size() + 1);
131             atTargetListeners.addAll(atTargetListeners_);
132             atTargetListeners.add(listener);
133             atTargetListeners = Collections.unmodifiableList(atTargetListeners);
134 
135             return new TypeContainer(capturingListeners, bubblingListeners, atTargetListeners, handler_);
136         }
137 
138         public TypeContainer removeListener(final Scriptable listener, final boolean useCapture) {
139             List<Scriptable> capturingListeners = capturingListeners_;
140             List<Scriptable> bubblingListeners = bubblingListeners_;
141             final List<Scriptable> listeners = useCapture ? capturingListeners : bubblingListeners;
142 
143             final int idx = listeners.indexOf(listener);
144             if (idx < 0) {
145                 return this;
146             }
147 
148             List<Scriptable> newListeners = new ArrayList<>(listeners);
149             newListeners.remove(idx);
150             newListeners = Collections.unmodifiableList(newListeners);
151 
152             if (useCapture) {
153                 capturingListeners = newListeners;
154             }
155             else {
156                 bubblingListeners = newListeners;
157             }
158 
159             List<Scriptable> atTargetListeners = new ArrayList<>(atTargetListeners_);
160             atTargetListeners.remove(listener);
161             atTargetListeners = Collections.unmodifiableList(atTargetListeners);
162 
163             return new TypeContainer(capturingListeners, bubblingListeners, atTargetListeners, handler_);
164         }
165 
166         // Refactoring note: This method doesn't appear to be used
167         @Override
168         protected TypeContainer clone() {
169             return new TypeContainer(capturingListeners_, bubblingListeners_, atTargetListeners_, handler_);
170         }
171     }
172 
173     /**
174      * Creates a new container for the given event target node.
175      *
176      * @param jsNode the node
177      */
178     public EventListenersContainer(final EventTarget jsNode) {
179         jsNode_ = jsNode;
180     }
181 
182     /**
183      * Adds an event listener.
184      *
185      * @param type the event type to listen for (e.g. {@code "load"})
186      * @param listener the event listener
187      * @param useCapture if {@code true}, the listener is added for the capture phase
188      * @return {@code true} if the listener was added; {@code false} if it was already registered
189      */
190     public boolean addEventListener(final String type, final Scriptable listener, final boolean useCapture) {
191         if (listener == null) {
192             return true;
193         }
194 
195         final boolean[] added = {false};
196         typeContainers_.compute(type.toLowerCase(Locale.ROOT), (k, container) -> {
197             if (container == null) {
198                 container = TypeContainer.EMPTY;
199             }
200             final TypeContainer newContainer = container.addListener(listener, useCapture);
201             added[0] = newContainer != container;
202             return newContainer;
203         });
204 
205         if (!added[0]) {
206             if (LOG.isDebugEnabled()) {
207                 LOG.debug(type + " listener already registered, skipping it (" + listener + ")");
208             }
209             return false;
210         }
211         return true;
212     }
213 
214     private TypeContainer getTypeContainer(final String type) {
215         final String typeLC = type.toLowerCase(Locale.ROOT);
216         return typeContainers_.getOrDefault(typeLC, TypeContainer.EMPTY);
217     }
218 
219     /**
220      * Returns the listeners for the given event type and capture mode.
221      *
222      * @param eventType the event type
223      * @param useCapture whether to return capture-phase listeners
224      * @return the list of listeners (empty list if none)
225      */
226     public List<Scriptable> getListeners(final String eventType, final boolean useCapture) {
227         return getTypeContainer(eventType).getListeners(useCapture ? Event.CAPTURING_PHASE : Event.BUBBLING_PHASE);
228     }
229 
230     /**
231      * Removes an event listener.
232      *
233      * @param eventType the event type
234      * @param listener the listener to remove
235      * @param useCapture whether to remove from the capture phase
236      */
237     void removeEventListener(final String eventType, final Scriptable listener, final boolean useCapture) {
238         if (listener == null) {
239             return;
240         }
241 
242         typeContainers_.computeIfPresent(eventType.toLowerCase(Locale.ROOT),
243             (k, container) -> container.removeListener(listener, useCapture));
244     }
245 
246     /**
247      * Sets the property handler for the given event type.
248      *
249      * @param eventType the event type (e.g. {@code "click"})
250      * @param value the new handler, or {@code null} to remove it
251      */
252     public void setEventHandler(final String eventType, final Object value) {
253         final Function handler;
254 
255         // Otherwise, ignore silently.
256         if (JavaScriptEngine.isUndefined(value) || !(value instanceof Function)) {
257             handler = null;
258         }
259         else {
260             handler = (Function) value;
261         }
262 
263         typeContainers_.compute(eventType.toLowerCase(Locale.ROOT), (k, container) -> {
264             if (container == null) {
265                 container = TypeContainer.EMPTY;
266             }
267             return container.setPropertyHandler(handler);
268         });
269     }
270 
271     private void executeEventListeners(final int eventPhase, final Event event, final Object[] args) {
272         final DomNode node = jsNode_.getDomNodeOrNull();
273         // some event don't apply on all kind of nodes, for instance "blur"
274         if (node != null && !node.handles(event)) {
275             return;
276         }
277 
278         final TypeContainer container = getTypeContainer(event.getType());
279         final List<Scriptable> listeners = container.getListeners(eventPhase);
280         if (!listeners.isEmpty()) {
281             event.setCurrentTarget(jsNode_);
282 
283             final HtmlPage page;
284             if (jsNode_ instanceof Window) {
285                 page = (HtmlPage) jsNode_.getDomNodeOrDie();
286             }
287             else {
288                 Scriptable scriptableObj = null;
289                 final VarScope parentScope = jsNode_.getParentScope();
290                 if (parentScope instanceof TopLevel topLevel) {
291                     scriptableObj = topLevel.getGlobalThis();
292                 }
293                 else if (parentScope instanceof WithScope withScope) {
294                     scriptableObj = withScope.getObject();
295                 }
296 
297                 if (scriptableObj instanceof Window window) {
298                     page = (HtmlPage) window.getDomNodeOrDie();
299                 }
300                 else if (scriptableObj instanceof HTMLDocument document) {
301                     page = document.getPage();
302                 }
303                 else if (scriptableObj != null) {
304                     page = ((HTMLElement) scriptableObj).getDomNodeOrDie().getHtmlPageOrNull();
305                 }
306                 else {
307                     page = null;
308                     throw new UnsupportedOperationException("TODO ");
309                 }
310             }
311 
312             // no need for a copy, listeners are copy on write
313             for (Scriptable listener : listeners) {
314                 boolean isPropertyHandler = false;
315                 if (listener == TypeContainer.EVENT_HANDLER_PLACEHOLDER) {
316                     listener = container.handler_;
317                     isPropertyHandler = true;
318                 }
319                 Function function = null;
320                 Scriptable thisObject = null;
321                 if (listener instanceof Function function2) {
322                     function = function2;
323                     thisObject = jsNode_;
324                 }
325                 else if (listener instanceof NativeObject) {
326                     final Object handleEvent = ScriptableObject.getProperty(listener, "handleEvent");
327                     if (handleEvent instanceof Function function1) {
328                         function = function1;
329                         thisObject = listener;
330                     }
331                 }
332                 if (function != null) {
333                     final ScriptResult result =
334                             page.executeJavaScriptFunction(function, thisObject, args, node);
335                     // Return value is only honored for property handlers (Tested in Chrome/FF/IE11)
336                     if (isPropertyHandler && !ScriptResult.isUndefined(result)) {
337                         event.handlePropertyHandlerReturnValue(result.getJavaScriptResult());
338                     }
339                 }
340                 if (event.isImmediatePropagationStopped()) {
341                     return;
342                 }
343             }
344         }
345     }
346 
347     /**
348      * Executes bubbling listeners for the given event.
349      *
350      * @param event the event
351      * @param args the arguments
352      */
353     public void executeBubblingListeners(final Event event, final Object[] args) {
354         executeEventListeners(Event.BUBBLING_PHASE, event, args);
355     }
356 
357     /**
358      * Executes capturing listeners for the given event.
359      *
360      * @param event the event
361      * @param args the arguments
362      */
363     public void executeCapturingListeners(final Event event, final Object[] args) {
364         executeEventListeners(Event.CAPTURING_PHASE, event, args);
365     }
366 
367     /**
368      * Executes listeners for events targeting this node (non-propagation phase).
369      *
370      * @param event the event
371      * @param args the arguments
372      */
373     public void executeAtTargetListeners(final Event event, final Object[] args) {
374         executeEventListeners(Event.AT_TARGET, event, args);
375     }
376 
377     /**
378      * Returns the event handler function for the given event type.
379      *
380      * @param eventType the event type (e.g. {@code "click"})
381      * @return the handler function, or {@code null} if not set
382      */
383     public Function getEventHandler(final String eventType) {
384         return getTypeContainer(eventType).handler_;
385     }
386 
387     /**
388      * Returns whether there are any event listeners registered for the given event type.
389      *
390      * @param eventType the event type (e.g. {@code "click"})
391      * @return {@code true} if there are any listeners, {@code false} otherwise
392      */
393     boolean hasEventListeners(final String eventType) {
394         return !getTypeContainer(eventType).atTargetListeners_.isEmpty();
395     }
396 
397     /**
398      * {@inheritDoc}
399      */
400     @Override
401     public String toString() {
402         return getClass().getSimpleName() + "[node=" + jsNode_ + " handlers=" + typeContainers_.keySet() + "]";
403     }
404 }