- 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');
+ }
+};