@eclipse-glsp/client
Version:
A sprotty-based client for GLSP
270 lines (245 loc) • 11 kB
text/typescript
/********************************************************************************
* Copyright (c) 2023-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,
GModelElement,
GModelRoot,
IActionHandler,
MoveAction,
Point,
SetBoundsAction,
TYPES,
Vector,
Writable
} from '@eclipse-glsp/sprotty';
import { inject, injectable, optional, postConstruct } from 'inversify';
import { IFeedbackActionDispatcher } from '../../base/feedback/feedback-action-dispatcher';
import { FeedbackEmitter } from '../../base/feedback/feedback-emitter';
import { ISelectionListener, SelectionService } from '../../base/selection-service';
import { SetBoundsFeedbackAction } from '../bounds/set-bounds-feedback-command';
import { GResizeHandle, ResizeHandleLocation } from '../change-bounds/model';
import { Grid } from '../grid/grid';
import { MoveFinishedEventAction, MoveInitializedEventAction } from '../tools/change-bounds/change-bounds-tool-feedback';
import {
AlignmentElementFilter,
DEFAULT_ALIGNABLE_ELEMENT_FILTER,
DEFAULT_DEBUG,
DEFAULT_ELEMENT_LINES,
DEFAULT_EPSILON,
DEFAULT_VIEWPORT_LINES,
DrawHelperLinesFeedbackAction,
RemoveHelperLinesFeedbackAction,
ViewportLineType
} from './helper-line-feedback';
import { Direction, HelperLine, HelperLineType, isHelperLine } from './model';
export interface IHelperLineManager {
/**
* Calculates the minimum move delta on one axis that is necessary to break through a helper line.
*
* @param element element that is being moved
* @param isSnap whether snapping is active or not
* @param direction direction in which the target element is moving
*/
getMinimumMoveDelta(element: GModelElement, isSnap: boolean, direction: Direction): number;
/**
* Calculates the minimum move vector that is necessary to break through a helper line.
*
* @param element element that is being moved
* @param isSnap whether snapping is active or not
* @param directions directions in which the target element is moving
*/
getMinimumMoveVector(element: GModelElement, isSnap: boolean, directions: Direction[]): Vector | undefined;
}
export interface IHelperLineOptions {
/**
* A list of helper line types that should be rendered when elements are aligned.
* Defaults to all possible alignments.
*/
elementLines?: HelperLineType[];
/**
* A list of helper line types that should be rendered when an element is aligned with the viewport.
* Defaults to middle and center alignment.
*/
viewportLines?: ViewportLineType[];
/**
* The minimum difference between two coordinates
* Defaults to 1 or zero (perfect match) if the optional grid module is loaded.
*/
alignmentEpsilon?: number;
/**
* A filter that is applied to determine on which elements the alignment calculation is performed.
* By default all top-level bounds-aware, non-routable elements that are visible on the canvas are considered.
*/
alignmentElementFilter?: AlignmentElementFilter;
/**
* The minimum move delta that is necessary for an element to break through a helper line.
* Defaults to { x: 1, y: 1 } whereas the x represents the horizontal distance and y represents the vertical distance.
* If the optional grid module is loaded, defaults to twice the grid size, i.e., two grid moves to break through a helper line.
*/
minimumMoveDelta?: Point;
/**
* Produces debug output.
* Defaults to false.
*/
debug?: boolean;
}
export const DEFAULT_MOVE_DELTA = { x: 1, y: 1 };
export const DEFAULT_HELPER_LINE_OPTIONS: Required<IHelperLineOptions> = {
elementLines: DEFAULT_ELEMENT_LINES,
viewportLines: DEFAULT_VIEWPORT_LINES,
alignmentEpsilon: DEFAULT_EPSILON,
alignmentElementFilter: DEFAULT_ALIGNABLE_ELEMENT_FILTER,
minimumMoveDelta: DEFAULT_MOVE_DELTA,
debug: DEFAULT_DEBUG
};
export class HelperLineManager implements IActionHandler, ISelectionListener, IHelperLineManager {
protected feedbackDispatcher: IFeedbackActionDispatcher;
protected selectionService: SelectionService;
protected userOptions?: IHelperLineOptions;
protected grid?: Grid;
protected options: Required<IHelperLineOptions>;
protected feedback: FeedbackEmitter;
protected init(): void {
this.feedback = this.feedbackDispatcher.createEmitter();
const dynamicOptions: IHelperLineOptions = {};
if (this.grid) {
dynamicOptions.alignmentEpsilon = 0;
dynamicOptions.minimumMoveDelta = Point.multiplyScalar(this.grid, 2);
}
this.options = { ...DEFAULT_HELPER_LINE_OPTIONS, ...dynamicOptions, ...this.userOptions };
this.selectionService.addListener(this);
}
handle(action: Action): void {
if (MoveInitializedEventAction.is(action)) {
this.handleMoveInitializedAction(action);
} else if (MoveAction.is(action)) {
this.handleMoveAction(action);
} else if (MoveFinishedEventAction.is(action)) {
this.handleMoveFinishedAction(action);
} else if (SetBoundsAction.is(action) || SetBoundsFeedbackAction.is(action)) {
this.handleSetBoundsAction(action);
}
}
protected handleMoveInitializedAction(_action: MoveInitializedEventAction): void {
this.submitHelperLineFeedback();
}
protected handleMoveFinishedAction(_action: MoveFinishedEventAction): void {
this.feedback.dispose();
}
protected handleMoveAction(action: MoveAction): void {
if (!action.finished) {
this.submitHelperLineFeedback(action.moves.map(move => move.elementId));
} else {
this.feedback.dispose();
}
}
protected submitHelperLineFeedback(elementIds: string[] = this.selectionService.getSelectedElementIDs()): void {
const feedback = this.createHelperLineFeedback(elementIds);
this.feedback.add(feedback, [RemoveHelperLinesFeedbackAction.create()]).submit();
}
protected createHelperLineFeedback(elementIds: string[]): DrawHelperLinesFeedbackAction {
return DrawHelperLinesFeedbackAction.create({ elementIds, ...this.options });
}
protected handleSetBoundsAction(action: SetBoundsAction | SetBoundsFeedbackAction): void {
this.submitHelperLineFeedback(action.bounds.map(bound => bound.elementId));
}
selectionChanged(root: Readonly<GModelRoot>, selectedElements: string[], deselectedElements?: string[] | undefined): void {
this.feedback.dispose();
}
getMinimumMoveDelta(element: GModelElement, isSnap: boolean, direction: Direction): number {
if (!isSnap) {
return 0;
}
const minimumMoveDelta = this.options.minimumMoveDelta;
return direction === Direction.Left || direction === Direction.Right ? minimumMoveDelta.x : minimumMoveDelta.y;
}
getMinimumMoveVector(element: GModelElement, isSnap: boolean, move: Direction[]): Vector | undefined {
if (!isSnap) {
return undefined;
}
const state = this.getHelperLineState(element);
if (state.helperLines.length === 0) {
return undefined;
}
const minimum: Writable<Vector> = { ...Vector.ZERO };
const resize =
element instanceof GResizeHandle
? ResizeHandleLocation.direction(element.location)
: [Direction.Left, Direction.Right, Direction.Up, Direction.Down];
if ((state.types.left || state.types.center) && move.includes(Direction.Left) && resize.includes(Direction.Left)) {
minimum.x = this.getMinimumMoveDelta(element, isSnap, Direction.Left);
} else if ((state.types.right || state.types.center) && move.includes(Direction.Right) && resize.includes(Direction.Right)) {
minimum.x = this.getMinimumMoveDelta(element, isSnap, Direction.Right);
}
if ((state.types.top || state.types.middle) && move.includes(Direction.Up) && resize.includes(Direction.Up)) {
minimum.y = this.getMinimumMoveDelta(element, isSnap, Direction.Up);
} else if ((state.types.bottom || state.types.middle) && move.includes(Direction.Down) && resize.includes(Direction.Down)) {
minimum.y = this.getMinimumMoveDelta(element, isSnap, Direction.Down);
}
return Vector.isZero(minimum) ? undefined : minimum;
}
protected getHelperLineState(element: GModelElement): HelperLineState {
const helperLines = element.root.children.filter(isHelperLine) || [];
const types = {
left: false,
right: false,
top: false,
bottom: false,
center: false,
middle: false
};
for (const line of helperLines) {
switch (line.lineType) {
case HelperLineType.Left:
case HelperLineType.LeftRight:
types.left = true;
break;
case HelperLineType.Right:
case HelperLineType.RightLeft:
types.right = true;
break;
case HelperLineType.Top:
case HelperLineType.TopBottom:
types.top = true;
break;
case HelperLineType.Bottom:
case HelperLineType.BottomTop:
types.bottom = true;
break;
case HelperLineType.Center:
types.center = true;
break;
case HelperLineType.Middle:
types.middle = true;
break;
}
}
return { helperLines, types };
}
}
export interface HelperLineState {
helperLines: HelperLine[];
types: {
left: boolean;
right: boolean;
top: boolean;
bottom: boolean;
center: boolean;
middle: boolean;
};
}