mirror of
https://github.com/langgenius/dify.git
synced 2026-06-15 13:31:08 +08:00
refactor(web): workflow hotkeys and history state (#35736)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
dcf21a6a84
commit
88196c186e
@ -3764,11 +3764,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/header/version-history-button.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/hooks-store/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 2
|
||||
@ -3791,7 +3786,7 @@
|
||||
},
|
||||
"web/app/components/workflow/hooks/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 27
|
||||
"count": 26
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/hooks/use-checklist.ts": {
|
||||
@ -4989,11 +4984,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/operator/tip-popup.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/operator/zoom-in-out.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@ -5333,11 +5323,6 @@
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/workflow-history-store.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/workflow-preview/components/nodes/base.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
47
pnpm-lock.yaml
generated
47
pnpm-lock.yaml
generated
@ -171,6 +171,9 @@ catalogs:
|
||||
'@tanstack/react-form-devtools':
|
||||
specifier: 0.2.22
|
||||
version: 0.2.22
|
||||
'@tanstack/react-hotkeys':
|
||||
specifier: 0.10.0
|
||||
version: 0.10.0
|
||||
'@tanstack/react-query':
|
||||
specifier: 5.100.6
|
||||
version: 5.100.6
|
||||
@ -884,6 +887,9 @@ importers:
|
||||
'@tanstack/react-form':
|
||||
specifier: 'catalog:'
|
||||
version: 1.29.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@tanstack/react-hotkeys':
|
||||
specifier: 'catalog:'
|
||||
version: 0.10.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@tanstack/react-query':
|
||||
specifier: 'catalog:'
|
||||
version: 5.100.6(react@19.2.5)
|
||||
@ -3839,6 +3845,10 @@ packages:
|
||||
peerDependencies:
|
||||
solid-js: 1.9.11
|
||||
|
||||
'@tanstack/hotkeys@0.8.0':
|
||||
resolution: {integrity: sha512-vqH7X9nb0MTJ/O08++dB5bP9jgj4+BIPOUu/U+6myG86lDsirZSVSobpq5UQpE7nBuk62i8eIYeOhd+OMl/UrA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@tanstack/pacer-lite@0.1.1':
|
||||
resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==}
|
||||
engines: {node: '>=18'}
|
||||
@ -3872,6 +3882,13 @@ packages:
|
||||
'@tanstack/react-start':
|
||||
optional: true
|
||||
|
||||
'@tanstack/react-hotkeys@0.10.0':
|
||||
resolution: {integrity: sha512-GwOSndI5j3qBVYTmgP1mYyRTnlxb2MS17cwGlsavSxMQPSnmDf+m3LzMIpRMs+3zzQMjg3cYhHsFYizYlFI2tw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
'@tanstack/react-query-devtools@5.100.6':
|
||||
resolution: {integrity: sha512-sz3ksMKA2t1rx0+Odzb0x1A3pXH/SVf7fzlzd3sKXzwXz8980f5sbOwfQD6+UfTG8G4Y2KaIg9e3sBn+uC4VTg==}
|
||||
peerDependencies:
|
||||
@ -3883,6 +3900,12 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-store@0.11.0':
|
||||
resolution: {integrity: sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/react-store@0.9.3':
|
||||
resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==}
|
||||
peerDependencies:
|
||||
@ -3895,6 +3918,9 @@ packages:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/store@0.11.0':
|
||||
resolution: {integrity: sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==}
|
||||
|
||||
'@tanstack/store@0.9.3':
|
||||
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
|
||||
|
||||
@ -11023,6 +11049,10 @@ snapshots:
|
||||
- react
|
||||
- vue
|
||||
|
||||
'@tanstack/hotkeys@0.8.0':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.11.0
|
||||
|
||||
'@tanstack/pacer-lite@0.1.1': {}
|
||||
|
||||
'@tanstack/query-core@5.100.6': {}
|
||||
@ -11061,6 +11091,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- react-dom
|
||||
|
||||
'@tanstack/react-hotkeys@0.10.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@tanstack/hotkeys': 0.8.0
|
||||
'@tanstack/react-store': 0.11.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
|
||||
'@tanstack/react-query-devtools@5.100.6(@tanstack/react-query@5.100.6(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@tanstack/query-devtools': 5.100.6
|
||||
@ -11072,6 +11109,13 @@ snapshots:
|
||||
'@tanstack/query-core': 5.100.6
|
||||
react: 19.2.5
|
||||
|
||||
'@tanstack/react-store@0.11.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.11.0
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
use-sync-external-store: 1.6.0(react@19.2.5)
|
||||
|
||||
'@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.9.3
|
||||
@ -11085,6 +11129,8 @@ snapshots:
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
|
||||
'@tanstack/store@0.11.0': {}
|
||||
|
||||
'@tanstack/store@0.9.3': {}
|
||||
|
||||
'@tanstack/virtual-core@3.14.0': {}
|
||||
@ -16512,6 +16558,7 @@ time:
|
||||
'@lexical/text@0.44.0': '2026-04-27T14:48:23.958Z'
|
||||
'@lexical/utils@0.44.0': '2026-04-27T14:48:26.689Z'
|
||||
'@tanstack/eslint-plugin-query@5.100.6': '2026-04-28T16:39:45.129Z'
|
||||
'@tanstack/react-hotkeys@0.10.0': '2026-04-25T12:28:06.989Z'
|
||||
'@tanstack/react-query-devtools@5.100.6': '2026-04-28T16:39:51.334Z'
|
||||
'@tanstack/react-query@5.100.6': '2026-04-28T16:39:52.105Z'
|
||||
'@tsslint/cli@3.1.0': '2026-04-29T04:57:38.423Z'
|
||||
|
||||
@ -110,6 +110,7 @@ catalog:
|
||||
'@tanstack/react-devtools': 0.10.2
|
||||
'@tanstack/react-form': 1.29.1
|
||||
'@tanstack/react-form-devtools': 0.2.22
|
||||
'@tanstack/react-hotkeys': 0.10.0
|
||||
'@tanstack/react-query': 5.100.6
|
||||
'@tanstack/react-query-devtools': 5.100.6
|
||||
'@tanstack/react-virtual': 3.13.24
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { CSSProperties, FC } from 'react'
|
||||
import type { ModelAndParameter } from '../types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -92,11 +93,16 @@ const DebugItem: FC<DebugItemProps> = ({
|
||||
modelAndParameter={modelAndParameter}
|
||||
/>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<ActionButton className={open ? 'bg-state-base-hover' : ''}>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
className={cn(open && 'bg-state-base-hover', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid')}
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
|
||||
@ -363,9 +363,7 @@ describe('Breadcrumbs', () => {
|
||||
|
||||
render(<Breadcrumbs {...props} />)
|
||||
|
||||
// Assert - Dropdown trigger (more button) should be present
|
||||
// Assert - Dropdown trigger (more button) should be present
|
||||
expect(screen.getByRole('button', { name: '' }))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.more' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show dropdown when breadcrumbs do not exceed displayBreadcrumbNum', () => {
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Menu from './menu'
|
||||
|
||||
type DropdownProps = {
|
||||
@ -19,6 +20,7 @@ const Dropdown = ({
|
||||
breadcrumbs,
|
||||
onBreadcrumbClick,
|
||||
}: DropdownProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleBreadCrumbClick = useCallback((index: number) => {
|
||||
@ -31,17 +33,21 @@ const Dropdown = ({
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex size-6 items-center justify-center rounded-md',
|
||||
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
className={cn(
|
||||
'flex size-6 items-center justify-center rounded-md',
|
||||
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { subscribeWorkflowCommand, WorkflowCommand } from '@/app/components/workflow/shortcuts/commands'
|
||||
import { registerCommands, unregisterCommands } from '../command-bus'
|
||||
import { ZEN_TOGGLE_EVENT, zenCommand } from '../zen'
|
||||
import { zenCommand } from '../zen'
|
||||
|
||||
vi.mock('../command-bus')
|
||||
|
||||
@ -24,10 +25,6 @@ describe('zenCommand', () => {
|
||||
expect(zenCommand.execute).toBeDefined()
|
||||
})
|
||||
|
||||
it('exports ZEN_TOGGLE_EVENT constant', () => {
|
||||
expect(ZEN_TOGGLE_EVENT).toBe('zen-toggle-maximize')
|
||||
})
|
||||
|
||||
describe('isAvailable', () => {
|
||||
it('delegates to isInWorkflowPage', async () => {
|
||||
const { isInWorkflowPage } = vi.mocked(
|
||||
@ -43,15 +40,14 @@ describe('zenCommand', () => {
|
||||
})
|
||||
|
||||
describe('execute', () => {
|
||||
it('dispatches custom zen-toggle event', () => {
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
it('emits the workflow canvas maximize command', () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribeWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize, listener)
|
||||
|
||||
zenCommand.execute?.()
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: ZEN_TOGGLE_EVENT }),
|
||||
)
|
||||
dispatchSpy.mockRestore()
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -3,17 +3,17 @@ import { RiFullscreenLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { isInWorkflowPage } from '@/app/components/workflow/constants'
|
||||
import {
|
||||
emitWorkflowCommand,
|
||||
WorkflowCommand,
|
||||
} from '@/app/components/workflow/shortcuts/commands'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
// Zen command dependency types - no external dependencies needed
|
||||
type ZenDeps = Record<string, never>
|
||||
|
||||
// Custom event name for zen toggle
|
||||
export const ZEN_TOGGLE_EVENT = 'zen-toggle-maximize'
|
||||
|
||||
// Shared function to dispatch zen toggle event
|
||||
const toggleZenMode = () => {
|
||||
window.dispatchEvent(new CustomEvent(ZEN_TOGGLE_EVENT))
|
||||
emitWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type {
|
||||
DataSourceCredential,
|
||||
} from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -45,11 +46,17 @@ const Operator = ({
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<ActionButton size="l" className={open ? 'bg-state-base-hover' : ''}>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
size="l"
|
||||
className={cn(open && 'bg-state-base-hover', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid')}
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[200px]">
|
||||
<DropdownMenuItem className="h-auto gap-2 py-2" onClick={() => handleAction('setDefault')}>
|
||||
<span aria-hidden className="i-ri-home-9-line h-4 w-4 text-text-tertiary" />
|
||||
|
||||
@ -218,7 +218,6 @@ describe('EdgeContextmenu', () => {
|
||||
})
|
||||
|
||||
const deleteAction = await screen.findByRole('menuitem', { name: /common:operation\.delete/i })
|
||||
expect(screen.getByText(/^del$/i))!.toBeInTheDocument()
|
||||
|
||||
await user.click(deleteAction)
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Edge, Node } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { WorkflowContextProvider } from '../context'
|
||||
import { useDatasetsDetailStore } from '../datasets-detail-store/store'
|
||||
import WorkflowWithDefaultContext from '../index'
|
||||
import { BlockEnum } from '../types'
|
||||
@ -35,14 +36,13 @@ const edges: Edge[] = [
|
||||
]
|
||||
|
||||
const ContextConsumer = () => {
|
||||
const { store, shortcutsEnabled } = useWorkflowHistoryStore()
|
||||
const { store } = useWorkflowHistoryStore()
|
||||
const datasetCount = useDatasetsDetailStore(state => Object.keys(state.datasetsDetail).length)
|
||||
const reactFlowStore = useStoreApi()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{`history:${store.getState().nodes.length}`}
|
||||
{` shortcuts:${String(shortcutsEnabled)}`}
|
||||
{` datasets:${datasetCount}`}
|
||||
{` reactflow:${String(!!reactFlowStore)}`}
|
||||
</div>
|
||||
@ -52,16 +52,18 @@ const ContextConsumer = () => {
|
||||
describe('WorkflowWithDefaultContext', () => {
|
||||
it('wires the ReactFlow, workflow history, and datasets detail providers around its children', () => {
|
||||
render(
|
||||
<WorkflowWithDefaultContext
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
<ContextConsumer />
|
||||
</WorkflowWithDefaultContext>,
|
||||
<WorkflowContextProvider>
|
||||
<WorkflowWithDefaultContext
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
<ContextConsumer />
|
||||
</WorkflowWithDefaultContext>
|
||||
</WorkflowContextProvider>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText('history:1 shortcuts:true datasets:0 reactflow:true'),
|
||||
screen.getByText('history:1 datasets:0 reactflow:true'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -145,8 +145,8 @@ describe('PanelContextmenu', () => {
|
||||
const { container } = render(<PanelContextmenu />)
|
||||
|
||||
expect(screen.getByTestId('add-block')).toHaveTextContent('common.addBlock')
|
||||
expect(screen.getByTestId('shortcut-alt-r')).toHaveTextContent('alt+r')
|
||||
expect(screen.getByTestId('shortcut-ctrl-v')).toHaveTextContent('ctrl+v')
|
||||
expect(screen.getByRole('button', { name: /common\.run/i })).toHaveTextContent(/Alt\s*R/)
|
||||
expect(screen.getByRole('button', { name: /common\.pasteHere/i })).toHaveTextContent(/Ctrl\s*V/)
|
||||
expect(container.firstChild).toHaveStyle({
|
||||
left: '24px',
|
||||
top: '48px',
|
||||
|
||||
@ -251,17 +251,23 @@ vi.mock('../hooks/use-workflow-comment', () => ({
|
||||
vi.mock('../base/confirm', () => ({
|
||||
default: ({
|
||||
isShow,
|
||||
title,
|
||||
desc,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
isShow: boolean
|
||||
title?: string
|
||||
desc?: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) => isShow
|
||||
? (
|
||||
<div data-testid="confirm-dialog">
|
||||
<button type="button" onClick={onConfirm}>confirm</button>
|
||||
<button type="button" onClick={onCancel}>cancel</button>
|
||||
<div role="alertdialog" data-testid="confirm-dialog">
|
||||
{title && <div>{title}</div>}
|
||||
{desc && <div>{desc}</div>}
|
||||
<button type="button" onClick={onConfirm}>common.operation.confirm</button>
|
||||
<button type="button" onClick={onCancel}>common.operation.cancel</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
@ -338,6 +344,11 @@ vi.mock('../syncing-data-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../shortcuts/use-workflow-hotkeys', () => ({
|
||||
useWorkflowHotkeys: workflowHookMocks.useShortcuts,
|
||||
useWorkflowShortcut: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useEdgesInteractions: () => ({
|
||||
handleEdgeEnter: workflowHookMocks.handleEdgeEnter,
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import type { WorkflowHistoryState } from '../store/workflow/history-slice'
|
||||
import type { Edge, Node } from '../types'
|
||||
import type { WorkflowHistoryState } from '../workflow-history-store'
|
||||
import { render, renderHook, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { WorkflowContext } from '../context'
|
||||
import { createWorkflowStore } from '../store/workflow'
|
||||
import { BlockEnum } from '../types'
|
||||
import { useWorkflowHistoryStore, WorkflowHistoryProvider } from '../workflow-history-store'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
|
||||
const nodes: Node[] = [
|
||||
{
|
||||
@ -36,44 +37,28 @@ const edges: Edge[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const HistoryConsumer = () => {
|
||||
const { store, shortcutsEnabled, setShortcutsEnabled } = useWorkflowHistoryStore()
|
||||
const createWrapper = () => {
|
||||
const workflowStore = createWorkflowStore({})
|
||||
workflowStore.temporal.getState().pause()
|
||||
workflowStore.getState().setWorkflowHistory({
|
||||
nodes,
|
||||
edges,
|
||||
workflowHistoryEvent: undefined,
|
||||
workflowHistoryEventMeta: undefined,
|
||||
})
|
||||
workflowStore.temporal.getState().clear()
|
||||
workflowStore.temporal.getState().resume()
|
||||
|
||||
return (
|
||||
<button onClick={() => setShortcutsEnabled(!shortcutsEnabled)}>
|
||||
{`nodes:${store.getState().nodes.length} shortcuts:${String(shortcutsEnabled)}`}
|
||||
</button>
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<WorkflowContext.Provider value={workflowStore}>
|
||||
{children}
|
||||
</WorkflowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkflowHistoryProvider', () => {
|
||||
it('provides workflow history state and shortcut toggles', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<WorkflowHistoryProvider
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
<HistoryConsumer />
|
||||
</WorkflowHistoryProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'nodes:1 shortcuts:true' }))!.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'nodes:1 shortcuts:true' }))
|
||||
expect(screen.getByRole('button', { name: 'nodes:1 shortcuts:false' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('workflow history store', () => {
|
||||
it('sanitizes selected flags when history state is replaced through the exposed store api', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<WorkflowHistoryProvider
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
{children}
|
||||
</WorkflowHistoryProvider>
|
||||
)
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const { result } = renderHook(() => useWorkflowHistoryStore(), { wrapper })
|
||||
const nextState: WorkflowHistoryState = {
|
||||
@ -91,7 +76,7 @@ describe('WorkflowHistoryProvider', () => {
|
||||
|
||||
it('throws when consumed outside the provider', () => {
|
||||
expect(() => renderHook(() => useWorkflowHistoryStore())).toThrow(
|
||||
'useWorkflowHistoryStoreApi must be used within a WorkflowHistoryProvider',
|
||||
'Missing WorkflowContext.Provider in the tree',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -63,22 +63,18 @@
|
||||
import type { RenderHookOptions, RenderHookResult, RenderOptions, RenderResult } from '@testing-library/react'
|
||||
import type { Shape as HooksStoreShape } from '../hooks-store/store'
|
||||
import type { Shape } from '../store/workflow'
|
||||
import type { WorkflowHistoryState } from '../store/workflow/history-slice'
|
||||
import type { Edge, Node, WorkflowRunningData } from '../types'
|
||||
import type { WorkflowHistoryStoreApi } from '../workflow-history-store'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, renderHook } from '@testing-library/react'
|
||||
import isDeepEqual from 'fast-deep-equal'
|
||||
import * as React from 'react'
|
||||
import ReactFlow, { ReactFlowProvider } from 'reactflow'
|
||||
import { temporal } from 'zundo'
|
||||
import { create } from 'zustand'
|
||||
import { seedSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { WorkflowContext } from '../context'
|
||||
import { HooksStoreContext } from '../hooks-store/provider'
|
||||
import { createHooksStore } from '../hooks-store/store'
|
||||
import { createWorkflowStore } from '../store/workflow'
|
||||
import { WorkflowRunningStatus } from '../types'
|
||||
import { WorkflowHistoryStoreContext } from '../workflow-history-store'
|
||||
|
||||
// Re-exports are in a separate non-JSX file to avoid react-refresh warnings.
|
||||
// Import directly from the individual modules:
|
||||
@ -156,9 +152,13 @@ function createWorkflowWrapper(
|
||||
historyConfig?: HistoryStoreConfig,
|
||||
externalQueryClient?: QueryClient,
|
||||
) {
|
||||
const historyCtxValue = historyConfig
|
||||
? createTestHistoryStoreContext(historyConfig)
|
||||
: undefined
|
||||
if (historyConfig) {
|
||||
stores.store.temporal.getState().pause()
|
||||
stores.store.getState().setWorkflowHistory(createTestWorkflowHistoryState(historyConfig))
|
||||
stores.store.temporal.getState().clear()
|
||||
stores.store.temporal.getState().resume()
|
||||
}
|
||||
|
||||
const queryClient = externalQueryClient ?? new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@ -172,14 +172,6 @@ function createWorkflowWrapper(
|
||||
return ({ children }: { children: React.ReactNode }) => {
|
||||
let inner: React.ReactNode = children
|
||||
|
||||
if (historyCtxValue) {
|
||||
inner = React.createElement(
|
||||
WorkflowHistoryStoreContext.Provider,
|
||||
{ value: historyCtxValue },
|
||||
inner,
|
||||
)
|
||||
}
|
||||
|
||||
if (stores.hooksStore) {
|
||||
inner = React.createElement(
|
||||
HooksStoreContext.Provider,
|
||||
@ -214,7 +206,7 @@ type WorkflowHookTestResult<R, P> = RenderHookResult<R, P> & StoreInstances
|
||||
* Contexts provided based on options:
|
||||
* - **Always**: `WorkflowContext` (real zustand store)
|
||||
* - **hooksStoreProps**: `HooksStoreContext` (real zustand store)
|
||||
* - **historyStore**: `WorkflowHistoryStoreContext` (real zundo temporal store)
|
||||
* - **historyStore**: workflow history zundo store on `WorkflowContext`
|
||||
*/
|
||||
export function renderWorkflowHook<R, P = undefined>(
|
||||
hook: (props: P) => R,
|
||||
@ -243,7 +235,7 @@ type WorkflowComponentTestResult = RenderResult & StoreInstances
|
||||
* Provides the same context layers as `renderWorkflowHook`:
|
||||
* - **Always**: `WorkflowContext` (real zustand store)
|
||||
* - **hooksStoreProps**: `HooksStoreContext` (real zustand store)
|
||||
* - **historyStore**: `WorkflowHistoryStoreContext` (real zundo temporal store)
|
||||
* - **historyStore**: workflow history zundo store on `WorkflowContext`
|
||||
*/
|
||||
export function renderWorkflowComponent(
|
||||
ui: React.ReactElement,
|
||||
@ -393,36 +385,13 @@ export function renderNodeComponent<T extends Record<string, unknown>>(
|
||||
// WorkflowHistoryStore test helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createTestHistoryStoreContext(config: HistoryStoreConfig) {
|
||||
function createTestWorkflowHistoryState(config: HistoryStoreConfig): WorkflowHistoryState {
|
||||
const nodes = config.nodes ?? []
|
||||
const edges = config.edges ?? []
|
||||
|
||||
type HistState = {
|
||||
workflowHistoryEvent: string | undefined
|
||||
workflowHistoryEventMeta: unknown
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
getNodes: () => Node[]
|
||||
setNodes: (n: Node[]) => void
|
||||
setEdges: (e: Edge[]) => void
|
||||
}
|
||||
|
||||
const store = create(temporal<HistState>(
|
||||
(set, get) => ({
|
||||
workflowHistoryEvent: undefined,
|
||||
workflowHistoryEventMeta: undefined,
|
||||
nodes,
|
||||
edges,
|
||||
getNodes: () => get().nodes,
|
||||
setNodes: (n: Node[]) => set({ nodes: n }),
|
||||
setEdges: (e: Edge[]) => set({ edges: e }),
|
||||
}),
|
||||
{ equality: (a, b) => isDeepEqual(a, b) },
|
||||
)) as unknown as WorkflowHistoryStoreApi
|
||||
|
||||
return {
|
||||
store,
|
||||
shortcutsEnabled: true,
|
||||
setShortcutsEnabled: () => {},
|
||||
nodes,
|
||||
edges,
|
||||
workflowHistoryEvent: undefined,
|
||||
workflowHistoryEventMeta: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,11 +74,16 @@ const OperationDropdown: FC<Props> = ({
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<ActionButton className={cn(open && 'bg-state-base-hover')}>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-components-button-secondary-accent-text" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
className={cn(open && 'bg-state-base-hover', 'focus-visible:ring-2 focus-visible:ring-state-accent-solid')}
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-components-button-secondary-accent-text" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { SliceFromInjection } from './store'
|
||||
import type { SliceFromInjection } from './store/workflow'
|
||||
import {
|
||||
createContext,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import {
|
||||
createWorkflowStore,
|
||||
} from './store'
|
||||
} from './store/workflow'
|
||||
|
||||
type WorkflowStore = ReturnType<typeof createWorkflowStore>
|
||||
export const WorkflowContext = createContext<WorkflowStore | null>(null)
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEdges } from 'reactflow'
|
||||
import { useEdgesInteractions, usePanelInteractions } from './hooks'
|
||||
import ShortcutsName from './shortcuts-name'
|
||||
import { ShortcutKbd } from './shortcuts/shortcut-kbd'
|
||||
import { useStore } from './store'
|
||||
|
||||
const EdgeContextmenu = () => {
|
||||
@ -53,7 +53,7 @@ const EdgeContextmenu = () => {
|
||||
onClick={() => handleEdgeDeleteById(edgeMenu.edgeId)}
|
||||
>
|
||||
<span>{t('common:operation.delete')}</span>
|
||||
<ShortcutsName keys={['del']} />
|
||||
<ShortcutKbd shortcut="workflow.delete" />
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
@ -37,11 +37,15 @@ vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
vi.mock('@/app/components/workflow/store/workflow', () => ({
|
||||
useStore: (selector: (state: { workflowRunningData?: unknown, isListening: boolean }) => unknown) =>
|
||||
selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/shortcuts/use-workflow-hotkeys', () => ({
|
||||
useWorkflowShortcut: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/use-dynamic-test-run-options', () => ({
|
||||
useDynamicTestRunOptions: () => mockDynamicOptions,
|
||||
}))
|
||||
|
||||
@ -27,12 +27,17 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
|
||||
render,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
render?: React.ReactElement
|
||||
render?: React.ReactElement<{ children?: React.ReactNode }>
|
||||
}) => {
|
||||
const { open, setOpen } = useDropdownMenuContext()
|
||||
|
||||
if (render)
|
||||
return React.cloneElement(render, { onClick: () => setOpen(!open) } as Record<string, unknown>, children)
|
||||
if (render) {
|
||||
return React.cloneElement(
|
||||
render,
|
||||
{ onClick: () => setOpen(!open) } as Record<string, unknown>,
|
||||
children ?? render.props.children,
|
||||
)
|
||||
}
|
||||
|
||||
return <button type="button" onClick={() => setOpen(!open)}>{children}</button>
|
||||
},
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import VersionHistoryButton from '../version-history-button'
|
||||
|
||||
let mockTheme: 'light' | 'dark' = 'light'
|
||||
const workflowShortcutHandlers = vi.hoisted(() => new Map<string, () => void | Promise<void>>())
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
@ -9,17 +10,22 @@ vi.mock('@/hooks/use-theme', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../utils')>()
|
||||
return {
|
||||
...actual,
|
||||
getKeyboardKeyCodeBySystem: () => 'ctrl',
|
||||
}
|
||||
})
|
||||
vi.mock('../../shortcuts/use-workflow-hotkeys', () => ({
|
||||
useWorkflowShortcut: (id: string, callback: () => void | Promise<void>) => {
|
||||
workflowShortcutHandlers.set(id, callback)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
describe('VersionHistoryButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
workflowShortcutHandlers.clear()
|
||||
mockTheme = 'light'
|
||||
})
|
||||
|
||||
@ -32,22 +38,14 @@ describe('VersionHistoryButton', () => {
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger onClick when the version history shortcut is pressed', () => {
|
||||
it('should trigger onClick when the version history shortcut is pressed', async () => {
|
||||
const onClick = vi.fn()
|
||||
render(<VersionHistoryButton onClick={onClick} />)
|
||||
|
||||
const keyboardEvent = new KeyboardEvent('keydown', {
|
||||
key: 'H',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
await act(async () => {
|
||||
await workflowShortcutHandlers.get('workflow.version-history')?.()
|
||||
})
|
||||
Object.defineProperty(keyboardEvent, 'keyCode', { value: 72 })
|
||||
Object.defineProperty(keyboardEvent, 'which', { value: 72 })
|
||||
window.dispatchEvent(keyboardEvent)
|
||||
|
||||
expect(keyboardEvent.defaultPrevented).toBe(true)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import type { TestRunMenuRef, TriggerOption } from './test-run-menu'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiLoader2Line, RiPlayLargeLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||
import { useWorkflowRun, useWorkflowRunValidation, useWorkflowStartRun } from '@/app/components/workflow/hooks'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||
import { useWorkflowShortcut } from '@/app/components/workflow/shortcuts/use-workflow-hotkeys'
|
||||
import { useStore } from '@/app/components/workflow/store/workflow'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
@ -42,17 +42,12 @@ const RunMode = ({
|
||||
const dynamicOptions = useDynamicTestRunOptions()
|
||||
const testRunMenuRef = useRef<TestRunMenuRef>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// @ts-expect-error - Dynamic property for backward compatibility with keyboard shortcuts
|
||||
window._toggleTestRunDropdown = () => {
|
||||
testRunMenuRef.current?.toggle()
|
||||
}
|
||||
return () => {
|
||||
// @ts-expect-error - Dynamic property cleanup
|
||||
delete window._toggleTestRunDropdown
|
||||
}
|
||||
const handleToggleTestRunMenu = useCallback(() => {
|
||||
testRunMenuRef.current?.toggle()
|
||||
}, [])
|
||||
|
||||
useWorkflowShortcut('workflow.open-test-run-menu', handleToggleTestRunMenu)
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
handleStopRun(workflowRunningData?.task_id || '')
|
||||
}, [handleStopRun, workflowRunningData?.task_id])
|
||||
@ -117,7 +112,7 @@ const RunMode = ({
|
||||
)}
|
||||
disabled={true}
|
||||
>
|
||||
<RiLoader2Line className="mr-1 size-4 animate-spin" />
|
||||
<span className="mr-1 i-ri-loader-2-line size-4 animate-spin" />
|
||||
{isListening ? t('common.listening', { ns: 'workflow' }) : t('common.running', { ns: 'workflow' })}
|
||||
</button>
|
||||
)
|
||||
@ -127,16 +122,17 @@ const RunMode = ({
|
||||
options={dynamicOptions}
|
||||
onSelect={handleTriggerSelect}
|
||||
>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-7 cursor-pointer items-center gap-x-1 rounded-md px-1.5 system-xs-medium text-text-accent hover:bg-state-accent-hover',
|
||||
)}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<RiPlayLargeLine className="mr-1 size-4" />
|
||||
<span aria-hidden className="mr-1 i-ri-play-large-line size-4" />
|
||||
{text ?? t('common.run', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['alt', 'R']} textColor="secondary" />
|
||||
</div>
|
||||
<ShortcutKbd shortcut="workflow.open-test-run-menu" textColor="secondary" />
|
||||
</button>
|
||||
</TestRunMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
isValidElement,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
|
||||
|
||||
export type ShortcutMapping = {
|
||||
option: TriggerOption
|
||||
@ -39,7 +39,7 @@ export const OptionRow = ({
|
||||
<span className="ml-2 truncate">{option.name}</span>
|
||||
</div>
|
||||
{shortcutKey && (
|
||||
<ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" />
|
||||
<ShortcutKbd hotkey={shortcutKey} className="ml-2" textColor="secondary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
@ -111,8 +111,8 @@ export const SingleOptionTrigger = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={handleRunClick}>
|
||||
<button type="button" onClick={handleRunClick}>
|
||||
{children}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ShortcutMapping } from './test-run-menu-helpers'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { forwardRef, isValidElement, useCallback, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { OptionRow, SingleOptionTrigger, useShortcutMenu } from './test-run-menu-helpers'
|
||||
|
||||
@ -145,9 +145,18 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger render={<div style={{ userSelect: 'none' }} />}>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
{isValidElement(children)
|
||||
? (
|
||||
<DropdownMenuTrigger
|
||||
render={children}
|
||||
style={{ userSelect: 'none' }}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<DropdownMenuTrigger style={{ userSelect: 'none' }}>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
)}
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={8}
|
||||
|
||||
@ -28,7 +28,7 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-[5px]">
|
||||
<TipPopup title={t('common.undo', { ns: 'workflow' })!} shortcuts={['ctrl', 'z']}>
|
||||
<TipPopup title={t('common.undo', { ns: 'workflow' })!} shortcut="workflow.undo">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.undo', { ns: 'workflow' })!}
|
||||
@ -43,7 +43,7 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
|
||||
<span className="i-ri-arrow-go-back-line h-4 w-4" />
|
||||
</button>
|
||||
</TipPopup>
|
||||
<TipPopup title={t('common.redo', { ns: 'workflow' })!} shortcuts={['ctrl', 'y']}>
|
||||
<TipPopup title={t('common.redo', { ns: 'workflow' })!} shortcut="workflow.redo">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.redo', { ns: 'workflow' })!}
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiHistoryLine } from '@remixicon/react'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@langgenius/dify-ui/tooltip'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
import { getKeyboardKeyCodeBySystem } from '../utils'
|
||||
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
|
||||
import { useWorkflowShortcut } from '../shortcuts/use-workflow-hotkeys'
|
||||
|
||||
type VersionHistoryButtonProps = {
|
||||
onClick: () => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
const VERSION_HISTORY_SHORTCUT = ['ctrl', '⇧', 'H']
|
||||
|
||||
const PopupContent = React.memo(() => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
@ -24,7 +24,7 @@ const PopupContent = React.memo(() => {
|
||||
<div className="px-0.5 system-xs-medium text-text-secondary">
|
||||
{t('common.versionHistory', { ns: 'workflow' })}
|
||||
</div>
|
||||
<ShortcutsName keys={VERSION_HISTORY_SHORTCUT} bgColor="gray" textColor="secondary" />
|
||||
<ShortcutKbd shortcut="workflow.version-history" bgColor="gray" textColor="secondary" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@ -39,27 +39,30 @@ const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({
|
||||
await onClick?.()
|
||||
}, [onClick])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.h`, (e) => {
|
||||
e.preventDefault()
|
||||
useWorkflowShortcut('workflow.version-history', () => {
|
||||
handleViewVersionHistory()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
})
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={<PopupContent />}
|
||||
noDecoration
|
||||
popupClassName="rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg
|
||||
shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px] p-1.5"
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
'rounded-lg border border-transparent p-2',
|
||||
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-xs',
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<Button
|
||||
className={cn(
|
||||
'rounded-lg border border-transparent p-2',
|
||||
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-xs',
|
||||
)}
|
||||
onClick={handleViewVersionHistory}
|
||||
>
|
||||
<span className="i-ri-history-line h-4 w-4 text-components-button-secondary-text" />
|
||||
</Button>
|
||||
)}
|
||||
onClick={handleViewVersionHistory}
|
||||
/>
|
||||
<TooltipContent
|
||||
className="rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]"
|
||||
>
|
||||
<RiHistoryLine className="h-4 w-4 text-components-button-secondary-text" />
|
||||
</Button>
|
||||
<PopupContent />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import type { WorkflowHistoryState } from '../workflow-history-store'
|
||||
import type { WorkflowHistoryState } from '../store/workflow/history-slice'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
@ -141,6 +142,7 @@ const ViewWorkflowHistory = () => {
|
||||
return (
|
||||
(
|
||||
<Popover
|
||||
modal="trap-focus"
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nodesReadOnly)
|
||||
@ -148,49 +150,56 @@ const ViewWorkflowHistory = () => {
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<TipPopup
|
||||
title={t('changeHistory.title', { ns: 'workflow' })}
|
||||
>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div
|
||||
className={
|
||||
cn('flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', open && 'bg-state-accent-active text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
|
||||
}
|
||||
onClick={() => {
|
||||
if (nodesReadOnly)
|
||||
return
|
||||
setCurrentLogItem()
|
||||
setShowMessageLogModal(false)
|
||||
}}
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('changeHistory.title', { ns: 'workflow' })}
|
||||
disabled={nodesReadOnly}
|
||||
className={
|
||||
cn('box-border inline-flex h-8 max-h-8 min-h-8 w-8 max-w-8 min-w-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', open && 'bg-state-accent-active text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
|
||||
}
|
||||
onClick={() => {
|
||||
if (nodesReadOnly)
|
||||
return
|
||||
setCurrentLogItem()
|
||||
setShowMessageLogModal(false)
|
||||
}}
|
||||
>
|
||||
<TipPopup
|
||||
title={t('changeHistory.title', { ns: 'workflow' })}
|
||||
>
|
||||
<RiHistoryLine className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</TipPopup>
|
||||
<span className="flex h-full w-full shrink-0 items-center justify-center">
|
||||
<span className="i-ri-history-line h-4 w-4 shrink-0" />
|
||||
</span>
|
||||
</TipPopup>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={131}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div
|
||||
className="ml-2 flex max-w-[360px] min-w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]"
|
||||
className="flex max-w-[360px] min-w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]"
|
||||
>
|
||||
<div className="sticky top-0 flex items-center justify-between px-4 pt-3">
|
||||
<div className="system-mg-regular grow text-text-secondary">{t('changeHistory.title', { ns: 'workflow' })}</div>
|
||||
<div
|
||||
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center"
|
||||
<PopoverClose
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-text-secondary" />
|
||||
</button>
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentLogItem()
|
||||
setShowMessageLogModal(false)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-text-secondary" />
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="overflow-y-auto p-2"
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
import { act } from '@testing-library/react'
|
||||
import { ZEN_TOGGLE_EVENT } from '@/app/components/goto-anything/actions/commands/zen'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { useShortcuts } from '../use-shortcuts'
|
||||
import { emitWorkflowCommand, WorkflowCommand } from '../../shortcuts/commands'
|
||||
import { useWorkflowHotkeys } from '../../shortcuts/use-workflow-hotkeys'
|
||||
|
||||
type KeyPressRegistration = {
|
||||
keyFilter: unknown
|
||||
handler: (event: KeyboardEvent) => void
|
||||
options?: {
|
||||
events?: string[]
|
||||
enabled?: boolean
|
||||
ignoreInputs?: boolean
|
||||
preventDefault?: boolean
|
||||
stopPropagation?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +22,12 @@ type ReactFlowNodeMock = {
|
||||
}
|
||||
}
|
||||
|
||||
type HotkeyDefinitionMock = {
|
||||
hotkey: unknown
|
||||
callback: (event: KeyboardEvent) => void
|
||||
options?: KeyPressRegistration['options'] & { eventType?: 'keydown' | 'keyup' }
|
||||
}
|
||||
|
||||
const keyPressRegistrations = vi.hoisted<KeyPressRegistration[]>(() => [])
|
||||
const mockZoomTo = vi.hoisted(() => vi.fn())
|
||||
const mockGetZoom = vi.hoisted(() => vi.fn(() => 1))
|
||||
@ -35,14 +45,34 @@ const mockUndimAllNodes = vi.hoisted(() => vi.fn())
|
||||
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
|
||||
const mockHandleModeHand = vi.hoisted(() => vi.fn())
|
||||
const mockHandleModePointer = vi.hoisted(() => vi.fn())
|
||||
const mockHandleModeComment = vi.hoisted(() => vi.fn())
|
||||
const mockHandleLayout = vi.hoisted(() => vi.fn())
|
||||
const mockHandleToggleMaximizeCanvas = vi.hoisted(() => vi.fn())
|
||||
const mockUseKeyHold = vi.hoisted(() => vi.fn(() => false))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: (keyFilter: unknown, handler: (event: KeyboardEvent) => void, options?: { events?: string[] }) => {
|
||||
keyPressRegistrations.push({ keyFilter, handler, options })
|
||||
},
|
||||
}))
|
||||
vi.mock('@tanstack/react-hotkeys', () => {
|
||||
const useHotkeys = (
|
||||
definitions: HotkeyDefinitionMock[],
|
||||
commonOptions?: KeyPressRegistration['options'],
|
||||
) => {
|
||||
definitions.forEach((definition) => {
|
||||
keyPressRegistrations.push({
|
||||
keyFilter: definition.hotkey,
|
||||
handler: definition.callback,
|
||||
options: {
|
||||
...commonOptions,
|
||||
...definition.options,
|
||||
events: definition.options?.eventType ? [definition.options.eventType] : undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
useHotkeys,
|
||||
useKeyHold: mockUseKeyHold,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useReactFlow: () => ({
|
||||
@ -53,7 +83,7 @@ vi.mock('reactflow', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('..', () => ({
|
||||
vi.mock('../use-nodes-interactions', () => ({
|
||||
useNodesInteractions: () => ({
|
||||
handleNodesCopy: mockHandleNodesCopy,
|
||||
handleNodesPaste: mockHandleNodesPaste,
|
||||
@ -64,32 +94,44 @@ vi.mock('..', () => ({
|
||||
dimOtherNodes: mockDimOtherNodes,
|
||||
undimAllNodes: mockUndimAllNodes,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-edges-interactions', () => ({
|
||||
useEdgesInteractions: () => ({
|
||||
handleEdgeDelete: mockHandleEdgeDelete,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-workflow-canvas-maximize', () => ({
|
||||
useWorkflowCanvasMaximize: () => ({
|
||||
handleToggleMaximizeCanvas: mockHandleToggleMaximizeCanvas,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-workflow-panel-interactions', () => ({
|
||||
useWorkflowMoveMode: () => ({
|
||||
handleModeHand: mockHandleModeHand,
|
||||
handleModePointer: mockHandleModePointer,
|
||||
handleModeComment: mockHandleModeComment,
|
||||
isCommentModeAvailable: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-workflow-organize', () => ({
|
||||
useWorkflowOrganize: () => ({
|
||||
handleLayout: mockHandleLayout,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../workflow-history-store', () => ({
|
||||
useWorkflowHistoryStore: () => ({
|
||||
shortcutsEnabled: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createKeyboardEvent = (target: HTMLElement = document.body) => ({
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
target,
|
||||
}) as unknown as KeyboardEvent
|
||||
|
||||
@ -107,49 +149,73 @@ const findRegistration = (matcher: (registration: KeyPressRegistration) => boole
|
||||
return registration as KeyPressRegistration
|
||||
}
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null) => {
|
||||
return target instanceof HTMLInputElement
|
||||
|| target instanceof HTMLTextAreaElement
|
||||
|| target instanceof HTMLSelectElement
|
||||
|| (target instanceof HTMLElement && target.isContentEditable)
|
||||
}
|
||||
|
||||
const triggerShortcut = (
|
||||
registration: KeyPressRegistration,
|
||||
event: KeyboardEvent = createKeyboardEvent(),
|
||||
) => {
|
||||
if (registration.options?.enabled === false)
|
||||
return
|
||||
|
||||
if (registration.options?.ignoreInputs !== false && isEditableTarget(event.target))
|
||||
return
|
||||
|
||||
if (registration.options?.preventDefault !== false)
|
||||
event.preventDefault()
|
||||
|
||||
if (registration.options?.stopPropagation !== false)
|
||||
event.stopPropagation()
|
||||
|
||||
registration.handler(event)
|
||||
}
|
||||
|
||||
describe('useShortcuts', () => {
|
||||
beforeEach(() => {
|
||||
keyPressRegistrations.length = 0
|
||||
vi.clearAllMocks()
|
||||
mockUseKeyHold.mockReturnValue(false)
|
||||
mockGetNodes.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('deletes selected nodes and edges only outside editable inputs', () => {
|
||||
renderWorkflowHook(() => useShortcuts())
|
||||
renderWorkflowHook(() => useWorkflowHotkeys())
|
||||
|
||||
const deleteShortcut = findRegistration(registration =>
|
||||
Array.isArray(registration.keyFilter)
|
||||
&& registration.keyFilter.includes('delete'),
|
||||
)
|
||||
const deleteShortcut = findRegistration(registration => registration.keyFilter === 'Delete')
|
||||
|
||||
const bodyEvent = createKeyboardEvent()
|
||||
deleteShortcut.handler(bodyEvent)
|
||||
triggerShortcut(deleteShortcut, bodyEvent)
|
||||
|
||||
expect(bodyEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleEdgeDelete).toHaveBeenCalledTimes(1)
|
||||
|
||||
const inputEvent = createKeyboardEvent(document.createElement('input'))
|
||||
deleteShortcut.handler(inputEvent)
|
||||
triggerShortcut(deleteShortcut, inputEvent)
|
||||
|
||||
expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleEdgeDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('runs layout and zoom shortcuts through the workflow actions', () => {
|
||||
renderWorkflowHook(() => useShortcuts())
|
||||
renderWorkflowHook(() => useWorkflowHotkeys())
|
||||
|
||||
const layoutShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.o' || registration.keyFilter === 'meta.o')
|
||||
const fitViewShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.1' || registration.keyFilter === 'meta.1')
|
||||
const halfZoomShortcut = findRegistration(registration => registration.keyFilter === 'shift.5')
|
||||
const zoomOutShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.dash' || registration.keyFilter === 'meta.dash')
|
||||
const zoomInShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.equalsign' || registration.keyFilter === 'meta.equalsign')
|
||||
const layoutShortcut = findRegistration(registration => registration.keyFilter === 'Mod+O')
|
||||
const fitViewShortcut = findRegistration(registration => registration.keyFilter === 'Mod+1')
|
||||
const halfZoomShortcut = findRegistration(registration => registration.keyFilter === 'Shift+5')
|
||||
const zoomOutShortcut = findRegistration(registration => registration.keyFilter === 'Mod+-')
|
||||
const zoomInShortcut = findRegistration(registration => registration.keyFilter === 'Mod+=')
|
||||
|
||||
layoutShortcut.handler(createKeyboardEvent())
|
||||
fitViewShortcut.handler(createKeyboardEvent())
|
||||
halfZoomShortcut.handler(createKeyboardEvent())
|
||||
zoomOutShortcut.handler(createKeyboardEvent())
|
||||
zoomInShortcut.handler(createKeyboardEvent())
|
||||
triggerShortcut(layoutShortcut)
|
||||
triggerShortcut(fitViewShortcut)
|
||||
triggerShortcut(halfZoomShortcut)
|
||||
triggerShortcut(zoomOutShortcut)
|
||||
triggerShortcut(zoomInShortcut)
|
||||
|
||||
expect(mockHandleLayout).toHaveBeenCalledTimes(1)
|
||||
expect(mockFitView).toHaveBeenCalledTimes(1)
|
||||
@ -176,11 +242,11 @@ describe('useShortcuts', () => {
|
||||
},
|
||||
])
|
||||
|
||||
renderWorkflowHook(() => useShortcuts())
|
||||
renderWorkflowHook(() => useWorkflowHotkeys())
|
||||
|
||||
const copyShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.c' || registration.keyFilter === 'meta.c')
|
||||
const copyShortcut = findRegistration(registration => registration.keyFilter === 'Mod+C')
|
||||
const event = createKeyboardEvent()
|
||||
copyShortcut.handler(event)
|
||||
triggerShortcut(copyShortcut, event)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(mockHandleNodesCopy).toHaveBeenCalledTimes(1)
|
||||
@ -188,28 +254,44 @@ describe('useShortcuts', () => {
|
||||
getSelectionSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('dims on shift down, undims on shift up, and responds to zen toggle events', () => {
|
||||
const { unmount } = renderWorkflowHook(() => useShortcuts())
|
||||
it('dims while shift is held, undims when released, and responds to zen toggle events', () => {
|
||||
const { rerender, unmount } = renderWorkflowHook(() => useWorkflowHotkeys())
|
||||
|
||||
const shiftDownShortcut = findRegistration(registration => registration.keyFilter === 'shift' && registration.options?.events?.[0] === 'keydown')
|
||||
const shiftUpShortcut = findRegistration(registration => typeof registration.keyFilter === 'function' && registration.options?.events?.[0] === 'keyup')
|
||||
mockUseKeyHold.mockReturnValue(true)
|
||||
rerender()
|
||||
|
||||
shiftDownShortcut.handler(createKeyboardEvent())
|
||||
shiftUpShortcut.handler({ ...createKeyboardEvent(), key: 'Shift' } as KeyboardEvent)
|
||||
mockUseKeyHold.mockReturnValue(false)
|
||||
rerender()
|
||||
|
||||
expect(mockDimOtherNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockUndimAllNodes).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(ZEN_TOGGLE_EVENT))
|
||||
emitWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize)
|
||||
})
|
||||
expect(mockHandleToggleMaximizeCanvas).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(ZEN_TOGGLE_EVENT))
|
||||
emitWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize)
|
||||
})
|
||||
expect(mockHandleToggleMaximizeCanvas).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not dim when shift is held inside editable inputs', () => {
|
||||
const input = document.createElement('input')
|
||||
document.body.appendChild(input)
|
||||
input.focus()
|
||||
|
||||
const { rerender } = renderWorkflowHook(() => useWorkflowHotkeys())
|
||||
|
||||
mockUseKeyHold.mockReturnValue(true)
|
||||
rerender()
|
||||
|
||||
expect(mockDimOtherNodes).not.toHaveBeenCalled()
|
||||
expect(mockUndimAllNodes).not.toHaveBeenCalled()
|
||||
|
||||
input.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@ -125,8 +125,14 @@ describe('useWorkflowHistory', () => {
|
||||
result.current.onRedo(onRedo)
|
||||
})
|
||||
|
||||
const undoSpy = vi.spyOn(result.current.store.temporal.getState(), 'undo')
|
||||
const redoSpy = vi.spyOn(result.current.store.temporal.getState(), 'redo')
|
||||
const temporalState = result.current.store.temporal.getState()
|
||||
const undoSpy = vi.fn()
|
||||
const redoSpy = vi.fn()
|
||||
vi.spyOn(result.current.store.temporal, 'getState').mockReturnValue({
|
||||
...temporalState,
|
||||
undo: undoSpy,
|
||||
redo: redoSpy,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.undo()
|
||||
|
||||
@ -13,7 +13,6 @@ export * from './use-panel-interactions'
|
||||
export * from './use-selection-interactions'
|
||||
export * from './use-serial-async-callback'
|
||||
export * from './use-set-workflow-vars-with-value'
|
||||
export * from './use-shortcuts'
|
||||
export * from './use-tool-icon'
|
||||
export * from './use-workflow'
|
||||
export * from './use-workflow-comment'
|
||||
|
||||
@ -1,293 +0,0 @@
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { ZEN_TOGGLE_EVENT } from '@/app/components/goto-anything/actions/commands/zen'
|
||||
import {
|
||||
useEdgesInteractions,
|
||||
useNodesInteractions,
|
||||
useNodesSyncDraft,
|
||||
useWorkflowCanvasMaximize,
|
||||
useWorkflowMoveMode,
|
||||
useWorkflowOrganize,
|
||||
} from '.'
|
||||
import { collaborationManager } from '../collaboration/core/collaboration-manager'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import {
|
||||
getKeyboardKeyCodeBySystem,
|
||||
isEventTargetInputArea,
|
||||
} from '../utils'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
|
||||
export const useShortcuts = (): void => {
|
||||
const {
|
||||
handleNodesCopy,
|
||||
handleNodesPaste,
|
||||
handleNodesDuplicate,
|
||||
handleNodesDelete,
|
||||
handleHistoryBack,
|
||||
handleHistoryForward,
|
||||
dimOtherNodes,
|
||||
undimAllNodes,
|
||||
} = useNodesInteractions()
|
||||
const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { handleEdgeDelete } = useEdgesInteractions()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const {
|
||||
handleModeHand,
|
||||
handleModePointer,
|
||||
handleModeComment,
|
||||
isCommentModeAvailable,
|
||||
} = useWorkflowMoveMode()
|
||||
const { handleLayout } = useWorkflowOrganize()
|
||||
const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize()
|
||||
|
||||
const {
|
||||
zoomTo,
|
||||
getZoom,
|
||||
fitView,
|
||||
getNodes,
|
||||
} = useReactFlow()
|
||||
|
||||
// Zoom out to a minimum of 0.25 for shortcut
|
||||
const constrainedZoomOut = () => {
|
||||
const currentZoom = getZoom()
|
||||
const newZoom = Math.max(currentZoom - 0.1, 0.25)
|
||||
zoomTo(newZoom)
|
||||
}
|
||||
|
||||
// Zoom in to a maximum of 2 for shortcut
|
||||
const constrainedZoomIn = () => {
|
||||
const currentZoom = getZoom()
|
||||
const newZoom = Math.min(currentZoom + 0.1, 2)
|
||||
zoomTo(newZoom)
|
||||
}
|
||||
|
||||
const shouldHandleShortcut = useCallback((e: KeyboardEvent) => {
|
||||
return !isEventTargetInputArea(e.target as HTMLElement)
|
||||
}, [])
|
||||
|
||||
const shouldHandleCopy = useCallback(() => {
|
||||
// Box selection can leave incidental DOM text selection behind while the
|
||||
// workflow selection itself lives on node.data._isBundled.
|
||||
if (getNodes().some(node => node.data._isBundled))
|
||||
return true
|
||||
|
||||
const selection = document.getSelection()
|
||||
return !selection || selection.isCollapsed || !selection.rangeCount
|
||||
}, [getNodes])
|
||||
|
||||
useKeyPress(['delete', 'backspace'], (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleNodesDelete()
|
||||
handleEdgeDelete()
|
||||
}
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, (e) => {
|
||||
const { showDebugAndPreviewPanel } = workflowStore.getState()
|
||||
if (shouldHandleShortcut(e) && shouldHandleCopy() && !showDebugAndPreviewPanel) {
|
||||
e.preventDefault()
|
||||
handleNodesCopy()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.v`, (e) => {
|
||||
const { showDebugAndPreviewPanel } = workflowStore.getState()
|
||||
if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) {
|
||||
e.preventDefault()
|
||||
handleNodesPaste()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.d`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleNodesDuplicate()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
// @ts-expect-error - Dynamic property added by run-and-history component
|
||||
if (window._toggleTestRunDropdown) {
|
||||
// @ts-expect-error - Dynamic property added by run-and-history component
|
||||
window._toggleTestRunDropdown()
|
||||
}
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.z`, (e) => {
|
||||
const { showDebugAndPreviewPanel } = workflowStore.getState()
|
||||
if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) {
|
||||
e.preventDefault()
|
||||
if (workflowHistoryShortcutsEnabled)
|
||||
handleHistoryBack()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(
|
||||
[`${getKeyboardKeyCodeBySystem('ctrl')}.y`, `${getKeyboardKeyCodeBySystem('ctrl')}.shift.z`],
|
||||
(e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
if (workflowHistoryShortcutsEnabled)
|
||||
handleHistoryForward()
|
||||
}
|
||||
},
|
||||
{ exactMatch: true, useCapture: true },
|
||||
)
|
||||
|
||||
useKeyPress('h', (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleModeHand()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress('v', (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleModePointer()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress('c', (e) => {
|
||||
if (shouldHandleShortcut(e) && isCommentModeAvailable) {
|
||||
e.preventDefault()
|
||||
handleModeComment()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.o`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleLayout()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress('f', (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleToggleMaximizeCanvas()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.1`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
fitView()
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress('shift.1', (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
zoomTo(1)
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress('shift.5', (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
zoomTo(0.5)
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.dash`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
constrainedZoomOut()
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.equalsign`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
constrainedZoomIn()
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.l`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
collaborationManager.downloadGraphImportLog()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
// Shift ↓
|
||||
useKeyPress(
|
||||
'shift',
|
||||
(e) => {
|
||||
if (shouldHandleShortcut(e))
|
||||
dimOtherNodes()
|
||||
},
|
||||
{
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
events: ['keydown'],
|
||||
},
|
||||
)
|
||||
|
||||
// Shift ↑
|
||||
useKeyPress(
|
||||
(e) => {
|
||||
return e.key === 'Shift'
|
||||
},
|
||||
(e) => {
|
||||
if (shouldHandleShortcut(e))
|
||||
undimAllNodes()
|
||||
},
|
||||
{
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
events: ['keyup'],
|
||||
},
|
||||
)
|
||||
|
||||
// Listen for zen toggle event from /zen command
|
||||
useEffect(() => {
|
||||
const handleZenToggle = () => {
|
||||
handleToggleMaximizeCanvas()
|
||||
}
|
||||
|
||||
window.addEventListener(ZEN_TOGGLE_EVENT, handleZenToggle)
|
||||
return () => {
|
||||
window.removeEventListener(ZEN_TOGGLE_EVENT, handleZenToggle)
|
||||
}
|
||||
}, [handleToggleMaximizeCanvas])
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import type { WorkflowHistoryEventMeta } from '../workflow-history-store'
|
||||
import type { WorkflowHistoryEventMeta } from '../store/workflow/history-slice'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import {
|
||||
useCallback,
|
||||
|
||||
@ -88,7 +88,6 @@ import {
|
||||
usePanelInteractions,
|
||||
useSelectionInteractions,
|
||||
useSetWorkflowVarsWithValue,
|
||||
useShortcuts,
|
||||
useWorkflow,
|
||||
useWorkflowReadOnly,
|
||||
useWorkflowRefreshDraft,
|
||||
@ -111,19 +110,19 @@ import Operator from './operator'
|
||||
import Control from './operator/control'
|
||||
import PanelContextmenu from './panel-contextmenu'
|
||||
import SelectionContextmenu from './selection-contextmenu'
|
||||
import { useWorkflowHotkeys } from './shortcuts/use-workflow-hotkeys'
|
||||
import CustomSimpleNode from './simple-node'
|
||||
import { CUSTOM_SIMPLE_NODE } from './simple-node/constants'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
} from './store'
|
||||
} from './store/workflow'
|
||||
import SyncingDataModal from './syncing-data-modal'
|
||||
import {
|
||||
ControlMode,
|
||||
WorkflowRunningStatus,
|
||||
} from './types'
|
||||
import { setupScrollToNodeListener } from './utils/node-navigation'
|
||||
import { WorkflowHistoryProvider } from './workflow-history-store'
|
||||
import 'reactflow/dist/style.css'
|
||||
import './style.css'
|
||||
|
||||
@ -530,7 +529,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
},
|
||||
})
|
||||
|
||||
useShortcuts()
|
||||
useWorkflowHotkeys()
|
||||
// Initialize workflow node search functionality
|
||||
useWorkflowSearch()
|
||||
|
||||
@ -794,6 +793,30 @@ type WorkflowWithDefaultContextProps
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const WorkflowHistoryStoreInitializer = ({
|
||||
nodes,
|
||||
edges,
|
||||
children,
|
||||
}: WorkflowWithDefaultContextProps) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const initializedRef = useRef(false)
|
||||
|
||||
if (!initializedRef.current) {
|
||||
workflowStore.temporal.getState().pause()
|
||||
workflowStore.getState().setWorkflowHistory({
|
||||
nodes,
|
||||
edges,
|
||||
workflowHistoryEvent: undefined,
|
||||
workflowHistoryEventMeta: undefined,
|
||||
})
|
||||
workflowStore.temporal.getState().clear()
|
||||
workflowStore.temporal.getState().resume()
|
||||
initializedRef.current = true
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
const WorkflowWithDefaultContext = ({
|
||||
nodes,
|
||||
edges,
|
||||
@ -801,14 +824,14 @@ const WorkflowWithDefaultContext = ({
|
||||
}: WorkflowWithDefaultContextProps) => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<WorkflowHistoryProvider
|
||||
<WorkflowHistoryStoreInitializer
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
<DatasetsDetailProvider nodes={nodes}>
|
||||
{children}
|
||||
</DatasetsDetailProvider>
|
||||
</WorkflowHistoryProvider>
|
||||
</WorkflowHistoryStoreInitializer>
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -35,10 +35,15 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
|
||||
<div>{children}</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children, render }: { children: ReactNode, render?: ReactNode }) => {
|
||||
DropdownMenuTrigger: ({ children, render }: { children: ReactNode, render?: React.ReactElement<{ children?: ReactNode }> }) => {
|
||||
const { open, setOpen } = useDropdownMenuContext()
|
||||
if (render)
|
||||
return <div onClick={() => setOpen(!open)}>{children}</div>
|
||||
if (render) {
|
||||
return React.cloneElement(
|
||||
render,
|
||||
{ onClick: () => setOpen(!open) } as Record<string, unknown>,
|
||||
children ?? render.props.children,
|
||||
)
|
||||
}
|
||||
|
||||
return <button type="button" onClick={() => setOpen(!open)}>{children}</button>
|
||||
},
|
||||
@ -50,8 +55,8 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({ children, className }: { children: ReactNode, className?: string }) => (
|
||||
<button type="button" className={className}>
|
||||
Button: ({ children, className, onClick }: { children: ReactNode, className?: string, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => (
|
||||
<button type="button" className={className} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
|
||||
@ -89,11 +89,13 @@ const Operator = ({
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<Button className="h-6 w-6 p-0">
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<Button className="h-6 w-6 p-0" aria-label={t('common.moreActions', { ns: 'workflow' })}>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import {
|
||||
canRunBySingle,
|
||||
@ -67,9 +67,10 @@ const PanelOperatorPopup = ({
|
||||
<div className="p-1">
|
||||
{
|
||||
canRunBySingle(data.type, isChildNode) && (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
flex h-8 cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary
|
||||
flex h-8 w-full cursor-pointer items-center rounded-lg px-3 text-sm text-text-secondary
|
||||
hover:bg-state-base-hover
|
||||
`}
|
||||
onClick={() => {
|
||||
@ -80,7 +81,7 @@ const PanelOperatorPopup = ({
|
||||
}}
|
||||
>
|
||||
{t('panel.runThisStep', { ns: 'workflow' })}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
{
|
||||
@ -104,26 +105,28 @@ const PanelOperatorPopup = ({
|
||||
!nodeMetaData.isSingleton && (
|
||||
<>
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onClosePopup()
|
||||
handleNodesCopy(id)
|
||||
}}
|
||||
>
|
||||
{t('common.copy', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'c']} />
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
<ShortcutKbd shortcut="workflow.copy" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onClosePopup()
|
||||
handleNodesDuplicate(id)
|
||||
}}
|
||||
>
|
||||
{t('common.duplicate', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'd']} />
|
||||
</div>
|
||||
<ShortcutKbd shortcut="workflow.duplicate" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular"></div>
|
||||
</>
|
||||
@ -133,16 +136,17 @@ const PanelOperatorPopup = ({
|
||||
!nodeMetaData.isUndeletable && (
|
||||
<>
|
||||
<div className="p-1">
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary
|
||||
flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary
|
||||
hover:bg-state-destructive-hover hover:text-text-destructive
|
||||
`}
|
||||
onClick={() => handleNodeDelete(id)}
|
||||
>
|
||||
{t('operation.delete', { ns: 'common' })}
|
||||
<ShortcutsName keys={['del']} />
|
||||
</div>
|
||||
<ShortcutKbd shortcut="workflow.delete" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular"></div>
|
||||
</>
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
|
||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||
import { useWorkflowShortcut } from '@/app/components/workflow/shortcuts/use-workflow-hotkeys'
|
||||
|
||||
type AdvancedActionsProps = {
|
||||
isConfirmDisabled: boolean
|
||||
@ -19,12 +18,11 @@ const AdvancedActions: FC<AdvancedActionsProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useKeyPress([`${getKeyboardKeyCodeBySystem('ctrl')}.enter`], (e) => {
|
||||
e.preventDefault()
|
||||
useWorkflowShortcut('workflow.json-schema-confirm', () => {
|
||||
onConfirm()
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
enabled: !isConfirmDisabled,
|
||||
ignoreInputs: false,
|
||||
})
|
||||
|
||||
return (
|
||||
@ -40,7 +38,7 @@ const AdvancedActions: FC<AdvancedActionsProps> = ({
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<span>{t('operation.confirm', { ns: 'common' })}</span>
|
||||
<ShortcutsName keys={['ctrl', '⏎']} bgColor="white" />
|
||||
<ShortcutKbd shortcut="workflow.json-schema-confirm" bgColor="white" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -14,7 +14,6 @@ const {
|
||||
mockHandleNodesDuplicate,
|
||||
mockHandleShowAuthorChange,
|
||||
mockHandleThemeChange,
|
||||
mockSetShortcutsEnabled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandleEditorChange: vi.fn(),
|
||||
mockHandleNodeDataUpdateWithSyncDraft: vi.fn(),
|
||||
@ -23,7 +22,6 @@ const {
|
||||
mockHandleNodesDuplicate: vi.fn(),
|
||||
mockHandleShowAuthorChange: vi.fn(),
|
||||
mockHandleThemeChange: vi.fn(),
|
||||
mockSetShortcutsEnabled: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', async (importOriginal) => {
|
||||
@ -49,12 +47,6 @@ vi.mock('../hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../workflow-history-store', () => ({
|
||||
useWorkflowHistoryStore: () => ({
|
||||
setShortcutsEnabled: mockSetShortcutsEnabled,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createNoteData = (overrides: Partial<NoteNodeType> = {}): NoteNodeType => ({
|
||||
title: '',
|
||||
desc: '',
|
||||
|
||||
@ -12,8 +12,7 @@ import {
|
||||
useNodesInteractions,
|
||||
} from '../hooks'
|
||||
import NodeResizer from '../nodes/_base/components/node-resizer'
|
||||
import { useStore } from '../store'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
import { useStore } from '../store/workflow'
|
||||
import { THEME_MAP } from './constants'
|
||||
import { useNote } from './hooks'
|
||||
import {
|
||||
@ -36,6 +35,7 @@ const NoteNode = ({
|
||||
}: NodeProps<NoteNodeType>) => {
|
||||
const { t } = useTranslation()
|
||||
const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey)
|
||||
const setHistoryShortcutsEnabled = useStore(s => s.setHistoryShortcutsEnabled)
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
const theme = data.theme
|
||||
const {
|
||||
@ -54,8 +54,6 @@ const NoteNode = ({
|
||||
handleNodeDataUpdateWithSyncDraft({ id, data: { selected: false } })
|
||||
}, ref)
|
||||
|
||||
const { setShortcutsEnabled } = useWorkflowHistoryStore()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -117,7 +115,7 @@ const NoteNode = ({
|
||||
containerElement={ref.current}
|
||||
placeholder={t('nodes.note.editor.placeholder', { ns: 'workflow' }) || ''}
|
||||
onChange={handleEditorChange}
|
||||
setShortcutsEnabled={setShortcutsEnabled}
|
||||
setHistoryShortcutsEnabled={setHistoryShortcutsEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -93,17 +93,17 @@ describe('Editor', () => {
|
||||
// Focus and blur should toggle workflow shortcuts while editing content.
|
||||
describe('Focus Management', () => {
|
||||
it('should disable shortcuts on focus and re-enable them on blur-sm', () => {
|
||||
const setShortcutsEnabled = vi.fn()
|
||||
const setHistoryShortcutsEnabled = vi.fn()
|
||||
|
||||
renderEditor({ setShortcutsEnabled })
|
||||
renderEditor({ setHistoryShortcutsEnabled })
|
||||
|
||||
const contentEditable = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.focus(contentEditable)
|
||||
fireEvent.blur(contentEditable)
|
||||
|
||||
expect(setShortcutsEnabled).toHaveBeenNthCalledWith(1, false)
|
||||
expect(setShortcutsEnabled).toHaveBeenNthCalledWith(2, true)
|
||||
expect(setHistoryShortcutsEnabled).toHaveBeenNthCalledWith(1, false)
|
||||
expect(setHistoryShortcutsEnabled).toHaveBeenNthCalledWith(2, true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -22,13 +22,13 @@ type EditorProps = {
|
||||
placeholder?: string
|
||||
onChange?: (editorState: EditorState) => void
|
||||
containerElement: HTMLDivElement | null
|
||||
setShortcutsEnabled?: (v: boolean) => void
|
||||
setHistoryShortcutsEnabled?: (v: boolean) => void
|
||||
}
|
||||
const Editor = ({
|
||||
placeholder = 'write you note...',
|
||||
onChange,
|
||||
containerElement,
|
||||
setShortcutsEnabled,
|
||||
setHistoryShortcutsEnabled,
|
||||
}: EditorProps) => {
|
||||
const handleEditorChange = useCallback((editorState: EditorState) => {
|
||||
onChange?.(editorState)
|
||||
@ -40,8 +40,8 @@ const Editor = ({
|
||||
contentEditable={(
|
||||
<div>
|
||||
<ContentEditable
|
||||
onFocus={() => setShortcutsEnabled?.(false)}
|
||||
onBlur={() => setShortcutsEnabled?.(true)}
|
||||
onFocus={() => setHistoryShortcutsEnabled?.(false)}
|
||||
onBlur={() => setHistoryShortcutsEnabled?.(true)}
|
||||
spellCheck={false}
|
||||
className="h-full w-full text-text-secondary caret-primary-600 outline-hidden"
|
||||
/>
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||
|
||||
export type OperatorProps = {
|
||||
onCopy: () => void
|
||||
@ -69,7 +69,7 @@ const Operator = ({
|
||||
}}
|
||||
>
|
||||
{t('common.copy', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'c']} />
|
||||
<ShortcutKbd shortcut="workflow.copy" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="justify-between rounded-md px-3 text-sm text-text-secondary"
|
||||
@ -79,7 +79,7 @@ const Operator = ({
|
||||
}}
|
||||
>
|
||||
{t('common.duplicate', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'd']} />
|
||||
<ShortcutKbd shortcut="workflow.duplicate" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
@ -107,7 +107,7 @@ const Operator = ({
|
||||
}}
|
||||
>
|
||||
{t('operation.delete', { ns: 'common' })}
|
||||
<ShortcutsName keys={['del']} />
|
||||
<ShortcutKbd shortcut="workflow.delete" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -47,7 +47,7 @@ const Control = () => {
|
||||
} = useNodesReadOnly()
|
||||
const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize()
|
||||
|
||||
const addNote = (e: MouseEvent<HTMLDivElement>) => {
|
||||
const addNote = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
@ -59,19 +59,25 @@ const Control = () => {
|
||||
<div className="pointer-events-auto flex flex-col items-center rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 text-text-tertiary shadow-lg">
|
||||
<AddBlock />
|
||||
<TipPopup title={t('nodes.note.addNote', { ns: 'workflow' })}>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('nodes.note.addNote', { ns: 'workflow' })}
|
||||
disabled={nodesReadOnly}
|
||||
className={cn(
|
||||
'ml-px flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
|
||||
`${nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`,
|
||||
)}
|
||||
onClick={addNote}
|
||||
>
|
||||
<RiStickyNoteAddLine className="h-4 w-4" />
|
||||
</div>
|
||||
<RiStickyNoteAddLine aria-hidden className="h-4 w-4" />
|
||||
</button>
|
||||
</TipPopup>
|
||||
<Divider className="my-1 w-3.5" />
|
||||
<TipPopup title={t('common.pointerMode', { ns: 'workflow' })} shortcuts={['v']}>
|
||||
<div
|
||||
<TipPopup title={t('common.pointerMode', { ns: 'workflow' })} shortcut="workflow.pointer-mode">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.pointerMode', { ns: 'workflow' })}
|
||||
disabled={nodesReadOnly}
|
||||
className={cn(
|
||||
'mr-px flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg',
|
||||
controlMode === ControlMode.Pointer ? 'bg-state-accent-active text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary',
|
||||
@ -79,11 +85,14 @@ const Control = () => {
|
||||
)}
|
||||
onClick={handleModePointer}
|
||||
>
|
||||
<RiCursorLine className="h-4 w-4" />
|
||||
</div>
|
||||
<RiCursorLine aria-hidden className="h-4 w-4" />
|
||||
</button>
|
||||
</TipPopup>
|
||||
<TipPopup title={t('common.handMode', { ns: 'workflow' })} shortcuts={['h']}>
|
||||
<div
|
||||
<TipPopup title={t('common.handMode', { ns: 'workflow' })} shortcut="workflow.hand-mode">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.handMode', { ns: 'workflow' })}
|
||||
disabled={nodesReadOnly}
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg',
|
||||
controlMode === ControlMode.Hand ? 'bg-state-accent-active text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary',
|
||||
@ -91,12 +100,15 @@ const Control = () => {
|
||||
)}
|
||||
onClick={handleModeHand}
|
||||
>
|
||||
<RiHand className="h-4 w-4" />
|
||||
</div>
|
||||
<RiHand aria-hidden className="h-4 w-4" />
|
||||
</button>
|
||||
</TipPopup>
|
||||
{isCommentModeAvailable && (
|
||||
<TipPopup title={t('common.commentMode', { ns: 'workflow' })} shortcuts={['c']}>
|
||||
<div
|
||||
<TipPopup title={t('common.commentMode', { ns: 'workflow' })} shortcut="workflow.comment-mode">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.commentMode', { ns: 'workflow' })}
|
||||
disabled={nodesReadOnly}
|
||||
className={cn(
|
||||
'ml-[1px] flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg',
|
||||
controlMode === ControlMode.Comment ? 'bg-state-accent-active text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary',
|
||||
@ -104,24 +116,30 @@ const Control = () => {
|
||||
)}
|
||||
onClick={handleModeComment}
|
||||
>
|
||||
<Comment className="h-4 w-4" />
|
||||
</div>
|
||||
<Comment aria-hidden className="h-4 w-4" />
|
||||
</button>
|
||||
</TipPopup>
|
||||
)}
|
||||
<Divider className="my-1 w-3.5" />
|
||||
<TipPopup title={t('panel.organizeBlocks', { ns: 'workflow' })} shortcuts={['ctrl', 'o']}>
|
||||
<div
|
||||
<TipPopup title={t('panel.organizeBlocks', { ns: 'workflow' })} shortcut="workflow.organize">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('panel.organizeBlocks', { ns: 'workflow' })}
|
||||
disabled={nodesReadOnly}
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
|
||||
`${nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`,
|
||||
)}
|
||||
onClick={handleLayout}
|
||||
>
|
||||
<RiFunctionAddLine className="h-4 w-4" />
|
||||
</div>
|
||||
<RiFunctionAddLine aria-hidden className="h-4 w-4" />
|
||||
</button>
|
||||
</TipPopup>
|
||||
<TipPopup title={maximizeCanvas ? t('panel.minimize', { ns: 'workflow' }) : t('panel.maximize', { ns: 'workflow' })} shortcuts={['f']}>
|
||||
<div
|
||||
<TipPopup title={maximizeCanvas ? t('panel.minimize', { ns: 'workflow' }) : t('panel.maximize', { ns: 'workflow' })} shortcut="workflow.toggle-maximize">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={maximizeCanvas ? t('panel.minimize', { ns: 'workflow' }) : t('panel.maximize', { ns: 'workflow' })}
|
||||
disabled={nodesReadOnly}
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
|
||||
maximizeCanvas ? 'bg-state-accent-active text-text-accent hover:text-text-accent' : 'hover:bg-state-base-hover hover:text-text-secondary',
|
||||
@ -129,9 +147,9 @@ const Control = () => {
|
||||
)}
|
||||
onClick={handleToggleMaximizeCanvas}
|
||||
>
|
||||
{maximizeCanvas && <RiAspectRatioFill className="h-4 w-4" />}
|
||||
{!maximizeCanvas && <RiAspectRatioLine className="h-4 w-4" />}
|
||||
</div>
|
||||
{maximizeCanvas && <RiAspectRatioFill aria-hidden className="h-4 w-4" />}
|
||||
{!maximizeCanvas && <RiAspectRatioLine aria-hidden className="h-4 w-4" />}
|
||||
</button>
|
||||
</TipPopup>
|
||||
<MoreActions />
|
||||
</div>
|
||||
|
||||
@ -1,32 +1,37 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { WorkflowShortcutId } from '../shortcuts/definitions'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@langgenius/dify-ui/tooltip'
|
||||
import { memo } from 'react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
|
||||
|
||||
type TipPopupProps = {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
shortcuts?: string[]
|
||||
children: ReactElement
|
||||
shortcut?: WorkflowShortcutId
|
||||
}
|
||||
const TipPopup = ({
|
||||
title,
|
||||
children,
|
||||
shortcuts,
|
||||
shortcut,
|
||||
}: TipPopupProps) => {
|
||||
return (
|
||||
<Tooltip
|
||||
needsDelay={false}
|
||||
offset={4}
|
||||
popupClassName="p-0 bg-transparent"
|
||||
popupContent={(
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={children} />
|
||||
<TooltipContent
|
||||
sideOffset={4}
|
||||
className="max-w-none bg-transparent p-0 shadow-none"
|
||||
>
|
||||
<div className="flex items-center gap-1 rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 shadow-lg backdrop-blur-[5px]">
|
||||
<span className="system-xs-medium text-text-secondary">{title}</span>
|
||||
{
|
||||
shortcuts && <ShortcutsName keys={shortcuts} />
|
||||
shortcut && <ShortcutKbd shortcut={shortcut} />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ import {
|
||||
useNodesSyncDraft,
|
||||
useWorkflowReadOnly,
|
||||
} from '../hooks'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
|
||||
import TipPopup from './tip-popup'
|
||||
|
||||
enum ZoomType {
|
||||
@ -181,9 +181,12 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
|
||||
<div className="flex h-8 w-[98px] items-center justify-between rounded-lg">
|
||||
<TipPopup
|
||||
title={t('operator.zoomOut', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '-']}
|
||||
shortcut="workflow.zoom-out"
|
||||
>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operator.zoomOut', { ns: 'workflow' })}
|
||||
disabled={zoom <= 0.25}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom <= 0.25 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom <= 0.25)
|
||||
@ -194,7 +197,7 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-zoom-out-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</button>
|
||||
</TipPopup>
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
@ -262,13 +265,13 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
|
||||
</div>
|
||||
<div className="flex items-center space-x-0.5">
|
||||
{option.key === ZoomType.zoomToFit && (
|
||||
<ShortcutsName keys={['ctrl', '1']} />
|
||||
<ShortcutKbd shortcut="workflow.zoom-to-fit" />
|
||||
)}
|
||||
{option.key === ZoomType.zoomTo50 && (
|
||||
<ShortcutsName keys={['shift', '5']} />
|
||||
<ShortcutKbd shortcut="workflow.zoom-to-50" />
|
||||
)}
|
||||
{option.key === ZoomType.zoomTo100 && (
|
||||
<ShortcutsName keys={['shift', '1']} />
|
||||
<ShortcutKbd shortcut="workflow.zoom-to-100" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
@ -281,9 +284,12 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
|
||||
</DropdownMenu>
|
||||
<TipPopup
|
||||
title={t('operator.zoomIn', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '+']}
|
||||
shortcut="workflow.zoom-in"
|
||||
>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operator.zoomIn', { ns: 'workflow' })}
|
||||
disabled={zoom >= 2}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom >= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom >= 2)
|
||||
@ -294,7 +300,7 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-zoom-in-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</button>
|
||||
</TipPopup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
} from './hooks'
|
||||
import AddBlock from './operator/add-block'
|
||||
import { useOperator } from './operator/hooks'
|
||||
import ShortcutsName from './shortcuts-name'
|
||||
import { ShortcutKbd } from './shortcuts/shortcut-kbd'
|
||||
import { useStore } from './store'
|
||||
|
||||
const PanelContextmenu = () => {
|
||||
@ -40,11 +40,12 @@ const PanelContextmenu = () => {
|
||||
|
||||
const renderTrigger = () => {
|
||||
return (
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
{t('common.addBlock', { ns: 'workflow' })}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -68,8 +69,9 @@ const PanelContextmenu = () => {
|
||||
crossAxis: -4,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddNote()
|
||||
@ -77,11 +79,13 @@ const PanelContextmenu = () => {
|
||||
}}
|
||||
>
|
||||
{t('nodes.note.addNote', { ns: 'workflow' })}
|
||||
</div>
|
||||
</button>
|
||||
{isCommentModeAvailable && (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!pendingComment}
|
||||
className={cn(
|
||||
'flex h-8 items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
|
||||
'flex h-8 w-full items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
|
||||
pendingComment ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
@ -94,24 +98,27 @@ const PanelContextmenu = () => {
|
||||
}}
|
||||
>
|
||||
{t('comments.actions.addComment', { ns: 'workflow' })}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
handleStartWorkflowRun()
|
||||
handlePaneContextmenuCancel()
|
||||
}}
|
||||
>
|
||||
{t('common.run', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['alt', 'r']} />
|
||||
</div>
|
||||
<ShortcutKbd shortcut="workflow.open-test-run-menu" />
|
||||
</button>
|
||||
</div>
|
||||
<Divider className="m-0" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
disabled={!clipboardElements.length}
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
|
||||
'flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
|
||||
!clipboardElements.length ? 'cursor-not-allowed opacity-50' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
@ -122,23 +129,25 @@ const PanelContextmenu = () => {
|
||||
}}
|
||||
>
|
||||
{t('common.pasteHere', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'v']} />
|
||||
</div>
|
||||
<ShortcutKbd shortcut="workflow.paste" />
|
||||
</button>
|
||||
</div>
|
||||
<Divider className="m-0" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => exportCheck?.()}
|
||||
>
|
||||
{t('export', { ns: 'app' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => setShowImportDSLModal(true)}
|
||||
>
|
||||
{t('importApp', { ns: 'app' })}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -20,7 +20,7 @@ import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-co
|
||||
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
|
||||
import { useSelectionInteractions } from './hooks/use-selection-interactions'
|
||||
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
|
||||
import ShortcutsName from './shortcuts-name'
|
||||
import { ShortcutKbd } from './shortcuts/shortcut-kbd'
|
||||
import { useStore, useWorkflowStore } from './store'
|
||||
|
||||
const AlignType = {
|
||||
@ -387,7 +387,7 @@ const SelectionContextmenu = () => {
|
||||
onClick={handleCopyNodes}
|
||||
>
|
||||
<span>{t('common.copy', { defaultValue: 'common.copy', ns: 'workflow' })}</span>
|
||||
<ShortcutsName keys={['ctrl', 'c']} />
|
||||
<ShortcutKbd shortcut="workflow.copy" />
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="justify-between px-3 text-text-secondary"
|
||||
@ -395,7 +395,7 @@ const SelectionContextmenu = () => {
|
||||
onClick={handleDuplicateNodes}
|
||||
>
|
||||
<span>{t('common.duplicate', { defaultValue: 'common.duplicate', ns: 'workflow' })}</span>
|
||||
<ShortcutsName keys={['ctrl', 'd']} />
|
||||
<ShortcutKbd shortcut="workflow.duplicate" />
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
<ContextMenuSeparator />
|
||||
@ -406,7 +406,7 @@ const SelectionContextmenu = () => {
|
||||
onClick={handleDeleteNodes}
|
||||
>
|
||||
<span>{t('operation.delete', { defaultValue: 'operation.delete', ns: 'common' })}</span>
|
||||
<ShortcutsName keys={['del']} />
|
||||
<ShortcutKbd shortcut="workflow.delete" />
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
<ContextMenuSeparator />
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
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', () => {
|
||||
const { container } = render(
|
||||
<ShortcutKbd
|
||||
shortcut="workflow.copy"
|
||||
platform="mac"
|
||||
bgColor="white"
|
||||
textColor="secondary"
|
||||
className="ml-2"
|
||||
/>,
|
||||
)
|
||||
|
||||
const wrapper = container.firstElementChild
|
||||
expect(wrapper).toHaveClass('flex', 'items-center', 'gap-0.5', 'ml-2')
|
||||
|
||||
const keys = container.querySelectorAll('kbd')
|
||||
expect(keys).toHaveLength(2)
|
||||
expect(screen.getByText('⌘')).toBeInTheDocument()
|
||||
expect(screen.getByText('C')).toBeInTheDocument()
|
||||
expect(keys[0]).toHaveClass(
|
||||
'h-4',
|
||||
'min-w-4',
|
||||
'rounded-sm',
|
||||
'font-sans',
|
||||
'not-italic',
|
||||
'system-kbd',
|
||||
'capitalize',
|
||||
'bg-components-kbd-bg-white',
|
||||
'text-text-tertiary',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps single-key shortcuts in one keycap', () => {
|
||||
const { container } = render(
|
||||
<ShortcutKbd shortcut="workflow.delete" platform="windows" />,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('kbd')).toHaveLength(1)
|
||||
expect(screen.getByText('⌦')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses TanStack non-mac modifier labels', () => {
|
||||
render(<ShortcutKbd shortcut="workflow.copy" platform="windows" />)
|
||||
|
||||
expect(screen.getByText('Ctrl')).toBeInTheDocument()
|
||||
expect(screen.getByText('C')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
19
web/app/components/workflow/shortcuts/commands.ts
Normal file
19
web/app/components/workflow/shortcuts/commands.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export const WorkflowCommand = {
|
||||
ToggleCanvasMaximize: 'workflow:toggle-canvas-maximize',
|
||||
} as const
|
||||
|
||||
type WorkflowCommandType = typeof WorkflowCommand[keyof typeof WorkflowCommand]
|
||||
|
||||
const workflowCommandTarget = new EventTarget()
|
||||
|
||||
export const emitWorkflowCommand = (command: WorkflowCommandType) => {
|
||||
workflowCommandTarget.dispatchEvent(new Event(command))
|
||||
}
|
||||
|
||||
export const subscribeWorkflowCommand = (
|
||||
command: WorkflowCommandType,
|
||||
listener: () => void,
|
||||
) => {
|
||||
workflowCommandTarget.addEventListener(command, listener)
|
||||
return () => workflowCommandTarget.removeEventListener(command, listener)
|
||||
}
|
||||
177
web/app/components/workflow/shortcuts/definitions.ts
Normal file
177
web/app/components/workflow/shortcuts/definitions.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
|
||||
|
||||
export type WorkflowShortcutId
|
||||
= | 'workflow.delete'
|
||||
| 'workflow.copy'
|
||||
| 'workflow.paste'
|
||||
| 'workflow.duplicate'
|
||||
| 'workflow.open-test-run-menu'
|
||||
| 'workflow.undo'
|
||||
| 'workflow.redo'
|
||||
| 'workflow.pointer-mode'
|
||||
| 'workflow.hand-mode'
|
||||
| 'workflow.comment-mode'
|
||||
| 'workflow.organize'
|
||||
| 'workflow.toggle-maximize'
|
||||
| 'workflow.zoom-to-fit'
|
||||
| 'workflow.zoom-to-100'
|
||||
| 'workflow.zoom-to-50'
|
||||
| 'workflow.zoom-out'
|
||||
| 'workflow.zoom-in'
|
||||
| 'workflow.download-import-log'
|
||||
| 'workflow.dim-other-nodes'
|
||||
| 'workflow.json-schema-confirm'
|
||||
| 'workflow.version-history'
|
||||
|
||||
export type WorkflowHotkeyMeta = {
|
||||
id: WorkflowShortcutId
|
||||
scope: 'workflow'
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type WorkflowShortcutDefinition = {
|
||||
id: WorkflowShortcutId
|
||||
hotkeys: readonly RegisterableHotkey[]
|
||||
displayHotkey?: RegisterableHotkey | (string & {})
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const WORKFLOW_SHORTCUTS: Record<WorkflowShortcutId, WorkflowShortcutDefinition> = {
|
||||
'workflow.delete': {
|
||||
id: 'workflow.delete',
|
||||
hotkeys: ['Delete', 'Backspace'],
|
||||
displayHotkey: 'Delete',
|
||||
name: 'Delete selection',
|
||||
description: 'Delete selected workflow nodes or edges',
|
||||
},
|
||||
'workflow.copy': {
|
||||
id: 'workflow.copy',
|
||||
hotkeys: ['Mod+C'],
|
||||
name: 'Copy',
|
||||
description: 'Copy selected workflow nodes',
|
||||
},
|
||||
'workflow.paste': {
|
||||
id: 'workflow.paste',
|
||||
hotkeys: ['Mod+V'],
|
||||
name: 'Paste',
|
||||
description: 'Paste copied workflow nodes',
|
||||
},
|
||||
'workflow.duplicate': {
|
||||
id: 'workflow.duplicate',
|
||||
hotkeys: ['Mod+D'],
|
||||
name: 'Duplicate',
|
||||
description: 'Duplicate selected workflow nodes',
|
||||
},
|
||||
'workflow.open-test-run-menu': {
|
||||
id: 'workflow.open-test-run-menu',
|
||||
hotkeys: ['Alt+R'],
|
||||
name: 'Open test run menu',
|
||||
description: 'Open the workflow test run menu',
|
||||
},
|
||||
'workflow.undo': {
|
||||
id: 'workflow.undo',
|
||||
hotkeys: ['Mod+Z'],
|
||||
name: 'Undo',
|
||||
description: 'Undo the previous workflow change',
|
||||
},
|
||||
'workflow.redo': {
|
||||
id: 'workflow.redo',
|
||||
hotkeys: ['Mod+Y', 'Mod+Shift+Z'],
|
||||
displayHotkey: 'Mod+Y',
|
||||
name: 'Redo',
|
||||
description: 'Redo the next workflow change',
|
||||
},
|
||||
'workflow.pointer-mode': {
|
||||
id: 'workflow.pointer-mode',
|
||||
hotkeys: ['V'],
|
||||
name: 'Pointer mode',
|
||||
description: 'Switch to pointer mode',
|
||||
},
|
||||
'workflow.hand-mode': {
|
||||
id: 'workflow.hand-mode',
|
||||
hotkeys: ['H'],
|
||||
name: 'Hand mode',
|
||||
description: 'Switch to hand mode',
|
||||
},
|
||||
'workflow.comment-mode': {
|
||||
id: 'workflow.comment-mode',
|
||||
hotkeys: ['C'],
|
||||
name: 'Comment mode',
|
||||
description: 'Switch to comment mode',
|
||||
},
|
||||
'workflow.organize': {
|
||||
id: 'workflow.organize',
|
||||
hotkeys: ['Mod+O'],
|
||||
name: 'Organize blocks',
|
||||
description: 'Automatically organize workflow blocks',
|
||||
},
|
||||
'workflow.toggle-maximize': {
|
||||
id: 'workflow.toggle-maximize',
|
||||
hotkeys: ['F'],
|
||||
name: 'Toggle maximize',
|
||||
description: 'Maximize or minimize the workflow canvas',
|
||||
},
|
||||
'workflow.zoom-to-fit': {
|
||||
id: 'workflow.zoom-to-fit',
|
||||
hotkeys: ['Mod+1'],
|
||||
name: 'Zoom to fit',
|
||||
description: 'Fit the workflow canvas into view',
|
||||
},
|
||||
'workflow.zoom-to-100': {
|
||||
id: 'workflow.zoom-to-100',
|
||||
hotkeys: ['Shift+1'],
|
||||
name: 'Zoom to 100%',
|
||||
description: 'Zoom the workflow canvas to 100%',
|
||||
},
|
||||
'workflow.zoom-to-50': {
|
||||
id: 'workflow.zoom-to-50',
|
||||
hotkeys: ['Shift+5'],
|
||||
name: 'Zoom to 50%',
|
||||
description: 'Zoom the workflow canvas to 50%',
|
||||
},
|
||||
'workflow.zoom-out': {
|
||||
id: 'workflow.zoom-out',
|
||||
hotkeys: ['Mod+-'],
|
||||
name: 'Zoom out',
|
||||
description: 'Zoom out of the workflow canvas',
|
||||
},
|
||||
'workflow.zoom-in': {
|
||||
id: 'workflow.zoom-in',
|
||||
hotkeys: ['Mod+='],
|
||||
displayHotkey: 'Mod+=',
|
||||
name: 'Zoom in',
|
||||
description: 'Zoom into the workflow canvas',
|
||||
},
|
||||
'workflow.download-import-log': {
|
||||
id: 'workflow.download-import-log',
|
||||
hotkeys: ['Mod+Shift+L'],
|
||||
name: 'Download import log',
|
||||
description: 'Download the workflow graph import log',
|
||||
},
|
||||
'workflow.dim-other-nodes': {
|
||||
id: 'workflow.dim-other-nodes',
|
||||
hotkeys: [{ key: 'Shift', shift: true }],
|
||||
displayHotkey: 'Shift',
|
||||
name: 'Dim other nodes',
|
||||
description: 'Dim nodes outside the current workflow selection',
|
||||
},
|
||||
'workflow.json-schema-confirm': {
|
||||
id: 'workflow.json-schema-confirm',
|
||||
hotkeys: ['Mod+Enter'],
|
||||
name: 'Confirm JSON schema edit',
|
||||
description: 'Confirm the current JSON schema edit',
|
||||
},
|
||||
'workflow.version-history': {
|
||||
id: 'workflow.version-history',
|
||||
hotkeys: ['Mod+Shift+H'],
|
||||
name: 'Version history',
|
||||
description: 'Open workflow version history',
|
||||
},
|
||||
}
|
||||
|
||||
export const getWorkflowShortcutDisplayHotkey = (id: WorkflowShortcutId): RegisterableHotkey | (string & {}) => {
|
||||
const shortcut = WORKFLOW_SHORTCUTS[id]
|
||||
return shortcut.displayHotkey ?? shortcut.hotkeys[0]!
|
||||
}
|
||||
70
web/app/components/workflow/shortcuts/shortcut-kbd.tsx
Normal file
70
web/app/components/workflow/shortcuts/shortcut-kbd.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import type { FormatDisplayOptions, RegisterableHotkey } from '@tanstack/react-hotkeys'
|
||||
import type { WorkflowShortcutId } from './definitions'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { getWorkflowShortcutDisplayHotkey } from './definitions'
|
||||
|
||||
type ShortcutKbdProps = {
|
||||
shortcut?: WorkflowShortcutId
|
||||
hotkey?: RegisterableHotkey | (string & {})
|
||||
className?: string
|
||||
textColor?: 'default' | 'secondary'
|
||||
bgColor?: 'gray' | 'white'
|
||||
platform?: FormatDisplayOptions['platform']
|
||||
}
|
||||
|
||||
const getDisplayKeys = (
|
||||
hotkey: RegisterableHotkey | (string & {}),
|
||||
platform?: FormatDisplayOptions['platform'],
|
||||
) => {
|
||||
const displayOptions = platform ? { platform } : undefined
|
||||
|
||||
if (typeof hotkey !== 'string')
|
||||
return [formatForDisplay(hotkey, displayOptions)]
|
||||
|
||||
return hotkey
|
||||
.split('+')
|
||||
.filter(Boolean)
|
||||
.map(key => formatForDisplay(key, displayOptions))
|
||||
}
|
||||
|
||||
export const ShortcutKbd = ({
|
||||
shortcut,
|
||||
hotkey,
|
||||
className,
|
||||
textColor = 'default',
|
||||
bgColor = 'gray',
|
||||
platform,
|
||||
}: ShortcutKbdProps) => {
|
||||
const displayHotkey = hotkey ?? (shortcut ? getWorkflowShortcutDisplayHotkey(shortcut) : undefined)
|
||||
|
||||
if (!displayHotkey)
|
||||
return null
|
||||
|
||||
const displayKeys = getDisplayKeys(displayHotkey, platform)
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-0.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{
|
||||
displayKeys.map((key, index) => (
|
||||
<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',
|
||||
)}
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))
|
||||
}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
255
web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts
Normal file
255
web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts
Normal file
@ -0,0 +1,255 @@
|
||||
import type {
|
||||
HotkeyCallback,
|
||||
UseHotkeyDefinition,
|
||||
UseHotkeyOptions,
|
||||
} from '@tanstack/react-hotkeys'
|
||||
import type { WorkflowHotkeyMeta, WorkflowShortcutDefinition, WorkflowShortcutId } from './definitions'
|
||||
import { useHotkeys, useKeyHold } from '@tanstack/react-hotkeys'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { collaborationManager } from '../collaboration/core/collaboration-manager'
|
||||
import { useEdgesInteractions } from '../hooks/use-edges-interactions'
|
||||
import { useNodesInteractions } from '../hooks/use-nodes-interactions'
|
||||
import { useNodesSyncDraft } from '../hooks/use-nodes-sync-draft'
|
||||
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,
|
||||
} from './commands'
|
||||
import { WORKFLOW_SHORTCUTS } from './definitions'
|
||||
|
||||
const workflowHotkeyOptions = {
|
||||
ignoreInputs: true,
|
||||
conflictBehavior: 'warn',
|
||||
} satisfies UseHotkeyOptions
|
||||
|
||||
const toHotkeyDefinitions = (
|
||||
shortcut: WorkflowShortcutDefinition,
|
||||
callback: HotkeyCallback,
|
||||
options?: UseHotkeyOptions,
|
||||
): UseHotkeyDefinition[] => {
|
||||
return shortcut.hotkeys.map(hotkey => ({
|
||||
hotkey,
|
||||
callback,
|
||||
options: {
|
||||
...options,
|
||||
meta: {
|
||||
id: shortcut.id,
|
||||
scope: 'workflow',
|
||||
name: shortcut.name,
|
||||
description: shortcut.description,
|
||||
} satisfies WorkflowHotkeyMeta,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export const useWorkflowShortcut = (
|
||||
id: WorkflowShortcutId,
|
||||
callback: HotkeyCallback,
|
||||
options?: UseHotkeyOptions,
|
||||
) => {
|
||||
const shortcut = WORKFLOW_SHORTCUTS[id]
|
||||
const hotkeys = useMemo(
|
||||
() => toHotkeyDefinitions(shortcut, callback, options),
|
||||
[callback, options, shortcut],
|
||||
)
|
||||
|
||||
useHotkeys(hotkeys, workflowHotkeyOptions)
|
||||
}
|
||||
|
||||
export const useWorkflowHotkeys = (): void => {
|
||||
const {
|
||||
handleNodesCopy,
|
||||
handleNodesPaste,
|
||||
handleNodesDuplicate,
|
||||
handleNodesDelete,
|
||||
handleHistoryBack,
|
||||
handleHistoryForward,
|
||||
dimOtherNodes,
|
||||
undimAllNodes,
|
||||
} = useNodesInteractions()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { handleEdgeDelete } = useEdgesInteractions()
|
||||
const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel)
|
||||
const historyShortcutsEnabled = useStore(s => s.historyShortcutsEnabled)
|
||||
const {
|
||||
handleModeHand,
|
||||
handleModePointer,
|
||||
handleModeComment,
|
||||
isCommentModeAvailable,
|
||||
} = useWorkflowMoveMode()
|
||||
const { handleLayout } = useWorkflowOrganize()
|
||||
const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize()
|
||||
|
||||
const {
|
||||
zoomTo,
|
||||
getZoom,
|
||||
fitView,
|
||||
getNodes,
|
||||
} = useReactFlow()
|
||||
const isShiftHeld = useKeyHold('Shift')
|
||||
const shiftDimmedRef = useRef(false)
|
||||
const undimAllNodesRef = useRef(undimAllNodes)
|
||||
undimAllNodesRef.current = undimAllNodes
|
||||
|
||||
const constrainedZoomOut = useCallback(() => {
|
||||
const currentZoom = getZoom()
|
||||
const newZoom = Math.max(currentZoom - 0.1, 0.25)
|
||||
zoomTo(newZoom)
|
||||
}, [getZoom, zoomTo])
|
||||
|
||||
const constrainedZoomIn = useCallback(() => {
|
||||
const currentZoom = getZoom()
|
||||
const newZoom = Math.min(currentZoom + 0.1, 2)
|
||||
zoomTo(newZoom)
|
||||
}, [getZoom, zoomTo])
|
||||
|
||||
const shouldHandleCopy = useCallback(() => {
|
||||
if (getNodes().some(node => node.data._isBundled))
|
||||
return true
|
||||
|
||||
const selection = document.getSelection()
|
||||
return !selection || selection.isCollapsed || !selection.rangeCount
|
||||
}, [getNodes])
|
||||
|
||||
const handleCopy = useCallback<HotkeyCallback>((event) => {
|
||||
if (!shouldHandleCopy())
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleNodesCopy()
|
||||
}, [handleNodesCopy, shouldHandleCopy])
|
||||
|
||||
const handleZenToggle = useCallback(() => {
|
||||
handleToggleMaximizeCanvas()
|
||||
}, [handleToggleMaximizeCanvas])
|
||||
|
||||
const hotkeys = useMemo<UseHotkeyDefinition[]>(() => [
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.delete'], () => {
|
||||
handleNodesDelete()
|
||||
handleEdgeDelete()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.copy'], handleCopy, {
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
enabled: !showDebugAndPreviewPanel,
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.paste'], () => {
|
||||
handleNodesPaste()
|
||||
}, {
|
||||
enabled: !showDebugAndPreviewPanel,
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.duplicate'], () => {
|
||||
handleNodesDuplicate()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.undo'], () => {
|
||||
handleHistoryBack()
|
||||
}, {
|
||||
enabled: !showDebugAndPreviewPanel && historyShortcutsEnabled,
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.redo'], () => {
|
||||
handleHistoryForward()
|
||||
}, {
|
||||
enabled: !showDebugAndPreviewPanel && historyShortcutsEnabled,
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.hand-mode'], () => {
|
||||
handleModeHand()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.pointer-mode'], () => {
|
||||
handleModePointer()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.comment-mode'], () => {
|
||||
handleModeComment()
|
||||
}, {
|
||||
enabled: isCommentModeAvailable,
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.organize'], () => {
|
||||
handleLayout()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.toggle-maximize'], () => {
|
||||
handleToggleMaximizeCanvas()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.zoom-to-fit'], () => {
|
||||
fitView()
|
||||
handleSyncWorkflowDraft()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.zoom-to-100'], () => {
|
||||
zoomTo(1)
|
||||
handleSyncWorkflowDraft()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.zoom-to-50'], () => {
|
||||
zoomTo(0.5)
|
||||
handleSyncWorkflowDraft()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.zoom-out'], () => {
|
||||
constrainedZoomOut()
|
||||
handleSyncWorkflowDraft()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.zoom-in'], () => {
|
||||
constrainedZoomIn()
|
||||
handleSyncWorkflowDraft()
|
||||
}),
|
||||
...toHotkeyDefinitions(WORKFLOW_SHORTCUTS['workflow.download-import-log'], () => {
|
||||
collaborationManager.downloadGraphImportLog()
|
||||
}),
|
||||
], [
|
||||
constrainedZoomIn,
|
||||
constrainedZoomOut,
|
||||
fitView,
|
||||
handleCopy,
|
||||
handleEdgeDelete,
|
||||
handleHistoryBack,
|
||||
handleHistoryForward,
|
||||
handleLayout,
|
||||
handleModeComment,
|
||||
handleModeHand,
|
||||
handleModePointer,
|
||||
handleNodesDelete,
|
||||
handleNodesDuplicate,
|
||||
handleNodesPaste,
|
||||
handleSyncWorkflowDraft,
|
||||
handleToggleMaximizeCanvas,
|
||||
historyShortcutsEnabled,
|
||||
isCommentModeAvailable,
|
||||
showDebugAndPreviewPanel,
|
||||
zoomTo,
|
||||
])
|
||||
|
||||
useHotkeys(hotkeys, workflowHotkeyOptions)
|
||||
|
||||
useEffect(() => {
|
||||
if (isShiftHeld) {
|
||||
if (shiftDimmedRef.current)
|
||||
return
|
||||
|
||||
if (isEventTargetInputArea(document.activeElement as HTMLElement))
|
||||
return
|
||||
|
||||
shiftDimmedRef.current = true
|
||||
dimOtherNodes()
|
||||
return
|
||||
}
|
||||
|
||||
if (!shiftDimmedRef.current)
|
||||
return
|
||||
|
||||
shiftDimmedRef.current = false
|
||||
undimAllNodes()
|
||||
}, [dimOtherNodes, isShiftHeld, undimAllNodes])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (shiftDimmedRef.current)
|
||||
undimAllNodesRef.current()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeWorkflowCommand(WorkflowCommand.ToggleCanvasMaximize, handleZenToggle)
|
||||
}, [handleZenToggle])
|
||||
}
|
||||
@ -1,12 +1,47 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { WorkflowHistoryEventT } from '../../hooks/use-workflow-history'
|
||||
import type { Edge, Node } from '../../types'
|
||||
import type {
|
||||
HistoryWorkflowData,
|
||||
} from '@/app/components/workflow/types'
|
||||
import type {
|
||||
VersionHistory,
|
||||
} from '@/types/workflow'
|
||||
import isDeepEqual from 'fast-deep-equal'
|
||||
|
||||
export type WorkflowHistoryEventMeta = {
|
||||
nodeId?: string
|
||||
nodeTitle?: string
|
||||
}
|
||||
|
||||
export type WorkflowHistoryState = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
workflowHistoryEvent: WorkflowHistoryEventT | undefined
|
||||
workflowHistoryEventMeta?: WorkflowHistoryEventMeta
|
||||
}
|
||||
|
||||
export type WorkflowHistoryTemporalState = Pick<HistorySliceShape, 'workflowHistory'>
|
||||
|
||||
export const getWorkflowHistoryTemporalState = (state: HistorySliceShape): WorkflowHistoryTemporalState => ({
|
||||
workflowHistory: state.workflowHistory,
|
||||
})
|
||||
|
||||
export const isWorkflowHistoryTemporalStateEqual = (
|
||||
pastState: WorkflowHistoryTemporalState,
|
||||
currentState: WorkflowHistoryTemporalState,
|
||||
) => {
|
||||
if (pastState.workflowHistory === currentState.workflowHistory)
|
||||
return true
|
||||
|
||||
return isDeepEqual(pastState.workflowHistory, currentState.workflowHistory)
|
||||
}
|
||||
|
||||
export type HistorySliceShape = {
|
||||
workflowHistory: WorkflowHistoryState
|
||||
setWorkflowHistory: (workflowHistory: WorkflowHistoryState) => void
|
||||
historyShortcutsEnabled: boolean
|
||||
setHistoryShortcutsEnabled: (enabled: boolean) => void
|
||||
historyWorkflowData?: HistoryWorkflowData
|
||||
setHistoryWorkflowData: (historyWorkflowData?: HistoryWorkflowData) => void
|
||||
showRunHistory: boolean
|
||||
@ -16,6 +51,15 @@ export type HistorySliceShape = {
|
||||
}
|
||||
|
||||
export const createHistorySlice: StateCreator<HistorySliceShape> = set => ({
|
||||
workflowHistory: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
workflowHistoryEvent: undefined,
|
||||
workflowHistoryEventMeta: undefined,
|
||||
},
|
||||
setWorkflowHistory: workflowHistory => set(() => ({ workflowHistory })),
|
||||
historyShortcutsEnabled: true,
|
||||
setHistoryShortcutsEnabled: historyShortcutsEnabled => set(() => ({ historyShortcutsEnabled })),
|
||||
historyWorkflowData: undefined,
|
||||
setHistoryWorkflowData: historyWorkflowData => set(() => ({ historyWorkflowData })),
|
||||
showRunHistory: false,
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import type { TemporalState } from 'zundo'
|
||||
import type {
|
||||
StateCreator,
|
||||
StoreApi,
|
||||
} from 'zustand'
|
||||
import type { ChatVariableSliceShape } from './chat-variable-slice'
|
||||
import type { CommentSliceShape } from './comment-slice'
|
||||
@ -7,7 +9,7 @@ import type { InspectVarsSliceShape } from './debug/inspect-vars-slice'
|
||||
import type { EnvVariableSliceShape } from './env-variable-slice'
|
||||
import type { FormSliceShape } from './form-slice'
|
||||
import type { HelpLineSliceShape } from './help-line-slice'
|
||||
import type { HistorySliceShape } from './history-slice'
|
||||
import type { HistorySliceShape, WorkflowHistoryTemporalState } from './history-slice'
|
||||
import type { LayoutSliceShape } from './layout-slice'
|
||||
import type { NodeSliceShape } from './node-slice'
|
||||
import type { PanelSliceShape } from './panel-slice'
|
||||
@ -17,7 +19,8 @@ import type { WorkflowDraftSliceShape } from './workflow-draft-slice'
|
||||
import type { WorkflowSliceShape } from './workflow-slice'
|
||||
import type { RagPipelineSliceShape } from '@/app/components/rag-pipeline/store'
|
||||
import type { WorkflowSliceShape as WorkflowAppSliceShape } from '@/app/components/workflow-app/store/workflow/workflow-slice'
|
||||
import { useContext } from 'react'
|
||||
import { use } from 'react'
|
||||
import { temporal } from 'zundo'
|
||||
import {
|
||||
useStore as useZustandStore,
|
||||
} from 'zustand'
|
||||
@ -29,7 +32,11 @@ import { createInspectVarsSlice } from './debug/inspect-vars-slice'
|
||||
import { createEnvVariableSlice } from './env-variable-slice'
|
||||
import { createFormSlice } from './form-slice'
|
||||
import { createHelpLineSlice } from './help-line-slice'
|
||||
import { createHistorySlice } from './history-slice'
|
||||
import {
|
||||
createHistorySlice,
|
||||
getWorkflowHistoryTemporalState,
|
||||
isWorkflowHistoryTemporalStateEqual,
|
||||
} from './history-slice'
|
||||
import { createLayoutSlice } from './layout-slice'
|
||||
import { createNodeSlice } from './node-slice'
|
||||
|
||||
@ -60,6 +67,10 @@ export type Shape
|
||||
& LayoutSliceShape
|
||||
& SliceFromInjection
|
||||
|
||||
type WorkflowStoreApi = StoreApi<Shape> & {
|
||||
temporal: StoreApi<TemporalState<WorkflowHistoryTemporalState>>
|
||||
}
|
||||
|
||||
export type InjectWorkflowStoreSliceFn = StateCreator<SliceFromInjection>
|
||||
|
||||
type CreateWorkflowStoreParams = {
|
||||
@ -69,27 +80,35 @@ type CreateWorkflowStoreParams = {
|
||||
export const createWorkflowStore = (params: CreateWorkflowStoreParams) => {
|
||||
const { injectWorkflowStoreSliceFn } = params || {}
|
||||
|
||||
return createStore<Shape>((...args) => ({
|
||||
...createChatVariableSlice(...args),
|
||||
...createEnvVariableSlice(...args),
|
||||
...createFormSlice(...args),
|
||||
...createHelpLineSlice(...args),
|
||||
...createHistorySlice(...args),
|
||||
...createNodeSlice(...args),
|
||||
...createPanelSlice(...args),
|
||||
...createCommentSlice(...args),
|
||||
...createToolSlice(...args),
|
||||
...createVersionSlice(...args),
|
||||
...createWorkflowDraftSlice(...args),
|
||||
...createWorkflowSlice(...args),
|
||||
...createInspectVarsSlice(...args),
|
||||
...createLayoutSlice(...args),
|
||||
...(injectWorkflowStoreSliceFn?.(...args) || {} as SliceFromInjection),
|
||||
}))
|
||||
return createStore<Shape>()(
|
||||
temporal<Shape, [], [], WorkflowHistoryTemporalState>(
|
||||
(...args) => ({
|
||||
...createChatVariableSlice(...args),
|
||||
...createEnvVariableSlice(...args),
|
||||
...createFormSlice(...args),
|
||||
...createHelpLineSlice(...args),
|
||||
...createHistorySlice(...args),
|
||||
...createNodeSlice(...args),
|
||||
...createPanelSlice(...args),
|
||||
...createCommentSlice(...args),
|
||||
...createToolSlice(...args),
|
||||
...createVersionSlice(...args),
|
||||
...createWorkflowDraftSlice(...args),
|
||||
...createWorkflowSlice(...args),
|
||||
...createInspectVarsSlice(...args),
|
||||
...createLayoutSlice(...args),
|
||||
...(injectWorkflowStoreSliceFn?.(...args) || {} as SliceFromInjection),
|
||||
}),
|
||||
{
|
||||
partialize: getWorkflowHistoryTemporalState,
|
||||
equality: isWorkflowHistoryTemporalStateEqual,
|
||||
},
|
||||
),
|
||||
) as WorkflowStoreApi
|
||||
}
|
||||
|
||||
export function useStore<T>(selector: (state: Shape) => T): T {
|
||||
const store = useContext(WorkflowContext)
|
||||
const store = use(WorkflowContext)
|
||||
if (!store)
|
||||
throw new Error('Missing WorkflowContext.Provider in the tree')
|
||||
|
||||
@ -97,5 +116,5 @@ export function useStore<T>(selector: (state: Shape) => T): T {
|
||||
}
|
||||
|
||||
export const useWorkflowStore = () => {
|
||||
return useContext(WorkflowContext)!
|
||||
return use(WorkflowContext)!
|
||||
}
|
||||
|
||||
99
web/app/components/workflow/workflow-history-store.ts
Normal file
99
web/app/components/workflow/workflow-history-store.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import type { TemporalState } from 'zundo'
|
||||
import type {
|
||||
WorkflowHistoryState,
|
||||
} from './store/workflow/history-slice'
|
||||
import type { Edge, Node } from './types'
|
||||
import { use, useMemo } from 'react'
|
||||
import { WorkflowContext } from './context'
|
||||
|
||||
type WorkflowHistoryTemporalSnapshot = {
|
||||
workflowHistory: WorkflowHistoryState
|
||||
}
|
||||
|
||||
type WorkflowHistoryTemporalStore = {
|
||||
getState: () => TemporalState<WorkflowHistoryState>
|
||||
subscribe: (listener: (state: TemporalState<WorkflowHistoryState>) => void) => () => void
|
||||
}
|
||||
|
||||
type WorkflowHistoryStore = {
|
||||
getState: () => WorkflowHistoryState
|
||||
setState: (state: WorkflowHistoryState) => void
|
||||
subscribe: (listener: (state: WorkflowHistoryState) => void) => () => void
|
||||
temporal: WorkflowHistoryTemporalStore
|
||||
}
|
||||
|
||||
const sanitizeWorkflowHistory = (state: WorkflowHistoryState): WorkflowHistoryState => ({
|
||||
workflowHistoryEvent: state.workflowHistoryEvent,
|
||||
workflowHistoryEventMeta: state.workflowHistoryEventMeta,
|
||||
nodes: state.nodes.map((node: Node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
selected: false,
|
||||
},
|
||||
})),
|
||||
edges: state.edges.map((edge: Edge) => ({
|
||||
...edge,
|
||||
selected: false,
|
||||
}) as Edge),
|
||||
})
|
||||
|
||||
const toHistoryState = (
|
||||
state?: Partial<WorkflowHistoryTemporalSnapshot>,
|
||||
): Partial<WorkflowHistoryState> => {
|
||||
return state?.workflowHistory ?? {}
|
||||
}
|
||||
|
||||
const toTemporalState = (
|
||||
temporalState: TemporalState<WorkflowHistoryTemporalSnapshot>,
|
||||
): TemporalState<WorkflowHistoryState> => ({
|
||||
pastStates: temporalState.pastStates.map(toHistoryState),
|
||||
futureStates: temporalState.futureStates.map(toHistoryState),
|
||||
undo: temporalState.undo,
|
||||
redo: temporalState.redo,
|
||||
clear: temporalState.clear,
|
||||
isTracking: temporalState.isTracking,
|
||||
pause: temporalState.pause,
|
||||
resume: temporalState.resume,
|
||||
setOnSave: onSave => temporalState.setOnSave(
|
||||
onSave
|
||||
? (pastState, currentState) => {
|
||||
onSave(
|
||||
toHistoryState(pastState) as WorkflowHistoryState,
|
||||
toHistoryState(currentState) as WorkflowHistoryState,
|
||||
)
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
})
|
||||
|
||||
export function useWorkflowHistoryStore() {
|
||||
const workflowStore = use(WorkflowContext)
|
||||
|
||||
if (!workflowStore)
|
||||
throw new Error('Missing WorkflowContext.Provider in the tree')
|
||||
|
||||
return {
|
||||
store: useMemo(
|
||||
() => ({
|
||||
getState: () => workflowStore.getState().workflowHistory,
|
||||
setState: (state: WorkflowHistoryState) => {
|
||||
workflowStore.getState().setWorkflowHistory(sanitizeWorkflowHistory(state))
|
||||
},
|
||||
subscribe: (listener: (state: WorkflowHistoryState) => void) => {
|
||||
return workflowStore.subscribe((state, previousState) => {
|
||||
if (state.workflowHistory !== previousState.workflowHistory)
|
||||
listener(state.workflowHistory)
|
||||
})
|
||||
},
|
||||
temporal: {
|
||||
getState: () => toTemporalState(workflowStore.temporal.getState()),
|
||||
subscribe: listener => workflowStore.temporal.subscribe((state) => {
|
||||
listener(toTemporalState(state))
|
||||
}),
|
||||
},
|
||||
}) satisfies WorkflowHistoryStore,
|
||||
[workflowStore],
|
||||
),
|
||||
}
|
||||
}
|
||||
@ -1,132 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { TemporalState } from 'zundo'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { WorkflowHistoryEventT } from './hooks'
|
||||
import type { Edge, Node } from './types'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import isDeepEqual from 'fast-deep-equal'
|
||||
import { createContext, useContext, useMemo, useState } from 'react'
|
||||
import { temporal } from 'zundo'
|
||||
import { create } from 'zustand'
|
||||
|
||||
export const WorkflowHistoryStoreContext = createContext<WorkflowHistoryStoreContextType>({ store: null, shortcutsEnabled: true, setShortcutsEnabled: noop })
|
||||
const Provider = WorkflowHistoryStoreContext.Provider
|
||||
|
||||
export function WorkflowHistoryProvider({
|
||||
nodes,
|
||||
edges,
|
||||
children,
|
||||
}: WorkflowWithHistoryProviderProps) {
|
||||
const [shortcutsEnabled, setShortcutsEnabled] = useState(true)
|
||||
const [store] = useState(() =>
|
||||
createStore({
|
||||
nodes,
|
||||
edges,
|
||||
}),
|
||||
)
|
||||
|
||||
const contextValue = {
|
||||
store,
|
||||
shortcutsEnabled,
|
||||
setShortcutsEnabled,
|
||||
}
|
||||
|
||||
return (
|
||||
<Provider value={contextValue}>
|
||||
{children}
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWorkflowHistoryStore() {
|
||||
const {
|
||||
store,
|
||||
shortcutsEnabled,
|
||||
setShortcutsEnabled,
|
||||
} = useContext(WorkflowHistoryStoreContext)
|
||||
if (store === null)
|
||||
throw new Error('useWorkflowHistoryStoreApi must be used within a WorkflowHistoryProvider')
|
||||
|
||||
return {
|
||||
store: useMemo(
|
||||
() => ({
|
||||
getState: store.getState,
|
||||
setState: (state: WorkflowHistoryState) => {
|
||||
store.setState({
|
||||
workflowHistoryEvent: state.workflowHistoryEvent,
|
||||
workflowHistoryEventMeta: state.workflowHistoryEventMeta,
|
||||
nodes: state.nodes.map((node: Node) => ({ ...node, data: { ...node.data, selected: false } })),
|
||||
edges: state.edges.map((edge: Edge) => ({ ...edge, selected: false }) as Edge),
|
||||
})
|
||||
},
|
||||
subscribe: store.subscribe,
|
||||
temporal: store.temporal,
|
||||
}),
|
||||
[store],
|
||||
),
|
||||
shortcutsEnabled,
|
||||
setShortcutsEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
function createStore({
|
||||
nodes: storeNodes,
|
||||
edges: storeEdges,
|
||||
}: {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
}): WorkflowHistoryStoreApi {
|
||||
const store = create(temporal<WorkflowHistoryState>(
|
||||
(set, get) => {
|
||||
return {
|
||||
workflowHistoryEvent: undefined,
|
||||
workflowHistoryEventMeta: undefined,
|
||||
nodes: storeNodes,
|
||||
edges: storeEdges,
|
||||
getNodes: () => get().nodes,
|
||||
setNodes: (nodes: Node[]) => set({ nodes }),
|
||||
setEdges: (edges: Edge[]) => set({ edges }),
|
||||
}
|
||||
},
|
||||
{
|
||||
equality: (pastState, currentState) =>
|
||||
isDeepEqual(pastState, currentState),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
type WorkflowHistoryStore = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
workflowHistoryEvent: WorkflowHistoryEventT | undefined
|
||||
workflowHistoryEventMeta?: WorkflowHistoryEventMeta
|
||||
}
|
||||
|
||||
type WorkflowHistoryActions = {
|
||||
setNodes?: (nodes: Node[]) => void
|
||||
setEdges?: (edges: Edge[]) => void
|
||||
}
|
||||
|
||||
export type WorkflowHistoryState = WorkflowHistoryStore & WorkflowHistoryActions
|
||||
|
||||
type WorkflowHistoryStoreContextType = {
|
||||
store: ReturnType<typeof createStore> | null
|
||||
shortcutsEnabled: boolean
|
||||
setShortcutsEnabled: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
export type WorkflowHistoryStoreApi = StoreApi<WorkflowHistoryState> & { temporal: StoreApi<TemporalState<WorkflowHistoryState>> }
|
||||
|
||||
type WorkflowWithHistoryProviderProps = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export type WorkflowHistoryEventMeta = {
|
||||
nodeId?: string
|
||||
nodeTitle?: string
|
||||
}
|
||||
@ -18,7 +18,7 @@ import {
|
||||
useViewport,
|
||||
} from 'reactflow'
|
||||
import TipPopup from '@/app/components/workflow/operator/tip-popup'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||
|
||||
enum ZoomType {
|
||||
zoomToFit = 'zoomToFit',
|
||||
@ -104,9 +104,12 @@ const ZoomInOut: FC = () => {
|
||||
<div className="flex h-8 w-[98px] items-center justify-between rounded-lg">
|
||||
<TipPopup
|
||||
title={t('operator.zoomOut', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '-']}
|
||||
shortcut="workflow.zoom-out"
|
||||
>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operator.zoomOut', { ns: 'workflow' })}
|
||||
disabled={zoom <= 0.25}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom <= 0.25 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom <= 0.25)
|
||||
@ -117,7 +120,7 @@ const ZoomInOut: FC = () => {
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-zoom-out-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</button>
|
||||
</TipPopup>
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
@ -149,13 +152,13 @@ const ZoomInOut: FC = () => {
|
||||
<span>{option.text}</span>
|
||||
<div className="flex items-center space-x-0.5">
|
||||
{option.key === ZoomType.zoomToFit && (
|
||||
<ShortcutsName keys={['ctrl', '1']} />
|
||||
<ShortcutKbd shortcut="workflow.zoom-to-fit" />
|
||||
)}
|
||||
{option.key === ZoomType.zoomTo50 && (
|
||||
<ShortcutsName keys={['shift', '5']} />
|
||||
<ShortcutKbd shortcut="workflow.zoom-to-50" />
|
||||
)}
|
||||
{option.key === ZoomType.zoomTo100 && (
|
||||
<ShortcutsName keys={['shift', '1']} />
|
||||
<ShortcutKbd shortcut="workflow.zoom-to-100" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
@ -168,9 +171,12 @@ const ZoomInOut: FC = () => {
|
||||
</DropdownMenu>
|
||||
<TipPopup
|
||||
title={t('operator.zoomIn', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '+']}
|
||||
shortcut="workflow.zoom-in"
|
||||
>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operator.zoomIn', { ns: 'workflow' })}
|
||||
disabled={zoom >= 2}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom >= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom >= 2)
|
||||
@ -181,7 +187,7 @@ const ZoomInOut: FC = () => {
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-zoom-in-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</button>
|
||||
</TipPopup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -76,6 +76,7 @@
|
||||
"@t3-oss/env-nextjs": "catalog:",
|
||||
"@tailwindcss/typography": "catalog:",
|
||||
"@tanstack/react-form": "catalog:",
|
||||
"@tanstack/react-hotkeys": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-virtual": "catalog:",
|
||||
"abcjs": "catalog:",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user