From 483a189d36ef64af8c204332f50dad4f64514460 Mon Sep 17 00:00:00 2001 From: Tobias Ortmayr Date: Sun, 24 Nov 2024 18:14:50 +0100 Subject: [PATCH] Various minor improvements - Merge *-default.ts implementation files of `FeedbackActionDispatcher` and `HelperLineManager` with the the interface definition files. The split is no longer necessary since we resolved potential circular dependency issues - Ensure that we consistently use the `LazyInjector` over dedicated async providers and deprecate existing provider injection - Move MaybeActions utility type from feedback-action-dispatcher into the protocol package to enable reuse + add testcases --- packages/client/src/base/default.module.ts | 4 +- .../feedback-action-dispatcher-default.ts | 130 --------- .../feedback/feedback-action-dispatcher.ts | 125 ++++++++- .../src/base/feedback/feedback-emitter.ts | 4 +- .../delete-element-context-menu.ts | 18 +- .../helper-line-manager-default.ts | 250 ------------------ .../helper-lines/helper-line-manager.ts | 236 ++++++++++++++++- .../helper-lines/helper-line-module.ts | 2 +- .../features/hints/type-hint-provider.spec.ts | 2 +- .../navigation/navigation-action-handler.ts | 14 +- .../client/src/features/tools/base-tools.ts | 4 +- packages/client/src/index.ts | 2 - packages/glsp-sprotty/src/types.ts | 6 +- packages/protocol/src/utils/type-util.spec.ts | 33 ++- packages/protocol/src/utils/type-util.ts | 12 + 15 files changed, 425 insertions(+), 417 deletions(-) delete mode 100644 packages/client/src/base/feedback/feedback-action-dispatcher-default.ts delete mode 100644 packages/client/src/features/helper-lines/helper-line-manager-default.ts diff --git a/packages/client/src/base/default.module.ts b/packages/client/src/base/default.module.ts index 0ad81f0a0..234992317 100644 --- a/packages/client/src/base/default.module.ts +++ b/packages/client/src/base/default.module.ts @@ -39,7 +39,7 @@ import { GLSPActionHandlerRegistry } from './action-handler-registry'; import { GLSPCommandStack } from './command-stack'; import { EditorContextService } from './editor-context-service'; import { ModifyCssFeedbackCommand } from './feedback/css-feedback'; -import { FeedbackActionDispatcher } from './feedback/feedback-action-dispatcher-default'; +import { FeedbackActionDispatcher } from './feedback/feedback-action-dispatcher'; import { FeedbackAwareSetModelCommand } from './feedback/set-model-command'; import { FeedbackAwareUpdateModelCommand } from './feedback/update-model-command'; import { FocusStateChangedAction } from './focus/focus-state-change-action'; @@ -72,6 +72,7 @@ export const defaultModule = new FeatureModule( bind(EditorContextService).toSelf().inSingletonScope(); bind(TYPES.IDiagramStartup).toService(EditorContextService); + // eslint-disable-next-line deprecation/deprecation bind(TYPES.IEditorContextServiceProvider).toProvider( ctx => async () => ctx.container.get(EditorContextService) ); @@ -80,6 +81,7 @@ export const defaultModule = new FeatureModule( configureActionHandler(context, SetDirtyStateAction.KIND, EditorContextService); bind(FocusTracker).toSelf().inSingletonScope(); + bind(TYPES.IDiagramStartup).toService(FocusTracker); configureActionHandler(context, FocusStateChangedAction.KIND, FocusTracker); // Model update initialization ------------------------------------ diff --git a/packages/client/src/base/feedback/feedback-action-dispatcher-default.ts b/packages/client/src/base/feedback/feedback-action-dispatcher-default.ts deleted file mode 100644 index f81a36f1a..000000000 --- a/packages/client/src/base/feedback/feedback-action-dispatcher-default.ts +++ /dev/null @@ -1,130 +0,0 @@ -/******************************************************************************** - * 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, - ActionHandlerRegistry, - Command, - CommandActionHandler, - CommandExecutionContext, - Disposable, - GModelElement, - IActionDispatcher, - ICommand, - ILogger, - TYPES, - toTypeGuard -} from '@eclipse-glsp/sprotty'; -import { inject, injectable, preDestroy } from 'inversify'; -import { IFeedbackActionDispatcher, IFeedbackEmitter, MaybeActions } from './feedback-action-dispatcher'; -import { getFeedbackRank } from './feedback-command'; -import { FeedbackEmitter } from './feedback-emitter'; - -@injectable() -export class FeedbackActionDispatcher implements IFeedbackActionDispatcher, Disposable { - protected registeredFeedback: Map = new Map(); - - @inject(TYPES.IActionDispatcher) protected actionDispatcher: IActionDispatcher; - - @inject(TYPES.ILogger) protected logger: ILogger; - - @inject(ActionHandlerRegistry) protected actionHandlerRegistry: ActionHandlerRegistry; - - protected isDisposed = false; - - registerFeedback(feedbackEmitter: IFeedbackEmitter, feedbackActions: Action[], cleanupActions?: MaybeActions): Disposable { - if (feedbackEmitter instanceof GModelElement) { - this.logger.log( - this, - // eslint-disable-next-line max-len - 'GModelElements as feedback emitters are discouraged, as they usually change between model updates and are considered unstable.' - ); - } - if (feedbackActions.length > 0) { - this.registeredFeedback.set(feedbackEmitter, feedbackActions); - this.dispatchFeedback(feedbackActions, feedbackEmitter); - } - return Disposable.create(() => this.deregisterFeedback(feedbackEmitter, cleanupActions)); - } - - deregisterFeedback(feedbackEmitter: IFeedbackEmitter, cleanupActions?: MaybeActions): void { - this.registeredFeedback.delete(feedbackEmitter); - const actions = MaybeActions.asArray(cleanupActions); - if (actions.length > 0) { - this.dispatchFeedback(actions, feedbackEmitter); - } - } - - getRegisteredFeedback(): Action[] { - const result: Action[] = []; - this.registeredFeedback.forEach(actions => result.push(...actions)); - return result; - } - - getRegisteredFeedbackEmitters(action: Action): IFeedbackEmitter[] { - const result: IFeedbackEmitter[] = []; - this.registeredFeedback.forEach((actions, emitter) => { - if (actions.includes(action)) { - result.push(emitter); - } - }); - return result; - } - - getFeedbackCommands(): Command[] { - return this.getRegisteredFeedback() - .flatMap(action => this.actionToCommands(action)) - .sort((left, right) => getFeedbackRank(left) - getFeedbackRank(right)); - } - - async applyFeedbackCommands(context: CommandExecutionContext): Promise { - const feedbackCommands = this.getFeedbackCommands() ?? []; - if (feedbackCommands?.length > 0) { - const results = feedbackCommands.map(command => command.execute(context)); - await Promise.all(results); - } - } - - protected actionToCommands(action: Action): ICommand[] { - return ( - this.actionHandlerRegistry - .get(action.kind) - .filter(toTypeGuard(CommandActionHandler)) - .map(handler => handler.handle(action)) ?? [] - ); - } - - createEmitter(): FeedbackEmitter { - return new FeedbackEmitter(this); - } - - protected async dispatchFeedback(actions: Action[], feedbackEmitter: IFeedbackEmitter): Promise { - try { - if (this.isDisposed) { - return; - } - await this.actionDispatcher.dispatchAll(actions); - this.logger.info(this, `Dispatched feedback actions for ${feedbackEmitter}`); - } catch (reason) { - this.logger.error(this, 'Failed to dispatch feedback actions', reason); - } - } - - @preDestroy() - dispose(): void { - this.registeredFeedback.clear(); - this.isDisposed = true; - } -} diff --git a/packages/client/src/base/feedback/feedback-action-dispatcher.ts b/packages/client/src/base/feedback/feedback-action-dispatcher.ts index e3e658a56..d8a156208 100644 --- a/packages/client/src/base/feedback/feedback-action-dispatcher.ts +++ b/packages/client/src/base/feedback/feedback-action-dispatcher.ts @@ -13,23 +13,29 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { Action, Command, CommandExecutionContext, Disposable, MaybeFunction, call, asArray as toArray } from '@eclipse-glsp/sprotty'; +import { + Action, + ActionHandlerRegistry, + Command, + CommandActionHandler, + CommandExecutionContext, + Disposable, + GModelElement, + IActionDispatcher, + ICommand, + ILogger, + MaybeActions, + TYPES, + toTypeGuard +} from '@eclipse-glsp/sprotty'; +import { inject, injectable, preDestroy } from 'inversify'; +import { getFeedbackRank } from './feedback-command'; import { FeedbackEmitter } from './feedback-emitter'; export interface IFeedbackEmitter {} export const feedbackFeature = Symbol('feedbackFeature'); -export type MaybeActions = MaybeFunction; - -export namespace MaybeActions { - // eslint-disable-next-line @typescript-eslint/no-shadow - export function asArray(actions?: MaybeActions): Action[] { - const cleanup = actions ? call(actions) : []; - return cleanup ? toArray(cleanup) : []; - } -} - /** * Dispatcher for actions that are meant to show visual feedback on * the diagram that is not part of the diagram sent from the server @@ -86,3 +92,100 @@ export interface IFeedbackActionDispatcher { */ createEmitter(): FeedbackEmitter; } + +@injectable() +export class FeedbackActionDispatcher implements IFeedbackActionDispatcher, Disposable { + protected registeredFeedback: Map = new Map(); + + @inject(TYPES.IActionDispatcher) protected actionDispatcher: IActionDispatcher; + + @inject(TYPES.ILogger) protected logger: ILogger; + + @inject(ActionHandlerRegistry) protected actionHandlerRegistry: ActionHandlerRegistry; + + protected isDisposed = false; + + registerFeedback(feedbackEmitter: IFeedbackEmitter, feedbackActions: Action[], cleanupActions?: MaybeActions): Disposable { + if (feedbackEmitter instanceof GModelElement) { + this.logger.log( + this, + // eslint-disable-next-line max-len + 'GModelElements as feedback emitters are discouraged, as they usually change between model updates and are considered unstable.' + ); + } + if (feedbackActions.length > 0) { + this.registeredFeedback.set(feedbackEmitter, feedbackActions); + this.dispatchFeedback(feedbackActions, feedbackEmitter); + } + return Disposable.create(() => this.deregisterFeedback(feedbackEmitter, cleanupActions)); + } + + deregisterFeedback(feedbackEmitter: IFeedbackEmitter, cleanupActions?: MaybeActions): void { + this.registeredFeedback.delete(feedbackEmitter); + const actions = MaybeActions.asArray(cleanupActions); + if (actions.length > 0) { + this.dispatchFeedback(actions, feedbackEmitter); + } + } + + getRegisteredFeedback(): Action[] { + const result: Action[] = []; + this.registeredFeedback.forEach(actions => result.push(...actions)); + return result; + } + + getRegisteredFeedbackEmitters(action: Action): IFeedbackEmitter[] { + const result: IFeedbackEmitter[] = []; + this.registeredFeedback.forEach((actions, emitter) => { + if (actions.includes(action)) { + result.push(emitter); + } + }); + return result; + } + + getFeedbackCommands(): Command[] { + return this.getRegisteredFeedback() + .flatMap(action => this.actionToCommands(action)) + .sort((left, right) => getFeedbackRank(left) - getFeedbackRank(right)); + } + + async applyFeedbackCommands(context: CommandExecutionContext): Promise { + const feedbackCommands = this.getFeedbackCommands() ?? []; + if (feedbackCommands?.length > 0) { + const results = feedbackCommands.map(command => command.execute(context)); + await Promise.all(results); + } + } + + protected actionToCommands(action: Action): ICommand[] { + return ( + this.actionHandlerRegistry + .get(action.kind) + .filter(toTypeGuard(CommandActionHandler)) + .map(handler => handler.handle(action)) ?? [] + ); + } + + createEmitter(): FeedbackEmitter { + return new FeedbackEmitter(this); + } + + protected async dispatchFeedback(actions: Action[], feedbackEmitter: IFeedbackEmitter): Promise { + try { + if (this.isDisposed) { + return; + } + await this.actionDispatcher.dispatchAll(actions); + this.logger.info(this, `Dispatched feedback actions for ${feedbackEmitter}`); + } catch (reason) { + this.logger.error(this, 'Failed to dispatch feedback actions', reason); + } + } + + @preDestroy() + dispose(): void { + this.registeredFeedback.clear(); + this.isDisposed = true; + } +} diff --git a/packages/client/src/base/feedback/feedback-emitter.ts b/packages/client/src/base/feedback/feedback-emitter.ts index f39a8bf2a..eeed779d1 100644 --- a/packages/client/src/base/feedback/feedback-emitter.ts +++ b/packages/client/src/base/feedback/feedback-emitter.ts @@ -14,8 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { Action, Disposable, arrayOf } from '@eclipse-glsp/sprotty'; -import { IFeedbackActionDispatcher, IFeedbackEmitter, MaybeActions } from './feedback-action-dispatcher'; +import { Action, Disposable, MaybeActions, arrayOf } from '@eclipse-glsp/sprotty'; +import type { IFeedbackActionDispatcher, IFeedbackEmitter } from './feedback-action-dispatcher'; // counter for internal id, mainly useful for debugging let idCounter = 0; diff --git a/packages/client/src/features/context-menu/delete-element-context-menu.ts b/packages/client/src/features/context-menu/delete-element-context-menu.ts index 647096172..c3dfbece4 100644 --- a/packages/client/src/features/context-menu/delete-element-context-menu.ts +++ b/packages/client/src/features/context-menu/delete-element-context-menu.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2019-2023 EclipseSource and others. + * 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 @@ -13,27 +13,31 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { DeleteElementOperation, GModelRoot, IContextMenuItemProvider, MenuItem, Point, TYPES } from '@eclipse-glsp/sprotty'; import { inject, injectable } from 'inversify'; -import { DeleteElementOperation, IContextMenuItemProvider, MenuItem, Point, GModelRoot, TYPES } from '@eclipse-glsp/sprotty'; import { EditorContextService, EditorContextServiceProvider } from '../../base/editor-context-service'; @injectable() export class DeleteElementContextMenuItemProvider implements IContextMenuItemProvider { + /** @deprecated No longer used. The {@link EditorContextService} is now directly injected.*/ + // eslint-disable-next-line deprecation/deprecation @inject(TYPES.IEditorContextServiceProvider) editorContextServiceProvider: EditorContextServiceProvider; + @inject(EditorContextService) + protected editorContext: EditorContextService; + async getItems(_root: Readonly, _lastMousePosition?: Point): Promise { - const editorContextService = await this.editorContextServiceProvider(); - return [this.createDeleteMenuItem(editorContextService)]; + return [this.createDeleteMenuItem()]; } - protected createDeleteMenuItem(editorContextService: EditorContextService): MenuItem { + protected createDeleteMenuItem(): MenuItem { return { id: 'delete', label: 'Delete', sortString: 'd', group: 'edit', - actions: [DeleteElementOperation.create(editorContextService.selectedElements.map(e => e.id))], - isEnabled: () => !editorContextService.isReadonly && editorContextService.selectedElements.length > 0 + actions: [DeleteElementOperation.create(this.editorContext.selectedElements.map(e => e.id))], + isEnabled: () => !this.editorContext.isReadonly && this.editorContext.selectedElements.length > 0 }; } } diff --git a/packages/client/src/features/helper-lines/helper-line-manager-default.ts b/packages/client/src/features/helper-lines/helper-line-manager-default.ts deleted file mode 100644 index 4a30d877d..000000000 --- a/packages/client/src/features/helper-lines/helper-line-manager-default.ts +++ /dev/null @@ -1,250 +0,0 @@ -/******************************************************************************** - * 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 { IHelperLineManager } from './helper-line-manager'; -import { Direction, HelperLine, HelperLineType, isHelperLine } from './model'; - -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 = { - elementLines: DEFAULT_ELEMENT_LINES, - viewportLines: DEFAULT_VIEWPORT_LINES, - alignmentEpsilon: DEFAULT_EPSILON, - alignmentElementFilter: DEFAULT_ALIGNABLE_ELEMENT_FILTER, - minimumMoveDelta: DEFAULT_MOVE_DELTA, - debug: DEFAULT_DEBUG -}; - -@injectable() -export class HelperLineManager implements IActionHandler, ISelectionListener, IHelperLineManager { - @inject(TYPES.IFeedbackActionDispatcher) protected feedbackDispatcher: IFeedbackActionDispatcher; - @inject(SelectionService) protected selectionService: SelectionService; - @optional() @inject(TYPES.IHelperLineOptions) protected userOptions?: IHelperLineOptions; - @optional() @inject(TYPES.Grid) protected grid?: Grid; - - protected options: Required; - protected feedback: FeedbackEmitter; - - @postConstruct() - 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, 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.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; - }; -} diff --git a/packages/client/src/features/helper-lines/helper-line-manager.ts b/packages/client/src/features/helper-lines/helper-line-manager.ts index 5c00c8850..e9391136a 100644 --- a/packages/client/src/features/helper-lines/helper-line-manager.ts +++ b/packages/client/src/features/helper-lines/helper-line-manager.ts @@ -13,8 +13,38 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { GModelElement, Vector } from '@eclipse-glsp/sprotty'; -import { Direction } from './model'; +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 { /** @@ -35,3 +65,205 @@ export interface IHelperLineManager { */ 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 = { + elementLines: DEFAULT_ELEMENT_LINES, + viewportLines: DEFAULT_VIEWPORT_LINES, + alignmentEpsilon: DEFAULT_EPSILON, + alignmentElementFilter: DEFAULT_ALIGNABLE_ELEMENT_FILTER, + minimumMoveDelta: DEFAULT_MOVE_DELTA, + debug: DEFAULT_DEBUG +}; + +@injectable() +export class HelperLineManager implements IActionHandler, ISelectionListener, IHelperLineManager { + @inject(TYPES.IFeedbackActionDispatcher) protected feedbackDispatcher: IFeedbackActionDispatcher; + @inject(SelectionService) protected selectionService: SelectionService; + @optional() @inject(TYPES.IHelperLineOptions) protected userOptions?: IHelperLineOptions; + @optional() @inject(TYPES.Grid) protected grid?: Grid; + + protected options: Required; + protected feedback: FeedbackEmitter; + + @postConstruct() + 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, 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.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; + }; +} diff --git a/packages/client/src/features/helper-lines/helper-line-module.ts b/packages/client/src/features/helper-lines/helper-line-module.ts index d12b56f42..0b02bb814 100644 --- a/packages/client/src/features/helper-lines/helper-line-module.ts +++ b/packages/client/src/features/helper-lines/helper-line-module.ts @@ -27,7 +27,7 @@ import { import { SetBoundsFeedbackAction } from '../bounds/set-bounds-feedback-command'; import { MoveFinishedEventAction, MoveInitializedEventAction } from '../tools/change-bounds/change-bounds-tool-feedback'; import { DrawHelperLinesFeedbackCommand, RemoveHelperLinesFeedbackCommand } from './helper-line-feedback'; -import { HelperLineManager } from './helper-line-manager-default'; +import { HelperLineManager } from './helper-line-manager'; import { HELPER_LINE, HelperLine, SELECTION_BOUNDS, SelectionBounds } from './model'; import { HelperLineView, SelectionBoundsView } from './view'; diff --git a/packages/client/src/features/hints/type-hint-provider.spec.ts b/packages/client/src/features/hints/type-hint-provider.spec.ts index 228c33b7e..38d254918 100644 --- a/packages/client/src/features/hints/type-hint-provider.spec.ts +++ b/packages/client/src/features/hints/type-hint-provider.spec.ts @@ -36,7 +36,7 @@ import { expect } from 'chai'; import { Container } from 'inversify'; import * as sinon from 'sinon'; import { GLSPActionDispatcher } from '../../base/action-dispatcher'; -import { FeedbackActionDispatcher } from '../../base/feedback/feedback-action-dispatcher-default'; +import { FeedbackActionDispatcher } from '../../base/feedback/feedback-action-dispatcher'; import { FeedbackEmitter } from '../../base/feedback/feedback-emitter'; import { GEdge } from '../../model'; import { isResizable } from '../change-bounds/model'; diff --git a/packages/client/src/features/navigation/navigation-action-handler.ts b/packages/client/src/features/navigation/navigation-action-handler.ts index b255d3399..5dd308e68 100644 --- a/packages/client/src/features/navigation/navigation-action-handler.ts +++ b/packages/client/src/features/navigation/navigation-action-handler.ts @@ -38,7 +38,7 @@ import { hasStringProp } from '@eclipse-glsp/sprotty'; import { inject, injectable } from 'inversify'; -import { EditorContextServiceProvider } from '../../base/editor-context-service'; +import { EditorContextService, EditorContextServiceProvider } from '../../base/editor-context-service'; import { NavigationTargetResolver } from './navigation-target-resolver'; /** @@ -140,9 +140,15 @@ export class NavigationActionHandler implements IActionHandler { @inject(TYPES.ILogger) protected logger: ILogger; @inject(TYPES.IActionDispatcher) protected dispatcher: IActionDispatcher; + /** @deprecated No longer in used. The {@link ActionHandlerRegistry} is now directly injected */ + // eslint-disable-next-line deprecation/deprecation @inject(TYPES.ActionHandlerRegistryProvider) protected actionHandlerRegistryProvider: () => Promise; + /** @deprecated No longer in used. The {@link EditorContextService} is now directly injected */ + // eslint-disable-next-line deprecation/deprecation @inject(TYPES.IEditorContextServiceProvider) protected editorContextService: EditorContextServiceProvider; + @inject(ActionHandlerRegistry) protected actionHandlerRegistry: ActionHandlerRegistry; @inject(NavigationTargetResolver) protected resolver: NavigationTargetResolver; + @inject(EditorContextService) protected editorContext: EditorContextService; handle(action: Action): ICommand | Action | void { if (NavigateAction.is(action)) { @@ -158,8 +164,7 @@ export class NavigationActionHandler implements IActionHandler { protected async handleNavigateAction(action: NavigateAction): Promise { try { - const editorContextService = await this.editorContextService(); - const editorContext = editorContextService.get(action.args); + const editorContext = this.editorContext.get(action.args); const response = await this.dispatcher.request( RequestNavigationTargetsAction.create({ targetTypeId: action.targetTypeId, editorContext }) ); @@ -250,8 +255,7 @@ export class NavigationActionHandler implements IActionHandler { } protected async handleNavigateToExternalTarget(action: NavigateToExternalTargetAction): Promise { - const registry = await this.actionHandlerRegistryProvider(); - const handlers = registry.get(NavigateToExternalTargetAction.KIND); + const handlers = this.actionHandlerRegistry.get(NavigateToExternalTargetAction.KIND); if (handlers.length === 1) { // we are the only handler so we know nobody took care of it this.warnAboutFailedNavigation('Could not resolve or navigate to target', action.target); diff --git a/packages/client/src/features/tools/base-tools.ts b/packages/client/src/features/tools/base-tools.ts index 4b3fcb181..164f4f95c 100644 --- a/packages/client/src/features/tools/base-tools.ts +++ b/packages/client/src/features/tools/base-tools.ts @@ -13,10 +13,10 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { Action, Disposable, DisposableCollection, IActionDispatcher, IActionHandler, TYPES } from '@eclipse-glsp/sprotty'; +import { Action, Disposable, DisposableCollection, IActionDispatcher, IActionHandler, MaybeActions, TYPES } from '@eclipse-glsp/sprotty'; import { inject, injectable } from 'inversify'; import { EditorContextService } from '../../base/editor-context-service'; -import { IFeedbackActionDispatcher, IFeedbackEmitter, MaybeActions } from '../../base/feedback/feedback-action-dispatcher'; +import { IFeedbackActionDispatcher, IFeedbackEmitter } from '../../base/feedback/feedback-action-dispatcher'; import { FeedbackEmitter } from '../../base/feedback/feedback-emitter'; import { EnableToolsAction, Tool } from '../../base/tool-manager/tool'; import { GLSPKeyTool } from '../../base/view/key-tool'; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 49d62a4af..bc980f3a2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -27,7 +27,6 @@ export * from './base/drag-aware-mouse-listener'; export * from './base/editor-context-service'; export * from './base/feedback/css-feedback'; export * from './base/feedback/feedback-action-dispatcher'; -export * from './base/feedback/feedback-action-dispatcher-default'; export * from './base/feedback/feedback-command'; export * from './base/feedback/feedback-emitter'; export * from './base/feedback/set-model-command'; @@ -145,7 +144,6 @@ export * from './features/grid/grid-snapper'; export * from './features/grid/grid-style'; export * from './features/helper-lines/helper-line-feedback'; export * from './features/helper-lines/helper-line-manager'; -export * from './features/helper-lines/helper-line-manager-default'; export * from './features/helper-lines/helper-line-module'; export * from './features/helper-lines/model'; export * from './features/helper-lines/view'; diff --git a/packages/glsp-sprotty/src/types.ts b/packages/glsp-sprotty/src/types.ts index 27ac94cc1..95b4381e9 100644 --- a/packages/glsp-sprotty/src/types.ts +++ b/packages/glsp-sprotty/src/types.ts @@ -13,6 +13,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +/* eslint-disable deprecation/deprecation */ import { TYPES as SprottyTYPES } from 'sprotty'; /** @@ -21,10 +22,12 @@ import { TYPES as SprottyTYPES } from 'sprotty'; */ const IGModelRootListener = Symbol('IGModelRootListener'); - export const TYPES = { ...SprottyTYPES, + /** @deprecated Using async providers for container service retrieval is discouraged. Use the `LazyInjector` instead */ + ActionHandlerRegistryProvider: SprottyTYPES.ActionHandlerRegistryProvider, IAsyncClipboardService: Symbol('IAsyncClipboardService'), + /** @deprecated Async provider is no longer necessary. Either directly inject or use `LazyInjector`*/ IEditorContextServiceProvider: Symbol('IEditorContextProvider'), IFeedbackActionDispatcher: Symbol('IFeedbackActionDispatcher'), IToolFactory: Symbol('Factory'), @@ -33,7 +36,6 @@ export const TYPES = { IMovementOptions: Symbol('IMovementOptions'), ISelectionListener: Symbol('ISelectionListener'), /** @deprecated Use {@link TYPES.IGModelRootListener} instead */ - // eslint-disable-next-line deprecation/deprecation ISModelRootListener: IGModelRootListener, IGModelRootListener: IGModelRootListener, IContextMenuProvider: Symbol('IContextMenuProvider'), diff --git a/packages/protocol/src/utils/type-util.spec.ts b/packages/protocol/src/utils/type-util.spec.ts index 8922a75cb..fbc34a4d2 100644 --- a/packages/protocol/src/utils/type-util.spec.ts +++ b/packages/protocol/src/utils/type-util.spec.ts @@ -14,7 +14,17 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { expect } from 'chai'; -import { AnyObject, hasArrayProp, hasBooleanProp, hasFunctionProp, hasNumberProp, hasObjectProp, hasStringProp } from './type-util'; +import { Action } from '../action-protocol/base-protocol'; +import { + AnyObject, + hasArrayProp, + hasBooleanProp, + hasFunctionProp, + hasNumberProp, + hasObjectProp, + hasStringProp, + MaybeActions +} from './type-util'; describe('TypeUtil', () => { describe('AnyObject', () => { @@ -192,4 +202,25 @@ describe('TypeUtil', () => { expect(hasArrayProp({ someProp: ['some', 'prop'] }, 'someProp', true)).to.be.true; }); }); + + describe('MaybeActions', () => { + describe('asArray', () => { + it('should return an empty array if undefined is provided', () => { + expect(MaybeActions.asArray(undefined)).to.deep.equal([]); + }); + it('should return an array with a single action if a single action is provided', () => { + const action = { kind: 'someAction' }; + expect(MaybeActions.asArray(action)).to.deep.equal([action]); + }); + it('should return the same array if an array of actions is provided', () => { + const actions = [{ kind: 'action1' }, { kind: 'action2' }]; + expect(MaybeActions.asArray(actions)).to.deep.equal(actions); + }); + it('should return the result of the function if a function returning actions is provided', () => { + const actions = [{ kind: 'action1' }, { kind: 'action2' }]; + const actionFunction = (): Action[] => actions; + expect(MaybeActions.asArray(actionFunction)).to.deep.equal(actions); + }); + }); + }); }); diff --git a/packages/protocol/src/utils/type-util.ts b/packages/protocol/src/utils/type-util.ts index f157a5f0d..78890ac55 100644 --- a/packages/protocol/src/utils/type-util.ts +++ b/packages/protocol/src/utils/type-util.ts @@ -14,6 +14,9 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { Action } from '../action-protocol/base-protocol'; +import { asArray as toArray } from '../utils/array-util'; + /** Helper type to describe any defined object*/ export type AnyObject = object; @@ -101,6 +104,15 @@ export function call(maybeFun: MaybeFunction, ...args: any[]): T { return typeof maybeFun === 'function' ? (maybeFun as SafeFunction)(...args) : maybeFun; } +export type MaybeActions = MaybeFunction; + +export namespace MaybeActions { + export function asArray(actions?: MaybeActions): Action[] { + const cleanup = actions ? call(actions) : []; + return cleanup ? toArray(cleanup) : []; + } +} + /** * Validates whether the given object has a property of type `string` with the given key. * @param object The object that should be validated