mirror of
https://github.com/langgenius/dify.git
synced 2026-04-04 21:00:18 -04:00
584 lines
19 KiB
TypeScript
584 lines
19 KiB
TypeScript
import type { CreateSnippetDialogPayload } from './create-snippet-dialog'
|
|
import type { Edge, Node } from './types'
|
|
import type { SnippetCanvasData } from '@/models/snippet'
|
|
import { produce } from 'immer'
|
|
import {
|
|
memo,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
} from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuSeparator,
|
|
} from '@/app/components/base/ui/context-menu'
|
|
import { toast } from '@/app/components/base/ui/toast'
|
|
import { useRouter } from '@/next/navigation'
|
|
import { consoleClient } from '@/service/client'
|
|
import { useCreateSnippetMutation } from '@/service/use-snippets'
|
|
import { cn } from '@/utils/classnames'
|
|
import CreateSnippetDialog from './create-snippet-dialog'
|
|
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 { useStore, useWorkflowStore } from './store'
|
|
import { BlockEnum, TRIGGER_NODE_TYPES } from './types'
|
|
|
|
const AlignType = {
|
|
Bottom: 'bottom',
|
|
Center: 'center',
|
|
DistributeHorizontal: 'distributeHorizontal',
|
|
DistributeVertical: 'distributeVertical',
|
|
Left: 'left',
|
|
Middle: 'middle',
|
|
Right: 'right',
|
|
Top: 'top',
|
|
} as const
|
|
|
|
type AlignTypeValue = (typeof AlignType)[keyof typeof AlignType]
|
|
|
|
type AlignBounds = {
|
|
minX: number
|
|
maxX: number
|
|
minY: number
|
|
maxY: number
|
|
}
|
|
|
|
type AlignMenuItem = {
|
|
alignType: AlignTypeValue
|
|
icon: string
|
|
iconClassName?: string
|
|
translationKey: string
|
|
}
|
|
|
|
type ActionMenuItem = {
|
|
action: 'copy' | 'createSnippet' | 'delete' | 'duplicate'
|
|
disabled?: boolean
|
|
shortcutKeys?: string[]
|
|
translationKey: string
|
|
}
|
|
|
|
const DEFAULT_SNIPPET_VIEWPORT: SnippetCanvasData['viewport'] = { x: 0, y: 0, zoom: 1 }
|
|
|
|
const alignMenuItems: AlignMenuItem[] = [
|
|
{ alignType: AlignType.Left, icon: 'i-ri-align-item-left-line', translationKey: 'operator.alignLeft' },
|
|
{ alignType: AlignType.Center, icon: 'i-ri-align-item-horizontal-center-line', translationKey: 'operator.alignCenter' },
|
|
{ alignType: AlignType.Right, icon: 'i-ri-align-item-right-line', translationKey: 'operator.alignRight' },
|
|
{ alignType: AlignType.Top, icon: 'i-ri-align-item-top-line', translationKey: 'operator.alignTop' },
|
|
{ alignType: AlignType.Middle, icon: 'i-ri-align-item-vertical-center-line', iconClassName: 'rotate-90', translationKey: 'operator.alignMiddle' },
|
|
{ alignType: AlignType.Bottom, icon: 'i-ri-align-item-bottom-line', translationKey: 'operator.alignBottom' },
|
|
{ alignType: AlignType.DistributeHorizontal, icon: 'i-ri-align-justify-line', translationKey: 'operator.distributeHorizontal' },
|
|
{ alignType: AlignType.DistributeVertical, icon: 'i-ri-align-justify-line', iconClassName: 'rotate-90', translationKey: 'operator.distributeVertical' },
|
|
]
|
|
|
|
const getAlignableNodes = (nodes: Node[], selectedNodes: Node[]) => {
|
|
const selectedNodeIds = new Set(selectedNodes.map(node => node.id))
|
|
const childNodeIds = new Set<string>()
|
|
|
|
nodes.forEach((node) => {
|
|
if (!node.data._children?.length || !selectedNodeIds.has(node.id))
|
|
return
|
|
|
|
node.data._children.forEach((child) => {
|
|
childNodeIds.add(child.nodeId)
|
|
})
|
|
})
|
|
|
|
return nodes.filter(node => selectedNodeIds.has(node.id) && !childNodeIds.has(node.id))
|
|
}
|
|
|
|
const getAlignBounds = (nodes: Node[]): AlignBounds | null => {
|
|
const validNodes = nodes.filter(node => node.width && node.height)
|
|
if (validNodes.length <= 1)
|
|
return null
|
|
|
|
return validNodes.reduce<AlignBounds>((bounds, node) => {
|
|
const width = node.width!
|
|
const height = node.height!
|
|
|
|
return {
|
|
minX: Math.min(bounds.minX, node.position.x),
|
|
maxX: Math.max(bounds.maxX, node.position.x + width),
|
|
minY: Math.min(bounds.minY, node.position.y),
|
|
maxY: Math.max(bounds.maxY, node.position.y + height),
|
|
}
|
|
}, {
|
|
minX: Number.MAX_SAFE_INTEGER,
|
|
maxX: Number.MIN_SAFE_INTEGER,
|
|
minY: Number.MAX_SAFE_INTEGER,
|
|
maxY: Number.MIN_SAFE_INTEGER,
|
|
})
|
|
}
|
|
|
|
const alignNodePosition = (
|
|
currentNode: Node,
|
|
nodeToAlign: Node,
|
|
alignType: AlignTypeValue,
|
|
bounds: AlignBounds,
|
|
) => {
|
|
const width = nodeToAlign.width ?? 0
|
|
const height = nodeToAlign.height ?? 0
|
|
|
|
switch (alignType) {
|
|
case AlignType.Left:
|
|
currentNode.position.x = bounds.minX
|
|
if (currentNode.positionAbsolute)
|
|
currentNode.positionAbsolute.x = bounds.minX
|
|
break
|
|
case AlignType.Center: {
|
|
const centerX = bounds.minX + (bounds.maxX - bounds.minX) / 2 - width / 2
|
|
currentNode.position.x = centerX
|
|
if (currentNode.positionAbsolute)
|
|
currentNode.positionAbsolute.x = centerX
|
|
break
|
|
}
|
|
case AlignType.Right: {
|
|
const rightX = bounds.maxX - width
|
|
currentNode.position.x = rightX
|
|
if (currentNode.positionAbsolute)
|
|
currentNode.positionAbsolute.x = rightX
|
|
break
|
|
}
|
|
case AlignType.Top:
|
|
currentNode.position.y = bounds.minY
|
|
if (currentNode.positionAbsolute)
|
|
currentNode.positionAbsolute.y = bounds.minY
|
|
break
|
|
case AlignType.Middle: {
|
|
const middleY = bounds.minY + (bounds.maxY - bounds.minY) / 2 - height / 2
|
|
currentNode.position.y = middleY
|
|
if (currentNode.positionAbsolute)
|
|
currentNode.positionAbsolute.y = middleY
|
|
break
|
|
}
|
|
case AlignType.Bottom: {
|
|
const bottomY = Math.round(bounds.maxY - height)
|
|
currentNode.position.y = bottomY
|
|
if (currentNode.positionAbsolute)
|
|
currentNode.positionAbsolute.y = bottomY
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
const distributeNodes = (
|
|
nodesToAlign: Node[],
|
|
nodes: Node[],
|
|
alignType: AlignTypeValue,
|
|
) => {
|
|
const isHorizontal = alignType === AlignType.DistributeHorizontal
|
|
const sortedNodes = [...nodesToAlign].sort((a, b) =>
|
|
isHorizontal ? a.position.x - b.position.x : a.position.y - b.position.y)
|
|
|
|
if (sortedNodes.length < 3)
|
|
return null
|
|
|
|
const firstNode = sortedNodes[0]
|
|
const lastNode = sortedNodes[sortedNodes.length - 1]
|
|
|
|
const totalGap = isHorizontal
|
|
? lastNode.position.x + (lastNode.width || 0) - firstNode.position.x
|
|
: lastNode.position.y + (lastNode.height || 0) - firstNode.position.y
|
|
|
|
const fixedSpace = sortedNodes.reduce((sum, node) =>
|
|
sum + (isHorizontal ? (node.width || 0) : (node.height || 0)), 0)
|
|
|
|
const spacing = (totalGap - fixedSpace) / (sortedNodes.length - 1)
|
|
if (spacing <= 0)
|
|
return null
|
|
|
|
return produce(nodes, (draft) => {
|
|
let currentPosition = isHorizontal
|
|
? firstNode.position.x + (firstNode.width || 0)
|
|
: firstNode.position.y + (firstNode.height || 0)
|
|
|
|
for (let index = 1; index < sortedNodes.length - 1; index++) {
|
|
const nodeToAlign = sortedNodes[index]
|
|
const currentNode = draft.find(node => node.id === nodeToAlign.id)
|
|
if (!currentNode)
|
|
continue
|
|
|
|
if (isHorizontal) {
|
|
const nextX = currentPosition + spacing
|
|
currentNode.position.x = nextX
|
|
if (currentNode.positionAbsolute)
|
|
currentNode.positionAbsolute.x = nextX
|
|
currentPosition = nextX + (nodeToAlign.width || 0)
|
|
}
|
|
else {
|
|
const nextY = currentPosition + spacing
|
|
currentNode.position.y = nextY
|
|
if (currentNode.positionAbsolute)
|
|
currentNode.positionAbsolute.y = nextY
|
|
currentPosition = nextY + (nodeToAlign.height || 0)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const getSelectedSnippetGraph = (
|
|
nodes: Node[],
|
|
edges: Edge[],
|
|
selectedNodes: Node[],
|
|
): SnippetCanvasData => {
|
|
const includedNodeIds = new Set(selectedNodes.map(node => node.id))
|
|
|
|
let shouldExpand = true
|
|
while (shouldExpand) {
|
|
shouldExpand = false
|
|
|
|
nodes.forEach((node) => {
|
|
if (!includedNodeIds.has(node.id))
|
|
return
|
|
|
|
if (node.parentId && !includedNodeIds.has(node.parentId)) {
|
|
includedNodeIds.add(node.parentId)
|
|
shouldExpand = true
|
|
}
|
|
|
|
node.data._children?.forEach((child) => {
|
|
if (!includedNodeIds.has(child.nodeId)) {
|
|
includedNodeIds.add(child.nodeId)
|
|
shouldExpand = true
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
const rootNodes = nodes.filter(node => includedNodeIds.has(node.id) && (!node.parentId || !includedNodeIds.has(node.parentId)))
|
|
const minRootX = rootNodes.length ? Math.min(...rootNodes.map(node => node.position.x)) : 0
|
|
const minRootY = rootNodes.length ? Math.min(...rootNodes.map(node => node.position.y)) : 0
|
|
|
|
return {
|
|
nodes: nodes
|
|
.filter(node => includedNodeIds.has(node.id))
|
|
.map((node) => {
|
|
const isRootNode = !node.parentId || !includedNodeIds.has(node.parentId)
|
|
const nextPosition = isRootNode
|
|
? { x: node.position.x - minRootX, y: node.position.y - minRootY }
|
|
: node.position
|
|
|
|
return {
|
|
...node,
|
|
position: nextPosition,
|
|
positionAbsolute: node.positionAbsolute
|
|
? (isRootNode
|
|
? {
|
|
x: node.positionAbsolute.x - minRootX,
|
|
y: node.positionAbsolute.y - minRootY,
|
|
}
|
|
: node.positionAbsolute)
|
|
: undefined,
|
|
selected: false,
|
|
data: {
|
|
...node.data,
|
|
selected: false,
|
|
_children: node.data._children?.filter(child => includedNodeIds.has(child.nodeId)),
|
|
},
|
|
}
|
|
}),
|
|
edges: edges
|
|
.filter(edge => includedNodeIds.has(edge.source) && includedNodeIds.has(edge.target))
|
|
.map(edge => ({
|
|
...edge,
|
|
selected: false,
|
|
})),
|
|
viewport: DEFAULT_SNIPPET_VIEWPORT,
|
|
}
|
|
}
|
|
|
|
const SelectionContextmenu = () => {
|
|
const { t } = useTranslation()
|
|
const { push } = useRouter()
|
|
const createSnippetMutation = useCreateSnippetMutation()
|
|
const { getNodesReadOnly } = useNodesReadOnly()
|
|
const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions()
|
|
const { handleSelectionContextmenuCancel } = useSelectionInteractions()
|
|
const selectionMenu = useStore(s => s.selectionMenu)
|
|
const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false)
|
|
const [isCreatingSnippet, setIsCreatingSnippet] = useState(false)
|
|
const [selectedGraphSnapshot, setSelectedGraphSnapshot] = useState<SnippetCanvasData | undefined>()
|
|
|
|
// Access React Flow methods
|
|
const store = useStoreApi()
|
|
const workflowStore = useWorkflowStore()
|
|
const selectedNodes = useReactFlowStore(state =>
|
|
state.getNodes().filter(node => node.selected),
|
|
)
|
|
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
|
const { saveStateToHistory } = useWorkflowHistory()
|
|
|
|
const anchor = useMemo(() => {
|
|
if (!selectionMenu)
|
|
return undefined
|
|
|
|
return {
|
|
getBoundingClientRect: () => DOMRect.fromRect({
|
|
width: 0,
|
|
height: 0,
|
|
x: selectionMenu.clientX,
|
|
y: selectionMenu.clientY,
|
|
}),
|
|
}
|
|
}, [selectionMenu])
|
|
|
|
useEffect(() => {
|
|
if (selectionMenu && selectedNodes.length <= 1)
|
|
handleSelectionContextmenuCancel()
|
|
}, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel])
|
|
|
|
const isAddToSnippetDisabled = useMemo(() => {
|
|
return selectedNodes.some(node =>
|
|
node.data.type === BlockEnum.Start
|
|
|| node.data.type === BlockEnum.End
|
|
|| node.data.type === BlockEnum.HumanInput
|
|
|| TRIGGER_NODE_TYPES.includes(node.data.type as typeof TRIGGER_NODE_TYPES[number]))
|
|
}, [selectedNodes])
|
|
|
|
const handleOpenCreateSnippetDialog = useCallback(() => {
|
|
if (isAddToSnippetDisabled)
|
|
return
|
|
|
|
const nodes = store.getState().getNodes()
|
|
const { edges } = store.getState()
|
|
|
|
setSelectedGraphSnapshot(getSelectedSnippetGraph(nodes, edges, selectedNodes))
|
|
setIsCreateSnippetDialogOpen(true)
|
|
handleSelectionContextmenuCancel()
|
|
}, [handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes, store])
|
|
|
|
const handleCloseCreateSnippetDialog = useCallback(() => {
|
|
setIsCreateSnippetDialogOpen(false)
|
|
setSelectedGraphSnapshot(undefined)
|
|
}, [])
|
|
|
|
const handleCreateSnippet = useCallback(async ({
|
|
name,
|
|
description,
|
|
icon,
|
|
graph,
|
|
}: CreateSnippetDialogPayload) => {
|
|
setIsCreatingSnippet(true)
|
|
|
|
try {
|
|
const snippet = await createSnippetMutation.mutateAsync({
|
|
body: {
|
|
name,
|
|
description: description || undefined,
|
|
icon_info: {
|
|
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
|
|
icon_type: icon.type,
|
|
icon_background: icon.type === 'emoji' ? icon.background : undefined,
|
|
icon_url: icon.type === 'image' ? icon.url : undefined,
|
|
},
|
|
},
|
|
})
|
|
|
|
await consoleClient.snippets.syncDraftWorkflow({
|
|
params: { snippetId: snippet.id },
|
|
body: { graph },
|
|
})
|
|
|
|
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
|
|
handleCloseCreateSnippetDialog()
|
|
push(`/snippets/${snippet.id}/orchestrate`)
|
|
}
|
|
catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' }))
|
|
}
|
|
finally {
|
|
setIsCreatingSnippet(false)
|
|
}
|
|
}, [createSnippetMutation, handleCloseCreateSnippetDialog, push, t])
|
|
|
|
const menuActions = useMemo<ActionMenuItem[]>(() => [
|
|
{
|
|
action: 'createSnippet',
|
|
disabled: isAddToSnippetDisabled,
|
|
translationKey: 'snippet.addToSnippet',
|
|
},
|
|
{
|
|
action: 'copy',
|
|
shortcutKeys: ['ctrl', 'c'],
|
|
translationKey: 'common.copy',
|
|
},
|
|
{
|
|
action: 'duplicate',
|
|
shortcutKeys: ['ctrl', 'd'],
|
|
translationKey: 'common.duplicate',
|
|
},
|
|
{
|
|
action: 'delete',
|
|
shortcutKeys: ['del'],
|
|
translationKey: 'operation.delete',
|
|
},
|
|
], [isAddToSnippetDisabled])
|
|
|
|
const getActionLabel = useCallback((translationKey: string) => {
|
|
if (translationKey === 'operation.delete')
|
|
return t(translationKey, { ns: 'common', defaultValue: translationKey })
|
|
|
|
return t(translationKey, { ns: 'workflow', defaultValue: translationKey })
|
|
}, [t])
|
|
|
|
const handleMenuAction = useCallback((action: ActionMenuItem['action']) => {
|
|
switch (action) {
|
|
case 'createSnippet':
|
|
handleOpenCreateSnippetDialog()
|
|
return
|
|
case 'copy':
|
|
handleSelectionContextmenuCancel()
|
|
handleNodesCopy()
|
|
return
|
|
case 'duplicate':
|
|
handleSelectionContextmenuCancel()
|
|
handleNodesDuplicate()
|
|
return
|
|
case 'delete':
|
|
handleSelectionContextmenuCancel()
|
|
handleNodesDelete()
|
|
}
|
|
}, [handleNodesCopy, handleNodesDelete, handleNodesDuplicate, handleOpenCreateSnippetDialog, handleSelectionContextmenuCancel])
|
|
|
|
const handleAlignNodes = useCallback((alignType: AlignTypeValue) => {
|
|
if (getNodesReadOnly() || selectedNodes.length <= 1) {
|
|
handleSelectionContextmenuCancel()
|
|
return
|
|
}
|
|
|
|
workflowStore.setState({ nodeAnimation: false })
|
|
|
|
const nodes = store.getState().getNodes()
|
|
const nodesToAlign = getAlignableNodes(nodes, selectedNodes)
|
|
|
|
if (nodesToAlign.length <= 1) {
|
|
handleSelectionContextmenuCancel()
|
|
return
|
|
}
|
|
|
|
const bounds = getAlignBounds(nodesToAlign)
|
|
if (!bounds) {
|
|
handleSelectionContextmenuCancel()
|
|
return
|
|
}
|
|
|
|
if (alignType === AlignType.DistributeHorizontal || alignType === AlignType.DistributeVertical) {
|
|
const distributedNodes = distributeNodes(nodesToAlign, nodes, alignType)
|
|
if (distributedNodes) {
|
|
store.getState().setNodes(distributedNodes)
|
|
handleSelectionContextmenuCancel()
|
|
|
|
const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState()
|
|
setHelpLineHorizontal()
|
|
setHelpLineVertical()
|
|
|
|
handleSyncWorkflowDraft()
|
|
saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)
|
|
return
|
|
}
|
|
}
|
|
|
|
const newNodes = produce(nodes, (draft) => {
|
|
const validNodesToAlign = nodesToAlign.filter(node => node.width && node.height)
|
|
validNodesToAlign.forEach((nodeToAlign) => {
|
|
const currentNode = draft.find(n => n.id === nodeToAlign.id)
|
|
if (!currentNode)
|
|
return
|
|
|
|
alignNodePosition(currentNode, nodeToAlign, alignType, bounds)
|
|
})
|
|
})
|
|
|
|
try {
|
|
store.getState().setNodes(newNodes)
|
|
handleSelectionContextmenuCancel()
|
|
const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState()
|
|
setHelpLineHorizontal()
|
|
setHelpLineVertical()
|
|
handleSyncWorkflowDraft()
|
|
saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)
|
|
}
|
|
catch (err) {
|
|
console.error('Failed to update nodes:', err)
|
|
}
|
|
}, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel])
|
|
|
|
if ((!selectionMenu || !anchor) && !isCreateSnippetDialogOpen)
|
|
return null
|
|
|
|
return (
|
|
<div data-testid="selection-contextmenu">
|
|
<ContextMenu
|
|
open
|
|
onOpenChange={(open) => {
|
|
if (!open)
|
|
handleSelectionContextmenuCancel()
|
|
}}
|
|
>
|
|
<ContextMenuContent
|
|
positionerProps={anchor ? { anchor } : undefined}
|
|
popupClassName="w-[240px] py-0"
|
|
>
|
|
<div className="p-1">
|
|
{menuActions.map(item => (
|
|
<ContextMenuItem
|
|
key={item.action}
|
|
data-testid={`selection-contextmenu-item-${item.action}`}
|
|
disabled={item.disabled}
|
|
className={cn(
|
|
'mx-0 h-8 justify-between gap-3 rounded-lg px-2 text-[14px] font-normal leading-5 text-text-secondary',
|
|
item.action === 'delete' && 'data-[highlighted]:bg-state-destructive-hover data-[highlighted]:text-text-destructive',
|
|
)}
|
|
onClick={() => handleMenuAction(item.action)}
|
|
>
|
|
<span>{getActionLabel(item.translationKey)}</span>
|
|
{item.shortcutKeys && (
|
|
<ShortcutsName
|
|
keys={item.shortcutKeys}
|
|
textColor="secondary"
|
|
/>
|
|
)}
|
|
</ContextMenuItem>
|
|
))}
|
|
</div>
|
|
<ContextMenuSeparator className="my-0" />
|
|
<div className="p-1.5">
|
|
<div className="flex items-center">
|
|
{alignMenuItems.map((item) => {
|
|
return (
|
|
<ContextMenuItem
|
|
key={item.alignType}
|
|
aria-label={t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })}
|
|
className="mx-0 h-8 w-8 justify-center rounded-md px-0 text-text-tertiary data-[highlighted]:bg-state-base-hover data-[highlighted]:text-text-secondary"
|
|
data-testid={`selection-contextmenu-item-${item.alignType}`}
|
|
onClick={() => handleAlignNodes(item.alignType)}
|
|
>
|
|
<span aria-hidden className={`${item.icon} h-4 w-4 ${item.iconClassName ?? ''}`.trim()} />
|
|
</ContextMenuItem>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
{isCreateSnippetDialogOpen && (
|
|
<CreateSnippetDialog
|
|
isOpen={isCreateSnippetDialogOpen}
|
|
selectedGraph={selectedGraphSnapshot}
|
|
isSubmitting={isCreatingSnippet || createSnippetMutation.isPending}
|
|
onClose={handleCloseCreateSnippetDialog}
|
|
onConfirm={handleCreateSnippet}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default memo(SelectionContextmenu)
|