Files
blockly/core/icons/mutator_icon.ts
Aaron Dodson a42c2d1508 feat: Add a BlockSvg.getStyle() method. (#8722)
* feat: Add a BlockSvg.getStyle() method.

* refactor: Remove direct field access to BlockSvg.style.
2025-01-07 14:44:48 -08:00

356 lines
10 KiB
TypeScript

/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.Mutator
import type {BlockSvg} from '../block_svg.js';
import type {BlocklyOptions} from '../blockly_options.js';
import {MiniWorkspaceBubble} from '../bubbles/mini_workspace_bubble.js';
import type {Abstract} from '../events/events_abstract.js';
import {BlockChange} from '../events/events_block_change.js';
import {isBlockChange, isBlockCreate} from '../events/predicates.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import * as renderManagement from '../render_management.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import {Rect} from '../utils/rect.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
import {Icon} from './icon.js';
import {IconType} from './icon_types.js';
/** The size of the mutator icon in workspace-scale units. */
const SIZE = 17;
/**
* The distance between the root block in the mini workspace and that
* workspace's edges.
*/
const WORKSPACE_MARGIN = 16;
/**
* An icon that allows the user to change the shape of the block.
*
* For example, it could be used to add additional fields or inputs to
* the block.
*/
export class MutatorIcon extends Icon implements IHasBubble {
/** The type string used to identify this icon. */
static readonly TYPE = IconType.MUTATOR;
/**
* The weight this icon has relative to other icons. Icons with more positive
* weight values are rendered farther toward the end of the block.
*/
static readonly WEIGHT = 1;
/** The bubble used to show the mini workspace to the user. */
private miniWorkspaceBubble: MiniWorkspaceBubble | null = null;
/** The root block in the mini workspace. */
private rootBlock: BlockSvg | null = null;
/** The PID tracking updating the workkspace in response to user events. */
private updateWorkspacePid: ReturnType<typeof setTimeout> | null = null;
/**
* The change listener in the main workspace that triggers the saveConnections
* method when anything in the main workspace changes.
*
* Only actually registered to listen for events while the mutator bubble is
* open.
*/
private saveConnectionsListener: (() => void) | null = null;
constructor(
private readonly flyoutBlockTypes: string[],
protected readonly sourceBlock: BlockSvg,
) {
super(sourceBlock);
}
override getType(): IconType<MutatorIcon> {
return MutatorIcon.TYPE;
}
override initView(pointerdownListener: (e: PointerEvent) => void): void {
if (this.svgRoot) return; // Already initialized.
super.initView(pointerdownListener);
// Square with rounded corners.
dom.createSvgElement(
Svg.RECT,
{
'class': 'blocklyIconShape',
'rx': '4',
'ry': '4',
'height': '16',
'width': '16',
},
this.svgRoot,
);
// Gear teeth.
dom.createSvgElement(
Svg.PATH,
{
'class': 'blocklyIconSymbol',
'd':
'm4.203,7.296 0,1.368 -0.92,0.677 -0.11,0.41 0.9,1.559 0.41,' +
'0.11 1.043,-0.457 1.187,0.683 0.127,1.134 0.3,0.3 1.8,0 0.3,' +
'-0.299 0.127,-1.138 1.185,-0.682 1.046,0.458 0.409,-0.11 0.9,' +
'-1.559 -0.11,-0.41 -0.92,-0.677 0,-1.366 0.92,-0.677 0.11,' +
'-0.41 -0.9,-1.559 -0.409,-0.109 -1.046,0.458 -1.185,-0.682 ' +
'-0.127,-1.138 -0.3,-0.299 -1.8,0 -0.3,0.3 -0.126,1.135 -1.187,' +
'0.682 -1.043,-0.457 -0.41,0.11 -0.899,1.559 0.108,0.409z',
},
this.svgRoot,
);
// Axle hole.
dom.createSvgElement(
Svg.CIRCLE,
{'class': 'blocklyIconShape', 'r': '2.7', 'cx': '8', 'cy': '8'},
this.svgRoot,
);
dom.addClass(this.svgRoot!, 'blockly-icon-mutator');
}
override dispose(): void {
super.dispose();
this.miniWorkspaceBubble?.dispose();
}
override getWeight(): number {
return MutatorIcon.WEIGHT;
}
override getSize(): Size {
return new Size(SIZE, SIZE);
}
override applyColour(): void {
super.applyColour();
this.miniWorkspaceBubble?.setColour(this.sourceBlock.getColour());
this.miniWorkspaceBubble?.updateBlockStyles();
}
override updateCollapsed(): void {
super.updateCollapsed();
if (this.sourceBlock.isCollapsed()) this.setBubbleVisible(false);
}
override onLocationChange(blockOrigin: Coordinate): void {
super.onLocationChange(blockOrigin);
this.miniWorkspaceBubble?.setAnchorLocation(this.getAnchorLocation());
}
override onClick(): void {
super.onClick();
if (this.sourceBlock.isEditable()) {
this.setBubbleVisible(!this.bubbleIsVisible());
}
}
override isClickableInFlyout(): boolean {
return false;
}
bubbleIsVisible(): boolean {
return !!this.miniWorkspaceBubble;
}
async setBubbleVisible(visible: boolean): Promise<void> {
if (this.bubbleIsVisible() === visible) return;
await renderManagement.finishQueuedRenders();
if (visible) {
this.miniWorkspaceBubble = new MiniWorkspaceBubble(
this.getMiniWorkspaceConfig(),
this.sourceBlock.workspace,
this.getAnchorLocation(),
this.getBubbleOwnerRect(),
);
this.applyColour();
this.createRootBlock();
this.addSaveConnectionsListener();
this.miniWorkspaceBubble?.addWorkspaceChangeListener(
this.createMiniWorkspaceChangeListener(),
);
} else {
this.miniWorkspaceBubble?.dispose();
this.miniWorkspaceBubble = null;
if (this.saveConnectionsListener) {
this.sourceBlock.workspace.removeChangeListener(
this.saveConnectionsListener,
);
}
this.saveConnectionsListener = null;
}
eventUtils.fire(
new (eventUtils.get(EventType.BUBBLE_OPEN))(
this.sourceBlock,
visible,
'mutator',
),
);
}
/** @returns the configuration the mini workspace should have. */
private getMiniWorkspaceConfig() {
const options: BlocklyOptions = {
'disable': false,
'media': this.sourceBlock.workspace.options.pathToMedia,
'rtl': this.sourceBlock.RTL,
'renderer': this.sourceBlock.workspace.options.renderer,
'rendererOverrides':
this.sourceBlock.workspace.options.rendererOverrides ?? undefined,
};
if (this.flyoutBlockTypes.length) {
options.toolbox = {
'kind': 'flyoutToolbox',
'contents': this.flyoutBlockTypes.map((type) => ({
'kind': 'block',
'type': type,
})),
};
}
return options;
}
/**
* @returns the location the bubble should be anchored to.
* I.E. the middle of this icon.
*/
private getAnchorLocation(): Coordinate {
const midIcon = SIZE / 2;
return Coordinate.sum(
this.workspaceLocation,
new Coordinate(midIcon, midIcon),
);
}
/**
* @returns the rect the bubble should avoid overlapping.
* I.E. the block that owns this icon.
*/
private getBubbleOwnerRect(): Rect {
const bbox = this.sourceBlock.getSvgRoot().getBBox();
return new Rect(bbox.y, bbox.y + bbox.height, bbox.x, bbox.x + bbox.width);
}
/** Decomposes the source block to create blocks in the mini workspace. */
private createRootBlock() {
if (!this.sourceBlock.decompose) {
throw new Error(
'Blocks with mutator icons must include a decompose method',
);
}
this.rootBlock = this.sourceBlock.decompose(
this.miniWorkspaceBubble!.getWorkspace(),
)!;
for (const child of this.rootBlock.getDescendants(false)) {
child.queueRender();
}
this.rootBlock.setMovable(false);
this.rootBlock.setDeletable(false);
const flyoutWidth =
this.miniWorkspaceBubble?.getWorkspace()?.getFlyout()?.getWidth() ?? 0;
this.rootBlock.moveBy(
this.rootBlock.RTL ? -(flyoutWidth + WORKSPACE_MARGIN) : WORKSPACE_MARGIN,
WORKSPACE_MARGIN,
);
}
/** Adds a listen to the source block that triggers saving connections. */
private addSaveConnectionsListener() {
if (!this.sourceBlock.saveConnections || !this.rootBlock) return;
this.saveConnectionsListener = () => {
if (!this.sourceBlock.saveConnections || !this.rootBlock) return;
this.sourceBlock.saveConnections(this.rootBlock);
};
this.saveConnectionsListener();
this.sourceBlock.workspace.addChangeListener(this.saveConnectionsListener);
}
/**
* Creates a change listener to add to the mini workspace which recomposes
* the block.
*/
private createMiniWorkspaceChangeListener() {
return (e: Abstract) => {
if (!MutatorIcon.isIgnorableMutatorEvent(e) && !this.updateWorkspacePid) {
this.updateWorkspacePid = setTimeout(() => {
this.updateWorkspacePid = null;
this.recomposeSourceBlock();
}, 0);
}
};
}
/**
* Returns true if the given event is not one the mutator needs to
* care about.
*
* @internal
*/
static isIgnorableMutatorEvent(e: Abstract) {
return (
e.isUiEvent ||
isBlockCreate(e) ||
(isBlockChange(e) && e.element === 'disabled')
);
}
/** Recomposes the source block based on changes to the mini workspace. */
private recomposeSourceBlock() {
if (!this.rootBlock) return;
if (!this.sourceBlock.compose) {
throw new Error(
'Blocks with mutator icons must include a compose method',
);
}
const existingGroup = eventUtils.getGroup();
if (!existingGroup) eventUtils.setGroup(true);
const oldExtraState = BlockChange.getExtraBlockState_(this.sourceBlock);
this.sourceBlock.compose(this.rootBlock);
const newExtraState = BlockChange.getExtraBlockState_(this.sourceBlock);
if (oldExtraState !== newExtraState) {
eventUtils.fire(
new (eventUtils.get(EventType.BLOCK_CHANGE))(
this.sourceBlock,
'mutation',
null,
oldExtraState,
newExtraState,
),
);
}
eventUtils.setGroup(existingGroup);
}
/**
* @returns The workspace of the mini workspace bubble, if the bubble is
* currently open.
*/
getWorkspace(): WorkspaceSvg | undefined {
return this.miniWorkspaceBubble?.getWorkspace();
}
}