@eclipse-glsp/client
Version:
A sprotty-based client for GLSP
489 lines (430 loc) • 19.3 kB
text/typescript
/********************************************************************************
* Copyright (c) 2023-2025 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,
ICommand,
KeyCode,
matchesKeystroke,
PaletteItem,
RequestContextActions,
RequestMarkersAction,
SetContextActions,
SetUIExtensionVisibilityAction,
TriggerNodeCreationAction
} from '@eclipse-glsp/sprotty';
import { injectable } from 'inversify';
import { messages } from '../../../base/messages';
import { EnableDefaultToolsAction, EnableToolsAction } from '../../../base/tool-manager/tool';
import { compare, createIcon, createToolGroup, EnableToolPaletteAction, ToolPalette } from '../../tool-palette/tool-palette';
import { MouseDeleteTool } from '../../tools/deletion/delete-tool';
import { MarqueeMouseTool } from '../../tools/marquee-selection/marquee-mouse-tool';
import { FocusDomAction } from '../actions';
import { EdgeAutocompletePaletteMetadata } from '../edge-autocomplete/edge-autocomplete-palette';
import { ElementNavigatorKeyListener } from '../element-navigation/diagram-navigation-tool';
import { KeyboardNodeGridMetadata } from '../keyboard-grid/constants';
import { ShowToastMessageAction } from '../toast/toast-handler';
const SEARCH_ICON_ID = 'search';
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];
export class KeyboardToolPalette extends ToolPalette {
protected deleteToolButton: HTMLElement;
protected marqueeToolButton: HTMLElement;
protected validateToolButton: HTMLElement;
protected searchToolButton: HTMLElement;
protected keyboardIndexButtonMapping = new Map<number, HTMLElement>();
protected headerToolsButtonMapping = new Map<number, HTMLElement>();
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', messages.tool_palette.label);
this.containerElement.tabIndex = 20;
this.containerElement.classList.add('accessibility-tool-palette');
this.addMinimizePaletteButton();
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 (EnableToolPaletteAction.is(action)) {
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 (FocusDomAction.is(action) && action.id === ToolPalette.ID) {
if (this.containerElement.contains(document.activeElement)) {
this.toggleShortcutVisibility();
} else {
this.showShortcuts();
}
this.containerElement.focus();
} else {
super.handle(action);
}
}
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 = messages.tool_palette.no_items;
noResultsDiv.classList.add('tool-button');
bodyDiv.appendChild(noResultsDiv);
}
// Replace 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();
let mappingIndex = 0;
const headerTools = document.createElement('div');
headerTools.classList.add('header-tools');
this.defaultToolsButton = this.createDefaultToolButton();
this.headerToolsButtonMapping.set(mappingIndex++, this.defaultToolsButton);
headerTools.appendChild(this.defaultToolsButton);
this.deleteToolButton = this.createMouseDeleteToolButton();
this.headerToolsButtonMapping.set(mappingIndex++, this.deleteToolButton);
headerTools.appendChild(this.deleteToolButton);
this.marqueeToolButton = this.createMarqueeToolButton();
this.headerToolsButtonMapping.set(mappingIndex++, this.marqueeToolButton);
headerTools.appendChild(this.marqueeToolButton);
this.validateToolButton = this.createValidateButton();
this.headerToolsButtonMapping.set(mappingIndex++, this.validateToolButton);
headerTools.appendChild(this.validateToolButton);
const resetViewportButton = this.createResetViewportButton();
this.headerToolsButtonMapping.set(mappingIndex++, resetViewportButton);
headerTools.appendChild(resetViewportButton);
if (this.gridManager) {
const toggleGridButton = this.createToggleGridButton();
this.headerToolsButtonMapping.set(mappingIndex++, toggleGridButton);
headerTools.appendChild(toggleGridButton);
}
if (this.debugManager) {
const toggleDebugButton = this.createToggleDebugButton();
this.headerToolsButtonMapping.set(mappingIndex++, toggleDebugButton);
headerTools.appendChild(toggleDebugButton);
}
// Create button for Search
this.searchToolButton = this.createSearchButton();
this.headerToolsButtonMapping.set(mappingIndex++, this.searchToolButton);
headerTools.appendChild(this.searchToolButton);
return headerTools;
}
protected override createDefaultToolButton(): HTMLElement {
const button = createIcon('inspect');
button.id = 'btn_default_tools';
button.title = messages.tool_palette.selection_button;
button.onclick = this.onClickStaticToolButton(button);
button.appendChild(this.createKeyboardShotcut(SELECTION_TOOL_KEY[0]));
return button;
}
protected override createMouseDeleteToolButton(): HTMLElement {
const deleteToolButton = createIcon('eraser');
deleteToolButton.title = messages.tool_palette.delete_button;
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 = messages.tool_palette.marquee_button;
const toastMessageAction = ShowToastMessageAction.createWithTimeout({
id: Symbol.for(ElementNavigatorKeyListener.name),
message: messages.tool_palette.marquee_message
});
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 = messages.tool_palette.validate_button;
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 = messages.tool_palette.search_button;
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 = messages.tool_palette.search_placeholder;
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);
}
}