diff --git a/examples/workflow-standalone/src/di.config.ts b/examples/workflow-standalone/src/di.config.ts index 2843cbe9..2e1c8ffe 100644 --- a/examples/workflow-standalone/src/di.config.ts +++ b/examples/workflow-standalone/src/di.config.ts @@ -22,12 +22,20 @@ import { IDiagramOptions, LogLevel, STANDALONE_MODULE_CONFIG, - TYPES + TYPES, + toolPaletteModule } from '@eclipse-glsp/client'; import { Container } from 'inversify'; import '../css/diagram.css'; export default function createContainer(options: IDiagramOptions): Container { - const container = createWorkflowDiagramContainer(createDiagramOptionsModule(options), accessibilityModule, STANDALONE_MODULE_CONFIG); + const container = createWorkflowDiagramContainer( + createDiagramOptionsModule(options), + { + add: accessibilityModule, + remove: toolPaletteModule + }, + STANDALONE_MODULE_CONFIG + ); bindOrRebind(container, TYPES.ILogger).to(ConsoleLogger).inSingletonScope(); bindOrRebind(container, TYPES.LogLevel).toConstantValue(LogLevel.warn); container.bind(TYPES.IMarqueeBehavior).toConstantValue({ entireEdge: true, entireElement: true }); diff --git a/packages/client/css/glsp-sprotty.css b/packages/client/css/glsp-sprotty.css index b08cae58..8bbd8267 100644 --- a/packages/client/css/glsp-sprotty.css +++ b/packages/client/css/glsp-sprotty.css @@ -264,7 +264,7 @@ } .search-hidden { - opacity: 0.2; + opacity: 0.4; } .search-highlighted .sprotty-node, diff --git a/packages/client/css/keyboard-tool-palette.css b/packages/client/css/keyboard-tool-palette.css new file mode 100644 index 00000000..28bf3a37 --- /dev/null +++ b/packages/client/css/keyboard-tool-palette.css @@ -0,0 +1,66 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +.accessibility-tool-palette.tool-palette { + top: 48px; + width: 240px; +} + +.accessibility-tool-palette .header-tools i { + margin-right: 0.5em; + position: relative; +} + +.accessibility-tool-palette .tool-button .key-shortcut, +.accessibility-tool-palette .header-tools .key-shortcut { + display: none; + position: absolute; + + border-radius: 50%; + width: 0.5rem; + height: 0.5rem; + padding: 0.5rem; + + background: #fff; + border: 2px solid #666; + color: black; + text-align: center; + + font: bold 0.8rem Arial, sans-serif; + line-height: 0.5rem; +} + +.accessibility-tool-palette .tool-button .key-shortcut { + left: -2rem; +} + +.accessibility-tool-palette .header-tools .key-shortcut { + top: -2.5rem; + right: -0.3rem; +} + +.accessibility-show-shortcuts:focus-within .header-tools .key-shortcut, +.accessibility-show-shortcuts:focus-within .tool-button .key-shortcut { + display: block; +} + +.accessibility-tool-palette.collapsible-palette { + overflow: visible; +} + +.accessibility-tool-palette.collapsible-palette + .minimize-palette-button { + top: 50px; +} diff --git a/packages/client/css/keyboard.css b/packages/client/css/keyboard.css new file mode 100644 index 00000000..1d4db6cf --- /dev/null +++ b/packages/client/css/keyboard.css @@ -0,0 +1,50 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +.grid-container { + display: grid; + position: absolute; + top: 0.5rem; + bottom: 0.5rem; + left: 0.5rem; + right: 0.5rem; + grid-template-columns: auto auto auto; +} + +.grid-container:focus { + opacity: 1 !important; +} + +.grid-item { + border: 1px solid rgba(0, 0, 0, 0.6); + padding: 1rem; + /* Fix overlapping borders */ + margin: 0 -1px -1px 0; +} + +.grid-item-number { + border-radius: 50%; + width: 1.5rem; + height: 1.5rem; + padding: 0.5rem; + + background: #fff; + border: 2px solid #666; + color: black; + text-align: center; + + font: bold 1.3rem Arial, sans-serif; +} diff --git a/packages/client/src/features/accessibility/README.md b/packages/client/src/features/accessibility/README.md index f403e878..a61952f4 100644 --- a/packages/client/src/features/accessibility/README.md +++ b/packages/client/src/features/accessibility/README.md @@ -11,15 +11,63 @@ To activate and use only specific accessibility features, the respective module Below is a list of the keyboard only accessibility features:
-| Feature | Description | Shortcut | Module | -| ------- | :----------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------: | :-------------------: | -| Search | Search for elements or edges using labels, types, etc.
with respective diagram highlighting | CTRL+F | `searchPaletteModule` | -| Move | Move elements, edges or viewport into the directions | \|\| \|\| \|\| | `moveZoomModule` | -| Zoom | Zoom in or out gradually to elements or viewport or reset to default zoom level. | + \|\| - \|\| CTRL+0 | `moveZoomModule` | -| Resize | Increase or decrease element sizes gradually or reset to default element size. | ALT+A then + \|\| - \|\| CTRL+0 | `resizeKeyModule` | +| Feature | Description | Shortcut | Module | +| -------------------------- | :----------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------: | :---------------------------: | +| Tool Palette | Accessible tool palette | ALT+P | `keyboardToolPaletteModule` | +| Graph | Focus on the graph | ALT+G | `-` | +| Grid & Pointer | Used for positioning on the canvas for creating new elements | activated through selected _Tool Palette_ entry | `keyboardControlModule` | +| Search | Search for elements or edges using labels, types, etc.
with respective diagram highlighting | CTRL+F | `searchPaletteModule` | +| Move | Move elements, edges or viewport into the directions | \|\| \|\| \|\| | `moveZoomModule` | +| Zoom | Zoom in or out gradually to elements or viewport or reset to default zoom level. | + \|\| - \|\| CTRL+0 | `moveZoomModule` | +| Zoom via Grid | Zoom in gradually according to the direction of the selected grid box. | CTRL++ then + \|\| - \|\| CTRL+0 | `moveZoomModule` | +| Resize | Increase or decrease element sizes gradually or reset to default element size. | ALT+A then + \|\| - \|\| CTRL+0 | `resizeKeyModule` | +| Element Navigation | Navigate through the elements of the diagram (**Default** or **Position-based** Navigation). | N or ALT+N then use arrow keys to iterate | `glspElementNavigationModule` | +| Help | Display list of existing keyboard shortcuts | ALT+H | `glspShortcutHelpModule` | +| Focus Tracker | Display the current focused element on the canvas | no key needed | `glspFocusTrackerModule` | +| User Notifications (Toast) | Displaying user notifications | no key needed | `glspToastModule` | ## Usage +### CRUD Modeling Operations + +#### Focus on the Graph + +- ALT+G: Use to set the focus on the graph. + +#### Tool Palette + +The shortcut ALT + P sets the focus on the tool palette. Afterward, the characters a - z select an element or 1 - 5 for the header menu options. + +#### Grid + Pointer + +After selecting a node in the tool palette, the grid gets visible. The grid is for positioning the _pointer_ in the screen. + +The following shortcuts are usable: + +- 1- 9: Position the pointer in the grid +- Use arrow keys ( || || || ) to move the pointer to a direction +- ENTER: Create the node +- CTRL + ENTER: Create multiple nodes + +#### Create Nodes + +1. ALT + P: Focus the tool palette +2. a - z: Select a node +3. 1 - 9: Position the pointer in a cell +4. Use arrow keys ( || || || ) to move the pointer to the correct position +5. Create the node by using either + - ENTER: Create the node und finishes the operation + - CTRL + ENTER: Create multiple nodes + +#### Create Edges + +1. ALT + P: Focus the tool palette +2. a - z: Select an edge +3. Type in either **type** or **name** of node for **source** +4. ENTER: Make selection +5. Type in either **type** or **name** of node for **target** +6. ENTER: Make selection + #### Search The search palette allows to search labelled elements or edges that have a labelled node as source or target. The result set will be highlighted accordingly, to also be able to visually distinguish between the searched and the remaining parts of the given diagram. @@ -52,7 +100,17 @@ viewport. 2. Use + or - to zoom in or out gradually. 3. CTRL+0: Set the zoom level to default. -### Resize element +#### Zoom via Grid + +This zoom functionality via Grid can be used to gradually adapt the zoom level according to the direction of the selected _Grid Box_. + +#### Steps + +1. CTRL++: Display the grid. +2. 1- 9: Position the pointer in a cell to zoom. +3. Repeat **2.)** to reach the desired zoom level. + +#### Resize element The resize functionality helps to set the size of the nodes, by either increasing or decreasing the size of the nodes’ shape gradually. @@ -66,11 +124,13 @@ The resize functionality helps to set the size of the nodes, by either increasin ### Model Navigation +For navigating through the elements of the diagram two navigation algorithms are provided. + #### Default Navigation (following directions of relations) 1. Select element as starting point. 2. N: Activate default navigation. -3. Use arrow keys to iterate through model according to the directions of the given relations. +3. Use arrow keys ( || || || ) to iterate through model according to the directions of the given relations. #### Position-based Navigation (following x and y coordinates) diff --git a/packages/client/src/features/accessibility/accessibility-module.ts b/packages/client/src/features/accessibility/accessibility-module.ts index b5101b92..c60c264e 100644 --- a/packages/client/src/features/accessibility/accessibility-module.ts +++ b/packages/client/src/features/accessibility/accessibility-module.ts @@ -20,9 +20,12 @@ import { configureMoveZoom } from './move-zoom/move-zoom-module'; import { configureResizeTools } from './resize-key-tool/resize-key-module'; import { configureSearchPaletteModule } from './search/search-palette-module'; import { configureViewKeyTools } from './view-key-tools/view-key-tools-module'; +import { configureKeyboardToolPaletteTool } from './keyboard-tool-palette/keyboard-tool-palette-module'; +import { configureKeyboardControlTools } from './keyboard-pointer/keyboard-pointer-module'; import { configureElementNavigationTool } from './element-navigation/element-navigation-module'; import { configureFocusTrackerTool } from './focus-tracker/focus-tracker-module'; import { configureToastTool } from './toast/toast-module'; + /** * Enables the accessibility tools for a keyboard-only-usage */ @@ -33,6 +36,8 @@ export const accessibilityModule = new FeatureModule((bind, unbind, isBound, reb configureMoveZoom(context); configureSearchPaletteModule(context); configureShortcutHelpTool(context); + configureKeyboardToolPaletteTool(context); + configureKeyboardControlTools(context); configureElementNavigationTool(context); configureFocusTrackerTool(context); configureToastTool(context); diff --git a/packages/client/src/features/accessibility/actions.ts b/packages/client/src/features/accessibility/actions.ts new file mode 100644 index 00000000..50796eae --- /dev/null +++ b/packages/client/src/features/accessibility/actions.ts @@ -0,0 +1,33 @@ +/******************************************************************************** + * 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, hasStringProp } from '~glsp-sprotty'; + +export interface FocusDomAction extends Action { + kind: typeof FocusDomAction.KIND; + id: string; +} + +export namespace FocusDomAction { + export const KIND = 'focusDomAction'; + + export function is(object: any): object is FocusDomAction { + return Action.hasKind(object, KIND) && hasStringProp(object, 'id'); + } + + export function create(id: string): FocusDomAction { + return { kind: KIND, id }; + } +} diff --git a/packages/client/src/features/accessibility/edge-autocomplete/action.ts b/packages/client/src/features/accessibility/edge-autocomplete/action.ts new file mode 100644 index 00000000..11cbfb99 --- /dev/null +++ b/packages/client/src/features/accessibility/edge-autocomplete/action.ts @@ -0,0 +1,34 @@ +/******************************************************************************** + * 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, hasObjectProp, hasStringProp } from '~glsp-sprotty'; + +export interface SetEdgeTargetSelectionAction extends Action { + kind: typeof SetEdgeTargetSelectionAction.KIND; + elementId: string; + context: string; +} + +export namespace SetEdgeTargetSelectionAction { + export const KIND = 'setEdgeTargetSelectionAction'; + + export function is(object: any): object is SetEdgeTargetSelectionAction { + return Action.hasKind(object, KIND) && hasStringProp(object, 'elementId') && hasObjectProp(object, 'context'); + } + + export function create(elementId: string, context: string): SetEdgeTargetSelectionAction { + return { kind: KIND, elementId, context }; + } +} diff --git a/packages/client/src/features/accessibility/edge-autocomplete/edge-autocomplete-context.ts b/packages/client/src/features/accessibility/edge-autocomplete/edge-autocomplete-context.ts new file mode 100644 index 00000000..f03131e5 --- /dev/null +++ b/packages/client/src/features/accessibility/edge-autocomplete/edge-autocomplete-context.ts @@ -0,0 +1,24 @@ +/******************************************************************************** + * 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 { TriggerEdgeCreationAction } from '~glsp-sprotty'; + +export interface EdgeAutocompleteContext { + role: 'source' | 'target'; + trigger: TriggerEdgeCreationAction; + sourceId?: string; + targetId?: string; +} diff --git a/packages/client/src/features/accessibility/edge-autocomplete/edge-autocomplete-palette.ts b/packages/client/src/features/accessibility/edge-autocomplete/edge-autocomplete-palette.ts new file mode 100644 index 00000000..0c68b4ab --- /dev/null +++ b/packages/client/src/features/accessibility/edge-autocomplete/edge-autocomplete-palette.ts @@ -0,0 +1,147 @@ +/******************************************************************************** + * 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 { injectable } from 'inversify'; +import { + codiconCSSString, + isConnectable, + SEdge, + SModelElement, + SModelRoot, + LabeledAction, + name, + SetUIExtensionVisibilityAction, + IActionHandler, + Action, + CreateEdgeOperation, + TriggerEdgeCreationAction +} from '~glsp-sprotty'; +import { EnableDefaultToolsAction } from '../../../base/tool-manager/tool'; +import { toArray } from 'sprotty/lib/utils/iterable'; +import { EdgeAutocompleteContext } from './edge-autocomplete-context'; +import { SearchAutocompletePalette } from '../search/search-palette'; +import { AutocompleteSuggestion, IAutocompleteSuggestionProvider } from '../../autocomplete-palette/autocomplete-suggestion-providers'; +import { SetEdgeTargetSelectionAction } from './action'; +import { CloseReason, toActionArray } from '../../../base/auto-complete/auto-complete-widget'; + +export namespace EdgeAutocompletePaletteMetadata { + export const ID = 'edge-autocomplete-palette'; +} + +@injectable() +export class EdgeAutocompletePalette extends SearchAutocompletePalette implements IActionHandler { + protected context?: EdgeAutocompleteContext; + + protected readonly targetSuggestionProvider = new PossibleEdgeTargetAutocompleteSuggestionProvider(); + + override id(): string { + return EdgeAutocompletePaletteMetadata.ID; + } + + handle(action: Action): Action | void { + if (TriggerEdgeCreationAction.is(action)) { + this.context = { + trigger: action, + role: 'source' + }; + this.targetSuggestionProvider.setContext(action, this.context); + } + } + + protected override onBeforeShow(containerElement: HTMLElement, root: Readonly, ...contextElementIds: string[]): void { + super.onBeforeShow(containerElement, root, ...contextElementIds); + + this.autocompleteWidget.inputField.placeholder = `Search for ${this.context?.role} elements`; + } + + protected override getSuggestionProviders(root: Readonly, input: string): IAutocompleteSuggestionProvider[] { + return [this.targetSuggestionProvider]; + } + + protected reload(): void { + const context = this.context; + this.hide(); + this.context = context; + this.actionDispatcher.dispatch( + SetUIExtensionVisibilityAction.create({ + extensionId: EdgeAutocompletePaletteMetadata.ID, + visible: true + }) + ); + } + + protected override executeSuggestion(input: LabeledAction | Action[] | Action): void { + const action = toActionArray(input)[0] as SetEdgeTargetSelectionAction; + + if (this.context?.role === 'source') { + this.context.sourceId = action.elementId; + this.context.role = 'target'; + this.reload(); + } else if (this.context?.role === 'target') { + this.context.targetId = action.elementId; + } + if (this.context?.sourceId !== undefined && this.context?.targetId !== undefined) { + this.actionDispatcher.dispatchAll([ + CreateEdgeOperation.create({ + elementTypeId: this.context.trigger.elementTypeId, + sourceElementId: this.context.sourceId, + targetElementId: this.context.targetId, + args: this.context.trigger.args + }), + EnableDefaultToolsAction.create() + ]); + this.hide(); + } + } + + protected override autocompleteHide(reason: CloseReason): void { + if (reason !== 'submission') { + this.hide(); + } + } +} + +@injectable() +export class PossibleEdgeTargetAutocompleteSuggestionProvider implements IAutocompleteSuggestionProvider { + protected proxyEdge?: SEdge; + protected context?: EdgeAutocompleteContext; + + setContext(triggerAction: TriggerEdgeCreationAction, edgeAutocompleteContext: EdgeAutocompleteContext): void { + this.proxyEdge = new SEdge(); + this.proxyEdge.type = triggerAction.elementTypeId; + this.context = edgeAutocompleteContext; + } + + isAllowedSource(element: SModelElement | undefined, role: 'source' | 'target'): boolean { + return element !== undefined && this.proxyEdge !== undefined && isConnectable(element) && element.canConnect(this.proxyEdge, role); + } + + async retrieveSuggestions(root: Readonly, text: string): Promise { + const context = this.context; + if (this.context === undefined) { + return []; + } + + const nodes = toArray(root.index.all().filter(element => this.isAllowedSource(element, context!.role))) as SEdge[]; + return nodes.map(node => ({ + element: node, + action: { + label: `[${node.type}] ${name(node) ?? ''}`, + actions: [SetEdgeTargetSelectionAction.create(node.id, context!.role)], + icon: codiconCSSString('arrow-both') + } + })); + } +} diff --git a/packages/client/src/features/accessibility/edge-autocomplete/edge-autocomplete-tool.ts b/packages/client/src/features/accessibility/edge-autocomplete/edge-autocomplete-tool.ts new file mode 100644 index 00000000..80f6a701 --- /dev/null +++ b/packages/client/src/features/accessibility/edge-autocomplete/edge-autocomplete-tool.ts @@ -0,0 +1,56 @@ +/******************************************************************************** + * 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 { inject, injectable } from 'inversify'; +import { KeyTool, KeyListener, SetUIExtensionVisibilityAction, SModelElement, Action } from '~glsp-sprotty'; +import { Tool } from '../../../base/tool-manager/tool'; +import { matchesKeystroke } from 'sprotty/lib/utils/keyboard'; +import { EdgeAutocompletePaletteMetadata } from './edge-autocomplete-palette'; + +@injectable() +export class EdgeAutocompletePaletteTool implements Tool { + static readonly ID = 'glsp.edge-autocomplete-palette-tool'; + + protected readonly keyListener = new EdgeAutocompletePaletteKeyListener(); + + @inject(KeyTool) protected keyTool: KeyTool; + + get id(): string { + return EdgeAutocompletePaletteTool.ID; + } + + enable(): void { + this.keyTool.register(this.keyListener); + } + + disable(): void { + this.keyTool.deregister(this.keyListener); + } +} + +export class EdgeAutocompletePaletteKeyListener extends KeyListener { + override keyDown(element: SModelElement, event: KeyboardEvent): Action[] { + if (matchesKeystroke(event, 'Escape')) { + return [ + SetUIExtensionVisibilityAction.create({ + extensionId: EdgeAutocompletePaletteMetadata.ID, + visible: false, + contextElementsId: [] + }) + ]; + } + return []; + } +} diff --git a/packages/client/src/features/accessibility/global-keylistener-tool.ts b/packages/client/src/features/accessibility/global-keylistener-tool.ts new file mode 100644 index 00000000..50f1d7ba --- /dev/null +++ b/packages/client/src/features/accessibility/global-keylistener-tool.ts @@ -0,0 +1,98 @@ +/******************************************************************************** + * 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 { inject, injectable } from 'inversify'; +import { SetUIExtensionVisibilityAction, Action } from '~glsp-sprotty'; +import { Tool } from '../../base/tool-manager/tool'; +import { matchesKeystroke } from 'sprotty/lib/utils/keyboard'; +import { ToolPalette } from '../tool-palette/tool-palette'; +import { FocusDomAction } from './actions'; +import { KeyboardGridMetadata, KeyboardNodeGridMetadata } from '../accessibility/keyboard-grid/constants'; +import { KeyboardPointerMetadata } from './keyboard-pointer/constants'; +import { AccessibleKeyShortcutProvider, SetAccessibleKeyShortcutAction } from './key-shortcut/accessible-key-shortcut'; +import { GLSPActionDispatcher } from '../../base/action-dispatcher'; +import { KeyboardToolPalette } from './keyboard-tool-palette/keyboard-tool-palette'; + +@injectable() +export class GlobalKeyListenerTool implements Tool, AccessibleKeyShortcutProvider { + static ID = 'glsp.global-key-listener'; + + isEditTool = false; + protected alreadyRegistered = false; + + @inject(GLSPActionDispatcher) + protected actionDispatcher: GLSPActionDispatcher; + + get id(): string { + return GlobalKeyListenerTool.ID; + } + + enable(): void { + if (!this.alreadyRegistered) { + this.alreadyRegistered = true; + document.addEventListener('keyup', this.trigger.bind(this)); + this.registerShortcutKey(); + } + } + + disable(): void { + // It is not possible to remove the handlers after registration + // The handlers need to be available all the time to work correctly + } + + registerShortcutKey(): void { + this.actionDispatcher.onceModelInitialized().then(() => { + this.actionDispatcher.dispatchAll([ + SetAccessibleKeyShortcutAction.create({ + token: KeyboardToolPalette.name, + keys: [{ shortcuts: ['ALT', 'P'], description: 'Focus on tool palette', group: 'Tool-Palette', position: 0 }] + }), + SetAccessibleKeyShortcutAction.create({ + token: 'Graph', + keys: [{ shortcuts: ['ALT', 'G'], description: 'Focus on graph', group: 'Graph', position: 0 }] + }) + ]); + }); + } + + trigger(event: KeyboardEvent): void { + this.actionDispatcher.dispatchAll(this.handleKeyEvent(event)); + } + + protected handleKeyEvent(event: KeyboardEvent): Action[] { + if (this.matchesSetFocusOnToolPalette(event)) { + return [FocusDomAction.create(ToolPalette.ID)]; + } else if (this.matchesSetFocusOnDiagram(event)) { + return [FocusDomAction.create('graph')]; + } else if (this.matchesReleaseFocusFromToolPalette(event)) { + return [ + SetUIExtensionVisibilityAction.create({ extensionId: KeyboardPointerMetadata.ID, visible: false, contextElementsId: [] }), + SetUIExtensionVisibilityAction.create({ extensionId: KeyboardGridMetadata.ID, visible: false, contextElementsId: [] }), + SetUIExtensionVisibilityAction.create({ extensionId: KeyboardNodeGridMetadata.ID, visible: false, contextElementsId: [] }) + ]; + } + return []; + } + + protected matchesSetFocusOnToolPalette(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'KeyP', 'alt'); + } + protected matchesSetFocusOnDiagram(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'KeyG', 'alt'); + } + protected matchesReleaseFocusFromToolPalette(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'Escape'); + } +} diff --git a/packages/client/src/features/accessibility/keyboard-grid/action.ts b/packages/client/src/features/accessibility/keyboard-grid/action.ts new file mode 100644 index 00000000..364d804b --- /dev/null +++ b/packages/client/src/features/accessibility/keyboard-grid/action.ts @@ -0,0 +1,93 @@ +/******************************************************************************** + * 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, hasObjectProp, Point } from '~glsp-sprotty'; + +export interface EnableKeyboardGridAction extends Action { + kind: typeof EnableKeyboardGridAction.KIND; + options: EnableKeyboardGridAction.Options; +} + +export namespace EnableKeyboardGridAction { + export const KIND = 'enableKeyboardGrid'; + + export interface Options { + originId: string; + triggerActions: Action[]; + } + + export function is(object: any): object is EnableKeyboardGridAction { + return Action.hasKind(object, KIND) && hasObjectProp(object, 'options'); + } + + export function create(options: Options): EnableKeyboardGridAction { + return { + kind: KIND, + options + }; + } +} + +export interface KeyboardGridCellSelectedAction extends Action { + kind: typeof KeyboardGridCellSelectedAction.KIND; + options: KeyboardGridCellSelectedAction.Options; +} + +export namespace KeyboardGridCellSelectedAction { + export const KIND = 'keyboardGridCellSelectedAction'; + + export interface Options { + originId: string; + cellId: string; + centerCellPosition: Point; + } + + export function is(object: any): object is KeyboardGridCellSelectedAction { + return Action.hasKind(object, KIND) && hasObjectProp(object, 'options'); + } + + export function create(options: Options): KeyboardGridCellSelectedAction { + return { + kind: KIND, + options + }; + } +} + +export interface KeyboardGridKeyboardEventAction extends Action { + kind: typeof KeyboardGridKeyboardEventAction.KIND; + options: KeyboardGridKeyboardEventAction.Options; +} + +export namespace KeyboardGridKeyboardEventAction { + export const KIND = 'keyboardGridKeyboardEvent'; + + export interface Options { + originId: string; + event: KeyboardEvent; + } + + export function is(object: any): object is KeyboardGridKeyboardEventAction { + return Action.hasKind(object, KIND) && hasObjectProp(object, 'options'); + } + + export function create(options: Options): KeyboardGridKeyboardEventAction { + return { + kind: KIND, + options + }; + } +} diff --git a/packages/client/src/features/accessibility/keyboard-grid/constants.ts b/packages/client/src/features/accessibility/keyboard-grid/constants.ts new file mode 100644 index 00000000..7d6aec15 --- /dev/null +++ b/packages/client/src/features/accessibility/keyboard-grid/constants.ts @@ -0,0 +1,24 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +export namespace KeyboardGridMetadata { + export const ID = 'keyboard-grid'; + export const TAB_INDEX = 15; +} + +export namespace KeyboardNodeGridMetadata { + export const ID = 'keyboard-node-grid'; +} diff --git a/packages/client/src/features/accessibility/keyboard-grid/keyboard-grid-search-palette.ts b/packages/client/src/features/accessibility/keyboard-grid/keyboard-grid-search-palette.ts new file mode 100644 index 00000000..cd383fb3 --- /dev/null +++ b/packages/client/src/features/accessibility/keyboard-grid/keyboard-grid-search-palette.ts @@ -0,0 +1,64 @@ +/******************************************************************************** + * 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 '../../../../css/keyboard.css'; + +import { injectable } from 'inversify'; +import { SEdge, SetUIExtensionVisibilityAction, SModelElement, SModelRoot, Action } from '~glsp-sprotty'; +import { KeyboardGridMetadata } from './constants'; +import { + RevealEdgeElementAutocompleteSuggestionProvider, + RevealNamedElementAutocompleteSuggestionProvider, + SearchAutocompletePalette +} from '../search/search-palette'; +import { IAutocompleteSuggestionProvider } from '../../../features/autocomplete-palette/autocomplete-suggestion-providers'; + +export namespace GridSearchPaletteMetadata { + export const ID = 'grid-search-palette'; +} + +@injectable() +export class GridSearchPalette extends SearchAutocompletePalette { + override id(): string { + return GridSearchPaletteMetadata.ID; + } + + protected override getSuggestionProviders(root: Readonly, input: string): IAutocompleteSuggestionProvider[] { + return [new GridRevealNamedElementSuggestionProvider(), new GridRevealEdgeSuggestionProvider()]; + } +} + +export class GridRevealEdgeSuggestionProvider extends RevealEdgeElementAutocompleteSuggestionProvider { + protected override getActions(edge: SEdge): Action[] { + return [ + ...super.getActions(edge), + SetUIExtensionVisibilityAction.create({ + extensionId: KeyboardGridMetadata.ID, + visible: true + }) + ]; + } +} +export class GridRevealNamedElementSuggestionProvider extends RevealNamedElementAutocompleteSuggestionProvider { + protected override getActions(nameable: SModelElement): Action[] { + return [ + ...super.getActions(nameable), + SetUIExtensionVisibilityAction.create({ + extensionId: KeyboardGridMetadata.ID, + visible: true + }) + ]; + } +} diff --git a/packages/client/src/features/accessibility/keyboard-grid/keyboard-grid.ts b/packages/client/src/features/accessibility/keyboard-grid/keyboard-grid.ts new file mode 100644 index 00000000..897b5890 --- /dev/null +++ b/packages/client/src/features/accessibility/keyboard-grid/keyboard-grid.ts @@ -0,0 +1,192 @@ +/******************************************************************************** + * 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 '../../../../css/keyboard.css'; + +import { inject, injectable } from 'inversify'; +import { + AbstractUIExtension, + ActionDispatcher, + IActionHandler, + SetUIExtensionVisibilityAction, + SModelRoot, + TYPES, + Action, + ICommand, + Point +} from '~glsp-sprotty'; +import { KeyCode, matchesKeystroke } from 'sprotty/lib/utils/keyboard'; +import { KeyboardGridMetadata } from './constants'; +import { EnableKeyboardGridAction, KeyboardGridCellSelectedAction, KeyboardGridKeyboardEventAction } from './action'; + +@injectable() +export class KeyboardGrid extends AbstractUIExtension implements IActionHandler { + @inject(TYPES.IActionDispatcher) protected readonly actionDispatcher: ActionDispatcher; + + protected triggerActions: Action[] = []; + protected originId: string; + + id(): string { + return KeyboardGridMetadata.ID; + } + + containerClass(): string { + return KeyboardGridMetadata.ID; + } + handle(action: Action): void | Action | ICommand { + if (EnableKeyboardGridAction.is(action)) { + this.triggerActions = action.options.triggerActions; + this.originId = action.options.originId; + this.actionDispatcher.dispatch( + SetUIExtensionVisibilityAction.create({ + extensionId: KeyboardGridMetadata.ID, + visible: true + }) + ); + } + } + + protected initializeContents(containerElement: HTMLElement): void { + containerElement.tabIndex = KeyboardGridMetadata.TAB_INDEX; + containerElement.classList.add('grid-container'); + + for (let i = 1; i <= 9; i++) { + const gridNumber = document.createElement('div'); + const gridItem = document.createElement('div'); + + gridItem.classList.add('grid-item'); + gridItem.id = `keyboard-grid-item-${i}`; + + gridNumber.classList.add('grid-item-number'); + gridNumber.innerHTML = i.toString(); + + gridItem.appendChild(gridNumber); + containerElement.appendChild(gridItem); + } + + this.containerElement.onkeydown = ev => { + this.onKeyDown(ev); + }; + } + + protected onKeyDown(event: KeyboardEvent): void { + this.activateCellIfDigitEvent(event); + this.hideIfEscapeEvent(event); + + this.actionDispatcher.dispatch( + KeyboardGridKeyboardEventAction.create({ + originId: this.originId, + event + }) + ); + } + + protected override setContainerVisible(visible: boolean): void { + if (this.containerElement) { + if (visible) { + this.containerElement.style.visibility = 'visible'; + this.containerElement.style.opacity = '0.7'; + } else { + this.containerElement.style.visibility = 'hidden'; + this.containerElement.style.opacity = '0'; + } + } + } + + override show(root: Readonly, ...contextElementIds: string[]): void { + super.show(root, ...contextElementIds); + this.containerElement.focus(); + } + + protected hideIfEscapeEvent(event: KeyboardEvent): any { + if (this.matchesDeactivateGrid(event)) { + this.hide(); + } + } + + protected activateCellIfDigitEvent(event: KeyboardEvent): any { + let index: number | undefined = undefined; + + for (let i = 1; i <= 9; i++) { + if (this.matchesGridBoxAtIndex(event, i)) { + index = i; + break; + } + } + + if (index !== undefined) { + const position = this.centerPositionOfCell(index); + + this.dispatchActionsForCell(index, position); + } + } + + protected dispatchActionsForCell(index: number, cellCenter: Point): void { + this.actionDispatcher.dispatchAll([ + ...this.triggerActions, + KeyboardGridCellSelectedAction.create({ + originId: this.originId, + cellId: index.toString(), + centerCellPosition: cellCenter + }) + ]); + } + protected centerPositionOfCell(index: number): Point { + let x = 0; + let y = 0; + + const activeGridCell = document.getElementById(`keyboard-grid-item-${index}`); + // eslint-disable-next-line no-null/no-null + if (activeGridCell !== null) { + const positions = this.getCenterOfCell(activeGridCell); + x = positions[0]; + y = positions[1]; + } + + return { + x, + y + }; + } + protected matchesDeactivateGrid(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'Escape'); + } + protected matchesGridBoxAtIndex(event: KeyboardEvent, index: number): boolean { + return matchesKeystroke(event, ('Digit' + index) as KeyCode) || matchesKeystroke(event, ('Numpad' + index) as KeyCode); + } + // https://www.delftstack.com/howto/javascript/get-position-of-element-in-javascript/ + private getOffset(el: any): { top: number; left: number } { + let _x = 0; + let _y = 0; + while (el && !isNaN(el.offsetLeft) && !isNaN(el.offsetTop)) { + _x += el.offsetLeft - el.scrollLeft; + _y += el.offsetTop - el.scrollTop; + el = el.offsetParent; + } + return { top: _y, left: _x }; + } + + private getCenterOfCell(cell: HTMLElement): number[] { + const cellLeft = this.getOffset(cell).left; + const cellTop = this.getOffset(cell).top; + const cellWidth = cell.offsetWidth; + const cellHeight = cell.offsetHeight; + + const newCellWidth = cellWidth / 2; + const newCellHeight = cellHeight / 2; + + return [cellLeft + newCellWidth, cellTop + newCellHeight]; + } +} diff --git a/packages/client/src/features/accessibility/keyboard-grid/keyboard-node-grid.ts b/packages/client/src/features/accessibility/keyboard-grid/keyboard-node-grid.ts new file mode 100644 index 00000000..568bdeda --- /dev/null +++ b/packages/client/src/features/accessibility/keyboard-grid/keyboard-node-grid.ts @@ -0,0 +1,63 @@ +/******************************************************************************** + * 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 '../../../../css/keyboard.css'; + +import { inject, injectable } from 'inversify'; +import { ICommand, SetUIExtensionVisibilityAction, Action } from '~glsp-sprotty'; +import { matchesKeystroke } from 'sprotty/lib/utils/keyboard'; +import { KeyboardPointer } from '../keyboard-pointer/keyboard-pointer'; +import { KeyboardPointerMetadata } from '../keyboard-pointer/constants'; +import { GridSearchPaletteMetadata } from './keyboard-grid-search-palette'; +import { KeyboardGrid } from './keyboard-grid'; +import { KeyboardNodeGridMetadata } from './constants'; + +@injectable() +export class KeyboardNodeGrid extends KeyboardGrid { + @inject(KeyboardPointer) protected readonly keyboardPointer: KeyboardPointer; + + protected override triggerActions = [SetUIExtensionVisibilityAction.create({ extensionId: KeyboardPointerMetadata.ID, visible: true })]; + protected override originId = KeyboardPointerMetadata.ID; + + override id(): string { + return KeyboardNodeGridMetadata.ID; + } + + override handle(action: Action): void | Action | ICommand { + // Do nothing + } + + protected override onKeyDown(event: KeyboardEvent): void { + super.onKeyDown(event); + this.showSearchOnEvent(event); + + if (this.keyboardPointer.isVisible) { + this.keyboardPointer.getKeyListener.keyDown(event); + } + } + + protected showSearchOnEvent(event: KeyboardEvent): void { + if (matchesKeystroke(event, 'KeyF', 'ctrl')) { + event.preventDefault(); + this.actionDispatcher.dispatch( + SetUIExtensionVisibilityAction.create({ + extensionId: GridSearchPaletteMetadata.ID, + visible: true + }) + ); + this.hide(); + } + } +} diff --git a/packages/client/src/features/accessibility/keyboard-pointer/actions.ts b/packages/client/src/features/accessibility/keyboard-pointer/actions.ts new file mode 100644 index 00000000..d1c16370 --- /dev/null +++ b/packages/client/src/features/accessibility/keyboard-pointer/actions.ts @@ -0,0 +1,34 @@ +/******************************************************************************** + * 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, hasNumberProp } from '~glsp-sprotty'; + +export interface SetKeyboardPointerRenderPositionAction extends Action { + kind: typeof SetKeyboardPointerRenderPositionAction.KIND; + x: number; + y: number; +} + +export namespace SetKeyboardPointerRenderPositionAction { + export const KIND = 'setKeyboardPointerRenderPositionAction'; + + export function is(object: any): object is SetKeyboardPointerRenderPositionAction { + return Action.hasKind(object, KIND) && hasNumberProp(object, 'x') && hasNumberProp(object, 'y'); + } + + export function create(x: number, y: number): SetKeyboardPointerRenderPositionAction { + return { kind: KIND, x, y }; + } +} diff --git a/packages/client/src/features/accessibility/keyboard-pointer/constants.ts b/packages/client/src/features/accessibility/keyboard-pointer/constants.ts new file mode 100644 index 00000000..a98001c1 --- /dev/null +++ b/packages/client/src/features/accessibility/keyboard-pointer/constants.ts @@ -0,0 +1,22 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +export namespace KeyboardPointerMetadata { + export const ID = 'keyboard-pointer'; + export const CRICLE_HEIGHT = 16; + export const CIRCLE_WIDTH = 16; + export const TAB_INDEX = 10; +} diff --git a/packages/client/src/features/accessibility/keyboard-pointer/keyboard-pointer-listener.ts b/packages/client/src/features/accessibility/keyboard-pointer/keyboard-pointer-listener.ts new file mode 100644 index 00000000..e851b0ea --- /dev/null +++ b/packages/client/src/features/accessibility/keyboard-pointer/keyboard-pointer-listener.ts @@ -0,0 +1,125 @@ +/******************************************************************************** + * 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 { IActionDispatcher, SetUIExtensionVisibilityAction, CreateNodeOperation } from '~glsp-sprotty'; +import { matchesKeystroke } from 'sprotty/lib/utils/keyboard'; +import { KeyboardNodeGridMetadata } from '../keyboard-grid/constants'; +import { KeyboardPointerMetadata } from './constants'; +import { KeyboardPointer } from './keyboard-pointer'; +import { KeyboardPointerPosition } from './keyboard-pointer-position'; +import { EnableDefaultToolsAction } from '../../../base/tool-manager/tool'; +import { CursorCSS } from '../../../base/feedback/css-feedback'; + +/** + * Keyboard listener for the pointer with the necessary logic to handle keyboard events + */ +export class KeyboardPointerKeyboardListener { + protected get position(): KeyboardPointerPosition { + return this.keyboardPointer.getPosition; + } + + constructor(protected readonly keyboardPointer: KeyboardPointer, protected readonly actionDispatcher: IActionDispatcher) {} + + keyDown(event: KeyboardEvent): void { + this.moveIfArrows(event); + this.createIfEnterEvent(event); + this.hideIfEscapeEvent(event); + } + + protected moveIfArrows(event: KeyboardEvent): any { + if (this.matchesMovePointerDown(event)) { + this.position.renderPosition = this.position.calcRelativeRenderPosition(0, 10); + this.keyboardPointer.render(); + } else if (this.matchesMovePointerUp(event)) { + this.position.renderPosition = this.position.calcRelativeRenderPosition(0, -10); + this.keyboardPointer.render(); + } else if (this.matchesMovePointerRight(event)) { + this.position.renderPosition = this.position.calcRelativeRenderPosition(10, 0); + this.keyboardPointer.render(); + } else if (this.matchesMovePointerLeft(event)) { + this.position.renderPosition = this.position.calcRelativeRenderPosition(-10, 0); + this.keyboardPointer.render(); + } + } + + protected createIfEnterEvent(event: KeyboardEvent): any { + const elementTypeId = this.keyboardPointer.triggerAction.elementTypeId; + + const { container, status } = this.position.containableParentAtDiagramPosition(elementTypeId); + + if (container !== undefined && status === CursorCSS.NODE_CREATION) { + if (this.matchesConfirmPointerPosition(event)) { + // close everything and return to default + + const containerId = container.id; + const location = this.position.diagramPosition; + + this.actionDispatcher.dispatchAll([ + SetUIExtensionVisibilityAction.create({ + extensionId: KeyboardPointerMetadata.ID, + visible: false, + contextElementsId: [] + }), + SetUIExtensionVisibilityAction.create({ + extensionId: KeyboardNodeGridMetadata.ID, + visible: false, + contextElementsId: [] + }), + CreateNodeOperation.create(elementTypeId, { location, containerId, args: this.keyboardPointer.triggerAction.args }), + EnableDefaultToolsAction.create() + ]); + } else if (this.matchesConfirmPointerPositionAndStayInMode(event)) { + // stay in this mode, selected palette option stays, grid and keyboard mouse are displayed + + const containerId = container.id; + const location = this.position.diagramPosition; + + this.actionDispatcher.dispatch( + CreateNodeOperation.create(elementTypeId, { location, containerId, args: this.keyboardPointer.triggerAction.args }) + ); + } + } + } + + protected hideIfEscapeEvent(event: KeyboardEvent): any { + if (this.matchesDeactivatePointer(event)) { + this.keyboardPointer.hide(); + } + } + + protected matchesDeactivatePointer(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'Escape'); + } + protected matchesConfirmPointerPosition(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'Enter'); + } + protected matchesConfirmPointerPositionAndStayInMode(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'Enter', 'ctrl'); + } + + protected matchesMovePointerLeft(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'ArrowLeft'); + } + protected matchesMovePointerRight(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'ArrowRight'); + } + protected matchesMovePointerUp(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'ArrowUp'); + } + protected matchesMovePointerDown(event: KeyboardEvent): boolean { + return matchesKeystroke(event, 'ArrowDown'); + } +} diff --git a/packages/client/src/features/accessibility/keyboard-pointer/keyboard-pointer-module.ts b/packages/client/src/features/accessibility/keyboard-pointer/keyboard-pointer-module.ts new file mode 100644 index 00000000..d2490151 --- /dev/null +++ b/packages/client/src/features/accessibility/keyboard-pointer/keyboard-pointer-module.ts @@ -0,0 +1,63 @@ +/******************************************************************************** + * 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 { + TYPES, + bindAsService, + configureActionHandler, + TriggerNodeCreationAction, + FeatureModule, + BindingContext, + TriggerEdgeCreationAction +} from '~glsp-sprotty'; +import { GlobalKeyListenerTool } from '../global-keylistener-tool'; +import { KeyboardPointer } from './keyboard-pointer'; +import { KeyboardGrid } from '../keyboard-grid/keyboard-grid'; +import { SetKeyboardPointerRenderPositionAction } from './actions'; +import { EdgeAutocompletePalette } from '../edge-autocomplete/edge-autocomplete-palette'; +import { EdgeAutocompletePaletteTool } from '../edge-autocomplete/edge-autocomplete-tool'; +import { SetEdgeTargetSelectionAction } from '../edge-autocomplete/action'; +import { GridSearchPalette } from '../keyboard-grid/keyboard-grid-search-palette'; +import { KeyboardNodeGrid } from '../keyboard-grid/keyboard-node-grid'; +import { EnableKeyboardGridAction, KeyboardGridCellSelectedAction } from '../keyboard-grid/action'; + +/** + * Handles the pointer used via grid to position new elements. + */ +export const keyboardControlModule = new FeatureModule((bind, unbind, isBound, rebind) => { + const context = { bind, unbind, isBound, rebind }; + configureKeyboardControlTools(context); +}); + +export function configureKeyboardControlTools(context: BindingContext): void { + bindAsService(context, TYPES.IDefaultTool, GlobalKeyListenerTool); + bindAsService(context, TYPES.IUIExtension, KeyboardPointer); + bindAsService(context, TYPES.IUIExtension, KeyboardGrid); + bindAsService(context, TYPES.IUIExtension, KeyboardNodeGrid); + + configureActionHandler(context, TriggerNodeCreationAction.KIND, KeyboardPointer); + configureActionHandler(context, SetKeyboardPointerRenderPositionAction.KIND, KeyboardPointer); + + bindAsService(context, TYPES.IUIExtension, EdgeAutocompletePalette); + bindAsService(context, TYPES.IDefaultTool, EdgeAutocompletePaletteTool); + + configureActionHandler(context, EnableKeyboardGridAction.KIND, KeyboardGrid); + configureActionHandler(context, KeyboardGridCellSelectedAction.KIND, KeyboardPointer); + + configureActionHandler(context, TriggerEdgeCreationAction.KIND, EdgeAutocompletePalette); + configureActionHandler(context, SetEdgeTargetSelectionAction.KIND, EdgeAutocompletePalette); + + bindAsService(context, TYPES.IUIExtension, GridSearchPalette); +} diff --git a/packages/client/src/features/accessibility/keyboard-pointer/keyboard-pointer-position.ts b/packages/client/src/features/accessibility/keyboard-pointer/keyboard-pointer-position.ts new file mode 100644 index 00000000..bff421a2 --- /dev/null +++ b/packages/client/src/features/accessibility/keyboard-pointer/keyboard-pointer-position.ts @@ -0,0 +1,78 @@ +/******************************************************************************** + * 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 { findChildrenAtPosition, findParentByFeature, SModelElement, Point } from '~glsp-sprotty'; +import { Containable, isContainable } from '../../hints/model'; +import { getAbsolutePositionByPoint } from '../../../utils/viewpoint-util'; +import { KeyboardPointerMetadata } from './constants'; +import { KeyboardPointer } from './keyboard-pointer'; +import { CursorCSS } from '../../../base/feedback/css-feedback'; + +export class KeyboardPointerPosition { + public renderPosition: Point = { x: 20, y: 20 }; + + constructor(protected readonly keyboardPointer: KeyboardPointer) {} + + get centerizedRenderPosition(): Point { + return { + x: this.renderPosition.x + KeyboardPointerMetadata.CIRCLE_WIDTH / 2, + y: this.renderPosition.y + KeyboardPointerMetadata.CRICLE_HEIGHT / 2 + }; + } + + get diagramPosition(): Point { + return getAbsolutePositionByPoint(this.keyboardPointer.editorContextService.modelRoot, this.centerizedRenderPosition); + } + + childrenAtDiagramPosition(): SModelElement[] { + const position = this.diagramPosition; + + return [ + this.keyboardPointer.editorContextService.modelRoot, + ...findChildrenAtPosition(this.keyboardPointer.editorContextService.modelRoot, position) + ]; + } + + containableParentAtDiagramPosition(elementTypeId: string): { + container: (SModelElement & Containable) | undefined; + status: CursorCSS; + } { + const children = this.childrenAtDiagramPosition(); + + return this.containableParentOf(children.reverse()[0], elementTypeId); + } + + calcRelativeRenderPosition(x: number, y: number): Point { + return { + x: this.renderPosition.x + x, + y: this.renderPosition.y + y + }; + } + + private containableParentOf( + target: SModelElement, + elementTypeId: string + ): { container: (SModelElement & Containable) | undefined; status: CursorCSS } { + const container = findParentByFeature(target, isContainable); + return { + container, + status: this.isCreationAllowed(container, elementTypeId) ? CursorCSS.NODE_CREATION : CursorCSS.OPERATION_NOT_ALLOWED + }; + } + + private isCreationAllowed(container: (SModelElement & Containable) | undefined, elementTypeId: string): boolean | undefined { + return container && container.isContainableElement(elementTypeId); + } +} diff --git a/packages/client/src/features/accessibility/keyboard-pointer/keyboard-pointer.ts b/packages/client/src/features/accessibility/keyboard-pointer/keyboard-pointer.ts new file mode 100644 index 00000000..2af0f5c7 --- /dev/null +++ b/packages/client/src/features/accessibility/keyboard-pointer/keyboard-pointer.ts @@ -0,0 +1,120 @@ +/******************************************************************************** + * 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 { inject, injectable } from 'inversify'; +import { + AbstractUIExtension, + IActionDispatcher, + IActionHandler, + SModelRoot, + TYPES, + Action, + TriggerNodeCreationAction +} from '~glsp-sprotty'; +import { SetKeyboardPointerRenderPositionAction } from './actions'; +import { KeyboardPointerMetadata } from './constants'; +import { KeyboardPointerKeyboardListener } from './keyboard-pointer-listener'; +import { KeyboardPointerPosition } from './keyboard-pointer-position'; +import { KeyboardGridCellSelectedAction } from '../keyboard-grid/action'; +import { EditorContextService } from '../../../base/editor-context-service'; +import { CursorCSS } from '../../../base/feedback/css-feedback'; + +@injectable() +export class KeyboardPointer extends AbstractUIExtension implements IActionHandler { + protected _triggerAction: TriggerNodeCreationAction = { + elementTypeId: 'task:automated', + kind: 'triggerNodeCreation' + }; + + protected position: KeyboardPointerPosition = new KeyboardPointerPosition(this); + protected keyListener: KeyboardPointerKeyboardListener; + @inject(EditorContextService) + public editorContextService: EditorContextService; + + constructor(@inject(TYPES.IActionDispatcher) protected readonly actionDispatcher: IActionDispatcher) { + super(); + + this.keyListener = new KeyboardPointerKeyboardListener(this, actionDispatcher); + } + + get triggerAction(): TriggerNodeCreationAction { + return this._triggerAction; + } + + get isVisible(): boolean { + return this.containerElement?.style.visibility === 'visible'; + } + + get getPosition(): KeyboardPointerPosition { + return this.position; + } + get getKeyListener(): KeyboardPointerKeyboardListener { + return this.keyListener; + } + + id(): string { + return KeyboardPointerMetadata.ID; + } + + containerClass(): string { + return KeyboardPointerMetadata.ID; + } + + protected initializeContents(containerElement: HTMLElement): void { + containerElement.style.position = 'absolute'; + containerElement.style.height = `${KeyboardPointerMetadata.CRICLE_HEIGHT}px`; + containerElement.style.width = `${KeyboardPointerMetadata.CIRCLE_WIDTH}px`; + containerElement.style.borderRadius = '100%'; + } + + protected override onBeforeShow(containerElement: HTMLElement, root: Readonly, ...selectedElementIds: string[]): void { + this.render(); + } + + handle(action: Action): Action | void { + if (TriggerNodeCreationAction.is(action)) { + this._triggerAction = action; + } else if (SetKeyboardPointerRenderPositionAction.is(action)) { + this.position.renderPosition = { x: action.x, y: action.y }; + this.render(); + } else if (KeyboardGridCellSelectedAction.is(action) && action.options.originId === KeyboardPointerMetadata.ID) { + this.position.renderPosition = action.options.centerCellPosition; + this.render(); + } + } + + render(): void { + if (this.containerElement !== undefined) { + const { x, y } = this.position.renderPosition; + this.containerElement.style.left = `${x}px`; + this.containerElement.style.top = `${y}px`; + + const { status } = this.position.containableParentAtDiagramPosition(this._triggerAction.elementTypeId); + + this.containerElement.style.borderStyle = 'solid'; + this.containerElement.style.borderWidth = 'thick'; + switch (status) { + case CursorCSS.NODE_CREATION: { + this.containerElement.style.borderColor = 'green'; + break; + } + case CursorCSS.OPERATION_NOT_ALLOWED: { + this.containerElement.style.borderColor = 'red'; + break; + } + } + } + } +} 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 new file mode 100644 index 00000000..18535e45 --- /dev/null +++ b/packages/client/src/features/accessibility/keyboard-tool-palette/keyboard-tool-palette-module.ts @@ -0,0 +1,35 @@ +/******************************************************************************** + * 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 '../../../../css/keyboard-tool-palette.css'; + +import { configureActionHandler, TYPES, bindAsService, BindingContext, FeatureModule } from '~glsp-sprotty'; +import { EnableToolPaletteAction } from '../../tool-palette/tool-palette'; +import { KeyboardToolPalette } from './keyboard-tool-palette'; +import { FocusDomAction } from '../actions'; +import { EnableDefaultToolsAction } from '../../../base/tool-manager/tool'; + +export const keyboardToolPaletteModule = new FeatureModule((bind, unbind, isBound, rebind) => { + const context = { bind, unbind, isBound, rebind }; + configureKeyboardToolPaletteTool(context); +}); + +export function configureKeyboardToolPaletteTool(context: BindingContext): void { + bindAsService(context, TYPES.IUIExtension, KeyboardToolPalette); + context.bind(TYPES.IDiagramStartup).toService(KeyboardToolPalette); + configureActionHandler(context, EnableDefaultToolsAction.KIND, KeyboardToolPalette); + configureActionHandler(context, FocusDomAction.KIND, KeyboardToolPalette); + configureActionHandler(context, EnableToolPaletteAction.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 new file mode 100644 index 00000000..cb900a09 --- /dev/null +++ b/packages/client/src/features/accessibility/keyboard-tool-palette/keyboard-tool-palette.ts @@ -0,0 +1,505 @@ +/******************************************************************************** + * 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 { injectable } from 'inversify'; +import { + ICommand, + SetUIExtensionVisibilityAction, + Action, + PaletteItem, + RequestContextActions, + RequestMarkersAction, + SetContextActions, + TriggerNodeCreationAction +} from '~glsp-sprotty'; +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 { + createIcon, + changeCodiconClass, + createToolGroup, + ToolPalette, + compare, + EnableToolPaletteAction +} from '../../tool-palette/tool-palette'; +import { KeyboardNodeGridMetadata } from '../keyboard-grid/constants'; +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 * as messages from '../toast/messages.json'; + +const SEARCH_ICON_ID = 'search'; +const PALETTE_ICON_ID = 'symbol-color'; +const CHEVRON_DOWN_ICON_ID = 'chevron-right'; +const PALETTE_HEIGHT = '500px'; +const SELECTION_TOOL_KEY: KeyCode[] = ['Digit1', 'Numpad1']; +const DELETION_TOOL_KEY: KeyCode[] = ['Digit2', 'Numpad2']; +const MARQUEE_TOOL_KEY: KeyCode[] = ['Digit3', 'Numpad3']; +const VALIDATION_TOOL_KEY: KeyCode[] = ['Digit4', 'Numpad4']; +const SEARCH_TOOL_KEY: KeyCode[] = ['Digit5', 'Numpad5']; +const SHOW_SHORTCUTS_CLASS = 'accessibility-show-shortcuts'; + +const AVAILABLE_KEYS: KeyCode[] = [ + 'KeyA', + 'KeyB', + 'KeyC', + 'KeyD', + 'KeyE', + 'KeyF', + 'KeyG', + 'KeyH', + 'KeyI', + 'KeyJ', + 'KeyK', + 'KeyL', + 'KeyM', + 'KeyN', + 'KeyO', + 'KeyP', + 'KeyQ', + 'KeyR', + 'KeyS', + 'KeyT', + 'KeyU', + 'KeyV', + 'KeyX', + 'KeyY', + 'KeyZ' +]; + +const HEADER_TOOL_KEYS: KeyCode[][] = [SELECTION_TOOL_KEY, DELETION_TOOL_KEY, MARQUEE_TOOL_KEY, VALIDATION_TOOL_KEY, SEARCH_TOOL_KEY]; + +@injectable() +export class KeyboardToolPalette extends ToolPalette { + protected deleteToolButton: HTMLElement; + protected marqueeToolButton: HTMLElement; + protected validateToolButton: HTMLElement; + protected searchToolButton: HTMLElement; + + protected keyboardIndexButtonMapping = new Map(); + protected headerToolsButtonMapping = new Map(); + + protected get interactablePaletteItems(): PaletteItem[] { + return this.paletteItems + .sort(compare) + .map(item => item.children?.sort(compare) ?? [item]) + .reduce((acc, val) => acc.concat(val), []); + } + + protected override initializeContents(_containerElement: HTMLElement): void { + this.containerElement.setAttribute('aria-label', 'Tool Palette'); + this.containerElement.tabIndex = 20; + this.containerElement.classList.add('accessibility-tool-palette'); + this.createHeader(); + this.createBody(); + this.lastActiveButton = this.defaultToolsButton; + + this.containerElement.onkeyup = ev => { + this.clearToolOnEscape(ev); + if (this.isShortcutsVisible()) { + this.selectItemOnCharacter(ev); + this.triggerHeaderToolsByKey(ev); + } + }; + } + + override handle(action: Action): ICommand | Action | void { + if (action.kind === EnableToolPaletteAction.KIND) { + const requestAction = RequestContextActions.create({ + contextId: ToolPalette.ID, + editorContext: { + selectedElementIds: [] + } + }); + this.actionDispatcher.requestUntil(requestAction).then(response => { + if (SetContextActions.is(response)) { + this.paletteItems = response.actions.map(e => e as PaletteItem); + this.actionDispatcher.dispatchAll([ + SetUIExtensionVisibilityAction.create({ extensionId: ToolPalette.ID, visible: !this.editorContext.isReadonly }) + ]); + } + }); + } 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(); + } else { + this.showShortcuts(); + } + this.containerElement.focus(); + } + } + + protected override addMinimizePaletteButton(): void { + const baseDiv = document.getElementById(this.options.baseDiv); + const minPaletteDiv = document.createElement('div'); + minPaletteDiv.classList.add('minimize-palette-button'); + this.containerElement.classList.add('collapsible-palette'); + if (baseDiv) { + const insertedDiv = baseDiv.insertBefore(minPaletteDiv, baseDiv.firstChild); + const minimizeIcon = createIcon(CHEVRON_DOWN_ICON_ID); + this.updateMinimizePaletteButtonTooltip(minPaletteDiv); + minimizeIcon.onclick = _event => { + if (this.isPaletteMaximized()) { + this.containerElement.style.overflow = 'hidden'; + this.containerElement.style.maxHeight = '0px'; + } else { + this.containerElement.style.overflow = 'visible'; + this.containerElement.style.maxHeight = PALETTE_HEIGHT; + } + this.updateMinimizePaletteButtonTooltip(minPaletteDiv); + changeCodiconClass(minimizeIcon, PALETTE_ICON_ID); + changeCodiconClass(minimizeIcon, CHEVRON_DOWN_ICON_ID); + }; + insertedDiv.appendChild(minimizeIcon); + } + } + + protected override createBody(): void { + const bodyDiv = document.createElement('div'); + bodyDiv.classList.add('palette-body'); + const tabIndex = 21; + let toolButtonCounter = 0; + + this.keyboardIndexButtonMapping.clear(); + this.paletteItems.sort(compare).forEach(item => { + if (item.children) { + const group = createToolGroup(item); + item.children.sort(compare).forEach(child => { + const button = this.createKeyboardToolButton(child, tabIndex, toolButtonCounter); + group.appendChild(button); + this.keyboardIndexButtonMapping.set(toolButtonCounter, button); + toolButtonCounter++; + }); + bodyDiv.appendChild(group); + } else { + const button = this.createKeyboardToolButton(item, tabIndex, toolButtonCounter); + bodyDiv.appendChild(button); + this.keyboardIndexButtonMapping.set(toolButtonCounter, button); + toolButtonCounter++; + } + }); + + if (this.paletteItems.length === 0) { + const noResultsDiv = document.createElement('div'); + noResultsDiv.innerText = 'No results found.'; + noResultsDiv.classList.add('tool-button'); + bodyDiv.appendChild(noResultsDiv); + } + // Remove existing body to refresh filtered entries + if (this.bodyDiv) { + this.containerElement.removeChild(this.bodyDiv); + } + this.containerElement.appendChild(bodyDiv); + this.bodyDiv = bodyDiv; + } + + protected override createHeaderTools(): HTMLElement { + this.headerToolsButtonMapping.clear(); + + const headerTools = document.createElement('div'); + headerTools.classList.add('header-tools'); + + this.defaultToolsButton = this.createDefaultToolButton(); + this.headerToolsButtonMapping.set(0, this.defaultToolsButton); + headerTools.appendChild(this.defaultToolsButton); + + this.deleteToolButton = this.createMouseDeleteToolButton(); + this.headerToolsButtonMapping.set(1, this.deleteToolButton); + headerTools.appendChild(this.deleteToolButton); + + this.marqueeToolButton = this.createMarqueeToolButton(); + this.headerToolsButtonMapping.set(2, this.marqueeToolButton); + headerTools.appendChild(this.marqueeToolButton); + + this.validateToolButton = this.createValidateButton(); + this.headerToolsButtonMapping.set(3, this.validateToolButton); + headerTools.appendChild(this.validateToolButton); + + // Create button for Search + this.searchToolButton = this.createSearchButton(); + this.headerToolsButtonMapping.set(4, this.searchToolButton); + headerTools.appendChild(this.searchToolButton); + + return headerTools; + } + + protected override createDefaultToolButton(): HTMLElement { + const button = createIcon('inspect'); + button.id = 'btn_default_tools'; + button.title = 'Enable selection tool'; + button.onclick = this.onClickStaticToolButton(button); + button.appendChild(this.createKeyboardShotcut(SELECTION_TOOL_KEY[0])); + + return button; + } + + protected override createMouseDeleteToolButton(): HTMLElement { + const deleteToolButton = createIcon('chrome-close'); + deleteToolButton.title = 'Enable deletion tool'; + deleteToolButton.onclick = this.onClickStaticToolButton(deleteToolButton, MouseDeleteTool.ID); + deleteToolButton.appendChild(this.createKeyboardShotcut(DELETION_TOOL_KEY[0])); + + return deleteToolButton; + } + + protected override createMarqueeToolButton(): HTMLElement { + const marqueeToolButton = createIcon('screen-full'); + marqueeToolButton.title = 'Enable marquee tool'; + const toastMessageAction = ShowToastMessageAction.createWithTimeout({ + id: Symbol.for(ElementNavigatorKeyListener.name), + message: messages.tool_palette.marqueeTool + }); + marqueeToolButton.onclick = this.onClickStaticToolButton(marqueeToolButton, MarqueeMouseTool.ID, toastMessageAction); + marqueeToolButton.appendChild(this.createKeyboardShotcut(MARQUEE_TOOL_KEY[0])); + + return marqueeToolButton; + } + + protected override createValidateButton(): HTMLElement { + const validateToolButton = createIcon('pass'); + validateToolButton.title = 'Validate model'; + validateToolButton.onclick = _event => { + const modelIds: string[] = [this.modelRootId]; + this.actionDispatcher.dispatch(RequestMarkersAction.create(modelIds)); + }; + validateToolButton.appendChild(this.createKeyboardShotcut(VALIDATION_TOOL_KEY[0])); + + return validateToolButton; + } + + protected override onClickStaticToolButton(button: HTMLElement, toolId?: string, action?: Action) { + return (_ev: MouseEvent) => { + if (!this.editorContext.isReadonly) { + const defaultAction = toolId ? EnableToolsAction.create([toolId]) : EnableDefaultToolsAction.create(); + if (action) { + this.actionDispatcher.dispatchAll([defaultAction, action]); + } else { + this.actionDispatcher.dispatchAll([defaultAction]); + } + this.changeActiveButton(button); + button.focus(); + } + }; + } + protected override createSearchButton(): HTMLElement { + const searchIcon = createIcon(SEARCH_ICON_ID); + searchIcon.onclick = _ev => { + const searchField = document.getElementById(this.containerElement.id + '_search_field'); + if (searchField) { + if (searchField.style.display === 'none') { + searchField.style.display = ''; + searchField.focus(); + } else { + searchField.style.display = 'none'; + } + } + }; + searchIcon.classList.add('search-icon'); + searchIcon.title = 'Filter palette entries'; + searchIcon.appendChild(this.createKeyboardShotcut(SEARCH_TOOL_KEY[0])); + + return searchIcon; + } + + protected override createHeaderSearchField(): HTMLInputElement { + const searchField = document.createElement('input'); + searchField.classList.add('search-input'); + searchField.tabIndex = 21; + searchField.id = this.containerElement.id + '_search_field'; + searchField.type = 'text'; + searchField.placeholder = ' Search...'; + searchField.style.display = 'none'; + searchField.onkeyup = ev => { + this.requestFilterUpdate(this.searchField.value); + ev.stopPropagation(); + + if (searchField.value === '') { + this.focusToolPaletteOnEscape(ev); + } else { + this.clearOnEscape(ev); + } + }; + + return searchField; + } + + protected focusToolPaletteOnEscape(event: KeyboardEvent): void { + if (matchesKeystroke(event, 'Escape')) { + this.containerElement.focus(); + } + } + + protected createKeyboardShotcut(keyShortcut: KeyCode): HTMLElement { + const hint = document.createElement('div'); + hint.classList.add('key-shortcut'); + let keyShortcutValue = keyShortcut.toString(); + + if (keyShortcut.includes('Key')) { + keyShortcutValue = keyShortcut.toString().substring(3); + } else if (keyShortcut.includes('Digit')) { + keyShortcutValue = keyShortcut.toString().substring(5); + } + hint.innerHTML = keyShortcutValue; + return hint; + } + + protected createKeyboardToolButton(item: PaletteItem, tabIndex: number, buttonIndex: number): HTMLElement { + const button = document.createElement('div'); + // add keyboard index + if (buttonIndex < AVAILABLE_KEYS.length) { + button.appendChild(this.createKeyboardShotcut(AVAILABLE_KEYS[buttonIndex])); + } + button.tabIndex = tabIndex; + button.classList.add('tool-button'); + if (item.icon) { + button.appendChild(createIcon(item.icon)); + } + button.insertAdjacentText('beforeend', item.label); + button.onclick = this.onClickCreateToolButton(button, item); + + button.onkeydown = ev => { + this.clickToolOnEnter(ev, button, item); + this.clearToolOnEscape(ev); + + if (matchesKeystroke(ev, 'ArrowDown')) { + if (buttonIndex + 1 > this.keyboardIndexButtonMapping.size - 1) { + this.selectItemViaArrowKey(this.keyboardIndexButtonMapping.get(0)); + } else { + this.selectItemViaArrowKey(this.keyboardIndexButtonMapping.get(buttonIndex + 1)); + } + } else if (matchesKeystroke(ev, 'ArrowUp')) { + if (buttonIndex - 1 < 0) { + this.selectItemViaArrowKey(this.keyboardIndexButtonMapping.get(this.keyboardIndexButtonMapping.size - 1)); + } else { + this.selectItemViaArrowKey(this.keyboardIndexButtonMapping.get(buttonIndex - 1)); + } + } + }; + + return button; + } + + protected clickToolOnEnter(event: KeyboardEvent, button: HTMLElement, item: PaletteItem): void { + if (matchesKeystroke(event, 'Enter')) { + if (!this.editorContext.isReadonly) { + this.actionDispatcher.dispatchAll(item.actions); + this.changeActiveButton(button); + this.selectItemOnCharacter(event); + } + } + } + + protected selectItemOnCharacter(event: KeyboardEvent): void { + let index: number | undefined = undefined; + const items = this.interactablePaletteItems; + + const itemsCount = items.length < AVAILABLE_KEYS.length ? items.length : AVAILABLE_KEYS.length; + + for (let i = 0; i < itemsCount; i++) { + const keycode = AVAILABLE_KEYS[i]; + if (matchesKeystroke(event, keycode)) { + index = i; + break; + } + } + + if (index !== undefined) { + if (items[index].actions.some(a => a.kind === TriggerNodeCreationAction.KIND)) { + this.actionDispatcher.dispatchAll([ + ...items[index].actions, + SetUIExtensionVisibilityAction.create({ + extensionId: KeyboardNodeGridMetadata.ID, + visible: true, + contextElementsId: [] + }) + ]); + } else { + this.actionDispatcher.dispatchAll([ + ...items[index].actions, + SetUIExtensionVisibilityAction.create({ + extensionId: EdgeAutocompletePaletteMetadata.ID, + visible: true, + contextElementsId: [] + }) + ]); + } + this.changeActiveButton(this.keyboardIndexButtonMapping.get(index)); + this.keyboardIndexButtonMapping.get(index)?.focus(); + } + } + + protected triggerHeaderToolsByKey(event: KeyboardEvent): void { + let index: number | undefined = undefined; + + for (let i = 0; i < HEADER_TOOL_KEYS.length; i++) { + for (let j = 0; j < HEADER_TOOL_KEYS[i].length; j++) { + const keycode = HEADER_TOOL_KEYS[i][j]; + + if (matchesKeystroke(event, keycode)) { + event.stopPropagation(); + event.preventDefault(); + index = i; + break; + } + } + } + + if (index !== undefined) { + this.headerToolsButtonMapping.get(index)?.click(); + } + } + + protected selectItemViaArrowKey(currentButton: HTMLElement | undefined): void { + if (currentButton !== undefined) { + this.changeActiveButton(currentButton); + currentButton?.focus(); + } + } + + protected override clearToolOnEscape(event: KeyboardEvent): void { + if (matchesKeystroke(event, 'Escape')) { + if (event.target instanceof HTMLElement) { + event.target.blur(); + } + this.actionDispatcher.dispatch(EnableDefaultToolsAction.create()); + } + } + + protected toggleShortcutVisibility(): void { + if (this.isShortcutsVisible()) { + this.hideShortcuts(); + } else { + this.showShortcuts(); + } + } + + protected isShortcutsVisible(): boolean { + return this.containerElement.classList.contains(SHOW_SHORTCUTS_CLASS); + } + + protected showShortcuts(): void { + this.containerElement.classList.add(SHOW_SHORTCUTS_CLASS); + } + + protected hideShortcuts(): void { + this.containerElement.classList.remove(SHOW_SHORTCUTS_CLASS); + } +} diff --git a/packages/client/src/features/accessibility/search/search-palette.ts b/packages/client/src/features/accessibility/search/search-palette.ts index daca4654..b9078c79 100644 --- a/packages/client/src/features/accessibility/search/search-palette.ts +++ b/packages/client/src/features/accessibility/search/search-palette.ts @@ -57,6 +57,23 @@ export class RevealNamedElementAutocompleteSuggestionProvider implements IAutoco return [SelectAction.create({ selectedElementsIDs: [nameable.id] }), CenterAction.create([nameable.id], { retainZoom: true })]; } } +export class RevealNodesWithoutNameAutocompleteSuggestionProvider implements IAutocompleteSuggestionProvider { + async retrieveSuggestions(root: Readonly, text: string): Promise { + const nodes = toArray(root.index.all().filter(element => !isNameable(element) && element instanceof SNode)); + return nodes.map(node => ({ + element: node, + action: { + label: `[${node.type}]`, + actions: this.getActions(node), + icon: codiconCSSString('symbol-namespace') + } + })); + } + + protected getActions(nameable: SModelElement): Action[] { + return [SelectAction.create({ selectedElementsIDs: [nameable.id] }), CenterAction.create([nameable.id], { retainZoom: true })]; + } +} export class RevealEdgeElementAutocompleteSuggestionProvider implements IAutocompleteSuggestionProvider { async retrieveSuggestions(root: Readonly, text: string): Promise { @@ -70,6 +87,7 @@ export class RevealEdgeElementAutocompleteSuggestionProvider implements IAutocom } })); } + protected getActions(edge: SEdge): Action[] { return [SelectAction.create({ selectedElementsIDs: [edge.id] }), CenterAction.create([edge.sourceId, edge.targetId])]; } @@ -107,7 +125,11 @@ export class SearchAutocompletePalette extends BaseAutocompletePalette { containerElement.setAttribute('aria-label', 'Search Field'); } protected getSuggestionProviders(root: Readonly, input: string): IAutocompleteSuggestionProvider[] { - return [new RevealNamedElementAutocompleteSuggestionProvider(), new RevealEdgeElementAutocompleteSuggestionProvider()]; + return [ + new RevealNamedElementAutocompleteSuggestionProvider(), + new RevealEdgeElementAutocompleteSuggestionProvider(), + new RevealNodesWithoutNameAutocompleteSuggestionProvider() + ]; } protected async retrieveSuggestions(root: Readonly, input: string): Promise { const providers = this.getSuggestionProviders(root, input); diff --git a/packages/client/src/features/accessibility/toast/messages.json b/packages/client/src/features/accessibility/toast/messages.json index 27afcfae..8a14a5e0 100644 --- a/packages/client/src/features/accessibility/toast/messages.json +++ b/packages/client/src/features/accessibility/toast/messages.json @@ -12,5 +12,7 @@ "resize": { "resize_mode_activated": "Resize On: Use plus(+) and minus(-) to resize, 'CTRL'+'0' for default size. Press 'ESC' to exit.", "resize_mode_deactivated": "Resize Off: Press 'ALT'+'A' for resize mode." - } + }, + "grid": { "zoom_in_grid": "Select a digit from 1-9 to zoom in on the respective box." }, + "tool_palette": { "marqueeTool": "Currently marquee tool is only usable with mouse." } } diff --git a/packages/client/src/features/accessibility/view-key-tools/view-key-tools-module.ts b/packages/client/src/features/accessibility/view-key-tools/view-key-tools-module.ts index 28f33648..8bb8b7bd 100644 --- a/packages/client/src/features/accessibility/view-key-tools/view-key-tools-module.ts +++ b/packages/client/src/features/accessibility/view-key-tools/view-key-tools-module.ts @@ -14,18 +14,21 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { bindAsService, BindingContext, FeatureModule, TYPES } from '~glsp-sprotty'; +import { bindAsService, BindingContext, configureActionHandler, FeatureModule, TYPES } from '~glsp-sprotty'; import { DeselectKeyTool } from './deselect-key-tool'; import { MovementKeyTool } from './movement-key-tool'; import { ZoomKeyTool } from './zoom-key-tool'; +import { KeyboardGridCellSelectedAction, KeyboardGridKeyboardEventAction } from '../keyboard-grid/action'; export const viewKeyToolsModule = new FeatureModule((bind, _unbind, isBound, rebind) => { const context = { bind, isBound, rebind }; configureViewKeyTools(context); }); -export function configureViewKeyTools(context: Pick): void { +export function configureViewKeyTools(context: Pick): void { bindAsService(context, TYPES.IDefaultTool, MovementKeyTool); bindAsService(context, TYPES.IDefaultTool, ZoomKeyTool); + configureActionHandler(context, KeyboardGridCellSelectedAction.KIND, ZoomKeyTool); + configureActionHandler(context, KeyboardGridKeyboardEventAction.KIND, ZoomKeyTool); bindAsService(context, TYPES.IDefaultTool, DeselectKeyTool); } 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 index eb2b3d0d..ea4b3c3f 100644 --- 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 @@ -16,12 +16,30 @@ import { inject, injectable } from 'inversify'; import { matchesKeystroke } from 'sprotty/lib/utils/keyboard'; -import { Action, CenterAction, KeyListener, KeyTool, SModelElement, TYPES } from '~glsp-sprotty'; +import { + Action, + CenterAction, + KeyListener, + KeyTool, + Point, + SModelElement, + SModelRoot, + SetViewportAction, + TYPES, + Viewport, + isViewport +} from '~glsp-sprotty'; import { GLSPActionDispatcher } from '../../../base/action-dispatcher'; import { SelectionService } from '../../../base/selection-service'; import { Tool } from '../../../base/tool-manager/tool'; import { SetAccessibleKeyShortcutAction } from '../key-shortcut/accessible-key-shortcut'; import { ZoomElementAction, ZoomViewportAction } from '../move-zoom/zoom-handler'; +import { EnableKeyboardGridAction, KeyboardGridCellSelectedAction, KeyboardGridKeyboardEventAction } from '../keyboard-grid/action'; +import { getAbsolutePositionByPoint } from '../../../utils/viewpoint-util'; +import { EditorContextService } from '../../../base/editor-context-service'; +import { HideToastAction, ShowToastMessageAction } from '../toast/toast-handler'; +import { ElementNavigatorKeyListener } from '../element-navigation/diagram-navigation-tool'; +import * as messages from '../toast/messages.json'; /** * Zoom viewport and elements when its focused and arrow keys are hit. @@ -37,6 +55,8 @@ export class ZoomKeyTool implements Tool { @inject(KeyTool) protected readonly keytool: KeyTool; @inject(TYPES.IActionDispatcher) readonly actionDispatcher: GLSPActionDispatcher; @inject(SelectionService) selectionService: SelectionService; + @inject(EditorContextService) + protected editorContextService: EditorContextService; get id(): string { return ZoomKeyTool.ID; @@ -50,6 +70,36 @@ export class ZoomKeyTool implements Tool { disable(): void { this.keytool.deregister(this.zoomKeyListener); } + + handle(action: Action): Action | void { + if (isViewport(this.editorContextService.modelRoot)) { + let viewportAction: Action | undefined = undefined; + + if (KeyboardGridCellSelectedAction.is(action) && action.options.originId === ZoomKeyTool.ID) { + viewportAction = this.zoomKeyListener.setNewZoomFactor( + this.editorContextService.modelRoot, + ZoomKeyListener.defaultZoomInFactor, + getAbsolutePositionByPoint(this.editorContextService.modelRoot, action.options.centerCellPosition) + ); + } else if (KeyboardGridKeyboardEventAction.is(action) && action.options.originId === ZoomKeyTool.ID) { + if (matchesKeystroke(action.options.event, 'Minus')) { + viewportAction = this.zoomKeyListener.setNewZoomFactor( + this.editorContextService.modelRoot, + ZoomKeyListener.defaultZoomOutFactor + ); + } else if (matchesKeystroke(action.options.event, 'Digit0', 'ctrl')) { + viewportAction = CenterAction.create([]); + } + } + + if (viewportAction) { + this.actionDispatcher.dispatchAll([ + viewportAction, + HideToastAction.create({ id: Symbol.for(ElementNavigatorKeyListener.name) }) + ]); + } + } + } } export class ZoomKeyListener extends KeyListener { @@ -75,10 +125,43 @@ export class ZoomKeyListener extends KeyListener { ); } + setNewZoomFactor(viewport: SModelElement & SModelRoot & Viewport, zoomFactor: number, point?: Point): SetViewportAction { + let newViewport: Viewport; + const newZoom = viewport.zoom * zoomFactor; + + if (point) { + newViewport = { + scroll: { + x: point.x - (0.5 * viewport.canvasBounds.width) / newZoom, + y: point.y - (0.5 * viewport.canvasBounds.height) / newZoom + }, + zoom: newZoom + }; + } else { + newViewport = { + scroll: viewport.scroll, + zoom: newZoom + }; + } + return SetViewportAction.create(viewport.id, newViewport, { animate: true }); + } + override keyDown(element: SModelElement, event: KeyboardEvent): Action[] { const selectedElementIds = this.tool.selectionService.getSelectedElementIDs(); - if (this.matchesZoomOutKeystroke(event)) { + if (this.matchesZoomViaGrid(event)) { + return [ + EnableKeyboardGridAction.create({ + originId: ZoomKeyTool.ID, + triggerActions: [] + }), + + ShowToastMessageAction.createWithTimeout({ + id: Symbol.for(ElementNavigatorKeyListener.name), + message: messages.grid.zoom_in_grid + }) + ]; + } else if (this.matchesZoomOutKeystroke(event)) { if (selectedElementIds.length > 0) { return [ZoomElementAction.create(selectedElementIds, ZoomKeyListener.defaultZoomOutFactor)]; } else { @@ -101,6 +184,9 @@ export class ZoomKeyListener extends KeyListener { return event.key === '+' || matchesKeystroke(event, 'NumpadAdd'); } + protected matchesZoomViaGrid(event: KeyboardEvent): boolean { + return event.key === '+' && event.ctrlKey; + } protected matchesMinZoomLevelKeystroke(event: KeyboardEvent): boolean { return matchesKeystroke(event, 'Digit0', 'ctrl') || matchesKeystroke(event, 'Numpad0', 'ctrl'); } diff --git a/packages/client/src/features/autocomplete-palette/base-autocomplete-palette.ts b/packages/client/src/features/autocomplete-palette/base-autocomplete-palette.ts index f992a7e2..9f9f092e 100644 --- a/packages/client/src/features/autocomplete-palette/base-autocomplete-palette.ts +++ b/packages/client/src/features/autocomplete-palette/base-autocomplete-palette.ts @@ -45,7 +45,7 @@ export abstract class BaseAutocompletePalette extends AbstractUIExtension { } override hide(): void { - this.autocompleteWidget.dispose(); + this.autocompleteWidget?.dispose(); this.root = undefined; super.hide(); } diff --git a/packages/client/src/features/tool-palette/tool-palette.ts b/packages/client/src/features/tool-palette/tool-palette.ts index b8771180..69ecc317 100644 --- a/packages/client/src/features/tool-palette/tool-palette.ts +++ b/packages/client/src/features/tool-palette/tool-palette.ts @@ -182,7 +182,7 @@ export class ToolPalette extends AbstractUIExtension implements IActionHandler, this.containerElement.appendChild(headerCompartment); } - private createHeaderTools(): HTMLElement { + protected createHeaderTools(): HTMLElement { const headerTools = document.createElement('div'); headerTools.classList.add('header-tools'); 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 641e2220..20df9d3b 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 @@ -70,6 +70,10 @@ export class NodeCreationToolMouseListener extends DragAwareMouseListener { override nonDraggingMouseUp(target: SModelElement, event: MouseEvent): Action[] { const result: Action[] = []; + if (this.container === undefined) { + this.mouseOver(target, event); + } + if (this.creationAllowed(this.elementTypeId)) { const containerId = this.container ? this.container.id : undefined; let location = getAbsolutePosition(target, event); diff --git a/packages/client/src/features/viewport/viewport-handler.ts b/packages/client/src/features/viewport/viewport-handler.ts new file mode 100644 index 00000000..71bd41f7 --- /dev/null +++ b/packages/client/src/features/viewport/viewport-handler.ts @@ -0,0 +1,67 @@ +/******************************************************************************** + * 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 { IActionHandler, ViewerOptions, TYPES, Action } from '~glsp-sprotty'; +import { EnableDefaultToolsAction } from '../../base/tool-manager/tool'; +import { injectable, inject } from 'inversify'; +import { FocusDomAction } from '../accessibility/actions'; +import { IDiagramStartup } from '../../base/model/diagram-loader'; + +/** + * Focuses the graph on different actions. + */ +@injectable() +export class RestoreViewportHandler implements IActionHandler, IDiagramStartup { + protected readonly graphSelector = '[data-svg-metadata-type="graph"]'; + + @inject(TYPES.ViewerOptions) protected options: ViewerOptions; + + handle(action: Action): void | Action { + if (EnableDefaultToolsAction.is(action) || (FocusDomAction.is(action) && action.id === 'graph')) { + this.focusGraph(); + } + } + + async postRequestModel(): Promise { + await this.waitForElement(this.graphSelector); + this.focusGraph(); + } + + protected focusGraph(): void { + const container = document.getElementById(this.options.baseDiv)?.querySelector(this.graphSelector) as HTMLElement | null; + container?.focus(); + } + + // https://stackoverflow.com/questions/5525071/how-to-wait-until-an-element-exists + protected waitForElement(selector: string): Promise { + return new Promise(resolve => { + if (document.querySelector(selector)) { + return resolve(document.querySelector(selector)); + } + + const observer = new MutationObserver(mutations => { + if (document.querySelector(selector)) { + observer.disconnect(); + resolve(document.querySelector(selector)); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true + }); + }); + } +} diff --git a/packages/client/src/features/viewport/viewport-modules.ts b/packages/client/src/features/viewport/viewport-modules.ts index 17d28b6d..9d9eed0c 100644 --- a/packages/client/src/features/viewport/viewport-modules.ts +++ b/packages/client/src/features/viewport/viewport-modules.ts @@ -28,7 +28,9 @@ import { } from '~glsp-sprotty'; import { EnableDefaultToolsAction, EnableToolsAction } from '../../base/tool-manager/tool'; import { GLSPScrollMouseListener } from './glsp-scroll-mouse-listener'; +import { RestoreViewportHandler } from './viewport-handler'; import { RepositionCommand } from './reposition'; +import { FocusDomAction } from '../accessibility/actions'; export const viewportModule = new FeatureModule((bind, _unbind, isBound) => { const context = { bind, isBound }; @@ -43,6 +45,10 @@ export const viewportModule = new FeatureModule((bind, _unbind, isBound) => { configureActionHandler(context, EnableToolsAction.KIND, GLSPScrollMouseListener); configureActionHandler(context, EnableDefaultToolsAction.KIND, GLSPScrollMouseListener); + + bindAsService(context, TYPES.IDiagramStartup, RestoreViewportHandler); + configureActionHandler(context, EnableDefaultToolsAction.KIND, RestoreViewportHandler); + configureActionHandler(context, FocusDomAction.KIND, RestoreViewportHandler); }); /** diff --git a/packages/client/src/utils/viewpoint-util.ts b/packages/client/src/utils/viewpoint-util.ts index 5bbc3efc..8fa7a5d0 100644 --- a/packages/client/src/utils/viewpoint-util.ts +++ b/packages/client/src/utils/viewpoint-util.ts @@ -44,8 +44,11 @@ import { BoundsAwareModelElement } from './smodel-util'; * A mouseEvent */ export function getAbsolutePosition(target: SModelElement, mouseEvent: MouseEvent): Point { - let xPos = mouseEvent.pageX; - let yPos = mouseEvent.pageY; + return getAbsolutePositionByPoint(target, { x: mouseEvent.pageX, y: mouseEvent.pageY }); +} +export function getAbsolutePositionByPoint(target: SModelElement, point: Point): Point { + let xPos = point.x; + let yPos = point.y; const canvasBounds = target.root.canvasBounds; xPos -= canvasBounds.x; yPos -= canvasBounds.y;