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.IOException;
18 import java.util.ArrayList;
19 import java.util.List;
20
21 import org.apache.commons.lang3.StringUtils;
22 import org.htmlunit.Page;
23 import org.htmlunit.ScriptResult;
24 import org.htmlunit.corejs.javascript.Function;
25 import org.htmlunit.corejs.javascript.Scriptable;
26 import org.htmlunit.html.DomElement;
27 import org.htmlunit.html.DomNode;
28 import org.htmlunit.html.HtmlElement;
29 import org.htmlunit.html.HtmlLabel;
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.host.Window;
36 import org.htmlunit.javascript.host.dom.Document;
37
38 /**
39 * JavaScript host object for {@code EventTarget}.
40 *
41 * @author Ahmed Ashour
42 * @author Ronald Brill
43 * @author Atsushi Nakagawa
44 *
45 * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventTarget">MDN Documentation</a>
46 */
47 @JsxClass
48 public class EventTarget extends HtmlUnitScriptable {
49
50 private EventListenersContainer eventListenersContainer_;
51
52 /**
53 * Creates an instance of this object.
54 */
55 @JsxConstructor
56 public void jsConstructor() {
57 // nothing to do
58 }
59
60 /**
61 * Registers an event listener on this event target.
62 *
63 * @param type the event type to listen for (e.g. {@code "click"})
64 * @param listener the event listener
65 * @param useCapture if {@code true}, indicates that the listener should be added for the capture phase
66 * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener">MDN Documentation</a>
67 */
68 @JsxFunction
69 public void addEventListener(final String type, final Scriptable listener, final boolean useCapture) {
70 getEventListenersContainer().addEventListener(type, listener, useCapture);
71 }
72
73 /**
74 * Returns the container for event listeners, creating it if necessary.
75 *
76 * @return the event listeners container
77 */
78 public final EventListenersContainer getEventListenersContainer() {
79 if (eventListenersContainer_ == null) {
80 eventListenersContainer_ = new EventListenersContainer(this);
81 }
82 return eventListenersContainer_;
83 }
84
85 /**
86 * Executes the event on this object only (needed for instance for {@code onload} on {@code (i)frame} tags).
87 *
88 * @param event the event to execute
89 * @see #fireEvent(Event)
90 */
91 public void executeEventLocally(final Event event) {
92 final EventListenersContainer eventListenersContainer = getEventListenersContainer();
93 final Window window = getWindow();
94 final Object[] args = {event};
95
96 final Event previousEvent = window.getCurrentEvent();
97 window.setCurrentEvent(event);
98 try {
99 event.setEventPhase(Event.AT_TARGET);
100 eventListenersContainer.executeAtTargetListeners(event, args);
101 }
102 finally {
103 window.setCurrentEvent(previousEvent); // reset event
104 }
105 }
106
107 /**
108 * Fires the event on this node with capturing and bubbling phases.
109 *
110 * @param event the event to fire
111 * @return the script result
112 */
113 public ScriptResult fireEvent(final Event event) {
114 final Window window = getWindow();
115
116 event.startFire();
117 final Event previousEvent = window.getCurrentEvent();
118 window.setCurrentEvent(event);
119
120 try {
121 // These can be null if we aren't tied to a DOM node
122 final DomNode ourNode = getDomNodeOrNull();
123 final DomNode ourParentNode = (ourNode != null) ? ourNode.getParentNode() : null;
124
125 // Determine the propagation path which is fixed here and not affected by
126 // DOM tree modification from intermediate listeners (tested in Chrome)
127 final List<EventTarget> propagationPath = new ArrayList<>();
128
129 // We're added to the propagation path first
130 propagationPath.add(this);
131
132 // Then add all our parents if we have any (pure JS object such as XMLHttpRequest
133 // and MessagePort, etc. will not have any parents)
134 for (DomNode parent = ourParentNode; parent != null; parent = parent.getParentNode()) {
135 // scroll does not bubble into the document/window
136 if (Event.TYPE_SCROLL.equals(event.getType()) && parent instanceof Page) {
137 break;
138 }
139
140 propagationPath.add(parent.getScriptableObject());
141 }
142
143 // The load event has some unnatural behavior that we need to handle specially
144 // The load event for other elements target that element but path only
145 // up to Document and not Window, so do nothing here
146 // (see Note in https://www.w3.org/TR/DOM-Level-3-Events/#event-type-load)
147 if (!Event.TYPE_LOAD.equals(event.getType())) {
148 // Add Window if the propagation path reached Document
149 if (propagationPath.get(propagationPath.size() - 1) instanceof Document) {
150 propagationPath.add(window);
151 }
152 }
153
154 // capturing phase
155 event.setEventPhase(Event.CAPTURING_PHASE);
156
157 for (int i = propagationPath.size() - 1; i >= 1; i--) {
158 final EventTarget jsNode = propagationPath.get(i);
159 final EventListenersContainer elc = jsNode.eventListenersContainer_;
160 if (elc != null) {
161 elc.executeCapturingListeners(event, new Object[] {event});
162 if (event.isPropagationStopped()) {
163 return new ScriptResult(null);
164 }
165 }
166 }
167
168 // at target phase
169 event.setEventPhase(Event.AT_TARGET);
170
171 if (!propagationPath.isEmpty()) {
172 // Note: This element is not always the same as event.getTarget():
173 // e.g. the 'load' event targets Document but "at target" is on Window.
174 final EventTarget jsNode = propagationPath.get(0);
175 final EventListenersContainer elc = jsNode.eventListenersContainer_;
176 if (elc != null) {
177 elc.executeAtTargetListeners(event, new Object[] {event});
178 if (event.isPropagationStopped()) {
179 return new ScriptResult(null);
180 }
181 }
182 }
183
184 // bubbling phase
185 if (event.isBubbles()) {
186 // This belongs here inside the block because events that don't bubble never set
187 // eventPhase = 3 (tested in Chrome)
188 event.setEventPhase(Event.BUBBLING_PHASE);
189
190 final int size = propagationPath.size();
191 for (int i = 1; i < size; i++) {
192 final EventTarget jsNode = propagationPath.get(i);
193 final EventListenersContainer elc = jsNode.eventListenersContainer_;
194 if (elc != null) {
195 elc.executeBubblingListeners(event, new Object[] {event});
196 if (event.isPropagationStopped()) {
197 return new ScriptResult(null);
198 }
199 }
200 }
201 }
202
203 HtmlLabel label = null;
204 if (event.processLabelAfterBubbling()) {
205 for (DomNode parent = ourParentNode; parent != null; parent = parent.getParentNode()) {
206 if (parent instanceof HtmlLabel htmlLabel) {
207 label = htmlLabel;
208 break;
209 }
210 }
211 }
212
213 if (label != null) {
214 final HtmlElement element = label.getLabeledElement();
215 if (element != null && element != getDomNodeOrNull()) {
216 try {
217 element.click(event.isShiftKey(), event.isCtrlKey(), event.isAltKey(), false, true, true, true);
218 }
219 catch (final IOException ignored) {
220 // ignore for now
221 }
222 }
223 }
224
225 }
226 finally {
227 event.endFire();
228 window.setCurrentEvent(previousEvent); // reset event
229 }
230
231 return new ScriptResult(null);
232 }
233
234 /**
235 * Returns whether there are any event handlers for the specified event name.
236 *
237 * @param eventName the event name (e.g. {@code "onclick"})
238 * @return {@code true} if there are any event handlers, {@code false} otherwise
239 */
240 public boolean hasEventHandlers(final String eventName) {
241 if (eventListenersContainer_ == null) {
242 return false;
243 }
244 return eventListenersContainer_.hasEventListeners(StringUtils.substring(eventName, 2));
245 }
246
247 /**
248 * Returns the specified event handler function.
249 *
250 * @param eventType the event type (e.g. {@code "click"})
251 * @return the handler function, or {@code null} if not set
252 */
253 public Function getEventHandler(final String eventType) {
254 if (eventListenersContainer_ == null) {
255 return null;
256 }
257 return eventListenersContainer_.getEventHandler(eventType);
258 }
259
260 /**
261 * Dispatches an event into the event system.
262 *
263 * @param event the event to be dispatched
264 * @return {@code false} if at least one of the event handlers called {@code preventDefault()};
265 * {@code true} otherwise
266 * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent">MDN Documentation</a>
267 */
268 @JsxFunction
269 public boolean dispatchEvent(final Event event) {
270 event.setTarget(this);
271
272 ScriptResult result = null;
273 final DomNode domNode = getDomNodeOrNull();
274 if (MouseEvent.TYPE_CLICK.equals(event.getType()) && (domNode instanceof DomElement element)) {
275 try {
276 element.click(event, event.isShiftKey(), event.isCtrlKey(), event.isAltKey(), true);
277 }
278 catch (final IOException e) {
279 throw JavaScriptEngine.reportRuntimeError("Error calling click(): " + e.getMessage());
280 }
281 }
282 else {
283 result = fireEvent(event);
284 }
285 return !event.isAborted(result);
286 }
287
288 /**
289 * Removes a previously registered event listener from this event target.
290 *
291 * @param type the event type (e.g. {@code "click"})
292 * @param listener the listener to remove
293 * @param useCapture if {@code true}, the listener is removed from the capture phase
294 * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener">MDN Documentation</a>
295 */
296 @JsxFunction
297 public void removeEventListener(final String type, final Scriptable listener, final boolean useCapture) {
298 if (eventListenersContainer_ == null) {
299 return;
300 }
301 eventListenersContainer_.removeEventListener(type, listener, useCapture);
302 }
303
304 /**
305 * Defines an event handler (or any other object) for the given event name.
306 *
307 * @param eventName the event name (e.g. {@code "click"})
308 * @param value the handler ({@code null} to reset it)
309 */
310 public void setEventHandler(final String eventName, final Object value) {
311 if (isEventHandlerOnWindow()) {
312 getWindow().getEventListenersContainer().setEventHandler(eventName, value);
313 return;
314 }
315 getEventListenersContainer().setEventHandler(eventName, value);
316 }
317
318 /**
319 * Returns whether the event handler property should be set at the window level.
320 *
321 * @return {@code true} if the event handler should be set at window level
322 */
323 protected boolean isEventHandlerOnWindow() {
324 return false;
325 }
326
327 /**
328 * Clears the event listener container.
329 */
330 protected void clearEventListenersContainer() {
331 eventListenersContainer_ = null;
332 }
333 }