diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 272fa034ad..2eaefd9436 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af3c4855fb..9529c1aee3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8be67e2427..b0c007ee4d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx index e76bbb0728..3c272f687e 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx @@ -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 = ({ modelAndParameter={modelAndParameter} /> - }> - - - - + + + + )} + /> { render() - // 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', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx index 43b5fcc71a..a77ba87ac6 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx @@ -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} > - }> - - + + + + )} + /> { 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() }) }) diff --git a/web/app/components/goto-anything/actions/commands/zen.tsx b/web/app/components/goto-anything/actions/commands/zen.tsx index 1645e40fd9..f4e0bec7a9 100644 --- a/web/app/components/goto-anything/actions/commands/zen.tsx +++ b/web/app/components/goto-anything/actions/commands/zen.tsx @@ -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 -// 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) } /** diff --git a/web/app/components/header/account-setting/data-source-page-new/operator.tsx b/web/app/components/header/account-setting/data-source-page-new/operator.tsx index bcd3acb6b2..ccdc13a8a0 100644 --- a/web/app/components/header/account-setting/data-source-page-new/operator.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/operator.tsx @@ -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 ( - }> - - - - + + + + )} + /> handleAction('setDefault')}> diff --git a/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx index 5767409dbe..a3ae111f89 100644 --- a/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx @@ -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) diff --git a/web/app/components/workflow/__tests__/index.spec.tsx b/web/app/components/workflow/__tests__/index.spec.tsx index 77b61e54e7..5e99baaaad 100644 --- a/web/app/components/workflow/__tests__/index.spec.tsx +++ b/web/app/components/workflow/__tests__/index.spec.tsx @@ -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 (
{`history:${store.getState().nodes.length}`} - {` shortcuts:${String(shortcutsEnabled)}`} {` datasets:${datasetCount}`} {` reactflow:${String(!!reactFlowStore)}`}
@@ -52,16 +52,18 @@ const ContextConsumer = () => { describe('WorkflowWithDefaultContext', () => { it('wires the ReactFlow, workflow history, and datasets detail providers around its children', () => { render( - - - , + + + + + , ) expect( - screen.getByText('history:1 shortcuts:true datasets:0 reactflow:true'), + screen.getByText('history:1 datasets:0 reactflow:true'), ).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx index 08ac245172..2c9c457245 100644 --- a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx @@ -145,8 +145,8 @@ describe('PanelContextmenu', () => { const { container } = render() 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', diff --git a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx index 47545baca8..6dc8be84b0 100644 --- a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx @@ -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 ? ( -
- - +
+ {title &&
{title}
} + {desc &&
{desc}
} + +
) : 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, diff --git a/web/app/components/workflow/__tests__/workflow-history-store.spec.tsx b/web/app/components/workflow/__tests__/workflow-history-store.spec.tsx index bc2495748a..3172cc401e 100644 --- a/web/app/components/workflow/__tests__/workflow-history-store.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-history-store.spec.tsx @@ -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 ( - + return ({ children }: { children: React.ReactNode }) => ( + + {children} + ) } -describe('WorkflowHistoryProvider', () => { - it('provides workflow history state and shortcut toggles', async () => { - const user = userEvent.setup() - - render( - - - , - ) - - 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 }) => ( - - {children} - - ) + 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', ) }) }) diff --git a/web/app/components/workflow/__tests__/workflow-test-env.tsx b/web/app/components/workflow/__tests__/workflow-test-env.tsx index 549a055c1e..b88600331a 100644 --- a/web/app/components/workflow/__tests__/workflow-test-env.tsx +++ b/web/app/components/workflow/__tests__/workflow-test-env.tsx @@ -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 = RenderHookResult & 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( 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>( // 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( - (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, } } diff --git a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx index 34bdf54715..2160008c6b 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/action.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/action.tsx @@ -74,11 +74,16 @@ const OperationDropdown: FC = ({ open={open} onOpenChange={setOpen} > - }> - - - - + + + + )} + /> export const WorkflowContext = createContext(null) diff --git a/web/app/components/workflow/edge-contextmenu.tsx b/web/app/components/workflow/edge-contextmenu.tsx index 2b7f13190a..e4f8ef95e0 100644 --- a/web/app/components/workflow/edge-contextmenu.tsx +++ b/web/app/components/workflow/edge-contextmenu.tsx @@ -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)} > {t('common:operation.delete')} - + diff --git a/web/app/components/workflow/header/__tests__/run-mode.spec.tsx b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx index 02b645e079..245b516cd3 100644 --- a/web/app/components/workflow/header/__tests__/run-mode.spec.tsx +++ b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx @@ -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, })) diff --git a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx index 8e93612dc0..ebf1af5a3d 100644 --- a/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx +++ b/web/app/components/workflow/header/__tests__/test-run-menu.spec.tsx @@ -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, children) + if (render) { + return React.cloneElement( + render, + { onClick: () => setOpen(!open) } as Record, + children ?? render.props.children, + ) + } return }, diff --git a/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx b/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx index 2ba950fd68..30c1ee9c0c 100644 --- a/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx +++ b/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx @@ -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 void | Promise>()) vi.mock('@/hooks/use-theme', () => ({ default: () => ({ @@ -9,17 +10,22 @@ vi.mock('@/hooks/use-theme', () => ({ }), })) -vi.mock('../../utils', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - getKeyboardKeyCodeBySystem: () => 'ctrl', - } -}) +vi.mock('../../shortcuts/use-workflow-hotkeys', () => ({ + useWorkflowShortcut: (id: string, callback: () => void | Promise) => { + 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 }) =>
{children}
, +})) 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() - 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) }) diff --git a/web/app/components/workflow/header/run-mode.tsx b/web/app/components/workflow/header/run-mode.tsx index 923f6f0330..108d1f98da 100644 --- a/web/app/components/workflow/header/run-mode.tsx +++ b/web/app/components/workflow/header/run-mode.tsx @@ -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(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} > - + {isListening ? t('common.listening', { ns: 'workflow' }) : t('common.running', { ns: 'workflow' })} ) @@ -127,16 +122,17 @@ const RunMode = ({ options={dynamicOptions} onSelect={handleTriggerSelect} > -
- + {text ?? t('common.run', { ns: 'workflow' })} - -
+ + ) } diff --git a/web/app/components/workflow/header/test-run-menu-helpers.tsx b/web/app/components/workflow/header/test-run-menu-helpers.tsx index 9f14190c54..dc2b5acd03 100644 --- a/web/app/components/workflow/header/test-run-menu-helpers.tsx +++ b/web/app/components/workflow/header/test-run-menu-helpers.tsx @@ -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 = ({ {option.name}
{shortcutKey && ( - + )}
) @@ -111,8 +111,8 @@ export const SingleOptionTrigger = ({ } return ( - + ) } diff --git a/web/app/components/workflow/header/test-run-menu.tsx b/web/app/components/workflow/header/test-run-menu.tsx index ceaf38592f..05e26918e8 100644 --- a/web/app/components/workflow/header/test-run-menu.tsx +++ b/web/app/components/workflow/header/test-run-menu.tsx @@ -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(({ open={open} onOpenChange={setOpen} > - }> - {children} - + {isValidElement(children) + ? ( + + ) + : ( + + {children} + + )} = ({ handleUndo, handleRedo }) => { return (
- + - +
) }) @@ -39,27 +39,30 @@ const VersionHistoryButton: FC = ({ await onClick?.() }, [onClick]) - useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.h`, (e) => { - e.preventDefault() + useWorkflowShortcut('workflow.version-history', () => { handleViewVersionHistory() - }, { exactMatch: true, useCapture: true }) + }) return ( - } - 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" - > - )} - onClick={handleViewVersionHistory} + /> + - - + + ) } diff --git a/web/app/components/workflow/header/view-workflow-history.tsx b/web/app/components/workflow/header/view-workflow-history.tsx index 036f27d38d..849fff9266 100644 --- a/web/app/components/workflow/header/view-workflow-history.tsx +++ b/web/app/components/workflow/header/view-workflow-history.tsx @@ -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 ( ( { if (nodesReadOnly) @@ -148,49 +150,56 @@ const ViewWorkflowHistory = () => { setOpen(nextOpen) }} > - - { - if (nodesReadOnly) - return - setCurrentLogItem() - setShowMessageLogModal(false) - }} + { + if (nodesReadOnly) + return + setCurrentLogItem() + setShowMessageLogModal(false) + }} + > + - - - )} - /> - + + + + + + )} + />
{t('changeHistory.title', { ns: 'workflow' })}
-
+ + + )} onClick={() => { setCurrentLogItem() setShowMessageLogModal(false) - setOpen(false) }} - > - -
+ />
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(() => []) 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() + }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-history.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-history.spec.tsx index 54917d009c..61ca72b13a 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-history.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-history.spec.tsx @@ -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() diff --git a/web/app/components/workflow/hooks/index.ts b/web/app/components/workflow/hooks/index.ts index d3e9a38bff..e0e24ef994 100644 --- a/web/app/components/workflow/hooks/index.ts +++ b/web/app/components/workflow/hooks/index.ts @@ -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' diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts deleted file mode 100644 index e4100908ff..0000000000 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ /dev/null @@ -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]) -} diff --git a/web/app/components/workflow/hooks/use-workflow-history.ts b/web/app/components/workflow/hooks/use-workflow-history.ts index 7feaec9709..6222102d5b 100644 --- a/web/app/components/workflow/hooks/use-workflow-history.ts +++ b/web/app/components/workflow/hooks/use-workflow-history.ts @@ -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, diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index c535196691..eff84ae01a 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -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 = 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 ( - {children} - + ) } diff --git a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx index 62cf571ae0..b9d57eb5a8 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx @@ -35,10 +35,15 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
{children}
), - DropdownMenuTrigger: ({ children, render }: { children: ReactNode, render?: ReactNode }) => { + DropdownMenuTrigger: ({ children, render }: { children: ReactNode, render?: React.ReactElement<{ children?: ReactNode }> }) => { const { open, setOpen } = useDropdownMenuContext() - if (render) - return
setOpen(!open)}>{children}
+ if (render) { + return React.cloneElement( + render, + { onClick: () => setOpen(!open) } as Record, + children ?? render.props.children, + ) + } return }, @@ -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 }) => ( - ), diff --git a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx index 2dd45bbe3c..b6681695fd 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx @@ -89,11 +89,13 @@ const Operator = ({ open={open} onOpenChange={onOpenChange} > - }> - - + + + + )} + /> { canRunBySingle(data.type, isChildNode) && ( -
{ @@ -80,7 +81,7 @@ const PanelOperatorPopup = ({ }} > {t('panel.runThisStep', { ns: 'workflow' })} -
+ ) } { @@ -104,26 +105,28 @@ const PanelOperatorPopup = ({ !nodeMetaData.isSingleton && ( <>
-
{ onClosePopup() handleNodesCopy(id) }} > {t('common.copy', { ns: 'workflow' })} - -
-
+ +
+ +
@@ -133,16 +136,17 @@ const PanelOperatorPopup = ({ !nodeMetaData.isUndeletable && ( <>
-
handleNodeDelete(id)} > {t('operation.delete', { ns: 'common' })} - -
+ +
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx index 9a72df3d31..4e9f54455c 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx @@ -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 = ({ }) => { 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 = ({ onClick={onConfirm} > {t('operation.confirm', { ns: 'common' })} - +
) diff --git a/web/app/components/workflow/note-node/__tests__/index.spec.tsx b/web/app/components/workflow/note-node/__tests__/index.spec.tsx index 1bfa14ffb1..7b20c0de08 100644 --- a/web/app/components/workflow/note-node/__tests__/index.spec.tsx +++ b/web/app/components/workflow/note-node/__tests__/index.spec.tsx @@ -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 => ({ title: '', desc: '', diff --git a/web/app/components/workflow/note-node/index.tsx b/web/app/components/workflow/note-node/index.tsx index fa69f05841..321df4cb09 100644 --- a/web/app/components/workflow/note-node/index.tsx +++ b/web/app/components/workflow/note-node/index.tsx @@ -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) => { const { t } = useTranslation() const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey) + const setHistoryShortcutsEnabled = useStore(s => s.setHistoryShortcutsEnabled) const ref = useRef(null) const theme = data.theme const { @@ -54,8 +54,6 @@ const NoteNode = ({ handleNodeDataUpdateWithSyncDraft({ id, data: { selected: false } }) }, ref) - const { setShortcutsEnabled } = useWorkflowHistoryStore() - return (
diff --git a/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx b/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx index b675f57849..516eb5bcd9 100644 --- a/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/__tests__/editor.spec.tsx @@ -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) }) }) diff --git a/web/app/components/workflow/note-node/note-editor/editor.tsx b/web/app/components/workflow/note-node/note-editor/editor.tsx index ab2c3df0c4..7af1b0ddeb 100644 --- a/web/app/components/workflow/note-node/note-editor/editor.tsx +++ b/web/app/components/workflow/note-node/note-editor/editor.tsx @@ -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={(
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" /> diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx index bfbfe317c7..fdc53a7d54 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx @@ -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' })} - + {t('common.duplicate', { ns: 'workflow' })} - +
@@ -107,7 +107,7 @@ const Operator = ({ }} > {t('operation.delete', { ns: 'common' })} - + diff --git a/web/app/components/workflow/operator/control.tsx b/web/app/components/workflow/operator/control.tsx index 07cec0360e..0ef4745e3e 100644 --- a/web/app/components/workflow/operator/control.tsx +++ b/web/app/components/workflow/operator/control.tsx @@ -47,7 +47,7 @@ const Control = () => { } = useNodesReadOnly() const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize() - const addNote = (e: MouseEvent) => { + const addNote = (e: MouseEvent) => { if (getNodesReadOnly()) return @@ -59,19 +59,25 @@ const Control = () => {
-
- -
+ +
- -
+
+ +
- -
+
+ +
{isCommentModeAvailable && ( - -
+
+ +
)} - -
+
+ +
- -
+
+ {maximizeCanvas && } + {!maximizeCanvas && } +
diff --git a/web/app/components/workflow/operator/tip-popup.tsx b/web/app/components/workflow/operator/tip-popup.tsx index 226a889359..0beb9453bd 100644 --- a/web/app/components/workflow/operator/tip-popup.tsx +++ b/web/app/components/workflow/operator/tip-popup.tsx @@ -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 ( - + +
{title} { - shortcuts && + shortcut && }
- )} - > - {children} +
) } diff --git a/web/app/components/workflow/operator/zoom-in-out.tsx b/web/app/components/workflow/operator/zoom-in-out.tsx index d7f172d2d1..ea2ff76977 100644 --- a/web/app/components/workflow/operator/zoom-in-out.tsx +++ b/web/app/components/workflow/operator/zoom-in-out.tsx @@ -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 = ({
-
{ if (zoom <= 0.25) @@ -194,7 +197,7 @@ const ZoomInOut: FC = ({ }} > -
+
= ({
{option.key === ZoomType.zoomToFit && ( - + )} {option.key === ZoomType.zoomTo50 && ( - + )} {option.key === ZoomType.zoomTo100 && ( - + )}
@@ -281,9 +284,12 @@ const ZoomInOut: FC = ({
-
= 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 = ({ }} > -
+
diff --git a/web/app/components/workflow/panel-contextmenu.tsx b/web/app/components/workflow/panel-contextmenu.tsx index 4478839077..5bac2e2364 100644 --- a/web/app/components/workflow/panel-contextmenu.tsx +++ b/web/app/components/workflow/panel-contextmenu.tsx @@ -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 ( -
{t('common.addBlock', { ns: 'workflow' })} -
+ ) } @@ -68,8 +69,9 @@ const PanelContextmenu = () => { crossAxis: -4, }} /> -
{ e.stopPropagation() handleAddNote() @@ -77,11 +79,13 @@ const PanelContextmenu = () => { }} > {t('nodes.note.addNote', { ns: 'workflow' })} -
+ {isCommentModeAvailable && ( -
{ @@ -94,24 +98,27 @@ const PanelContextmenu = () => { }} > {t('comments.actions.addComment', { ns: 'workflow' })} -
+ )} -
{ handleStartWorkflowRun() handlePaneContextmenuCancel() }} > {t('common.run', { ns: 'workflow' })} - -
+ +
-
{ @@ -122,23 +129,25 @@ const PanelContextmenu = () => { }} > {t('common.pasteHere', { ns: 'workflow' })} - -
+ +
-
exportCheck?.()} > {t('export', { ns: 'app' })} -
-
+
+
) diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 26b5429df4..56a1afbba6 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -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} > {t('common.copy', { defaultValue: 'common.copy', ns: 'workflow' })} - + { onClick={handleDuplicateNodes} > {t('common.duplicate', { defaultValue: 'common.duplicate', ns: 'workflow' })} - + @@ -406,7 +406,7 @@ const SelectionContextmenu = () => { onClick={handleDeleteNodes} > {t('operation.delete', { defaultValue: 'operation.delete', ns: 'common' })} - + diff --git a/web/app/components/workflow/shortcuts/__tests__/shortcut-kbd.spec.tsx b/web/app/components/workflow/shortcuts/__tests__/shortcut-kbd.spec.tsx new file mode 100644 index 0000000000..d1be1bf008 --- /dev/null +++ b/web/app/components/workflow/shortcuts/__tests__/shortcut-kbd.spec.tsx @@ -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( + , + ) + + 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( + , + ) + + expect(container.querySelectorAll('kbd')).toHaveLength(1) + expect(screen.getByText('⌦')).toBeInTheDocument() + }) + + it('uses TanStack non-mac modifier labels', () => { + render() + + expect(screen.getByText('Ctrl')).toBeInTheDocument() + expect(screen.getByText('C')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/shortcuts/commands.ts b/web/app/components/workflow/shortcuts/commands.ts new file mode 100644 index 0000000000..99537fde26 --- /dev/null +++ b/web/app/components/workflow/shortcuts/commands.ts @@ -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) +} diff --git a/web/app/components/workflow/shortcuts/definitions.ts b/web/app/components/workflow/shortcuts/definitions.ts new file mode 100644 index 0000000000..c0566032c3 --- /dev/null +++ b/web/app/components/workflow/shortcuts/definitions.ts @@ -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 = { + '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]! +} diff --git a/web/app/components/workflow/shortcuts/shortcut-kbd.tsx b/web/app/components/workflow/shortcuts/shortcut-kbd.tsx new file mode 100644 index 0000000000..8e36e08f3c --- /dev/null +++ b/web/app/components/workflow/shortcuts/shortcut-kbd.tsx @@ -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 ( + + { + displayKeys.map((key, index) => ( + + {key} + + )) + } + + ) +} diff --git a/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts b/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts new file mode 100644 index 0000000000..98b23d08e0 --- /dev/null +++ b/web/app/components/workflow/shortcuts/use-workflow-hotkeys.ts @@ -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((event) => { + if (!shouldHandleCopy()) + return + + event.preventDefault() + event.stopPropagation() + handleNodesCopy() + }, [handleNodesCopy, shouldHandleCopy]) + + const handleZenToggle = useCallback(() => { + handleToggleMaximizeCanvas() + }, [handleToggleMaximizeCanvas]) + + const hotkeys = useMemo(() => [ + ...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]) +} diff --git a/web/app/components/workflow/store/workflow/history-slice.ts b/web/app/components/workflow/store/workflow/history-slice.ts index 47d1b8ad42..7f527c2c8f 100644 --- a/web/app/components/workflow/store/workflow/history-slice.ts +++ b/web/app/components/workflow/store/workflow/history-slice.ts @@ -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 + +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 = 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, diff --git a/web/app/components/workflow/store/workflow/index.ts b/web/app/components/workflow/store/workflow/index.ts index 4bbe117b7b..541fecce2d 100644 --- a/web/app/components/workflow/store/workflow/index.ts +++ b/web/app/components/workflow/store/workflow/index.ts @@ -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 & { + temporal: StoreApi> +} + export type InjectWorkflowStoreSliceFn = StateCreator type CreateWorkflowStoreParams = { @@ -69,27 +80,35 @@ type CreateWorkflowStoreParams = { export const createWorkflowStore = (params: CreateWorkflowStoreParams) => { const { injectWorkflowStoreSliceFn } = params || {} - return createStore((...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()( + temporal( + (...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(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(selector: (state: Shape) => T): T { } export const useWorkflowStore = () => { - return useContext(WorkflowContext)! + return use(WorkflowContext)! } diff --git a/web/app/components/workflow/workflow-history-store.ts b/web/app/components/workflow/workflow-history-store.ts new file mode 100644 index 0000000000..83b32ed3e9 --- /dev/null +++ b/web/app/components/workflow/workflow-history-store.ts @@ -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 + subscribe: (listener: (state: TemporalState) => 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, +): Partial => { + return state?.workflowHistory ?? {} +} + +const toTemporalState = ( + temporalState: TemporalState, +): TemporalState => ({ + 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], + ), + } +} diff --git a/web/app/components/workflow/workflow-history-store.tsx b/web/app/components/workflow/workflow-history-store.tsx deleted file mode 100644 index 97c9f2ac33..0000000000 --- a/web/app/components/workflow/workflow-history-store.tsx +++ /dev/null @@ -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({ 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 ( - - {children} - - ) -} - -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( - (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 | null - shortcutsEnabled: boolean - setShortcutsEnabled: (enabled: boolean) => void -} - -export type WorkflowHistoryStoreApi = StoreApi & { temporal: StoreApi> } - -type WorkflowWithHistoryProviderProps = { - nodes: Node[] - edges: Edge[] - children: ReactNode -} - -export type WorkflowHistoryEventMeta = { - nodeId?: string - nodeTitle?: string -} diff --git a/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx b/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx index 9ea32caec3..92a96a030f 100644 --- a/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx +++ b/web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx @@ -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 = () => {
-
{ if (zoom <= 0.25) @@ -117,7 +120,7 @@ const ZoomInOut: FC = () => { }} > -
+
{ {option.text}
{option.key === ZoomType.zoomToFit && ( - + )} {option.key === ZoomType.zoomTo50 && ( - + )} {option.key === ZoomType.zoomTo100 && ( - + )}
@@ -168,9 +171,12 @@ const ZoomInOut: FC = () => {
-
= 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 = () => { }} > -
+
diff --git a/web/package.json b/web/package.json index abd1d24dcd..092d82a17e 100644 --- a/web/package.json +++ b/web/package.json @@ -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:",