Files
ComfyUI_frontend/src/composables/node/useNodePreviewAndDrag.ts
Christian Byrne 3f497081ee feat: Node Library sidebar and V2 Search dialog UI/UX updates (#9085)
## Summary

Implement 11 Figma design discrepancies for the Node Library sidebar and
V2 Node Search dialog, aligning the UI with the [Toolbox Figma
design](https://www.figma.com/design/xMFxCziXJe6Denz4dpDGTq/Toolbox?node-id=2074-21394&m=dev).

## Changes

- **What**: Sidebar: reorder tabs (All/Essentials/Blueprints), rename
Custom→Blueprints, uppercase section headers, chevron-left of folder
icon, bookmark-on-hover for node rows, filter dropdown with checkbox
items, sort labels (Categorized/A-Z) with label-left/check-right layout,
hide section headers in A-Z mode. Search dialog: expand filter chips
from 3→6, add Recents and source categories to sidebar, remove "Filter
by" label. Pull foundation V2 components from merged PR #8548.
- **Dependencies**: Depends on #8987 (V2 Node Search) and #8548
(NodeLibrarySidebarTabV2)

## Review Focus

- Filter dropdown (`filterOptions`) is UI-scaffolded but not yet wired
to filtering logic (pending V2 integration)
- "Recents" category currently returns frequency-based results as
placeholder until a usage-tracking store is implemented
- Pre-existing type errors from V2 PR dependencies not in the base
commit (SearchBoxV2, usePerTabState, TextTicker, getProviderIcon,
getLinkTypeColor, SidebarContainerKey) are expected and will resolve
when rebased onto main after parent PRs land

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9085-feat-Node-Library-sidebar-and-V2-Search-dialog-Figma-design-improvements-30f6d73d36508175bf72d716f5904476)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-28 22:34:27 +08:00

149 lines
3.9 KiB
TypeScript

import type { CSSProperties, Ref } from 'vue'
import { computed, ref } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const PREVIEW_WIDTH = 200
const PREVIEW_MARGIN = 16
export function useNodePreviewAndDrag(
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
panelRef?: Ref<HTMLElement | null>
) {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
const settingStore = useSettingStore()
const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const previewRef = ref<HTMLElement | null>(null)
const isHovered = ref(false)
const isDragging = ref(false)
const showPreview = computed(() => isHovered.value && !isDragging.value)
const nodePreviewStyle = ref<CSSProperties>({
position: 'fixed',
top: '0px',
left: '0px',
pointerEvents: 'none',
zIndex: 1000
})
function calculatePreviewPosition(rect: DOMRect) {
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
let left: number
if (sidebarLocation.value === 'left') {
left = rect.right + PREVIEW_MARGIN
if (left + PREVIEW_WIDTH > viewportWidth) {
left = rect.left - PREVIEW_MARGIN - PREVIEW_WIDTH
}
} else {
left = rect.left - PREVIEW_MARGIN - PREVIEW_WIDTH
if (left < 0) {
left = rect.right + PREVIEW_MARGIN
}
}
return { left, viewportHeight }
}
function handleMouseEnter(e: MouseEvent) {
if (!nodeDef.value) return
const target = e.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const horizontalRect = panelRef?.value?.getBoundingClientRect() ?? rect
const { left, viewportHeight } = calculatePreviewPosition(horizontalRect)
let top = rect.top
nodePreviewStyle.value = {
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
pointerEvents: 'none',
zIndex: 1000,
opacity: 0
}
isHovered.value = true
requestAnimationFrame(() => {
if (previewRef.value) {
const previewRect = previewRef.value.getBoundingClientRect()
const previewHeight = previewRect.height
const mouseY = rect.top + rect.height / 2
top = mouseY - previewHeight * 0.3
const minTop = PREVIEW_MARGIN
const maxTop = viewportHeight - previewHeight - PREVIEW_MARGIN
top = Math.max(minTop, Math.min(top, maxTop))
nodePreviewStyle.value = {
...nodePreviewStyle.value,
top: `${top}px`,
opacity: 1
}
}
})
}
function handleMouseLeave() {
isHovered.value = false
}
function createEmptyDragImage(): 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 handleDragStart(e: DragEvent) {
if (!nodeDef.value) return
isDragging.value = true
isHovered.value = false
startDrag(nodeDef.value, 'native')
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-comfy-node', nodeDef.value.name)
const dragImage = createEmptyDragImage()
document.body.appendChild(dragImage)
e.dataTransfer.setDragImage(dragImage, 0, 0)
requestAnimationFrame(() => {
document.body.removeChild(dragImage)
})
}
}
function handleDragEnd(e: DragEvent) {
isDragging.value = false
handleNativeDrop(e.clientX, e.clientY)
}
return {
previewRef,
isHovered,
isDragging,
showPreview,
nodePreviewStyle,
sidebarLocation,
handleMouseEnter,
handleMouseLeave,
handleDragStart,
handleDragEnd
}
}