From 1b79ac0c67a5370508b45b4bfb4814ae90c803ae Mon Sep 17 00:00:00 2001 From: Tom Wilkinson Date: Wed, 24 Apr 2024 18:07:03 -0500 Subject: [PATCH] refactor(core): Move action resolution into a standalone class. This refactor will enable moving this functionality into the dispatcher. --- .../event-dispatch/src/action_resolver.ts | 363 +++++++++++++++++ .../event-dispatch/src/eventcontract.ts | 371 +----------------- 2 files changed, 376 insertions(+), 358 deletions(-) create mode 100644 packages/core/primitives/event-dispatch/src/action_resolver.ts diff --git a/packages/core/primitives/event-dispatch/src/action_resolver.ts b/packages/core/primitives/event-dispatch/src/action_resolver.ts new file mode 100644 index 00000000000000..de06648cfa35a9 --- /dev/null +++ b/packages/core/primitives/event-dispatch/src/action_resolver.ts @@ -0,0 +1,363 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Attribute} from './attribute'; +import {Char} from './char'; +import {EventInfo} from './event_info'; +import {EventType} from './event_type'; +import {Property} from './property'; +import * as a11yClick from './a11y_click'; +import * as cache from './cache'; +import * as eventInfoLib from './event_info'; +import * as eventLib from './event'; + +/** + * Since maps from event to action are immutable we can use a single map + * to represent the empty map. + */ +const EMPTY_ACTION_MAP: {[key: string]: string} = {}; + +/** + * This regular expression matches a semicolon. + */ +const REGEXP_SEMICOLON = /\s*;\s*/; + +/** If no event type is defined, defaults to `click`. */ +const DEFAULT_EVENT_TYPE: string = EventType.CLICK; + +/** Resolves actions for Events. */ +export class ActionResolver { + private a11yClickSupport: boolean = false; + private readonly customEventSupport: boolean; + private readonly jsnamespaceSupport: boolean; + private readonly syntheticMouseEventSupport: boolean; + + private updateEventInfoForA11yClick?: (eventInfo: eventInfoLib.EventInfo) => void = undefined; + + private preventDefaultForA11yClick?: (eventInfo: eventInfoLib.EventInfo) => void = undefined; + + private populateClickOnlyAction?: ( + actionElement: Element, + eventInfo: eventInfoLib.EventInfo, + actionMap: {[key: string]: string}, + ) => void = undefined; + + constructor({ + customEventSupport = false, + jsnamespaceSupport = false, + syntheticMouseEventSupport = false, + }: { + customEventSupport?: boolean; + jsnamespaceSupport?: boolean; + syntheticMouseEventSupport?: boolean; + } = {}) { + this.customEventSupport = customEventSupport; + this.jsnamespaceSupport = jsnamespaceSupport; + this.syntheticMouseEventSupport = syntheticMouseEventSupport; + } + + resolve(eventInfo: EventInfo) { + if (this.customEventSupport) { + if (eventInfoLib.getEventType(eventInfo) === EventType.CUSTOM) { + const detail = (eventInfoLib.getEvent(eventInfo) as CustomEvent).detail; + // For custom events, use a secondary dispatch based on the internal + // custom type of the event. + if (!detail || !detail['_type']) { + // This should never happen. + return; + } + eventInfoLib.setEventType(eventInfo, detail['_type']); + } + } + + this.populateAction(eventInfo); + } + + /** + * Searches for a jsaction that the DOM event maps to and creates an + * object containing event information used for dispatching by + * jsaction.Dispatcher. This method populates the `action` and `actionElement` + * fields of the EventInfo object passed in by finding the first + * jsaction attribute above the target Node of the event, and below + * the container Node, that specifies a jsaction for the event + * type. If no such jsaction is found, then action is undefined. + * + * @param eventInfo `EventInfo` to set `action` and `actionElement` if an + * action is found on any `Element` in the path of the `Event`. + */ + private populateAction(eventInfo: eventInfoLib.EventInfo) { + // We distinguish modified and plain clicks in order to support the + // default browser behavior of modified clicks on links; usually to + // open the URL of the link in new tab or new window on ctrl/cmd + // click. A DOM 'click' event is mapped to the jsaction 'click' + // event iff there is no modifier present on the event. If there is + // a modifier, it's mapped to 'clickmod' instead. + // + // It's allowed to omit the event in the jsaction attribute. In that + // case, 'click' is assumed. Thus the following two are equivalent: + // + // + // + // + // For unmodified clicks, EventContract invokes the jsaction + // 'gna.fu'. For modified clicks, EventContract won't find a + // suitable action and leave the event to be handled by the + // browser. + // + // In order to also invoke a jsaction handler for a modifier click, + // 'clickmod' needs to be used: + // + // + // + // EventContract invokes the jsaction 'gna.fu' for modified + // clicks. Unmodified clicks are left to the browser. + // + // In order to set up the event contract to handle both clickonly and + // clickmod, only addEvent(EventType.CLICK) is necessary. + // + // In order to set up the event contract to handle click, + // addEvent() is necessary for CLICK, KEYDOWN, and KEYPRESS event types. If + // a11y click support is enabled, addEvent() will set up the appropriate key + // event handler automatically. + if ( + eventInfoLib.getEventType(eventInfo) === EventType.CLICK && + eventLib.isModifiedClickEvent(eventInfoLib.getEvent(eventInfo)) + ) { + eventInfoLib.setEventType(eventInfo, EventType.CLICKMOD); + } else if (this.a11yClickSupport) { + this.updateEventInfoForA11yClick!(eventInfo); + } + + // Walk to the parent node, unless the node has a different owner in + // which case we walk to the owner. Attempt to walk to host of a + // shadow root if needed. + let actionElement: Element | null = eventInfoLib.getTargetElement(eventInfo); + while (actionElement && actionElement !== eventInfoLib.getContainer(eventInfo)) { + if (actionElement.nodeType === Node.ELEMENT_NODE) { + this.populateActionOnElement(actionElement, eventInfo); + } + + if (eventInfoLib.getAction(eventInfo)) { + // An event is handled by at most one jsaction. Thus we stop at the + // first matching jsaction specified in a jsaction attribute up the + // ancestor chain of the event target node. + break; + } + if (actionElement[Property.OWNER]) { + actionElement = actionElement[Property.OWNER] as Element; + continue; + } + if (actionElement.parentNode?.nodeName !== '#document-fragment') { + actionElement = actionElement.parentNode as Element | null; + } else { + actionElement = (actionElement.parentNode as ShadowRoot | null)?.host ?? null; + } + } + + const action = eventInfoLib.getAction(eventInfo); + if (!action) { + // No action found. + return; + } + + if (this.a11yClickSupport) { + this.preventDefaultForA11yClick!(eventInfo); + } + + // We attempt to handle the mouseenter/mouseleave events here by + // detecting whether the mouseover/mouseout events correspond to + // entering/leaving an element. + if (this.syntheticMouseEventSupport) { + if ( + eventInfoLib.getEventType(eventInfo) === EventType.MOUSEENTER || + eventInfoLib.getEventType(eventInfo) === EventType.MOUSELEAVE || + eventInfoLib.getEventType(eventInfo) === EventType.POINTERENTER || + eventInfoLib.getEventType(eventInfo) === EventType.POINTERLEAVE + ) { + // We attempt to handle the mouseenter/mouseleave events here by + // detecting whether the mouseover/mouseout events correspond to + // entering/leaving an element. + if ( + eventLib.isMouseSpecialEvent( + eventInfoLib.getEvent(eventInfo), + eventInfoLib.getEventType(eventInfo), + eventInfoLib.getActionElement(action), + ) + ) { + // If both mouseover/mouseout and mouseenter/mouseleave events are + // enabled, two separate handlers for mouseover/mouseout are + // registered. Both handlers will see the same event instance + // so we create a copy to avoid interfering with the dispatching of + // the mouseover/mouseout event. + const copiedEvent = eventLib.createMouseSpecialEvent( + eventInfoLib.getEvent(eventInfo), + eventInfoLib.getActionElement(action), + ); + eventInfoLib.setEvent(eventInfo, copiedEvent); + // Since the mouseenter/mouseleave events do not bubble, the target + // of the event is technically the `actionElement` (the node with the + // `jsaction` attribute) + eventInfoLib.setTargetElement(eventInfo, eventInfoLib.getActionElement(action)); + } else { + eventInfoLib.unsetAction(eventInfo); + } + } + } + } + + /** + * Accesses the jsaction map on a node and retrieves the name of the + * action the given event is mapped to, if any. It parses the + * attribute value and stores it in a property on the node for + * subsequent retrieval without re-parsing and re-accessing the + * attribute. In order to fully qualify jsaction names using a + * namespace, the DOM is searched starting at the current node and + * going through ancestor nodes until a jsnamespace attribute is + * found. + * + * @param actionElement The DOM node to retrieve the jsaction map from. + * @param eventInfo `EventInfo` to set `action` and `actionElement` if an + * action is found on the `actionElement`. + */ + private populateActionOnElement(actionElement: Element, eventInfo: eventInfoLib.EventInfo) { + const actionMap = this.parseActions(actionElement, eventInfoLib.getContainer(eventInfo)); + + const actionName = actionMap[eventInfoLib.getEventType(eventInfo)]; + if (actionName !== undefined) { + eventInfoLib.setAction(eventInfo, actionName, actionElement); + } + + if (this.a11yClickSupport) { + this.populateClickOnlyAction!(actionElement, eventInfo, actionMap); + } + } + + /** + * Parses and caches an element's jsaction element into a map. + * + * This is primarily for internal use. + * + * @param actionElement The DOM node to retrieve the jsaction map from. + * @param container The node which limits the namespace lookup for a jsaction + * name. The container node itself will not be searched. + * @return Map from event to qualified name of the jsaction bound to it. + */ + private parseActions(actionElement: Element, container: Node): {[key: string]: string} { + let actionMap: {[key: string]: string} | undefined = cache.get(actionElement); + if (!actionMap) { + const jsactionAttribute = actionElement.getAttribute(Attribute.JSACTION); + if (!jsactionAttribute) { + actionMap = EMPTY_ACTION_MAP; + cache.set(actionElement, actionMap); + } else { + actionMap = cache.getParsed(jsactionAttribute); + if (!actionMap) { + actionMap = {}; + const values = jsactionAttribute.split(REGEXP_SEMICOLON); + for (let idx = 0; idx < values.length; idx++) { + const value = values[idx]; + if (!value) { + continue; + } + const colon = value.indexOf(Char.EVENT_ACTION_SEPARATOR); + const hasColon = colon !== -1; + const type = hasColon ? value.substr(0, colon).trim() : DEFAULT_EVENT_TYPE; + const action = hasColon ? value.substr(colon + 1).trim() : value; + actionMap[type] = action; + } + cache.setParsed(jsactionAttribute, actionMap); + } + // If namespace support is active we need to augment the (potentially + // cached) jsaction mapping with the namespace. + if (this.jsnamespaceSupport) { + const noNs = actionMap; + actionMap = {}; + for (const type in noNs) { + actionMap[type] = this.getFullyQualifiedAction(noNs[type], actionElement, container); + } + } + cache.set(actionElement, actionMap); + } + } + return actionMap; + } + + /** + * Returns the fully qualified jsaction action. If the given jsaction + * name doesn't already contain the namespace, the function iterates + * over ancestor nodes until a jsnamespace attribute is found, and + * uses the value of that attribute as the namespace. + * + * @param action The jsaction action to resolve. + * @param start The node from which to start searching for a jsnamespace + * attribute. + * @param container The node which limits the search for a jsnamespace + * attribute. This node will be searched. + * @return The fully qualified name of the jsaction. If no namespace is found, + * returns the unqualified name in case it exists in the global namespace. + */ + private getFullyQualifiedAction(action: string, start: Element, container: Node): string { + if (isNamespacedAction(action)) { + return action; + } + + let node: Node | null = start; + while (node && node.nodeType === Node.ELEMENT_NODE) { + const namespace = getNamespaceFromElement(node as Element); + if (namespace) { + return namespace + Char.NAMESPACE_ACTION_SEPARATOR + action; + } + + // If this node is the container, stop. + if (node === container) { + break; + } + + node = node.parentNode; + } + + return action; + } + + addA11yClickSupport( + updateEventInfoForA11yClick: typeof a11yClick.updateEventInfoForA11yClick, + preventDefaultForA11yClick: typeof a11yClick.preventDefaultForA11yClick, + populateClickOnlyAction: typeof a11yClick.populateClickOnlyAction, + ) { + this.a11yClickSupport = true; + this.updateEventInfoForA11yClick = updateEventInfoForA11yClick; + this.preventDefaultForA11yClick = preventDefaultForA11yClick; + this.populateClickOnlyAction = populateClickOnlyAction; + } +} + +/** + * Checks if a jsaction action contains a namespace part. + */ +function isNamespacedAction(action: string): boolean { + return action.indexOf(Char.NAMESPACE_ACTION_SEPARATOR) >= 0; +} + +/** + * Returns the value of the jsnamespace attribute of the given node. + * Also caches the value for subsequent lookups. + * @param element The node whose jsnamespace attribute is being asked for. + * @return The value of the jsnamespace attribute, or null if not found. + */ +function getNamespaceFromElement(element: Element): string | null { + let namespace = cache.getNamespace(element); + // Only query for the attribute if it has not been queried for + // before. getAttribute() returns null if an attribute is not present. Thus, + // namespace is string|null if the query took place in the past, or + // undefined if the query did not take place. + if (namespace === undefined) { + namespace = element.getAttribute(Attribute.JSNAMESPACE); + cache.setNamespace(element, namespace); + } + return namespace; +} diff --git a/packages/core/primitives/event-dispatch/src/eventcontract.ts b/packages/core/primitives/event-dispatch/src/eventcontract.ts index fbd2a86f1ef84f..d363e99b2ebd3a 100644 --- a/packages/core/primitives/event-dispatch/src/eventcontract.ts +++ b/packages/core/primitives/event-dispatch/src/eventcontract.ts @@ -31,9 +31,7 @@ */ import * as a11yClickLib from './a11y_click'; -import {Attribute} from './attribute'; -import * as cache from './cache'; -import {Char} from './char'; +import {ActionResolver} from './action_resolver'; import {EarlyJsactionData} from './earlyeventcontract'; import * as eventLib from './event'; import {EventContractContainerManager} from './event_contract_container'; @@ -77,19 +75,6 @@ export type Dispatcher = (eventInfo: eventInfoLib.EventInfo, globalDispatch?: bo */ type EventHandler = (eventType: string, event: Event, container: Element) => void; -const DEFAULT_EVENT_TYPE: string = EventType.CLICK; - -/** - * Since maps from event to action are immutable we can use a single map - * to represent the empty map. - */ -const EMPTY_ACTION_MAP: {[key: string]: string} = {}; - -/** - * This regular expression matches a semicolon. - */ -const REGEXP_SEMICOLON = /\s*;\s*/; - /** * EventContract intercepts events in the bubbling phase at the * boundary of a container element, and maps them to generic actions @@ -113,6 +98,12 @@ export class EventContract implements UnrenamedEventContract { private containerManager: EventContractContainerManager | null; + private readonly actionResolver = new ActionResolver({ + customEventSupport: EventContract.CUSTOM_EVENT_SUPPORT, + jsnamespaceSupport: EventContract.JSNAMESPACE_SUPPORT, + syntheticMouseEventSupport: EventContract.MOUSE_SPECIAL_SUPPORT, + }); + /** * The DOM events which this contract covers. Used to prevent double * registration of event types. The value of the map is the @@ -139,21 +130,9 @@ export class EventContract implements UnrenamedEventContract { */ private queuedEventInfos: eventInfoLib.EventInfo[] | null = []; - /** Whether a11y click support has been loaded or not. */ - private hasA11yClickSupport = false; /** Whether to add an a11y click listener. */ private addA11yClickListener = false; - private updateEventInfoForA11yClick?: (eventInfo: eventInfoLib.EventInfo) => void = undefined; - - private preventDefaultForA11yClick?: (eventInfo: eventInfoLib.EventInfo) => void = undefined; - - private populateClickOnlyAction?: ( - actionElement: Element, - eventInfo: eventInfoLib.EventInfo, - actionMap: {[key: string]: string}, - ) => void = undefined; - ecaacs?: ( updateEventInfoForA11yClick: typeof a11yClickLib.updateEventInfoForA11yClick, preventDefaultForA11yClick: typeof a11yClickLib.preventDefaultForA11yClick, @@ -191,21 +170,7 @@ export class EventContract implements UnrenamedEventContract { eventInfoLib.setIsReplay(eventInfo, true); this.queuedEventInfos?.push(eventInfo); } - if ( - EventContract.CUSTOM_EVENT_SUPPORT && - eventInfoLib.getEventType(eventInfo) === EventType.CUSTOM - ) { - const detail = (eventInfoLib.getEvent(eventInfo) as CustomEvent).detail; - // For custom events, use a secondary dispatch based on the internal - // custom type of the event. - if (!detail || !detail['_type']) { - // This should never happen. - return; - } - eventInfoLib.setEventType(eventInfo, detail['_type']); - } - - this.populateAction(eventInfo); + this.actionResolver.resolve(eventInfo); if (!this.dispatcher) { return; @@ -232,162 +197,6 @@ export class EventContract implements UnrenamedEventContract { this.dispatcher(eventInfo); } - /** - * Searches for a jsaction that the DOM event maps to and creates an - * object containing event information used for dispatching by - * jsaction.Dispatcher. This method populates the `action` and `actionElement` - * fields of the EventInfo object passed in by finding the first - * jsaction attribute above the target Node of the event, and below - * the container Node, that specifies a jsaction for the event - * type. If no such jsaction is found, then action is undefined. - * - * @param eventInfo `EventInfo` to set `action` and `actionElement` if an - * action is found on any `Element` in the path of the `Event`. - */ - private populateAction(eventInfo: eventInfoLib.EventInfo) { - // We distinguish modified and plain clicks in order to support the - // default browser behavior of modified clicks on links; usually to - // open the URL of the link in new tab or new window on ctrl/cmd - // click. A DOM 'click' event is mapped to the jsaction 'click' - // event iff there is no modifier present on the event. If there is - // a modifier, it's mapped to 'clickmod' instead. - // - // It's allowed to omit the event in the jsaction attribute. In that - // case, 'click' is assumed. Thus the following two are equivalent: - // - // - // - // - // For unmodified clicks, EventContract invokes the jsaction - // 'gna.fu'. For modified clicks, EventContract won't find a - // suitable action and leave the event to be handled by the - // browser. - // - // In order to also invoke a jsaction handler for a modifier click, - // 'clickmod' needs to be used: - // - // - // - // EventContract invokes the jsaction 'gna.fu' for modified - // clicks. Unmodified clicks are left to the browser. - // - // In order to set up the event contract to handle both clickonly and - // clickmod, only addEvent(EventType.CLICK) is necessary. - // - // In order to set up the event contract to handle click, - // addEvent() is necessary for CLICK, KEYDOWN, and KEYPRESS event types. If - // a11y click support is enabled, addEvent() will set up the appropriate key - // event handler automatically. - if ( - eventInfoLib.getEventType(eventInfo) === EventType.CLICK && - eventLib.isModifiedClickEvent(eventInfoLib.getEvent(eventInfo)) - ) { - eventInfoLib.setEventType(eventInfo, EventType.CLICKMOD); - } else if (this.hasA11yClickSupport) { - this.updateEventInfoForA11yClick!(eventInfo); - } - - // Walk to the parent node, unless the node has a different owner in - // which case we walk to the owner. Attempt to walk to host of a - // shadow root if needed. - let actionElement: Element | null = eventInfoLib.getTargetElement(eventInfo); - while (actionElement && actionElement !== eventInfoLib.getContainer(eventInfo)) { - this.populateActionOnElement(actionElement, eventInfo); - - if (eventInfoLib.getAction(eventInfo)) { - // An event is handled by at most one jsaction. Thus we stop at the - // first matching jsaction specified in a jsaction attribute up the - // ancestor chain of the event target node. - break; - } - if (actionElement[Property.OWNER]) { - actionElement = actionElement[Property.OWNER] as Element; - continue; - } - if (actionElement.parentNode?.nodeName !== '#document-fragment') { - actionElement = actionElement.parentNode as Element | null; - } else { - actionElement = (actionElement.parentNode as ShadowRoot | null)?.host ?? null; - } - } - - const action = eventInfoLib.getAction(eventInfo); - if (!action) { - // No action found. - return; - } - - if (this.hasA11yClickSupport) { - this.preventDefaultForA11yClick!(eventInfo); - } - - // We attempt to handle the mouseenter/mouseleave events here by - // detecting whether the mouseover/mouseout events correspond to - // entering/leaving an element. - if ( - EventContract.MOUSE_SPECIAL_SUPPORT && - (eventInfoLib.getEventType(eventInfo) === EventType.MOUSEENTER || - eventInfoLib.getEventType(eventInfo) === EventType.MOUSELEAVE || - eventInfoLib.getEventType(eventInfo) === EventType.POINTERENTER || - eventInfoLib.getEventType(eventInfo) === EventType.POINTERLEAVE) - ) { - // We attempt to handle the mouseenter/mouseleave events here by - // detecting whether the mouseover/mouseout events correspond to - // entering/leaving an element. - if ( - eventLib.isMouseSpecialEvent( - eventInfoLib.getEvent(eventInfo), - eventInfoLib.getEventType(eventInfo), - eventInfoLib.getActionElement(action), - ) - ) { - // If both mouseover/mouseout and mouseenter/mouseleave events are - // enabled, two separate handlers for mouseover/mouseout are - // registered. Both handlers will see the same event instance - // so we create a copy to avoid interfering with the dispatching of - // the mouseover/mouseout event. - const copiedEvent = eventLib.createMouseSpecialEvent( - eventInfoLib.getEvent(eventInfo), - eventInfoLib.getActionElement(action), - ); - eventInfoLib.setEvent(eventInfo, copiedEvent); - // Since the mouseenter/mouseleave events do not bubble, the target - // of the event is technically the `actionElement` (the node with the - // `jsaction` attribute) - eventInfoLib.setTargetElement(eventInfo, eventInfoLib.getActionElement(action)); - } else { - eventInfoLib.unsetAction(eventInfo); - } - } - } - - /** - * Accesses the jsaction map on a node and retrieves the name of the - * action the given event is mapped to, if any. It parses the - * attribute value and stores it in a property on the node for - * subsequent retrieval without re-parsing and re-accessing the - * attribute. In order to fully qualify jsaction names using a - * namespace, the DOM is searched starting at the current node and - * going through ancestor nodes until a jsnamespace attribute is - * found. - * - * @param actionElement The DOM node to retrieve the jsaction map from. - * @param eventInfo `EventInfo` to set `action` and `actionElement` if an - * action is found on the `actionElement`. - */ - private populateActionOnElement(actionElement: Element, eventInfo: eventInfoLib.EventInfo) { - const actionMap = parseActions(actionElement, eventInfoLib.getContainer(eventInfo)); - - const actionName = actionMap[eventInfoLib.getEventType(eventInfo)]; - if (actionName !== undefined) { - eventInfoLib.setAction(eventInfo, actionName, actionElement); - } - - if (this.hasA11yClickSupport) { - this.populateClickOnlyAction!(actionElement, eventInfo, actionMap); - } - } - /** * Enables jsaction handlers to be called for the event type given by * name. @@ -576,10 +385,11 @@ export class EventContract implements UnrenamedEventContract { populateClickOnlyAction: typeof a11yClickLib.populateClickOnlyAction, ) { this.addA11yClickListener = true; - this.hasA11yClickSupport = true; - this.updateEventInfoForA11yClick = updateEventInfoForA11yClick; - this.preventDefaultForA11yClick = preventDefaultForA11yClick; - this.populateClickOnlyAction = populateClickOnlyAction; + this.actionResolver.addA11yClickSupport( + updateEventInfoForA11yClick, + preventDefaultForA11yClick, + populateClickOnlyAction, + ); } } @@ -615,158 +425,3 @@ function shouldPreventDefaultBeforeDispatching( eventInfoLib.getEventType(eventInfo) === EventType.CLICKMOD) ); } - -/** - * Parses and caches an element's jsaction element into a map. - * - * This is primarily for internal use. - * - * @param actionElement The DOM node to retrieve the jsaction map from. - * @param container The node which limits the namespace lookup for a jsaction - * name. The container node itself will not be searched. - * @return Map from event to qualified name of the jsaction bound to it. - */ -export function parseActions(actionElement: Element, container: Node): {[key: string]: string} { - let actionMap: {[key: string]: string} | undefined = cache.get(actionElement); - if (!actionMap) { - const jsactionAttribute = getAttr(actionElement, Attribute.JSACTION); - if (!jsactionAttribute) { - actionMap = EMPTY_ACTION_MAP; - cache.set(actionElement, actionMap); - } else { - actionMap = cache.getParsed(jsactionAttribute); - if (!actionMap) { - actionMap = {}; - const values = jsactionAttribute.split(REGEXP_SEMICOLON); - for (let idx = 0; idx < values.length; idx++) { - const value = values[idx]; - if (!value) { - continue; - } - const colon = value.indexOf(Char.EVENT_ACTION_SEPARATOR); - const hasColon = colon !== -1; - const type = hasColon ? stringTrim(value.substr(0, colon)) : DEFAULT_EVENT_TYPE; - const action = hasColon ? stringTrim(value.substr(colon + 1)) : value; - actionMap[type] = action; - } - cache.setParsed(jsactionAttribute, actionMap); - } - // If namespace support is active we need to augment the (potentially - // cached) jsaction mapping with the namespace. - if (EventContract.JSNAMESPACE_SUPPORT) { - const noNs = actionMap; - actionMap = {}; - for (const type in noNs) { - actionMap[type] = getFullyQualifiedAction(noNs[type], actionElement, container); - } - } - cache.set(actionElement, actionMap); - } - } - return actionMap; -} - -/** - * Returns the fully qualified jsaction action. If the given jsaction - * name doesn't already contain the namespace, the function iterates - * over ancestor nodes until a jsnamespace attribute is found, and - * uses the value of that attribute as the namespace. - * - * @param action The jsaction action to resolve. - * @param start The node from which to start searching for a jsnamespace - * attribute. - * @param container The node which limits the search for a jsnamespace - * attribute. This node will be searched. - * @return The fully qualified name of the jsaction. If no namespace is found, - * returns the unqualified name in case it exists in the global namespace. - */ -function getFullyQualifiedAction(action: string, start: Element, container: Node): string { - if (EventContract.JSNAMESPACE_SUPPORT) { - if (isNamespacedAction(action)) { - return action; - } - - let node: Node | null = start; - while (node) { - const namespace = getNamespaceFromElement(node as Element); - if (namespace) { - return namespace + Char.NAMESPACE_ACTION_SEPARATOR + action; - } - - // If this node is the container, stop. - if (node === container) { - break; - } - - node = node.parentNode; - } - } - - return action; -} - -/** - * Checks if a jsaction action contains a namespace part. - */ -function isNamespacedAction(action: string): boolean { - return action.indexOf(Char.NAMESPACE_ACTION_SEPARATOR) >= 0; -} - -/** - * Returns the value of the jsnamespace attribute of the given node. - * Also caches the value for subsequent lookups. - * @param element The node whose jsnamespace attribute is being asked for. - * @return The value of the jsnamespace attribute, or null if not found. - */ -function getNamespaceFromElement(element: Element): string | null { - let namespace = cache.getNamespace(element); - // Only query for the attribute if it has not been queried for - // before. getAttr() returns null if an attribute is not present. Thus, - // namespace is string|null if the query took place in the past, or - // undefined if the query did not take place. - if (namespace === undefined) { - namespace = getAttr(element, Attribute.JSNAMESPACE); - cache.setNamespace(element, namespace); - } - return namespace; -} - -/** - * Accesses the event handler attribute value of a DOM node. It guards - * against weird situations (described in the body) that occur in - * connection with nodes that are removed from their document. - * @param element The DOM element. - * @param attribute The name of the attribute to access. - * @return The attribute value if it was found, null otherwise. - */ -function getAttr(element: Element, attribute: string): string | null { - let value = null; - // NOTE: Nodes in IE do not always have a getAttribute - // method defined. This is the case where sourceElement has in - // fact been removed from the DOM before eventContract begins - // handling - where a parentNode does not have getAttribute - // defined. - // NOTE: We must use the 'in' operator instead of the regular dot - // notation, since the latter fails in IE8 if the getAttribute method is not - // defined. See b/7139109. - if ('getAttribute' in element) { - value = element.getAttribute(attribute); - } - return value; -} - -/** - * Helper function to trim whitespace from the beginning and the end - * of the string. This deliberately doesn't use the closure equivalent - * to keep dependencies small. - * @param str Input string. - * @return Trimmed string. - */ -function stringTrim(str: string): string { - if (typeof String.prototype.trim === 'function') { - return str.trim(); - } - - const trimmedLeft = str.replace(/^\s+/, ''); - return trimmedLeft.replace(/\s+$/, ''); -}