mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-26 23:37:34 +00:00
Compare commits
1 Commits
refactor/a
...
bl/nodes-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
023ff7f36a |
110
browser_tests/tests/vueNodes/interactions/node/alignment.spec.ts
Normal file
110
browser_tests/tests/vueNodes/interactions/node/alignment.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
108
src/composables/graph/useMoreOptionsMenu.test.ts
Normal file
108
src/composables/graph/useMoreOptionsMenu.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.31.0'
|
||||
},
|
||||
{
|
||||
id: 'LiteGraph.Reroute.SplineOffset',
|
||||
name: 'Reroute spline offset',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -275,7 +275,8 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
widget.name,
|
||||
widget.nodeId !== undefined
|
||||
? String(stripGraphPrefix(widget.nodeId))
|
||||
: undefined
|
||||
: undefined,
|
||||
'contextmenu'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ export function useNodePointerInteractions(
|
||||
|
||||
function cleanupDragState() {
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
layoutStore.vueDragSnapGuides.value = []
|
||||
}
|
||||
|
||||
function safeDragStart(event: PointerEvent, nodeId: string) {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
225
src/renderer/extensions/vueNodes/layout/nodeAlignmentSnap.ts
Normal file
225
src/renderer/extensions/vueNodes/layout/nodeAlignmentSnap.ts
Normal 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
|
||||
}
|
||||
}
|
||||
150
src/renderer/extensions/vueNodes/layout/useNodeDrag.test.ts
Normal file
150
src/renderer/extensions/vueNodes/layout/useNodeDrag.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user