@blockly/zoom-to-fit
Version:
A Blockly plugin that adds a zoom-to-fit control to the workspace.
269 lines (243 loc) • 6.97 kB
text/typescript
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as Blockly from 'blockly/core';
/**
* Class for zoom to fit control.
*/
export class ZoomToFitControl implements Blockly.IPositionable {
/**
* The unique id for this component.
*/
id = 'zoomToFit';
/**
* The SVG group containing the zoom-to-fit control.
*/
private svgGroup: SVGElement | null = null;
/**
* Left coordinate of the zoom-to-fit control.
*/
private left = 0;
/**
* Top coordinate of the zoom-to-fit control.
*/
private top = 0;
/**
* Width of the zoom-to-fit control.
*/
private readonly width = 32;
/**
* Height of the zoom-to-fit control.
*/
private readonly height = 32;
/**
* Distance between zoom-to-fit control and bottom or top edge of workspace.
*/
private readonly marginVertical = 20;
/**
* Distance between zoom-to-fit control and right or left edge of workspace.
*/
private readonly marginHorizontal = 20;
/**
* Whether this has been initialized.
*/
private initialized = false;
private onZoomToFitWrapper: Blockly.browserEvents.Data | null = null;
/**
* Constructor for zoom-to-fit control.
*
* @param workspace The workspace that the zoom-to-fit
* control will be added to.
*/
constructor(protected workspace: Blockly.WorkspaceSvg) {}
/**
* Initializes the zoom reset control.
*/
init() {
this.workspace.getComponentManager().addComponent({
component: this,
weight: 2,
capabilities: [Blockly.ComponentManager.Capability.POSITIONABLE],
});
this.createDom();
this.initialized = true;
this.workspace.resize();
}
/**
* Disposes of workspace search.
* Unlink from all DOM elements and remove all event listeners
* to prevent memory leaks.
*/
dispose() {
if (this.svgGroup) {
Blockly.utils.dom.removeNode(this.svgGroup);
}
if (this.onZoomToFitWrapper) {
Blockly.browserEvents.unbind(this.onZoomToFitWrapper);
this.onZoomToFitWrapper = null;
}
}
/**
* Creates DOM for ui element.
*/
private createDom() {
this.svgGroup = Blockly.utils.dom.createSvgElement(
Blockly.utils.Svg.IMAGE,
{
height: `${this.height}px`,
width: `${this.width}px`,
class: 'zoomToFit',
},
);
this.svgGroup.setAttributeNS(
Blockly.utils.dom.XLINK_NS,
'xlink:href',
zoomToFitSvgDataUri,
);
Blockly.utils.dom.insertAfter(
this.svgGroup,
this.workspace.getBubbleCanvas(),
);
// Attach listener.
this.onZoomToFitWrapper = Blockly.browserEvents.conditionalBind(
this.svgGroup,
'pointerdown',
null,
this.onClick.bind(this),
);
}
/**
* Handle click event.
*
* @param e A pointer down event.
*/
private onClick(e: PointerEvent) {
this.workspace.zoomToFit();
const uiEvent = new (Blockly.Events.get(Blockly.Events.CLICK))(
null,
this.workspace.id,
'zoom_reset_control',
);
Blockly.Events.fire(uiEvent);
e.stopPropagation(); // avoid to also fire workspace click event
e.preventDefault();
}
/**
* Returns the bounding rectangle of the UI element in pixel units relative to
* the Blockly injection div.
*
* @returns The component’s bounding box.
*/
getBoundingRectangle(): Blockly.utils.Rect {
return new Blockly.utils.Rect(
this.top,
this.top + this.height,
this.left,
this.left + this.width,
);
}
/**
* Positions the zoom-to-fit control.
* It is positioned in the opposite corner to the corner the
* categories/toolbox starts at.
*
* @param metrics The workspace metrics.
* @param savedPositions List of rectangles that
* are already on the workspace.
*/
position(
metrics: Blockly.MetricsManager.UiMetrics,
savedPositions: Blockly.utils.Rect[],
) {
if (!this.initialized) {
return;
}
const hasVerticalScrollbars =
this.workspace.scrollbar &&
this.workspace.scrollbar.canScrollHorizontally();
const hasHorizontalScrollbars =
this.workspace.scrollbar &&
this.workspace.scrollbar.canScrollVertically();
if (
metrics.toolboxMetrics.position === Blockly.TOOLBOX_AT_LEFT ||
(this.workspace.horizontalLayout && !this.workspace.RTL)
) {
// Right corner placement.
this.left =
metrics.absoluteMetrics.left +
metrics.viewMetrics.width -
this.width -
this.marginHorizontal;
if (hasVerticalScrollbars && !this.workspace.RTL) {
this.left -= Blockly.Scrollbar.scrollbarThickness;
}
} else {
// Left corner placement.
this.left = this.marginHorizontal;
if (hasVerticalScrollbars && this.workspace.RTL) {
this.left += Blockly.Scrollbar.scrollbarThickness;
}
}
const startAtBottom =
metrics.toolboxMetrics.position !== Blockly.TOOLBOX_AT_BOTTOM;
if (startAtBottom) {
// Bottom corner placement
this.top =
metrics.absoluteMetrics.top +
metrics.viewMetrics.height -
this.height -
this.marginVertical;
if (hasHorizontalScrollbars) {
// The horizontal scrollbars are always positioned on the bottom.
this.top -= Blockly.Scrollbar.scrollbarThickness;
}
} else {
// Upper corner placement
this.top = metrics.absoluteMetrics.top + this.marginVertical;
}
// Check for collision and bump if needed.
let boundingRect = this.getBoundingRectangle();
for (let i = 0, otherEl; (otherEl = savedPositions[i]); i++) {
if (boundingRect.intersects(otherEl)) {
if (startAtBottom) {
// Bump up.
this.top = otherEl.top - this.height - this.marginVertical;
} else {
// Bump down.
this.top = otherEl.bottom + this.marginVertical;
}
// Recheck other savedPositions
boundingRect = this.getBoundingRectangle();
i = -1;
}
}
this.svgGroup?.setAttribute(
'transform',
`translate(${this.left}, ${this.top})`,
);
}
}
/**
* Base64 encoded data uri for zoom to fit icon.
*/
const zoomToFitSvgDataUri =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC' +
'9zdmciIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiB3aWR0aD0iMjRweCIgZm' +
'lsbD0iIzU0NkU3QSI+PHBhdGggZD0iTTAgMGgyNHYyNEgwVjB6IiBmaWxsPSJub25lIi8+PH' +
'BhdGggZD0iTTUgNi40Mkw4LjA5IDkuNSA5LjUgOC4wOSA2LjQxIDVIOVYzSDN2Nmgyem0xMC' +
'0zLjQxdjJoMi41N0wxNC41IDguMDlsMS40MSAxLjQxTDE5IDYuNDFWOWgyVjMuMDF6bTQgMT' +
'QuNTdsLTMuMDktMy4wOC0xLjQxIDEuNDFMMTcuNTkgMTlIMTV2Mmg2di02aC0yek04LjA5ID' +
'E0LjVMNSAxNy41OVYxNUgzdjZoNnYtMkg2LjQybDMuMDgtMy4wOXoiLz48L3N2Zz4=';
Blockly.Css.register(`
.zoomToFit {
opacity: 0.4;
}
.zoomToFit:hover {
opacity: 0.6;
}
.zoomToFit:active {
opacity: 0.8;
}
`);