Skip to content

Commit

Permalink
Add Helper Lines module to provide feedback and helper line manager
Browse files Browse the repository at this point in the history
Helper Lines:
- Draw helper lines when bounds of given elements match with existing
- Draw selection bounds if more than one element is considered
- Use manager class to draw them on client-side move or bounds change

Integration:
- Create PointPositionSnapper for more fine-grained snapping
- Use PointPositionSnapper in PointPositionUpdater
- Use PointPositionUpdater wherever necessary

Other changes:
- Centralize unsnap-modifier as Shift key
- Ensure unsnapped moves are possible in keyboard tool
- Fix typo in 'SetBoundsFeebackCommand' and some file names
- Make popup mouse event transparent by default

Fixes eclipse-glsp/glsp#621
  • Loading branch information
martin-fleck-at committed Nov 29, 2023
1 parent 2247067 commit d3225aa
Show file tree
Hide file tree
Showing 33 changed files with 1,275 additions and 266 deletions.
1 change: 1 addition & 0 deletions packages/client/css/glsp-sprotty.css
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
max-width: 400px;
min-width: 100px;
z-index: 1;
pointer-events: none;
}

.sprotty-popup > div {
Expand Down
33 changes: 33 additions & 0 deletions packages/client/css/helper-lines.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/********************************************************************************
* Copyright (c) 2023 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
********************************************************************************/

.helper-line {
pointer-events: none;
stroke: red;
stroke-width: 1px;
opacity: 1;
}

.selection-bounds {
pointer-events: none;
fill: blue;
fill-opacity: 0.05;
stroke-linejoin: miter;
stroke-linecap: round;
stroke: darkblue;
stroke-width: 1px;
stroke-dasharray: 2;
}
6 changes: 4 additions & 2 deletions packages/client/src/default-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,19 @@ import { copyPasteModule } from './features/copy-paste/copy-paste-modules';
import { decorationModule } from './features/decoration/decoration-module';
import { elementTemplateModule } from './features/element-template/element-template-module';
import { exportModule } from './features/export/export-modules';
import { helperLineModule } from './features/helper-lines/helper-line-module';
import { typeHintsModule } from './features/hints/type-hints-module';
import { hoverModule } from './features/hover/hover-module';
import { labelEditModule } from './features/label-edit/label-edit-module';
import { layoutModule } from './features/layout/layout-module';
import { navigationModule } from './features/navigation/navigation-module';
import { routingModule } from './features/routing/routing-module';
import { selectModule } from './features/select/select-module';
import { sourceModelWatcherModule } from './features/source-model-watcher/source-model-wacher-module';
import { sourceModelWatcherModule } from './features/source-model-watcher/source-model-watcher-module';
import { statusModule } from './features/status/status-module';
import { svgMetadataModule } from './features/svg-metadata/svg-metadata-module';
import { toolPaletteModule } from './features/tool-palette/tool-palette-module';
import { changeBoundsToolModule } from './features/tools/change-bounds/change-boounds-tool-module';
import { changeBoundsToolModule } from './features/tools/change-bounds/change-bounds-tool-module';
import { deletionToolModule } from './features/tools/deletion/deletion-tool-module';
import { edgeCreationToolModule } from './features/tools/edge-creation/edege-creation-module';
import { edgeEditToolModule } from './features/tools/edge-edit/edge-edit-module';
Expand Down Expand Up @@ -91,6 +92,7 @@ export const DEFAULT_MODULES = [
edgeCreationToolModule,
edgeEditToolModule,
deletionToolModule,
helperLineModule,
elementTemplateModule,
nodeCreationToolModule,
changeBoundsToolModule,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { Action, GModelElement, ISnapper, KeyListener, KeyTool, Point, TYPES } from '@eclipse-glsp/sprotty';
import { Action, GModelElement, ISnapper, KeyListener, KeyTool, TYPES } from '@eclipse-glsp/sprotty';
import { inject, injectable, optional } from 'inversify';
import { matchesKeystroke } from 'sprotty/lib/utils/keyboard';
import { GLSPActionDispatcher } from '../../../base/action-dispatcher';
import { SelectionService } from '../../../base/selection-service';
import { Tool } from '../../../base/tool-manager/tool';
import { GridSnapper } from '../../change-bounds/snap';
import { GridSnapper, unsnapModifier, useSnap } from '../../change-bounds/snap';
import { AccessibleKeyShortcutProvider, SetAccessibleKeyShortcutAction } from '../key-shortcut/accessible-key-shortcut';
import { MoveElementAction, MoveViewportAction } from '../move-zoom/move-handler';

Expand Down Expand Up @@ -63,18 +63,14 @@ export class MoveKeyListener extends KeyListener implements AccessibleKeyShortcu

protected readonly token = MoveKeyListener.name;

protected grid = { x: MoveKeyListener.defaultMoveX, y: MoveKeyListener.defaultMoveY };

constructor(protected readonly tool: MovementKeyTool) {
super();
}

protected get grid(): Point {
if (this.tool.snapper instanceof GridSnapper) {
return this.tool.snapper.grid;
this.grid = this.tool.snapper.grid;
}
return {
x: MoveKeyListener.defaultMoveX,
y: MoveKeyListener.defaultMoveY
};
}

registerShortcutKey(): void {
Expand All @@ -86,9 +82,9 @@ export class MoveKeyListener extends KeyListener implements AccessibleKeyShortcu
);
}

override keyDown(element: GModelElement, event: KeyboardEvent): Action[] {
override keyDown(_element: GModelElement, event: KeyboardEvent): Action[] {
const selectedElementIds = this.tool.selectionService.getSelectedElementIDs();
const snap = !event.altKey;
const snap = useSnap(event);
const offsetX = snap ? this.grid.x : 1;
const offsetY = snap ? this.grid.y : 1;

Expand All @@ -104,31 +100,31 @@ export class MoveKeyListener extends KeyListener implements AccessibleKeyShortcu
}
} else {
if (this.matchesMoveUpKeystroke(event)) {
return [MoveViewportAction.create(0, -this.grid.y)];
return [MoveViewportAction.create(0, -offsetY)];
} else if (this.matchesMoveDownKeystroke(event)) {
return [MoveViewportAction.create(0, this.grid.y)];
return [MoveViewportAction.create(0, offsetY)];
} else if (this.matchesMoveRightKeystroke(event)) {
return [MoveViewportAction.create(this.grid.x, 0)];
return [MoveViewportAction.create(offsetX, 0)];
} else if (this.matchesMoveLeftKeystroke(event)) {
return [MoveViewportAction.create(-this.grid.x, 0)];
return [MoveViewportAction.create(-offsetX, 0)];
}
}
return [];
}

protected matchesMoveUpKeystroke(event: KeyboardEvent): boolean {
return matchesKeystroke(event, 'ArrowUp') || matchesKeystroke(event, 'ArrowUp', 'alt');
return matchesKeystroke(event, 'ArrowUp') || matchesKeystroke(event, 'ArrowUp', unsnapModifier());
}

protected matchesMoveDownKeystroke(event: KeyboardEvent): boolean {
return matchesKeystroke(event, 'ArrowDown') || matchesKeystroke(event, 'ArrowDown', 'alt');
return matchesKeystroke(event, 'ArrowDown') || matchesKeystroke(event, 'ArrowDown', unsnapModifier());
}

protected matchesMoveRightKeystroke(event: KeyboardEvent): boolean {
return matchesKeystroke(event, 'ArrowRight') || matchesKeystroke(event, 'ArrowRight', 'alt');
return matchesKeystroke(event, 'ArrowRight') || matchesKeystroke(event, 'ArrowRight', unsnapModifier());
}

protected matchesMoveLeftKeystroke(event: KeyboardEvent): boolean {
return matchesKeystroke(event, 'ArrowLeft') || matchesKeystroke(event, 'ArrowLeft', 'alt');
return matchesKeystroke(event, 'ArrowLeft') || matchesKeystroke(event, 'ArrowLeft', unsnapModifier());
}
}
7 changes: 5 additions & 2 deletions packages/client/src/features/bounds/bounds-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ import {
configureCommand,
configureLayout
} from '@eclipse-glsp/sprotty';
import { PositionSnapper } from '../change-bounds/position-snapper';
import { FreeFormLayouter } from './freeform-layout';
import { GLSPHiddenBoundsUpdater } from './glsp-hidden-bounds-updater';
import { HBoxLayouterExt } from './hbox-layout';
import { LayouterExt } from './layouter';
import { LocalComputedBoundsCommand } from './local-bounds';
import { SetBoundsFeebackCommand } from './set-bounds-feedback-command';
import { SetBoundsFeedbackCommand } from './set-bounds-feedback-command';
import { VBoxLayouterExt } from './vbox-layout';

export const boundsModule = new FeatureModule((bind, _unbind, isBound, _rebind) => {
Expand All @@ -42,12 +43,14 @@ export const boundsModule = new FeatureModule((bind, _unbind, isBound, _rebind)
bindAsService(context, TYPES.HiddenVNodePostprocessor, GLSPHiddenBoundsUpdater);

configureCommand(context, LocalComputedBoundsCommand);
configureCommand(context, SetBoundsFeebackCommand);
configureCommand(context, SetBoundsFeedbackCommand);

bind(TYPES.Layouter).to(LayouterExt).inSingletonScope();
bind(TYPES.LayoutRegistry).to(LayoutRegistry).inSingletonScope();

configureLayout(context, VBoxLayouter.KIND, VBoxLayouterExt);
configureLayout(context, HBoxLayouter.KIND, HBoxLayouterExt);
configureLayout(context, FreeFormLayouter.KIND, FreeFormLayouter);

bind(PositionSnapper).toSelf();
});
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export namespace SetBoundsFeedbackAction {
}

@injectable()
export class SetBoundsFeebackCommand extends SetBoundsCommand implements FeedbackCommand {
export class SetBoundsFeedbackCommand extends SetBoundsCommand implements FeedbackCommand {
static override readonly KIND: string = SetBoundsFeedbackAction.KIND;

readonly priority: number = 0;
Expand Down
20 changes: 18 additions & 2 deletions packages/client/src/features/change-bounds/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,28 @@ export class SResizeHandle extends GChildElement implements Hoverable {
return feature === hoverFeedbackFeature;
}

isNwResize(): boolean {
return this.location === ResizeHandleLocation.TopLeft;
}

isSeResize(): boolean {
return this.location === ResizeHandleLocation.BottomRight;
}

isNeResize(): boolean {
return this.location === ResizeHandleLocation.TopRight;
}

isSwResize(): boolean {
return this.location === ResizeHandleLocation.BottomLeft;
}

isNwSeResize(): boolean {
return this.location === ResizeHandleLocation.TopLeft || this.location === ResizeHandleLocation.BottomRight;
return this.isNwResize() || this.isSeResize();
}

isNeSwResize(): boolean {
return this.location === ResizeHandleLocation.TopRight || this.location === ResizeHandleLocation.BottomLeft;
return this.isNeResize() || this.isSwResize();
}
}

Expand Down
120 changes: 120 additions & 0 deletions packages/client/src/features/change-bounds/point-position-updater.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/********************************************************************************
* Copyright (c) 2020-2023 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
********************************************************************************/
/* eslint-disable @typescript-eslint/no-shadow */
import { GModelElement, ISnapper, Point, Writable } from '@eclipse-glsp/sprotty';
import { calculateDeltaBetweenPoints } from '../../utils/gmodel-util';
import { isMouseEvent } from '../../utils/html-utils';
import { IHelperLineManager } from '../helper-lines/helper-line-manager';
import { Direction, getDirectionOf } from '../helper-lines/model';
import { PositionSnapper } from './position-snapper';
import { useSnap } from './snap';

/**
* This class can be used to calculate the current position, when an element is
* moved. This includes node movements, node resizing (resize handle movement)
* or edge routing-point movements.
*
* You can initialize a this class with a optional {@link ISnapper}. If a
* snapper is present, the positions will be snapped to the defined grid.
*/
export class PointPositionUpdater {
protected positionSnapper: PositionSnapper;
protected lastDragPosition?: Point;
protected positionDelta: Writable<Point> = { x: 0, y: 0 };

constructor(snapper?: PositionSnapper);
constructor(snapper?: ISnapper, helperLineManager?: IHelperLineManager);
constructor(first?: PositionSnapper | ISnapper, helperLineManager?: IHelperLineManager) {
this.positionSnapper = first instanceof PositionSnapper ? first : new PositionSnapper(first, helperLineManager);
}

/**
* Init the position with the {@link Point} of your mouse cursor.
* This method is normally called in the `mouseDown` event.
* @param mousePosition current mouse position e.g `{x: event.pageX, y: event.pageY }`
*/
public updateLastDragPosition(mousePosition: Point): void;
public updateLastDragPosition(mouseEvent: MouseEvent): void;
public updateLastDragPosition(first: Point | MouseEvent): void {
this.lastDragPosition = isMouseEvent(first) ? { x: first.pageX, y: first.pageY } : first;
}

/**
* Check if the mouse is currently not in a drag mode.
* @returns true if the last drag position is undefined
*/
public isLastDragPositionUndefined(): boolean {
return this.lastDragPosition === undefined;
}

/**
* Reset the updater for new movements.
* This method is normally called in the `mouseUp` event.
*/
public resetPosition(): void {
this.lastDragPosition = undefined;
this.positionDelta = { x: 0, y: 0 };
}

/**
* Calculate the current position of your movement.
* This method is normally called in the `mouseMove` event.
* @param target node which is moved around
* @param mousePosition current mouse position e.g `{x: event.pageX, y: event.pageY }`
* @param useSnap if a snapper is defined you can disable it, e.g when a specific key is pressed `!event.shiftKey`
* @param direction the direction in which the position is updated, will be calculated if not provided
* @returns delta to previous position or undefined if no delta should be applied
*/
public updatePosition(target: GModelElement, mousePosition: Point, useSnap: boolean, direction?: Direction[]): Point | undefined;
public updatePosition(target: GModelElement, mouseEvent: MouseEvent, direction?: Direction[]): Point | undefined;
public updatePosition(
target: GModelElement,
second: Point | MouseEvent,
third?: boolean | Direction[],
fourth?: Direction[]
): Point | undefined {
if (!this.lastDragPosition) {
return undefined;
}
const mousePosition = isMouseEvent(second) ? { x: second.pageX, y: second.pageY } : second;
const shouldSnap = typeof third === 'boolean' ? third : useSnap(second as MouseEvent);
const direction = typeof third !== 'boolean' ? third : fourth;

// calculate update to last drag position
const deltaToLastPosition = calculateDeltaBetweenPoints(mousePosition, this.lastDragPosition, target);
this.lastDragPosition = mousePosition;
if (Point.equals(deltaToLastPosition, Point.ORIGIN)) {
return undefined;
}

// accumulate position delta with latest delta
this.positionDelta.x += deltaToLastPosition.x;
this.positionDelta.y += deltaToLastPosition.y;

const directions = direction ?? getDirectionOf(this.positionDelta);

// only send update if the position actually changes
// otherwise accumulate delta until we get to an update
const positionUpdate = this.positionSnapper.snapDelta(this.positionDelta, target, shouldSnap, directions);
if (Point.equals(positionUpdate, Point.ORIGIN)) {
return undefined;
}
// we update our position so we update our delta by the snapped position
this.positionDelta.x -= positionUpdate.x;
this.positionDelta.y -= positionUpdate.y;
return positionUpdate;
}
}
Loading

0 comments on commit d3225aa

Please sign in to comment.