UNPKG

@eclipse-glsp/client

Version:

A sprotty-based client for GLSP

228 lines (202 loc) 9.77 kB
/******************************************************************************** * 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, ElementMove, GModelElement, GModelRoot, MoveAction, Point, TypeGuard, findParentByFeature } from '@eclipse-glsp/sprotty'; import { DebouncedFunc, debounce } from 'lodash'; import { DragAwareMouseListener } from '../../../base/drag-aware-mouse-listener'; import { CursorCSS, cursorFeedbackAction } from '../../../base/feedback/css-feedback'; import { FeedbackEmitter } from '../../../base/feedback/feedback-emitter'; import { ISelectionListener } from '../../../base/selection-service'; import { MoveableElement, filter, getElements, isNonRoutableMovableBoundsAware, isNonRoutableSelectedMovableBoundsAware, removeDescendants } from '../../../utils/gmodel-util'; import { GResizeHandle } from '../../change-bounds/model'; import { ChangeBoundsTool } from './change-bounds-tool'; import { MoveFinishedEventAction, MoveInitializedEventAction } from './change-bounds-tool-feedback'; import { ChangeBoundsTracker, TrackedMove } from './change-bounds-tracker'; /** * This mouse listener provides visual feedback for moving by sending client-side * `MoveAction`s while elements are selected and dragged. This will also update * their bounds, which is important, as it is not only required for rendering * the visual feedback but also the basis for sending the change to the server * (see also `tools/MoveTool`). */ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements ISelectionListener { protected rootElement?: GModelRoot; protected tracker: ChangeBoundsTracker; protected elementId2startPos = new Map<string, Point>(); protected pendingMoveInitialized?: DebouncedFunc<() => void>; protected moveInitializedFeedback: FeedbackEmitter; protected moveFeedback: FeedbackEmitter; constructor(protected tool: ChangeBoundsTool) { super(); this.tracker = tool.createChangeBoundsTracker(); this.moveInitializedFeedback = tool.createFeedbackEmitter(); this.moveFeedback = tool.createFeedbackEmitter(); } override mouseDown(target: GModelElement, event: MouseEvent): Action[] { super.mouseDown(target, event); if (event.button === 0) { if (this.tracker.isTracking()) { // we have a move in progress that was not resolved yet (e.g., user may have triggered a mouse up outside the window) this.draggingMouseUp(target, event); return []; } this.initializeMove(target, event); return []; } this.tracker.stopTracking(); return []; } protected initializeMove(target: GModelElement, event: MouseEvent): void { if (target instanceof GResizeHandle) { // avoid conflict with resize tool return; } const moveable = findParentByFeature(target, this.isValidMoveable); if (moveable !== undefined) { this.tracker.startTracking(); this.scheduleMoveInitialized(); } else { this.tracker.stopTracking(); } } protected scheduleMoveInitialized(): void { this.pendingMoveInitialized?.cancel(); this.pendingMoveInitialized = debounce(() => { this.moveInitialized(); this.pendingMoveInitialized = undefined; }, 750); this.pendingMoveInitialized(); } protected moveInitializationTimeout(): number { return 750; } protected moveInitialized(): void { if (this.isMouseDown) { this.moveInitializedFeedback .add(MoveInitializedEventAction.create(), MoveFinishedEventAction.create()) .add(cursorFeedbackAction(CursorCSS.MOVE), cursorFeedbackAction(CursorCSS.DEFAULT)) .submit(); } } protected isValidMoveable(element?: GModelElement): element is MoveableElement { return !!element && isNonRoutableSelectedMovableBoundsAware(element) && !(element instanceof GResizeHandle); } protected isValidRevertable(element?: GModelElement): element is MoveableElement { return !!element && isNonRoutableMovableBoundsAware(element) && !(element instanceof GResizeHandle); } override nonDraggingMouseUp(element: GModelElement, event: MouseEvent): Action[] { // should reset everything that may have happend on mouse down this.moveInitializedFeedback.dispose(); this.tracker.stopTracking(); return []; } override draggingMouseMove(target: GModelElement, event: MouseEvent): Action[] { super.draggingMouseMove(target, event); if (this.tracker.isTracking()) { return this.moveElements(target, event); } return []; } protected moveElements(target: GModelElement, event: MouseEvent): Action[] { if (this.elementId2startPos.size === 0) { this.initializeElementsToMove(target.root); } const elementsToMove = this.getElementsToMove(target); const move = this.tracker.moveElements(elementsToMove, { snap: event, restrict: event }); if (move.elementMoves.length === 0) { return []; } // cancel any pending move this.pendingMoveInitialized?.cancel(); this.moveFeedback.add(this.createMoveAction(move), () => this.resetElementPositions(target)); this.addMoveFeedback(move, target, event); this.tracker.updateTrackingPosition(move); this.moveFeedback.submit(); return []; } protected createMoveAction(trackedMove: TrackedMove): Action { // we never want to animate the move action as this interferes with the move feedback return MoveAction.create( trackedMove.elementMoves.map(move => ({ elementId: move.element.id, toPosition: move.toPosition })), { animate: false } ); } protected addMoveFeedback(trackedMove: TrackedMove, ctx: GModelElement, event: MouseEvent): void { this.tool.changeBoundsManager.addMoveFeedback(this.moveFeedback, trackedMove, ctx, event); } protected initializeElementsToMove(root: GModelRoot): void { const elementsToMove = this.collectElementsToMove(root); elementsToMove.forEach(element => this.elementId2startPos.set(element.id, element.position)); } protected collectElementsToMove(root: GModelRoot): MoveableElement[] { const moveableElements = filter(root.index, this.isValidMoveable); const topLevelElements = removeDescendants(moveableElements); return Array.from(topLevelElements); } protected getElementsToMove(context: GModelElement, moveable: TypeGuard<MoveableElement> = this.isValidMoveable): MoveableElement[] { return getElements(context.root.index, Array.from(this.elementId2startPos.keys()), moveable); } protected resetElementPositions(context: GModelElement): MoveAction | undefined { const elementMoves: ElementMove[] = this.revertElementMoves(context); return MoveAction.create(elementMoves, { animate: false, finished: true }); } protected revertElementMoves(context?: GModelElement): ElementMove[] { const elementMoves: ElementMove[] = []; if (context?.root?.index) { const movableElements = this.getElementsToMove(context, this.isValidRevertable); movableElements.forEach(element => elementMoves.push({ elementId: element.id, toPosition: this.elementId2startPos.get(element.id)! }) ); } return elementMoves; } override draggingMouseUp(target: GModelElement, event: MouseEvent): Action[] { if (!this.tracker.isTracking()) { return []; } const elementsToMove = this.getElementsToMove(target); if (!this.tool.movementOptions.allElementsNeedToBeValid) { // only reset the move of invalid elements, the others will be handled by the change bounds tool itself elementsToMove .filter(element => this.tool.changeBoundsManager.isValid(element)) .forEach(element => this.elementId2startPos.delete(element.id)); } else { if (elementsToMove.length > 0 && elementsToMove.every(element => this.tool.changeBoundsManager.isValid(element))) { // do not reset any element as all are valid this.elementId2startPos.clear(); } } this.dispose(); return []; } selectionChanged(root: Readonly<GModelRoot>, selectedElements: string[], deselectedElements?: string[]): void { this.dispose(); } override dispose(): void { this.pendingMoveInitialized?.cancel(); this.moveInitializedFeedback.dispose(); this.moveFeedback.dispose(); this.tracker.dispose(); this.elementId2startPos.clear(); super.dispose(); } }