feat: add to snippet

This commit is contained in:
zxhlyh
2026-03-13 14:29:59 +08:00
parent f7afa103a5
commit c1011f4e5c
4 changed files with 335 additions and 79 deletions

View 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

View File

@@ -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
}}
/>
</>
)
}

View File

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

View File

@@ -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 策略",