From 545a20ca017d2efdf7bfc15fddea7f89197916c5 Mon Sep 17 00:00:00 2001 From: Martin Fleck Date: Tue, 21 Nov 2023 00:23:23 +0100 Subject: [PATCH] Support rendering of ghost element when creating elements (#301) * Support rendering of ghost element when creating elements - Introduce new element template module -- Adding new elements to the diagram based on templates -- Removing templates again - Support local bounds calculation for client-only added elements -- Must trigger RequestBounds for hidden view calculation -- Must not modify main view until ComputedBounds response is handled -- Mark ResizeHandles as not to be considered for bounds calculation -- Fix positioning of decorations for hidden view calculation - Add ghost element extension to tool palette -- Optional ghost element field in the trigger node creation action -- Ghost element is added as feedback to the mouse cursor -- If ghost element is dynamic, templates (palette items) are reloaded - Bonus: Re-calculate local bounds for resize behavior Fixes https://github.com/eclipse-glsp/glsp/issues/1159 --- CHANGELOG.md | 10 +- packages/client/css/ghost-element.css | 25 ++++ packages/client/css/glsp-sprotty.css | 5 + packages/client/src/base/args-feature.ts | 14 ++- .../client/src/base/feedback/css-feedback.ts | 5 +- packages/client/src/default-modules.ts | 2 + .../keyboard-tool-palette-module.ts | 16 ++- .../keyboard-tool-palette.ts | 31 +++-- .../src/features/bounds/bounds-module.ts | 6 + .../bounds/glsp-hidden-bounds-updater.ts | 18 +-- .../src/features/bounds/local-bounds.ts | 91 +++++++++++++++ .../bounds/set-bounds-feedback-command.ts | 70 +++++++++++ .../src/features/change-bounds/model.ts | 5 +- .../features/decoration/decoration-placer.ts | 6 +- .../element-template/add-template-element.ts | 109 +++++++++++++++++ .../element-template-module.ts | 24 ++++ ...ouse-tracking-element-position-listener.ts | 110 ++++++++++++++++++ .../remove-template-element.ts | 56 +++++++++ .../tool-palette/tool-palette-module.ts | 4 +- .../src/features/tool-palette/tool-palette.ts | 56 ++++++--- .../tools/change-bounds/change-bounds-tool.ts | 22 ++-- .../src/features/tools/change-bounds/view.tsx | 2 +- .../node-creation/node-creation-module.ts | 14 ++- .../tools/node-creation/node-creation-tool.ts | 64 +++++++--- packages/client/src/index.ts | 4 + .../src/action-protocol/tool-palette.ts | 24 +++- .../protocol/src/action-protocol/types.ts | 6 + .../protocol/src/utils/disposable.spec.ts | 7 ++ packages/protocol/src/utils/disposable.ts | 11 +- 29 files changed, 730 insertions(+), 87 deletions(-) create mode 100644 packages/client/css/ghost-element.css create mode 100644 packages/client/src/features/bounds/local-bounds.ts create mode 100644 packages/client/src/features/bounds/set-bounds-feedback-command.ts create mode 100644 packages/client/src/features/element-template/add-template-element.ts create mode 100644 packages/client/src/features/element-template/element-template-module.ts create mode 100644 packages/client/src/features/element-template/mouse-tracking-element-position-listener.ts create mode 100644 packages/client/src/features/element-template/remove-template-element.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c1736b58..5ebc9420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # Eclipse GLSP Client Changelog -## v2.0.0 - 14/10/2023 +## v2.10.0 - active + +### Changes + +- [diagram] Remove unused handleSetContextActions from ToolPalette [#301](https://github.com/eclipse-glsp/glsp-client/pull/301) + +### Breaking Changes + +## [v2.0.0 - 14/10/2023](https://github.com/eclipse-glsp/glsp-client/releases/tag/v2.0.0) ### Changes diff --git a/packages/client/css/ghost-element.css b/packages/client/css/ghost-element.css new file mode 100644 index 00000000..2ced7565 --- /dev/null +++ b/packages/client/css/ghost-element.css @@ -0,0 +1,25 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +.ghost-element { + /* we are a true ghost so we do not want to be used as a target for any mouse event */ + pointer-events: none; + opacity: 0.8; +} + +.ghost-element.hidden { + display: none; +} diff --git a/packages/client/css/glsp-sprotty.css b/packages/client/css/glsp-sprotty.css index 29d5740f..2fe87a97 100644 --- a/packages/client/css/glsp-sprotty.css +++ b/packages/client/css/glsp-sprotty.css @@ -72,6 +72,11 @@ fill: #1d80d1; } +.sprotty-hidden .sprotty-resize-handle { + /** resize handles should not be considered as part of the elements bounds */ + display: none; +} + .sprotty-edge { fill: none; stroke-width: 1.5px; diff --git a/packages/client/src/base/args-feature.ts b/packages/client/src/base/args-feature.ts index 680b6fc3..6e958c41 100644 --- a/packages/client/src/base/args-feature.ts +++ b/packages/client/src/base/args-feature.ts @@ -28,10 +28,20 @@ export interface ArgsAware { args?: Args; } -export function isArgsAware(element: GModelElement): element is GModelElement & ArgsAware { - return element.hasFeature(argsFeature); +export function isArgsAware(element?: GModelElement): element is GModelElement & ArgsAware { + return element !== undefined && element.hasFeature(argsFeature); } export function hasArgs(element?: GModelElement): element is GModelElement & Required { return element !== undefined && isArgsAware(element) && element.args !== undefined; } + +export function ensureArgs(element?: GModelElement): element is GModelElement & Required { + if (!isArgsAware(element)) { + return false; + } + if (element.args === undefined) { + element.args = {}; + } + return true; +} diff --git a/packages/client/src/base/feedback/css-feedback.ts b/packages/client/src/base/feedback/css-feedback.ts index 7d1cd9db..b13b976a 100644 --- a/packages/client/src/base/feedback/css-feedback.ts +++ b/packages/client/src/base/feedback/css-feedback.ts @@ -13,8 +13,8 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { inject, injectable } from 'inversify'; import { Action, CommandExecutionContext, GModelElement, GModelRoot, TYPES, hasArrayProp } from '@eclipse-glsp/sprotty'; +import { inject, injectable } from 'inversify'; import { addCssClasses, getElements, removeCssClasses } from '../../utils/gmodel-util'; import { FeedbackCommand } from './feedback-command'; @@ -69,6 +69,9 @@ export class ModifyCssFeedbackCommand extends FeedbackCommand { } } +export const CSS_GHOST_ELEMENT = 'ghost-element'; +export const CSS_HIDDEN = 'hidden'; + export enum CursorCSS { DEFAULT = 'default-mode', OVERLAP_FORBIDDEN = 'overlap-forbidden-mode', diff --git a/packages/client/src/default-modules.ts b/packages/client/src/default-modules.ts index 4087b01a..8d07becf 100644 --- a/packages/client/src/default-modules.ts +++ b/packages/client/src/default-modules.ts @@ -39,6 +39,7 @@ import { commandPaletteModule } from './features/command-palette/command-palette import { contextMenuModule } from './features/context-menu/context-menu-module'; 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 { typeHintsModule } from './features/hints/type-hints-module'; import { hoverModule } from './features/hover/hover-module'; @@ -90,6 +91,7 @@ export const DEFAULT_MODULES = [ edgeCreationToolModule, edgeEditToolModule, deletionToolModule, + elementTemplateModule, nodeCreationToolModule, changeBoundsToolModule, marqueeSelectionToolModule, diff --git a/packages/client/src/features/accessibility/keyboard-tool-palette/keyboard-tool-palette-module.ts b/packages/client/src/features/accessibility/keyboard-tool-palette/keyboard-tool-palette-module.ts index 6928566c..9f26fb09 100644 --- a/packages/client/src/features/accessibility/keyboard-tool-palette/keyboard-tool-palette-module.ts +++ b/packages/client/src/features/accessibility/keyboard-tool-palette/keyboard-tool-palette-module.ts @@ -15,11 +15,19 @@ ********************************************************************************/ import '../../../../css/keyboard-tool-palette.css'; -import { configureActionHandler, TYPES, bindAsService, BindingContext, FeatureModule } from '@eclipse-glsp/sprotty'; +import { + BindingContext, + FeatureModule, + SetModelAction, + TYPES, + UpdateModelAction, + bindAsService, + configureActionHandler +} from '@eclipse-glsp/sprotty'; +import { EnableDefaultToolsAction } from '../../../base/tool-manager/tool'; import { EnableToolPaletteAction } from '../../tool-palette/tool-palette'; -import { KeyboardToolPalette } from './keyboard-tool-palette'; import { FocusDomAction } from '../actions'; -import { EnableDefaultToolsAction } from '../../../base/tool-manager/tool'; +import { KeyboardToolPalette } from './keyboard-tool-palette'; export const keyboardToolPaletteModule = new FeatureModule((bind, unbind, isBound, rebind) => { const context = { bind, unbind, isBound, rebind }; @@ -32,4 +40,6 @@ export function configureKeyboardToolPaletteTool(context: BindingContext): void configureActionHandler(context, EnableDefaultToolsAction.KIND, KeyboardToolPalette); configureActionHandler(context, FocusDomAction.KIND, KeyboardToolPalette); configureActionHandler(context, EnableToolPaletteAction.KIND, KeyboardToolPalette); + configureActionHandler(context, UpdateModelAction.KIND, KeyboardToolPalette); + configureActionHandler(context, SetModelAction.KIND, KeyboardToolPalette); } diff --git a/packages/client/src/features/accessibility/keyboard-tool-palette/keyboard-tool-palette.ts b/packages/client/src/features/accessibility/keyboard-tool-palette/keyboard-tool-palette.ts index 93dda95a..a20e0d27 100644 --- a/packages/client/src/features/accessibility/keyboard-tool-palette/keyboard-tool-palette.ts +++ b/packages/client/src/features/accessibility/keyboard-tool-palette/keyboard-tool-palette.ts @@ -13,35 +13,35 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable } from 'inversify'; import { - ICommand, - SetUIExtensionVisibilityAction, Action, + ICommand, PaletteItem, RequestContextActions, RequestMarkersAction, SetContextActions, + SetUIExtensionVisibilityAction, TriggerNodeCreationAction } from '@eclipse-glsp/sprotty'; +import { injectable } from 'inversify'; import { KeyCode, matchesKeystroke } from 'sprotty/lib/utils/keyboard'; -import { MouseDeleteTool } from '../../tools/deletion/delete-tool'; -import { MarqueeMouseTool } from '../../tools/marquee-selection/marquee-mouse-tool'; +import { EnableDefaultToolsAction, EnableToolsAction } from '../../../base/tool-manager/tool'; import { - createIcon, - changeCodiconClass, - createToolGroup, + EnableToolPaletteAction, ToolPalette, + changeCodiconClass, compare, - EnableToolPaletteAction + createIcon, + createToolGroup } from '../../tool-palette/tool-palette'; -import { KeyboardNodeGridMetadata } from '../keyboard-grid/constants'; +import { MouseDeleteTool } from '../../tools/deletion/delete-tool'; +import { MarqueeMouseTool } from '../../tools/marquee-selection/marquee-mouse-tool'; import { FocusDomAction } from '../actions'; import { EdgeAutocompletePaletteMetadata } from '../edge-autocomplete/edge-autocomplete-palette'; -import { EnableDefaultToolsAction, EnableToolsAction } from '../../../base/tool-manager/tool'; -import { ShowToastMessageAction } from '../toast/toast-handler'; import { ElementNavigatorKeyListener } from '../element-navigation/diagram-navigation-tool'; +import { KeyboardNodeGridMetadata } from '../keyboard-grid/constants'; import * as messages from '../toast/messages.json'; +import { ShowToastMessageAction } from '../toast/toast-handler'; const SEARCH_ICON_ID = 'search'; const PALETTE_ICON_ID = 'symbol-color'; @@ -119,7 +119,7 @@ export class KeyboardToolPalette extends ToolPalette { } override handle(action: Action): ICommand | Action | void { - if (action.kind === EnableToolPaletteAction.KIND) { + if (EnableToolPaletteAction.is(action)) { const requestAction = RequestContextActions.create({ contextId: ToolPalette.ID, editorContext: { @@ -134,9 +134,6 @@ export class KeyboardToolPalette extends ToolPalette { ]); } }); - } else if (action.kind === EnableDefaultToolsAction.KIND) { - this.changeActiveButton(); - this.restoreFocus(); } else if (FocusDomAction.is(action) && action.id === ToolPalette.ID) { if (this.containerElement.contains(document.activeElement)) { this.toggleShortcutVisibility(); @@ -144,6 +141,8 @@ export class KeyboardToolPalette extends ToolPalette { this.showShortcuts(); } this.containerElement.focus(); + } else { + super.handle(action); } } diff --git a/packages/client/src/features/bounds/bounds-module.ts b/packages/client/src/features/bounds/bounds-module.ts index 0b0d817d..239472ef 100644 --- a/packages/client/src/features/bounds/bounds-module.ts +++ b/packages/client/src/features/bounds/bounds-module.ts @@ -30,6 +30,8 @@ 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 { VBoxLayouterExt } from './vbox-layout'; export const boundsModule = new FeatureModule((bind, _unbind, isBound, _rebind) => { @@ -38,6 +40,10 @@ export const boundsModule = new FeatureModule((bind, _unbind, isBound, _rebind) configureCommand(context, RequestBoundsCommand); bind(HiddenBoundsUpdater).toSelf().inSingletonScope(); bindAsService(context, TYPES.HiddenVNodePostprocessor, GLSPHiddenBoundsUpdater); + + configureCommand(context, LocalComputedBoundsCommand); + configureCommand(context, SetBoundsFeebackCommand); + bind(TYPES.Layouter).to(LayouterExt).inSingletonScope(); bind(TYPES.LayoutRegistry).to(LayoutRegistry).inSingletonScope(); diff --git a/packages/client/src/features/bounds/glsp-hidden-bounds-updater.ts b/packages/client/src/features/bounds/glsp-hidden-bounds-updater.ts index 5e90fa5a..115776e9 100644 --- a/packages/client/src/features/bounds/glsp-hidden-bounds-updater.ts +++ b/packages/client/src/features/bounds/glsp-hidden-bounds-updater.ts @@ -14,22 +14,23 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { inject, injectable, optional } from 'inversify'; -import { VNode } from 'snabbdom'; import { Action, ComputedBoundsAction, Deferred, EdgeRouterRegistry, ElementAndRoutingPoints, + GModelElement, + GRoutableElement, HiddenBoundsUpdater, IActionDispatcher, RequestAction, - ResponseAction, - GModelElement, - GRoutableElement + ResponseAction } from '@eclipse-glsp/sprotty'; +import { inject, injectable, optional } from 'inversify'; +import { VNode } from 'snabbdom'; import { calcElementAndRoute, isRoutable } from '../../utils/gmodel-util'; +import { LocalComputedBoundsAction, LocalRequestBoundsAction } from './local-bounds'; /** * Grabs the bounds from hidden SVG DOM elements, applies layouts, collects routes and fires {@link ComputedBoundsAction}s. @@ -56,7 +57,7 @@ export class GLSPHiddenBoundsUpdater extends HiddenBoundsUpdater { const actions = this.captureActions(() => super.postUpdate(cause)); actions .filter(action => ComputedBoundsAction.is(action)) - .forEach(action => this.actionDispatcher.dispatch(this.enhanceAction(action as ComputedBoundsAction))); + .forEach(action => this.actionDispatcher.dispatch(this.enhanceAction(action as ComputedBoundsAction, cause))); this.element2route = []; } @@ -72,7 +73,10 @@ export class GLSPHiddenBoundsUpdater extends HiddenBoundsUpdater { } } - protected enhanceAction(action: ComputedBoundsAction): ComputedBoundsAction { + protected enhanceAction(action: ComputedBoundsAction, cause?: Action): ComputedBoundsAction { + if (LocalRequestBoundsAction.is(cause)) { + LocalComputedBoundsAction.mark(action); + } action.routes = this.element2route.length === 0 ? undefined : this.element2route; return action; } diff --git a/packages/client/src/features/bounds/local-bounds.ts b/packages/client/src/features/bounds/local-bounds.ts new file mode 100644 index 00000000..82e6c8ac --- /dev/null +++ b/packages/client/src/features/bounds/local-bounds.ts @@ -0,0 +1,91 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ +import { + Action, + ActionDispatcher, + Command, + CommandExecutionContext, + CommandResult, + CommandReturn, + ComputedBoundsAction, + ComputedBoundsApplicator, + GModelRoot, + GModelRootSchema, + RequestBoundsAction, + TYPES +} from '@eclipse-glsp/sprotty'; +import { inject, injectable } from 'inversify'; +import { ServerAction } from '../../base/model/glsp-model-source'; + +export namespace LocalRequestBoundsAction { + export function is(object: unknown): object is RequestBoundsAction { + return RequestBoundsAction.is(object) && !ServerAction.is(object); + } + + export function fromCommand(context: CommandExecutionContext, actionDispatcher: ActionDispatcher, cause?: Action): CommandResult { + // do not modify the main model (modelChanged = false) but request local bounds calculation on hidden model + actionDispatcher.dispatch(RequestBoundsAction.create(context.root as unknown as GModelRootSchema)); + return { + model: context.root, + modelChanged: false, + cause + }; + } +} + +export namespace LocalComputedBoundsAction { + export function is(object: unknown): object is RequestBoundsAction { + return ComputedBoundsAction.is(object) && ServerAction.is(object); + } + + export function mark(action: ComputedBoundsAction): ComputedBoundsAction { + // mimic: we mark the computed bounds action as coming from the server so it is not sent to the server and handled locally + ServerAction.mark(action); + return action; + } +} + +@injectable() +export class LocalComputedBoundsCommand extends Command { + static readonly KIND: string = ComputedBoundsAction.KIND; + + @inject(ComputedBoundsApplicator) protected readonly computedBoundsApplicator: ComputedBoundsApplicator; + + constructor(@inject(TYPES.Action) readonly action: ComputedBoundsAction) { + super(); + } + + override execute(context: CommandExecutionContext): GModelRoot | CommandResult { + if (LocalComputedBoundsAction.is(this.action)) { + // apply computed bounds from the hidden model and return updated model to render new main model + this.computedBoundsApplicator.apply(context.root as unknown as GModelRootSchema, this.action); + return context.root; + } + // computed bounds action from server -> we do not care and do not trigger any update of the main model + return { + model: context.root, + modelChanged: false + }; + } + + override undo(context: CommandExecutionContext): CommandReturn { + return context.root; + } + + override redo(context: CommandExecutionContext): CommandReturn { + return context.root; + } +} diff --git a/packages/client/src/features/bounds/set-bounds-feedback-command.ts b/packages/client/src/features/bounds/set-bounds-feedback-command.ts new file mode 100644 index 00000000..c8cd3312 --- /dev/null +++ b/packages/client/src/features/bounds/set-bounds-feedback-command.ts @@ -0,0 +1,70 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ +import { + Action, + CommandExecutionContext, + CommandReturn, + ElementAndBounds, + SetBoundsAction, + SetBoundsCommand, + TYPES, + isLayoutContainer +} from '@eclipse-glsp/sprotty'; +import { inject, injectable } from 'inversify'; +import { GLSPActionDispatcher } from '../../base/action-dispatcher'; +import { FeedbackCommand } from '../../base/feedback/feedback-command'; +import { LocalRequestBoundsAction } from './local-bounds'; + +export interface SetBoundsFeedbackAction extends Omit { + kind: typeof SetBoundsFeedbackAction.KIND; +} + +export namespace SetBoundsFeedbackAction { + export const KIND = 'setBoundsFeedback'; + + export function is(object: any): object is SetBoundsFeedbackAction { + return Action.hasKind(object, KIND); + } + + export function create(bounds: ElementAndBounds[]): SetBoundsFeedbackAction { + return { kind: KIND, bounds }; + } +} + +@injectable() +export class SetBoundsFeebackCommand extends SetBoundsCommand implements FeedbackCommand { + static override readonly KIND: string = SetBoundsFeedbackAction.KIND; + + readonly priority: number = 0; + + @inject(TYPES.IActionDispatcher) protected actionDispatcher: GLSPActionDispatcher; + + override execute(context: CommandExecutionContext): CommandReturn { + super.execute(context); + + // apply set bounds as layout options so that when we calculate the bounds they are considered by the layouter + this.action.bounds.forEach(bounds => { + const element = context.root.index.getById(bounds.elementId); + if (element && isLayoutContainer(element)) { + const options = element.layoutOptions ?? {}; + options.prefHeight = bounds.newSize.height; + options.prefWidth = bounds.newSize.width; + element.layoutOptions = options; + } + }); + return LocalRequestBoundsAction.fromCommand(context, this.actionDispatcher, this.action); + } +} diff --git a/packages/client/src/features/change-bounds/model.ts b/packages/client/src/features/change-bounds/model.ts index 9f679a89..d0242419 100644 --- a/packages/client/src/features/change-bounds/model.ts +++ b/packages/client/src/features/change-bounds/model.ts @@ -16,10 +16,10 @@ import { BoundsAware, GChildElement, - Hoverable, - Locateable, GModelElement, GParentElement, + Hoverable, + Locateable, Selectable, hoverFeedbackFeature, isBoundsAware, @@ -72,7 +72,6 @@ export class SResizeHandle extends GChildElement implements Hoverable { } export function addResizeHandles(element: GParentElement): void { - removeResizeHandles(element); element.add(new SResizeHandle(ResizeHandleLocation.TopLeft)); element.add(new SResizeHandle(ResizeHandleLocation.TopRight)); element.add(new SResizeHandle(ResizeHandleLocation.BottomLeft)); diff --git a/packages/client/src/features/decoration/decoration-placer.ts b/packages/client/src/features/decoration/decoration-placer.ts index 4d1960dc..3fd21356 100644 --- a/packages/client/src/features/decoration/decoration-placer.ts +++ b/packages/client/src/features/decoration/decoration-placer.ts @@ -13,8 +13,8 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { Decoration, DecorationPlacer, GChildElement, GModelElement, GRoutableElement, Point, isSizeable } from '@eclipse-glsp/sprotty'; import { injectable } from 'inversify'; -import { Decoration, DecorationPlacer, GChildElement, Point, GModelElement, GRoutableElement, isSizeable } from '@eclipse-glsp/sprotty'; @injectable() export class GlspDecorationPlacer extends DecorationPlacer { @@ -26,8 +26,8 @@ export class GlspDecorationPlacer extends DecorationPlacer { } if (isSizeable(element)) { return { - x: GlspDecorationPlacer.DECORATION_OFFSET.x * element.bounds.width, - y: GlspDecorationPlacer.DECORATION_OFFSET.y * element.bounds.height + x: -GlspDecorationPlacer.DECORATION_OFFSET.x, + y: -GlspDecorationPlacer.DECORATION_OFFSET.y }; } return Point.ORIGIN; diff --git a/packages/client/src/features/element-template/add-template-element.ts b/packages/client/src/features/element-template/add-template-element.ts new file mode 100644 index 00000000..7bc81bac --- /dev/null +++ b/packages/client/src/features/element-template/add-template-element.ts @@ -0,0 +1,109 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +import { + Action, + CommandExecutionContext, + CommandResult, + ElementTemplate, + GChildElement, + GModelElementSchema, + TYPES, + distinctAdd, + remove +} from '@eclipse-glsp/sprotty'; +import { inject, injectable } from 'inversify'; +import { GLSPActionDispatcher } from '../../base/action-dispatcher'; +import { FeedbackCommand } from '../../base/feedback/feedback-command'; +import { isNotUndefined } from '../../utils/gmodel-util'; +import { LocalRequestBoundsAction } from '../bounds/local-bounds'; + +export interface AddTemplateElementsAction extends Action { + kind: typeof AddTemplateElementsAction.KIND; + templates: ElementTemplate[]; + addClasses?: string[]; + removeClasses?: string[]; +} + +export namespace AddTemplateElementsAction { + export const KIND = 'addTemplateElements'; + + export function create(options: Omit): AddTemplateElementsAction { + return { + kind: KIND, + ...options + }; + } +} + +export function getTemplateElementId(template: ElementTemplate): string { + return typeof template === 'string' ? `${template}_feedback` : template.id; +} + +@injectable() +export class AddTemplateElementsFeedbackCommand extends FeedbackCommand { + static readonly KIND = AddTemplateElementsAction.KIND; + + @inject(TYPES.IActionDispatcher) protected actionDispatcher: GLSPActionDispatcher; + + constructor(@inject(TYPES.Action) readonly action: AddTemplateElementsAction) { + super(); + } + + override execute(context: CommandExecutionContext): CommandResult { + this.action.templates + .map(template => templateToSchema(template, context)) + .filter(isNotUndefined) + .map(schema => context.modelFactory.createElement(schema)) + .map(element => this.applyRootCssClasses(element, this.action.addClasses, this.action.removeClasses)) + .forEach(templateElement => context.root.add(templateElement)); + return LocalRequestBoundsAction.fromCommand(context, this.actionDispatcher, this.action); + } + + protected applyRootCssClasses(element: GChildElement, addClasses?: string[], removeClasses?: string[]): GChildElement { + element.cssClasses = modifyCssClasses(element.cssClasses, addClasses, removeClasses); + return element; + } +} + +export function templateToSchema(template: ElementTemplate, context: CommandExecutionContext): GModelElementSchema | undefined { + if (typeof template === 'string') { + const element = context.root.index.getById(template); + const schema = element ? context.modelFactory.createSchema(element) : undefined; + if (schema) { + adaptSchemaIds(schema); + } + return schema; + } + return template; +} + +function adaptSchemaIds(schema: GModelElementSchema): GModelElementSchema { + schema.id = getTemplateElementId(schema.id); + schema.children?.forEach(child => adaptSchemaIds(child)); + return schema; +} + +function modifyCssClasses(source?: string[], toAdd?: string[], toRemove?: string[]): string[] { + const classes = source ?? []; + if (toAdd) { + distinctAdd(classes, ...toAdd); + } + if (toRemove) { + remove(classes, ...toRemove); + } + return classes; +} diff --git a/packages/client/src/features/element-template/element-template-module.ts b/packages/client/src/features/element-template/element-template-module.ts new file mode 100644 index 00000000..30082318 --- /dev/null +++ b/packages/client/src/features/element-template/element-template-module.ts @@ -0,0 +1,24 @@ +/******************************************************************************** + * Copyright (c) 2019-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 + ********************************************************************************/ +import { FeatureModule, configureCommand } from '@eclipse-glsp/sprotty'; +import { AddTemplateElementsFeedbackCommand } from './add-template-element'; +import { RemoveTemplateElementsFeedbackCommand } from './remove-template-element'; + +export const elementTemplateModule = new FeatureModule((bind, unbind, isBound, rebind) => { + const context = { bind, unbind, isBound, rebind }; + configureCommand(context, AddTemplateElementsFeedbackCommand); + configureCommand(context, RemoveTemplateElementsFeedbackCommand); +}); diff --git a/packages/client/src/features/element-template/mouse-tracking-element-position-listener.ts b/packages/client/src/features/element-template/mouse-tracking-element-position-listener.ts new file mode 100644 index 00000000..a79a4109 --- /dev/null +++ b/packages/client/src/features/element-template/mouse-tracking-element-position-listener.ts @@ -0,0 +1,110 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +import { Action, Disposable, GModelElement, ISnapper, MoveAction, Point, isBoundsAware, isMoveable } from '@eclipse-glsp/sprotty'; +import { injectable } from 'inversify'; +import { DragAwareMouseListener } from '../../base/drag-aware-mouse-listener'; +import { CSS_HIDDEN, ModifyCSSFeedbackAction } from '../../base/feedback/css-feedback'; +import { IFeedbackEmitter } from '../../base/feedback/feedback-action-dispatcher'; +import { Tool } from '../../base/tool-manager/tool'; +import { getAbsolutePosition } from '../../utils/viewpoint-util'; +import { + IMovementRestrictor, + createMovementRestrictionFeedback, + removeMovementRestrictionFeedback +} from '../change-bounds/movement-restrictor'; + +export interface PositioningTool extends Tool { + readonly snapper?: ISnapper; + readonly movementRestrictor?: IMovementRestrictor; + + registerFeedback(feedbackActions: Action[], feedbackEmitter?: IFeedbackEmitter, cleanupActions?: Action[]): Disposable; + deregisterFeedback(feedbackEmitter?: IFeedbackEmitter, cleanupActions?: Action[]): void; +} + +@injectable() +export class MouseTrackingElementPositionListener extends DragAwareMouseListener { + element?: GModelElement; + currentPosition?: Point; + + constructor( + protected elementId: string, + protected tool: PositioningTool, + protected cursorPosition: 'top-left' | 'middle' = 'top-left' + ) { + super(); + } + + override mouseMove(target: GModelElement, event: MouseEvent): Action[] { + super.mouseMove(target, event); + const element = target.root.index.getById(this.elementId); + this.element = element; + if (!element) { + return []; + } + + let newPosition = getAbsolutePosition(target, event); + if (this.cursorPosition === 'middle' && isBoundsAware(element)) { + newPosition = Point.subtract(newPosition, { x: element.bounds.width / 2, y: element.bounds.height / 2 }); + } + newPosition = this.snap(newPosition, element); + + const finished = false; + if (isMoveable(element)) { + newPosition = this.validateMove(this.currentPosition ?? newPosition, newPosition, element, finished); + } + this.currentPosition = newPosition; + const moveGhostElement = MoveAction.create( + [ + { + elementId: element.id, + toPosition: newPosition + } + ], + { animate: false, finished } + ); + this.tool.registerFeedback([moveGhostElement], this); + return element.cssClasses?.includes(CSS_HIDDEN) + ? [ModifyCSSFeedbackAction.create({ elements: [element.id], remove: [CSS_HIDDEN] })] + : []; + } + + protected snap(position: Point, element: GModelElement, isSnap = true): Point { + if (isSnap && this.tool.snapper) { + return this.tool.snapper.snap(position, element); + } else { + return position; + } + } + + protected validateMove(startPosition: Point, toPosition: Point, element: GModelElement, isFinished: boolean): Point { + let newPosition = toPosition; + if (this.tool.movementRestrictor) { + const valid = this.tool.movementRestrictor.validate(element, toPosition); + let action; + if (!valid) { + action = createMovementRestrictionFeedback(element, this.tool.movementRestrictor); + if (isFinished) { + newPosition = startPosition; + } + } else { + action = removeMovementRestrictionFeedback(element, this.tool.movementRestrictor); + } + this.tool.registerFeedback([action], this, [removeMovementRestrictionFeedback(element, this.tool.movementRestrictor)]); + } + return newPosition; + } +} diff --git a/packages/client/src/features/element-template/remove-template-element.ts b/packages/client/src/features/element-template/remove-template-element.ts new file mode 100644 index 00000000..d9798685 --- /dev/null +++ b/packages/client/src/features/element-template/remove-template-element.ts @@ -0,0 +1,56 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +import { Action, CommandExecutionContext, CommandReturn, ElementTemplate, GChildElement, TYPES } from '@eclipse-glsp/sprotty'; +import { inject, injectable } from 'inversify'; +import { FeedbackCommand } from '../../base/feedback/feedback-command'; +import { getTemplateElementId } from './add-template-element'; + +export interface RemoveTemplateElementsAction extends Action { + kind: typeof RemoveTemplateElementsAction.KIND; + templates: ElementTemplate[]; +} + +export namespace RemoveTemplateElementsAction { + export const KIND = 'removeTemplateElements'; + + export function create(options: Omit): RemoveTemplateElementsAction { + return { + kind: KIND, + ...options + }; + } +} + +@injectable() +export class RemoveTemplateElementsFeedbackCommand extends FeedbackCommand { + static readonly KIND = RemoveTemplateElementsAction.KIND; + + constructor(@inject(TYPES.Action) readonly action: RemoveTemplateElementsAction) { + super(); + } + + execute(context: CommandExecutionContext): CommandReturn { + const index = context.root.index; + for (const template of this.action.templates) { + const element = index.getById(getTemplateElementId(template)); + if (element && element instanceof GChildElement) { + element.parent.remove(element); + } + } + return context.root; + } +} diff --git a/packages/client/src/features/tool-palette/tool-palette-module.ts b/packages/client/src/features/tool-palette/tool-palette-module.ts index 5d989e66..a8a4f4b1 100644 --- a/packages/client/src/features/tool-palette/tool-palette-module.ts +++ b/packages/client/src/features/tool-palette/tool-palette-module.ts @@ -13,7 +13,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { bindAsService, configureActionHandler, FeatureModule, TYPES } from '@eclipse-glsp/sprotty'; +import { bindAsService, configureActionHandler, FeatureModule, SetModelAction, TYPES, UpdateModelAction } from '@eclipse-glsp/sprotty'; import '../../../css/tool-palette.css'; import { EnableDefaultToolsAction } from '../../base/tool-manager/tool'; import { ToolPalette } from './tool-palette'; @@ -22,4 +22,6 @@ export const toolPaletteModule = new FeatureModule((bind, _unbind, isBound, _reb bindAsService(bind, TYPES.IUIExtension, ToolPalette); bind(TYPES.IDiagramStartup).toService(ToolPalette); configureActionHandler({ bind, isBound }, EnableDefaultToolsAction.KIND, ToolPalette); + configureActionHandler({ bind, isBound }, UpdateModelAction.KIND, ToolPalette); + configureActionHandler({ bind, isBound }, SetModelAction.KIND, ToolPalette); }); diff --git a/packages/client/src/features/tool-palette/tool-palette.ts b/packages/client/src/features/tool-palette/tool-palette.ts index 5135cf90..e2ccb21a 100644 --- a/packages/client/src/features/tool-palette/tool-palette.ts +++ b/packages/client/src/features/tool-palette/tool-palette.ts @@ -13,22 +13,25 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { inject, injectable, postConstruct } from 'inversify'; import { AbstractUIExtension, Action, + GModelRoot, IActionHandler, ICommand, MarkersReason, PaletteItem, RequestContextActions, RequestMarkersAction, - GModelRoot, SetContextActions, + SetModelAction, SetUIExtensionVisibilityAction, + TriggerNodeCreationAction, + UpdateModelAction, codiconCSSClasses, matchesKeystroke } from '@eclipse-glsp/sprotty'; +import { inject, injectable, postConstruct } from 'inversify'; import { GLSPActionDispatcher } from '../../base/action-dispatcher'; import { EditorContextService, IEditModeListener } from '../../base/editor-context-service'; import { FocusTracker } from '../../base/focus/focus-tracker'; @@ -73,6 +76,7 @@ export class ToolPalette extends AbstractUIExtension implements IActionHandler, protected paletteItems: PaletteItem[]; protected paletteItemsCopy: PaletteItem[] = []; + protected dynamic = false; protected bodyDiv?: HTMLElement; protected lastActiveButton?: HTMLElement; protected defaultToolsButton: HTMLElement; @@ -333,11 +337,15 @@ export class ToolPalette extends AbstractUIExtension implements IActionHandler, } } - handle(action: EnableDefaultToolsAction): ICommand | Action | void { + handle(action: Action): ICommand | Action | void { this.changeActiveButton(); - if (this.focusTracker.hasFocus) { - // if focus was deliberately taken do not restore focus to the palette - this.focusTracker.diagramElement?.focus(); + if (UpdateModelAction.is(action) || SetModelAction.is(action)) { + this.reloadPaletteBody(); + } else if (EnableDefaultToolsAction.is(action)) { + if (this.focusTracker.hasFocus) { + // if focus was deliberately taken do not restore focus to the palette + this.focusTracker.diagramElement?.focus(); + } } } @@ -360,11 +368,6 @@ export class ToolPalette extends AbstractUIExtension implements IActionHandler, } } - protected handleSetContextActions(action: SetContextActions): void { - this.paletteItems = action.actions.map(e => e as PaletteItem); - this.createBody(); - } - protected requestFilterUpdate(filter: string): void { // Initialize the copy if it's empty if (this.paletteItemsCopy.length === 0) { @@ -395,6 +398,17 @@ export class ToolPalette extends AbstractUIExtension implements IActionHandler, } async preRequestModel(): Promise { + await this.setPaletteItems(); + if (!this.editorContext.isReadonly) { + this.show(this.editorContext.modelRoot); + } + } + + async postRequestModel(): Promise { + this.reloadPaletteBody(); + } + + protected async setPaletteItems(): Promise { const requestAction = RequestContextActions.create({ contextId: ToolPalette.ID, editorContext: { @@ -402,9 +416,23 @@ export class ToolPalette extends AbstractUIExtension implements IActionHandler, } }); const response = await this.actionDispatcher.request(requestAction); - this.paletteItems = response.actions.map(e => e as PaletteItem); - if (!this.editorContext.isReadonly) { - this.show(this.editorContext.modelRoot); + this.paletteItems = response.actions.map(action => action as PaletteItem); + this.dynamic = this.paletteItems.some(item => this.hasDynamicAction(item)); + } + + protected hasDynamicAction(item: PaletteItem): boolean { + const dynamic = !!item.actions.find(action => TriggerNodeCreationAction.is(action) && action.ghostElement?.dynamic); + if (dynamic) { + return dynamic; + } + return item.children?.some(child => this.hasDynamicAction(child)) || false; + } + + protected async reloadPaletteBody(): Promise { + if (this.dynamic) { + await this.setPaletteItems(); + this.paletteItemsCopy = []; + this.requestFilterUpdate(this.searchField.value); } } } diff --git a/packages/client/src/features/tools/change-bounds/change-bounds-tool.ts b/packages/client/src/features/tools/change-bounds/change-bounds-tool.ts index 0a832b13..672c7677 100644 --- a/packages/client/src/features/tools/change-bounds/change-bounds-tool.ts +++ b/packages/client/src/features/tools/change-bounds/change-bounds-tool.ts @@ -28,15 +28,14 @@ import { ElementAndBounds, ElementAndRoutingPoints, GChildElement, - ISnapper, - MouseListener, - Operation, - Point, GConnectableElement, GModelElement, GModelRoot, GParentElement, - SetBoundsAction, + ISnapper, + MouseListener, + Operation, + Point, TYPES, findParentByFeature, isSelected @@ -45,13 +44,14 @@ import { DragAwareMouseListener } from '../../../base/drag-aware-mouse-listener' import { CursorCSS, applyCssClasses, cursorFeedbackAction, deleteCssClasses } from '../../../base/feedback/css-feedback'; import { ISelectionListener, SelectionService } from '../../../base/selection-service'; import { PointPositionUpdater } from '../../../features/change-bounds/snap'; -import { isValidMove, isValidSize } from '../../../utils/layout-utils'; import { calcElementAndRoutingPoints, forEachElement, isNonRoutableSelectedMovableBoundsAware, toElementAndBounds } from '../../../utils/gmodel-util'; +import { isValidMove, isValidSize } from '../../../utils/layout-utils'; +import { SetBoundsFeedbackAction } from '../../bounds/set-bounds-feedback-command'; import { Resizable, ResizeHandleLocation, SResizeHandle, isBoundsAwareMoveable, isResizable } from '../../change-bounds/model'; import { IMovementRestrictor, @@ -335,7 +335,7 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel if (this.initialBounds && this.activeResizeHandle && resetBounds) { // we only reset the bounds if an active resize operation was cancelled due to the tool being disabled resetFeedback.push( - SetBoundsAction.create([ + SetBoundsFeedbackAction.create([ { elementId: this.activeResizeElement.id, newPosition: this.initialBounds, @@ -424,7 +424,9 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel if (this.tool.movementRestrictor) { actions.push(removeMovementRestrictionFeedback(element, this.tool.movementRestrictor)); } - actions.push(SetBoundsAction.create([{ elementId: element.id, newPosition: this.initialBounds, newSize: this.initialBounds }])); + actions.push( + SetBoundsFeedbackAction.create([{ elementId: element.id, newPosition: this.initialBounds, newSize: this.initialBounds }]) + ); return actions; } return []; @@ -446,12 +448,12 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel if (this.tool.movementRestrictor) { result.push(removeMovementRestrictionFeedback(element, this.tool.movementRestrictor)); } - result.push(SetBoundsAction.create([{ elementId: element.id, newPosition, newSize }])); + result.push(SetBoundsFeedbackAction.create([{ elementId: element.id, newPosition, newSize }])); } else if (this.isValidSize(element, newSize)) { if (this.tool.movementRestrictor) { result.push(createMovementRestrictionFeedback(element, this.tool.movementRestrictor)); } - result.push(SetBoundsAction.create([{ elementId: element.id, newPosition, newSize }])); + result.push(SetBoundsFeedbackAction.create([{ elementId: element.id, newPosition, newSize }])); } return result; diff --git a/packages/client/src/features/tools/change-bounds/view.tsx b/packages/client/src/features/tools/change-bounds/view.tsx index 7cb919e6..f0a4243d 100644 --- a/packages/client/src/features/tools/change-bounds/view.tsx +++ b/packages/client/src/features/tools/change-bounds/view.tsx @@ -13,9 +13,9 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { IView, Point, RenderingContext, setAttr, svg } from '@eclipse-glsp/sprotty'; import { injectable } from 'inversify'; import { VNode } from 'snabbdom'; -import { IView, Point, RenderingContext, setAttr, svg } from '@eclipse-glsp/sprotty'; import { ResizeHandleLocation, SResizeHandle, isResizable } from '../../change-bounds/model'; // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/packages/client/src/features/tools/node-creation/node-creation-module.ts b/packages/client/src/features/tools/node-creation/node-creation-module.ts index 4fd94be7..3691616c 100644 --- a/packages/client/src/features/tools/node-creation/node-creation-module.ts +++ b/packages/client/src/features/tools/node-creation/node-creation-module.ts @@ -14,10 +14,14 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { FeatureModule, TYPES, TriggerNodeCreationAction, bindAsService, configureActionHandler } from '@eclipse-glsp/sprotty'; +import { elementTemplateModule } from '../../element-template/element-template-module'; import { NodeCreationTool } from './node-creation-tool'; -export const nodeCreationToolModule = new FeatureModule((bind, unbind, isBound, rebind) => { - const context = { bind, unbind, isBound, rebind }; - bindAsService(context, TYPES.ITool, NodeCreationTool); - configureActionHandler(context, TriggerNodeCreationAction.KIND, NodeCreationTool); -}); +export const nodeCreationToolModule = new FeatureModule( + (bind, unbind, isBound, rebind) => { + const context = { bind, unbind, isBound, rebind }; + bindAsService(context, TYPES.ITool, NodeCreationTool); + configureActionHandler(context, TriggerNodeCreationAction.KIND, NodeCreationTool); + }, + { requires: elementTemplateModule } +); diff --git a/packages/client/src/features/tools/node-creation/node-creation-tool.ts b/packages/client/src/features/tools/node-creation/node-creation-tool.ts index 329538ed..edefe885 100644 --- a/packages/client/src/features/tools/node-creation/node-creation-tool.ts +++ b/packages/client/src/features/tools/node-creation/node-creation-tool.ts @@ -13,40 +13,61 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { inject, injectable, optional } from 'inversify'; import { Action, CreateNodeOperation, - ISnapper, GModelElement, GNode, + ISnapper, + Point, TYPES, TriggerNodeCreationAction, findParentByFeature, isCtrlOrCmd } from '@eclipse-glsp/sprotty'; +import { inject, injectable, optional } from 'inversify'; +import '../../../../css/ghost-element.css'; import { DragAwareMouseListener } from '../../../base/drag-aware-mouse-listener'; -import { CursorCSS, cursorFeedbackAction } from '../../../base/feedback/css-feedback'; +import { CSS_GHOST_ELEMENT, CSS_HIDDEN, CursorCSS, cursorFeedbackAction } from '../../../base/feedback/css-feedback'; import { EnableDefaultToolsAction } from '../../../base/tool-manager/tool'; import { getAbsolutePosition } from '../../../utils/viewpoint-util'; +import { IMovementRestrictor } from '../../change-bounds/movement-restrictor'; +import { AddTemplateElementsAction, getTemplateElementId } from '../../element-template/add-template-element'; +import { MouseTrackingElementPositionListener, PositioningTool } from '../../element-template/mouse-tracking-element-position-listener'; +import { RemoveTemplateElementsAction } from '../../element-template/remove-template-element'; import { Containable, isContainable } from '../../hints/model'; import { BaseCreationTool } from '../base-tools'; @injectable() -export class NodeCreationTool extends BaseCreationTool { +export class NodeCreationTool extends BaseCreationTool implements PositioningTool { static ID = 'tool_create_node'; protected isTriggerAction = TriggerNodeCreationAction.is; @inject(TYPES.ISnapper) @optional() readonly snapper?: ISnapper; + @inject(TYPES.IMovementRestrictor) @optional() readonly movementRestrictor?: IMovementRestrictor; get id(): string { return NodeCreationTool.ID; } doEnable(): void { + let trackingListener: MouseTrackingElementPositionListener | undefined; + const ghostElement = this.triggerAction.ghostElement; + if (ghostElement) { + trackingListener = new MouseTrackingElementPositionListener(getTemplateElementId(ghostElement.template), this, 'middle'); + this.toDisposeOnDisable.push( + this.registerFeedback( + [AddTemplateElementsAction.create({ templates: [ghostElement.template], addClasses: [CSS_HIDDEN, CSS_GHOST_ELEMENT] })], + ghostElement, + [RemoveTemplateElementsAction.create({ templates: [ghostElement.template] })] + ), + this.mouseTool.registerListener(trackingListener) + ); + } + this.toDisposeOnDisable.push( - this.mouseTool.registerListener(new NodeCreationToolMouseListener(this.triggerAction, this)), + this.mouseTool.registerListener(new NodeCreationToolMouseListener(this.triggerAction, this, trackingListener)), this.registerFeedback([cursorFeedbackAction(CursorCSS.NODE_CREATION)], this, [cursorFeedbackAction()]) ); } @@ -56,7 +77,11 @@ export class NodeCreationTool extends BaseCreationTool + ): TriggerNodeCreationAction { return { kind: KIND, elementTypeId, diff --git a/packages/protocol/src/action-protocol/types.ts b/packages/protocol/src/action-protocol/types.ts index 5f7a34c0..a2486a0c 100644 --- a/packages/protocol/src/action-protocol/types.ts +++ b/packages/protocol/src/action-protocol/types.ts @@ -15,6 +15,7 @@ ********************************************************************************/ import * as sprotty from 'sprotty-protocol'; import { Dimension, Point } from 'sprotty-protocol'; +import { GModelElementSchema } from '../model/model-schema'; import { AnyObject, hasArrayProp, hasStringProp } from '../utils/type-util'; import { Action } from './base-protocol'; import { TriggerEdgeCreationAction, TriggerNodeCreationAction } from './tool-palette'; @@ -27,6 +28,11 @@ export interface Args { [key: string]: sprotty.JsonPrimitive; } +/** + * The template for a model element, i.e., either a reference to an existing element by element id or an element schema. + */ +export type ElementTemplate = string | GModelElementSchema; + /** * The ElementAndBounds type is used to associate new bounds with a model element, which is referenced via its id. */ diff --git a/packages/protocol/src/utils/disposable.spec.ts b/packages/protocol/src/utils/disposable.spec.ts index 9e664dca..7de19b41 100644 --- a/packages/protocol/src/utils/disposable.spec.ts +++ b/packages/protocol/src/utils/disposable.spec.ts @@ -66,6 +66,13 @@ describe('Disposable', () => { toRemove.dispose(); expect(disposableCollection['disposables'].length).to.be.equal(0); }); + it('should add one disposable function to the collection and remove it again', () => { + const disposable = (): void => {}; + const toRemove = disposableCollection.push(disposable); + expect(disposableCollection['disposables'].length).to.be.equal(1); + toRemove.dispose(); + expect(disposableCollection['disposables'].length).to.be.equal(0); + }); }); describe('dispose', () => { describe('should invoke dispose on all elements of the collection exactly once', () => { diff --git a/packages/protocol/src/utils/disposable.ts b/packages/protocol/src/utils/disposable.ts index 3a12f52a..4ee52864 100644 --- a/packages/protocol/src/utils/disposable.ts +++ b/packages/protocol/src/utils/disposable.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import * as jsonrpc from 'vscode-jsonrpc'; -import { remove } from '../utils/array-util'; +import { isArrayOfType, remove } from '../utils/array-util'; import { AnyObject, hasFunctionProp } from '../utils/type-util'; /** @@ -85,9 +85,12 @@ export class DisposableCollection implements Disposable { * @param disposables The disposables that should be added * @returns A disposable that removes the previously pushed values from the collection when invoked */ - push(...disposables: Disposable[]): Disposable { - this.disposables.push(...disposables); - return Disposable.create(() => remove(this.disposables, ...disposables)); + push(...disposables: Disposable[]): Disposable; + push(...disposables: (() => void)[]): Disposable; + push(...disposables: (() => void)[] | Disposable[]): Disposable { + const toAdd = isArrayOfType(disposables, Disposable.is) ? disposables : disposables.map(Disposable.create); + this.disposables.push(...toAdd); + return Disposable.create(() => remove(this.disposables, ...toAdd)); } get isDisposed(): boolean {