From 856eb446a5e65d501a2c9dd922e2ae305b9392ff Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sat, 27 Sep 2025 10:56:46 -0700 Subject: [PATCH] Add node pinning functionality to Vue nodes (#5772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) Screenshot from 2025-09-25 13-02-21 Screenshot from 2025-09-25 13-02-10 ## 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> --- .../tests/vueNodes/nodeStates/pin.spec.ts | 85 +++++++++++++++++++ src/composables/graph/useGraphNodeManager.ts | 10 +++ src/lib/litegraph/src/LGraphNode.ts | 4 +- src/lib/litegraph/src/LGraphNodeProperties.ts | 1 + .../core/canvas/useCanvasInteractions.ts | 3 +- .../vueNodes/components/LGraphNode.vue | 2 +- .../vueNodes/components/NodeHeader.vue | 9 +- .../composables/useNodePointerInteractions.ts | 16 +++- 8 files changed, 119 insertions(+), 11 deletions(-) create mode 100644 browser_tests/tests/vueNodes/nodeStates/pin.spec.ts diff --git a/browser_tests/tests/vueNodes/nodeStates/pin.spec.ts b/browser_tests/tests/vueNodes/nodeStates/pin.spec.ts new file mode 100644 index 000000000..27f1ad1ac --- /dev/null +++ b/browser_tests/tests/vueNodes/nodeStates/pin.spec.ts @@ -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) + }) +}) diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index 701e14891..beaca8d4c 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -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, diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts index 20e26e211..a1eeb8ae2 100644 --- a/src/lib/litegraph/src/LGraphNode.ts +++ b/src/lib/litegraph/src/LGraphNode.ts @@ -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 { diff --git a/src/lib/litegraph/src/LGraphNodeProperties.ts b/src/lib/litegraph/src/LGraphNodeProperties.ts index 41bb1f673..271d98e1e 100644 --- a/src/lib/litegraph/src/LGraphNodeProperties.ts +++ b/src/lib/litegraph/src/LGraphNodeProperties.ts @@ -6,6 +6,7 @@ import type { LGraphNode } from './LGraphNode' const DEFAULT_TRACKED_PROPERTIES: string[] = [ 'title', 'flags.collapsed', + 'flags.pinned', 'mode' ] diff --git a/src/renderer/core/canvas/useCanvasInteractions.ts b/src/renderer/core/canvas/useCanvasInteractions.ts index cddc50d08..a32d5e864 100644 --- a/src/renderer/core/canvas/useCanvasInteractions.ts +++ b/src/renderer/core/canvas/useCanvasInteractions.ts @@ -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() diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index 9e4ecefd6..a86d8de3a 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -49,7 +49,7 @@
+
@@ -141,6 +146,8 @@ watch( } ) +const isPinned = computed(() => Boolean(nodeData?.flags?.pinned)) + // Subgraph detection const isSubgraphNode = computed(() => { if (!nodeData?.id) return false diff --git a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts index ce63dcadd..e00e77c24 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts @@ -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) => {