feat: support drag and drop creation

This commit is contained in:
Yourz
2026-02-11 00:48:20 +08:00
parent 41d4adaf45
commit d78e4b92cb
5 changed files with 366 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

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