mirror of
https://github.com/google/blockly.git
synced 2026-01-07 17:10:11 +01:00
* Add a new method to be called when the contents of the workspace change and the scrollbars need to be adjusted but the the chrome (trash, toolbox, etc) are expected to stay in the same place. Change a bunch of calls to svgResize to either be removed or call the new method instead. This is a nice performance win since the offsetHeight/Width call in svgResize can be expensive, especially when called as often as we do - there was some layout thrashing. This also paves the way for moving calls to recordDeleteAreas (which is also expensive) to a more cacheable spot than on every mouse down/touch event. of things (namely the scrollbars) * Fix size of graph demo when it first loads by calling svgResize. The graph starts with fixed width and was relying on a resize event to fire (which I believe was removed in commit217c681b86). * Fix the resizing of the code demo. The demo's tab min-width used to match the toolbox's width was only being set on a resize event, but commit217c681b86changed how that worked. * Fix up some comments. * Use specific workspaces rather than Blockly.getMainWorkspace(). * Make workspace required for resizeSvgContents and update some calls to send real workspaces rather than ones that are null. Remove the private tag on terminateDrag_ because it is only actually called from outside the BlockSvg object. * Remove a rogue period. * Recategorize BlockSvg.terminateDrag_ to @package instead of @private so that other developers don't use it, but it still can be used by other Blockly classes. * Add a TODO to fix issue #307. * Add @package to workspace resizeContents.
564 lines
18 KiB
JavaScript
564 lines
18 KiB
JavaScript
/**
|
|
* @license
|
|
* Visual Blocks Editor
|
|
*
|
|
* Copyright 2012 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 XML reader and writer.
|
|
* @author fraser@google.com (Neil Fraser)
|
|
*/
|
|
'use strict';
|
|
|
|
goog.provide('Blockly.Xml');
|
|
|
|
// TODO(scr): Fix circular dependencies
|
|
// goog.require('Blockly.Block');
|
|
goog.require('goog.asserts');
|
|
goog.require('goog.dom');
|
|
|
|
|
|
/**
|
|
* Encode a block tree as XML.
|
|
* @param {!Blockly.Workspace} workspace The workspace containing blocks.
|
|
* @return {!Element} XML document.
|
|
*/
|
|
Blockly.Xml.workspaceToDom = function(workspace) {
|
|
var xml = goog.dom.createDom('xml');
|
|
var blocks = workspace.getTopBlocks(true);
|
|
for (var i = 0, block; block = blocks[i]; i++) {
|
|
xml.appendChild(Blockly.Xml.blockToDomWithXY(block));
|
|
}
|
|
return xml;
|
|
};
|
|
|
|
/**
|
|
* Encode a block subtree as XML with XY coordinates.
|
|
* @param {!Blockly.Block} block The root block to encode.
|
|
* @return {!Element} Tree of XML elements.
|
|
*/
|
|
Blockly.Xml.blockToDomWithXY = function(block) {
|
|
var width; // Not used in LTR.
|
|
if (block.workspace.RTL) {
|
|
width = block.workspace.getWidth();
|
|
}
|
|
var element = Blockly.Xml.blockToDom(block);
|
|
var xy = block.getRelativeToSurfaceXY();
|
|
element.setAttribute('x',
|
|
Math.round(block.workspace.RTL ? width - xy.x : xy.x));
|
|
element.setAttribute('y', Math.round(xy.y));
|
|
return element;
|
|
};
|
|
|
|
/**
|
|
* Encode a block subtree as XML.
|
|
* @param {!Blockly.Block} block The root block to encode.
|
|
* @return {!Element} Tree of XML elements.
|
|
*/
|
|
Blockly.Xml.blockToDom = function(block) {
|
|
var element = goog.dom.createDom(block.isShadow() ? 'shadow' : 'block');
|
|
element.setAttribute('type', block.type);
|
|
element.setAttribute('id', block.id);
|
|
if (block.mutationToDom) {
|
|
// Custom data for an advanced block.
|
|
var mutation = block.mutationToDom();
|
|
if (mutation && (mutation.hasChildNodes() || mutation.hasAttributes())) {
|
|
element.appendChild(mutation);
|
|
}
|
|
}
|
|
function fieldToDom(field) {
|
|
if (field.name && field.EDITABLE) {
|
|
var container = goog.dom.createDom('field', null, field.getValue());
|
|
container.setAttribute('name', field.name);
|
|
element.appendChild(container);
|
|
}
|
|
}
|
|
for (var i = 0, input; input = block.inputList[i]; i++) {
|
|
for (var j = 0, field; field = input.fieldRow[j]; j++) {
|
|
fieldToDom(field);
|
|
}
|
|
}
|
|
|
|
var commentText = block.getCommentText();
|
|
if (commentText) {
|
|
var commentElement = goog.dom.createDom('comment', null, commentText);
|
|
if (typeof block.comment == 'object') {
|
|
commentElement.setAttribute('pinned', block.comment.isVisible());
|
|
var hw = block.comment.getBubbleSize();
|
|
commentElement.setAttribute('h', hw.height);
|
|
commentElement.setAttribute('w', hw.width);
|
|
}
|
|
element.appendChild(commentElement);
|
|
}
|
|
|
|
if (block.data) {
|
|
var dataElement = goog.dom.createDom('data', null, block.data);
|
|
element.appendChild(dataElement);
|
|
}
|
|
|
|
for (var i = 0, input; input = block.inputList[i]; i++) {
|
|
var container;
|
|
var empty = true;
|
|
if (input.type == Blockly.DUMMY_INPUT) {
|
|
continue;
|
|
} else {
|
|
var childBlock = input.connection.targetBlock();
|
|
if (input.type == Blockly.INPUT_VALUE) {
|
|
container = goog.dom.createDom('value');
|
|
} else if (input.type == Blockly.NEXT_STATEMENT) {
|
|
container = goog.dom.createDom('statement');
|
|
}
|
|
var shadow = input.connection.getShadowDom();
|
|
if (shadow && (!childBlock || !childBlock.isShadow())) {
|
|
container.appendChild(Blockly.Xml.cloneShadow_(shadow));
|
|
}
|
|
if (childBlock) {
|
|
container.appendChild(Blockly.Xml.blockToDom(childBlock));
|
|
empty = false;
|
|
}
|
|
}
|
|
container.setAttribute('name', input.name);
|
|
if (!empty) {
|
|
element.appendChild(container);
|
|
}
|
|
}
|
|
if (block.inputsInlineDefault != block.inputsInline) {
|
|
element.setAttribute('inline', block.inputsInline);
|
|
}
|
|
if (block.isCollapsed()) {
|
|
element.setAttribute('collapsed', true);
|
|
}
|
|
if (block.disabled) {
|
|
element.setAttribute('disabled', true);
|
|
}
|
|
if (!block.isDeletable() && !block.isShadow()) {
|
|
element.setAttribute('deletable', false);
|
|
}
|
|
if (!block.isMovable() && !block.isShadow()) {
|
|
element.setAttribute('movable', false);
|
|
}
|
|
if (!block.isEditable()) {
|
|
element.setAttribute('editable', false);
|
|
}
|
|
|
|
var nextBlock = block.getNextBlock();
|
|
if (nextBlock) {
|
|
var container = goog.dom.createDom('next', null,
|
|
Blockly.Xml.blockToDom(nextBlock));
|
|
element.appendChild(container);
|
|
}
|
|
var shadow = block.nextConnection && block.nextConnection.getShadowDom();
|
|
if (shadow && (!nextBlock || !nextBlock.isShadow())) {
|
|
container.appendChild(Blockly.Xml.cloneShadow_(shadow));
|
|
}
|
|
|
|
return element;
|
|
};
|
|
|
|
/**
|
|
* Deeply clone the shadow's DOM so that changes don't back-wash to the block.
|
|
* @param {!Element} shadow A tree of XML elements.
|
|
* @return {!Element} A tree of XML elements.
|
|
* @private
|
|
*/
|
|
Blockly.Xml.cloneShadow_ = function(shadow) {
|
|
shadow = shadow.cloneNode(true);
|
|
// Walk the tree looking for whitespace. Don't prune whitespace in a tag.
|
|
var node = shadow;
|
|
var textNode;
|
|
while (node) {
|
|
if (node.firstChild) {
|
|
node = node.firstChild;
|
|
} else {
|
|
while (node && !node.nextSibling) {
|
|
textNode = node;
|
|
node = node.parentNode;
|
|
if (textNode.nodeType == 3 && textNode.data.trim() == '' &&
|
|
node.firstChild != textNode) {
|
|
// Prune whitespace after a tag.
|
|
goog.dom.removeNode(textNode);
|
|
}
|
|
}
|
|
if (node) {
|
|
textNode = node;
|
|
node = node.nextSibling;
|
|
if (textNode.nodeType == 3 && textNode.data.trim() == '') {
|
|
// Prune whitespace before a tag.
|
|
goog.dom.removeNode(textNode);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return shadow;
|
|
};
|
|
|
|
/**
|
|
* Converts a DOM structure into plain text.
|
|
* Currently the text format is fairly ugly: all one line with no whitespace.
|
|
* @param {!Element} dom A tree of XML elements.
|
|
* @return {string} Text representation.
|
|
*/
|
|
Blockly.Xml.domToText = function(dom) {
|
|
var oSerializer = new XMLSerializer();
|
|
return oSerializer.serializeToString(dom);
|
|
};
|
|
|
|
/**
|
|
* Converts a DOM structure into properly indented text.
|
|
* @param {!Element} dom A tree of XML elements.
|
|
* @return {string} Text representation.
|
|
*/
|
|
Blockly.Xml.domToPrettyText = function(dom) {
|
|
// This function is not guaranteed to be correct for all XML.
|
|
// But it handles the XML that Blockly generates.
|
|
var blob = Blockly.Xml.domToText(dom);
|
|
// Place every open and close tag on its own line.
|
|
var lines = blob.split('<');
|
|
// Indent every line.
|
|
var indent = '';
|
|
for (var i = 1; i < lines.length; i++) {
|
|
var line = lines[i];
|
|
if (line[0] == '/') {
|
|
indent = indent.substring(2);
|
|
}
|
|
lines[i] = indent + '<' + line;
|
|
if (line[0] != '/' && line.slice(-2) != '/>') {
|
|
indent += ' ';
|
|
}
|
|
}
|
|
// Pull simple tags back together.
|
|
// E.g. <foo></foo>
|
|
var text = lines.join('\n');
|
|
text = text.replace(/(<(\w+)\b[^>]*>[^\n]*)\n *<\/\2>/g, '$1</$2>');
|
|
// Trim leading blank line.
|
|
return text.replace(/^\n/, '');
|
|
};
|
|
|
|
/**
|
|
* Converts plain text into a DOM structure.
|
|
* Throws an error if XML doesn't parse.
|
|
* @param {string} text Text representation.
|
|
* @return {!Element} A tree of XML elements.
|
|
*/
|
|
Blockly.Xml.textToDom = function(text) {
|
|
var oParser = new DOMParser();
|
|
var dom = oParser.parseFromString(text, 'text/xml');
|
|
// The DOM should have one and only one top-level node, an XML tag.
|
|
if (!dom || !dom.firstChild ||
|
|
dom.firstChild.nodeName.toLowerCase() != 'xml' ||
|
|
dom.firstChild !== dom.lastChild) {
|
|
// Whatever we got back from the parser is not XML.
|
|
goog.asserts.fail('Blockly.Xml.textToDom did not obtain a valid XML tree.');
|
|
}
|
|
return dom.firstChild;
|
|
};
|
|
|
|
/**
|
|
* Decode an XML DOM and create blocks on the workspace.
|
|
* @param {!Element} xml XML DOM.
|
|
* @param {!Blockly.Workspace} workspace The workspace.
|
|
*/
|
|
Blockly.Xml.domToWorkspace = function(xml, workspace) {
|
|
if (xml instanceof Blockly.Workspace) {
|
|
var swap = xml;
|
|
xml = workspace;
|
|
workspace = swap;
|
|
console.warn('Deprecated call to Blockly.Xml.domToWorkspace, ' +
|
|
'swap the arguments.');
|
|
}
|
|
var width; // Not used in LTR.
|
|
if (workspace.RTL) {
|
|
width = workspace.getWidth();
|
|
}
|
|
Blockly.Field.startCache();
|
|
// Safari 7.1.3 is known to provide node lists with extra references to
|
|
// children beyond the lists' length. Trust the length, do not use the
|
|
// looping pattern of checking the index for an object.
|
|
var childCount = xml.childNodes.length;
|
|
var existingGroup = Blockly.Events.getGroup();
|
|
if (!existingGroup) {
|
|
Blockly.Events.setGroup(true);
|
|
}
|
|
for (var i = 0; i < childCount; i++) {
|
|
var xmlChild = xml.childNodes[i];
|
|
var name = xmlChild.nodeName.toLowerCase();
|
|
if (name == 'block') {
|
|
var block = Blockly.Xml.domToBlock(xmlChild, workspace);
|
|
var blockX = parseInt(xmlChild.getAttribute('x'), 10);
|
|
var blockY = parseInt(xmlChild.getAttribute('y'), 10);
|
|
if (!isNaN(blockX) && !isNaN(blockY)) {
|
|
block.moveBy(workspace.RTL ? width - blockX : blockX, blockY);
|
|
}
|
|
} else if (name == 'shadow') {
|
|
goog.asserts.fail('Shadow block cannot be a top-level block.');
|
|
}
|
|
}
|
|
if (!existingGroup) {
|
|
Blockly.Events.setGroup(false);
|
|
}
|
|
Blockly.Field.stopCache();
|
|
};
|
|
|
|
/**
|
|
* Decode an XML block tag and create a block (and possibly sub blocks) on the
|
|
* workspace.
|
|
* @param {!Element} xmlBlock XML block element.
|
|
* @param {!Blockly.Workspace} workspace The workspace.
|
|
* @return {!Blockly.Block} The root block created.
|
|
*/
|
|
Blockly.Xml.domToBlock = function(xmlBlock, workspace) {
|
|
if (xmlBlock instanceof Blockly.Workspace) {
|
|
var swap = xmlBlock;
|
|
xmlBlock = workspace;
|
|
workspace = swap;
|
|
console.warn('Deprecated call to Blockly.Xml.domToBlock, ' +
|
|
'swap the arguments.');
|
|
}
|
|
// Create top-level block.
|
|
Blockly.Events.disable();
|
|
var topBlock = Blockly.Xml.domToBlockHeadless_(xmlBlock, workspace);
|
|
if (workspace.rendered) {
|
|
// Hide connections to speed up assembly.
|
|
topBlock.setConnectionsHidden(true);
|
|
// Generate list of all blocks.
|
|
var blocks = topBlock.getDescendants();
|
|
// Render each block.
|
|
for (var i = blocks.length - 1; i >= 0; i--) {
|
|
blocks[i].initSvg();
|
|
}
|
|
for (var i = blocks.length - 1; i >= 0; i--) {
|
|
blocks[i].render(false);
|
|
}
|
|
// Populating the connection database may be defered until after the blocks
|
|
// have renderend.
|
|
setTimeout(function() {
|
|
if (topBlock.workspace) { // Check that the block hasn't been deleted.
|
|
topBlock.setConnectionsHidden(false);
|
|
}
|
|
}, 1);
|
|
topBlock.updateDisabled();
|
|
// Allow the scrollbars to resize and move based on the new contents.
|
|
// TODO(@picklesrus): #387. Remove when domToBlock avoids resizing.
|
|
Blockly.resizeSvgContents(workspace);
|
|
}
|
|
Blockly.Events.enable();
|
|
if (Blockly.Events.isEnabled()) {
|
|
Blockly.Events.fire(new Blockly.Events.Create(topBlock));
|
|
}
|
|
return topBlock;
|
|
};
|
|
|
|
/**
|
|
* Decode an XML block tag and create a block (and possibly sub blocks) on the
|
|
* workspace.
|
|
* @param {!Element} xmlBlock XML block element.
|
|
* @param {!Blockly.Workspace} workspace The workspace.
|
|
* @return {!Blockly.Block} The root block created.
|
|
* @private
|
|
*/
|
|
Blockly.Xml.domToBlockHeadless_ = function(xmlBlock, workspace) {
|
|
var block = null;
|
|
var prototypeName = xmlBlock.getAttribute('type');
|
|
goog.asserts.assert(prototypeName, 'Block type unspecified: %s',
|
|
xmlBlock.outerHTML);
|
|
var id = xmlBlock.getAttribute('id');
|
|
block = workspace.newBlock(prototypeName, id);
|
|
|
|
var blockChild = null;
|
|
for (var i = 0, xmlChild; xmlChild = xmlBlock.childNodes[i]; i++) {
|
|
if (xmlChild.nodeType == 3) {
|
|
// Ignore any text at the <block> level. It's all whitespace anyway.
|
|
continue;
|
|
}
|
|
var input;
|
|
|
|
// Find any enclosed blocks or shadows in this tag.
|
|
var childBlockNode = null;
|
|
var childShadowNode = null;
|
|
for (var j = 0, grandchildNode; grandchildNode = xmlChild.childNodes[j];
|
|
j++) {
|
|
if (grandchildNode.nodeType == 1) {
|
|
if (grandchildNode.nodeName.toLowerCase() == 'block') {
|
|
childBlockNode = grandchildNode;
|
|
} else if (grandchildNode.nodeName.toLowerCase() == 'shadow') {
|
|
childShadowNode = grandchildNode;
|
|
}
|
|
}
|
|
}
|
|
// Use the shadow block if there is no child block.
|
|
if (!childBlockNode && childShadowNode) {
|
|
childBlockNode = childShadowNode;
|
|
}
|
|
|
|
var name = xmlChild.getAttribute('name');
|
|
switch (xmlChild.nodeName.toLowerCase()) {
|
|
case 'mutation':
|
|
// Custom data for an advanced block.
|
|
if (block.domToMutation) {
|
|
block.domToMutation(xmlChild);
|
|
if (block.initSvg) {
|
|
// Mutation may have added some elements that need initalizing.
|
|
block.initSvg();
|
|
}
|
|
}
|
|
break;
|
|
case 'comment':
|
|
block.setCommentText(xmlChild.textContent);
|
|
var visible = xmlChild.getAttribute('pinned');
|
|
if (visible && !block.isInFlyout) {
|
|
// Give the renderer a millisecond to render and position the block
|
|
// before positioning the comment bubble.
|
|
setTimeout(function() {
|
|
if (block.comment && block.comment.setVisible) {
|
|
block.comment.setVisible(visible == 'true');
|
|
}
|
|
}, 1);
|
|
}
|
|
var bubbleW = parseInt(xmlChild.getAttribute('w'), 10);
|
|
var bubbleH = parseInt(xmlChild.getAttribute('h'), 10);
|
|
if (!isNaN(bubbleW) && !isNaN(bubbleH) &&
|
|
block.comment && block.comment.setVisible) {
|
|
block.comment.setBubbleSize(bubbleW, bubbleH);
|
|
}
|
|
break;
|
|
case 'data':
|
|
block.data = xmlChild.textContent;
|
|
break;
|
|
case 'title':
|
|
// Titles were renamed to field in December 2013.
|
|
// Fall through.
|
|
case 'field':
|
|
var field = block.getField(name);
|
|
if (!field) {
|
|
console.warn('Ignoring non-existent field ' + name + ' in block ' +
|
|
prototypeName);
|
|
break;
|
|
}
|
|
field.setValue(xmlChild.textContent);
|
|
break;
|
|
case 'value':
|
|
case 'statement':
|
|
input = block.getInput(name);
|
|
if (!input) {
|
|
console.warn('Ignoring non-existent input ' + name + ' in block ' +
|
|
prototypeName);
|
|
break;
|
|
}
|
|
if (childShadowNode) {
|
|
input.connection.setShadowDom(childShadowNode);
|
|
}
|
|
if (childBlockNode) {
|
|
blockChild = Blockly.Xml.domToBlockHeadless_(childBlockNode,
|
|
workspace);
|
|
if (blockChild.outputConnection) {
|
|
input.connection.connect(blockChild.outputConnection);
|
|
} else if (blockChild.previousConnection) {
|
|
input.connection.connect(blockChild.previousConnection);
|
|
} else {
|
|
goog.asserts.fail(
|
|
'Child block does not have output or previous statement.');
|
|
}
|
|
}
|
|
break;
|
|
case 'next':
|
|
if (childShadowNode && block.nextConnection) {
|
|
block.nextConnection.setShadowDom(childShadowNode);
|
|
}
|
|
if (childBlockNode) {
|
|
goog.asserts.assert(block.nextConnection,
|
|
'Next statement does not exist.');
|
|
// If there is more than one XML 'next' tag.
|
|
goog.asserts.assert(!block.nextConnection.isConnected(),
|
|
'Next statement is already connected.');
|
|
blockChild = Blockly.Xml.domToBlockHeadless_(childBlockNode,
|
|
workspace);
|
|
goog.asserts.assert(blockChild.previousConnection,
|
|
'Next block does not have previous statement.');
|
|
block.nextConnection.connect(blockChild.previousConnection);
|
|
}
|
|
break;
|
|
default:
|
|
// Unknown tag; ignore. Same principle as HTML parsers.
|
|
console.warn('Ignoring unknown tag: ' + xmlChild.nodeName);
|
|
}
|
|
}
|
|
|
|
var inline = xmlBlock.getAttribute('inline');
|
|
if (inline) {
|
|
block.setInputsInline(inline == 'true');
|
|
}
|
|
var disabled = xmlBlock.getAttribute('disabled');
|
|
if (disabled) {
|
|
block.setDisabled(disabled == 'true');
|
|
}
|
|
var deletable = xmlBlock.getAttribute('deletable');
|
|
if (deletable) {
|
|
block.setDeletable(deletable == 'true');
|
|
}
|
|
var movable = xmlBlock.getAttribute('movable');
|
|
if (movable) {
|
|
block.setMovable(movable == 'true');
|
|
}
|
|
var editable = xmlBlock.getAttribute('editable');
|
|
if (editable) {
|
|
block.setEditable(editable == 'true');
|
|
}
|
|
var collapsed = xmlBlock.getAttribute('collapsed');
|
|
if (collapsed) {
|
|
block.setCollapsed(collapsed == 'true');
|
|
}
|
|
if (xmlBlock.nodeName.toLowerCase() == 'shadow') {
|
|
// Ensure all children are also shadows.
|
|
var children = block.getChildren();
|
|
for (var i = 0, child; child = children[i]; i++) {
|
|
goog.asserts.assert(child.isShadow(),
|
|
'Shadow block not allowed non-shadow child.');
|
|
}
|
|
block.setShadow(true);
|
|
}
|
|
// Give the block a chance to clean up any initial inputs.
|
|
if (block.validate) {
|
|
block.validate();
|
|
}
|
|
return block;
|
|
};
|
|
|
|
/**
|
|
* Remove any 'next' block (statements in a stack).
|
|
* @param {!Element} xmlBlock XML block element.
|
|
*/
|
|
Blockly.Xml.deleteNext = function(xmlBlock) {
|
|
for (var i = 0, child; child = xmlBlock.childNodes[i]; i++) {
|
|
if (child.nodeName.toLowerCase() == 'next') {
|
|
xmlBlock.removeChild(child);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Export symbols that would otherwise be renamed by Closure compiler.
|
|
if (!goog.global['Blockly']) {
|
|
goog.global['Blockly'] = {};
|
|
}
|
|
if (!goog.global['Blockly']['Xml']) {
|
|
goog.global['Blockly']['Xml'] = {};
|
|
}
|
|
goog.global['Blockly']['Xml']['domToText'] = Blockly.Xml.domToText;
|
|
goog.global['Blockly']['Xml']['domToWorkspace'] = Blockly.Xml.domToWorkspace;
|
|
goog.global['Blockly']['Xml']['textToDom'] = Blockly.Xml.textToDom;
|
|
goog.global['Blockly']['Xml']['workspaceToDom'] = Blockly.Xml.workspaceToDom;
|