diff --git a/webapp/packages/core-ui/src/ContextMenu/ContextMenu.tsx b/webapp/packages/core-ui/src/ContextMenu/ContextMenu.tsx index 36c547c4710..42cef80d125 100644 --- a/webapp/packages/core-ui/src/ContextMenu/ContextMenu.tsx +++ b/webapp/packages/core-ui/src/ContextMenu/ContextMenu.tsx @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -44,6 +44,7 @@ export const ContextMenu = observer(function ContextMenuIn const handler = menuData.handler; useAutoLoad({ name: `${ContextMenuInner.name}(${menuData.menu.id})` }, menuData.loaders, true, isMenuOpen, true); + menu.setAutoFocusOnShow(true); const handlers = useObjectRef( () => ({ diff --git a/webapp/packages/core-view/src/Action/KeyBinding/matchesKeyBinding.ts b/webapp/packages/core-view/src/Action/KeyBinding/matchesKeyBinding.ts new file mode 100644 index 00000000000..a1da033aab6 --- /dev/null +++ b/webapp/packages/core-view/src/Action/KeyBinding/matchesKeyBinding.ts @@ -0,0 +1,77 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { getCommonAndOSSpecificKeys } from './getCommonAndOSSpecificKeys.js'; +import type { IKeyBinding } from './IKeyBinding.js'; + +// Overrides for keys where the display value in FORMAT_SHORTCUT_KEYS_MAP differs from event.key +const EVENT_KEY_OVERRIDES: Record = { + backspace: 'Backspace', + enter: 'Enter', + return: 'Enter', + escape: 'Escape', + esc: 'Escape', + del: 'Delete', + delete: 'Delete', + tab: 'Tab', + space: ' ', + up: 'ArrowUp', + down: 'ArrowDown', + left: 'ArrowLeft', + right: 'ArrowRight', +}; + +function resolveEventKey(keyPart: string): string { + return EVENT_KEY_OVERRIDES[keyPart] ?? keyPart; +} + +const MODIFIER_KEYS = new Set(['mod', 'ctrl', 'shift', 'alt', 'meta']); + +function matchesCombo(event: KeyboardEvent | React.KeyboardEvent, combo: string): boolean { + const parts = combo.toLowerCase().split('+'); + const keyParts = parts.filter(p => !MODIFIER_KEYS.has(p)); + + const needsMod = parts.includes('mod'); + const needsCtrl = parts.includes('ctrl'); + const needsShift = parts.includes('shift'); + const needsAlt = parts.includes('alt'); + const needsMeta = parts.includes('meta'); + + if (needsMod && !(event.ctrlKey || event.metaKey)) { + return false; + } + if (needsCtrl && !event.ctrlKey) { + return false; + } + if (needsMeta && !event.metaKey) { + return false; + } + if (needsShift && !event.shiftKey) { + return false; + } + if (needsAlt && !event.altKey) { + return false; + } + + // Disallow unexpected modifiers + if (!needsMod && !needsCtrl && !needsMeta && (event.ctrlKey || event.metaKey)) { + return false; + } + if (!needsShift && event.shiftKey) { + return false; + } + if (!needsAlt && event.altKey) { + return false; + } + + const eventKey = event.key.toLowerCase(); + return keyParts.some(k => resolveEventKey(k).toLowerCase() === eventKey); +} + +export function matchesKeyBinding(event: KeyboardEvent | React.KeyboardEvent, binding: IKeyBinding): boolean { + return getCommonAndOSSpecificKeys(binding).some(combo => matchesCombo(event, combo)); +} diff --git a/webapp/packages/core-view/src/index.ts b/webapp/packages/core-view/src/index.ts index 90536fd8a85..4237f2c3e71 100644 --- a/webapp/packages/core-view/src/index.ts +++ b/webapp/packages/core-view/src/index.ts @@ -52,6 +52,7 @@ export * from './Action/IActionItem.js'; export * from './Action/KeyBinding/IKeyBinding.js'; export * from './Action/KeyBinding/getCommonAndOSSpecificKeys.js'; export * from './Action/KeyBinding/getBindingLabel.js'; +export * from './Action/KeyBinding/matchesKeyBinding.js'; export * from './LoadableStateContext/DATA_CONTEXT_LOADABLE_STATE.js'; export * from './Menu/MenuItem/IMenuCheckboxItem.js'; export * from './Menu/MenuItem/IMenuActionItem.js'; diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellRenderer/CellRenderer.tsx b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellRenderer/CellRenderer.tsx index ed19a32d7a3..9600b62eab2 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellRenderer/CellRenderer.tsx +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellRenderer/CellRenderer.tsx @@ -13,7 +13,8 @@ import { getComputed, useHover, useMergeRefs, useObjectRef, useObservableRef } f import { EventContext, EventStopPropagationFlag } from '@cloudbeaver/core-events'; import { clsx } from '@dbeaver/ui-kit'; import { type IDataGridCellRenderer, type ICellPosition } from '@cloudbeaver/plugin-data-grid'; -import { DatabaseEditChangeType, type IGridDataKey, type IGridRowKey } from '@cloudbeaver/plugin-data-viewer'; +import { DatabaseEditChangeType, type IGridDataKey, type IGridRowKey, KEY_BINDING_OPEN_CELL_CONTEXT_MENU } from '@cloudbeaver/plugin-data-viewer'; +import { matchesKeyBinding } from '@cloudbeaver/core-view'; import { isObjectsEqual } from '@cloudbeaver/core-utils'; import { ColumnDnDContext } from '../ColumnDnDContext.js'; @@ -144,6 +145,13 @@ export const CellRenderer = observer(function CellRenderer({ rowIdx, colI this.selectionContext.clearSelection(); } }, + keyDown(event: React.KeyboardEvent) { + if (matchesKeyBinding(event, KEY_BINDING_OPEN_CELL_CONTEXT_MENU)) { + event.preventDefault(); + event.stopPropagation(); + this.cellContext.setMenuVisibility(!this.cellContext.isMenuVisible); + } + }, openContextMenu(event: React.MouseEvent) { if (EventContext.has(event, EventStopPropagationFlag)) { return; @@ -177,7 +185,7 @@ export const CellRenderer = observer(function CellRenderer({ rowIdx, colI dataGridContext, cellContext, }, - ['mouseUp', 'openContextMenu'], + ['keyDown', 'mouseUp', 'openContextMenu'], ); const formatting = getComputed( @@ -195,6 +203,7 @@ export const CellRenderer = observer(function CellRenderer({ rowIdx, colI style: formatting || undefined, 'data-row-index': rowIdx, 'data-column-index': colIdx, + onKeyDown: state.keyDown, onMouseUp: state.mouseUp, onContextMenu: state.openContextMenu, })} diff --git a/webapp/packages/plugin-data-viewer/src/DATA_VIEWER_KEY_BINDINGS.ts b/webapp/packages/plugin-data-viewer/src/DATA_VIEWER_KEY_BINDINGS.ts index 7e38ca9c8b4..eb1c64a4952 100644 --- a/webapp/packages/plugin-data-viewer/src/DATA_VIEWER_KEY_BINDINGS.ts +++ b/webapp/packages/plugin-data-viewer/src/DATA_VIEWER_KEY_BINDINGS.ts @@ -37,3 +37,9 @@ export const KEY_BINDING_CANCEL: IKeyBinding = { id: 'data-viewer-cancel', keys: ['mod+period'], }; + +export const KEY_BINDING_OPEN_CELL_CONTEXT_MENU: IKeyBinding = { + id: 'data-viewer-open-cell-context-menu', + keys: ['mod+/'], + preventDefault: true, +}; diff --git a/webapp/packages/plugin-help/src/Shortcuts/SHORTCUTS_DATA.ts b/webapp/packages/plugin-help/src/Shortcuts/SHORTCUTS_DATA.ts index 20fb2c74c71..bb78085b7ca 100644 --- a/webapp/packages/plugin-help/src/Shortcuts/SHORTCUTS_DATA.ts +++ b/webapp/packages/plugin-help/src/Shortcuts/SHORTCUTS_DATA.ts @@ -11,6 +11,7 @@ import { KEY_BINDING_CANCEL, KEY_BINDING_DELETE_ROW, KEY_BINDING_DUPLICATE_ROW, + KEY_BINDING_OPEN_CELL_CONTEXT_MENU, KEY_BINDING_REVERT_INLINE_EDITOR_CHANGES, KEY_BINDING_SAVE, } from '@cloudbeaver/plugin-data-viewer'; @@ -41,6 +42,10 @@ const KEY_BINDING_FIND = createKeyBinding({ import type { IShortcut } from './IShortcut.js'; export const DATA_VIEWER_SHORTCUTS: IShortcut[] = [ + { + label: 'data_viewer_shortcut_open_cell_context_menu', + code: transformKeys(KEY_BINDING_OPEN_CELL_CONTEXT_MENU), + }, { label: 'data_viewer_shortcut_revert_inline_editor_changes', code: transformKeys(KEY_BINDING_REVERT_INLINE_EDITOR_CHANGES), diff --git a/webapp/packages/plugin-help/src/locales/en.ts b/webapp/packages/plugin-help/src/locales/en.ts index bbfd865412e..732beb88ed5 100644 --- a/webapp/packages/plugin-help/src/locales/en.ts +++ b/webapp/packages/plugin-help/src/locales/en.ts @@ -4,6 +4,7 @@ export default [ ['plugin_help_welcome_docs_label', 'Documentation'], ['plugin_help_welcome_docs_description', 'Learn how to use {arg:product}'], + ['data_viewer_shortcut_open_cell_context_menu', 'Open context menu for the selected Cell'], ['data_viewer_shortcut_start_inline_editing', 'Open Inline Editor for the selected Cell'], ['data_viewer_shortcut_add_new_row', 'Add a new Row'], ['data_viewer_shortcut_duplicate_row', 'Duplicate row'], diff --git a/webapp/packages/plugin-help/src/locales/fr.ts b/webapp/packages/plugin-help/src/locales/fr.ts index 23147abf824..f333d763876 100644 --- a/webapp/packages/plugin-help/src/locales/fr.ts +++ b/webapp/packages/plugin-help/src/locales/fr.ts @@ -4,6 +4,7 @@ export default [ ['plugin_help_welcome_docs_label', 'Documentation'], ['plugin_help_welcome_docs_description', 'Apprenez à utiliser {arg:product}'], + ['data_viewer_shortcut_open_cell_context_menu', 'Ouvrir le menu contextuel pour la cellule sélectionnée'], ['data_viewer_shortcut_start_inline_editing', "Ouvrir l'éditeur en ligne pour la cellule sélectionnée"], ['data_viewer_shortcut_add_new_row', 'Ajouter une nouvelle ligne'], ['data_viewer_shortcut_duplicate_row', 'Dupliquer la ligne'], diff --git a/webapp/packages/plugin-help/src/locales/it.ts b/webapp/packages/plugin-help/src/locales/it.ts index 35daa88e602..00438fc1d96 100644 --- a/webapp/packages/plugin-help/src/locales/it.ts +++ b/webapp/packages/plugin-help/src/locales/it.ts @@ -4,6 +4,7 @@ export default [ ['plugin_help_welcome_docs_label', 'Documentation'], ['plugin_help_welcome_docs_description', 'Learn how to use {arg:product}'], + ['data_viewer_shortcut_open_cell_context_menu', 'Apri il menu contestuale per la cella selezionata'], ['data_viewer_shortcut_start_inline_editing', 'Open Inline Editor for the selected Cell'], ['data_viewer_shortcut_add_new_row', 'Add a new Row'], ['data_viewer_shortcut_duplicate_row', 'Duplicate row'], diff --git a/webapp/packages/plugin-help/src/locales/ru.ts b/webapp/packages/plugin-help/src/locales/ru.ts index 93f64b72815..9a5283e886c 100644 --- a/webapp/packages/plugin-help/src/locales/ru.ts +++ b/webapp/packages/plugin-help/src/locales/ru.ts @@ -4,6 +4,7 @@ export default [ ['plugin_help_welcome_docs_label', 'Документация'], ['plugin_help_welcome_docs_description', 'Узнайте как использовать {arg:product}'], + ['data_viewer_shortcut_open_cell_context_menu', 'Открыть контекстное меню для выбранной ячейки'], ['data_viewer_shortcut_start_inline_editing', 'Начать редактирование ячейки во встроенном редакторе'], ['data_viewer_shortcut_add_new_row', 'Добавить новую строку'], ['data_viewer_shortcut_duplicate_row', 'Продублировать строку'], diff --git a/webapp/packages/plugin-help/src/locales/vi.ts b/webapp/packages/plugin-help/src/locales/vi.ts index ba36545b9e3..7c25033a497 100644 --- a/webapp/packages/plugin-help/src/locales/vi.ts +++ b/webapp/packages/plugin-help/src/locales/vi.ts @@ -4,6 +4,7 @@ export default [ ['plugin_help_welcome_docs_label', 'Documentation'], ['plugin_help_welcome_docs_description', 'Learn how to use {arg:product}'], + ['data_viewer_shortcut_open_cell_context_menu', 'Mở menu ngữ cảnh cho ô được chọn'], ['data_viewer_shortcut_start_inline_editing', 'Mở Trình chỉnh sửa inline cho ô được chọn'], ['data_viewer_shortcut_add_new_row', 'Thêm một hàng mới'], ['data_viewer_shortcut_duplicate_row', 'Sao chép hàng'], diff --git a/webapp/packages/plugin-help/src/locales/zh.ts b/webapp/packages/plugin-help/src/locales/zh.ts index 642c60bba22..001d7967143 100644 --- a/webapp/packages/plugin-help/src/locales/zh.ts +++ b/webapp/packages/plugin-help/src/locales/zh.ts @@ -4,6 +4,7 @@ export default [ ['plugin_help_welcome_docs_label', 'Documentation'], ['plugin_help_welcome_docs_description', 'Learn how to use {arg:product}'], + ['data_viewer_shortcut_open_cell_context_menu', '打开所选单元格的上下文菜单'], ['data_viewer_shortcut_start_inline_editing', '为所选单元格打开内联编辑器'], ['data_viewer_shortcut_add_new_row', '添加行'], ['data_viewer_shortcut_duplicate_row', '复制行'],