From df6b71541e2f41ea1df9451dc8450765c15e0193 Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Tue, 9 Feb 2021 18:25:32 -0800 Subject: [PATCH] GUACAMOLE-1204: Add generic, listener-driven event system. --- guacamole-common-js/pom.xml | 22 ++ .../src/main/webapp/modules/Event.js | 243 ++++++++++++++++++ .../src/test/javascript/EventSpec.js | 139 ++++++++++ 3 files changed, 404 insertions(+) create mode 100644 guacamole-common-js/src/main/webapp/modules/Event.js create mode 100644 guacamole-common-js/src/test/javascript/EventSpec.js diff --git a/guacamole-common-js/pom.xml b/guacamole-common-js/pom.xml index e38ea1c39..9025e9a95 100644 --- a/guacamole-common-js/pom.xml +++ b/guacamole-common-js/pom.xml @@ -135,7 +135,29 @@ + + + + com.github.searls + jasmine-maven-plugin + 2.2 + + + + test + + + + + + 2.1.1 + + + **/*.min.js + + ${project.build.directory}/${project.build.finalName} + diff --git a/guacamole-common-js/src/main/webapp/modules/Event.js b/guacamole-common-js/src/main/webapp/modules/Event.js new file mode 100644 index 000000000..d83330553 --- /dev/null +++ b/guacamole-common-js/src/main/webapp/modules/Event.js @@ -0,0 +1,243 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +var Guacamole = Guacamole || {}; + +/** + * An arbitrary event, emitted by a {@link Guacamole.Event.Target}. This object + * should normally serve as the base class for a different object that is more + * specific to the event type. + * + * @constructor + * @param {String} type + * The unique name of this event type. + */ +Guacamole.Event = function Event(type) { + + /** + * The unique name of this event type. + * + * @type {String} + */ + this.type = type; + + /** + * An arbitrary timestamp in milliseconds, indicating this event's + * position in time relative to other events. + * + * @type {Number} + */ + this.timestamp = new Date().getTime(); + + /** + * Returns the number of milliseconds elapsed since this event was created. + * + * @return {Number} + * The number of milliseconds elapsed since this event was created. + */ + this.getAge = function getAge() { + return new Date().getTime() - this.timestamp; + }; + + /** + * Requests that the legacy event handler associated with this event be + * invoked on the given event target. This function will be invoked + * automatically by implementations of {@link Guacamole.Event.Target} + * whenever {@link Guacamole.Event.Target#emit emit()} is invoked. + *

+ * Older versions of Guacamole relied on single event handlers with the + * prefix "on", such as "onmousedown" or "onkeyup". If a Guacamole.Event + * implementation is replacing the event previously represented by one of + * these handlers, this function gives the implementation the opportunity + * to provide backward compatibility with the old handler. + *

+ * Unless overridden, this function does nothing. + * + * @param {Guacamole.Event.Target} eventTarget + * The {@link Guacamole.Event.Target} that emitted this event. + */ + this.invokeLegacyHandler = function invokeLegacyHandler(eventTarget) { + // Do nothing + }; + +}; + +/** + * A {@link Guacamole.Event} that relates to one or more DOM events. Continued + * propagation and default behavior of the related DOM events may be prevented + * with {@link Guacamole.Event.DOMEvent#stopPropagation stopPropagation()} and + * {@link Guacamole.Event.DOMEvent#preventDefault preventDefault()} + * respectively. + * + * @constructor + * @augments Guacamole.Event + * + * @param {String} type + * The unique name of this event type. + * + * @param {Event[]} events + * The DOM events that are related to this event. Future calls to + * {@link Guacamole.Event.DOMEvent#preventDefault preventDefault()} and + * {@link Guacamole.Event.DOMEvent#stopPropagation stopPropagation()} will + * affect these events. + */ +Guacamole.Event.DOMEvent = function DOMEvent(type, events) { + + Guacamole.Event.call(this, type); + + /** + * Requests that the default behavior of related DOM events be prevented. + * Whether this request will be honored by the browser depends on the + * nature of those events and the timing of the request. + */ + this.preventDefault = function preventDefault() { + events.forEach(function applyPreventDefault(event) { + if (event.preventDefault) event.preventDefault(); + event.returnValue = false; + }); + }; + + /** + * Stops further propagation of related events through the DOM. Only events + * that are directly related to this event will be stopped. + */ + this.stopPropagation = function stopPropagation() { + events.forEach(function applyStopPropagation(event) { + event.stopPropagation(); + }); + }; + +}; + +/** + * An object which can dispatch {@link Guacamole.Event} objects. Listeners + * registered with {@link Guacamole.Event.Target#on on()} will automatically + * be invoked based on the type of {@link Guacamole.Event} passed to + * {@link Guacamole.Event.Target#dispatch dispatch()}. It is normally + * subclasses of Guacamole.Event.Target that will dispatch events, and usages + * of those subclasses that will catch dispatched events with on(). + * + * @constructor + */ +Guacamole.Event.Target = function Target() { + + /** + * A callback function which handles an event dispatched by an event + * target. + * + * @callback Guacamole.Event.Target~listener + * @param {Guacamole.Event} event + * The event that was dispatched. + * + * @param {Guacamole.Event.Target} target + * The object that dispatched the event. + */ + + /** + * All listeners (callback functions) registered for each event type passed + * to {@link Guacamole.Event.Targer#on on()}. + * + * @private + * @type {Object.} + */ + var listeners = {}; + + /** + * Registers a listener for events having the given type, as dictated by + * the {@link Guacamole.Event#type type} property of {@link Guacamole.Event} + * provided to {@link Guacamole.Event.Target#dispatch dispatch()}. + * + * @param {String} type + * The unique name of this event type. + * + * @param {Guacamole.Event.Target~listener} listener + * The function to invoke when an event having the given type is + * dispatched. The {@link Guacamole.Event} object provided to + * {@link Guacamole.Event.Target#dispatch dispatch()} will be passed to + * this function, along with the dispatching Guacamole.Event.Target. + */ + this.on = function on(type, listener) { + + var relevantListeners = listeners[type]; + if (!relevantListeners) + listeners[type] = relevantListeners = []; + + relevantListeners.push(listener); + + }; + + /** + * Dispatches the given event, invoking all event handlers registered with + * this Guacamole.Event.Target for that event's + * {@link Guacamole.Event#type type}. + * + * @param {Guacamole.Event} event + * The event to dispatch. + */ + this.dispatch = function dispatch(event) { + + // Invoke any relevant legacy handler for the event + event.invokeLegacyHandler(this); + + // Invoke all registered listeners + var relevantListeners = listeners[event.type]; + if (relevantListeners) { + for (var i = 0; i < relevantListeners.length; i++) { + relevantListeners[i](event, this); + } + } + + }; + + /** + * Unregisters a listener that was previously registered with + * {@link Guacamole.Event.Target#on on()}. If no such listener was + * registered, this function has no effect. If multiple copies of the same + * listener were registered, the first listener still registered will be + * removerd. + * + * @param {String} type + * The unique name of the event type handled by the listener being + * removed. + * + * @param {Guacamole.Event.Target~listener} listener + * The listener function previously provided to + * {@link Guacamole.Event.Target#on on()}. + * + * @returns {Boolean} + * true if the specified listener was removed, false otherwise. + */ + this.off = function off(type, listener) { + + var relevantListeners = listeners[type]; + if (!relevantListeners) + return false; + + for (var i = 0; i < relevantListeners.length; i++) { + if (relevantListeners[i] === listener) { + relevantListeners.splice(i, 1); + return true; + } + } + + return false; + + }; + +}; diff --git a/guacamole-common-js/src/test/javascript/EventSpec.js b/guacamole-common-js/src/test/javascript/EventSpec.js new file mode 100644 index 000000000..9f94d6fa1 --- /dev/null +++ b/guacamole-common-js/src/test/javascript/EventSpec.js @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* global Guacamole, jasmine, expect */ + +describe("Guacamole.Event", function EventSpec() { + + /** + * Test subclass of {@link Guacamole.Event} which provides a single + * "value" property supports an "ontest" legacy event handler. + * + * @constructor + * @augments Guacamole.Event + */ + var TestEvent = function TestEvent(value) { + + Guacamole.Event.apply(this, [ 'test' ]); + + /** + * An arbitrary value to expose to the handler of this event. + * + * @type {Object} + */ + this.value = value; + + /** + * @inheritdoc + */ + this.invokeLegacyHandler = function invokeLegacyHandler(target) { + if (target.ontest) + target.ontest(value); + }; + + }; + + /** + * Event target instance which will receive each fired {@link TestEvent}. + * + * @type {Guacamole.Event.Target} + */ + var eventTarget; + + beforeEach(function() { + eventTarget = new Guacamole.Event.Target(); + }); + + describe("when an event is dispatched", function(){ + + it("should invoke the legacy handler for matching events", function() { + + eventTarget.ontest = jasmine.createSpy('ontest'); + eventTarget.dispatch(new TestEvent('event1')); + expect(eventTarget.ontest).toHaveBeenCalledWith('event1'); + + }); + + it("should invoke all listeners for matching events", function() { + + var listener1 = jasmine.createSpy('listener1'); + var listener2 = jasmine.createSpy('listener2'); + + eventTarget.on('test', listener1); + eventTarget.on('test', listener2); + + eventTarget.dispatch(new TestEvent('event2')); + + expect(listener1).toHaveBeenCalledWith(jasmine.objectContaining({ type : 'test', value : 'event2' }), eventTarget); + expect(listener2).toHaveBeenCalledWith(jasmine.objectContaining({ type : 'test', value : 'event2' }), eventTarget); + + }); + + it("should not invoke any listeners for non-matching events", function() { + + var listener1 = jasmine.createSpy('listener1'); + var listener2 = jasmine.createSpy('listener2'); + + eventTarget.on('test2', listener1); + eventTarget.on('test2', listener2); + + eventTarget.dispatch(new TestEvent('event3')); + + expect(listener1).not.toHaveBeenCalled(); + expect(listener2).not.toHaveBeenCalled(); + + }); + + it("should not invoke any listeners that have been removed", function() { + + var listener1 = jasmine.createSpy('listener1'); + var listener2 = jasmine.createSpy('listener2'); + + eventTarget.on('test', listener1); + eventTarget.on('test', listener2); + eventTarget.off('test', listener1); + + eventTarget.dispatch(new TestEvent('event4')); + + expect(listener1).not.toHaveBeenCalled(); + expect(listener2).toHaveBeenCalledWith(jasmine.objectContaining({ type : 'test', value : 'event4' }), eventTarget); + + }); + + }); + + describe("when listeners are removed", function(){ + + it("should return whether a listener is successfully removed", function() { + + var listener1 = jasmine.createSpy('listener1'); + var listener2 = jasmine.createSpy('listener2'); + + eventTarget.on('test', listener1); + eventTarget.on('test', listener2); + + expect(eventTarget.off('test', listener1)).toBe(true); + expect(eventTarget.off('test', listener1)).toBe(false); + expect(eventTarget.off('test', listener2)).toBe(true); + expect(eventTarget.off('test', listener2)).toBe(false); + + }); + + }); +});