Groups keyboard shortcuts have been updated
Dismiss
See shortcuts

How to Customize Blockly Flyout Position and Behavior?

138 views
Skip to first unread message

Yujin Lee

unread,
Nov 19, 2024, 5:05:12 AM11/19/24
to Blockly

Hi everyone,

I’m currently customizing the Blockly toolbox to match my desired design. My goal is to have a tabbed interface on top, with blocks and categories displayed below. I also want the flyout (where the blocks appear) to stay fixed and always open, without automatically closing. 

(Example)

Screenshot 2024-11-19 at 3.48.25 PM.png

To achieve this, I injected my custom tab buttons and used the hide function to prevent flyout from closing. However, I ran into an issue: the flyout doesn’t stay in the correct position.

Screenshot 2024-11-19 at 3.51.45 PM.png

After resizing the blocklyToolboxDiv, I noticed the flyout shifts to the left, likely because it’s originally programmed to align with the left of the blocklyToolboxDiv. 

Screenshot 2024-11-19 at 3.52.22 PM.png

Additionally, since the flyout is rendered as an SVG, I noticed it scales. However, I don’t need the scaling functionality.

I came up with two possible approaches to solve this:

  1. Replace the SVG-based flyout with a div-based implementation.
  2. Adjust the flyout’s size and position dynamically and modify the functions that handle flyout positioning during window resizing.

Which approach would be more appropriate? Or is there another way to achieve this? I’d appreciate any advice or suggestions on how to implement the best solution.

Screen Recording 2024-11-19 at 3.29.03 PM.mov

Aaron Dodson

unread,
Nov 25, 2024, 12:40:58 PM11/25/24
to Blockly
Hi,

If you don't have one already, you'll likely need to create a subclass of the flyout and register it in place of the built-in one. Then:

* Override the getX() method to return the X coordinate of the left side of the flyout. As you note, the default implementation does try to account for the toolbox, so you may need to adjust the math accordingly.
* You note that the flyout scales; I assume you mean that it does so to stay in sync with the zoom level of the main workspace? If that is indeed what you're trying to avoid, override getFlyoutScale() in your subclass and return a constant value (e.g. 1). That will cause the flyout to display itself using that constant scaling factor rather than varying with the scale of the main workspace.

Hopefully that helps, and please let me know if you run into any issues!

- Aaron

Yujin Lee

unread,
Nov 29, 2024, 6:23:37 AM11/29/24
to Blockly

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 = '';
  }
}
2024년 11월 26일 화요일 오전 2시 40분 58초 UTC+9에 ado...@google.com님이 작성:
Reply all
Reply to author
Forward
0 new messages