diff --git a/js/imports.ts b/js/imports.ts index 27f190232..4069feefc 100644 --- a/js/imports.ts +++ b/js/imports.ts @@ -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'; diff --git a/js/input/Hotkey.ts b/js/input/Hotkey.ts index 6fdfd84e5..48c3875ee 100644 --- a/js/input/Hotkey.ts +++ b/js/input/Hotkey.ts @@ -10,7 +10,7 @@ * @author Jonathan Olson */ -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'; @@ -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; @@ -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; @@ -118,6 +130,8 @@ export default class Hotkey extends EnabledComponent { modifierKeys: [], ignoredModifierKeys: [], fire: _.noop, + press: _.noop, + release: _.noop, fireOnDown: true, fireOnHold: false, fireOnHoldTiming: 'browser', @@ -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; @@ -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, diff --git a/js/input/Input.ts b/js/input/Input.ts index 6eb4cb8ef..0a0865393 100644 --- a/js/input/Input.ts +++ b/js/input/Input.ts @@ -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 * @@ -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( 'globalkeydown', context, true ); - const trail = this.getPDOMEventTrail( context.domEvent, 'keydown' ); trail && this.dispatchPDOMEvent( trail, 'keydown', context, true ); - this.dispatchGlobalEvent( 'globalkeydown', context, false ); - sceneryLog && sceneryLog.Input && sceneryLog.pop(); }, { phetioPlayback: true, @@ -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( 'globalkeyup', context, true ); - const trail = this.getPDOMEventTrail( context.domEvent, 'keydown' ); trail && this.dispatchPDOMEvent( trail, 'keyup', context, true ); - this.dispatchGlobalEvent( 'globalkeyup', context, false ); - sceneryLog && sceneryLog.Input && sceneryLog.pop(); }, { phetioPlayback: true, @@ -1127,30 +1114,6 @@ export default class Input extends PhetioObject { } } - private dispatchGlobalEvent( eventType: SupportedEventTypes, context: EventContext, capture: boolean ): void { - this.ensurePDOMPointer(); - assert && assert( this.pdomPointer ); - const pointer = this.pdomPointer!; - const inputEvent = new SceneryEvent( 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( 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 @@ -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( pointer: Pointer, listeners: TInputListener[], type: SupportedEventTypes, inputEvent: SceneryEvent, capture: boolean | null = null ): void { + private dispatchToListeners( pointer: Pointer, listeners: TInputListener[], type: SupportedEventTypes, inputEvent: SceneryEvent ): void { if ( inputEvent.handled ) { return; @@ -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 )( inputEvent ); + ( listener[ specificType as keyof TInputListener ] as SceneryListenerFunction )( 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 )( inputEvent ); + ( listener[ type as keyof TInputListener ] as SceneryListenerFunction )( inputEvent ); - sceneryLog && sceneryLog.EventDispatch && sceneryLog.pop(); - } + sceneryLog && sceneryLog.EventDispatch && sceneryLog.pop(); } } } diff --git a/js/input/TInputListener.ts b/js/input/TInputListener.ts index fa895f5bb..b9e5c26b9 100644 --- a/js/input/TInputListener.ts +++ b/js/input/TInputListener.ts @@ -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. @@ -100,6 +95,6 @@ type TInputListener = { }; // Exclude all but the actual browser events -export type SupportedEventTypes = keyof StrictOmit; +export type SupportedEventTypes = keyof StrictOmit; export default TInputListener; \ No newline at end of file diff --git a/js/input/hotkeyManager.ts b/js/input/hotkeyManager.ts index 681327ac8..41bc0c25e 100644 --- a/js/input/hotkeyManager.ts +++ b/js/input/hotkeyManager.ts @@ -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 ); } } } @@ -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 ); } } diff --git a/js/listeners/KeyboardDragListener.ts b/js/listeners/KeyboardDragListener.ts index 35e3257b4..7053a30a1 100644 --- a/js/listeners/KeyboardDragListener.ts +++ b/js/listeners/KeyboardDragListener.ts @@ -2,18 +2,7 @@ /** * A general type for keyboard dragging. Objects can be dragged in one or two dimensions with the arrow keys and with - * the WASD keys. See the option keyboardDragDirection for a description of how keyboard keys can be mapped to - * motion for 1D and 2D motion. This can be added to a node through addInputListener for accessibility, which is mixed - * into Nodes with the ParallelDOM trait. - * - * JavaScript does not natively handle multiple 'keydown' events at once, so we have a custom implementation that - * tracks which keys are down and for how long in a step() function. To support keydown timing, AXON/timer is used. In - * scenery this is supported via Display.updateOnRequestAnimationFrame(), which will step the time on each frame. - * If using KeyboardDragListener in a more customized Display, like done in phetsims (see JOIST/Sim), the time must be - * manually stepped (by emitting the timer). - * - * For the purposes of this file, a "hotkey" is a collection of keys that, when pressed together in the right - * order, fire a callback. + * the WASD keys. Add this to a Node with addInputListener to enable keyboard dragging. * * @author Jesse Greenberg (PhET Interactive Simulations) * @author Michael Barlow @@ -21,75 +10,39 @@ */ import PhetioAction from '../../../tandem/js/PhetioAction.js'; -import EnabledComponent, { EnabledComponentOptions } from '../../../axon/js/EnabledComponent.js'; -import Emitter from '../../../axon/js/Emitter.js'; import Property from '../../../axon/js/Property.js'; -import stepTimer from '../../../axon/js/stepTimer.js'; import Bounds2 from '../../../dot/js/Bounds2.js'; import Transform3 from '../../../dot/js/Transform3.js'; import Vector2 from '../../../dot/js/Vector2.js'; -import platform from '../../../phet-core/js/platform.js'; import EventType from '../../../tandem/js/EventType.js'; import Tandem from '../../../tandem/js/Tandem.js'; -import { KeyboardUtils, Node, PDOMPointer, scenery, SceneryEvent, TInputListener } from '../imports.js'; +import { KeyboardListener, KeyboardListenerOptions, KeyboardUtils, Node, PDOMPointer, scenery, SceneryEvent, TInputListener } from '../imports.js'; import TProperty from '../../../axon/js/TProperty.js'; -import optionize from '../../../phet-core/js/optionize.js'; +import optionize, { EmptySelfOptions } from '../../../phet-core/js/optionize.js'; import TReadOnlyProperty from '../../../axon/js/TReadOnlyProperty.js'; -import TEmitter from '../../../axon/js/TEmitter.js'; import assertMutuallyExclusiveOptions from '../../../phet-core/js/assertMutuallyExclusiveOptions.js'; import { PhetioObjectOptions } from '../../../tandem/js/PhetioObject.js'; -import BooleanProperty from '../../../axon/js/BooleanProperty.js'; - -type PressedKeyTiming = { - - // Is the key currently down? - keyDown: boolean; - - // How long has the key been pressed in milliseconds - timeDown: number; - - // KeyboardEvent.key string - key: string; -}; - -type Hotkey = { +import TinyProperty from '../../../axon/js/TinyProperty.js'; +import CallbackTimer from '../../../axon/js/CallbackTimer.js'; +import PickOptional from '../../../phet-core/js/types/PickOptional.js'; +import platform from '../../../phet-core/js/platform.js'; +import StrictOmit from '../../../phet-core/js/types/StrictOmit.js'; +import DerivedProperty from '../../../axon/js/DerivedProperty.js'; +import { EnabledComponentOptions } from '../../../axon/js/EnabledComponent.js'; +import Emitter from '../../../axon/js/Emitter.js'; - // Keys to be pressed in order to trigger the callback of the Hotkey - keys: string[]; +const allKeys = [ 'arrowLeft', 'arrowRight', 'arrowUp', 'arrowDown', 'w', 'a', 's', 'd', 'shift' ] as const; +const leftRightKeys = [ 'arrowLeft', 'arrowRight', 'a', 'd', 'shift' ] as const; +const upDownKeys = [ 'arrowUp', 'arrowDown', 'w', 's', 'shift' ] as const; - // Called when keys are pressed in order - callback: () => void; -}; +type KeyboardDragListenerKeyStroke = typeof allKeys | typeof leftRightKeys | typeof upDownKeys; // Possible movement types for this KeyboardDragListener. 2D motion ('both') or 1D motion ('leftRight' or 'upDown'). type KeyboardDragDirection = 'both' | 'leftRight' | 'upDown'; - -type KeyboardDragDirectionKeys = { - left: string[]; - right: string[]; - up: string[]; - down: string[]; -}; - -const KEYBOARD_DRAG_DIRECTION_KEY_MAP = new Map( [ - [ 'both', { - left: [ KeyboardUtils.KEY_A, KeyboardUtils.KEY_LEFT_ARROW ], - right: [ KeyboardUtils.KEY_RIGHT_ARROW, KeyboardUtils.KEY_D ], - up: [ KeyboardUtils.KEY_UP_ARROW, KeyboardUtils.KEY_W ], - down: [ KeyboardUtils.KEY_DOWN_ARROW, KeyboardUtils.KEY_S ] - } ], - [ 'leftRight', { - left: [ KeyboardUtils.KEY_A, KeyboardUtils.KEY_LEFT_ARROW, KeyboardUtils.KEY_DOWN_ARROW, KeyboardUtils.KEY_S ], - right: [ KeyboardUtils.KEY_RIGHT_ARROW, KeyboardUtils.KEY_D, KeyboardUtils.KEY_UP_ARROW, KeyboardUtils.KEY_W ], - up: [], - down: [] - } ], - [ 'upDown', { - left: [], - right: [], - up: [ KeyboardUtils.KEY_RIGHT_ARROW, KeyboardUtils.KEY_D, KeyboardUtils.KEY_UP_ARROW, KeyboardUtils.KEY_W ], - down: [ KeyboardUtils.KEY_A, KeyboardUtils.KEY_LEFT_ARROW, KeyboardUtils.KEY_DOWN_ARROW, KeyboardUtils.KEY_S ] - } ] +const KeyboardDragDirectionToKeysMap = new Map( [ + [ 'both', allKeys ], + [ 'leftRight', leftRightKeys ], + [ 'upDown', upDownKeys ] ] ); type MapPosition = ( point: Vector2 ) => Vector2; @@ -144,12 +97,12 @@ type SelfOptions = { mapPosition?: MapPosition | null; // Called when keyboard drag is started (on initial press). - start?: ( ( event: SceneryEvent ) => void ) | null; + start?: ( ( event: SceneryEvent, listener: KeyboardDragListener ) => void ) | null; // Called during drag. If providedOptions.transform is provided, vectorDelta will be in model coordinates. // Otherwise, it will be in view coordinates. Note that this does not provide the SceneryEvent. Dragging // happens during animation (as long as keys are down), so there is no event associated with the drag. - drag?: ( ( vectorDelta: Vector2 ) => void ) | null; + drag?: ( ( event: SceneryEvent, listener: KeyboardDragListener ) => void ) | null; // Called when keyboard dragging ends. end?: ( ( event: SceneryEvent | null, listener: KeyboardDragListener ) => void ) | null; @@ -161,77 +114,55 @@ type SelfOptions = { // than 0 to prevent dragging that is based on how often animation-frame steps occur. moveOnHoldInterval?: number; - // Time interval at which holding down a hotkey group will trigger an associated listener, in ms - hotkeyHoldInterval?: number; - - // EnabledComponent - // By default, do not instrument the enabledProperty; opt in with this option. See EnabledComponent - phetioEnabledPropertyInstrumented?: boolean; - // Though DragListener is not instrumented, declare these here to support properly passing this to children, see // https://github.com/phetsims/tandem/issues/60. } & Pick; -export type KeyboardDragListenerOptions = SelfOptions & EnabledComponentOptions; +type ParentOptions = StrictOmit, 'keys'>; -class KeyboardDragListener extends EnabledComponent implements TInputListener { +export type KeyboardDragListenerOptions = SelfOptions & // Options specific to this class + PickOptional & // Only focus/blur are optional from the superclass + EnabledComponentOptions; // Other superclass options are allowed + +class KeyboardDragListener extends KeyboardListener { // See options for documentation - private _start: ( ( event: SceneryEvent ) => void ) | null; - private _drag: ( ( vectorDelta: Vector2, listener: KeyboardDragListener ) => void ) | null; - private _end: ( ( event: SceneryEvent | null, listener: KeyboardDragListener ) => void ) | null; + private readonly _start: ( ( event: SceneryEvent, listener: KeyboardDragListener ) => void ) | null; + private readonly _drag: ( ( event: SceneryEvent, listener: KeyboardDragListener ) => void ) | null; + private readonly _end: ( ( event: SceneryEvent | null, listener: KeyboardDragListener ) => void ) | null; private _dragBoundsProperty: TReadOnlyProperty; - private _mapPosition: MapPosition | null; + private readonly _mapPosition: MapPosition | null; private _transform: Transform3 | TReadOnlyProperty | null; - private _keyboardDragDirection: KeyboardDragDirection; - private _positionProperty: Pick, 'value'> | null; + private readonly _positionProperty: Pick, 'value'> | null; private _dragSpeed: number; private _shiftDragSpeed: number; private _dragDelta: number; private _shiftDragDelta: number; - private _moveOnHoldDelay: number; - private _moveOnHoldInterval!: number; - private _hotkeyHoldInterval: number; - - // (read-only) - Tracks whether this listener is "pressed" or not. - public readonly isPressedProperty: TProperty; - - // Tracks the state of the keyboard. JavaScript doesn't handle multiple key presses, so we track which keys are - // currently down and update based on state of this collection of objects. - private keyState: PressedKeyTiming[]; - - // A list of hotkeys, each of which has some behavior when each individual key of the hotkey is pressed in order. - // See this.addHotkey() for more information. - private _hotkeys: Hotkey[]; - - // The Hotkey that is currently down - private currentHotkey: Hotkey | null; - - // When a hotkey group is pressed down, dragging will be disabled until any keys are up again - private hotkeyDisablingDragging: boolean; - - // Delay before calling a Hotkey listener (if all Hotkeys are being held down), incremented in step. In milliseconds. - private hotkeyHoldIntervalCounter: number; + private readonly _moveOnHoldDelay: number; - // Counters to allow for press-and-hold functionality that enables user to incrementally move the draggable object or - // hold the movement key for continuous or stepped movement - values in ms - private moveOnHoldDelayCounter: number; - private moveOnHoldIntervalCounter: number; - - // Variable to determine when the initial delay is complete - private delayComplete: boolean; + // Properties internal to the listener that track pressed keys. Instead of updating in the KeyboardListener + // callback, the positionProperty is updated in a callback timer depending on the state of these Properties + // so that movement is smooth. + private leftKeyDownProperty = new TinyProperty( false ); + private rightKeyDownProperty = new TinyProperty( false ); + private upKeyDownProperty = new TinyProperty( false ); + private downKeyDownProperty = new TinyProperty( false ); + private shiftKeyDownProperty = new TinyProperty( false ); // Fires to conduct the start and end of a drag, added for PhET-iO interoperability private dragStartAction: PhetioAction<[ SceneryEvent ]>; - private dragEndAction: PhetioAction<[ SceneryEvent ]>; + private dragEndAction: PhetioAction; - // @deprecated - Use the drag option instead. - public dragEmitter: TEmitter; + // KeyboardDragListener is implemented with KeyboardListener and therefore Hotkey. Hotkeys use 'global' DOM events + // instead of SceneryEvent dispatch. In order to start the drag with a SceneryEvent, this listener waits + // to start until its keys are pressed, and it starts the drag on the next SceneryEvent from keydown dispatch. + private startNextKeyboardEvent = false; - //(read-only) - Whether the last drag was interrupted. Will be valid until the next drag start. - public interrupted = false; + // Similar to the above, but used for restarting the callback timer on the next keydown event when a new key is + // pressed. + private restartTimerNextKeyboardEvent = false; - // Implements disposal + // Implements disposal. private readonly _disposeKeyboardDragListener: () => void; // A listener added to the pointer when dragging starts so that we can attach a listener and provide a channel of @@ -241,17 +172,31 @@ class KeyboardDragListener extends EnabledComponent implements TInputListener { // A reference to the Pointer during a drag operation so that we can add/remove the _pointerListener. private _pointer: PDOMPointer | null; - // Whether we are using a speed implementation or delta implementation for dragging. See options + // Whether this listener uses a speed implementation or delta implementation for dragging. See options // dragSpeed and dragDelta for more information. private readonly useDragSpeed: boolean; + // The vector delta that is used to move the object during a drag operation. Assigned to the listener so that + // it is usable in the drag callback. + public vectorDelta: Vector2 = new Vector2( 0, 0 ); + + // True when any movement key is pressed (arrow or WASD keys), false otherwise. + private readonly movementKeyPressedProperty: TReadOnlyProperty; + + // The callback timer that is used to move the object during a drag operation to support animated motion and + // motion every moveOnHoldInterval. + private readonly callbackTimer: CallbackTimer; + public constructor( providedOptions?: KeyboardDragListenerOptions ) { // Use either dragSpeed or dragDelta, cannot use both at the same time. assert && assertMutuallyExclusiveOptions( providedOptions, [ 'dragSpeed', 'shiftDragSpeed' ], [ 'dragDelta', 'shiftDragDelta' ] ); + + // 'move on hold' timings are only relevant for 'delta' implementations of dragging + assert && assertMutuallyExclusiveOptions( providedOptions, [ 'dragSpeed' ], [ 'moveOnHoldDelay', 'moveOnHOldInterval' ] ); assert && assertMutuallyExclusiveOptions( providedOptions, [ 'mapPosition' ], [ 'dragBoundsProperty' ] ); - const options = optionize()( { + const options = optionize()( { // default moves the object roughly 600 view coordinates every second, assuming 60 fps dragDelta: 10, @@ -266,10 +211,8 @@ class KeyboardDragListener extends EnabledComponent implements TInputListener { start: null, drag: null, end: null, - moveOnHoldDelay: 0, - moveOnHoldInterval: 1000 / 60, // an average dt value at 60 frames a second - hotkeyHoldInterval: 800, - phetioEnabledPropertyInstrumented: false, + moveOnHoldDelay: 500, + moveOnHoldInterval: 400, tandem: Tandem.REQUIRED, // DragListener by default doesn't allow PhET-iO to trigger drag Action events @@ -279,9 +222,40 @@ class KeyboardDragListener extends EnabledComponent implements TInputListener { assert && assert( options.shiftDragSpeed <= options.dragSpeed, 'shiftDragSpeed should be less than or equal to shiftDragSpeed, it is intended to provide more fine-grained control' ); assert && assert( options.shiftDragDelta <= options.dragDelta, 'shiftDragDelta should be less than or equal to dragDelta, it is intended to provide more fine-grained control' ); - super( options ); + const keys = KeyboardDragDirectionToKeysMap.get( options.keyboardDragDirection )!; + assert && assert( keys, 'Invalid keyboardDragDirection' ); + + const superOptions = optionize>()( { + keys: keys, + ignoredModifierKeys: [ 'shift' ] + }, options ); + + super( superOptions ); + + // Emits an event every drag + // TODO: Delete this if it is OK to change the phet-io APIs, see https://github.com/phetsims/scenery/issues/1570 + // @deprecated - Use the drag option instead + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const dragEmitter = new Emitter( { + tandem: options.tandem.createTandem( 'dragEmitter' ), + phetioHighFrequency: true, + phetioDocumentation: 'Emits whenever a keyboard drag occurs.', + phetioReadOnly: options.phetioReadOnly, + phetioEventType: EventType.USER + } ); + + // pressedKeysProperty comes from KeyboardListener, and it is used to determine the state of the movement keys. + // This approach gives more control over the positionProperty in the callbackTimer than using the KeyboardListener + // callback. + this.pressedKeysProperty.link( pressedKeys => { + this.shiftKeyDownProperty.value = pressedKeys.includes( 'shift' ); + this.leftKeyDownProperty.value = pressedKeys.includes( 'arrowLeft' ) || pressedKeys.includes( 'a' ); + this.rightKeyDownProperty.value = pressedKeys.includes( 'arrowRight' ) || pressedKeys.includes( 'd' ); + this.upKeyDownProperty.value = pressedKeys.includes( 'arrowUp' ) || pressedKeys.includes( 'w' ); + this.downKeyDownProperty.value = pressedKeys.includes( 'arrowDown' ) || pressedKeys.includes( 's' ); + } ); - // mutable attributes declared from options, see options for info, as well as getters and setters + // Mutable attributes declared from options, see options for info, as well as getters and setters. this._start = options.start; this._drag = options.drag; this._end = options.end; @@ -294,65 +268,16 @@ class KeyboardDragListener extends EnabledComponent implements TInputListener { this._dragDelta = options.dragDelta; this._shiftDragDelta = options.shiftDragDelta; this._moveOnHoldDelay = options.moveOnHoldDelay; - this.moveOnHoldInterval = options.moveOnHoldInterval; - this._hotkeyHoldInterval = options.hotkeyHoldInterval; - this._keyboardDragDirection = options.keyboardDragDirection; - - this.isPressedProperty = new BooleanProperty( false, { reentrant: true } ); - - this.keyState = []; - this._hotkeys = []; - this.currentHotkey = null; - this.hotkeyDisablingDragging = false; - - // This is initialized to the "threshold" so that the first hotkey will fire immediately. Only subsequent actions - // while holding the hotkey should result in a delay of this much. in ms - this.hotkeyHoldIntervalCounter = this._hotkeyHoldInterval; // Since dragSpeed and dragDelta are mutually-exclusive drag implementations, a value for either one of these // options indicates we should use a speed implementation for dragging. this.useDragSpeed = options.dragSpeed > 0 || options.shiftDragSpeed > 0; - this.moveOnHoldDelayCounter = 0; - this.moveOnHoldIntervalCounter = 0; - - this.delayComplete = false; - this.dragStartAction = new PhetioAction( event => { - const key = KeyboardUtils.getEventCode( event.domEvent ); - assert && assert( key, 'How can we have a null key for KeyboardDragListener?' ); + this._start && this._start( event, this ); - this.interrupted = false; - - // If there are no movement keys down, attach a listener to the Pointer that will tell the AnimatedPanZoomListener - // to keep this Node in view - if ( !this.movementKeysDown && KeyboardUtils.isMovementKey( event.domEvent ) ) { - assert && assert( this._pointer === null, 'We should have cleared the Pointer reference by now.' ); - this._pointer = event.pointer as PDOMPointer; - event.pointer.addInputListener( this._pointerListener, true ); - this.isPressedProperty.value = true; - } - - // update the key state - this.keyState.push( { - keyDown: true, - key: key!, - timeDown: 0 // in ms - } ); - - if ( this._start ) { - if ( this.movementKeysDown ) { - this._start( event ); - } - } - - // initial movement on down should only be used for dragDelta implementation - if ( !this.useDragSpeed ) { - - // move object on first down before a delay - const positionDelta = this.shiftKeyDown() ? this._shiftDragDelta : this._dragDelta; - this.updatePosition( positionDelta ); - this.moveOnHoldIntervalCounter = 0; + if ( this.useDragSpeed ) { + this.callbackTimer.start(); } }, { parameters: [ { name: 'event', phetioType: SceneryEvent.SceneryEventIO } ], @@ -362,42 +287,23 @@ class KeyboardDragListener extends EnabledComponent implements TInputListener { phetioEventType: EventType.USER } ); - // Emits an event every drag - // @deprecated - Use the drag option instead - this.dragEmitter = new Emitter( { - tandem: options.tandem.createTandem( 'dragEmitter' ), - phetioHighFrequency: true, - phetioDocumentation: 'Emits whenever a keyboard drag occurs.', - phetioReadOnly: options.phetioReadOnly, - phetioEventType: EventType.USER - } ); + this.dragEndAction = new PhetioAction( () => { - this.dragEndAction = new PhetioAction( event => { + // stop the callback timer + this.callbackTimer.stop( false ); - // If there are no movement keys down, attach a listener to the Pointer that will tell the AnimatedPanZoomListener - // to keep this Node in view - if ( !this.movementKeysDown ) { - assert && assert( event.pointer === this._pointer, 'How could the event Pointer be anything other than this PDOMPointer?' ); - this._pointer!.removeInputListener( this._pointerListener ); - this._pointer = null; - this.isPressedProperty.value = false; - } + const syntheticEvent = this._pointer ? this.createSyntheticEvent( this._pointer ) : null; + this._end && this._end( syntheticEvent, this ); - this._end && this._end( event, this ); + this.clearPointer(); }, { - parameters: [ { name: 'event', phetioType: SceneryEvent.SceneryEventIO } ], + parameters: [], tandem: options.tandem.createTandem( 'dragEndAction' ), phetioDocumentation: 'Emits whenever a keyboard drag ends.', phetioReadOnly: options.phetioReadOnly, phetioEventType: EventType.USER } ); - // step the drag listener, must be removed in dispose - const stepListener = this.step.bind( this ); - stepTimer.addListener( stepListener ); - - this.enabledProperty.lazyLink( this.onEnabledPropertyChange.bind( this ) ); - this._pointerListener = { listener: this, interrupt: this.interrupt.bind( this ) @@ -405,10 +311,129 @@ class KeyboardDragListener extends EnabledComponent implements TInputListener { this._pointer = null; - // called in dispose + // For dragSpeed implementation, the CallbackTimer will fire every animation frame, so the interval is + // meant to work at 60 frames per second. + const interval = this.useDragSpeed ? 1000 / 60 : options.moveOnHoldInterval; + const delay = this.useDragSpeed ? 0 : options.moveOnHoldDelay; + + this.callbackTimer = new CallbackTimer( { + delay: delay, + interval: interval, + + callback: () => { + + let deltaX = 0; + let deltaY = 0; + + let delta: number; + if ( this.useDragSpeed ) { + + // We know that CallbackTimer is going to fire at the interval so we can use that to get the dt. + const dt = interval / 1000; // the interval in seconds + delta = dt * ( this.shiftKeyDownProperty.value ? options.shiftDragSpeed : options.dragSpeed ); + } + else { + delta = this.shiftKeyDownProperty.value ? options.shiftDragDelta : options.dragDelta; + } + + if ( this.leftKeyDownProperty.value ) { + deltaX -= delta; + } + if ( this.rightKeyDownProperty.value ) { + deltaX += delta; + } + if ( this.upKeyDownProperty.value ) { + deltaY -= delta; + } + if ( this.downKeyDownProperty.value ) { + deltaY += delta; + } + + let vectorDelta = new Vector2( deltaX, deltaY ); + + // only initiate move if there was some attempted keyboard drag + if ( !vectorDelta.equals( Vector2.ZERO ) ) { + + // to model coordinates + if ( options.transform ) { + const transform = options.transform instanceof Transform3 ? options.transform : options.transform.value; + vectorDelta = transform.inverseDelta2( vectorDelta ); + } + + // synchronize with model position + if ( this._positionProperty ) { + let newPosition = this._positionProperty.value.plus( vectorDelta ); + newPosition = this.mapModelPoint( newPosition ); + + // update the position if it is different + if ( !newPosition.equals( this._positionProperty.value ) ) { + this._positionProperty.value = newPosition; + } + } + } + + // the optional drag function at the end of any movement + if ( this._drag ) { + this.vectorDelta = vectorDelta; + + assert && assert( this._pointer, 'the pointer must be assigned at the start of a drag action' ); + const syntheticEvent = this.createSyntheticEvent( this._pointer! ); + this._drag( syntheticEvent, this ); + } + } + } ); + + // When the drag keys are down, start the callback timer. When they are up, stop the callback timer. A custom + // Property instead of this.isPressedProperty because we don't want to start/end drag from the shift key. + this.movementKeyPressedProperty = DerivedProperty.or( [ + this.leftKeyDownProperty, + this.rightKeyDownProperty, + this.upKeyDownProperty, + this.downKeyDownProperty + ] ); + + // When any of the movement keys first go down, start the drag operation on the next keydown event (so that + // the SceneryEvent is available). + this.movementKeyPressedProperty.lazyLink( dragKeysDown => { + if ( dragKeysDown ) { + this.startNextKeyboardEvent = true; + } + else { + + // In case movement keys are released before we get a keydown event (mostly possible during fuzz testing), + // don't start the next drag action. + this.startNextKeyboardEvent = false; + this.restartTimerNextKeyboardEvent = false; + + this.dragEndAction.execute(); + } + } ); + + // If not the shift key, the drag should start immediately in the direction of the newly pressed key instead + // of waiting for the next interval. Only important for !useDragSpeed. + if ( !this.useDragSpeed ) { + const addStartTimerListener = ( keyProperty: TReadOnlyProperty ) => { + keyProperty.link( keyDown => { + if ( keyDown ) { + this.restartTimerNextKeyboardEvent = true; + } + } ); + }; + addStartTimerListener( this.leftKeyDownProperty ); + addStartTimerListener( this.rightKeyDownProperty ); + addStartTimerListener( this.upKeyDownProperty ); + addStartTimerListener( this.downKeyDownProperty ); + } + this._disposeKeyboardDragListener = () => { - stepTimer.removeListener( stepListener ); - this.isPressedProperty.dispose(); + this.movementKeyPressedProperty.dispose(); + + this.leftKeyDownProperty.dispose(); + this.rightKeyDownProperty.dispose(); + this.upKeyDownProperty.dispose(); + this.downKeyDownProperty.dispose(); + + this.callbackTimer.dispose(); }; } @@ -480,41 +505,38 @@ class KeyboardDragListener extends EnabledComponent implements TInputListener { public set shiftDragDelta( shiftDragDelta: number ) { this._shiftDragDelta = shiftDragDelta; } /** - * Getter for the moveOnHoldDelay property, see options.moveOnHoldDelay for more info. - */ - public get moveOnHoldDelay(): number { return this._moveOnHoldDelay; } - - /** - * Setter for the moveOnHoldDelay property, see options.moveOnHoldDelay for more info. + * Returns true if this listener is currently pressed such that it is moving the target Node. */ - public set moveOnHoldDelay( moveOnHoldDelay: number ) { this._moveOnHoldDelay = moveOnHoldDelay; } + public get isPressed(): boolean { + return this.movementKeyPressedProperty.value; + } /** - * Getter for the moveOnHoldInterval property, see options.moveOnHoldInterval for more info. + * Are keys pressed that would move the target Node to the left? */ - public get moveOnHoldInterval(): number { return this._moveOnHoldInterval; } + public movingLeft(): boolean { + return this.leftKeyDownProperty.value && !this.rightKeyDownProperty.value; + } /** - * Setter for the moveOnHoldInterval property, see options.moveOnHoldInterval for more info. + * Are keys pressed that would move the target Node to the right? */ - public set moveOnHoldInterval( moveOnHoldInterval: number ) { - assert && assert( moveOnHoldInterval > 0, 'if the moveOnHoldInterval is 0, then the dragging will be ' + - 'dependent on how often the dragListener is stepped' ); - this._moveOnHoldInterval = moveOnHoldInterval; + public movingRight(): boolean { + return this.rightKeyDownProperty.value && !this.leftKeyDownProperty.value; } /** - * Getter for the hotkeyHoldInterval property, see options.hotkeyHoldInterval for more info. + * Are keys pressed that would move the target Node up? */ - public get hotkeyHoldInterval(): number { return this._hotkeyHoldInterval; } + public movingUp(): boolean { + return this.upKeyDownProperty.value && !this.downKeyDownProperty.value; + } /** - * Setter for the hotkeyHoldInterval property, see options.hotkeyHoldInterval for more info. + * Are keys pressed that would move the target Node down? */ - public set hotkeyHoldInterval( hotkeyHoldInterval: number ) { this._hotkeyHoldInterval = hotkeyHoldInterval; } - - public get isPressed(): boolean { - return !!this._pointer; + public movingDown(): boolean { + return this.downKeyDownProperty.value && !this.upKeyDownProperty.value; } /** @@ -527,20 +549,17 @@ class KeyboardDragListener extends EnabledComponent implements TInputListener { } /** - * Fired when the enabledProperty changes + * Scenery internal. Part of the events API. Do not call directly. + * + * Does specific work for the keydown event. This is called during scenery event dispatch, and AFTER any global + * key state updates. This is important because interruption needs to happen after hotkeyManager has fully processed + * the key state. And this implementation assumes that the keydown event will happen after Hotkey updates + * (see startNextKeyboardEvent). */ - private onEnabledPropertyChange( enabled: boolean ): void { - !enabled && this.interrupt(); - } + public override keydown( event: SceneryEvent ): void { + super.keydown( event ); - /** - * Implements keyboard dragging when listener is attached to the Node, public because this is called as part of - * the Scenery Input API, but clients should not call this directly. - */ - public keydown( event: SceneryEvent ): void { - const domEvent = event.domEvent as KeyboardEvent; - const key = KeyboardUtils.getEventCode( domEvent ); - assert && assert( key, 'How can we have a null key from a keydown in KeyboardDragListener?' ); + const domEvent = event.domEvent!; // If the meta key is down (command key/windows key) prevent movement and do not preventDefault. // Meta key + arrow key is a command to go back a page, and we need to allow that. But also, macOS @@ -550,304 +569,47 @@ class KeyboardDragListener extends EnabledComponent implements TInputListener { return; } - // required to work with Safari and VoiceOver, otherwise arrow keys will move virtual cursor, see https://github.com/phetsims/balloons-and-static-electricity/issues/205#issuecomment-263428003 - // prevent default for WASD too, see https://github.com/phetsims/friction/issues/167 if ( KeyboardUtils.isMovementKey( domEvent ) ) { - domEvent.preventDefault(); - } - // reserve keyboard events for dragging to prevent default panning behavior with zoom features - event.pointer.reserveForKeyboardDrag(); - - // if the key is already down, don't do anything else (we don't want to create a new keystate object - // for a key that is already being tracked and down, nor call startDrag every keydown event) - if ( this.keyInListDown( [ key! ] ) ) { return; } - - // Prevent a VoiceOver bug where pressing multiple arrow keys at once causes the AT to send the wrong keys - // through the keyup event - as a workaround, we only allow one arrow key to be down at a time. If two are pressed - // down, we immediately clear the keystate and return - // see https://github.com/phetsims/balloons-and-static-electricity/issues/384 - if ( platform.safari ) { - if ( KeyboardUtils.isArrowKey( domEvent ) ) { - if ( this.keyInListDown( [ - KeyboardUtils.KEY_RIGHT_ARROW, KeyboardUtils.KEY_LEFT_ARROW, - KeyboardUtils.KEY_UP_ARROW, KeyboardUtils.KEY_DOWN_ARROW ] ) ) { - this.interrupt(); - return; - } + // Prevent a VoiceOver bug where pressing multiple arrow keys at once causes the AT to send the wrong keys + // through the keyup event - as a workaround, we only allow one arrow key to be down at a time. If two are pressed + // down, we immediately interrupt. + if ( platform.safari && this.pressedKeysProperty.value.length > 1 ) { + this.interrupt(); + return; } - } - - this.canDrag( event ) && this.dragStartAction.execute( event ); - } - - /** - * Behavior for keyboard 'up' DOM event. Public so it can be attached with addInputListener() - * - * Note that this event is assigned in the constructor, and not to the prototype. As of writing this, - * `Node.addInputListener` only supports type properties as event listeners, and not the event keys as - * prototype methods. Please see https://github.com/phetsims/scenery/issues/851 for more information. - */ - public keyup( event: SceneryEvent ): void { - const domEvent = event.domEvent as KeyboardEvent; - const key = KeyboardUtils.getEventCode( domEvent ); - - const moveKeysDown = this.movementKeysDown; - - // if the shift key is down when we navigate to the object, add it to the keystate because it won't be added until - // the next keydown event - if ( key === KeyboardUtils.KEY_TAB ) { - if ( domEvent.shiftKey ) { - - // add 'shift' to the keystate until it is released again - this.keyState.push( { - keyDown: true, - key: KeyboardUtils.KEY_SHIFT_LEFT, - timeDown: 0 // in ms - } ); - } - } - - for ( let i = 0; i < this.keyState.length; i++ ) { - if ( key === this.keyState[ i ].key ) { - this.keyState.splice( i, 1 ); - } - } - - const moveKeysStillDown = this.movementKeysDown; - - // if movement keys are no longer down after keyup, call the optional end drag function - if ( !moveKeysStillDown && moveKeysDown !== moveKeysStillDown ) { - this.dragEndAction.execute( event ); - } - - // if any current hotkey keys are no longer down, clear out the current hotkey and reset. - if ( this.currentHotkey && !this.allKeysInListDown( this.currentHotkey.keys ) ) { - this.resetHotkeyState(); - } - - this.resetPressAndHold(); - } - /** - * Interrupts and resets the listener on blur so that listener state is reset and keys are removed from the keyState - * array. Public because this is called with the scenery listener API. Clients should not call this directly. - * - * focusout bubbles, which is important so that the work of interrupt happens as focus moves between children of - * a parent with a KeyboardDragListener, which can create state for the keystate. - * See https://github.com/phetsims/scenery/issues/1461. - */ - public focusout( event: SceneryEvent ): void { - this.interrupt(); - } - - /** - * Step function for the drag handler. JavaScript does not natively handle multiple keydown events at once, - * so we need to track the state of the keyboard in an Object and manage dragging in this function. - * In order for the drag handler to work. - * - * @param dt - in seconds - */ - private step( dt: number ): void { - - // dt is in seconds and we convert to ms - const ms = dt * 1000; - - // no-op unless a key is down - if ( this.keyState.length > 0 ) { - // for each key that is still down, increment the tracked time that has been down - for ( let i = 0; i < this.keyState.length; i++ ) { - if ( this.keyState[ i ].keyDown ) { - this.keyState[ i ].timeDown += ms; - } - } - - // Movement delay counters should only increment if movement keys are pressed down. They will get reset - // every up event. - if ( this.movementKeysDown ) { - this.moveOnHoldDelayCounter += ms; - this.moveOnHoldIntervalCounter += ms; - } - - // update timer for keygroup if one is being held down - if ( this.currentHotkey ) { - this.hotkeyHoldIntervalCounter += ms; - } - - let positionDelta = 0; - - if ( this.useDragSpeed ) { - - // calculate change in position from time step - const positionSpeedSeconds = this.shiftKeyDown() ? this._shiftDragSpeed : this._dragSpeed; - const positionSpeedMilliseconds = positionSpeedSeconds / 1000; - positionDelta = ms * positionSpeedMilliseconds; - } - else { - - // If dragging by deltas, we are only movable every moveOnHoldInterval. - let movable = false; - - // Wait for a longer delay (moveOnHoldDelay) on initial press and hold. - if ( this.moveOnHoldDelayCounter >= this._moveOnHoldDelay && !this.delayComplete ) { - movable = true; - this.delayComplete = true; - this.moveOnHoldIntervalCounter = 0; - } - - // Initial delay is complete, now we will move every moveOnHoldInterval - if ( this.delayComplete && this.moveOnHoldIntervalCounter >= this._moveOnHoldInterval ) { - movable = true; - - // If updating as a result of the moveOnHoldIntervalCounter, don't automatically throw away any "remainder" - // time by setting back to 0. We want to accumulate them so that, no matter the clock speed of the - // runtime, the long-term effect of the drag is consistent. - const overflowTime = this.moveOnHoldIntervalCounter - this._moveOnHoldInterval; // ms - - // This doesn't take into account if 2 updatePosition calls should occur based on the current timing. - this.moveOnHoldIntervalCounter = overflowTime; - } - - positionDelta = movable ? ( this.shiftKeyDown() ? this._shiftDragDelta : this._dragDelta ) : 0; - } - - if ( positionDelta > 0 ) { - this.updatePosition( positionDelta ); - } - } - } - - /** - * Returns true if a drag can begin from input with this listener. - */ - private canDrag( event: SceneryEvent ): boolean { - - // must be enabled and must not be attached to a listener (other than this._pointerListener - (canDrag - // is used every new key press) - const attachedListener = event.pointer.attachedListener; - return this.enabledProperty.value && ( !attachedListener || attachedListener === this._pointerListener ); - } - - /** - * Update the state of hotkeys, and fire hotkey callbacks if one is active. - */ - private updateHotkeys(): void { - - // check to see if any hotkey combinations are down - for ( let j = 0; j < this._hotkeys.length; j++ ) { - const hotkeysDownList = []; - const keys = this._hotkeys[ j ].keys; - - for ( let k = 0; k < keys.length; k++ ) { - for ( let l = 0; l < this.keyState.length; l++ ) { - if ( this.keyState[ l ].key === keys[ k ] ) { - hotkeysDownList.push( this.keyState[ l ] ); - } - } - } - - // There is only a single hotkey and it is down, the hotkeys must be in order - let keysInOrder = hotkeysDownList.length === 1 && keys.length === 1; - - // the hotkeysDownList array order should match the order of the key group, so now we just need to make - // sure that the key down times are in the right order - for ( let m = 0; m < hotkeysDownList.length - 1; m++ ) { - if ( hotkeysDownList[ m + 1 ] && hotkeysDownList[ m ].timeDown > hotkeysDownList[ m + 1 ].timeDown ) { - keysInOrder = true; - } - } - - // if keys are in order, call the callback associated with the group, and disable dragging until - // all hotkeys associated with that group are up again - if ( keysInOrder ) { - this.currentHotkey = this._hotkeys[ j ]; - if ( this.hotkeyHoldIntervalCounter >= this._hotkeyHoldInterval ) { - - // Set the counter to begin counting the next interval between hotkey activations. - this.hotkeyHoldIntervalCounter = 0; - - // call the callback last, after internal state has been updated. This solves a bug caused if this callback - // then makes this listener interrupt. - this._hotkeys[ j ].callback(); - } - } - } - - // if a key group is down, check to see if any of those keys are still down - if so, we will disable dragging - // until all of them are up - if ( this.currentHotkey ) { - if ( this.keyInListDown( this.currentHotkey.keys ) ) { - this.hotkeyDisablingDragging = true; - } - else { - this.hotkeyDisablingDragging = false; - - // keys are no longer down, clear the group - this.currentHotkey = null; - } - } - } - - /** - * Handle the actual change in position of associated object based on currently pressed keys. Called in step function - * and keydown listener. - * - * @param delta - potential change in position in x and y for the position Property - */ - private updatePosition( delta: number ): void { - - // hotkeys may disable dragging, so do this first - this.updateHotkeys(); - - if ( !this.hotkeyDisablingDragging ) { + // Finally, in this case we are actually going to drag the object. Prevent default behavior so that Safari + // doesn't play a 'bonk' sound every arrow key press. + domEvent.preventDefault(); - // handle the change in position - let deltaX = 0; - let deltaY = 0; + // Cannot attach a listener to a Pointer that is already attached. + if ( this.startNextKeyboardEvent && !event.pointer.isAttached() ) { - if ( this.leftMovementKeysDown() ) { - deltaX -= delta; - } - if ( this.rightMovementKeysDown() ) { - deltaX += delta; - } + // If there are no movement keys down, attach a listener to the Pointer that will tell the AnimatedPanZoomListener + // to keep this Node in view + assert && assert( this._pointer === null, 'Pointer should be null at the start of a drag action' ); + this._pointer = event.pointer as PDOMPointer; + event.pointer.addInputListener( this._pointerListener, true ); - if ( this.upMovementKeysDown() ) { - deltaY -= delta; - } - if ( this.downMovementKeysDown() ) { - deltaY += delta; + this.dragStartAction.execute( event ); + this.startNextKeyboardEvent = false; } - // only initiate move if there was some attempted keyboard drag - let vectorDelta = new Vector2( deltaX, deltaY ); - if ( !vectorDelta.equals( Vector2.ZERO ) ) { - - // to model coordinates - if ( this._transform ) { - const transform = this._transform instanceof Transform3 ? this._transform : this._transform.value; - - vectorDelta = transform.inverseDelta2( vectorDelta ); - } - - // synchronize with model position - if ( this._positionProperty ) { - let newPosition = this._positionProperty.value.plus( vectorDelta ); + if ( this.restartTimerNextKeyboardEvent ) { - newPosition = this.mapModelPoint( newPosition ); + // restart the callback timer + this.callbackTimer.stop( false ); + this.callbackTimer.start(); - // update the position if it is different - if ( !newPosition.equals( this._positionProperty.value ) ) { - this._positionProperty.value = newPosition; - } - } + if ( this._moveOnHoldDelay > 0 ) { - // call our drag function - if ( this._drag ) { - this._drag( vectorDelta, this ); + // fire right away if there is a delay - if there is no delay the timer is going to fire in the next + // animation frame and so it would appear that the object makes two steps in one frame + this.callbackTimer.fire(); } - this.dragEmitter.emit(); + this.restartTimerNextKeyboardEvent = false; } } } @@ -876,178 +638,14 @@ class KeyboardDragListener extends EnabledComponent implements TInputListener { } /** - * Returns true if any of the keys in the list are currently down. - */ - public keyInListDown( keys: string[] ): boolean { - let keyIsDown = false; - for ( let i = 0; i < this.keyState.length; i++ ) { - if ( this.keyState[ i ].keyDown ) { - for ( let j = 0; j < keys.length; j++ ) { - if ( keys[ j ] === this.keyState[ i ].key ) { - keyIsDown = true; - break; - } - } - } - if ( keyIsDown ) { - // no need to keep looking - break; - } - } - - return keyIsDown; - } - - /** - * Return true if all keys in the list are currently held down. - */ - public allKeysInListDown( keys: string[] ): boolean { - assert && assert( keys.length > 0, 'You are testing to see if an empty list of keys is down?' ); - - let allKeysDown = true; - - for ( let i = 0; i < keys.length; i++ ) { - const foundKey = _.find( this.keyState, pressedKeyTiming => pressedKeyTiming.key === keys[ i ] ); - if ( !foundKey || !foundKey.keyDown ) { - - // key is not in the keystate or is not currently pressed down, all provided keys are not down - allKeysDown = false; - break; - } - } - - return allKeysDown; - } - - /** - * Get the keyboard keys for the KeyboardDragDirection of this KeyboardDragListener. - */ - private getKeyboardDragDirectionKeys(): KeyboardDragDirectionKeys { - const directionKeys = KEYBOARD_DRAG_DIRECTION_KEY_MAP.get( this._keyboardDragDirection )!; - assert && assert( directionKeys, `No direction keys found in map for KeyboardDragDirection ${this._keyboardDragDirection}` ); - return directionKeys; - } - - /** - * Returns true if the keystate indicates that a key is down that should move the object to the left. - */ - public leftMovementKeysDown(): boolean { - return this.keyInListDown( this.getKeyboardDragDirectionKeys().left ); - } - - /** - * Returns true if the keystate indicates that a key is down that should move the object to the right. - */ - public rightMovementKeysDown(): boolean { - return this.keyInListDown( this.getKeyboardDragDirectionKeys().right ); - } - - /** - * Returns true if the keystate indicates that a key is down that should move the object up. - */ - public upMovementKeysDown(): boolean { - return this.keyInListDown( this.getKeyboardDragDirectionKeys().up ); - } - - /** - * Returns true if the keystate indicates that a key is down that should move the upject down. - */ - public downMovementKeysDown(): boolean { - return this.keyInListDown( this.getKeyboardDragDirectionKeys().down ); - } - - /** - * Returns true if any of the movement keys are down (arrow keys or WASD keys). - */ - public getMovementKeysDown(): boolean { - return this.rightMovementKeysDown() || this.leftMovementKeysDown() || - this.upMovementKeysDown() || this.downMovementKeysDown(); - } - - public get movementKeysDown(): boolean { return this.getMovementKeysDown(); } - - /** - * Returns true if the enter key is currently pressed down. - */ - public enterKeyDown(): boolean { - return this.keyInListDown( [ KeyboardUtils.KEY_ENTER ] ); - } - - /** - * Returns true if the keystate indicates that the shift key is currently down. - */ - public shiftKeyDown(): boolean { - return this.keyInListDown( KeyboardUtils.SHIFT_KEYS ); - } - - /** - * Add a hotkey that behaves such that the desired callback will be called when all keys listed in the array are - * pressed down in order. - */ - public addHotkey( hotkey: Hotkey ): void { - this._hotkeys.push( hotkey ); - } - - /** - * Remove a hotkey that was added with addHotkey. + * If the pointer is set, remove the listener from it and clear the reference. */ - public removeHotkey( hotkey: Hotkey ): void { - assert && assert( this._hotkeys.includes( hotkey ), 'Trying to remove a hotkey that is not in the list of hotkeys.' ); - - const hotkeyIndex = this._hotkeys.indexOf( hotkey ); - this._hotkeys.splice( hotkeyIndex, 1 ); - } - - /** - * Sets the hotkeys of the KeyboardDragListener to passed-in array. - */ - public setHotkeys( hotkeys: Hotkey[] ): void { - this._hotkeys = hotkeys.slice( 0 ); // shallow copy - } - - /** - * Clear all hotkeys from this KeyboardDragListener. - */ - public removeAllHotkeys(): void { - this._hotkeys = []; - } - - /** - * Resets the timers and control variables for the press and hold functionality. - */ - private resetPressAndHold(): void { - this.delayComplete = false; - this.moveOnHoldDelayCounter = 0; - this.moveOnHoldIntervalCounter = 0; - } - - /** - * Resets the timers and control variables for the hotkey functionality. - */ - private resetHotkeyState(): void { - this.currentHotkey = null; - this.hotkeyHoldIntervalCounter = this._hotkeyHoldInterval; // reset to threshold so the hotkey fires immediately next time. - this.hotkeyDisablingDragging = false; - } - - /** - * Reset the keystate Object tracking which keys are currently pressed down. - */ - public interrupt(): void { - this.keyState = []; - this.resetHotkeyState(); - this.resetPressAndHold(); - + private clearPointer(): void { if ( this._pointer ) { - this.interrupted = true; // We weren't interrupted unless we had a pointer dragging us. - assert && assert( this._pointer.listeners.includes( this._pointerListener ), 'A reference to the Pointer means it should have the pointerListener' ); this._pointer.removeInputListener( this._pointerListener ); this._pointer = null; - this.isPressedProperty.value = false; - - this._end && this._end( null, this ); } } @@ -1059,34 +657,6 @@ class KeyboardDragListener extends EnabledComponent implements TInputListener { this._disposeKeyboardDragListener(); super.dispose(); } - - /** - * Returns true if the key corresponds to a key that should move the object to the left. - */ - public static isLeftMovementKey( key: string ): boolean { - return key === KeyboardUtils.KEY_A || key === KeyboardUtils.KEY_LEFT_ARROW; - } - - /** - * Returns true if the key corresponds to a key that should move the object to the right. - */ - public static isRightMovementKey( key: string ): boolean { - return key === KeyboardUtils.KEY_D || key === KeyboardUtils.KEY_RIGHT_ARROW; - } - - /** - * Returns true if the key corresponds to a key that should move the object up. - */ - private static isUpMovementKey( key: string ): boolean { - return key === KeyboardUtils.KEY_W || key === KeyboardUtils.KEY_UP_ARROW; - } - - /** - * Returns true if the key corresponds to a key that should move the object down. - */ - public static isDownMovementKey( key: string ): boolean { - return key === KeyboardUtils.KEY_S || key === KeyboardUtils.KEY_DOWN_ARROW; - } } scenery.register( 'KeyboardDragListener', KeyboardDragListener ); diff --git a/js/listeners/KeyboardListener.ts b/js/listeners/KeyboardListener.ts index 33a8c6ce9..68eb969fd 100644 --- a/js/listeners/KeyboardListener.ts +++ b/js/listeners/KeyboardListener.ts @@ -4,8 +4,8 @@ * A listener for general keyboard input. Specify the keys with a `keys` option in a readable format that looks like * this: [ 'shift+t', 'alt+shift+r' ] * - * - Each entry in the array represents a "group" of keys. - * - '+' separates each key in a single group. + * - Each entry in the array represents a combination of keys that must be pressed to fire a callback. + * - '+' separates each key in a single combination. * - The keys leading up to the last key in the group are considered "modifier" keys. The last key in the group needs * to be pressed while the modifier keys are down. * - The order modifier keys are pressed does not matter for firing the callback. @@ -16,7 +16,7 @@ * * this.addInputListener( new KeyboardListener( { * keys: [ 'a+b', 'a+c', 'shift+arrowLeft', 'alt+g+t', 'ctrl+3', 'alt+ctrl+t' ], - * callback: ( event, keysPressed, listener ) => { + * fire: ( event, keysPressed, listener ) => { * if ( keysPressed === 'a+b' ) { * console.log( 'you just pressed a+b!' ); * } @@ -35,11 +35,25 @@ * } * } ) ); * - * By default the callback will fire when the last key is pressed down. See additional options for firing on key + * By default, the fire callback will fire when the last key is pressed down. See additional options for firing on key * up or other press and hold behavior. * + * **Important Modifier Key Behavior** + * Modifier keys prevent other key combinations from firing their behavior if they are pressed down. + * For example, if you have a listener with 'shift+t' and 'y', pressing 'shift' will prevent 'y' from firing. + * This behavior matches the behavior of the browser and is intended to prevent unexpected behavior. However, + * this behavior is also true for custom (non-standard) modifier keys. For example, if you have a listener with + * 'j+t' and 'y', pressing 'j' will prevent 'y' from firing. This is a PhET specific design decision, but it + * is consistent with typical modifier key behavior. + * + * **Global Keyboard Listeners** + * A KeyboardListener can be added to a Node with addInputListener, and it will fire with normal scenery input dispatch + * behavior when the Node has focus. However, a KeyboardListener can also be added "globally", meaning it will fire + * regardless of where focus is in the document. Use KeyboardListener.createGlobal. This adds Hotkeys to the + * globalHotkeyRegistry. Be sure to dispose of the KeyboardListener when it is no longer needed. + * * **Potential Pitfall!** - * The callback is only called if exactly the keys in a group are pressed. If you need to listen to a modifier key, + * The fire callback is only called if exactly the keys in a group are pressed. If you need to listen to a modifier key, * you must include it in the keys array. For example if you add a listener for 'tab', you must ALSO include * 'shift+tab' in the array to observe 'shift+tab' presses. If you provide 'tab' alone, the callback will not fire * if 'shift' is also pressed. @@ -47,10 +61,13 @@ * @author Jesse Greenberg (PhET Interactive Simulations) */ -import CallbackTimer from '../../../axon/js/CallbackTimer.js'; -import optionize from '../../../phet-core/js/optionize.js'; -import { EnglishStringToCodeMap, FocusManager, globalKeyStateTracker, scenery, SceneryEvent, TInputListener } from '../imports.js'; -import KeyboardUtils from '../accessibility/KeyboardUtils.js'; +import DerivedProperty from '../../../axon/js/DerivedProperty.js'; +import optionize, { EmptySelfOptions } from '../../../phet-core/js/optionize.js'; +import { DisplayedTrailsProperty, EnglishKey, EnglishStringToCodeMap, EventContext, globalHotkeyRegistry, Hotkey, HotkeyFireOnHoldTiming, Node, PDOMPointer, scenery, SceneryEvent, TInputListener, Trail } from '../imports.js'; +import TReadOnlyProperty from '../../../axon/js/TReadOnlyProperty.js'; +import EnabledComponent, { EnabledComponentOptions } from '../../../axon/js/EnabledComponent.js'; +import Property from '../../../axon/js/Property.js'; +import TProperty from '../../../axon/js/TProperty.js'; // NOTE: The typing for ModifierKey and OneKeyStroke is limited TypeScript, there is a limitation to the number of // entries in a union type. If that limitation is not acceptable remove this typing. OR maybe TypeScript will @@ -71,43 +88,22 @@ export type OneKeyStroke = `${AllowedKeys}` | // `${AllowedKeys}+${AllowedKeys}+${AllowedKeys}+${AllowedKeys}`; // type KeyCombinations = `${OneKeyStroke}` | `${OneKeyStroke},${OneKeyStroke}`; -// Controls when the callback listener fires. -// - 'up': Callbacks fire on release of keys. -// - 'down': Callbacks fire on press of keys. -// - 'both': Callbacks fire on both press and release of keys. -type ListenerFireTrigger = 'up' | 'down' | 'both'; - -export type KeyboardListenerOptions = { +type KeyboardListenerSelfOptions = { // The keys that need to be pressed to fire the callback. In a form like `[ 'shift+t', 'alt+shift+r' ]`. See top // level documentation for more information and an example of providing keys. keys: Keys; - // If true, the listener will fire for keys regardless of where focus is in the document. Use this when you want - // to add some key press behavior that will always fire no matter what the event target is. If this listener - // is added to a Node, it will only fire if the Node (and all of its ancestors) are visible with inputEnabled: true. - // More specifically, this uses `globalKeyUp` and `globalKeyDown`. See definitions in Input.ts for more information. - global?: boolean; - - // If true, this listener is fired during the 'capture' phase. Only relevant for `global` key events. - // When a listener uses capture, the callbacks will be fired BEFORE the dispatch through the scene graph - // (very similar to DOM's addEventListener with `useCapture` set to true - see - // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener). - capture?: boolean; - - // If true, all SceneryEvents that trigger this listener (keydown and keyup) will be `handled` (no more - // event bubbling). See `manageEvent` for more information. - handle?: boolean; - - // If true, all SceneryEvents that trigger this listener (keydown and keyup) will be `aborted` (no more - // event bubbling, no more listeners fire). See `manageEvent` for more information. - abort?: boolean; - // Called when the listener detects that the set of keys are pressed. - callback?: ( event: SceneryEvent | null, keysPressed: Keys[number], listener: KeyboardListener ) => void; + fire?: ( event: KeyboardEvent | null, keysPressed: Keys[number], listener: KeyboardListener ) => void; - // Called when the listener is cancelled/interrupted. - cancel?: ( listener: KeyboardListener ) => void; + // Called when the listener detects that the set of keys are pressed. Press is always called on the first press of + // keys, but does not continue with fire-on-hold behavior. Will be called before fire if fireOnDown is true. + press?: ( event: KeyboardEvent | null, keysPressed: Keys[number], listener: KeyboardListener ) => void; + + // Called when the listener detects that the set of keys have been released. keysPressed may be null + // in cases of interruption. + release?: ( event: KeyboardEvent | null, keysPressed: Keys[number] | null, listener: KeyboardListener ) => void; // Called when the listener target receives focus. focus?: ( listener: KeyboardListener ) => void; @@ -115,442 +111,288 @@ export type KeyboardListenerOptions = { // Called when the listener target loses focus. blur?: ( listener: KeyboardListener ) => void; - // When true, the listener will fire continuously while keys are held down, at the following intervals. - fireOnHold?: boolean; + // 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; - // If fireOnHold true, this is the delay in (in milliseconds) before the callback is fired continuously. - fireOnHoldDelay?: number; + // Whether the fire-on-hold feature is enabled + fireOnHold?: boolean; - // If fireOnHold true, this is the interval (in milliseconds) that the callback fires after the fireOnHoldDelay. - fireOnHoldInterval?: number; + // Whether we should listen to the browser's fire-on-hold timing, or use our own. + fireOnHoldTiming?: HotkeyFireOnHoldTiming; - // Possible input types that decide when callbacks of the listener fire in response to input. See - // ListenerFireTrigger type documentation. - listenerFireTrigger?: ListenerFireTrigger; -}; + // Start to fire continuously after pressing for this long (milliseconds) + fireOnHoldCustomDelay?: number; -type KeyGroup = { + // Fire continuously at this interval (milliseconds) + fireOnHoldCustomInterval?: number; - // All must be pressed fully before the key is pressed to activate the command. - modifierKeys: string[][]; + // Controls whether the keys used by this KeyboardListener are allowed to overlap with other KeyboardListeners + // that are listening for the same keys. If true, the KeyboardListener will fire even if another KeyboardListener. + // This is implemented with Hotkey, see Hotkey.ts for more information. + allowOverlap?: boolean; - // Contains the triggering key for the listener. One of these keys must be pressed to activate callbacks. - keys: string[]; + // If true, Keyboard listeners with overlapping keys (either added to an ancestor's inputListener or later in the + // local/global order) will be ignored. Only the most 'local' Hotkey will fire. The default is true for + // KeyboardListeners added to focusable Nodes, and false for global KeyboardListeners to catch overlapping global + // keys. + override?: boolean; - // All keys in this KeyGroup using the readable form - naturalKeys: Keys[number]; - - // A callback timer for this KeyGroup to support press and hold timing and callbacks - timer: CallbackTimer | null; + // If you want the callback to fire when a particular modifier key is down, you can add that key to this list. + // For example, you may want to fire the callback even when 'shift' is down, but not when another modifier key + // is pressed. + ignoredModifierKeys?: EnglishKey[]; }; -class KeyboardListener implements TInputListener { - - // The function called when a KeyGroup is pressed (or just released). Provides the SceneryEvent that fired the input - // listeners and this the keys that were pressed from the active KeyGroup. The event may be null when using - // fireOnHold or in cases of cancel or interrupt. A reference to the listener is provided for other state. - private readonly _callback: ( event: SceneryEvent | null, keysPressed: Keys[number], listener: KeyboardListener ) => void; +export type KeyboardListenerOptions = KeyboardListenerSelfOptions & EnabledComponentOptions; - // The optional function called when this listener is cancelled. - private readonly _cancel: ( listener: KeyboardListener ) => void; +class KeyboardListener extends EnabledComponent implements TInputListener { - // The optional function called when this listener's target receives focus. + // from options + private readonly _fire: ( event: KeyboardEvent | null, keysPressed: Keys[number], listener: KeyboardListener ) => void; + private readonly _press: ( event: KeyboardEvent | null, keysPressed: Keys[number], listener: KeyboardListener ) => void; + private readonly _release: ( event: KeyboardEvent | null, keysPressed: Keys[number] | null, listener: KeyboardListener ) => void; private readonly _focus: ( listener: KeyboardListener ) => void; - - // The optional function called when this listener's target loses focus. private readonly _blur: ( listener: KeyboardListener ) => void; + public readonly fireOnDown: boolean; + public readonly fireOnHold: boolean; + public readonly fireOnHoldTiming: HotkeyFireOnHoldTiming; + public readonly fireOnHoldCustomDelay: number; + public readonly fireOnHoldCustomInterval: number; + public readonly allowOverlap: boolean; + public readonly ignoredModifierKeys: EnglishKey[]; + private readonly override: boolean; - // When callbacks are fired in response to input. Could be on keys pressed down, up, or both. - private readonly _listenerFireTrigger: ListenerFireTrigger; + public readonly hotkeys: Hotkey[]; - // Does the listener fire the callback continuously when keys are held down? - private readonly _fireOnHold: boolean; + // A Property that is true when any of the keys + public readonly isPressedProperty: TReadOnlyProperty; - // (scenery-internal) All the KeyGroups of this listener from the keys provided in natural language. - public readonly _keyGroups: KeyGroup[]; + // A Property that has the list of currenty pressed keys, from the keys array. + public readonly pressedKeysProperty: TProperty>; - // All the KeyGroups that are currently firing - private readonly _activeKeyGroups: KeyGroup[]; - - // True when keys are pressed down. If listenerFireTrigger is 'both', you can look at this in your callback to - // determine if keys are pressed or released. - public keysDown: boolean; - - // Timing variables for the CallbackTimers. - private readonly _fireOnHoldDelay: number; - private readonly _fireOnHoldInterval: number; - - // see options documentation - private readonly _global: boolean; - private readonly _handle: boolean; - private readonly _abort: boolean; - - private readonly _windowFocusListener: ( windowHasFocus: boolean ) => void; + // (read-only) - Whether the last key press was interrupted. Will be valid until the next press. + public interrupted: boolean; public constructor( providedOptions: KeyboardListenerOptions ) { - const options = optionize>()( { - callback: _.noop, - cancel: _.noop, + const options = optionize, KeyboardListenerSelfOptions, EnabledComponentOptions>()( { + fire: _.noop, + press: _.noop, + release: _.noop, focus: _.noop, blur: _.noop, - global: false, - capture: false, - handle: false, - abort: false, - listenerFireTrigger: 'down', + fireOnDown: true, fireOnHold: false, - fireOnHoldDelay: 400, - fireOnHoldInterval: 100 + fireOnHoldTiming: 'browser', + fireOnHoldCustomDelay: 400, + fireOnHoldCustomInterval: 100, + allowOverlap: false, + override: true, + ignoredModifierKeys: [], + + // EnabledComponent + // By default, do not instrument the enabledProperty; opt in with this option. See EnabledComponent + phetioEnabledPropertyInstrumented: false }, providedOptions ); - this._callback = options.callback; - this._cancel = options.cancel; + super( options ); + + this._fire = options.fire; + this._press = options.press; + this._release = options.release; this._focus = options.focus; this._blur = options.blur; - - this._listenerFireTrigger = options.listenerFireTrigger; - this._fireOnHold = options.fireOnHold; - this._fireOnHoldDelay = options.fireOnHoldDelay; - this._fireOnHoldInterval = options.fireOnHoldInterval; - - this._activeKeyGroups = []; - - this.keysDown = false; - - this._global = options.global; - this._handle = options.handle; - this._abort = options.abort; + this.fireOnDown = options.fireOnDown; + this.fireOnHold = options.fireOnHold; + this.fireOnHoldTiming = options.fireOnHoldTiming; + this.fireOnHoldCustomDelay = options.fireOnHoldCustomDelay; + this.fireOnHoldCustomInterval = options.fireOnHoldCustomInterval; + this.allowOverlap = options.allowOverlap; + this.ignoredModifierKeys = options.ignoredModifierKeys; + this.override = options.override; // convert the provided keys to data that we can respond to with scenery's Input system - this._keyGroups = this.convertKeysToKeyGroups( options.keys ); + this.hotkeys = this.createHotkeys( options.keys ); - // Assign listener and capture to this, implementing TInputListener - ( this as unknown as TInputListener ).listener = this; - ( this as unknown as TInputListener ).capture = options.capture; + this.isPressedProperty = DerivedProperty.or( this.hotkeys.map( hotkey => hotkey.isPressedProperty ) ); + this.pressedKeysProperty = new Property( [] ); + this.interrupted = false; - this._windowFocusListener = this.handleWindowFocusChange.bind( this ); - FocusManager.windowHasFocusProperty.link( this._windowFocusListener ); + this.enabledProperty.lazyLink( this.onEnabledPropertyChange.bind( this ) ); } /** - * Mostly required to fire with CallbackTimer since the callback cannot take arguments. + * Fired when the enabledProperty changes */ - public fireCallback( event: SceneryEvent | null, keyGroup: KeyGroup ): void { - this._callback( event, keyGroup.naturalKeys, this ); - } - - /** - * Responding to a keydown event, update active KeyGroups and potentially fire callbacks and start CallbackTimers. - */ - private handleKeyDown( event: SceneryEvent ): void { - if ( this._listenerFireTrigger === 'down' || this._listenerFireTrigger === 'both' ) { - - // modifier keys can be pressed in any order but the last key in the group must be pressed last - this._keyGroups.forEach( keyGroup => { - - if ( !this._activeKeyGroups.includes( keyGroup ) ) { - if ( this.areKeysDownForListener( keyGroup ) && - keyGroup.keys.includes( globalKeyStateTracker.getLastKeyDown()! ) ) { - - this._activeKeyGroups.push( keyGroup ); - - this.keysDown = true; - - // reserve the event for this listener, disabling more 'global' input listeners such as - // those for pan and zoom (this is similar to DOM event.preventDefault). - event.pointer.reserveForKeyboardDrag(); - - if ( keyGroup.timer ) { - keyGroup.timer.start(); - } - - this.fireCallback( event, keyGroup ); - } - } - } ); - } - - this.manageEvent( event ); + private onEnabledPropertyChange( enabled: boolean ): void { + !enabled && this.interrupt(); } /** - * If there are any active KeyGroup firing stop and remove if KeyGroup keys are no longer down. Also, potentially - * fires a KeyGroup callback if the key that was released has all other modifier keys down. + * Dispose of this listener by disposing of any Callback timers. Then clear all KeyGroups. */ - private handleKeyUp( event: SceneryEvent ): void { - - if ( this._activeKeyGroups.length > 0 ) { - this._activeKeyGroups.forEach( ( activeKeyGroup, index ) => { - if ( !this.areKeysDownForListener( activeKeyGroup ) ) { - if ( activeKeyGroup.timer ) { - activeKeyGroup.timer.stop( false ); - } - this._activeKeyGroups.splice( index, 1 ); - } - } ); - } - - if ( this._listenerFireTrigger === 'up' || this._listenerFireTrigger === 'both' ) { - const eventCode = KeyboardUtils.getEventCode( event.domEvent )!; - - // Screen readers may send key events with no code for unknown reasons, we need to be graceful when that - // happens, see https://github.com/phetsims/scenery/issues/1534. - if ( eventCode ) { - this._keyGroups.forEach( keyGroup => { - if ( this.areModifierKeysDownForListener( keyGroup ) && - keyGroup.keys.includes( eventCode ) ) { - this.keysDown = false; - this.fireCallback( event, keyGroup ); - } - } ); - } - } - - this.manageEvent( event ); + public override dispose(): void { + ( this as unknown as TInputListener ).hotkeys!.forEach( hotkey => hotkey.dispose() ); + this.isPressedProperty.dispose(); + this.pressedKeysProperty.dispose(); + super.dispose(); } /** - * Returns an array of KeyboardEvent.codes from the provided key group that are currently pressed down. + * Everything that uses a KeyboardListener should prevent more global scenery keyboard behavior, such as pan/zoom + * from arrow keys. */ - private getDownModifierKeys( keyGroup: KeyGroup ): string[] { - - // Remember, this is a 2D array. The inner array is the list of 'equivalent' keys to be pressed for the required - // modifier key. For example [ 'shiftLeft', 'shiftRight' ]. If any of the keys in that inner array are pressed, - // that set of modifier keys is considered pressed. - const modifierKeysCollection = keyGroup.modifierKeys; - - // The list of modifier keys that are actually pressed - const downModifierKeys: string[] = []; - modifierKeysCollection.forEach( modifierKeys => { - for ( const modifierKey of modifierKeys ) { - if ( globalKeyStateTracker.isKeyDown( modifierKey ) ) { - downModifierKeys.push( modifierKey ); - - // One modifier key from this inner set is down, stop looking - break; - } - } - } ); - - return downModifierKeys; + public keydown( event: SceneryEvent ): void { + event.pointer.reserveForKeyboardDrag(); } /** - * Returns true if keys are pressed such that the listener should fire. In order to fire, all modifier keys - * should be down and the final key of the group should be down. If any extra modifier keys are down that are - * not specified in the keyGroup, the listener will not fire. + * Public because this is called with the scenery listener API. Do not call this directly. */ - private areKeysDownForListener( keyGroup: KeyGroup ): boolean { - const downModifierKeys = this.getDownModifierKeys( keyGroup ); - - // modifier keys are down if one key from each inner array is down - const areModifierKeysDown = downModifierKeys.length === keyGroup.modifierKeys.length; - - // The final key of the group is down if any of them are pressed - const finalDownKey = keyGroup.keys.find( key => globalKeyStateTracker.isKeyDown( key ) ); - - if ( areModifierKeysDown && !!finalDownKey ) { - - // All keys are down. - const allKeys = [ ...downModifierKeys, finalDownKey ]; + public focusout( event: SceneryEvent ): void { + this.interrupt(); - // If there are any extra modifier keys down, the listener will not fire - return globalKeyStateTracker.areKeysDownWithoutExtraModifiers( allKeys ); - } - else { - return false; - } + // Optional work to do on blur. + this._blur( this ); } /** - * Returns true if the modifier keys of the provided key group are currently down. If any extra modifier keys are - * down that are not specified in the keyGroup, the listener will not fire. + * Public because this is called through the scenery listener API. Do not call this directly. */ - private areModifierKeysDownForListener( keyGroup: KeyGroup ): boolean { - const downModifierKeys = this.getDownModifierKeys( keyGroup ); - - // modifier keys are down if one key from each inner array is down - const modifierKeysDown = downModifierKeys.length === keyGroup.modifierKeys.length; - - if ( modifierKeysDown ) { + public focusin( event: SceneryEvent ): void { - // If there are any extra modifier keys down, the listener will not fire - return globalKeyStateTracker.areKeysDownWithoutExtraModifiers( downModifierKeys ); - } - else { - return false; - } + // Optional work to do on focus. + this._focus( this ); } /** - * In response to every SceneryEvent, handle and/or abort depending on listener options. This cannot be done in - * the callbacks because press-and-hold behavior triggers many keydown events. We need to handle/abort each, not - * just the event that triggered the callback. Also, callbacks can be called without a SceneryEvent from the - * CallbackTimer. + * Part of the scenery listener API. On cancel, clear active KeyGroups and stop their behavior. */ - private manageEvent( event: SceneryEvent ): void { - this._handle && event.handle(); - this._abort && event.abort(); + public cancel(): void { + this.handleInterrupt(); } /** - * This is part of the scenery Input API (implementing TInputListener). Handle the keydown event when not - * added to the global key events. Target will be the Node, Display, or Pointer this listener was added to. + * Part of the scenery listener API. Clear active KeyGroups and stop their callbacks. */ - public keydown( event: SceneryEvent ): void { - if ( !this._global ) { - this.handleKeyDown( event ); - } + public interrupt(): void { + this.handleInterrupt(); } /** - * This is part of the scenery Input API (implementing TInputListener). Handle the keyup event when not - * added to the global key events. Target will be the Node, Display, or Pointer this listener was added to. + * Work to be done on both cancel and interrupt. */ - public keyup( event: SceneryEvent ): void { - if ( !this._global ) { - this.handleKeyUp( event ); - } - } + private handleInterrupt(): void { - /** - * This is part of the scenery Input API (implementing TInputListener). Handle the global keydown event. - * Event has no target. - */ - public globalkeydown( event: SceneryEvent ): void { - if ( this._global ) { - this.handleKeyDown( event ); - } + // interrupt all hotkeys (will do nothing if hotkeys are interrupted or not active) + this.hotkeys.forEach( hotkey => hotkey.interrupt() ); } - /** - * This is part of the scenery Input API (implementing TInputListener). Handle the global keyup event. - * Event has no target. - */ - public globalkeyup( event: SceneryEvent ): void { - if ( this._global ) { - this.handleKeyUp( event ); - } + protected createSyntheticEvent( pointer: PDOMPointer ): SceneryEvent { + const context = EventContext.createSynthetic(); + return new SceneryEvent( new Trail(), 'synthetic', pointer, context ); } /** - * Work to be done on both cancel and interrupt. + * Converts the provided keys for this listener into a collection of Hotkeys to easily track what keys are down. */ - private handleCancel(): void { - this.clearActiveKeyGroups(); - this._cancel( this ); - } + private createHotkeys( keys: Keys ): Hotkey[] { + return keys.map( naturalKeys => { + + // Split the keys into the main key and the modifier keys + const keys = naturalKeys.split( '+' ); + const modifierKeys = keys.slice( 0, keys.length - 1 ); + const naturalKey = keys[ keys.length - 1 ]; + + const hotkey = new Hotkey( { + key: naturalKey as EnglishKey, + modifierKeys: modifierKeys as EnglishKey[], + ignoredModifierKeys: this.ignoredModifierKeys, + fire: ( event: KeyboardEvent | null ) => { + this._fire( event, naturalKeys, this ); + }, + press: ( event: KeyboardEvent | null ) => { + this.interrupted = false; + this._press( event, naturalKeys, this ); + + assert && assert( !this.pressedKeysProperty.value.includes( naturalKeys ), 'Key already pressed' ); + this.pressedKeysProperty.value = [ ...this.pressedKeysProperty.value, naturalKeys ]; + }, + release: ( event: KeyboardEvent | null ) => { + this.interrupted = hotkey.interrupted; + + this._release( event, naturalKeys, this ); + + assert && assert( this.pressedKeysProperty.value.includes( naturalKeys ), 'Key not pressed' ); + this.pressedKeysProperty.value = this.pressedKeysProperty.value.filter( key => key !== naturalKeys ); + }, + fireOnDown: this.fireOnDown, + fireOnHold: this.fireOnHold, + fireOnHoldTiming: this.fireOnHoldTiming, + fireOnHoldCustomDelay: this.fireOnHoldTiming === 'custom' ? this.fireOnHoldCustomDelay : undefined, + fireOnHoldCustomInterval: this.fireOnHoldTiming === 'custom' ? this.fireOnHoldCustomInterval : undefined, + allowOverlap: this.allowOverlap, + enabledProperty: this.enabledProperty, + override: this.override + } ); - /** - * When the window loses focus, cancel. - */ - private handleWindowFocusChange( windowHasFocus: boolean ): void { - if ( !windowHasFocus ) { - this.handleCancel(); - } + return hotkey; + } ); } /** - * Part of the scenery listener API. On cancel, clear active KeyGroups and stop their behavior. + * Adds a global KeyboardListener to a target Node. This listener will fire regardless of where focus is in + * the document. The listener is returned so that it can be disposed. */ - public cancel(): void { - this.handleCancel(); + public static createGlobal( target: Node, providedOptions: KeyboardListenerOptions ): KeyboardListener { + return new GlobalKeyboardListener( target, providedOptions ); } +} - /** - * Part of the scenery listener API. Clear active KeyGroups and stop their callbacks. - */ - public interrupt(): void { - this.handleCancel(); - } +/** + * Inner class for a KeyboardListener that is global with a target Node. The listener will fire no matter where + * focus is in the document, as long as the target Node can receive input events. Create this listener with + * KeyboardListener.createGlobal. + */ +class GlobalKeyboardListener extends KeyboardListener { - /** - * Interrupts and resets the listener on blur so that state is reset and active keyGroups are cleared. - * Public because this is called with the scenery listener API. Do not call this directly. - */ - public focusout( event: SceneryEvent ): void { - this.interrupt(); + // All of the Trails to the target Node that can receive alternative input events. + private readonly displayedTrailsProperty: DisplayedTrailsProperty; - // Optional work to do on blur. - this._blur( this ); - } + // Derived from above, whether the target Node is 'enabled' to receive input events. + private readonly globallyEnabledProperty: TReadOnlyProperty; - /** - * Public because this is called through the scenery listener API. Do not call this directly. - */ - public focusin( event: SceneryEvent ): void { + public constructor( target: Node, providedOptions: KeyboardListenerOptions ) { + const displayedTrailsProperty = new DisplayedTrailsProperty( target, { - // Optional work to do on focus. - this._focus( this ); - } + // For alt input events, use the PDOM trail to determine if the trail is displayed. This may be different + // from the "visual" trail if the Node is placed in a PDOM order that is different from the visual order. + followPdomOrder: true, - /** - * Dispose of this listener by disposing of any Callback timers. Then clear all KeyGroups. - */ - public dispose(): void { - this._keyGroups.forEach( activeKeyGroup => { - activeKeyGroup.timer && activeKeyGroup.timer.dispose(); + // Additionally, the target must have each of these true up its Trails to receive alt input events. + requirePdomVisible: true, + requireEnabled: true, + requireInputEnabled: true + } ); + const globallyEnabledProperty = new DerivedProperty( [ displayedTrailsProperty ], ( trails: Trail[] ) => { + return trails.length > 0; } ); - this._keyGroups.length = 0; - FocusManager.windowHasFocusProperty.unlink( this._windowFocusListener ); - } + const options = optionize, EmptySelfOptions, KeyboardListenerOptions>()( { - /** - * Clear the active KeyGroups on this listener. Stopping any active groups if they use a CallbackTimer. - */ - private clearActiveKeyGroups(): void { - this._activeKeyGroups.forEach( activeKeyGroup => { - activeKeyGroup.timer && activeKeyGroup.timer.stop( false ); - } ); + // The enabledProperty is forwarded to the Hotkeys so that they are disabled when the target cannot receive input. + enabledProperty: globallyEnabledProperty + }, providedOptions ); - this._activeKeyGroups.length = 0; - } + super( options ); - /** - * Converts the provided keys into a collection of KeyGroups to easily track what keys are down. For example, - * will take a string that defines the keys for this listener like 'a+c|1+2+3+4|shift+leftArrow' and return an array - * with three KeyGroups, one describing 'a+c', one describing '1+2+3+4' and one describing 'shift+leftArrow'. - */ - private convertKeysToKeyGroups( keys: Keys ): KeyGroup[] { - - const keyGroups = keys.map( naturalKeys => { - - // all of the keys in this group in an array - const groupKeys = naturalKeys.split( '+' ); - assert && assert( groupKeys.length > 0, 'no keys provided?' ); - - const naturalKey = groupKeys.slice( -1 )[ 0 ] as AllowedKeys; - const keys = EnglishStringToCodeMap[ naturalKey ]; - assert && assert( keys, `Codes were not found, do you need to add it to EnglishStringToCodeMap? ${naturalKey}` ); - - let modifierKeys: string[][] = []; - if ( groupKeys.length > 1 ) { - const naturalModifierKeys = groupKeys.slice( 0, groupKeys.length - 1 ) as ModifierKey[]; - modifierKeys = naturalModifierKeys.map( naturalModifierKey => { - const modifierKeys = EnglishStringToCodeMap[ naturalModifierKey ]; - assert && assert( modifierKeys, `Key not found, do you need to add it to EnglishStringToCodeMap? ${naturalModifierKey}` ); - return modifierKeys; - } ); - } - - // Set up the timer for triggering callbacks if this listener supports press and hold behavior - const timer = this._fireOnHold ? new CallbackTimer( { - callback: () => this.fireCallback( null, keyGroup ), - delay: this._fireOnHoldDelay, - interval: this._fireOnHoldInterval - } ) : null; - - const keyGroup: KeyGroup = { - keys: keys, - modifierKeys: modifierKeys, - naturalKeys: naturalKeys, - timer: timer - }; - return keyGroup; - } ); + this.displayedTrailsProperty = displayedTrailsProperty; + this.globallyEnabledProperty = globallyEnabledProperty; - return keyGroups; + // Add all global keys to the registry + this.hotkeys.forEach( hotkey => { + globalHotkeyRegistry.add( hotkey ); + } ); } } diff --git a/js/listeners/KeyboardListenerTests.ts b/js/listeners/KeyboardListenerTests.ts index e04f6947e..63d6b6550 100644 --- a/js/listeners/KeyboardListenerTests.ts +++ b/js/listeners/KeyboardListenerTests.ts @@ -8,7 +8,7 @@ * @author Agustín Vallejo (PhET Interactive Simulations) */ -import { Display, globalKeyStateTracker, KeyboardListener, KeyboardUtils, Node, SceneryEvent } from '../imports.js'; +import { Display, globalKeyStateTracker, KeyboardListener, KeyboardUtils, Node } from '../imports.js'; QUnit.module( 'KeyboardListener', { before() { @@ -46,7 +46,7 @@ QUnit.test( 'KeyboardListener Tests', assert => { let callbackFired = false; const listener = new KeyboardListener( { keys: [ 'enter' ], - callback: () => { + fire: () => { assert.ok( !callbackFired, 'callback cannot be fired' ); callbackFired = true; } @@ -58,7 +58,7 @@ QUnit.test( 'KeyboardListener Tests', assert => { // @ts-expect-error - Typescript should catch bad keys too keys: [ 'badKey' ], - callback: () => { + fire: () => { // just testing the typing, no work to do here } @@ -91,7 +91,7 @@ QUnit.test( 'KeyboardListener Tests', assert => { const listenerWithOverlappingKeys = new KeyboardListener( { keys: [ 'p', 'ctrl+p' ], - callback: ( event, keysPressed ) => { + fire: ( event, keysPressed ) => { if ( keysPressed === 'p' ) { pFired = true; } @@ -109,97 +109,6 @@ QUnit.test( 'KeyboardListener Tests', assert => { assert.ok( ctrlPFired, 'ctrl P should have fired' ); ////////////////////////////////////////////////////// - - ////////////////////////////////////////////////////// - // test handle/abort - a.removeInputListener( listenerWithOverlappingKeys ); - const b = new Node( { tagName: 'div' } ); - a.addChild( b ); - - const domElementB = b.pdomInstances[ 0 ].peer!.primarySibling!; - - // test handled - event should no longer bubble, b listener should handle and a listener should not fire - let pFiredFromA = false; - let pFiredFromB = false; - const listenerPreventedByHandle = new KeyboardListener( { - keys: [ 'p' ], - callback: ( event, keysPressed, listener ) => { - if ( keysPressed === 'p' ) { - pFiredFromA = true; - } - } - } ); - a.addInputListener( listenerPreventedByHandle ); - - const handlingListener = new KeyboardListener( { - keys: [ 'p' ], - callback: ( event, keysPressed ) => { - if ( keysPressed === 'p' ) { - pFiredFromB = true; - - assert.ok( !!event, 'An event should be provided to the callback in this case.' ); - event!.handle(); - } - } - } ); - b.addInputListener( handlingListener ); - - triggerKeydownEvent( domElementB, KeyboardUtils.KEY_P ); - assert.ok( !pFiredFromA, 'A should not have received the event because of event handling' ); - assert.ok( pFiredFromB, 'B received the event and handled it (stopping bubbling)' ); - triggerKeyupEvent( domElementB, KeyboardUtils.KEY_P ); - - a.removeInputListener( listenerPreventedByHandle ); - b.removeInputListener( handlingListener ); - pFiredFromA = false; - pFiredFromB = false; - - // test abort - const listenerPreventedByAbort = new KeyboardListener( { - keys: [ 'p' ], - callback: ( event, keysPressed ) => { - if ( keysPressed === 'p' ) { - pFiredFromA = true; - } - } - } ); - a.addInputListener( listenerPreventedByAbort ); - - const abortingListener = new KeyboardListener( { - keys: [ 'p' ], - callback: ( event, keysPressed ) => { - if ( keysPressed === 'p' ) { - pFiredFromB = true; - - assert.ok( !!event, 'An event should be provided to the callback in this case.' ); - event!.abort(); - } - } - } ); - b.addInputListener( abortingListener ); - - let pFiredFromExtraListener = false; - const otherListenerPreventedByAbort = { - keydown: ( event: SceneryEvent ) => { - pFiredFromExtraListener = true; - } - }; - b.addInputListener( otherListenerPreventedByAbort ); - - triggerKeydownEvent( domElementB, KeyboardUtils.KEY_P ); - assert.ok( !pFiredFromA, 'A should not have received the event because of abort' ); - assert.ok( pFiredFromB, 'B received the event and handled it (stopping bubbling)' ); - assert.ok( !pFiredFromExtraListener, 'Other listener on B did not fire because of abort (stopping all listeners)' ); - triggerKeyupEvent( domElementB, KeyboardUtils.KEY_P ); - - a.removeInputListener( listenerPreventedByAbort ); - b.removeInputListener( abortingListener ); - b.removeInputListener( otherListenerPreventedByAbort ); - pFiredFromA = false; - pFiredFromB = false; - - ////////////////////////////////////////////////////// - // test interrupt/cancel // TODO: This test fails but that is working as expected. interrupt/cancel are only relevant for the https://github.com/phetsims/scenery/issues/1581 // listener for press and hold functionality. Interrupt/cancel cannot clear the keystate because the listener