Thank you for your kind explanation! While trying to implement the UI as mentioned above, I was going to modify getX as originally suggested, but I solved it by putting the flyout inside the toolbox to calculate the flyout to match the same width as the tab! I will proceed with overriding getFlyoutScale! Thank you.
However, I have a question: I ended up redefining the toolbox's private property flyout_ to maintain the flyout values. Is there any other way to approach this? And I'm wondering if this could become a significant problem.
I would greatly appreciate your insights on this matter. Thank you for your time and assistance!
Here is our code.
----------------------------------------
// fixedFlyout.ts
import * as Blockly from 'blockly/core';
import TabbedToolbox from './tabbedToolbox';
/**
* FixedFlyout class extends Blockly's VerticalFlyout to display the Flyout at a fixed position.
*
* @description
* By default, Blockly's Flyout is an SVG element, and when the show function is called,
* the position function is automatically called to dynamically calculate the Flyout's position.
* This class overrides the position function to display the Flyout at a fixed position.
*/
// @ts-expect-error Private field inheritance
export default class FixedFlyout extends Blockly.VerticalFlyout {
static registryName = 'FixedFlyout';
/**
* Calculates and sets the position of the Flyout.
*
* @description
* This method operates in the following sequence:
* 1. Checks the visibility of both Flyout and Workspace
* 2. Gets workspace metrics and sets Flyout height (this is not essential for fixedFlyout but maintained as it's basic flyout behavior)
* 3. Uses Toolbox's ContentArea metrics to calculate the final position of the Flyout
*
* @throws {Error} If workspace is not initialized or not visible
* @throws {Error} If Toolbox is missing or not initialized
* @override
*/
override position(): void {
if (!this.isVisible() || !this.targetWorkspace!.isVisible()) {
return;
}
const toolbox = this.targetWorkspace.getToolbox() as TabbedToolbox;
const x = 7;
const y = 0;
if (!toolbox) {
throw new Error('no toolbox');
}
const metrics = toolbox.getContentAreaMetrics();
this.width_ = metrics.width;
this.height_ = metrics.height;
this.positionAt_(metrics.width - 7, metrics.height - toolbox.getContentHeight(), x, y);
}
/**
* Disables the hide functionality of the Flyout.
*
* @description
* While the default Blockly Flyout automatically hides under certain conditions,
* FixedFlyout should always be displayed at a fixed position, so we override
* the hide method to do nothing.
*
* @override
*/
override hide(): void {
return;
}
/**
* Specifies the position when adding a new block.
*
* @description
* This method is originally private and shouldn't be accessed, but it was modified to fix an issue
* where blocks weren't being created at the correct position when moving the flyout inside the contentArea.
*
* @throws {Error} If workspace is not initialized or not visible
* @throws {Error} If Toolbox is missing or not initialized
* @override
*/
override positionNewBlock(oldBlock: Blockly.BlockSvg, block: Blockly.BlockSvg) {
const targetWorkspace = this.targetWorkspace;
const flyout = this.svgGroup_;
if (!flyout) {
throw new Error('Cannot find flyout element');
}
const mainOffsetPixels = targetWorkspace.getOriginOffsetInPixels();
const flyoutRect = flyout.getBoundingClientRect();
const workspaceRect = targetWorkspace.getParentSvg().getBoundingClientRect();
const canvasTransform = this.workspace_.getCanvas().getAttribute('transform');
if (!canvasTransform) {
throw new Error('Cannot find transform value in blocklyBlockCanvas');
}
const flyoutOffsetPixels = new Blockly.utils.Coordinate(
flyoutRect.x - workspaceRect.x,
flyoutRect.y - workspaceRect.y
);
const translateValue = this.getTranslate_(canvasTransform);
flyoutOffsetPixels.x = flyoutRect!.x - workspaceRect!.x;
flyoutOffsetPixels.y = flyoutRect!.y - workspaceRect!.y + translateValue.y;
const oldBlockPos = oldBlock.getRelativeToSurfaceXY();
oldBlockPos.scale(this.workspace_.scale);
const oldBlockOffsetPixels = Blockly.utils.Coordinate.sum(flyoutOffsetPixels, oldBlockPos);
const finalOffset = Blockly.utils.Coordinate.difference(oldBlockOffsetPixels, mainOffsetPixels);
finalOffset.scale(1 / targetWorkspace.scale);
block.moveTo(new Blockly.utils.Coordinate(finalOffset.x, finalOffset.y));
}
private getTranslate_(transformStr: string): { x: number; y: number } {
const translateMatch = transformStr?.match(/translate\(\s*[\d.-]+,\s*([\d.-]+)\)/);
if (!translateMatch) {
throw new Error('No matching translate value found');
}
return {
x: parseFloat(translateMatch[0]),
y: parseFloat(translateMatch![1]),
};
}
}
// tabbedToolbox.ts
import * as Blockly from 'blockly/core';
import { TTabToolboxConfig, TTabsConfig } from '@/shared/types';
import Dom from './dom';
import FixedFlyout from './fixedFlyout';
export interface IContentAreaMetrics {
width: number;
height: number;
}
// @ts-expect-error Private field inheritance
export default class TabbedToolbox extends Blockly.Toolbox {
private tabsConfig_: TTabsConfig | undefined;
private currentTabId_: string | undefined;
private tabContainer_: HTMLDivElement | null = null;
private contentsContainer_: HTMLDivElement | null = null;
private contentArea_: HTMLDivElement | null = null;
private flyout_: Blockly.IFlyout | null = null;
constructor(workspace: Blockly.WorkspaceSvg) {
super(workspace);
}
/**
* Initializes the Toolbox and adds Flyout and ContentArea in initInternal_.
* @throws {Error} If flyout is not initialized
* @throws {Error} If ContentsContainer is not initialized
* @override
*/
override init() {
super.init();
const flyout = this.getFlyout();
if (!flyout) {
throw new Error('Flyout not initialized. Flyout configuration is required when creating Toolbox.');
}
if (!this.contentsContainer_) {
throw new Error('ContentsContainer not initialized. DOM element creation is required.');
}
const contentArea = Dom.createElement<HTMLDivElement>('div', { class: 'contentArea' });
contentArea.prepend(flyout.createDom('svg'));
this.contentArea_ = contentArea;
this.contentsContainer_.prepend(contentArea);
}
/**
* Creates DOM elements for the Toolbox.
* @param workspace - The Blockly workspace this Toolbox belongs to
* @returns The main Container Div of the Toolbox
* @override
*/
override createDom_(workspace: Blockly.WorkspaceSvg): HTMLDivElement {
const svg = workspace.getParentSvg();
const container = this.createContainer_();
svg.parentNode!.insertBefore(container, svg);
this.tabContainer_ = this.initTabContainer_();
container.appendChild(this.tabContainer_);
this.contentsContainer_ = this.initContentContainer_();
container.appendChild(this.contentsContainer_);
this.contentsDiv_ = this.createContentsContainer_();
this.contentsDiv_.tabIndex = 0;
this.contentsContainer_.appendChild(this.contentsDiv_);
this.attachEvents_(container, this.contentsDiv_);
return container;
}
/**
* Adds tab-related configuration to TabbedToolbox and initializes tabs.
* @param config - Tab information
* @throws {Error} If Flyout is not initialized
*/
public setConfig(tabToolboxConfig: TTabToolboxConfig) {
const flyout = this.getFlyout();
if (!flyout) {
throw new Error(
'Flyout not initialized. Flyout must be initialized to change its position after creating tabs.'
);
}
this.tabsConfig_ = tabToolboxConfig.tabs;
this.currentTabId_ = tabToolboxConfig.defaultSelectedTab;
this.initTabs_();
flyout.position();
}
/**
* Calculates and returns the total height of content inserted before the Flyout.
*
* @description
* While the Flyout typically occupies the full height of the ContentArea,
* when other elements are added to the ContentArea, the Flyout's height should be reduced accordingly.
* This function measures the height of added elements to properly adjust the Flyout's height.
*
* @returns Total content height
* @throws {Error} If ContentArea is not initialized
*/
public getContentHeight(): number {
if (!this.contentArea_) {
throw new Error(
'ContentArea not initialized. ContentArea must be initialized to calculate height.'
);
}
const parentRect = this.contentArea_.getBoundingClientRect();
const children = this.contentArea_.children;
let maxBottom = 0;
for (const child of children) {
if (child.classList.contains('blocklyFlyout')) {
break;
}
const rect = child.getBoundingClientRect();
const bottom = rect.bottom - parentRect.top;
const computedStyle = window.getComputedStyle(child);
const marginBottom = parseFloat(computedStyle.marginBottom);
maxBottom = Math.max(maxBottom, bottom + marginBottom);
}
return maxBottom;
}
/**
* Returns the width and height of the ContentArea as an object.
*
* @description
* The Flyout typically occupies the full height of the ContentArea.
* This is needed when calculating the Flyout size to match the ContentArea.
*
* @returns An IContentAreaMetrics object containing the width and height of ContentArea.
* @throws {Error} If ContentArea is not initialized
*/
public getContentAreaMetrics(): IContentAreaMetrics {
if (!this.contentArea_) {
throw new Error(
'ContentArea not initialized. ContentArea must be initialized to calculate width and height.'
);
}
const contentAreaClientRect = this.contentArea_.getBoundingClientRect();
return {
width: contentAreaClientRect.width,
height: contentAreaClientRect.height,
};
}
/**
* Adds an HTML element to the ContentArea.
*
* @description
* ContentArea is a div that wraps the flyout element.
* This function uses appendChild by default to add new HTML elements to the ContentArea.
*
* @param element - HTML element to add to ContentArea
* @param prepend - If true, adds the element to the beginning of ContentArea. Default is false.
*
* @throws {Error} If contentArea is not initialized
*
* @example
* ```typescript
* const element = document.createElement('div');
* element.textContent = 'text';
*
* // Add element to end of ContentArea
* toolbox.addElementToContentArea(element);
*
* // Add element to beginning of ContentArea
* toolbox.addElementToContentArea(element, true);
* ```
*/
public addElementToContentArea(element: HTMLElement, prepend: boolean = false): void {
if (!this.contentArea_) {
throw new Error('ContentArea not initialized.');
}
if (prepend) {
this.contentArea_.prepend(element);
} else {
this.contentArea_.appendChild(element);
}
}
private initTabContainer_() {
return Dom.createElement<HTMLDivElement>('div', {
class: 'toolboxTabs',
});
}
private initContentContainer_() {
return Dom.createElement<HTMLDivElement>('div', {
class: 'contentContainer',
});
}
private initTabs_() {
if (!this.HtmlDiv || !this.tabContainer_) {
throw new Error('HtmlDiv or ContentArea not initialized.');
}
Object.entries(this.tabsConfig_!).forEach(([id, tabConfig]) => {
const tabElement = this.createTab_(tabConfig.label, id);
if (this.currentTabId_ && this.currentTabId_ === id) {
this.selectTab_(id, tabElement);
}
tabElement.addEventListener('click', () => this.selectTab_(id, tabElement));
this.tabContainer_!.appendChild(tabElement);
});
}
private createTab_(label: string, id: string) {
const tab = Dom.createElement<HTMLDivElement>('div', {
class: 'toolboxTab',
});
tab.dataset.id = id.toString();
tab.appendChild(this.createLabel_(label));
return tab;
}
private createLabel_(label: string) {
const labelSpan = Dom.createElement<HTMLDivElement>('span', {
class: 'toolboxTabLabel',
});
labelSpan.textContent = label;
return labelSpan;
}
private selectTab_(id: string, tabElement: HTMLDivElement) {
if (!this.workspace_ || !this.tabsConfig_) {
return;
}
this.currentTabId_ = id;
const tabConfig = this.tabsConfig_[id];
if (this.flyout_) {
this.flyout_.dispose();
}
this.flyout_ = this.createFlyoutByRegistry_(
tabConfig.flyoutRegistryName || FixedFlyout.registryName
);
if (!this.contentArea_) {
throw new Error('ContentArea not initialized.');
}
this.clearContentArea_();
this.contentArea_.prepend(this.flyout_.createDom('svg'));
this.flyout_.init(this.workspace_);
this.workspace_.updateToolbox(tabConfig.toolboxConfig);
Array.from(this.tabContainer_!.children).forEach((child) => {
child.classList.remove('tabSelected');
});
tabElement.classList.add('tabSelected');
if (tabConfig.toolboxConfig.kind === 'categoryToolbox' && this.getToolboxItems().length !== 0) {
this.setSelectedItem(this.getToolboxItems()![0]);
}
}
private createFlyoutByRegistry_(flyoutRegistryName: string): Blockly.IFlyout {
const workspace = this.workspace_;
const workspaceOptions = new Blockly.Options({
parentWorkspace: workspace,
rtl: workspace.RTL,
oneBasedIndex: workspace.options.oneBasedIndex,
horizontalLayout: workspace.horizontalLayout,
renderer: workspace.options.renderer,
rendererOverrides: workspace.options.rendererOverrides,
move: {
scrollbars: true,
},
} as Blockly.BlocklyOptions);
workspaceOptions.toolboxPosition = workspace.options.toolboxPosition;
const FlyoutClass = Blockly.registry.getClass(
Blockly.registry.Type.FLYOUTS_VERTICAL_TOOLBOX,
flyoutRegistryName
);
return new FlyoutClass!(workspaceOptions);
}
private clearContentArea_() {
if (!this.contentArea_) {
throw new Error('');
}
this.contentArea_.innerHTML = '';
}
}