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:
Christian Byrne
2025-09-27 10:56:46 -07:00
committed by GitHub
parent 992ba9d585
commit 856eb446a5
8 changed files with 119 additions and 11 deletions

View 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)
})
})

View File

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

View File

@@ -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 {

View File

@@ -6,6 +6,7 @@ import type { LGraphNode } from './LGraphNode'
const DEFAULT_TRACKED_PROPERTIES: string[] = [
'title',
'flags.collapsed',
'flags.pinned',
'mode'
]

View File

@@ -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()

View File

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

View File

@@ -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

View File

@@ -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) => {