UNPKG

blockly

Version:

Blockly is a library for building visual programming editors.

1,599 lines (1,452 loc) 87.2 kB
/** * @license * Copyright 2014 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Object representing a workspace rendered as SVG. */ 'use strict'; /** * Object representing a workspace rendered as SVG. * @class */ goog.module('Blockly.WorkspaceSvg'); const ContextMenu = goog.require('Blockly.ContextMenu'); /* eslint-disable-next-line no-unused-vars */ const Procedures = goog.requireType('Blockly.Procedures'); const Tooltip = goog.require('Blockly.Tooltip'); /* eslint-disable-next-line no-unused-vars */ const Variables = goog.requireType('Blockly.Variables'); /* eslint-disable-next-line no-unused-vars */ const VariablesDynamic = goog.requireType('Blockly.VariablesDynamic'); const WidgetDiv = goog.require('Blockly.WidgetDiv'); const Xml = goog.require('Blockly.Xml'); const arrayUtils = goog.require('Blockly.utils.array'); const blockRendering = goog.require('Blockly.blockRendering'); const blocks = goog.require('Blockly.serialization.blocks'); const browserEvents = goog.require('Blockly.browserEvents'); const common = goog.require('Blockly.common'); const dom = goog.require('Blockly.utils.dom'); const eventUtils = goog.require('Blockly.Events.utils'); const internalConstants = goog.require('Blockly.internalConstants'); const object = goog.require('Blockly.utils.object'); const registry = goog.require('Blockly.registry'); const svgMath = goog.require('Blockly.utils.svgMath'); const toolbox = goog.require('Blockly.utils.toolbox'); const userAgent = goog.require('Blockly.utils.userAgent'); const utils = goog.require('Blockly.utils'); /* eslint-disable-next-line no-unused-vars */ const {BlockDragSurfaceSvg} = goog.requireType('Blockly.BlockDragSurfaceSvg'); const {BlockSvg} = goog.require('Blockly.BlockSvg'); /* eslint-disable-next-line no-unused-vars */ const {BlocklyOptions} = goog.requireType('Blockly.BlocklyOptions'); /* eslint-disable-next-line no-unused-vars */ const {Block} = goog.requireType('Blockly.Block'); const {Classic} = goog.require('Blockly.Themes.Classic'); const {ComponentManager} = goog.require('Blockly.ComponentManager'); const {ConnectionDB} = goog.require('Blockly.ConnectionDB'); const {ContextMenuRegistry} = goog.require('Blockly.ContextMenuRegistry'); const {Coordinate} = goog.require('Blockly.utils.Coordinate'); /* eslint-disable-next-line no-unused-vars */ const {Cursor} = goog.requireType('Blockly.Cursor'); const {DropDownDiv} = goog.require('Blockly.DropDownDiv'); /* eslint-disable-next-line no-unused-vars */ const {FlyoutButton} = goog.requireType('Blockly.FlyoutButton'); const {Gesture} = goog.require('Blockly.Gesture'); const {Grid} = goog.require('Blockly.Grid'); /* eslint-disable-next-line no-unused-vars */ const {IASTNodeLocationSvg} = goog.require('Blockly.IASTNodeLocationSvg'); /* eslint-disable-next-line no-unused-vars */ const {IBoundedElement} = goog.requireType('Blockly.IBoundedElement'); /* eslint-disable-next-line no-unused-vars */ const {IDragTarget} = goog.requireType('Blockly.IDragTarget'); /* eslint-disable-next-line no-unused-vars */ const {IFlyout} = goog.requireType('Blockly.IFlyout'); /* eslint-disable-next-line no-unused-vars */ const {IMetricsManager} = goog.requireType('Blockly.IMetricsManager'); /* eslint-disable-next-line no-unused-vars */ const {IToolbox} = goog.requireType('Blockly.IToolbox'); const {MarkerManager} = goog.require('Blockly.MarkerManager'); /* eslint-disable-next-line no-unused-vars */ const {Marker} = goog.requireType('Blockly.Marker'); /* eslint-disable-next-line no-unused-vars */ const {Metrics} = goog.requireType('Blockly.utils.Metrics'); const {Options} = goog.require('Blockly.Options'); const {Rect} = goog.require('Blockly.utils.Rect'); /* eslint-disable-next-line no-unused-vars */ const {Renderer} = goog.requireType('Blockly.blockRendering.Renderer'); /* eslint-disable-next-line no-unused-vars */ const {ScrollbarPair} = goog.requireType('Blockly.ScrollbarPair'); const {Size} = goog.require('Blockly.utils.Size'); const {Svg} = goog.require('Blockly.utils.Svg'); const {ThemeManager} = goog.require('Blockly.ThemeManager'); /* eslint-disable-next-line no-unused-vars */ const {Theme} = goog.requireType('Blockly.Theme'); const {TouchGesture} = goog.require('Blockly.TouchGesture'); /* eslint-disable-next-line no-unused-vars */ const {Trashcan} = goog.requireType('Blockly.Trashcan'); /* eslint-disable-next-line no-unused-vars */ const {VariableModel} = goog.requireType('Blockly.VariableModel'); const {WorkspaceAudio} = goog.require('Blockly.WorkspaceAudio'); /* eslint-disable-next-line no-unused-vars */ const {WorkspaceCommentSvg} = goog.requireType('Blockly.WorkspaceCommentSvg'); /* eslint-disable-next-line no-unused-vars */ const {WorkspaceComment} = goog.requireType('Blockly.WorkspaceComment'); /* eslint-disable-next-line no-unused-vars */ const {WorkspaceDragSurfaceSvg} = goog.requireType('Blockly.WorkspaceDragSurfaceSvg'); const {Workspace} = goog.require('Blockly.Workspace'); /* eslint-disable-next-line no-unused-vars */ const {ZoomControls} = goog.requireType('Blockly.ZoomControls'); /** @suppress {extraRequire} */ goog.require('Blockly.Events.BlockCreate'); /** @suppress {extraRequire} */ goog.require('Blockly.Events.ThemeChange'); /** @suppress {extraRequire} */ goog.require('Blockly.Events.ViewportChange'); /** @suppress {extraRequire} */ goog.require('Blockly.MetricsManager'); /** @suppress {extraRequire} */ goog.require('Blockly.Msg'); /** * Class for a workspace. This is an onscreen area with optional trashcan, * scrollbars, bubbles, and dragging. * @param {!Options} options Dictionary of options. * @param {BlockDragSurfaceSvg=} opt_blockDragSurface Drag surface for * blocks. * @param {WorkspaceDragSurfaceSvg=} opt_wsDragSurface Drag surface for * the workspace. * @extends {Workspace} * @implements {IASTNodeLocationSvg} * @constructor * @alias Blockly.WorkspaceSvg */ const WorkspaceSvg = function( options, opt_blockDragSurface, opt_wsDragSurface) { WorkspaceSvg.superClass_.constructor.call(this, options); const MetricsManagerClass = registry.getClassFromOptions( registry.Type.METRICS_MANAGER, options, true); /** * Object in charge of calculating metrics for the workspace. * @type {!IMetricsManager} * @private */ this.metricsManager_ = new MetricsManagerClass(this); /** * Method to get all the metrics that have to do with a workspace. * @type {function():!Metrics} * @package */ this.getMetrics = options.getMetrics || this.metricsManager_.getMetrics.bind(this.metricsManager_); /** * Translates the workspace. * @type {function(!{x:number, y:number}):void} * @package */ this.setMetrics = options.setMetrics || WorkspaceSvg.setTopLevelWorkspaceMetrics_; /** * @type {!ComponentManager} * @private */ this.componentManager_ = new ComponentManager(); this.connectionDBList = ConnectionDB.init(this.connectionChecker); if (opt_blockDragSurface) { this.blockDragSurface_ = opt_blockDragSurface; } if (opt_wsDragSurface) { this.workspaceDragSurface_ = opt_wsDragSurface; } this.useWorkspaceDragSurface_ = !!this.workspaceDragSurface_ && svgMath.is3dSupported(); /** * List of currently highlighted blocks. Block highlighting is often used to * visually mark blocks currently being executed. * @type {!Array<!BlockSvg>} * @private */ this.highlightedBlocks_ = []; /** * Object in charge of loading, storing, and playing audio for a workspace. * @type {!WorkspaceAudio} * @private */ this.audioManager_ = new WorkspaceAudio( /** @type {WorkspaceSvg} */ (options.parentWorkspace)); /** * This workspace's grid object or null. * @type {Grid} * @private */ this.grid_ = this.options.gridPattern ? new Grid(this.options.gridPattern, options.gridOptions) : null; /** * Manager in charge of markers and cursors. * @type {!MarkerManager} * @private */ this.markerManager_ = new MarkerManager(this); /** * Map from function names to callbacks, for deciding what to do when a custom * toolbox category is opened. * @type {!Object<string, ?function(!Workspace): * !toolbox.FlyoutDefinition>} * @private */ this.toolboxCategoryCallbacks_ = Object.create(null); /** * Map from function names to callbacks, for deciding what to do when a button * is clicked. * @type {!Object<string, ?function(!FlyoutButton)>} * @private */ this.flyoutButtonCallbacks_ = Object.create(null); const Variables = goog.module.get('Blockly.Variables'); if (Variables && Variables.flyoutCategory) { this.registerToolboxCategoryCallback( Variables.CATEGORY_NAME, Variables.flyoutCategory); } const VariablesDynamic = goog.module.get('Blockly.VariablesDynamic'); if (VariablesDynamic && VariablesDynamic.flyoutCategory) { this.registerToolboxCategoryCallback( VariablesDynamic.CATEGORY_NAME, VariablesDynamic.flyoutCategory); } const Procedures = goog.module.get('Blockly.Procedures'); if (Procedures && Procedures.flyoutCategory) { this.registerToolboxCategoryCallback( Procedures.CATEGORY_NAME, Procedures.flyoutCategory); this.addChangeListener(Procedures.mutatorOpenListener); } /** * Object in charge of storing and updating the workspace theme. * @type {!ThemeManager} * @protected */ this.themeManager_ = this.options.parentWorkspace ? this.options.parentWorkspace.getThemeManager() : new ThemeManager(this, this.options.theme || Classic); this.themeManager_.subscribeWorkspace(this); /** * The block renderer used for rendering blocks on this workspace. * @type {!Renderer} * @private */ this.renderer_ = blockRendering.init( this.options.renderer || 'geras', this.getTheme(), this.options.rendererOverrides); /** * Cached parent SVG. * @type {SVGElement} * @private */ this.cachedParentSvg_ = null; /** * True if keyboard accessibility mode is on, false otherwise. * @type {boolean} */ this.keyboardAccessibilityMode = false; /** * The list of top-level bounded elements on the workspace. * @type {!Array<!IBoundedElement>} * @private */ this.topBoundedElements_ = []; /** * The recorded drag targets. * @type {!Array< * { * component: !IDragTarget, * clientRect: !Rect * }>} * @private */ this.dragTargetAreas_ = []; /** * The cached size of the parent svg element. * Used to compute svg metrics. * @type {!Size} * @private */ this.cachedParentSvgSize_ = new Size(0, 0); }; object.inherits(WorkspaceSvg, Workspace); /** * A wrapper function called when a resize event occurs. * You can pass the result to `eventHandling.unbind`. * @type {?browserEvents.Data} * @private */ WorkspaceSvg.prototype.resizeHandlerWrapper_ = null; /** * The render status of an SVG workspace. * Returns `false` for headless workspaces and true for instances of * `WorkspaceSvg`. * @type {boolean} */ WorkspaceSvg.prototype.rendered = true; /** * Whether the workspace is visible. False if the workspace has been hidden * by calling `setVisible(false)`. * @type {boolean} * @private */ WorkspaceSvg.prototype.isVisible_ = true; /** * Is this workspace the surface for a flyout? * @type {boolean} */ WorkspaceSvg.prototype.isFlyout = false; /** * Is this workspace the surface for a mutator? * @type {boolean} * @package */ WorkspaceSvg.prototype.isMutator = false; /** * Whether this workspace has resizes enabled. * Disable during batch operations for a performance improvement. * @type {boolean} * @private */ WorkspaceSvg.prototype.resizesEnabled_ = true; /** * Current horizontal scrolling offset in pixel units, relative to the * workspace origin. * * It is useful to think about a view, and a canvas moving beneath that * view. As the canvas moves right, this value becomes more positive, and * the view is now "seeing" the left side of the canvas. As the canvas moves * left, this value becomes more negative, and the view is now "seeing" the * right side of the canvas. * * The confusing thing about this value is that it does not, and must not * include the absoluteLeft offset. This is because it is used to calculate * the viewLeft value. * * The viewLeft is relative to the workspace origin (although in pixel * units). The workspace origin is the top-left corner of the workspace (at * least when it is enabled). It is shifted from the top-left of the blocklyDiv * so as not to be beneath the toolbox. * * When the workspace is enabled the viewLeft and workspace origin are at * the same X location. As the canvas slides towards the right beneath the view * this value (scrollX) becomes more positive, and the viewLeft becomes more * negative relative to the workspace origin (imagine the workspace origin * as a dot on the canvas sliding to the right as the canvas moves). * * So if the scrollX were to include the absoluteLeft this would in a way * "unshift" the workspace origin. This means that the viewLeft would be * representing the left edge of the blocklyDiv, rather than the left edge * of the workspace. * * @type {number} */ WorkspaceSvg.prototype.scrollX = 0; /** * Current vertical scrolling offset in pixel units, relative to the * workspace origin. * * It is useful to think about a view, and a canvas moving beneath that * view. As the canvas moves down, this value becomes more positive, and the * view is now "seeing" the upper part of the canvas. As the canvas moves * up, this value becomes more negative, and the view is "seeing" the lower * part of the canvas. * * This confusing thing about this value is that it does not, and must not * include the absoluteTop offset. This is because it is used to calculate * the viewTop value. * * The viewTop is relative to the workspace origin (although in pixel * units). The workspace origin is the top-left corner of the workspace (at * least when it is enabled). It is shifted from the top-left of the * blocklyDiv so as not to be beneath the toolbox. * * When the workspace is enabled the viewTop and workspace origin are at the * same Y location. As the canvas slides towards the bottom this value * (scrollY) becomes more positive, and the viewTop becomes more negative * relative to the workspace origin (image in the workspace origin as a dot * on the canvas sliding downwards as the canvas moves). * * So if the scrollY were to include the absoluteTop this would in a way * "unshift" the workspace origin. This means that the viewTop would be * representing the top edge of the blocklyDiv, rather than the top edge of * the workspace. * * @type {number} */ WorkspaceSvg.prototype.scrollY = 0; /** * Horizontal scroll value when scrolling started in pixel units. * @type {number} */ WorkspaceSvg.prototype.startScrollX = 0; /** * Vertical scroll value when scrolling started in pixel units. * @type {number} */ WorkspaceSvg.prototype.startScrollY = 0; /** * Distance from mouse to object being dragged. * @type {Coordinate} * @private */ WorkspaceSvg.prototype.dragDeltaXY_ = null; /** * Current scale. * @type {number} */ WorkspaceSvg.prototype.scale = 1; /** * Cached scale value. Used to detect changes in viewport. * @type {number} * @private */ WorkspaceSvg.prototype.oldScale_ = 1; /** * Cached viewport top value. Used to detect changes in viewport. * @type {number} * @private */ WorkspaceSvg.prototype.oldTop_ = 0; /** * Cached viewport left value. Used to detect changes in viewport. * @type {number} * @private */ WorkspaceSvg.prototype.oldLeft_ = 0; /** * The workspace's trashcan (if any). * @type {Trashcan} */ WorkspaceSvg.prototype.trashcan = null; /** * This workspace's scrollbars, if they exist. * @type {ScrollbarPair} */ WorkspaceSvg.prototype.scrollbar = null; /** * Fixed flyout providing blocks which may be dragged into this workspace. * @type {IFlyout} * @private */ WorkspaceSvg.prototype.flyout_ = null; /** * Category-based toolbox providing blocks which may be dragged into this * workspace. * @type {IToolbox} * @private */ WorkspaceSvg.prototype.toolbox_ = null; /** * The current gesture in progress on this workspace, if any. * @type {TouchGesture} * @private */ WorkspaceSvg.prototype.currentGesture_ = null; /** * This workspace's surface for dragging blocks, if it exists. * @type {BlockDragSurfaceSvg} * @private */ WorkspaceSvg.prototype.blockDragSurface_ = null; /** * This workspace's drag surface, if it exists. * @type {WorkspaceDragSurfaceSvg} * @private */ WorkspaceSvg.prototype.workspaceDragSurface_ = null; /** * Whether to move workspace to the drag surface when it is dragged. * True if it should move, false if it should be translated directly. * @type {boolean} * @private */ WorkspaceSvg.prototype.useWorkspaceDragSurface_ = false; /** * Whether the drag surface is actively in use. When true, calls to * translate will translate the drag surface instead of the translating the * workspace directly. * This is set to true in setupDragSurface and to false in resetDragSurface. * @type {boolean} * @private */ WorkspaceSvg.prototype.isDragSurfaceActive_ = false; /** * The first parent div with 'injectionDiv' in the name, or null if not set. * Access this with getInjectionDiv. * @type {Element} * @private */ WorkspaceSvg.prototype.injectionDiv_ = null; /** * Last known position of the page scroll. * This is used to determine whether we have recalculated screen coordinate * stuff since the page scrolled. * @type {Coordinate} * @private */ WorkspaceSvg.prototype.lastRecordedPageScroll_ = null; /** * Developers may define this function to add custom menu options to the * workspace's context menu or edit the workspace-created set of menu options. * @param {!Array<!Object>} options List of menu options to add to. * @param {!Event} e The right-click event that triggered the context menu. */ WorkspaceSvg.prototype.configureContextMenu; /** * In a flyout, the target workspace where blocks should be placed after a drag. * Otherwise null. * @type {WorkspaceSvg} * @package */ WorkspaceSvg.prototype.targetWorkspace = null; /** * Inverted screen CTM, for use in mouseToSvg. * @type {?SVGMatrix} * @private */ WorkspaceSvg.prototype.inverseScreenCTM_ = null; /** * Inverted screen CTM is dirty, recalculate it. * @type {boolean} * @private */ WorkspaceSvg.prototype.inverseScreenCTMDirty_ = true; /** * Get the marker manager for this workspace. * @return {!MarkerManager} The marker manager. */ WorkspaceSvg.prototype.getMarkerManager = function() { return this.markerManager_; }; /** * Gets the metrics manager for this workspace. * @return {!IMetricsManager} The metrics manager. * @public */ WorkspaceSvg.prototype.getMetricsManager = function() { return this.metricsManager_; }; /** * Sets the metrics manager for the workspace. * @param {!IMetricsManager} metricsManager The metrics manager. * @package */ WorkspaceSvg.prototype.setMetricsManager = function(metricsManager) { this.metricsManager_ = metricsManager; this.getMetrics = this.metricsManager_.getMetrics.bind(this.metricsManager_); }; /** * Gets the component manager for this workspace. * @return {!ComponentManager} The component manager. * @public */ WorkspaceSvg.prototype.getComponentManager = function() { return this.componentManager_; }; /** * Add the cursor SVG to this workspaces SVG group. * @param {SVGElement} cursorSvg The SVG root of the cursor to be added to the * workspace SVG group. * @package */ WorkspaceSvg.prototype.setCursorSvg = function(cursorSvg) { this.markerManager_.setCursorSvg(cursorSvg); }; /** * Add the marker SVG to this workspaces SVG group. * @param {SVGElement} markerSvg The SVG root of the marker to be added to the * workspace SVG group. * @package */ WorkspaceSvg.prototype.setMarkerSvg = function(markerSvg) { this.markerManager_.setMarkerSvg(markerSvg); }; /** * Get the marker with the given ID. * @param {string} id The ID of the marker. * @return {?Marker} The marker with the given ID or null if no marker * with the given ID exists. * @package */ WorkspaceSvg.prototype.getMarker = function(id) { if (this.markerManager_) { return this.markerManager_.getMarker(id); } return null; }; /** * The cursor for this workspace. * @return {?Cursor} The cursor for the workspace. */ WorkspaceSvg.prototype.getCursor = function() { if (this.markerManager_) { return this.markerManager_.getCursor(); } return null; }; /** * Get the block renderer attached to this workspace. * @return {!Renderer} The renderer attached to this * workspace. */ WorkspaceSvg.prototype.getRenderer = function() { return this.renderer_; }; /** * Get the theme manager for this workspace. * @return {!ThemeManager} The theme manager for this workspace. * @package */ WorkspaceSvg.prototype.getThemeManager = function() { return this.themeManager_; }; /** * Get the workspace theme object. * @return {!Theme} The workspace theme object. */ WorkspaceSvg.prototype.getTheme = function() { return this.themeManager_.getTheme(); }; /** * Set the workspace theme object. * If no theme is passed, default to the `Classic` theme. * @param {Theme} theme The workspace theme object. */ WorkspaceSvg.prototype.setTheme = function(theme) { if (!theme) { theme = /** @type {!Theme} */ (Classic); } this.themeManager_.setTheme(theme); }; /** * Refresh all blocks on the workspace after a theme update. * @package */ WorkspaceSvg.prototype.refreshTheme = function() { if (this.svgGroup_) { this.renderer_.refreshDom(this.svgGroup_, this.getTheme()); } // Update all blocks in workspace that have a style name. this.updateBlockStyles_(this.getAllBlocks(false).filter(function(block) { return !!block.getStyleName(); })); // Update current toolbox selection. this.refreshToolboxSelection(); if (this.toolbox_) { this.toolbox_.refreshTheme(); } // Re-render if workspace is visible if (this.isVisible()) { this.setVisible(true); } const event = new (eventUtils.get(eventUtils.THEME_CHANGE))( this.getTheme().name, this.id); eventUtils.fire(event); }; /** * Updates all the blocks with new style. * @param {!Array<!Block>} blocks List of blocks to update the style * on. * @private */ WorkspaceSvg.prototype.updateBlockStyles_ = function(blocks) { for (let i = 0, block; (block = blocks[i]); i++) { const blockStyleName = block.getStyleName(); if (blockStyleName) { block.setStyle(blockStyleName); if (block.mutator) { block.mutator.updateBlockStyle(); } } } }; /** * Getter for the inverted screen CTM. * @return {?SVGMatrix} The matrix to use in mouseToSvg */ WorkspaceSvg.prototype.getInverseScreenCTM = function() { // Defer getting the screen CTM until we actually need it, this should // avoid forced reflows from any calls to updateInverseScreenCTM. if (this.inverseScreenCTMDirty_) { const ctm = this.getParentSvg().getScreenCTM(); if (ctm) { this.inverseScreenCTM_ = ctm.inverse(); this.inverseScreenCTMDirty_ = false; } } return this.inverseScreenCTM_; }; /** * Mark the inverse screen CTM as dirty. */ WorkspaceSvg.prototype.updateInverseScreenCTM = function() { this.inverseScreenCTMDirty_ = true; }; /** * Getter for isVisible * @return {boolean} Whether the workspace is visible. * False if the workspace has been hidden by calling `setVisible(false)`. */ WorkspaceSvg.prototype.isVisible = function() { return this.isVisible_; }; /** * Return the absolute coordinates of the top-left corner of this element, * scales that after canvas SVG element, if it's a descendant. * The origin (0,0) is the top-left corner of the Blockly SVG. * @param {!SVGElement} element SVG element to find the coordinates of. * @return {!Coordinate} Object with .x and .y properties. * @package */ WorkspaceSvg.prototype.getSvgXY = function(element) { let x = 0; let y = 0; let scale = 1; if (dom.containsNode(this.getCanvas(), element) || dom.containsNode(this.getBubbleCanvas(), element)) { // Before the SVG canvas, scale the coordinates. scale = this.scale; } do { // Loop through this block and every parent. const xy = svgMath.getRelativeXY(element); if (element === this.getCanvas() || element === this.getBubbleCanvas()) { // After the SVG canvas, don't scale the coordinates. scale = 1; } x += xy.x * scale; y += xy.y * scale; element = /** @type {!SVGElement} */ (element.parentNode); } while (element && element !== this.getParentSvg()); return new Coordinate(x, y); }; /** * Gets the size of the workspace's parent SVG element. * @return {!Size} The cached width and height of the workspace's * parent SVG element. * @package */ WorkspaceSvg.prototype.getCachedParentSvgSize = function() { const size = this.cachedParentSvgSize_; return new Size(size.width, size.height); }; /** * Return the position of the workspace origin relative to the injection div * origin in pixels. * The workspace origin is where a block would render at position (0, 0). * It is not the upper left corner of the workspace SVG. * @return {!Coordinate} Offset in pixels. * @package */ WorkspaceSvg.prototype.getOriginOffsetInPixels = function() { return svgMath.getInjectionDivXY(this.getCanvas()); }; /** * Return the injection div that is a parent of this workspace. * Walks the DOM the first time it's called, then returns a cached value. * Note: We assume this is only called after the workspace has been injected * into the DOM. * @return {!Element} The first parent div with 'injectionDiv' in the name. * @package */ WorkspaceSvg.prototype.getInjectionDiv = function() { // NB: it would be better to pass this in at createDom, but is more likely to // break existing uses of Blockly. if (!this.injectionDiv_) { let element = this.svgGroup_; while (element) { const classes = element.getAttribute('class') || ''; if ((' ' + classes + ' ').indexOf(' injectionDiv ') !== -1) { this.injectionDiv_ = element; break; } element = /** @type {!Element} */ (element.parentNode); } } return /** @type {!Element} */ (this.injectionDiv_); }; /** * Get the SVG block canvas for the workspace. * @return {?SVGElement} The SVG group for the workspace. * @package */ WorkspaceSvg.prototype.getBlockCanvas = function() { return this.svgBlockCanvas_; }; /** * Save resize handler data so we can delete it later in dispose. * @param {!browserEvents.Data} handler Data that can be passed to * eventHandling.unbind. */ WorkspaceSvg.prototype.setResizeHandlerWrapper = function(handler) { this.resizeHandlerWrapper_ = handler; }; /** * Create the workspace DOM elements. * @param {string=} opt_backgroundClass Either 'blocklyMainBackground' or * 'blocklyMutatorBackground'. * @return {!Element} The workspace's SVG group. */ WorkspaceSvg.prototype.createDom = function(opt_backgroundClass) { /** * <g class="blocklyWorkspace"> * <rect class="blocklyMainBackground" height="100%" width="100%"></rect> * [Trashcan and/or flyout may go here] * <g class="blocklyBlockCanvas"></g> * <g class="blocklyBubbleCanvas"></g> * </g> * @type {SVGElement} */ this.svgGroup_ = dom.createSvgElement(Svg.G, {'class': 'blocklyWorkspace'}, null); // Note that a <g> alone does not receive mouse events--it must have a // valid target inside it. If no background class is specified, as in the // flyout, the workspace will not receive mouse events. if (opt_backgroundClass) { /** @type {SVGElement} */ this.svgBackground_ = dom.createSvgElement( Svg.RECT, {'height': '100%', 'width': '100%', 'class': opt_backgroundClass}, this.svgGroup_); if (opt_backgroundClass === 'blocklyMainBackground' && this.grid_) { this.svgBackground_.style.fill = 'url(#' + this.grid_.getPatternId() + ')'; } else { this.themeManager_.subscribe( this.svgBackground_, 'workspaceBackgroundColour', 'fill'); } } /** @type {SVGElement} */ this.svgBlockCanvas_ = dom.createSvgElement( Svg.G, {'class': 'blocklyBlockCanvas'}, this.svgGroup_); /** @type {SVGElement} */ this.svgBubbleCanvas_ = dom.createSvgElement( Svg.G, {'class': 'blocklyBubbleCanvas'}, this.svgGroup_); if (!this.isFlyout) { browserEvents.conditionalBind( this.svgGroup_, 'mousedown', this, this.onMouseDown_, false, true); // This no-op works around https://bugs.webkit.org/show_bug.cgi?id=226683, // which otherwise prevents zoom/scroll events from being observed in // Safari. Once that bug is fixed it should be removed. document.body.addEventListener('wheel', function() {}); browserEvents.conditionalBind( this.svgGroup_, 'wheel', this, this.onMouseWheel_); } // Determine if there needs to be a category tree, or a simple list of // blocks. This cannot be changed later, since the UI is very different. if (this.options.hasCategories) { const ToolboxClass = registry.getClassFromOptions(registry.Type.TOOLBOX, this.options, true); this.toolbox_ = new ToolboxClass(this); } if (this.grid_) { this.grid_.update(this.scale); } this.recordDragTargets(); const CursorClass = registry.getClassFromOptions(registry.Type.CURSOR, this.options); CursorClass && this.markerManager_.setCursor(new CursorClass()); this.renderer_.createDom(this.svgGroup_, this.getTheme()); return this.svgGroup_; }; /** * Dispose of this workspace. * Unlink from all DOM elements to prevent memory leaks. * @suppress {checkTypes} */ WorkspaceSvg.prototype.dispose = function() { // Stop rerendering. this.rendered = false; if (this.currentGesture_) { this.currentGesture_.cancel(); } if (this.svgGroup_) { dom.removeNode(this.svgGroup_); this.svgGroup_ = null; } this.svgBlockCanvas_ = null; this.svgBubbleCanvas_ = null; if (this.toolbox_) { this.toolbox_.dispose(); this.toolbox_ = null; } if (this.flyout_) { this.flyout_.dispose(); this.flyout_ = null; } if (this.trashcan) { this.trashcan.dispose(); this.trashcan = null; } if (this.scrollbar) { this.scrollbar.dispose(); this.scrollbar = null; } if (this.zoomControls_) { this.zoomControls_.dispose(); this.zoomControls_ = null; } if (this.audioManager_) { this.audioManager_.dispose(); this.audioManager_ = null; } if (this.grid_) { this.grid_.dispose(); this.grid_ = null; } this.renderer_.dispose(); if (this.markerManager_) { this.markerManager_.dispose(); this.markerManager_ = null; } WorkspaceSvg.superClass_.dispose.call(this); // Dispose of theme manager after all blocks and mutators are disposed of. if (this.themeManager_) { this.themeManager_.unsubscribeWorkspace(this); this.themeManager_.unsubscribe(this.svgBackground_); if (!this.options.parentWorkspace) { this.themeManager_.dispose(); this.themeManager_ = null; } } this.connectionDBList = null; this.toolboxCategoryCallbacks_ = null; this.flyoutButtonCallbacks_ = null; if (!this.options.parentWorkspace) { // Top-most workspace. Dispose of the div that the // SVG is injected into (i.e. injectionDiv). const parentSvg = this.getParentSvg(); if (parentSvg && parentSvg.parentNode) { dom.removeNode(parentSvg.parentNode); } } if (this.resizeHandlerWrapper_) { browserEvents.unbind(this.resizeHandlerWrapper_); this.resizeHandlerWrapper_ = null; } }; /** * Obtain a newly created block. * * This block's SVG must still be initialized * ([initSvg]{@link BlockSvg#initSvg}) and it must be rendered * ([render]{@link BlockSvg#render}) before the block will be visible. * @param {!string} prototypeName Name of the language object containing * type-specific functions for this block. * @param {string=} opt_id Optional ID. Use this ID if provided, otherwise * create a new ID. * @return {!BlockSvg} The created block. * @override */ WorkspaceSvg.prototype.newBlock = function(prototypeName, opt_id) { return new BlockSvg(this, prototypeName, opt_id); }; /** * Add a trashcan. * @package */ WorkspaceSvg.prototype.addTrashcan = function() { const {Trashcan} = goog.module.get('Blockly.Trashcan'); if (!Trashcan) { throw Error('Missing require for Blockly.Trashcan'); } /** @type {Trashcan} */ this.trashcan = new Trashcan(this); const svgTrashcan = this.trashcan.createDom(); this.svgGroup_.insertBefore(svgTrashcan, this.svgBlockCanvas_); }; /** * Add zoom controls. * @package */ WorkspaceSvg.prototype.addZoomControls = function() { const {ZoomControls} = goog.module.get('Blockly.ZoomControls'); if (!ZoomControls) { throw Error('Missing require for Blockly.ZoomControls'); } /** @type {ZoomControls} */ this.zoomControls_ = new ZoomControls(this); const svgZoomControls = this.zoomControls_.createDom(); this.svgGroup_.appendChild(svgZoomControls); }; /** * Add a flyout element in an element with the given tag name. * @param {string| * !Svg<!SVGSVGElement>| * !Svg<!SVGGElement>} tagName What type of tag the * flyout belongs in. * @return {!Element} The element containing the flyout DOM. * @package */ WorkspaceSvg.prototype.addFlyout = function(tagName) { const workspaceOptions = new Options( /** @type {!BlocklyOptions} */ ({ 'parentWorkspace': this, 'rtl': this.RTL, 'oneBasedIndex': this.options.oneBasedIndex, 'horizontalLayout': this.horizontalLayout, 'renderer': this.options.renderer, 'rendererOverrides': this.options.rendererOverrides, 'move': { 'scrollbars': true, }, })); workspaceOptions.toolboxPosition = this.options.toolboxPosition; if (this.horizontalLayout) { const HorizontalFlyout = registry.getClassFromOptions( registry.Type.FLYOUTS_HORIZONTAL_TOOLBOX, this.options, true); this.flyout_ = new HorizontalFlyout(workspaceOptions); } else { const VerticalFlyout = registry.getClassFromOptions( registry.Type.FLYOUTS_VERTICAL_TOOLBOX, this.options, true); this.flyout_ = new VerticalFlyout(workspaceOptions); } this.flyout_.autoClose = false; this.flyout_.getWorkspace().setVisible(true); // Return the element so that callers can place it in their desired // spot in the DOM. For example, mutator flyouts do not go in the same place // as main workspace flyouts. return this.flyout_.createDom(tagName); }; /** * Getter for the flyout associated with this workspace. This flyout may be * owned by either the toolbox or the workspace, depending on toolbox * configuration. It will be null if there is no flyout. * @param {boolean=} opt_own Whether to only return the workspace's own flyout. * @return {?IFlyout} The flyout on this workspace. * @package */ WorkspaceSvg.prototype.getFlyout = function(opt_own) { if (this.flyout_ || opt_own) { return this.flyout_; } if (this.toolbox_) { return this.toolbox_.getFlyout(); } return null; }; /** * Getter for the toolbox associated with this workspace, if one exists. * @return {?IToolbox} The toolbox on this workspace. * @package */ WorkspaceSvg.prototype.getToolbox = function() { return this.toolbox_; }; /** * Update items that use screen coordinate calculations * because something has changed (e.g. scroll position, window size). * @private */ WorkspaceSvg.prototype.updateScreenCalculations_ = function() { this.updateInverseScreenCTM(); this.recordDragTargets(); }; /** * If enabled, resize the parts of the workspace that change when the workspace * contents (e.g. block positions) change. This will also scroll the * workspace contents if needed. * @package */ WorkspaceSvg.prototype.resizeContents = function() { if (!this.resizesEnabled_ || !this.rendered) { return; } if (this.scrollbar) { this.scrollbar.resize(); } this.updateInverseScreenCTM(); }; /** * Resize and reposition all of the workspace chrome (toolbox, * trash, scrollbars etc.) * This should be called when something changes that * requires recalculating dimensions and positions of the * trash, zoom, toolbox, etc. (e.g. window resize). */ WorkspaceSvg.prototype.resize = function() { if (this.toolbox_) { this.toolbox_.position(); } if (this.flyout_) { this.flyout_.position(); } const positionables = this.componentManager_.getComponents( ComponentManager.Capability.POSITIONABLE, true); const metrics = this.getMetricsManager().getUiMetrics(); const savedPositions = []; for (let i = 0, positionable; (positionable = positionables[i]); i++) { positionable.position(metrics, savedPositions); const boundingRect = positionable.getBoundingRectangle(); if (boundingRect) { savedPositions.push(boundingRect); } } if (this.scrollbar) { this.scrollbar.resize(); } this.updateScreenCalculations_(); }; /** * Resizes and repositions workspace chrome if the page has a new * scroll position. * @package */ WorkspaceSvg.prototype.updateScreenCalculationsIfScrolled = function() { /* eslint-disable indent */ const currScroll = svgMath.getDocumentScroll(); if (!Coordinate.equals(this.lastRecordedPageScroll_, currScroll)) { this.lastRecordedPageScroll_ = currScroll; this.updateScreenCalculations_(); } }; /* eslint-enable indent */ /** * Get the SVG element that forms the drawing surface. * @return {!SVGGElement} SVG group element. */ WorkspaceSvg.prototype.getCanvas = function() { return /** @type {!SVGGElement} */ (this.svgBlockCanvas_); }; /** * Caches the width and height of the workspace's parent SVG element for use * with getSvgMetrics. * @param {?number} width The width of the parent SVG element. * @param {?number} height The height of the parent SVG element * @package */ WorkspaceSvg.prototype.setCachedParentSvgSize = function(width, height) { const svg = this.getParentSvg(); if (width) { this.cachedParentSvgSize_.width = width; // This is set to support the public (but deprecated) Blockly.svgSize // method. svg.cachedWidth_ = width; } if (height) { this.cachedParentSvgSize_.height = height; // This is set to support the public (but deprecated) Blockly.svgSize // method. svg.cachedHeight_ = height; } }; /** * Get the SVG element that forms the bubble surface. * @return {!SVGGElement} SVG group element. */ WorkspaceSvg.prototype.getBubbleCanvas = function() { return /** @type {!SVGGElement} */ (this.svgBubbleCanvas_); }; /** * Get the SVG element that contains this workspace. * Note: We assume this is only called after the workspace has been injected * into the DOM. * @return {!SVGElement} SVG element. */ WorkspaceSvg.prototype.getParentSvg = function() { if (!this.cachedParentSvg_) { let element = this.svgGroup_; while (element) { if (element.tagName === 'svg') { this.cachedParentSvg_ = element; break; } element = /** @type {!SVGElement} */ (element.parentNode); } } return /** @type {!SVGElement} */ (this.cachedParentSvg_); }; /** * Fires a viewport event if events are enabled and there is a change in * viewport values. * @package */ WorkspaceSvg.prototype.maybeFireViewportChangeEvent = function() { if (!eventUtils.isEnabled()) { return; } const scale = this.scale; const top = -this.scrollY; const left = -this.scrollX; if (scale === this.oldScale_ && Math.abs(top - this.oldTop_) < 1 && Math.abs(left - this.oldLeft_) < 1) { // Ignore sub-pixel changes in top and left. Due to #4192 there are a lot of // negligible changes in viewport top/left. return; } const event = new (eventUtils.get(eventUtils.VIEWPORT_CHANGE))( top, left, scale, this.id, this.oldScale_); this.oldScale_ = scale; this.oldTop_ = top; this.oldLeft_ = left; eventUtils.fire(event); }; /** * Translate this workspace to new coordinates. * @param {number} x Horizontal translation, in pixel units relative to the * top left of the Blockly div. * @param {number} y Vertical translation, in pixel units relative to the * top left of the Blockly div. */ WorkspaceSvg.prototype.translate = function(x, y) { if (this.useWorkspaceDragSurface_ && this.isDragSurfaceActive_) { this.workspaceDragSurface_.translateSurface(x, y); } else { const translation = 'translate(' + x + ',' + y + ') ' + 'scale(' + this.scale + ')'; this.svgBlockCanvas_.setAttribute('transform', translation); this.svgBubbleCanvas_.setAttribute('transform', translation); } // Now update the block drag surface if we're using one. if (this.blockDragSurface_) { this.blockDragSurface_.translateAndScaleGroup(x, y, this.scale); } // And update the grid if we're using one. if (this.grid_) { this.grid_.moveTo(x, y); } this.maybeFireViewportChangeEvent(); }; /** * Called at the end of a workspace drag to take the contents * out of the drag surface and put them back into the workspace SVG. * Does nothing if the workspace drag surface is not enabled. * @package */ WorkspaceSvg.prototype.resetDragSurface = function() { // Don't do anything if we aren't using a drag surface. if (!this.useWorkspaceDragSurface_) { return; } this.isDragSurfaceActive_ = false; const trans = this.workspaceDragSurface_.getSurfaceTranslation(); this.workspaceDragSurface_.clearAndHide(this.svgGroup_); const translation = 'translate(' + trans.x + ',' + trans.y + ') ' + 'scale(' + this.scale + ')'; this.svgBlockCanvas_.setAttribute('transform', translation); this.svgBubbleCanvas_.setAttribute('transform', translation); }; /** * Called at the beginning of a workspace drag to move contents of * the workspace to the drag surface. * Does nothing if the drag surface is not enabled. * @package */ WorkspaceSvg.prototype.setupDragSurface = function() { // Don't do anything if we aren't using a drag surface. if (!this.useWorkspaceDragSurface_) { return; } // This can happen if the user starts a drag, mouses up outside of the // document where the mouseup listener is registered (e.g. outside of an // iframe) and then moves the mouse back in the workspace. On mobile and ff, // we get the mouseup outside the frame. On chrome and safari desktop we do // not. if (this.isDragSurfaceActive_) { return; } this.isDragSurfaceActive_ = true; // Figure out where we want to put the canvas back. The order // in the is important because things are layered. const previousElement = /** @type {Element} */ (this.svgBlockCanvas_.previousSibling); const width = parseInt(this.getParentSvg().getAttribute('width'), 10); const height = parseInt(this.getParentSvg().getAttribute('height'), 10); const coord = svgMath.getRelativeXY(this.getCanvas()); this.workspaceDragSurface_.setContentsAndShow( this.getCanvas(), this.getBubbleCanvas(), previousElement, width, height, this.scale); this.workspaceDragSurface_.translateSurface(coord.x, coord.y); }; /** * Gets the drag surface blocks are moved to when a drag is started. * @return {?BlockDragSurfaceSvg} This workspace's block drag surface, * if one is in use. * @package */ WorkspaceSvg.prototype.getBlockDragSurface = function() { return this.blockDragSurface_; }; /** * Returns the horizontal offset of the workspace. * Intended for LTR/RTL compatibility in XML. * @return {number} Width. */ WorkspaceSvg.prototype.getWidth = function() { const metrics = this.getMetrics(); return metrics ? metrics.viewWidth / this.scale : 0; }; /** * Toggles the visibility of the workspace. * Currently only intended for main workspace. * @param {boolean} isVisible True if workspace should be visible. */ WorkspaceSvg.prototype.setVisible = function(isVisible) { this.isVisible_ = isVisible; if (!this.svgGroup_) { return; } // Tell the scrollbar whether its container is visible so it can // tell when to hide itself. if (this.scrollbar) { this.scrollbar.setContainerVisible(isVisible); } // Tell the flyout whether its container is visible so it can // tell when to hide itself. if (this.getFlyout()) { this.getFlyout().setContainerVisible(isVisible); } this.getParentSvg().style.display = isVisible ? 'block' : 'none'; if (this.toolbox_) { // Currently does not support toolboxes in mutators. this.toolbox_.setVisible(isVisible); } if (isVisible) { const blocks = this.getAllBlocks(false); // Tell each block on the workspace to mark its fields as dirty. for (let i = blocks.length - 1; i >= 0; i--) { blocks[i].markDirty(); } this.render(); if (this.toolbox_) { this.toolbox_.position(); } } else { this.hideChaff(true); } }; /** * Render all blocks in workspace. */ WorkspaceSvg.prototype.render = function() { // Generate list of all blocks. const blocks = this.getAllBlocks(false); // Render each block. for (let i = blocks.length - 1; i >= 0; i--) { blocks[i].render(false); } if (this.currentGesture_) { const imList = this.currentGesture_.getInsertionMarkers(); for (let i = 0; i < imList.length; i++) { imList[i].render(false); } } this.markerManager_.updateMarkers(); }; /** * Highlight or unhighlight a block in the workspace. Block highlighting is * often used to visually mark blocks currently being executed. * @param {?string} id ID of block to highlight/unhighlight, * or null for no block (used to unhighlight all blocks). * @param {boolean=} opt_state If undefined, highlight specified block and * automatically unhighlight all others. If true or false, manually * highlight/unhighlight the specified block. */ WorkspaceSvg.prototype.highlightBlock = function(id, opt_state) { if (opt_state === undefined) { // Unhighlight all blocks. for (let i = 0, block; (block = this.highlightedBlocks_[i]); i++) { block.setHighlighted(false); } this.highlightedBlocks_.length = 0; } // Highlight/unhighlight the specified block. const block = id ? this.getBlockById(id) : null; if (block) { const state = (opt_state === undefined) || opt_state; // Using Set here would be great, but at the cost of IE10 support. if (!state) { arrayUtils.removeElem(this.highlightedBlocks_, block); } else if (this.highlightedBlocks_.indexOf(block) === -1) { this.highlightedBlocks_.push(block); } block.setHighlighted(state); } }; /** * Pastes the provided block or workspace comment onto the workspace. * Does not check whether there is remaining capacity for the object, that * should be done before calling this method. * @param {!Object|!Element|!DocumentFragment} state The representation of the * thing to paste. */ WorkspaceSvg.prototype.paste = function(state) { if (!this.rendered || !state['type'] && !state.tagName) { return; } if (this.currentGesture_) { this.currentGesture_.cancel(); // Dragging while pasting? No. } // Checks if this is JSON. JSON has a type property, while elements don't. if (state['type']) { this.pasteBlock_(null, /** @type {!blocks.State} */ (state)); } else { const xmlBlock = /** @type {!Element} */ (state); if (xmlBlock.tagName.toLowerCase() === 'comment') { this.pasteWorkspaceComment_(xmlBlock); } else { this.pasteBlock_(xmlBlock, null); } } }; /** * Paste the provided block onto the workspace. * @param {?Element} xmlBlock XML block element. * @param {?blocks.State} jsonBlock JSON block * representation. * @private */ WorkspaceSvg.prototype.pasteBlock_ = function(xmlBlock, jsonBlock) { eventUtils.disable(); let block; try { let blockX = 0; let blockY = 0; if (xmlBlock) { block = Xml.domToBlock(xmlBlock, this); blockX = parseInt(xmlBlock.getAttribute('x'), 10); if (this.RTL) { blockX = -blockX; } blockY = parseInt(xmlBlock.getAttribute('y'), 10); } else if (jsonBlock) { block = blocks.append(jsonBlock, this); blockX = jsonBlock['x'] || 10; if (this.RTL) { blockX = this.getWidth() - blockX; } blockY = jsonBlock['y'] || 10; } // Move the duplicate to original position. if (!isNaN(blockX) && !isNaN(blockY)) { // Offset block until not clobbering another block and not in connection // distance with neighbouring blocks. let collide; do { collide = false; const allBlocks = this.getAllBlocks(false); for (let i = 0, otherBlock; (otherBlock = allBlocks[i]); i++) { const otherXY = otherBlock.getRelativeToSurfaceXY(); if (Math.abs(blockX - otherXY.x) <= 1 && Math.abs(blockY - otherXY.y) <= 1) { collide = true; break; } } if (!collide) { // Check for blocks in snap range to any of its connections. const connections = block.getConnections_(false); for (let i = 0, connection; (connection = connections[i]); i++) { const neighbour = connection.closest( internalConstants.SNAP_RADIUS, new Coordinate(blockX, blockY)); if (neighbour.connection) { collide = true; break; } } } if (collide) { if (this.RTL) {