/** * @license * Copyright 2016 Massachusetts Institute of Technology * All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /** * A div that floats on top of the workspace, for drop-down menus. * * @class */ import * as goog from '../closure/goog/goog.js'; goog.declareModuleId('Blockly.dropDownDiv'); import type {BlockSvg} from './block_svg.js'; import * as common from './common.js'; import * as dom from './utils/dom.js'; import type {Field} from './field.js'; import * as math from './utils/math.js'; import {Rect} from './utils/rect.js'; import type {Size} from './utils/size.js'; import * as style from './utils/style.js'; import type {WorkspaceSvg} from './workspace_svg.js'; /** * Arrow size in px. Should match the value in CSS * (need to position pre-render). */ export const ARROW_SIZE = 16; /** * Drop-down border size in px. Should match the value in CSS (need to position * the arrow). */ export const BORDER_SIZE = 1; /** * Amount the arrow must be kept away from the edges of the main drop-down div, * in px. */ export const ARROW_HORIZONTAL_PADDING = 12; /** Amount drop-downs should be padded away from the source, in px. */ export const PADDING_Y = 16; /** Length of animations in seconds. */ export const ANIMATION_TIME = 0.25; /** * Timer for animation out, to be cleared if we need to immediately hide * without disrupting new shows. */ let animateOutTimer: ReturnType | null = null; /** Callback for when the drop-down is hidden. */ let onHide: Function | null = null; /** A class name representing the current owner's workspace renderer. */ let renderedClassName = ''; /** A class name representing the current owner's workspace theme. */ let themeClassName = ''; /** The content element. */ let div: HTMLDivElement; /** The content element. */ let content: HTMLDivElement; /** The arrow element. */ let arrow: HTMLDivElement; /** * Drop-downs will appear within the bounds of this element if possible. * Set in setBoundsElement. */ let boundsElement: Element | null = null; /** The object currently using the drop-down. */ let owner: Field | null = null; /** Whether the dropdown was positioned to a field or the source block. */ let positionToField: boolean | null = null; /** * Dropdown bounds info object used to encapsulate sizing information about a * bounding element (bounding box and width/height). */ export interface BoundsInfo { top: number; left: number; bottom: number; right: number; width: number; height: number; } /** Dropdown position metrics. */ export interface PositionMetrics { initialX: number; initialY: number; finalX: number; finalY: number; arrowX: number | null; arrowY: number | null; arrowAtTop: boolean | null; arrowVisible: boolean; } /** * Create and insert the DOM element for this div. * * @internal */ export function createDom() { if (div) { return; // Already created. } div = document.createElement('div'); div.className = 'blocklyDropDownDiv'; const parentDiv = common.getParentContainer() || document.body; parentDiv.appendChild(div); content = document.createElement('div'); content.className = 'blocklyDropDownContent'; div.appendChild(content); arrow = document.createElement('div'); arrow.className = 'blocklyDropDownArrow'; div.appendChild(arrow); div.style.opacity = '0'; // Transition animation for transform: translate() and opacity. div.style.transition = 'transform ' + ANIMATION_TIME + 's, ' + 'opacity ' + ANIMATION_TIME + 's'; // Handle focusin/out events to add a visual indicator when // a child is focused or blurred. div.addEventListener('focusin', function () { dom.addClass(div, 'blocklyFocused'); }); div.addEventListener('focusout', function () { dom.removeClass(div, 'blocklyFocused'); }); } /** * Set an element to maintain bounds within. Drop-downs will appear * within the box of this element if possible. * * @param boundsElem Element to bind drop-down to. */ export function setBoundsElement(boundsElem: Element | null) { boundsElement = boundsElem; } /** * @returns The field that currently owns this, or null. */ export function getOwner(): Field | null { return owner; } /** * Provide the div for inserting content into the drop-down. * * @returns Div to populate with content. */ export function getContentDiv(): Element { return content; } /** Clear the content of the drop-down. */ export function clearContent() { content.textContent = ''; content.style.width = ''; } /** * Set the colour for the drop-down. * * @param backgroundColour Any CSS colour for the background. * @param borderColour Any CSS colour for the border. */ export function setColour(backgroundColour: string, borderColour: string) { div.style.backgroundColor = backgroundColour; div.style.borderColor = borderColour; } /** * Shortcut to show and place the drop-down with positioning determined * by a particular block. The primary position will be below the block, * and the secondary position above the block. Drop-down will be * constrained to the block's workspace. * * @param field The field showing the drop-down. * @param block Block to position the drop-down around. * @param opt_onHide Optional callback for when the drop-down is hidden. * @param opt_secondaryYOffset Optional Y offset for above-block positioning. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByBlock( field: Field, block: BlockSvg, opt_onHide?: Function, opt_secondaryYOffset?: number ): boolean { return showPositionedByRect( getScaledBboxOfBlock(block), field as Field, opt_onHide, opt_secondaryYOffset ); } /** * Shortcut to show and place the drop-down with positioning determined * by a particular field. The primary position will be below the field, * and the secondary position above the field. Drop-down will be * constrained to the block's workspace. * * @param field The field to position the dropdown against. * @param opt_onHide Optional callback for when the drop-down is hidden. * @param opt_secondaryYOffset Optional Y offset for above-block positioning. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByField( field: Field, opt_onHide?: Function, opt_secondaryYOffset?: number ): boolean { positionToField = true; return showPositionedByRect( getScaledBboxOfField(field as Field), field as Field, opt_onHide, opt_secondaryYOffset ); } /** * Get the scaled bounding box of a block. * * @param block The block. * @returns The scaled bounding box of the block. */ function getScaledBboxOfBlock(block: BlockSvg): Rect { const blockSvg = block.getSvgRoot(); const scale = block.workspace.scale; const scaledHeight = block.height * scale; const scaledWidth = block.width * scale; const xy = style.getPageOffset(blockSvg); return new Rect(xy.y, xy.y + scaledHeight, xy.x, xy.x + scaledWidth); } /** * Get the scaled bounding box of a field. * * @param field The field. * @returns The scaled bounding box of the field. */ function getScaledBboxOfField(field: Field): Rect { const bBox = field.getScaledBBox(); return new Rect(bBox.top, bBox.bottom, bBox.left, bBox.right); } /** * Helper method to show and place the drop-down with positioning determined * by a scaled bounding box. The primary position will be below the rect, * and the secondary position above the rect. Drop-down will be constrained to * the block's workspace. * * @param bBox The scaled bounding box. * @param field The field to position the dropdown against. * @param opt_onHide Optional callback for when the drop-down is hidden. * @param opt_secondaryYOffset Optional Y offset for above-block positioning. * @returns True if the menu rendered below block; false if above. */ function showPositionedByRect( bBox: Rect, field: Field, opt_onHide?: Function, opt_secondaryYOffset?: number ): boolean { // If we can fit it, render below the block. const primaryX = bBox.left + (bBox.right - bBox.left) / 2; const primaryY = bBox.bottom; // If we can't fit it, render above the entire parent block. const secondaryX = primaryX; let secondaryY = bBox.top; if (opt_secondaryYOffset) { secondaryY += opt_secondaryYOffset; } const sourceBlock = field.getSourceBlock() as BlockSvg; // Set bounds to main workspace; show the drop-down. let workspace = sourceBlock.workspace; while (workspace.options.parentWorkspace) { workspace = workspace.options.parentWorkspace; } setBoundsElement(workspace.getParentSvg().parentNode as Element | null); return show( field, sourceBlock.RTL, primaryX, primaryY, secondaryX, secondaryY, opt_onHide ); } /** * Show and place the drop-down. * The drop-down is placed with an absolute "origin point" (x, y) - i.e., * the arrow will point at this origin and box will positioned below or above * it. If we can maintain the container bounds at the primary point, the arrow * will point there, and the container will be positioned below it. * If we can't maintain the container bounds at the primary point, fall-back to * the secondary point and position above. * * @param newOwner The object showing the drop-down * @param rtl Right-to-left (true) or left-to-right (false). * @param primaryX Desired origin point x, in absolute px. * @param primaryY Desired origin point y, in absolute px. * @param secondaryX Secondary/alternative origin point x, in absolute px. * @param secondaryY Secondary/alternative origin point y, in absolute px. * @param opt_onHide Optional callback for when the drop-down is hidden. * @returns True if the menu rendered at the primary origin point. * @internal */ export function show( newOwner: Field, rtl: boolean, primaryX: number, primaryY: number, secondaryX: number, secondaryY: number, opt_onHide?: Function ): boolean { owner = newOwner as Field; onHide = opt_onHide || null; // Set direction. div.style.direction = rtl ? 'rtl' : 'ltr'; const mainWorkspace = common.getMainWorkspace() as WorkspaceSvg; renderedClassName = mainWorkspace.getRenderer().getClassName(); themeClassName = mainWorkspace.getTheme().getClassName(); if (renderedClassName) { dom.addClass(div, renderedClassName); } if (themeClassName) { dom.addClass(div, themeClassName); } // When we change `translate` multiple times in close succession, // Chrome may choose to wait and apply them all at once. // Since we want the translation to initial X, Y to be immediate, // and the translation to final X, Y to be animated, // we saw problems where both would be applied after animation was turned on, // making the dropdown appear to fly in from (0, 0). // Using both `left`, `top` for the initial translation and then `translate` // for the animated transition to final X, Y is a workaround. return positionInternal(primaryX, primaryY, secondaryX, secondaryY); } const internal = { /** * Get sizing info about the bounding element. * * @returns An object containing size information about the bounding element * (bounding box and width/height). */ getBoundsInfo: function (): BoundsInfo { const boundPosition = style.getPageOffset(boundsElement as Element); const boundSize = style.getSize(boundsElement as Element); return { left: boundPosition.x, right: boundPosition.x + boundSize.width, top: boundPosition.y, bottom: boundPosition.y + boundSize.height, width: boundSize.width, height: boundSize.height, }; }, /** * Helper to position the drop-down and the arrow, maintaining bounds. * See explanation of origin points in show. * * @param primaryX Desired origin point x, in absolute px. * @param primaryY Desired origin point y, in absolute px. * @param secondaryX Secondary/alternative origin point x, in absolute px. * @param secondaryY Secondary/alternative origin point y, in absolute px. * @returns Various final metrics, including rendered positions for drop-down * and arrow. */ getPositionMetrics: function ( primaryX: number, primaryY: number, secondaryX: number, secondaryY: number ): PositionMetrics { const boundsInfo = internal.getBoundsInfo(); const divSize = style.getSize(div as Element); // Can we fit in-bounds below the target? if (primaryY + divSize.height < boundsInfo.bottom) { return getPositionBelowMetrics(primaryX, primaryY, boundsInfo, divSize); } // Can we fit in-bounds above the target? if (secondaryY - divSize.height > boundsInfo.top) { return getPositionAboveMetrics( secondaryX, secondaryY, boundsInfo, divSize ); } // Can we fit outside the workspace bounds (but inside the window) // below? if (primaryY + divSize.height < document.documentElement.clientHeight) { return getPositionBelowMetrics(primaryX, primaryY, boundsInfo, divSize); } // Can we fit outside the workspace bounds (but inside the window) // above? if (secondaryY - divSize.height > document.documentElement.clientTop) { return getPositionAboveMetrics( secondaryX, secondaryY, boundsInfo, divSize ); } // Last resort, render at top of page. return getPositionTopOfPageMetrics(primaryX, boundsInfo, divSize); }, }; /** * Get the metrics for positioning the div below the source. * * @param primaryX Desired origin point x, in absolute px. * @param primaryY Desired origin point y, in absolute px. * @param boundsInfo An object containing size information about the bounding * element (bounding box and width/height). * @param divSize An object containing information about the size of the * DropDownDiv (width & height). * @returns Various final metrics, including rendered positions for drop-down * and arrow. */ function getPositionBelowMetrics( primaryX: number, primaryY: number, boundsInfo: BoundsInfo, divSize: Size ): PositionMetrics { const xCoords = getPositionX( primaryX, boundsInfo.left, boundsInfo.right, divSize.width ); const arrowY = -(ARROW_SIZE / 2 + BORDER_SIZE); const finalY = primaryY + PADDING_Y; return { initialX: xCoords.divX, initialY: primaryY, finalX: xCoords.divX, // X position remains constant during animation. finalY, arrowX: xCoords.arrowX, arrowY, arrowAtTop: true, arrowVisible: true, }; } /** * Get the metrics for positioning the div above the source. * * @param secondaryX Secondary/alternative origin point x, in absolute px. * @param secondaryY Secondary/alternative origin point y, in absolute px. * @param boundsInfo An object containing size information about the bounding * element (bounding box and width/height). * @param divSize An object containing information about the size of the * DropDownDiv (width & height). * @returns Various final metrics, including rendered positions for drop-down * and arrow. */ function getPositionAboveMetrics( secondaryX: number, secondaryY: number, boundsInfo: BoundsInfo, divSize: Size ): PositionMetrics { const xCoords = getPositionX( secondaryX, boundsInfo.left, boundsInfo.right, divSize.width ); const arrowY = divSize.height - BORDER_SIZE * 2 - ARROW_SIZE / 2; const finalY = secondaryY - divSize.height - PADDING_Y; const initialY = secondaryY - divSize.height; // No padding on Y. return { initialX: xCoords.divX, initialY, finalX: xCoords.divX, // X position remains constant during animation. finalY, arrowX: xCoords.arrowX, arrowY, arrowAtTop: false, arrowVisible: true, }; } /** * Get the metrics for positioning the div at the top of the page. * * @param sourceX Desired origin point x, in absolute px. * @param boundsInfo An object containing size information about the bounding * element (bounding box and width/height). * @param divSize An object containing information about the size of the * DropDownDiv (width & height). * @returns Various final metrics, including rendered positions for drop-down * and arrow. */ function getPositionTopOfPageMetrics( sourceX: number, boundsInfo: BoundsInfo, divSize: Size ): PositionMetrics { const xCoords = getPositionX( sourceX, boundsInfo.left, boundsInfo.right, divSize.width ); // No need to provide arrow-specific information because it won't be visible. return { initialX: xCoords.divX, initialY: 0, finalX: xCoords.divX, // X position remains constant during animation. finalY: 0, // Y position remains constant during animation. arrowAtTop: null, arrowX: null, arrowY: null, arrowVisible: false, }; } /** * Get the x positions for the left side of the DropDownDiv and the arrow, * accounting for the bounds of the workspace. * * @param sourceX Desired origin point x, in absolute px. * @param boundsLeft The left edge of the bounding element, in absolute px. * @param boundsRight The right edge of the bounding element, in absolute px. * @param divWidth The width of the div in px. * @returns An object containing metrics for the x positions of the left side of * the DropDownDiv and the arrow. * @internal */ export function getPositionX( sourceX: number, boundsLeft: number, boundsRight: number, divWidth: number ): {divX: number; arrowX: number} { let divX = sourceX; // Offset the topLeft coord so that the dropdowndiv is centered. divX -= divWidth / 2; // Fit the dropdowndiv within the bounds of the workspace. divX = math.clamp(boundsLeft, divX, boundsRight - divWidth); let arrowX = sourceX; // Offset the arrow coord so that the arrow is centered. arrowX -= ARROW_SIZE / 2; // Convert the arrow position to be relative to the top left of the div. let relativeArrowX = arrowX - divX; const horizPadding = ARROW_HORIZONTAL_PADDING; // Clamp the arrow position so that it stays attached to the dropdowndiv. relativeArrowX = math.clamp( horizPadding, relativeArrowX, divWidth - horizPadding - ARROW_SIZE ); return {arrowX: relativeArrowX, divX}; } /** * Is the container visible? * * @returns True if visible. */ export function isVisible(): boolean { return !!owner; } /** * Hide the menu only if it is owned by the provided object. * * @param divOwner Object which must be owning the drop-down to hide. * @param opt_withoutAnimation True if we should hide the dropdown without * animating. * @returns True if hidden. */ export function hideIfOwner( divOwner: Field, opt_withoutAnimation?: boolean ): boolean { if (owner === divOwner) { if (opt_withoutAnimation) { hideWithoutAnimation(); } else { hide(); } return true; } return false; } /** Hide the menu, triggering animation. */ export function hide() { // Start the animation by setting the translation and fading out. // Reset to (initialX, initialY) - i.e., no translation. div.style.transform = 'translate(0, 0)'; div.style.opacity = '0'; // Finish animation - reset all values to default. animateOutTimer = setTimeout(function () { hideWithoutAnimation(); }, ANIMATION_TIME * 1000); if (onHide) { onHide(); onHide = null; } } /** Hide the menu, without animation. */ export function hideWithoutAnimation() { if (!isVisible()) { return; } if (animateOutTimer) { clearTimeout(animateOutTimer); } // Reset style properties in case this gets called directly // instead of hide() - see discussion on #2551. div.style.transform = ''; div.style.left = ''; div.style.top = ''; div.style.opacity = '0'; div.style.display = 'none'; div.style.backgroundColor = ''; div.style.borderColor = ''; if (onHide) { onHide(); onHide = null; } clearContent(); owner = null; if (renderedClassName) { dom.removeClass(div, renderedClassName); renderedClassName = ''; } if (themeClassName) { dom.removeClass(div, themeClassName); themeClassName = ''; } (common.getMainWorkspace() as WorkspaceSvg).markFocused(); } /** * Set the dropdown div's position. * * @param primaryX Desired origin point x, in absolute px. * @param primaryY Desired origin point y, in absolute px. * @param secondaryX Secondary/alternative origin point x, in absolute px. * @param secondaryY Secondary/alternative origin point y, in absolute px. * @returns True if the menu rendered at the primary origin point. */ function positionInternal( primaryX: number, primaryY: number, secondaryX: number, secondaryY: number ): boolean { const metrics = internal.getPositionMetrics( primaryX, primaryY, secondaryX, secondaryY ); // Update arrow CSS. if (metrics.arrowVisible) { arrow.style.display = ''; arrow.style.transform = 'translate(' + metrics.arrowX + 'px,' + metrics.arrowY + 'px) rotate(45deg)'; arrow.setAttribute( 'class', metrics.arrowAtTop ? 'blocklyDropDownArrow blocklyArrowTop' : 'blocklyDropDownArrow blocklyArrowBottom' ); } else { arrow.style.display = 'none'; } const initialX = Math.floor(metrics.initialX); const initialY = Math.floor(metrics.initialY); const finalX = Math.floor(metrics.finalX); const finalY = Math.floor(metrics.finalY); // First apply initial translation. div.style.left = initialX + 'px'; div.style.top = initialY + 'px'; // Show the div. div.style.display = 'block'; div.style.opacity = '1'; // Add final translate, animated through `transition`. // Coordinates are relative to (initialX, initialY), // where the drop-down is absolutely positioned. const dx = finalX - initialX; const dy = finalY - initialY; div.style.transform = 'translate(' + dx + 'px,' + dy + 'px)'; return !!metrics.arrowAtTop; } /** * Repositions the dropdownDiv on window resize. If it doesn't know how to * calculate the new position, it will just hide it instead. * * @internal */ export function repositionForWindowResize() { // This condition mainly catches the dropdown div when it is being used as a // dropdown. It is important not to close it in this case because on Android, // when a field is focused, the soft keyboard opens triggering a window resize // event and we want the dropdown div to stick around so users can type into // it. if (owner) { const block = owner.getSourceBlock() as BlockSvg; const bBox = positionToField ? getScaledBboxOfField(owner) : getScaledBboxOfBlock(block); // If we can fit it, render below the block. const primaryX = bBox.left + (bBox.right - bBox.left) / 2; const primaryY = bBox.bottom; // If we can't fit it, render above the entire parent block. const secondaryX = primaryX; const secondaryY = bBox.top; positionInternal(primaryX, primaryY, secondaryX, secondaryY); } else { hide(); } } export const TEST_ONLY = internal;