Skip to content

Instantly share code, notes, and snippets.

@renoirb
Last active February 11, 2021 17:08
Show Gist options
  • Save renoirb/ef3ead6062eb454a0755a10a3f37c009 to your computer and use it in GitHub Desktop.
Save renoirb/ef3ead6062eb454a0755a10a3f37c009 to your computer and use it in GitHub Desktop.
Chained Keyboard Key combination Event handler
// 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
// https://gist.github.com/bennadel/4d84ab31c8e54fcd68ae0241eb46b2ac#file-keyboard-event-helper-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( "." ) );
}
}
/**
* TODO: Remove angular out of this.
*/
// 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();
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment