mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
Add node pinning functionality to Vue nodes (#5772)
## Summary Added pinning functionality to Vue nodes with hotkey support and visual indicators. ## Changes - **What**: Added node pinning feature with 'p' hotkey toggle and pin icon indicator - **Components**: Updated `LGraphNode.vue` and `NodeHeader.vue` with pin state tracking - **State Management**: Extended `useGraphNodeManager` to sync pinned flag with Vue components - **Tests**: Added E2E tests for single and multi-node pin toggling ## Review Focus Pin state persistence in graph serialization and visual indicator positioning in node header layout. Verify hotkey doesn't conflict with existing shortcuts. ## Technical Details - Pin state tracked via `flags.pinned` property in `LGraphNode` - Uses [Vue memoization](https://vuejs.org/api/reactivity-advanced.html#v-memo) for efficient header re-rendering - Integrates with existing node property change detection system - Visual indicator uses Lucide pin icon with theme-aware styling ## Screenshots (if applicable) <img width="875" height="977" alt="Screenshot from 2025-09-25 13-02-21" src="https://github.com/user-attachments/assets/51d46cea-08f0-44fb-8b07-56d1b939338f" /> <img width="875" height="977" alt="Screenshot from 2025-09-25 13-02-10" src="https://github.com/user-attachments/assets/ce247426-1e39-48c0-924b-658b65c24f52" /> ## Related - https://github.com/Comfy-Org/ComfyUI_frontend/pull/5715 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5772-Add-node-pinning-functionality-to-Vue-nodes-2796d73d36508195914bcfc986aa66b5) by [Unito](https://www.unito.io) --------- Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
This commit is contained in:
85
browser_tests/tests/vueNodes/nodeStates/pin.spec.ts
Normal file
85
browser_tests/tests/vueNodes/nodeStates/pin.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const PIN_HOTKEY = 'p'
|
||||
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
|
||||
|
||||
test.describe('Vue Node Pin', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should allow toggling pin on a selected node with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const pinIndicator = checkpointNode.locator(PIN_INDICATOR)
|
||||
|
||||
await expect(pinIndicator).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
await expect(pinIndicator).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should allow toggling pin on multiple selected nodes with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
const pinIndicator1 = checkpointNode.locator(PIN_INDICATOR)
|
||||
await expect(pinIndicator1).toBeVisible()
|
||||
const pinIndicator2 = ksamplerNode.locator(PIN_INDICATOR)
|
||||
await expect(pinIndicator2).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
await expect(pinIndicator1).not.toBeVisible()
|
||||
await expect(pinIndicator2).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
|
||||
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
// Try to drag the node
|
||||
const headerPos = await checkpointNodeHeader.boundingBox()
|
||||
if (!headerPos) throw new Error('Failed to get header position')
|
||||
await comfyPage.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
|
||||
// Verify the node is not dragged (same position before and after click-and-drag)
|
||||
const headerPosAfterDrag = await checkpointNodeHeader.boundingBox()
|
||||
if (!headerPosAfterDrag)
|
||||
throw new Error('Failed to get header position after drag')
|
||||
expect(headerPosAfterDrag).toEqual(headerPos)
|
||||
|
||||
// Unpin the node with the hotkey
|
||||
await checkpointNodeHeader.click()
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
// Try to drag the node again
|
||||
await comfyPage.dragAndDrop(
|
||||
{ x: headerPos.x, y: headerPos.y },
|
||||
{ x: headerPos.x + 256, y: headerPos.y + 256 }
|
||||
)
|
||||
|
||||
// Verify the node is dragged
|
||||
const headerPosAfterDrag2 = await checkpointNodeHeader.boundingBox()
|
||||
if (!headerPosAfterDrag2)
|
||||
throw new Error('Failed to get header position after drag')
|
||||
expect(headerPosAfterDrag2).not.toEqual(headerPos)
|
||||
})
|
||||
})
|
||||
@@ -39,6 +39,7 @@ export interface VueNodeData {
|
||||
hasErrors?: boolean
|
||||
flags?: {
|
||||
collapsed?: boolean
|
||||
pinned?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,6 +435,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'flags.pinned':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
pinned: Boolean(event.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'mode':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
|
||||
@@ -3398,9 +3398,7 @@ export class LGraphNode
|
||||
this.graph._version++
|
||||
this.flags.pinned = v ?? !this.flags.pinned
|
||||
this.resizable = !this.pinned
|
||||
// Delete the flag if unpinned, so that we don't get unnecessary
|
||||
// flags.pinned = false in serialized object.
|
||||
if (!this.pinned) delete this.flags.pinned
|
||||
if (!this.pinned) this.flags.pinned = undefined
|
||||
}
|
||||
|
||||
unpin(): void {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { LGraphNode } from './LGraphNode'
|
||||
const DEFAULT_TRACKED_PROPERTIES: string[] = [
|
||||
'title',
|
||||
'flags.collapsed',
|
||||
'flags.pinned',
|
||||
'mode'
|
||||
]
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@ import { app } from '@/scripts/app'
|
||||
|
||||
/**
|
||||
* Composable for handling canvas interactions from Vue components.
|
||||
* This provides a unified way to forward events to the LiteGraph canvas
|
||||
* and will be the foundation for migrating canvas interactions to Vue.
|
||||
* This provides a unified way to forward events to the LiteGraph canvas.
|
||||
*/
|
||||
export function useCanvasInteractions() {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</template>
|
||||
<!-- Header only updates on title/color changes -->
|
||||
<NodeHeader
|
||||
v-memo="[nodeData.title, isCollapsed]"
|
||||
v-memo="[nodeData.title, isCollapsed, nodeData.flags?.pinned]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:collapsed="isCollapsed"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<!-- Node Title -->
|
||||
<div
|
||||
v-tooltip.top="tooltipConfig"
|
||||
class="text-sm font-bold truncate flex-1 lod-toggle"
|
||||
class="text-sm font-bold truncate flex-1 lod-toggle flex items-center gap-2"
|
||||
data-testid="node-title"
|
||||
>
|
||||
<EditableText
|
||||
@@ -36,6 +36,11 @@
|
||||
@edit="handleTitleEdit"
|
||||
@cancel="handleTitleCancel"
|
||||
/>
|
||||
<i-lucide:pin
|
||||
v-if="isPinned"
|
||||
class="w-5 h-5 text-stone-200 dark-theme:text-slate-300"
|
||||
data-testid="node-pin-indicator"
|
||||
/>
|
||||
</div>
|
||||
<LODFallback />
|
||||
</div>
|
||||
@@ -141,6 +146,8 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const isPinned = computed(() => Boolean(nodeData?.flags?.pinned))
|
||||
|
||||
// Subgraph detection
|
||||
const isSubgraphNode = computed(() => {
|
||||
if (!nodeData?.id) return false
|
||||
|
||||
@@ -36,9 +36,12 @@ export function useNodePointerInteractions(
|
||||
|
||||
// Drag state for styling
|
||||
const isDragging = ref(false)
|
||||
const dragStyle = computed(() => ({
|
||||
cursor: isDragging.value ? 'grabbing' : 'grab'
|
||||
}))
|
||||
const dragStyle = computed(() => {
|
||||
if (nodeData.value?.flags?.pinned) {
|
||||
return { cursor: 'default' }
|
||||
}
|
||||
return { cursor: isDragging.value ? 'grabbing' : 'grab' }
|
||||
})
|
||||
const startPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
@@ -60,6 +63,12 @@ export function useNodePointerInteractions(
|
||||
return
|
||||
}
|
||||
|
||||
// Don't allow dragging if node is pinned (but still record position for selection)
|
||||
startPosition.value = { x: event.clientX, y: event.clientY }
|
||||
if (nodeData.value.flags?.pinned) {
|
||||
return
|
||||
}
|
||||
|
||||
// Start drag using layout system
|
||||
isDragging.value = true
|
||||
|
||||
@@ -67,7 +76,6 @@ export function useNodePointerInteractions(
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
|
||||
startDrag(event)
|
||||
startPosition.value = { x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
|
||||
Reference in New Issue
Block a user