mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
feat: support drag and drop creation
This commit is contained in:
@@ -18,6 +18,18 @@ vi.mock('@/components/node/NodePreviewCard.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}))
|
||||
|
||||
const mockStartDrag = vi.fn()
|
||||
const mockHandleNativeDrop = vi.fn()
|
||||
const mockCancelDrag = vi.fn()
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
startDrag: mockStartDrag,
|
||||
handleNativeDrop: mockHandleNativeDrop,
|
||||
cancelDrag: mockCancelDrag
|
||||
})
|
||||
}))
|
||||
|
||||
describe('TreeExplorerV2Node', () => {
|
||||
function createMockItem(
|
||||
type: 'node' | 'folder',
|
||||
@@ -216,4 +228,98 @@ describe('TreeExplorerV2Node', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag and drop', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('sets draggable attribute on node items', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node')
|
||||
})
|
||||
|
||||
const nodeDiv = wrapper.find('div.group\\/tree-node')
|
||||
expect(nodeDiv.attributes('draggable')).toBe('true')
|
||||
})
|
||||
|
||||
it('does not set draggable on folder items', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('folder')
|
||||
})
|
||||
|
||||
const folderDiv = wrapper.find('div.group\\/tree-node')
|
||||
expect(folderDiv.attributes('draggable')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls startDrag with native mode on dragstart', async () => {
|
||||
const mockData = { name: 'TestNode' }
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node', { data: mockData })
|
||||
})
|
||||
|
||||
const nodeDiv = wrapper.find('div.group\\/tree-node')
|
||||
await nodeDiv.trigger('dragstart')
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
|
||||
})
|
||||
|
||||
it('does not call startDrag for folder items on dragstart', async () => {
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('folder')
|
||||
})
|
||||
|
||||
const folderDiv = wrapper.find('div.group\\/tree-node')
|
||||
await folderDiv.trigger('dragstart')
|
||||
|
||||
expect(mockStartDrag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls handleNativeDrop on dragend with valid drop', async () => {
|
||||
const mockData = { name: 'TestNode' }
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node', { data: mockData })
|
||||
})
|
||||
|
||||
const nodeDiv = wrapper.find('div.group\\/tree-node')
|
||||
|
||||
await nodeDiv.trigger('dragstart')
|
||||
|
||||
const dragEndEvent = new DragEvent('dragend', { bubbles: true })
|
||||
Object.defineProperty(dragEndEvent, 'clientX', { value: 100 })
|
||||
Object.defineProperty(dragEndEvent, 'clientY', { value: 200 })
|
||||
Object.defineProperty(dragEndEvent, 'dataTransfer', {
|
||||
value: { dropEffect: 'copy' }
|
||||
})
|
||||
|
||||
await nodeDiv.element.dispatchEvent(dragEndEvent)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(mockHandleNativeDrop).toHaveBeenCalledWith(100, 200)
|
||||
})
|
||||
|
||||
it('calls cancelDrag on dragend with cancelled drop', async () => {
|
||||
const mockData = { name: 'TestNode' }
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node', { data: mockData })
|
||||
})
|
||||
|
||||
const nodeDiv = wrapper.find('div.group\\/tree-node')
|
||||
|
||||
await nodeDiv.trigger('dragstart')
|
||||
|
||||
const dragEndEvent = new DragEvent('dragend', { bubbles: true })
|
||||
Object.defineProperty(dragEndEvent, 'clientX', { value: 100 })
|
||||
Object.defineProperty(dragEndEvent, 'clientY', { value: 200 })
|
||||
Object.defineProperty(dragEndEvent, 'dataTransfer', {
|
||||
value: { dropEffect: 'none' }
|
||||
})
|
||||
|
||||
await nodeDiv.element.dispatchEvent(dragEndEvent)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(mockCancelDrag).toHaveBeenCalled()
|
||||
expect(mockHandleNativeDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,10 +10,13 @@
|
||||
<div
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-highlight')"
|
||||
:style="rowStyle"
|
||||
draggable="true"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
@contextmenu="handleContextMenu"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<i class="icon-[comfy--node] size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
@@ -52,7 +55,7 @@
|
||||
</TreeItem>
|
||||
|
||||
<Teleport
|
||||
v-if="isHovered && item.value.type === 'node' && item.value.data"
|
||||
v-if="showPreview && item.value.type === 'node' && item.value.data"
|
||||
to="#node-library-node-preview-container-v2"
|
||||
>
|
||||
<div ref="previewRef" :style="nodePreviewStyle">
|
||||
@@ -68,6 +71,7 @@ import type { CSSProperties } from 'vue'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
@@ -86,8 +90,10 @@ const emit = defineEmits<{
|
||||
|
||||
const contextMenuNode = inject(InjectKeyContextMenuNode)
|
||||
const settingStore = useSettingStore()
|
||||
const { startDrag, handleNativeDrop, cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
const isHovered = ref(false)
|
||||
const isDraggingNode = ref(false)
|
||||
const previewRef = ref<HTMLElement>()
|
||||
const nodePreviewStyle = ref<CSSProperties>({
|
||||
position: 'fixed',
|
||||
@@ -97,6 +103,8 @@ const nodePreviewStyle = ref<CSSProperties>({
|
||||
zIndex: 1001
|
||||
})
|
||||
|
||||
const showPreview = computed(() => isHovered.value && !isDraggingNode.value)
|
||||
|
||||
const rowStyle = computed(() => ({
|
||||
paddingLeft: `${16 + (item.level - 1) * 24}px`
|
||||
}))
|
||||
@@ -181,4 +189,46 @@ function handleMouseEnter(e: MouseEvent) {
|
||||
function handleMouseLeave() {
|
||||
isHovered.value = false
|
||||
}
|
||||
|
||||
function handleDragStart(e: DragEvent) {
|
||||
if (item.value.type !== 'node' || !item.value.data) return
|
||||
|
||||
isDraggingNode.value = true
|
||||
isHovered.value = false
|
||||
|
||||
startDrag(item.value.data, 'native')
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
e.dataTransfer.setData('application/x-comfy-node', item.value.data.name)
|
||||
|
||||
const dragImage = createDragImage()
|
||||
document.body.appendChild(dragImage)
|
||||
e.dataTransfer.setDragImage(dragImage, 0, 0)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document.body.removeChild(dragImage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function createDragImage(): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
el.style.position = 'absolute'
|
||||
el.style.left = '-9999px'
|
||||
el.style.top = '-9999px'
|
||||
el.style.width = '1px'
|
||||
el.style.height = '1px'
|
||||
return el
|
||||
}
|
||||
|
||||
function handleDragEnd(e: DragEvent) {
|
||||
isDraggingNode.value = false
|
||||
|
||||
if (e.dataTransfer?.dropEffect !== 'none') {
|
||||
handleNativeDrop(e.clientX, e.clientY)
|
||||
} else {
|
||||
cancelDrag()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,37 +1,69 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isDragging && draggedNode"
|
||||
class="pointer-events-none fixed z-[10000] flex items-center gap-2 rounded-lg bg-neutral-700 px-3 py-2 text-sm font-medium text-neutral-400 shadow-lg"
|
||||
v-if="isDragging && draggedNode && showPreview"
|
||||
class="pointer-events-none fixed z-[10000]"
|
||||
:style="{
|
||||
left: `${cursorPosition.x + 12}px`,
|
||||
top: `${cursorPosition.y + 12}px`
|
||||
left: `${previewPosition.x + 12}px`,
|
||||
top: `${previewPosition.y + 12}px`
|
||||
}"
|
||||
>
|
||||
<span class="size-3 shrink-0 rounded-full bg-neutral-500" />
|
||||
{{ draggedNode.display_name }}
|
||||
<div class="origin-top-left scale-50 opacity-80">
|
||||
<LGraphNodePreview :node-def="draggedNode" position="relative" />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
|
||||
const {
|
||||
isDragging,
|
||||
draggedNode,
|
||||
cursorPosition,
|
||||
dragMode,
|
||||
setupGlobalListeners,
|
||||
cleanupGlobalListeners
|
||||
} = useNodeDragToCanvas()
|
||||
|
||||
const nativeDragPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const previewPosition = computed(() => {
|
||||
if (dragMode.value === 'native') {
|
||||
return nativeDragPosition.value
|
||||
}
|
||||
return cursorPosition.value
|
||||
})
|
||||
|
||||
const showPreview = computed(() => {
|
||||
if (dragMode.value === 'native') {
|
||||
return nativeDragPosition.value.x > 0 || nativeDragPosition.value.y > 0
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
function handleDrag(e: DragEvent) {
|
||||
if (e.clientX === 0 && e.clientY === 0) return
|
||||
nativeDragPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
nativeDragPosition.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupGlobalListeners()
|
||||
document.addEventListener('drag', handleDrag)
|
||||
document.addEventListener('dragend', handleDragEnd)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupGlobalListeners()
|
||||
document.removeEventListener('drag', handleDrag)
|
||||
document.removeEventListener('dragend', handleDragEnd)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -41,6 +41,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const { cleanupGlobalListeners } = useNodeDragToCanvas()
|
||||
cleanupGlobalListeners()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -56,6 +58,22 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(isDragging.value).toBe(true)
|
||||
expect(draggedNode.value).toBe(mockNodeDef)
|
||||
})
|
||||
|
||||
it('should set dragMode to click by default', () => {
|
||||
const { dragMode, startDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
|
||||
it('should set dragMode to native when specified', () => {
|
||||
const { dragMode, startDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
expect(dragMode.value).toBe('native')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelDrag', () => {
|
||||
@@ -71,6 +89,17 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(isDragging.value).toBe(false)
|
||||
expect(draggedNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('should reset dragMode to click', () => {
|
||||
const { dragMode, startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
expect(dragMode.value).toBe('native')
|
||||
|
||||
cancelDrag()
|
||||
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupGlobalListeners', () => {
|
||||
@@ -203,5 +232,105 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
expect(isDragging.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should not add node on pointerup when in native drag mode', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(isDragging.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleNativeDrop', () => {
|
||||
it('should add node when drop position is over canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
|
||||
pos: [200, 200]
|
||||
})
|
||||
})
|
||||
|
||||
it('should not add node when drop position is outside canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(600, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(isDragging.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not add node when dragMode is click', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'click')
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset drag state after drop', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging, dragMode } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(isDragging.value).toBe(false)
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,9 +4,12 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
export type DragMode = 'click' | 'native'
|
||||
|
||||
const isDragging = ref(false)
|
||||
const draggedNode = shallowRef<ComfyNodeDefImpl | null>(null)
|
||||
const cursorPosition = ref({ x: 0, y: 0 })
|
||||
const dragMode = ref<DragMode>('click')
|
||||
let listenersSetup = false
|
||||
|
||||
function updatePosition(e: PointerEvent) {
|
||||
@@ -16,32 +19,42 @@ function updatePosition(e: PointerEvent) {
|
||||
function cancelDrag() {
|
||||
isDragging.value = false
|
||||
draggedNode.value = null
|
||||
dragMode.value = 'click'
|
||||
}
|
||||
|
||||
function endDrag(e: PointerEvent) {
|
||||
if (!isDragging.value || !draggedNode.value) return
|
||||
function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
if (!draggedNode.value) return false
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const canvas = canvasStore.canvas
|
||||
if (!canvas) {
|
||||
cancelDrag()
|
||||
return
|
||||
}
|
||||
if (!canvas) return false
|
||||
|
||||
const canvasElement = canvas.canvas as HTMLCanvasElement
|
||||
const rect = canvasElement.getBoundingClientRect()
|
||||
const isOverCanvas =
|
||||
e.clientX >= rect.left &&
|
||||
e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top &&
|
||||
e.clientY <= rect.bottom
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom
|
||||
|
||||
if (isOverCanvas) {
|
||||
const pos = canvas.convertEventToCanvasOffset({
|
||||
clientX,
|
||||
clientY
|
||||
} as PointerEvent)
|
||||
const litegraphService = useLitegraphService()
|
||||
litegraphService.addNodeOnGraph(draggedNode.value, { pos })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function endDrag(e: PointerEvent) {
|
||||
if (!isDragging.value || !draggedNode.value) return
|
||||
if (dragMode.value !== 'click') return
|
||||
|
||||
try {
|
||||
if (isOverCanvas) {
|
||||
const pos = canvas.convertEventToCanvasOffset(e)
|
||||
const litegraphService = useLitegraphService()
|
||||
litegraphService.addNodeOnGraph(draggedNode.value, { pos })
|
||||
}
|
||||
addNodeAtPosition(e.clientX, e.clientY)
|
||||
} finally {
|
||||
cancelDrag()
|
||||
}
|
||||
@@ -70,17 +83,29 @@ function cleanupGlobalListeners() {
|
||||
}
|
||||
|
||||
export function useNodeDragToCanvas() {
|
||||
function startDrag(nodeDef: ComfyNodeDefImpl) {
|
||||
function startDrag(nodeDef: ComfyNodeDefImpl, mode: DragMode = 'click') {
|
||||
isDragging.value = true
|
||||
draggedNode.value = nodeDef
|
||||
dragMode.value = mode
|
||||
}
|
||||
|
||||
function handleNativeDrop(clientX: number, clientY: number) {
|
||||
if (dragMode.value !== 'native') return
|
||||
try {
|
||||
addNodeAtPosition(clientX, clientY)
|
||||
} finally {
|
||||
cancelDrag()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
draggedNode,
|
||||
cursorPosition,
|
||||
dragMode,
|
||||
startDrag,
|
||||
cancelDrag,
|
||||
handleNativeDrop,
|
||||
setupGlobalListeners,
|
||||
cleanupGlobalListeners
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user