/// /// /// export interface FieldMusicOptions extends pxtblockly.FieldImagesOptions { columns?: string; width?: string; } declare const pxtTargetBundle: any; let soundCache: any; let soundIconCache: any; export class FieldMusic extends pxtblockly.FieldImages implements Blockly.FieldCustom { public isFieldCustom_ = true; private selectedCategory_: string; private categoriesCache_: string[]; constructor(text: string, options: FieldMusicOptions, validator?: Function) { super(text, { blocksInfo: options.blocksInfo, 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']); } if (!soundIconCache) { soundIconCache = JSON.parse(pxtTargetBundle.bundledpkgs['music']['icons.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 = this.getSoundIcon(category); //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(); } protected 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(); } private getSoundIcon(category: string) { if (soundIconCache && soundIconCache[category]) { return soundIconCache[category].icon; } return undefined; } }