From f44322128b1ebb3d3d0db5ab9527ee4cdfbc7942 Mon Sep 17 00:00:00 2001 From: Adam Conrad <422184+acconrad@users.noreply.github.com> Date: Sat, 5 Jan 2019 15:02:44 +0000 Subject: [PATCH] Add Event web API (#1059) --- BUILD.gn | 2 + js/dom_types.ts | 75 ++++++------ js/event.ts | 252 ++++++++++++++++++++++++++++++++++++++++ js/event_target.ts | 52 +++++++++ js/event_target_test.ts | 66 +++++++++++ js/event_test.ts | 70 +++++++++++ js/globals.ts | 8 ++ js/unit_tests.ts | 2 + js/util.ts | 12 ++ 9 files changed, 504 insertions(+), 35 deletions(-) create mode 100644 js/event.ts create mode 100644 js/event_target.ts create mode 100644 js/event_target_test.ts create mode 100644 js/event_test.ts diff --git a/BUILD.gn b/BUILD.gn index f5a5f30dda..695a603c11 100644 --- a/BUILD.gn +++ b/BUILD.gn @@ -60,6 +60,8 @@ ts_sources = [ "js/dispatch.ts", "js/dom_types.ts", "js/errors.ts", + "js/event.ts", + "js/event_target.ts", "js/fetch.ts", "js/dom_file.ts", "js/file_info.ts", diff --git a/js/dom_types.ts b/js/dom_types.ts index 4bfb3e0415..549b0057c4 100644 --- a/js/dom_types.ts +++ b/js/dom_types.ts @@ -53,14 +53,6 @@ export interface DomIterable { ): void; } -interface Element { - // TODO -} - -export interface HTMLFormElement { - // TODO -} - type EndingType = "transparent" | "native"; export interface BlobPropertyBag { @@ -72,7 +64,7 @@ interface AbortSignalEventMap { abort: ProgressEvent; } -interface EventTarget { +export interface EventTarget { addEventListener( type: string, listener: EventListenerOrEventListenerObject | null, @@ -140,39 +132,52 @@ export interface URLSearchParams { ): void; } -interface EventListener { +export interface EventListener { (evt: Event): void; } -interface EventInit { +export interface EventInit { bubbles?: boolean; cancelable?: boolean; composed?: boolean; } -interface Event { - readonly bubbles: boolean; - cancelBubble: boolean; - readonly cancelable: boolean; - readonly composed: boolean; - readonly currentTarget: EventTarget | null; - readonly defaultPrevented: boolean; - readonly eventPhase: number; - readonly isTrusted: boolean; - returnValue: boolean; - readonly srcElement: Element | null; - readonly target: EventTarget | null; - readonly timeStamp: number; +export enum EventPhase { + NONE = 0, + CAPTURING_PHASE = 1, + AT_TARGET = 2, + BUBBLING_PHASE = 3 +} + +export interface EventPath { + item: EventTarget; + itemInShadowTree: boolean; + relatedTarget: EventTarget | null; + rootOfClosedTree: boolean; + slotInClosedTree: boolean; + target: EventTarget | null; + touchTargetList: EventTarget[]; +} + +export interface Event { readonly type: string; - deepPath(): EventTarget[]; - initEvent(type: string, bubbles?: boolean, cancelable?: boolean): void; - preventDefault(): void; - stopImmediatePropagation(): void; + readonly target: EventTarget | null; + readonly currentTarget: EventTarget | null; + composedPath(): EventPath[]; + + readonly eventPhase: number; + stopPropagation(): void; - readonly AT_TARGET: number; - readonly BUBBLING_PHASE: number; - readonly CAPTURING_PHASE: number; - readonly NONE: number; + stopImmediatePropagation(): void; + + readonly bubbles: boolean; + readonly cancelable: boolean; + preventDefault(): void; + readonly defaultPrevented: boolean; + readonly composed: boolean; + + readonly isTrusted: boolean; + readonly timeStamp: Date; } /* TODO(ry) Re-expose this interface. There is currently some interference @@ -193,11 +198,11 @@ interface ProgressEvent extends Event { readonly total: number; } -interface EventListenerOptions { +export interface EventListenerOptions { capture?: boolean; } -interface AddEventListenerOptions extends EventListenerOptions { +export interface AddEventListenerOptions extends EventListenerOptions { once?: boolean; passive?: boolean; } @@ -236,7 +241,7 @@ export interface ReadableStream { getReader(): ReadableStreamReader; } -interface EventListenerObject { +export interface EventListenerObject { handleEvent(evt: Event): void; } diff --git a/js/event.ts b/js/event.ts new file mode 100644 index 0000000000..29fd8177b5 --- /dev/null +++ b/js/event.ts @@ -0,0 +1,252 @@ +// Copyright 2018 the Deno authors. All rights reserved. MIT license. +import * as domTypes from "./dom_types"; +import { getPrivateValue } from "./util"; + +// WeakMaps are recommended for private attributes (see MDN link below) +// tslint:disable-next-line:max-line-length +// https://developer.mozilla.org/en-US/docs/Archive/Add-ons/Add-on_SDK/Guides/Contributor_s_Guide/Private_Properties#Using_WeakMaps +export const eventAttributes = new WeakMap(); + +export class EventInit implements domTypes.EventInit { + bubbles = false; + cancelable = false; + composed = false; + + constructor({ bubbles = false, cancelable = false, composed = false } = {}) { + this.bubbles = bubbles; + this.cancelable = cancelable; + this.composed = composed; + } +} + +export class Event implements domTypes.Event { + // Each event has the following associated flags + private _canceledFlag = false; + private _inPassiveListenerFlag = false; + private _stopImmediatePropagationFlag = false; + private _stopPropagationFlag = false; + + // Property for objects on which listeners will be invoked + private _path: domTypes.EventPath[] = []; + + constructor(type: string, eventInitDict: domTypes.EventInit = {}) { + eventAttributes.set(this, { + type, + bubbles: eventInitDict.bubbles || false, + cancelable: eventInitDict.cancelable || false, + composed: eventInitDict.composed || false, + currentTarget: null, + eventPhase: domTypes.EventPhase.NONE, + isTrusted: false, + target: null, + timeStamp: Date.now() + }); + } + + get bubbles(): boolean { + return getPrivateValue(this, eventAttributes, "bubbles"); + } + + get cancelBubble(): boolean { + return this._stopPropagationFlag; + } + + get cancelBubbleImmediately(): boolean { + return this._stopImmediatePropagationFlag; + } + + get cancelable(): boolean { + return getPrivateValue(this, eventAttributes, "cancelable"); + } + + get composed(): boolean { + return getPrivateValue(this, eventAttributes, "composed"); + } + + get currentTarget(): domTypes.EventTarget { + return getPrivateValue(this, eventAttributes, "currentTarget"); + } + + get defaultPrevented(): boolean { + return this._canceledFlag; + } + + get eventPhase(): number { + return getPrivateValue(this, eventAttributes, "eventPhase"); + } + + get isTrusted(): boolean { + return getPrivateValue(this, eventAttributes, "isTrusted"); + } + + get target(): domTypes.EventTarget { + return getPrivateValue(this, eventAttributes, "target"); + } + + get timeStamp(): Date { + return getPrivateValue(this, eventAttributes, "timeStamp"); + } + + get type(): string { + return getPrivateValue(this, eventAttributes, "type"); + } + + /** Returns the event’s path (objects on which listeners will be + * invoked). This does not include nodes in shadow trees if the + * shadow root was created with its ShadowRoot.mode closed. + * + * event.composedPath(); + */ + composedPath(): domTypes.EventPath[] { + if (this._path.length === 0) { + return []; + } + + const composedPath: domTypes.EventPath[] = [ + { + item: this.currentTarget, + itemInShadowTree: false, + relatedTarget: null, + rootOfClosedTree: false, + slotInClosedTree: false, + target: null, + touchTargetList: [] + } + ]; + + let currentTargetIndex = 0; + let currentTargetHiddenSubtreeLevel = 0; + + for (let index = this._path.length - 1; index >= 0; index--) { + const { item, rootOfClosedTree, slotInClosedTree } = this._path[index]; + + if (rootOfClosedTree) { + currentTargetHiddenSubtreeLevel++; + } + + if (item === this.currentTarget) { + currentTargetIndex = index; + break; + } + + if (slotInClosedTree) { + currentTargetHiddenSubtreeLevel--; + } + } + + let currentHiddenLevel = currentTargetHiddenSubtreeLevel; + let maxHiddenLevel = currentTargetHiddenSubtreeLevel; + + for (let i = currentTargetIndex - 1; i >= 0; i--) { + const { item, rootOfClosedTree, slotInClosedTree } = this._path[i]; + + if (rootOfClosedTree) { + currentHiddenLevel++; + } + + if (currentHiddenLevel <= maxHiddenLevel) { + composedPath.unshift({ + item, + itemInShadowTree: false, + relatedTarget: null, + rootOfClosedTree: false, + slotInClosedTree: false, + target: null, + touchTargetList: [] + }); + } + + if (slotInClosedTree) { + currentHiddenLevel--; + + if (currentHiddenLevel < maxHiddenLevel) { + maxHiddenLevel = currentHiddenLevel; + } + } + } + + currentHiddenLevel = currentTargetHiddenSubtreeLevel; + maxHiddenLevel = currentTargetHiddenSubtreeLevel; + + for ( + let index = currentTargetIndex + 1; + index < this._path.length; + index++ + ) { + const { item, rootOfClosedTree, slotInClosedTree } = this._path[index]; + + if (slotInClosedTree) { + currentHiddenLevel++; + } + + if (currentHiddenLevel <= maxHiddenLevel) { + composedPath.push({ + item, + itemInShadowTree: false, + relatedTarget: null, + rootOfClosedTree: false, + slotInClosedTree: false, + target: null, + touchTargetList: [] + }); + } + + if (rootOfClosedTree) { + currentHiddenLevel--; + + if (currentHiddenLevel < maxHiddenLevel) { + maxHiddenLevel = currentHiddenLevel; + } + } + } + + return composedPath; + } + + /** Cancels the event (if it is cancelable). + * See https://dom.spec.whatwg.org/#set-the-canceled-flag + * + * event.preventDefault(); + */ + preventDefault(): void { + if (this.cancelable && !this._inPassiveListenerFlag) { + this._canceledFlag = true; + } + } + + /** Stops the propagation of events further along in the DOM. + * + * event.stopPropagation(); + */ + stopPropagation(): void { + this._stopPropagationFlag = true; + } + + /** For this particular event, no other listener will be called. + * Neither those attached on the same element, nor those attached + * on elements which will be traversed later (in capture phase, + * for instance). + * + * event.stopImmediatePropagation(); + */ + stopImmediatePropagation(): void { + this._stopPropagationFlag = true; + this._stopImmediatePropagationFlag = true; + } +} + +/** Built-in objects providing `get` methods for our + * interceptable JavaScript operations. + */ +Reflect.defineProperty(Event.prototype, "bubbles", { enumerable: true }); +Reflect.defineProperty(Event.prototype, "cancelable", { enumerable: true }); +Reflect.defineProperty(Event.prototype, "composed", { enumerable: true }); +Reflect.defineProperty(Event.prototype, "currentTarget", { enumerable: true }); +Reflect.defineProperty(Event.prototype, "defaultPrevented", { + enumerable: true +}); +Reflect.defineProperty(Event.prototype, "eventPhase", { enumerable: true }); +Reflect.defineProperty(Event.prototype, "isTrusted", { enumerable: true }); +Reflect.defineProperty(Event.prototype, "target", { enumerable: true }); +Reflect.defineProperty(Event.prototype, "timeStamp", { enumerable: true }); +Reflect.defineProperty(Event.prototype, "type", { enumerable: true }); diff --git a/js/event_target.ts b/js/event_target.ts new file mode 100644 index 0000000000..3226fde96e --- /dev/null +++ b/js/event_target.ts @@ -0,0 +1,52 @@ +// Copyright 2018 the Deno authors. All rights reserved. MIT license. +import * as domTypes from "./dom_types"; + +/* TODO: This is an incomplete implementation to provide functionality + * for Event. A proper spec is still required for a proper Web API. + */ +export class EventTarget implements domTypes.EventTarget { + public listeners: { + [type in string]: domTypes.EventListenerOrEventListenerObject[] + } = {}; + + public addEventListener( + type: string, + listener: domTypes.EventListenerOrEventListenerObject | null, + options?: boolean | domTypes.AddEventListenerOptions + ): void { + if (!(type in this.listeners)) { + this.listeners[type] = []; + } + if (listener !== null) { + this.listeners[type].push(listener); + } + } + + public removeEventListener( + type: string, + callback: domTypes.EventListenerOrEventListenerObject | null, + options?: domTypes.EventListenerOptions | boolean + ): void { + if (type in this.listeners && callback !== null) { + this.listeners[type] = this.listeners[type].filter( + listener => listener !== callback + ); + } + } + + public dispatchEvent(event: domTypes.Event): boolean { + if (!(event.type in this.listeners)) { + return true; + } + const stack = this.listeners[event.type].slice(); + + for (const stackElement of stack) { + if ((stackElement as domTypes.EventListenerObject).handleEvent) { + (stackElement as domTypes.EventListenerObject).handleEvent(event); + } else { + (stackElement as domTypes.EventListener).call(this, event); + } + } + return !event.defaultPrevented; + } +} diff --git a/js/event_target_test.ts b/js/event_target_test.ts new file mode 100644 index 0000000000..ebb92c7f0f --- /dev/null +++ b/js/event_target_test.ts @@ -0,0 +1,66 @@ +// Copyright 2018 the Deno authors. All rights reserved. MIT license. +import { test, assert, assertEqual } from "./test_util.ts"; + +test(function addEventListenerTest() { + const document = new EventTarget(); + + assertEqual(document.addEventListener("x", null, false), undefined); + assertEqual(document.addEventListener("x", null, true), undefined); + assertEqual(document.addEventListener("x", null), undefined); +}); + +test(function constructedEventTargetCanBeUsedAsExpected() { + const target = new EventTarget(); + const event = new Event("foo", { bubbles: true, cancelable: false }); + let callCount = 0; + + function listener(e) { + assertEqual(e, event); + ++callCount; + } + + target.addEventListener("foo", listener); + + target.dispatchEvent(event); + assertEqual(callCount, 1); + + target.dispatchEvent(event); + assertEqual(callCount, 2); + + target.removeEventListener("foo", listener); + target.dispatchEvent(event); + assertEqual(callCount, 2); +}); + +test(function anEventTargetCanBeSubclassed() { + class NicerEventTarget extends EventTarget { + on(type, listener?, options?) { + this.addEventListener(type, listener, options); + } + + off(type, callback?, options?) { + this.removeEventListener(type, callback, options); + } + } + + const target = new NicerEventTarget(); + const event = new Event("foo", { bubbles: true, cancelable: false }); + let callCount = 0; + + function listener() { + ++callCount; + } + + target.on("foo", listener); + assertEqual(callCount, 0); + + target.off("foo", listener); + assertEqual(callCount, 0); +}); + +test(function removingNullEventListenerShouldSucceed() { + const document = new EventTarget(); + assertEqual(document.removeEventListener("x", null, false), undefined); + assertEqual(document.removeEventListener("x", null, true), undefined); + assertEqual(document.removeEventListener("x", null), undefined); +}); diff --git a/js/event_test.ts b/js/event_test.ts new file mode 100644 index 0000000000..3a12549569 --- /dev/null +++ b/js/event_test.ts @@ -0,0 +1,70 @@ +// Copyright 2018 the Deno authors. All rights reserved. MIT license. +import { test, assertEqual } from "./test_util.ts"; + +test(function eventInitializedWithType() { + const type = "click"; + const event = new Event(type); + + assertEqual(event.isTrusted, false); + assertEqual(event.target, null); + assertEqual(event.currentTarget, null); + assertEqual(event.type, "click"); + assertEqual(event.bubbles, false); + assertEqual(event.cancelable, false); +}); + +test(function eventInitializedWithTypeAndDict() { + const init = "submit"; + const eventInitDict = new EventInit({ bubbles: true, cancelable: true }); + const event = new Event(init, eventInitDict); + + assertEqual(event.isTrusted, false); + assertEqual(event.target, null); + assertEqual(event.currentTarget, null); + assertEqual(event.type, "submit"); + assertEqual(event.bubbles, true); + assertEqual(event.cancelable, true); +}); + +test(function eventComposedPathSuccess() { + const type = "click"; + const event = new Event(type); + const composedPath = event.composedPath(); + + assertEqual(composedPath, []); +}); + +test(function eventStopPropagationSuccess() { + const type = "click"; + const event = new Event(type); + + assertEqual(event.cancelBubble, false); + event.stopPropagation(); + assertEqual(event.cancelBubble, true); +}); + +test(function eventStopImmediatePropagationSuccess() { + const type = "click"; + const event = new Event(type); + + assertEqual(event.cancelBubble, false); + assertEqual(event.cancelBubbleImmediately, false); + event.stopImmediatePropagation(); + assertEqual(event.cancelBubble, true); + assertEqual(event.cancelBubbleImmediately, true); +}); + +test(function eventPreventDefaultSuccess() { + const type = "click"; + const event = new Event(type); + + assertEqual(event.defaultPrevented, false); + event.preventDefault(); + assertEqual(event.defaultPrevented, false); + + const eventInitDict = new EventInit({ bubbles: true, cancelable: true }); + const cancelableEvent = new Event(type, eventInitDict); + assertEqual(cancelableEvent.defaultPrevented, false); + cancelableEvent.preventDefault(); + assertEqual(cancelableEvent.defaultPrevented, true); +}); diff --git a/js/globals.ts b/js/globals.ts index efa377e9bf..6632153418 100644 --- a/js/globals.ts +++ b/js/globals.ts @@ -10,6 +10,8 @@ import * as blob from "./blob"; import * as consoleTypes from "./console"; import * as domTypes from "./dom_types"; +import * as event from "./event"; +import * as eventTarget from "./event_target"; import * as formData from "./form_data"; import * as fetchTypes from "./fetch"; import * as headers from "./headers"; @@ -61,6 +63,12 @@ export type Blob = blob.DenoBlob; // window.File = file.DenoFile; // export type File = file.DenoFile; +window.EventInit = event.EventInit; +export type EventInit = event.EventInit; +window.Event = event.Event; +export type Event = event.Event; +window.EventTarget = eventTarget.EventTarget; +export type EventTarget = eventTarget.EventTarget; window.URL = url.URL; export type URL = url.URL; window.URLSearchParams = urlSearchParams.URLSearchParams; diff --git a/js/unit_tests.ts b/js/unit_tests.ts index b24a156d54..73298297bd 100644 --- a/js/unit_tests.ts +++ b/js/unit_tests.ts @@ -10,6 +10,8 @@ import "./compiler_test.ts"; import "./console_test.ts"; import "./copy_file_test.ts"; import "./dir_test.ts"; +import "./event_test.ts"; +import "./event_target_test.ts"; import "./fetch_test.ts"; // TODO The global "File" has been temporarily disabled. See the note in // js/globals.ts diff --git a/js/util.ts b/js/util.ts index 58e865337c..ec24ec5bd5 100644 --- a/js/util.ts +++ b/js/util.ts @@ -151,3 +151,15 @@ export function requiredArguments( throw new TypeError(errMsg); } } + +// Returns values from a WeakMap to emulate private properties in JavaScript +export function getPrivateValue< + K extends object, + V extends object, + W extends keyof V +>(instance: K, weakMap: WeakMap, key: W): V[W] { + if (weakMap.has(instance)) { + return weakMap.get(instance)![key]; + } + throw new TypeError("Illegal invocation"); +}