gui/frontend/src/components/ui/html-helpers.ts (331 lines of code) (raw):

/* * Copyright (c) 2020, 2024, Oracle and/or its affiliates. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License, version 2.0, * as published by the Free Software Foundation. * * This program is designed to work with certain software (including * but not limited to OpenSSL) that is licensed under separate terms, as * designated in a particular file or component or in included license * documentation. The authors of MySQL hereby grant you an additional * permission to link the program and your derivative works with the * separately licensed software that they have either included with * the program or referenced in the documentation. * * This program is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See * the GNU General Public License, version 2.0, for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA */ import { ComponentPlacement } from "./Component/ComponentBase.js"; // Helper functions required for direct work with HTML DOM elements. // Testing notes: These functions are not covered by unit tests, as they require a browser environment. /** * Computes the target coordinates for the content element for a given placement. * * @param placement The position where to place the given content relative to the reference element. * @param content The element for which the position is to be computed. * @param reference The area relative to which the content must be placed. * @param offset An additional distance between content and reference elements. * * @returns Left and top values in the coordinate system of the parent of the reference element. */ // istanbul ignore next const computePositionForPlacement = (placement: ComponentPlacement, content: HTMLElement, reference: DOMRect, offset: number): { left: number; top: number; } => { const contentBounds = content.getBoundingClientRect(); let left = 0; let top = 0; switch (placement) { case ComponentPlacement.TopLeft: { left = reference.left; top = reference.top - contentBounds.height - offset; break; } case ComponentPlacement.TopCenter: { left = (reference.left + reference.right) / 2 - contentBounds.width / 2; top = reference.top - contentBounds.height - offset; break; } case ComponentPlacement.TopRight: { left = reference.right - contentBounds.width; top = reference.top - contentBounds.height - offset; break; } case ComponentPlacement.RightTop: { top = reference.top; left = reference.left + reference.width + offset; break; } case ComponentPlacement.RightCenter: { top = (reference.top + reference.bottom) / 2 - contentBounds.height / 2; left = reference.left + reference.width + offset; break; } case ComponentPlacement.RightBottom: { top = reference.top + reference.height - contentBounds.height; left = reference.left + reference.width + offset; break; } case ComponentPlacement.BottomLeft: { left = reference.left; top = reference.top + reference.height + offset; break; } case ComponentPlacement.BottomCenter: { left = (reference.left + reference.right) / 2 - contentBounds.width / 2; top = reference.top + reference.height + offset; break; } case ComponentPlacement.BottomRight: { left = reference.right - contentBounds.width; top = reference.top + reference.height + offset; break; } case ComponentPlacement.LeftTop: { top = reference.top; left = reference.left - contentBounds.width - offset; break; } case ComponentPlacement.LeftCenter: { top = (reference.top + reference.bottom) / 2 - contentBounds.height / 2; left = reference.left - contentBounds.width - offset; break; } case ComponentPlacement.LeftBottom: { top = reference.top + reference.height - contentBounds.height; left = reference.left - contentBounds.width - offset; break; } default: { break; } } return { left, top }; }; /** * Determines the top-left position of the content element, depending on the specified placement under consideration * of possible overlaps with the bounds of the given outer container. * * @param placement The position where to place the given content. * @param content The element for which the position is to be computed. * @param reference The area relative to which the content must be placed. * @param offset An additional distance between content and reference elements. * @param canFlip A flag to tell if the position can automatically be changed to the opposite if there's not enough * space. * @param container Optional element to test against overlaps (default is the body element). Only useful when * canFlip is true. * * @returns Left and top values in the coordinate system of the parent of the reference element. */ // istanbul ignore next export const computeContentPosition = (placement: ComponentPlacement, content: HTMLElement, reference: DOMRect, offset: number, canFlip: boolean, container: HTMLElement = document.body): { left: number; top: number; } => { let { left, top } = computePositionForPlacement(placement, content, reference, offset); const { width, height } = content.getBoundingClientRect(); const containerBounds = container.getBoundingClientRect(); const right = left + width; const bottom = top + height; if (canFlip) { // Automatically flip or shift the popup to avoid container overlap. // The closures return true if the popup was flipped, which requires to update the placement class. const checkTop = (newPlacement: ComponentPlacement): boolean => { if (top < 0) { const { top: tc } = computePositionForPlacement(newPlacement, content, reference, offset); if (tc + height > containerBounds.bottom) { // Would move the popup too far down, so move it to the top of the container. top = 0; } else { top = tc; return true; } } return false; }; const checkRight = (newPlacement: ComponentPlacement): boolean => { if (right > containerBounds.right) { const { left: lc } = computePositionForPlacement(newPlacement, content, reference, offset); if (lc < 0) { // Would move the popup too far left, so move it to the right edge of the container. left = containerBounds.right - width; } else { left = lc; return true; } } return false; }; const checkBottom = (newPlacement: ComponentPlacement): boolean => { if (bottom > containerBounds.bottom) { const { top: tc } = computePositionForPlacement(newPlacement, content, reference, offset); if (tc < 0) { top = containerBounds.bottom - height; } else { top = tc; return true; } } return false; }; const checkLeft = (newPlacement: ComponentPlacement): boolean => { if (left < 0) { const { left: lc } = computePositionForPlacement(newPlacement, content, reference, offset); if (lc + width > containerBounds.right) { left = containerBounds.right - width; } else { left = lc; return true; } } return false; }; /** * Updates the popup's placement class depending on the previous flip check. * * @param flipH The popup was horizontally flipped. * @param flipV The popup was vertically flipped. * @param flippedBoth The new placement if both directions were flipped. * @param flippedH Ditto for horizontal only. * @param flippedV Ditto for vertical only. */ const handleFlip = (flipH: boolean, flipV: boolean, flippedBoth: ComponentPlacement | null, flippedH: ComponentPlacement | null, flippedV: ComponentPlacement | null): void => { if (flipH || flipV) { let newClass = ""; if (flipH && flipV) { newClass = flippedBoth!; } else { if (flipH) { newClass = flippedH!; } else { newClass = flippedV!; } } content.classList.remove(placement); content.classList.add(newClass); } }; switch (placement) { case ComponentPlacement.TopLeft: { handleFlip( checkRight(ComponentPlacement.TopRight), checkTop(ComponentPlacement.BottomLeft), ComponentPlacement.BottomRight, ComponentPlacement.TopRight, ComponentPlacement.BottomLeft, ); break; } case ComponentPlacement.TopCenter: { handleFlip( false, checkTop(ComponentPlacement.BottomCenter), null, null, ComponentPlacement.BottomCenter, ); break; } case ComponentPlacement.TopRight: { handleFlip( checkLeft(ComponentPlacement.TopLeft), checkTop(ComponentPlacement.BottomRight), ComponentPlacement.BottomLeft, ComponentPlacement.TopLeft, ComponentPlacement.BottomRight, ); break; } case ComponentPlacement.RightTop: { handleFlip( checkRight(ComponentPlacement.LeftTop), checkBottom(ComponentPlacement.RightBottom), ComponentPlacement.LeftBottom, ComponentPlacement.LeftTop, ComponentPlacement.RightBottom, ); break; } case ComponentPlacement.RightCenter: { handleFlip( checkRight(ComponentPlacement.LeftCenter), false, null, ComponentPlacement.LeftCenter, null, ); break; } case ComponentPlacement.RightBottom: { handleFlip( checkRight(ComponentPlacement.LeftBottom), checkTop(ComponentPlacement.RightTop), ComponentPlacement.LeftTop, ComponentPlacement.LeftBottom, ComponentPlacement.RightTop, ); break; } case ComponentPlacement.BottomLeft: { handleFlip( checkRight(ComponentPlacement.BottomRight), checkBottom(ComponentPlacement.TopLeft), ComponentPlacement.TopRight, ComponentPlacement.BottomRight, ComponentPlacement.TopLeft, ); break; } case ComponentPlacement.BottomCenter: { handleFlip( false, checkBottom(ComponentPlacement.TopCenter), null, null, ComponentPlacement.TopCenter, ); break; } case ComponentPlacement.BottomRight: { handleFlip( checkLeft(ComponentPlacement.BottomLeft), checkBottom(ComponentPlacement.TopRight), ComponentPlacement.TopLeft, ComponentPlacement.BottomLeft, ComponentPlacement.TopRight, ); break; } case ComponentPlacement.LeftTop: { handleFlip( checkLeft(ComponentPlacement.RightTop), checkBottom(ComponentPlacement.LeftBottom), ComponentPlacement.RightBottom, ComponentPlacement.RightTop, ComponentPlacement.LeftBottom, ); break; } case ComponentPlacement.LeftCenter: { handleFlip( checkLeft(ComponentPlacement.RightCenter), false, null, ComponentPlacement.RightCenter, null); break; } case ComponentPlacement.LeftBottom: { handleFlip( checkLeft(ComponentPlacement.RightBottom), checkTop(ComponentPlacement.LeftTop), ComponentPlacement.RightTop, ComponentPlacement.RightBottom, ComponentPlacement.LeftTop, ); break; } default: { break; } } } return { left, top }; }; /** * Checks if the given element is clipped by any of its parent elements. * * @param element The element to check. * * @param checkVertical If true, the function checks for vertical clipping. * @param checkHorizontal If true, the function checks for horizontal clipping. * @returns True if the element is clipped, false otherwise. */ // istanbul ignore next export const isElementClipped = (element: HTMLElement, checkVertical: boolean, checkHorizontal: boolean): boolean => { const elementRect = element.getBoundingClientRect(); let parent = element.parentElement; while (parent) { const parentRect = parent.getBoundingClientRect(); if (checkVertical && (elementRect.top < parentRect.top || elementRect.bottom > parentRect.bottom)) { return true; } if (checkHorizontal && (elementRect.left < parentRect.left || elementRect.right > parentRect.right)) { return true; } parent = parent.parentElement; } return false; }; /** * Checks if the given element has the nowrap style. * * @param element The element to check. * * @returns True if the element has the nowrap style, false otherwise. */ // istanbul ignore next export const hasNoWrap = (element: HTMLElement): boolean => { const style = window.getComputedStyle(element); return style.whiteSpace === "nowrap"; }; /** * Convert document (actually: viewport) coordinates to screen coordinates. * * @param x The x-coordinate in the document. * @param y The y-coordinate in the document. * * @returns The x and y coordinates in the screen coordinate system. */ // istanbul ignore next export const documentToScreen = (x: number, y: number): [number, number] => { const screenX = x + window.scrollX + window.screenX; const screenY = y + window.scrollY + window.screenY; return [screenX, screenY]; }; /** * Convert screen coordinates to document (viewport) coordinates. * * @param x The x-coordinate in the screen coordinate system. * @param y The y-coordinate in the screen coordinate system. * * @returns The x and y coordinates in the document. */ // istanbul ignore next export const screenToDocument = (x: number, y: number): [number, number] => { const documentX = x - window.scrollX - window.screenX; const documentY = y - window.scrollY - window.screenY; return [documentX, documentY]; }; /** * Clamps the given coordinates to the document bounds, considering also the screen position of the browser window. * Unfortunately, screen clamping only works for the primary screen. * * @param bounds The bounds of the element to clamp. * @param offsetX An additional offset for the x-coordinate. * @param offsetY An additional offset for the y-coordinate. * * @returns The clamped x and y coordinates. */ // istanbul ignore next export const clampToDocument = (bounds: DOMRect, offsetX: number, offsetY: number): [number, number] => { // First compute the new x and y coordinates within the document bounds. const newX = Math.min(Math.max(bounds.left + offsetX, 0), window.innerWidth - bounds.width + offsetX); const newY = Math.min(Math.max(bounds.top + offsetY, 0), window.innerHeight - bounds.height + offsetY); // Then clamp the new coordinates to the screen bounds. let [screenX, screenY] = documentToScreen(newX, newY); screenX = Math.min(Math.max(screenX, 0), window.screen.availWidth - bounds.width + offsetX); screenY = Math.min(Math.max(screenY, 0), window.screen.availHeight - bounds.height + offsetY); return screenToDocument(screenX, screenY); }; /** * Computes the dimensions of the given content with the given style, which can include font, size and border values. * * @param content The text to measure. * @param style The style to apply to the text. * * @returns A tuple with the computed width and height of the content. */ export const computeBounds = (content: string, style: Partial<CSSStyleDeclaration>): [number, number] => { // Create a new element, set its properties to the given style and measure the text. const element = document.createElement("div"); element.style.position = "absolute"; element.style.visibility = "hidden"; element.style.whiteSpace = style.whiteSpace ?? "nowrap"; element.style.width = style.width ?? "fit-content"; element.style.maxWidth = style.maxWidth ?? "800px"; element.style.height = style.height ?? "fit-content"; element.style.maxHeight = style.maxHeight ?? "500px"; element.style.padding = style.padding ?? "6px"; element.style.font = style.font ?? ""; element.style.fontWeight = style.fontWeight ?? ""; element.style.fontStyle = style.fontStyle ?? ""; element.style.lineHeight = style.lineHeight ?? ""; element.innerText = content; document.body.appendChild(element); const width = element.offsetWidth; const height = element.offsetHeight; document.body.removeChild(element); return [width, height]; };