/** * @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 Utility methods. * These methods are not specific to Blockly, and could be factored out into * a JavaScript framework such as Closure. * @author fraser@google.com (Neil Fraser) */ 'use strict'; /** * @name Blockly.utils * @namespace */ goog.provide('Blockly.utils'); goog.require('Blockly.Msg'); goog.require('Blockly.utils.base'); goog.require('Blockly.utils.Coordinate'); goog.require('Blockly.utils.string'); goog.require('Blockly.utils.userAgent'); goog.require('Blockly.utils.style'); /** * Don't do anything for this event, just halt propagation. * @param {!Event} e An event. */ Blockly.utils.noEvent = function(e) { // This event has been handled. No need to bubble up to the document. e.preventDefault(); e.stopPropagation(); }; /** * Is this event targeting a text input widget? * @param {!Event} e An event. * @return {boolean} True if text input. */ Blockly.utils.isTargetInput = function(e) { return e.target.type == 'textarea' || e.target.type == 'text' || e.target.type == 'number' || e.target.type == 'email' || e.target.type == 'password' || e.target.type == 'search' || e.target.type == 'tel' || e.target.type == 'url' || e.target.isContentEditable; }; /** * Return the coordinates of the top-left corner of this element relative to * its parent. Only for SVG elements and children (e.g. rect, g, path). * @param {!Element} element SVG element to find the coordinates of. * @return {!Blockly.utils.Coordinate} Object with .x and .y properties. */ Blockly.utils.getRelativeXY = function(element) { var xy = new Blockly.utils.Coordinate(0, 0); // First, check for x and y attributes. var x = element.getAttribute('x'); if (x) { xy.x = parseInt(x, 10); } var y = element.getAttribute('y'); if (y) { xy.y = parseInt(y, 10); } // Second, check for transform="translate(...)" attribute. var transform = element.getAttribute('transform'); var r = transform && transform.match(Blockly.utils.getRelativeXY.XY_REGEX_); if (r) { xy.x += Number(r[1]); if (r[3]) { xy.y += Number(r[3]); } } // Then check for style = transform: translate(...) or translate3d(...) var style = element.getAttribute('style'); if (style && style.indexOf('translate') > -1) { var styleComponents = style.match(Blockly.utils.getRelativeXY.XY_STYLE_REGEX_); if (styleComponents) { xy.x += Number(styleComponents[1]); if (styleComponents[3]) { xy.y += Number(styleComponents[3]); } } } return xy; }; /** * Return the coordinates of the top-left corner of this element relative to * the div Blockly was injected into. * @param {!Element} element SVG element to find the coordinates of. If this is * not a child of the div Blockly was injected into, the behaviour is * undefined. * @return {!Blockly.utils.Coordinate} Object with .x and .y properties. */ Blockly.utils.getInjectionDivXY_ = function(element) { var x = 0; var y = 0; while (element) { var xy = Blockly.utils.getRelativeXY(element); x = x + xy.x; y = y + xy.y; var classes = element.getAttribute('class') || ''; if ((' ' + classes + ' ').indexOf(' injectionDiv ') != -1) { break; } element = element.parentNode; } return new Blockly.utils.Coordinate(x, y); }; /** * Static regex to pull the x,y values out of an SVG translate() directive. * Note that Firefox and IE (9,10) return 'translate(12)' instead of * 'translate(12, 0)'. * Note that IE (9,10) returns 'translate(16 8)' instead of 'translate(16, 8)'. * Note that IE has been reported to return scientific notation (0.123456e-42). * @type {!RegExp} * @private */ Blockly.utils.getRelativeXY.XY_REGEX_ = /translate\(\s*([-+\d.e]+)([ ,]\s*([-+\d.e]+)\s*)?/; /** * Static regex to pull the x,y values out of a translate() or translate3d() * style property. * Accounts for same exceptions as XY_REGEX_. * @type {!RegExp} * @private */ Blockly.utils.getRelativeXY.XY_STYLE_REGEX_ = /transform:\s*translate(?:3d)?\(\s*([-+\d.e]+)\s*px([ ,]\s*([-+\d.e]+)\s*px)?/; /** * Is this event a right-click? * @param {!Event} e Mouse event. * @return {boolean} True if right-click. */ Blockly.utils.isRightButton = function(e) { if (e.ctrlKey && Blockly.utils.userAgent.MAC) { // Control-clicking on Mac OS X is treated as a right-click. // WebKit on Mac OS X fails to change button to 2 (but Gecko does). return true; } return e.button == 2; }; /** * Return the converted coordinates of the given mouse event. * The origin (0,0) is the top-left corner of the Blockly SVG. * @param {!Event} e Mouse event. * @param {!Element} svg SVG element. * @param {SVGMatrix} matrix Inverted screen CTM to use. * @return {!SVGPoint} Object with .x and .y properties. */ Blockly.utils.mouseToSvg = function(e, svg, matrix) { var svgPoint = svg.createSVGPoint(); svgPoint.x = e.clientX; svgPoint.y = e.clientY; if (!matrix) { matrix = svg.getScreenCTM().inverse(); } return svgPoint.matrixTransform(matrix); }; /** * Get the scroll delta of a mouse event in pixel units. * @param {!Event} e Mouse event. * @return {{x: number, y: number}} Scroll delta object with .x and .y * properties. */ Blockly.utils.getScrollDeltaPixels = function(e) { switch (e.deltaMode) { case 0x00: // Pixel mode. return { x: e.deltaX, y: e.deltaY }; case 0x01: // Line mode. return { x: e.deltaX * Blockly.LINE_MODE_MULTIPLIER, y: e.deltaY * Blockly.LINE_MODE_MULTIPLIER }; case 0x02: // Page mode. return { x: e.deltaX * Blockly.PAGE_MODE_MULTIPLIER, y: e.deltaY * Blockly.PAGE_MODE_MULTIPLIER }; } }; /** * Parse a string with any number of interpolation tokens (%1, %2, ...). * It will also replace string table references (e.g., %{bky_my_msg} and * %{BKY_MY_MSG} will both be replaced with the value in * Blockly.Msg['MY_MSG']). Percentage sign characters '%' may be self-escaped * (e.g., '%%'). * @param {string} message Text which might contain string table references and * interpolation tokens. * @return {!Array.} Array of strings and numbers. */ Blockly.utils.tokenizeInterpolation = function(message) { return Blockly.utils.tokenizeInterpolation_(message, true); }; /** * Replaces string table references in a message, if the message is a string. * For example, "%{bky_my_msg}" and "%{BKY_MY_MSG}" will both be replaced with * the value in Blockly.Msg['MY_MSG']. * @param {string|?} message Message, which may be a string that contains * string table references. * @return {string} String with message references replaced. */ Blockly.utils.replaceMessageReferences = function(message) { if (typeof message != 'string') { return message; } var interpolatedResult = Blockly.utils.tokenizeInterpolation_(message, false); // When parseInterpolationTokens == false, interpolatedResult should be at // most length 1. return interpolatedResult.length ? interpolatedResult[0] : ''; }; /** * Validates that any %{MSG_KEY} references in the message refer to keys of * the Blockly.Msg string table. * @param {string} message Text which might contain string table references. * @return {boolean} True if all message references have matching values. * Otherwise, false. */ Blockly.utils.checkMessageReferences = function(message) { var validSoFar = true; var msgTable = Blockly.Msg; // TODO (#1169): Implement support for other string tables, // prefixes other than BKY_. var m = message.match(/%{BKY_[A-Z]\w*}/ig); for (var i = 0; i < m.length; i++) { var msgKey = m[i].toUpperCase(); if (msgTable[msgKey.slice(6, -1)] == undefined) { console.log('WARNING: No message string for ' + m[i] + ' in ' + message); validSoFar = false; // Continue to report other errors. } } return validSoFar; }; /** * Internal implementation of the message reference and interpolation token * parsing used by tokenizeInterpolation() and replaceMessageReferences(). * @param {string} message Text which might contain string table references and * interpolation tokens. * @param {boolean} parseInterpolationTokens Option to parse numeric * interpolation tokens (%1, %2, ...) when true. * @return {!Array.} Array of strings and numbers. * @private */ Blockly.utils.tokenizeInterpolation_ = function(message, parseInterpolationTokens) { var tokens = []; var chars = message.split(''); chars.push(''); // End marker. // Parse the message with a finite state machine. // 0 - Base case. // 1 - % found. // 2 - Digit found. // 3 - Message ref found. var state = 0; var buffer = []; var number = null; for (var i = 0; i < chars.length; i++) { var c = chars[i]; if (state == 0) { if (c == '%') { var text = buffer.join(''); if (text) { tokens.push(text); } buffer.length = 0; state = 1; // Start escape. } else { buffer.push(c); // Regular char. } } else if (state == 1) { if (c == '%') { buffer.push(c); // Escaped %: %% state = 0; } else if (parseInterpolationTokens && '0' <= c && c <= '9') { state = 2; number = c; var text = buffer.join(''); if (text) { tokens.push(text); } buffer.length = 0; } else if (c == '{') { state = 3; } else { buffer.push('%', c); // Not recognized. Return as literal. state = 0; } } else if (state == 2) { if ('0' <= c && c <= '9') { number += c; // Multi-digit number. } else { tokens.push(parseInt(number, 10)); i--; // Parse this char again. state = 0; } } else if (state == 3) { // String table reference if (c == '') { // Premature end before closing '}' buffer.splice(0, 0, '%{'); // Re-insert leading delimiter i--; // Parse this char again. state = 0; // and parse as string literal. } else if (c != '}') { buffer.push(c); } else { var rawKey = buffer.join(''); if (/[A-Z]\w*/i.test(rawKey)) { // Strict matching // Found a valid string key. Attempt case insensitive match. var keyUpper = rawKey.toUpperCase(); // BKY_ is the prefix used to namespace the strings used in Blockly // core files and the predefined blocks in ../blocks/. // These strings are defined in ../msgs/ files. var bklyKey = Blockly.utils.string.startsWith(keyUpper, 'BKY_') ? keyUpper.substring(4) : null; if (bklyKey && bklyKey in Blockly.Msg) { var rawValue = Blockly.Msg[bklyKey]; if (typeof rawValue == 'string') { // Attempt to dereference substrings, too, appending to the end. Array.prototype.push.apply(tokens, Blockly.utils.tokenizeInterpolation_( rawValue, parseInterpolationTokens)); } else if (parseInterpolationTokens) { // When parsing interpolation tokens, numbers are special // placeholders (%1, %2, etc). Make sure all other values are // strings. tokens.push(String(rawValue)); } else { tokens.push(rawValue); } } else { // No entry found in the string table. Pass reference as string. tokens.push('%{' + rawKey + '}'); } buffer.length = 0; // Clear the array state = 0; } else { tokens.push('%{' + rawKey + '}'); buffer.length = 0; state = 0; // and parse as string literal. } } } } var text = buffer.join(''); if (text) { tokens.push(text); } // Merge adjacent text tokens into a single string. var mergedTokens = []; buffer.length = 0; for (var i = 0; i < tokens.length; ++i) { if (typeof tokens[i] == 'string') { buffer.push(tokens[i]); } else { text = buffer.join(''); if (text) { mergedTokens.push(text); } buffer.length = 0; mergedTokens.push(tokens[i]); } } text = buffer.join(''); if (text) { mergedTokens.push(text); } buffer.length = 0; return mergedTokens; }; /** * Generate a unique ID. This should be globally unique. * 87 characters ^ 20 length > 128 bits (better than a UUID). * @return {string} A globally unique ID string. */ Blockly.utils.genUid = function() { var length = 20; var soupLength = Blockly.utils.genUid.soup_.length; var id = []; for (var i = 0; i < length; i++) { id[i] = Blockly.utils.genUid.soup_.charAt(Math.random() * soupLength); } return id.join(''); }; /** * Legal characters for the unique ID. Should be all on a US keyboard. * No characters that conflict with XML or JSON. Requests to remove additional * 'problematic' characters from this soup will be denied. That's your failure * to properly escape in your own environment. Issues #251, #625, #682, #1304. * @private */ Blockly.utils.genUid.soup_ = '!#$%()*+,-./:;=?@[]^_`{|}~' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; /** * Check if 3D transforms are supported by adding an element * and attempting to set the property. * @return {boolean} True if 3D transforms are supported. */ Blockly.utils.is3dSupported = function() { if (Blockly.utils.is3dSupported.cached_ !== undefined) { return Blockly.utils.is3dSupported.cached_; } // CC-BY-SA Lorenzo Polidori // stackoverflow.com/questions/5661671/detecting-transform-translate3d-support if (!Blockly.utils.global.getComputedStyle) { return false; } var el = document.createElement('p'); var has3d = 'none'; var transforms = { 'webkitTransform': '-webkit-transform', 'OTransform': '-o-transform', 'msTransform': '-ms-transform', 'MozTransform': '-moz-transform', 'transform': 'transform' }; // Add it to the body to get the computed style. document.body.insertBefore(el, null); for (var t in transforms) { if (el.style[t] !== undefined) { el.style[t] = 'translate3d(1px,1px,1px)'; var computedStyle = Blockly.utils.global.getComputedStyle(el); if (!computedStyle) { // getComputedStyle in Firefox returns null when Blockly is loaded // inside an iframe with display: none. Returning false and not // caching is3dSupported means we try again later. This is most likely // when users are interacting with blocks which should mean Blockly is // visible again. // See https://bugzilla.mozilla.org/show_bug.cgi?id=548397 document.body.removeChild(el); return false; } has3d = computedStyle.getPropertyValue(transforms[t]); } } document.body.removeChild(el); Blockly.utils.is3dSupported.cached_ = has3d !== 'none'; return Blockly.utils.is3dSupported.cached_; }; /** * Calls a function after the page has loaded, possibly immediately. * @param {function()} fn Function to run. * @throws Error Will throw if no global document can be found (e.g., Node.js). */ Blockly.utils.runAfterPageLoad = function(fn) { if (typeof document != 'object') { throw Error('Blockly.utils.runAfterPageLoad() requires browser document.'); } if (document.readyState == 'complete') { fn(); // Page has already loaded. Call immediately. } else { // Poll readyState. var readyStateCheckInterval = setInterval(function() { if (document.readyState == 'complete') { clearInterval(readyStateCheckInterval); fn(); } }, 10); } }; /** * Get the position of the current viewport in window coordinates. This takes * scroll into account. * @return {!Object} An object containing window width, height, and scroll * position in window coordinates. * @package */ Blockly.utils.getViewportBBox = function() { // Pixels, in window coordinates. var scrollOffset = Blockly.utils.style.getViewportPageOffset(); return { right: document.documentElement.clientWidth + scrollOffset.x, bottom: document.documentElement.clientHeight + scrollOffset.y, top: scrollOffset.y, left: scrollOffset.x }; }; /** * Removes the first occurrence of a particular value from an array. * @param {!Array} arr Array from which to remove * value. * @param {*} obj Object to remove. * @return {boolean} True if an element was removed. * @package */ Blockly.utils.arrayRemove = function(arr, obj) { var i = arr.indexOf(obj); if (i == -1) { return false; } arr.splice(i, 1); return true; }; /** * Gets the document scroll distance as a coordinate object. * Copied from Closure's goog.dom.getDocumentScroll. * @return {!Blockly.utils.Coordinate} Object with values 'x' and 'y'. */ Blockly.utils.getDocumentScroll = function() { var el = document.documentElement; var win = window; if (Blockly.utils.userAgent.IE && win.pageYOffset != el.scrollTop) { // The keyboard on IE10 touch devices shifts the page using the pageYOffset // without modifying scrollTop. For this case, we want the body scroll // offsets. return new Blockly.utils.Coordinate(el.scrollLeft, el.scrollTop); } return new Blockly.utils.Coordinate( win.pageXOffset || el.scrollLeft, win.pageYOffset || el.scrollTop); }; /** * Get a map of all the block's descendants mapping their type to the number of * children with that type. * @param {!Blockly.Block} block The block to map. * @param {boolean=} opt_stripFollowing Optionally ignore all following * statements (blocks that are not inside a value or statement input * of the block). * @return {!Object} Map of types to type counts for descendants of the bock. */ Blockly.utils.getBlockTypeCounts = function(block, opt_stripFollowing) { var typeCountsMap = Object.create(null); var descendants = block.getDescendants(true); if (opt_stripFollowing) { var nextBlock = block.getNextBlock(); if (nextBlock) { var index = descendants.indexOf(nextBlock); descendants.splice(index, descendants.length - index); } } for (var i = 0, checkBlock; checkBlock = descendants[i]; i++) { if (typeCountsMap[checkBlock.type]) { typeCountsMap[checkBlock.type]++; } else { typeCountsMap[checkBlock.type] = 1; } } return typeCountsMap; }; /** * Converts screen coordinates to workspace coordinates. * @param {Blockly.WorkspaceSvg} ws The workspace to find the coordinates on. * @param {Blockly.utils.Coordinate} screenCoordinates The screen coordinates to be * converted to workspace coordintaes * @return {Blockly.utils.Coordinate} The workspace coordinates. * @package */ Blockly.utils.screenToWsCoordinates = function(ws, screenCoordinates) { var screenX = screenCoordinates.x; var screenY = screenCoordinates.y; var injectionDiv = ws.getInjectionDiv(); // Bounding rect coordinates are in client coordinates, meaning that they // are in pixels relative to the upper left corner of the visible browser // window. These coordinates change when you scroll the browser window. var boundingRect = injectionDiv.getBoundingClientRect(); // The client coordinates offset by the injection div's upper left corner. var clientOffsetPixels = new Blockly.utils.Coordinate( screenX - boundingRect.left, screenY - boundingRect.top); // The offset in pixels between the main workspace's origin and the upper // left corner of the injection div. var mainOffsetPixels = ws.getOriginOffsetInPixels(); // The position of the new comment in pixels relative to the origin of the // main workspace. var finalOffsetPixels = Blockly.utils.Coordinate.difference(clientOffsetPixels, mainOffsetPixels); // The position in main workspace coordinates. var finalOffsetMainWs = finalOffsetPixels.scale(1 / ws.scale); return finalOffsetMainWs; };