mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Compare commits
8 Commits
architectu
...
bl/nodes-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c7eabb993 | ||
|
|
45f8ec337a | ||
|
|
72381dcc38 | ||
|
|
94fb623fc8 | ||
|
|
a5b517a12f | ||
|
|
8c8143b4bd | ||
|
|
8dd03d54da | ||
|
|
fad0d17508 |
@@ -0,0 +1,55 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Node Drag Snapping', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.canvasOps.resetView()
|
||||
await comfyPage.vueNodes.waitForNodes(6)
|
||||
})
|
||||
|
||||
test('snaps a dragged node to another node in Vue nodes mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.AlignNodesWhileDragging',
|
||||
true
|
||||
)
|
||||
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler').first()
|
||||
const checkpointNode = comfyPage.vueNodes
|
||||
.getNodeByTitle('Load Checkpoint')
|
||||
.first()
|
||||
const ksamplerHeader = ksamplerNode.locator('.lg-node-header')
|
||||
|
||||
const ksamplerBox = await ksamplerNode.boundingBox()
|
||||
const checkpointBox = await checkpointNode.boundingBox()
|
||||
const headerBox = await ksamplerHeader.boundingBox()
|
||||
|
||||
if (!ksamplerBox || !checkpointBox || !headerBox) {
|
||||
throw new Error('Expected Vue node bounding boxes to be available')
|
||||
}
|
||||
|
||||
const dragStart = {
|
||||
x: headerBox.x + headerBox.width / 2,
|
||||
y: headerBox.y + headerBox.height / 2
|
||||
}
|
||||
const targetLeft = checkpointBox.x + 5
|
||||
const dragTarget = {
|
||||
x: dragStart.x + (targetLeft - ksamplerBox.x),
|
||||
y: dragStart.y
|
||||
}
|
||||
|
||||
await comfyPage.canvasOps.dragAndDrop(dragStart, dragTarget)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const draggedBox = await ksamplerNode.boundingBox()
|
||||
return draggedBox ? Math.round(draggedBox.x) : null
|
||||
})
|
||||
.toBe(Math.round(checkpointBox.x))
|
||||
})
|
||||
})
|
||||
@@ -50,6 +50,12 @@ const snapToGrid = computed({
|
||||
set: (value) => settingStore.set('pysssss.SnapToGrid', value)
|
||||
})
|
||||
|
||||
const alignNodesWhileDragging = computed({
|
||||
get: () => settingStore.get('Comfy.Canvas.AlignNodesWhileDragging'),
|
||||
set: (value) =>
|
||||
settingStore.set('Comfy.Canvas.AlignNodesWhileDragging', value)
|
||||
})
|
||||
|
||||
// CONNECTION LINKS settings
|
||||
const linkShape = computed({
|
||||
get: () => settingStore.get('Comfy.Graph.LinkMarkers'),
|
||||
@@ -160,6 +166,11 @@ function openFullSettings() {
|
||||
:label="t('rightSidePanel.globalSettings.snapNodesToGrid')"
|
||||
:tooltip="t('settings.pysssss_SnapToGrid.tooltip')"
|
||||
/>
|
||||
<FieldSwitch
|
||||
v-model="alignNodesWhileDragging"
|
||||
:label="t('settings.Comfy_Canvas_AlignNodesWhileDragging.name')"
|
||||
:tooltip="t('settings.Comfy_Canvas_AlignNodesWhileDragging.tooltip')"
|
||||
/>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
|
||||
@@ -5007,6 +5007,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
if (!LiteGraph.vueNodesMode || !this.overlayCtx) {
|
||||
this._drawConnectingLinks(ctx)
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
this._drawVueDragAlignmentGuides(ctx)
|
||||
}
|
||||
} else {
|
||||
this._drawOverlayLinks()
|
||||
}
|
||||
@@ -5111,7 +5114,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
octx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height)
|
||||
|
||||
if (!this.linkConnector.isConnecting) return
|
||||
const hasDragGuides = layoutStore.vueDragSnapGuides.value.length > 0
|
||||
if (!this.linkConnector.isConnecting && !hasDragGuides) return
|
||||
|
||||
octx.save()
|
||||
|
||||
@@ -5120,11 +5124,39 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
this.ds.toCanvasContext(octx)
|
||||
|
||||
this._drawConnectingLinks(octx)
|
||||
if (this.linkConnector.isConnecting) {
|
||||
this._drawConnectingLinks(octx)
|
||||
}
|
||||
this._drawVueDragAlignmentGuides(octx)
|
||||
|
||||
octx.restore()
|
||||
}
|
||||
|
||||
private _drawVueDragAlignmentGuides(ctx: CanvasRenderingContext2D): void {
|
||||
const guides = layoutStore.vueDragSnapGuides.value
|
||||
if (!guides.length) return
|
||||
|
||||
const scale = this.ds.scale || 1
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.lineWidth = 1 / scale
|
||||
ctx.strokeStyle = '#ff4d4f'
|
||||
ctx.setLineDash([6 / scale, 4 / scale])
|
||||
|
||||
for (const guide of guides) {
|
||||
if (guide.axis === 'vertical') {
|
||||
ctx.moveTo(guide.coordinate, guide.start)
|
||||
ctx.lineTo(guide.coordinate, guide.end)
|
||||
} else {
|
||||
ctx.moveTo(guide.start, guide.coordinate)
|
||||
ctx.lineTo(guide.end, guide.coordinate)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/** Get the target snap / highlight point in graph space */
|
||||
private _getHighlightPosition(): Readonly<Point> {
|
||||
return LiteGraph.snaps_for_comfy
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
"name": "Show selection toolbox",
|
||||
"tooltip": "Display a floating toolbar when nodes are selected, providing quick access to common actions."
|
||||
},
|
||||
"Comfy_Canvas_AlignNodesWhileDragging": {
|
||||
"name": "Align nodes while dragging",
|
||||
"tooltip": "When enabled in Nodes 2.0, dragging a node selection near another node will snap matching edges and centers together."
|
||||
},
|
||||
"Comfy_ConfirmClear": {
|
||||
"name": "Require confirmation when clearing workflow"
|
||||
},
|
||||
|
||||
@@ -996,6 +996,16 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: true,
|
||||
versionAdded: '1.10.5'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.AlignNodesWhileDragging',
|
||||
category: ['LiteGraph', 'Canvas', 'AlignNodesWhileDragging'],
|
||||
name: 'Align nodes while dragging',
|
||||
tooltip:
|
||||
'When enabled in Nodes 2.0, dragging a node selection near another node will snap matching edges and centers together.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
versionAdded: '1.42.0'
|
||||
},
|
||||
{
|
||||
id: 'LiteGraph.Reroute.SplineOffset',
|
||||
name: 'Reroute spline offset',
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
LinkSegmentLayout,
|
||||
MoveNodeOperation,
|
||||
MoveRerouteOperation,
|
||||
NodeAlignmentGuide,
|
||||
NodeBoundsUpdate,
|
||||
NodeId,
|
||||
NodeLayout,
|
||||
@@ -144,6 +145,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
// Vue dragging state for selection toolbox (public ref for direct mutation)
|
||||
public isDraggingVueNodes = ref(false)
|
||||
public vueDragSnapGuides = ref<NodeAlignmentGuide[]>([])
|
||||
// Vue resizing state to prevent drag from activating during resize
|
||||
public isResizingVueNodes = ref(false)
|
||||
|
||||
|
||||
@@ -32,6 +32,13 @@ export interface Bounds {
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface NodeAlignmentGuide {
|
||||
axis: 'horizontal' | 'vertical'
|
||||
coordinate: number
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface NodeBoundsUpdate {
|
||||
nodeId: NodeId
|
||||
bounds: Bounds
|
||||
|
||||
@@ -14,6 +14,14 @@ export function isBoundsEqual(a: Bounds, b: Bounds): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
export function translateBounds(bounds: Bounds, delta: Point): Bounds {
|
||||
return {
|
||||
...bounds,
|
||||
x: bounds.x + delta.x,
|
||||
y: bounds.y + delta.y
|
||||
}
|
||||
}
|
||||
|
||||
export function isSizeEqual(a: Size, b: Size): boolean {
|
||||
return a.width === b.width && a.height === b.height
|
||||
}
|
||||
|
||||
@@ -24,11 +24,13 @@ vi.mock('@/renderer/extensions/vueNodes/layout/useNodeDrag', () => {
|
||||
const startDrag = vi.fn()
|
||||
const handleDrag = vi.fn()
|
||||
const endDrag = vi.fn()
|
||||
const cancelDrag = vi.fn()
|
||||
return {
|
||||
useNodeDrag: () => ({
|
||||
startDrag,
|
||||
handleDrag,
|
||||
endDrag
|
||||
endDrag,
|
||||
cancelDrag
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -93,6 +95,7 @@ const mockData = vi.hoisted(() => {
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => {
|
||||
const isDraggingVueNodes = ref(false)
|
||||
const isResizingVueNodes = ref(false)
|
||||
const vueDragSnapGuides = ref([])
|
||||
const fakeNodeLayoutRef = ref(mockData.fakeNodeLayout)
|
||||
const getNodeLayoutRef = vi.fn(() => fakeNodeLayoutRef)
|
||||
const setSource = vi.fn()
|
||||
@@ -100,6 +103,7 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => {
|
||||
layoutStore: {
|
||||
isDraggingVueNodes,
|
||||
isResizingVueNodes,
|
||||
vueDragSnapGuides,
|
||||
getNodeLayoutRef,
|
||||
setSource
|
||||
}
|
||||
@@ -136,6 +140,9 @@ describe('useNodePointerInteractions', () => {
|
||||
vi.resetAllMocks()
|
||||
selectedItemsState.items = []
|
||||
setActivePinia(createTestingPinia())
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
layoutStore.isResizingVueNodes.value = false
|
||||
layoutStore.vueDragSnapGuides.value = []
|
||||
})
|
||||
|
||||
it('should only start drag on left-click', async () => {
|
||||
@@ -184,6 +191,7 @@ describe('useNodePointerInteractions', () => {
|
||||
|
||||
it('should handle drag termination via cancel and context menu', async () => {
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
const { cancelDrag } = useNodeDrag()
|
||||
|
||||
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
|
||||
|
||||
@@ -229,6 +237,7 @@ describe('useNodePointerInteractions', () => {
|
||||
pointerHandlers.onContextmenu(contextMenuEvent)
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
expect(cancelDrag).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should integrate with layout store dragging state', async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
|
||||
export function useNodePointerInteractions(
|
||||
nodeIdRef: MaybeRefOrGetter<string>
|
||||
) {
|
||||
const { startDrag, endDrag, handleDrag } = useNodeDrag()
|
||||
const { startDrag, endDrag, handleDrag, cancelDrag } = useNodeDrag()
|
||||
// Use canvas interactions for proper wheel event handling and pointer event capture control
|
||||
const { forwardEventToCanvas, shouldHandleNodePointerEvents } =
|
||||
useCanvasInteractions()
|
||||
@@ -102,6 +102,7 @@ export function useNodePointerInteractions(
|
||||
|
||||
function cleanupDragState() {
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
layoutStore.vueDragSnapGuides.value = []
|
||||
}
|
||||
|
||||
function safeDragStart(event: PointerEvent, nodeId: string) {
|
||||
@@ -124,6 +125,17 @@ export function useNodePointerInteractions(
|
||||
}
|
||||
}
|
||||
|
||||
function safeDragCancel() {
|
||||
try {
|
||||
cancelDrag()
|
||||
} catch (error) {
|
||||
console.error('Error during cancelDrag:', error)
|
||||
} finally {
|
||||
hasDraggingStarted = false
|
||||
cleanupDragState()
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerup(event: PointerEvent) {
|
||||
if (forwardMiddlePointerIfNeeded(event)) return
|
||||
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
|
||||
@@ -166,12 +178,15 @@ export function useNodePointerInteractions(
|
||||
if (!layoutStore.isDraggingVueNodes.value) return
|
||||
|
||||
event.preventDefault()
|
||||
// Simply cleanup state without calling endDrag to avoid synthetic event creation
|
||||
cleanupDragState()
|
||||
safeDragCancel()
|
||||
}
|
||||
|
||||
// Cleanup on unmount to prevent resource leaks
|
||||
onScopeDispose(() => {
|
||||
if (hasDraggingStarted || layoutStore.isDraggingVueNodes.value) {
|
||||
safeDragCancel()
|
||||
return
|
||||
}
|
||||
cleanupDragState()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveNodeAlignmentSnap } from './nodeAlignmentSnap'
|
||||
|
||||
describe('resolveNodeAlignmentSnap', () => {
|
||||
const selectionBounds = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 80
|
||||
}
|
||||
|
||||
it('snaps matching edges within threshold', () => {
|
||||
const result = resolveNodeAlignmentSnap({
|
||||
selectionBounds,
|
||||
candidateBounds: [
|
||||
{
|
||||
x: 200,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 80
|
||||
}
|
||||
],
|
||||
delta: { x: 193, y: 0 },
|
||||
zoomScale: 1
|
||||
})
|
||||
|
||||
expect(result.delta).toEqual({ x: 200, y: 0 })
|
||||
expect(result.guides).toContainEqual({
|
||||
axis: 'vertical',
|
||||
coordinate: 200,
|
||||
start: 0,
|
||||
end: 80
|
||||
})
|
||||
})
|
||||
|
||||
it('snaps matching centers within threshold', () => {
|
||||
const result = resolveNodeAlignmentSnap({
|
||||
selectionBounds,
|
||||
candidateBounds: [
|
||||
{
|
||||
x: 0,
|
||||
y: 200,
|
||||
width: 100,
|
||||
height: 80
|
||||
}
|
||||
],
|
||||
delta: { x: 0, y: 196 },
|
||||
zoomScale: 1
|
||||
})
|
||||
|
||||
expect(result.delta).toEqual({ x: 0, y: 200 })
|
||||
expect(result.guides).toContainEqual({
|
||||
axis: 'horizontal',
|
||||
coordinate: 200,
|
||||
start: 0,
|
||||
end: 100
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the nearest candidate correction', () => {
|
||||
const result = resolveNodeAlignmentSnap({
|
||||
selectionBounds,
|
||||
candidateBounds: [
|
||||
{
|
||||
x: 200,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 80
|
||||
},
|
||||
{
|
||||
x: 198,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 80
|
||||
}
|
||||
],
|
||||
delta: { x: 193, y: 0 },
|
||||
zoomScale: 1
|
||||
})
|
||||
|
||||
expect(result.delta).toEqual({ x: 198, y: 0 })
|
||||
expect(result.guides).toContainEqual({
|
||||
axis: 'vertical',
|
||||
coordinate: 198,
|
||||
start: 0,
|
||||
end: 80
|
||||
})
|
||||
})
|
||||
|
||||
it('does not snap when outside the zoom-adjusted threshold', () => {
|
||||
const result = resolveNodeAlignmentSnap({
|
||||
selectionBounds,
|
||||
candidateBounds: [
|
||||
{
|
||||
x: 200,
|
||||
y: 200,
|
||||
width: 100,
|
||||
height: 80
|
||||
}
|
||||
],
|
||||
delta: { x: 183, y: 0 },
|
||||
zoomScale: 0.5
|
||||
})
|
||||
|
||||
expect(result.delta).toEqual({ x: 183, y: 0 })
|
||||
expect(result.guides).toEqual([])
|
||||
})
|
||||
|
||||
it('tightens the canvas threshold as zoom increases', () => {
|
||||
const result = resolveNodeAlignmentSnap({
|
||||
selectionBounds,
|
||||
candidateBounds: [
|
||||
{
|
||||
x: 200,
|
||||
y: 200,
|
||||
width: 100,
|
||||
height: 80
|
||||
}
|
||||
],
|
||||
delta: { x: 195, y: 0 },
|
||||
zoomScale: 2
|
||||
})
|
||||
|
||||
expect(result.delta).toEqual({ x: 195, y: 0 })
|
||||
expect(result.guides).toEqual([])
|
||||
})
|
||||
})
|
||||
215
src/renderer/extensions/vueNodes/layout/nodeAlignmentSnap.ts
Normal file
215
src/renderer/extensions/vueNodes/layout/nodeAlignmentSnap.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import type {
|
||||
Bounds,
|
||||
NodeAlignmentGuide,
|
||||
Point
|
||||
} from '@/renderer/core/layout/types'
|
||||
import { translateBounds } from '@/renderer/core/layout/utils/geometry'
|
||||
|
||||
const DEFAULT_THRESHOLD_PX = 8
|
||||
|
||||
type HorizontalAnchor = 'bottom' | 'centerY' | 'top'
|
||||
type VerticalAnchor = 'centerX' | 'left' | 'right'
|
||||
|
||||
export interface NodeAlignmentSnapResult {
|
||||
delta: Point
|
||||
guides: NodeAlignmentGuide[]
|
||||
}
|
||||
|
||||
interface AxisMatch {
|
||||
axis: 'horizontal' | 'vertical'
|
||||
anchor: HorizontalAnchor | VerticalAnchor
|
||||
candidateBounds: Bounds
|
||||
correction: number
|
||||
}
|
||||
|
||||
interface ResolveNodeAlignmentSnapOptions {
|
||||
candidateBounds: Bounds[]
|
||||
delta: Point
|
||||
selectionBounds: Bounds
|
||||
thresholdPx?: number
|
||||
zoomScale: number
|
||||
}
|
||||
|
||||
export function resolveNodeAlignmentSnap({
|
||||
candidateBounds,
|
||||
delta,
|
||||
selectionBounds,
|
||||
thresholdPx = DEFAULT_THRESHOLD_PX,
|
||||
zoomScale
|
||||
}: ResolveNodeAlignmentSnapOptions): NodeAlignmentSnapResult {
|
||||
if (!candidateBounds.length || zoomScale <= 0) {
|
||||
return { delta, guides: [] }
|
||||
}
|
||||
|
||||
const threshold = thresholdPx / zoomScale
|
||||
const translatedSelectionBounds = translateBounds(selectionBounds, delta)
|
||||
|
||||
const verticalMatch = findBestVerticalMatch(
|
||||
translatedSelectionBounds,
|
||||
candidateBounds,
|
||||
threshold
|
||||
)
|
||||
const horizontalMatch = findBestHorizontalMatch(
|
||||
translatedSelectionBounds,
|
||||
candidateBounds,
|
||||
threshold
|
||||
)
|
||||
|
||||
const snappedDelta = {
|
||||
x: delta.x + (verticalMatch?.correction ?? 0),
|
||||
y: delta.y + (horizontalMatch?.correction ?? 0)
|
||||
}
|
||||
const snappedSelectionBounds = translateBounds(selectionBounds, snappedDelta)
|
||||
const guides = [
|
||||
verticalMatch &&
|
||||
createVerticalGuide(
|
||||
snappedSelectionBounds,
|
||||
verticalMatch.candidateBounds
|
||||
),
|
||||
horizontalMatch &&
|
||||
createHorizontalGuide(
|
||||
snappedSelectionBounds,
|
||||
horizontalMatch.candidateBounds
|
||||
)
|
||||
].filter((guide): guide is NodeAlignmentGuide => guide !== undefined)
|
||||
|
||||
return {
|
||||
delta: snappedDelta,
|
||||
guides
|
||||
}
|
||||
}
|
||||
|
||||
function findBestVerticalMatch(
|
||||
selectionBounds: Bounds,
|
||||
candidateBounds: Bounds[],
|
||||
threshold: number
|
||||
): AxisMatch | undefined {
|
||||
return findBestMatch<VerticalAnchor>(
|
||||
'vertical',
|
||||
getVerticalAnchorValues(selectionBounds),
|
||||
candidateBounds,
|
||||
threshold,
|
||||
getVerticalAnchorValues
|
||||
)
|
||||
}
|
||||
|
||||
function findBestHorizontalMatch(
|
||||
selectionBounds: Bounds,
|
||||
candidateBounds: Bounds[],
|
||||
threshold: number
|
||||
): AxisMatch | undefined {
|
||||
return findBestMatch<HorizontalAnchor>(
|
||||
'horizontal',
|
||||
getHorizontalAnchorValues(selectionBounds),
|
||||
candidateBounds,
|
||||
threshold,
|
||||
getHorizontalAnchorValues
|
||||
)
|
||||
}
|
||||
|
||||
function findBestMatch<TAnchor extends HorizontalAnchor | VerticalAnchor>(
|
||||
axis: 'horizontal' | 'vertical',
|
||||
selectionAnchors: Record<TAnchor, number>,
|
||||
candidateBounds: Bounds[],
|
||||
threshold: number,
|
||||
getCandidateAnchors: (bounds: Bounds) => Record<TAnchor, number>
|
||||
): AxisMatch | undefined {
|
||||
let bestMatch: AxisMatch | undefined
|
||||
|
||||
for (const bounds of candidateBounds) {
|
||||
const candidateAnchors = getCandidateAnchors(bounds)
|
||||
|
||||
for (const anchor of Object.keys(selectionAnchors) as TAnchor[]) {
|
||||
const correction = candidateAnchors[anchor] - selectionAnchors[anchor]
|
||||
if (Math.abs(correction) > threshold) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!bestMatch || Math.abs(correction) < Math.abs(bestMatch.correction)) {
|
||||
bestMatch = {
|
||||
axis,
|
||||
anchor,
|
||||
candidateBounds: bounds,
|
||||
correction
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
function getVerticalAnchorValues(
|
||||
bounds: Bounds
|
||||
): Record<VerticalAnchor, number> {
|
||||
return {
|
||||
left: bounds.x,
|
||||
centerX: bounds.x + bounds.width * 0.5,
|
||||
right: bounds.x + bounds.width
|
||||
}
|
||||
}
|
||||
|
||||
function getHorizontalAnchorValues(
|
||||
bounds: Bounds
|
||||
): Record<HorizontalAnchor, number> {
|
||||
return {
|
||||
top: bounds.y,
|
||||
centerY: bounds.y + bounds.height * 0.5,
|
||||
bottom: bounds.y + bounds.height
|
||||
}
|
||||
}
|
||||
|
||||
function createVerticalGuide(
|
||||
selectionBounds: Bounds,
|
||||
candidateBounds: Bounds
|
||||
): NodeAlignmentGuide {
|
||||
const candidateAnchors = getVerticalAnchorValues(candidateBounds)
|
||||
const selectionAnchors = getVerticalAnchorValues(selectionBounds)
|
||||
const coordinate = getSharedAnchorValue(selectionAnchors, candidateAnchors)
|
||||
|
||||
return {
|
||||
axis: 'vertical',
|
||||
coordinate,
|
||||
start: Math.min(selectionBounds.y, candidateBounds.y),
|
||||
end: Math.max(
|
||||
selectionBounds.y + selectionBounds.height,
|
||||
candidateBounds.y + candidateBounds.height
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function createHorizontalGuide(
|
||||
selectionBounds: Bounds,
|
||||
candidateBounds: Bounds
|
||||
): NodeAlignmentGuide {
|
||||
const candidateAnchors = getHorizontalAnchorValues(candidateBounds)
|
||||
const selectionAnchors = getHorizontalAnchorValues(selectionBounds)
|
||||
const coordinate = getSharedAnchorValue(selectionAnchors, candidateAnchors)
|
||||
|
||||
return {
|
||||
axis: 'horizontal',
|
||||
coordinate,
|
||||
start: Math.min(selectionBounds.x, candidateBounds.x),
|
||||
end: Math.max(
|
||||
selectionBounds.x + selectionBounds.width,
|
||||
candidateBounds.x + candidateBounds.width
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getSharedAnchorValue<
|
||||
TAnchor extends HorizontalAnchor | VerticalAnchor
|
||||
>(
|
||||
selectionAnchors: Record<TAnchor, number>,
|
||||
candidateAnchors: Record<TAnchor, number>
|
||||
): number {
|
||||
const anchors = Object.keys(selectionAnchors) as TAnchor[]
|
||||
|
||||
for (const anchor of anchors) {
|
||||
if (selectionAnchors[anchor] === candidateAnchors[anchor]) {
|
||||
return selectionAnchors[anchor]
|
||||
}
|
||||
}
|
||||
|
||||
return selectionAnchors[anchors[0]]
|
||||
}
|
||||
@@ -1,21 +1,50 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { NodeLayout } from '@/renderer/core/layout/types'
|
||||
import type * as VueUseModule from '@vueuse/core'
|
||||
|
||||
import type { Bounds, NodeLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
function getStoredNodeBounds(
|
||||
layout: Pick<NodeLayout, 'position' | 'size'>
|
||||
): Bounds {
|
||||
return {
|
||||
x: layout.position.x,
|
||||
y: layout.position.y,
|
||||
width: layout.size.width,
|
||||
height: layout.size.height
|
||||
}
|
||||
}
|
||||
|
||||
function boundsIntersect(a: Bounds, b: Bounds): boolean {
|
||||
return !(
|
||||
a.x + a.width < b.x ||
|
||||
b.x + b.width < a.x ||
|
||||
a.y + a.height < b.y ||
|
||||
b.y + b.height < a.y
|
||||
)
|
||||
}
|
||||
|
||||
const testState = vi.hoisted(() => {
|
||||
return {
|
||||
selectedNodeIds: null as unknown as Ref<Set<string>>,
|
||||
selectedItems: null as unknown as Ref<unknown[]>,
|
||||
nodeLayouts: new Map<string, Pick<NodeLayout, 'position' | 'size'>>(),
|
||||
canvas: {
|
||||
setDirty: vi.fn()
|
||||
},
|
||||
settingValues: {
|
||||
'Comfy.Canvas.AlignNodesWhileDragging': false
|
||||
} as Record<string, boolean>,
|
||||
mutationFns: {
|
||||
setSource: vi.fn(),
|
||||
moveNode: vi.fn(),
|
||||
batchMoveNodes: vi.fn()
|
||||
},
|
||||
batchUpdateNodeBounds: vi.fn(),
|
||||
vueDragSnapGuides: { value: [] as unknown[] } as Ref<unknown[]>,
|
||||
nodeSnap: {
|
||||
shouldSnap: vi.fn(() => false),
|
||||
shouldSnap: vi.fn((event: PointerEvent) => event.shiftKey),
|
||||
applySnapToPosition: vi.fn((pos: { x: number; y: number }) => pos)
|
||||
},
|
||||
cancelAnimationFrame: vi.fn(),
|
||||
@@ -23,6 +52,17 @@ const testState = vi.hoisted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual<typeof VueUseModule>('@vueuse/core')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
createSharedComposable: <TArgs extends unknown[], TResult>(
|
||||
composable: (...args: TArgs) => TResult
|
||||
) => composable
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: <T>(store: T) => store
|
||||
}))
|
||||
@@ -30,7 +70,8 @@ vi.mock('pinia', () => ({
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
selectedNodeIds: testState.selectedNodeIds,
|
||||
selectedItems: testState.selectedItems
|
||||
selectedItems: testState.selectedItems,
|
||||
canvas: testState.canvas
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -42,10 +83,23 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
getNodeLayoutRef: (nodeId: string) =>
|
||||
ref(testState.nodeLayouts.get(nodeId) ?? null),
|
||||
batchUpdateNodeBounds: testState.batchUpdateNodeBounds
|
||||
queryNodesInBounds: (bounds: Bounds) =>
|
||||
Array.from(testState.nodeLayouts.entries())
|
||||
.filter(([, layout]) =>
|
||||
boundsIntersect(getStoredNodeBounds(layout), bounds)
|
||||
)
|
||||
.map(([nodeId]) => nodeId),
|
||||
batchUpdateNodeBounds: testState.batchUpdateNodeBounds,
|
||||
vueDragSnapGuides: testState.vueDragSnapGuides
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => testState.settingValues[key]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/composables/useNodeSnap', () => ({
|
||||
useNodeSnap: () => testState.nodeSnap
|
||||
}))
|
||||
@@ -58,6 +112,7 @@ vi.mock('@/renderer/extensions/vueNodes/composables/useShiftKeySync', () => ({
|
||||
|
||||
vi.mock('@/renderer/core/layout/transform/useTransformState', () => ({
|
||||
useTransformState: () => ({
|
||||
camera: { z: 1 },
|
||||
screenToCanvas: ({ x, y }: { x: number; y: number }) => ({ x, y })
|
||||
})
|
||||
}))
|
||||
@@ -66,6 +121,12 @@ vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphGroup: () => false
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LiteGraph: {
|
||||
NODE_TITLE_HEIGHT: 30
|
||||
}
|
||||
}))
|
||||
|
||||
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
|
||||
|
||||
describe('useNodeDrag', () => {
|
||||
@@ -73,12 +134,17 @@ describe('useNodeDrag', () => {
|
||||
testState.selectedNodeIds = ref(new Set<string>())
|
||||
testState.selectedItems = ref<unknown[]>([])
|
||||
testState.nodeLayouts.clear()
|
||||
testState.canvas.setDirty.mockReset()
|
||||
testState.settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = false
|
||||
testState.mutationFns.setSource.mockReset()
|
||||
testState.mutationFns.moveNode.mockReset()
|
||||
testState.mutationFns.batchMoveNodes.mockReset()
|
||||
testState.batchUpdateNodeBounds.mockReset()
|
||||
testState.vueDragSnapGuides.value = []
|
||||
testState.nodeSnap.shouldSnap.mockReset()
|
||||
testState.nodeSnap.shouldSnap.mockReturnValue(false)
|
||||
testState.nodeSnap.shouldSnap.mockImplementation(
|
||||
(event: PointerEvent) => event.shiftKey
|
||||
)
|
||||
testState.nodeSnap.applySnapToPosition.mockReset()
|
||||
testState.nodeSnap.applySnapToPosition.mockImplementation(
|
||||
(pos: { x: number; y: number }) => pos
|
||||
@@ -178,6 +244,55 @@ describe('useNodeDrag', () => {
|
||||
expect(testState.mutationFns.moveNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the latest pointer event when moves arrive before the next frame', () => {
|
||||
testState.selectedNodeIds.value = new Set(['1'])
|
||||
testState.nodeLayouts.set('1', {
|
||||
position: { x: 50, y: 80 },
|
||||
size: { width: 180, height: 110 }
|
||||
})
|
||||
|
||||
const { startDrag, handleDrag } = useNodeDrag()
|
||||
|
||||
startDrag(
|
||||
{
|
||||
clientX: 5,
|
||||
clientY: 10
|
||||
} as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
const target = document.createElement('div')
|
||||
target.hasPointerCapture = vi.fn(() => false)
|
||||
target.setPointerCapture = vi.fn()
|
||||
|
||||
handleDrag(
|
||||
{
|
||||
clientX: 25,
|
||||
clientY: 30,
|
||||
target,
|
||||
pointerId: 1
|
||||
} as unknown as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
handleDrag(
|
||||
{
|
||||
clientX: 45,
|
||||
clientY: 60,
|
||||
target,
|
||||
pointerId: 1
|
||||
} as unknown as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
testState.requestAnimationFrameCallback?.(0)
|
||||
|
||||
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledTimes(1)
|
||||
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
|
||||
{ nodeId: '1', position: { x: 90, y: 130 } }
|
||||
])
|
||||
})
|
||||
|
||||
it('cancels pending RAF and applies snap updates on endDrag', () => {
|
||||
testState.selectedNodeIds.value = new Set(['1'])
|
||||
testState.nodeLayouts.set('1', {
|
||||
@@ -231,4 +346,294 @@ describe('useNodeDrag', () => {
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('snaps a dragged multi-selection to matching node edges', () => {
|
||||
testState.settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
|
||||
testState.selectedNodeIds.value = new Set(['1', '2'])
|
||||
testState.nodeLayouts.set('1', {
|
||||
position: { x: 0, y: 40 },
|
||||
size: { width: 100, height: 60 }
|
||||
})
|
||||
testState.nodeLayouts.set('2', {
|
||||
position: { x: 40, y: 40 },
|
||||
size: { width: 100, height: 60 }
|
||||
})
|
||||
testState.nodeLayouts.set('3', {
|
||||
position: { x: 200, y: 40 },
|
||||
size: { width: 100, height: 60 }
|
||||
})
|
||||
|
||||
const { startDrag, handleDrag } = useNodeDrag()
|
||||
|
||||
startDrag({ clientX: 0, clientY: 0 } as PointerEvent, '1')
|
||||
testState.canvas.setDirty.mockClear()
|
||||
|
||||
const target = document.createElement('div')
|
||||
target.hasPointerCapture = vi.fn(() => false)
|
||||
target.setPointerCapture = vi.fn()
|
||||
|
||||
handleDrag(
|
||||
{
|
||||
clientX: 193,
|
||||
clientY: 0,
|
||||
target,
|
||||
pointerId: 1
|
||||
} as unknown as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
testState.requestAnimationFrameCallback?.(0)
|
||||
|
||||
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
|
||||
{ nodeId: '1', position: { x: 200, y: 40 } },
|
||||
{ nodeId: '2', position: { x: 240, y: 40 } }
|
||||
])
|
||||
expect(testState.vueDragSnapGuides.value).toContainEqual({
|
||||
axis: 'vertical',
|
||||
coordinate: 200,
|
||||
start: 10,
|
||||
end: 100
|
||||
})
|
||||
expect(testState.canvas.setDirty).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('excludes selected nodes from alignment candidates', () => {
|
||||
testState.settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
|
||||
testState.selectedNodeIds.value = new Set(['1', '2'])
|
||||
testState.nodeLayouts.set('1', {
|
||||
position: { x: 0, y: 40 },
|
||||
size: { width: 100, height: 60 }
|
||||
})
|
||||
testState.nodeLayouts.set('2', {
|
||||
position: { x: 60, y: 40 },
|
||||
size: { width: 100, height: 60 }
|
||||
})
|
||||
|
||||
const { startDrag, handleDrag } = useNodeDrag()
|
||||
|
||||
startDrag({ clientX: 0, clientY: 0 } as PointerEvent, '1')
|
||||
|
||||
const target = document.createElement('div')
|
||||
target.hasPointerCapture = vi.fn(() => false)
|
||||
target.setPointerCapture = vi.fn()
|
||||
|
||||
handleDrag(
|
||||
{
|
||||
clientX: 63,
|
||||
clientY: 0,
|
||||
target,
|
||||
pointerId: 1
|
||||
} as unknown as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
testState.requestAnimationFrameCallback?.(0)
|
||||
|
||||
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
|
||||
{ nodeId: '1', position: { x: 63, y: 40 } },
|
||||
{ nodeId: '2', position: { x: 123, y: 40 } }
|
||||
])
|
||||
expect(testState.vueDragSnapGuides.value).toEqual([])
|
||||
})
|
||||
|
||||
it('only snaps against nearby nodes returned by the spatial query', () => {
|
||||
testState.settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
|
||||
testState.selectedNodeIds.value = new Set(['1'])
|
||||
testState.nodeLayouts.set('1', {
|
||||
position: { x: 0, y: 40 },
|
||||
size: { width: 100, height: 60 }
|
||||
})
|
||||
testState.nodeLayouts.set('2', {
|
||||
position: { x: 200, y: 320 },
|
||||
size: { width: 100, height: 60 }
|
||||
})
|
||||
|
||||
const { startDrag, handleDrag } = useNodeDrag()
|
||||
|
||||
startDrag({ clientX: 0, clientY: 0 } as PointerEvent, '1')
|
||||
|
||||
const target = document.createElement('div')
|
||||
target.hasPointerCapture = vi.fn(() => false)
|
||||
target.setPointerCapture = vi.fn()
|
||||
|
||||
handleDrag(
|
||||
{
|
||||
clientX: 193,
|
||||
clientY: 0,
|
||||
target,
|
||||
pointerId: 1
|
||||
} as unknown as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
testState.requestAnimationFrameCallback?.(0)
|
||||
|
||||
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
|
||||
{ nodeId: '1', position: { x: 193, y: 40 } }
|
||||
])
|
||||
expect(testState.vueDragSnapGuides.value).toEqual([])
|
||||
})
|
||||
|
||||
it('suppresses alignment snapping when grid snapping is active', () => {
|
||||
testState.settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
|
||||
testState.selectedNodeIds.value = new Set(['1'])
|
||||
testState.nodeLayouts.set('1', {
|
||||
position: { x: 0, y: 40 },
|
||||
size: { width: 100, height: 60 }
|
||||
})
|
||||
testState.nodeLayouts.set('2', {
|
||||
position: { x: 200, y: 40 },
|
||||
size: { width: 100, height: 60 }
|
||||
})
|
||||
|
||||
const { startDrag, handleDrag } = useNodeDrag()
|
||||
|
||||
startDrag({ clientX: 0, clientY: 0 } as PointerEvent, '1')
|
||||
|
||||
const target = document.createElement('div')
|
||||
target.hasPointerCapture = vi.fn(() => false)
|
||||
target.setPointerCapture = vi.fn()
|
||||
|
||||
handleDrag(
|
||||
{
|
||||
clientX: 193,
|
||||
clientY: 0,
|
||||
shiftKey: true,
|
||||
target,
|
||||
pointerId: 1
|
||||
} as unknown as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
testState.requestAnimationFrameCallback?.(0)
|
||||
|
||||
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
|
||||
{ nodeId: '1', position: { x: 193, y: 40 } }
|
||||
])
|
||||
expect(testState.vueDragSnapGuides.value).toEqual([])
|
||||
})
|
||||
|
||||
it('does not move an unrelated selection when dragging an unselected node', () => {
|
||||
testState.settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
|
||||
testState.selectedNodeIds.value = new Set(['2'])
|
||||
testState.nodeLayouts.set('1', {
|
||||
position: { x: 0, y: 40 },
|
||||
size: { width: 100, height: 60 }
|
||||
})
|
||||
testState.nodeLayouts.set('2', {
|
||||
position: { x: 200, y: 40 },
|
||||
size: { width: 100, height: 60 }
|
||||
})
|
||||
|
||||
const { startDrag, handleDrag } = useNodeDrag()
|
||||
|
||||
startDrag({ clientX: 0, clientY: 0 } as PointerEvent, '1')
|
||||
|
||||
const target = document.createElement('div')
|
||||
target.hasPointerCapture = vi.fn(() => false)
|
||||
target.setPointerCapture = vi.fn()
|
||||
|
||||
handleDrag(
|
||||
{
|
||||
clientX: 193,
|
||||
clientY: 0,
|
||||
target,
|
||||
pointerId: 1
|
||||
} as unknown as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
testState.requestAnimationFrameCallback?.(0)
|
||||
|
||||
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
|
||||
{ nodeId: '1', position: { x: 200, y: 40 } }
|
||||
])
|
||||
})
|
||||
|
||||
it('snaps unselected drags using only the dragged node bounds', () => {
|
||||
testState.settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
|
||||
testState.selectedNodeIds.value = new Set(['2', '3'])
|
||||
testState.nodeLayouts.set('1', {
|
||||
position: { x: 0, y: 40 },
|
||||
size: { width: 100, height: 60 }
|
||||
})
|
||||
testState.nodeLayouts.set('2', {
|
||||
position: { x: -500, y: 40 },
|
||||
size: { width: 100, height: 60 }
|
||||
})
|
||||
testState.nodeLayouts.set('3', {
|
||||
position: { x: 400, y: 40 },
|
||||
size: { width: 100, height: 60 }
|
||||
})
|
||||
testState.nodeLayouts.set('4', {
|
||||
position: { x: 200, y: 40 },
|
||||
size: { width: 100, height: 60 }
|
||||
})
|
||||
|
||||
const { startDrag, handleDrag } = useNodeDrag()
|
||||
|
||||
startDrag({ clientX: 0, clientY: 0 } as PointerEvent, '1')
|
||||
|
||||
const target = document.createElement('div')
|
||||
target.hasPointerCapture = vi.fn(() => false)
|
||||
target.setPointerCapture = vi.fn()
|
||||
|
||||
handleDrag(
|
||||
{
|
||||
clientX: 193,
|
||||
clientY: 0,
|
||||
target,
|
||||
pointerId: 1
|
||||
} as unknown as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
testState.requestAnimationFrameCallback?.(0)
|
||||
|
||||
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
|
||||
{ nodeId: '1', position: { x: 200, y: 40 } }
|
||||
])
|
||||
expect(testState.vueDragSnapGuides.value).toContainEqual({
|
||||
axis: 'vertical',
|
||||
coordinate: 200,
|
||||
start: 10,
|
||||
end: 100
|
||||
})
|
||||
})
|
||||
|
||||
it('skips redraw when clearing already-empty snap guides', () => {
|
||||
testState.selectedNodeIds.value = new Set(['1'])
|
||||
testState.nodeLayouts.set('1', {
|
||||
position: { x: 50, y: 80 },
|
||||
size: { width: 180, height: 110 }
|
||||
})
|
||||
|
||||
const { startDrag, handleDrag } = useNodeDrag()
|
||||
|
||||
startDrag(
|
||||
{
|
||||
clientX: 5,
|
||||
clientY: 10
|
||||
} as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
const target = document.createElement('div')
|
||||
target.hasPointerCapture = vi.fn(() => false)
|
||||
target.setPointerCapture = vi.fn()
|
||||
|
||||
handleDrag(
|
||||
{
|
||||
clientX: 25,
|
||||
clientY: 30,
|
||||
target,
|
||||
pointerId: 1
|
||||
} as unknown as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
testState.requestAnimationFrameCallback?.(0)
|
||||
|
||||
expect(testState.canvas.setDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,33 +1,45 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type {
|
||||
Bounds,
|
||||
NodeAlignmentGuide,
|
||||
NodeBoundsUpdate,
|
||||
NodeId,
|
||||
Point
|
||||
} from '@/renderer/core/layout/types'
|
||||
import { translateBounds } from '@/renderer/core/layout/utils/geometry'
|
||||
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
|
||||
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { isLGraphGroup } from '@/utils/litegraphUtil'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import type { NodeAlignmentSnapResult } from './nodeAlignmentSnap'
|
||||
import { resolveNodeAlignmentSnap } from './nodeAlignmentSnap'
|
||||
|
||||
export const useNodeDrag = createSharedComposable(useNodeDragIndividual)
|
||||
|
||||
const SNAP_SEARCH_RADIUS_PX = 96
|
||||
|
||||
function useNodeDragIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const mutations = useLayoutMutations()
|
||||
const { selectedNodeIds, selectedItems } = storeToRefs(useCanvasStore())
|
||||
const { selectedNodeIds, selectedItems } = storeToRefs(canvasStore)
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// Get transform utilities from TransformPane if available
|
||||
const transformState = useTransformState()
|
||||
|
||||
// Snap-to-grid functionality
|
||||
const { shouldSnap, applySnapToPosition } = useNodeSnap()
|
||||
const { shouldSnap: shouldSnapToGrid, applySnapToPosition } = useNodeSnap()
|
||||
|
||||
// Shift key sync for LiteGraph canvas preview
|
||||
const { trackShiftKey } = useShiftKeySync()
|
||||
@@ -36,12 +48,15 @@ function useNodeDragIndividual() {
|
||||
let dragStartPos: Point | null = null
|
||||
let dragStartMouse: Point | null = null
|
||||
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
|
||||
let pendingDragEvent: PointerEvent | null = null
|
||||
let rafId: number | null = null
|
||||
let stopShiftSync: (() => void) | null = null
|
||||
|
||||
// For groups: track the last applied canvas delta to compute frame delta
|
||||
let lastCanvasDelta: Point | null = null
|
||||
let selectedGroups: LGraphGroup[] | null = null
|
||||
let draggedSelectionBounds: Bounds | null = null
|
||||
let draggedSelectionNodeIds: Set<NodeId> | null = null
|
||||
|
||||
function startDrag(event: PointerEvent, nodeId: NodeId) {
|
||||
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
|
||||
@@ -53,12 +68,15 @@ function useNodeDragIndividual() {
|
||||
|
||||
dragStartPos = { ...position }
|
||||
dragStartMouse = { x: event.clientX, y: event.clientY }
|
||||
pendingDragEvent = null
|
||||
|
||||
const selectedNodes = toValue(selectedNodeIds)
|
||||
|
||||
// capture the starting positions of all other selected nodes
|
||||
// Only move other selected items if the dragged node is part of the selection
|
||||
const isDraggedNodeInSelection = selectedNodes?.has(nodeId)
|
||||
const isDraggedNodeInSelection = selectedNodes.has(nodeId)
|
||||
draggedSelectionNodeIds = isDraggedNodeInSelection
|
||||
? new Set(selectedNodes)
|
||||
: new Set([nodeId])
|
||||
draggedSelectionBounds = getDraggedSelectionBounds(draggedSelectionNodeIds)
|
||||
updateDragSnapGuides([])
|
||||
|
||||
if (isDraggedNodeInSelection && selectedNodes.size > 1) {
|
||||
otherSelectedNodesStartPositions = new Map()
|
||||
@@ -94,23 +112,28 @@ function useNodeDragIndividual() {
|
||||
return
|
||||
}
|
||||
|
||||
// Throttle position updates using requestAnimationFrame for better performance
|
||||
if (rafId !== null) return // Skip if frame already scheduled
|
||||
|
||||
const { target, pointerId } = event
|
||||
if (target instanceof HTMLElement && !target.hasPointerCapture(pointerId)) {
|
||||
// Delay capture to drag to allow for the Node cloning
|
||||
target.setPointerCapture(pointerId)
|
||||
}
|
||||
pendingDragEvent = event
|
||||
|
||||
// Throttle position updates using requestAnimationFrame for better performance
|
||||
if (rafId !== null) return // Skip if frame already scheduled
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
|
||||
if (!dragStartPos || !dragStartMouse) return
|
||||
if (!dragStartPos || !dragStartMouse || !pendingDragEvent) return
|
||||
|
||||
const dragEvent = pendingDragEvent
|
||||
pendingDragEvent = null
|
||||
|
||||
// Calculate mouse delta in screen coordinates
|
||||
const mouseDelta = {
|
||||
x: event.clientX - dragStartMouse.x,
|
||||
y: event.clientY - dragStartMouse.y
|
||||
x: dragEvent.clientX - dragStartMouse.x,
|
||||
y: dragEvent.clientY - dragStartMouse.y
|
||||
}
|
||||
|
||||
// Convert to canvas coordinates
|
||||
@@ -120,11 +143,15 @@ function useNodeDragIndividual() {
|
||||
x: canvasWithDelta.x - canvasOrigin.x,
|
||||
y: canvasWithDelta.y - canvasOrigin.y
|
||||
}
|
||||
const snappedCanvasDelta = maybeResolveAlignmentSnap(
|
||||
dragEvent,
|
||||
canvasDelta
|
||||
)
|
||||
|
||||
// Calculate new position for the current node
|
||||
const newPosition = {
|
||||
x: dragStartPos.x + canvasDelta.x,
|
||||
y: dragStartPos.y + canvasDelta.y
|
||||
x: dragStartPos.x + snappedCanvasDelta.x,
|
||||
y: dragStartPos.y + snappedCanvasDelta.y
|
||||
}
|
||||
|
||||
// Move drag updates in one transaction to avoid per-node notify fan-out.
|
||||
@@ -140,8 +167,8 @@ function useNodeDragIndividual() {
|
||||
startPos
|
||||
] of otherSelectedNodesStartPositions) {
|
||||
const newOtherPosition = {
|
||||
x: startPos.x + canvasDelta.x,
|
||||
y: startPos.y + canvasDelta.y
|
||||
x: startPos.x + snappedCanvasDelta.x,
|
||||
y: startPos.y + snappedCanvasDelta.y
|
||||
}
|
||||
updates.push({ nodeId: otherNodeId, position: newOtherPosition })
|
||||
}
|
||||
@@ -153,8 +180,8 @@ function useNodeDragIndividual() {
|
||||
// This matches LiteGraph's behavior which uses delta-based movement
|
||||
if (selectedGroups && selectedGroups.length > 0 && lastCanvasDelta) {
|
||||
const frameDelta = {
|
||||
x: canvasDelta.x - lastCanvasDelta.x,
|
||||
y: canvasDelta.y - lastCanvasDelta.y
|
||||
x: snappedCanvasDelta.x - lastCanvasDelta.x,
|
||||
y: snappedCanvasDelta.y - lastCanvasDelta.y
|
||||
}
|
||||
|
||||
for (const group of selectedGroups) {
|
||||
@@ -162,13 +189,13 @@ function useNodeDragIndividual() {
|
||||
}
|
||||
}
|
||||
|
||||
lastCanvasDelta = canvasDelta
|
||||
lastCanvasDelta = snappedCanvasDelta
|
||||
})
|
||||
}
|
||||
|
||||
function endDrag(event: PointerEvent, nodeId: NodeId | undefined) {
|
||||
// Apply snap to final position if snap was active (matches LiteGraph behavior)
|
||||
if (shouldSnap(event) && nodeId) {
|
||||
if (shouldSnapToGrid(event) && nodeId) {
|
||||
const boundsUpdates: NodeBoundsUpdate[] = []
|
||||
|
||||
// Snap main node
|
||||
@@ -228,26 +255,171 @@ function useNodeDragIndividual() {
|
||||
}
|
||||
}
|
||||
|
||||
dragStartPos = null
|
||||
dragStartMouse = null
|
||||
otherSelectedNodesStartPositions = null
|
||||
selectedGroups = null
|
||||
lastCanvasDelta = null
|
||||
cleanupDrag()
|
||||
}
|
||||
|
||||
// Stop tracking shift key state
|
||||
stopShiftSync?.()
|
||||
stopShiftSync = null
|
||||
|
||||
// Cancel any pending animation frame
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
function cancelDrag() {
|
||||
cleanupDrag()
|
||||
}
|
||||
|
||||
return {
|
||||
startDrag,
|
||||
handleDrag,
|
||||
endDrag
|
||||
endDrag,
|
||||
cancelDrag
|
||||
}
|
||||
|
||||
function maybeResolveAlignmentSnap(
|
||||
event: PointerEvent,
|
||||
canvasDelta: Point
|
||||
): Point {
|
||||
if (!settingStore.get('Comfy.Canvas.AlignNodesWhileDragging')) {
|
||||
updateDragSnapGuides([])
|
||||
return canvasDelta
|
||||
}
|
||||
|
||||
const isGridSnapActive = shouldSnapToGrid(event)
|
||||
if (
|
||||
isGridSnapActive ||
|
||||
!draggedSelectionBounds ||
|
||||
!draggedSelectionNodeIds
|
||||
) {
|
||||
updateDragSnapGuides([])
|
||||
return canvasDelta
|
||||
}
|
||||
|
||||
const translatedSelectionBounds = translateBounds(
|
||||
draggedSelectionBounds,
|
||||
canvasDelta
|
||||
)
|
||||
const searchRadius = SNAP_SEARCH_RADIUS_PX / transformState.camera.z
|
||||
const candidateBounds = getNearbyAlignmentCandidateBounds(
|
||||
translatedSelectionBounds,
|
||||
draggedSelectionNodeIds,
|
||||
searchRadius
|
||||
)
|
||||
|
||||
const snapResult: NodeAlignmentSnapResult = resolveNodeAlignmentSnap({
|
||||
selectionBounds: draggedSelectionBounds,
|
||||
candidateBounds,
|
||||
delta: canvasDelta,
|
||||
zoomScale: transformState.camera.z
|
||||
})
|
||||
|
||||
updateDragSnapGuides(snapResult.guides)
|
||||
return snapResult.delta
|
||||
}
|
||||
|
||||
function getDraggedSelectionBounds(nodeIds: Set<NodeId>): Bounds | null {
|
||||
const bounds = Array.from(nodeIds)
|
||||
.map((id) => layoutStore.getNodeLayoutRef(id).value)
|
||||
.filter((layout): layout is NonNullable<typeof layout> => layout !== null)
|
||||
.map(getRenderedNodeBounds)
|
||||
|
||||
return mergeBounds(bounds)
|
||||
}
|
||||
|
||||
function getNearbyAlignmentCandidateBounds(
|
||||
translatedSelectionBounds: Bounds,
|
||||
selectedNodeSet: Set<NodeId>,
|
||||
searchRadius: number
|
||||
): Bounds[] {
|
||||
const candidateIds = layoutStore.queryNodesInBounds(
|
||||
expandBounds(translatedSelectionBounds, searchRadius)
|
||||
)
|
||||
const candidates: Bounds[] = []
|
||||
|
||||
for (const nodeId of candidateIds) {
|
||||
if (selectedNodeSet.has(nodeId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const layout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (!layout) {
|
||||
continue
|
||||
}
|
||||
|
||||
candidates.push(getRenderedNodeBounds(layout))
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
function updateDragSnapGuides(guides: NodeAlignmentGuide[]) {
|
||||
if (!guides.length && !layoutStore.vueDragSnapGuides.value.length) {
|
||||
return
|
||||
}
|
||||
|
||||
layoutStore.vueDragSnapGuides.value = guides
|
||||
canvasStore.canvas?.setDirty(true)
|
||||
}
|
||||
|
||||
function cleanupDrag() {
|
||||
dragStartPos = null
|
||||
dragStartMouse = null
|
||||
otherSelectedNodesStartPositions = null
|
||||
pendingDragEvent = null
|
||||
selectedGroups = null
|
||||
lastCanvasDelta = null
|
||||
draggedSelectionBounds = null
|
||||
draggedSelectionNodeIds = null
|
||||
updateDragSnapGuides([])
|
||||
|
||||
stopShiftSync?.()
|
||||
stopShiftSync = null
|
||||
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getRenderedNodeBounds(layout: {
|
||||
position: Point
|
||||
size: { width: number; height: number }
|
||||
}): Bounds {
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT || 0
|
||||
|
||||
return {
|
||||
x: layout.position.x,
|
||||
y: layout.position.y - titleHeight,
|
||||
width: layout.size.width,
|
||||
height: layout.size.height + titleHeight
|
||||
}
|
||||
}
|
||||
|
||||
function mergeBounds(boundsList: Bounds[]): Bounds | null {
|
||||
const [firstBounds, ...remainingBounds] = boundsList
|
||||
if (!firstBounds) {
|
||||
return null
|
||||
}
|
||||
|
||||
let left = firstBounds.x
|
||||
let top = firstBounds.y
|
||||
let right = firstBounds.x + firstBounds.width
|
||||
let bottom = firstBounds.y + firstBounds.height
|
||||
|
||||
for (const bounds of remainingBounds) {
|
||||
left = Math.min(left, bounds.x)
|
||||
top = Math.min(top, bounds.y)
|
||||
right = Math.max(right, bounds.x + bounds.width)
|
||||
bottom = Math.max(bottom, bounds.y + bounds.height)
|
||||
}
|
||||
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
width: right - left,
|
||||
height: bottom - top
|
||||
}
|
||||
}
|
||||
|
||||
function expandBounds(bounds: Bounds, padding: number): Bounds {
|
||||
return {
|
||||
x: bounds.x - padding,
|
||||
y: bounds.y - padding,
|
||||
width: bounds.width + padding * 2,
|
||||
height: bounds.height + padding * 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,6 +466,7 @@ const zSettings = z.object({
|
||||
'Comfy.RightSidePanel.IsOpen': z.boolean(),
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': z.boolean(),
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets': z.boolean(),
|
||||
'Comfy.Canvas.AlignNodesWhileDragging': z.boolean(),
|
||||
'LiteGraph.Group.SelectChildrenOnClick': z.boolean()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user