diff --git a/packages/client/src/features/accessibility/di.config.ts b/packages/client/src/features/accessibility/di.config.ts index 00b45f13..d6e65c12 100644 --- a/packages/client/src/features/accessibility/di.config.ts +++ b/packages/client/src/features/accessibility/di.config.ts @@ -15,12 +15,16 @@ ********************************************************************************/ import { ContainerModule } from 'inversify'; +import { configureMoveZoom } from './move-zoom/di.config'; import { configureSearchPaletteModule } from './search/di.config'; +import { configureViewKeyTools } from './view-key-tools/di.config'; /** * Enables the accessibility tools for a keyboard-only-usage */ -export const glspAccessibilityModule = new ContainerModule((bind, _unbind, isBound, rebind) => { - const context = { bind, isBound, rebind }; +export const glspAccessibilityModule = new ContainerModule((bind, unbind, isBound, rebind) => { + const context = { bind, unbind, isBound, rebind }; + configureViewKeyTools(context); + configureMoveZoom(context); configureSearchPaletteModule(context); }); diff --git a/packages/client/src/features/accessibility/move-zoom/di.config.ts b/packages/client/src/features/accessibility/move-zoom/di.config.ts new file mode 100644 index 00000000..38a867e0 --- /dev/null +++ b/packages/client/src/features/accessibility/move-zoom/di.config.ts @@ -0,0 +1,43 @@ +/******************************************************************************** + * Copyright (c) 2023 Business Informatics Group (TU Wien) 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 { BindingContext } from '@eclipse-glsp/protocol'; +import { ContainerModule } from 'inversify'; +import { configureActionHandler } from 'sprotty'; +import { MoveElementAction, MoveElementHandler, MoveViewportAction, MoveViewportHandler } from './move-handler'; +import { ZoomElementAction, ZoomElementHandler, ZoomViewportAction, ZoomViewportHandler } from './zoom-handler'; + +/** + * Handles move and zoom actions. + */ +export const glspMoveZoomModule = new ContainerModule((bind, unbind, isBound, rebind) => { + const context = { bind, unbind, isBound, rebind }; + configureMoveZoom(context); +}); + +export function configureMoveZoom(context: BindingContext): void { + context.bind(MoveViewportHandler).toSelf().inSingletonScope(); + context.bind(MoveElementHandler).toSelf().inSingletonScope(); + + context.bind(ZoomViewportHandler).toSelf().inSingletonScope(); + context.bind(ZoomElementHandler).toSelf().inSingletonScope(); + + configureActionHandler(context, MoveViewportAction.KIND, MoveViewportHandler); + configureActionHandler(context, MoveElementAction.KIND, MoveElementHandler); + + configureActionHandler(context, ZoomViewportAction.KIND, ZoomViewportHandler); + configureActionHandler(context, ZoomElementAction.KIND, ZoomElementHandler); +} diff --git a/packages/client/src/features/accessibility/move-zoom/move-handler.ts b/packages/client/src/features/accessibility/move-zoom/move-handler.ts new file mode 100644 index 00000000..688b9c70 --- /dev/null +++ b/packages/client/src/features/accessibility/move-zoom/move-handler.ts @@ -0,0 +1,198 @@ +/******************************************************************************** + * Copyright (c) 2023 Business Informatics Group (TU Wien) 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, ChangeBoundsOperation, Point, SetViewportAction, Viewport } from '@eclipse-glsp/protocol'; +import { inject, injectable } from 'inversify'; +import { throttle } from 'lodash'; +import { findParentByFeature, IActionDispatcher, IActionHandler, ICommand, isViewport, SModelRoot } from 'sprotty'; +import { EditorContextService } from '../../../base/editor-context-service'; +import { TYPES } from '../../../base/types'; +import { getElements, isSelectableAndBoundsAware, SelectableBoundsAware } from '../../../utils/smodel-util'; + +/** + * Action for triggering moving of the viewport. + */ +export interface MoveViewportAction extends Action { + kind: typeof MoveViewportAction.KIND; + /** + * used to specify the amount to be moved in the x-axis + */ + moveX: number; + /** + * used to specify the amount to be moved in the y-axis + */ + moveY: number; +} + +export namespace MoveViewportAction { + export const KIND = 'moveViewportAction'; + + export function is(object: any): object is MoveViewportAction { + return Action.hasKind(object, KIND); + } + + export function create(moveX: number, moveY: number): MoveViewportAction { + return { kind: KIND, moveX, moveY }; + } +} + +/** + * Action for triggering moving of elements. + */ +export interface MoveElementAction extends Action { + kind: typeof MoveElementAction.KIND; + /** + * used to specify the elements to be zoomed in/out + */ + elementIds: string[]; + /** + * used to specify the amount to be moved in the x-axis + */ + moveX: number; + /** + * used to specify the amount to be moved in the y-axis + */ + moveY: number; +} + +export namespace MoveElementAction { + export const KIND = 'moveElementAction'; + + export function is(object: any): object is MoveElementAction { + return Action.hasKind(object, KIND); + } + + export function create(elementIds: string[], moveX: number, moveY: number): MoveElementAction { + return { kind: KIND, elementIds, moveX, moveY }; + } +} + +/* The MoveViewportHandler class is an implementation of the IActionHandler interface that handles +moving of the viewport. */ +@injectable() +export class MoveViewportHandler implements IActionHandler { + @inject(EditorContextService) + protected editorContextService: EditorContextService; + + @inject(TYPES.IActionDispatcher) protected dispatcher: IActionDispatcher; + protected readonly throttledHandleViewportMove = throttle((action: MoveViewportAction) => this.handleMoveViewport(action), 150); + + handle(action: Action): void | Action | ICommand { + if (MoveViewportAction.is(action)) { + this.throttledHandleViewportMove(action); + } + } + + handleMoveViewport(action: MoveViewportAction): void { + const viewport = findParentByFeature(this.editorContextService.modelRoot, isViewport); + if (!viewport) { + return; + } + this.dispatcher.dispatch(this.moveViewport(viewport, action.moveX, action.moveY)); + } + + protected moveViewport(viewport: SModelRoot & Viewport, offsetX: number, offSetY: number): SetViewportAction { + const newViewport: Viewport = { + scroll: { + x: viewport.scroll.x + offsetX, + y: viewport.scroll.y + offSetY + }, + zoom: viewport.zoom + }; + + return SetViewportAction.create(viewport.id, newViewport, { animate: true }); + } +} + +/* The MoveElementHandler class is an implementation of the IActionHandler interface that handles +moving elements. */ +@injectable() +export class MoveElementHandler implements IActionHandler { + @inject(EditorContextService) + protected editorContextService: EditorContextService; + @inject(TYPES.IActionDispatcher) protected dispatcher: IActionDispatcher; + protected readonly throttledHandleElementMove = throttle((action: MoveElementAction) => this.handleMoveElement(action), 150); + + handle(action: Action): void | Action | ICommand { + if (MoveElementAction.is(action)) { + this.throttledHandleElementMove(action); + } + } + + handleMoveElement(action: MoveElementAction): void { + const viewport = findParentByFeature(this.editorContextService.modelRoot, isViewport); + if (!viewport) { + return; + } + + const elements = getElements(this.editorContextService.modelRoot.index, action.elementIds, isSelectableAndBoundsAware); + + this.dispatcher.dispatchAll(this.move(viewport, elements, action.moveX, action.moveY)); + } + + protected getBounds(element: SelectableBoundsAware, offSetX: number, offSetY: number): Point { + return { x: element.bounds.x + offSetX, y: element.bounds.y + offSetY }; + } + + protected adaptViewport( + viewport: SModelRoot & Viewport, + newPoint: Point, + moveX: number, + moveY: number + ): MoveViewportAction | undefined { + if ( + newPoint.x < viewport.scroll.x || + newPoint.x > viewport.scroll.x + viewport.canvasBounds.width || + newPoint.y < viewport.scroll.y || + newPoint.y > viewport.scroll.y + viewport.canvasBounds.height + ) { + return MoveViewportAction.create(moveX, moveY); + } + return; + } + + protected moveElement(element: SelectableBoundsAware, offSetX: number, offSetY: number): ChangeBoundsOperation { + return ChangeBoundsOperation.create([ + { + elementId: element.id, + newSize: { + width: element.bounds.width, + height: element.bounds.height + }, + newPosition: { + x: element.bounds.x + offSetX, + y: element.bounds.y + offSetY + } + } + ]); + } + + protected move(viewport: SModelRoot & Viewport, selectedElements: SelectableBoundsAware[], deltaX: number, deltaY: number): Action[] { + const results: Action[] = []; + + if (selectedElements.length !== 0) { + selectedElements.forEach(currentElement => { + results.push(this.moveElement(currentElement, deltaX, deltaY)); + const newPosition = this.getBounds(currentElement, deltaX, deltaY); + const viewportAction = this.adaptViewport(viewport, newPosition, deltaX, deltaY); + if (viewportAction) { + results.push(viewportAction); + } + }); + } + return results; + } +} diff --git a/packages/client/src/features/accessibility/move-zoom/zoom-handler.ts b/packages/client/src/features/accessibility/move-zoom/zoom-handler.ts new file mode 100644 index 00000000..48656a21 --- /dev/null +++ b/packages/client/src/features/accessibility/move-zoom/zoom-handler.ts @@ -0,0 +1,178 @@ +/******************************************************************************** + * Copyright (c) 2023 Business Informatics Group (TU Wien) 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, Bounds, Point, SetViewportAction, Viewport } from '@eclipse-glsp/protocol'; +import { inject, injectable } from 'inversify'; +import { throttle } from 'lodash'; +import { + findParentByFeature, + IActionDispatcher, + IActionHandler, + ICommand, + isViewport, + SChildElement, + SModelElement, + SModelRoot +} from 'sprotty'; +import { EditorContextService } from '../../../base/editor-context-service'; +import { TYPES } from '../../../base/types'; +import { getElements, isSelectableAndBoundsAware, SelectableBoundsAware } from '../../../utils/smodel-util'; + +/** + * Action for triggering zooming of the viewport. + */ +export interface ZoomViewportAction extends Action { + kind: typeof ZoomViewportAction.KIND; + /** + * used to specify the amount by which the viewport should be zoomed + */ + zoomFactor: number; +} + +export namespace ZoomViewportAction { + export const KIND = 'zoomViewportAction'; + + export function is(object: any): object is ZoomViewportAction { + return Action.hasKind(object, KIND); + } + + export function create(zoomFactor: number): ZoomViewportAction { + return { kind: KIND, zoomFactor }; + } +} + +/** + * Action for triggering zooming of the elements.. + */ +export interface ZoomElementAction extends Action { + kind: typeof ZoomElementAction.KIND; + /** + * used to specify the elements to be zoomed in/out + */ + elementIds: string[]; + /** + * used to specify the amount by which the viewport should be zoomed + */ + zoomFactor: number; +} + +export namespace ZoomElementAction { + export const KIND = 'zoomElementAction'; + + export function is(object: any): object is ZoomElementAction { + return Action.hasKind(object, KIND); + } + + export function create(elementIds: string[], zoomFactor: number): ZoomElementAction { + return { kind: KIND, elementIds, zoomFactor }; + } +} + +/* The ZoomViewportHandler class is an implementation of the IActionHandler interface that handles +zooming in and out of a viewport. */ +@injectable() +export class ZoomViewportHandler implements IActionHandler { + @inject(EditorContextService) + protected editorContextService: EditorContextService; + + static readonly defaultZoomInFactor = 1.1; + static readonly defaultZoomOutFactor = 0.9; + + @inject(TYPES.IActionDispatcher) protected dispatcher: IActionDispatcher; + protected readonly throttledHandleViewportZoom = throttle((action: ZoomViewportAction) => this.handleZoomViewport(action), 150); + + handle(action: Action): void { + if (ZoomViewportAction.is(action)) { + this.throttledHandleViewportZoom(action); + } + } + + handleZoomViewport(action: ZoomViewportAction): void { + const viewport = findParentByFeature(this.editorContextService.modelRoot, isViewport); + if (!viewport) { + return; + } + this.dispatcher.dispatch(this.setNewZoomFactor(viewport, action.zoomFactor)); + } + + protected setNewZoomFactor(viewport: SModelRoot & Viewport, zoomFactor: number): SetViewportAction { + const newZoom = viewport.zoom * zoomFactor; + + const newViewport = { + scroll: viewport.scroll, + zoom: newZoom + }; + + return SetViewportAction.create(viewport.id, newViewport, { animate: true }); + } +} + +/* The ZoomElementHandler class is an implementation of the IActionHandler interface that handles +zooming in and out of elements. */ +@injectable() +export class ZoomElementHandler implements IActionHandler { + @inject(EditorContextService) + protected editorContextService: EditorContextService; + @inject(TYPES.IActionDispatcher) protected dispatcher: IActionDispatcher; + protected readonly throttledHandleElementZoom = throttle((action: ZoomElementAction) => this.handleZoomElement(action), 150); + + handle(action: Action): void | Action | ICommand { + if (ZoomElementAction.is(action)) { + this.throttledHandleElementZoom(action); + } + } + + handleZoomElement(action: ZoomElementAction): void { + const viewport = findParentByFeature(this.editorContextService.modelRoot, isViewport); + if (!viewport) { + return; + } + + const elements = getElements(this.editorContextService.modelRoot.index, action.elementIds, isSelectableAndBoundsAware); + const center = this.getCenter(viewport, elements); + this.dispatcher.dispatch(this.setNewZoomFactor(viewport, action.zoomFactor, center)); + } + + protected getCenter(viewport: SModelRoot & Viewport, selectedElements: SelectableBoundsAware[]): Point { + // Get bounds of elements based on the viewport + const allBounds = selectedElements.map(e => this.boundsInViewport(viewport, e, e.bounds)); + const mergedBounds = allBounds.reduce((b0, b1) => Bounds.combine(b0, b1)); + return Bounds.center(mergedBounds); + } + + // copy from center-fit.ts, translates the children bounds to the viewport bounds + protected boundsInViewport(viewport: SModelRoot & Viewport, element: SModelElement, bounds: Bounds): Bounds { + if (element instanceof SChildElement && element.parent !== viewport) { + return this.boundsInViewport(viewport, element.parent, element.parent.localToParent(bounds) as Bounds); + } else { + return bounds; + } + } + + protected setNewZoomFactor(viewport: SModelRoot & Viewport, zoomFactor: number, point: Point): SetViewportAction { + const newZoom = viewport.zoom * zoomFactor; + + const newViewport = { + scroll: { + x: point.x - (0.5 * viewport.canvasBounds.width) / newZoom, + y: point.y - (0.5 * viewport.canvasBounds.height) / newZoom + }, + zoom: newZoom + }; + + return SetViewportAction.create(viewport.id, newViewport, { animate: true }); + } +} diff --git a/packages/client/src/features/accessibility/view-key-tools/deselect-key-tool.ts b/packages/client/src/features/accessibility/view-key-tools/deselect-key-tool.ts new file mode 100644 index 00000000..5b1f49d4 --- /dev/null +++ b/packages/client/src/features/accessibility/view-key-tools/deselect-key-tool.ts @@ -0,0 +1,80 @@ +/******************************************************************************** + * Copyright (c) 2023 Business Informatics Group (TU Wien) 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, SelectAction } from '@eclipse-glsp/protocol'; +import { inject, injectable } from 'inversify'; +import { isSelectable, KeyListener, KeyTool, SModelElement, SRoutableElement, SwitchEditModeAction } from 'sprotty'; +import { toArray } from 'sprotty/lib/utils/iterable'; +import { matchesKeystroke } from 'sprotty/lib/utils/keyboard'; +import { GLSPTool } from '../../../base/tool-manager/glsp-tool-manager'; +import { SResizeHandle } from '../../change-bounds/model'; + +/** + * Deselects the element if there is no interaction possible with element. + */ +@injectable() +export class DeselectKeyTool implements GLSPTool { + static ID = 'glsp.deselect-key-tool'; + + isEditTool = true; + + protected readonly deselectKeyListener = new DeselectKeyListener(); + + @inject(KeyTool) protected readonly keytool: KeyTool; + + get id(): string { + return DeselectKeyTool.ID; + } + + enable(): void { + this.keytool.register(this.deselectKeyListener); + } + + disable(): void { + this.keytool.deregister(this.deselectKeyListener); + } +} + +export class DeselectKeyListener extends KeyListener { + override keyDown(target: SModelElement, event: KeyboardEvent): Action[] { + if (this.matchesDeselectKeystroke(event)) { + const isResizeHandleActive = toArray(target.root.index.all().filter(el => el instanceof SResizeHandle)).length > 0; + + if (isResizeHandleActive) { + return []; + } + + const deselect = toArray(target.root.index.all().filter(element => isSelectable(element) && element.selected)); + const actions: Action[] = []; + + if (deselect.length > 0) { + actions.push(SelectAction.create({ deselectedElementsIDs: deselect.map(e => e.id) })); + } + + const routableDeselect = deselect.filter(e => e instanceof SRoutableElement).map(e => e.id); + if (routableDeselect.length > 0) { + actions.push(SwitchEditModeAction.create({ elementsToDeactivate: routableDeselect })); + } + + return actions; + } + return []; + } + + protected matchesDeselectKeystroke(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'Escape'); + } +} diff --git a/packages/client/src/features/accessibility/view-key-tools/di.config.ts b/packages/client/src/features/accessibility/view-key-tools/di.config.ts new file mode 100644 index 00000000..d9000eb8 --- /dev/null +++ b/packages/client/src/features/accessibility/view-key-tools/di.config.ts @@ -0,0 +1,33 @@ +/******************************************************************************** + * 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 { bindAsService, BindingContext } from '@eclipse-glsp/protocol'; +import { ContainerModule } from 'inversify'; +import { TYPES } from '../../../base/types'; +import { DeselectKeyTool } from '../view-key-tools/deselect-key-tool'; +import { MovementKeyTool } from './movement-key-tool'; +import { ZoomKeyTool } from './zoom-key-tool'; + +export const glspViewKeyToolsModule = new ContainerModule((bind, _unbind, isBound, rebind) => { + const context = { bind, isBound, rebind }; + configureViewKeyTools(context); +}); + +export function configureViewKeyTools(context: Pick): void { + bindAsService(context, TYPES.IDefaultTool, MovementKeyTool); + bindAsService(context, TYPES.IDefaultTool, ZoomKeyTool); + bindAsService(context, TYPES.IDefaultTool, DeselectKeyTool); +} diff --git a/packages/client/src/features/accessibility/view-key-tools/movement-key-tool.ts b/packages/client/src/features/accessibility/view-key-tools/movement-key-tool.ts new file mode 100644 index 00000000..c60a0c97 --- /dev/null +++ b/packages/client/src/features/accessibility/view-key-tools/movement-key-tool.ts @@ -0,0 +1,115 @@ +/******************************************************************************** + * Copyright (c) 2023 Business Informatics Group (TU Wien) 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 } from '@eclipse-glsp/protocol'; +import { inject, injectable, optional } from 'inversify'; +import { ISnapper, KeyListener, KeyTool, SModelElement } from 'sprotty'; +import { matchesKeystroke } from 'sprotty/lib/utils/keyboard'; +import { GLSPTool } from '../../../base/tool-manager/glsp-tool-manager'; +import { TYPES } from '../../../base/types'; +import { GridSnapper } from '../../change-bounds/snap'; + +import { SelectionService } from '../../select/selection-service'; +import { MoveElementAction, MoveViewportAction } from '../move-zoom/move-handler'; + +/** + * Moves viewport and elements when its focused and arrow keys are hit. + */ +@injectable() +export class MovementKeyTool implements GLSPTool { + static ID = 'glsp.movement-key-tool'; + + isEditTool = true; + + protected readonly movementKeyListener = new MoveKeyListener(this); + + @inject(KeyTool) protected readonly keytool: KeyTool; + @inject(TYPES.SelectionService) selectionService: SelectionService; + @inject(TYPES.ISnapper) @optional() readonly snapper?: ISnapper; + + get id(): string { + return MovementKeyTool.ID; + } + + enable(): void { + this.keytool.register(this.movementKeyListener); + } + + disable(): void { + this.keytool.deregister(this.movementKeyListener); + } +} + +export class MoveKeyListener extends KeyListener { + // Default x distance used if GridSnapper is not provided + static readonly defaultMoveX = 20; + + // Default y distance used if GridSnapper is not provided + static readonly defaultMoveY = 20; + + protected grid = { x: MoveKeyListener.defaultMoveX, y: MoveKeyListener.defaultMoveY }; + + constructor(protected readonly tool: MovementKeyTool) { + super(); + + if (this.tool.snapper instanceof GridSnapper) { + this.grid = this.tool.snapper.grid; + } + } + + override keyDown(element: SModelElement, event: KeyboardEvent): Action[] { + const selectedElementIds = this.tool.selectionService.getSelectedElementIDs(); + + if (selectedElementIds.length > 0) { + if (this.matchesMoveUpKeystroke(event)) { + return [MoveElementAction.create(selectedElementIds, 0, -this.grid.x)]; + } else if (this.matchesMoveDownKeystroke(event)) { + return [MoveElementAction.create(selectedElementIds, 0, this.grid.x)]; + } else if (this.matchesMoveRightKeystroke(event)) { + return [MoveElementAction.create(selectedElementIds, this.grid.x, 0)]; + } else if (this.matchesMoveLeftKeystroke(event)) { + return [MoveElementAction.create(selectedElementIds, -this.grid.x, 0)]; + } + } else { + if (this.matchesMoveUpKeystroke(event)) { + return [MoveViewportAction.create(0, -this.grid.x)]; + } else if (this.matchesMoveDownKeystroke(event)) { + return [MoveViewportAction.create(0, this.grid.x)]; + } else if (this.matchesMoveRightKeystroke(event)) { + return [MoveViewportAction.create(this.grid.x, 0)]; + } else if (this.matchesMoveLeftKeystroke(event)) { + return [MoveViewportAction.create(-this.grid.x, 0)]; + } + } + return []; + } + + protected matchesMoveUpKeystroke(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'ArrowUp'); + } + + protected matchesMoveDownKeystroke(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'ArrowDown'); + } + + protected matchesMoveRightKeystroke(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'ArrowRight'); + } + + protected matchesMoveLeftKeystroke(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'ArrowLeft'); + } +} diff --git a/packages/client/src/features/accessibility/view-key-tools/zoom-key-tool.ts b/packages/client/src/features/accessibility/view-key-tools/zoom-key-tool.ts new file mode 100644 index 00000000..681e5905 --- /dev/null +++ b/packages/client/src/features/accessibility/view-key-tools/zoom-key-tool.ts @@ -0,0 +1,94 @@ +/******************************************************************************** + * Copyright (c) 2023 Business Informatics Group (TU Wien) 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, CenterAction } from '@eclipse-glsp/protocol'; +import { inject, injectable } from 'inversify'; +import { KeyListener, KeyTool, SModelElement } from 'sprotty'; +import { matchesKeystroke } from 'sprotty/lib/utils/keyboard'; +import { GLSPTool } from '../../../base/tool-manager/glsp-tool-manager'; +import { TYPES } from '../../../base/types'; +import { SelectionService } from '../../select/selection-service'; +import { ZoomElementAction, ZoomViewportAction } from '../move-zoom/zoom-handler'; + +/** + * Zoom viewport and elements when its focused and arrow keys are hit. + */ +@injectable() +export class ZoomKeyTool implements GLSPTool { + static ID = 'glsp.zoom-key-tool'; + + isEditTool = false; + + protected readonly zoomKeyListener = new ZoomKeyListener(this); + + @inject(KeyTool) protected readonly keytool: KeyTool; + @inject(TYPES.SelectionService) selectionService: SelectionService; + + get id(): string { + return ZoomKeyTool.ID; + } + + enable(): void { + this.keytool.register(this.zoomKeyListener); + } + + disable(): void { + this.keytool.deregister(this.zoomKeyListener); + } +} + +export class ZoomKeyListener extends KeyListener { + static readonly defaultZoomInFactor = 1.1; + static readonly defaultZoomOutFactor = 0.9; + + constructor(protected tool: ZoomKeyTool) { + super(); + } + + override keyDown(element: SModelElement, event: KeyboardEvent): Action[] { + const selectedElementIds = this.tool.selectionService.getSelectedElementIDs(); + + if (this.matchesZoomOutKeystroke(event)) { + if (selectedElementIds.length > 0) { + return [ZoomElementAction.create(selectedElementIds, ZoomKeyListener.defaultZoomOutFactor)]; + } else { + return [ZoomViewportAction.create(ZoomKeyListener.defaultZoomOutFactor)]; + } + } else if (this.matchesZoomInKeystroke(event)) { + if (selectedElementIds.length > 0) { + return [ZoomElementAction.create(selectedElementIds, ZoomKeyListener.defaultZoomInFactor)]; + } else { + return [ZoomViewportAction.create(ZoomKeyListener.defaultZoomInFactor)]; + } + } else if (this.matchesMinZoomLevelKeystroke(event)) { + return [CenterAction.create(selectedElementIds)]; + } + return []; + } + + protected matchesZoomInKeystroke(event: KeyboardEvent): boolean { + /** here event.key is used for '+', as keycode 187 is already declared for 'Equals' in {@link matchesKeystroke}.*/ + return event.key === '+' || matchesKeystroke(event, 'NumpadAdd'); + } + + protected matchesMinZoomLevelKeystroke(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'Digit0', 'ctrl') || matchesKeystroke(event, 'Numpad0', 'ctrl'); + } + + protected matchesZoomOutKeystroke(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'Minus') || matchesKeystroke(event, 'NumpadSubtract'); + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index de878903..b20c4814 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -15,7 +15,9 @@ ********************************************************************************/ import defaultGLSPModule from './base/di.config'; import { glspAccessibilityModule } from './features/accessibility/di.config'; +import { glspMoveZoomModule } from './features/accessibility/move-zoom/di.config'; import { glspSearchPaletteModule } from './features/accessibility/search/di.config'; +import { glspViewKeyToolsModule } from './features/accessibility/view-key-tools/di.config'; import glspCommandPaletteModule from './features/command-palette/di.config'; import glspContextMenuModule from './features/context-menu/di.config'; import { copyPasteContextMenuModule, glspServerCopyPasteModule } from './features/copy-paste/di.config'; @@ -58,6 +60,9 @@ export * from './base/tool-manager/glsp-tool-manager'; export * from './base/tool-manager/tool-actions'; export * from './base/types'; export * from './base/view/view-registry'; +export * from './features/accessibility/view-key-tools/deselect-key-tool'; +export * from './features/accessibility/view-key-tools/movement-key-tool'; +export * from './features/accessibility/view-key-tools/zoom-key-tool'; // // ------------------ Features ------------------ export * from './features/bounds/freeform-layout'; @@ -157,6 +162,8 @@ export { markerNavigatorContextMenuModule, glspViewportModule, svgMetadataModule, - glspSearchPaletteModule, - glspAccessibilityModule + glspViewKeyToolsModule, + glspMoveZoomModule, + glspAccessibilityModule, + glspSearchPaletteModule };