diff --git a/core/blockly.ts b/core/blockly.ts index fae43022b..2caa2a272 100644 --- a/core/blockly.ts +++ b/core/blockly.ts @@ -174,6 +174,7 @@ import {Menu} from './menu.js'; import {MenuItem} from './menuitem.js'; import {MetricsManager} from './metrics_manager.js'; import {Msg, setLocale} from './msg.js'; +import {MiniWorkspaceBubble} from './bubbles/mini_workspace_bubble.js'; import {Mutator} from './mutator.js'; import {Names} from './names.js'; import {Options} from './options.js'; @@ -443,6 +444,12 @@ Mutator.prototype.newWorkspaceSvg = function (options: Options): WorkspaceSvg { return new WorkspaceSvg(options); }; +MiniWorkspaceBubble.prototype.newWorkspaceSvg = function ( + options: Options +): WorkspaceSvg { + return new WorkspaceSvg(options); +}; + Names.prototype.populateProcedures = function ( this: Names, workspace: Workspace diff --git a/core/bubbles/bubble.ts b/core/bubbles/bubble.ts index eaf34c741..39a723f50 100644 --- a/core/bubbles/bubble.ts +++ b/core/bubbles/bubble.ts @@ -18,28 +18,31 @@ import {WorkspaceSvg} from '../workspace_svg.js'; export abstract class Bubble implements IBubble { /** The width of the border around the bubble. */ - static BORDER_WIDTH = 6; + static readonly BORDER_WIDTH = 6; + + /** Double the width of the border around the bubble. */ + static readonly DOUBLE_BORDER = this.BORDER_WIDTH * 2; /** The minimum size the bubble can have. */ - static MIN_SIZE = this.BORDER_WIDTH * 2; + static readonly MIN_SIZE = this.DOUBLE_BORDER; /** * The thickness of the base of the tail in relation to the size of the * bubble. Higher numbers result in thinner tails. */ - static TAIL_THICKNESS = 1; + static readonly TAIL_THICKNESS = 1; /** The number of degrees that the tail bends counter-clockwise. */ - static TAIL_ANGLE = 20; + static readonly TAIL_ANGLE = 20; /** * The sharpness of the tail's bend. Higher numbers result in smoother * tails. */ - static TAIL_BEND = 4; + static readonly TAIL_BEND = 4; /** Distance between arrow point and anchor point. */ - static ANCHOR_RADIUS = 8; + static readonly ANCHOR_RADIUS = 8; /** The SVG group containing all parts of the bubble. */ private svgRoot: SVGGElement; @@ -548,11 +551,8 @@ export abstract class Bubble implements IBubble { } /** - * Move this bubble during a drag, taking into account whether or not there is - * a drag surface. + * Move this bubble during a drag. * - * @param dragSurface The surface that carries rendered items during a drag, - * or null if no drag surface is in use. * @param newLoc The location to translate to, in workspace coordinates. * @internal */ diff --git a/core/bubbles/mini_workspace_bubble.ts b/core/bubbles/mini_workspace_bubble.ts new file mode 100644 index 000000000..eee0acb1e --- /dev/null +++ b/core/bubbles/mini_workspace_bubble.ts @@ -0,0 +1,224 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Abstract as AbstractEvent} from '../events/events_abstract.js'; +import type {BlocklyOptions} from '../blockly_options.js'; +import {Bubble} from './bubble.js'; +import {Coordinate} from '../utils/coordinate.js'; +import * as dom from '../utils/dom.js'; +import {Options} from '../options.js'; +import {Svg} from '../utils/svg.js'; +import type {Rect} from '../utils/rect.js'; +import {Size} from '../utils/size.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; + +export class MiniWorkspaceBubble extends Bubble { + /** + * The minimum amount of change to the mini workspace view to trigger + * resizing the bubble. + */ + private static readonly MINIMUM_VIEW_CHANGE = 10; + + /** + * An arbitrary margin of whitespace to put around the blocks in the + * workspace. + */ + private static readonly MARGIN = Bubble.DOUBLE_BORDER * 3; + + /** The root svg element containing the workspace. */ + private svgDialog: SVGElement; + + /** The workspace that gets shown within this bubble. */ + private miniWorkspace: WorkspaceSvg; + + /** + * Should this bubble automatically reposition itself when it resizes? + * Becomes false after this bubble is first dragged. + */ + private autoLayout = true; + + /** @internal */ + constructor( + workspaceOptions: BlocklyOptions, + protected readonly workspace: WorkspaceSvg, + protected anchor: Coordinate, + protected ownerRect?: Rect + ) { + super(workspace, anchor, ownerRect); + const options = new Options(workspaceOptions); + this.validateWorkspaceOptions(options); + + this.svgDialog = dom.createSvgElement( + Svg.SVG, + { + 'x': Bubble.BORDER_WIDTH, + 'y': Bubble.BORDER_WIDTH, + }, + this.contentContainer + ); + workspaceOptions.parentWorkspace = this.workspace; + this.miniWorkspace = this.newWorkspaceSvg(new Options(workspaceOptions)); + const background = this.miniWorkspace.createDom('blocklyMutatorBackground'); + this.svgDialog.appendChild(background); + if (options.languageTree) { + background.insertBefore( + this.miniWorkspace.addFlyout(Svg.G), + this.miniWorkspace.getCanvas() + ); + const flyout = this.miniWorkspace.getFlyout(); + flyout?.init(this.miniWorkspace); + flyout?.show(options.languageTree); + } + + this.miniWorkspace.addChangeListener(this.updateBubbleSize.bind(this)); + this.miniWorkspace + .getFlyout() + ?.getWorkspace() + ?.addChangeListener(this.updateBubbleSize.bind(this)); + this.updateBubbleSize(); + } + + dispose() { + this.miniWorkspace.dispose(); + super.dispose(); + } + + /** @internal */ + getWorkspace(): WorkspaceSvg { + return this.miniWorkspace; + } + + /** Adds a change listener to the mini workspace. */ + addWorkspaceChangeListener(listener: (e: AbstractEvent) => void) { + this.miniWorkspace.addChangeListener(listener); + } + + /** + * Validates the workspace options to make sure folks aren't trying to + * enable things the miniworkspace doesn't support. + */ + private validateWorkspaceOptions(options: Options) { + if (options.hasCategories) { + throw new Error( + 'The miniworkspace bubble does not support toolboxes with categories' + ); + } + if (options.hasTrashcan) { + throw new Error('The miniworkspace bubble does not support trashcans'); + } + if ( + options.zoomOptions.controls || + options.zoomOptions.wheel || + options.zoomOptions.pinch + ) { + throw new Error('The miniworkspace bubble does not support zooming'); + } + if ( + options.moveOptions.scrollbars || + options.moveOptions.wheel || + options.moveOptions.drag + ) { + throw new Error( + 'The miniworkspace bubble does not scrolling/moving the workspace' + ); + } + if (options.horizontalLayout) { + throw new Error( + 'The miniworkspace bubble does not support horizontal layouts' + ); + } + } + + /** + * Updates the size of this bubble to account for the size of the + * mini workspace. + */ + private updateBubbleSize() { + if (this.miniWorkspace.isDragging()) return; + + const currSize = this.getSize(); + const newSize = this.calculateWorkspaceSize(); + if ( + Math.abs(currSize.width - newSize.width) < + MiniWorkspaceBubble.MINIMUM_VIEW_CHANGE && + Math.abs(currSize.height - newSize.height) < + MiniWorkspaceBubble.MINIMUM_VIEW_CHANGE + ) { + // Only resize if the size has noticeably changed. + return; + } + this.svgDialog.setAttribute('width', `${newSize.width}px`); + this.svgDialog.setAttribute('height', `${newSize.height}px`); + this.miniWorkspace.setCachedParentSvgSize(newSize.width, newSize.height); + if (this.miniWorkspace.RTL) { + // Scroll the workspace to always left-align. + this.miniWorkspace + .getCanvas() + .setAttribute('transform', `translate(${newSize.width}, 0)`); + } + this.setSize( + new Size( + newSize.width + Bubble.DOUBLE_BORDER, + newSize.height + Bubble.DOUBLE_BORDER + ), + this.autoLayout + ); + this.miniWorkspace.resize(); + this.miniWorkspace.recordDragTargets(); + } + + /** + * Calculates the size of the mini workspace for use in resizing the bubble. + */ + private calculateWorkspaceSize(): Size { + // TODO (#7104): Clean this up to be more readable and unified for RTL + // vs LTR. + const canvas = this.miniWorkspace.getCanvas(); + const workspaceSize = canvas.getBBox(); + let width = workspaceSize.width + workspaceSize.x; + let height = workspaceSize.height + MiniWorkspaceBubble.MARGIN; + const flyout = this.miniWorkspace.getFlyout(); + if (flyout) { + const flyoutScrollMetrics = flyout + .getWorkspace() + .getMetricsManager() + .getScrollMetrics(); + height = Math.max(height, flyoutScrollMetrics.height + 20); + width += flyout.getWidth(); + } + if (this.miniWorkspace.RTL) { + width = -workspaceSize.x; + } + width += MiniWorkspaceBubble.MARGIN; + return new Size(width, height); + } + + /** + * Move this bubble during a drag. + * + * @param newLoc The location to translate to, in workspace coordinates. + * @internal + */ + moveDuringDrag(newLoc: Coordinate): void { + super.moveDuringDrag(newLoc); + this.autoLayout = false; + } + + /** @internal */ + moveTo(x: number, y: number): void { + super.moveTo(x, y); + this.miniWorkspace.recordDragTargets(); + } + + /** @internal */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + newWorkspaceSvg(options: Options): WorkspaceSvg { + throw new Error( + 'The implementation of newWorkspaceSvg should be ' + + 'monkey-patched in by blockly.ts' + ); + } +} diff --git a/core/flyout_base.ts b/core/flyout_base.ts index 68ddd90c8..a97ad7d05 100644 --- a/core/flyout_base.ts +++ b/core/flyout_base.ts @@ -12,6 +12,7 @@ import * as goog from '../closure/goog/goog.js'; goog.declareModuleId('Blockly.Flyout'); +import type {Abstract as AbstractEvent} from './events/events_abstract.js'; import type {Block} from './block.js'; import type {BlockSvg} from './block_svg.js'; import * as browserEvents from './browser_events.js'; @@ -136,14 +137,14 @@ export abstract class Flyout extends DeleteArea implements IFlyout { * Function that will be registered as a change listener on the workspace * to reflow when blocks in the flyout workspace change. */ - private reflowWrapper: Function | null = null; + private reflowWrapper: ((e: AbstractEvent) => void) | null = null; /** * Function that disables blocks in the flyout based on max block counts * allowed in the target workspace. Registered as a change listener on the * target workspace. */ - private filterWrapper: Function | null = null; + private filterWrapper: ((e: AbstractEvent) => void) | null = null; /** * List of background mats that lurk behind each block to catch clicks diff --git a/core/mutator.ts b/core/mutator.ts index 3896a30fa..bc5284de8 100644 --- a/core/mutator.ts +++ b/core/mutator.ts @@ -69,7 +69,7 @@ export class Mutator extends Icon { * Function registered on the main workspace to update the mutator contents * when the main workspace changes. */ - private sourceListener: Function | null = null; + private sourceListener: (() => void) | null = null; /** * The PID associated with the updateWorkpace_ timeout, or null if no timeout diff --git a/core/workspace.ts b/core/workspace.ts index d23ee8611..2cac2cc5f 100644 --- a/core/workspace.ts +++ b/core/workspace.ts @@ -668,7 +668,7 @@ export class Workspace implements IASTNodeLocation { * @param func Function to call. * @returns Obsolete return value, ignore. */ - addChangeListener(func: Function): Function { + addChangeListener(func: (e: Abstract) => void): Function { this.listeners.push(func); return func; }