feat(ui): add kbd primitive (#36729)

This commit is contained in:
yyh
2026-05-27 19:58:13 +08:00
committed by GitHub
parent b2710b875b
commit cee90a4e82
64 changed files with 811 additions and 703 deletions

View File

@@ -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. |

View File

@@ -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:",

View File

@@ -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 = () => {
<span className="block truncate system-xs-regular text-text-tertiary">{item.description}</span>
</span>
</span>
<kbd className="rounded-md border border-divider-subtle bg-components-badge-bg-dimm px-1.5 py-0.5 text-text-quaternary system-2xs-medium">
<Kbd className="text-text-quaternary">
Enter
</kbd>
</Kbd>
</AutocompleteItem>
)}
</AutocompleteCollection>

View File

@@ -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(<Kbd></Kbd>)
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(<Kbd color="white"></Kbd>)
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(<Kbd disabled></Kbd>)
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(<Kbd className="custom-key h-5">K</Kbd>)
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(
<KbdGroup aria-label="Command Shift K">
<Kbd></Kbd>
<Kbd></Kbd>
<Kbd>K</Kbd>
</KbdGroup>,
)
const group = screen.getByLabelText('Command Shift K').element()
expect(group.tagName).toBe('SPAN')
expect(group.querySelectorAll('kbd')).toHaveLength(3)
})
})

View File

@@ -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 `<kbd>` element for a single key or key-like token. '
+ '`KbdGroup` only groups multiple keycaps; it does not replace the individual `<kbd>` semantics.',
},
},
},
tags: ['autodocs'],
argTypes: {
color: {
control: 'select',
options: ['gray', 'white'],
},
disabled: { control: 'boolean' },
},
args: {
children: 'K',
color: 'gray',
},
} satisfies Meta<typeof Kbd>
export default meta
type Story = StoryObj<typeof meta>
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']
}) => (
<KbdGroup>
{displayKeys(hotkey, platform).map((key, index) => (
<Kbd key={`${key}-${index}`} color={color}>
{key}
</Kbd>
))}
</KbdGroup>
)
export const Default: Story = {
render: () => <HotkeyKbdGroup hotkey="Mod+K" />,
}
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 `<kbd>` is not an interactive widget.',
},
},
},
render: () => (
<div className="grid grid-cols-[auto_auto_auto] items-center gap-x-4 gap-y-3 rounded-xl bg-components-panel-bg p-5">
<span className="system-xs-medium text-text-tertiary">Gray</span>
<KbdGroup>
<Kbd></Kbd>
<Kbd></Kbd>
</KbdGroup>
<KbdGroup>
<Kbd disabled></Kbd>
<Kbd disabled></Kbd>
</KbdGroup>
<span className="system-xs-medium text-text-tertiary">White</span>
<div className="rounded-lg bg-gray-900 p-2">
<KbdGroup>
<Kbd color="white"></Kbd>
<Kbd color="white"></Kbd>
</KbdGroup>
</div>
<div className="rounded-lg bg-gray-900 p-2">
<KbdGroup>
<Kbd color="white" disabled></Kbd>
<Kbd color="white" disabled></Kbd>
</KbdGroup>
</div>
</div>
),
}
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: () => (
<div className="grid grid-cols-[auto_auto_auto] items-center gap-x-5 gap-y-3 rounded-xl bg-components-panel-bg p-5">
<span className="system-xs-medium text-text-tertiary">Action</span>
<span className="system-xs-medium text-text-tertiary">macOS</span>
<span className="system-xs-medium text-text-tertiary">Windows</span>
<span className="system-sm-regular text-text-secondary">Search</span>
<HotkeyKbdGroup hotkey="Mod+K" platform="mac" />
<HotkeyKbdGroup hotkey="Mod+K" platform="windows" />
<span className="system-sm-regular text-text-secondary">Save</span>
<HotkeyKbdGroup hotkey="Mod+S" platform="mac" />
<HotkeyKbdGroup hotkey="Mod+S" platform="windows" />
<span className="system-sm-regular text-text-secondary">Redo</span>
<HotkeyKbdGroup hotkey="Mod+Shift+Z" platform="mac" />
<HotkeyKbdGroup hotkey="Mod+Shift+Z" platform="windows" />
</div>
),
}
export const InTooltip: Story = {
decorators: [
Story => (
<TooltipProvider delay={0}>
<Story />
</TooltipProvider>
),
],
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: () => (
<Tooltip open>
<TooltipTrigger
render={(
<button
type="button"
aria-label="Collapse sidebar"
className="inline-flex size-8 items-center justify-center rounded-lg border border-divider-subtle bg-components-button-secondary-bg text-text-secondary shadow-xs"
>
<span aria-hidden className="i-ri-sidebar-fold-line size-4" />
</button>
)}
/>
<TooltipContent className="flex items-center gap-1">
<span>Collapse sidebar</span>
<HotkeyKbdGroup hotkey="Mod+B" />
</TooltipContent>
</Tooltip>
),
}
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: () => (
<ContextMenu>
<ContextMenuTrigger
render={(
<button
type="button"
className="flex h-28 w-60 items-center justify-center rounded-xl border border-divider-subtle bg-background-default-subtle px-6 text-center system-sm-regular text-text-tertiary"
/>
)}
>
Context menu trigger
</ContextMenuTrigger>
<ContextMenuContent popupClassName="w-60">
{MENU_ITEMS.map(({ label, icon, hotkey }) => (
<ContextMenuItem key={label} className="justify-between gap-4">
<span aria-hidden className={`${icon} size-4 shrink-0 text-text-tertiary`} />
<span className="min-w-0 flex-1 truncate">{label}</span>
<HotkeyKbdGroup hotkey={hotkey} />
</ContextMenuItem>
))}
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" className="justify-between gap-4">
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">Delete</span>
<HotkeyKbdGroup hotkey="Delete" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
),
}

View File

@@ -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<VariantProps<typeof kbdVariants>['color']>
export type KbdProps
= Omit<ComponentProps<'kbd'>, 'color'>
& VariantProps<typeof kbdVariants>
export function Kbd({
className,
color,
disabled,
...props
}: KbdProps) {
return (
<kbd
data-disabled={disabled ? '' : undefined}
className={cn(kbdVariants({ color, disabled, className }))}
{...props}
/>
)
}
export type KbdGroupProps = ComponentProps<'span'>
export function KbdGroup({
className,
...props
}: KbdGroupProps) {
return (
<span
className={cn('inline-flex items-center gap-0.5 align-middle', className)}
{...props}
/>
)
}

View File

@@ -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 = {
<PopoverDescription className="text-xs text-text-secondary">
Press
{' '}
<kbd className="rounded bg-background-default-subtle px-1 py-0.5 font-mono text-[11px]"></kbd>
{' '}
+
{' '}
<kbd className="rounded bg-background-default-subtle px-1 py-0.5 font-mono text-[11px]">K</kbd>
<KbdGroup>
<Kbd></Kbd>
<Kbd>K</Kbd>
</KbdGroup>
{' '}
to open the command palette anywhere in the app.
</PopoverDescription>

21
pnpm-lock.yaml generated
View File

@@ -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'

View File

@@ -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

View File

@@ -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<typeof import('@tanstack/react-hotkeys')>()
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')

View File

@@ -43,10 +43,6 @@ vi.mock('react-i18next', () => ({
}),
}))
vi.mock('ahooks', () => ({
useKeyPress: vi.fn(),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => 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()

View File

@@ -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 }) => <hr data-testid="divider" className={className} />,
}))
vi.mock('@/app/components/workflow/utils', () => ({
getKeyboardKeyCodeBySystem: () => 'ctrl',
}))
vi.mock('../app-info', () => ({
default: ({ expand }: { expand: boolean }) => (
<div data-testid="app-info" data-expand={expand} />
@@ -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(<AppDetailNav navigation={navigation} />)
const cb = mockKeyPressCallback

View File

@@ -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[] }) => (
<span data-testid="shortcuts">{keys.join('+')}</span>
),
}))
describe('ToggleButton', () => {
it('should render collapse arrow when expanded', () => {
render(<ToggleButton expand handleToggle={vi.fn()} />)

View File

@@ -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 (

View File

@@ -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 (
<div className="flex items-center gap-x-1">
<span className="px-0.5 system-xs-medium text-text-secondary">{expand ? t('sidebar.collapseSidebar', { ns: 'layout' }) : t('sidebar.expandSidebar', { ns: 'layout' })}</span>
<ShortcutsName keys={TOGGLE_SHORTCUT} textColor="secondary" />
<KbdGroup>
{TOGGLE_SHORTCUT.map(key => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</div>
)
}

View File

@@ -28,8 +28,8 @@ const sectionProps = vi.hoisted(() => ({
access: null as null | Record<string, any>,
actions: null as null | Record<string, any>,
}))
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<string, any> | 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<string, any> | 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()

View File

@@ -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={{}}
/>,

View File

@@ -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> | 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

View File

@@ -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 = ({
: (
<div className="flex gap-1">
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
<ShortcutsName keys={publishShortcut} bgColor="white" />
<KbdGroup>
{publishShortcut.map(key => (
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</div>
)}
</Button>

View File

@@ -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<Props> = ({
/>
<div className="absolute bottom-0 left-4 flex h-8 items-center space-x-0.5 system-xs-regular text-components-input-text-placeholder">
<span>{t('generate.press', { ns: 'appDebug' })}</span>
<span className="flex h-4 w-3.5 items-center justify-center rounded-sm bg-components-kbd-bg-gray system-kbd text-text-placeholder">/</span>
<Kbd className="text-text-placeholder">/</Kbd>
<span>{t('generate.to', { ns: 'appDebug' })}</span>
<button
type="button"

View File

@@ -13,8 +13,8 @@ import { getRedirection } from '@/utils/app-redirection'
import { trackCreateApp } from '@/utils/create-app-tracking'
import CreateAppModal from '../index'
const ahooksMocks = vi.hoisted(() => ({
keyPressHandlers: [] as Array<() => void>,
const hotkeyMocks = vi.hoisted(() => ({
handlers: new Map<string, () => void>(),
}))
vi.mock('ahooks', () => ({
@@ -24,11 +24,18 @@ vi.mock('ahooks', () => ({
const flush = vi.fn()
return { run, cancel, flush }
},
useKeyPress: (_keys: unknown, handler: () => void) => {
ahooksMocks.keyPressHandlers.push(handler)
},
useHover: () => false,
}))
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
return {
...actual,
useHotkey: (hotkey: string, handler: () => void) => {
hotkeyMocks.handlers.set(hotkey, handler)
},
}
})
vi.mock('@/next/navigation', () => ({
useRouter: vi.fn(),
useParams: () => ({}),
@@ -113,7 +120,7 @@ describe('CreateAppModal', () => {
beforeEach(() => {
vi.clearAllMocks()
ahooksMocks.keyPressHandlers.length = 0
hotkeyMocks.handlers.clear()
mockUseRouter.mockReturnValue({ push: mockPush } as unknown as ReturnType<typeof useRouter>)
mockUseProviderContext.mockReturnValue({
plan: {
@@ -205,6 +212,13 @@ describe('CreateAppModal', () => {
expect(onCreateFromTemplate).toHaveBeenCalled()
})
it('renders the create shortcut with kbd primitives', () => {
renderModal()
const createButton = screen.getByRole('button', { name: /app\.newApp\.Create/ })
expect(createButton.querySelectorAll('kbd')).toHaveLength(2)
})
it('creates a beginner chat app with the keyboard shortcut and selected icon style', async () => {
const user = userEvent.setup()
mockCreateApp.mockResolvedValue({ id: 'chat-app', mode: AppModeEnum.CHAT } as App)
@@ -228,7 +242,7 @@ describe('CreateAppModal', () => {
target: { value: 'Created from shortcut' },
})
ahooksMocks.keyPressHandlers.at(-1)?.()
hotkeyMocks.handlers.get('Mod+Enter')?.()
await waitFor(() => {
expect(mockCreateApp).toHaveBeenCalledWith({
@@ -245,7 +259,7 @@ describe('CreateAppModal', () => {
it('shows validation feedback when the keyboard shortcut runs without a name', () => {
renderModal()
ahooksMocks.keyPressHandlers.at(-1)?.()
hotkeyMocks.handlers.get('Mod+Enter')?.()
expect(mockToastError).toHaveBeenCalledWith('app.newApp.nameNotEmpty')
expect(mockCreateApp).not.toHaveBeenCalled()
@@ -276,7 +290,7 @@ describe('CreateAppModal', () => {
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
ahooksMocks.keyPressHandlers.at(-1)?.()
hotkeyMocks.handlers.get('Mod+Enter')?.()
expect(mockCreateApp).not.toHaveBeenCalled()
})

View File

@@ -4,10 +4,12 @@ import type { AppIconSelection } from '../../base/app-icon-picker'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useDebounceFn } from 'ahooks'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
@@ -26,7 +28,6 @@ import { getRedirection } from '@/utils/app-redirection'
import { trackCreateApp } from '@/utils/create-app-tracking'
import { basePath } from '@/utils/var'
import AppIconPicker from '../../base/app-icon-picker'
import ShortcutsName from '../../workflow/shortcuts-name'
import { CreateAppDialogShell } from '../create-app-dialog-shell'
type CreateAppProps = {
@@ -94,10 +95,12 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
}, [name, t, appMode, appIcon, description, onSuccess, onClose, push, isCurrentWorkspaceEditor])
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
useHotkey('Mod+Enter', () => {
if (isAppsFull)
return
handleCreateApp()
}, {
ignoreInputs: false,
})
return (
<>
@@ -265,7 +268,11 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
<Button onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button>
<Button disabled={isAppsFull || !name} className="gap-1" variant="primary" onClick={handleCreateApp}>
<span>{t('newApp.Create', { ns: 'app' })}</span>
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
<KbdGroup>
{['Mod', 'Enter'].map(key => (
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</Button>
</div>
</div>

View File

@@ -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<string, { handler: () => 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<typeof import('@tanstack/react-hotkeys')>()
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: () => <div>apps-full</div>,
}))
vi.mock('../../workflow/shortcuts-name', () => ({
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
}))
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(
<CreateFromDSLModal
show
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
dslUrl="https://example.com/app.yml"
/>,
)
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)
})

View File

@@ -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 (
<>
<Dialog open={show}>
<Dialog open={show} onOpenChange={open => !open && !showErrorModal && onClose()}>
<DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl">
<div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6 title-2xl-semi-bold text-text-primary">
@@ -285,7 +283,11 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
className="gap-1"
>
<span>{t('newApp.Create', { ns: 'app' })}</span>
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
<KbdGroup>
{['Mod', 'Enter'].map(key => (
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</Button>
</div>
</DialogContent>

View File

@@ -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<PdfPreviewProps> = ({
})
}
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' })

View File

@@ -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 = () => {
>
<div className="flex grow items-center">
Type or press
<div className="mx-0.5 flex size-4 items-center justify-center rounded-sm bg-components-kbd-bg-gray system-kbd text-text-placeholder">/</div>
<Kbd className="mx-0.5 text-text-placeholder">/</Kbd>
<div
className="cursor-pointer system-sm-regular text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary"
onClick={((e) => {

View File

@@ -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<ImagePreviewProps> = ({
}
}, [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' })

View File

@@ -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<InputFieldProps> = ({
onClick={handleSave}
>
<span className="mr-1">{t(`${i18nPrefix}.insert`, { ns: 'workflow' })}</span>
<span className="mr-0.5 flex h-4 items-center rounded-sm bg-components-kbd-bg-white px-1 system-kbd">{getKeyboardKeyNameBySystem('ctrl')}</span>
<span className="flex h-4 items-center rounded-sm bg-components-kbd-bg-white px-1 system-kbd"></span>
<KbdGroup>
{['Mod', 'Enter'].map(key => (
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</Button>
)}

View File

@@ -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 = () => {
<span className="text-xl">{cmd.icon}</span>
<span className="text-sm">{cmd.name}</span>
</div>
<kbd className="rounded-sm bg-gray-200 px-2 py-1 font-mono text-xs">
<Kbd>
{cmd.shortcut}
</kbd>
</Kbd>
</div>
))
)

View File

@@ -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 (
<Dialog
@@ -170,9 +171,7 @@ const ImagePreviewer = ({
>
<RiCloseLine className="size-5" />
</Button>
<span className="system-2xs-medium-uppercase text-text-tertiary">
Esc
</span>
<Kbd>{formatForDisplay('Escape')}</Kbd>
</div>
{cachedImages[currentImage!.url]!.status === 'loading' && (
<Loading type="app" />

View File

@@ -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',

View File

@@ -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 (
<>
<Dialog open={show}>
<Dialog open={show} onOpenChange={open => !open && !showConfirmModal && onClose()}>
<DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl">
<Header onClose={onClose} />

View File

@@ -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<typeof import('@tanstack/react-hotkeys')>()
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(
<ActionButtons
handleCancel={vi.fn()}
@@ -471,12 +460,8 @@ describe('ActionButtons', () => {
{ 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 })
})
})
})

View File

@@ -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<IActionButtonsProps> = ({
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<IActionButtonsProps> = ({
>
<div className="flex items-center gap-x-1">
<span className="system-sm-medium text-components-button-secondary-text">{t('operation.cancel', { ns: 'common' })}</span>
<ShortcutsName keys={['ESC']} textColor="secondary" />
<Kbd>{formatForDisplay('Escape')}</Kbd>
</div>
</Button>
{(isParentChildParagraphMode && actionType === 'edit' && !isChildChunk && showRegenerationButton)
@@ -77,7 +76,11 @@ const ActionButtons: FC<IActionButtonsProps> = ({
>
<div className="flex items-center gap-x-1">
<span className="text-components-button-primary-text">{t('operation.save', { ns: 'common' })}</span>
<ShortcutsName keys={['ctrl', 'S']} bgColor="white" />
<KbdGroup>
{['Mod', 'S'].map(key => (
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</div>
</Button>
</div>

View File

@@ -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<string, { handler: () => void, options?: { enabled?: boolean } }>(),
}))
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
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)
})

View File

@@ -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}
>
<span>{!isEditModal ? t('operation.create', { ns: 'common' }) : t('operation.save', { ns: 'common' })}</span>
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
<KbdGroup>
{['Mod', 'Enter'].map(key => (
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</Button>
<Button className="w-24" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
</div>

View File

@@ -23,22 +23,41 @@ type KeyPressEvent = {
target?: EventTarget
}
const keyPressHandlers: Record<string, (event: KeyPressEvent) => void> = {}
type HotkeyRegistration = {
handler: (event: KeyPressEvent) => void
options?: { enabled?: boolean }
}
const hotkeyHandlers: Record<string, HotkeyRegistration> = {}
vi.mock('ahooks', () => ({
useDebounce: <T,>(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<typeof import('@tanstack/react-hotkeys')>()
return {
...actual,
useHotkey: (
hotkey: string,
handler: (event: KeyPressEvent) => void,
options?: HotkeyRegistration['options'],
) => {
hotkeyHandlers[hotkey] = { handler, options }
},
}
})
const HOTKEY_ALIAS: Record<string, string> = {
'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()

View File

@@ -8,14 +8,6 @@ vi.mock('@remixicon/react', () => ({
),
}))
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
default: ({ keys, textColor }: { keys: string[], textColor: string }) => (
<div data-testid="shortcuts-name" data-keys={keys.join(',')} data-color={textColor}>
{keys.join('+')}
</div>
),
}))
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(<SearchInput {...defaultProps} />)
it('should render shortcut keycaps', () => {
const { container } = render(<SearchInput {...defaultProps} />)
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', () => {

View File

@@ -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<HTMLInputElement | null>
@@ -54,7 +55,11 @@ const SearchInput: FC<SearchInputProps> = ({
</div>
)}
</div>
<ShortcutsName keys={['ctrl', 'K']} textColor="secondary" />
<KbdGroup>
{['Mod', 'K'].map(key => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</div>
)
}

View File

@@ -6,27 +6,34 @@ type KeyPressEvent = {
target?: EventTarget
}
const keyPressHandlers: Record<string, (event: KeyPressEvent) => 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<string, HotkeyRegistration> = {}
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()

View File

@@ -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(() => {

View File

@@ -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', () => ({

View File

@@ -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(<Popup />)
const { container } = render(<Popup />)
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(<RunMode />)
const { container } = render(<RunMode />)
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(<RunMode />)
const { container } = render(<RunMode />)
expect(screen.getByText('alt'))!.toBeInTheDocument()
expect(container.querySelectorAll('kbd')).toHaveLength(2)
expect(screen.getByText('Alt'))!.toBeInTheDocument()
expect(screen.getByText('R'))!.toBeInTheDocument()
})

View File

@@ -19,10 +19,6 @@ vi.mock('@/app/components/workflow/hooks', () => ({
}),
}))
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
default: ({ keys }: { keys: string[] }) => <span data-testid="shortcuts">{keys.join('+')}</span>,
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
@@ -95,9 +91,9 @@ describe('RunMode', () => {
})
it('should render keyboard shortcuts', () => {
render(<RunMode />)
const { container } = render(<RunMode />)
expect(screen.getByTestId('shortcuts')).toBeInTheDocument()
expect(container.querySelectorAll('kbd')).toHaveLength(2)
})
it('should call start run when button clicked', () => {

View File

@@ -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<string, (event: KeyboardEvent) => void>())
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
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<string, unknown>) => (
@@ -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(<Popup />)
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(<Popup />)
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(<Popup />)
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)

View File

@@ -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<typeof import('@tanstack/react-hotkeys')>()
return {
...actual,
useHotkey: vi.fn(),
}
})
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
@@ -130,14 +136,6 @@ vi.mock('@/app/components/workflow/hooks', () => ({
}),
}))
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
default: ({ keys }: { keys: string[] }) => <span data-testid="shortcuts">{keys.join('+')}</span>,
}))
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(<Popup />)
const { container } = render(<Popup />)
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', () => {

View File

@@ -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 = ({
: (
<div className="flex gap-1">
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
<ShortcutsName keys={PUBLISH_SHORTCUT} bgColor="white" />
<KbdGroup>
{PUBLISH_SHORTCUT.map(key => (
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</div>
)}
</Button>

View File

@@ -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 && (
<ShortcutsName keys={['alt', 'R']} textColor="secondary" />
<KbdGroup>
{['Alt', 'R'].map(key => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
)
}
</button>

View File

@@ -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()),

View File

@@ -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(
<ShortcutsName
keys={['ctrl', 'shift', 's']}
bgColor="white"
textColor="secondary"
/>,
)
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(<ShortcutsName keys={['ctrl', 'alt']} />)
expect(screen.getByText('ctrl')).toBeInTheDocument()
expect(screen.getByText('alt')).toBeInTheDocument()
})
})

View File

@@ -71,10 +71,6 @@ vi.mock('@/context/event-emitter', () => ({
}),
}))
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
default: () => <span data-testid="shortcuts-name">Shortcut</span>,
}))
vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
StopCircle: () => <span data-testid="stop-circle" />,
}))

View File

@@ -37,10 +37,6 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
}
})
vi.mock('../shortcuts-name', () => ({
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
}))
const createOption = (overrides: Partial<TriggerOption> = {}): TriggerOption => ({
id: 'user-input',
type: TriggerType.UserInput,

View File

@@ -67,10 +67,6 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
}
})
vi.mock('../shortcuts-name', () => ({
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
}))
const createOption = (overrides: Partial<TriggerOption> = {}): TriggerOption => ({
id: 'user-input',
type: TriggerType.UserInput,

View File

@@ -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 = () => {
>
<div className="flex grow items-center">
{t('nodes.tool.insertPlaceholder1', { ns: 'workflow' })}
<div className="mx-0.5 flex size-4 items-center justify-center rounded-sm bg-components-kbd-bg-gray system-kbd text-text-placeholder">/</div>
<Kbd className="mx-0.5 text-text-placeholder">/</Kbd>
<div
className="cursor-pointer system-sm-regular text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary"
onMouseDown={((e) => {

View File

@@ -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<typeof import('@tanstack/react-hotkeys')>()
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(
<FormContent

View File

@@ -4,6 +4,8 @@ import type { FC } from 'react'
import type { FormInputItem } from '../types'
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Kbd } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useEffect, useState } from 'react'
@@ -12,7 +14,6 @@ import PromptEditor from '@/app/components/base/prompt-editor'
import { INSERT_HITL_INPUT_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/hitl-input-block'
import { useWorkflowVariableType } from '../../../hooks'
import { BlockEnum } from '../../../types'
import { isMac } from '../../../utils'
import AddInputField from './add-input-field'
type FormContentProps = {
@@ -30,14 +31,6 @@ type FormContentProps = {
readonly?: boolean
}
const Key: FC<{ children: React.ReactNode, className?: string }> = ({ children, className }) => {
return <span className={cn('mx-0.5 inline-flex size-4 items-center justify-center rounded-sm bg-components-kbd-bg-gray system-kbd text-text-placeholder', className)}>{children}</span>
}
const CtrlKey: FC = () => {
return <Key className={cn('mr-0', !isMac() && 'w-7')}>{isMac() ? '⌘' : 'Ctrl'}</Key>
}
const FormContent: FC<FormContentProps> = ({
nodeId,
value,
@@ -162,8 +155,8 @@ const FormContent: FC<FormContentProps> = ({
ns="workflow"
components={
{
Key: <Key>/</Key>,
CtrlKey: <CtrlKey />,
Key: <Kbd className="mx-0.5 text-text-placeholder">/</Kbd>,
CtrlKey: <Kbd className="mx-0.5 text-text-placeholder">{formatForDisplay('Mod')}</Kbd>,
}
}
/>

View File

@@ -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) && (
<>
<div className="mx-0.5 flex size-4 items-center justify-center rounded-sm bg-components-kbd-bg-gray system-kbd text-text-placeholder">/</div>
<Kbd className="mx-0.5 text-text-placeholder">/</Kbd>
<div
className="cursor-pointer system-sm-regular text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary"
onMouseDown={((e) => {

View File

@@ -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 (
<div className={cn(
'flex items-center gap-0.5',
className,
)}
>
{
keys.map(key => (
<div
key={key}
className={cn(
'flex h-4 min-w-4 items-center justify-center rounded-sm px-1 system-kbd capitalize',
bgColor === 'gray' && 'bg-components-kbd-bg-gray',
bgColor === 'white' && 'bg-components-kbd-bg-white text-text-primary-on-surface',
textColor === 'secondary' && 'text-text-tertiary',
)}
>
{getKeyboardKeyNameBySystem(key)}
</div>
))
}
</div>
)
}
export default memo(ShortcutsName)

View File

@@ -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(
<ShortcutKbd
shortcut="workflow.copy"
@@ -14,7 +14,7 @@ describe('ShortcutKbd', () => {
)
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)

View File

@@ -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 (
<span
<KbdGroup
className={cn(
'flex items-center gap-0.5',
className,
)}
>
{
displayKeys.map((key, index) => (
<kbd
<Kbd
key={`${key}-${index}`}
className={cn(
'flex h-4 min-w-4 items-center justify-center rounded-sm px-1 font-sans system-kbd capitalize not-italic',
bgColor === 'gray' && 'bg-components-kbd-bg-gray',
bgColor === 'white' && 'bg-components-kbd-bg-white text-text-primary-on-surface',
textColor === 'secondary' && 'text-text-tertiary',
)}
color={bgColor}
className={cn(textColor === 'secondary' && 'text-text-tertiary')}
>
{key}
</kbd>
</Kbd>
))
}
</span>
</KbdGroup>
)
}

View File

@@ -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

View File

@@ -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)')

View File

@@ -1,39 +1,3 @@
export const isMac = () => {
return navigator.userAgent.toUpperCase().includes('MAC')
}
const specialKeysNameMap: Record<string, string | undefined> = {
ctrl: '⌘',
alt: '⌥',
shift: '⇧',
}
export const getKeyboardKeyNameBySystem = (key: string) => {
if (isMac())
return specialKeysNameMap[key] || key
return key
}
const specialKeysCodeMap: Record<string, string | undefined> = {
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

View File

@@ -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:",