From ef577b88754523abeab3844115506a0b6e914874 Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Sat, 23 Jan 2021 21:13:01 -0500 Subject: Audio button menu (#1302) * Fix popup menus not stoping events * Ensure non-stale use of buttons * Enable popup menus on the popup/search pages * Add audio menu --- ext/mixed/css/display.css | 15 ++++ ext/mixed/display-templates.html | 14 ++- ext/mixed/js/display-audio.js | 185 +++++++++++++++++++++++++++++++++++++- ext/mixed/js/display-generator.js | 4 + ext/mixed/js/display.js | 23 ++++- ext/mixed/js/popup-menu.js | 4 + 6 files changed, 240 insertions(+), 5 deletions(-) (limited to 'ext/mixed') diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css index 003d0962..ce5cac6c 100644 --- a/ext/mixed/css/display.css +++ b/ext/mixed/css/display.css @@ -1620,6 +1620,21 @@ button.footer-notification-close-button:active { } +/* Audio menu */ +.audio-button-popup-menu[data-show-icons=false] .popup-menu-item-icon { + display: none; +} +.popup-menu-item-icon[data-icon=checkmark] { + background-color: var(--success-color); +} +.popup-menu-item-icon[data-icon=cross] { + background-color: var(--danger-color); +} +.popup-menu-item[data-source-in-options=false][data-valid=null] { + color: var(--text-color-light1); +} + + /* Conditional styles */ :root:not([data-enable-search-tags=true]) .tag[data-category=search] { display: none; diff --git a/ext/mixed/display-templates.html b/ext/mixed/display-templates.html index 40716469..2d363b7b 100644 --- a/ext/mixed/display-templates.html +++ b/ext/mixed/display-templates.html @@ -10,7 +10,7 @@ - +
@@ -44,7 +44,7 @@
- +
@@ -149,4 +149,14 @@
+ + + diff --git a/ext/mixed/js/display-audio.js b/ext/mixed/js/display-audio.js index e1a9e250..c60831b1 100644 --- a/ext/mixed/js/display-audio.js +++ b/ext/mixed/js/display-audio.js @@ -17,6 +17,7 @@ /* global * AudioSystem + * PopupMenu * api */ @@ -29,6 +30,7 @@ class DisplayAudio { this._autoPlayAudioDelay = 400; this._eventListeners = new EventListenerCollection(); this._cache = new Map(); + this._menuContainer = document.querySelector('#popup-menus'); } get autoPlayAudioDelay() { @@ -58,6 +60,8 @@ class DisplayAudio { for (const button of entry.querySelectorAll('.action-play-audio')) { const expressionIndex = this._getAudioPlayButtonExpressionIndex(button); this._eventListeners.addEventListener(button, 'click', this._onAudioPlayButtonClick.bind(this, definitionIndex, expressionIndex), false); + this._eventListeners.addEventListener(button, 'contextmenu', this._onAudioPlayButtonContextMenu.bind(this, definitionIndex, expressionIndex), false); + this._eventListeners.addEventListener(button, 'menuClose', this._onAudioPlayMenuCloseClick.bind(this, definitionIndex, expressionIndex), false); } } @@ -104,6 +108,8 @@ class DisplayAudio { const expressionReading = this._getExpressionAndReading(definitionIndex, expressionIndex); if (expressionReading === null) { return; } + const buttons = this._getAudioPlayButtons(definitionIndex, expressionIndex); + const {expression, reading} = expressionReading; const audioOptions = this._getAudioOptions(); const {textToSpeechVoice, customSourceUrl, volume} = audioOptions; @@ -136,7 +142,7 @@ class DisplayAudio { // Update details const potentialAvailableAudioCount = this._getPotentialAvailableAudioCount(expression, reading); - for (const button of this._getAudioPlayButtons(definitionIndex, expressionIndex)) { + for (const button of buttons) { const titleDefault = button.dataset.titleDefault || ''; button.title = `${titleDefault}\n${title}`; this._updateAudioPlayButtonBadge(button, potentialAvailableAudioCount); @@ -165,7 +171,37 @@ class DisplayAudio { _onAudioPlayButtonClick(definitionIndex, expressionIndex, e) { e.preventDefault(); - this.playAudio(definitionIndex, expressionIndex); + + if (e.shiftKey) { + this._showAudioMenu(e.currentTarget, definitionIndex, expressionIndex); + } else { + this.playAudio(definitionIndex, expressionIndex); + } + } + + _onAudioPlayButtonContextMenu(definitionIndex, expressionIndex, e) { + e.preventDefault(); + + this._showAudioMenu(e.currentTarget, definitionIndex, expressionIndex); + } + + _onAudioPlayMenuCloseClick(definitionIndex, expressionIndex, e) { + const {detail: {action, item}} = e; + switch (action) { + case 'playAudioFromSource': + { + const {source, index} = item.dataset; + let sourceDetailsMap = null; + if (typeof index !== 'undefined') { + const index2 = Number.parseInt(index, 10); + sourceDetailsMap = new Map([ + [source, {start: index2, end: index2 + 1}] + ]); + } + this.playAudio(definitionIndex, expressionIndex, [source], sourceDetailsMap); + } + break; + } } _getAudioPlayButtonExpressionIndex(button) { @@ -360,4 +396,149 @@ class DisplayAudio { } return count; } + + _showAudioMenu(button, definitionIndex, expressionIndex) { + const expressionReading = this._getExpressionAndReading(definitionIndex, expressionIndex); + if (expressionReading === null) { return; } + + const {expression, reading} = expressionReading; + const popupMenu = this._createMenu(button, expression, reading); + popupMenu.prepare(); + } + + _createMenu(button, expression, reading) { + // Options + const {sources, textToSpeechVoice, customSourceUrl} = this._getAudioOptions(); + const sourceIndexMap = new Map(); + for (let i = 0, ii = sources.length; i < ii; ++i) { + sourceIndexMap.set(sources[i], i); + } + + // Create menu + const menuNode = this._display.displayGenerator.createPopupMenu('audio-button'); + + // Create menu item metadata + const menuItems = []; + const menuItemNodes = menuNode.querySelectorAll('.popup-menu-item'); + for (let i = 0, ii = menuItemNodes.length; i < ii; ++i) { + const node = menuItemNodes[i]; + const {source} = node.dataset; + let optionsIndex = sourceIndexMap.get(source); + if (typeof optionsIndex === 'undefined') { optionsIndex = null; } + menuItems.push({node, source, index: i, optionsIndex}); + } + + // Sort according to source order in options + menuItems.sort((a, b) => { + const ai = a.optionsIndex; + const bi = b.optionsIndex; + if (ai !== null) { + if (bi !== null) { + const i = ai - bi; + if (i !== 0) { return i; } + } else { + return -1; + } + } else { + if (bi !== null) { + return 1; + } + } + return a.index - b.index; + }); + + // Set up items based on cache data + const sourceMap = this._cache.get(this._getExpressionReadingKey(expression, reading)); + const menuEntryMap = new Map(); + let showIcons = false; + for (let i = 0, ii = menuItems.length; i < ii; ++i) { + const {node, source, optionsIndex} = menuItems[i]; + const entries = this._getMenuItemEntries(node, sourceMap, source); + menuEntryMap.set(source, entries); + for (const {node: node2, valid, index} of entries) { + if (valid !== null) { + const icon = node2.querySelector('.popup-menu-item-icon'); + icon.dataset.icon = valid ? 'checkmark' : 'cross'; + showIcons = true; + } + if (index !== null) { + node2.dataset.index = `${index}`; + } + node2.dataset.valid = `${valid}`; + node2.dataset.sourceInOptions = `${optionsIndex !== null}`; + node2.style.order = `${i}`; + } + } + menuNode.dataset.showIcons = `${showIcons}`; + + // Hide options + if (textToSpeechVoice.length === 0) { + this._setMenuItemEntriesHidden(menuEntryMap, 'text-to-speech', true); + this._setMenuItemEntriesHidden(menuEntryMap, 'text-to-speech-reading', true); + } + if (customSourceUrl.length === 0) { + this._setMenuItemEntriesHidden(menuEntryMap, 'custom', true); + } + + // Create popup menu + this._menuContainer.appendChild(menuNode); + return new PopupMenu(button, menuNode); + } + + _getMenuItemEntries(node, sourceMap, source) { + const entries = [{node, valid: null, index: null}]; + + const nextNode = node.nextSibling; + + if (typeof sourceMap === 'undefined') { return entries; } + + const sourceInfo = sourceMap.get(source); + if (typeof sourceInfo === 'undefined') { return entries; } + + const {infoList} = sourceInfo; + if (infoList === null) { return entries; } + + if (infoList.length === 0) { + entries[0].valid = false; + return entries; + } + + const defaultLabel = node.querySelector('.popup-menu-item-label').textContent; + + for (let i = 0, ii = infoList.length; i < ii; ++i) { + // Get/create entry + let entry; + if (i < entries.length) { + entry = entries[i]; + } else { + const node2 = node.cloneNode(true); + nextNode.parentNode.insertBefore(node2, nextNode); + entry = {node: node2, valid: null, index: null}; + entries.push(entry); + } + + // Entry info + entry.index = i; + + const {audio, audioResolved, title} = infoList[i]; + if (audioResolved) { entry.valid = (audio !== null); } + + const labelNode = entry.node.querySelector('.popup-menu-item-label'); + let label = defaultLabel; + if (ii > 1) { label = `${label} ${i + 1}`; } + if (typeof title === 'string' && title.length > 0) { label += `: ${title}`; } + labelNode.textContent = label; + } + + return entries; + } + + _setMenuItemEntriesHidden(menuEntryMap, source, hidden) { + const entries = menuEntryMap.get(source); + if (typeof entries === 'undefined') { return; } + + for (const {node} of entries) { + node.hidden = hidden; + } + } } diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js index 0324f16a..e9eaa68f 100644 --- a/ext/mixed/js/display-generator.js +++ b/ext/mixed/js/display-generator.js @@ -175,6 +175,10 @@ class DisplayGenerator { return this._templates.instantiate('profile-list-item'); } + createPopupMenu(name) { + return this._templates.instantiate(`${name}-popup-menu`); + } + // Private _createTermExpression(details) { diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index 6af35074..eb8b2900 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -27,6 +27,7 @@ * HotkeyHelpController * MediaLoader * PopupFactory + * PopupMenu * QueryParser * TextScanner * WindowScroll @@ -113,7 +114,7 @@ class Display extends EventDispatcher { this._displayAudio = new DisplayAudio(this); this._hotkeyHandler.registerActions([ - ['close', () => { this.close(); }], + ['close', () => { this._onHotkeyClose(); }], ['nextEntry', () => { this._focusEntry(this._index + 1, true); }], ['nextEntry3', () => { this._focusEntry(this._index + 3, true); }], ['previousEntry', () => { this._focusEntry(this._index - 1, true); }], @@ -517,6 +518,7 @@ class Display extends EventDispatcher { try { // Clear this._closePopups(); + this._closeAllPopupMenus(); this._eventListeners.removeAllEventListeners(); this._mediaLoader.unloadAll(); this._displayAudio.cleanupEntries(); @@ -1806,4 +1808,23 @@ class Display extends EventDispatcher { }); }); } + + _onHotkeyClose() { + if (this._closeSinglePopupMenu()) { return; } + this.close(); + } + + _closeAllPopupMenus() { + for (const popupMenu of PopupMenu.openMenus) { + popupMenu.close(); + } + } + + _closeSinglePopupMenu() { + for (const popupMenu of PopupMenu.openMenus) { + popupMenu.close(); + return true; + } + return false; + } } diff --git a/ext/mixed/js/popup-menu.js b/ext/mixed/js/popup-menu.js index 124c1984..9ad4e260 100644 --- a/ext/mixed/js/popup-menu.js +++ b/ext/mixed/js/popup-menu.js @@ -76,12 +76,16 @@ class PopupMenu extends EventDispatcher { _onMenuContainerClick(e) { if (e.currentTarget !== e.target) { return; } + e.stopPropagation(); + e.preventDefault(); this._close(null, 'outside', true); } _onMenuItemClick(e) { const item = e.currentTarget; if (item.disabled) { return; } + e.stopPropagation(); + e.preventDefault(); this._close(item, 'item', true); } -- cgit v1.2.3