diff --git a/js/RichDragListener.ts b/js/RichDragListener.ts new file mode 100644 index 00000000..17f2b67f --- /dev/null +++ b/js/RichDragListener.ts @@ -0,0 +1,337 @@ +// Copyright 2024, University of Colorado Boulder + +/** + * A drag listener that supports both pointer and keyboard input. It is composed with a RichPointerDragListener and a + * RichKeyboardDragListener to support pointer input, alternative input, sounds, and other PhET-specific features. + * + * Be sure to dispose of this listener when it is no longer needed. + * + * Typical PhET usage will use a position Property in a model coordinate frame and look like this: + * + * // A focusable Node that can be dragged with pointer or keyboard. + * const draggableNode = new Node( { + * tagName: 'div', + * focusable: true + * } ); + * + * const richDragListener = new RichDragListener( { + * positionProperty: someObject.positionProperty, + * transform: modelViewTransform + * } ); + * + * draggableNode.addInputListener( richDragListener ); + * + * @author Jesse Greenberg + */ + +import { Hotkey, PressListenerEvent, SceneryEvent, TInputListener } from '../../scenery/js/imports.js'; +import TProperty from '../../axon/js/TProperty.js'; +import Vector2 from '../../dot/js/Vector2.js'; +import Transform3 from '../../dot/js/Transform3.js'; +import TReadOnlyProperty from '../../axon/js/TReadOnlyProperty.js'; +import Bounds2 from '../../dot/js/Bounds2.js'; +import RichPointerDragListener, { PressedRichPointerDragListener, RichPointerDragListenerOptions } from './RichPointerDragListener.js'; +import RichKeyboardDragListener, { RichKeyboardDragListenerOptions } from './RichKeyboardDragListener.js'; +import optionize, { combineOptions } from '../../phet-core/js/optionize.js'; +import WrappedAudioBuffer from '../../tambo/js/WrappedAudioBuffer.js'; +import { SoundClipOptions } from '../../tambo/js/sound-generators/SoundClip.js'; +import { SoundGeneratorAddOptions } from '../../tambo/js/soundManager.js'; +import grab_mp3 from '../../tambo/sounds/grab_mp3.js'; +import release_mp3 from '../../tambo/sounds/release_mp3.js'; +import SceneryPhetConstants from './SceneryPhetConstants.js'; +import sceneryPhet from './sceneryPhet.js'; + +type SelfOptions = { + + // Called when the drag is started, for any input type. If you want to determine the type of input, you can check + // SceneryEvent.isFromPDOM or SceneryEvent.type. If you need a start behavior for a specific form of input, + // provide a start callback for that listener's options. It will be called IN ADDITION to this callback. + start?: ( ( event: SceneryEvent, listener: RichPointerDragListener | RichKeyboardDragListener ) => void ) | null; + + // Called during the drag event, for any input type. If you want to determine the type of input, you can check + // SceneryEvent.isFromPDOM or SceneryEvent.type. If you need a drag behavior for a specific form of input, + // provide a drag callback for that listener's options. It will be called IN ADDITION to this callback. + drag?: ( ( event: SceneryEvent, listener: RichPointerDragListener | RichKeyboardDragListener ) => void ) | null; + + // Called when the drag is ended, for any input type. If you want to determine the type of input, you can check + // SceneryEvent.isFromPDOM or SceneryEvent.type. If you need an end behavior for a specific form of input, + // provide an end callback for that listener's options. It will be called IN ADDITION to this callback. The event + // may be null for cases of interruption. + end?: ( ( event: SceneryEvent | null, listener: RichPointerDragListener | RichKeyboardDragListener ) => void ) | null; + + // If provided, it will be synchronized with the drag position in the model coordinate frame. The optional transform + // is applied. + positionProperty?: Pick, 'value'> | null; + + // If provided, this will be used to convert between the parent (view) and model coordinate frames. Most useful + // when you also provide a positionProperty. + transform?: Transform3 | TReadOnlyProperty | null; + + // If provided, the model position will be constrained to these bounds. + dragBoundsProperty?: TReadOnlyProperty | null; + + // If provided, this allows custom mapping from the desired position (i.e. where the pointer is, or where the + // RichKeyboardListener will set the position) to the actual position that will be used. + mapPosition?: null | ( ( point: Vector2 ) => Vector2 ); + + // If true, the target Node will be translated during the drag operation. + translateNode?: boolean; + + // Grab and release sounds. `null` means no sound. + grabSound?: WrappedAudioBuffer | null; + releaseSound?: WrappedAudioBuffer | null; + + // Passed to the grab and release SoundClip instances. + grabSoundClipOptions?: SoundClipOptions; + releaseSoundClipOptions?: SoundClipOptions; + + // addSoundGeneratorOptions + grabSoundGeneratorAddOptions?: SoundGeneratorAddOptions; + releaseSoundGeneratorAddOptions?: SoundGeneratorAddOptions; + + // Additional options for the RichPointerDragListener, OR any overrides for the RichPointerDragListener that should + // be used instead of the above options. For example, if the RichPointerDragListener should have different + // grab/release sounds, you can provide those options here. + richPointerDragListenerOptions?: RichPointerDragListenerOptions; + + // Additional options for the RichKeyboardDragListener, OR any overrides for the RichKeyboardDragListener that should + // be used instead of the above options. For example, if the RichKeyboardDragListener should have different + // grab/release sounds, you can provide those options here. + richKeyboardDragListenerOptions?: RichKeyboardDragListenerOptions; +}; + +export type RichDragListenerOptions = SelfOptions; + +export default class RichDragListener implements TInputListener { + private readonly richPointerDragListener: RichPointerDragListener; + private readonly richKeyboardDragListener: RichKeyboardDragListener; + + // Implements TInputListener + public readonly hotkeys: Hotkey[]; + + public constructor( providedOptions?: RichDragListenerOptions ) { + + const options = optionize()( { + + // RichDragListenerOptions + positionProperty: null, + start: null, + end: null, + drag: null, + transform: null, + dragBoundsProperty: null, + mapPosition: null, + translateNode: false, + grabSound: grab_mp3, + releaseSound: release_mp3, + grabSoundClipOptions: SceneryPhetConstants.DEFAULT_DRAG_CLIP_OPTIONS, + releaseSoundClipOptions: SceneryPhetConstants.DEFAULT_DRAG_CLIP_OPTIONS, + grabSoundGeneratorAddOptions: SceneryPhetConstants.DEFAULT_GRAB_SOUND_GENERATOR_ADD_OPTIONS, + releaseSoundGeneratorAddOptions: SceneryPhetConstants.DEFAULT_GRAB_SOUND_GENERATOR_ADD_OPTIONS, + richPointerDragListenerOptions: {}, + richKeyboardDragListenerOptions: {} + }, providedOptions ); + + // Options that will apply to both listeners. + const sharedOptions = { + positionProperty: options.positionProperty, + transform: options.transform, + dragBoundsProperty: options.dragBoundsProperty || undefined, + mapPosition: options.mapPosition || undefined, + translateNode: options.translateNode, + grabSound: options.grabSound, + releaseSound: options.releaseSound, + grabSoundClipOptions: options.grabSoundClipOptions, + releaseSoundClipOptions: options.releaseSoundClipOptions, + grabSoundGeneratorAddOptions: options.grabSoundGeneratorAddOptions + }; + + //--------------------------------------------------------------------------------- + // Construct the RichPointerDragListener and combine its options. + //--------------------------------------------------------------------------------- + const wrappedDragListenerStart = ( event: PressListenerEvent, listener: PressedRichPointerDragListener ) => { + + // when the drag listener starts, interrupt the keyboard dragging + this.richKeyboardDragListener.interrupt(); + + options.start && options.start( event, listener ); + options.richPointerDragListenerOptions.start && options.richPointerDragListenerOptions.start( event, listener ); + }; + + const wrappedDragListenerDrag = ( event: PressListenerEvent, listener: PressedRichPointerDragListener ) => { + options.drag && options.drag( event, listener ); + options.richPointerDragListenerOptions.drag && options.richPointerDragListenerOptions.drag( event, listener ); + }; + + const wrappedDragListenerEnd = ( event: PressListenerEvent | null, listener: PressedRichPointerDragListener ) => { + options.end && options.end( event, listener ); + options.richPointerDragListenerOptions.end && options.richPointerDragListenerOptions.end( event, listener ); + }; + + const richPointerDragListenerOptions = combineOptions( + // target object + {}, + // Options that apply to both, but can be overridden by provided listener-specific options + sharedOptions, + // Provided listener-specific options + options.richPointerDragListenerOptions, + // Options that cannot be overridden - see wrapped callbacks above + { + start: wrappedDragListenerStart, + drag: wrappedDragListenerDrag, + end: wrappedDragListenerEnd + } + ); + + this.richPointerDragListener = new RichPointerDragListener( richPointerDragListenerOptions ); + + //--------------------------------------------------------------------------------- + // Construct the RichKeyboardDragListener and combine its options. + //--------------------------------------------------------------------------------- + const wrappedKeyboardListenerStart = ( event: SceneryEvent, listener: RichKeyboardDragListener ) => { + + // when the drag listener starts, interrupt the pointer dragging + this.richPointerDragListener.interrupt(); + + options.start && options.start( event, listener ); + options.richKeyboardDragListenerOptions.start && options.richKeyboardDragListenerOptions.start( event, listener ); + }; + + const wrappedKeyboardListenerDrag = ( event: SceneryEvent, listener: RichKeyboardDragListener ) => { + options.drag && options.drag( event, listener ); + options.richKeyboardDragListenerOptions.drag && options.richKeyboardDragListenerOptions.drag( event, listener ); + }; + + const wrappedKeyboardListenerEnd = ( event: SceneryEvent | null, listener: RichKeyboardDragListener ) => { + options.end && options.end( event, listener ); + options.richKeyboardDragListenerOptions.end && options.richKeyboardDragListenerOptions.end( event, listener ); + }; + + const richKeyboardDragListenerOptions = combineOptions( + // target object + {}, + // Options that apply to both, but can be overridden by provided listener-specific options + sharedOptions, + // Provided listener-specific options + options.richKeyboardDragListenerOptions, + // Options that cannot be overridden - see wrapped callbacks above + { + start: wrappedKeyboardListenerStart, + drag: wrappedKeyboardListenerDrag, + end: wrappedKeyboardListenerEnd + } + ); + + this.richKeyboardDragListener = new RichKeyboardDragListener( richKeyboardDragListenerOptions ); + + // The hotkeys from the keyboard listener are assigned to this listener so that they are activated for Nodes + // where this listener is added. + this.hotkeys = this.richKeyboardDragListener.hotkeys; + } + + public get isPressed(): boolean { + return this.richPointerDragListener.isPressed || this.richKeyboardDragListener.isPressed; + } + + public dispose(): void { + this.richPointerDragListener.dispose(); + this.richKeyboardDragListener.dispose(); + } + + /** + * ******************************************************************** + * Forward input to both listeners + * ******************************************************************** + */ + public interrupt(): void { + this.richPointerDragListener.interrupt(); + this.richKeyboardDragListener.interrupt(); + } + + /** + * ******************************************************************** + * Forward to the KeyboardListener + * ******************************************************************** + */ + /** + * Forward the keydown event to the KeyboardDragListener. + */ + public keydown( event: SceneryEvent ): void { + this.richKeyboardDragListener.keydown( event ); + } + + public focusout( event: SceneryEvent ): void { + this.richKeyboardDragListener.focusout( event ); + } + + public focusin( event: SceneryEvent ): void { + this.richKeyboardDragListener.focusin( event ); + } + + public cancel(): void { + this.richKeyboardDragListener.cancel(); + } + + /** + * ******************************************************************** + * Forward to the DragListener + * ******************************************************************** + */ + public click( event: SceneryEvent ): void { + this.richPointerDragListener.click( event ); + } + + public touchenter( event: PressListenerEvent ): void { + this.richPointerDragListener.touchenter( event ); + } + + public touchmove( event: PressListenerEvent ): void { + this.richPointerDragListener.touchmove( event ); + } + + public focus( event: SceneryEvent ): void { + this.richPointerDragListener.focus( event ); + } + + public blur(): void { + this.richPointerDragListener.blur(); + } + + public down( event: PressListenerEvent ): void { + this.richPointerDragListener.down( event ); + } + + public up( event: PressListenerEvent ): void { + this.richPointerDragListener.up( event ); + } + + public enter( event: PressListenerEvent ): void { + this.richPointerDragListener.enter( event ); + } + + public move( event: PressListenerEvent ): void { + this.richPointerDragListener.move( event ); + } + + public exit( event: PressListenerEvent ): void { + this.richPointerDragListener.exit( event ); + } + + public pointerUp( event: PressListenerEvent ): void { + this.richPointerDragListener.pointerUp( event ); + } + + public pointerCancel( event: PressListenerEvent ): void { + this.richPointerDragListener.pointerCancel( event ); + } + + public pointerMove( event: PressListenerEvent ): void { + this.richPointerDragListener.pointerMove( event ); + } + + public pointerInterrupt(): void { + this.richPointerDragListener.pointerInterrupt(); + } +} + +sceneryPhet.register( 'RichDragListener', RichDragListener ); \ No newline at end of file diff --git a/js/RichKeyboardDragListener.ts b/js/RichKeyboardDragListener.ts index 0df4018e..c6fbb007 100644 --- a/js/RichKeyboardDragListener.ts +++ b/js/RichKeyboardDragListener.ts @@ -22,10 +22,7 @@ import grab_mp3 from '../../tambo/sounds/grab_mp3.js'; import release_mp3 from '../../tambo/sounds/release_mp3.js'; import soundManager, { SoundGeneratorAddOptions } from '../../tambo/js/soundManager.js'; import WrappedAudioBuffer from '../../tambo/js/WrappedAudioBuffer.js'; - -const DEFAULT_DRAG_CLIP_OPTIONS: SoundClipOptions = { - initialOutputLevel: 0.4 -}; +import SceneryPhetConstants from './SceneryPhetConstants.js'; type SelfOptions = { @@ -53,10 +50,10 @@ export default class RichKeyboardDragListener extends KeyboardDragListener { // SelfOptions grabSound: grab_mp3, releaseSound: release_mp3, - grabSoundClipOptions: DEFAULT_DRAG_CLIP_OPTIONS, - releaseSoundClipOptions: DEFAULT_DRAG_CLIP_OPTIONS, - grabSoundGeneratorAddOptions: {}, - releaseSoundGeneratorAddOptions: {} + grabSoundClipOptions: SceneryPhetConstants.DEFAULT_DRAG_CLIP_OPTIONS, + releaseSoundClipOptions: SceneryPhetConstants.DEFAULT_DRAG_CLIP_OPTIONS, + grabSoundGeneratorAddOptions: SceneryPhetConstants.DEFAULT_GRAB_SOUND_GENERATOR_ADD_OPTIONS, + releaseSoundGeneratorAddOptions: SceneryPhetConstants.DEFAULT_GRAB_SOUND_GENERATOR_ADD_OPTIONS }, providedOptions ); // Create the grab SoundClip and wire it into the start function for the drag cycle. diff --git a/js/RichPointerDragListener.ts b/js/RichPointerDragListener.ts index 6edeeade..db3212f1 100644 --- a/js/RichPointerDragListener.ts +++ b/js/RichPointerDragListener.ts @@ -22,11 +22,7 @@ import grab_mp3 from '../../tambo/sounds/grab_mp3.js'; import release_mp3 from '../../tambo/sounds/release_mp3.js'; import soundManager, { SoundGeneratorAddOptions } from '../../tambo/js/soundManager.js'; import WrappedAudioBuffer from '../../tambo/js/WrappedAudioBuffer.js'; - -const DEFAULT_DRAG_CLIP_OPTIONS: SoundClipOptions = { - initialOutputLevel: 0.4 -}; -const DEFAULT_ADD_SOUND_GENERATOR_OPTIONS: SoundGeneratorAddOptions = { categoryName: 'user-interface' }; +import SceneryPhetConstants from './SceneryPhetConstants.js'; type SelfOptions = { @@ -57,10 +53,10 @@ export default class RichPointerDragListener extends DragListener { // SelfOptions grabSound: grab_mp3, releaseSound: release_mp3, - grabSoundClipOptions: DEFAULT_DRAG_CLIP_OPTIONS, - releaseSoundClipOptions: DEFAULT_DRAG_CLIP_OPTIONS, - grabSoundGeneratorAddOptions: DEFAULT_ADD_SOUND_GENERATOR_OPTIONS, - releaseSoundGeneratorAddOptions: DEFAULT_ADD_SOUND_GENERATOR_OPTIONS + grabSoundClipOptions: SceneryPhetConstants.DEFAULT_DRAG_CLIP_OPTIONS, + releaseSoundClipOptions: SceneryPhetConstants.DEFAULT_DRAG_CLIP_OPTIONS, + grabSoundGeneratorAddOptions: SceneryPhetConstants.DEFAULT_GRAB_SOUND_GENERATOR_ADD_OPTIONS, + releaseSoundGeneratorAddOptions: SceneryPhetConstants.DEFAULT_GRAB_SOUND_GENERATOR_ADD_OPTIONS }, providedOptions ); // Create the grab SoundClip and wire it into the start function for the drag cycle. diff --git a/js/SceneryPhetConstants.ts b/js/SceneryPhetConstants.ts index f37fdfcd..e148d92e 100644 --- a/js/SceneryPhetConstants.ts +++ b/js/SceneryPhetConstants.ts @@ -5,15 +5,20 @@ * * @author Jesse Greenberg */ + import sceneryPhet from './sceneryPhet.js'; + const SceneryPhetConstants = { // default radius for various round buttons to ensure they are generally the same size DEFAULT_BUTTON_RADIUS: 20.8, // default radius for PlayControlButton and its subtypes - PLAY_CONTROL_BUTTON_RADIUS: 28 + PLAY_CONTROL_BUTTON_RADIUS: 28, + + DEFAULT_DRAG_CLIP_OPTIONS: { initialOutputLevel: 0.4 }, + DEFAULT_GRAB_SOUND_GENERATOR_ADD_OPTIONS: { categoryName: 'user-interface' } }; sceneryPhet.register( 'SceneryPhetConstants', SceneryPhetConstants ); export default SceneryPhetConstants; \ No newline at end of file diff --git a/js/demo/components/demoRichDragListeners.ts b/js/demo/components/demoRichDragListeners.ts index 65f7bd43..7ef06c05 100644 --- a/js/demo/components/demoRichDragListeners.ts +++ b/js/demo/components/demoRichDragListeners.ts @@ -1,42 +1,53 @@ // Copyright 2024, University of Colorado Boulder - /** * Demo for RichPointerDragListener and RichKeyboardDragListener * * @author Michael Kauzmann (PhET Interactive Simulations) * @author Agustín Vallejo (PhET Interactive Simulations) + * @author Jesse Greenberg (PhET Interactive Simulations) */ -import { Circle, Node, Rectangle, RichText } from '../../../../scenery/js/imports.js'; +import { Circle, Node, Path, Rectangle, RichText } from '../../../../scenery/js/imports.js'; import Bounds2 from '../../../../dot/js/Bounds2.js'; import RichPointerDragListener from '../../RichPointerDragListener.js'; import TinyProperty from '../../../../axon/js/TinyProperty.js'; import RichKeyboardDragListener from '../../RichKeyboardDragListener.js'; import PhetFont from '../../PhetFont.js'; +import { Shape } from '../../../../kite/js/imports.js'; +import RichDragListener from '../../RichDragListener.js'; export default function demoRichDragListeners( layoutBounds: Bounds2 ): Node { const RADIUS = 75; - const richDragListenerCircle = new Circle( RADIUS, { + const dragBoundsProperty = new TinyProperty( layoutBounds.eroded( RADIUS ) ); + + // A visualization of the drag bounds + const dragArea = new Rectangle( dragBoundsProperty.value, { + fill: 'lightGray' + } ); + + //--------------------------------------------------------------------------------- + // RichPointerDragListener + //--------------------------------------------------------------------------------- + const richPointerDragListenerCircle = new Circle( RADIUS, { fill: 'red', - cursor: 'pointer', - centerX: -RADIUS * 2 + cursor: 'pointer' } ); const innerCircleMessage = new RichText( 'Mouse-drag me!', { font: new PhetFont( 15 ), fill: 'white', - centerX: 0, - centerY: 0 + center: richPointerDragListenerCircle.center } ); - richDragListenerCircle.addChild( innerCircleMessage ); - const dragBoundsProperty = new TinyProperty( layoutBounds.shifted( layoutBounds.center.times( -1 ) ).eroded( RADIUS ) ); - - richDragListenerCircle.addInputListener( new RichPointerDragListener( { + richPointerDragListenerCircle.addChild( innerCircleMessage ); + richPointerDragListenerCircle.addInputListener( new RichPointerDragListener( { dragBoundsProperty: dragBoundsProperty, translateNode: true, - targetNode: richDragListenerCircle + targetNode: richPointerDragListenerCircle } ) ); + //--------------------------------------------------------------------------------- + // RichKeyboardDragListener + //--------------------------------------------------------------------------------- const richKeyboardDragListenerRectangle = new Rectangle( RADIUS * 2, -RADIUS / 2, RADIUS * 3, RADIUS, { fill: 'blue', tagName: 'div', @@ -45,11 +56,9 @@ export default function demoRichDragListeners( layoutBounds: Bounds2 ): Node { const innerRectangleMessage = new RichText( 'Tab and keyboard-drag me!', { font: new PhetFont( 15 ), fill: 'white', - centerX: richKeyboardDragListenerRectangle.centerX, - centerY: richKeyboardDragListenerRectangle.centerY + center: richKeyboardDragListenerRectangle.center } ); richKeyboardDragListenerRectangle.addChild( innerRectangleMessage ); - richKeyboardDragListenerRectangle.addInputListener( new RichKeyboardDragListener( { dragBoundsProperty: dragBoundsProperty, drag: ( event, listener ) => { @@ -57,11 +66,71 @@ export default function demoRichDragListeners( layoutBounds: Bounds2 ): Node { } } ) ); - return new Node( { + //--------------------------------------------------------------------------------- + // RichDragListener + //--------------------------------------------------------------------------------- + const richDragListenerEllipse = new Path( Shape.ellipse( 0, 0, RADIUS * 2, RADIUS, 0 ), { + fill: 'green', + + // so that it is focusable and can receive keyboard input + tagName: 'div', + focusable: true + } ); + const innerEllipseMessage = new RichText( 'Drag me with any input!', { + font: new PhetFont( 15 ), + fill: 'white', + center: richDragListenerEllipse.center + } ); + const richDragListenerStateText = new RichText( 'Ready to drag...', { + font: new PhetFont( 24 ) + } ); + + const updateStateText = ( newString: string ) => { + richDragListenerStateText.string = newString; + richDragListenerStateText.centerBottom = dragArea.centerBottom; + }; + + richDragListenerEllipse.addChild( innerEllipseMessage ); + richDragListenerEllipse.addInputListener( new RichDragListener( { + dragBoundsProperty: dragBoundsProperty, + translateNode: true, + richKeyboardDragListenerOptions: { + dragSpeed: RADIUS * 5 + }, + start: ( event, listener ) => { + if ( event.isFromPDOM() ) { + updateStateText( 'Keyboard drag started' ); + } + else { + updateStateText( 'Mouse drag started' ); + } + }, + end: ( event, listener ) => { + if ( event && event.isFromPDOM() ) { + updateStateText( 'Keyboard drag ended' ); + } + else { + updateStateText( 'Mouse drag ended' ); + } + } + } ) ); + + const content = new Node( { children: [ - richDragListenerCircle, - richKeyboardDragListenerRectangle - ], - center: layoutBounds.center + dragArea, + richPointerDragListenerCircle, + richKeyboardDragListenerRectangle, + richDragListenerEllipse, + richDragListenerStateText + ] } ); + + // initial positions + dragArea.center = layoutBounds.center; + richPointerDragListenerCircle.center = layoutBounds.center.plusXY( -RADIUS, -RADIUS ); + richKeyboardDragListenerRectangle.center = layoutBounds.center.plusXY( RADIUS, -RADIUS ); + richDragListenerEllipse.center = layoutBounds.center.plusXY( 0, RADIUS ); + updateStateText( 'Ready to drag...' ); + + return content; } \ No newline at end of file