@eclipse-scout/core
Version:
Eclipse Scout runtime
486 lines (428 loc) • 16.6 kB
text/typescript
/*
* Copyright (c) 2010, 2025 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 {Event, EventEmitter, EventMap, events, graphics, Insets, keys, Point, Rectangle, Session, Widget} from '../index';
import $ from 'jquery';
export class MoveSupport<TElem extends Widget> extends EventEmitter {
declare self: MoveSupport<TElem>;
declare eventMap: MoveSupportEventMap;
/**
* Minimal distance in pixels for a "mouse move" action to take effect.
* Prevents "mini jumps" when simply clicking on an element.
*/
mouseMoveThreshold: number;
/**
* The maximum size the clone should have. If it exceeds that size it will be scaled down.
*/
maxCloneSize: number;
/**
* Widget containing the draggable elements
*/
widget: Widget;
protected _moveData: MoveData<TElem>;
protected _animationDurationFactor: number;
protected _mouseMoveHandler: (event: JQuery.MouseMoveEvent) => void;
protected _mouseUpHandler: (event: JQuery.MouseUpEvent) => void;
protected _keyDownHandler: (event: KeyboardEvent) => void;
protected _releasingScrollHandler: (event: JQuery.ScrollEvent) => void;
/**
* @param widget the widget containing the draggable elements. Is used to automatically cancel the move operation when the widget is removed.
*/
constructor(widget: Widget) {
super();
this.maxCloneSize = 200;
this.mouseMoveThreshold = 7;
this.widget = widget;
this._moveData = null;
this._animationDurationFactor = 1; // for debugging to slow down the animation
this._mouseMoveHandler = this._onMouseMove.bind(this);
this._mouseUpHandler = this._onMouseUp.bind(this);
this._keyDownHandler = this._onKeyDown.bind(this);
this._releasingScrollHandler = this._onReleasingScroll.bind(this);
}
/**
* @return `true` if the dragging was started successfully, falsy otherwise.
*/
start(event: JQuery.MouseDownEvent, elements: TElem[], draggedElement: TElem): boolean {
if (this._moveData) {
// Do nothing, when dragging is already in progress. This can happen when the user leaves
// the browser window (e.g. using Alt-Tab) while holding the mouse button pressed and
// then returns and presses the mouse button again.
return;
}
if (draggedElement.$container.hasClass('dragged')) {
// If MoveSupport is created again for an already dragged element, do nothing. This makes sure the placeholder element cannot be dragged if clone is released and drag started right again
return;
}
if (!event || !elements || !draggedElement || !elements.includes(draggedElement) || !draggedElement.$container) {
return;
}
if (event.which !== 1) {
// Only accept left mouse button clicks (right one is reserved for context menu)
return;
}
events.fixTouchEvent(event);
this._initMoveData(event, elements, draggedElement);
$('iframe').addClass('dragging-in-progress');
// TODO CGU on touch devices it must be possible to scroll but also to drag the element -> drag should start not when pointer is moved but when touch is pressed down for some time
// Cancel moving when widget is removed
let handler = () => this.cancel();
this.widget.one('remove', handler);
this.one('cancel end', () => {
this.widget.off('remove', handler);
});
return true;
}
protected _initMoveData(event: JQuery.MouseDownEvent, elements: TElem[], draggedElement: TElem) {
let $window = draggedElement.$container.window();
let $elements = draggedElement.$container.parent();
this._moveData = {} as MoveData<TElem>;
this._moveData.session = draggedElement.session;
this._moveData.$window = $window;
this._moveData.$container = $elements;
this._moveData.containerBounds = graphics.offsetBounds($elements, {
includeMargin: true
});
this._moveData.elements = elements;
this._moveData.elementInfos = this._createElementInfos(elements, draggedElement);
this._moveData.startCursorPosition = new Point(
event.pageX - this._moveData.containerBounds.x,
event.pageY - this._moveData.containerBounds.y
);
this._moveData.currentCursorPosition = this._moveData.startCursorPosition;
// Compute distances from the cursor to the edges of the dragged element
let draggedElementInfo = this._moveData.draggedElementInfo;
this._moveData.cursorDistance = new Insets(
event.pageY - draggedElementInfo.bounds.y,
draggedElementInfo.bounds.x + draggedElementInfo.bounds.width - event.pageX,
draggedElementInfo.bounds.y + this._moveData.draggedElementInfo.bounds.height - event.pageY,
event.pageX - draggedElementInfo.bounds.x
);
this._moveData.$window
.off('mousemove touchmove', this._mouseMoveHandler)
.off('mouseup touchend touchcancel', this._mouseUpHandler)
.on('mousemove touchmove', this._mouseMoveHandler)
.on('mouseup touchend touchcancel', this._mouseUpHandler);
this._moveData.$window[0].removeEventListener('keydown', this._keyDownHandler, true);
this._moveData.$window[0].addEventListener('keydown', this._keyDownHandler, true);
}
protected _createElementInfos(elements: TElem[], draggedElement: TElem): DraggableElementInfo<TElem>[] {
return elements
.filter(element => !!element.$container)
.map((element, index) => {
// Collect various information about each element. This allows us to retrieve positions later on without
// needing to measure them each time the mouse cursor moves. We can also skip null checks for $element.
let $element = element.$container;
let info = {
element: element,
$element: $element
} as DraggableElementInfo<TElem>;
this._updateElementInfo(info);
if (element === draggedElement) {
this._moveData.draggedElementInfo = info;
this._moveData.$draggedElement = $element;
}
return info;
});
}
protected _updateElementInfo(elementInfo: DraggableElementInfo<TElem>) {
let $element = elementInfo.$element;
let bounds = graphics.offsetBounds($element);
let position = new Point(
bounds.x - this._moveData.containerBounds.x,
bounds.y - this._moveData.containerBounds.y
);
$.extend(elementInfo, {
position: position,
bounds: bounds
});
}
protected _updateElementInfos() {
this._moveData.elementInfos.forEach(info => this._updateElementInfo(info));
}
cancel() {
if (!this._moveData) {
return;
}
this._cleanup();
this._restoreStyles();
this._moveData = null;
this._cancel();
}
protected _restoreStyles() {
// Remove clone
this._moveData.$clone && this._moveData.$clone.remove();
// A done class makes it possible to disable transitions that must not be active while the clone will be swapped with the dragged element
this._moveData.$draggedElement.removeClass('dragged releasing');
this._moveData.$container.removeClass('dragging-element');
}
protected _onMouseMove(event: JQuery.MouseMoveEvent) {
events.fixTouchEvent(event);
this._updateOffsets();
this._moveData.currentCursorPosition = new Point(
event.pageX - this._moveData.containerBounds.x,
event.pageY - this._moveData.containerBounds.y
);
let distance = this._moveData.currentCursorPosition.subtract(this._moveData.startCursorPosition);
// Ignore small mouse movements
if (!this._moveData.moving) {
if (Math.abs(distance.x) < this.mouseMoveThreshold && Math.abs(distance.y) < this.mouseMoveThreshold) {
return;
}
this._moveData.moving = true;
this._onFirstMouseMove();
}
// Create a clone of the dragged element that is positioned 'fixed', i.e. with document-absolute coordinates
if (!this._moveData.$clone) {
this._moveData.cloneBounds = graphics.offsetBounds(this._moveData.$draggedElement);
this._moveData.cloneStartOffset = this._moveData.cloneBounds.point();
this._append$Clone();
// Change style of dragged element
this._moveData.$draggedElement.addClass('dragged');
}
// Update clone position
this._moveData.cloneBounds = this._moveData.cloneBounds.moveTo(this._moveData.cloneStartOffset.add(distance));
// Scale down clone if necessary
let scale = this._calculateScale();
this._moveData.$clone.css({
'top': this._moveData.cloneBounds.y,
'left': this._moveData.cloneBounds.x,
'--dragging-scale': scale,
'transform-origin': this._moveData.cursorDistance.left + 'px ' + this._moveData.cursorDistance.top + 'px'
});
// Don't change element order if the clone is outside the container area
if (!this._moveData.containerBounds.intersects(this._moveData.cloneBounds)) {
return;
}
this._drag(event);
}
protected _calculateScale(): number {
let scale = 1;
if (this._moveData.cloneBounds.width > this.maxCloneSize) {
scale = this.maxCloneSize / this._moveData.cloneBounds.width;
}
if (this._moveData.cloneBounds.height > this.maxCloneSize) {
scale = Math.min(this.maxCloneSize / this._moveData.cloneBounds.height, scale);
}
return scale;
}
/**
* Adjusts relative values if the panel has been scrolled while dragging (e.g. using the mouse wheel)
*/
protected _updateOffsets() {
let containerOffset = graphics.offset(this._moveData.$container);
if (!containerOffset.equals(this._moveData.containerBounds.point())) {
let diff = containerOffset.subtract(this._moveData.containerBounds.point());
this._moveData.containerBounds = this._moveData.containerBounds.translate(diff);
if (this._moveData.cloneStartOffset) {
this._moveData.cloneStartOffset = this._moveData.cloneStartOffset.add(diff);
}
this._moveData.elementInfos.forEach(info => {
info.bounds = info.bounds.translate(diff);
});
}
}
protected _drag(event: JQuery.MouseMoveEvent) {
this.trigger('drag');
}
protected _onFirstMouseMove() {
this._moveData.$container.addClass('dragging-element');
}
protected _append$Clone() {
let $clone = this._moveData.$draggedElement.clone()
.addClass('dragged-clone')
.removeAttr('data-id')
.css('position', 'fixed')
.appendTo(this._moveData.session.$entryPoint);
// Because the clone is added to the $entryPoint (to ensure it is drawn above everything else),
// the wheel events won't bubble to the container. To make the mouse work while dragging,
// we delegate the event manually.
$clone.on('wheel', event => this._moveData.$container.trigger(event));
// Clone canvas contents manually
let origCanvases = this._moveData.$draggedElement.find('canvas:visible') as JQuery<HTMLCanvasElement>;
$clone.find('canvas:visible').each((index, canvas: HTMLCanvasElement) => {
try {
canvas.getContext('2d').drawImage(origCanvases.get(index), 0, 0);
} catch (err) {
// Drawing on the canvas can throw unexpected errors, for example:
// "DOMException: Failed to execute 'drawImage' on 'CanvasRenderingContext2D':
// The image argument is a canvas element with a width or height of 0."
$.log.isWarnEnabled() && $.log.warn('Unable to clone canvas. Reason: ', err);
}
});
this._moveData.$clone = $clone;
this._moveData.$cloneShadow = this._moveData.$clone.prependDiv('shadow')
.animate({
opacity: 1
}, {
duration: 250 * this._animationDurationFactor
});
}
protected _onMouseUp(event: JQuery.MouseUpEvent) {
events.fixTouchEvent(event);
this._updateOffsets();
this._cleanup();
this._dragEnd(event)
.then(targetBounds => this._moveToTarget(targetBounds).then(() => targetBounds))
.then(targetBounds => {
this._restoreStyles();
if (!targetBounds.equals(this._moveData.draggedElementInfo.bounds)) {
this._moveEnd();
}
this._moveData = null;
this._end();
});
}
protected _onKeyDown(event: KeyboardEvent) {
if (event.which === keys.ESC) {
this.cancel();
event.stopPropagation();
}
}
protected _cleanup() {
this._moveData.$window
.off('mousemove touchmove', this._mouseMoveHandler)
.off('mouseup touchend touchcancel', this._mouseUpHandler);
this._moveData.$window[0].removeEventListener('keydown', this._keyDownHandler, true);
$('iframe').removeClass('dragging-in-progress');
}
protected _moveToTarget(targetBounds: Rectangle): JQuery.Promise<void> {
if (!this._moveData.$clone) {
return $.resolvedPromise();
}
// stop all animations in case of scroll (e.g. by mousewheel, page down etc.)
let $scrollParents = this._moveData.$draggedElement.scrollParents();
$scrollParents.on('scroll', this._releasingScrollHandler);
let promises = [];
this._moveData.$clone.addClass('releasing');
this._moveData.$draggedElement.addClass('releasing');
// Move clone to target position and restore original size
promises.push(this._moveData.$clone
.css('pointer-events', 'none')
.css('--dragging-scale', '1')
.animate({
top: targetBounds.y,
left: targetBounds.x,
width: targetBounds.width,
height: targetBounds.height
}, {
easing: 'easeOutQuart',
duration: 500 * this._animationDurationFactor
})
.promise());
// Fade out shadow
promises.push(this._moveData.$cloneShadow
.stop(true)
.animate({
opacity: 0
}, {
duration: 500 * this._animationDurationFactor
})
.promise());
return $.promiseAll(promises).then(() => {
$scrollParents.off('scroll', this._releasingScrollHandler);
});
}
protected _onReleasingScroll(event: JQuery.ScrollEvent) {
this._moveData.elementInfos.forEach(info => info.$element.stop(true, true));
this._moveData.$cloneShadow.stop(true, true);
this._moveData.$clone.stop(true, true);
}
/**
* @returns the target offset bounds to where the element should be moved
*/
protected _dragEnd(event: JQuery.MouseUpEvent): JQuery.Promise<Rectangle> {
let info = this._moveData.draggedElementInfo;
return $.resolvedPromise(new Rectangle(info.bounds.x, info.bounds.y, info.bounds.width, info.bounds.height));
}
protected _moveEnd() {
this.trigger('moveEnd');
}
protected _end() {
this.trigger('end');
}
protected _cancel() {
this.trigger('cancel');
}
}
/**
* Temporary data structure to store data while mouse actions are handled.
*/
export interface MoveData<TElem extends Widget> {
/**
* Distance from cursor to the edges of the dragged element.
*/
cursorDistance: Insets;
session: Session;
$window: JQuery<Window>;
/**
* The container containing the draggable elements
*/
$container: JQuery;
/**
* The offset bounds of the container;
*/
containerBounds: Rectangle;
/**
* The draggable elements.
*/
elements: TElem[];
/**
* Contains various information about each element.
*/
elementInfos: DraggableElementInfo<TElem>[];
/**
* Contains various information about the dragged element.
*/
draggedElementInfo: DraggableElementInfo<TElem>;
/**
* Points to draggedElementInfo.$element.
*/
$draggedElement: JQuery;
/**
* The position of the cursor when the dragging started.
*/
startCursorPosition: Point;
/**
* The current position of the cursor.
*/
currentCursorPosition: Point;
/**
* Whether an element is being moved.
*/
moving: boolean;
/**
* A clone of the dragged element that follows the cursor. The dragged element itself stays at its original position until it should be moved to a new location.
*/
$clone: JQuery;
/**
* A dedicated shadow element so it can be animated.
*/
$cloneShadow: JQuery;
cloneStartOffset: Point;
cloneBounds: Rectangle;
}
export interface DraggableElementInfo<TElem extends Widget> {
element: TElem;
$element: JQuery;
/**
* The relative position to the container.
*/
position: Point;
/**
* The size and absolute position (relative to the window).
*/
bounds: Rectangle;
}
export interface MoveSupportEventMap extends EventMap {
'drag': Event;
'moveEnd': Event;
'end': Event;
'cancel': Event;
}