diff --git a/js/accessibility/EnglishStringToCodeMap.ts b/js/accessibility/EnglishStringToCodeMap.ts index b5dde475c..184dfd3ef 100644 --- a/js/accessibility/EnglishStringToCodeMap.ts +++ b/js/accessibility/EnglishStringToCodeMap.ts @@ -10,6 +10,7 @@ import { KeyboardUtils, scenery } from '../imports.js'; export type EnglishKey = keyof typeof EnglishStringToCodeMap; +export type EnglishKeyString = `${EnglishKey}`; const EnglishStringToCodeMap = { @@ -83,7 +84,7 @@ const EnglishStringToCodeMap = { scenery.register( 'EnglishStringToCodeMap', EnglishStringToCodeMap ); export default EnglishStringToCodeMap; -export const metaEnglishKeys: EnglishKey[] = [ 'ctrl', 'alt', 'shift', 'meta' ]; +export const metaEnglishKeys: EnglishKeyString[] = [ 'ctrl', 'alt', 'shift', 'meta' ]; /** * Returns the first EnglishStringToCodeMap that corresponds to the provided event.code. Null if no match is found. @@ -98,11 +99,11 @@ export const metaEnglishKeys: EnglishKey[] = [ 'ctrl', 'alt', 'shift', 'meta' ]; * * NOTE: This cannot be in KeyboardUtils because it would create a circular dependency. */ -export const eventCodeToEnglishString = ( eventCode: string ): EnglishKey | null => { +export const eventCodeToEnglishString = ( eventCode: string ): EnglishKeyString | null => { for ( const key in EnglishStringToCodeMap ) { if ( EnglishStringToCodeMap.hasOwnProperty( key ) && ( EnglishStringToCodeMap[ key as EnglishKey ] ).includes( eventCode ) ) { - return key as EnglishKey; + return key as EnglishKeyString; } } return null; diff --git a/js/accessibility/KeyStateTracker.ts b/js/accessibility/KeyStateTracker.ts index ae1283215..b85e502bc 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 { EnglishKey, EnglishStringToCodeMap, eventCodeToEnglishString, EventIO, KeyboardUtils, scenery } from '../imports.js'; +import { EnglishKey, EnglishKeyString, EnglishStringToCodeMap, eventCodeToEnglishString, 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'; @@ -306,8 +306,8 @@ class KeyStateTracker { * * NOTE: Always returns a new Set, so a defensive copy is not needed. */ - public getEnglishKeysDown(): Set { - const englishKeySet = new Set(); + public getEnglishKeysDown(): Set { + const englishKeySet = new Set(); for ( const key of this.getKeysDown() ) { const englishKey = eventCodeToEnglishString( key ); diff --git a/js/imports.ts b/js/imports.ts index 9dfe37a75..740d18994 100644 --- a/js/imports.ts +++ b/js/imports.ts @@ -25,7 +25,7 @@ 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, eventCodeToEnglishString, metaEnglishKeys } from './accessibility/EnglishStringToCodeMap.js'; -export type { EnglishKey } from './accessibility/EnglishStringToCodeMap.js'; +export type { EnglishKey, EnglishKeyString } from './accessibility/EnglishStringToCodeMap.js'; export { default as EnglishStringKeyUtils } from './accessibility/EnglishStringKeyUtils.js'; export { default as EventIO } from './input/EventIO.js'; export { default as SceneryStyle } from './util/SceneryStyle.js'; @@ -220,8 +220,9 @@ 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 HotkeyData } from './input/HotkeyData.js'; export { default as KeyDescriptor } from './input/KeyDescriptor.js'; -export type { KeyDescriptorOptions, OneKeyStroke, OneKeyStrokeEntry } from './input/KeyDescriptor.js'; +export type { KeyDescriptorOptions, OneKeyStroke, OneKeyStrokeEntry, AllowedKeysString } from './input/KeyDescriptor.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'; diff --git a/js/input/Hotkey.ts b/js/input/Hotkey.ts index d175818ff..22edd6bbb 100644 --- a/js/input/Hotkey.ts +++ b/js/input/Hotkey.ts @@ -32,13 +32,12 @@ * @author Jonathan Olson */ -import { EnglishKey, EnglishStringToCodeMap, hotkeyManager, OneKeyStroke, scenery } from '../imports.js'; +import { AllowedKeysString, EnglishStringToCodeMap, hotkeyManager, KeyDescriptor, OneKeyStroke, 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'; -import KeyDescriptor from './KeyDescriptor.js'; import DerivedProperty from '../../../axon/js/DerivedProperty.js'; import TReadOnlyProperty from '../../../axon/js/TReadOnlyProperty.js'; @@ -109,7 +108,7 @@ export default class Hotkey extends EnabledComponent { public readonly keyDescriptorProperty: TReadOnlyProperty; // All keys that are part of this hotkey (key + modifierKeys) as defined by the current KeyDescriptor. - public keysProperty: TReadOnlyProperty; + public keysProperty: TReadOnlyProperty; // A Property that tracks whether the hotkey is currently pressed. // Will be true if it meets the following conditions: diff --git a/js/input/HotkeyData.ts b/js/input/HotkeyData.ts new file mode 100644 index 000000000..69a13b24d --- /dev/null +++ b/js/input/HotkeyData.ts @@ -0,0 +1,121 @@ +// Copyright 2024, University of Colorado Boulder + +/** + * Data pertaining to a hotkey, including keystrokes and associated metadata for documentation and the keyboard help + * dialog. + * + * @author Jesse Greenberg (PhET Interactive Simulations) + */ + +import { KeyDescriptor, OneKeyStroke, scenery } from '../../../scenery/js/imports.js'; +import optionize from '../../../phet-core/js/optionize.js'; +import TReadOnlyProperty from '../../../axon/js/TReadOnlyProperty.js'; +import DerivedProperty from '../../../axon/js/DerivedProperty.js'; +import InstanceRegistry from '../../../phet-core/js/documentation/InstanceRegistry.js'; + +// The type for a serialized HotkeyData object for documentation (binder). +type SerializedHotkeyData = { + keyStrings: string[]; + repoName: string; + binderName: string; + global: boolean; +}; + +export type HotkeyDataOptions = { + + // The list of keystrokes that will trigger the hotkey. Wrapping in a Property allows for i18n in the future. + keyStringProperties: TReadOnlyProperty[]; + + // The visual label for this Hotkey in the Keyboard Help dialog. This will also be used as the label in + // generated documentation, unless binderName is provided. + keyboardHelpDialogLabelStringProperty?: TReadOnlyProperty | null; + + // The PDOM label and description for this Hotkey in the Keyboard Help dialog. + keyboardHelpDialogPDOMLabelStringProperty?: TReadOnlyProperty | string | null; + + // Data for binder (generated documentation). + repoName: string; // Name of the repository where the hotkey is defined. + global?: boolean; // Is this Hotkey global? + binderName?: string; // If there is no keyboardHelpDialogLabelStringProperty, this name will be used in documentation. +}; + +export default class HotkeyData { + public readonly keyStringProperties: TReadOnlyProperty[]; + public readonly keyboardHelpDialogLabelStringProperty: TReadOnlyProperty | null; + public readonly keyboardHelpDialogPDOMLabelStringProperty: TReadOnlyProperty | string | null; + + // KeyDescriptors derived from keyStringProperties. + public readonly keyDescriptorsProperty: TReadOnlyProperty; + + private readonly repoName: string; + private readonly global: boolean; + private readonly binderName: string; + + public constructor( providedOptions: HotkeyDataOptions ) { + assert && assert( providedOptions.binderName || providedOptions.keyboardHelpDialogLabelStringProperty, + 'You must provide some label for the hotkey' ); + + const options = optionize()( { + keyboardHelpDialogPDOMLabelStringProperty: null, + keyboardHelpDialogLabelStringProperty: null, + global: false, + binderName: '' + }, providedOptions ); + + this.keyStringProperties = options.keyStringProperties; + this.keyboardHelpDialogLabelStringProperty = options.keyboardHelpDialogLabelStringProperty; + this.keyboardHelpDialogPDOMLabelStringProperty = options.keyboardHelpDialogPDOMLabelStringProperty; + + this.repoName = options.repoName; + this.global = options.global; + this.binderName = options.binderName; + + this.keyDescriptorsProperty = DerivedProperty.deriveAny( this.keyStringProperties, () => { + return this.keyStringProperties.map( keyStringProperty => { + return KeyDescriptor.keyStrokeToKeyDescriptor( keyStringProperty.value ); + } ); + } ); + + // Add this Hotkey to the binder registry for documentation. See documentation in the binder repository + // for more information about how this is done. + assert && phet?.chipper?.queryParameters?.binder && InstanceRegistry.registerHotkey( this ); + } + + /** + * Returns true if any of the keyStringProperties of this HotkeyData have the given keyStroke. + */ + public hasKeyStroke( keyStroke: OneKeyStroke ): boolean { + return this.keyStringProperties.some( keyStringProperty => keyStringProperty.value === keyStroke ); + } + + /** + * Serialization for usage with binder (generated documentation). + */ + public serialize(): SerializedHotkeyData { + return { + keyStrings: this.keyStringProperties.map( keyStringProperty => keyStringProperty.value ), + binderName: ( this.binderName || this.keyboardHelpDialogLabelStringProperty?.value )!, + repoName: this.repoName, + global: this.global + }; + } + + /** + * Dispose of owned Properties to prevent memory leaks. + */ + public dispose(): void { + this.keyDescriptorsProperty.dispose(); + } + + /** + * Combine the keyStringProperties of an array of HotkeyData into a single array. Useful if you want to combine + * multiple HotkeyData for a single KeyboardListener. + */ + public static combineKeyStringProperties( hotkeyDataArray: HotkeyData[] ): TReadOnlyProperty[] { + return hotkeyDataArray.reduce[]>( ( accumulator, hotkeyData ) => { + return accumulator.concat( hotkeyData.keyStringProperties ); + }, [] ); + } +} + +scenery.register( 'HotkeyData', HotkeyData ); \ No newline at end of file diff --git a/js/input/KeyDescriptor.ts b/js/input/KeyDescriptor.ts index c16ce248f..2fc7c0800 100644 --- a/js/input/KeyDescriptor.ts +++ b/js/input/KeyDescriptor.ts @@ -10,7 +10,7 @@ * @author Jesse Greenberg (PhET Interactive Simulations) */ -import { EnglishKey, EnglishStringToCodeMap, metaEnglishKeys, scenery } from '../imports.js'; +import { EnglishKey, EnglishKeyString, EnglishStringToCodeMap, metaEnglishKeys, scenery } from '../imports.js'; import optionize from '../../../phet-core/js/optionize.js'; // NOTE: The typing for ModifierKey and OneKeyStroke is limited TypeScript, there is a limitation to the number of @@ -30,6 +30,10 @@ type IgnoreOtherModifierKeys = `${IgnoreDelimiter}${ModifierKey}`; // Allowed keys are the keys of the EnglishStringToCodeMap. type AllowedKeys = keyof typeof EnglishStringToCodeMap; +// Allowed keys as a string - the format they will be provided by the user. +export type AllowedKeysString = `${AllowedKeys}`; + +// A key stroke entry is a single key or a key with "ignore" modifiers, see examples and keyStrokeToKeyDescriptor. export type OneKeyStrokeEntry = `${AllowedKeys}` | `${IgnoreModifierKey}+${EnglishKey}` | `${IgnoreOtherModifierKeys}+${EnglishKey}`; export type OneKeyStroke = @@ -49,7 +53,7 @@ export type KeyDescriptorOptions = { // 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; + key: AllowedKeysString; // A set of modifier keys that: // @@ -67,17 +71,17 @@ export type KeyDescriptorOptions = { // so that is kept consistent for PhET-specific modifier keys. // // Note that the release of a modifier key may "activate" the hotkey for "fire-on-hold", but not for "fire-on-down". - modifierKeys?: EnglishKey[]; + modifierKeys?: AllowedKeysString[]; // A set of modifier keys that can be down and the hotkey will still fire. Essentially ignoring the modifier // key behavior for this key. - ignoredModifierKeys?: EnglishKey[]; + ignoredModifierKeys?: AllowedKeysString[]; }; export default class KeyDescriptor { - public readonly key: EnglishKey; - public readonly modifierKeys: EnglishKey[]; - public readonly ignoredModifierKeys: EnglishKey[]; + public readonly key: AllowedKeysString; + public readonly modifierKeys: AllowedKeysString[]; + public readonly ignoredModifierKeys: AllowedKeysString[]; public constructor( providedOptions?: KeyDescriptorOptions ) { const options = optionize()( { @@ -136,7 +140,7 @@ export default class KeyDescriptor { */ public static keyStrokeToKeyDescriptor( keyStroke: OneKeyStroke ): KeyDescriptor { - const tokens = keyStroke.split( '+' ); + const tokens = keyStroke.split( '+' ) as OneKeyStrokeEntry[]; // assertions let foundIgnoreDelimiter = false; @@ -145,13 +149,13 @@ export default class KeyDescriptor { // the ignore delimiter can only be used on default modifier keys if ( token.length > 1 && token.includes( IGNORE_DELIMITER ) ) { assert && assert( !foundIgnoreDelimiter, 'There can only be one ignore delimiter' ); - assert && assert( metaEnglishKeys.includes( token.replace( IGNORE_DELIMITER, '' ) as EnglishKey ), 'The ignore delimiter can only be used on default modifier keys' ); + assert && assert( metaEnglishKeys.includes( token.replace( IGNORE_DELIMITER, '' ) as EnglishKeyString ), 'The ignore delimiter can only be used on default modifier keys' ); foundIgnoreDelimiter = true; } } ); - const modifierKeys: string[] = []; - const ignoredModifierKeys: string[] = []; + const modifierKeys: AllowedKeysString[] = []; + const ignoredModifierKeys: AllowedKeysString[] = []; tokens.forEach( token => { @@ -162,35 +166,35 @@ export default class KeyDescriptor { if ( token.startsWith( IGNORE_DELIMITER ) ) { // Add all default modifiers except the current stripped token to the ignored keys - const otherModifiers = metaEnglishKeys.filter( mod => mod !== strippedToken ) as string[]; + const otherModifiers = metaEnglishKeys.filter( mod => mod !== strippedToken ); ignoredModifierKeys.push( ...otherModifiers ); // Include the stripped token as a regular modifier key - modifierKeys.push( strippedToken ); + modifierKeys.push( strippedToken as AllowedKeysString ); } else { // Add the stripped token to the ignored modifier keys - ignoredModifierKeys.push( strippedToken ); + ignoredModifierKeys.push( strippedToken as AllowedKeysString ); } } else { // If there's no question mark, add the token to the modifier keys - modifierKeys.push( token ); + modifierKeys.push( token as AllowedKeysString ); } } ); // Assume the last token is the key - const key = modifierKeys.pop() as EnglishKey; + const key = modifierKeys.pop()!; // Filter out ignored modifier keys from the modifier keys list const filteredModifierKeys = modifierKeys.filter( mod => !ignoredModifierKeys.includes( mod ) ); return new KeyDescriptor( { key: key, - modifierKeys: filteredModifierKeys as EnglishKey[], - ignoredModifierKeys: ignoredModifierKeys as EnglishKey[] + modifierKeys: filteredModifierKeys, + ignoredModifierKeys: ignoredModifierKeys } ); } } diff --git a/js/input/KeyDescriptorTests.ts b/js/input/KeyDescriptorTests.ts index f775f3394..4f5316274 100644 --- a/js/input/KeyDescriptorTests.ts +++ b/js/input/KeyDescriptorTests.ts @@ -6,12 +6,12 @@ * @author Jesse Greenberg (PhET Interactive Simulations) */ -import { EnglishKey, KeyDescriptor } from '../imports.js'; +import { EnglishKeyString, KeyDescriptor } from '../imports.js'; QUnit.module( 'KeyDescriptor' ); // Helper function to compare two arrays with QUnit assertions, ignoring order. -const arraysEqualIgnoringOrder = function( array1: EnglishKey[], array2: EnglishKey[], assert: Assert, message: string ) { +const arraysEqualIgnoringOrder = function( array1: EnglishKeyString[], array2: EnglishKeyString[], assert: Assert, message: string ) { array1 = array1.slice().sort(); array2 = array2.slice().sort(); diff --git a/js/input/hotkeyManager.ts b/js/input/hotkeyManager.ts index a2c0053ba..3ae307dc7 100644 --- a/js/input/hotkeyManager.ts +++ b/js/input/hotkeyManager.ts @@ -23,7 +23,7 @@ * @author Jonathan Olson */ -import { EnglishKey, eventCodeToEnglishString, FocusManager, globalHotkeyRegistry, globalKeyStateTracker, Hotkey, KeyboardUtils, metaEnglishKeys, Node, scenery } from '../imports.js'; +import { AllowedKeysString, EnglishKeyString, eventCodeToEnglishString, FocusManager, globalHotkeyRegistry, globalKeyStateTracker, Hotkey, KeyboardUtils, metaEnglishKeys, Node, scenery } from '../imports.js'; import DerivedProperty, { UnknownDerivedProperty } from '../../../axon/js/DerivedProperty.js'; import TProperty from '../../../axon/js/TProperty.js'; import TinyProperty from '../../../axon/js/TinyProperty.js'; @@ -46,12 +46,12 @@ class HotkeyManager { private readonly enabledHotkeysProperty: TProperty = new TinyProperty( [] ); // The set of EnglishKeys that are currently pressed. - private englishKeysDown: Set = new Set(); + private englishKeysDown: Set = new Set(); // The current set of modifier keys (pressed or not) based on current enabled hotkeys // NOTE: Pressed modifier keys will prevent any other Hotkeys from becoming active. For example if you have a hotkey // with 'b+x', pressing 'b' will prevent any other hotkeys from becoming active. - private modifierKeys: EnglishKey[] = []; + private modifierKeys: AllowedKeysString[] = []; // Hotkeys that are actively pressed private readonly activeHotkeys: Set = new Set(); @@ -246,7 +246,7 @@ class HotkeyManager { * 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 getHotkeysForMainKey( mainKey: EnglishKey ): Hotkey[] { + private getHotkeysForMainKey( mainKey: EnglishKeyString ): Hotkey[] { // If the main key isn't down, there's no way it could be active if ( !this.englishKeysDown.has( mainKey ) ) {