@eclipse-glsp/client
Version:
A sprotty-based client for GLSP
478 lines (411 loc) • 19.8 kB
text/typescript
/********************************************************************************
* Copyright (c) 2024 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/
import {
Bounds,
Dimension,
GModelElement,
GRoutingHandle,
Locateable,
Movement,
Point,
ResolvedElementMove,
TypeGuard,
Vector,
Writable,
hasBooleanProp,
hasObjectProp,
isBoundsAware,
isMoveable
} from '@eclipse-glsp/sprotty';
import { BoundsAwareModelElement, MoveableElement, ResizableModelElement, getElements } from '../../../utils/gmodel-util';
import { GResizeHandle, ResizeHandleLocation } from '../../change-bounds/model';
import { DiagramMovementCalculator } from '../../change-bounds/tracker';
import { ChangeBoundsManager } from './change-bounds-manager';
export interface ElementTrackingOptions {
/** Snap position. Default: true. */
snap: boolean | MouseEvent | KeyboardEvent | any;
/** Restrict position. Default: true */
restrict: boolean | MouseEvent | KeyboardEvent | any;
/** Validate operation. Default: true */
validate: boolean;
/** Skip operations that do not trigger change. Default: true */
skipStatic: boolean;
}
export interface MoveOptions extends ElementTrackingOptions {
/** Skip operations are invalid. Default: false */
skipInvalid: boolean;
}
export const DEFAULT_MOVE_OPTIONS: MoveOptions = {
snap: true,
restrict: true,
validate: true,
skipStatic: true,
skipInvalid: false
};
export type MoveableElements =
| MoveableElement[]
| {
ctx: GModelElement;
elementIDs: string[];
guard?: TypeGuard<MoveableElement>;
};
export interface TrackedElementMove extends ResolvedElementMove {
moveVector: Vector;
sourceVector: Vector;
valid: boolean;
}
export namespace TrackedElementMove {
export function is(obj: any): obj is TrackedElementMove {
return (
hasObjectProp(obj, 'element') &&
hasObjectProp(obj, 'fromPosition') &&
hasObjectProp(obj, 'toPosition') &&
hasBooleanProp(obj, 'valid')
);
}
}
export type TypedElementMove<T extends MoveableElement> = TrackedElementMove & { element: T };
export interface TrackedMove extends Movement {
elementMoves: TrackedElementMove[];
valid: boolean;
options: MoveOptions;
}
export namespace TrackedMove {
export function is(obj: any): obj is TrackedMove {
return Movement.is(obj) && hasBooleanProp(obj, 'valid');
}
}
export interface ResizeOptions extends ElementTrackingOptions {
/** Skip resizes that do not actually change the dimension of the element. Default: true. */
skipStatic: boolean;
/** Perform symmetric resize on the opposite side. Default: false. */
symmetric: boolean | MouseEvent | KeyboardEvent | any;
/**
* Avoids resizes smaller than the minimum size which will result in invalid sizes.
* Please note that the snapping will be applied before the constraining so an element may still be resized to an unsnapped size.
*
* Default: true.
*/
constrainResize: boolean;
/** Skip resizes that produce an invalid size. Default: false. */
skipInvalidSize: boolean;
/** Skip resizes that produce an invalid move. Default: false. */
skipInvalidMove: boolean;
}
export const DEFAULT_RESIZE_OPTIONS: ResizeOptions = {
snap: true,
restrict: true,
validate: true,
symmetric: true,
constrainResize: true,
skipStatic: true,
skipInvalidSize: false,
skipInvalidMove: false
};
export interface TrackedHandleMove extends TypedElementMove<MoveableResizeHandle> {}
export interface TrackedElementResize {
element: BoundsAwareModelElement;
fromBounds: Bounds;
toBounds: Bounds;
valid: {
size: boolean;
move: boolean;
};
}
export namespace TrackedElementResize {
export function is(obj: any): obj is TrackedElementResize {
return (
isBoundsAware(obj.element) && hasObjectProp(obj, 'fromBounds') && hasObjectProp(obj, 'toBounds') && hasObjectProp(obj, 'valid')
);
}
}
export interface TrackedResize extends Movement {
handleMove: TrackedHandleMove;
elementResizes: TrackedElementResize[];
valid: {
size: boolean;
move: boolean;
};
options: ResizeOptions;
}
export class ChangeBoundsTracker {
protected diagramMovement: DiagramMovementCalculator;
constructor(readonly manager: ChangeBoundsManager) {
this.diagramMovement = new DiagramMovementCalculator(manager.positionTracker);
}
startTracking(): this {
this.diagramMovement.init();
return this;
}
updateTrackingPosition(param: Vector | Movement | TrackedMove): void {
const update = TrackedMove.is(param) ? Vector.max(...param.elementMoves.map(move => move.moveVector)) : param;
this.diagramMovement.updatePosition(update);
}
isTracking(): boolean {
return this.diagramMovement.hasPosition;
}
stopTracking(): this {
this.diagramMovement.dispose();
return this;
}
//
// MOVE
//
moveElements(elements: MoveableElements, opts?: Partial<MoveOptions>): TrackedMove {
const options = this.resolveMoveOptions(opts);
const update = this.calculateDiagramMovement();
const move: TrackedMove = { ...update, elementMoves: [], valid: true, options };
if (Vector.isZero(update.vector) && options.skipStatic) {
// no movement detected so elements won't be moved, exit early
return move;
}
// calculate move for each element
const elementsToMove = this.getMoveableElements(elements, options);
for (const element of elementsToMove) {
const elementMove = this.calculateElementMove(element, update.vector, options);
if (!this.skipElementMove(elementMove, options)) {
move.elementMoves.push(elementMove);
move.valid &&= elementMove.valid;
}
}
return move;
}
protected resolveMoveOptions(opts?: Partial<MoveOptions>): MoveOptions {
return {
...DEFAULT_MOVE_OPTIONS,
...opts,
snap: this.manager.usePositionSnap(opts?.snap ?? DEFAULT_MOVE_OPTIONS.snap),
restrict: this.manager.useMovementRestriction(opts?.restrict ?? DEFAULT_MOVE_OPTIONS.restrict)
};
}
protected calculateDiagramMovement(): Movement {
return this.diagramMovement.calculateMoveToCurrent();
}
protected getMoveableElements(elements: MoveableElements, options: MoveOptions): MoveableElement[] {
return Array.isArray(elements) ? elements : getElements(elements.ctx.index, elements.elementIDs, elements.guard ?? isMoveable);
}
protected skipElementMove(elementMove: TrackedElementMove, options: MoveOptions): boolean {
return (options.skipInvalid && !elementMove.valid) || (options.skipStatic && Vector.isZero(elementMove.moveVector));
}
protected calculateElementMove<T extends MoveableElement>(element: T, vector: Vector, options: MoveOptions): TypedElementMove<T> {
const fromPosition = element.position;
const toPosition = Point.add(fromPosition, vector);
const move: TypedElementMove<T> = { element, fromPosition, toPosition, valid: true, moveVector: vector, sourceVector: vector };
if (options.snap) {
move.toPosition = this.snapPosition(move, options);
}
if (options.restrict) {
move.toPosition = this.restrictMovement(move, options);
}
if (options.validate) {
move.valid = this.validateElementMove(move, options);
}
move.moveVector = Point.vector(move.fromPosition, move.toPosition);
return move;
}
protected snapPosition(elementMove: TrackedElementMove, opts: MoveOptions): Point {
return this.manager.snapPosition(elementMove.element, elementMove.toPosition);
}
protected restrictMovement(elementMove: TrackedElementMove, opts: MoveOptions): Point {
const movement = Point.move(elementMove.fromPosition, elementMove.toPosition);
return this.manager.restrictMovement(elementMove.element, movement).to;
}
protected validateElementMove(elementMove: TrackedElementMove, opts: MoveOptions): boolean {
return this.manager.hasValidPosition(elementMove.element, elementMove.toPosition);
}
//
// RESIZE
//
resizeElements(handle: GResizeHandle, opts?: Partial<ResizeOptions>): TrackedResize {
const options = this.resolveResizeOptions(opts);
const update = this.calculateDiagramMovement();
const handleMove = this.calculateHandleMove(new MoveableResizeHandle(handle), update.vector, options);
const resize: TrackedResize = { ...update, valid: { move: true, size: true }, options, handleMove, elementResizes: [] };
if (Vector.isZero(handleMove.moveVector) && options.skipStatic) {
// no movement detected so elements won't be moved, exit early
return resize;
}
// calculate resize for each element (typically only one element is resized at a time but customizations are possible)
const elementsToResize = this.getResizableElements(handle, options);
for (const element of elementsToResize) {
const elementResize = this.calculateElementResize(element, handleMove, options);
if (!this.skipElementResize(elementResize, options)) {
resize.elementResizes.push(elementResize);
resize.valid.move = resize.valid.move && elementResize.valid.move;
resize.valid.size = resize.valid.size && elementResize.valid.size;
}
}
return resize;
}
protected resolveResizeOptions(opts?: Partial<ResizeOptions>): ResizeOptions {
return {
...DEFAULT_RESIZE_OPTIONS,
...opts,
snap: this.manager.usePositionSnap(opts?.snap ?? DEFAULT_RESIZE_OPTIONS.snap),
restrict: this.manager.useMovementRestriction(opts?.restrict ?? DEFAULT_RESIZE_OPTIONS.restrict),
symmetric: this.manager.useSymmetricResize(opts?.symmetric ?? DEFAULT_RESIZE_OPTIONS.symmetric)
};
}
protected calculateHandleMove(handle: MoveableResizeHandle, diagramMovement: Vector, opts?: Partial<ResizeOptions>): TrackedHandleMove {
const moveOptions = this.resolveMoveOptions({ ...opts, validate: false });
return this.calculateElementMove(handle, diagramMovement, moveOptions);
}
protected getResizableElements(handle: GResizeHandle, options: ResizeOptions): ResizableModelElement[] {
return [handle.parent];
}
protected skipElementResize(elementResize: TrackedElementResize, options: ResizeOptions): boolean {
return (
(options.skipInvalidMove && !elementResize.valid.move) ||
(options.skipInvalidSize && !elementResize.valid.size) ||
(options.skipStatic && Dimension.equals(elementResize.fromBounds, elementResize.toBounds))
);
}
protected calculateElementResize(
element: ResizableModelElement,
handleMove: TrackedHandleMove,
options: ResizeOptions
): TrackedElementResize {
const fromBounds = element.bounds;
const toBounds = this.calculateElementBounds(element, handleMove, options);
const resize: TrackedElementResize = { element, fromBounds, toBounds, valid: { size: true, move: true } };
if (options.validate) {
resize.valid.size = this.manager.hasValidSize(resize.element, resize.toBounds);
resize.valid.move = handleMove.valid && this.manager.hasValidPosition(resize.element, resize.toBounds);
}
return resize;
}
protected calculateElementBounds(element: ResizableModelElement, handleMove: TrackedHandleMove, options: ResizeOptions): Bounds {
let toBounds = this.calculateBounds(element.bounds, handleMove);
if (options.symmetric) {
const symmetricHandleMove = this.calculateSymmetricHandleMove(handleMove, options);
toBounds = this.calculateBounds(toBounds, symmetricHandleMove);
}
if (!options.constrainResize || this.manager.hasValidSize(element, toBounds)) {
return toBounds;
}
// we need to adjust to the minimum size but it is not enough to simply set the size
// we need to make sure that the element is still at the expected position
// we therefore constrain the movement vector to actually avoid going below the minimum size
const minimum = this.manager.getMinimumSize(element);
handleMove.moveVector = this.constrainResizeVector(element.bounds, handleMove, minimum);
if (options.symmetric) {
// if we have symmetric resize we want to distribute the constrained movement vector to both sides
// but only for the dimension that was actually resized beyond the minimum
handleMove.moveVector.x = element.bounds.width > minimum.width ? handleMove.moveVector.x / 2 : handleMove.moveVector.x;
handleMove.moveVector.y = element.bounds.height > minimum.height ? handleMove.moveVector.y / 2 : handleMove.moveVector.y;
}
toBounds = this.calculateBounds(element.bounds, handleMove);
if (options.symmetric) {
// since we already distributed the available movement vector, we do not want to snap the symmetric handle move
const symmetricHandleMove = this.calculateSymmetricHandleMove(handleMove, { ...options, snap: false });
toBounds = this.calculateBounds(toBounds, symmetricHandleMove);
}
return toBounds;
}
protected calculateSymmetricHandleMove(handleMove: TrackedHandleMove, options: ResizeOptions): TrackedHandleMove {
const moveOptions = this.resolveMoveOptions({ ...options, validate: false, restrict: false });
return this.calculateElementMove(handleMove.element.opposite(), Vector.reverse(handleMove.moveVector), moveOptions);
}
protected calculateBounds(src: Readonly<Bounds>, handleMove?: TrackedHandleMove): Bounds {
if (!handleMove || Vector.isZero(handleMove.moveVector)) {
return src;
}
return this.doCalculateBounds(src, handleMove.moveVector, handleMove.element.location);
}
protected doCalculateBounds(src: Readonly<Bounds>, vector: Vector, location: ResizeHandleLocation): Bounds {
switch (location) {
case ResizeHandleLocation.TopLeft:
return { x: src.x + vector.x, y: src.y + vector.y, width: src.width - vector.x, height: src.height - vector.y };
case ResizeHandleLocation.Top:
return { ...src, y: src.y + vector.y, height: src.height - vector.y };
case ResizeHandleLocation.TopRight:
return { ...src, y: src.y + vector.y, width: src.width + vector.x, height: src.height - vector.y };
case ResizeHandleLocation.Right:
return { ...src, width: src.width + vector.x };
case ResizeHandleLocation.BottomRight:
return { ...src, width: src.width + vector.x, height: src.height + vector.y };
case ResizeHandleLocation.Bottom:
return { ...src, height: src.height + vector.y };
case ResizeHandleLocation.BottomLeft:
return { ...src, x: src.x + vector.x, width: src.width - vector.x, height: src.height + vector.y };
case ResizeHandleLocation.Left:
return { ...src, x: src.x + vector.x, width: src.width - vector.x };
}
}
protected constrainResizeVector(src: Readonly<Bounds>, handleMove: TrackedHandleMove, minimum: Dimension): Vector {
const vector = handleMove.moveVector as Writable<Vector>;
switch (handleMove.element.location) {
case ResizeHandleLocation.TopLeft:
vector.x = src.width - vector.x < minimum.width ? src.width - minimum.width : vector.x;
vector.y = src.height - vector.y < minimum.height ? src.height - minimum.height : vector.y;
break;
case ResizeHandleLocation.Top:
vector.y = src.height - vector.y < minimum.height ? src.height - minimum.height : vector.y;
break;
case ResizeHandleLocation.TopRight:
vector.x = src.width + vector.x < minimum.width ? minimum.width - src.width : vector.x;
vector.y = src.height - vector.y < minimum.height ? src.height - minimum.height : vector.y;
break;
case ResizeHandleLocation.Right:
vector.x = src.width + vector.x < minimum.width ? minimum.width - src.width : vector.x;
break;
case ResizeHandleLocation.BottomRight:
vector.x = src.width + vector.x < minimum.width ? minimum.width - src.width : vector.x;
vector.y = src.height + vector.y < minimum.height ? minimum.height - src.height : vector.y;
break;
case ResizeHandleLocation.Bottom:
vector.y = src.height + vector.y < minimum.height ? minimum.height - src.height : vector.y;
break;
case ResizeHandleLocation.BottomLeft:
vector.x = src.width - vector.x < minimum.width ? src.width - minimum.width : vector.x;
vector.y = src.height + vector.y < minimum.height ? minimum.height - src.height : vector.y;
break;
case ResizeHandleLocation.Left:
vector.x = src.width - vector.x < minimum.width ? src.width - minimum.width : vector.x;
break;
}
return vector;
}
dispose(): void {
this.stopTracking();
}
}
export class MoveableResizeHandle extends GResizeHandle implements Locateable {
constructor(
protected handle: GResizeHandle,
override location: ResizeHandleLocation = handle.location,
readonly position = GResizeHandle.getHandlePosition(handle.parent, location)
) {
super(location, handle.type, handle.hoverFeedback);
this.id = handle.id;
// this only acts as a wrapper so we do not actually add this to the parent but still want the parent reference
(this as any).parent = handle.parent;
}
opposite(): MoveableResizeHandle {
return new MoveableResizeHandle(this.handle, ResizeHandleLocation.opposite(this.location));
}
}
export class MoveableRoutingHandle extends GRoutingHandle implements Locateable {
constructor(
protected handle: GRoutingHandle,
readonly position: Point
) {
super();
this.id = handle.id;
// this only acts as a wrapper so we do not actually add this to the parent but still want the parent reference
(this as any).parent = handle.parent;
}
}