Files
blockly/core/flyout_button.ts
2024-12-04 12:06:12 -08:00

417 lines
11 KiB
TypeScript

/**
* @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Class for a button in the flyout.
*
* @class
*/
// Former goog.module ID: Blockly.FlyoutButton
import type {IASTNodeLocationSvg} from './blockly.js';
import * as browserEvents from './browser_events.js';
import * as Css from './css.js';
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import type {IRenderedElement} from './interfaces/i_rendered_element.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
import {Rect} from './utils/rect.js';
import * as style from './utils/style.js';
import {Svg} from './utils/svg.js';
import type * as toolbox from './utils/toolbox.js';
import type {WorkspaceSvg} from './workspace_svg.js';
/**
* Class for a button or label in the flyout.
*/
export class FlyoutButton
implements IASTNodeLocationSvg, IBoundedElement, IRenderedElement
{
/** The horizontal margin around the text in the button. */
static TEXT_MARGIN_X = 5;
/** The vertical margin around the text in the button. */
static TEXT_MARGIN_Y = 2;
/** The radius of the flyout button's borders. */
static BORDER_RADIUS = 4;
private readonly text: string;
private readonly position: Coordinate;
private readonly callbackKey: string;
private readonly cssClass: string | null;
/** Mouse up event data. */
private onMouseDownWrapper: browserEvents.Data;
private onMouseUpWrapper: browserEvents.Data;
info: toolbox.ButtonOrLabelInfo;
/** The width of the button's rect. */
width = 0;
/** The height of the button's rect. */
height = 0;
/** The root SVG group for the button or label. */
private svgGroup: SVGGElement;
/** The SVG element with the text of the label or button. */
private svgText: SVGTextElement | null = null;
/**
* Holds the cursors svg element when the cursor is attached to the button.
* This is null if there is no cursor on the button.
*/
cursorSvg: SVGElement | null = null;
/**
* @param workspace The workspace in which to place this button.
* @param targetWorkspace The flyout's target workspace.
* @param json The JSON specifying the label/button.
* @param isFlyoutLabel Whether this button should be styled as a label.
* @internal
*/
constructor(
private readonly workspace: WorkspaceSvg,
private readonly targetWorkspace: WorkspaceSvg,
json: toolbox.ButtonOrLabelInfo,
private readonly isFlyoutLabel: boolean,
) {
this.text = json['text'];
this.position = new Coordinate(0, 0);
/** The key to the function called when this button is clicked. */
this.callbackKey =
(json as AnyDuringMigration)[
'callbackKey'
] /* Check the lower case version
too to satisfy IE */ ||
(json as AnyDuringMigration)['callbackkey'];
/** If specified, a CSS class to add to this button. */
this.cssClass = (json as AnyDuringMigration)['web-class'] || null;
/** The JSON specifying the label / button. */
this.info = json;
let cssClass = this.isFlyoutLabel
? 'blocklyFlyoutLabel'
: 'blocklyFlyoutButton';
if (this.cssClass) {
cssClass += ' ' + this.cssClass;
}
this.svgGroup = dom.createSvgElement(
Svg.G,
{'class': cssClass},
this.workspace.getCanvas(),
);
let shadow;
if (!this.isFlyoutLabel) {
// Shadow rectangle (light source does not mirror in RTL).
shadow = dom.createSvgElement(
Svg.RECT,
{
'class': 'blocklyFlyoutButtonShadow',
'rx': FlyoutButton.BORDER_RADIUS,
'ry': FlyoutButton.BORDER_RADIUS,
'x': 1,
'y': 1,
},
this.svgGroup!,
);
}
// Background rectangle.
const rect = dom.createSvgElement(
Svg.RECT,
{
'class': this.isFlyoutLabel
? 'blocklyFlyoutLabelBackground'
: 'blocklyFlyoutButtonBackground',
'rx': FlyoutButton.BORDER_RADIUS,
'ry': FlyoutButton.BORDER_RADIUS,
},
this.svgGroup!,
);
const svgText = dom.createSvgElement(
Svg.TEXT,
{
'class': this.isFlyoutLabel ? 'blocklyFlyoutLabelText' : 'blocklyText',
'x': 0,
'y': 0,
'text-anchor': 'middle',
},
this.svgGroup!,
);
let text = parsing.replaceMessageReferences(this.text);
if (this.workspace.RTL) {
// Force text to be RTL by adding an RLM.
text += '\u200F';
}
svgText.textContent = text;
if (this.isFlyoutLabel) {
this.svgText = svgText;
this.workspace
.getThemeManager()
.subscribe(this.svgText, 'flyoutForegroundColour', 'fill');
}
const fontSize = style.getComputedStyle(svgText, 'fontSize');
const fontWeight = style.getComputedStyle(svgText, 'fontWeight');
const fontFamily = style.getComputedStyle(svgText, 'fontFamily');
this.width = dom.getFastTextWidthWithSizeString(
svgText,
fontSize,
fontWeight,
fontFamily,
);
const fontMetrics = dom.measureFontMetrics(
text,
fontSize,
fontWeight,
fontFamily,
);
this.height = this.height || fontMetrics.height;
if (!this.isFlyoutLabel) {
this.width += 2 * FlyoutButton.TEXT_MARGIN_X;
this.height += 2 * FlyoutButton.TEXT_MARGIN_Y;
shadow?.setAttribute('width', String(this.width));
shadow?.setAttribute('height', String(this.height));
}
rect.setAttribute('width', String(this.width));
rect.setAttribute('height', String(this.height));
svgText.setAttribute('x', String(this.width / 2));
svgText.setAttribute(
'y',
String(this.height / 2 - fontMetrics.height / 2 + fontMetrics.baseline),
);
this.updateTransform();
this.onMouseDownWrapper = browserEvents.conditionalBind(
this.svgGroup,
'pointerdown',
this,
this.onMouseDown,
);
this.onMouseUpWrapper = browserEvents.conditionalBind(
this.svgGroup,
'pointerup',
this,
this.onMouseUp,
);
}
createDom(): SVGElement {
// No-op, now handled in constructor. Will be removed in followup refactor
// PR that updates the flyout classes to use inflaters.
return this.svgGroup;
}
/** Correctly position the flyout button and make it visible. */
show() {
this.updateTransform();
this.svgGroup!.setAttribute('display', 'block');
}
/** Update SVG attributes to match internal state. */
private updateTransform() {
this.svgGroup!.setAttribute(
'transform',
'translate(' + this.position.x + ',' + this.position.y + ')',
);
}
/**
* Move the button to the given x, y coordinates.
*
* @param x The new x coordinate.
* @param y The new y coordinate.
*/
moveTo(x: number, y: number) {
this.position.x = x;
this.position.y = y;
this.updateTransform();
}
/**
* Move the element by a relative offset.
*
* @param dx Horizontal offset in workspace units.
* @param dy Vertical offset in workspace units.
* @param _reason Why is this move happening? 'user', 'bump', 'snap'...
*/
moveBy(dx: number, dy: number, _reason?: string[]) {
this.moveTo(this.position.x + dx, this.position.y + dy);
}
/** @returns Whether or not the button is a label. */
isLabel(): boolean {
return this.isFlyoutLabel;
}
/**
* Location of the button.
*
* @returns x, y coordinates.
* @internal
*/
getPosition(): Coordinate {
return this.position;
}
/**
* Returns the coordinates of a bounded element describing the dimensions of
* the element. Coordinate system: workspace coordinates.
*
* @returns Object with coordinates of the bounded element.
*/
getBoundingRectangle() {
return new Rect(
this.position.y,
this.position.y + this.height,
this.position.x,
this.position.x + this.width,
);
}
/** @returns Text of the button. */
getButtonText(): string {
return this.text;
}
/**
* Get the button's target workspace.
*
* @returns The target workspace of the flyout where this button resides.
*/
getTargetWorkspace(): WorkspaceSvg {
return this.targetWorkspace;
}
/**
* Get the button's workspace.
*
* @returns The workspace in which to place this button.
*/
getWorkspace(): WorkspaceSvg {
return this.workspace;
}
/** Dispose of this button. */
dispose() {
browserEvents.unbind(this.onMouseDownWrapper);
browserEvents.unbind(this.onMouseUpWrapper);
if (this.svgGroup) {
dom.removeNode(this.svgGroup);
}
if (this.svgText) {
this.workspace.getThemeManager().unsubscribe(this.svgText);
}
}
/**
* Add the cursor SVG to this buttons's SVG group.
*
* @param cursorSvg The SVG root of the cursor to be added to the button SVG
* group.
*/
setCursorSvg(cursorSvg: SVGElement) {
if (!cursorSvg) {
this.cursorSvg = null;
return;
}
if (this.svgGroup) {
this.svgGroup.appendChild(cursorSvg);
this.cursorSvg = cursorSvg;
}
}
/**
* Required by IASTNodeLocationSvg, but not used. A marker cannot be set on a
* button. If the 'mark' shortcut is used on a button, its associated callback
* function is triggered.
*/
setMarkerSvg() {
throw new Error('Attempted to set a marker on a button.');
}
/**
* Do something when the button is clicked.
*
* @param e Pointer up event.
*/
private onMouseUp(e: PointerEvent) {
const gesture = this.targetWorkspace.getGesture(e);
if (gesture) {
gesture.cancel();
}
if (this.isFlyoutLabel && this.callbackKey) {
console.warn(
'Labels should not have callbacks. Label text: ' + this.text,
);
} else if (
!this.isFlyoutLabel &&
!(
this.callbackKey &&
this.targetWorkspace.getButtonCallback(this.callbackKey)
)
) {
console.warn('Buttons should have callbacks. Button text: ' + this.text);
} else if (!this.isFlyoutLabel) {
const callback = this.targetWorkspace.getButtonCallback(this.callbackKey);
if (callback) {
callback(this);
}
}
}
private onMouseDown(e: PointerEvent) {
const gesture = this.targetWorkspace.getGesture(e);
const flyout = this.targetWorkspace.getFlyout();
if (gesture && flyout) {
gesture.handleFlyoutStart(e, flyout);
}
}
/**
* @returns The root SVG element of this rendered element.
*/
getSvgRoot() {
return this.svgGroup;
}
}
/** CSS for buttons and labels. See css.js for use. */
Css.register(`
.blocklyFlyoutButton {
fill: #888;
cursor: default;
}
.blocklyFlyoutButtonShadow {
fill: #666;
}
.blocklyFlyoutButton:hover {
fill: #aaa;
}
.blocklyFlyoutLabel {
cursor: default;
}
.blocklyFlyoutLabelBackground {
opacity: 0;
}
`);