diff --git a/js/accessibility/EnglishStringToCodeMap.ts b/js/accessibility/EnglishStringToCodeMap.ts index 90827d301..cf6981924 100644 --- a/js/accessibility/EnglishStringToCodeMap.ts +++ b/js/accessibility/EnglishStringToCodeMap.ts @@ -74,10 +74,36 @@ const EnglishStringToCodeMap = { arrowDown: [ KeyboardUtils.KEY_DOWN_ARROW ], // modifier keys - ctrl: [ KeyboardUtils.KEY_CONTROL_LEFT, KeyboardUtils.KEY_CONTROL_RIGHT ], - alt: [ KeyboardUtils.KEY_ALT_LEFT, KeyboardUtils.KEY_ALT_RIGHT ], - shift: [ KeyboardUtils.KEY_SHIFT_LEFT, KeyboardUtils.KEY_SHIFT_RIGHT ] + ctrl: KeyboardUtils.CONTROL_KEYS, + alt: KeyboardUtils.ALT_KEYS, + shift: KeyboardUtils.SHIFT_KEYS, + meta: KeyboardUtils.META_KEYS }; scenery.register( 'EnglishStringToCodeMap', EnglishStringToCodeMap ); -export default EnglishStringToCodeMap; \ No newline at end of file +export default EnglishStringToCodeMap; + +export const metaEnglishKeys: EnglishKey[] = [ 'ctrl', 'alt', 'shift', 'meta' ]; + +/** + * Returns the first EnglishStringToCodeMap that corresponds to the provided event.code. Null if no match is found. + * Useful when matching an english string used by KeyboardListener to the event code from a + * SceneryEvent.domEvent.code. + * + * For example: + * + * KeyboardUtils.eventCodeToEnglishString( 'KeyA' ) === 'a' + * KeyboardUtils.eventCodeToEnglishString( 'Numpad0' ) === '0' + * KeyboardUtils.eventCodeToEnglishString( 'Digit0' ) === '0' + * + * NOTE: This cannot be in KeyboardUtils because it would create a circular dependency. + */ +export const eventCodeToEnglishString = ( eventCode: string ): EnglishKey | null => { + for ( const key in EnglishStringToCodeMap ) { + if ( EnglishStringToCodeMap.hasOwnProperty( key ) && + ( EnglishStringToCodeMap[ key as EnglishKey ] ).includes( eventCode ) ) { + return key as EnglishKey; + } + } + return null; +}; \ No newline at end of file diff --git a/js/accessibility/KeyStateTracker.ts b/js/accessibility/KeyStateTracker.ts index fd96eacf4..ce1606595 100644 --- a/js/accessibility/KeyStateTracker.ts +++ b/js/accessibility/KeyStateTracker.ts @@ -14,7 +14,7 @@ import PhetioAction from '../../../tandem/js/PhetioAction.js'; import Emitter from '../../../axon/js/Emitter.js'; import stepTimer from '../../../axon/js/stepTimer.js'; import EventType from '../../../tandem/js/EventType.js'; -import { EventIO, KeyboardUtils, scenery } from '../imports.js'; +import { EnglishKey, EnglishStringToCodeMap, EventIO, KeyboardUtils, scenery } from '../imports.js'; import { PhetioObjectOptions } from '../../../tandem/js/PhetioObject.js'; import PickOptional from '../../../phet-core/js/types/PickOptional.js'; import TEmitter from '../../../axon/js/TEmitter.js'; @@ -264,6 +264,13 @@ class KeyStateTracker { return this.keyState[ key ].keyDown; } + /** + * Returns true if the key with the KeyboardEvent.code is currently down. + */ + public isEnglishKeyDown( key: EnglishKey ): boolean { + return this.isAnyKeyInListDown( EnglishStringToCodeMap[ key ] ); + } + /** * Returns true if any of the keys in the list are currently down. Keys are the KeyboardEvent.code strings. */ diff --git a/js/imports.ts b/js/imports.ts index 8e8b6a826..2cd18d234 100644 --- a/js/imports.ts +++ b/js/imports.ts @@ -24,7 +24,7 @@ export { default as xlinkns } from './util/xlinkns.js'; export { default as Utils } from './util/Utils.js'; export { default as Focus } from './accessibility/Focus.js'; export { default as KeyboardUtils } from './accessibility/KeyboardUtils.js'; -export { default as EnglishStringToCodeMap } from './accessibility/EnglishStringToCodeMap.js'; +export { default as EnglishStringToCodeMap, eventCodeToEnglishString, metaEnglishKeys } from './accessibility/EnglishStringToCodeMap.js'; export type { EnglishKey } from './accessibility/EnglishStringToCodeMap.js'; export { default as EnglishStringKeyUtils } from './accessibility/EnglishStringKeyUtils.js'; export { default as EventIO } from './input/EventIO.js'; @@ -217,6 +217,10 @@ export type { InputOptions } from './input/Input.js'; export { default as BatchedDOMEvent, BatchedDOMEventType } from './input/BatchedDOMEvent.js'; export type { BatchedDOMEventCallback } from './input/BatchedDOMEvent.js'; export { default as BrowserEvents } from './input/BrowserEvents.js'; +export { default as Hotkey } from './input/Hotkey.js'; +export type { HotkeyOptions, HotkeyFireOnHoldTiming } from './input/Hotkey.js'; +export { default as globalHotkeyRegistry } from './input/globalHotkeyRegistry.js'; +export { default as hotkeyManager } from './input/hotkeyManager.js'; export { default as InputFuzzer } from './input/InputFuzzer.js'; export { default as DownUpListener } from './input/DownUpListener.js'; diff --git a/js/input/Hotkey.ts b/js/input/Hotkey.ts new file mode 100644 index 000000000..3f7e33013 --- /dev/null +++ b/js/input/Hotkey.ts @@ -0,0 +1,152 @@ +// Copyright 2024, University of Colorado Boulder + +/** + * IMPORTANT: EXPERIMENTAL! + * TODO DO NOT USE IN SIMULATIONS, SEE https://github.com/phetsims/scenery/issues/1621 FIRST + * + * Represents a single hotkey (keyboard shortcut) that can be either: + * + * 1. Added to globalHotkeyRegistry (to be available regardless of keyboard focus) + * 2. Added to a node's inputListeners (to be available only when that node is part of the focused trail) + * + * @author Jesse Greenberg (PhET Interactive Simulations) + * @author Jonathan Olson + */ + +import { EnglishKey, 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'; +import BooleanProperty from '../../../axon/js/BooleanProperty.js'; +import CallbackTimer from '../../../axon/js/CallbackTimer.js'; + +export type HotkeyFireOnHoldTiming = 'browser' | 'custom'; + +type SelfOptions = { + // The key that should be pressed to trigger the hotkey (in fireOnDown:true mode) or released to trigger the hotkey (in + // fireOnDown:false mode). + key: EnglishKey; + + // A set of modifier keys that: + // + // 1. Need to be pressed before the main key before this hotkey is considered pressed. + // 2. If not a normal (ctrl/alt/meta/shift) modifier key, will also be required to be "off" for other hotkeys to be + // activated when this hotkey is present. + // + // Note that the release of a modifier key may "activate" the hotkey for "fire-on-hold", but not for "fire-on-down". + modifierKeys?: EnglishKey[]; + + // TODO: consider the ability to "not" match modifier keys? https://github.com/phetsims/scenery/issues/1621 + + // Called as fire() when the hotkey is tired + fire?: ( 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; + + // Whether the fire-on-hold feature is enabled + fireOnHold?: boolean; + + // Whether we should listen to the browser's fire-on-hold timing, or use our own. + fireOnHoldTiming?: HotkeyFireOnHoldTiming; + + // Start to fire continuously after pressing for this long (milliseconds) + fireOnHoldCustomDelay?: number; + + // Fire continuously at this interval (milliseconds) + fireOnHoldCustomInterval?: number; + + // TODO: consider attach:false https://github.com/phetsims/scenery/issues/1621 +}; + +export type HotkeyOptions = SelfOptions & EnabledComponentOptions; + +export default class Hotkey extends EnabledComponent { + + // Straight from options + public readonly key: EnglishKey; + public readonly modifierKeys: EnglishKey[]; + public readonly fire: ( event: KeyboardEvent | null ) => void; + public readonly fireOnDown: boolean; + public readonly fireOnHold: boolean; + public readonly fireOnHoldTiming: HotkeyFireOnHoldTiming; + + // A Property that tracks whether the hotkey is currently pressed. + // Will be true if it meets the following conditions: + // + // 1. Main `key` pressed + // 2. All modifier keys in the hotkey's `modifierKeys` are pressed + // 3. All modifier keys not in the hotkey's `modifierKeys` (but in the other enabled hotkeys) are not pressed + public readonly isPressedProperty: TProperty = new BooleanProperty( false ); + + // (read-only for client code) + // Whether the last release (value during isPressedProperty => false) was due to an interruption (e.g. focus changed). + // If false, the hotkey was released due to the key being released. + public interrupted = false; + + // Internal timer for when fireOnHold:true and fireOnHoldTiming:custom. + private fireOnHoldTimer?: CallbackTimer; + + public constructor( + providedOptions: HotkeyOptions + ) { + + assert && assert( providedOptions.fireOnHoldTiming === 'custom' || ( providedOptions.fireOnHoldCustomDelay === undefined && providedOptions.fireOnHoldCustomInterval === undefined ), + 'Cannot specify fireOnHoldCustomDelay / fireOnHoldCustomInterval if fireOnHoldTiming is not custom' ); + + const options = optionize()( { + modifierKeys: [], + fire: _.noop, + fireOnDown: true, + fireOnHold: false, + fireOnHoldTiming: 'browser', + fireOnHoldCustomDelay: 400, + fireOnHoldCustomInterval: 100 + }, providedOptions ); + + super( options ); + + // Store public things + this.key = options.key; + this.modifierKeys = options.modifierKeys; + this.fire = options.fire; + this.fireOnDown = options.fireOnDown; + this.fireOnHold = options.fireOnHold; + this.fireOnHoldTiming = options.fireOnHoldTiming; + + // Create a timer to handle the optional fire-on-hold feature. + if ( this.fireOnHold && this.fireOnHoldTiming === 'custom' ) { + this.fireOnHoldTimer = new CallbackTimer( { + // TODO: Consider passing the _original_ event here? https://github.com/phetsims/scenery/issues/1621 + callback: this.fire.bind( this, null ), // Pass null for fire-on-hold events + delay: options.fireOnHoldCustomDelay, + interval: options.fireOnHoldCustomInterval + } ); + this.disposeEmitter.addListener( () => this.fireOnHoldTimer!.dispose() ); + + this.isPressedProperty.link( isPressed => { + // We need to reset the timer, so we stop it (even if we are starting it in just a bit again) + this.fireOnHoldTimer!.stop( false ); + + if ( isPressed ) { + this.fireOnHoldTimer!.start(); + } + } ); + } + } + + public getHotkeyString(): string { + return [ + ...this.modifierKeys, + this.key + ].join( '+' ); + } + + public override dispose(): void { + this.isPressedProperty.dispose(); + + super.dispose(); + } +} +scenery.register( 'Hotkey', Hotkey ); \ No newline at end of file diff --git a/js/input/TInputListener.ts b/js/input/TInputListener.ts index 37c6022af..fa895f5bb 100644 --- a/js/input/TInputListener.ts +++ b/js/input/TInputListener.ts @@ -10,7 +10,7 @@ import Bounds2 from '../../../dot/js/Bounds2.js'; import StrictOmit from '../../../phet-core/js/types/StrictOmit.js'; -import { SceneryEvent } from '../imports.js'; +import { Hotkey, SceneryEvent } from '../imports.js'; export type SceneryListenerFunction = ( event: SceneryEvent ) => void; @@ -31,6 +31,9 @@ type TInputListener = { // called from a listener attached to a Pointer so that the API is compatible with multi-touch. createPanTargetBounds?: ( () => Bounds2 ) | null; + // Hotkeys that will be available whenever a node with this listener is in a focused trail. + hotkeys?: Hotkey[]; + //////////////////////////////////////////////// ////////////////////////////////////////////// // Only actual events below here diff --git a/js/input/globalHotkeyRegistry.ts b/js/input/globalHotkeyRegistry.ts new file mode 100644 index 000000000..25aab1727 --- /dev/null +++ b/js/input/globalHotkeyRegistry.ts @@ -0,0 +1,38 @@ +// Copyright 2024, University of Colorado Boulder + +/** + * IMPORTANT: EXPERIMENTAL! + * TODO DO NOT USE IN SIMULATIONS, SEE https://github.com/phetsims/scenery/issues/1621 FIRST + * + * Stores a record of all global hotkeys (Hotkey instances that should be available regardless of focus). + * + * @author Jesse Greenberg (PhET Interactive Simulations) + * @author Jonathan Olson + */ + +import { Hotkey, scenery } from '../imports.js'; +import TinyProperty from '../../../axon/js/TinyProperty.js'; +import TProperty from '../../../axon/js/TProperty.js'; + +class GlobalHotkeyRegistry { + + // (read-only) The set of hotkeys that are currently available globally + public readonly hotkeysProperty: TProperty> = new TinyProperty( new Set() ); + + public add( hotkey: Hotkey ): void { + assert && assert( !this.hotkeysProperty.value.has( hotkey ), 'Hotkey already added' ); + + this.hotkeysProperty.value = new Set( [ ...this.hotkeysProperty.value, hotkey ] ); + } + + public remove( hotkey: Hotkey ): void { + assert && assert( this.hotkeysProperty.value.has( hotkey ), 'Hotkey not found' ); + + this.hotkeysProperty.value = new Set( [ ...this.hotkeysProperty.value ].filter( value => value !== hotkey ) ); + } +} +scenery.register( 'GlobalHotkeyRegistry', GlobalHotkeyRegistry ); + +const globalHotkeyRegistry = new GlobalHotkeyRegistry(); + +export default globalHotkeyRegistry; \ No newline at end of file diff --git a/js/input/hotkeyManager.ts b/js/input/hotkeyManager.ts new file mode 100644 index 000000000..84a0f195c --- /dev/null +++ b/js/input/hotkeyManager.ts @@ -0,0 +1,298 @@ +// Copyright 2024, University of Colorado Boulder + +/** + * IMPORTANT: EXPERIMENTAL! + * TODO DO NOT USE IN SIMULATIONS, SEE https://github.com/phetsims/scenery/issues/1621 FIRST + * + * Manages hotkeys based on two sources: + * + * 1. Global hotkeys (from globalHotkeyRegistry) + * 2. Hotkeys from the current focus trail (FocusManager.pdomFocusProperty, all hotkeys on all input listeners of + * nodes in the trail) + * + * Manages key press state using EnglishKey from globalKeyStateTracker. + * TODO: We need to gracefully handle when the user presses BOTH keys that correspond to an EnglishKey, e.g. https://github.com/phetsims/scenery/issues/1621 + * TODO: left-ctrl and right-ctrl, then releases one (it should still count ctrl as pressed). + * + * The "available" hotkeys are the union of the above two sources. + * + * The "enabled" hotkeys are the subset of available hotkeys whose enabledProperties are true. + * + * The "active" hotkeys are the subset of enabled hotkeys that are considered pressed. They will have fire-on-hold + * behavior active. + * + * The set of enabled hotkeys determines the set of modifier keys that are considered "active" (in addition to + * ctrl/alt/meta/shift, which are always included). + * + * + * @author Jesse Greenberg (PhET Interactive Simulations) + * @author Jonathan Olson + */ + +import { EnglishKey, eventCodeToEnglishString, FocusManager, globalHotkeyRegistry, globalKeyStateTracker, Hotkey, KeyboardUtils, metaEnglishKeys, scenery } from '../imports.js'; +import TReadOnlyProperty from '../../../axon/js/TReadOnlyProperty.js'; +import DerivedProperty from '../../../axon/js/DerivedProperty.js'; +import TProperty from '../../../axon/js/TProperty.js'; +import TinyProperty from '../../../axon/js/TinyProperty.js'; + +const hotkeySetComparator = ( a: Set, b: Set ) => { + return a.size === b.size && [ ...a ].every( element => b.has( element ) ); +}; + +class HotkeyManager { + + // All hotkeys that are either globally or under the current focus trail + private readonly availableHotkeysProperty: TReadOnlyProperty>; + + // Enabled hotkeys that are either global, or under the current focus trail + private readonly enabledHotkeysProperty: TProperty> = new TinyProperty( new Set() ); + + private readonly englishKeysDownProperty: TProperty> = new TinyProperty( new Set() ); + + // The current set of modifier keys (pressed or not) based on current enabled hotkeys + // TODO: Should we actually only have a set of modifier keys PER main key? https://github.com/phetsims/scenery/issues/1621 + // TODO: e.g. should "b+x" (b being pressed) prevent "y"? https://github.com/phetsims/scenery/issues/1621 + private modifierKeys: EnglishKey[] = []; + + // Hotkeys that are actively pressed + private readonly activeHotkeys: Set = new Set(); + + public constructor() { + this.availableHotkeysProperty = new DerivedProperty( [ + globalHotkeyRegistry.hotkeysProperty, + FocusManager.pdomFocusProperty + ], ( globalHotkeys, focus ) => { + // Always include global hotkeys. Use a set since we might have duplicates. + const hotkeys = new Set( globalHotkeys ); + + // If we have focus, include the hotkeys from the focus trail + if ( focus ) { + for ( const node of focus.trail.nodes ) { + // TODO: we might need to listen to things, if this is our list? https://github.com/phetsims/scenery/issues/1621 + if ( node.isDisposed || !node.isVisible() || !node.isInputEnabled() || !node.isPDOMVisible() ) { + break; + } + + node.inputListeners.forEach( listener => { + listener.hotkeys?.forEach( hotkey => { + hotkeys.add( hotkey ); + } ); + } ); + } + } + + return hotkeys; + }, { + // We want to not over-notify, so we compare the sets directly + valueComparisonStrategy: hotkeySetComparator + } ); + + // Update enabledHotkeysProperty when availableHotkeysProperty (or any enabledProperty) changes + const rebuildHotkeys = () => { + this.enabledHotkeysProperty.value = new Set( [ ...this.availableHotkeysProperty.value ].filter( hotkey => hotkey.enabledProperty.value ) ); + }; + // Because we can't add duplicate listeners, we create extra closures to have a unique handle for each hotkey + const hotkeyRebuildListenerMap = new Map void>(); // eslint-disable-line no-spaced-func + this.availableHotkeysProperty.link( ( newHotkeys, oldHotkeys ) => { + // Track whether any hotkeys changed. If none did, we don't need to rebuild. + let hotkeysChanged = false; + + // Any old hotkeys and aren't in new hotkeys should be unlinked + if ( oldHotkeys ) { + for ( const hotkey of oldHotkeys ) { + if ( !newHotkeys.has( hotkey ) ) { + const listener = hotkeyRebuildListenerMap.get( hotkey )!; + hotkeyRebuildListenerMap.delete( hotkey ); + assert && assert( listener ); + + hotkey.enabledProperty.unlink( listener ); + hotkeysChanged = true; + } + } + } + + // Any new hotkeys that aren't in old hotkeys should be linked + for ( const hotkey of newHotkeys ) { + if ( !oldHotkeys || !oldHotkeys.has( hotkey ) ) { + // Unfortunate. Perhaps in the future we could have an abstraction that makes a "count" of how many times we + // are "listening" to a Property. + const listener = () => rebuildHotkeys(); + hotkeyRebuildListenerMap.set( hotkey, listener ); + + hotkey.enabledProperty.lazyLink( listener ); + hotkeysChanged = true; + } + } + + if ( hotkeysChanged ) { + rebuildHotkeys(); + } + } ); + + // Update modifierKeys and whether each hotkey is currently pressed. This is how hotkeys can have their state change + // from either themselves (or other hotkeys with modifier keys) being added/removed from enabledHotkeys. + this.enabledHotkeysProperty.link( ( newHotkeys, oldHotkeys ) => { + this.modifierKeys = _.uniq( [ + ...metaEnglishKeys, + ...[ ...newHotkeys ].flatMap( hotkey => hotkey.modifierKeys ) + ] ); + + // Remove any hotkeys that are no longer available or enabled + if ( oldHotkeys ) { + for ( const hotkey of oldHotkeys ) { + if ( !newHotkeys.has( hotkey ) && this.activeHotkeys.has( hotkey ) ) { + this.removeActiveHotkey( hotkey, null, false ); + } + } + } + + // Re-check all hotkeys (since modifier keys might have changed, OR we need to validate that there are no conflicts). + this.updateHotkeyStatus(); + } ); + + // Track keydowns + globalKeyStateTracker.keydownEmitter.addListener( keyboardEvent => { + const keyCode = KeyboardUtils.getEventCode( keyboardEvent ); + + if ( keyCode !== null ) { + const englishKey = eventCodeToEnglishString( keyCode ); + if ( englishKey ) { + this.onKeyDown( englishKey, keyboardEvent ); + } + } + } ); + + // Track keyups + globalKeyStateTracker.keyupEmitter.addListener( keyboardEvent => { + const keyCode = KeyboardUtils.getEventCode( keyboardEvent ); + + if ( keyCode !== null ) { + const englishKey = eventCodeToEnglishString( keyCode ); + if ( englishKey ) { + this.onKeyUp( englishKey, keyboardEvent ); + } + } + } ); + } + + /** + * Given a main `key`, see if there is a hotkey that should be considered "active/pressed" for it. + * + * For a hotkey to be compatible, it needs to have: + * + * 1. Main key pressed + * 2. All modifier keys in the hotkey's modifierKeys pressed + * 3. All modifier keys not in the hotkey's modifierKeys (but in the other hotkeys above) not pressed + */ + private getHotkeyForMainKey( mainKey: EnglishKey ): Hotkey | null { + const englishKeysDown = this.englishKeysDownProperty.value; + + // If the main key isn't down, there's no way it could be active + if ( !englishKeysDown.has( mainKey ) ) { + return null; + } + + const compatibleKeys = [ ...this.enabledHotkeysProperty.value ].filter( hotkey => { + // Filter out hotkeys that don't have the main key + if ( hotkey.key !== mainKey ) { + return false; + } + + // See whether the modifier keys match + return this.modifierKeys.every( modifierKey => { + return englishKeysDown.has( modifierKey ) === hotkey.modifierKeys.includes( modifierKey ); + } ); + } ); + + assert && assert( compatibleKeys.length < 2, `Key conflict detected: ${compatibleKeys.map( hotkey => hotkey.getHotkeyString() )}` ); + + return compatibleKeys[ 0 ] ?? null; + } + + /** + * Re-check all hotkey active/pressed states (since modifier keys might have changed, OR we need to validate that + * there are no conflicts). + */ + private updateHotkeyStatus(): void { + for ( const hotkey of this.enabledHotkeysProperty.value ) { + const shouldBeActive = this.getHotkeyForMainKey( hotkey.key ) === hotkey; + const isActive = this.activeHotkeys.has( hotkey ); + + if ( shouldBeActive && !isActive ) { + this.addActiveHotkey( hotkey, null, false ); + } + else if ( !shouldBeActive && isActive ) { + this.removeActiveHotkey( hotkey, null, false ); + } + } + } + + /** + * Hotkey made active/pressed + */ + 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 ); + } + } + + /** + * 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; + this.activeHotkeys.delete( hotkey ); + } + + private onKeyDown( englishKey: EnglishKey, keyboardEvent: KeyboardEvent ): void { + if ( this.englishKeysDownProperty.value.has( englishKey ) ) { + // Still pressed, got the browser/OS "fire on hold". See what hotkeys have the browser fire-on-hold behavior. + + // Handle re-entrancy (if something changes the state of activeHotkeys) + for ( const hotkey of [ ...this.activeHotkeys ] ) { + if ( hotkey.fireOnHold && hotkey.fireOnHoldTiming === 'browser' ) { + hotkey.fire( keyboardEvent ); + } + } + } + else { + // Freshly pressed, was not pressed before. See if there is a hotkey to fire. + + this.englishKeysDownProperty.value = new Set( [ ...this.englishKeysDownProperty.value, englishKey ] ); + + const hotkey = this.getHotkeyForMainKey( englishKey ); + if ( hotkey ) { + this.addActiveHotkey( hotkey, keyboardEvent, true ); + } + } + + this.updateHotkeyStatus(); + } + + private onKeyUp( englishKey: EnglishKey, keyboardEvent: KeyboardEvent ): void { + + const hotkey = this.getHotkeyForMainKey( englishKey ); + if ( hotkey ) { + this.removeActiveHotkey( hotkey, keyboardEvent, true ); + } + + this.englishKeysDownProperty.value = new Set( [ ...this.englishKeysDownProperty.value ].filter( key => key !== englishKey ) ); + + this.updateHotkeyStatus(); + } +} +scenery.register( 'HotkeyManager', HotkeyManager ); + +const hotkeyManager = new HotkeyManager(); + +export default hotkeyManager; \ No newline at end of file diff --git a/js/listeners/KeyboardDragListener.ts b/js/listeners/KeyboardDragListener.ts index 0d9415cb9..9e24479d6 100644 --- a/js/listeners/KeyboardDragListener.ts +++ b/js/listeners/KeyboardDragListener.ts @@ -1000,13 +1000,6 @@ class KeyboardDragListener extends EnabledComponent implements TInputListener { this._hotkeys = hotkeys.slice( 0 ); // shallow copy } - /** - * See setHotkeys() for more information. - */ - public set hotkeys( hotkeys: Hotkey[] ) { - this.setHotkeys( hotkeys ); - } - /** * Clear all hotkeys from this KeyboardDragListener. */ diff --git a/js/listeners/KeyboardListener.ts b/js/listeners/KeyboardListener.ts index 04dececd7..e64d4c0cd 100644 --- a/js/listeners/KeyboardListener.ts +++ b/js/listeners/KeyboardListener.ts @@ -49,7 +49,7 @@ import CallbackTimer from '../../../axon/js/CallbackTimer.js'; import optionize from '../../../phet-core/js/optionize.js'; -import { EnglishKey, EnglishStringToCodeMap, FocusManager, globalKeyStateTracker, scenery, SceneryEvent, TInputListener } from '../imports.js'; +import { EnglishStringToCodeMap, FocusManager, globalKeyStateTracker, scenery, SceneryEvent, TInputListener } from '../imports.js'; import KeyboardUtils from '../accessibility/KeyboardUtils.js'; // NOTE: The typing for ModifierKey and OneKeyStroke is limited TypeScript, there is a limitation to the number of @@ -552,29 +552,6 @@ class KeyboardListener implements TInputLi return keyGroups; } - - /** - * Returns the first EnglishStringToCodeMap that corresponds to the provided event.code. Null if no match is found. - * Useful when matching an english string used by KeyboardListener to the event code from a - * SceneryEvent.domEvent.code. - * - * For example: - * - * KeyboardUtils.eventCodeToEnglishString( 'KeyA' ) === 'a' - * KeyboardUtils.eventCodeToEnglishString( 'Numpad0' ) === '0' - * KeyboardUtils.eventCodeToEnglishString( 'Digit0' ) === '0' - * - * NOTE: This cannot be in KeyboardUtils because it would create a circular dependency. - */ - public static eventCodeToEnglishString( eventCode: string ): EnglishKey | null { - for ( const key in EnglishStringToCodeMap ) { - if ( EnglishStringToCodeMap.hasOwnProperty( key ) && - ( EnglishStringToCodeMap[ key as EnglishKey ] ).includes( eventCode ) ) { - return key as EnglishKey; - } - } - return null; - } } scenery.register( 'KeyboardListener', KeyboardListener );