Skip to content

Commit

Permalink
re-implement KeyboardListener with Hotkey and KeyboardDragListener wi…
Browse files Browse the repository at this point in the history
…th KeyboardListener, see #1570
  • Loading branch information
jessegreenberg committed Apr 25, 2024
1 parent 7b52451 commit de88232
Show file tree
Hide file tree
Showing 8 changed files with 628 additions and 1,277 deletions.
4 changes: 2 additions & 2 deletions js/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,10 @@ export type { PanZoomListenerOptions } from './listeners/PanZoomListener.js';
export { default as AnimatedPanZoomListener } from './listeners/AnimatedPanZoomListener.js';
export { default as animatedPanZoomSingleton } from './listeners/animatedPanZoomSingleton.js';
export { default as HandleDownListener } from './listeners/HandleDownListener.js';
export { default as KeyboardDragListener } from './listeners/KeyboardDragListener.js';
export type { KeyboardDragListenerOptions } from './listeners/KeyboardDragListener.js';
export { default as KeyboardListener } from './listeners/KeyboardListener.js';
export type { KeyboardListenerOptions } from './listeners/KeyboardListener.js';
export { default as KeyboardDragListener } from './listeners/KeyboardDragListener.js';
export type { KeyboardDragListenerOptions } from './listeners/KeyboardDragListener.js';
export type { OneKeyStroke } from './listeners/KeyboardListener.js';
export { default as SpriteListenable } from './listeners/SpriteListenable.js';
export { default as SwipeListener } from './listeners/SwipeListener.js';
Expand Down
65 changes: 64 additions & 1 deletion js/input/Hotkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* @author Jonathan Olson <[email protected]>
*/

import { EnglishKey, scenery } from '../imports.js';
import { EnglishKey, hotkeyManager, scenery } from '../imports.js';
import optionize from '../../../phet-core/js/optionize.js';
import EnabledComponent, { EnabledComponentOptions } from '../../../axon/js/EnabledComponent.js';
import TProperty from '../../../axon/js/TProperty.js';
Expand Down Expand Up @@ -48,6 +48,16 @@ type SelfOptions = {
// The event will be null if the hotkey was fired due to fire-on-hold.
fire?: ( event: KeyboardEvent | null ) => void;

// Called as press() when the hotkey is pressed. Note that the Hotkey may be pressed before firing depending
// on fireOnDown. And press is not called with fire-on-hold. The event may be null if there is a press due to
// the hotkey becoming active due to change in state without a key press.
press?: ( event: KeyboardEvent | null ) => void;

// Called as release() when the Hotkey is released. Note that the Hotkey may release without calling fire() depending
// on fireOnDown. Event may be null in cases of interrupt or if the hotkey is released due to change in state without
// a key release.
release?: ( event: KeyboardEvent | null ) => void;

// If true, the hotkey will fire when the hotkey is initially pressed.
// If false, the hotkey will fire when the hotkey is finally released.
fireOnDown?: boolean;
Expand Down Expand Up @@ -82,6 +92,8 @@ export default class Hotkey extends EnabledComponent {
public readonly modifierKeys: EnglishKey[];
public readonly ignoredModifierKeys: EnglishKey[];
public readonly fire: ( event: KeyboardEvent | null ) => void;
public readonly press: ( event: KeyboardEvent | null ) => void;
public readonly release: ( event: KeyboardEvent | null ) => void;
public readonly fireOnDown: boolean;
public readonly fireOnHold: boolean;
public readonly fireOnHoldTiming: HotkeyFireOnHoldTiming;
Expand Down Expand Up @@ -118,6 +130,8 @@ export default class Hotkey extends EnabledComponent {
modifierKeys: [],
ignoredModifierKeys: [],
fire: _.noop,
press: _.noop,
release: _.noop,
fireOnDown: true,
fireOnHold: false,
fireOnHoldTiming: 'browser',
Expand All @@ -134,6 +148,8 @@ export default class Hotkey extends EnabledComponent {
this.modifierKeys = options.modifierKeys;
this.ignoredModifierKeys = options.ignoredModifierKeys;
this.fire = options.fire;
this.press = options.press;
this.release = options.release;
this.fireOnDown = options.fireOnDown;
this.fireOnHold = options.fireOnHold;
this.fireOnHoldTiming = options.fireOnHoldTiming;
Expand Down Expand Up @@ -162,6 +178,53 @@ export default class Hotkey extends EnabledComponent {
}
}

/**
* On "press" of a Hotkey. All keys are pressed while the Hotkey is active. May also fire depending on
* events. See hotkeyManager.
*
* (scenery-internal)
*/
public onPress( event: KeyboardEvent | null, shouldFire: boolean ): void {

// clear the flag on every press (set before notifying the isPressedProperty)
this.interrupted = false;

this.isPressedProperty.value = true;

// press after setting up state
this.press( event );

if ( shouldFire ) {
this.fire( event );
}
}

/**
* On "release" of a Hotkey. All keys are released while the Hotkey is inactive. May also fire depending on
* events. See hotkeyManager.
*/
public onRelease( event: KeyboardEvent | null, interrupted: boolean, shouldFire: boolean ): void {
this.interrupted = interrupted;

this.isPressedProperty.value = false;

this.release( event );

if ( shouldFire ) {
this.fire( event );
}
}

/**
* Manually interrupt this hotkey, releasing it and setting a flag so that it will not fire until the next time
* keys are pressed.
*/
public interrupt(): void {
if ( this.isPressedProperty.value ) {
hotkeyManager.interruptHotkey( this );
}
}

public getHotkeyString(): string {
return [
...this.modifierKeys,
Expand Down
65 changes: 12 additions & 53 deletions js/input/Input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,6 @@
* - keyup : Triggered for all keys when released. When a screen reader is active, this event will be omitted
* role="button" is activated.
* See https://www.w3.org/TR/DOM-Level-3-Events/#keyup
* - globalkeydown: Triggered for all keys pressed, regardless of whether the Node has focus. It just needs to be
* visible, inputEnabled, and all of its ancestors visible and inputEnabled.
* - globalkeyup: Triggered for all keys released, regardless of whether the Node has focus. It just needs to be
* visible, inputEnabled, and all of its ancestors visible and inputEnabled.
*
*
* *** Event Dispatch
*
Expand Down Expand Up @@ -794,13 +789,9 @@ export default class Input extends PhetioObject {
sceneryLog && sceneryLog.Input && sceneryLog.Input( `keydown(${Input.debugText( null, context.domEvent )});` );
sceneryLog && sceneryLog.Input && sceneryLog.push();

this.dispatchGlobalEvent<KeyboardEvent>( 'globalkeydown', context, true );

const trail = this.getPDOMEventTrail( context.domEvent, 'keydown' );
trail && this.dispatchPDOMEvent<KeyboardEvent>( trail, 'keydown', context, true );

this.dispatchGlobalEvent<KeyboardEvent>( 'globalkeydown', context, false );

sceneryLog && sceneryLog.Input && sceneryLog.pop();
}, {
phetioPlayback: true,
Expand All @@ -816,13 +807,9 @@ export default class Input extends PhetioObject {
sceneryLog && sceneryLog.Input && sceneryLog.Input( `keyup(${Input.debugText( null, context.domEvent )});` );
sceneryLog && sceneryLog.Input && sceneryLog.push();

this.dispatchGlobalEvent<KeyboardEvent>( 'globalkeyup', context, true );

const trail = this.getPDOMEventTrail( context.domEvent, 'keydown' );
trail && this.dispatchPDOMEvent<KeyboardEvent>( trail, 'keyup', context, true );

this.dispatchGlobalEvent<KeyboardEvent>( 'globalkeyup', context, false );

sceneryLog && sceneryLog.Input && sceneryLog.pop();
}, {
phetioPlayback: true,
Expand Down Expand Up @@ -1127,30 +1114,6 @@ export default class Input extends PhetioObject {
}
}

private dispatchGlobalEvent<DOMEvent extends Event>( eventType: SupportedEventTypes, context: EventContext<DOMEvent>, capture: boolean ): void {
this.ensurePDOMPointer();
assert && assert( this.pdomPointer );
const pointer = this.pdomPointer!;
const inputEvent = new SceneryEvent<DOMEvent>( new Trail(), eventType, pointer, context );

const recursiveGlobalDispatch = ( node: Node ) => {
if ( !node.isDisposed && node.isVisible() && node.isInputEnabled() && node.isPDOMVisible() ) {
// Reverse iteration follows the z-order from "visually in front" to "visually in back" like normal dipatch
for ( let i = node._children.length - 1; i >= 0; i-- ) {
recursiveGlobalDispatch( node._children[ i ] );
}

if ( !inputEvent.aborted && !inputEvent.handled ) {
// Notification of ourself AFTER our children results in the depth-first scan.
inputEvent.currentTarget = node;
this.dispatchToListeners<DOMEvent>( pointer, node._inputListeners, eventType, inputEvent, capture );
}
}
};

recursiveGlobalDispatch( this.rootNode );
}

/**
* From a DOM Event, get its relatedTarget and map that to the scenery Node. Will return null if relatedTarget
* is not provided, or if relatedTarget is not under PDOM, or there is no associated Node with trail id on the
Expand Down Expand Up @@ -1878,10 +1841,8 @@ export default class Input extends PhetioObject {
* @param listeners - Should be a defensive array copy already.
* @param type
* @param inputEvent
* @param capture - If true, this dispatch is in the capture sequence (like DOM's addEventListener useCapture).
* Listeners will only be called if the listener also indicates it is for the capture sequence.
*/
private dispatchToListeners<DOMEvent extends Event>( pointer: Pointer, listeners: TInputListener[], type: SupportedEventTypes, inputEvent: SceneryEvent<DOMEvent>, capture: boolean | null = null ): void {
private dispatchToListeners<DOMEvent extends Event>( pointer: Pointer, listeners: TInputListener[], type: SupportedEventTypes, inputEvent: SceneryEvent<DOMEvent> ): void {

if ( inputEvent.handled ) {
return;
Expand All @@ -1892,24 +1853,22 @@ export default class Input extends PhetioObject {
for ( let i = 0; i < listeners.length; i++ ) {
const listener = listeners[ i ];

if ( capture === null || capture === !!listener.capture ) {
if ( !inputEvent.aborted && listener[ specificType ] ) {
sceneryLog && sceneryLog.EventDispatch && sceneryLog.EventDispatch( specificType );
sceneryLog && sceneryLog.EventDispatch && sceneryLog.push();
if ( !inputEvent.aborted && listener[ specificType as keyof TInputListener ] ) {
sceneryLog && sceneryLog.EventDispatch && sceneryLog.EventDispatch( specificType );
sceneryLog && sceneryLog.EventDispatch && sceneryLog.push();

( listener[ specificType ] as SceneryListenerFunction<DOMEvent> )( inputEvent );
( listener[ specificType as keyof TInputListener ] as SceneryListenerFunction<DOMEvent> )( inputEvent );

sceneryLog && sceneryLog.EventDispatch && sceneryLog.pop();
}
sceneryLog && sceneryLog.EventDispatch && sceneryLog.pop();
}

if ( !inputEvent.aborted && listener[ type ] ) {
sceneryLog && sceneryLog.EventDispatch && sceneryLog.EventDispatch( type );
sceneryLog && sceneryLog.EventDispatch && sceneryLog.push();
if ( !inputEvent.aborted && listener[ type as keyof TInputListener ] ) {
sceneryLog && sceneryLog.EventDispatch && sceneryLog.EventDispatch( type );
sceneryLog && sceneryLog.EventDispatch && sceneryLog.push();

( listener[ type ] as SceneryListenerFunction<DOMEvent> )( inputEvent );
( listener[ type as keyof TInputListener ] as SceneryListenerFunction<DOMEvent> )( inputEvent );

sceneryLog && sceneryLog.EventDispatch && sceneryLog.pop();
}
sceneryLog && sceneryLog.EventDispatch && sceneryLog.pop();
}
}
}
Expand Down
7 changes: 1 addition & 6 deletions js/input/TInputListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,6 @@ type TInputListener = {
interrupt?: () => void;
cursor?: string | null;

// Only applies to globalkeydown/globalkeyup. When true, this listener is fired during the 'capture'
// phase. Listeners are fired BEFORE the dispatch through the scene graph. (very similar to DOM addEventListener's
// useCapture).
capture?: boolean;

listener?: unknown;

// Function that returns the Bounds2 for AnimatedPanZoomListener to keep in view during drag input.
Expand Down Expand Up @@ -100,6 +95,6 @@ type TInputListener = {
};

// Exclude all but the actual browser events
export type SupportedEventTypes = keyof StrictOmit<TInputListener, 'interrupt' | 'cursor' | 'capture' | 'listener' | 'createPanTargetBounds'>;
export type SupportedEventTypes = keyof StrictOmit<TInputListener, 'interrupt' | 'cursor' | 'listener' | 'createPanTargetBounds'>;

export default TInputListener;
43 changes: 28 additions & 15 deletions js/input/hotkeyManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,14 +265,21 @@ class HotkeyManager {
const pressedOrReleasedEnglishKey = pressedOrReleasedKeyCode ? eventCodeToEnglishString( pressedOrReleasedKeyCode ) : null;

for ( const hotkey of this.enabledHotkeysProperty.value ) {
const shouldBeActive = this.getHotkeysForMainKey( hotkey.key ).includes( hotkey );

// A hotkey should be active if its main key is pressed. If it was interrupted, it can only become
// active again if there was an actual key press event from the user. If a Hotkey is interrupted during
// a press, it should remain inactive and interrupted until the NEXT press.
const keyPressed = this.getHotkeysForMainKey( hotkey.key ).includes( hotkey );
const notInterrupted = !hotkey.interrupted || ( keyboardEvent && keyboardEvent.type === 'keydown' );
const shouldBeActive = keyPressed && notInterrupted;

const isActive = this.activeHotkeys.has( hotkey );

if ( shouldBeActive && !isActive ) {
this.addActiveHotkey( hotkey, null, hotkey.key === pressedOrReleasedEnglishKey );
this.addActiveHotkey( hotkey, keyboardEvent, hotkey.key === pressedOrReleasedEnglishKey );
}
else if ( !shouldBeActive && isActive ) {
this.removeActiveHotkey( hotkey, null, hotkey.key === pressedOrReleasedEnglishKey );
this.removeActiveHotkey( hotkey, keyboardEvent, hotkey.key === pressedOrReleasedEnglishKey );
}
}
}
Expand All @@ -282,26 +289,32 @@ class HotkeyManager {
*/
private addActiveHotkey( hotkey: Hotkey, keyboardEvent: KeyboardEvent | null, triggeredFromPress: boolean ): void {
this.activeHotkeys.add( hotkey );
hotkey.isPressedProperty.value = true;
hotkey.interrupted = false;

if ( triggeredFromPress && hotkey.fireOnDown ) {
hotkey.fire( keyboardEvent );
}
const shouldFire = triggeredFromPress && hotkey.fireOnDown;
hotkey.onPress( keyboardEvent, shouldFire );
}

/**
* Hotkey made inactive/released
* Hotkey made inactive/released.
*/
private removeActiveHotkey( hotkey: Hotkey, keyboardEvent: KeyboardEvent | null, triggeredFromRelease: boolean ): void {
hotkey.interrupted = !triggeredFromRelease;

if ( triggeredFromRelease && !hotkey.fireOnDown ) {
hotkey.fire( keyboardEvent );
}

hotkey.isPressedProperty.value = false;
// Remove from activeHotkeys before Hotkey.onRelease so that we do not try to remove it again if there is
// re-entrancy. This is possible if the release listener moves focus or interrupts a Hotkey.
this.activeHotkeys.delete( hotkey );

const shouldFire = triggeredFromRelease && !hotkey.fireOnDown;
const interrupted = !triggeredFromRelease;
hotkey.onRelease( keyboardEvent, interrupted, shouldFire );
}

/**
* Called by Hotkey, removes the Hotkey from the active set when it is interrupted. The Hotkey cannot be active
* again in this manager until there is an actual key press event from the user.
*/
public interruptHotkey( hotkey: Hotkey ): void {
assert && assert( hotkey.isPressedProperty.value, 'hotkey must be pressed to be interrupted' );
this.removeActiveHotkey( hotkey, null, false );
}
}

Expand Down
Loading

0 comments on commit de88232

Please sign in to comment.