@eclipse-glsp/client
Version:
A sprotty-based client for GLSP
493 lines (440 loc) • 18.4 kB
text/typescript
/********************************************************************************
* Copyright (c) 2019-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 {
Action,
ChangeBoundsOperation,
Dimension,
ElementAndBounds,
ElementMove,
GModelElement,
IActionDispatcher,
IActionHandler,
ICommand,
MoveAction,
Point,
SetBoundsAction,
TYPES,
Writable,
hasArrayProp,
hasNumberProp,
hasStringProp
} from '@eclipse-glsp/sprotty';
import { inject, injectable, optional } from 'inversify';
import { SelectionService } from '../../base/selection-service';
import { BoundsAwareModelElement, getElements } from '../../utils/gmodel-util';
import { toValidElementAndBounds, toValidElementMove } from '../../utils/layout-utils';
import { isBoundsAwareMoveable, isResizable } from '../change-bounds/model';
import { IMovementRestrictor } from '../change-bounds/movement-restrictor';
/**
* Used to specify the desired resize dimension for a {@link ResizeElementsCommand}.
*/
export enum ResizeDimension {
Width,
Height,
Width_And_Height
}
/**
* A function that computes a single number from a set of given numbers
* i.e. it reduces the given numbers to one single number.
* Mainly used by the {@link ResizeElementsCommand} to reduce the given dimensions to a target dimension value.
*/
export type ReduceFunction = (...values: number[]) => number;
export namespace ReduceFunction {
/**
* Returns the minimal value of the given numbers.
* @param values Numbers to be evaluated.
* @returns The reduced number.
*/
export function min(...values: number[]): number {
return Math.min(...values);
}
/**
* Returns the maximal value of the given numbers.
* @param values Numbers to be evaluated.
* @returns The reduced number.
*/
export function max(...values: number[]): number {
return Math.max(...values);
}
/**
* Computes the average of the given numbers.
* @param values Numbers to be evaluated.
*/
export function avg(...values: number[]): number {
return values.reduce((a, b) => a + b, 0) / values.length;
}
/**
* Returns the last value of the given numbers.
* @param values Numbers to be evaluated.
* @returns The reduced number.
*/
export function first(...values: number[]): number {
return values[0];
}
/**
* Returns the minimal value of the given numbers.
* @param values Numbers to be evaluated.
* @returns The reduced number.
*/
export function last(...values: number[]): number {
return values[values.length - 1];
}
/**
* Returns the reduce function that corresponds to the given {@link ReduceFunctionType}.
* @param type The reduce function kind.
* @returns The corresponding reduce function.
*/
export function get(type: ReduceFunctionType): ReduceFunction {
return ReduceFunction[type];
}
}
/** Union type of all {@link ReduceFunction} keys. */
export type ReduceFunctionType = Exclude<keyof typeof ReduceFunction, 'get'>;
export interface ResizeElementsAction extends Action {
kind: typeof ResizeElementsAction.KIND;
/**
* IDs of the elements that should be resized. If no IDs are given, the selected elements will be resized.
*/
elementIds: string[];
/**
* Resize dimension. The default is {@link ResizeDimension.Width}.
*/
dimension: ResizeDimension;
/**
* Type of the {@link ReduceFunction} that should be used to reduce the dimension to a target dimension value
*/
reduceFunction: ReduceFunctionType;
}
export namespace ResizeElementsAction {
export const KIND = 'resizeElementAction';
export function is(object: any): object is ResizeElementsAction {
return (
Action.hasKind(object, KIND) &&
hasArrayProp(object, 'elementIds') &&
hasNumberProp(object, 'dimension') &&
hasStringProp(object, 'reduceFunction')
);
}
export function create(options: {
elementIds?: string[];
dimension?: ResizeDimension;
reduceFunction: ReduceFunctionType;
}): ResizeElementsAction {
return {
kind: KIND,
dimension: ResizeDimension.Width,
elementIds: [],
...options
};
}
}
()
export abstract class LayoutElementsActionHandler implements IActionHandler {
(TYPES.IActionDispatcher)
protected actionDispatcher: IActionDispatcher;
(SelectionService)
protected selectionService: SelectionService;
(TYPES.IMovementRestrictor)
()
protected movementRestrictor?: IMovementRestrictor;
abstract handle(action: Action): void | Action | ICommand;
getSelectedElements(selection: { elementIds: string[] }): BoundsAwareModelElement[] {
const index = this.selectionService.getModelRoot().index;
const selectedElements = selection.elementIds.length > 0 ? selection.elementIds : this.selectionService.getSelectedElementIDs();
return getElements(index, selectedElements, this.isActionElement);
}
protected abstract isActionElement(element: GModelElement): element is BoundsAwareModelElement;
dispatchAction(action: Action): void {
this.actionDispatcher.dispatch(action);
}
dispatchActions(actions: Action[]): void {
this.actionDispatcher.dispatchAll(actions);
}
}
()
export class ResizeElementsActionHandler extends LayoutElementsActionHandler {
handle(action: ResizeElementsAction): void {
const elements = this.getSelectedElements(action);
if (elements.length > 1) {
const reduceFn = ReduceFunction.get(action.reduceFunction);
switch (action.dimension) {
case ResizeDimension.Width:
return this.resizeWidth(elements, reduceFn);
case ResizeDimension.Height:
return this.resizeHeight(elements, reduceFn);
case ResizeDimension.Width_And_Height:
return this.resizeWidthAndHeight(elements, reduceFn);
}
}
}
resizeWidth(elements: BoundsAwareModelElement[], reduceFn: ReduceFunction): void {
const targetWidth = reduceFn(...elements.map(element => element.bounds.width));
this.dispatchResizeActions(elements, (element, bounds) => {
// resize around center (horizontal)
const halfDiffWidth = 0.5 * (targetWidth - element.bounds.width);
bounds.newPosition!.x = element.bounds.x - halfDiffWidth;
bounds.newSize.width = targetWidth;
});
}
resizeHeight(elements: BoundsAwareModelElement[], reduceFn: ReduceFunction): void {
const targetHeight = reduceFn(...elements.map(element => element.bounds.height));
this.dispatchResizeActions(elements, (element, bounds) => {
// resize around middle (vertical)
const halfDiffHeight = 0.5 * (targetHeight - element.bounds.height);
bounds.newPosition!.y = element.bounds.y - halfDiffHeight;
bounds.newSize.height = targetHeight;
});
}
resizeWidthAndHeight(elements: BoundsAwareModelElement[], reduceFn: ReduceFunction): void {
const targetWidth = reduceFn(...elements.map(element => element.bounds.width));
const targetHeight = reduceFn(...elements.map(element => element.bounds.height));
const targetDimension: Dimension = { width: targetWidth, height: targetHeight };
this.dispatchResizeActions(elements, (element, bounds) => {
// resize around center and middle (horizontal & vertical)
const difference = Dimension.subtract(targetDimension, element.bounds);
const center = Dimension.center(difference);
bounds.newPosition = Point.subtract(element.bounds, center);
bounds.newSize = targetDimension;
});
}
dispatchResizeActions(
elements: BoundsAwareModelElement[],
change: (element: BoundsAwareModelElement, bounds: Writable<ElementAndBounds>) => void
): void {
const elementAndBounds: ElementAndBounds[] = []; // client- and server-side resize
elements.forEach(element => {
const elementChange = this.createElementAndBounds(element, change);
if (elementChange) {
// simply skip invalid changes
elementAndBounds.push(elementChange);
}
});
this.dispatchActions([SetBoundsAction.create(elementAndBounds), ChangeBoundsOperation.create(elementAndBounds)]);
}
createElementAndBounds(
element: BoundsAwareModelElement,
change: (_element: BoundsAwareModelElement, _bounds: Writable<ElementAndBounds>) => void
): Writable<ElementAndBounds> | undefined {
const bounds: ElementAndBounds = {
elementId: element.id,
newPosition: {
x: element.bounds.x,
y: element.bounds.y
},
newSize: {
width: element.bounds.width,
height: element.bounds.height
}
};
change(element, bounds);
return toValidElementAndBounds(element, bounds, this.movementRestrictor);
}
protected isActionElement(element: GModelElement): element is BoundsAwareModelElement {
return isResizable(element);
}
}
export enum Alignment {
Left,
Center,
Right,
Top,
Middle,
Bottom
}
/**
* A function that retrieves a specific (sub)-selection of elements from a given set of elements.
* Mainly used by the {@link AlignElementsActionHandler}.
*/
export type SelectFunction = (elements: BoundsAwareModelElement[]) => BoundsAwareModelElement[];
export namespace SelectFunction {
/**
* Select all elements from the given set of elements.
* @param elements The set of elements.
* @returns All elements.
*/
export function all(elements: BoundsAwareModelElement[]): BoundsAwareModelElement[] {
return elements;
}
/**
* Select the first element from a given set of elements.
* @param elements The elements.
* @returns An array containing the first element of the given elements.
*/
export function first(elements: BoundsAwareModelElement[]): BoundsAwareModelElement[] {
return [elements[0]];
}
/**
* Select the last element from a given set of elements.
* @param elements The elements.
* @returns An array containing the last element of the given elements.
*/
export function last(elements: BoundsAwareModelElement[]): BoundsAwareModelElement[] {
return [elements[elements.length - 1]];
}
/**
* Returns the select function that corresponds to the given {@link SelectFunctionType}.
* @param type The select function type.
* @returns The corresponding select function.
*/
export function get(kind: SelectFunctionType): SelectFunction {
return SelectFunction[kind];
}
}
/** Union type of all {@link SelectFunction} keys. */
export type SelectFunctionType = Exclude<keyof typeof SelectFunction, 'get'>;
export interface AlignElementsAction extends Action {
kind: typeof AlignElementsAction.KIND;
/**
* IDs of the elements that should be aligned. If no IDs are given, the selected elements will be aligned.
*/
elementIds: string[];
/**
* Alignment direction. The default is {@link Alignment.Left}
*/
alignment: Alignment;
/**
* Function to selected elements that are considered during alignment calculation.
* The default value is {@link Select.all}.
*/
selectFunction: SelectFunctionType;
}
export namespace AlignElementsAction {
export const KIND = 'alignElements';
export function is(object: any): object is AlignElementsAction {
return (
Action.hasKind(object, KIND) &&
hasArrayProp(object, 'elementIds') &&
hasNumberProp(object, 'alignment') &&
hasStringProp(object, 'selectFunction')
);
}
export function create(
options: { elementIds?: string[]; alignment?: Alignment; selectionFunction?: SelectFunctionType } = {}
): AlignElementsAction {
return {
kind: KIND,
elementIds: [],
alignment: Alignment.Left,
selectFunction: 'all',
...options
};
}
}
()
export class AlignElementsActionHandler extends LayoutElementsActionHandler {
handle(action: AlignElementsAction): void {
const elements = this.getSelectedElements(action);
const selectFn = SelectFunction.get(action.selectFunction);
const calculatedElements = selectFn(elements);
if (elements.length > 1) {
switch (action.alignment) {
case Alignment.Left:
return this.alignLeft(calculatedElements);
case Alignment.Center:
return this.alignCenter(calculatedElements);
case Alignment.Right:
return this.alignRight(calculatedElements);
case Alignment.Top:
return this.alignTop(calculatedElements);
case Alignment.Middle:
return this.alignMiddle(calculatedElements);
case Alignment.Bottom:
return this.alignBottom(calculatedElements);
}
}
}
alignLeft(elements: BoundsAwareModelElement[]): void {
const minX = elements.map(element => element.bounds.x).reduce((a, b) => Math.min(a, b));
this.dispatchAlignActions(elements, (_, move) => (move.toPosition.x = minX));
}
alignCenter(elements: BoundsAwareModelElement[]): void {
const minX = elements.map(element => element.bounds.x).reduce((a, b) => Math.min(a, b));
const maxX = elements.map(element => element.bounds.x + element.bounds.width).reduce((a, b) => Math.max(a, b));
const diffX = maxX - minX;
const centerX = minX + 0.5 * diffX;
this.dispatchAlignActions(elements, (element, move) => (move.toPosition.x = centerX - 0.5 * element.bounds.width));
}
alignRight(elements: BoundsAwareModelElement[]): void {
const maxX = elements.map(element => element.bounds.x + element.bounds.width).reduce((a, b) => Math.max(a, b));
this.dispatchAlignActions(elements, (element, move) => (move.toPosition.x = maxX - element.bounds.width));
}
alignTop(elements: BoundsAwareModelElement[]): void {
const minY = elements.map(element => element.bounds.y).reduce((a, b) => Math.min(a, b));
this.dispatchAlignActions(elements, (_, move) => (move.toPosition.y = minY));
}
alignMiddle(elements: BoundsAwareModelElement[]): void {
const minY = elements.map(element => element.bounds.y).reduce((a, b) => Math.min(a, b));
const maxY = elements.map(element => element.bounds.y + element.bounds.height).reduce((a, b) => Math.max(a, b));
const diffY = maxY - minY;
const middleY = minY + 0.5 * diffY;
this.dispatchAlignActions(elements, (element, move) => (move.toPosition.y = middleY - 0.5 * element.bounds.height));
}
alignBottom(elements: BoundsAwareModelElement[]): void {
const maxY = elements.map(element => element.bounds.y + element.bounds.height).reduce((a, b) => Math.max(a, b));
this.dispatchAlignActions(elements, (element, move) => (move.toPosition.y = maxY - element.bounds.height));
}
dispatchAlignActions(
elements: BoundsAwareModelElement[],
change: (element: BoundsAwareModelElement, move: Writable<ElementMove>) => void
): void {
const moves: ElementMove[] = []; // client-side move
const elementAndBounds: ElementAndBounds[] = []; // server-side move
elements.forEach(element => {
const move = this.createElementMove(element, change);
if (move) {
// simply skip invalid changes
moves.push(move);
const elementAndBound = this.createElementAndBounds(element, move);
elementAndBounds.push(elementAndBound);
}
});
this.dispatchActions([MoveAction.create(moves), ChangeBoundsOperation.create(elementAndBounds)]);
}
createElementMove(
element: BoundsAwareModelElement,
change: (_element: BoundsAwareModelElement, _move: Writable<ElementMove>) => void
): Writable<ElementMove> | undefined {
const move: ElementMove = {
elementId: element.id,
fromPosition: {
x: element.bounds.x,
y: element.bounds.y
},
toPosition: {
x: element.bounds.x,
y: element.bounds.y
}
};
change(element, move);
return toValidElementMove(element, move, this.movementRestrictor);
}
createElementAndBounds(element: BoundsAwareModelElement, move: ElementMove): ElementAndBounds {
return {
elementId: element.id,
newPosition: {
x: move.toPosition.x,
y: move.toPosition.y
},
newSize: {
width: element.bounds.width,
height: element.bounds.height
}
};
}
protected isActionElement(element: GModelElement): element is BoundsAwareModelElement {
return isBoundsAwareMoveable(element);
}
}