-
Notifications
You must be signed in to change notification settings - Fork 6
/
Copy pathVoicingToolbarItem.ts
260 lines (208 loc) · 11.1 KB
/
VoicingToolbarItem.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
// Copyright 2021-2022, University of Colorado Boulder
/**
* An item for the Toolbar that includes components related to the voicing feature. Includes a switch to
* enable/disable all speech, and buttons to hear overview information about the active sim Screen.
*
* @author Jesse Greenberg (PhET Interactive Simulations)
*/
import BooleanProperty from '../../../axon/js/BooleanProperty.js';
import PlayStopButton from '../../../scenery-phet/js/buttons/PlayStopButton.js';
import PhetFont from '../../../scenery-phet/js/PhetFont.js';
import { AlignGroup, Display, HBox, Node, NodeOptions, ReadingBlockHighlight, SceneryEvent, Text, voicingManager, VoicingText, voicingUtteranceQueue } from '../../../scenery/js/imports.js';
import Tandem from '../../../tandem/js/Tandem.js';
import Utterance from '../../../utterance-queue/js/Utterance.js';
import joist from '../joist.js';
import joistStrings from '../joistStrings.js';
import PreferencesToggleSwitch from '../preferences/PreferencesToggleSwitch.js';
import VoicingToolbarAlertManager from './VoicingToolbarAlertManager.js';
import LookAndFeel from '../LookAndFeel.js';
import optionize, { EmptySelfOptions } from '../../../phet-core/js/optionize.js';
import PickRequired from '../../../phet-core/js/types/PickRequired.js';
// constants
const CONTENT_VERTICAL_SPACING = 10;
const QUICK_INFO = 20;
// strings
const titleString = joistStrings.a11y.toolbar.voicing.title;
const quickInfoString = joistStrings.a11y.toolbar.voicing.quickInfo;
const simVoicingOnString = joistStrings.a11y.toolbar.voicing.simVoicingOnAlert;
const simVoicingOffString = joistStrings.a11y.toolbar.voicing.simVoicingOffAlert;
const toolbarString = joistStrings.a11y.toolbar.title;
const playOverviewString = joistStrings.a11y.toolbar.voicing.playOverviewLabel;
const playDetailsString = joistStrings.a11y.toolbar.voicing.playDetailsLabel;
const playHintString = joistStrings.a11y.toolbar.voicing.playHintLabel;
const overviewString = joistStrings.a11y.toolbar.voicing.overviewLabel;
const detailsString = joistStrings.a11y.toolbar.voicing.detailsLabel;
const hintString = joistStrings.a11y.toolbar.voicing.hintLabel;
type SelfOptions = EmptySelfOptions;
export type VoicingToolbarItemOptions = SelfOptions & NodeOptions & PickRequired<NodeOptions, 'tandem'>;
class VoicingToolbarItem extends Node {
public constructor( alertManager: VoicingToolbarAlertManager, lookAndFeel: LookAndFeel, providedOptions?: VoicingToolbarItemOptions ) {
const options = optionize<VoicingToolbarItemOptions, SelfOptions, NodeOptions>()( {
// pdom
tagName: 'section',
labelTagName: 'h2',
labelContent: toolbarString,
// phet-io
tandem: Tandem.REQUIRED,
visiblePropertyOptions: {
phetioReadOnly: true
}
}, providedOptions );
super( options );
const titleTextOptions = {
font: new PhetFont( 14 ),
fill: lookAndFeel.navigationBarTextFillProperty,
maxWidth: 90 // i18n, by inspection
};
const titleText = new Text( titleString, titleTextOptions );
const quickInfoText = new VoicingText( quickInfoString, titleTextOptions );
quickInfoText.focusHighlight = new ReadingBlockHighlight( quickInfoText, {
// the inner stroke is white since the toolbar is on a black background
innerStroke: 'white'
} );
const muteSpeechSwitch = new PreferencesToggleSwitch( voicingManager.mainWindowVoicingEnabledProperty, false, true, {
labelNode: titleText,
a11yLabel: titleString,
rightValueContextResponse: simVoicingOnString,
leftValueContextResponse: simVoicingOffString,
tandem: options.tandem.createTandem( 'muteSpeechSwitch' )
} );
// layout
const labelAlignGroup = new AlignGroup();
const inputAlignGroup = new AlignGroup();
const overviewRow = new LabelButtonRow( overviewString, playOverviewString, labelAlignGroup, inputAlignGroup, lookAndFeel, alertManager.createOverviewContent.bind( alertManager ) );
const detailsRow = new LabelButtonRow( detailsString, playDetailsString, labelAlignGroup, inputAlignGroup, lookAndFeel, alertManager.createDetailsContent.bind( alertManager ) );
const hintRow = new LabelButtonRow( hintString, playHintString, labelAlignGroup, inputAlignGroup, lookAndFeel, alertManager.createHintContent.bind( alertManager ) );
this.children = [ muteSpeechSwitch, quickInfoText, overviewRow.content, detailsRow.content, hintRow.content ];
// layout
quickInfoText.leftTop = muteSpeechSwitch.leftBottom.plusXY( 0, CONTENT_VERTICAL_SPACING );
overviewRow.content.leftTop = quickInfoText.leftBottom.plusXY( QUICK_INFO, CONTENT_VERTICAL_SPACING );
detailsRow.content.leftTop = overviewRow.content.leftBottom.plusXY( 0, CONTENT_VERTICAL_SPACING );
hintRow.content.leftTop = detailsRow.content.leftBottom.plusXY( 0, CONTENT_VERTICAL_SPACING );
const rows = [ overviewRow, detailsRow, hintRow ];
const playingProperties = [ overviewRow.playingProperty, detailsRow.playingProperty, hintRow.playingProperty ];
rows.forEach( row => {
row.playingProperty.link( playing => {
row.playContent( playingProperties );
} );
} );
}
/**
*/
public override dispose(): void {
super.dispose();
}
}
/**
* An inner class that manages a labelled PlayStopButton in the VoicingToolbarItem. Creates the label, button,
* and adds listeners that generate the alert to be Voiced and toggle the button's playing state when
* the voicingManager stops speaking.
*/
class LabelButtonRow {
private readonly lookAndFeel: LookAndFeel;
// A unique Utterance for the object response so that it can be independently cancelled and have a dynamic Priority
// depending on interaction with the screen.
private readonly objectResponseUtterance: Utterance;
private readonly createAlert: () => string;
private readonly playStopButton: PlayStopButton;
// Whether the PlayStopButton has been pressed and the voicingManager is actively speaking this content
public readonly playingProperty: BooleanProperty;
// The Node of content to be displayed in the view, managing layout of the label and button
public readonly content: Node;
/**
* @param labelString - the visually rendered Text label for the button
* @param a11yLabel - the string read in the PDOM and with the Voicing feature that labels this Button
* @param labelAlignGroup - To align all labels in the VoicingToolbarItem
* @param inputAlignGroup - To align all inputs in the VoicingToolbarItem
* @param lookAndFeel
* @param createAlert - function that creates the alert when the button is pressed
*/
public constructor( labelString: string, a11yLabel: string, labelAlignGroup: AlignGroup, inputAlignGroup: AlignGroup, lookAndFeel: LookAndFeel, createAlert: () => string ) {
this.lookAndFeel = lookAndFeel;
this.objectResponseUtterance = new Utterance();
this.createAlert = createAlert;
this.playingProperty = new BooleanProperty( false, {
// Speech is requested from a listener on the isPlayingProperty. But if the browser cannot speak it may
// immediately cancel speech and set this Property to false again causing reentrancy.
reentrant: true
} );
this.playStopButton = new PlayStopButton( this.playingProperty, {
startPlayingLabel: a11yLabel,
// voicing
voicingNameResponse: a11yLabel,
voicingIgnoreVoicingManagerProperties: true,
radius: 12,
// phet-io
tandem: Tandem.OPT_OUT
} );
const textLabel = new Text( labelString, {
font: new PhetFont( 12 ),
fill: this.lookAndFeel.navigationBarTextFillProperty,
maxWidth: 100 // i18n, by inspection
} );
const labelBox = labelAlignGroup.createBox( textLabel, { xAlign: 'left' } );
const inputBox = inputAlignGroup.createBox( this.playStopButton, { xAlign: 'right' } );
this.content = new HBox( { children: [ labelBox, inputBox ], spacing: CONTENT_VERTICAL_SPACING } );
voicingManager.endSpeakingEmitter.addListener( ( text, endedUtterance ) => {
if ( endedUtterance === this.objectResponseUtterance ) {
this.playingProperty.set( false );
// clear the voicingUtteranceQueue because stale alerts may have collected while the quick info button announced
voicingUtteranceQueue.clear();
// Remove if listener wasn't interrupted by Display input.
if ( Display.inputListeners.includes( displayListener ) ) {
Display.removeInputListener( displayListener );
}
}
} );
// Reduces the priority of this.utterance as soon as there is an interaction with the screen so that it may
// be interrupted by simulation responses. See https://github.com/phetsims/joist/issues/752.
const reducePriorityListener = ( event: SceneryEvent ) => {
// After a down event it will be possible for this.utterance to be interrupted. That will turn the "Stop" button
// into a "Play" button. If the mouse is still down in this case the next up event on the "Play" button will
// immediately play its content which is unexpected. We get around this by not reducing priority if the event
// is going to this button.
if ( !event.trail.nodes.includes( this.playStopButton ) ) {
Display.removeInputListener( displayListener );
// Wait until the listener is removed before reducing this, this may immediately end the Utterance and remove
// the listener again in the endSpeakingListener above.
this.objectResponseUtterance.priorityProperty.value = Utterance.LOW_PRIORITY;
}
};
const displayListener = {
// The events that indicate some kind of input so we should allow this.utterance to be interrupted.
down: reducePriorityListener,
focus: reducePriorityListener
};
voicingManager.startSpeakingEmitter.addListener( ( response, utterance ) => {
if ( utterance === this.objectResponseUtterance ) {
Display.addInputListener( displayListener );
}
} );
}
/**
* Play the Voicing content for this Row.
*/
public playContent( playingProperties: BooleanProperty[] ): void {
if ( this.playingProperty.value ) {
// when one button is pressed, immediately stop any other buttons, only one should be playing at a time
const otherProperties = _.without( playingProperties, this.playingProperty );
otherProperties.forEach( property => {
property.value = false;
} );
// This utterance is top priority so that it does not get interrupted during responses that happen as
// the simulation changes. It stays top priority until there is some interaction with the display.
this.objectResponseUtterance.priorityProperty.value = Utterance.TOP_PRIORITY;
this.playStopButton.voicingSpeakResponse( {
objectResponse: this.createAlert(),
// A sepparate Utterance from the default voicingUtterance so that if default responses are cancelled we
// don't also cancel this content in the listeners related to priorityProperty above.
utterance: this.objectResponseUtterance
} );
}
else {
voicingUtteranceQueue.cancelUtterance( this.objectResponseUtterance );
}
}
}
joist.register( 'VoicingToolbarItem', VoicingToolbarItem );
export default VoicingToolbarItem;