mirror of
https://github.com/google/blockly.git
synced 2025-12-15 22:00:07 +01:00
2547 lines
77 KiB
TypeScript
2547 lines
77 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2014 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Object representing a workspace rendered as SVG.
|
|
*
|
|
* @class
|
|
*/
|
|
// Former goog.module ID: Blockly.WorkspaceSvg
|
|
|
|
// Unused import preserved for side-effects. Remove if unneeded.
|
|
import './events/events_block_create.js';
|
|
// Unused import preserved for side-effects. Remove if unneeded.
|
|
import './events/events_theme_change.js';
|
|
// Unused import preserved for side-effects. Remove if unneeded.
|
|
import './events/events_viewport.js';
|
|
|
|
import type {Block} from './block.js';
|
|
import type {BlockSvg} from './block_svg.js';
|
|
import type {BlocklyOptions} from './blockly_options.js';
|
|
import * as browserEvents from './browser_events.js';
|
|
import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
|
|
import {WorkspaceComment} from './comments/workspace_comment.js';
|
|
import * as common from './common.js';
|
|
import {ComponentManager} from './component_manager.js';
|
|
import {ConnectionDB} from './connection_db.js';
|
|
import * as ContextMenu from './contextmenu.js';
|
|
import {
|
|
ContextMenuOption,
|
|
ContextMenuRegistry,
|
|
} from './contextmenu_registry.js';
|
|
import * as dropDownDiv from './dropdowndiv.js';
|
|
import {EventType} from './events/type.js';
|
|
import * as eventUtils from './events/utils.js';
|
|
import {Flyout} from './flyout_base.js';
|
|
import type {FlyoutButton} from './flyout_button.js';
|
|
import {Gesture} from './gesture.js';
|
|
import {Grid} from './grid.js';
|
|
import type {IASTNodeLocationSvg} from './interfaces/i_ast_node_location_svg.js';
|
|
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
|
|
import type {IDragTarget} from './interfaces/i_drag_target.js';
|
|
import type {IFlyout} from './interfaces/i_flyout.js';
|
|
import type {IMetricsManager} from './interfaces/i_metrics_manager.js';
|
|
import type {IToolbox} from './interfaces/i_toolbox.js';
|
|
import type {
|
|
IVariableModel,
|
|
IVariableState,
|
|
} from './interfaces/i_variable_model.js';
|
|
import type {Cursor} from './keyboard_nav/cursor.js';
|
|
import type {Marker} from './keyboard_nav/marker.js';
|
|
import {LayerManager} from './layer_manager.js';
|
|
import {MarkerManager} from './marker_manager.js';
|
|
import {Options} from './options.js';
|
|
import * as Procedures from './procedures.js';
|
|
import * as registry from './registry.js';
|
|
import * as renderManagement from './render_management.js';
|
|
import * as blockRendering from './renderers/common/block_rendering.js';
|
|
import type {Renderer} from './renderers/common/renderer.js';
|
|
import type {ScrollbarPair} from './scrollbar_pair.js';
|
|
import type {Theme} from './theme.js';
|
|
import {Classic} from './theme/classic.js';
|
|
import {ThemeManager} from './theme_manager.js';
|
|
import * as Tooltip from './tooltip.js';
|
|
import type {Trashcan} from './trashcan.js';
|
|
import * as arrayUtils from './utils/array.js';
|
|
import {Coordinate} from './utils/coordinate.js';
|
|
import * as dom from './utils/dom.js';
|
|
import * as drag from './utils/drag.js';
|
|
import type {Metrics} from './utils/metrics.js';
|
|
import {Rect} from './utils/rect.js';
|
|
import {Size} from './utils/size.js';
|
|
import {Svg} from './utils/svg.js';
|
|
import * as svgMath from './utils/svg_math.js';
|
|
import * as toolbox from './utils/toolbox.js';
|
|
import * as userAgent from './utils/useragent.js';
|
|
import * as Variables from './variables.js';
|
|
import * as VariablesDynamic from './variables_dynamic.js';
|
|
import * as WidgetDiv from './widgetdiv.js';
|
|
import {Workspace} from './workspace.js';
|
|
import {WorkspaceAudio} from './workspace_audio.js';
|
|
import {ZoomControls} from './zoom_controls.js';
|
|
|
|
/** Margin around the top/bottom/left/right after a zoomToFit call. */
|
|
const ZOOM_TO_FIT_MARGIN = 20;
|
|
|
|
/**
|
|
* Class for a workspace. This is an onscreen area with optional trashcan,
|
|
* scrollbars, bubbles, and dragging.
|
|
*/
|
|
export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg {
|
|
/**
|
|
* A wrapper function called when a resize event occurs.
|
|
* You can pass the result to `eventHandling.unbind`.
|
|
*/
|
|
private resizeHandlerWrapper: browserEvents.Data | null = null;
|
|
|
|
/**
|
|
* The render status of an SVG workspace.
|
|
* Returns `false` for headless workspaces and true for instances of
|
|
* `WorkspaceSvg`.
|
|
*/
|
|
override rendered = true;
|
|
|
|
/**
|
|
* Whether the workspace is visible. False if the workspace has been hidden
|
|
* by calling `setVisible(false)`.
|
|
*/
|
|
private visible = true;
|
|
|
|
/**
|
|
* Whether this workspace has resizes enabled.
|
|
* Disable during batch operations for a performance improvement.
|
|
*/
|
|
private 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.
|
|
*/
|
|
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.
|
|
*/
|
|
scrollY = 0;
|
|
|
|
/** Horizontal scroll value when scrolling started in pixel units. */
|
|
startScrollX = 0;
|
|
|
|
/** Vertical scroll value when scrolling started in pixel units. */
|
|
startScrollY = 0;
|
|
|
|
/** Current scale. */
|
|
scale = 1;
|
|
|
|
/** Cached scale value. Used to detect changes in viewport. */
|
|
private oldScale = 1;
|
|
|
|
/** Cached viewport top value. Used to detect changes in viewport. */
|
|
private oldTop = 0;
|
|
|
|
/** Cached viewport left value. Used to detect changes in viewport. */
|
|
private oldLeft = 0;
|
|
|
|
/** The workspace's trashcan (if any). */
|
|
trashcan: Trashcan | null = null;
|
|
|
|
/** This workspace's scrollbars, if they exist. */
|
|
scrollbar: ScrollbarPair | null = null;
|
|
|
|
/**
|
|
* Fixed flyout providing blocks which may be dragged into this workspace.
|
|
*/
|
|
private flyout: IFlyout | null = null;
|
|
|
|
/**
|
|
* Category-based toolbox providing blocks which may be dragged into this
|
|
* workspace.
|
|
*/
|
|
private toolbox: IToolbox | null = null;
|
|
|
|
/**
|
|
* The current gesture in progress on this workspace, if any.
|
|
*
|
|
* @internal
|
|
*/
|
|
currentGesture_: Gesture | null = null;
|
|
|
|
/**
|
|
* The first parent div with 'injectionDiv' in the name, or null if not set.
|
|
* Access this with getInjectionDiv.
|
|
*/
|
|
private injectionDiv: HTMLElement | null = null;
|
|
|
|
/**
|
|
* Last known position of the page scroll.
|
|
* This is used to determine whether we have recalculated screen coordinate
|
|
* stuff since the page scrolled.
|
|
*/
|
|
private lastRecordedPageScroll: Coordinate | null = 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 options List of menu options to add to.
|
|
* @param e The right-click event that triggered the context menu.
|
|
*/
|
|
configureContextMenu:
|
|
| ((menuOptions: ContextMenuOption[], e: Event) => void)
|
|
| null = null;
|
|
|
|
/**
|
|
* A dummy wheel event listener used as a workaround for a Safari scrolling issue.
|
|
* Set in createDom and used for removal in dispose to ensure proper cleanup.
|
|
*/
|
|
private dummyWheelListener: (() => void) | null = null;
|
|
|
|
/**
|
|
* In a flyout, the target workspace where blocks should be placed after a
|
|
* drag. Otherwise null.
|
|
*
|
|
* @internal
|
|
*/
|
|
targetWorkspace: WorkspaceSvg | null = null;
|
|
|
|
/** Inverted screen CTM, for use in mouseToSvg. */
|
|
private inverseScreenCTM: SVGMatrix | null = null;
|
|
|
|
/** Inverted screen CTM is dirty, recalculate it. */
|
|
private inverseScreenCTMDirty = true;
|
|
private metricsManager: IMetricsManager;
|
|
/** @internal */
|
|
getMetrics: () => Metrics;
|
|
/** @internal */
|
|
setMetrics: (p1: {x?: number; y?: number}) => void;
|
|
private readonly componentManager: ComponentManager;
|
|
|
|
/**
|
|
* List of currently highlighted blocks. Block highlighting is often used
|
|
* to visually mark blocks currently being executed.
|
|
*/
|
|
private readonly highlightedBlocks: BlockSvg[] = [];
|
|
private audioManager: WorkspaceAudio;
|
|
private grid: Grid | null;
|
|
private markerManager: MarkerManager;
|
|
|
|
/**
|
|
* Map from function names to callbacks, for deciding what to do when a
|
|
* custom toolbox category is opened.
|
|
*/
|
|
private toolboxCategoryCallbacks = new Map<
|
|
string,
|
|
(p1: WorkspaceSvg) => toolbox.FlyoutDefinition
|
|
>();
|
|
|
|
/**
|
|
* Map from function names to callbacks, for deciding what to do when a
|
|
* button is clicked.
|
|
*/
|
|
private flyoutButtonCallbacks = new Map<string, (p1: FlyoutButton) => void>();
|
|
protected themeManager_: ThemeManager;
|
|
private readonly renderer: Renderer;
|
|
|
|
/** Cached parent SVG. */
|
|
private cachedParentSvg: SVGElement | null = null;
|
|
|
|
/** True if keyboard accessibility mode is on, false otherwise. */
|
|
keyboardAccessibilityMode = false;
|
|
|
|
/** The list of top-level bounded elements on the workspace. */
|
|
private topBoundedElements: IBoundedElement[] = [];
|
|
|
|
/** The recorded drag targets. */
|
|
private dragTargetAreas: Array<{component: IDragTarget; clientRect: Rect}> =
|
|
[];
|
|
private readonly cachedParentSvgSize: Size;
|
|
private layerManager: LayerManager | null = null;
|
|
// TODO(b/109816955): remove '!', see go/strict-prop-init-fix.
|
|
svgGroup_!: SVGElement;
|
|
// TODO(b/109816955): remove '!', see go/strict-prop-init-fix.
|
|
svgBackground_!: SVGElement;
|
|
// TODO(b/109816955): remove '!', see go/strict-prop-init-fix.
|
|
svgBlockCanvas_!: SVGElement;
|
|
// TODO(b/109816955): remove '!', see go/strict-prop-init-fix.
|
|
svgBubbleCanvas_!: SVGElement;
|
|
zoomControls_: ZoomControls | null = null;
|
|
|
|
/**
|
|
* @param options Dictionary of options.
|
|
*/
|
|
constructor(options: Options) {
|
|
super(options);
|
|
|
|
const MetricsManagerClass = registry.getClassFromOptions(
|
|
registry.Type.METRICS_MANAGER,
|
|
options,
|
|
true,
|
|
);
|
|
/** Object in charge of calculating metrics for the workspace. */
|
|
this.metricsManager = new MetricsManagerClass!(this);
|
|
|
|
/** Method to get all the metrics that have to do with a workspace. */
|
|
this.getMetrics =
|
|
options.getMetrics ||
|
|
this.metricsManager.getMetrics.bind(this.metricsManager);
|
|
|
|
/** Translates the workspace. */
|
|
this.setMetrics =
|
|
options.setMetrics || WorkspaceSvg.setTopLevelWorkspaceMetrics;
|
|
|
|
this.componentManager = new ComponentManager();
|
|
|
|
this.connectionDBList = ConnectionDB.init(this.connectionChecker);
|
|
|
|
/**
|
|
* Object in charge of loading, storing, and playing audio for a workspace.
|
|
*/
|
|
this.audioManager = new WorkspaceAudio(
|
|
options.parentWorkspace as WorkspaceSvg,
|
|
);
|
|
|
|
/** This workspace's grid object or null. */
|
|
this.grid = this.options.gridPattern
|
|
? new Grid(this.options.gridPattern, options.gridOptions)
|
|
: null;
|
|
|
|
/** Manager in charge of markers and cursors. */
|
|
this.markerManager = new MarkerManager(this);
|
|
|
|
if (Variables && Variables.flyoutCategory) {
|
|
this.registerToolboxCategoryCallback(
|
|
Variables.CATEGORY_NAME,
|
|
Variables.flyoutCategory,
|
|
);
|
|
}
|
|
|
|
if (VariablesDynamic && VariablesDynamic.flyoutCategory) {
|
|
this.registerToolboxCategoryCallback(
|
|
VariablesDynamic.CATEGORY_NAME,
|
|
VariablesDynamic.flyoutCategory,
|
|
);
|
|
}
|
|
|
|
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. */
|
|
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. */
|
|
this.renderer = blockRendering.init(
|
|
this.options.renderer || 'geras',
|
|
this.getTheme(),
|
|
this.options.rendererOverrides ?? undefined,
|
|
);
|
|
|
|
/**
|
|
* The cached size of the parent svg element.
|
|
* Used to compute svg metrics.
|
|
*/
|
|
this.cachedParentSvgSize = new Size(0, 0);
|
|
}
|
|
|
|
/**
|
|
* Get the marker manager for this workspace.
|
|
*
|
|
* @returns The marker manager.
|
|
*/
|
|
getMarkerManager(): MarkerManager {
|
|
return this.markerManager;
|
|
}
|
|
|
|
/**
|
|
* Gets the metrics manager for this workspace.
|
|
*
|
|
* @returns The metrics manager.
|
|
*/
|
|
getMetricsManager(): IMetricsManager {
|
|
return this.metricsManager;
|
|
}
|
|
|
|
/**
|
|
* Sets the metrics manager for the workspace.
|
|
*
|
|
* @param metricsManager The metrics manager.
|
|
* @internal
|
|
*/
|
|
setMetricsManager(metricsManager: IMetricsManager) {
|
|
this.metricsManager = metricsManager;
|
|
this.getMetrics = this.metricsManager.getMetrics.bind(this.metricsManager);
|
|
}
|
|
|
|
/**
|
|
* Gets the component manager for this workspace.
|
|
*
|
|
* @returns The component manager.
|
|
*/
|
|
getComponentManager(): ComponentManager {
|
|
return this.componentManager;
|
|
}
|
|
|
|
/**
|
|
* Add the cursor SVG to this workspaces SVG group.
|
|
*
|
|
* @param cursorSvg The SVG root of the cursor to be added to the workspace
|
|
* SVG group.
|
|
* @internal
|
|
*/
|
|
setCursorSvg(cursorSvg: SVGElement) {
|
|
this.markerManager.setCursorSvg(cursorSvg);
|
|
}
|
|
|
|
/**
|
|
* Add the marker SVG to this workspaces SVG group.
|
|
*
|
|
* @param markerSvg The SVG root of the marker to be added to the workspace
|
|
* SVG group.
|
|
* @internal
|
|
*/
|
|
setMarkerSvg(markerSvg: SVGElement) {
|
|
this.markerManager.setMarkerSvg(markerSvg);
|
|
}
|
|
|
|
/**
|
|
* Get the marker with the given ID.
|
|
*
|
|
* @param id The ID of the marker.
|
|
* @returns The marker with the given ID or null if no marker with the given
|
|
* ID exists.
|
|
* @internal
|
|
*/
|
|
getMarker(id: string): Marker | null {
|
|
if (this.markerManager) {
|
|
return this.markerManager.getMarker(id);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The cursor for this workspace.
|
|
*
|
|
* @returns The cursor for the workspace.
|
|
*/
|
|
getCursor(): Cursor | null {
|
|
if (this.markerManager) {
|
|
return this.markerManager.getCursor();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the block renderer attached to this workspace.
|
|
*
|
|
* @returns The renderer attached to this workspace.
|
|
*/
|
|
getRenderer(): Renderer {
|
|
return this.renderer;
|
|
}
|
|
|
|
/**
|
|
* Get the theme manager for this workspace.
|
|
*
|
|
* @returns The theme manager for this workspace.
|
|
* @internal
|
|
*/
|
|
getThemeManager(): ThemeManager {
|
|
return this.themeManager_;
|
|
}
|
|
|
|
/**
|
|
* Get the workspace theme object.
|
|
*
|
|
* @returns The workspace theme object.
|
|
*/
|
|
getTheme(): Theme {
|
|
return this.themeManager_.getTheme();
|
|
}
|
|
|
|
/**
|
|
* Set the workspace theme object.
|
|
* If no theme is passed, default to the `Classic` theme.
|
|
*
|
|
* @param theme The workspace theme object.
|
|
*/
|
|
setTheme(theme: Theme) {
|
|
if (!theme) {
|
|
theme = Classic as Theme;
|
|
}
|
|
this.themeManager_.setTheme(theme);
|
|
}
|
|
|
|
/**
|
|
* Refresh all blocks on the workspace after a theme update.
|
|
*/
|
|
refreshTheme() {
|
|
if (this.svgGroup_) {
|
|
const isParentWorkspace = this.options.parentWorkspace === null;
|
|
this.renderer.refreshDom(
|
|
this.svgGroup_,
|
|
this.getTheme(),
|
|
isParentWorkspace ? this.getInjectionDiv() : undefined,
|
|
);
|
|
}
|
|
|
|
// Update all blocks in workspace that have a style name.
|
|
this.updateBlockStyles(
|
|
this.getAllBlocks(false).filter((block) => !!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(EventType.THEME_CHANGE))(
|
|
this.getTheme().name,
|
|
this.id,
|
|
);
|
|
eventUtils.fire(event);
|
|
}
|
|
|
|
/**
|
|
* Updates all the blocks with new style.
|
|
*
|
|
* @param blocks List of blocks to update the style on.
|
|
*/
|
|
private updateBlockStyles(blocks: Block[]) {
|
|
for (let i = 0, block; (block = blocks[i]); i++) {
|
|
const blockStyleName = block.getStyleName();
|
|
if (blockStyleName) {
|
|
const blockSvg = block as BlockSvg;
|
|
blockSvg.setStyle(blockStyleName);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Getter for the inverted screen CTM.
|
|
*
|
|
* @returns The matrix to use in mouseToSvg
|
|
*/
|
|
getInverseScreenCTM(): SVGMatrix | null {
|
|
// 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. */
|
|
updateInverseScreenCTM() {
|
|
this.inverseScreenCTMDirty = true;
|
|
}
|
|
|
|
/**
|
|
* Getter for isVisible
|
|
*
|
|
* @returns Whether the workspace is visible.
|
|
* False if the workspace has been hidden by calling `setVisible(false)`.
|
|
*/
|
|
isVisible(): boolean {
|
|
return this.visible;
|
|
}
|
|
|
|
/**
|
|
* 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 element SVG element to find the coordinates of.
|
|
* @returns Object with .x and .y properties.
|
|
* @internal
|
|
*/
|
|
getSvgXY(element: SVGElement): Coordinate {
|
|
let x = 0;
|
|
let y = 0;
|
|
let scale = 1;
|
|
if (
|
|
this.getCanvas().contains(element) ||
|
|
this.getBubbleCanvas().contains(element)
|
|
) {
|
|
// Before the SVG canvas, scale the coordinates.
|
|
scale = this.scale;
|
|
}
|
|
let ancestor: Element = element;
|
|
do {
|
|
// Loop through this block and every parent.
|
|
const xy = svgMath.getRelativeXY(ancestor);
|
|
if (
|
|
ancestor === this.getCanvas() ||
|
|
ancestor === this.getBubbleCanvas()
|
|
) {
|
|
// After the SVG canvas, don't scale the coordinates.
|
|
scale = 1;
|
|
}
|
|
x += xy.x * scale;
|
|
y += xy.y * scale;
|
|
ancestor = ancestor.parentNode as Element;
|
|
} while (
|
|
ancestor &&
|
|
ancestor !== this.getParentSvg() &&
|
|
ancestor !== this.getInjectionDiv()
|
|
);
|
|
return new Coordinate(x, y);
|
|
}
|
|
|
|
/**
|
|
* Gets the size of the workspace's parent SVG element.
|
|
*
|
|
* @returns The cached width and height of the workspace's parent SVG element.
|
|
* @internal
|
|
*/
|
|
getCachedParentSvgSize(): Size {
|
|
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.
|
|
*
|
|
* @returns Offset in pixels.
|
|
* @internal
|
|
*/
|
|
getOriginOffsetInPixels(): Coordinate {
|
|
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.
|
|
*
|
|
* @returns The first parent div with 'injectionDiv' in the name.
|
|
* @internal
|
|
*/
|
|
getInjectionDiv(): HTMLElement {
|
|
// 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: Element = this.svgGroup_;
|
|
while (element) {
|
|
const classes = element.getAttribute('class') || '';
|
|
if ((' ' + classes + ' ').includes(' injectionDiv ')) {
|
|
this.injectionDiv = element as HTMLElement;
|
|
break;
|
|
}
|
|
element = element.parentNode as Element;
|
|
}
|
|
}
|
|
return this.injectionDiv!;
|
|
}
|
|
|
|
/**
|
|
* Returns the SVG group for the workspace.
|
|
*
|
|
* @returns The SVG group for the workspace.
|
|
*/
|
|
getSvgGroup(): Element {
|
|
return this.svgGroup_;
|
|
}
|
|
|
|
/**
|
|
* Get the SVG block canvas for the workspace.
|
|
*
|
|
* @returns The SVG group for the workspace.
|
|
* @internal
|
|
*/
|
|
getBlockCanvas(): SVGElement | null {
|
|
return this.getCanvas();
|
|
}
|
|
|
|
/**
|
|
* Save resize handler data so we can delete it later in dispose.
|
|
*
|
|
* @param handler Data that can be passed to eventHandling.unbind.
|
|
*/
|
|
setResizeHandlerWrapper(handler: browserEvents.Data) {
|
|
this.resizeHandlerWrapper = handler;
|
|
}
|
|
|
|
/**
|
|
* Create the workspace DOM elements.
|
|
*
|
|
* @param opt_backgroundClass Either 'blocklyMainBackground' or
|
|
* 'blocklyMutatorBackground'.
|
|
* @returns The workspace's SVG group.
|
|
*/
|
|
createDom(opt_backgroundClass?: string, injectionDiv?: HTMLElement): Element {
|
|
if (!this.injectionDiv) {
|
|
this.injectionDiv = injectionDiv ?? null;
|
|
}
|
|
|
|
/**
|
|
* <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>
|
|
*/
|
|
this.svgGroup_ = dom.createSvgElement(Svg.G, {'class': 'blocklyWorkspace'});
|
|
|
|
// 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) {
|
|
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 = 'var(--blocklyGridPattern)';
|
|
} else {
|
|
this.themeManager_.subscribe(
|
|
this.svgBackground_,
|
|
'workspaceBackgroundColour',
|
|
'fill',
|
|
);
|
|
}
|
|
}
|
|
|
|
this.layerManager = new LayerManager(this);
|
|
// Assign the canvases for backwards compatibility.
|
|
this.svgBlockCanvas_ = this.layerManager.getBlockLayer();
|
|
this.svgBubbleCanvas_ = this.layerManager.getBubbleLayer();
|
|
|
|
if (!this.isFlyout) {
|
|
browserEvents.conditionalBind(
|
|
this.svgGroup_,
|
|
'pointerdown',
|
|
this,
|
|
this.onMouseDown,
|
|
false,
|
|
);
|
|
// 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.
|
|
this.dummyWheelListener = () => {};
|
|
document.body.addEventListener('wheel', this.dummyWheelListener);
|
|
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,
|
|
);
|
|
|
|
if (CursorClass) this.markerManager.setCursor(new CursorClass());
|
|
|
|
const isParentWorkspace = this.options.parentWorkspace === null;
|
|
this.renderer.createDom(
|
|
this.svgGroup_,
|
|
this.getTheme(),
|
|
isParentWorkspace ? this.getInjectionDiv() : undefined,
|
|
);
|
|
return this.svgGroup_;
|
|
}
|
|
|
|
/**
|
|
* Dispose of this workspace.
|
|
* Unlink from all DOM elements to prevent memory leaks.
|
|
*/
|
|
override dispose() {
|
|
// Stop rerendering.
|
|
this.rendered = false;
|
|
if (this.currentGesture_) {
|
|
this.currentGesture_.cancel();
|
|
}
|
|
if (this.svgGroup_) {
|
|
dom.removeNode(this.svgGroup_);
|
|
}
|
|
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();
|
|
}
|
|
|
|
if (this.audioManager) {
|
|
this.audioManager.dispose();
|
|
}
|
|
|
|
if (this.grid) {
|
|
this.grid = null;
|
|
}
|
|
|
|
this.renderer.dispose();
|
|
|
|
if (this.markerManager) {
|
|
this.markerManager.dispose();
|
|
}
|
|
|
|
super.dispose();
|
|
|
|
// 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.connectionDBList.length = 0;
|
|
|
|
this.toolboxCategoryCallbacks.clear();
|
|
this.flyoutButtonCallbacks.clear();
|
|
|
|
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;
|
|
}
|
|
|
|
// Remove the dummy wheel listener
|
|
if (this.dummyWheelListener) {
|
|
document.body.removeEventListener('wheel', this.dummyWheelListener);
|
|
this.dummyWheelListener = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a trashcan.
|
|
*
|
|
* @internal
|
|
*/
|
|
addTrashcan() {
|
|
this.trashcan = WorkspaceSvg.newTrashcan(this);
|
|
const svgTrashcan = this.trashcan.createDom();
|
|
this.svgGroup_.insertBefore(svgTrashcan, this.getCanvas());
|
|
}
|
|
|
|
/**
|
|
* @param _workspace
|
|
* @internal
|
|
*/
|
|
static newTrashcan(_workspace: WorkspaceSvg): Trashcan {
|
|
throw new Error(
|
|
'The implementation of newTrashcan should be ' +
|
|
'monkey-patched in by blockly.ts',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Add zoom controls.
|
|
*
|
|
* @internal
|
|
*/
|
|
addZoomControls() {
|
|
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 tagName What type of tag the flyout belongs in.
|
|
* @returns The element containing the flyout DOM.
|
|
* @internal
|
|
*/
|
|
addFlyout(tagName: string | Svg<SVGSVGElement> | Svg<SVGGElement>): Element {
|
|
const workspaceOptions = new Options({
|
|
'parentWorkspace': this,
|
|
'rtl': this.RTL,
|
|
'oneBasedIndex': this.options.oneBasedIndex,
|
|
'horizontalLayout': this.horizontalLayout,
|
|
'renderer': this.options.renderer,
|
|
'rendererOverrides': this.options.rendererOverrides,
|
|
'move': {
|
|
'scrollbars': true,
|
|
},
|
|
} as BlocklyOptions);
|
|
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 opt_own Whether to only return the workspace's own flyout.
|
|
* @returns The flyout on this workspace.
|
|
*/
|
|
getFlyout(opt_own?: boolean): IFlyout | null {
|
|
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.
|
|
*
|
|
* @returns The toolbox on this workspace.
|
|
*/
|
|
getToolbox(): IToolbox | null {
|
|
return this.toolbox;
|
|
}
|
|
|
|
/**
|
|
* Update items that use screen coordinate calculations
|
|
* because something has changed (e.g. scroll position, window size).
|
|
*/
|
|
private updateScreenCalculations() {
|
|
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.
|
|
*
|
|
* @internal
|
|
*/
|
|
resizeContents() {
|
|
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).
|
|
*/
|
|
resize() {
|
|
if (this.toolbox) {
|
|
this.toolbox.position();
|
|
} else 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.
|
|
*
|
|
* @internal
|
|
*/
|
|
updateScreenCalculationsIfScrolled() {
|
|
const currScroll = svgMath.getDocumentScroll();
|
|
if (!Coordinate.equals(this.lastRecordedPageScroll, currScroll)) {
|
|
this.lastRecordedPageScroll = currScroll;
|
|
this.updateScreenCalculations();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns The layer manager for this workspace.
|
|
* @internal
|
|
*/
|
|
getLayerManager(): LayerManager | null {
|
|
return this.layerManager;
|
|
}
|
|
|
|
/**
|
|
* Get the SVG element that forms the drawing surface.
|
|
*
|
|
* @returns SVG group element.
|
|
*/
|
|
getCanvas(): SVGGElement {
|
|
return this.layerManager!.getBlockLayer();
|
|
}
|
|
|
|
/**
|
|
* Caches the width and height of the workspace's parent SVG element for use
|
|
* with getSvgMetrics.
|
|
*
|
|
* @param width The width of the parent SVG element.
|
|
* @param height The height of the parent SVG element
|
|
* @internal
|
|
*/
|
|
setCachedParentSvgSize(width: number | null, height: number | null) {
|
|
const svg = this.getParentSvg();
|
|
if (width != null) {
|
|
this.cachedParentSvgSize.width = width;
|
|
// This is set to support the public (but deprecated) Blockly.svgSize
|
|
// method.
|
|
svg.setAttribute('data-cached-width', `${width}`);
|
|
}
|
|
if (height != null) {
|
|
this.cachedParentSvgSize.height = height;
|
|
// This is set to support the public (but deprecated) Blockly.svgSize
|
|
// method.
|
|
svg.setAttribute('data-cached-height', `${height}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the SVG element that forms the bubble surface.
|
|
*
|
|
* @returns SVG group element.
|
|
*/
|
|
getBubbleCanvas(): SVGGElement {
|
|
return this.layerManager!.getBubbleLayer();
|
|
}
|
|
|
|
/**
|
|
* Get the SVG element that contains this workspace.
|
|
* Note: We assume this is only called after the workspace has been injected
|
|
* into the DOM.
|
|
*
|
|
* @returns SVG element.
|
|
*/
|
|
getParentSvg(): SVGSVGElement {
|
|
if (!this.cachedParentSvg) {
|
|
let element = this.svgGroup_;
|
|
while (element) {
|
|
if (element.tagName === 'svg') {
|
|
this.cachedParentSvg = element;
|
|
break;
|
|
}
|
|
element = element.parentNode as SVGSVGElement;
|
|
}
|
|
}
|
|
return this.cachedParentSvg as SVGSVGElement;
|
|
}
|
|
|
|
/**
|
|
* Fires a viewport event if events are enabled and there is a change in
|
|
* viewport values.
|
|
*
|
|
* @internal
|
|
*/
|
|
maybeFireViewportChangeEvent() {
|
|
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(EventType.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 x Horizontal translation, in pixel units relative to the top left of
|
|
* the Blockly div.
|
|
* @param y Vertical translation, in pixel units relative to the top left of
|
|
* the Blockly div.
|
|
*/
|
|
translate(x: number, y: number) {
|
|
this.layerManager?.translateLayers(new Coordinate(x, y), this.scale);
|
|
this.grid?.moveTo(x, y);
|
|
this.maybeFireViewportChangeEvent();
|
|
}
|
|
|
|
/**
|
|
* Returns the horizontal offset of the workspace.
|
|
* Intended for LTR/RTL compatibility in XML.
|
|
*
|
|
* @returns Width.
|
|
*/
|
|
override getWidth(): number {
|
|
const metrics = this.getMetrics();
|
|
return metrics ? metrics.viewWidth / this.scale : 0;
|
|
}
|
|
|
|
/**
|
|
* Toggles the visibility of the workspace.
|
|
* Currently only intended for main workspace.
|
|
*
|
|
* @param isVisible True if workspace should be visible.
|
|
*/
|
|
setVisible(isVisible: boolean) {
|
|
this.visible = 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) {
|
|
this.hideChaff(true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render all blocks in workspace.
|
|
*/
|
|
render() {
|
|
// Generate list of all blocks.
|
|
const blocks = this.getAllBlocks(false);
|
|
// Render each block.
|
|
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
blocks[i].queueRender();
|
|
}
|
|
|
|
this.getTopBlocks()
|
|
.flatMap((block) => block.getDescendants(false))
|
|
.filter((block) => block.isInsertionMarker())
|
|
.forEach((block) => block.queueRender());
|
|
|
|
renderManagement
|
|
.finishQueuedRenders()
|
|
.then(() => void this.markerManager.updateMarkers());
|
|
}
|
|
|
|
/**
|
|
* Highlight or unhighlight a block in the workspace. Block highlighting is
|
|
* often used to visually mark blocks currently being executed.
|
|
*
|
|
* @param id ID of block to highlight/unhighlight, or null for no block (used
|
|
* to unhighlight all blocks).
|
|
* @param opt_state If undefined, highlight specified block and automatically
|
|
* unhighlight all others. If true or false, manually
|
|
* highlight/unhighlight the specified block.
|
|
*/
|
|
highlightBlock(id: string | null, opt_state?: boolean) {
|
|
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.includes(block)) {
|
|
this.highlightedBlocks.push(block);
|
|
}
|
|
block.setHighlighted(state);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh the toolbox unless there's a drag in progress.
|
|
*
|
|
* @internal
|
|
*/
|
|
refreshToolboxSelection() {
|
|
const ws = this.isFlyout ? this.targetWorkspace : this;
|
|
if (ws && !ws.currentGesture_ && ws.toolbox && ws.toolbox.getFlyout()) {
|
|
ws.toolbox.refreshSelection();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rename a variable by updating its name in the variable map. Update the
|
|
* flyout to show the renamed variable immediately.
|
|
*
|
|
* @param id ID of the variable to rename.
|
|
* @param newName New variable name.
|
|
*/
|
|
override renameVariableById(id: string, newName: string) {
|
|
super.renameVariableById(id, newName);
|
|
this.refreshToolboxSelection();
|
|
}
|
|
|
|
/**
|
|
* Delete a variable by the passed in ID. Update the flyout to show
|
|
* immediately that the variable is deleted.
|
|
*
|
|
* @param id ID of variable to delete.
|
|
*/
|
|
override deleteVariableById(id: string) {
|
|
super.deleteVariableById(id);
|
|
this.refreshToolboxSelection();
|
|
}
|
|
|
|
/**
|
|
* Create a new variable with the given name. Update the flyout to show the
|
|
* new variable immediately.
|
|
*
|
|
* @param name The new variable's name.
|
|
* @param opt_type The type of the variable like 'int' or 'string'.
|
|
* Does not need to be unique. Field_variable can filter variables based
|
|
* on their type. This will default to '' which is a specific type.
|
|
* @param opt_id The unique ID of the variable. This will default to a UUID.
|
|
* @returns The newly created variable.
|
|
*/
|
|
override createVariable(
|
|
name: string,
|
|
opt_type?: string | null,
|
|
opt_id?: string | null,
|
|
): IVariableModel<IVariableState> {
|
|
const newVar = super.createVariable(name, opt_type, opt_id);
|
|
this.refreshToolboxSelection();
|
|
return newVar;
|
|
}
|
|
|
|
/** Make a list of all the delete areas for this workspace. */
|
|
recordDragTargets() {
|
|
const dragTargets = this.componentManager.getComponents(
|
|
ComponentManager.Capability.DRAG_TARGET,
|
|
true,
|
|
);
|
|
|
|
this.dragTargetAreas = [];
|
|
for (let i = 0, targetArea; (targetArea = dragTargets[i]); i++) {
|
|
const rect = targetArea.getClientRect();
|
|
if (rect) {
|
|
this.dragTargetAreas.push({
|
|
component: targetArea,
|
|
clientRect: rect,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
/* eslint-disable jsdoc/require-returns-check */
|
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
/**
|
|
* Obtain a newly created block.
|
|
*
|
|
* @param prototypeName Name of the language object containing type-specific
|
|
* functions for this block.
|
|
* @param opt_id Optional ID. Use this ID if provided, otherwise create a new
|
|
* ID.
|
|
* @returns The created block.
|
|
*/
|
|
override newBlock(prototypeName: string, opt_id?: string): BlockSvg {
|
|
throw new Error(
|
|
'The implementation of newBlock should be ' +
|
|
'monkey-patched in by blockly.ts',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Obtain a newly created comment.
|
|
*
|
|
* @param id Optional ID. Use this ID if provided, otherwise create a new
|
|
* ID.
|
|
* @returns The created comment.
|
|
*/
|
|
newComment(id?: string): WorkspaceComment {
|
|
throw new Error(
|
|
'The implementation of newComment should be ' +
|
|
'monkey-patched in by blockly.ts',
|
|
);
|
|
}
|
|
/* eslint-enable */
|
|
|
|
/**
|
|
* Returns the drag target the pointer event is over.
|
|
*
|
|
* @param e Pointer move event.
|
|
* @returns Null if not over a drag target, or the drag target the event is
|
|
* over.
|
|
*/
|
|
getDragTarget(e: PointerEvent): IDragTarget | null {
|
|
for (let i = 0, targetArea; (targetArea = this.dragTargetAreas[i]); i++) {
|
|
if (targetArea.clientRect.contains(e.clientX, e.clientY)) {
|
|
return targetArea.component;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Handle a pointerdown on SVG drawing surface.
|
|
*
|
|
* @param e Pointer down event.
|
|
*/
|
|
private onMouseDown(e: PointerEvent) {
|
|
const gesture = this.getGesture(e);
|
|
if (gesture) {
|
|
gesture.handleWsStart(e, this);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start tracking a drag of an object on this workspace.
|
|
*
|
|
* @param e Pointer down event.
|
|
* @param xy Starting location of object.
|
|
*/
|
|
startDrag(e: PointerEvent, xy: Coordinate) {
|
|
drag.start(this, e, xy);
|
|
}
|
|
|
|
/**
|
|
* Track a drag of an object on this workspace.
|
|
*
|
|
* @param e Pointer move event.
|
|
* @returns New location of object.
|
|
*/
|
|
moveDrag(e: PointerEvent): Coordinate {
|
|
return drag.move(this, e);
|
|
}
|
|
|
|
/**
|
|
* Is the user currently dragging a block or scrolling the flyout/workspace?
|
|
*
|
|
* @returns True if currently dragging or scrolling.
|
|
*/
|
|
isDragging(): boolean {
|
|
return this.currentGesture_ !== null && this.currentGesture_.isDragging();
|
|
}
|
|
|
|
/**
|
|
* Is this workspace draggable?
|
|
*
|
|
* @returns True if this workspace may be dragged.
|
|
*/
|
|
isDraggable(): boolean {
|
|
return this.options.moveOptions && this.options.moveOptions.drag;
|
|
}
|
|
|
|
/**
|
|
* Is this workspace movable?
|
|
*
|
|
* This means the user can reposition the X Y coordinates of the workspace
|
|
* through input. This can be through scrollbars, scroll wheel, dragging, or
|
|
* through zooming with the scroll wheel or pinch (since the zoom is centered
|
|
* on the mouse position). This does not include zooming with the zoom
|
|
* controls since the X Y coordinates are decided programmatically.
|
|
*
|
|
* @returns True if the workspace is movable, false otherwise.
|
|
*/
|
|
isMovable(): boolean {
|
|
return (
|
|
(this.options.moveOptions && !!this.options.moveOptions.scrollbars) ||
|
|
(this.options.moveOptions && this.options.moveOptions.wheel) ||
|
|
(this.options.moveOptions && this.options.moveOptions.drag) ||
|
|
(this.options.zoomOptions && this.options.zoomOptions.wheel) ||
|
|
(this.options.zoomOptions && this.options.zoomOptions.pinch)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Is this workspace movable horizontally?
|
|
*
|
|
* @returns True if the workspace is movable horizontally, false otherwise.
|
|
*/
|
|
isMovableHorizontally(): boolean {
|
|
const hasScrollbars = !!this.scrollbar;
|
|
return (
|
|
this.isMovable() &&
|
|
(!hasScrollbars ||
|
|
(hasScrollbars && this.scrollbar!.canScrollHorizontally()))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Is this workspace movable vertically?
|
|
*
|
|
* @returns True if the workspace is movable vertically, false otherwise.
|
|
*/
|
|
isMovableVertically(): boolean {
|
|
const hasScrollbars = !!this.scrollbar;
|
|
return (
|
|
this.isMovable() &&
|
|
(!hasScrollbars ||
|
|
(hasScrollbars && this.scrollbar!.canScrollVertically()))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle a mouse-wheel on SVG drawing surface.
|
|
*
|
|
* @param e Mouse wheel event.
|
|
*/
|
|
private onMouseWheel(e: WheelEvent) {
|
|
// Don't scroll or zoom anything if drag is in progress.
|
|
if (Gesture.inProgress()) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
return;
|
|
}
|
|
const canWheelZoom =
|
|
this.options.zoomOptions && this.options.zoomOptions.wheel;
|
|
const canWheelMove =
|
|
this.options.moveOptions && this.options.moveOptions.wheel;
|
|
if (!canWheelZoom && !canWheelMove) {
|
|
return;
|
|
}
|
|
|
|
const scrollDelta = browserEvents.getScrollDeltaPixels(e);
|
|
|
|
// Zoom should also be enabled by the command key on Mac devices,
|
|
// but not super on Unix.
|
|
let commandKey;
|
|
if (userAgent.MAC) {
|
|
commandKey = e.metaKey;
|
|
}
|
|
|
|
if (canWheelZoom && (e.ctrlKey || commandKey || !canWheelMove)) {
|
|
// Zoom.
|
|
// The vertical scroll distance that corresponds to a click of a zoom
|
|
// button.
|
|
const PIXELS_PER_ZOOM_STEP = 50;
|
|
const delta = -scrollDelta.y / PIXELS_PER_ZOOM_STEP;
|
|
const position = browserEvents.mouseToSvg(
|
|
e,
|
|
this.getParentSvg(),
|
|
this.getInverseScreenCTM(),
|
|
);
|
|
this.zoom(position.x, position.y, delta);
|
|
} else {
|
|
// Scroll.
|
|
let x = this.scrollX - scrollDelta.x;
|
|
let y = this.scrollY - scrollDelta.y;
|
|
|
|
if (e.shiftKey && !scrollDelta.x) {
|
|
// Scroll horizontally (based on vertical scroll delta).
|
|
// This is needed as for some browser/system combinations which do not
|
|
// set deltaX.
|
|
x = this.scrollX - scrollDelta.y;
|
|
y = this.scrollY; // Don't scroll vertically.
|
|
}
|
|
this.scroll(x, y);
|
|
}
|
|
e.preventDefault();
|
|
}
|
|
|
|
/**
|
|
* Calculate the bounding box for the blocks on the workspace.
|
|
* Coordinate system: workspace coordinates.
|
|
*
|
|
* @returns Contains the position and size of the bounding box containing the
|
|
* blocks on the workspace.
|
|
*/
|
|
getBlocksBoundingBox(): Rect {
|
|
const topElements = this.getTopBoundedElements();
|
|
// There are no blocks, return empty rectangle.
|
|
if (!topElements.length) {
|
|
return new Rect(0, 0, 0, 0);
|
|
}
|
|
|
|
// Initialize boundary using the first block.
|
|
const boundary = topElements[0].getBoundingRectangle();
|
|
|
|
// Start at 1 since the 0th block was used for initialization.
|
|
for (let i = 1; i < topElements.length; i++) {
|
|
const topElement = topElements[i];
|
|
if (
|
|
(topElement as any).isInsertionMarker &&
|
|
(topElement as any).isInsertionMarker()
|
|
) {
|
|
continue;
|
|
}
|
|
const blockBoundary = topElement.getBoundingRectangle();
|
|
if (blockBoundary.top < boundary.top) {
|
|
boundary.top = blockBoundary.top;
|
|
}
|
|
if (blockBoundary.bottom > boundary.bottom) {
|
|
boundary.bottom = blockBoundary.bottom;
|
|
}
|
|
if (blockBoundary.left < boundary.left) {
|
|
boundary.left = blockBoundary.left;
|
|
}
|
|
if (blockBoundary.right > boundary.right) {
|
|
boundary.right = blockBoundary.right;
|
|
}
|
|
}
|
|
return boundary;
|
|
}
|
|
|
|
/** Clean up the workspace by ordering all the blocks in a column such that none overlap. */
|
|
cleanUp() {
|
|
this.setResizesEnabled(false);
|
|
eventUtils.setGroup(true);
|
|
|
|
const topBlocks = this.getTopBlocks(true);
|
|
const movableBlocks = topBlocks.filter((block) => block.isMovable());
|
|
const immovableBlocks = topBlocks.filter((block) => !block.isMovable());
|
|
|
|
const immovableBlockBounds = immovableBlocks.map((block) =>
|
|
block.getBoundingRectangle(),
|
|
);
|
|
|
|
const getNextIntersectingImmovableBlock = function (
|
|
rect: Rect,
|
|
): Rect | null {
|
|
for (const immovableRect of immovableBlockBounds) {
|
|
if (rect.intersects(immovableRect)) {
|
|
return immovableRect;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
let cursorY = 0;
|
|
const minBlockHeight = this.renderer.getConstants().MIN_BLOCK_HEIGHT;
|
|
for (const block of movableBlocks) {
|
|
// Make the initial movement of shifting the block to its best possible position.
|
|
let boundingRect = block.getBoundingRectangle();
|
|
block.moveBy(-boundingRect.left, cursorY - boundingRect.top, ['cleanup']);
|
|
block.snapToGrid();
|
|
|
|
boundingRect = block.getBoundingRectangle();
|
|
let conflictingRect = getNextIntersectingImmovableBlock(boundingRect);
|
|
while (conflictingRect != null) {
|
|
// If the block intersects with an immovable block, move it down past that immovable block.
|
|
cursorY =
|
|
conflictingRect.top + conflictingRect.getHeight() + minBlockHeight;
|
|
block.moveBy(0, cursorY - boundingRect.top, ['cleanup']);
|
|
block.snapToGrid();
|
|
boundingRect = block.getBoundingRectangle();
|
|
conflictingRect = getNextIntersectingImmovableBlock(boundingRect);
|
|
}
|
|
|
|
// Ensure all next blocks start past the most recent (which will also put them past all
|
|
// previously intersecting immovable blocks).
|
|
cursorY =
|
|
block.getRelativeToSurfaceXY().y +
|
|
block.getHeightWidth().height +
|
|
minBlockHeight;
|
|
}
|
|
eventUtils.setGroup(false);
|
|
this.setResizesEnabled(true);
|
|
}
|
|
|
|
/**
|
|
* Show the context menu for the workspace.
|
|
*
|
|
* @param e Mouse event.
|
|
* @internal
|
|
*/
|
|
showContextMenu(e: PointerEvent) {
|
|
if (this.options.readOnly || this.isFlyout) {
|
|
return;
|
|
}
|
|
const menuOptions = ContextMenuRegistry.registry.getContextMenuOptions(
|
|
ContextMenuRegistry.ScopeType.WORKSPACE,
|
|
{workspace: this},
|
|
);
|
|
|
|
// Allow the developer to add or modify menuOptions.
|
|
if (this.configureContextMenu) {
|
|
this.configureContextMenu(menuOptions, e);
|
|
}
|
|
|
|
ContextMenu.show(e, menuOptions, this.RTL, this);
|
|
}
|
|
|
|
/**
|
|
* Modify the block tree on the existing toolbox.
|
|
*
|
|
* @param toolboxDef DOM tree of toolbox contents, string of toolbox contents,
|
|
* or JSON representing toolbox definition.
|
|
*/
|
|
updateToolbox(toolboxDef: toolbox.ToolboxDefinition | null) {
|
|
const parsedToolboxDef = toolbox.convertToolboxDefToJson(toolboxDef);
|
|
|
|
if (!parsedToolboxDef) {
|
|
if (this.options.languageTree) {
|
|
throw Error("Can't nullify an existing toolbox.");
|
|
}
|
|
return; // No change (null to null).
|
|
}
|
|
if (!this.options.languageTree) {
|
|
throw Error("Existing toolbox is null. Can't create new toolbox.");
|
|
}
|
|
|
|
if (toolbox.hasCategories(parsedToolboxDef)) {
|
|
if (!this.toolbox) {
|
|
throw Error("Existing toolbox has no categories. Can't change mode.");
|
|
}
|
|
this.options.languageTree = parsedToolboxDef;
|
|
this.toolbox.render(parsedToolboxDef);
|
|
} else {
|
|
if (!this.flyout) {
|
|
throw Error("Existing toolbox has categories. Can't change mode.");
|
|
}
|
|
this.options.languageTree = parsedToolboxDef;
|
|
this.flyout.show(parsedToolboxDef);
|
|
}
|
|
}
|
|
|
|
/** Mark this workspace as the currently focused main workspace. */
|
|
markFocused() {
|
|
if (this.options.parentWorkspace) {
|
|
this.options.parentWorkspace.markFocused();
|
|
} else {
|
|
common.setMainWorkspace(this);
|
|
// We call e.preventDefault in many event handlers which means we
|
|
// need to explicitly grab focus (e.g from a textarea) because
|
|
// the browser will not do it for us.
|
|
this.getParentSvg().focus({preventScroll: true});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zooms the workspace in or out relative to/centered on the given (x, y)
|
|
* coordinate.
|
|
*
|
|
* @param x X coordinate of center, in pixel units relative to the top-left
|
|
* corner of the parentSVG.
|
|
* @param y Y coordinate of center, in pixel units relative to the top-left
|
|
* corner of the parentSVG.
|
|
* @param amount Amount of zooming. The formula for the new scale is newScale
|
|
* = currentScale * (scaleSpeed^amount). scaleSpeed is set in the
|
|
* workspace options. Negative amount values zoom out, and positive amount
|
|
* values zoom in.
|
|
*/
|
|
zoom(x: number, y: number, amount: number) {
|
|
// Scale factor.
|
|
const speed = this.options.zoomOptions.scaleSpeed;
|
|
let scaleChange = Math.pow(speed, amount);
|
|
const newScale = this.scale * scaleChange;
|
|
if (this.scale === newScale) {
|
|
return; // No change in zoom.
|
|
}
|
|
|
|
// Clamp scale within valid range.
|
|
if (newScale > this.options.zoomOptions.maxScale) {
|
|
scaleChange = this.options.zoomOptions.maxScale / this.scale;
|
|
} else if (newScale < this.options.zoomOptions.minScale) {
|
|
scaleChange = this.options.zoomOptions.minScale / this.scale;
|
|
}
|
|
|
|
// Transform the x/y coordinates from the parentSVG's space into the
|
|
// canvas' space, so that they are in workspace units relative to the top
|
|
// left of the visible portion of the workspace.
|
|
let matrix = this.getCanvas().getCTM();
|
|
let center = this.getParentSvg().createSVGPoint();
|
|
center.x = x;
|
|
center.y = y;
|
|
center = center.matrixTransform(matrix!.inverse());
|
|
x = center.x;
|
|
y = center.y;
|
|
|
|
// Find the new scrollX/scrollY so that the center remains in the same
|
|
// position (relative to the center) after we zoom.
|
|
// newScale and matrix.a should be identical (within a rounding error).
|
|
matrix = matrix!
|
|
.translate(x * (1 - scaleChange), y * (1 - scaleChange))
|
|
.scale(scaleChange);
|
|
// scrollX and scrollY are in pixels.
|
|
// The scrollX and scrollY still need to have absoluteLeft and absoluteTop
|
|
// subtracted from them, but we'll leave that for setScale so that they're
|
|
// correctly updated for the new flyout size if we have a simple toolbox.
|
|
this.scrollX = matrix.e;
|
|
this.scrollY = matrix.f;
|
|
this.setScale(newScale);
|
|
}
|
|
|
|
/**
|
|
* Zooming the blocks centered in the center of view with zooming in or out.
|
|
*
|
|
* @param type Type of zooming (-1 zooming out and 1 zooming in).
|
|
*/
|
|
zoomCenter(type: number) {
|
|
const metrics = this.getMetrics();
|
|
let x;
|
|
let y;
|
|
if (this.flyout) {
|
|
// If you want blocks in the center of the view (visible portion of the
|
|
// workspace) to stay centered when the size of the view decreases (i.e.
|
|
// when the size of the flyout increases) you need the center of the
|
|
// *blockly div* to stay in the same pixel-position.
|
|
// Note: This only works because of how scrollCenter positions blocks.
|
|
x = metrics.svgWidth ? metrics.svgWidth / 2 : 0;
|
|
y = metrics.svgHeight ? metrics.svgHeight / 2 : 0;
|
|
} else {
|
|
x = metrics.viewWidth / 2 + metrics.absoluteLeft;
|
|
y = metrics.viewHeight / 2 + metrics.absoluteTop;
|
|
}
|
|
this.zoom(x, y, type);
|
|
}
|
|
|
|
/** Zoom the blocks to fit in the workspace if possible. */
|
|
zoomToFit() {
|
|
if (!this.isMovable()) {
|
|
console.warn(
|
|
'Tried to move a non-movable workspace. This could result' +
|
|
' in blocks becoming inaccessible.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const metrics = this.getMetrics();
|
|
let workspaceWidth = metrics.viewWidth;
|
|
let workspaceHeight = metrics.viewHeight;
|
|
const blocksBox = this.getBlocksBoundingBox();
|
|
const doubleMargin = ZOOM_TO_FIT_MARGIN * 2;
|
|
let blocksWidth = blocksBox.right - blocksBox.left + doubleMargin;
|
|
let blocksHeight = blocksBox.bottom - blocksBox.top + doubleMargin;
|
|
if (!blocksWidth) {
|
|
return; // Prevents zooming to infinity.
|
|
}
|
|
if (this.flyout) {
|
|
// We have to add the flyout size to both the workspace size and the
|
|
// block size because the blocks we want to resize include the blocks in
|
|
// the flyout, and the area we want to fit them includes the portion of
|
|
// the workspace that is behind the flyout.
|
|
if (this.horizontalLayout) {
|
|
workspaceHeight += this.flyout.getHeight();
|
|
// Convert from pixels to workspace coordinates.
|
|
blocksHeight += this.flyout.getHeight() / this.scale;
|
|
} else {
|
|
workspaceWidth += this.flyout.getWidth();
|
|
// Convert from pixels to workspace coordinates.
|
|
blocksWidth += this.flyout.getWidth() / this.scale;
|
|
}
|
|
}
|
|
|
|
// Scale Units: (pixels / workspaceUnit)
|
|
const ratioX = workspaceWidth / blocksWidth;
|
|
const ratioY = workspaceHeight / blocksHeight;
|
|
eventUtils.disable();
|
|
try {
|
|
this.setScale(Math.min(ratioX, ratioY));
|
|
this.scrollCenter();
|
|
} finally {
|
|
eventUtils.enable();
|
|
}
|
|
this.maybeFireViewportChangeEvent();
|
|
}
|
|
|
|
/**
|
|
* Add a transition class to the block and bubble canvas, to animate any
|
|
* transform changes.
|
|
*
|
|
* @internal
|
|
*/
|
|
beginCanvasTransition() {
|
|
dom.addClass(this.getCanvas(), 'blocklyCanvasTransitioning');
|
|
dom.addClass(this.getBubbleCanvas(), 'blocklyCanvasTransitioning');
|
|
}
|
|
|
|
/**
|
|
* Remove transition class from the block and bubble canvas.
|
|
*
|
|
* @internal
|
|
*/
|
|
endCanvasTransition() {
|
|
dom.removeClass(this.getCanvas(), 'blocklyCanvasTransitioning');
|
|
dom.removeClass(this.getBubbleCanvas(), 'blocklyCanvasTransitioning');
|
|
}
|
|
|
|
/** Center the workspace. */
|
|
scrollCenter() {
|
|
if (!this.isMovable()) {
|
|
console.warn(
|
|
'Tried to move a non-movable workspace. This could result' +
|
|
' in blocks becoming inaccessible.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const metrics = this.getMetrics();
|
|
let x = (metrics.scrollWidth - metrics.viewWidth) / 2;
|
|
let y = (metrics.scrollHeight - metrics.viewHeight) / 2;
|
|
|
|
// Convert from workspace directions to canvas directions.
|
|
x = -x - metrics.scrollLeft;
|
|
y = -y - metrics.scrollTop;
|
|
this.scroll(x, y);
|
|
}
|
|
|
|
/**
|
|
* Scroll the workspace to center on the given block. If the block has other
|
|
* blocks stacked below it, the workspace will be centered on the stack,
|
|
* unless blockOnly is true.
|
|
*
|
|
* @param id ID of block center on.
|
|
* @param blockOnly True to center only on the block itself, not its stack.
|
|
*/
|
|
centerOnBlock(id: string | null, blockOnly?: boolean) {
|
|
if (!this.isMovable()) {
|
|
console.warn(
|
|
'Tried to move a non-movable workspace. This could result' +
|
|
' in blocks becoming inaccessible.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const block = id ? this.getBlockById(id) : null;
|
|
if (!block) {
|
|
return;
|
|
}
|
|
|
|
// XY is in workspace coordinates.
|
|
const xy = block.getRelativeToSurfaceXY();
|
|
// Height/width is in workspace units.
|
|
const heightWidth = blockOnly
|
|
? {height: block.height, width: block.width}
|
|
: block.getHeightWidth();
|
|
|
|
// Find the enter of the block in workspace units.
|
|
const blockCenterY = xy.y + heightWidth.height / 2;
|
|
|
|
// In RTL the block's position is the top right of the block, not top left.
|
|
const multiplier = this.RTL ? -1 : 1;
|
|
const blockCenterX = xy.x + (multiplier * heightWidth.width) / 2;
|
|
|
|
// Workspace scale, used to convert from workspace coordinates to pixels.
|
|
const scale = this.scale;
|
|
|
|
// Center of block in pixels, relative to workspace origin (center 0,0).
|
|
// Scrolling to here would put the block in the top-left corner of the
|
|
// visible workspace.
|
|
const pixelX = blockCenterX * scale;
|
|
const pixelY = blockCenterY * scale;
|
|
|
|
const metrics = this.getMetrics();
|
|
|
|
// viewHeight and viewWidth are in pixels.
|
|
const halfViewWidth = metrics.viewWidth / 2;
|
|
const halfViewHeight = metrics.viewHeight / 2;
|
|
|
|
// Put the block in the center of the visible workspace instead.
|
|
const scrollToCenterX = pixelX - halfViewWidth;
|
|
const scrollToCenterY = pixelY - halfViewHeight;
|
|
|
|
// Convert from workspace directions to canvas directions.
|
|
const x = -scrollToCenterX;
|
|
const y = -scrollToCenterY;
|
|
|
|
this.scroll(x, y);
|
|
}
|
|
|
|
/**
|
|
* Set the workspace's zoom factor.
|
|
*
|
|
* @param newScale Zoom factor. Units: (pixels / workspaceUnit).
|
|
*/
|
|
setScale(newScale: number) {
|
|
if (
|
|
this.options.zoomOptions.maxScale &&
|
|
newScale > this.options.zoomOptions.maxScale
|
|
) {
|
|
newScale = this.options.zoomOptions.maxScale;
|
|
} else if (
|
|
this.options.zoomOptions.minScale &&
|
|
newScale < this.options.zoomOptions.minScale
|
|
) {
|
|
newScale = this.options.zoomOptions.minScale;
|
|
}
|
|
this.scale = newScale;
|
|
|
|
this.hideChaff(false);
|
|
// Get the flyout, if any, whether our own or owned by the toolbox.
|
|
const flyout = this.getFlyout(false);
|
|
if (flyout && flyout.isVisible()) {
|
|
flyout.reflow();
|
|
this.recordDragTargets();
|
|
}
|
|
if (this.grid) {
|
|
this.grid.update(this.scale);
|
|
}
|
|
|
|
// We call scroll instead of scrollbar.resize() so that we can center the
|
|
// zoom correctly without scrollbars, but scroll does not resize the
|
|
// scrollbars so we have to call resizeView/resizeContent as well.
|
|
const metrics = this.getMetrics();
|
|
|
|
this.scrollX -= metrics.absoluteLeft;
|
|
this.scrollY -= metrics.absoluteTop;
|
|
// The scroll values and the view values are additive inverses of
|
|
// each other, so when we subtract from one we have to add to the other.
|
|
metrics.viewLeft += metrics.absoluteLeft;
|
|
metrics.viewTop += metrics.absoluteTop;
|
|
|
|
this.scroll(this.scrollX, this.scrollY);
|
|
if (this.scrollbar) {
|
|
if (this.flyout) {
|
|
this.scrollbar.resizeView(metrics);
|
|
} else {
|
|
this.scrollbar.resizeContent(metrics);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the workspace's zoom factor.
|
|
*
|
|
* @returns The workspace zoom factor. Units: (pixels / workspaceUnit).
|
|
*/
|
|
getScale(): number {
|
|
return this.scale;
|
|
}
|
|
|
|
/**
|
|
* Returns the absolute scale of the workspace.
|
|
*
|
|
* Workspace scaling is multiplicative; if a workspace B (e.g. a mutator editor)
|
|
* with scale Y is nested within a root workspace A with scale X, workspace B's
|
|
* effective scale is X * Y, because, as a child of A, it is already transformed
|
|
* by A's scaling factor, and then further transforms itself by its own scaling
|
|
* factor. Normally this Just Works, but for global elements (e.g. field
|
|
* editors) that are visually associated with a particular workspace but live at
|
|
* the top level of the DOM rather than being a child of their associated
|
|
* workspace, the absolute/effective scale may be needed to render
|
|
* appropriately.
|
|
*
|
|
* @returns The absolute/effective scale of the given workspace.
|
|
*/
|
|
getAbsoluteScale() {
|
|
// Returns a workspace's own scale, without regard to multiplicative scaling.
|
|
const getLocalScale = (workspace: WorkspaceSvg): number => {
|
|
// Workspaces in flyouts may have a distinct scale; use this if relevant.
|
|
if (workspace.isFlyout) {
|
|
const flyout = workspace.targetWorkspace?.getFlyout();
|
|
if (flyout instanceof Flyout) {
|
|
return flyout.getFlyoutScale();
|
|
}
|
|
}
|
|
|
|
return workspace.getScale();
|
|
};
|
|
|
|
const computeScale = (workspace: WorkspaceSvg, scale: number): number => {
|
|
// If the workspace has no parent, or it does have a parent but is not
|
|
// actually a child of its parent workspace in the DOM (this is the case for
|
|
// flyouts in the main workspace), we're done; just return the scale so far
|
|
// multiplied by the workspace's own scale.
|
|
if (
|
|
!workspace.options.parentWorkspace ||
|
|
!workspace.options.parentWorkspace
|
|
.getSvgGroup()
|
|
.contains(workspace.getSvgGroup())
|
|
) {
|
|
return scale * getLocalScale(workspace);
|
|
}
|
|
|
|
// If there is a parent workspace, and this workspace is a child of it in
|
|
// the DOM, scales are multiplicative, so recurse up the workspace
|
|
// hierarchy.
|
|
return computeScale(
|
|
workspace.options.parentWorkspace,
|
|
scale * getLocalScale(workspace),
|
|
);
|
|
};
|
|
|
|
return computeScale(this, 1);
|
|
}
|
|
|
|
/**
|
|
* Scroll the workspace to a specified offset (in pixels), keeping in the
|
|
* workspace bounds. See comment on workspaceSvg.scrollX for more detail on
|
|
* the meaning of these values.
|
|
*
|
|
* @param x Target X to scroll to.
|
|
* @param y Target Y to scroll to.
|
|
*/
|
|
scroll(x: number, y: number) {
|
|
this.hideChaff(/* opt_onlyClosePopups= */ true);
|
|
|
|
// Keep scrolling within the bounds of the content.
|
|
const metrics = this.getMetrics();
|
|
// Canvas coordinates (aka scroll coordinates) have inverse directionality
|
|
// to workspace coordinates so we have to inverse them.
|
|
x = Math.min(x, -metrics.scrollLeft);
|
|
y = Math.min(y, -metrics.scrollTop);
|
|
const maxXDisplacement = Math.max(
|
|
0,
|
|
metrics.scrollWidth - metrics.viewWidth,
|
|
);
|
|
const maxXScroll = metrics.scrollLeft + maxXDisplacement;
|
|
const maxYDisplacement = Math.max(
|
|
0,
|
|
metrics.scrollHeight - metrics.viewHeight,
|
|
);
|
|
const maxYScroll = metrics.scrollTop + maxYDisplacement;
|
|
x = Math.max(x, -maxXScroll);
|
|
y = Math.max(y, -maxYScroll);
|
|
this.scrollX = x;
|
|
this.scrollY = y;
|
|
|
|
if (this.scrollbar) {
|
|
// The content position (displacement from the content's top-left to the
|
|
// origin) plus the scroll position (displacement from the view's top-left
|
|
// to the origin) gives us the distance from the view's top-left to the
|
|
// content's top-left. Then we negate this so we get the displacement from
|
|
// the content's top-left to the view's top-left, matching the
|
|
// directionality of the scrollbars.
|
|
this.scrollbar.set(
|
|
-(x + metrics.scrollLeft),
|
|
-(y + metrics.scrollTop),
|
|
false,
|
|
);
|
|
}
|
|
// We have to shift the translation so that when the canvas is at 0, 0 the
|
|
// workspace origin is not underneath the toolbox.
|
|
x += metrics.absoluteLeft;
|
|
y += metrics.absoluteTop;
|
|
this.translate(x, y);
|
|
}
|
|
|
|
/**
|
|
* Find the block on this workspace with the specified ID.
|
|
*
|
|
* @param id ID of block to find.
|
|
* @returns The sought after block, or null if not found.
|
|
*/
|
|
override getBlockById(id: string): BlockSvg | null {
|
|
return super.getBlockById(id) as BlockSvg;
|
|
}
|
|
|
|
/**
|
|
* Find all blocks in workspace. Blocks are optionally sorted
|
|
* by position; top to bottom (with slight LTR or RTL bias).
|
|
*
|
|
* @param ordered Sort the list if true.
|
|
* @returns Array of blocks.
|
|
*/
|
|
override getAllBlocks(ordered = false): BlockSvg[] {
|
|
return super.getAllBlocks(ordered) as BlockSvg[];
|
|
}
|
|
|
|
/**
|
|
* Finds the top-level blocks and returns them. Blocks are optionally sorted
|
|
* by position; top to bottom (with slight LTR or RTL bias).
|
|
*
|
|
* @param ordered Sort the list if true.
|
|
* @returns The top-level block objects.
|
|
*/
|
|
override getTopBlocks(ordered = false): BlockSvg[] {
|
|
return super.getTopBlocks(ordered) as BlockSvg[];
|
|
}
|
|
|
|
/**
|
|
* Adds a block to the list of top blocks.
|
|
*
|
|
* @param block Block to add.
|
|
*/
|
|
override addTopBlock(block: Block) {
|
|
this.addTopBoundedElement(block as BlockSvg);
|
|
super.addTopBlock(block);
|
|
}
|
|
|
|
/**
|
|
* Removes a block from the list of top blocks.
|
|
*
|
|
* @param block Block to remove.
|
|
*/
|
|
override removeTopBlock(block: Block) {
|
|
this.removeTopBoundedElement(block as BlockSvg);
|
|
super.removeTopBlock(block);
|
|
}
|
|
|
|
/**
|
|
* Adds a comment to the list of top comments.
|
|
*
|
|
* @param comment comment to add.
|
|
*/
|
|
override addTopComment(comment: WorkspaceComment) {
|
|
this.addTopBoundedElement(comment as RenderedWorkspaceComment);
|
|
super.addTopComment(comment);
|
|
}
|
|
|
|
/**
|
|
* Removes a comment from the list of top comments.
|
|
*
|
|
* @param comment comment to remove.
|
|
*/
|
|
override removeTopComment(comment: WorkspaceComment) {
|
|
this.removeTopBoundedElement(comment as RenderedWorkspaceComment);
|
|
super.removeTopComment(comment);
|
|
}
|
|
|
|
override getRootWorkspace(): WorkspaceSvg | null {
|
|
return super.getRootWorkspace() as WorkspaceSvg | null;
|
|
}
|
|
|
|
/**
|
|
* Adds a bounded element to the list of top bounded elements.
|
|
*
|
|
* @param element Bounded element to add.
|
|
*/
|
|
addTopBoundedElement(element: IBoundedElement) {
|
|
this.topBoundedElements.push(element);
|
|
}
|
|
|
|
/**
|
|
* Removes a bounded element from the list of top bounded elements.
|
|
*
|
|
* @param element Bounded element to remove.
|
|
*/
|
|
removeTopBoundedElement(element: IBoundedElement) {
|
|
arrayUtils.removeElem(this.topBoundedElements, element);
|
|
}
|
|
|
|
/**
|
|
* Finds the top-level bounded elements and returns them.
|
|
*
|
|
* @returns The top-level bounded elements.
|
|
*/
|
|
getTopBoundedElements(): IBoundedElement[] {
|
|
return new Array<IBoundedElement>().concat(this.topBoundedElements);
|
|
}
|
|
|
|
/**
|
|
* Update whether this workspace has resizes enabled.
|
|
* If enabled, workspace will resize when appropriate.
|
|
* If disabled, workspace will not resize until re-enabled.
|
|
* Use to avoid resizing during a batch operation, for performance.
|
|
*
|
|
* @param enabled Whether resizes should be enabled.
|
|
*/
|
|
setResizesEnabled(enabled: boolean) {
|
|
const reenabled = !this.resizesEnabled && enabled;
|
|
this.resizesEnabled = enabled;
|
|
if (reenabled) {
|
|
// Newly enabled. Trigger a resize.
|
|
this.resizeContents();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dispose of all blocks in workspace, with an optimization to prevent
|
|
* resizes.
|
|
*/
|
|
override clear() {
|
|
this.setResizesEnabled(false);
|
|
super.clear();
|
|
this.topBoundedElements = [];
|
|
this.setResizesEnabled(true);
|
|
}
|
|
|
|
/**
|
|
* Register a callback function associated with a given key, for clicks on
|
|
* buttons and labels in the flyout.
|
|
* For instance, a button specified by the XML
|
|
* <button text="create variable" callbackKey="CREATE_VARIABLE"></button>
|
|
* should be matched by a call to
|
|
* registerButtonCallback("CREATE_VARIABLE", yourCallbackFunction).
|
|
*
|
|
* @param key The name to use to look up this function.
|
|
* @param func The function to call when the given button is clicked.
|
|
*/
|
|
registerButtonCallback(key: string, func: (p1: FlyoutButton) => void) {
|
|
if (typeof func !== 'function') {
|
|
throw TypeError('Button callbacks must be functions.');
|
|
}
|
|
this.flyoutButtonCallbacks.set(key, func);
|
|
}
|
|
|
|
/**
|
|
* Get the callback function associated with a given key, for clicks on
|
|
* buttons and labels in the flyout.
|
|
*
|
|
* @param key The name to use to look up the function.
|
|
* @returns The function corresponding to the given key for this workspace;
|
|
* null if no callback is registered.
|
|
*/
|
|
getButtonCallback(key: string): ((p1: FlyoutButton) => void) | null {
|
|
return this.flyoutButtonCallbacks.get(key) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Remove a callback for a click on a button in the flyout.
|
|
*
|
|
* @param key The name associated with the callback function.
|
|
*/
|
|
removeButtonCallback(key: string) {
|
|
this.flyoutButtonCallbacks.delete(key);
|
|
}
|
|
|
|
/**
|
|
* Register a callback function associated with a given key, for populating
|
|
* custom toolbox categories in this workspace. See the variable and
|
|
* procedure categories as an example.
|
|
*
|
|
* @param key The name to use to look up this function.
|
|
* @param func The function to call when the given toolbox category is opened.
|
|
*/
|
|
registerToolboxCategoryCallback(
|
|
key: string,
|
|
func: (p1: WorkspaceSvg) => toolbox.FlyoutDefinition,
|
|
) {
|
|
if (typeof func !== 'function') {
|
|
throw TypeError('Toolbox category callbacks must be functions.');
|
|
}
|
|
this.toolboxCategoryCallbacks.set(key, func);
|
|
}
|
|
|
|
/**
|
|
* Get the callback function associated with a given key, for populating
|
|
* custom toolbox categories in this workspace.
|
|
*
|
|
* @param key The name to use to look up the function.
|
|
* @returns The function corresponding to the given key for this workspace, or
|
|
* null if no function is registered.
|
|
*/
|
|
getToolboxCategoryCallback(
|
|
key: string,
|
|
): ((p1: WorkspaceSvg) => toolbox.FlyoutDefinition) | null {
|
|
return this.toolboxCategoryCallbacks.get(key) || null;
|
|
}
|
|
|
|
/**
|
|
* Remove a callback for a click on a custom category's name in the toolbox.
|
|
*
|
|
* @param key The name associated with the callback function.
|
|
*/
|
|
removeToolboxCategoryCallback(key: string) {
|
|
this.toolboxCategoryCallbacks.delete(key);
|
|
}
|
|
|
|
/**
|
|
* Look up the gesture that is tracking this touch stream on this workspace.
|
|
* May create a new gesture.
|
|
*
|
|
* @param e Pointer event.
|
|
* @returns The gesture that is tracking this touch stream, or null if no
|
|
* valid gesture exists.
|
|
* @internal
|
|
*/
|
|
getGesture(e: PointerEvent): Gesture | null {
|
|
const isStart = e.type === 'pointerdown';
|
|
|
|
const gesture = this.currentGesture_;
|
|
if (gesture) {
|
|
if (isStart && gesture.hasStarted()) {
|
|
console.warn('Tried to start the same gesture twice.');
|
|
// That's funny. We must have missed a mouse up.
|
|
// Cancel it, rather than try to retrieve all of the state we need.
|
|
gesture.cancel();
|
|
return null;
|
|
}
|
|
return gesture;
|
|
}
|
|
|
|
// No gesture existed on this workspace, but this looks like the start of a
|
|
// new gesture.
|
|
if (isStart) {
|
|
this.currentGesture_ = new Gesture(e, this);
|
|
return this.currentGesture_;
|
|
}
|
|
// No gesture existed and this event couldn't be the start of a new gesture.
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Clear the reference to the current gesture.
|
|
*
|
|
* @internal
|
|
*/
|
|
clearGesture() {
|
|
this.currentGesture_ = null;
|
|
}
|
|
|
|
/**
|
|
* Cancel the current gesture, if one exists.
|
|
*
|
|
* @internal
|
|
*/
|
|
cancelCurrentGesture() {
|
|
if (this.currentGesture_) {
|
|
this.currentGesture_.cancel();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the audio manager for this workspace.
|
|
*
|
|
* @returns The audio manager for this workspace.
|
|
*/
|
|
getAudioManager(): WorkspaceAudio {
|
|
return this.audioManager;
|
|
}
|
|
|
|
/**
|
|
* Get the grid object for this workspace, or null if there is none.
|
|
*
|
|
* @returns The grid object for this workspace.
|
|
*/
|
|
getGrid(): Grid | null {
|
|
return this.grid;
|
|
}
|
|
|
|
/**
|
|
* Close tooltips, context menus, dropdown selections, etc.
|
|
*
|
|
* @param onlyClosePopups Whether only popups should be closed. Defaults to
|
|
* false.
|
|
*/
|
|
hideChaff(onlyClosePopups = false) {
|
|
Tooltip.hide();
|
|
WidgetDiv.hideIfOwnerIsInWorkspace(this);
|
|
dropDownDiv.hideWithoutAnimation();
|
|
|
|
this.hideComponents(onlyClosePopups);
|
|
}
|
|
|
|
/**
|
|
* Hide any autohideable components (like flyout, trashcan, and any
|
|
* user-registered components).
|
|
*
|
|
* @param onlyClosePopups Whether only popups should be closed. Defaults to
|
|
* false.
|
|
*/
|
|
hideComponents(onlyClosePopups = false) {
|
|
const autoHideables = this.getComponentManager().getComponents(
|
|
ComponentManager.Capability.AUTOHIDEABLE,
|
|
true,
|
|
);
|
|
autoHideables.forEach((autoHideable) =>
|
|
autoHideable.autoHide(onlyClosePopups),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Sets the X/Y translations of a top level workspace.
|
|
*
|
|
* @param xyRatio Contains an x and/or y property which is a float between 0
|
|
* and 1 specifying the degree of scrolling.
|
|
*/
|
|
private static setTopLevelWorkspaceMetrics(
|
|
this: WorkspaceSvg,
|
|
xyRatio: {x?: number; y?: number},
|
|
) {
|
|
const metrics = this.getMetrics();
|
|
|
|
if (typeof xyRatio.x === 'number') {
|
|
this.scrollX = -(
|
|
metrics.scrollLeft +
|
|
(metrics.scrollWidth - metrics.viewWidth) * xyRatio.x
|
|
);
|
|
}
|
|
if (typeof xyRatio.y === 'number') {
|
|
this.scrollY = -(
|
|
metrics.scrollTop +
|
|
(metrics.scrollHeight - metrics.viewHeight) * xyRatio.y
|
|
);
|
|
}
|
|
// We have to shift the translation so that when the canvas is at 0, 0 the
|
|
// workspace origin is not underneath the toolbox.
|
|
const x = this.scrollX + metrics.absoluteLeft;
|
|
const y = this.scrollY + metrics.absoluteTop;
|
|
// We could call scroll here, but that has extra checks we don't need to do.
|
|
this.translate(x, y);
|
|
}
|
|
|
|
/**
|
|
* Adds a CSS class to the workspace.
|
|
*
|
|
* @param className Name of class to add.
|
|
*/
|
|
addClass(className: string) {
|
|
if (this.injectionDiv) {
|
|
dom.addClass(this.injectionDiv, className);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes a CSS class from the workspace.
|
|
*
|
|
* @param className Name of class to remove.
|
|
*/
|
|
removeClass(className: string) {
|
|
if (this.injectionDiv) {
|
|
dom.removeClass(this.injectionDiv, className);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Size the workspace when the contents change. This also updates
|
|
* scrollbars accordingly.
|
|
*
|
|
* @param workspace The workspace to resize.
|
|
* @internal
|
|
*/
|
|
export function resizeSvgContents(workspace: WorkspaceSvg) {
|
|
workspace.resizeContents();
|
|
}
|