diff --git a/frontend/webEditor/src/commandPalette/commandStack.ts b/frontend/webEditor/src/commandPalette/commandStack.ts new file mode 100644 index 00000000..fd5de380 --- /dev/null +++ b/frontend/webEditor/src/commandPalette/commandStack.ts @@ -0,0 +1,35 @@ +import { + BringToFrontCommand, + CenterCommand, + CommandStack, + FitToScreenCommand, + HiddenCommand, + ICommand, + SelectCommand, + SetViewportCommand, +} from "sprotty"; + +/** + * Custom command stack implementations that only pushes + * commands that modify the diagram to the undo stack. + * Commands like selections, viewport moves etc. are filtered out + * and not pushed to the undo stack. Because of this they will not + * be undone when the user presses Ctrl+Z. + * + * This is done because the commands like selections clutter up + * the stack and the user has to undo many commands without + * really knowing what they are undoing when the selections/viewport moves + * are small. + */ +export class DiagramModificationCommandStack extends CommandStack { + protected override isPushToUndoStack(command: ICommand): boolean { + return !( + command instanceof HiddenCommand || + command instanceof SelectCommand || + command instanceof SetViewportCommand || + command instanceof BringToFrontCommand || + command instanceof FitToScreenCommand || + command instanceof CenterCommand + ); + } +} diff --git a/frontend/webEditor/src/commandPalette/di.config.ts b/frontend/webEditor/src/commandPalette/di.config.ts index e8f536bd..a4ecf0b7 100644 --- a/frontend/webEditor/src/commandPalette/di.config.ts +++ b/frontend/webEditor/src/commandPalette/di.config.ts @@ -2,10 +2,12 @@ import { ContainerModule } from "inversify"; import { CommandPalette, TYPES } from "sprotty"; import { WebEditorCommandPaletteActionProvider } from "./commandPaletteProvider"; import { WebEditorCommandPalette } from "./commandPalette"; +import { DiagramModificationCommandStack } from "./commandStack"; export const commandPaletteModule = new ContainerModule((bind, _, __, rebind) => { rebind(CommandPalette).to(WebEditorCommandPalette).inSingletonScope(); bind(WebEditorCommandPaletteActionProvider).toSelf().inSingletonScope(); bind(TYPES.ICommandPaletteActionProvider).toService(WebEditorCommandPaletteActionProvider); + rebind(TYPES.ICommandStack).to(DiagramModificationCommandStack).inSingletonScope(); }); diff --git a/frontend/webEditor/src/deleteKey/deleteKeyListener.ts b/frontend/webEditor/src/deleteKey/deleteKeyListener.ts new file mode 100644 index 00000000..015fe387 --- /dev/null +++ b/frontend/webEditor/src/deleteKey/deleteKeyListener.ts @@ -0,0 +1,67 @@ +import { + CommitModelAction, + KeyListener, + SModelElementImpl, + isDeletable, + isSelectable, + SConnectableElementImpl, + SChildElementImpl, +} from "sprotty"; +import { Action, DeleteElementAction } from "sprotty-protocol"; +import { matchesKeystroke } from "sprotty/lib/utils/keyboard"; + +/** + * Custom sprotty key listener that deletes all selected elements when the user presses the delete key. + */ +export class DeleteKeyListener extends KeyListener { + override keyDown(element: SModelElementImpl, event: KeyboardEvent): Action[] { + if (matchesKeystroke(event, "Delete")) { + return this.deleteSelectedElements(element); + } + return []; + } + + private deleteSelectedElements(element: SModelElementImpl): Action[] { + const index = element.root.index; + const selectedElements = Array.from( + index + .all() + .filter((e) => isDeletable(e) && isSelectable(e) && e.selected) + .filter((e) => e.id !== e.root.id), // Deleting the model root would be a bad idea + ); + + const deleteElementIds = selectedElements.flatMap((e) => { + const ids = [e.id]; + + if (e instanceof SConnectableElementImpl) { + // This element can be connected to other elements, so we need to delete all edges connected to it as well. + // Otherwise the edges would be left dangling in the graph. + ids.push(...this.getEdgeIdsOfElement(e)); + } + if (e instanceof SChildElementImpl) { + // Add all children and their edges to the list of elements to delete + // This is needed when the edges are not connected to the element itself but to a port of the element. + e.children.forEach((child) => { + ids.push(child.id); + if (child instanceof SConnectableElementImpl) { + ids.push(...this.getEdgeIdsOfElement(child)); + } + }); + } + + return ids; + }); + + if (deleteElementIds.length > 0) { + const uniqueIds = [...new Set(deleteElementIds)]; + + return [DeleteElementAction.create(uniqueIds), CommitModelAction.create()]; + } else { + return []; + } + } + + private getEdgeIdsOfElement(element: SConnectableElementImpl): string[] { + return [...element.incomingEdges.map((e) => e.id), ...element.outgoingEdges.map((e) => e.id)]; + } +} diff --git a/frontend/webEditor/src/deleteKey/di.config.ts b/frontend/webEditor/src/deleteKey/di.config.ts new file mode 100644 index 00000000..a86f47da --- /dev/null +++ b/frontend/webEditor/src/deleteKey/di.config.ts @@ -0,0 +1,8 @@ +import { ContainerModule } from "inversify"; +import { DeleteKeyListener } from "./deleteKeyListener"; +import { TYPES } from "sprotty"; + +export const deleteKeyModule = new ContainerModule((bind) => { + bind(DeleteKeyListener).toSelf().inSingletonScope(); + bind(TYPES.KeyListener).toService(DeleteKeyListener); +}); diff --git a/frontend/webEditor/src/index.ts b/frontend/webEditor/src/index.ts index ce0f74b7..68f56b0c 100644 --- a/frontend/webEditor/src/index.ts +++ b/frontend/webEditor/src/index.ts @@ -25,6 +25,7 @@ import { constraintModule } from "./constraint/di.config"; import { assignmentModule } from "./assignment/di.config"; import { editorModeOverwritesModule } from "./editModeOverwrites/di.config"; import { loadingIndicatorModule } from "./loadingIndicator/di.config"; +import { deleteKeyModule } from "./deleteKey/di.config"; const container = new Container(); @@ -53,6 +54,7 @@ container.load( assignmentModule, editorModeOverwritesModule, loadingIndicatorModule, + deleteKeyModule, ); const startUpAgents = container.getAll(StartUpAgent);