UNPKG

@eclipse-scout/core

Version:
351 lines (318 loc) 12.9 kB
/* * Copyright (c) 2010, 2024 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ import {arrays, EnumObject, graphics, InitModelOf, Insets, keys, ObjectWithType, Point, Rectangle, ResizableModel, scout, SomeRequired} from '../index'; import $ from 'jquery'; import MouseDownEvent = JQuery.MouseDownEvent; import MouseUpEvent = JQuery.MouseUpEvent; import MouseMoveEvent = JQuery.MouseMoveEvent; /** * Resizable makes a DOM element resizable by adding resize handlers to all edges of the given model.$container. * The following events are triggered on the DOM element: * - resizeStep: triggered during resizing. * - resizeEnd: triggered when resizing ends. */ export class Resizable implements ResizableModel, ObjectWithType { declare model: ResizableModel; declare initModel: SomeRequired<this['model'], '$container'>; objectType: string; modes: ResizableMode[]; boundaries: Insets; useOverlay: boolean; $container: JQuery; $window: JQuery<Window>; $resizableS: JQuery; $resizableE: JQuery; $resizableSE: JQuery; $resizableW: JQuery; $resizableSW: JQuery; $resizableN: JQuery; $resizableNW: JQuery; $resizableNE: JQuery; $resizingOverlay: JQuery; protected _context: ResizableContext; protected _mouseDownHandler: (event: MouseDownEvent) => void; protected _mouseUpHandler: (event: MouseUpEvent) => void; protected _mouseMoveHandler: (event: MouseMoveEvent) => void; protected _keyDownHandler: (event: KeyboardEvent) => void; protected _resizeHandler: (newBounds: Rectangle) => void; constructor() { this._mouseDownHandler = this._onMouseDown.bind(this); this._mouseUpHandler = this._onMouseUp.bind(this); this._mouseMoveHandler = this._onMouseMove.bind(this); this._keyDownHandler = this._onKeyDown.bind(this); this._resizeHandler = this._resize.bind(this); } static MODES = { SOUTH: 's', EAST: 'e', WEST: 'w', NORTH: 'n' } as const; init(model: InitModelOf<Resizable>) { scout.assertParameter('model', model); scout.assertParameter('$container', model.$container); this.$container = model.$container; this.$window = model.$container.window(); this.useOverlay = scout.nvl(model.useOverlay, false); this.$container.addClass('resizable'); this._appendResizeHandles(); this.setModes(model.modes); this.setBoundaries(model.boundaries); this._installRemoveHandler(); } setModes(modes?: ResizableMode[]) { let ensuredModes = modes || [Resizable.MODES.SOUTH, Resizable.MODES.EAST, Resizable.MODES.WEST, Resizable.MODES.NORTH]; if (arrays.equals(ensuredModes, this.modes)) { return; } this.modes = ensuredModes; this._calculateResizeHandlersVisibility(); } setBoundaries(boundaries?: Insets) { this.boundaries = $.extend(new Insets(), boundaries); if (this._boundaryValueSet(this.boundaries.left)) { this.boundaries.left -= this.$container.cssMarginLeft(); } if (this._boundaryValueSet(this.boundaries.right)) { this.boundaries.right -= this.$container.cssMarginRight(); } if (this._boundaryValueSet(this.boundaries.top)) { this.boundaries.top -= this.$container.cssMarginTop(); } if (this._boundaryValueSet(this.boundaries.bottom)) { this.boundaries.bottom -= this.$container.cssMarginBottom(); } } protected _appendResizeHandles() { this.$resizableS = this._appendResizeHandle('s'); this.$resizableE = this._appendResizeHandle('e'); this.$resizableSE = this._appendResizeHandle('se'); this.$resizableW = this._appendResizeHandle('w'); this.$resizableSW = this._appendResizeHandle('sw'); this.$resizableN = this._appendResizeHandle('n'); this.$resizableNW = this._appendResizeHandle('nw'); this.$resizableNE = this._appendResizeHandle('ne'); } protected _appendResizeHandle(edge: ResizableEdge): JQuery { return this.$container.appendDiv(`resizable-handle resizable-${edge}`) .data('edge', edge) .on('mousedown', this._mouseDownHandler); } protected _calculateResizeHandlersVisibility() { this.$resizableS.setVisible(this._hasMode(Resizable.MODES.SOUTH)); this.$resizableE.setVisible(this._hasMode(Resizable.MODES.EAST)); this.$resizableSE.setVisible(this._hasMode(Resizable.MODES.SOUTH) && this._hasMode(Resizable.MODES.EAST)); this.$resizableW.setVisible(this._hasMode(Resizable.MODES.WEST)); this.$resizableSW.setVisible(this._hasMode(Resizable.MODES.SOUTH) && this._hasMode(Resizable.MODES.WEST)); this.$resizableN.setVisible(this._hasMode(Resizable.MODES.NORTH)); this.$resizableNW.setVisible(this._hasMode(Resizable.MODES.NORTH) && this._hasMode(Resizable.MODES.WEST)); this.$resizableNE.setVisible(this._hasMode(Resizable.MODES.NORTH) && this._hasMode(Resizable.MODES.EAST)); } protected _hasMode(mode: ResizableMode): boolean { return this.modes.some(m => m === mode); } protected _installRemoveHandler() { this.$container.on('remove', this.destroy.bind(this)); } destroy() { this.$resizableS.remove(); this.$resizableE.remove(); this.$resizableSE.remove(); this.$resizableW.remove(); this.$resizableSW.remove(); this.$resizableN.remove(); this.$resizableNW.remove(); this.$resizableNE.remove(); this._cleanup(); } protected _onMouseDown(event: MouseDownEvent) { if (event.which !== 1 || this._context) { // Only accept left mouse button clicks (right one is reserved for context menu) // Also ensure resizing cannot be started twice (e.g. by pressing alt-tab, releasing the mouse button and pressing it again on the resizable) return; } let $resizable = this.$container; let $myWindow = $resizable.window(); let $handle = $(event.target); let minWidth = $resizable.cssMinWidth(); let minHeight = $resizable.cssMinHeight(); let maxWidth = $resizable.cssMaxWidth(); let maxHeight = $resizable.cssMaxHeight(); let $offsetParent = $resizable.offsetParent(); let initialBounds = graphics.bounds($resizable, {exact: true}) .translate(new Point($offsetParent[0].scrollLeft, $offsetParent[0].scrollTop)); this._context = { initialBounds: initialBounds, currentBounds: initialBounds.clone(), minBounds: new Rectangle( initialBounds.right() - minWidth, initialBounds.bottom() - minHeight, minWidth, minHeight ), maxBounds: new Rectangle( Math.max(-$resizable.cssMarginLeft(), initialBounds.right() - maxWidth), Math.max(-$resizable.cssMarginTop(), initialBounds.bottom() - maxHeight), Math.min($myWindow.width() - $resizable[0].offsetLeft, maxWidth), Math.min($myWindow.height() - $resizable[0].offsetTop, maxHeight) ), distance: [0, 0], edge: $handle.data('edge'), mousedownEvent: event }; if (this.useOverlay) { this.$resizingOverlay = $resizable.parent().appendDiv('resizing-overlay'); this.$resizingOverlay.css('border-radius', $resizable.css('border-radius')); this.$resizingOverlay.css('border-color', $resizable.css('--resizable-color')); graphics.setBounds(this.$resizingOverlay, initialBounds); } $resizable.addClass('resizing'); this.$window .off('mouseup', this._mouseUpHandler) .off('mousemove', this._mouseMoveHandler) .on('mouseup', this._mouseUpHandler) .on('mousemove', this._mouseMoveHandler) .body().addClass(`${this._context.edge}-resize`); this.$window[0].removeEventListener('keydown', this._keyDownHandler, true); this.$window[0].addEventListener('keydown', this._keyDownHandler, true); $('iframe').addClass('dragging-in-progress'); } protected _onMouseUp(event: MouseUpEvent) { this.finish(); } /** * Finishes the resizing by cleaning up all temporary states and triggering the `resizeEnd` event with the initial and new bounds. */ finish() { this._cleanup(); this._resizeEnd(); this._context = null; } /** * Cancels the resizing if it is in progress. */ cancel() { if (!this._context) { return; } this._context.currentBounds = this._context.initialBounds; this.finish(); } protected _onKeyDown(event: KeyboardEvent) { if (event.which === keys.ESC) { this.cancel(); event.stopPropagation(); } } protected _cleanup() { this.$container.removeClass('resizing'); if (this.$resizingOverlay) { this.$resizingOverlay.remove(); this.$resizingOverlay = null; } this.$window .off('mouseup', this._mouseUpHandler) .off('mousemove', this._mouseMoveHandler); this.$window[0].removeEventListener('keydown', this._keyDownHandler, true); if (this._context) { this.$window.body().removeClass(`${this._context.edge}-resize`); } $('iframe').removeClass('dragging-in-progress'); } protected _onMouseMove(event: MouseMoveEvent) { let newBounds = this._computeBounds(event); if (newBounds) { this._resizeHandler(newBounds); } } protected _computeBounds(event: MouseMoveEvent) { let ctx = this._context; let newBounds = ctx.initialBounds.clone(); let distance = this._calcDistance(ctx.mousedownEvent, event); if (scout.isOneOf(ctx.edge, 'ne', 'e', 'se')) { newBounds.width = Math.max(ctx.minBounds.width, Math.min(ctx.maxBounds.width, ctx.initialBounds.width + distance[0])); } else if (scout.isOneOf(ctx.edge, 'nw', 'w', 'sw')) { // Resize to the left newBounds.x = Math.min(ctx.minBounds.x, Math.max(ctx.maxBounds.x, ctx.initialBounds.x + distance[0])); newBounds.width += ctx.initialBounds.x - newBounds.x; } if (scout.isOneOf(ctx.edge, 'sw', 's', 'se')) { newBounds.height = Math.max(ctx.minBounds.height, Math.min(ctx.maxBounds.height, ctx.initialBounds.height + distance[1])); } else if (scout.isOneOf(ctx.edge, 'nw', 'n', 'ne')) { // Resize to the bottom newBounds.y = Math.min(ctx.minBounds.y, Math.max(ctx.maxBounds.y, ctx.initialBounds.y + distance[1])); newBounds.height += ctx.initialBounds.y - newBounds.y; } return newBounds; } protected _resize(newBounds: Rectangle) { this._cropToBoundaries(newBounds); if (this._context.currentBounds.equals(newBounds)) { return; } this._context.currentBounds = newBounds; if (this.useOverlay) { graphics.setBounds(this.$resizingOverlay, newBounds); } else { graphics.setBounds(this.$container, newBounds); } // 'resize' would be a better name, but it is a native event triggered by the browser when the window is resized // To not accidentally trigger event handlers listening for window resizes, another name is used. this.$container.trigger('resizeStep', { newBounds: newBounds, initialBounds: this._context.initialBounds }); } protected _resizeEnd() { if (this._context.currentBounds.equals(this._context.initialBounds)) { return; } this.$container.trigger('resizeEnd', { newBounds: this._context.currentBounds, initialBounds: this._context.initialBounds }); } protected _cropToBoundaries(newBounds: Rectangle) { if (this._boundaryValueSet(this.boundaries.left) && newBounds.x > this.boundaries.left) { newBounds.width -= (this.boundaries.left - newBounds.x); newBounds.x = this.boundaries.left; } if (this._boundaryValueSet(this.boundaries.right) && (newBounds.x + newBounds.width) < this.boundaries.right) { newBounds.width = this.boundaries.right - newBounds.x; } if (this._boundaryValueSet(this.boundaries.top) && newBounds.y > this.boundaries.top) { newBounds.height -= (this.boundaries.top - newBounds.y); newBounds.y = this.boundaries.top; } if (this._boundaryValueSet(this.boundaries.bottom) && (newBounds.y + newBounds.height) < this.boundaries.bottom) { newBounds.height = this.boundaries.bottom - newBounds.y; } } protected _boundaryValueSet(value: number): boolean { return value > 0; } protected _calcDistance(eventA: MouseDownEvent, eventB: MouseMoveEvent): number[] { let distX = eventB.pageX - eventA.pageX, distY = eventB.pageY - eventA.pageY; return [distX, distY]; } } export interface ResizableContext { initialBounds: Rectangle; currentBounds: Rectangle; minBounds: Rectangle; maxBounds: Rectangle; distance: number[]; edge: ResizableEdge; mousedownEvent: MouseDownEvent; } export type ResizableMode = EnumObject<typeof Resizable.MODES>; export type ResizableEdge = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw';