js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
168 lines (167 loc) • 7.17 kB
JavaScript
import { LineSegment2, Color4 } from '@js-draw/math';
import BaseTool from './BaseTool.mjs';
class SoundFeedback {
constructor() {
this.closed = false;
// No AudioContext? Exit!
if (!window.AudioContext) {
console.warn('Accessibility sound UI: Unable to open AudioContext.');
this.closed = true;
return;
}
this.ctx = new AudioContext();
// Color oscillator and gain
this.colorOscHue = this.ctx.createOscillator();
this.colorOscValue = this.ctx.createOscillator();
this.colorOscSaturation = this.ctx.createOscillator();
this.colorOscHue.type = 'triangle';
this.colorOscSaturation.type = 'sine';
this.colorOscValue.type = 'sawtooth';
this.valueGain = this.ctx.createGain();
this.colorOscValue.connect(this.valueGain);
this.valueGain.gain.setValueAtTime(0.18, this.ctx.currentTime);
this.colorGain = this.ctx.createGain();
this.colorOscHue.connect(this.colorGain);
this.valueGain.connect(this.colorGain);
this.colorOscSaturation.connect(this.colorGain);
this.colorGain.connect(this.ctx.destination);
// Boundary oscillator and gain
this.boundaryGain = this.ctx.createGain();
this.boundaryOsc = this.ctx.createOscillator();
this.boundaryOsc.type = 'sawtooth';
this.boundaryGain.gain.setValueAtTime(0, this.ctx.currentTime);
this.boundaryOsc.connect(this.boundaryGain);
this.boundaryGain.connect(this.ctx.destination);
// Prepare for the first announcement/feedback.
this.colorOscHue.start();
this.colorOscSaturation.start();
this.colorOscValue.start();
this.boundaryOsc.start();
this.pause();
}
pause() {
if (this.closed)
return;
this.colorGain.gain.setValueAtTime(0, this.ctx.currentTime);
void this.ctx.suspend();
}
play() {
if (this.closed)
return;
void this.ctx.resume();
}
setColor(color) {
const hsv = color.asHSV();
// Choose frequencies that roughly correspond to hue, saturation, and value.
const hueFrequency = -Math.cos(hsv.x / 2) * 220 + 440;
const saturationFrequency = hsv.y * 440 + 220;
const valueFrequency = (hsv.z + 0.1) * 440;
// Sigmoid with maximum 0.25 * alpha.
// Louder for greater value.
const gain = (0.25 * Math.min(1, color.a)) / (1 + Math.exp(-(hsv.z - 0.5) * 3));
this.colorOscHue.frequency.setValueAtTime(hueFrequency, this.ctx.currentTime);
this.colorOscSaturation.frequency.setValueAtTime(saturationFrequency, this.ctx.currentTime);
this.colorOscValue.frequency.setValueAtTime(valueFrequency, this.ctx.currentTime);
this.valueGain.gain.setValueAtTime((1 - hsv.z) * 0.4, this.ctx.currentTime);
this.colorGain.gain.setValueAtTime(gain, this.ctx.currentTime);
}
announceBoundaryCross(boundaryCrossCount) {
this.boundaryGain.gain.cancelScheduledValues(this.ctx.currentTime);
this.boundaryGain.gain.setValueAtTime(0, this.ctx.currentTime);
this.boundaryGain.gain.linearRampToValueAtTime(0.018, this.ctx.currentTime + 0.1);
this.boundaryOsc.frequency.setValueAtTime(440 + Math.atan(boundaryCrossCount / 2) * 100, this.ctx.currentTime);
this.boundaryGain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + 0.25);
}
close() {
void this.ctx.close();
this.closed = true;
}
}
/**
* This tool, when enabled, plays a sound representing the color of the portion of the display
* currently under the cursor. This tool adds a button that can be navigated to with the tab key
* that enables/disables the tool.
*
* This allows the user to explore the content of the display without a working screen.
*/
export default class SoundUITool extends BaseTool {
constructor(editor, description) {
super(editor.notifier, description);
this.editor = editor;
this.soundFeedback = null;
// Create a screen-reader-usable method of toggling the tool:
this.toggleButtonContainer = document.createElement('div');
this.toggleButtonContainer.classList.add('js-draw-sound-ui-toggle');
this.toggleButton = document.createElement('button');
this.toggleButton.onclick = () => {
this.setEnabled(!this.isEnabled());
};
this.toggleButtonContainer.appendChild(this.toggleButton);
this.updateToggleButtonText();
editor.createHTMLOverlay(this.toggleButtonContainer);
}
canReceiveInputInReadOnlyEditor() {
return true;
}
updateToggleButtonText() {
const containerEnabledClass = 'sound-ui-tool-enabled';
if (this.isEnabled()) {
this.toggleButton.innerText = this.editor.localization.disableAccessibilityExploreTool;
this.toggleButtonContainer.classList.add(containerEnabledClass);
}
else {
this.toggleButton.innerText = this.editor.localization.enableAccessibilityExploreTool;
this.toggleButtonContainer.classList.remove(containerEnabledClass);
}
}
setEnabled(enabled) {
super.setEnabled(enabled);
if (!this.isEnabled()) {
this.soundFeedback?.close();
this.soundFeedback = null;
}
else {
this.editor.announceForAccessibility(this.editor.localization.soundExplorerUsageAnnouncement);
}
this.updateToggleButtonText();
}
onKeyPress(event) {
if (event.code === 'Escape') {
this.setEnabled(false);
return true;
}
return false;
}
onPointerDown({ current, allPointers }) {
if (!this.soundFeedback) {
this.soundFeedback = new SoundFeedback();
}
// Allow two-finger gestures to move the screen.
if (allPointers.length >= 2) {
return false;
}
// Accept multiple cursors -- some screen readers require multiple (touch) pointers to interact with
// an image instead of using the built-in navigation features.
this.soundFeedback?.play();
this.soundFeedback?.setColor(this.editor.display.getColorAt(current.screenPos) ?? Color4.black);
this.lastPointerPos = current.canvasPos;
return true;
}
onPointerMove({ current }) {
this.soundFeedback?.setColor(this.editor.display.getColorAt(current.screenPos) ?? Color4.black);
const pointerMotionLine = new LineSegment2(this.lastPointerPos, current.canvasPos);
const collisions = this.editor.image
.getComponentsIntersecting(pointerMotionLine.bbox)
.filter((component) => component.intersects(pointerMotionLine));
this.lastPointerPos = current.canvasPos;
if (collisions.length > 0) {
this.soundFeedback?.announceBoundaryCross(collisions.length);
}
}
onPointerUp(_event) {
this.soundFeedback?.pause();
}
onGestureCancel() {
this.soundFeedback?.pause();
}
}