From cee90a4e8229a6762c2c93471554fc5104061ac5 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 27 May 2026 19:58:13 +0800 Subject: [PATCH] feat(ui): add kbd primitive (#36729) --- packages/dify-ui/README.md | 2 + packages/dify-ui/package.json | 5 + .../src/autocomplete/index.stories.tsx | 5 +- .../dify-ui/src/kbd/__tests__/index.spec.tsx | 59 +++++ packages/dify-ui/src/kbd/index.stories.tsx | 230 ++++++++++++++++++ packages/dify-ui/src/kbd/index.tsx | 61 +++++ .../dify-ui/src/popover/index.stories.tsx | 10 +- pnpm-lock.yaml | 21 +- pnpm-workspace.yaml | 1 - .../app-sidebar/sidebar-shell-flow.test.tsx | 24 +- web/__tests__/app/app-publisher-flow.test.tsx | 9 - .../app-sidebar/__tests__/index.spec.tsx | 12 +- .../__tests__/toggle-button.spec.tsx | 6 - web/app/components/app-sidebar/index.tsx | 22 +- .../components/app-sidebar/toggle-button.tsx | 11 +- .../app-publisher/__tests__/index.spec.tsx | 22 +- .../app-publisher/__tests__/sections.spec.tsx | 8 +- .../components/app/app-publisher/index.tsx | 9 +- .../components/app/app-publisher/sections.tsx | 9 +- .../config/automatic/instruction-editor.tsx | 3 +- .../create-app-modal/__tests__/index.spec.tsx | 32 ++- .../components/app/create-app-modal/index.tsx | 15 +- .../__tests__/index.spec.tsx | 59 +++-- .../app/create-from-dsl-modal/index.tsx | 26 +- .../base/file-uploader/pdf-preview.tsx | 6 +- .../mixed-variable-text-input/placeholder.tsx | 3 +- .../base/image-uploader/image-preview.tsx | 10 +- .../plugins/hitl-input-block/input-field.tsx | 10 +- .../base/search-input/index.stories.tsx | 5 +- .../datasets/common/image-previewer/index.tsx | 11 +- .../__tests__/index.spec.tsx | 2 - .../create-from-dsl-modal/index.tsx | 8 +- .../common/__tests__/action-buttons.spec.tsx | 49 ++-- .../completed/common/action-buttons.tsx | 19 +- .../create-app-modal/__tests__/index.spec.tsx | 44 +++- .../explore/create-app-modal/index.tsx | 19 +- .../goto-anything/__tests__/index.spec.tsx | 52 ++-- .../__tests__/search-input.spec.tsx | 19 +- .../goto-anything/components/search-input.tsx | 9 +- .../__tests__/use-goto-anything-modal.spec.ts | 66 ++--- .../hooks/use-goto-anything-modal.ts | 24 +- .../components/__tests__/index.spec.tsx | 2 - .../__tests__/index.spec.tsx | 23 +- .../__tests__/run-mode.spec.tsx | 8 +- .../publisher/__tests__/index.spec.tsx | 40 ++- .../publisher/__tests__/popup.spec.tsx | 23 +- .../rag-pipeline-header/publisher/popup.tsx | 18 +- .../rag-pipeline-header/run-mode.tsx | 9 +- .../__tests__/reactflow-mock-state.ts | 1 - .../__tests__/shortcuts-name.spec.tsx | 51 ---- .../header/__tests__/run-mode.spec.tsx | 4 - .../__tests__/test-run-menu-helpers.spec.tsx | 4 - .../header/__tests__/test-run-menu.spec.tsx | 4 - .../mixed-variable-text-input/placeholder.tsx | 3 +- .../__tests__/form-content.spec.tsx | 16 +- .../human-input/components/form-content.tsx | 15 +- .../mixed-variable-text-input/placeholder.tsx | 3 +- .../components/workflow/shortcuts-name.tsx | 42 ---- .../shortcuts/__tests__/shortcut-kbd.spec.tsx | 4 +- .../workflow/shortcuts/shortcut-kbd.tsx | 21 +- .../shortcuts/use-workflow-hotkeys.ts | 13 +- .../workflow/utils/__tests__/common.spec.ts | 156 ------------ web/app/components/workflow/utils/common.ts | 36 --- web/package.json | 1 - 64 files changed, 811 insertions(+), 703 deletions(-) create mode 100644 packages/dify-ui/src/kbd/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/kbd/index.stories.tsx create mode 100644 packages/dify-ui/src/kbd/index.tsx delete mode 100644 web/app/components/workflow/__tests__/shortcuts-name.spec.tsx delete mode 100644 web/app/components/workflow/shortcuts-name.tsx diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 12e5915397..157f6eb752 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -32,6 +32,7 @@ import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer' import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field' import { Form } from '@langgenius/dify-ui/form' +import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control' import { Textarea } from '@langgenius/dify-ui/textarea' @@ -46,6 +47,7 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | | Actions | `./button` | Design-system CTA primitive with `cva` variants. | | Controls | `./segmented-control` | SegmentedControl for mode, filter, and view selection. | +| Display | `./kbd` | Keyboard input and shortcut keycap primitives. | | Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. | | Form | `./form`, `./field`, `./fieldset`, `./input`, `./textarea`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. | | Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. | diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 279c45c3c6..3f4a7d7999 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -69,6 +69,10 @@ "types": "./src/input/index.tsx", "import": "./src/input/index.tsx" }, + "./kbd": { + "types": "./src/kbd/index.tsx", + "import": "./src/kbd/index.tsx" + }, "./meter": { "types": "./src/meter/index.tsx", "import": "./src/meter/index.tsx" @@ -171,6 +175,7 @@ "@storybook/addon-themes": "catalog:", "@storybook/react-vite": "catalog:", "@tailwindcss/vite": "catalog:", + "@tanstack/react-hotkeys": "catalog:", "@tanstack/react-virtual": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", diff --git a/packages/dify-ui/src/autocomplete/index.stories.tsx b/packages/dify-ui/src/autocomplete/index.stories.tsx index 5fa35015c7..5b98b39f1f 100644 --- a/packages/dify-ui/src/autocomplete/index.stories.tsx +++ b/packages/dify-ui/src/autocomplete/index.stories.tsx @@ -23,6 +23,7 @@ import { useAutocompleteFilteredItems, } from '.' import { cn } from '../cn' +import { Kbd } from '../kbd' type Suggestion = { value: string @@ -309,9 +310,9 @@ const CommandPaletteList = () => { {item.description} - + Enter - + )} diff --git a/packages/dify-ui/src/kbd/__tests__/index.spec.tsx b/packages/dify-ui/src/kbd/__tests__/index.spec.tsx new file mode 100644 index 0000000000..7bfb5530b1 --- /dev/null +++ b/packages/dify-ui/src/kbd/__tests__/index.spec.tsx @@ -0,0 +1,59 @@ +import { render } from 'vitest-browser-react' +import { Kbd, KbdGroup } from '../index' + +describe('Kbd', () => { + it('renders a native kbd element with the default gray variant', async () => { + const screen = await render() + const key = screen.getByText('⌘').element() + + expect(key.tagName).toBe('KBD') + await expect.element(screen.getByText('⌘')).toHaveClass( + 'h-4', + 'min-w-4', + 'px-px', + 'rounded-sm', + 'bg-components-kbd-bg-gray', + 'text-text-tertiary', + 'system-kbd', + ) + }) + + it('applies the white variant for elevated or inverse surfaces', async () => { + const screen = await render() + + await expect.element(screen.getByText('↵')).toHaveClass( + 'bg-components-kbd-bg-white', + 'text-text-primary-on-surface', + ) + }) + + it('marks disabled keycaps visually without adding widget semantics', async () => { + const screen = await render() + + await expect.element(screen.getByText('⌘')).toHaveAttribute('data-disabled') + await expect.element(screen.getByText('⌘')).toHaveClass('opacity-30') + await expect.element(screen.getByText('⌘')).not.toHaveAttribute('aria-disabled') + }) + + it('merges custom classes with the design-system recipe', async () => { + const screen = await render(K) + + await expect.element(screen.getByText('K')).toHaveClass('custom-key', 'h-5') + }) +}) + +describe('KbdGroup', () => { + it('groups keycaps without replacing individual kbd semantics', async () => { + const screen = await render( + + + + K + , + ) + + const group = screen.getByLabelText('Command Shift K').element() + expect(group.tagName).toBe('SPAN') + expect(group.querySelectorAll('kbd')).toHaveLength(3) + }) +}) diff --git a/packages/dify-ui/src/kbd/index.stories.tsx b/packages/dify-ui/src/kbd/index.stories.tsx new file mode 100644 index 0000000000..b953d22a36 --- /dev/null +++ b/packages/dify-ui/src/kbd/index.stories.tsx @@ -0,0 +1,230 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { FormatDisplayOptions, RegisterableHotkey } from '@tanstack/react-hotkeys' +import { formatForDisplay } from '@tanstack/react-hotkeys' +import { Kbd, KbdGroup } from '.' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '../context-menu' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '../tooltip' + +const meta = { + title: 'Base/UI/Kbd', + component: Kbd, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Keyboard input primitives aligned with the Dify Key Set design. ' + + '`Kbd` renders a native `` element for a single key or key-like token. ' + + '`KbdGroup` only groups multiple keycaps; it does not replace the individual `` semantics.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + color: { + control: 'select', + options: ['gray', 'white'], + }, + disabled: { control: 'boolean' }, + }, + args: { + children: 'K', + color: 'gray', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const displayKeys = ( + hotkey: RegisterableHotkey | (string & {}), + platform: FormatDisplayOptions['platform'] = 'mac', +) => { + if (typeof hotkey !== 'string') + return [formatForDisplay(hotkey, { platform })] + + return hotkey + .split('+') + .filter(Boolean) + .map(key => formatForDisplay(key, { platform })) +} + +const HotkeyKbdGroup = ({ + hotkey, + color = 'gray', + platform = 'mac', +}: { + hotkey: RegisterableHotkey | (string & {}) + color?: 'gray' | 'white' + platform?: FormatDisplayOptions['platform'] +}) => ( + + {displayKeys(hotkey, platform).map((key, index) => ( + + {key} + + ))} + +) + +export const Default: Story = { + render: () => , +} + +export const KeySet: Story = { + parameters: { + docs: { + description: { + story: 'Figma Key Set variants: gray and white, each with a disabled state. Disabled is visual only because `` is not an interactive widget.', + }, + }, + }, + render: () => ( +
+ Gray + + + + + + + + + + White +
+ + + + +
+
+ + + + +
+
+ ), +} + +export const FormattedShortcuts: Story = { + parameters: { + docs: { + description: { + story: '`Kbd` does not parse hotkeys. Compose it with a formatter at the feature layer; this story uses TanStack Hotkeys `formatForDisplay` for platform-aware labels.', + }, + }, + }, + render: () => ( +
+ Action + macOS + Windows + + Search + + + + Save + + + + Redo + + +
+ ), +} + +export const InTooltip: Story = { + decorators: [ + Story => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: 'Shortcut keycaps can be composed inside short tooltip content. The trigger keeps its own accessible name; the tooltip is only a visual hint.', + }, + }, + }, + render: () => ( + + + + + )} + /> + + Collapse sidebar + + + + ), +} + +const MENU_ITEMS = [ + { label: 'Copy', icon: 'i-ri-file-copy-line', hotkey: 'Mod+C' }, + { label: 'Duplicate', icon: 'i-ri-stack-line', hotkey: 'Mod+D' }, + { label: 'Paste', icon: 'i-ri-clipboard-line', hotkey: 'Mod+V' }, +] as const + +export const InContextMenu: Story = { + parameters: { + docs: { + description: { + story: 'A compact context-menu composition based on the Dify Design Kit context menu example. The menu is intentionally small here because the story focuses on shortcut keycaps.', + }, + }, + }, + render: () => ( + + + )} + > + Context menu trigger + + + {MENU_ITEMS.map(({ label, icon, hotkey }) => ( + + + {label} + + + ))} + + + + Delete + + + + + ), +} diff --git a/packages/dify-ui/src/kbd/index.tsx b/packages/dify-ui/src/kbd/index.tsx new file mode 100644 index 0000000000..22b9397b2b --- /dev/null +++ b/packages/dify-ui/src/kbd/index.tsx @@ -0,0 +1,61 @@ +'use client' + +import type { VariantProps } from 'class-variance-authority' +import type { ComponentProps } from 'react' +import { cva } from 'class-variance-authority' +import { cn } from '../cn' + +const kbdVariants = cva( + 'pointer-events-none inline-flex h-4 min-w-4 select-none items-center justify-center rounded-sm px-px font-sans system-kbd capitalize not-italic', + { + variants: { + color: { + gray: 'bg-components-kbd-bg-gray text-text-tertiary', + white: 'bg-components-kbd-bg-white text-text-primary-on-surface', + }, + disabled: { + true: 'opacity-30', + false: '', + }, + }, + defaultVariants: { + color: 'gray', + disabled: false, + }, + }, +) + +export type KbdColor = NonNullable['color']> + +export type KbdProps + = Omit, 'color'> + & VariantProps + +export function Kbd({ + className, + color, + disabled, + ...props +}: KbdProps) { + return ( + + ) +} + +export type KbdGroupProps = ComponentProps<'span'> + +export function KbdGroup({ + className, + ...props +}: KbdGroupProps) { + return ( + + ) +} diff --git a/packages/dify-ui/src/popover/index.stories.tsx b/packages/dify-ui/src/popover/index.stories.tsx index 802337634b..a622fa3d21 100644 --- a/packages/dify-ui/src/popover/index.stories.tsx +++ b/packages/dify-ui/src/popover/index.stories.tsx @@ -10,6 +10,7 @@ import { PopoverTitle, PopoverTrigger, } from '.' +import { Kbd, KbdGroup } from '../kbd' const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover' @@ -46,11 +47,10 @@ export const Default: Story = { Press {' '} - - {' '} - + - {' '} - K + + + K + {' '} to open the command palette anywhere in the app. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0aae05e1a..bceb0fe7ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -477,9 +477,6 @@ catalogs: react-easy-crop: specifier: 5.5.7 version: 5.5.7 - react-hotkeys-hook: - specifier: 5.3.2 - version: 5.3.2 react-i18next: specifier: 16.6.6 version: 16.6.6 @@ -855,6 +852,9 @@ importers: '@tailwindcss/vite': specifier: 'catalog:' version: 4.3.0(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + '@tanstack/react-hotkeys': + specifier: 'catalog:' + version: 0.10.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-virtual': specifier: 'catalog:' version: 3.13.25(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1212,9 +1212,6 @@ importers: react-easy-crop: specifier: 'catalog:' version: 5.5.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-hotkeys-hook: - specifier: 'catalog:' - version: 5.3.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-i18next: specifier: 'catalog:' version: 16.6.6(i18next@26.2.0(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) @@ -8096,12 +8093,6 @@ packages: react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} - react-hotkeys-hook@5.3.2: - resolution: {integrity: sha512-DDDy9xK6mbTQ6aPlQvIl0dA/a90T/AWml4Rm21JXFDLlRHalIg4/Rv3equUQYs5xPTWq+oEl6RD7mi/nBpU3Uw==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - react-i18next@16.6.6: resolution: {integrity: sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==} peerDependencies: @@ -16253,11 +16244,6 @@ snapshots: react-fast-compare@3.2.2: {} - react-hotkeys-hook@5.3.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-i18next@16.6.6(i18next@26.2.0(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3): dependencies: '@babel/runtime': 7.29.2 @@ -17760,7 +17746,6 @@ time: qs@6.15.2: '2026-05-16T23:19:19.539Z' react-dom@19.2.6: '2026-05-06T16:16:56.080Z' react-easy-crop@5.5.7: '2026-03-24T09:41:01.114Z' - react-hotkeys-hook@5.3.2: '2026-05-05T13:01:00.987Z' react-i18next@16.6.6: '2026-03-24T15:35:20.832Z' react-multi-email@1.0.25: '2024-07-18T04:31:06.176Z' react-papaparse@4.4.0: '2023-10-13T10:27:07.978Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d88119c370..4155780308 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -202,7 +202,6 @@ catalog: react: 19.2.6 react-dom: 19.2.6 react-easy-crop: 5.5.7 - react-hotkeys-hook: 5.3.2 react-i18next: 16.6.6 react-multi-email: 1.0.25 react-papaparse: 4.4.0 diff --git a/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx b/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx index ef765c06f2..053edb8288 100644 --- a/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx +++ b/web/__tests__/app-sidebar/sidebar-shell-flow.test.tsx @@ -10,7 +10,7 @@ let mockAppSidebarExpand = 'expand' let mockPathname = '/app/app-1/logs' let mockSelectedSegment = 'logs' let mockIsHovering = true -let keyPressHandler: ((event: { preventDefault: () => void }) => void) | null = null +let hotkeyHandler: ((event: { preventDefault: () => void }) => void) | null = null vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -63,11 +63,18 @@ vi.mock('@/next/link', () => ({ vi.mock('ahooks', () => ({ useHover: () => mockIsHovering, - useKeyPress: (_key: string, handler: (event: { preventDefault: () => void }) => void) => { - keyPressHandler = handler - }, })) +vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useHotkey: (_hotkey: string, handler: (event: { preventDefault: () => void }) => void) => { + hotkeyHandler = handler + }, + } +}) + vi.mock('@/hooks/use-breakpoints', () => ({ default: () => 'desktop', MediaType: { @@ -90,11 +97,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/app/components/workflow/utils', () => ({ - getKeyboardKeyCodeBySystem: () => 'ctrl', - getKeyboardKeyNameBySystem: (key: string) => key, -})) - vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu')) vi.mock('@langgenius/dify-ui/tooltip', () => import('@/__mocks__/base-ui-tooltip')) @@ -131,7 +133,7 @@ describe('App Sidebar Shell Flow', () => { mockPathname = '/app/app-1/logs' mockSelectedSegment = 'logs' mockIsHovering = true - keyPressHandler = null + hotkeyHandler = null }) it('renders the expanded sidebar, marks the active nav item, and toggles collapse by click and shortcut', () => { @@ -146,7 +148,7 @@ describe('App Sidebar Shell Flow', () => { expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse') const preventDefault = vi.fn() - keyPressHandler?.({ preventDefault }) + hotkeyHandler?.({ preventDefault }) expect(preventDefault).toHaveBeenCalled() expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse') diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx index ff2e3f6f44..d598051f72 100644 --- a/web/__tests__/app/app-publisher-flow.test.tsx +++ b/web/__tests__/app/app-publisher-flow.test.tsx @@ -43,10 +43,6 @@ vi.mock('react-i18next', () => ({ }), })) -vi.mock('ahooks', () => ({ - useKeyPress: vi.fn(), -})) - vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: Record) => unknown) => selector({ appDetail: mockAppDetail, @@ -124,11 +120,6 @@ vi.mock('@/app/components/app/app-access-control', () => ({ vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) -vi.mock('@/app/components/workflow/utils', () => ({ - getKeyboardKeyCodeBySystem: () => 'ctrl', - getKeyboardKeyNameBySystem: (key: string) => key, -})) - describe('App Publisher Flow', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/app-sidebar/__tests__/index.spec.tsx b/web/app/components/app-sidebar/__tests__/index.spec.tsx index 5c00ced6cc..10aedeb071 100644 --- a/web/app/components/app-sidebar/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/index.spec.tsx @@ -28,7 +28,10 @@ let mockKeyPressCallback: ((e: { preventDefault: () => void }) => void) | null = vi.mock('ahooks', () => ({ useHover: () => mockIsHovering, - useKeyPress: (_key: string, cb: (e: { preventDefault: () => void }) => void) => { +})) + +vi.mock('@tanstack/react-hotkeys', () => ({ + useHotkey: (_hotkey: string, cb: (e: { preventDefault: () => void }) => void) => { mockKeyPressCallback = cb }, })) @@ -52,10 +55,6 @@ vi.mock('../../base/divider', () => ({ default: ({ className }: { className?: string }) =>
, })) -vi.mock('@/app/components/workflow/utils', () => ({ - getKeyboardKeyCodeBySystem: () => 'ctrl', -})) - vi.mock('../app-info', () => ({ default: ({ expand }: { expand: boolean }) => (
@@ -110,6 +109,7 @@ describe('AppDetailNav', () => { mockAppSidebarExpand = 'expand' mockPathname = '/app/123/overview' mockIsHovering = true + mockKeyPressCallback = null }) describe('Normal sidebar mode', () => { @@ -279,7 +279,7 @@ describe('AppDetailNav', () => { }) describe('Keyboard shortcut', () => { - it('should toggle sidebar on ctrl+b', () => { + it('should toggle sidebar on Mod+B', () => { render() const cb = mockKeyPressCallback diff --git a/web/app/components/app-sidebar/__tests__/toggle-button.spec.tsx b/web/app/components/app-sidebar/__tests__/toggle-button.spec.tsx index 1a117ac5e3..4ab0ecc338 100644 --- a/web/app/components/app-sidebar/__tests__/toggle-button.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/toggle-button.spec.tsx @@ -3,12 +3,6 @@ import userEvent from '@testing-library/user-event' import * as React from 'react' import ToggleButton from '../toggle-button' -vi.mock('@/app/components/workflow/shortcuts-name', () => ({ - default: ({ keys }: { keys: string[] }) => ( - {keys.join('+')} - ), -})) - describe('ToggleButton', () => { it('should render collapse arrow when expanded', () => { render() diff --git a/web/app/components/app-sidebar/index.tsx b/web/app/components/app-sidebar/index.tsx index 6fe5fffe8c..a4a2bd441f 100644 --- a/web/app/components/app-sidebar/index.tsx +++ b/web/app/components/app-sidebar/index.tsx @@ -1,7 +1,8 @@ import type { AppInfoActions } from './app-info/use-app-info-actions' import type { NavIcon } from './nav-link' import { cn } from '@langgenius/dify-ui/cn' -import { useHover, useKeyPress } from 'ahooks' +import { useHotkey } from '@tanstack/react-hotkeys' +import { useHover } from 'ahooks' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useShallow } from 'zustand/react/shallow' @@ -10,7 +11,6 @@ import { useEventEmitterContextContext } from '@/context/event-emitter' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { usePathname } from '@/next/navigation' import Divider from '../base/divider' -import { getKeyboardKeyCodeBySystem } from '../workflow/utils' import AppInfo, { AppInfoView } from './app-info' import AppSidebarDropdown from './app-sidebar-dropdown' import DatasetInfo from './dataset-info' @@ -18,15 +18,6 @@ import DatasetSidebarDropdown from './dataset-sidebar-dropdown' import NavLink from './nav-link' import ToggleButton from './toggle-button' -const isShortcutFromInputArea = (target: EventTarget | null) => { - if (!(target instanceof HTMLElement)) - return false - - return target.tagName === 'INPUT' - || target.tagName === 'TEXTAREA' - || target.isContentEditable -} - type IAppDetailNavProps = { iconType?: 'app' | 'dataset' navigation: Array<{ @@ -81,13 +72,12 @@ const AppDetailNav = ({ } }, [appSidebarExpand, setAppSidebarExpand]) - useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => { - if (isShortcutFromInputArea(e.target)) - return - + useHotkey('Mod+B', (e) => { e.preventDefault() handleToggle() - }, { exactMatch: true, useCapture: true }) + }, { + ignoreInputs: true, + }) if (inWorkflowCanvas && hideHeader) { return ( diff --git a/web/app/components/app-sidebar/toggle-button.tsx b/web/app/components/app-sidebar/toggle-button.tsx index 9dd5a58ef2..a58763ac63 100644 --- a/web/app/components/app-sidebar/toggle-button.tsx +++ b/web/app/components/app-sidebar/toggle-button.tsx @@ -1,16 +1,17 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react' +import { formatForDisplay } from '@tanstack/react-hotkeys' import * as React from 'react' import { useTranslation } from 'react-i18next' -import ShortcutsName from '../workflow/shortcuts-name' type ToggleTooltipContentProps = { expand: boolean } -const TOGGLE_SHORTCUT = ['ctrl', 'B'] +const TOGGLE_SHORTCUT = ['Mod', 'B'] const ToggleTooltipContent = ({ expand, @@ -20,7 +21,11 @@ const ToggleTooltipContent = ({ return (
{expand ? t('sidebar.collapseSidebar', { ns: 'layout' }) : t('sidebar.expandSidebar', { ns: 'layout' })} - + + {TOGGLE_SHORTCUT.map(key => ( + {formatForDisplay(key)} + ))} +
) } diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index 0dfb4347e4..4a52baae5f 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -28,8 +28,8 @@ const sectionProps = vi.hoisted(() => ({ access: null as null | Record, actions: null as null | Record, })) -const ahooksMocks = vi.hoisted(() => ({ - keyPressHandlers: [] as Array<(event: { preventDefault: () => void }) => void>, +const hotkeyMocks = vi.hoisted(() => ({ + handlers: [] as Array<(event: { preventDefault: () => void }) => void>, })) let mockAppDetail: Record | null = null @@ -41,13 +41,11 @@ vi.mock('react-i18next', () => ({ Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null, })) -vi.mock('ahooks', async () => { - return { - useKeyPress: (_keys: unknown, handler: (event: { preventDefault: () => void }) => void) => { - ahooksMocks.keyPressHandlers.push(handler) - }, - } -}) +vi.mock('@tanstack/react-hotkeys', () => ({ + useHotkey: (_hotkey: string, handler: (event: { preventDefault: () => void }) => void) => { + hotkeyMocks.handlers.push(handler) + }, +})) vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: { appDetail: Record | null, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({ @@ -184,7 +182,7 @@ vi.mock('../sections', () => ({ describe('AppPublisher', () => { beforeEach(() => { vi.clearAllMocks() - ahooksMocks.keyPressHandlers.length = 0 + hotkeyMocks.handlers.length = 0 sectionProps.summary = null sectionProps.access = null sectionProps.actions = null @@ -443,7 +441,7 @@ describe('AppPublisher', () => { />, ) - ahooksMocks.keyPressHandlers[0]!({ preventDefault }) + hotkeyMocks.handlers[0]!({ preventDefault }) await waitFor(() => { expect(preventDefault).toHaveBeenCalled() @@ -472,7 +470,7 @@ describe('AppPublisher', () => { />, ) - ahooksMocks.keyPressHandlers[0]!({ preventDefault }) + hotkeyMocks.handlers[0]!({ preventDefault }) await waitFor(() => { expect(preventDefault).toHaveBeenCalled() diff --git a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx index 453779504e..1572f3d4e0 100644 --- a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx @@ -72,7 +72,7 @@ describe('app-publisher sections', () => { publishDisabled={false} published={false} publishedAt={Date.now()} - publishShortcut={['ctrl', '⇧', 'P']} + publishShortcut={['Mod', 'Shift', 'P']} startNodeLimitExceeded={false} upgradeHighlightStyle={{}} />, @@ -110,7 +110,7 @@ describe('app-publisher sections', () => { publishDisabled={false} published={false} publishedAt={undefined} - publishShortcut={['ctrl', '⇧', 'P']} + publishShortcut={['Mod', 'Shift', 'P']} startNodeLimitExceeded={false} upgradeHighlightStyle={{}} />, @@ -134,7 +134,7 @@ describe('app-publisher sections', () => { publishDisabled={false} published={false} publishedAt={undefined} - publishShortcut={['ctrl', '⇧', 'P']} + publishShortcut={['Mod', 'Shift', 'P']} startNodeLimitExceeded={false} upgradeHighlightStyle={{}} />, @@ -158,7 +158,7 @@ describe('app-publisher sections', () => { publishDisabled={false} published={false} publishedAt={undefined} - publishShortcut={['ctrl', '⇧', 'P']} + publishShortcut={['Mod', 'Shift', 'P']} startNodeLimitExceeded upgradeHighlightStyle={{}} />, diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 4b87081f04..58b611ac45 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -7,8 +7,8 @@ import type { PublishWorkflowParams } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' +import { useHotkey } from '@tanstack/react-hotkeys' import { useSuspenseQuery } from '@tanstack/react-query' -import { useKeyPress } from 'ahooks' import { memo, @@ -46,7 +46,6 @@ import { useInvalidateAppWorkflow } from '@/service/use-workflow' import { fetchPublishedWorkflow } from '@/service/workflow' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' -import { getKeyboardKeyCodeBySystem } from '../../workflow/utils' import AccessControl from '../app-access-control' import { PublisherAccessSection, @@ -84,7 +83,7 @@ export type AppPublisherProps = { hasHumanInputNode?: boolean } -const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] +const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P'] type AppPublisherPublishHandler = | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise | unknown) @@ -302,12 +301,12 @@ const AppPublisher = ({ } }, [appDetail?.id, publishingToMarketplace, t]) - useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => { + useHotkey('Mod+Shift+P', (e) => { e.preventDefault() if (publishDisabled || published) return handlePublish() - }, { exactMatch: true, useCapture: true }) + }) useEffect(() => { const appId = appDetail?.id diff --git a/web/app/components/app/app-publisher/sections.tsx b/web/app/components/app/app-publisher/sections.tsx index 67efb931d7..b3fd7dc14b 100644 --- a/web/app/components/app/app-publisher/sections.tsx +++ b/web/app/components/app/app-publisher/sections.tsx @@ -3,19 +3,20 @@ import type { ModelAndParameter } from '../configuration/debug/types' import type { AppPublisherProps } from './index' import type { PublishWorkflowParams } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' +import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Tooltip, TooltipContent, TooltipTrigger, } from '@langgenius/dify-ui/tooltip' import { RiSettings2Line } from '@remixicon/react' +import { formatForDisplay } from '@tanstack/react-hotkeys' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import Loading from '@/app/components/base/loading' import UpgradeBtn from '@/app/components/billing/upgrade-btn' import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button' import { AppModeEnum } from '@/types/app' -import ShortcutsName from '../../workflow/shortcuts-name' import PublishWithMultipleModel from './publish-with-multiple-model' import SuggestedAction from './suggested-action' import { ACCESS_MODE_MAP } from './utils' @@ -163,7 +164,11 @@ export const PublisherSummarySection = ({ : (
{t('common.publishUpdate', { ns: 'workflow' })} - + + {publishShortcut.map(key => ( + {formatForDisplay(key)} + ))} +
)} diff --git a/web/app/components/app/configuration/config/automatic/instruction-editor.tsx b/web/app/components/app/configuration/config/automatic/instruction-editor.tsx index 710f530c9e..18169f39ad 100644 --- a/web/app/components/app/configuration/config/automatic/instruction-editor.tsx +++ b/web/app/components/app/configuration/config/automatic/instruction-editor.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react' import type { GeneratorType } from './types' import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' +import { Kbd } from '@langgenius/dify-ui/kbd' import * as React from 'react' import { useTranslation } from 'react-i18next' import PromptEditor from '@/app/components/base/prompt-editor' @@ -111,7 +112,7 @@ const InstructionEditor: FC = ({ />
{t('generate.press', { ns: 'appDebug' })} - / + / {t('generate.to', { ns: 'appDebug' })}
diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx index c6be430fac..d9aa8e957a 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx @@ -17,8 +17,8 @@ const toastMocks = vi.hoisted(() => ({ error: vi.fn(), warning: vi.fn(), })) -const ahooksMocks = vi.hoisted(() => ({ - handlers: [] as Array<{ keys: unknown, handler: () => void }>, +const hotkeyMocks = vi.hoisted(() => ({ + handlers: new Map void, options?: { enabled?: boolean } }>(), })) let mockPlanUsage = 0 let mockPlanTotal = 10 @@ -33,11 +33,25 @@ vi.mock('ahooks', () => ({ useDebounceFn: (fn: (...args: any[]) => any) => ({ run: fn, }), - useKeyPress: (keys: unknown, handler: () => void) => { - ahooksMocks.handlers.push({ keys, handler }) - }, })) +vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useHotkey: (hotkey: string, handler: () => void, options?: { enabled?: boolean }) => { + hotkeyMocks.handlers.set(hotkey, { handler, options }) + }, + } +}) + +const triggerHotkey = (hotkey: string) => { + const registration = hotkeyMocks.handlers.get(hotkey) + if (registration?.options?.enabled === false) + return + registration?.handler() +} + vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, @@ -98,14 +112,10 @@ vi.mock('@/app/components/billing/apps-full-in-dialog', () => ({ default: () =>
apps-full
, })) -vi.mock('../../workflow/shortcuts-name', () => ({ - default: ({ keys }: { keys: string[] }) => {keys.join('+')}, -})) - describe('CreateFromDSLModal', () => { beforeEach(() => { vi.clearAllMocks() - ahooksMocks.handlers.length = 0 + hotkeyMocks.handlers.clear() mockPlanUsage = 0 mockPlanTotal = 10 localStorage.clear() @@ -153,7 +163,7 @@ describe('CreateFromDSLModal', () => { />, ) - ahooksMocks.handlers.find(item => Array.isArray(item.keys))?.handler() + triggerHotkey('Mod+Enter') expect(mockImportDSL).not.toHaveBeenCalled() await act(async () => { @@ -166,6 +176,20 @@ describe('CreateFromDSLModal', () => { expect(handleClose).toHaveBeenCalledTimes(1) }) + it('should render the import shortcut with kbd primitives', () => { + render( + , + ) + + const createButton = getCreateButton() + expect(createButton.querySelectorAll('kbd')).toHaveLength(2) + }) + it('should import from a URL and redirect after a successful import', async () => { const handleClose = vi.fn() const handleSuccess = vi.fn() @@ -258,8 +282,7 @@ describe('CreateFromDSLModal', () => { expect(getCreateButton())!.toBeDisabled() }) - const latestHandlerAfterRemove = [...ahooksMocks.handlers].reverse().find(item => Array.isArray(item.keys)) - latestHandlerAfterRemove?.handler() + triggerHotkey('Mod+Enter') expect(mockImportDSL).not.toHaveBeenCalled() }) @@ -418,7 +441,7 @@ describe('CreateFromDSLModal', () => { expect(toastMocks.error).not.toHaveBeenCalled() }) - it('should handle keyboard shortcuts, quota guard, and escape close', async () => { + it('should handle keyboard shortcut and quota guard', async () => { const handleClose = vi.fn() mockImportDSL.mockResolvedValue({ id: 'import-shortcut', @@ -436,7 +459,7 @@ describe('CreateFromDSLModal', () => { />, ) - ahooksMocks.handlers.find(item => Array.isArray(item.keys))?.handler() + triggerHotkey('Mod+Enter') await waitFor(() => { expect(mockImportDSL).toHaveBeenCalledWith({ @@ -445,9 +468,6 @@ describe('CreateFromDSLModal', () => { }) }) - ahooksMocks.handlers.find(item => item.keys === 'esc')?.handler() - expect(handleClose).toHaveBeenCalled() - mockPlanUsage = 1 mockPlanTotal = 1 render( @@ -460,8 +480,7 @@ describe('CreateFromDSLModal', () => { ) expect(screen.getByText('apps-full'))!.toBeInTheDocument() - const latestPlanLimitHandler = [...ahooksMocks.handlers].reverse().find(item => Array.isArray(item.keys)) - latestPlanLimitHandler?.handler() + triggerHotkey('Mod+Enter') expect(mockImportDSL).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index 6e4c9ec366..f6cf347820 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -4,8 +4,10 @@ import type { MouseEventHandler } from 'react' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { toast } from '@langgenius/dify-ui/toast' -import { useDebounceFn, useKeyPress } from 'ahooks' +import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' +import { useDebounceFn } from 'ahooks' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' @@ -25,7 +27,6 @@ import { } from '@/service/apps' import { getRedirection } from '@/utils/app-redirection' import { trackCreateApp } from '@/utils/create-app-tracking' -import ShortcutsName from '../../workflow/shortcuts-name' import Uploader from './uploader' type CreateFromDSLModalProps = { @@ -150,14 +151,11 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 }) - useKeyPress(['meta.enter', 'ctrl.enter'], () => { - if (show && !isAppsFull && ((currentTab === CreateFromDSLModalTab.FROM_FILE && currentFile) || (currentTab === CreateFromDSLModalTab.FROM_URL && dslUrlValue))) - handleCreateApp(undefined) - }) - - useKeyPress('esc', () => { - if (show && !showErrorModal) - onClose() + useHotkey('Mod+Enter', () => { + handleCreateApp(undefined) + }, { + enabled: show && !isAppsFull && ((currentTab === CreateFromDSLModalTab.FROM_FILE && !!currentFile) || (currentTab === CreateFromDSLModalTab.FROM_URL && !!dslUrlValue)), + ignoreInputs: false, }) const onDSLConfirm: MouseEventHandler = async () => { @@ -215,7 +213,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS return ( <> - + !open && !showErrorModal && onClose()}>
@@ -285,7 +283,11 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS className="gap-1" > {t('newApp.Create', { ns: 'app' })} - + + {['Mod', 'Enter'].map(key => ( + {formatForDisplay(key)} + ))} +
diff --git a/web/app/components/base/file-uploader/pdf-preview.tsx b/web/app/components/base/file-uploader/pdf-preview.tsx index 9b3128ab06..839a9b6df7 100644 --- a/web/app/components/base/file-uploader/pdf-preview.tsx +++ b/web/app/components/base/file-uploader/pdf-preview.tsx @@ -2,9 +2,9 @@ import type { FC } from 'react' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiCloseLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react' +import { useHotkey } from '@tanstack/react-hotkeys' import { noop } from 'es-toolkit/function' import { useState } from 'react' -import { useHotkeys } from 'react-hotkeys-hook' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' @@ -42,8 +42,8 @@ const PdfPreview: FC = ({ }) } - useHotkeys('up', zoomIn) - useHotkeys('down', zoomOut) + useHotkey('ArrowUp', zoomIn) + useHotkey('ArrowDown', zoomOut) const zoomOutLabel = t('operation.zoomOut', { ns: 'common' }) const zoomInLabel = t('operation.zoomIn', { ns: 'common' }) diff --git a/web/app/components/base/form/components/field/mixed-variable-text-input/placeholder.tsx b/web/app/components/base/form/components/field/mixed-variable-text-input/placeholder.tsx index 8fda84cfeb..79c6948dd6 100644 --- a/web/app/components/base/form/components/field/mixed-variable-text-input/placeholder.tsx +++ b/web/app/components/base/form/components/field/mixed-variable-text-input/placeholder.tsx @@ -1,3 +1,4 @@ +import { Kbd } from '@langgenius/dify-ui/kbd' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { $insertNodes, FOCUS_COMMAND } from 'lexical' import { useCallback } from 'react' @@ -25,7 +26,7 @@ const Placeholder = () => { >
Type or press -
/
+ /
{ diff --git a/web/app/components/base/image-uploader/image-preview.tsx b/web/app/components/base/image-uploader/image-preview.tsx index 013ead3f7f..d141541fe1 100644 --- a/web/app/components/base/image-uploader/image-preview.tsx +++ b/web/app/components/base/image-uploader/image-preview.tsx @@ -2,10 +2,10 @@ import type { FC } from 'react' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useHotkey } from '@tanstack/react-hotkeys' import { noop } from 'es-toolkit/function' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' -import { useHotkeys } from 'react-hotkeys-hook' import { useTranslation } from 'react-i18next' import { downloadUrl } from '@/utils/download' @@ -168,10 +168,10 @@ const ImagePreview: FC = ({ } }, [handleMouseUp]) - useHotkeys('up', zoomIn) - useHotkeys('down', zoomOut) - useHotkeys('left', onPrev || noop) - useHotkeys('right', onNext || noop) + useHotkey('ArrowUp', zoomIn) + useHotkey('ArrowDown', zoomOut) + useHotkey('ArrowLeft', onPrev || noop) + useHotkey('ArrowRight', onNext || noop) const copyImageLabel = t('operation.copyImage', { ns: 'common' }) const zoomOutLabel = t('operation.zoomOut', { ns: 'common' }) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx index ca7dc387ed..f42ee37e62 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx @@ -1,13 +1,14 @@ import type { FormInputItem, FormInputItemDefault } from '@/app/components/workflow/nodes/human-input/types' import type { ValueSelector } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' +import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' +import { formatForDisplay } from '@tanstack/react-hotkeys' import { produce } from 'immer' import * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import { InputVarType } from '@/app/components/workflow/types' -import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils' import PrePopulate from './pre-populate' const i18nPrefix = 'nodes.humanInput.insertInputField' @@ -142,8 +143,11 @@ const InputField: React.FC = ({ onClick={handleSave} > {t(`${i18nPrefix}.insert`, { ns: 'workflow' })} - {getKeyboardKeyNameBySystem('ctrl')} - ↩︎ + + {['Mod', 'Enter'].map(key => ( + {formatForDisplay(key)} + ))} + )} diff --git a/web/app/components/base/search-input/index.stories.tsx b/web/app/components/base/search-input/index.stories.tsx index 37bc27bc82..bf8881c7c8 100644 --- a/web/app/components/base/search-input/index.stories.tsx +++ b/web/app/components/base/search-input/index.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { Kbd } from '@langgenius/dify-ui/kbd' import { useState } from 'react' import SearchInput from '.' @@ -355,9 +356,9 @@ const CommandPaletteDemo = () => { {cmd.icon} {cmd.name}
- + {cmd.shortcut} - +
)) ) diff --git a/web/app/components/datasets/common/image-previewer/index.tsx b/web/app/components/datasets/common/image-previewer/index.tsx index 7520f62f81..d089202690 100644 --- a/web/app/components/datasets/common/image-previewer/index.tsx +++ b/web/app/components/datasets/common/image-previewer/index.tsx @@ -1,8 +1,9 @@ import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { Kbd } from '@langgenius/dify-ui/kbd' import { RiArrowLeftLine, RiArrowRightLine, RiCloseLine, RiRefreshLine } from '@remixicon/react' +import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useHotkeys } from 'react-hotkeys-hook' import Loading from '@/app/components/base/loading' import { formatFileSize } from '@/utils/format' @@ -145,8 +146,8 @@ const ImagePreviewer = ({ fetchImage(image) }, [fetchImage]) - useHotkeys('left', prevImage) - useHotkeys('right', nextImage) + useHotkey('ArrowLeft', prevImage) + useHotkey('ArrowRight', nextImage) return ( - - Esc - + {formatForDisplay('Escape')} {cachedImages[currentImage!.url]!.status === 'loading' && ( diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx index 2021320616..6138fc10f3 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx @@ -563,8 +563,6 @@ describe('CreateFromDSLModal', () => { { wrapper: createWrapper() }, ) - // Trigger ESC key event - ahooks useKeyPress listens for 'esc' which maps to Escape key - // Need to dispatch on window/document with the correct event properties const escEvent = new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx index 8aeaed3ebd..593aae185a 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx @@ -1,7 +1,6 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' -import { useKeyPress } from 'ahooks' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import DSLConfirmModal from './dsl-confirm-modal' @@ -50,14 +49,9 @@ const CreateFromDSLModal = ({ onClose, }) - useKeyPress('esc', () => { - if (show && !showConfirmModal) - onClose() - }, { target: () => document }) - return ( <> - + !open && !showConfirmModal && onClose()}>
diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx index c8500e3769..2d59ac4227 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx @@ -4,14 +4,16 @@ import { ChunkingMode } from '@/models/datasets' import { DocumentContext } from '../../../context' import ActionButtons from '../action-buttons' -// Mock useKeyPress: required because tests capture registered callbacks -// via mockUseKeyPress to verify ESC and Ctrl+S keyboard shortcut behavior. -const mockUseKeyPress = vi.fn() -vi.mock('ahooks', () => ({ - useKeyPress: (keys: string | string[], callback: (e: KeyboardEvent) => void, options?: object) => { - mockUseKeyPress(keys, callback, options) - }, -})) +const mockUseHotkey = vi.fn() +vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useHotkey: (hotkey: string, callback: (e: KeyboardEvent) => void, options?: object) => { + mockUseHotkey(hotkey, callback, options) + }, + } +}) // Create wrapper component for providing context const createWrapper = (contextValue: { @@ -25,31 +27,20 @@ const createWrapper = (contextValue: { ) } -// Helper to get captured callbacks from useKeyPress mock const getEscCallback = (): ((e: KeyboardEvent) => void) | undefined => { - const escCall = mockUseKeyPress.mock.calls.find( - (call) => { - const keys = call[0] - return Array.isArray(keys) && keys.includes('esc') - }, - ) + const escCall = mockUseHotkey.mock.calls.find(call => call[0] === 'Escape') return escCall?.[1] } const getCtrlSCallback = (): ((e: KeyboardEvent) => void) | undefined => { - const ctrlSCall = mockUseKeyPress.mock.calls.find( - (call) => { - const keys = call[0] - return typeof keys === 'string' && keys.includes('.s') - }, - ) + const ctrlSCall = mockUseHotkey.mock.calls.find(call => call[0] === 'Mod+S') return ctrlSCall?.[1] } describe('ActionButtons', () => { beforeEach(() => { vi.clearAllMocks() - mockUseKeyPress.mockClear() + mockUseHotkey.mockClear() }) describe('Rendering', () => { @@ -102,7 +93,7 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - expect(screen.getByText('ESC'))!.toBeInTheDocument() + expect(screen.getByText('Esc'))!.toBeInTheDocument() }) it('should render S keyboard hint on save button', () => { @@ -381,7 +372,6 @@ describe('ActionButtons', () => { }) }) - // Keyboard shortcuts tests via useKeyPress callbacks describe('Keyboard Shortcuts', () => { it('should display ctrl key hint on save button', () => { render( @@ -393,8 +383,7 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Assert - check for ctrl key hint (Ctrl or Cmd depending on system) - const kbdElements = document.querySelectorAll('.system-kbd') + const kbdElements = document.querySelectorAll('kbd') expect(kbdElements.length).toBeGreaterThan(0) }) @@ -461,7 +450,7 @@ describe('ActionButtons', () => { expect(mockHandleSave).not.toHaveBeenCalled() }) - it('should register useKeyPress with correct options for Ctrl+S', () => { + it('should register the Mod+S hotkey', () => { render( { { wrapper: createWrapper({}) }, ) - // Assert - verify useKeyPress was called with correct options - const ctrlSCall = mockUseKeyPress.mock.calls.find( - call => typeof call[0] === 'string' && call[0].includes('.s'), - ) + const ctrlSCall = mockUseHotkey.mock.calls.find(call => call[0] === 'Mod+S') expect(ctrlSCall).toBeDefined() - expect(ctrlSCall![2]).toEqual({ exactMatch: true, useCapture: true }) }) }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx b/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx index 3b2e63977d..f955b95a75 100644 --- a/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx @@ -1,11 +1,10 @@ import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' -import { useKeyPress } from 'ahooks' +import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' +import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import ShortcutsName from '@/app/components/workflow/shortcuts-name' -import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils' import { ChunkingMode } from '@/models/datasets' import { useDocumentContext } from '../../context' @@ -32,17 +31,17 @@ const ActionButtons: FC = ({ const docForm = useDocumentContext(s => s.docForm) const parentMode = useDocumentContext(s => s.parentMode) - useKeyPress(['esc'], (e) => { + useHotkey('Escape', (e) => { e.preventDefault() handleCancel() }) - useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.s`, (e) => { + useHotkey('Mod+S', (e) => { e.preventDefault() if (loading) return handleSave() - }, { exactMatch: true, useCapture: true }) + }) const isParentChildParagraphMode = useMemo(() => { return docForm === ChunkingMode.parentChild && parentMode === 'paragraph' @@ -55,7 +54,7 @@ const ActionButtons: FC = ({ >
{t('operation.cancel', { ns: 'common' })} - + {formatForDisplay('Escape')}
{(isParentChildParagraphMode && actionType === 'edit' && !isChildChunk && showRegenerationButton) @@ -77,7 +76,11 @@ const ActionButtons: FC = ({ >
{t('operation.save', { ns: 'common' })} - + + {['Mod', 'S'].map(key => ( + {formatForDisplay(key)} + ))} +
diff --git a/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx index 2b4a2d74cc..48b801b5b3 100644 --- a/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx @@ -7,6 +7,27 @@ import { Plan } from '@/app/components/billing/type' import { AppModeEnum } from '@/types/app' import CreateAppModal from '../index' +const hotkeyMocks = vi.hoisted(() => ({ + handlers: new Map void, options?: { enabled?: boolean } }>(), +})) + +vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useHotkey: (hotkey: string, handler: () => void, options?: { enabled?: boolean }) => { + hotkeyMocks.handlers.set(hotkey, { handler, options }) + }, + } +}) + +const triggerHotkey = (hotkey: string) => { + const registration = hotkeyMocks.handlers.get(hotkey) + if (registration?.options?.enabled === false) + return + registration?.handler() +} + vi.mock('emoji-mart', () => ({ init: vi.fn(), SearchIndex: { search: vi.fn().mockResolvedValue([]) }, @@ -100,6 +121,7 @@ describe('CreateAppModal', () => { mockPlanType = Plan.team mockUsagePlanInfo = createPlanInfo(1) mockTotalPlanInfo = createPlanInfo(10) + hotkeyMocks.handlers.clear() }) describe('Rendering', () => { @@ -111,6 +133,13 @@ describe('CreateAppModal', () => { expect(screen.getByRole('button', { name: 'common.operation.cancel' }))!.toBeInTheDocument() }) + it('should render the submit shortcut with kbd primitives', async () => { + await setup() + + const createButton = screen.getByRole('button', { name: /common\.operation\.create/ }) + expect(createButton.querySelectorAll('kbd')).toHaveLength(2) + }) + it('should render edit-only fields when editing a chat app', async () => { await setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 }) @@ -214,13 +243,10 @@ describe('CreateAppModal', () => { vi.useRealTimers() }) - it.each([ - ['meta+enter', { metaKey: true }], - ['ctrl+enter', { ctrlKey: true }], - ])('should submit when %s is pressed while visible', async (_, modifier) => { + it('should submit when Mod+Enter is pressed while visible', async () => { const { onConfirm, onHide } = await setup() - fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, ...modifier }) + triggerHotkey('Mod+Enter') await act(async () => { vi.advanceTimersByTime(300) }) @@ -232,7 +258,7 @@ describe('CreateAppModal', () => { it('should not submit when modal is hidden', async () => { const { onConfirm, onHide } = await setup({ show: false }) - fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true }) + triggerHotkey('Mod+Enter') await act(async () => { vi.advanceTimersByTime(300) }) @@ -249,7 +275,7 @@ describe('CreateAppModal', () => { const { onConfirm, onHide } = await setup({ isEditModal: false }) - fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true }) + triggerHotkey('Mod+Enter') await act(async () => { vi.advanceTimersByTime(300) }) @@ -266,7 +292,7 @@ describe('CreateAppModal', () => { const { onConfirm, onHide } = await setup({ isEditModal: true }) - fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true }) + triggerHotkey('Mod+Enter') await act(async () => { vi.advanceTimersByTime(300) }) @@ -278,7 +304,7 @@ describe('CreateAppModal', () => { it('should not submit when name is empty', async () => { const { onConfirm, onHide } = await setup({ appName: ' ' }) - fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true }) + triggerHotkey('Mod+Enter') await act(async () => { vi.advanceTimersByTime(300) }) diff --git a/web/app/components/explore/create-app-modal/index.tsx b/web/app/components/explore/create-app-modal/index.tsx index b5a31292bc..3664ab865c 100644 --- a/web/app/components/explore/create-app-modal/index.tsx +++ b/web/app/components/explore/create-app-modal/index.tsx @@ -2,10 +2,12 @@ import type { AppIconType } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Switch } from '@langgenius/dify-ui/switch' import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' -import { useDebounceFn, useKeyPress } from 'ahooks' +import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' +import { useDebounceFn } from 'ahooks' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -15,7 +17,6 @@ import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { useProviderContext } from '@/context/provider-context' import { AppModeEnum } from '@/types/app' import AppIconPicker from '../../base/app-icon-picker' -import ShortcutsName from '../../workflow/shortcuts-name' export type CreateAppModalProps = { show: boolean @@ -103,9 +104,11 @@ const CreateAppModal = ({ const { run: handleSubmit } = useDebounceFn(submit, { wait: 300 }) - useKeyPress(['meta.enter', 'ctrl.enter'], () => { - if (show && !(!isEditModal && isAppsFull) && name.trim()) - handleSubmit() + useHotkey('Mod+Enter', () => { + handleSubmit() + }, { + enabled: show && !(!isEditModal && isAppsFull) && !!name.trim(), + ignoreInputs: false, }) return ( @@ -191,7 +194,11 @@ const CreateAppModal = ({ onClick={handleSubmit} > {!isEditModal ? t('operation.create', { ns: 'common' }) : t('operation.save', { ns: 'common' })} - + + {['Mod', 'Enter'].map(key => ( + {formatForDisplay(key)} + ))} + diff --git a/web/app/components/goto-anything/__tests__/index.spec.tsx b/web/app/components/goto-anything/__tests__/index.spec.tsx index 39249e0427..6ad36b47bc 100644 --- a/web/app/components/goto-anything/__tests__/index.spec.tsx +++ b/web/app/components/goto-anything/__tests__/index.spec.tsx @@ -23,22 +23,41 @@ type KeyPressEvent = { target?: EventTarget } -const keyPressHandlers: Record void> = {} +type HotkeyRegistration = { + handler: (event: KeyPressEvent) => void + options?: { enabled?: boolean } +} + +const hotkeyHandlers: Record = {} vi.mock('ahooks', () => ({ useDebounce: (value: T) => value, - useKeyPress: (keys: string | string[], handler: (event: KeyPressEvent) => void) => { - const keyList = Array.isArray(keys) ? keys : [keys] - keyList.forEach((key) => { - keyPressHandlers[key] = handler - }) - }, })) +vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useHotkey: ( + hotkey: string, + handler: (event: KeyPressEvent) => void, + options?: HotkeyRegistration['options'], + ) => { + hotkeyHandlers[hotkey] = { handler, options } + }, + } +}) + +const HOTKEY_ALIAS: Record = { + 'ctrl.k': 'Mod+K', + 'esc': 'Escape', +} + const triggerKeyPress = (combo: string) => { - const handler = keyPressHandlers[combo] - if (handler) { + const hotkey = HOTKEY_ALIAS[combo] ?? combo + const registration = hotkeyHandlers[hotkey] + if (registration && registration.options?.enabled !== false) { act(() => { - handler({ preventDefault: vi.fn(), target: document.body }) + registration.handler({ preventDefault: vi.fn(), target: document.body }) }) } } @@ -58,10 +77,6 @@ vi.mock('../context', () => ({ GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })) -vi.mock('@/app/components/workflow/utils', () => ({ - getKeyboardKeyNameBySystem: (key: string) => key, -})) - const createActionItem = (key: ActionItem['key'], shortcut: string): ActionItem => ({ key, shortcut, @@ -106,13 +121,6 @@ vi.mock('../actions/commands/registry', () => ({ }, })) -vi.mock('@/app/components/workflow/utils/common', () => ({ - getKeyboardKeyCodeBySystem: () => 'ctrl', - getKeyboardKeyNameBySystem: (key: string) => key, - isEventTargetInputArea: () => false, - isMac: () => false, -})) - vi.mock('@/app/components/workflow/utils/node-navigation', () => ({ selectWorkflowNode: vi.fn(), })) @@ -130,7 +138,7 @@ vi.mock('../../plugins/install-plugin/install-from-marketplace', () => ({ describe('GotoAnything', () => { beforeEach(() => { routerPush.mockClear() - Object.keys(keyPressHandlers).forEach(key => delete keyPressHandlers[key]) + Object.keys(hotkeyHandlers).forEach(key => delete hotkeyHandlers[key]) mockQueryResult = { data: [], isLoading: false, isError: false, error: null } matchActionMock.mockReset() searchAnythingMock.mockClear() diff --git a/web/app/components/goto-anything/components/__tests__/search-input.spec.tsx b/web/app/components/goto-anything/components/__tests__/search-input.spec.tsx index 85d84c26f2..e337ae2518 100644 --- a/web/app/components/goto-anything/components/__tests__/search-input.spec.tsx +++ b/web/app/components/goto-anything/components/__tests__/search-input.spec.tsx @@ -8,14 +8,6 @@ vi.mock('@remixicon/react', () => ({ ), })) -vi.mock('@/app/components/workflow/shortcuts-name', () => ({ - default: ({ keys, textColor }: { keys: string[], textColor: string }) => ( -
- {keys.join('+')} -
- ), -})) - vi.mock('@/app/components/base/input', async () => { const { forwardRef } = await import('react') @@ -74,13 +66,12 @@ describe('SearchInput', () => { expect(screen.getByTestId('search-input')).toBeInTheDocument() }) - it('should render shortcuts name', () => { - render() + it('should render shortcut keycaps', () => { + const { container } = render() - const shortcuts = screen.getByTestId('shortcuts-name') - expect(shortcuts).toBeInTheDocument() - expect(shortcuts).toHaveAttribute('data-keys', 'ctrl,K') - expect(shortcuts).toHaveAttribute('data-color', 'secondary') + const keycaps = container.querySelectorAll('kbd') + expect(keycaps).toHaveLength(2) + expect(keycaps[1]).toHaveTextContent('K') }) it('should use provided placeholder', () => { diff --git a/web/app/components/goto-anything/components/search-input.tsx b/web/app/components/goto-anything/components/search-input.tsx index b1cf1123e0..5adc943a46 100644 --- a/web/app/components/goto-anything/components/search-input.tsx +++ b/web/app/components/goto-anything/components/search-input.tsx @@ -1,10 +1,11 @@ 'use client' import type { FC, KeyboardEvent, RefObject } from 'react' +import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { RiSearchLine } from '@remixicon/react' +import { formatForDisplay } from '@tanstack/react-hotkeys' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import ShortcutsName from '@/app/components/workflow/shortcuts-name' type SearchInputProps = { inputRef: RefObject @@ -54,7 +55,11 @@ const SearchInput: FC = ({ )} - + + {['Mod', 'K'].map(key => ( + {formatForDisplay(key)} + ))} + ) } diff --git a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts index 45bbfb7447..27d283a38b 100644 --- a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts +++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts @@ -6,27 +6,34 @@ type KeyPressEvent = { target?: EventTarget } -const keyPressHandlers: Record void> = {} -let mockIsEventTargetInputArea = false +type HotkeyRegistration = { + handler: (event: KeyPressEvent) => void + options?: { enabled?: boolean, ignoreInputs?: boolean } +} -vi.mock('ahooks', () => ({ - useKeyPress: (keys: string | string[], handler: (event: KeyPressEvent) => void) => { - const keyList = Array.isArray(keys) ? keys : [keys] - keyList.forEach((key) => { - keyPressHandlers[key] = handler - }) +const hotkeyHandlers: Record = {} + +vi.mock('@tanstack/react-hotkeys', () => ({ + useHotkey: ( + hotkey: string, + handler: (event: KeyPressEvent) => void, + options?: HotkeyRegistration['options'], + ) => { + hotkeyHandlers[hotkey] = { handler, options } }, })) -vi.mock('@/app/components/workflow/utils/common', () => ({ - getKeyboardKeyCodeBySystem: () => 'ctrl', - isEventTargetInputArea: () => mockIsEventTargetInputArea, -})) +const triggerHotkey = (hotkey: string, event: KeyPressEvent) => { + const registration = hotkeyHandlers[hotkey] + if (registration?.options?.enabled === false) + return + + registration?.handler(event) +} describe('useGotoAnythingModal', () => { beforeEach(() => { - Object.keys(keyPressHandlers).forEach(key => delete keyPressHandlers[key]) - mockIsEventTargetInputArea = false + Object.keys(hotkeyHandlers).forEach(key => delete hotkeyHandlers[key]) vi.useFakeTimers() }) @@ -58,43 +65,36 @@ describe('useGotoAnythingModal', () => { }) describe('keyboard shortcuts', () => { - it('should toggle show state when Ctrl+K is triggered', () => { + it('should toggle show state when Mod+K is triggered', () => { const { result } = renderHook(() => useGotoAnythingModal()) expect(result.current.show).toBe(false) act(() => { - keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body }) + triggerHotkey('Mod+K', { preventDefault: vi.fn(), target: document.body }) }) expect(result.current.show).toBe(true) }) - it('should toggle back to closed when Ctrl+K is triggered twice', () => { + it('should toggle back to closed when Mod+K is triggered twice', () => { const { result } = renderHook(() => useGotoAnythingModal()) act(() => { - keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body }) + triggerHotkey('Mod+K', { preventDefault: vi.fn(), target: document.body }) }) expect(result.current.show).toBe(true) act(() => { - keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body }) + triggerHotkey('Mod+K', { preventDefault: vi.fn(), target: document.body }) }) expect(result.current.show).toBe(false) }) - it('should NOT toggle when focus is in input area and modal is closed', () => { - mockIsEventTargetInputArea = true - const { result } = renderHook(() => useGotoAnythingModal()) + it('should let the hotkey library ignore inputs when the modal is closed', () => { + renderHook(() => useGotoAnythingModal()) - expect(result.current.show).toBe(false) - - act(() => { - keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body }) - }) - - expect(result.current.show).toBe(false) + expect(hotkeyHandlers['Mod+K']?.options?.ignoreInputs).toBe(true) }) it('should close modal when escape is pressed and modal is open', () => { @@ -106,7 +106,7 @@ describe('useGotoAnythingModal', () => { expect(result.current.show).toBe(true) act(() => { - keyPressHandlers.esc?.({ preventDefault: vi.fn() }) + triggerHotkey('Escape', { preventDefault: vi.fn() }) }) expect(result.current.show).toBe(false) @@ -119,19 +119,19 @@ describe('useGotoAnythingModal', () => { const preventDefaultMock = vi.fn() act(() => { - keyPressHandlers.esc?.({ preventDefault: preventDefaultMock }) + triggerHotkey('Escape', { preventDefault: preventDefaultMock }) }) expect(result.current.show).toBe(false) expect(preventDefaultMock).not.toHaveBeenCalled() }) - it('should call preventDefault when Ctrl+K is triggered', () => { + it('should call preventDefault when Mod+K is triggered', () => { renderHook(() => useGotoAnythingModal()) const preventDefaultMock = vi.fn() act(() => { - keyPressHandlers['ctrl.k']?.({ preventDefault: preventDefaultMock, target: document.body }) + triggerHotkey('Mod+K', { preventDefault: preventDefaultMock, target: document.body }) }) expect(preventDefaultMock).toHaveBeenCalled() diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts b/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts index 3e616cdd95..6a06f781e3 100644 --- a/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts +++ b/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts @@ -1,9 +1,8 @@ 'use client' import type { RefObject } from 'react' -import { useKeyPress } from 'ahooks' +import { useHotkey } from '@tanstack/react-hotkeys' import { useCallback, useEffect, useRef, useState } from 'react' -import { getKeyboardKeyCodeBySystem, isEventTargetInputArea } from '@/app/components/workflow/utils/common' type UseGotoAnythingModalReturn = { show: boolean @@ -18,23 +17,20 @@ export const useGotoAnythingModal = (): UseGotoAnythingModalReturn => { // Handle keyboard shortcuts const handleToggleModal = useCallback((e: KeyboardEvent) => { - // Allow closing when modal is open, even if focus is in the search input - if (!show && isEventTargetInputArea(e.target as HTMLElement)) - return e.preventDefault() setShow(prev => !prev) - }, [show]) + }, []) - useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.k`, handleToggleModal, { - exactMatch: true, - useCapture: true, + useHotkey('Mod+K', handleToggleModal, { + ignoreInputs: !show, }) - useKeyPress(['esc'], (e) => { - if (show) { - e.preventDefault() - setShow(false) - } + useHotkey('Escape', (e) => { + e.preventDefault() + setShow(false) + }, { + enabled: show, + ignoreInputs: false, }) const handleClose = useCallback(() => { diff --git a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx index 949872d14b..3b171c2494 100644 --- a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx @@ -278,8 +278,6 @@ vi.mock('@/app/components/workflow/constants', () => ({ vi.mock('@/app/components/workflow/utils', () => ({ initialNodes: vi.fn(nodes => nodes), initialEdges: vi.fn(edges => edges), - getKeyboardKeyCodeBySystem: (key: string) => key, - getKeyboardKeyNameBySystem: (key: string) => key, })) vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({ diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx index cff2a5f4c2..1ed0aab1c1 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx @@ -172,11 +172,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ promise: toastMocks.promise, }), })) -vi.mock('@/app/components/workflow/utils', () => ({ - getKeyboardKeyCodeBySystem: (key: string) => key, - getKeyboardKeyNameBySystem: (key: string) => key, -})) - vi.mock('ahooks', () => ({ useBoolean: (initial: boolean) => { let value = initial @@ -189,7 +184,6 @@ vi.mock('ahooks', () => ({ }, ] }, - useKeyPress: vi.fn(), })) vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({ @@ -408,10 +402,11 @@ describe('Popup', () => { }) it('should render keyboard shortcuts', () => { - render() + const { container } = render() - expect(screen.getByText('ctrl'))!.toBeInTheDocument() - expect(screen.getByText('⇧'))!.toBeInTheDocument() + expect(container.querySelectorAll('kbd')).toHaveLength(3) + expect(screen.getByText('Ctrl'))!.toBeInTheDocument() + expect(screen.getByText('Shift'))!.toBeInTheDocument() expect(screen.getByText('P'))!.toBeInTheDocument() }) @@ -566,9 +561,10 @@ describe('RunMode', () => { }) it('should render keyboard shortcuts when not disabled', () => { - render() + const { container } = render() - expect(screen.getByText('alt'))!.toBeInTheDocument() + expect(container.querySelectorAll('kbd')).toHaveLength(2) + expect(screen.getByText('Alt'))!.toBeInTheDocument() expect(screen.getByText('R'))!.toBeInTheDocument() }) }) @@ -1077,9 +1073,10 @@ describe('Edge Cases', () => { mockStoreState.workflowRunningData = null mockStoreState.isPreparingDataSource = false - render() + const { container } = render() - expect(screen.getByText('alt'))!.toBeInTheDocument() + expect(container.querySelectorAll('kbd')).toHaveLength(2) + expect(screen.getByText('Alt'))!.toBeInTheDocument() expect(screen.getByText('R'))!.toBeInTheDocument() }) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx index 42e158b66a..7655952aed 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx @@ -19,10 +19,6 @@ vi.mock('@/app/components/workflow/hooks', () => ({ }), })) -vi.mock('@/app/components/workflow/shortcuts-name', () => ({ - default: ({ keys }: { keys: string[] }) => {keys.join('+')}, -})) - vi.mock('@/app/components/workflow/store', () => ({ useStore: (selector: (state: Record) => unknown) => { const state = { @@ -95,9 +91,9 @@ describe('RunMode', () => { }) it('should render keyboard shortcuts', () => { - render() + const { container } = render() - expect(screen.getByTestId('shortcuts')).toBeInTheDocument() + expect(container.querySelectorAll('kbd')).toHaveLength(2) }) it('should call start run when button clicked', () => { diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx index ffc7d684ed..72b7f3cb78 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx @@ -1,11 +1,33 @@ import type { IconInfo } from '@/models/datasets' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import Publisher from '../index' import Popup from '../popup' +const hotkeyHandlers = vi.hoisted(() => new Map void>()) + +vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useHotkey: (hotkey: string, handler: (event: KeyboardEvent) => void) => { + hotkeyHandlers.set(hotkey, handler) + }, + } +}) + +const triggerHotkey = (hotkey: string) => { + const handler = hotkeyHandlers.get(hotkey) + if (!handler) + return + + act(() => { + handler({ preventDefault: vi.fn() } as unknown as KeyboardEvent) + }) +} + vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children, onClick, disabled, variant, className }: Record) => ( @@ -170,11 +192,6 @@ vi.mock('@/service/use-workflow', () => ({ }), })) -vi.mock('@/app/components/workflow/utils', () => ({ - getKeyboardKeyCodeBySystem: (key: string) => key, - getKeyboardKeyNameBySystem: (key: string) => key === 'ctrl' ? '⌘' : key, -})) - vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({ default: ({ confirmDisabled, onConfirm, onCancel }: { confirmDisabled: boolean @@ -215,6 +232,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => { describe('publisher', () => { beforeEach(() => { vi.clearAllMocks() + hotkeyHandlers.clear() vi.spyOn(console, 'error').mockImplementation(() => {}) mockPublishedAt.mockReturnValue(null) mockDraftUpdatedAt.mockReturnValue(1700000000) @@ -872,7 +890,7 @@ describe('publisher', () => { mockPublishWorkflow.mockResolvedValue({ created_at: 1700100000 }) renderWithQueryClient() - fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true }) + triggerHotkey('Mod+Shift+P') await waitFor(() => { expect(mockPublishWorkflow).toHaveBeenCalled() @@ -893,7 +911,7 @@ describe('publisher', () => { vi.clearAllMocks() - fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true }) + triggerHotkey('Mod+Shift+P') expect(mockPublishWorkflow).not.toHaveBeenCalled() }) @@ -902,7 +920,7 @@ describe('publisher', () => { mockPublishedAt.mockReturnValue(null) renderWithQueryClient() - fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true }) + triggerHotkey('Mod+Shift+P') await waitFor(() => { expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument() @@ -917,14 +935,14 @@ describe('publisher', () => { })) renderWithQueryClient() - fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true }) + triggerHotkey('Mod+Shift+P') await waitFor(() => { const publishButton = screen.getByRole('button', { name: /workflow.common.publishUpdate/i }) expect(publishButton).toBeDisabled() }) - fireEvent.keyDown(window, { key: 'p', keyCode: 80, ctrlKey: true, shiftKey: true }) + triggerHotkey('Mod+Shift+P') expect(mockPublishWorkflow).toHaveBeenCalledTimes(1) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx index 6d8ee0ca04..453c56d944 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx @@ -62,7 +62,6 @@ let mockDraftUpdatedAt: string | undefined = '2024-06-01T00:00:00Z' let mockPipelineId: string | undefined = 'pipeline-123' let mockIsAllowPublishAsCustom = true const mockUseBoolean = vi.hoisted(() => vi.fn()) -const mockUseKeyPress = vi.hoisted(() => vi.fn()) vi.mock('@/next/navigation', () => ({ useParams: () => ({ datasetId: 'ds-123' }), useRouter: () => ({ push: mockPush }), @@ -76,9 +75,16 @@ vi.mock('@/next/link', () => ({ vi.mock('ahooks', () => ({ useBoolean: (initial: boolean) => mockUseBoolean(initial), - useKeyPress: (...args: unknown[]) => mockUseKeyPress(...args), })) +vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useHotkey: vi.fn(), + } +}) + vi.mock('@/app/components/workflow/store', () => ({ useStore: (selector: (state: Record) => unknown) => { const state = { @@ -130,14 +136,6 @@ vi.mock('@/app/components/workflow/hooks', () => ({ }), })) -vi.mock('@/app/components/workflow/shortcuts-name', () => ({ - default: ({ keys }: { keys: string[] }) => {keys.join('+')}, -})) - -vi.mock('@/app/components/workflow/utils', () => ({ - getKeyboardKeyCodeBySystem: () => 'ctrl', -})) - vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: () => mockMutateDatasetRes, })) @@ -220,7 +218,6 @@ describe('Popup', () => { setFalse: vi.fn(), setTrue: vi.fn(), }]) - mockUseKeyPress.mockImplementation(() => {}) }) afterEach(() => { @@ -244,10 +241,10 @@ describe('Popup', () => { }) it('should render publish button with shortcuts', () => { - render() + const { container } = render() expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument() - expect(screen.getByTestId('shortcuts')).toBeInTheDocument() + expect(container.querySelectorAll('kbd')).toHaveLength(3) }) it('should render "Go to Add Documents" button', () => { diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx index 7acce03c08..12db397a1c 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx @@ -10,9 +10,11 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { toast } from '@langgenius/dify-ui/toast' import { RiArrowRightUpLine, RiHammerLine, RiPlayCircleLine, RiTerminalBoxLine } from '@remixicon/react' -import { useBoolean, useKeyPress } from 'ahooks' +import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' +import { useBoolean } from 'ahooks' import { memo, useCallback, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' @@ -20,9 +22,7 @@ import Divider from '@/app/components/base/divider' import { SparklesSoft } from '@/app/components/base/icons/src/public/common' import PremiumBadge from '@/app/components/base/premium-badge' import { useChecklistBeforePublish } from '@/app/components/workflow/hooks' -import ShortcutsName from '@/app/components/workflow/shortcuts-name' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' -import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useModalContextSelector } from '@/context/modal-context' import { useProviderContextSelector } from '@/context/provider-context' @@ -35,7 +35,7 @@ import { useInvalid } from '@/service/use-base' import { publishedPipelineInfoQueryKeyPrefix } from '@/service/use-pipeline' import { usePublishWorkflow } from '@/service/use-workflow' -const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] +const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P'] type PopupProps = { onRequestClose?: () => void confirmVisible?: boolean @@ -133,12 +133,12 @@ const Popup = ({ handleHideConfirm() } }, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, handleHideConfirm]) - useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => { + useHotkey('Mod+Shift+P', (e) => { e.preventDefault() if (published) return handlePublish() - }, { exactMatch: true, useCapture: true }) + }) const goToAddDocuments = useCallback(() => { push(`/datasets/${datasetId}/documents/create-from-pipeline`) }, [datasetId, push]) @@ -181,7 +181,11 @@ const Popup = ({ : (
{t('common.publishUpdate', { ns: 'workflow' })} - + + {PUBLISH_SHORTCUT.map(key => ( + {formatForDisplay(key)} + ))} +
)} diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx index 5326e3b0f5..2002141265 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx @@ -1,11 +1,12 @@ import { cn } from '@langgenius/dify-ui/cn' +import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { RiCloseLine, RiDatabase2Line, RiLoader2Line, RiPlayLargeLine } from '@remixicon/react' +import { formatForDisplay } from '@tanstack/react-hotkeys' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks' -import ShortcutsName from '@/app/components/workflow/shortcuts-name' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' @@ -78,7 +79,11 @@ const RunMode = ({ )} { !isDisabled && ( - + + {['Alt', 'R'].map(key => ( + {formatForDisplay(key)} + ))} + ) } diff --git a/web/app/components/workflow/__tests__/reactflow-mock-state.ts b/web/app/components/workflow/__tests__/reactflow-mock-state.ts index a90bdbaed1..7e31a8c3c1 100644 --- a/web/app/components/workflow/__tests__/reactflow-mock-state.ts +++ b/web/app/components/workflow/__tests__/reactflow-mock-state.ts @@ -104,7 +104,6 @@ export function createReactFlowModuleMock() { useNodes: vi.fn(() => rfState.nodes), useEdges: vi.fn(() => rfState.edges), useViewport: vi.fn(() => ({ x: 0, y: 0, zoom: 1 })), - useKeyPress: vi.fn(() => false), useOnSelectionChange: vi.fn(), useOnViewportChange: vi.fn(), useUpdateNodeInternals: vi.fn(() => vi.fn()), diff --git a/web/app/components/workflow/__tests__/shortcuts-name.spec.tsx b/web/app/components/workflow/__tests__/shortcuts-name.spec.tsx deleted file mode 100644 index 87efddb005..0000000000 --- a/web/app/components/workflow/__tests__/shortcuts-name.spec.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { render, screen } from '@testing-library/react' -import ShortcutsName from '../shortcuts-name' - -describe('ShortcutsName', () => { - const originalNavigator = globalThis.navigator - - afterEach(() => { - Object.defineProperty(globalThis, 'navigator', { - value: originalNavigator, - writable: true, - configurable: true, - }) - }) - - it('renders mac-friendly key labels and style variants', () => { - Object.defineProperty(globalThis, 'navigator', { - value: { userAgent: 'Macintosh' }, - writable: true, - configurable: true, - }) - - const { container } = render( - , - ) - - expect(screen.getByText('⌘')).toBeInTheDocument() - expect(screen.getByText('⇧')).toBeInTheDocument() - expect(screen.getByText('s')).toBeInTheDocument() - expect(container.querySelector('.system-kbd')).toHaveClass( - 'bg-components-kbd-bg-white', - 'text-text-tertiary', - ) - }) - - it('keeps raw key names on non-mac systems', () => { - Object.defineProperty(globalThis, 'navigator', { - value: { userAgent: 'Windows NT' }, - writable: true, - configurable: true, - }) - - render() - - expect(screen.getByText('ctrl')).toBeInTheDocument() - expect(screen.getByText('alt')).toBeInTheDocument() - }) -}) diff --git a/web/app/components/workflow/header/__tests__/run-mode.spec.tsx b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx index 245b516cd3..0a0fe9ec6d 100644 --- a/web/app/components/workflow/header/__tests__/run-mode.spec.tsx +++ b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx @@ -71,10 +71,6 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -vi.mock('@/app/components/workflow/shortcuts-name', () => ({ - default: () => Shortcut, -})) - vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({ StopCircle: () => , })) diff --git a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx index 09452055db..66f7ff86af 100644 --- a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx +++ b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx @@ -37,10 +37,6 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { } }) -vi.mock('../shortcuts-name', () => ({ - default: ({ keys }: { keys: string[] }) => {keys.join('+')}, -})) - const createOption = (overrides: Partial = {}): TriggerOption => ({ id: 'user-input', type: TriggerType.UserInput, diff --git a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx index ebf1af5a3d..4c0122a7d0 100644 --- a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx +++ b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx @@ -67,10 +67,6 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { } }) -vi.mock('../shortcuts-name', () => ({ - default: ({ keys }: { keys: string[] }) => {keys.join('+')}, -})) - const createOption = (overrides: Partial = {}): TriggerOption => ({ id: 'user-input', type: TriggerType.UserInput, diff --git a/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/placeholder.tsx b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/placeholder.tsx index 54b21e81dd..a0cd3d0e3c 100644 --- a/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/placeholder.tsx +++ b/web/app/components/workflow/nodes/_base/components/mixed-variable-text-input/placeholder.tsx @@ -1,3 +1,4 @@ +import { Kbd } from '@langgenius/dify-ui/kbd' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { $insertNodes, FOCUS_COMMAND } from 'lexical' import { useCallback } from 'react' @@ -27,7 +28,7 @@ const Placeholder = () => { >
{t('nodes.tool.insertPlaceholder1', { ns: 'workflow' })} -
/
+ /
{ diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx index 218da57fbb..f546ca00df 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx @@ -4,7 +4,7 @@ import FormContent from '../form-content' const mockUseTranslation = vi.hoisted(() => vi.fn()) const mockUseWorkflowVariableType = vi.hoisted(() => vi.fn()) -const mockIsMac = vi.hoisted(() => vi.fn()) +const mockFormatForDisplay = vi.hoisted(() => vi.fn((hotkey: string) => hotkey)) const mockPromptEditor = vi.hoisted(() => vi.fn()) const mockAddInputField = vi.hoisted(() => vi.fn()) const mockOnInsert = vi.hoisted(() => vi.fn()) @@ -30,9 +30,13 @@ vi.mock('@/app/components/workflow/hooks', () => ({ useWorkflowVariableType: () => mockUseWorkflowVariableType(), })) -vi.mock('@/app/components/workflow/utils', () => ({ - isMac: () => mockIsMac(), -})) +vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + formatForDisplay: (hotkey: string) => mockFormatForDisplay(hotkey), + } +}) vi.mock('@/app/components/base/prompt-editor', () => ({ __esModule: true, @@ -114,7 +118,7 @@ describe('FormContent', () => { t: (key: string) => key, }) mockUseWorkflowVariableType.mockReturnValue(() => 'string') - mockIsMac.mockReturnValue(false) + mockFormatForDisplay.mockImplementation((hotkey: string) => hotkey) }) it('should build workflow node maps, show the hotkey tip on focus, and defer form-input sync until value changes', async () => { @@ -232,7 +236,7 @@ describe('FormContent', () => { }) it('should render the mac hotkey hint when focused on macOS', () => { - mockIsMac.mockReturnValue(true) + mockFormatForDisplay.mockReturnValue('⌘') render( = ({ children, className }) => { - return {children} -} - -const CtrlKey: FC = () => { - return {isMac() ? '⌘' : 'Ctrl'} -} - const FormContent: FC = ({ nodeId, value, @@ -162,8 +155,8 @@ const FormContent: FC = ({ ns="workflow" components={ { - Key: /, - CtrlKey: , + Key: /, + CtrlKey: {formatForDisplay('Mod')}, } } /> diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx index 83e273329f..de09ace1ec 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx @@ -1,4 +1,5 @@ import { cn } from '@langgenius/dify-ui/cn' +import { Kbd } from '@langgenius/dify-ui/kbd' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { $insertNodes, FOCUS_COMMAND } from 'lexical' import { useCallback } from 'react' @@ -38,7 +39,7 @@ const Placeholder = ({ disableVariableInsertion = false, hideBadge = false }: Pl {t('nodes.tool.insertPlaceholder1', { ns: 'workflow' })} {(!disableVariableInsertion) && ( <> -
/
+ /
{ diff --git a/web/app/components/workflow/shortcuts-name.tsx b/web/app/components/workflow/shortcuts-name.tsx deleted file mode 100644 index 70084977e8..0000000000 --- a/web/app/components/workflow/shortcuts-name.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { cn } from '@langgenius/dify-ui/cn' -import { memo } from 'react' -import { getKeyboardKeyNameBySystem } from './utils' - -type ShortcutsNameProps = { - keys: string[] - className?: string - textColor?: 'default' | 'secondary' - bgColor?: 'gray' | 'white' -} -const ShortcutsName = ({ - keys, - className, - textColor = 'default', - bgColor = 'gray', -}: ShortcutsNameProps) => { - return ( -
- { - keys.map(key => ( -
- {getKeyboardKeyNameBySystem(key)} -
- )) - } -
- ) -} - -export default memo(ShortcutsName) diff --git a/web/app/components/workflow/shortcuts/__tests__/shortcut-kbd.spec.tsx b/web/app/components/workflow/shortcuts/__tests__/shortcut-kbd.spec.tsx index d1be1bf008..e8dc0be3c4 100644 --- a/web/app/components/workflow/shortcuts/__tests__/shortcut-kbd.spec.tsx +++ b/web/app/components/workflow/shortcuts/__tests__/shortcut-kbd.spec.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react' import { ShortcutKbd } from '../shortcut-kbd' describe('ShortcutKbd', () => { - it('renders shortcut chords as separate keycaps with the legacy visual classes', () => { + it('renders shortcut chords as separate design-system keycaps', () => { const { container } = render( { ) const wrapper = container.firstElementChild - expect(wrapper).toHaveClass('flex', 'items-center', 'gap-0.5', 'ml-2') + expect(wrapper).toHaveClass('inline-flex', 'items-center', 'gap-0.5', 'ml-2') const keys = container.querySelectorAll('kbd') expect(keys).toHaveLength(2) diff --git a/web/app/components/workflow/shortcuts/shortcut-kbd.tsx b/web/app/components/workflow/shortcuts/shortcut-kbd.tsx index 8e36e08f3c..dc1082436d 100644 --- a/web/app/components/workflow/shortcuts/shortcut-kbd.tsx +++ b/web/app/components/workflow/shortcuts/shortcut-kbd.tsx @@ -1,6 +1,8 @@ +import type { KbdColor } from '@langgenius/dify-ui/kbd' import type { FormatDisplayOptions, RegisterableHotkey } from '@tanstack/react-hotkeys' import type { WorkflowShortcutId } from './definitions' import { cn } from '@langgenius/dify-ui/cn' +import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { formatForDisplay } from '@tanstack/react-hotkeys' import { getWorkflowShortcutDisplayHotkey } from './definitions' @@ -9,7 +11,7 @@ type ShortcutKbdProps = { hotkey?: RegisterableHotkey | (string & {}) className?: string textColor?: 'default' | 'secondary' - bgColor?: 'gray' | 'white' + bgColor?: KbdColor platform?: FormatDisplayOptions['platform'] } @@ -44,27 +46,22 @@ export const ShortcutKbd = ({ const displayKeys = getDisplayKeys(displayHotkey, platform) return ( - { displayKeys.map((key, index) => ( - {key} - + )) } - + ) } diff --git a/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts b/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts index 98b23d08e0..7bee7b3119 100644 --- a/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts +++ b/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts @@ -15,7 +15,6 @@ import { useWorkflowCanvasMaximize } from '../hooks/use-workflow-canvas-maximize import { useWorkflowOrganize } from '../hooks/use-workflow-organize' import { useWorkflowMoveMode } from '../hooks/use-workflow-panel-interactions' import { useStore } from '../store/workflow' -import { isEventTargetInputArea } from '../utils' import { subscribeWorkflowCommand, WorkflowCommand, @@ -27,6 +26,16 @@ const workflowHotkeyOptions = { conflictBehavior: 'warn', } satisfies UseHotkeyOptions +const isInputLikeElement = (element: Element | null) => { + if (!element) + return false + + return element instanceof HTMLInputElement + || element instanceof HTMLTextAreaElement + || element instanceof HTMLSelectElement + || (element instanceof HTMLElement && element.isContentEditable) +} + const toHotkeyDefinitions = ( shortcut: WorkflowShortcutDefinition, callback: HotkeyCallback, @@ -227,7 +236,7 @@ export const useWorkflowHotkeys = (): void => { if (shiftDimmedRef.current) return - if (isEventTargetInputArea(document.activeElement as HTMLElement)) + if (isInputLikeElement(document.activeElement)) return shiftDimmedRef.current = true diff --git a/web/app/components/workflow/utils/__tests__/common.spec.ts b/web/app/components/workflow/utils/__tests__/common.spec.ts index 8c84a21d09..9dc096ca78 100644 --- a/web/app/components/workflow/utils/__tests__/common.spec.ts +++ b/web/app/components/workflow/utils/__tests__/common.spec.ts @@ -1,163 +1,7 @@ import { formatWorkflowRunIdentifier, - getKeyboardKeyCodeBySystem, - getKeyboardKeyNameBySystem, - isEventTargetInputArea, - isMac, } from '../common' -describe('isMac', () => { - const originalNavigator = globalThis.navigator - - afterEach(() => { - Object.defineProperty(globalThis, 'navigator', { - value: originalNavigator, - writable: true, - configurable: true, - }) - }) - - it('should return true when userAgent contains MAC', () => { - Object.defineProperty(globalThis, 'navigator', { - value: { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }, - writable: true, - configurable: true, - }) - expect(isMac()).toBe(true) - }) - - it('should return false when userAgent does not contain MAC', () => { - Object.defineProperty(globalThis, 'navigator', { - value: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }, - writable: true, - configurable: true, - }) - expect(isMac()).toBe(false) - }) -}) - -describe('getKeyboardKeyNameBySystem', () => { - const originalNavigator = globalThis.navigator - - afterEach(() => { - Object.defineProperty(globalThis, 'navigator', { - value: originalNavigator, - writable: true, - configurable: true, - }) - }) - - function setMac() { - Object.defineProperty(globalThis, 'navigator', { - value: { userAgent: 'Macintosh' }, - writable: true, - configurable: true, - }) - } - - function setWindows() { - Object.defineProperty(globalThis, 'navigator', { - value: { userAgent: 'Windows NT' }, - writable: true, - configurable: true, - }) - } - - it('should map ctrl to ⌘ on Mac', () => { - setMac() - expect(getKeyboardKeyNameBySystem('ctrl')).toBe('⌘') - }) - - it('should map alt to ⌥ on Mac', () => { - setMac() - expect(getKeyboardKeyNameBySystem('alt')).toBe('⌥') - }) - - it('should map shift to ⇧ on Mac', () => { - setMac() - expect(getKeyboardKeyNameBySystem('shift')).toBe('⇧') - }) - - it('should return the original key for unmapped keys on Mac', () => { - setMac() - expect(getKeyboardKeyNameBySystem('enter')).toBe('enter') - }) - - it('should return the original key on non-Mac', () => { - setWindows() - expect(getKeyboardKeyNameBySystem('ctrl')).toBe('ctrl') - expect(getKeyboardKeyNameBySystem('alt')).toBe('alt') - }) -}) - -describe('getKeyboardKeyCodeBySystem', () => { - const originalNavigator = globalThis.navigator - - afterEach(() => { - Object.defineProperty(globalThis, 'navigator', { - value: originalNavigator, - writable: true, - configurable: true, - }) - }) - - it('should map ctrl to meta on Mac', () => { - Object.defineProperty(globalThis, 'navigator', { - value: { userAgent: 'Macintosh' }, - writable: true, - configurable: true, - }) - expect(getKeyboardKeyCodeBySystem('ctrl')).toBe('meta') - }) - - it('should return the original key on non-Mac', () => { - Object.defineProperty(globalThis, 'navigator', { - value: { userAgent: 'Windows NT' }, - writable: true, - configurable: true, - }) - expect(getKeyboardKeyCodeBySystem('ctrl')).toBe('ctrl') - }) - - it('should return the original key for unmapped keys on Mac', () => { - Object.defineProperty(globalThis, 'navigator', { - value: { userAgent: 'Macintosh' }, - writable: true, - configurable: true, - }) - expect(getKeyboardKeyCodeBySystem('alt')).toBe('alt') - }) -}) - -describe('isEventTargetInputArea', () => { - it('should return true for INPUT elements', () => { - const el = document.createElement('input') - expect(isEventTargetInputArea(el)).toBe(true) - }) - - it('should return true for TEXTAREA elements', () => { - const el = document.createElement('textarea') - expect(isEventTargetInputArea(el)).toBe(true) - }) - - it('should return true for contentEditable elements', () => { - const el = document.createElement('div') - el.contentEditable = 'true' - expect(isEventTargetInputArea(el)).toBe(true) - }) - - it('should return undefined for non-input elements', () => { - const el = document.createElement('div') - expect(isEventTargetInputArea(el)).toBeUndefined() - }) - - it('should return undefined for contentEditable=false elements', () => { - const el = document.createElement('div') - el.contentEditable = 'false' - expect(isEventTargetInputArea(el)).toBeUndefined() - }) -}) - describe('formatWorkflowRunIdentifier', () => { it('should return fallback text when finishedAt is undefined', () => { expect(formatWorkflowRunIdentifier()).toBe(' (Running)') diff --git a/web/app/components/workflow/utils/common.ts b/web/app/components/workflow/utils/common.ts index 81bb22359c..0f5dacf3f2 100644 --- a/web/app/components/workflow/utils/common.ts +++ b/web/app/components/workflow/utils/common.ts @@ -1,39 +1,3 @@ -export const isMac = () => { - return navigator.userAgent.toUpperCase().includes('MAC') -} - -const specialKeysNameMap: Record = { - ctrl: '⌘', - alt: '⌥', - shift: '⇧', -} - -export const getKeyboardKeyNameBySystem = (key: string) => { - if (isMac()) - return specialKeysNameMap[key] || key - - return key -} - -const specialKeysCodeMap: Record = { - ctrl: 'meta', -} - -export const getKeyboardKeyCodeBySystem = (key: string) => { - if (isMac()) - return specialKeysCodeMap[key] || key - - return key -} - -export const isEventTargetInputArea = (target: HTMLElement) => { - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') - return true - - if (target.contentEditable === 'true') - return true -} - /** * Format workflow run identifier using finished_at timestamp * @param finishedAt - Unix timestamp in seconds diff --git a/web/package.json b/web/package.json index 16c44235fd..b8fbbd2fdb 100644 --- a/web/package.json +++ b/web/package.json @@ -125,7 +125,6 @@ "react": "catalog:", "react-dom": "catalog:", "react-easy-crop": "catalog:", - "react-hotkeys-hook": "catalog:", "react-i18next": "catalog:", "react-multi-email": "catalog:", "react-papaparse": "catalog:",