// -*- mode: javascript; js-indent-level: 2; -*- // Copyright © 2016-2018 Massachusetts Institute of Technology. All rights reserved. /** * @license * @fileoverview Visual blocks editor for MIT App Inventor * App Inventor extensions to Blockly's SVG Workspace * * @author ewpatton@mit.edu (Evan W. Patton) */ 'use strict'; //goog.provide('AI.Blockly.WorkspaceSvg'); //goog.require('Blockly.WorkspaceSvg'); /** * AI2 Blocks Drawer * @type {Blockly.Drawer} * @private */ Blockly.WorkspaceSvg.prototype.drawer_ = null; /** * The workspace's backpack (if any). * @type {Blockly.Backpack} * @private */ Blockly.WorkspaceSvg.prototype.backpack_ = null; /** * The workspace's component database. * @type {Blockly.ComponentDatabase} * @private */ Blockly.WorkspaceSvg.prototype.componentDb_ = null; /** * The workspace's typeblock instance. * @type {Blockly.TypeBlock} * @private */ Blockly.WorkspaceSvg.prototype.typeBlock_ = null; /** * Shared flydown for parameters and variables. * @type {Blockly.Flydown} * @private */ Blockly.WorkspaceSvg.prototype.flydown_ = null; /** * A list of blocks that need rendering the next time the workspace is shown. * @type {?Array.} */ Blockly.WorkspaceSvg.prototype.blocksNeedingRendering = null; /** * latest clicked position is used to open the type blocking suggestions window * Initial position is 0,0 * @type {{x: number, y: number}} */ Blockly.WorkspaceSvg.prototype.latestClick = { x: 0, y: 0 }; /** * Whether the workspace elements are hidden * @type {boolean} */ Blockly.WorkspaceSvg.prototype.chromeHidden = false; /** * Wrap the onMouseClick_ event to handle additional behaviors. */ Blockly.WorkspaceSvg.prototype.onMouseDown_ = (function(func) { if (func.isWrapped) { return func; } else { var f = function(e) { try { var metrics = Blockly.mainWorkspace.getMetrics(); var point = Blockly.utils.mouseToSvg(e, this.getParentSvg(), this.getInverseScreenCTM()); point.x = (point.x + metrics.viewLeft) / this.scale; point.y = (point.y + metrics.viewTop) / this.scale; this.latestClick = point; return func.call(this, e); } finally { // focus the workspace's parent typeblocking and other keystrokes this.getTopWorkspace().getParentSvg().parentNode.focus(); //if drawer exists and supposed to close if (this.drawer_ && this.drawer_.flyout_.autoClose) { this.drawer_.hide(); } if (this.backpack_ && this.backpack_.flyout_.autoClose) { this.backpack_.hide(); } //Closes mutators var blocks = this.getAllBlocks(); var numBlocks = blocks.length; var temp_block = null; for(var i = 0; i < numBlocks; i++) { temp_block = blocks[i]; if(temp_block.mutator){ //deselect block in mutator workspace if(Blockly.selected && Blockly.selected.workspace && Blockly.selected.workspace!=Blockly.mainWorkspace){ Blockly.selected.unselect(); } blocks[i].mutator.setVisible(false); } } } }; f.isWrapper = true; return f; } })(Blockly.WorkspaceSvg.prototype.onMouseDown_); Blockly.WorkspaceSvg.prototype.createDom = (function(func) { if (func.isWrapped) { return func; } else { var f = function() { var self = /** @type {Blockly.WorkspaceSvg} */ this; var result = func.apply(this, Array.prototype.slice.call(arguments)); // BEGIN: Configure drag and drop of blocks images to workspace result.addEventListener('dragenter', function(e) { if (e.dataTransfer.types.indexOf('Files') >= 0 || e.dataTransfer.types.indexOf('text/uri-list') >= 0) { self.svgBackground_.style.fill = 'rgba(0, 255, 0, 0.3)'; e.dataTransfer.dropEffect = 'copy'; e.preventDefault(); } }, true); result.addEventListener('dragover', function(e) { if (e.dataTransfer.types.indexOf('Files') >= 0 || e.dataTransfer.types.indexOf('text/uri-list') >= 0) { self.svgBackground_.style.fill = 'rgba(0, 255, 0, 0.3)'; e.dataTransfer.dropEffect = 'copy'; e.preventDefault(); } }, true); result.addEventListener('dragleave', function(e) { self.setGridSettings(self.options.gridOptions['enabled'], self.options.gridOptions['snap']); }, true); result.addEventListener('dragexit', function(e) { self.setGridSettings(self.options.gridOptions['enabled'], self.options.gridOptions['snap']); }, true); result.addEventListener('drop', function(e) { self.setGridSettings(self.options.gridOptions['enabled'], self.options.gridOptions['snap']); if (e.dataTransfer.types.indexOf('Files') >= 0) { if (e.dataTransfer.files.item(0).type === 'image/png') { e.preventDefault(); var metrics = Blockly.mainWorkspace.getMetrics(); var point = Blockly.utils.mouseToSvg(e, self.getParentSvg(), self.getInverseScreenCTM()); point.x = (point.x + metrics.viewLeft) / self.scale; point.y = (point.y + metrics.viewTop) / self.scale; Blockly.importPngAsBlock(self, point, e.dataTransfer.files.item(0)); } } else if (e.dataTransfer.types.indexOf('text/uri-list') >= 0) { var data = e.dataTransfer.getData('text/uri-list') if (data.match(/\.png$/)) { e.preventDefault(); var xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { if (xhr.readyState === 4 && xhr.status === 200) { var metrics = Blockly.mainWorkspace.getMetrics(); var point = Blockly.utils.mouseToSvg(e, self.getParentSvg(), self.getInverseScreenCTM()); point.x = (point.x + metrics.viewLeft) / self.scale; point.y = (point.y + metrics.viewTop) / self.scale; Blockly.importPngAsBlock(self, point, xhr.response); } }; xhr.responseType = 'blob'; xhr.open('GET', data, true); xhr.send(); } } }); // END: Configure drag and drop of blocks images to workspace return result; }; f.isWrapper = true; return f; } })(Blockly.WorkspaceSvg.prototype.createDom); Blockly.WorkspaceSvg.prototype.dispose = (function(func) { if (func.isWrapped) { return func; } else { var wrappedFunc = function() { func.call(this); if (this.backpack_) { this.backpack_.dispose(); return null; } }; wrappedFunc.isWrapped = true; return wrappedFunc; } })(Blockly.WorkspaceSvg.prototype.dispose); /** * Add the warning handler. */ Blockly.WorkspaceSvg.prototype.addWarningHandler = function() { if (!this.warningHandler_) { this.warningHandler_ = new Blockly.WarningHandler(this); } }; /** * Adds the warning indicator. */ Blockly.WorkspaceSvg.prototype.addWarningIndicator = function() { if (!this.options.readOnly && this.warningIndicator_ == null) { if (!this.warningHandler_) { this.warningHandler_ = new Blockly.WarningHandler(this); } this.warningIndicator_ = new Blockly.WarningIndicator(this); var svgWarningIndicator = this.warningIndicator_.createDom(); this.svgGroup_.appendChild(svgWarningIndicator); this.warningIndicator_.init(); } }; /** * Add a backpack. */ Blockly.WorkspaceSvg.prototype.addBackpack = function() { if (Blockly.Backpack && !this.options.readOnly) { this.backpack_ = new Blockly.Backpack(this, { scrollbars: true, media: './assets/', disabledPatternId: this.options.disabledPatternId, }); var svgBackpack = this.backpack_.createDom(this); this.svgGroup_.appendChild(svgBackpack); this.backpack_.init(); } }; /** * Handle backpack rescaling */ Blockly.WorkspaceSvg.prototype.setScale = (function(func) { if (func.isWrapped) { return func; } else { var wrappedFunction = function(newScale) { func.call(this, newScale); if (this.backpack_) { this.backpack_.flyout_.reflow(); } }; wrappedFunction.isWrapped = true; return wrappedFunction; } })(Blockly.WorkspaceSvg.prototype.setScale); //noinspection JSUnusedGlobalSymbols Called from BlocklyPanel.java /** * Hide the blocks drawer. */ Blockly.WorkspaceSvg.prototype.hideDrawer = function() { if (this.drawer_) this.drawer_.hide(); return this; }; /** * Show the blocks drawer for the built-in category. */ Blockly.WorkspaceSvg.prototype.showBuiltin = function(name) { if (this.drawer_) this.drawer_.showBuiltin(name); return this; }; /** * Show the drawer with generic blocks for a component type. */ Blockly.WorkspaceSvg.prototype.showGeneric = function(name) { if (this.drawer_) this.drawer_.showGeneric(name); return this; }; /** * Show the drawer for a component instance. */ Blockly.WorkspaceSvg.prototype.showComponent = function(component) { if (this.drawer_) this.drawer_.showComponent(component); return this; }; //noinspection JSUnusedGlobalSymbols Called from BlocklyPanel.java /** * Check whether the drawer is showing. */ Blockly.WorkspaceSvg.prototype.isDrawerShowing = function() { if (this.drawer_) { return this.drawer_.isShowing(); } else { return false; } }; /** * Render the workspace. * @param {Array.=} blocks */ // Override Blockly's render with optimized version from lyn Blockly.WorkspaceSvg.prototype.render = function(blocks) { this.rendered = true; this.bulkRendering = true; Blockly.Field.startCache(); try { if (Blockly.Instrument.isOn) { var start = new Date().getTime(); } // [lyn, 04/08/14] Get both top and all blocks for stats var topBlocks = blocks || this.getTopBlocks(/* ordered */ false); var allBlocks = this.getAllBlocks(); if (Blockly.Instrument.useRenderDown) { for (var t = 0, topBlock; topBlock = topBlocks[t]; t++) { Blockly.Instrument.timer( function () { topBlock.render(false); }, function (result, timeDiffInner) { Blockly.Instrument.stats.renderDownTime += timeDiffInner; } ); } } else { for (var x = 0, block; block = allBlocks[x]; x++) { if (!block.getChildren().length) { block.render(); } } } if (Blockly.Instrument.isOn) { var stop = new Date().getTime(); var timeDiffOuter = stop - start; Blockly.Instrument.stats.blockCount = allBlocks.length; Blockly.Instrument.stats.topBlockCount = topBlocks.length; Blockly.Instrument.stats.workspaceRenderCalls++; Blockly.Instrument.stats.workspaceRenderTime += timeDiffOuter; } } finally { this.bulkRendering = false; this.requestConnectionDBUpdate(); Blockly.Field.stopCache(); // must balance with startCache() call above } }; /** * Obtain the {@link Blockly.ComponentDatabase} associated with the workspace. * * @returns {!Blockly.ComponentDatabase} */ Blockly.WorkspaceSvg.prototype.getComponentDatabase = function() { return this.componentDb_; }; /** * Obtain the {@link Blockly.ProcedureDatabase} associated with the workspace. * @returns {!Blockly.ProcedureDatabase} */ Blockly.WorkspaceSvg.prototype.getProcedureDatabase = function() { return this.procedureDb_; }; //noinspection JSUnusedGlobalSymbols Called from BlocklyPanel.java /** * Add a new component to the workspace. * * @param {string} uid * @param {string} instanceName * @param {string} typeName * @returns {Blockly.WorkspaceSvg} The workspace for call chaining. */ Blockly.WorkspaceSvg.prototype.addComponent = function(uid, instanceName, typeName) { if (this.componentDb_.addInstance(uid, instanceName, typeName)) { this.typeBlock_.needsReload.components = true; } return this; }; //noinspection JSUnusedGlobalSymbols Called from BlocklyPanel.java /** * Remove a component from the workspace. * * @param {string} uid The component's unique identifier * @returns {Blockly.WorkspaceSvg} The workspace for call chaining. */ Blockly.WorkspaceSvg.prototype.removeComponent = function(uid) { var component = this.componentDb_.getInstance(uid); // Fixes #1175 if (this.drawer_ && component.name === this.drawer_.lastComponent) { this.drawer_.hide(); } if (!this.componentDb_.removeInstance(uid)) { return this; } this.typeBlock_.needsReload.components = true; var blocks = this.getAllBlocks(); for (var i = 0, block; block = blocks[i]; ++i) { if (block.category == 'Component' && block.getFieldValue('COMPONENT_SELECTOR') == component.name) { block.dispose(true); } } Blockly.hideChaff(); return this; }; //noinspection JSUnusedGlobalSymbols Called from BlocklyPanel.java /** * Rename a component in the workspace. * * @param {!string} uid The unique identifier of the component. * @param {!string} oldName The previous name of the component. * @param {!string} newName The new name of the component. * @returns {Blockly.WorkspaceSvg} The workspace for call chaining. */ Blockly.WorkspaceSvg.prototype.renameComponent = function(uid, oldName, newName) { if (!this.componentDb_.renameInstance(uid, oldName, newName)) { console.log('Renaming: No such component instance ' + oldName + '; aborting.'); return this; } this.typeBlock_.needsReload.components = true; var blocks = this.getAllBlocks(); for (var i = 0, block; block = blocks[i]; ++i) { if (block.category == 'Component' && block.rename(oldName, newName)) { this.blocksNeedingRendering.push(block); } } Blockly.hideChaff(); return this; }; //noinspection JSUnusedGlobalSymbols Called from BlocklyPanel.java. /** * Populate the component type database with the components encoded by * strComponentInfos. * * @param {string} strComponentInfos String containing JSON-encoded * @param {Object.} translations Translation dictionary provided by GWT * component information. */ Blockly.WorkspaceSvg.prototype.populateComponentTypes = function(strComponentInfos, translations) { this.componentDb_.populateTypes(JSON.parse(strComponentInfos)); this.componentDb_.populateTranslations(translations); }; //noinspection JSUnusedGlobalSymbols Called from BlocklyPanel.java. /** * Loads the contents of a blocks file into the workspace. * * @param {!string} formJson JSON string containing structure of the Form * @param {!string} blocksContent XML serialization of the blocks * @returns {Blockly.WorkspaceSvg} The workspace for call chaining. */ Blockly.WorkspaceSvg.prototype.loadBlocksFile = function(formJson, blocksContent) { if (blocksContent.length != 0) { try { Blockly.Events.disable(); this.isLoading = true; if (Blockly.Versioning.upgrade(formJson, blocksContent, this)) { var self = this; setTimeout(function() { self.fireChangeListener(new AI.Events.ForceSave(self)); }); } } finally { this.isLoading = false; Blockly.Events.enable(); } if (this.getCanvas() != null) { this.render(); } } return this; }; //noinspection JSUnusedGlobalSymbols Called from BlocklyPanel.java /** * Verifies all of the blocks on the workspace and adds error icons if * any problems are identified. * * @returns {Blockly.WorkspaceSvg} The workspace for call chaining. */ Blockly.WorkspaceSvg.prototype.verifyAllBlocks = function() { var blocks = this.getAllBlocks(); for (var i = 0, block; block = blocks[i]; ++i) { if (block.category == 'Component') { block.verify(); } } return this; }; //noinspection JSUnusedGlobalSymbols Called from BlocklyPanel.java /** * Saves the workspace as an XML file and returns the contents as a * string. * * @param {boolean} prettify Specify true if the resulting workspace should be pretty-printed. * @returns {string} XML serialization of the workspace's blocks. */ Blockly.WorkspaceSvg.prototype.saveBlocksFile = function(prettify) { return Blockly.SaveFile.get(prettify, this); }; /** * Generate the YAIL for the blocks workspace. * * @param {string} formJson * @param {string} packageName * @param {boolean=false} opt_repl * @returns String containing YAIL to be sent to the phone. */ Blockly.WorkspaceSvg.prototype.getFormYail = function(formJson, packageName, opt_repl) { return Blockly.Yail.getFormYail(formJson, packageName, !!opt_repl, this); }; /** * Get the warning handler for the workspace. * @returns {Blockly.WarningHandler} */ Blockly.WorkspaceSvg.prototype.getWarningHandler = function() { if (!this.warningHandler_) { this.warningHandler_ = new Blockly.WarningHandler(this); } return this.warningHandler_; }; /** * Get the warning indicator UI element. * @returns {Blockly.WarningIndicator} */ Blockly.WorkspaceSvg.prototype.getWarningIndicator = function() { return this.warningIndicator_; }; //noinspection JSUnusedGlobalSymbols Called from BlocklyPanel.java Blockly.WorkspaceSvg.prototype.exportBlocksImageToUri = function(cb) { Blockly.ExportBlocksImage.getUri(cb, this); }; Blockly.WorkspaceSvg.prototype.getFlydown = function() { return this.flydown_; }; Blockly.WorkspaceSvg.prototype.hideChaff = function(opt_allowToolbox) { this.flydown_ && this.flydown_.hide(); this.typeBlock_ && this.typeBlock_.hide(); if (!opt_allowToolbox) { // Fixes #1269 this.backpack_ && this.backpack_.hide(); } this.setScrollbarsVisible(true); }; Blockly.WorkspaceSvg.prototype.activate = function() { Blockly.mainWorkspace = this; }; Blockly.WorkspaceSvg.prototype.buildComponentMap = function(warnings, errors, forRepl, compileUnattachedBlocks) { var map = {components: {}, globals: []}; var blocks = this.getTopBlocks(/* ordered */ true); for (var i = 0, block; block = blocks[i]; ++i) { if (block.type == 'procedures_defnoreturn' || block.type == 'procedures_defreturn' || block.type == 'global_declaration') { map.globals.push(block); } else if (block.category == 'Component' && block.type == 'event') { if (block.isGeneric) { map.globals.push(block); continue; } var instanceName = block.instanceName; if (!map.components[instanceName]) { map.components[instanceName] = []; } map.components[instanceName].push(block); } } return map; }; Blockly.WorkspaceSvg.prototype.resize = (function(resize) { return function() { resize.call(this); if (this.warningIndicator_ && this.warningIndicator_.position_) { this.warningIndicator_.position_(); } if (this.backpack_ && this.backpack_.position_) { this.backpack_.position_(); } return this; }; })(Blockly.WorkspaceSvg.prototype.resize); Blockly.WorkspaceSvg.prototype.customContextMenu = function(menuOptions) { var self = this; function addResetArrangements(callback) { return function() { try { callback.call(); } finally { self.resetArrangements(); } }; } function instrument(callback) { return function() { Blockly.Instrument.initializeStats('expandAllCollapsedBlocks'); Blockly.Instrument.timer( function() { callback.call(self); }, function(result, timeDiff) { Blockly.Instrument.stats.totalTime = timeDiff; Blockly.Instrument.displayStats('expandAllCollapsedBlocks'); }); }; } for (var i = 0; i < menuOptions.length; ++i) { if (menuOptions[i].text == Blockly.Msg.COLLAPSE_ALL) { menuOptions[i].callback = addResetArrangements(menuOptions[i].callback); } else if (menuOptions[i].text == Blockly.Msg.EXPAND_ALL) { menuOptions[i].callback = instrument(addResetArrangements(menuOptions[i].callback)); } } var exportOption = {enabled: true}; exportOption.text = Blockly.Msg.EXPORT_IMAGE; exportOption.callback = function() { Blockly.ExportBlocksImage.onclickExportBlocks(Blockly.getMainWorkspace().getMetrics()); }; menuOptions.splice(3, 0, exportOption); //Show or hide workspace SVG elements backpack, zoom, and trashcan var workspaceOption = {enabled: true}; workspaceOption.text = this.chromeHidden ? Blockly.Msg.SHOW : Blockly.Msg.HIDE; var displayStyle = this.chromeHidden ? 'block' : 'none'; workspaceOption.callback= function() { self.backpack_.svgGroup_.style.display=displayStyle; self.trashcan.svgGroup_.style.display=displayStyle; self.zoomControls_.svgGroup_.style.display=displayStyle; self.warningIndicator_.svgGroup_.style.display=displayStyle; self.chromeHidden = !self.chromeHidden; }; menuOptions.push(workspaceOption); // Arrange blocks in row order. var arrangeOptionH = {enabled: (Blockly.workspace_arranged_position !== Blockly.BLKS_HORIZONTAL)}; arrangeOptionH.text = Blockly.Msg.ARRANGE_H; arrangeOptionH.callback = function(opt_type) { opt_type = opt_type instanceof goog.events.Event ? null : opt_type; arrangeBlocks(opt_type? opt_type : Blockly.workspace_arranged_type, Blockly.BLKS_HORIZONTAL); }; menuOptions.push(arrangeOptionH); // Arrange blocks in column order. var arrangeOptionV = {enabled: (Blockly.workspace_arranged_position !== Blockly.BLKS_VERTICAL)}; arrangeOptionV.text = Blockly.Msg.ARRANGE_V; arrangeOptionV.callback = function(opt_type) { opt_type = opt_type instanceof goog.events.Event ? null : opt_type; arrangeBlocks(opt_type? opt_type : Blockly.workspace_arranged_type, Blockly.BLKS_VERTICAL); }; menuOptions.push(arrangeOptionV); /** * Function that returns a name to be used to sort blocks. * The general comparator is the block.category attribute. * In the case of Procedures the comparator is the NAME(for definitions) or PROCNAME (for calls) * In the case of 'Components' the comparator is the type name, instance name, then event name * @param {!Blockly.Block} block the block that will be compared in the sortByCategory function * @returns {string} text to be used in the comparison */ function comparisonName(block) { // Add trailing numbers to represent their sequence if (block.category == 'Variables') { return ('a,' + block.type + ',' + block.getVars().join(',')); } if (block.category === 'Procedures') { // sort procedure definitions before calls if (block.type.indexOf('procedures_def') == 0) { return ('b,a:' + (block.getFieldValue('NAME') || block.getFieldValue('PROCNAME'))); } else { return ('b,b:'+ (block.getFieldValue('NAME') || block.getFieldValue('PROCNAME'))); } } if (block.category == 'Component') { var component = block.type + ',' + block.typeName + ',' + (block.isGeneric ? '!GENERIC!' : block.instanceName) + ','; // sort Component blocks first, then events, methods, getters, or setters if (block.type == 'component_event') { component += block.eventName; } else if (block.type == 'component_method') { component += block.methodName; } else if (block.type == 'component_set_get') { component += block.setOrGet + block.propertyName; } else { // component blocks component += '.Component'; } return ('c,' + component); } // Floating blocks that are not Component return ('d,' + block.type); } /** * Function used to compare two strings with text and numbers * @param {string} a first string to be compared * @param {string} b second string to be compared * @returns {number} returns 0 if the strings are equal, and -1 or 1 if they are not */ function compareStrTextNum(strA,strB) { // Use Regular Expression to match text and numbers var regexStrA = strA.match(/^(.*?)([0-9]+)/i); var regexStrB = strB.match(/^(.*?)([0-9]+)/i); // There are numbers in the strings, compare numbers if (regexStrA != null && regexStrB != null) { if (regexStrA[1] < regexStrB[1]) { return -1; } else if (regexStrA[1] > regexStrB[1]) { return 1; } else { return parseInt(regexStrA[2]) - parseInt(regexStrB[2]); } } else { return strA.localeCompare(strB, undefined, {numeric:true}); } } /** * Function used to sort blocks by Category. * @param {!Blockly.Block} a first block to be compared * @param {!Blockly.Block} b second block to be compared * @returns {number} returns 0 if the blocks are equal, and -1 or 1 if they are not */ function sortByCategory(a,b) { var comparatorA = comparisonName(a).toLowerCase(); var comparatorB = comparisonName(b).toLowerCase(); if (a.category != b.category) { return comparatorA.localeCompare(comparatorB, undefined, {numeric:true}); } // Sort by Category First, also handles other floating blocks if (a.category == b.category && a.category != "Component") { // Remove '1,' comparatorA = comparatorA.substr(2); comparatorB = comparatorB.substr(2); var res = compareStrTextNum(comparatorA, comparatorB); if (a.category == "Variables" && a.type == b.type) { // Sort Variables if (a.type == "global_declaration") { // initialize variables, extract just global variable names var nameA = a.svgGroup_.textContent; // remove substring "initialize globalto" and only keep nameA = nameA.substring(17, nameA.length - 2); var nameB = b.svgGroup_.textContent; nameB = nameB.substring(17, nameB.length - 2); res = compareStrTextNum(nameA, nameB); } else { var nameA = a.fieldVar_.text_; var nameB = b.fieldVar_.text_; if (nameA.includes("global") && nameB.includes("global")) { // Global Variables and get variable names, remove "global" res = compareStrTextNum(nameA.substring(6), nameB.substring(6)); }else { // Other floating variables res = compareStrTextNum(nameA, nameB); } } } return res; } // 3.Component event handlers, lexicographically sorted by // type name, instance name, then event name if (a.category == "Component" && b.category == "Component" && a.eventName && b.eventName) { if (a.typeName == b.typeName) { if (a.instanceName == b.instanceName) { return 0; } else if (!a.instanceName) { return -1; } else if (!b.instanceName) { return 1; } return compareStrTextNum(a.instanceName, b.instanceName); } return comparatorA.localeCompare(comparatorB, undefined, {numeric:true}); } // 4. For Component blocks, sorted internally first by type, // whether they are generic (generics precede specifics), // then by instance name (for specific blocks), // then by method/property name. if (a.category == "Component" && b.category == "Component") { var geneA = ',2'; if (a.isGeneric) { geneA = ',1'; } var geneB = ',2'; if (b.isGeneric) { geneB = ',1'; } var componentA = a.type + geneA; var componentB = b.type + geneB; var res = componentA.localeCompare(componentB, undefined, {numeric:true}); if (res == 0) { // compare type names res = compareStrTextNum(a.typeName, b.typeName); } //the comparator is the type name, instance name, then event name if (res == 0) { if (a.instanceName && b.instanceName) { res = compareStrTextNum(a.instanceName, b.instanceName); } // Compare property names var prop_method_A = a.propertyName || a.methodName; var prop_method_B = b.propertyName || b.methodName; res = prop_method_A.toLowerCase().localeCompare(prop_method_B.toLowerCase(), undefined, {numeric:true}); } return res; } } // Arranges block in layout (Horizontal or Vertical). function arrangeBlocks(type, layout) { Blockly.Events.setGroup(true); // group these movements together // start arrangement var workspaceId = Blockly.mainWorkspace.id; Blockly.Events.fire(new AI.Events.StartArrangeBlocks(workspaceId)); Blockly.workspace_arranged_type = type; Blockly.workspace_arranged_position = layout; Blockly.workspace_arranged_latest_position = layout; var event = new AI.Events.EndArrangeBlocks(workspaceId, type, layout); var SPACER = 25; var topblocks = Blockly.mainWorkspace.getTopBlocks(/* ordered */ false); // If the blocks are arranged by Category, sort the array if (Blockly.workspace_arranged_type === Blockly.BLKS_CATEGORY){ topblocks.sort(sortByCategory); } var metrics = Blockly.mainWorkspace.getMetrics(); var spacing = Blockly.mainWorkspace.options.gridOptions.spacing; var spacingInv = 1 / spacing; var snap = Blockly.mainWorkspace.options.gridOptions.snap ? function(x) { return (Math.ceil(x * spacingInv) - .5) * spacing; } : function(x) { return x; }; var viewLeft = snap(metrics.viewLeft + 5); var viewTop = snap(metrics.viewTop + 5); var x = viewLeft; var y = viewTop; var wsRight = viewLeft + metrics.viewWidth / Blockly.mainWorkspace.scale; var wsBottom = viewTop + metrics.viewHeight / Blockly.mainWorkspace.scale; var maxHgt = 0; var maxWidth = 0; for (var i = 0, len = topblocks.length; i < len; i++) { var blk = topblocks[i]; var blkXY = blk.getRelativeToSurfaceXY(); var blockHW = blk.getHeightWidth(); var blkHgt = blockHW.height; var blkWidth = blockHW.width; switch (layout) { case Blockly.BLKS_HORIZONTAL: if (x < wsRight) { blk.moveBy(x - blkXY.x, y - blkXY.y); blk.select(); x = snap(x + blkWidth + SPACER); if (blkHgt > maxHgt) // Remember highest block maxHgt = blkHgt; } else { y = snap(y + maxHgt + SPACER); maxHgt = blkHgt; x = viewLeft; blk.moveBy(x - blkXY.x, y - blkXY.y); blk.select(); x = snap(x + blkWidth + SPACER); } break; case Blockly.BLKS_VERTICAL: if (y < wsBottom) { blk.moveBy(x - blkXY.x, y - blkXY.y); blk.select(); y = snap(y + blkHgt + SPACER); if (blkWidth > maxWidth) // Remember widest block maxWidth = blkWidth; } else { x = snap(x + maxWidth + SPACER); maxWidth = blkWidth; y = viewTop; blk.moveBy(x - blkXY.x, y - blkXY.y); blk.select(); y = snap(y + blkHgt + SPACER); } break; } } Blockly.Events.fire(event); // end arrangement Blockly.Events.setGroup(false); setTimeout(function() { Blockly.workspace_arranged_type = type; Blockly.workspace_arranged_position = layout; Blockly.workspace_arranged_latest_position = layout; }); // need to run after all events have run } // Sort by Category. var sortOptionCat = {enabled: (Blockly.workspace_arranged_type !== Blockly.BLKS_CATEGORY)}; sortOptionCat.text = Blockly.Msg.SORT_C; sortOptionCat.callback = function() { rearrangeWorkspace(Blockly.BLKS_CATEGORY); }; menuOptions.push(sortOptionCat); // Called after a sort or collapse/expand to redisplay blocks. function rearrangeWorkspace(opt_type) { //default arrangement position set to Horizontal if it hasn't been set yet (is null) if (Blockly.workspace_arranged_latest_position === null || Blockly.workspace_arranged_latest_position === Blockly.BLKS_HORIZONTAL) arrangeOptionH.callback(opt_type); else if (Blockly.workspace_arranged_latest_position === Blockly.BLKS_VERTICAL) arrangeOptionV.callback(opt_type); } // Enable all blocks var enableAll = {enabled: true}; enableAll.text = Blockly.Msg.ENABLE_ALL_BLOCKS; enableAll.callback = function() { var allBlocks = Blockly.mainWorkspace.getAllBlocks(); Blockly.Events.setGroup(true); for (var x = 0, block; block = allBlocks[x]; x++) { block.setDisabled(false); } Blockly.Events.setGroup(false); }; menuOptions.push(enableAll); // Disable all blocks var disableAll = {enabled: true}; disableAll.text = Blockly.Msg.DISABLE_ALL_BLOCKS; disableAll.callback = function() { var allBlocks = Blockly.mainWorkspace.getAllBlocks(); Blockly.Events.setGroup(true); for (var x = 0, block; block = allBlocks[x]; x++) { block.setDisabled(true); } Blockly.Events.setGroup(false); }; menuOptions.push(disableAll); // Show all comments var showAll = {enabled: true}; showAll.text = Blockly.Msg.SHOW_ALL_COMMENTS; showAll.callback = function() { var allBlocks = Blockly.mainWorkspace.getAllBlocks(); Blockly.Events.setGroup(true); for (var x = 0, block; block = allBlocks[x]; x++) { if (block.comment != null && !block.isCollapsed()) { block.comment.setVisible(true); } } Blockly.Events.setGroup(false); }; menuOptions.push(showAll); // Hide all comments var hideAll = {enabled: true}; hideAll.text = Blockly.Msg.HIDE_ALL_COMMENTS; hideAll.callback = function() { var allBlocks = Blockly.mainWorkspace.getAllBlocks(); Blockly.Events.setGroup(true); for (var x = 0, block; block = allBlocks[x]; x++) { if (block.comment != null && !block.isCollapsed()) { block.comment.setVisible(false); } } Blockly.Events.setGroup(false); }; menuOptions.push(hideAll); // Copy all blocks to backpack option. var backpackCopyAll = {enabled: true}; backpackCopyAll.text = Blockly.Msg.COPY_ALLBLOCKS; backpackCopyAll.callback = function() { if (Blockly.getMainWorkspace().hasBackpack()) { Blockly.getMainWorkspace().getBackpack().addAllToBackpack(); } }; menuOptions.push(backpackCopyAll); // Retrieve from backpack option. var backpackRetrieve = {enabled: true}; backpackRetrieve.text = Blockly.Msg.BACKPACK_GET + " (" + Blockly.getMainWorkspace().getBackpack().count() + ")"; backpackRetrieve.callback = function() { if (Blockly.getMainWorkspace().hasBackpack()) { Blockly.getMainWorkspace().getBackpack().pasteBackpack(); } }; menuOptions.push(backpackRetrieve); // Enable grid var gridOption = {enabled: true}; gridOption.text = this.options.gridOptions['enabled'] ? Blockly.Msg.DISABLE_GRID : Blockly.Msg.ENABLE_GRID; gridOption.callback = function() { self.options.gridOptions['enabled'] = !self.options.gridOptions['enabled']; self.options.gridOptions['snap'] = self.options.gridOptions['enabled'] && top.BlocklyPanel_getSnapEnabled(); if (self.options.gridOptions['enabled']) { // add grid self.svgBackground_.setAttribute('style', 'fill: url(#' + self.options.gridPattern.id + ');'); } else { // remove grid self.svgBackground_.setAttribute('style', 'fill: white;'); } if (top.BlocklyPanel_setGridEnabled) { top.BlocklyPanel_setGridEnabled(self.options.gridOptions['enabled']); top.BlocklyPanel_saveUserSettings(); } }; menuOptions.push(gridOption); if (this.options.gridOptions['enabled']) { // Enable Snapping var snapOption = {enabled: this.options.gridOptions['enabled']}; snapOption.text = this.options.gridOptions['snap'] ? Blockly.Msg.DISABLE_SNAPPING : Blockly.Msg.ENABLE_SNAPPING; snapOption.callback = function() { self.options.gridOptions['snap'] = !self.options.gridOptions['snap']; if (top.BlocklyPanel_setSnapEnabled) { top.BlocklyPanel_setSnapEnabled(self.options.gridOptions['enabled']); top.BlocklyPanel_saveUserSettings(); } }; menuOptions.push(snapOption); } // Option to get help. var helpOption = {enabled: false}; helpOption.text = Blockly.Msg.HELP; helpOption.callback = function() {}; menuOptions.push(helpOption); }; Blockly.WorkspaceSvg.prototype.recordDeleteAreas = function() { if (this.trashcan) { this.deleteAreaTrash_ = this.trashcan.getClientRect(); } else { this.deleteAreaTrash_ = null; } if (this.isMutator) { if (this.flyout_) { this.deleteAreaToolbox_ = this.flyout_.getClientRect(); } else if (this.toolbox_) { this.deleteAreaToolbox_ = this.toolbox_.getClientRect(); } else { this.deleteAreaToolbox_ = null; } } else { this.deleteAreaToolbox_ = null; } }; Blockly.WorkspaceSvg.prototype.getBackpack = function() { return this.backpack_; }; Blockly.WorkspaceSvg.prototype.hasBackpack = function() { return this.backpack_ != null; }; Blockly.WorkspaceSvg.prototype.onMouseWheel_ = function(e) { Blockly.terminateDrag_(); if (e.eventPhase == 3) { if (e.ctrlKey == true) { // multi-touch pinch gesture if (e.deltaY == 0) { // Multi-stage wheel movement triggers jumpy zoom-in then zoom-out behavior e.preventDefault(); return; } var delta = e.deltaY > 0 ? -1 : 1; var position = Blockly.utils.mouseToSvg(e, this.getParentSvg(), this.getInverseScreenCTM()); this.zoom(position.x, position.y, delta); } else { // pan using mouse wheel this.scrollX -= e.deltaX; this.scrollY -= e.deltaY; this.updateGridPattern_(); if (this.scrollbar) { // can only pan if scrollbars exist this.scrollbar.resize(); } else { this.translate(this.scrollX, this.scrollY); } } e.preventDefault(); } }; Blockly.WorkspaceSvg.prototype.setGridSettings = function(enabled, snap) { this.options.gridOptions['enabled'] = enabled; this.options.gridOptions['snap'] = enabled && snap; if (this.svgBackground_) { if (this.options.gridOptions['enabled']) { // add grid this.svgBackground_.setAttribute('style', 'fill: url(#' + this.options.gridPattern.id + ');'); } else { // remove grid this.svgBackground_.setAttribute('style', 'fill: white;'); } } }; /** * Builds a map of component name -> top level blocks for that component. * A special entry for "globals" maps to top-level global definitions. * * @param warnings a Map that will be filled with warnings for troublesome blocks * @param errors a list that will be filled with error messages * @param forRepl whether this is executed for REPL * @param compileUnattachedBlocks whether to compile unattached blocks * @returns object mapping component names to the top-level blocks for that component in the * workspace. For each component C the object contains a field "component.C" whose * value is an array of blocks. In addition, the object contains a field named "globals" * whose value is an array of all valid top-level blocks not associated with a * component (procedure and variable definitions) */ Blockly.WorkspaceSvg.prototype.buildComponentMap = function(warnings, errors, forRepl, compileUnattachedBlocks) { var map = {}; map.components = {}; map.globals = []; // TODO: populate warnings, errors as we traverse the top-level blocks var blocks = this.getTopBlocks(true); for (var x = 0, block; block = blocks[x]; x++) { // TODO: deal with unattached blocks that are not valid top-level definitions. Valid blocks // are events, variable definitions, or procedure definitions. if (!block.category) { continue; } if (block.type == 'procedures_defnoreturn' || block.type == 'procedures_defreturn' || block.type == 'global_declaration') { map.globals.push(block); // TODO: eventually deal with variable declarations, once we have them } else if (block.category == 'Component') { var instanceName = block.instanceName; if(block.blockType != "event") { continue; } if (block.isGeneric) { map.globals.push(block); continue; } if (!map.components[instanceName]) { map.components[instanceName] = []; // first block we've found for this component } // TODO: check for duplicate top-level blocks (e.g., two event handlers with same name) - // or better yet, prevent these from happening! map.components[instanceName].push(block); } } return map; }; /** * Get the topmost workspace in the workspace hierarchy. * @returns {Blockly.WorkspaceSvg} */ Blockly.WorkspaceSvg.prototype.getTopWorkspace = function() { var parent = this; while (parent.targetWorkspace) { parent = parent.targetWorkspace; } return parent; }; Blockly.WorkspaceSvg.prototype.fireChangeListener = function(event) { Blockly.WorkspaceSvg.superClass_.fireChangeListener.call(this, event); if (event instanceof Blockly.Events.Move) { // Reset arrangement parameters Blockly.workspace_arranged_latest_position = null; Blockly.workspace_arranged_position = null; Blockly.workspace_arranged_type = null; var oldParent = this.blockDB_[event.oldParentId], block = this.blockDB_[event.blockId]; oldParent && this.requestErrorChecking(oldParent); block && this.requestErrorChecking(block); } // Double-click to collapse/expand blocks if (event instanceof Blockly.Events.Ui && event.element === 'click') { if (this.doubleClickPid_) { clearTimeout(this.doubleClickPid_); this.doubleClickPid_ = undefined; if (event.blockId === this.doubleClickBlock_) { // double click var block = this.getBlockById(this.doubleClickBlock_); block.setCollapsed(!block.isCollapsed()); return; } } if (!this.doubleClickPid_) { this.doubleClickBlock_ = event.blockId; this.doubleClickPid_ = setTimeout(function() { this.doubleClickPid_ = undefined; }.bind(this), 500); // windows uses 500ms as the default speed; seems reasonable enough } } }; /** * Request a re-render the workspace. If block is provided, only descendants of * block's top-most block will be rendered. This may be called multiple times to queue * many blocks to be rendered. * @param {Blockly.BlockSvg=} block */ Blockly.WorkspaceSvg.prototype.requestRender = function(block) { if (!this.pendingRender) { this.needsRendering = []; this.pendingBlockIds = {}; this.pendingRenderFunc = function() { try { this.render(this.needsRendering.length === 0 ? undefined : this.needsRendering); } finally { this.pendingRender = null; } }.bind(this); if (this.svgGroup_.parentElement.parentElement.parentElement.style.display === 'none') { this.pendingRender = true; } else { this.pendingRender = setTimeout(this.pendingRenderFunc, 0); } } if (block) { // Rendering uses Blockly.BlockSvg.renderDown, so we only need a list of the topmost blocks while (block.getParent()) { block = /** @type {Blockly.BlockSvg} */ block.getParent(); } if (!(block.id in this.pendingBlockIds)) { this.pendingBlockIds[block.id] = true; this.needsRendering.push(block); } } }; /** * Request error checking on the specified block. This will queue error checking events until the * next time the JavaScript thread relinquishes control to the UI thread. * @param {Blockly.BlockSvg=} block */ Blockly.WorkspaceSvg.prototype.requestErrorChecking = function(block) { if (!this.warningHandler_) { return; // no error checking before warning handler exists } if (this.checkAllBlocks) { return; // already planning to check all blocks } if (!this.pendingErrorCheck) { this.needsErrorCheck = []; this.pendingErrorBlockIds = {}; this.checkAllBlocks = !!block; this.pendingErrorCheck = setTimeout(function() { try { var handler = this.getWarningHandler(); if (handler) { // not true for flyouts and before the main workspace is rendered. goog.array.forEach(this.checkAllBlocks ? this.getAllBlocks() : this.needsErrorCheck, function(block) { handler.checkErrors(block); }); } } finally { this.pendingErrorCheck = null; this.checkAllBlocks = false; // Let any disposed blocks be GCed... this.needsErrorCheck = null; this.pendingErrorBlockIds = null; } }.bind(this)); } if (block && !(block.id in this.pendingErrorBlockIds)) { while (block.getParent()) { block = /** @type {Blockly.BlockSvg} */ block.getParent(); } var pendingBlocks = [block]; while (pendingBlocks.length > 0) { block = pendingBlocks.shift(); if (!(block.id in this.pendingErrorBlockIds)) { this.pendingErrorBlockIds[block.id] = true; this.needsErrorCheck.push(block); } Array.prototype.push.apply(pendingBlocks, block.getChildren()); } } else if (!block) { // schedule all blocks this.checkAllBlocks = true; } }; /** * Sort the workspace's connection database. This only needs to be called if the bulkRendering * property of the workspace is set to true to false as any connections that Blockly attempted to * update during that time may be incorrectly ordered in the database. */ Blockly.WorkspaceSvg.prototype.sortConnectionDB = function() { goog.array.forEach(this.connectionDBList, function(connectionDB) { connectionDB.sort(function(a, b) { return a.y_ - b.y_; }); // If we are rerendering due to a new error, we only redraw the error block, which means that // we can't clear the database, otherwise all other connections disappear. Instead, we add // the moved connections anyway, and at this point we can remove the duplicate entries in the // database. We remove after sorting so that the operation is O(n) rather than O(n^2). This // assumption may break in the future if Blockly decides on a different mechanism for indexing // connections. connectionDB.removeDupes(); }); }; /** * Request an update to the connection database's order due to movement of a block while a bulk * rendering operation was in progress. */ Blockly.WorkspaceSvg.prototype.requestConnectionDBUpdate = function() { if (!this.pendingConnectionDBUpdate) { this.pendingConnectionDBUpdate = setTimeout(function() { try { this.sortConnectionDB(); } finally { this.pendingConnectionDBUpdate = null; } }.bind(this)); } }; /** * Scroll the workspace to center on the given block. * @param {?string} id ID of block center on. * @public */ // TODO: This is code from a later version of Blockly. Remove on next Blockly update. Blockly.WorkspaceSvg.prototype.centerOnBlock = function(id) { if (!this.scrollbar) { console.warn('Tried to scroll a non-scrollable workspace.'); return; } var block = this.getBlockById(id); if (!block) { return; } // XY is in workspace coordinates. var xy = block.getRelativeToSurfaceXY(); // Height/width is in workspace units. var heightWidth = block.getHeightWidth(); // Find the enter of the block in workspace units. var blockCenterY = xy.y + heightWidth.height / 2; // In RTL the block's position is the top right of the block, not top left. var multiplier = this.RTL ? -1 : 1; var blockCenterX = xy.x + (multiplier * heightWidth.width / 2); // Workspace scale, used to convert from workspace coordinates to pixels. var scale = this.scale; // Center in pixels. 0, 0 is at the workspace origin. These numbers may // be negative. var pixelX = blockCenterX * scale; var pixelY = blockCenterY * scale; var metrics = this.getMetrics(); // Scrolling to here would put the block in the top-left corner of the // visible workspace. var scrollToBlockX = pixelX - metrics.contentLeft; var scrollToBlockY = pixelY - metrics.contentTop; // viewHeight and viewWidth are in pixels. var halfViewWidth = metrics.viewWidth / 2; var halfViewHeight = metrics.viewHeight / 2; // Put the block in the center of the visible workspace instead. var scrollToCenterX = scrollToBlockX - halfViewWidth; var scrollToCenterY = scrollToBlockY - halfViewHeight; Blockly.hideChaff(); var event = new AI.Events.WorkspaceMove(this.id); this.scrollbar.set(scrollToCenterX, scrollToCenterY); event.recordNew(); Blockly.Events.fire(event); }; /* * Refresh the state of the backpack. Called from BlocklyPanel.java */ Blockly.WorkspaceSvg.prototype.refreshBackpack = function() { if (this.backpack_) { this.backpack_.resize(); } };