diff --git a/js/artwork.js b/js/artwork.js index d43c2624ab..f164ccd9d8 100644 --- a/js/artwork.js +++ b/js/artwork.js @@ -27,7 +27,7 @@ PALETTEFILLCOLORS, PALETTESTROKECOLORS, PALETTEHIGHLIGHTCOLORS, HIGHLIGHTSTROKECOLORS, UPICON, DOWNICON, FADEDUPICON, FADEDDOWNICON, CLOSEICON, CARTESIANBUTTON, CARTESIANPOLARBUTTON, - EFFECTPALETTEICON, CLEARBUTTON, COLLAPSEBUTTON, EXPANDBUTTON, + EFFECTPALETTEICON, CLEARBUTTON, SPEAKBUTTON, COLLAPSEBUTTON, EXPANDBUTTON, NOGRIDBUTTON, PLAYBUTTON, POLARBUTTON, STEPBUTTON, RUNBUTTON, STEPMUSICBUTTON, STOPBUTTON, RECORDBUTTON, CARTESIAN, POLAR, TREBLE, SOPRANO, ALTO, TENOR, BASS, GRAND, GRAPHICSICONX, TRASHICON, PALETTEICONS, @@ -378,6 +378,9 @@ const ERASEBUTTON = const CLEARBUTTON = ERASEBUTTON; +const SPEAKBUTTON = + ''; + const EXTRASPALETTEICON = 'アセット 19'; diff --git a/js/piemenus.js b/js/piemenus.js index ef044bd36b..8d5e5b5bee 100644 --- a/js/piemenus.js +++ b/js/piemenus.js @@ -163,6 +163,135 @@ const enableWheelScroll = (wheel, itemCount) => { wheelDiv.addEventListener("wheel", scrollHandler, { passive: false }); }; +// Accessibility helpers for pie menus (screen reader announcements) +/** + * this function is to ensure that the pie menu live region is created and returned +*/ +const __ensurePieMenuLiveRegion = () => { + let liveRegion = docById("pieMenuLiveRegion"); + if (liveRegion) { + return liveRegion; + } + liveRegion = document.createElement("div"); + liveRegion.id = "pieMenuLiveRegion"; + liveRegion.setAttribute("role", "status"); + liveRegion.setAttribute("aria-live", "polite"); + liveRegion.setAttribute("aria-atomic", "true"); + liveRegion.style.position = "absolute"; + liveRegion.style.left = "-9999px"; + liveRegion.style.width = "1px"; + liveRegion.style.height = "1px"; + liveRegion.style.overflow = "hidden"; + document.body.appendChild(liveRegion); + return liveRegion; +}; + + +/** + * this function is to get the aria label from the title of the pie menu item +*/ +const __pieMenuA11yLabelFromTitle = title => { + if (title === null || title === undefined) { + return ""; + } + + const text = String(title).trim(); + if (text.length === 0) { + return ""; + } + + if (text === "×") { + return _("Close"); + } + if (text === "+") { + return _("Increase"); + } + if (text === "-") { + return _("Decrease"); + } + if (text === "▶") { + return _("Play"); + } + + if (text.startsWith("imgsrc:")) { + const raw = text.slice("imgsrc:".length); + const file = raw.split("/").pop() || raw; + const base = file.replace(/\.[^/.]+$/, ""); + return base.replace(/[-_]+/g, " ").trim(); + } + + return text; +}; + +const __announcePieMenuLabel = label => { + const trimmed = String(label || "").trim(); + if (!trimmed) { + return; + } + /** + * this logic is to prevent any duplicate announcements + * i.e if the same label is announced within 400ms, it will not be announced again + */ + const now = Date.now(); + if ( + window.__wheelnavA11yLastLabel === trimmed && + window.__wheelnavA11yLastTime && + now - window.__wheelnavA11yLastTime < 400 + ) { + return; + } + + window.__wheelnavA11yLastLabel = trimmed; + window.__wheelnavA11yLastTime = now; + __ensurePieMenuLiveRegion().textContent = trimmed; +}; + +/** + * this function is to speak the pie menu label to the screen reader +*/ +const __speakPieMenuLabel = label => { + const trimmed = String(label || "").trim(); + if (!trimmed) { + return; + } + //If the browser doesn’t support TTS, do nothing. + if (!("speechSynthesis" in window)) { + return; + } + //If the user toggled speech off, do nothing. + if (window.__wheelnavA11ySpeakEnabled === false) { + return; + } + + try { + window.speechSynthesis.cancel(); //Stop any current speech so the new label replaces it immediately. + const utterance = new SpeechSynthesisUtterance(trimmed); + utterance.rate = 1; + utterance.pitch = 1; + window.speechSynthesis.speak(utterance); + } catch (e) { + // Ignore speech synthesis errors + } +}; + +// Expose hooks for the wheelnav library to call +if (window.__wheelnavA11ySpeakEnabled === undefined) { + window.__wheelnavA11ySpeakEnabled = true; +} + +window.__wheelnavA11yLabelForItem = navItem => + __pieMenuA11yLabelFromTitle(navItem && navItem.title); + +// When called, it writes the label into the live region so screen readers announce it. +window.__wheelnavA11yAnnounce = navItem => { + const label = __pieMenuA11yLabelFromTitle(navItem && navItem.title); + __announcePieMenuLabel(label); +}; + +//When called (on click), it speaks the label using speechSynthesis +window.__wheelnavA11ySpeak = navItem => { + const label = __pieMenuA11yLabelFromTitle(navItem && navItem.title); + __speakPieMenuLabel(label); // Ensure exit wheels behave like stateless buttons (no sticky selection) const configureExitWheel = exitWheel => { if (!exitWheel || !exitWheel.navItems) { diff --git a/js/toolbar.js b/js/toolbar.js index f6ac4a2a06..af05445109 100644 --- a/js/toolbar.js +++ b/js/toolbar.js @@ -395,6 +395,20 @@ class Toolbar { const stopIcon = docById("stop"); const recordButton = docById("record"); let isPlayIconRunning = false; + const speakToolbarLabel = label => { + if (!label) return; + if (!("speechSynthesis" in window)) return; + if (window.__wheelnavA11ySpeakEnabled === false) return; + try { + window.speechSynthesis.cancel(); + const utterance = new SpeechSynthesisUtterance(label); + utterance.rate = 1; + utterance.pitch = 1; + window.speechSynthesis.speak(utterance); + } catch { + // Ignore speech synthesis errors + } + }; function handleClick() { if (!isPlayIconRunning) { @@ -419,6 +433,7 @@ class Toolbar { this.activity.hideMsgs(); }; isPlayIconRunning = false; + speakToolbarLabel(_("Play")); onclick(this.activity); handleClick(); stopIcon.style.color = this.stopIconColorWhenPlaying; @@ -449,6 +464,17 @@ class Toolbar { const stopIcon = docById("stop"); const recordButton = docById("record"); stopIcon.onclick = () => { + if ("speechSynthesis" in window && window.__wheelnavA11ySpeakEnabled !== false) { + try { + window.speechSynthesis.cancel(); + const utterance = new SpeechSynthesisUtterance(_("Stop")); + utterance.rate = 1; + utterance.pitch = 1; + window.speechSynthesis.speak(utterance); + } catch { + // Ignore speech synthesis errors + } + } onclick(this.activity); stopIcon.style.color = "white"; saveButton.disabled = false; diff --git a/js/turtles.js b/js/turtles.js index 628e4256f8..fef9bc24e5 100644 --- a/js/turtles.js +++ b/js/turtles.js @@ -22,8 +22,8 @@ setupPitchActions, setupIntervalsActions, setupToneActions, setupOrnamentActions, setupVolumeActions, setupDrumActions, setupDictActions, _, Turtle, TURTLESVG, METRONOMESVG, FILLCOLORS, STROKECOLORS, getMunsellColor, DEFAULTVALUE, DEFAULTCHROMA, - jQuery, docById, LEADING, CARTESIANBUTTON, piemenuGrid, CLEARBUTTON, COLLAPSEBUTTON, - EXPANDBUTTON, MBOUNDARY + jQuery, docById, LEADING, CARTESIANBUTTON, piemenuGrid, CLEARBUTTON, SPEAKBUTTON, + COLLAPSEBUTTON, EXPANDBUTTON, MBOUNDARY */ /* exported Turtles */ @@ -983,7 +983,7 @@ Turtles.TurtlesView = class { name: "Grid", label: _("Grid") }, - this._w - 10 - 3 * 55, + this._w - 10 - 4 * 55, 70 + LEADING + 6 ); const that = this; @@ -1000,7 +1000,7 @@ Turtles.TurtlesView = class { name: "Clear", label: _("Clear") }, - this._w - 5 - 2 * 55, + this._w - 5 - 3 * 55, 70 + LEADING + 6 ); @@ -1010,6 +1010,34 @@ Turtles.TurtlesView = class { }; }; + const __makeSpeakToggleButton = () => { + this._speakToggleButton = _makeButton( + SPEAKBUTTON, + { + name: "PieMenuSpeech", + label: _("Pie menu speech on") + }, + this._w - 5 - 2 * 55, + 70 + LEADING + 6 + ); + this._speakToggleButton.id = "PieMenuSpeech"; + + const updateSpeakButton = () => { + const enabled = window.__wheelnavA11ySpeakEnabled !== false; + this._speakToggleButton.style.opacity = enabled ? "1" : "0.4"; + const label = enabled ? _("Pie menu speech on") : _("Pie menu speech off"); + this._speakToggleButton.setAttribute("data-tooltip", label); + }; + + updateSpeakButton(); + + this._speakToggleButton.onclick = () => { + window.__wheelnavA11ySpeakEnabled = + window.__wheelnavA11ySpeakEnabled === false ? true : false; + updateSpeakButton(); + }; + }; + /** * Makes collapse button by initailising 'COLLAPSEBUTTON' SVG. * Assigns click listener function to call __collapse() method. @@ -1143,13 +1171,13 @@ Turtles.TurtlesView = class { this._clearButton.scaleX = 1; this._clearButton.scaleY = 1; this._clearButton.scale = 1; - this._clearButton.x = this._w - 5 - 2 * 55; + this._clearButton.x = this._w - 5 - 3 * 55; if (this.gridButton !== null) { this.gridButton.scaleX = 1; this.gridButton.scaleY = 1; this.gridButton.scale = 1; - this.gridButton.x = this._w - 10 - 3 * 55; + this.gridButton.x = this._w - 10 - 4 * 55; this.gridButton.visible = true; } @@ -1186,6 +1214,7 @@ Turtles.TurtlesView = class { cont.setAttribute("id", "buttoncontainerTOP"); __makeCollapseButton(); __makeExpandButton(); + __makeSpeakToggleButton(); __makeClearButton(); __makeGridButton(); jQuery diff --git a/lib/wheelnav.js b/lib/wheelnav.js index 946e74e019..e2b7e40e68 100644 --- a/lib/wheelnav.js +++ b/lib/wheelnav.js @@ -130,7 +130,8 @@ if(this.wheelnav.currentPercent<0.05){this.navTitleCurrentTransformString+=",s0. if(this.navTitleSizeTransform!==undefined){this.navTitleCurrentTransformString+=this.navTitleSizeTransform;} this.navSlice.attr({transform:this.navSliceCurrentTransformString});this.navLine.attr({transform:this.navLineCurrentTransformString});this.navTitle.attr({transform:this.navTitleCurrentTransformString});this.navItem=this.wheelnav.raphael.set();if(this.sliceClickablePathFunction!==null){var sliceClickablePath=this.getCurrentClickablePath();this.navClickableSlice=this.wheelnav.raphael.path(sliceClickablePath.slicePathString).attr(this.sliceClickablePathAttr).toBack();this.navClickableSlice.id=this.wheelnav.getClickableSliceId(this.wheelItemIndex);this.navClickableSlice.node.id=this.navClickableSlice.id;this.navItem.push(this.navSlice,this.navLine,this.navTitle,this.navClickableSlice);} else{this.navItem.push(this.navSlice,this.navLine,this.navTitle);} -this.setTooltip(this.tooltip);this.navItem.id=this.wheelnav.getItemId(this.wheelItemIndex);var thisWheelNav=this.wheelnav;var thisNavItem=this;var thisItemIndex=this.wheelItemIndex;if(this.enabled){this.navItem.mouseup(function(){thisWheelNav.navigateWheel(thisItemIndex);});this.navItem.mouseover(function(){if(thisNavItem.hovered!==true){thisNavItem.hoverEffect(thisItemIndex,true);}});this.navItem.mouseout(function(){thisNavItem.hovered=false;thisNavItem.hoverEffect(thisItemIndex,false);});} +this.setTooltip(this.tooltip);this.navItem.id=this.wheelnav.getItemId(this.wheelItemIndex);if(typeof window!=="undefined"&&window.__wheelnavA11yLabelForItem){var a11yLabel=window.__wheelnavA11yLabelForItem(this);if(a11yLabel){var a11yNodes=[this.navSlice,this.navTitle,this.navLine,this.navClickableSlice];for(var a=0;a