From 8211bb30f408eaf892bfc8a5adbf0dfabfdc8257 Mon Sep 17 00:00:00 2001 From: Emma Dauterman Date: Wed, 10 Aug 2016 11:03:11 -0700 Subject: [PATCH] Workspace Factory (#522) Workspace factory helps developers configure their workspace by allowing them to drag blocks into the workspace to add them to their toolbox. Current features: supports categories or a single flyout of blocks updates a preview workspace automatically imports toolbox XML already written exports toolbox XML to a file prints toolbox XML to the console imports a standard Blockly category supports shadow blocks (allowing the user to move shadow blocks and toggle between shadow blocks and normal blocks), disabled blocks, block groups allows the user to add/move/delete/rename/color categories and separators. --- .../workspacefactory/index.html | 682 +++++++++++++++++ .../workspacefactory/standard_categories.js | 375 ++++++++++ .../blocklyfactory/workspacefactory/style.css | 238 ++++++ .../workspacefactory/wfactory_controller.js | 704 ++++++++++++++++++ .../workspacefactory/wfactory_generator.js | 159 ++++ .../workspacefactory/wfactory_model.js | 450 +++++++++++ .../workspacefactory/wfactory_view.js | 341 +++++++++ 7 files changed, 2949 insertions(+) create mode 100644 demos/blocklyfactory/workspacefactory/index.html create mode 100644 demos/blocklyfactory/workspacefactory/standard_categories.js create mode 100644 demos/blocklyfactory/workspacefactory/style.css create mode 100644 demos/blocklyfactory/workspacefactory/wfactory_controller.js create mode 100644 demos/blocklyfactory/workspacefactory/wfactory_generator.js create mode 100644 demos/blocklyfactory/workspacefactory/wfactory_model.js create mode 100644 demos/blocklyfactory/workspacefactory/wfactory_view.js diff --git a/demos/blocklyfactory/workspacefactory/index.html b/demos/blocklyfactory/workspacefactory/index.html new file mode 100644 index 000000000..061749f59 --- /dev/null +++ b/demos/blocklyfactory/workspacefactory/index.html @@ -0,0 +1,682 @@ + + +Blockly Workspace Factory + + + + + + + + + + + + + + + + + + + + + +
+

Blockly‏ > + Demos‏ > + Workspace Factory +

+
+

+ + + + + +

+
+ +
+

Drag blocks into your toolbox:

+
+
+
+
+ +
+ + + + + + diff --git a/demos/blocklyfactory/workspacefactory/standard_categories.js b/demos/blocklyfactory/workspacefactory/standard_categories.js new file mode 100644 index 000000000..5112c5ee2 --- /dev/null +++ b/demos/blocklyfactory/workspacefactory/standard_categories.js @@ -0,0 +1,375 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2016 Google Inc. + * https://developers.google.com/blockly/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Contains a map of standard Blockly categories used to load + * standard Blockly categories into the user's toolbox. The map is keyed by + * the lower case name of the category, and contains the Category object for + * that particular category. + * + * @author Emma Dauterman (evd2014) + */ + +FactoryController.prototype.standardCategories = Object.create(null); + +FactoryController.prototype.standardCategories['logic'] = + new ListElement(ListElement.TYPE_CATEGORY, 'Logic'); +FactoryController.prototype.standardCategories['logic'].xml = + Blockly.Xml.textToDom( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''); +FactoryController.prototype.standardCategories['logic'].color = '#5C81A6'; + +FactoryController.prototype.standardCategories['loops'] = + new ListElement(ListElement.TYPE_CATEGORY, 'Loops'); +FactoryController.prototype.standardCategories['loops'].xml = + Blockly.Xml.textToDom( + '' + + '' + + '' + + '' + + '10' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '1' + + '' + + '' + + '' + + '' + + '10' + + '' + + '' + + '' + + '' + + '1' + + '' + + '' + + '' + + '' + + '' + + ''); +FactoryController.prototype.standardCategories['loops'].color = '#5CA65C'; + +FactoryController.prototype.standardCategories['math'] = + new ListElement(ListElement.TYPE_CATEGORY, 'Math'); +FactoryController.prototype.standardCategories['math'].xml = + Blockly.Xml.textToDom( + '' + + '' + + '' + + '' + + '' + + '1' + + '' + + '' + + '' + + '' + + '1' + + '' + + '' + + '' + + '' + + '' + + '' + + '9' + + '' + + '' + + '' + + '' + + '' + + '' + + '45' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '0' + + '' + + '' + + '' + + '' + + '' + + '' + + '1' + + '' + + '' + + '' + + '' + + '' + + '' + + '3.1' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '64' + + '' + + '' + + '' + + '' + + '10'+ + '' + + '' + + '' + + '' + + '' + + '' + + '50' + + '' + + '' + + '' + + '' + + '1' + + '' + + '' + + '' + + '' + + '100' + + '' + + '' + + '' + + '' + + '' + + '' + + '1' + + '' + + '' + + '' + + '' + + '100' + + '' + + '' + + '' + + '' + + ''); +FactoryController.prototype.standardCategories['math'].color = '#5C68A6'; + +FactoryController.prototype.standardCategories['text'] = + new ListElement(ListElement.TYPE_CATEGORY, 'Text'); +FactoryController.prototype.standardCategories['text'].xml = + Blockly.Xml.textToDom( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + 'abc' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + 'text' + + '' + + '' + + '' + + '' + + 'abc' + + '' + + '' + + '' + + '' + + '' + + '' + + 'text' + + '' + + '' + + '' + + '' + + '' + + '' + + 'text' + + '' + + '' + + '' + + '' + + '' + + '' + + 'abc' + + '' + + '' + + '' + + '' + + '' + + '' + + 'abc' + + '' + + '' + + '' + + '' + + '' + + '' + + 'abc' + + '' + + '' + + '' + + '' + + '' + + '' + + 'abc' + + '' + + '' + + '' + + ''); +FactoryController.prototype.standardCategories['text'].color = '#5CA68D'; + +FactoryController.prototype.standardCategories['lists'] = + new ListElement(ListElement.TYPE_CATEGORY, 'Lists'); +FactoryController.prototype.standardCategories['lists'].xml = + Blockly.Xml.textToDom( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '5' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + 'list' + + '' + + '' + + '' + + '' + + '' + + '' + + 'list' + + '' + + '' + + '' + + '' + + '' + + '' + + 'list' + + '' + + '' + + '' + + '' + + '' + + '' + + 'list' + + '' + + '' + + '' + + '' + + '' + + '' + + ',' + + '' + + '' + + '' + + '' + + ''); +FactoryController.prototype.standardCategories['lists'].color = '#745CA6'; + +FactoryController.prototype.standardCategories['colour'] = + new ListElement(ListElement.TYPE_CATEGORY, 'Colour'); +FactoryController.prototype.standardCategories['colour'].xml = + Blockly.Xml.textToDom( + '' + + '' + + '' + + '' + + '' + + '' + + '100' + + '' + + '' + + '' + + '' + + '50' + + '' + + '' + + '' + + '' + + '0' + + '' + + '' + + '' + + '' + + '' + + '' + + '#ff0000' + + '' + + '' + + '' + + '' + + '#3333ff' + + '' + + '' + + '' + + '' + + '0.5' + + '' + + '' + + '' + + ''); +FactoryController.prototype.standardCategories['colour'].color = '#A6745C'; + +FactoryController.prototype.standardCategories['functions'] = + new ListElement(ListElement.TYPE_CATEGORY, 'Functions'); +FactoryController.prototype.standardCategories['functions'].color = '#9A5CA6' +FactoryController.prototype.standardCategories['functions'].custom = + 'PROCEDURE'; + +FactoryController.prototype.standardCategories['variables'] = + new ListElement(ListElement.TYPE_CATEGORY, 'Variables'); +FactoryController.prototype.standardCategories['variables'].color = '#A65C81'; +FactoryController.prototype.standardCategories['variables'].custom = 'VARIABLE'; diff --git a/demos/blocklyfactory/workspacefactory/style.css b/demos/blocklyfactory/workspacefactory/style.css new file mode 100644 index 000000000..efa5897fa --- /dev/null +++ b/demos/blocklyfactory/workspacefactory/style.css @@ -0,0 +1,238 @@ +body { + background-color: #fff; + font-family: sans-serif; +} + +h1 { + font-weight: normal; + font-size: 140%; +} + +section { + float: left; +} + +aside { + float: right; +} + +#categoryTable>table { + border: 1px solid #ccc; + border-bottom: none; +} + +td.tabon { + border-bottom-color: #ddd !important; + background-color: #ddd; + padding: 5px 19px; +} + +td.taboff { + cursor: pointer; + padding: 5px 19px; +} + +td.taboff:hover { + background-color: #eee; +} + +button { + border-radius: 4px; + border: 1px solid #ddd; + background-color: #eee; + color: #000; + font-size: large; + margin: 0 5px; + padding: 10px; +} + +button:hover:not(:disabled) { + box-shadow: 2px 2px 5px #888; +} + +button:disabled { + opacity: .6; +} + +button>* { + opacity: .6; + vertical-align: text-bottom; +} + +button:hover:not(:disabled)>* { + opacity: 1; +} + +label { + border-radius: 4px; + border: 1px solid #ddd; + background-color: #eee; + color: #000; + font-size: large; + margin: 0 5px; + padding: 10px; +} + +label:hover:not(:disabled) { + box-shadow: 2px 2px 5px #888; +} + +label:disabled { + opacity: .6; +} + +label>* { + opacity: .6; + vertical-align: text-bottom; +} + +label:hover:not(:disabled)>* { + opacity: 1; +} + +table { + border: none; + border-collapse: collapse; + margin: 0; + padding: 0; +} + +td { + padding: 0; + vertical-align: top; +} + +.inputfile { + height: 0; + opacity: 0; + overflow: hidden; + position: absolute; + width: 0; + z-index: -1; +} + +#toolbox_section { + height: 480px; + width: 80%; + position: relative; +} + +#toolbox_blocks { + height: 100%; + width: 100%; +} + +#preview_blocks { + height: 300px; + width: 100%; +} + +#createDiv { + width: 70%; +} + +#previewDiv { + width: 30%; +} + +#category_section { + width: 20%; +} + +#disable_div { + background-color: white; + height: 100%; + left: 0; + opacity: .5; + position: absolute; + top: 0; + width: 100%; + z-index: -1; /* Start behind workspace */ +} + +/* Rules for Closure popup color picker */ +.goog-palette { + outline: none; + cursor: default; +} + +.goog-palette-cell { + height: 13px; + width: 15px; + margin: 0; + border: 0; + text-align: center; + vertical-align: middle; + border-right: 1px solid #000000; + font-size: 1px; +} + +.goog-palette-colorswatch { + border: 1px solid #000000; + height: 13px; + position: relative; + width: 15px; +} + +.goog-palette-cell-hover .goog-palette-colorswatch { + border: 1px solid #FFF; +} + +.goog-palette-cell-selected .goog-palette-colorswatch { + border: 1px solid #000; + color: #fff; +} + +.goog-palette-table { + border: 1px solid #000; + border-collapse: collapse; +} + +.goog-popupcolorpicker { + position: absolute; +} + +/* The container
- needed to position the dropdown content */ +.dropdown { + position: relative; + display: inline-block; +} + +/* Dropdown Content (Hidden by Default) */ +.dropdown-content { + background-color: #f9f9f9; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,.2); + display: none; + min-width: 170px; + opacity: 1; + position: absolute; + z-index: 1; +} + +/* Links inside the dropdown */ +.dropdown-content a { + color: black; + display: block; + padding: 12px 16px; + text-decoration: none; +} + +/* Change color of dropdown links on hover */ +.dropdown-content a:hover { + background-color: #f1f1f1 +} + +/* Show the dropdown menu */ +.show { + display: block; +} + +.shadowBlock>.blocklyPath { + fill-opacity: .5; + stroke-opacity: .5; +} + +.shadowBlock>.blocklyPathLight, +.shadowBlock>.blocklyPathDark { + display: none; +} diff --git a/demos/blocklyfactory/workspacefactory/wfactory_controller.js b/demos/blocklyfactory/workspacefactory/wfactory_controller.js new file mode 100644 index 000000000..994f0442e --- /dev/null +++ b/demos/blocklyfactory/workspacefactory/wfactory_controller.js @@ -0,0 +1,704 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2016 Google Inc. + * https://developers.google.com/blockly/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Contains the controller code for workspace factory. Depends + * on the model and view objects (created as internal variables) and interacts + * with previewWorkspace and toolboxWorkspace (internal references stored to + * both). Also depends on standard_categories.js for standard Blockly + * categories. Provides the functionality for the actions the user can initiate: + * - adding and removing categories + * - switching between categories + * - printing and downloading configuration xml + * - updating the preview workspace + * - changing a category name + * - moving the position of a category. + * + * @author Emma Dauterman (evd2014) + */ + +/** + * Class for a FactoryController + * @constructor + * @param {!Blockly.workspace} toolboxWorkspace workspace where blocks are + * dragged into corresponding categories + * @param {!Blockly.workspace} previewWorkspace workspace that shows preview + * of what workspace would look like using generated XML + */ +FactoryController = function(toolboxWorkspace, previewWorkspace) { + // Workspace for user to drag blocks in for a certain category. + this.toolboxWorkspace = toolboxWorkspace; + // Workspace for user to preview their changes. + this.previewWorkspace = previewWorkspace; + // Model to keep track of categories and blocks. + this.model = new FactoryModel(); + // Updates the category tabs. + this.view = new FactoryView(); + // Generates XML for categories. + this.generator = new FactoryGenerator(this.model); +}; + +/** + * Currently prompts the user for a name, checking that it's valid (not used + * before), and then creates a tab and switches to it. + */ +FactoryController.prototype.addCategory = function() { + // Check if it's the first category added. + var firstCategory = !this.model.hasToolbox(); + // Give the option to save blocks if their workspace is not empty and they + // are creating their first category. + if (firstCategory && this.toolboxWorkspace.getAllBlocks().length > 0) { + var confirmCreate = confirm('Do you want to save your work in another ' + + 'category? If you don\'t, the blocks in your workspace will be ' + + 'deleted.'); + // Create a new category for current blocks. + if (confirmCreate) { + var name = prompt('Enter the name of the category for your ' + + 'current blocks: '); + if (!name) { // Exit if cancelled. + return; + } + this.createCategory(name, true); + this.model.setSelectedById(this.model.getCategoryIdByName(name)); + } + } + // After possibly creating a category, check again if it's the first category. + firstCategory = !this.model.hasToolbox(); + // Get name from user. + name = this.promptForNewCategoryName('Enter the name of your new category: '); + if (!name) { //Exit if cancelled. + return; + } + // Create category. + this.createCategory(name, firstCategory); + // Switch to category. + this.switchElement(this.model.getCategoryIdByName(name)); + // Update preview. + this.updatePreview(); +}; + +/** + * Helper method for addCategory. Adds a category to the view given a name, ID, + * and a boolean for if it's the first category created. Assumes the category + * has already been created in the model. Does not switch to category. + * + * @param {!string} name Name of category being added. + * @param {!string} id The ID of the category being added. + * @param {boolean} firstCategory True if it's the first category created, + * false otherwise. + */ +FactoryController.prototype.createCategory = function(name, firstCategory) { + // Create empty category + var category = new ListElement(ListElement.TYPE_CATEGORY, name); + this.model.addElementToList(category); + // Create new category. + var tab = this.view.addCategoryRow(name, category.id, firstCategory); + this.addClickToSwitch(tab, category.id); +}; + +/** + * Given a tab and a ID to be associated to that tab, adds a listener to + * that tab so that when the user clicks on the tab, it switches to the + * element associated with that ID. + * + * @param {!Element} tab The DOM element to add the listener to. + * @param {!string} id The ID of the element to switch to when tab is clicked. + */ +FactoryController.prototype.addClickToSwitch = function(tab, id) { + var self = this; + var clickFunction = function(id) { // Keep this in scope for switchElement + return function() { + self.switchElement(id); + }; + }; + this.view.bindClick(tab, clickFunction(id)); +}; + +/** + * Attached to "-" button. Checks if the user wants to delete + * the current element. Removes the element and switches to another element. + * When the last element is removed, it switches to a single flyout mode. + * + */ +FactoryController.prototype.removeElement = function() { + // Check that there is a currently selected category to remove. + if (!this.model.getSelected()) { + return; + } + // Check if user wants to remove current category. + var check = confirm('Are you sure you want to delete the currently selected ' + + this.model.getSelected().type + '?'); + if (!check) { // If cancelled, exit. + return; + } + var selectedId = this.model.getSelectedId(); + var selectedIndex = this.model.getIndexByElementId(selectedId); + // Delete element visually. + this.view.deleteElementRow(selectedId, selectedIndex); + // Delete element in model. + this.model.deleteElementFromList(selectedIndex); + // Find next logical element to switch to. + var next = this.model.getElementByIndex(selectedIndex); + if (!next && this.model.hasToolbox()) { + next = this.model.getElementByIndex(selectedIndex - 1); + } + var nextId = next ? next.id : null; + // Open next element. + this.clearAndLoadElement(nextId); + if (!nextId) { + alert('You currently have no categories or separators. All your blocks' + + ' will be displayed in a single flyout.'); + } + // Update preview. + this.updatePreview(); +}; + +/** + * Gets a valid name for a new category from the user. + * + * @param {!string} promptString Prompt for the user to enter a name. + * @return {string} Valid name for a new category, or null if cancelled. + */ +FactoryController.prototype.promptForNewCategoryName = function(promptString) { + do { + var name = prompt(promptString); + if (!name) { // If cancelled. + return null; + } + } while (this.model.hasCategoryByName(name)); + return name; +} + +/** + * Switches to a new tab for the element given by ID. Stores XML and blocks + * to reload later, updates selected accordingly, and clears the workspace + * and clears undo, then loads the new element. + * + * @param {!string} id ID of tab to be opened, must be valid element ID. + */ +FactoryController.prototype.switchElement = function(id) { + // Disables events while switching so that Blockly delete and create events + // don't update the preview repeatedly. + Blockly.Events.disable(); + // Caches information to reload or generate xml if switching to/from element. + // Only saves if a category is selected. + if (this.model.getSelectedId() != null && id != null) { + this.model.getSelected().saveFromWorkspace(this.toolboxWorkspace); + } + // Load element. + this.clearAndLoadElement(id); + // Enable Blockly events again. + Blockly.Events.enable(); +}; + +/** + * Switches to a new tab for the element by ID. Helper for switchElement. + * Updates selected, clears the workspace and clears undo, loads a new element. + * + * @param {!string} id ID of category to load + */ +FactoryController.prototype.clearAndLoadElement = function(id) { + // Unselect current tab if switching to and from an element. + if (this.model.getSelectedId() != null && id != null) { + this.view.setCategoryTabSelection(this.model.getSelectedId(), false); + } + + // If switching from a separator, enable workspace in view. + if (this.model.getSelectedId() != null && this.model.getSelected().type == + ListElement.TYPE_SEPARATOR) { + this.view.disableWorkspace(false); + } + + // Set next category. + this.model.setSelectedById(id); + + // Clear workspace. + this.toolboxWorkspace.clear(); + this.toolboxWorkspace.clearUndo(); + + // Loads next category if switching to an element. + if (id != null) { + this.view.setCategoryTabSelection(id, true); + Blockly.Xml.domToWorkspace(this.model.getSelectedXml(), + this.toolboxWorkspace); + // Disable workspace if switching to a separator. + if (this.model.getSelected().type == ListElement.TYPE_SEPARATOR) { + this.view.disableWorkspace(true); + } + } + + // Mark all shadow blocks laoded and order blocks as if shown in a flyout. + this.view.markShadowBlocks(this.model.getShadowBlocksInWorkspace + (toolboxWorkspace.getAllBlocks())); + this.toolboxWorkspace.cleanUp_(); + + // Update category editing buttons. + this.view.updateState(this.model.getIndexByElementId + (this.model.getSelectedId()), this.model.getSelected()); +}; + +/** + * Tied to "Export Config" button. Gets a file name from the user and downloads + * the corresponding configuration xml to that file. + */ +FactoryController.prototype.exportConfig = function() { + // Generate XML. + var configXml = Blockly.Xml.domToPrettyText + (this.generator.generateConfigXml(this.toolboxWorkspace)); + // Get file name. + var fileName = prompt("File Name: "); + if (!fileName) { // If cancelled + return; + } + // Download file. + var data = new Blob([configXml], {type: 'text/xml'}); + this.view.createAndDownloadFile(fileName, data); + }; + +/** + * Tied to "Print Config" button. Mainly used for debugging purposes. Prints + * the configuration XML to the console. + */ +FactoryController.prototype.printConfig = function() { + window.console.log(Blockly.Xml.domToPrettyText + (this.generator.generateConfigXml(this.toolboxWorkspace))); +}; + +/** + * Updates the preview workspace based on the toolbox workspace. If switching + * from no categories to categories or categories to no categories, reinjects + * Blockly with reinjectPreview, otherwise just updates without reinjecting. + * Called whenever a list element is created, removed, or modified and when + * Blockly move and delete events are fired. Do not call on create events + * or disabling will cause the user to "drop" their current blocks. + */ +FactoryController.prototype.updatePreview = function() { + // Disable events to stop updatePreview from recursively calling itself + // through event handlers. + Blockly.Events.disable(); + var tree = Blockly.Options.parseToolboxTree + (this.generator.generateConfigXml(this.toolboxWorkspace)); + // No categories, creates a simple flyout. + if (tree.getElementsByTagName('category').length == 0) { + if (this.previewWorkspace.toolbox_) { + this.reinjectPreview(tree); // Switch to simple flyout, more expensive. + } else { + this.previewWorkspace.flyout_.show(tree.childNodes); + } + // Uses categories, creates a toolbox. + } else { + if (!previewWorkspace.toolbox_) { + this.reinjectPreview(tree); // Create a toolbox, more expensive. + } else { + this.previewWorkspace.toolbox_.populate_(tree); + } + } + // Reenable events. + Blockly.Events.enable(); +}; + +/** + * Used to completely reinject the preview workspace. This should be used only + * when switching from simple flyout to categories, or categories to simple + * flyout. More expensive than simply updating the flyout or toolbox. + * + * @param {!Element} tree of xml elements + * @package + */ +FactoryController.prototype.reinjectPreview = function(tree) { + this.previewWorkspace.dispose(); + previewToolbox = Blockly.Xml.domToPrettyText(tree); + this.previewWorkspace = Blockly.inject('preview_blocks', + {grid: + {spacing: 25, + length: 3, + colour: '#ccc', + snap: true}, + media: '../../../media/', + toolbox: previewToolbox, + zoom: + {controls: true, + wheel: true} + }); +}; + +/** + * Tied to "change name" button. Changes the name of the selected category. + * Continues prompting the user until they input a category name that is not + * currently in use, exits if user presses cancel. + */ +FactoryController.prototype.changeCategoryName = function() { + // Return if no category selected or element a separator. + if (!this.model.getSelected() || + this.model.getSelected().type == ListElement.TYPE_SEPARATOR) { + return; + } + // Get new name from user. + var newName = this.promptForNewCategoryName('What do you want to change this' + + ' category\'s name to?'); + if (!newName) { // If cancelled. + return; + } + // Change category name. + this.model.getSelected().changeName(newName); + this.view.updateCategoryName(newName, this.model.getSelectedId()); + // Update preview. + this.updatePreview(); +}; + +/** + * Tied to arrow up and arrow down buttons. Swaps with the element above or + * below the currently selected element (offset categories away from the + * current element). Updates state to enable the correct element editing + * buttons. + * + * @param {int} offset The index offset from the currently selected element + * to swap with. Positive if the element to be swapped with is below, negative + * if the element to be swapped with is above. + */ +FactoryController.prototype.moveElement = function(offset) { + var curr = this.model.getSelected(); + if (!curr) { // Return if no selected element. + return; + } + var currIndex = this.model.getIndexByElementId(curr.id); + var swapIndex = this.model.getIndexByElementId(curr.id) + offset; + var swap = this.model.getElementByIndex(swapIndex); + if (!swap) { // Return if cannot swap in that direction. + return; + } + // Move currently selected element to index of other element. + // Indexes must be valid because confirmed that curr and swap exist. + this.moveElementToIndex(curr, swapIndex, currIndex); + // Update element editing buttons. + this.view.updateState(swapIndex, this.model.getSelected()); + // Update preview. + this.updatePreview(); +}; + +/** + * Moves a element to a specified index and updates the model and view + * accordingly. Helper functions throw an error if indexes are out of bounds. + * + * @param {!Element} element The element to move. + * @param {int} newIndex The index to insert the element at. + * @param {int} oldIndex The index the element is currently at. + */ +FactoryController.prototype.moveElementToIndex = function(element, newIndex, + oldIndex) { + this.model.moveElementToIndex(element, newIndex, oldIndex); + this.view.moveTabToIndex(element.id, newIndex, oldIndex); +}; + +/** + * Changes the color of the selected category. Return if selected element is + * a separator. + * + * @param {!string} color The color to change the selected category. Must be + * a valid CSS string. + */ +FactoryController.prototype.changeSelectedCategoryColor = function(color) { + // Return if no category selected or element a separator. + if (!this.model.getSelected() || + this.model.getSelected().type == ListElement.TYPE_SEPARATOR) { + return; + } + // Change color of selected category. + this.model.getSelected().changeColor(color); + this.view.setBorderColor(this.model.getSelectedId(), color); + this.updatePreview(); +}; + +/** + * Tied to the "Standard Category" dropdown option, this function prompts + * the user for a name of a standard Blockly category (case insensitive) and + * loads it as a new category and switches to it. Leverages standardCategories + * map in standard_categories.js. + */ +FactoryController.prototype.loadCategory = function() { + // Prompt user for the name of the standard category to load. + do { + var name = prompt('Enter the name of the category you would like to import ' + + '(Logic, Loops, Math, Text, Lists, Colour, Variables, or Functions)'); + if (!name) { + return; // Exit if cancelled. + } + } while (!this.isStandardCategoryName(name)); + + // Check if the user can create that standard category. + if (this.model.hasVariables() && name.toLowerCase() == 'variables') { + alert('A Variables category already exists. You cannot create multiple' + + ' variables categories.'); + return; + } + if (this.model.hasProcedures() && name.toLowerCase() == 'functions') { + alert('A Functions category already exists. You cannot create multiple' + + ' functions categories.'); + return; + } + // Check if the user can create a category with that name. + var standardCategory = this.standardCategories[name.toLowerCase()] + if (this.model.hasCategoryByName(standardCategory.name)) { + alert('You already have a category with the name ' + standardCategory.name + + '. Rename your category and try again.'); + return; + } + + // Copy the standard category in the model. + var copy = standardCategory.copy(); + + // Add the copy in the view. + var tab = this.view.addCategoryRow(copy.name, copy.id, + !this.model.hasToolbox()); + + // Add it to the model. + this.model.addElementToList(copy); + + // Update the view. + this.addClickToSwitch(tab, copy.id); + // Color the category tab in the view. + if (copy.color) { + this.view.setBorderColor(copy.id, copy.color); + } + // Switch to loaded category. + this.switchElement(copy.id); + // Convert actual shadow blocks to user-generated shadow blocks. + this.convertShadowBlocks(); + // Update preview. + this.updatePreview(); +}; + +/** + * Given the name of a category, determines if it's the name of a standard + * category (case insensitive). + * + * @param {string} name The name of the category that should be checked if it's + * in standardCategories + * @return {boolean} True if name is a standard category name, false otherwise. + */ +FactoryController.prototype.isStandardCategoryName = function(name) { + for (var category in this.standardCategories) { + if (name.toLowerCase() == category) { + return true; + } + } + return false; +}; + +/** + * Connected to the "add separator" dropdown option. If categories already + * exist, adds a separator to the model and view. Does not switch to select + * the separator, and updates the preview. + */ +FactoryController.prototype.addSeparator = function() { + // Don't allow the user to add a separator if a category has not been created. + if (!this.model.hasToolbox()) { + alert('Add a category before adding a separator.'); + return; + } + // Create the separator in the model. + var separator = new ListElement(ListElement.TYPE_SEPARATOR); + this.model.addElementToList(separator); + // Create the separator in the view. + var tab = this.view.addSeparatorTab(separator.id); + this.addClickToSwitch(tab, separator.id); + // Switch to the separator and update the preview. + this.switchElement(separator.id); + this.updatePreview(); +}; + +/** + * Connected to the import button. Given the file path inputted by the user + * from file input, this function loads that toolbox XML to the workspace, + * creating category and separator tabs as necessary. This allows the user + * to be able to edit toolboxes given their XML form. Catches errors from + * file reading and prints an error message alerting the user. + * + * @param {string} file The path for the file to be imported into the workspace. + * Should contain valid toolbox XML. + */ +FactoryController.prototype.importFile = function(file) { + // Exit if cancelled. + if (!file) { + return; + } + + var reader = new FileReader(); + // To be executed when the reader has read the file. + reader.onload = function() { + // Try to parse XML from file and load it into toolbox editing area. + // Print error message if fail. + try { + var tree = Blockly.Xml.textToDom(reader.result); + controller.importFromTree_(tree); + } catch(e) { + alert('Cannot load XML from file.'); + console.log(e); + } + } + + // Read the file. + reader.readAsText(file); +}; + +/** + * Given a XML DOM tree, loads it into the toolbox editing area so that the + * user can continue editing their work. Assumes that tree is in valid toolbox + * XML format. + * @private + * + * @param {!Element} tree XML tree to be loaded to toolbox editing area. + */ +FactoryController.prototype.importFromTree_ = function(tree) { + // Clear current editing area. + this.model.clearToolboxList(); + this.view.clearToolboxTabs(); + + if (tree.getElementsByTagName('category').length == 0) { + // No categories present. + // Load all the blocks into a single category evenly spaced. + Blockly.Xml.domToWorkspace(tree, this.toolboxWorkspace); + this.toolboxWorkspace.cleanUp_(); + + // Convert actual shadow blocks to user-generated shadow blocks. + this.convertShadowBlocks(); + + // Add message to denote empty category. + this.view.addEmptyCategoryMessage(); + } else { + // Categories/separators present. + for (var i = 0, item; item = tree.children[i]; i++) { + if (item.tagName == 'category') { + // If the element is a category, create a new category and switch to it. + this.createCategory(item.getAttribute('name'), false); + var category = this.model.getElementByIndex(i); + this.switchElement(category.id); + + // Load all blocks in that category to the workspace to be evenly + // spaced and saved to that category. + for (var j = 0, blockXml; blockXml = item.children[j]; j++) { + Blockly.Xml.domToBlock(blockXml, this.toolboxWorkspace); + } + + // Evenly space the blocks. + // TODO(evd2014): Change to cleanUp once cleanUp_ is made public in + // master. + this.toolboxWorkspace.cleanUp_(); + + // Convert actual shadow blocks to user-generated shadow blocks. + this.convertShadowBlocks(); + + // Set category color. + if (item.getAttribute('colour')) { + category.changeColor(item.getAttribute('colour')); + this.view.setBorderColor(category.id, category.color); + } + // Set any custom tags. + if (item.getAttribute('custom')) { + this.model.addCustomTag(category, item.getAttribute('custom')); + } + } else { + // If the element is a separator, add the separator and switch to it. + this.addSeparator(); + this.switchElement(this.model.getElementByIndex(i).id); + } + } + } + this.view.updateState(this.model.getIndexByElementId + (this.model.getSelectedId()), this.model.getSelected()); + this.updatePreview(); +}; + +/** + * Clears the toolbox editing area completely, deleting all categories and all + * blocks in the model and view. + */ +FactoryController.prototype.clear = function() { + this.model.clearToolboxList(); + this.view.clearToolboxTabs(); + this.view.addEmptyCategoryMessage(); + this.view.updateState(-1, null); + this.toolboxWorkspace.clear(); + this.toolboxWorkspace.clearUndo(); + this.updatePreview(); +}; + +/* + * Makes the currently selected block a user-generated shadow block. These + * blocks are not made into real shadow blocks, but recorded in the model + * and visually marked as shadow blocks, allowing the user to move and edit + * them (which would be impossible with actual shadow blocks). Updates the + * preview when done. + * + */ +FactoryController.prototype.addShadow = function() { + // No block selected to make a shadow block. + if (!Blockly.selected) { + return; + } + this.view.markShadowBlock(Blockly.selected); + this.model.addShadowBlock(Blockly.selected.id); + this.updatePreview(); +}; + +/** + * If the currently selected block is a user-generated shadow block, this + * function makes it a normal block again, removing it from the list of + * shadow blocks and loading the workspace again. Updates the preview again. + * + */ +FactoryController.prototype.removeShadow = function() { + // No block selected to modify. + if (!Blockly.selected) { + return; + } + this.model.removeShadowBlock(Blockly.selected.id); + this.view.unmarkShadowBlock(Blockly.selected); + this.updatePreview(); +}; + +/** + * Given a unique block ID, uses the model to determine if a block is a + * user-generated shadow block. + * + * @param {!string} blockId The unique ID of the block to examine. + * @return {boolean} True if the block is a user-generated shadow block, false + * otherwise. + */ +FactoryController.prototype.isUserGenShadowBlock = function(blockId) { + return this.model.isShadowBlock(blockId); +} + +/** + * Call when importing XML containing real shadow blocks. This function turns + * all real shadow blocks loaded in the workspace into user-generated shadow + * blocks, meaning they are marked as shadow blocks by the model and appear as + * shadow blocks in the view but are still editable and movable. + */ +FactoryController.prototype.convertShadowBlocks = function() { + var blocks = this.toolboxWorkspace.getAllBlocks(); + for (var i = 0, block; block = blocks[i]; i++) { + if (block.isShadow()) { + block.setShadow(false); + this.model.addShadowBlock(block.id); + this.view.markShadowBlock(block); + } + } +}; diff --git a/demos/blocklyfactory/workspacefactory/wfactory_generator.js b/demos/blocklyfactory/workspacefactory/wfactory_generator.js new file mode 100644 index 000000000..2de701e80 --- /dev/null +++ b/demos/blocklyfactory/workspacefactory/wfactory_generator.js @@ -0,0 +1,159 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2016 Google Inc. + * https://developers.google.com/blockly/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Generates the configuration xml used to update the preview + * workspace or print to the console or download to a file. Leverages + * Blockly.Xml and depends on information in the model (holds a reference). + * Depends on a hidden workspace created in the generator to load saved XML in + * order to generate toolbox XML. + * + * @author Emma Dauterman (evd2014) + */ + +/** + * Class for a FactoryGenerator + * @constructor + */ +FactoryGenerator = function(model) { + // Model to share information about categories and shadow blocks. + this.model = model; + // Create hidden workspace to load saved XML to generate toolbox XML. + var hiddenBlocks = document.createElement('div'); + // Generate a globally unique ID for the hidden div element to avoid + // collisions. + var hiddenBlocksId = Blockly.genUid(); + hiddenBlocks.id = hiddenBlocksId; + hiddenBlocks.style.display = 'none'; + document.body.appendChild(hiddenBlocks); + this.hiddenWorkspace = Blockly.inject(hiddenBlocksId); +}; + +/** + * Generates the xml for the toolbox or flyout with information from + * toolboxWorkspace and the model. Uses the hiddenWorkspace to generate XML. + * + * @param {!Blockly.workspace} toolboxWorkspace Toolbox editing workspace where + * blocks are added by user to be part of the toolbox. + * @return {!Element} XML element representing toolbox or flyout corresponding + * to toolbox workspace. + */ +FactoryGenerator.prototype.generateConfigXml = function(toolboxWorkspace) { + // Create DOM for XML. + var xmlDom = goog.dom.createDom('xml', + { + 'id' : 'toolbox', + 'style' : 'display:none' + }); + if (!this.model.hasToolbox()) { + // Toolbox has no categories. Use XML directly from workspace. + this.loadToHiddenWorkspaceAndSave_ + (Blockly.Xml.workspaceToDom(toolboxWorkspace), xmlDom); + } else { + // Toolbox has categories. + // Assert that selected != null + if (!this.model.getSelected()) { + throw new Error('Selected is null when the toolbox is empty.'); + } + + // Capture any changes made by user before generating XML. + this.model.getSelected().saveFromWorkspace(toolboxWorkspace); + var xml = this.model.getSelectedXml(); + var toolboxList = this.model.getToolboxList(); + + // Iterate through each category to generate XML for each using the + // hidden workspace. Load each category to the hidden workspace to make sure + // that all the blocks that are not top blocks are also captured as block + // groups in the flyout. + for (var i = 0; i < toolboxList.length; i++) { + var element = toolboxList[i]; + if (element.type == ListElement.TYPE_SEPARATOR) { + // If the next element is a separator. + var nextElement = goog.dom.createDom('sep'); + } else { + // If the next element is a category. + var nextElement = goog.dom.createDom('category'); + nextElement.setAttribute('name', element.name); + // Add a colour attribute if one exists. + if (element.color != null) { + nextElement.setAttribute('colour', element.color); + } + // Add a custom attribute if one exists. + if (element.custom != null) { + nextElement.setAttribute('custom', element.custom); + } + // Load that category to hidden workspace, setting user-generated shadow + // blocks as real shadow blocks. + this.loadToHiddenWorkspaceAndSave_(element.xml, nextElement); + } + xmlDom.appendChild(nextElement); + } + } + return xmlDom; + }; + +/** + * Load the given XML to the hidden workspace, set any user-generated shadow + * blocks to be actual shadow blocks, then append the XML from the workspace + * to the DOM element passed in. + * @private + * + * @param {!Element} xml The XML to be loaded to the hidden workspace. + * @param {!Element} dom The DOM element to append the generated XML to. + */ +FactoryGenerator.prototype.loadToHiddenWorkspaceAndSave_ = function(xml, dom) { + this.hiddenWorkspace.clear(); + Blockly.Xml.domToWorkspace(xml, this.hiddenWorkspace); + this.setShadowBlocksInHiddenWorkspace_(); + this.appendHiddenWorkspaceToDom_(dom); +} + + /** + * Encodes blocks in the hidden workspace in a XML DOM element. Very + * similar to workspaceToDom, but doesn't capture IDs. Uses the top-level + * blocks loaded in hiddenWorkspace. + * @private + * + * @param {!Element} xmlDom Tree of XML elements to be appended to. + */ +FactoryGenerator.prototype.appendHiddenWorkspaceToDom_ = function(xmlDom) { + var blocks = this.hiddenWorkspace.getTopBlocks(); + for (var i = 0, block; block = blocks[i]; i++) { + var blockChild = Blockly.Xml.blockToDom(block); + blockChild.removeAttribute('id'); + xmlDom.appendChild(blockChild); + } +}; + +/** + * Sets the user-generated shadow blocks loaded into hiddenWorkspace to be + * actual shadow blocks. This is done so that blockToDom records them as + * shadow blocks instead of regular blocks. + * @private + * + */ +FactoryGenerator.prototype.setShadowBlocksInHiddenWorkspace_ = function() { + var blocks = this.hiddenWorkspace.getAllBlocks(); + for (var i = 0; i < blocks.length; i++) { + if (this.model.isShadowBlock(blocks[i].id)) { + blocks[i].setShadow(true); + } + } +}; diff --git a/demos/blocklyfactory/workspacefactory/wfactory_model.js b/demos/blocklyfactory/workspacefactory/wfactory_model.js new file mode 100644 index 000000000..3d8585c9a --- /dev/null +++ b/demos/blocklyfactory/workspacefactory/wfactory_model.js @@ -0,0 +1,450 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2016 Google Inc. + * https://developers.google.com/blockly/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Stores and updates information about state and categories + * in workspace factory. Each list element is either a separator or a category, + * and each category stores its name, XML to load that category, color, + * custom tags, and a unique ID making it possible to change category names and + * move categories easily. Keeps track of the currently selected list + * element. Also keeps track of all the user-created shadow blocks and + * manipulates them as necessary. + * + * @author Emma Dauterman (evd2014) + */ + +/** + * Class for a FactoryModel + * @constructor + */ +FactoryModel = function() { + // Ordered list of ListElement objects. + this.toolboxList = []; + // Array of block IDs for all user created shadow blocks. + this.shadowBlocks = []; + // String name of current selected list element, null if no list elements. + this.selected = null; + // Boolean for if a Variable category has been added. + this.hasVariableCategory = false; + // Boolean for if a Procedure category has been added. + this.hasProcedureCategory = false; +}; + +// String name of current selected list element, null if no list elements. +FactoryModel.prototype.selected = null; + +/** + * Given a name, determines if it is the name of a category already present. + * Used when getting a valid category name from the user. + * + * @param {string} name String name to be compared against. + * @return {boolean} True if string is a used category name, false otherwise. + */ +FactoryModel.prototype.hasCategoryByName = function(name) { + for (var i = 0; i < this.toolboxList.length; i++) { + if (this.toolboxList[i].type == ListElement.TYPE_CATEGORY && + this.toolboxList[i].name == name) { + return true; + } + } + return false; +}; + +/** + * Determines if a category with the 'VARIABLE' tag exists. + * + * @return {boolean} True if there exists a category with the Variables tag, + * false otherwise. + */ +FactoryModel.prototype.hasVariables = function() { + return this.hasVariableCategory; +}; + +/** + * Determines if a category with the 'PROCEDURE' tag exists. + * + * @return {boolean} True if there exists a category with the Procedures tag, + * false otherwise. + */ +FactoryModel.prototype.hasProcedures = function() { + return this.hasFunctionCategory; +}; + +/** + * Determines if the user has any elements in the toolbox. Uses the length of + * toolboxList. + * + * @return {boolean} True if categories exist, false otherwise. + */ +FactoryModel.prototype.hasToolbox = function() { + return this.toolboxList.length > 0; +}; + +/** + * Given a ListElement, adds it to the toolbox list. + * + * @param {!ListElement} element The element to be added to the list. + */ +FactoryModel.prototype.addElementToList = function(element) { + // Update state if the copied category has a custom tag. + this.hasVariableCategory = element.custom == 'VARIABLE' ? true : + this.hasVariableCategory; + this.hasProcedureCategory = element.custom == 'PROCEDURE' ? true : + this.hasProcedureCategory; + // Add element to toolboxList. + this.toolboxList.push(element); +}; + +/** + * Given an index, deletes a list element and all associated data. + * + * @param {int} index The index of the list element to delete. + */ +FactoryModel.prototype.deleteElementFromList = function(index) { + // Check if index is out of bounds. + if (index < 0 || index >= this.toolboxList.length) { + return; // No entry to delete. + } + // Check if need to update flags. + this.hasVariableCategory = this.toolboxList[index].custom == 'VARIABLE' ? + false : this.hasVariableCategory; + this.hasProcedureCategory = this.toolboxList[index].custom == 'PROCEDURE' ? + false : this.hasProcedureCategory; + // Remove element. + this.toolboxList.splice(index, 1); +}; + +/** + * Moves a list element to a certain position in toolboxList by removing it + * and then inserting it at the correct index. Checks that indices are in + * bounds (throws error if not), but assumes that oldIndex is the correct index + * for list element. + * + * @param {!ListElement} element The element to move in toolboxList. + * @param {int} newIndex The index to insert the element at. + * @param {int} oldIndex The index the element is currently at. + */ +FactoryModel.prototype.moveElementToIndex = function(element, newIndex, + oldIndex) { + // Check that indexes are in bounds. + if (newIndex < 0 || newIndex >= this.toolboxList.length || oldIndex < 0 || + oldIndex >= this.toolboxList.length) { + throw new Error('Index out of bounds when moving element in the model.'); + } + this.deleteElementFromList(oldIndex); + this.toolboxList.splice(newIndex, 0, element); +} + +/** + * Returns the ID of the currently selected element. Returns null if there are + * no categories (if selected == null). + * + * @return {string} The ID of the element currently selected. + */ +FactoryModel.prototype.getSelectedId = function() { + return this.selected ? this.selected.id : null; +}; + +/** + * Returns the name of the currently selected category. Returns null if there + * are no categories (if selected == null) or the selected element is not + * a category (in which case its name is null). + * + * @return {string} The name of the category currently selected. + */ +FactoryModel.prototype.getSelectedName = function() { + return this.selected ? this.selected.name : null; +}; + +/** + * Returns the currently selected list element object. + * + * @return {ListElement} The currently selected ListElement + */ +FactoryModel.prototype.getSelected = function() { + return this.selected; +}; + +/** + * Sets list element currently selected by id. + * + * @param {string} id ID of list element that should now be selected. + */ +FactoryModel.prototype.setSelectedById = function(id) { + this.selected = this.getElementById(id); +}; + +/** + * Given an ID of a list element, returns the index of that list element in + * toolboxList. Returns -1 if ID is not present. + * + * @param {!string} id The ID of list element to search for. + * @return {int} The index of the list element in toolboxList, or -1 if it + * doesn't exist. + */ + +FactoryModel.prototype.getIndexByElementId = function(id) { + for (var i = 0; i < this.toolboxList.length; i++) { + if (this.toolboxList[i].id == id) { + return i; + } + } + return -1; // ID not present in toolboxList. +}; + +/** + * Given the ID of a list element, returns that ListElement object. + * + * @param {!string} id The ID of element to search for. + * @return {ListElement} Corresponding ListElement object in toolboxList, or + * null if that element does not exist. + */ +FactoryModel.prototype.getElementById = function(id) { + for (var i = 0; i < this.toolboxList.length; i++) { + if (this.toolboxList[i].id == id) { + return this.toolboxList[i]; + } + } + return null; // ID not present in toolboxList. +}; + +/** + * Given the index of a list element in toolboxList, returns that ListElement + * object. + * + * @param {int} index The index of the element to return. + * @return {ListElement} The corresponding ListElement object in toolboxList. + */ +FactoryModel.prototype.getElementByIndex = function(index) { + if (index < 0 || index >= this.toolboxList.length) { + return null; + } + return this.toolboxList[index]; +}; + +/** + * Returns the xml to load the selected element. + * + * @return {!Element} The XML of the selected element, or null if there is + * no selected element. + */ +FactoryModel.prototype.getSelectedXml = function() { + return this.selected ? this.selected.xml : null; +}; + +/** + * Return ordered list of ListElement objects. + * + * @return {!Array} ordered list of ListElement objects + */ +FactoryModel.prototype.getToolboxList = function() { + return this.toolboxList; +}; + +/** + * Gets the ID of a category given its name. + * + * @param {string} name Name of category. + * @return {int} ID of category + */ +FactoryModel.prototype.getCategoryIdByName = function(name) { + for (var i = 0; i < this.toolboxList.length; i++) { + if (this.toolboxList[i].name == name) { + return this.toolboxList[i].id; + } + } + return null; // Name not present in toolboxList. +}; + +/** + * Clears the toolbox list, deleting all ListElements. + */ +FactoryModel.prototype.clearToolboxList = function() { + this.toolboxList = []; + this.hasVariableCategory = false; + this.hasVariableCategory = false; + // TODO(evd2014): When merge changes, also clear shadowList. +}; + +/** + * Class for a ListElement + * Adds a shadow block to the list of shadow blocks. + * + * @param {!string} blockId The unique ID of block to be added. + */ +FactoryModel.prototype.addShadowBlock = function(blockId) { + this.shadowBlocks.push(blockId); +}; + +/** + * Removes a shadow block ID from the list of shadow block IDs if that ID is + * in the list. + * + * @param {!string} blockId The unique ID of block to be removed. + */ +FactoryModel.prototype.removeShadowBlock = function(blockId) { + for (var i = 0; i < this.shadowBlocks.length; i++) { + if (this.shadowBlocks[i] == blockId) { + this.shadowBlocks.splice(i, 1); + return; + } + } +}; + +/** + * Determines if a block is a shadow block given a unique block ID. + * + * @param {!string} blockId The unique ID of the block to examine. + * @return {boolean} True if the block is a user-generated shadow block, false + * otherwise. + */ +FactoryModel.prototype.isShadowBlock = function(blockId) { + for (var i = 0; i < this.shadowBlocks.length; i++) { + if (this.shadowBlocks[i] == blockId) { + return true; + } + } + return false; +}; + +/** + * Given a set of blocks currently loaded, returns all blocks in the workspace + * that are user generated shadow blocks. + * + * @param {!} blocks Array of blocks currently loaded. + * @return {!} Array of user-generated shadow blocks currently + * loaded. + */ +FactoryModel.prototype.getShadowBlocksInWorkspace = function(workspaceBlocks) { + var shadowsInWorkspace = []; + for (var i = 0; i < workspaceBlocks.length; i++) { + if (this.isShadowBlock(workspaceBlocks[i].id)) { + shadowsInWorkspace.push(workspaceBlocks[i]); + } + } + return shadowsInWorkspace; +}; + +/** + * Adds a custom tag to a category, updating state variables accordingly. + * Only accepts 'VARIABLE' and 'PROCEDURE' tags. + * + * @param {!ListElement} category The category to add the tag to. + * @param {!string} tag The custom tag to add to the category. + */ +FactoryModel.prototype.addCustomTag = function(category, tag) { + // Only update list elements that are categories. + if (category.type != ListElement.TYPE_CATEGORY) { + return; + } + // Only update the tag to be 'VARIABLE' or 'PROCEDURE'. + if (tag == 'VARIABLE') { + this.hasVariableCategory = true; + category.custom = 'VARIABLE'; + } else if (tag == 'PROCEDURE') { + this.hasProcedureCategory = true; + category.custom = 'PROCEDURE'; + } +}; + + +/** + * Class for a ListElement. + * @constructor + */ +ListElement = function(type, opt_name) { + this.type = type; + // XML DOM element to load the element. + this.xml = Blockly.Xml.textToDom(''); + // Name of category. Can be changed by user. Null if separator. + this.name = opt_name ? opt_name : null; + // Unique ID of element. Does not change. + this.id = Blockly.genUid(); + // Color of category. Default is no color. Null if separator. + this.color = null; + // Stores a custom tag, if necessary. Null if no custom tag or separator. + this.custom = null; +}; + +// List element types. +ListElement.TYPE_CATEGORY = 'category'; +ListElement.TYPE_SEPARATOR = 'separator'; + +/** + * Saves a category by updating its XML (does not save XML for + * elements that are not categories). + * + * @param {!Blockly.workspace} workspace The workspace to save category entry + * from. + */ +ListElement.prototype.saveFromWorkspace = function(workspace) { + // Only save list elements that are categories. + if (this.type != ListElement.TYPE_CATEGORY) { + return; + } + this.xml = Blockly.Xml.workspaceToDom(workspace); +}; + + +/** + * Changes the name of a category object given a new name. Returns if + * not a category. + * + * @param {string} name New name of category. + */ +ListElement.prototype.changeName = function (name) { + // Only update list elements that are categories. + if (this.type != ListElement.TYPE_CATEGORY) { + return; + } + this.name = name; +}; + +/** + * Sets the color of a category. If tries to set the color of something other + * than a category, returns. + * + * @param {!string} color The color that should be used for that category. + */ +ListElement.prototype.changeColor = function (color) { + if (this.type != ListElement.TYPE_CATEGORY) { + return; + } + this.color = color; +}; + +/** + * Makes a copy of the original element and returns it. Everything about the + * copy is identical except for its ID. + * + * @return {!ListElement} The copy of the ListElement. + */ +ListElement.prototype.copy = function() { + copy = new ListElement(this.type); + // Generate a unique ID for the element. + copy.id = Blockly.genUid(); + // Copy all attributes except ID. + copy.name = this.name; + copy.xml = this.xml; + copy.color = this.color; + copy.custom = this.custom; + // Return copy. + return copy; +}; diff --git a/demos/blocklyfactory/workspacefactory/wfactory_view.js b/demos/blocklyfactory/workspacefactory/wfactory_view.js new file mode 100644 index 000000000..6ad6bf51d --- /dev/null +++ b/demos/blocklyfactory/workspacefactory/wfactory_view.js @@ -0,0 +1,341 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2016 Google Inc. + * https://developers.google.com/blockly/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Controls the UI elements for workspace factory, mainly the category tabs. + * Also includes downloading files because that interacts directly with the DOM. + * Depends on FactoryController (for adding mouse listeners). Tabs for each + * category are stored in tab map, which associates a unique ID for a + * category with a particular tab. + * + * @author Emma Dauterman (edauterman) + */ + + /** + * Class for a FactoryView + * @constructor + */ + +FactoryView = function() { + // For each tab, maps ID of a ListElement to the td DOM element. + this.tabMap = Object.create(null); +}; + +/** + * Adds a category tab to the UI, and updates tabMap accordingly. + * + * @param {!string} name The name of the category being created + * @param {!string} id ID of category being created + * @param {boolean} firstCategory true if it's the first category, false + * otherwise + * @return {!Element} DOM element created for tab + */ +FactoryView.prototype.addCategoryRow = function(name, id, firstCategory) { + var table = document.getElementById('categoryTable'); + // Delete help label and enable category buttons if it's the first category. + if (firstCategory) { + table.deleteRow(0); + } + // Create tab. + var count = table.rows.length; + var row = table.insertRow(count); + var nextEntry = row.insertCell(0); + // Configure tab. + nextEntry.id = this.createCategoryIdName(name); + nextEntry.textContent = name; + // Store tab. + this.tabMap[id] = table.rows[count].cells[0]; + // Return tab. + return nextEntry; +}; + +/** + * Deletes a category tab from the UI and updates tabMap accordingly. + * + * @param {!string} id ID of category to be deleted. + * @param {!string} name The name of the category to be deleted. + */ +FactoryView.prototype.deleteElementRow = function(id, index) { + // Delete tab entry. + delete this.tabMap[id]; + // Delete tab row. + var table = document.getElementById('categoryTable'); + var count = table.rows.length; + table.deleteRow(index); + + // If last category removed, add category help text and disable category + // buttons. + this.addEmptyCategoryMessage(); +}; + +/** + * If there are no toolbox elements created, adds a help message to show + * where categories will appear. Should be called when deleting list elements + * in case the last element is deleted. + */ +FactoryView.prototype.addEmptyCategoryMessage = function() { + var table = document.getElementById('categoryTable'); + if (table.rows.length == 0) { + var row = table.insertRow(0); + row.textContent = 'Your categories will appear here'; + } +} + +/** + * Given the index of the currently selected element, updates the state of + * the buttons that allow the user to edit the list elements. Updates the edit + * and arrow buttons. Should be called when adding or removing elements + * or when changing to a new element or when swapping to a different element. + * + * TODO(evd2014): Switch to using CSS to add/remove styles. + * + * @param {int} selectedIndex The index of the currently selected category, + * -1 if no categories created. + * @param {ListElement} selected The selected ListElement. + */ +FactoryView.prototype.updateState = function(selectedIndex, selected) { + // Disable/enable editing buttons as necessary. + document.getElementById('button_editCategory').disabled = selectedIndex < 0 || + selected.type != ListElement.TYPE_CATEGORY; + document.getElementById('button_remove').disabled = selectedIndex < 0; + document.getElementById('button_up').disabled = + selectedIndex <= 0 ? true : false; + var table = document.getElementById('categoryTable'); + document.getElementById('button_down').disabled = selectedIndex >= + table.rows.length - 1 || selectedIndex < 0 ? true : false; + // Disable/enable the workspace as necessary. + this.disableWorkspace(this.shouldDisableWorkspace(selected)); +}; + +/** + * Determines the DOM id for a category given its name. + * + * @param {!string} name Name of category + * @return {!string} ID of category tab + */ +FactoryView.prototype.createCategoryIdName = function(name) { + return 'tab_' + name; +}; + +/** + * Switches a tab on or off. + * + * @param {!string} id ID of the tab to switch on or off. + * @param {boolean} selected True if tab should be on, false if tab should be + * off. + */ +FactoryView.prototype.setCategoryTabSelection = function(id, selected) { + if (!this.tabMap[id]) { + return; // Exit if tab does not exist. + } + this.tabMap[id].className = selected ? 'tabon' : 'taboff'; +}; + +/** + * Used to bind a click to a certain DOM element (used for category tabs). + * Taken directly from code.js + * + * @param {string|!Element} e1 tab element or corresponding id string + * @param {!Function} func Function to be executed on click + */ +FactoryView.prototype.bindClick = function(el, func) { + if (typeof el == 'string') { + el = document.getElementById(el); + } + el.addEventListener('click', func, true); + el.addEventListener('touchend', func, true); +}; + +/** + * Creates a file and downloads it. In some browsers downloads, and in other + * browsers, opens new tab with contents. + * + * @param {!string} filename Name of file + * @param {!Blob} data Blob containing contents to download + */ +FactoryView.prototype.createAndDownloadFile = function(filename, data) { + var clickEvent = new MouseEvent("click", { + "view": window, + "bubbles": true, + "cancelable": false + }); + var a = document.createElement('a'); + a.href = window.URL.createObjectURL(data); + a.download = filename; + a.textContent = 'Download file!'; + a.dispatchEvent(clickEvent); + }; + +/** + * Given the ID of a certain category, updates the corresponding tab in + * the DOM to show a new name. + * + * @param {!string} newName Name of string to be displayed on tab + * @param {!string} id ID of category to be updated + * + */ +FactoryView.prototype.updateCategoryName = function(newName, id) { + this.tabMap[id].textContent = newName; + this.tabMap[id].id = this.createCategoryIdName(newName); +}; + +/** + * Moves a tab from one index to another. Adjusts index inserting before + * based on if inserting before or after. Checks that the indexes are in + * bounds, throws error if not. + * + * @param {!string} id The ID of the category to move. + * @param {int} newIndex The index to move the category to. + * @param {int} oldIndex The index the category is currently at. + */ +FactoryView.prototype.moveTabToIndex = function(id, newIndex, oldIndex) { + var table = document.getElementById('categoryTable'); + // Check that indexes are in bounds + if (newIndex < 0 || newIndex >= table.rows.length || oldIndex < 0 || + oldIndex >= table.rows.length) { + throw new Error('Index out of bounds when moving tab in the view.'); + } + if (newIndex < oldIndex) { // Inserting before. + var row = table.insertRow(newIndex); + row.appendChild(this.tabMap[id]); + table.deleteRow(oldIndex + 1); + } else { // Inserting after. + var row = table.insertRow(newIndex + 1); + row.appendChild(this.tabMap[id]); + table.deleteRow(oldIndex); + } +}; + +/** + * Given a category ID and color, use that color to color the left border of the + * tab for that category. + * + * @param {!string} id The ID of the category to color. + * @param {!string} color The color for to be used for the border of the tab. + * Must be a valid CSS string. + */ +FactoryView.prototype.setBorderColor = function(id, color) { + var tab = this.tabMap[id]; + tab.style.borderLeftWidth = "8px"; + tab.style.borderLeftStyle = "solid"; + tab.style.borderColor = color; +}; + +/** + * Given a separator ID, creates a corresponding tab in the view, updates + * tab map, and returns the tab. + * + * @param {!string} id The ID of the separator. + * @param {!Element} The td DOM element representing the separator. + */ +FactoryView.prototype.addSeparatorTab = function(id) { + // Create separator. + var table = document.getElementById('categoryTable'); + var count = table.rows.length; + var row = table.insertRow(count); + var nextEntry = row.insertCell(0); + // Configure separator. + nextEntry.style.height = '10px'; + // Store and return separator. + this.tabMap[id] = table.rows[count].cells[0]; + return nextEntry; +}; + +/** + * Disables or enables the workspace by putting a div over or under the + * toolbox workspace, depending on the value of disable. Used when switching + * to/from separators where the user shouldn't be able to drag blocks into + * the workspace. + * + * @param {boolean} disable True if the workspace should be disabled, false + * if it should be enabled. + */ +FactoryView.prototype.disableWorkspace = function(disable) { + document.getElementById('disable_div').style.zIndex = disable ? 1 : -1; +}; + +/** + * Determines if the workspace should be disabled. The workspace should be + * disabled if category is a separator or has VARIABLE or PROCEDURE tags. + * + * @return {boolean} True if the workspace should be disabled, false otherwise. + */ +FactoryView.prototype.shouldDisableWorkspace = function(category) { + return category != null && (category.type == ListElement.TYPE_SEPARATOR || + category.custom == 'VARIABLE' || category.custom == 'PROCEDURE'); +}; + +/* + * Removes all categories and separators in the view. Clears the tabMap to + * reflect this. + */ +FactoryView.prototype.clearToolboxTabs = function() { + this.tabMap = []; + var oldCategoryTable = document.getElementById('categoryTable'); + var newCategoryTable = document.createElement('table'); + newCategoryTable.id = 'categoryTable'; + oldCategoryTable.parentElement.replaceChild(newCategoryTable, + oldCategoryTable); +}; + +/** + * Given a set of blocks currently loaded user-generated shadow blocks, visually + * marks them without making them actual shadow blocks (allowing them to still + * be editable and movable). + * + * @param {!} blocks Array of user-generated shadow blocks + * currently loaded. + */ +FactoryView.prototype.markShadowBlocks = function(blocks) { + for (var i = 0; i < blocks.length; i++) { + this.markShadowBlock(blocks[i]); + } +}; + +/** + * Visually marks a user-generated shadow block as a shadow block in the + * workspace without making the block an actual shadow block (allowing it + * to be moved and edited). + * + * @param {!Blockly.Block} block The block that should be marked as a shadow + * block (must be rendered). + */ +FactoryView.prototype.markShadowBlock = function(block) { + // Add Blockly CSS for user-generated shadow blocks. + Blockly.addClass_(block.svgGroup_, 'shadowBlock'); + // If not a valid shadow block, add a warning message. + if (!block.getSurroundParent()) { + block.setWarningText('Shadow blocks must be nested inside' + + ' other blocks to be displayed.'); + } +}; + +/** + * Removes visual marking for a shadow block given a rendered block. + * + * @param {!Blockly.Block} block The block that should be unmarked as a shadow + * block (must be rendered). + */ +FactoryView.prototype.unmarkShadowBlock = function(block) { + // Remove Blockly CSS for user-generated shadow blocks. + if (Blockly.hasClass_(block.svgGroup_, 'shadowBlock')) { + Blockly.removeClass_(block.svgGroup_, 'shadowBlock'); + } +};