Created
March 31, 2020 12:40
-
-
Save bennadel/4d84ab31c8e54fcd68ae0241eb46b2ac to your computer and use it in GitHub Desktop.
Experimenting With Chained Keyboard Events Using Event Plug-ins In Angular 9.1.0
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Import the core angular services. | |
import { Component } from "@angular/core"; | |
// ----------------------------------------------------------------------------------- // | |
// ----------------------------------------------------------------------------------- // | |
@Component({ | |
selector: "app-root", | |
host: { | |
"(window:keydown.Meta.K+Meta.U)": "handleUppercase( $event )", | |
"(window:keydown.Meta.K+Meta.L)": "handleLowercase( $event )", | |
"(window:keydown.ArrowUp+ArrowUp+ArrowDown+ArrowDown+ArrowLeft+ArrowRight+ArrowLeft+ArrowRight+Space)": "handleContra()" | |
}, | |
styleUrls: [ "./app.component.less" ], | |
template: | |
` | |
<p> | |
Possible key-combinations (watch console logging): | |
</p> | |
<ul> | |
<li><code>CMD-K + CMD-U</code> - Upper-casing</li> | |
<li><code>CMD-K + CMD-L</code> - Lower-casing</li> | |
</ul> | |
` | |
}) | |
export class AppComponent { | |
// I handle the Konami Conta code ... shhhhhh! | |
public handleContra() : void { | |
console.group( "Key Combination Used" ); | |
console.log( "Contra code unlocked" ); | |
console.log( "30-free lives!" ); | |
console.groupEnd(); | |
} | |
// I handle the lower-case key-combination. | |
public handleLowercase( event: KeyboardEvent ) : void { | |
console.group( "Key Combination Used" ); | |
console.log( "K+L" ); | |
console.log( "Perform lower-case command." ); | |
console.groupEnd(); | |
} | |
// I handle the upper-case key-combination. | |
public handleUppercase( event: KeyboardEvent ) : void { | |
console.group( "Key Combination Used" ); | |
console.log( "K+U" ); | |
console.log( "Perform upper-case command." ); | |
console.groupEnd(); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Import the core angular services. | |
import { BrowserModule } from "@angular/platform-browser"; | |
import { EVENT_MANAGER_PLUGINS } from "@angular/platform-browser"; | |
import { NgModule } from "@angular/core"; | |
// Import the application components and services. | |
import { AppComponent } from "./app.component"; | |
import { KeyboardEventsChainedKeydownPlugin } from "./keyboard-events-chained-keydown-plugin"; | |
// ----------------------------------------------------------------------------------- // | |
// ----------------------------------------------------------------------------------- // | |
@NgModule({ | |
imports: [ | |
BrowserModule | |
], | |
providers: [ | |
{ | |
provide: EVENT_MANAGER_PLUGINS, | |
useClass: KeyboardEventsChainedKeydownPlugin, | |
multi: true | |
} | |
], | |
declarations: [ | |
AppComponent | |
], | |
bootstrap: [ | |
AppComponent | |
] | |
}) | |
export class AppModule { | |
// ... | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// CAUTION: Much of what is in this file has been COPIED FROM THE ANGULAR PROJECT. As | |
// such, I'm not going to comment too much on it. It is here to help normalize the way | |
// keyboard events are configured by the developer and interpreted by the browser. To | |
// see how ANGULAR originally defined this data, read more in the source: | |
// -- | |
// https://github.com/angular/angular/blob/master/packages/platform-browser/src/dom/events/key_events.ts | |
var DOM_KEY_LOCATION_NUMPAD = 3; | |
interface KeyMap { | |
[ key: string ]: string; | |
}; | |
// Map to convert some key or keyIdentifier values to what will be returned by the | |
// getEventKey() method. | |
var _keyMap: KeyMap = { | |
// The following values are here for cross-browser compatibility and to match the W3C | |
// standard cf http://www.w3.org/TR/DOM-Level-3-Events-key/ | |
"\b": "Backspace", | |
"\t": "Tab", | |
"\x7F": "Delete", | |
"\x1B": "Escape", | |
"Del": "Delete", | |
"Esc": "Escape", | |
"Left": "ArrowLeft", | |
"Right": "ArrowRight", | |
"Up": "ArrowUp", | |
"Down": "ArrowDown", | |
"Menu": "ContextMenu", | |
"Scroll": "ScrollLock", | |
"Win": "OS" | |
}; | |
// There is a bug in Chrome for numeric keypad keys: | |
// https://code.google.com/p/chromium/issues/detail?id=155654 | |
// 1, 2, 3 ... are reported as A, B, C ... | |
var _chromeNumKeyPadMap = { | |
"A": "1", | |
"B": "2", | |
"C": "3", | |
"D": "4", | |
"E": "5", | |
"F": "6", | |
"G": "7", | |
"H": "8", | |
"I": "9", | |
"J": "*", | |
"K": "+", | |
"M": "-", | |
"N": ".", | |
"O": "/", | |
"\x60": "0", | |
"\x90": "NumLock" | |
}; | |
export class KeyboardEventHelper { | |
// I return the key from the given KeyboardEvent with special keys normalized for | |
// internal use. | |
static getEventKey( event: KeyboardEvent ) : string { | |
var key = KeyboardEventHelper.getEventKeyRaw( event ).toLowerCase(); | |
switch ( key ) { | |
case " ": | |
return( "space" ); | |
break; | |
case ".": | |
return( "dot" ); | |
break; | |
default: | |
return( key ); | |
break; | |
} | |
} | |
// I return the raw key from the given KeyboardEvent. This is normalized for cross- | |
// browser compatibility; but, doesn't represent a format that is normalized for | |
// internal usage. | |
static getEventKeyRaw( event: any ) : string { | |
var key = event.key; | |
if ( key == null ) { | |
key = event.keyIdentifier; | |
// keyIdentifier is defined in the old draft of DOM Level 3 Events | |
// implemented by Chrome and Safari cf | |
// http://www.w3.org/TR/2007/WD-DOM-Level-3-Events-20071221/events.html#Events-KeyboardEvents-Interfaces | |
if ( key == null ) { | |
return( "Unidentified" ); | |
} | |
if ( key.startsWith( "U+" ) ) { | |
key = String.fromCharCode( parseInt( key.substring( 2 ), 16 ) ); | |
if ( ( event.location === DOM_KEY_LOCATION_NUMPAD ) && _chromeNumKeyPadMap.hasOwnProperty( key ) ) { | |
// There is a bug in Chrome for numeric keypad keys: | |
// https://code.google.com/p/chromium/issues/detail?id=155654 | |
// 1, 2, 3 ... are reported as A, B, C ... | |
key = ( _chromeNumKeyPadMap as any )[ key ]; | |
} | |
} | |
} | |
return( _keyMap[ key ] || key ); | |
} | |
// I return the normalized key-combination from the given KeyboardEvent. This | |
// includes the primary key as well as any modifier keys, composed in a consistent | |
// order and format. The result of this method can be compared to the result of the | |
// .parseEventName() method. | |
static getEventName( event: KeyboardEvent ) : string { | |
var parts: string[] = []; | |
// Always add modifier keys in alphabetical order. | |
( event.altKey ) && parts.push( "alt" ); | |
( event.ctrlKey ) && parts.push( "control" ); | |
( event.metaKey ) && parts.push( "meta" ); | |
( event.shiftKey ) && parts.push( "shift" ); | |
// Always add the key last. | |
parts.push( KeyboardEventHelper.getEventKey( event ) ); | |
return( parts.join( "." ) ); | |
} | |
// I determine if the given KeyboardEvent represents some combination of modifier | |
// keys without any other key. | |
static isModifierOnlyEvent( event: KeyboardEvent ) : boolean { | |
switch ( KeyboardEventHelper.getEventKey( event ) ) { | |
case "alt": | |
case "control": | |
case "meta": | |
case "shift": | |
return( true ); | |
break; | |
default: | |
return( false ); | |
break; | |
} | |
} | |
// I parse the given key name for internal use. This allows for some alias to be | |
// used in the event-bindings while still using a consistent internal representation. | |
static parseEventKeyAlias( keyName: string ) : string { | |
switch( keyName ) { | |
case "esc": | |
return( "escape" ); | |
break; | |
default: | |
return( keyName ); | |
break; | |
} | |
} | |
// I parse the given keyboard event name into a consistent formatting. It is assumed | |
// that the event-type (ex, keydown) has already been removed. The result of this | |
// method can be compared to the result of the .getEventName() method. | |
static parseEventName( eventName: string ) : string { | |
var parts = eventName.toLowerCase().split( "." ); | |
var altKey = false; | |
var controlKey = false; | |
var metaKey = false; | |
var shiftKey = false; | |
// The key is always ASSUMED to be the LAST item in the event name. | |
var key = KeyboardEventHelper.parseEventKeyAlias( parts.pop() ! ); | |
// With the remaining parts, let's look for modifiers. | |
for ( var part of parts ) { | |
switch ( part ) { | |
case "alt": | |
altKey = true; | |
break; | |
case "control": | |
controlKey = true; | |
break; | |
case "meta": | |
metaKey = true; | |
break; | |
case "shift": | |
shiftKey = true; | |
break; | |
default: | |
throw( new Error( `Unexpected event name part: ${ part }` ) ); | |
break; | |
} | |
} | |
var normalizedParts: string[] = []; | |
// Always add modifier keys in alphabetical order. | |
( altKey ) && normalizedParts.push( "alt" ); | |
( controlKey ) && normalizedParts.push( "control" ); | |
( metaKey ) && normalizedParts.push( "meta" ); | |
( shiftKey ) && normalizedParts.push( "shift" ); | |
// Always add the key last. | |
normalizedParts.push( key ); | |
return( normalizedParts.join( "." ) ); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Import the core angular services. | |
import { EventManager } from "@angular/platform-browser"; | |
// Import the application components and services. | |
import { KeyboardEventHelper } from "./keyboard-event-helper"; | |
// ----------------------------------------------------------------------------------- // | |
// ----------------------------------------------------------------------------------- // | |
// I define how long the user has to complete an chained-event sequence before the | |
// internal state is reset and the chain has to be started-over. | |
// -- | |
// TODO: If we were to package this plugin into a module, we'd likely want to provide a | |
// module-setting that would allow an application to override this timer duration. | |
var TIMER_DURATION = 3000; | |
export class KeyboardEventsChainedKeydownPlugin { | |
// The manager will get injected by the EventPluginManager at runtime. | |
// -- | |
// NOTE: Using Definite Assignment Assertion to get around initialization. | |
public manager!: EventManager; | |
// --- | |
// PUBLIC METHODS. | |
// --- | |
// I bind the given event handler to the given element. Returns a function that | |
// tears-down the event binding. | |
public addEventListener( | |
element: HTMLElement, | |
higherOrderEventName: string, | |
handler: Function | |
) : Function { | |
var eventNames = this.parseHigherOrderEventName( higherOrderEventName ); | |
return( this.setupEventBinding( element, eventNames, handler ) ); | |
} | |
// I bind the given event handler to the given global element selector. Returns a | |
// function that tears-down the event binding. | |
public addGlobalEventListener( | |
higherOrderElement: string, | |
higherOrderEventName: string, | |
handler: Function | |
) : Function { | |
var target = this.parseHigherOrderElement( higherOrderElement ); | |
var eventNames = this.parseHigherOrderEventName( higherOrderEventName ); | |
return( this.setupEventBinding( target, eventNames, handler ) ); | |
} | |
// I determine if the given event name is supported by this plug-in. For each event | |
// binding, the plug-ins are tested in the reverse order of the EVENT_MANAGER_PLUGINS | |
// multi-collection. Angular will use the first plug-in that supports the event. In | |
// this case, we are supporting KEYDOWN events that use a "+" concatenation. | |
public supports( eventName: string ) : boolean { | |
return( | |
eventName.includes( "keydown." ) && | |
eventName.includes( "+" ) | |
); | |
} | |
// --- | |
// PRIVATE METHODS. | |
// --- | |
// I parse the "higher order" element selector into an actual browser DOM reference. | |
private parseHigherOrderElement( selector: string ) : EventTarget { | |
switch( selector ) { | |
case "window": | |
return( window ); | |
break; | |
case "document": | |
return( document ); | |
break; | |
case "body": | |
return( document.body ); | |
break; | |
default: | |
throw( new Error( `Element selector [${ selector }] not supported.` ) ); | |
break; | |
} | |
} | |
// I parse the "higher order" event name into a collection of individual event names. | |
private parseHigherOrderEventName( eventName: string ) : string[] { | |
// We know that the event name starts with "keydown.". As such, we can strip that | |
// portion off and then split the event on the "+" to get the individual sub- | |
// event names. | |
var eventNames = eventName | |
.slice( "keydown.".length ) | |
.split( "+" ) | |
.map( | |
( subEventName ) => { | |
return( KeyboardEventHelper.parseEventName( subEventName ) ); | |
} | |
) | |
; | |
console.log( "Parsed event names:", eventNames ); | |
return( eventNames ); | |
} | |
// I bind the given event handler to the given event target. I can be used for both | |
// local and global targets. Returns a function that tears-down the event binding. | |
private setupEventBinding( | |
target: EventTarget, | |
eventNames: string[], | |
handler: Function | |
) : Function { | |
var pendingEventNames = eventNames.slice(); | |
var timer: any = null; | |
var zone = this.manager.getZone(); | |
// In order to bypass the change-detection system, we're going to bind the DOM | |
// event handler outside of the Angular Zone. The calling context can always | |
// choose to re-enter the Angular zone if it needs to (such as when synthesizing | |
// an event). | |
zone.runOutsideAngular( addProxyFunction ); | |
return( removeProxyFunction ); | |
// -- HOISTED FUNCTIONS. -- // | |
// I add the proxy function as the DOM-event-binding. | |
function addProxyFunction() { | |
target.addEventListener( "keydown", proxyFunction, false ); | |
} | |
// I remove the proxy function as the DOM-event-binding. | |
function removeProxyFunction() { | |
// Clear any pending timer so we don't attempt to mess with state after the | |
// event-binding has been removed. | |
( timer ) && window.clearTimeout( timer ); | |
target.removeEventListener( "keydown", proxyFunction, false ); | |
} | |
// I reset the internal tracking for the chained-event sequence. | |
function reset() { | |
// Reset chained state. | |
window.clearTimeout( timer ); | |
pendingEventNames = eventNames.slice(); | |
timer = null; | |
} | |
// I am the event-handler that is bound to the DOM. I keep track of the state of | |
// the event sequence as the user triggers individual keydown events. | |
function proxyFunction( event: KeyboardEvent ) { | |
var eventName = KeyboardEventHelper.getEventName( event ); | |
// If there's no timer, then we're looking for the first event in the chained | |
// event sequence. | |
if ( ! timer ) { | |
// If the current event DOES NOT MATCH the first event name in the chain, | |
// ignore this event. | |
if ( pendingEventNames[ 0 ] !== eventName ) { | |
return; | |
} | |
// If the current event DOES MATCH the first event name in the chain, | |
// setup the timer - this creates a constraint in which the chained | |
// keydown events needs to be consumed. | |
timer = window.setTimeout( reset, TIMER_DURATION ); | |
} | |
// ASSERT: At this point, we've either just setup the timer for the first | |
// event in the sequence; or, we're already part way through the event-chain. | |
var pendingEventName = pendingEventNames.shift() !; | |
// The incoming event matches the next event in the chained sequence. | |
if ( pendingEventName === eventName ) { | |
// CAUTION: Since this keyboard event is part of key combination, we want | |
// to cancel the default behavior in case the user is trying to override | |
// a native browser behavior. | |
event.preventDefault(); | |
// If there are no more pending event-names, it means the user just | |
// executed the last event in the chained sequence! We can now re-enter | |
// the Angular Zone and invoke the callback. | |
if ( ! pendingEventNames.length ) { | |
zone.runGuarded( | |
function runInZoneSoChangeDetectionWillBeTriggered() { | |
handler( event ); | |
} | |
); | |
reset(); | |
// NOTE: Return is not really needed. Including it for clarity. | |
return; | |
} | |
// The incoming event does NOT MATCH the next event in the chained sequence; | |
// however, the incoming event is composed entirely of MODIFIER KEYS. In that | |
// case, it's possible that the use is "building up" to the desired key. As | |
// such, we're going to ignore intermediary events that only contain | |
// modifiers. | |
} else if ( KeyboardEventHelper.isModifierOnlyEvent( event ) ) { | |
// Since we'll need to re-process this pending event, stuff it back into | |
// the pending queue. | |
pendingEventNames.unshift( pendingEventName ); | |
// The incoming event does NOT MATCH the next event in the chained sequence. | |
// As such, we need to reset the internal state for tracking. | |
} else { | |
reset(); | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public supports( eventName: string ) : boolean { | |
return( | |
eventName.includes( "keydown." ) && | |
eventName.includes( "+" ) | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment