diff --git a/fieldeditors/extension.ts b/fieldeditors/extension.ts index 8e854911..c687c6c5 100644 --- a/fieldeditors/extension.ts +++ b/fieldeditors/extension.ts @@ -7,6 +7,7 @@ import { FieldSpeed } from "./field_speed"; import { FieldBrickButtons } from "./field_brickbuttons"; import { FieldTurnRatio } from "./field_turnratio"; import { FieldColorEnum } from "./field_color"; +import { FieldMusic } from "./field_music"; pxt.editor.initFieldExtensionsAsync = function (opts: pxt.editor.FieldExtensionOptions): Promise { pxt.debug('loading pxt-ev3 target extensions...') @@ -30,6 +31,9 @@ pxt.editor.initFieldExtensionsAsync = function (opts: pxt.editor.FieldExtensionO }, { selector: "colorenum", editor: FieldColorEnum + }, { + selector: "music", + editor: FieldMusic }] }; return Promise.resolve(res); diff --git a/fieldeditors/field_motors.ts b/fieldeditors/field_motors.ts index 6c438531..5929f495 100644 --- a/fieldeditors/field_motors.ts +++ b/fieldeditors/field_motors.ts @@ -544,7 +544,7 @@ export class FieldMotors extends Blockly.FieldDropdown implements Blockly.FieldC /** * Callback for when the drop-down is hidden. */ - protected onHide_ = function () { + protected onHide_() { Blockly.DropDownDiv.content_.removeAttribute('role'); Blockly.DropDownDiv.content_.removeAttribute('aria-haspopup'); Blockly.DropDownDiv.content_.removeAttribute('aria-activedescendant'); diff --git a/fieldeditors/field_music.ts b/fieldeditors/field_music.ts new file mode 100644 index 00000000..96028db4 --- /dev/null +++ b/fieldeditors/field_music.ts @@ -0,0 +1,311 @@ +/// +/// +/// + +export interface FieldMusicOptions extends pxtblockly.FieldImagesOptions { + columns?: string; + width?: string; +} + +declare const pxtTargetBundle: any; + +let soundCache: any; + +export class FieldMusic extends pxtblockly.FieldImages implements Blockly.FieldCustom { + public isFieldCustom_ = true; + + private selectedCategory_: string; + + private categoriesCache_: string[]; + + private static MUSIC_DATA_URI = `data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnNDEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDIzIDIzIj48cGF0aCBpZD0ibWVudV9pY25fbXVzaWMiIGQ9Ik0xMy45IDEyLjhjMS43LjMgMy4zIDEuMiA0LjMgMi42aDFzMS41LTQuNC0xLjgtNy41LTkuNy0zLjEtMTIuMSAwYy0xLjcgMi4xLTIuMyA1LTEuNCA3LjVoLjhzMS43LTIuNSA0LjQtMi42QzkgMTcuMiA5IDIxIDkgMjFjLTEuOS0uNC0zLjUtMS42LTQuNC0zLjQtMi0uNC0zLjYtMi4yLTMuNi00LjRDMSA2LjcgNS45IDMgMTEuNSAzczEwLjggNC4zIDEwLjQgMTAuMmMtLjIgNC4xLTMuNiA0LjQtMy42IDQuNC0uOCAxLjgtMi40IDMuMS00LjMgMy40LS4xLTQuNS0uMS04LjItLjEtOC4yeiIgZmlsbD0iI2ZmZiIvPjwvc3ZnPg==`; + + constructor(text: string, options: FieldMusicOptions, validator?: Function) { + super(text, { sort: true, data: options.data }, validator); + + this.columns_ = parseInt(options.columns) || 4; + this.width_ = parseInt(options.width) || 380; + + this.setText = Blockly.FieldDropdown.prototype.setText; + this.updateWidth = (Blockly.Field as any).prototype.updateWidth; + this.updateTextNode_ = Blockly.Field.prototype.updateTextNode_; + + if (!pxt.BrowserUtils.isIE() && !soundCache) { + soundCache = JSON.parse(pxtTargetBundle.bundledpkgs['music']['sounds.jres']); + } + } + + /** + * Create a dropdown menu under the text. + * @private + */ + public showEditor_() { + // If there is an existing drop-down we own, this is a request to hide the drop-down. + if (Blockly.DropDownDiv.hideIfOwner(this)) { + return; + } + // If there is an existing drop-down someone else owns, hide it immediately and clear it. + Blockly.DropDownDiv.hideWithoutAnimation(); + Blockly.DropDownDiv.clearContent(); + // Populate the drop-down with the icons for this field. + let dropdownDiv = Blockly.DropDownDiv.getContentDiv(); + let contentDiv = document.createElement('div'); + // Accessibility properties + contentDiv.setAttribute('role', 'menu'); + contentDiv.setAttribute('aria-haspopup', 'true'); + contentDiv.className = 'blocklyMusicFieldOptions'; + const options = this.getOptions(); + options.sort(); + + // Create categoies + const categories = this.getCategories(options); + const selectedCategory = this.parseCategory(this.getText()); + this.selectedCategory_ = selectedCategory || categories[0]; + + let categoriesDiv = document.createElement('div'); + // Accessibility properties + categoriesDiv.setAttribute('role', 'menu'); + categoriesDiv.setAttribute('aria-haspopup', 'true'); + categoriesDiv.style.backgroundColor = this.sourceBlock_.getColourTertiary(); + categoriesDiv.className = 'blocklyMusicFieldCategories'; + + this.refreshCategories(categoriesDiv, categories); + + this.refreshOptions(contentDiv, options); + + contentDiv.style.width = (this as any).width_ + 'px'; + contentDiv.style.cssFloat = 'left'; + + dropdownDiv.style.maxHeight = `410px`; + dropdownDiv.appendChild(categoriesDiv); + dropdownDiv.appendChild(contentDiv); + + Blockly.DropDownDiv.setColour(this.sourceBlock_.getColour(), this.sourceBlock_.getColourTertiary()); + + // Calculate positioning based on the field position. + let scale = this.sourceBlock_.workspace.scale; + let bBox = { width: this.size_.width, height: this.size_.height }; + bBox.width *= scale; + bBox.height *= scale; + let position = this.fieldGroup_.getBoundingClientRect(); + let primaryX = position.left + bBox.width / 2; + let primaryY = position.top + bBox.height; + let secondaryX = primaryX; + let secondaryY = position.top; + // Set bounds to workspace; show the drop-down. + (Blockly.DropDownDiv as any).setBoundsElement(this.sourceBlock_.workspace.getParentSvg().parentNode); + (Blockly.DropDownDiv as any).show(this, primaryX, primaryY, secondaryX, secondaryY, + this.onHide_.bind(this)); + + // Update colour to look selected. + if (this.sourceBlock_.isShadow()) { + this.savedPrimary_ = this.sourceBlock_.getColour(); + this.sourceBlock_.setColour(this.sourceBlock_.getColourTertiary(), + this.sourceBlock_.getColourSecondary(), this.sourceBlock_.getColourTertiary()); + } else if (this.box_) { + this.box_.setAttribute('fill', this.sourceBlock_.getColourTertiary()); + } + } + + getCategories(options: any) { + if (this.categoriesCache_) return this.categoriesCache_; + let categoryMap = {}; + for (let i = 0, option: any; option = options[i]; i++) { + const content = (options[i] as any)[0]; // Human-readable text or image. + const category = this.parseCategory(content); + categoryMap[category] = true; + } + this.categoriesCache_ = Object.keys(categoryMap); + return this.categoriesCache_; + } + + refreshCategories(categoriesDiv: Element, categories: string[]) { + // Show category dropdown. + for (let i = 0; i < categories.length; i++) { + const category = categories[i]; + + let button = document.createElement('button'); + button.setAttribute('id', ':' + i); // For aria-activedescendant + button.setAttribute('role', 'menuitem'); + button.setAttribute('class', 'blocklyDropdownTag'); + button.setAttribute('data-value', category); + + let backgroundColor = '#1A9DBC'; + if (category == this.selectedCategory_) { + // This icon is selected, show it in a different colour + backgroundColor = '#0c4e5e'; + button.setAttribute('aria-selected', 'true'); + } + button.style.backgroundColor = backgroundColor; + button.style.borderColor = backgroundColor; + Blockly.bindEvent_(button, 'click', this, this.categoryClick_); + Blockly.bindEvent_(button, 'mouseup', this, this.categoryClick_); + + const textNode = this.createTextNode_(category); + textNode.setAttribute('data-value', category); + button.appendChild(textNode); + categoriesDiv.appendChild(button); + } + } + + refreshOptions(contentDiv: Element, options: any) { + + // Show options + for (let i = 0, option: any; option = options[i]; i++) { + let content = (options[i] as any)[0]; // Human-readable text or image. + const value = (options[i] as any)[1]; // Language-neutral value. + + // Filter for options in selected category + const category = this.parseCategory(content); + if (this.selectedCategory_ != category) continue; + + // Icons with the type property placeholder take up space but don't have any functionality + // Use for special-case layouts + if (content.type == 'placeholder') { + let placeholder = document.createElement('span'); + placeholder.setAttribute('class', 'blocklyDropDownPlaceholder'); + placeholder.style.width = content.width + 'px'; + placeholder.style.height = content.height + 'px'; + contentDiv.appendChild(placeholder); + continue; + } + let button = document.createElement('button'); + button.setAttribute('id', ':' + i); // For aria-activedescendant + button.setAttribute('role', 'menuitem'); + button.setAttribute('class', 'blocklyDropDownButton'); + button.title = content; + if ((this as any).columns_) { + button.style.width = (((this as any).width_ / (this as any).columns_) - 8) + 'px'; + //button.style.height = ((this.width_ / this.columns_) - 8) + 'px'; + } else { + button.style.width = content.width + 'px'; + button.style.height = content.height + 'px'; + } + let backgroundColor = this.savedPrimary_ || this.sourceBlock_.getColour(); + if (value == this.getValue()) { + // This icon is selected, show it in a different colour + backgroundColor = this.sourceBlock_.getColourTertiary(); + button.setAttribute('aria-selected', 'true'); + } + button.style.backgroundColor = backgroundColor; + button.style.borderColor = this.sourceBlock_.getColourTertiary(); + Blockly.bindEvent_(button, 'click', this, this.buttonClick_); + Blockly.bindEvent_(button, 'mouseup', this, this.buttonClick_); + // These are applied manually instead of using the :hover pseudoclass + // because Android has a bad long press "helper" menu and green highlight + // that we must prevent with ontouchstart preventDefault + let that = this; + Blockly.bindEvent_(button, 'mousedown', button, function (e) { + this.setAttribute('class', 'blocklyDropDownButton blocklyDropDownButtonHover'); + e.preventDefault(); + }); + Blockly.bindEvent_(button, 'mouseenter', button, function () { + that.buttonEnter_(value); + }); + Blockly.bindEvent_(button, 'mouseleave', button, function () { + that.buttonLeave_(); + }); + Blockly.bindEvent_(button, 'mouseover', button, function () { + this.setAttribute('class', 'blocklyDropDownButton blocklyDropDownButtonHover'); + contentDiv.setAttribute('aria-activedescendant', this.id); + }); + Blockly.bindEvent_(button, 'mouseout', button, function () { + this.setAttribute('class', 'blocklyDropDownButton'); + contentDiv.removeAttribute('aria-activedescendant'); + }); + let buttonImg = document.createElement('img'); + buttonImg.src = FieldMusic.MUSIC_DATA_URI; + //buttonImg.alt = icon.alt; + // Upon click/touch, we will be able to get the clicked element as e.target + // Store a data attribute on all possible click targets so we can match it to the icon. + const textNode = this.createTextNode_(content); + button.setAttribute('data-value', value); + buttonImg.setAttribute('data-value', value); + textNode.setAttribute('data-value', value); + + button.appendChild(buttonImg); + button.appendChild(textNode); + contentDiv.appendChild(button); + } + } + + trimOptions_() { + } + + protected onHide_() { + super.onHide_(); + Blockly.DropDownDiv.getContentDiv().style.maxHeight = ''; + this.stopSounds(); + } + + private createTextNode_(content: string) { + const category = this.parseCategory(content); + let text = content.substr(content.indexOf(' ') + 1); + text = text.length > 15 ? text.substr(0, 12) + "..." : text; + const textSpan = document.createElement('span'); + textSpan.setAttribute('class', 'blocklyDropdownText'); + textSpan.textContent = text; + return textSpan; + } + + private parseCategory(content: string) { + return content.substr(0, content.indexOf(' ')); + } + + protected buttonClick_ = function (e: any) { + let value = e.target.getAttribute('data-value'); + this.setValue(value); + Blockly.DropDownDiv.hide(); + }; + + private setSelectedCategory(value: string) { + this.selectedCategory_ = value; + } + + protected categoryClick_ = function (e: any) { + let value = e.target.getAttribute('data-value'); + this.setSelectedCategory(value); + + const options = this.getOptions(); + options.sort(); + const categories = this.getCategories(options); + + const dropdownDiv = Blockly.DropDownDiv.getContentDiv(); + const categoriesDiv = dropdownDiv.childNodes[0] as HTMLElement; + const contentDiv = dropdownDiv.childNodes[1] as HTMLDivElement; + categoriesDiv.innerHTML = ''; + contentDiv.innerHTML = ''; + + this.refreshCategories(categoriesDiv, categories); + this.refreshOptions(contentDiv, options); + + this.stopSounds(); + } + + /** + * Callback for when a button is hovered over inside the drop-down. + * Should be bound to the FieldIconMenu. + * @param {Event} e DOM event for the mouseover + * @private + */ + protected buttonEnter_ = function (value: any) { + if (soundCache) { + const jresValue = value.substring(value.lastIndexOf('.') + 1); + const buf = soundCache[jresValue]; + if (buf) { + const refBuf = { + data: pxt.U.stringToUint8Array(atob(buf)) + } + pxsim.AudioContextManager.playBufferAsync(refBuf as any); + } + } + }; + + protected buttonLeave_ = function () { + this.stopSounds(); + }; + + private stopSounds() { + pxsim.AudioContextManager.stop(); + } +} diff --git a/fieldeditors/field_music_icons/music.svg b/fieldeditors/field_music_icons/music.svg new file mode 100644 index 00000000..555e90d2 --- /dev/null +++ b/fieldeditors/field_music_icons/music.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/libs/music/sounds.ts b/libs/music/sounds.ts index 6d2f35a1..7d979f42 100644 --- a/libs/music/sounds.ts +++ b/libs/music/sounds.ts @@ -49,7 +49,7 @@ namespace sounds { export const communicationGameOver = music.fromWAV(hex``); //% fixedInstance jres block="communication go" export const communicationGo = music.fromWAV(hex``); - //% fixedInstance jres block="communicationGoodJob" + //% fixedInstance jres block="communication good job" export const communicationGoodJob = music.fromWAV(hex``); //% fixedInstance jres block="communication good" export const communicationGood = music.fromWAV(hex``); @@ -263,7 +263,7 @@ namespace music { * Play a sound and wait until it finishes * @param sound the sound to play */ - //% blockId=music_play_sound_effect_until_done block="play sound effect %sound|until done" + //% blockId=music_play_sound_effect_until_done block="play sound effect %sound=music_sound_picker|until done" //% weight=98 blockGap=8 //% help=music/play-sound-effect-until-done export function playSoundEffectUntilDone(sound: Sound) { @@ -278,6 +278,7 @@ namespace music { * @param sound the sound */ //% blockId=music_sound_picker block="%sound" shim=TD_ID + //% sound.fieldEditor="music" //% weight=0 blockHidden=1 export function __soundPicker(sound: Sound): Sound { return sound; @@ -287,7 +288,7 @@ namespace music { * Start playing a sound and don't wait for it to finish. * @param sound the sound to play */ - //% blockId=music_play_sound_effect block="play sound effect %sound" + //% blockId=music_play_sound_effect block="play sound effect %sound=music_sound_picker" //% weight=99 blockGap=8 //% help=music/play-sound-effect export function playSoundEffect(sound: Sound) { diff --git a/sim/state/sounds.ts b/sim/state/sounds.ts index 63232079..533b0509 100644 --- a/sim/state/sounds.ts +++ b/sim/state/sounds.ts @@ -21,12 +21,7 @@ namespace pxsim.SoundMethods { } export function stop() { - return new Promise(resolve => { - if (audio) { - audio.pause(); - } - resolve(); - }) + pxsim.AudioContextManager.stop(); } } diff --git a/theme/blockly.less b/theme/blockly.less index a0804463..896e5b72 100644 --- a/theme/blockly.less +++ b/theme/blockly.less @@ -30,3 +30,35 @@ .blocklyDropDownButton { border-radius: 0px !important; } + +/* Music field editor */ + +.blocklyDropdownText { + display: flex; + justify-content: center; + line-height: 1.5rem; + color: #fff; +} + +.blocklyMusicFieldCategories { + position: absolute; + text-align: center; + left: 0; + right: 0; + top: 0; + width: 100%; +} +.blocklyMusicFieldOptions { + margin-top: 80px; +} + +.blocklyDropdownTag { + padding: 2px; + font-weight: bold; + margin: 4px; + border-radius: 0; + outline: none; + border: 1px solid; + transition: box-shadow .1s; + cursor: pointer; +} \ No newline at end of file