mirror of
https://github.com/langgenius/dify.git
synced 2026-04-06 18:00:33 -04:00
feat: add to snippet
This commit is contained in:
178
web/app/components/workflow/create-snippet-dialog.tsx
Normal file
178
web/app/components/workflow/create-snippet-dialog.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import AppIconPicker from '@/app/components/base/app-icon-picker'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogPortal, DialogTitle } from '@/app/components/base/ui/dialog'
|
||||
import ShortcutsName from './shortcuts-name'
|
||||
|
||||
export type CreateSnippetDialogPayload = {
|
||||
name: string
|
||||
description: string
|
||||
icon: AppIconSelection
|
||||
selectedNodeIds: string[]
|
||||
}
|
||||
|
||||
type CreateSnippetDialogProps = {
|
||||
isOpen: boolean
|
||||
selectedNodeIds: string[]
|
||||
onClose: () => void
|
||||
onConfirm: (payload: CreateSnippetDialogPayload) => void
|
||||
}
|
||||
|
||||
const defaultIcon: AppIconSelection = {
|
||||
type: 'emoji',
|
||||
icon: '🤖',
|
||||
background: '#FFEAD5',
|
||||
}
|
||||
|
||||
const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
|
||||
isOpen,
|
||||
selectedNodeIds,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [icon, setIcon] = useState<AppIconSelection>(defaultIcon)
|
||||
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setIcon(defaultIcon)
|
||||
setShowAppIconPicker(false)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetForm()
|
||||
onClose()
|
||||
}, [onClose, resetForm])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
const trimmedName = name.trim()
|
||||
const trimmedDescription = description.trim()
|
||||
|
||||
if (!trimmedName)
|
||||
return
|
||||
|
||||
const payload = {
|
||||
name: trimmedName,
|
||||
description: trimmedDescription,
|
||||
icon,
|
||||
selectedNodeIds,
|
||||
}
|
||||
|
||||
onConfirm(payload)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('snippet.createSuccess', { ns: 'workflow' }),
|
||||
})
|
||||
handleClose()
|
||||
}, [description, handleClose, icon, name, onConfirm, selectedNodeIds, t])
|
||||
|
||||
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
|
||||
if (!isOpen)
|
||||
return
|
||||
|
||||
handleConfirm()
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={open => !open && handleClose()}>
|
||||
<DialogContent className="w-[520px] max-w-[520px] p-0">
|
||||
<DialogCloseButton />
|
||||
|
||||
<div className="px-6 pb-3 pt-6">
|
||||
<DialogTitle className="text-text-primary title-2xl-semi-bold">
|
||||
{t('snippet.createDialogTitle', { ns: 'workflow' })}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 py-2">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1 pb-0.5">
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">
|
||||
{t('snippet.nameLabel', { ns: 'workflow' })}
|
||||
</div>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('snippet.namePlaceholder', { ns: 'workflow' }) || ''}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AppIcon
|
||||
size="xxl"
|
||||
className="shrink-0 cursor-pointer"
|
||||
iconType={icon.type}
|
||||
icon={icon.type === 'emoji' ? icon.icon : icon.fileId}
|
||||
background={icon.type === 'emoji' ? icon.background : undefined}
|
||||
imageUrl={icon.type === 'image' ? icon.url : undefined}
|
||||
onClick={() => setShowAppIconPicker(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">
|
||||
{t('snippet.descriptionLabel', { ns: 'workflow' })}
|
||||
</div>
|
||||
<Textarea
|
||||
className="resize-none"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder={t('snippet.descriptionPlaceholder', { ns: 'workflow' }) || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 pb-6 pt-5">
|
||||
<Button onClick={handleClose}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!name.trim()}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{t('snippet.confirm', { ns: 'workflow' })}
|
||||
<ShortcutsName className="ml-1" keys={['ctrl', 'enter']} bgColor="white" />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
<DialogPortal>
|
||||
<div className="pointer-events-none fixed left-1/2 top-1/2 z-[1002] flex -translate-x-1/2 translate-y-[170px] items-center gap-1 text-text-quaternary body-xs-regular">
|
||||
<span>{t('snippet.shortcuts.press', { ns: 'workflow' })}</span>
|
||||
<ShortcutsName keys={['ctrl', 'enter']} textColor="secondary" />
|
||||
<span>{t('snippet.shortcuts.toConfirm', { ns: 'workflow' })}</span>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
|
||||
{showAppIconPicker && (
|
||||
<AppIconPicker
|
||||
className="z-[1100]"
|
||||
onSelect={(selection) => {
|
||||
setIcon(selection)
|
||||
setShowAppIconPicker(false)
|
||||
}}
|
||||
onClose={() => setShowAppIconPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateSnippetDialog
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
RiAlignRight,
|
||||
RiAlignTop,
|
||||
} from '@remixicon/react'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import { useClickAway, useKeyPress } from 'ahooks'
|
||||
import { produce } from 'immer'
|
||||
import {
|
||||
memo,
|
||||
@@ -14,13 +14,17 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
|
||||
import CreateSnippetDialog from './create-snippet-dialog'
|
||||
import { 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 { useStore, useWorkflowStore } from './store'
|
||||
import { BlockEnum, TRIGGER_NODE_TYPES } from './types'
|
||||
|
||||
enum AlignType {
|
||||
Left = 'left',
|
||||
@@ -39,6 +43,8 @@ const SelectionContextmenu = () => {
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const { handleSelectionContextmenuCancel } = useSelectionInteractions()
|
||||
const selectionMenu = useStore(s => s.selectionMenu)
|
||||
const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false)
|
||||
const [selectedNodeIdsSnapshot, setSelectedNodeIdsSnapshot] = useState<string[]>([])
|
||||
|
||||
// Access React Flow methods
|
||||
const store = useStoreApi()
|
||||
@@ -67,7 +73,7 @@ const SelectionContextmenu = () => {
|
||||
|
||||
const menuWidth = 240
|
||||
|
||||
const estimatedMenuHeight = 380
|
||||
const estimatedMenuHeight = 420
|
||||
|
||||
if (left + menuWidth > containerWidth)
|
||||
left = left - menuWidth
|
||||
@@ -91,6 +97,32 @@ const SelectionContextmenu = () => {
|
||||
handleSelectionContextmenuCancel()
|
||||
}, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel])
|
||||
|
||||
const isAddToSnippetDisabled = useMemo(() => {
|
||||
return selectedNodes.some(node =>
|
||||
node.data.type === BlockEnum.Start || TRIGGER_NODE_TYPES.includes(node.data.type as typeof TRIGGER_NODE_TYPES[number]))
|
||||
}, [selectedNodes])
|
||||
|
||||
const handleOpenCreateSnippetDialog = useCallback(() => {
|
||||
if (isAddToSnippetDisabled)
|
||||
return
|
||||
|
||||
setSelectedNodeIdsSnapshot(selectedNodes.map(node => node.id))
|
||||
setIsCreateSnippetDialogOpen(true)
|
||||
handleSelectionContextmenuCancel()
|
||||
}, [handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes])
|
||||
|
||||
const handleCloseCreateSnippetDialog = useCallback(() => {
|
||||
setIsCreateSnippetDialogOpen(false)
|
||||
setSelectedNodeIdsSnapshot([])
|
||||
}, [])
|
||||
|
||||
useKeyPress(['meta.g', 'ctrl.g'], () => {
|
||||
if (!selectionMenu || isAddToSnippetDisabled)
|
||||
return
|
||||
|
||||
handleOpenCreateSnippetDialog()
|
||||
})
|
||||
|
||||
// Handle align nodes logic
|
||||
const handleAlignNode = useCallback((currentNode: any, nodeToAlign: any, alignType: AlignType, minX: number, maxX: number, minY: number, maxY: number) => {
|
||||
const width = nodeToAlign.width
|
||||
@@ -369,88 +401,114 @@ const SelectionContextmenu = () => {
|
||||
}
|
||||
}, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes])
|
||||
|
||||
if (!selectionMenu)
|
||||
if (!selectionMenu && !isCreateSnippetDialogOpen)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[9]"
|
||||
style={{
|
||||
left: menuPosition.left,
|
||||
top: menuPosition.top,
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<div ref={menuRef} className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
|
||||
<div className="p-1">
|
||||
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
|
||||
{t('operator.vertical', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Top)}
|
||||
>
|
||||
<RiAlignTop className="h-4 w-4" />
|
||||
{t('operator.alignTop', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Middle)}
|
||||
>
|
||||
<RiAlignCenter className="h-4 w-4 rotate-90" />
|
||||
{t('operator.alignMiddle', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Bottom)}
|
||||
>
|
||||
<RiAlignBottom className="h-4 w-4" />
|
||||
{t('operator.alignBottom', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.DistributeVertical)}
|
||||
>
|
||||
<RiAlignJustify className="h-4 w-4 rotate-90" />
|
||||
{t('operator.distributeVertical', { ns: 'workflow' })}
|
||||
<>
|
||||
{selectionMenu && (
|
||||
<div
|
||||
className="absolute z-[9]"
|
||||
style={{
|
||||
left: menuPosition.left,
|
||||
top: menuPosition.top,
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<div ref={menuRef} className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-30 data-[disabled=true]:hover:bg-transparent"
|
||||
data-disabled={isAddToSnippetDisabled}
|
||||
aria-disabled={isAddToSnippetDisabled}
|
||||
onClick={handleOpenCreateSnippetDialog}
|
||||
>
|
||||
<span>{t('snippet.addToSnippet', { ns: 'workflow' })}</span>
|
||||
{!isAddToSnippetDisabled && (
|
||||
<ShortcutsName className="ml-auto" keys={['ctrl', 'g']} textColor="secondary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular"></div>
|
||||
<div className="p-1">
|
||||
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
|
||||
{t('operator.vertical', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Top)}
|
||||
>
|
||||
<RiAlignTop className="h-4 w-4" />
|
||||
{t('operator.alignTop', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Middle)}
|
||||
>
|
||||
<RiAlignCenter className="h-4 w-4 rotate-90" />
|
||||
{t('operator.alignMiddle', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Bottom)}
|
||||
>
|
||||
<RiAlignBottom className="h-4 w-4" />
|
||||
{t('operator.alignBottom', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.DistributeVertical)}
|
||||
>
|
||||
<RiAlignJustify className="h-4 w-4 rotate-90" />
|
||||
{t('operator.distributeVertical', { ns: 'workflow' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular"></div>
|
||||
<div className="p-1">
|
||||
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
|
||||
{t('operator.horizontal', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Left)}
|
||||
>
|
||||
<RiAlignLeft className="h-4 w-4" />
|
||||
{t('operator.alignLeft', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Center)}
|
||||
>
|
||||
<RiAlignCenter className="h-4 w-4" />
|
||||
{t('operator.alignCenter', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Right)}
|
||||
>
|
||||
<RiAlignRight className="h-4 w-4" />
|
||||
{t('operator.alignRight', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.DistributeHorizontal)}
|
||||
>
|
||||
<RiAlignJustify className="h-4 w-4" />
|
||||
{t('operator.distributeHorizontal', { ns: 'workflow' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular"></div>
|
||||
<div className="p-1">
|
||||
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
|
||||
{t('operator.horizontal', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Left)}
|
||||
>
|
||||
<RiAlignLeft className="h-4 w-4" />
|
||||
{t('operator.alignLeft', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Center)}
|
||||
>
|
||||
<RiAlignCenter className="h-4 w-4" />
|
||||
{t('operator.alignCenter', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Right)}
|
||||
>
|
||||
<RiAlignRight className="h-4 w-4" />
|
||||
{t('operator.alignRight', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.DistributeHorizontal)}
|
||||
>
|
||||
<RiAlignJustify className="h-4 w-4" />
|
||||
{t('operator.distributeHorizontal', { ns: 'workflow' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CreateSnippetDialog
|
||||
isOpen={isCreateSnippetDialogOpen}
|
||||
selectedNodeIds={selectedNodeIdsSnapshot}
|
||||
onClose={handleCloseCreateSnippetDialog}
|
||||
onConfirm={(payload) => {
|
||||
void payload
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1083,6 +1083,16 @@
|
||||
"singleRun.testRun": "Test Run",
|
||||
"singleRun.testRunIteration": "Test Run Iteration",
|
||||
"singleRun.testRunLoop": "Test Run Loop",
|
||||
"snippet.addToSnippet": "Add to snippet",
|
||||
"snippet.confirm": "Confirm",
|
||||
"snippet.createDialogTitle": "Create Snippet",
|
||||
"snippet.createSuccess": "Snippet created",
|
||||
"snippet.descriptionLabel": "Description (Optional)",
|
||||
"snippet.descriptionPlaceholder": "Briefly describe your snippet",
|
||||
"snippet.nameLabel": "Snippet Name & Icon",
|
||||
"snippet.namePlaceholder": "Snippet name",
|
||||
"snippet.shortcuts.press": "Press",
|
||||
"snippet.shortcuts.toConfirm": "to confirm",
|
||||
"tabs.-": "Default",
|
||||
"tabs.addAll": "Add all",
|
||||
"tabs.agent": "Agent Strategy",
|
||||
|
||||
@@ -1083,6 +1083,16 @@
|
||||
"singleRun.testRun": "测试运行",
|
||||
"singleRun.testRunIteration": "测试运行迭代",
|
||||
"singleRun.testRunLoop": "测试运行循环",
|
||||
"snippet.addToSnippet": "添加到 snippet",
|
||||
"snippet.confirm": "确认",
|
||||
"snippet.createDialogTitle": "创建 Snippet",
|
||||
"snippet.createSuccess": "Snippet 已创建",
|
||||
"snippet.descriptionLabel": "描述(可选)",
|
||||
"snippet.descriptionPlaceholder": "简要描述你的 snippet",
|
||||
"snippet.nameLabel": "Snippet 名称和图标",
|
||||
"snippet.namePlaceholder": "Snippet 名称",
|
||||
"snippet.shortcuts.press": "按下",
|
||||
"snippet.shortcuts.toConfirm": "确认",
|
||||
"tabs.-": "默认",
|
||||
"tabs.addAll": "添加全部",
|
||||
"tabs.agent": "Agent 策略",
|
||||
|
||||
Reference in New Issue
Block a user