Compare commits

...

1 Commits

Author SHA1 Message Date
Benjamin Lu
023ff7f36a feat: add nodes 2 alignment tools 2026-03-11 12:07:10 -07:00
24 changed files with 1073 additions and 51 deletions

View File

@@ -0,0 +1,110 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
async function getNodeHeader(comfyPage: ComfyPage, title: string) {
const node = comfyPage.vueNodes.getNodeByTitle(title).first()
await expect(node).toBeVisible()
return node.locator('.lg-node-header')
}
async function selectTwoNodes(comfyPage: ComfyPage) {
const checkpointHeader = await getNodeHeader(comfyPage, 'Load Checkpoint')
const ksamplerHeader = await getNodeHeader(comfyPage, 'KSampler')
await checkpointHeader.click()
await ksamplerHeader.click({ modifiers: ['Control'] })
await comfyPage.nextFrame()
}
test.describe('Vue Node Alignment', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', 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))
})
test('shows center alignment actions from the multi-node right-click menu', async ({
comfyPage
}) => {
await selectTwoNodes(comfyPage)
const ksamplerHeader = await getNodeHeader(comfyPage, 'KSampler')
await ksamplerHeader.click({ button: 'right' })
const alignMenuItem = comfyPage.page.getByText('Align Selected To', {
exact: true
})
await expect(alignMenuItem).toBeVisible()
await alignMenuItem.hover()
await expect(
comfyPage.page.getByText('Horizontal Center', { exact: true })
).toBeVisible()
await expect(
comfyPage.page.getByText('Vertical Center', { exact: true })
).toBeVisible()
})
test('does not show alignment actions from the selection toolbox More Options menu', async ({
comfyPage
}) => {
await selectTwoNodes(comfyPage)
await expect(comfyPage.selectionToolbox).toBeVisible()
await comfyPage.page.click('[data-testid="more-options-button"]')
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeVisible()
await expect(
comfyPage.page.getByText('Align Selected To', { exact: true })
).not.toBeVisible()
})
})

View File

@@ -18,6 +18,6 @@ import Button from '@/components/ui/button/Button.vue'
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
const handleClick = (event: Event) => {
toggleNodeOptions(event)
toggleNodeOptions(event, 'toolbar')
}
</script>

View File

@@ -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('rightSidePanel.globalSettings.alignNodesWhileDragging')"
:tooltip="t('settings.Comfy_Canvas_AlignNodesWhileDragging.tooltip')"
/>
</div>
</PropertiesAccordionItem>

View File

@@ -45,6 +45,8 @@ const CORE_MENU_ITEMS = new Set([
'Convert to Subgraph',
'Frame selection',
'Frame Nodes',
'Align Selected To',
'Distribute Nodes',
'Minimize Node',
'Expand',
'Collapse',
@@ -229,6 +231,8 @@ const MENU_ORDER: string[] = [
'Convert to Subgraph',
'Frame selection',
'Frame Nodes',
'Align Selected To',
'Distribute Nodes',
'Minimize Node',
'Expand',
'Collapse',
@@ -301,14 +305,14 @@ export function buildStructuredMenu(options: MenuOption[]): MenuOption[] {
// Section boundaries based on MENU_ORDER indices
// Section 1: 0-2 (Rename, Copy, Duplicate)
// Section 2: 3-8 (Run Branch, Pin, Unpin, Bypass, Remove Bypass, Mute)
// Section 3: 9-15 (Convert to Subgraph, Frame selection, Minimize Node, Expand, Collapse, Resize, Clone)
// Section 4: 16-17 (Node Info, Color)
// Section 5: 18+ (Image operations and fallback items)
// Section 3: 9-17 (Convert to Subgraph ... Clone)
// Section 4: 18-19 (Node Info, Color)
// Section 5: 20+ (Image operations and fallback items)
const getSectionNumber = (index: number): number => {
if (index <= 2) return 1
if (index <= 8) return 2
if (index <= 15) return 3
if (index <= 17) return 4
if (index <= 17) return 3
if (index <= 19) return 4
return 5
}

View File

@@ -0,0 +1,108 @@
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
showNodeOptions,
toggleNodeOptions,
useMoreOptionsMenu
} from '@/composables/graph/useMoreOptionsMenu'
const selectedItems = ref([{ id: 'node-1' }, { id: 'node-2' }])
const selectedNodes = ref([{ id: 'node-1' }, { id: 'node-2' }])
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: null
})
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphGroup: () => false
}))
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: () => ({
selectedItems: computed(() => selectedItems.value),
selectedNodes: computed(() => selectedNodes.value),
nodeDef: computed(() => null),
showNodeHelp: vi.fn(),
hasSubgraphs: computed(() => false),
hasImageNode: computed(() => false),
hasOutputNodesSelected: computed(() => false),
hasMultipleSelection: computed(() => true),
computeSelectionFlags: () => ({
collapsed: false,
pinned: false,
bypassed: false
})
})
}))
vi.mock('@/composables/graph/useImageMenuOptions', () => ({
useImageMenuOptions: () => ({
getImageMenuOptions: () => []
})
}))
vi.mock('@/composables/graph/useNodeMenuOptions', () => ({
useNodeMenuOptions: () => ({
getNodeInfoOption: () => ({ label: 'Node Info' }),
getNodeVisualOptions: () => [],
getPinOption: () => ({ label: 'Pin' }),
getBypassOption: () => ({ label: 'Bypass' }),
getRunBranchOption: () => ({ label: 'Run Branch' })
})
}))
vi.mock('@/composables/graph/useGroupMenuOptions', () => ({
useGroupMenuOptions: () => ({
getFitGroupToNodesOption: () => ({ label: 'Fit Group to Nodes' }),
getGroupColorOptions: () => ({ label: 'Group Color' }),
getGroupModeOptions: () => []
})
}))
vi.mock('@/composables/graph/useSelectionMenuOptions', () => ({
useSelectionMenuOptions: () => ({
getBasicSelectionOptions: () => [{ label: 'Rename' }],
getMultipleNodesOptions: () => [{ label: 'Frame Nodes' }],
getSubgraphOptions: () => [],
getAlignmentOptions: () => [{ label: 'Align Selected To' }]
})
}))
vi.mock('@/core/graph/subgraph/promotedWidgetTypes', () => ({
isPromotedWidgetView: () => false
}))
vi.mock('@/services/litegraphService', () => ({
getExtraOptionsForWidget: () => []
}))
describe('useMoreOptionsMenu', () => {
beforeEach(() => {
selectedItems.value = [{ id: 'node-1' }, { id: 'node-2' }]
selectedNodes.value = [{ id: 'node-1' }, { id: 'node-2' }]
toggleNodeOptions(new Event('click'), 'toolbar')
})
it('adds alignment options for right-click menus only', () => {
const { menuOptions } = useMoreOptionsMenu()
expect(
menuOptions.value.some((option) => option.label === 'Align Selected To')
).toBe(false)
showNodeOptions(new MouseEvent('contextmenu'))
expect(
menuOptions.value.some((option) => option.label === 'Align Selected To')
).toBe(true)
toggleNodeOptions(new Event('click'), 'toolbar')
expect(
menuOptions.value.some((option) => option.label === 'Align Selected To')
).toBe(false)
})
})

View File

@@ -43,6 +43,8 @@ export interface SubMenuOption {
disabled?: boolean
}
type NodeOptionsTriggerSource = 'contextmenu' | 'toolbar'
export enum BadgeVariant {
NEW = 'new',
DEPRECATED = 'deprecated'
@@ -50,6 +52,7 @@ export enum BadgeVariant {
// Global singleton for NodeOptions component reference
let nodeOptionsInstance: null | NodeOptionsInstance = null
const nodeOptionsTriggerSource = ref<NodeOptionsTriggerSource>('toolbar')
const hoveredWidget = ref<[string, NodeId | undefined]>()
@@ -57,7 +60,11 @@ const hoveredWidget = ref<[string, NodeId | undefined]>()
* Toggle the node options popover
* @param event - The trigger event
*/
export function toggleNodeOptions(event: Event) {
export function toggleNodeOptions(
event: Event,
triggerSource: NodeOptionsTriggerSource = 'toolbar'
) {
nodeOptionsTriggerSource.value = triggerSource
if (nodeOptionsInstance?.toggle) {
nodeOptionsInstance.toggle(event)
}
@@ -71,8 +78,10 @@ export function toggleNodeOptions(event: Event) {
export function showNodeOptions(
event: MouseEvent,
widgetName?: string,
nodeId?: NodeId
nodeId?: NodeId,
triggerSource: NodeOptionsTriggerSource = 'contextmenu'
) {
nodeOptionsTriggerSource.value = triggerSource
hoveredWidget.value = widgetName ? [widgetName, nodeId] : undefined
if (nodeOptionsInstance?.show) {
nodeOptionsInstance.show(event)
@@ -147,7 +156,8 @@ export function useMoreOptionsMenu() {
const {
getBasicSelectionOptions,
getMultipleNodesOptions,
getSubgraphOptions
getSubgraphOptions,
getAlignmentOptions
} = useSelectionMenuOptions()
const hasSubgraphs = hasSubgraphsComputed
@@ -227,6 +237,9 @@ export function useMoreOptionsMenu() {
)
if (hasMultipleNodes.value) {
options.push(...getMultipleNodesOptions())
if (nodeOptionsTriggerSource.value === 'contextmenu') {
options.push(...getAlignmentOptions(selectedNodes.value.length))
}
}
if (groupContext) {
options.push(getFitGroupToNodesOption(groupContext))

View File

@@ -1,6 +1,6 @@
import { useI18n } from 'vue-i18n'
import type { Direction } from '@/lib/litegraph/src/interfaces'
import type { ArrangementDirection } from '@/lib/litegraph/src/interfaces'
import { alignNodes, distributeNodes } from '@/lib/litegraph/src/utils/arrange'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
@@ -10,7 +10,7 @@ import { useCanvasRefresh } from './useCanvasRefresh'
interface AlignOption {
name: string
localizedName: string
value: Direction
value: ArrangementDirection
icon: string
}
@@ -52,6 +52,18 @@ export function useNodeArrangement() {
localizedName: t('contextMenu.Right'),
value: 'right',
icon: 'icon-[lucide--align-end-horizontal]'
},
{
name: 'horizontal-center',
localizedName: t('contextMenu.Horizontal Center'),
value: 'horizontal-center',
icon: 'icon-[lucide--align-center-horizontal]'
},
{
name: 'vertical-center',
localizedName: t('contextMenu.Vertical Center'),
value: 'vertical-center',
icon: 'icon-[lucide--align-center-vertical]'
}
]
@@ -75,7 +87,7 @@ export function useNodeArrangement() {
isLGraphNode(item)
)
if (selectedNodes.length === 0) {
if (selectedNodes.length < 2) {
return
}
@@ -90,7 +102,7 @@ export function useNodeArrangement() {
isLGraphNode(item)
)
if (selectedNodes.length < 2) {
if (selectedNodes.length < 3) {
return
}

View File

@@ -81,6 +81,27 @@ describe('useSelectionMenuOptions - multiple nodes options', () => {
)
expect(groupNodeOption).toBeDefined()
})
it('returns align options for two selected nodes', () => {
const { getAlignmentOptions } = useSelectionMenuOptions()
const options = getAlignmentOptions(2)
expect(options.map((option) => option.label)).toEqual([
'contextMenu.Align Selected To'
])
})
it('returns align and distribute options for three selected nodes', () => {
const { getAlignmentOptions } = useSelectionMenuOptions()
const options = getAlignmentOptions(3)
expect(options.map((option) => option.label)).toEqual([
'contextMenu.Align Selected To',
'contextMenu.Distribute Nodes'
])
})
})
describe('useSelectionMenuOptions - subgraph options', () => {

View File

@@ -125,22 +125,31 @@ export function useSelectionMenuOptions() {
]
}
const getAlignmentOptions = (): MenuOption[] => [
{
label: t('contextMenu.Align Selected To'),
icon: 'icon-[lucide--align-start-horizontal]',
hasSubmenu: true,
submenu: alignSubmenu.value,
action: () => {}
},
{
label: t('contextMenu.Distribute Nodes'),
icon: 'icon-[lucide--align-center-horizontal]',
hasSubmenu: true,
submenu: distributeSubmenu.value,
action: () => {}
const getAlignmentOptions = (selectedNodeCount: number): MenuOption[] => {
const options: MenuOption[] = []
if (selectedNodeCount >= 2) {
options.push({
label: t('contextMenu.Align Selected To'),
icon: 'icon-[lucide--align-start-horizontal]',
hasSubmenu: true,
submenu: alignSubmenu.value,
action: () => {}
})
}
]
if (selectedNodeCount >= 3) {
options.push({
label: t('contextMenu.Distribute Nodes'),
icon: 'icon-[lucide--align-center-horizontal]',
hasSubmenu: true,
submenu: distributeSubmenu.value,
action: () => {}
})
}
return options
}
const getDeleteOption = (): MenuOption => ({
label: t('contextMenu.Delete'),

View File

@@ -40,7 +40,7 @@ import type {
ContextMenuDivElement,
DefaultConnectionColors,
Dictionary,
Direction,
ArrangementDirection,
IBoundaryNodes,
IColorable,
IContextMenuOptions,
@@ -1053,7 +1053,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
*/
static alignNodes(
nodes: Dictionary<LGraphNode>,
direction: Direction,
direction: ArrangementDirection,
align_to?: LGraphNode
): void {
const newPositions = alignNodes(Object.values(nodes), direction, align_to)
@@ -1077,7 +1077,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
function inner_clicked(value: string) {
const newPositions = alignNodes(
Object.values(LGraphCanvas.active_canvas.selected_nodes),
value.toLowerCase() as Direction,
value.toLowerCase() as ArrangementDirection,
node
)
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
@@ -1100,7 +1100,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
function inner_clicked(value: string) {
const newPositions = alignNodes(
Object.values(LGraphCanvas.active_canvas.selected_nodes),
value.toLowerCase() as Direction
value.toLowerCase() as ArrangementDirection
)
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
LGraphCanvas.active_canvas.setDirty(true, true)
@@ -4923,6 +4923,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!LiteGraph.vueNodesMode || !this.overlayCtx) {
this._drawConnectingLinks(ctx)
this._drawVueDragAlignmentGuides(ctx)
} else {
this._drawOverlayLinks()
}
@@ -5027,7 +5028,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()
@@ -5036,11 +5038,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

View File

@@ -281,7 +281,13 @@ export interface IBoundaryNodes {
left: LGraphNode
}
export type Direction = 'top' | 'bottom' | 'left' | 'right'
export type ArrangementDirection =
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'horizontal-center'
| 'vertical-center'
/** Resize handle positions (compass points) */
export type CompassCorners = 'NE' | 'SE' | 'SW' | 'NW'

View File

@@ -1,5 +1,18 @@
import type { LGraphNode } from '../LGraphNode'
import type { Direction, IBoundaryNodes, NewNodePosition } from '../interfaces'
import type {
ArrangementDirection,
IBoundaryNodes,
NewNodePosition
} from '../interfaces'
interface NodeSelectionBounds {
top: number
right: number
bottom: number
left: number
centerX: number
centerY: number
}
/**
* Finds the nodes that are farthest in all four directions, representing the boundary of the nodes.
@@ -45,7 +58,7 @@ export function distributeNodes(
horizontal?: boolean
): NewNodePosition[] {
const nodeCount = nodes?.length
if (!(nodeCount > 1)) return []
if (!(nodeCount > 2)) return []
const index = horizontal ? 0 : 1
@@ -88,7 +101,7 @@ export function distributeNodes(
*/
export function alignNodes(
nodes: LGraphNode[],
direction: Direction,
direction: ArrangementDirection,
align_to?: LGraphNode
): NewNodePosition[] {
if (!nodes) return []
@@ -100,6 +113,16 @@ export function alignNodes(
if (boundary === null) return []
const selectionBounds = getNodeSelectionBounds(nodes)
const alignToCenterX =
align_to === undefined
? selectionBounds.centerX
: align_to.pos[0] + align_to.size[0] * 0.5
const alignToCenterY =
align_to === undefined
? selectionBounds.centerY
: align_to.pos[1] + align_to.size[1] * 0.5
const nodePositions = nodes.map((node): NewNodePosition => {
switch (direction) {
case 'right':
@@ -134,6 +157,22 @@ export function alignNodes(
y: boundary.bottom.pos[1] + boundary.bottom.size[1] - node.size[1]
}
}
case 'horizontal-center':
return {
node,
newPos: {
x: node.pos[0],
y: alignToCenterY - node.size[1] * 0.5
}
}
case 'vertical-center':
return {
node,
newPos: {
x: alignToCenterX - node.size[0] * 0.5,
y: node.pos[1]
}
}
}
})
@@ -142,3 +181,24 @@ export function alignNodes(
}
return nodePositions
}
function getNodeSelectionBounds(nodes: LGraphNode[]): NodeSelectionBounds {
const boundary = getBoundaryNodes(nodes)
if (!boundary) {
throw new TypeError('Cannot calculate selection bounds without nodes.')
}
const top = boundary.top.pos[1]
const left = boundary.left.pos[0]
const right = boundary.right.pos[0] + boundary.right.size[0]
const bottom = boundary.bottom.pos[1] + boundary.bottom.size[1]
return {
top,
right,
bottom,
left,
centerX: left + (right - left) * 0.5,
centerY: top + (bottom - top) * 0.5
}
}

View File

@@ -546,6 +546,8 @@
"Bottom": "Bottom",
"Left": "Left",
"Right": "Right",
"Horizontal Center": "Horizontal Center",
"Vertical Center": "Vertical Center",
"Horizontal": "Horizontal",
"Vertical": "Vertical",
"new": "new",
@@ -3335,6 +3337,7 @@
"nodes2": "Nodes 2.0",
"gridSpacing": "Grid spacing",
"snapNodesToGrid": "Snap nodes to grid",
"alignNodesWhileDragging": "Align nodes while dragging",
"linkShape": "Link shape",
"showConnectedLinks": "Show connected links",
"viewAllSettings": "View all settings"

View File

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

View File

@@ -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.31.0'
},
{
id: 'LiteGraph.Reroute.SplineOffset',
name: 'Reroute spline offset',

View File

@@ -9,6 +9,7 @@ import { computed, customRef, ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import * as Y from 'yjs'
import type { NodeAlignmentGuide } from '@/renderer/extensions/vueNodes/layout/nodeAlignmentSnap'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
@@ -138,6 +139,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)

View File

@@ -426,7 +426,7 @@ const handleContextMenu = (event: MouseEvent) => {
handleNodeRightClick(event as PointerEvent, nodeData.id)
// Show the node options menu at the cursor position
showNodeOptions(event)
showNodeOptions(event, undefined, undefined, 'contextmenu')
}
/**

View File

@@ -275,7 +275,8 @@ const processedWidgets = computed((): ProcessedWidget[] => {
widget.name,
widget.nodeId !== undefined
? String(stripGraphPrefix(widget.nodeId))
: undefined
: undefined,
'contextmenu'
)
}

View File

@@ -102,6 +102,7 @@ export function useNodePointerInteractions(
function cleanupDragState() {
layoutStore.isDraggingVueNodes.value = false
layoutStore.vueDragSnapGuides.value = []
}
function safeDragStart(event: PointerEvent, nodeId: string) {

View File

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

View File

@@ -0,0 +1,225 @@
import type { Bounds, Point } from '@/renderer/core/layout/types'
const DEFAULT_THRESHOLD_PX = 8
type HorizontalAnchor = 'bottom' | 'centerY' | 'top'
type VerticalAnchor = 'centerX' | 'left' | 'right'
export interface NodeAlignmentGuide {
axis: 'horizontal' | 'vertical'
coordinate: number
start: number
end: number
}
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]]
}
function translateBounds(bounds: Bounds, delta: Point): Bounds {
return {
...bounds,
x: bounds.x + delta.x,
y: bounds.y + delta.y
}
}

View File

@@ -0,0 +1,150 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
import type * as VueUseModule from '@vueuse/core'
const settingValues: Record<string, boolean | number> = {
'Comfy.Canvas.AlignNodesWhileDragging': false,
'Comfy.SnapToGrid.GridSize': 10,
'pysssss.SnapToGrid': false
}
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('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => settingValues[key]
})
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: null
}
}))
describe('useNodeDrag', () => {
beforeEach(() => {
setActivePinia(createPinia())
layoutStore.initializeFromLiteGraph([
{ id: 'node-1', pos: [0, 40], size: [100, 60] },
{ id: 'node-2', pos: [40, 40], size: [100, 60] },
{ id: 'node-3', pos: [200, 40], size: [100, 60] }
])
layoutStore.vueDragSnapGuides.value = []
layoutStore.isDraggingVueNodes.value = false
LiteGraph.NODE_TITLE_HEIGHT = 30
const canvasStore = useCanvasStore()
canvasStore.selectedItems = [{ id: 'node-1' }, { id: 'node-2' }] as never[]
canvasStore.canvas = {
setDirty: vi.fn()
} as never
settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = false
settingValues['pysssss.SnapToGrid'] = false
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0)
return 1
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
})
it('snaps the dragged multi-selection and clears guides on drag end', () => {
settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
const { startDrag, handleDrag, endDrag } = useNodeDrag()
startDrag(
new PointerEvent('pointerdown', { clientX: 0, clientY: 0 }),
'node-1'
)
handleDrag(
new PointerEvent('pointermove', {
clientX: 193,
clientY: 0
}),
'node-1'
)
expect(layoutStore.getNodeLayoutRef('node-1').value?.position.x).toBe(200)
expect(layoutStore.getNodeLayoutRef('node-2').value?.position.x).toBe(240)
expect(layoutStore.vueDragSnapGuides.value).toContainEqual(
expect.objectContaining({
axis: 'vertical',
coordinate: 200
})
)
endDrag(
new PointerEvent('pointerup', { clientX: 193, clientY: 0 }),
'node-1'
)
expect(layoutStore.vueDragSnapGuides.value).toEqual([])
})
it('excludes already selected nodes from alignment candidates', () => {
settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
layoutStore.initializeFromLiteGraph([
{ id: 'node-1', pos: [0, 40], size: [100, 60] },
{ id: 'node-2', pos: [60, 40], size: [100, 60] }
])
const { startDrag, handleDrag } = useNodeDrag()
startDrag(
new PointerEvent('pointerdown', { clientX: 0, clientY: 0 }),
'node-1'
)
handleDrag(
new PointerEvent('pointermove', {
clientX: 63,
clientY: 0
}),
'node-1'
)
expect(layoutStore.getNodeLayoutRef('node-1').value?.position.x).toBe(63)
expect(layoutStore.vueDragSnapGuides.value).toEqual([])
})
it('suppresses alignment snapping while grid snapping is active', () => {
settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
const { startDrag, handleDrag } = useNodeDrag()
startDrag(
new PointerEvent('pointerdown', { clientX: 0, clientY: 0 }),
'node-1'
)
handleDrag(
new PointerEvent('pointermove', {
clientX: 193,
clientY: 0,
shiftKey: true
}),
'node-1'
)
expect(layoutStore.getNodeLayoutRef('node-1').value?.position.x).toBe(193)
expect(layoutStore.vueDragSnapGuides.value).toEqual([])
})
})

View File

@@ -1,12 +1,15 @@
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,
NodeBoundsUpdate,
NodeId,
Point
@@ -17,11 +20,16 @@ import { useTransformState } from '@/renderer/core/layout/transform/useTransform
import { isLGraphGroup } from '@/utils/litegraphUtil'
import { createSharedComposable } from '@vueuse/core'
import type { NodeAlignmentSnapResult } from './nodeAlignmentSnap'
import { resolveNodeAlignmentSnap } from './nodeAlignmentSnap'
export const useNodeDrag = createSharedComposable(useNodeDragIndividual)
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()
@@ -42,6 +50,8 @@ function useNodeDragIndividual() {
// 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 alignmentCandidateBounds: Bounds[] = []
function startDrag(event: PointerEvent, nodeId: NodeId) {
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
@@ -54,11 +64,15 @@ function useNodeDragIndividual() {
dragStartPos = { ...position }
dragStartMouse = { x: event.clientX, y: event.clientY }
const selectedNodes = toValue(selectedNodeIds)
const selectedNodes = new Set(toValue(selectedNodeIds))
selectedNodes.add(nodeId)
draggedSelectionBounds = getDraggedSelectionBounds(selectedNodes)
alignmentCandidateBounds = getAlignmentCandidateBounds(selectedNodes)
updateDragSnapGuides([])
// 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)
if (isDraggedNodeInSelection && selectedNodes.size > 1) {
otherSelectedNodesStartPositions = new Map()
@@ -120,11 +134,12 @@ function useNodeDragIndividual() {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
const snappedCanvasDelta = maybeResolveAlignmentSnap(event, 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
}
// Apply mutation through the layout system (Vue batches DOM updates automatically)
@@ -140,8 +155,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
}
mutations.moveNode(otherNodeId, newOtherPosition)
}
@@ -151,8 +166,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) {
@@ -160,7 +175,7 @@ function useNodeDragIndividual() {
}
}
lastCanvasDelta = canvasDelta
lastCanvasDelta = snappedCanvasDelta
})
}
@@ -231,6 +246,9 @@ function useNodeDragIndividual() {
otherSelectedNodesStartPositions = null
selectedGroups = null
lastCanvasDelta = null
draggedSelectionBounds = null
alignmentCandidateBounds = []
updateDragSnapGuides([])
// Stop tracking shift key state
stopShiftSync?.()
@@ -248,4 +266,99 @@ function useNodeDragIndividual() {
handleDrag,
endDrag
}
function maybeResolveAlignmentSnap(
event: PointerEvent,
canvasDelta: Point
): Point {
if (!settingStore.get('Comfy.Canvas.AlignNodesWhileDragging')) {
updateDragSnapGuides([])
return canvasDelta
}
if (shouldSnap(event) || !draggedSelectionBounds) {
updateDragSnapGuides([])
return canvasDelta
}
const snapResult: NodeAlignmentSnapResult = resolveNodeAlignmentSnap({
selectionBounds: draggedSelectionBounds,
candidateBounds: alignmentCandidateBounds,
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 getAlignmentCandidateBounds(selectedNodeSet: Set<NodeId>): Bounds[] {
const allNodes = layoutStore.getAllNodes().value
const candidates: Bounds[] = []
for (const [nodeId, layout] of allNodes) {
if (selectedNodeSet.has(nodeId)) {
continue
}
candidates.push(getRenderedNodeBounds(layout))
}
return candidates
}
function updateDragSnapGuides(
guides: typeof layoutStore.vueDragSnapGuides.value
) {
layoutStore.vueDragSnapGuides.value = guides
canvasStore.canvas?.setDirty(true, true)
}
}
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
}
}

View File

@@ -464,7 +464,8 @@ const zSettings = z.object({
'Comfy.VersionCompatibility.DisableWarnings': z.boolean(),
'Comfy.RightSidePanel.IsOpen': z.boolean(),
'Comfy.RightSidePanel.ShowErrorsTab': z.boolean(),
'Comfy.Node.AlwaysShowAdvancedWidgets': z.boolean()
'Comfy.Node.AlwaysShowAdvancedWidgets': z.boolean(),
'Comfy.Canvas.AlignNodesWhileDragging': z.boolean()
})
export type EmbeddingsResponse = z.infer<typeof zEmbeddingsResponse>