@eclipse-glsp/client
Version:
A sprotty-based client for GLSP
354 lines (309 loc) • 17.2 kB
text/typescript
/********************************************************************************
* Copyright (c) 2023-2025 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,
Bounds,
CommandExecutionContext,
CommandReturn,
GChildElement,
GLabel,
GModelElement,
GModelRoot,
ILogger,
Point,
TYPES,
Viewport,
equalUpTo,
isBoundsAware,
isDecoration,
isViewport
} from '@eclipse-glsp/sprotty';
import { inject, injectable } from 'inversify';
import { partition } from 'lodash';
import '../../../css/helper-lines.css';
import { EditorContextService } from '../../base/editor-context-service';
import { FeedbackCommand } from '../../base/feedback/feedback-command';
import {
BoundsAwareModelElement,
findTopLevelElementByFeature,
forEachElement,
getMatchingElements,
isRoutable,
isVisibleOnCanvas
} from '../../utils/gmodel-util';
import { getViewportBounds, toAbsoluteBounds } from '../../utils/viewpoint-util';
import { feedbackEdgeEndId, feedbackEdgeId } from '../tools/edge-creation/dangling-edge-feedback';
import { HelperLine, HelperLineType, SelectionBounds, isHelperLine, isSelectionBounds } from './model';
export type ViewportLineType = typeof HelperLineType.Center | typeof HelperLineType.Middle | string;
export type AlignmentElementFilter = (element: BoundsAwareModelElement, referenceElementIds: string[]) => boolean;
export const isTopLevelBoundsAwareElement: AlignmentElementFilter = element =>
findTopLevelElementByFeature(element, isBoundsAware, isViewport) === element;
export interface DrawHelperLinesFeedbackAction extends Action {
kind: typeof DrawHelperLinesFeedbackAction.KIND;
elementIds: string[];
elementLines?: HelperLineType[];
viewportLines?: ViewportLineType[];
alignmentEpsilon?: number;
alignmentElementFilter?: AlignmentElementFilter;
debug?: boolean;
}
export const ALL_ELEMENT_LINE_TYPES = Object.values(HelperLineType);
export const ALL_VIEWPORT_LINE_TYPES = [HelperLineType.Center, HelperLineType.Middle];
export const DEFAULT_ELEMENT_LINES = ALL_ELEMENT_LINE_TYPES;
export const DEFAULT_VIEWPORT_LINES = ALL_VIEWPORT_LINE_TYPES;
export const DEFAULT_EPSILON = 1;
export const DEFAULT_ALIGNABLE_ELEMENT_FILTER = (element: BoundsAwareModelElement): boolean =>
isVisibleOnCanvas(element) &&
!isRoutable(element) &&
!(element instanceof GLabel) &&
!(element.id === feedbackEdgeId(element.root)) &&
!(element.id === feedbackEdgeEndId(element.root)) &&
!isDecoration(element);
export const DEFAULT_DEBUG = false;
export namespace DrawHelperLinesFeedbackAction {
export const KIND = 'drawHelperLines';
export function create(options: Omit<DrawHelperLinesFeedbackAction, 'kind'>): DrawHelperLinesFeedbackAction {
return {
kind: KIND,
...options
};
}
}
export class DrawHelperLinesFeedbackCommand extends FeedbackCommand {
static readonly KIND = DrawHelperLinesFeedbackAction.KIND;
protected readonly editorContext: EditorContextService;
protected elementIds: string[];
protected elementLines: HelperLineType[];
protected viewportLines: ViewportLineType[];
protected alignmentEpsilon: number;
protected alignableElementFilter: AlignmentElementFilter;
protected isAlignableElementPredicate: (element: GModelElement) => element is BoundsAwareModelElement;
protected debug: boolean;
constructor(
action: DrawHelperLinesFeedbackAction,
protected logger: ILogger
) {
super();
this.elementIds = action.elementIds;
this.elementLines = action.elementLines ?? DEFAULT_ELEMENT_LINES;
this.viewportLines = action.viewportLines ?? DEFAULT_VIEWPORT_LINES;
this.alignmentEpsilon = action.alignmentEpsilon ?? DEFAULT_EPSILON;
this.alignableElementFilter = action.alignmentElementFilter ?? DEFAULT_ALIGNABLE_ELEMENT_FILTER;
this.isAlignableElementPredicate = this.isAlignableElement.bind(this);
this.debug = action.debug ?? DEFAULT_DEBUG;
}
execute(context: CommandExecutionContext): CommandReturn {
removeHelperLines(context.root);
removeSelectionBounds(context.root);
const alignableElements = getMatchingElements(context.root.index, this.isAlignableElementPredicate);
this.log('All alignable elements: ', alignableElements);
const [referenceElements, elements] = partition(alignableElements, element => this.elementIds.includes(element.id));
this.log('Split alignable elements into reference elements and other elements: ', referenceElements, elements);
if (referenceElements.length === 0) {
this.log('--> No helper lines as we do not have any reference elements.');
return context.root;
}
const referenceBounds = this.calcReferenceBounds(referenceElements);
this.log('Bounds encompasing all reference elements: ', referenceBounds);
const helperLines = this.calcHelperLines(elements, referenceBounds, context);
if (referenceElements.length > 1) {
context.root.add(new SelectionBounds(referenceBounds));
this.log('Render selection bounds for more than one reference element:', referenceBounds);
}
helperLines.forEach(helperLine => context.root.add(helperLine));
if (helperLines.length > 0) {
this.log(`--> Add ${helperLines.length} helper lines to root:`, helperLines);
} else {
this.log('--> Add no helper lines to root.');
}
return context.root;
}
protected isAlignableElement(element: GModelElement): element is BoundsAwareModelElement {
return isBoundsAware(element) && this.alignableElementFilter(element, this.elementIds);
}
protected calcReferenceBounds(referenceElements: BoundsAwareModelElement[]): Bounds {
return referenceElements.map(element => this.calcBounds(element)).reduce(Bounds.combine, Bounds.EMPTY);
}
protected calcBounds(element: BoundsAwareModelElement): Bounds {
return toAbsoluteBounds(element);
}
protected calcHelperLines(elements: BoundsAwareModelElement[], bounds: Bounds, context: CommandExecutionContext): HelperLine[] {
const helperLines: HelperLine[] = [];
const viewport = this.editorContext.viewport;
if (viewport) {
helperLines.push(...this.calcHelperLinesForViewport(viewport, bounds, this.viewportLines));
}
elements
.flatMap(element => this.calcHelperLinesForElement(element, bounds, this.elementLines))
.forEach(line => helperLines.push(line));
return helperLines;
}
protected calcHelperLinesForViewport(root: Viewport & GModelRoot, bounds: Bounds, lineTypes: HelperLineType[]): HelperLine[] {
const helperLines: HelperLine[] = [];
this.log('Find helperlines for viewport:', root);
const viewportBounds = getViewportBounds(root, root.canvasBounds);
if (lineTypes.includes(HelperLineType.Center) && this.isAligned(Bounds.centerX, viewportBounds, bounds, 2)) {
helperLines.push(new HelperLine(Bounds.topCenter(viewportBounds), Bounds.bottomCenter(viewportBounds), HelperLineType.Center));
this.log('- Reference bounds center align with viewport.', viewportBounds);
}
if (lineTypes.includes(HelperLineType.Middle) && this.isAligned(Bounds.middle, viewportBounds, bounds, 2)) {
helperLines.push(new HelperLine(Bounds.middleLeft(viewportBounds), Bounds.middleRight(viewportBounds), HelperLineType.Middle));
this.log('- Reference bounds middle align with viewport.', viewportBounds);
}
if (helperLines.length > 0) {
this.log(`--> Add ${helperLines.length} helperlines for viewport:`, helperLines);
}
return helperLines;
}
protected calcHelperLinesForElement(element: BoundsAwareModelElement, bounds: Bounds, lineTypes: HelperLineType[]): HelperLine[] {
this.log('Find helperlines for element:', element);
return this.calcHelperLinesForBounds(this.calcBounds(element), bounds, lineTypes);
}
protected calcHelperLinesForBounds(elementBounds: Bounds, bounds: Bounds, lineTypes: HelperLineType[]): HelperLine[] {
const helperLines: HelperLine[] = [];
if (lineTypes.includes(HelperLineType.Left) && this.isAligned(Bounds.left, elementBounds, bounds, this.alignmentEpsilon)) {
const [above, below] = Bounds.sortBy(Bounds.top, elementBounds, bounds); // higher top-value ==> lower
helperLines.push(new HelperLine(Bounds.bottomLeft(below), Bounds.topLeft(above), HelperLineType.Left));
this.log('- Reference bounds left align with element', elementBounds);
}
if (lineTypes.includes(HelperLineType.Center) && this.isAligned(Bounds.centerX, elementBounds, bounds, this.alignmentEpsilon)) {
const [above, below] = Bounds.sortBy(Bounds.top, elementBounds, bounds); // higher top-value ==> lower
helperLines.push(new HelperLine(Bounds.topCenter(above), Bounds.bottomCenter(below), HelperLineType.Center));
this.log('- Reference bounds center align with element', elementBounds);
}
if (lineTypes.includes(HelperLineType.Right) && this.isAligned(Bounds.right, elementBounds, bounds, this.alignmentEpsilon)) {
const [above, below] = Bounds.sortBy(Bounds.top, elementBounds, bounds); // higher top-value ==> lower
helperLines.push(new HelperLine(Bounds.bottomRight(below), Bounds.topRight(above), HelperLineType.Right));
this.log('- Reference bounds right align with element', elementBounds);
}
if (lineTypes.includes(HelperLineType.Bottom) && this.isAligned(Bounds.bottom, elementBounds, bounds, this.alignmentEpsilon)) {
const [before, after] = Bounds.sortBy(Bounds.left, elementBounds, bounds); // higher left-value ==> more to the right
helperLines.push(new HelperLine(Bounds.bottomLeft(before), Bounds.bottomRight(after), HelperLineType.Bottom));
this.log('- Reference bounds bottom align with element', elementBounds);
}
if (lineTypes.includes(HelperLineType.Middle) && this.isAligned(Bounds.centerY, elementBounds, bounds, this.alignmentEpsilon)) {
const [before, after] = Bounds.sortBy(Bounds.left, elementBounds, bounds); // higher left-value ==> more to the right
helperLines.push(new HelperLine(Bounds.middleLeft(before), Bounds.middleRight(after), HelperLineType.Middle));
this.log('- Reference bounds middle align with element', elementBounds);
}
if (lineTypes.includes(HelperLineType.Top) && this.isAligned(Bounds.top, elementBounds, bounds, this.alignmentEpsilon)) {
const [before, after] = Bounds.sortBy(Bounds.left, elementBounds, bounds); // higher left-value ==> more to the right
helperLines.push(new HelperLine(Bounds.topLeft(before), Bounds.topRight(after), HelperLineType.Top));
this.log('- Reference bounds top align with element', elementBounds);
}
if (
lineTypes.includes(HelperLineType.LeftRight) &&
this.isMatch(Bounds.left(elementBounds), Bounds.right(bounds), this.alignmentEpsilon)
) {
if (Bounds.isAbove(bounds, elementBounds)) {
helperLines.push(new HelperLine(Bounds.bottomLeft(elementBounds), Bounds.topRight(bounds), HelperLineType.RightLeft));
this.log('- Reference bounds right aligns with element left', elementBounds);
} else {
helperLines.push(new HelperLine(Bounds.topLeft(elementBounds), Bounds.bottomRight(bounds), HelperLineType.RightLeft));
this.log('- Reference bounds right aligns with element left', elementBounds);
}
}
if (
lineTypes.includes(HelperLineType.LeftRight) &&
this.isMatch(Bounds.right(elementBounds), Bounds.left(bounds), this.alignmentEpsilon)
) {
if (Bounds.isAbove(bounds, elementBounds)) {
helperLines.push(new HelperLine(Bounds.bottomRight(elementBounds), Bounds.topLeft(bounds), HelperLineType.LeftRight));
this.log('- Reference bounds left aligns with element right', elementBounds);
} else {
helperLines.push(new HelperLine(Bounds.topRight(elementBounds), Bounds.bottomLeft(bounds), HelperLineType.LeftRight));
this.log('- Reference bounds left aligns with element right', elementBounds);
}
}
if (
lineTypes.includes(HelperLineType.TopBottom) &&
this.isMatch(Bounds.top(elementBounds), Bounds.bottom(bounds), this.alignmentEpsilon)
) {
if (Bounds.isBefore(bounds, elementBounds)) {
helperLines.push(new HelperLine(Bounds.topRight(elementBounds), Bounds.bottomLeft(bounds), HelperLineType.BottomTop));
this.log('- Reference bounds bottom aligns with element top', elementBounds);
} else {
helperLines.push(new HelperLine(Bounds.topLeft(elementBounds), Bounds.bottomRight(bounds), HelperLineType.BottomTop));
this.log('- Reference bounds bottom aligns with element top', elementBounds);
}
}
if (
lineTypes.includes(HelperLineType.TopBottom) &&
this.isMatch(Bounds.bottom(elementBounds), Bounds.top(bounds), this.alignmentEpsilon)
) {
if (Bounds.isBefore(bounds, elementBounds)) {
helperLines.push(new HelperLine(Bounds.bottomRight(elementBounds), Bounds.topLeft(bounds), HelperLineType.TopBottom));
this.log('- Reference bounds top aligns with element bottom', elementBounds);
} else {
helperLines.push(new HelperLine(Bounds.bottomLeft(elementBounds), Bounds.topRight(bounds), HelperLineType.TopBottom));
this.log('- Reference bounds top aligns with element bottom', elementBounds);
}
}
if (helperLines.length > 0) {
this.log(`--> Add ${helperLines.length} helperlines for element:`, helperLines);
}
return helperLines;
}
protected isAligned(coordinate: (elem: Bounds) => number, leftBounds: Bounds, rightBounds: Bounds, epsilon: number): boolean {
return this.isMatch(coordinate(leftBounds), coordinate(rightBounds), epsilon);
}
protected isMatch(leftCoordinate: number, rightCoordinate: number, epsilon: number): boolean {
return equalUpTo(leftCoordinate, rightCoordinate, epsilon);
}
protected log(message: string, ...params: any[]): void {
if (this.debug) {
this.logger.log(this, message, params);
}
}
}
export interface RemoveHelperLinesFeedbackAction extends Action {
kind: typeof RemoveHelperLinesFeedbackAction.KIND;
}
export namespace RemoveHelperLinesFeedbackAction {
export const KIND = 'removeHelperLines';
export function create(options: Omit<RemoveHelperLinesFeedbackAction, 'kind'> = {}): RemoveHelperLinesFeedbackAction {
return {
kind: KIND,
...options
};
}
}
export class RemoveHelperLinesFeedbackCommand extends FeedbackCommand {
static readonly KIND = RemoveHelperLinesFeedbackAction.KIND;
constructor( public action: RemoveHelperLinesFeedbackAction) {
super();
}
override execute(context: CommandExecutionContext): CommandReturn {
removeHelperLines(context.root);
removeSelectionBounds(context.root);
return context.root;
}
}
export function removeHelperLines(root: GModelRoot): void {
forEachElement(root.index, isHelperLine, line => root.remove(line));
}
export function removeSelectionBounds(root: GModelRoot): void {
forEachElement(root.index, isSelectionBounds, line => root.remove(line));
}
export function boundsInViewport(element: GModelElement, bounds: Bounds | Point): Bounds | Point {
if (element instanceof GChildElement && !isViewport(element.parent)) {
return boundsInViewport(element.parent, element.parent.localToParent(bounds) as Bounds);
} else {
return bounds;
}
}