mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-06 05:30:08 +00:00
## 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>
195 lines
5.2 KiB
Vue
195 lines
5.2 KiB
Vue
<template>
|
|
<div v-if="renderError" class="node-error p-4 text-red-500 text-sm">
|
|
{{ $t('Node Header Error') }}
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="lg-node-header p-4 rounded-t-2xl cursor-move"
|
|
:data-testid="`node-header-${nodeData?.id || ''}`"
|
|
@dblclick="handleDoubleClick"
|
|
>
|
|
<div class="flex items-center justify-between relative">
|
|
<!-- Collapse/Expand Button -->
|
|
<button
|
|
v-show="!readonly"
|
|
class="bg-transparent border-transparent flex items-center lod-toggle"
|
|
data-testid="node-collapse-button"
|
|
@click.stop="handleCollapse"
|
|
@dblclick.stop
|
|
>
|
|
<i
|
|
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
|
|
class="text-xs leading-none relative top-px text-stone-200 dark-theme:text-slate-300"
|
|
></i>
|
|
</button>
|
|
|
|
<!-- Node Title -->
|
|
<div
|
|
v-tooltip.top="tooltipConfig"
|
|
class="text-sm font-bold truncate flex-1 lod-toggle flex items-center gap-2"
|
|
data-testid="node-title"
|
|
>
|
|
<EditableText
|
|
:model-value="displayTitle"
|
|
:is-editing="isEditing"
|
|
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
|
@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>
|
|
|
|
<!-- Title Buttons -->
|
|
<div v-if="!readonly" class="flex items-center lod-toggle">
|
|
<IconButton
|
|
v-if="isSubgraphNode"
|
|
size="sm"
|
|
type="transparent"
|
|
class="text-stone-200 dark-theme:text-slate-300"
|
|
data-testid="subgraph-enter-button"
|
|
title="Enter Subgraph"
|
|
@click.stop="handleEnterSubgraph"
|
|
@dblclick.stop
|
|
>
|
|
<i class="pi pi-external-link"></i>
|
|
</IconButton>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { type Ref, computed, inject, onErrorCaptured, ref, watch } from 'vue'
|
|
|
|
import IconButton from '@/components/button/IconButton.vue'
|
|
import EditableText from '@/components/common/EditableText.vue'
|
|
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
|
import { useErrorHandling } from '@/composables/useErrorHandling'
|
|
import { st } from '@/i18n'
|
|
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
|
import { app } from '@/scripts/app'
|
|
import { normalizeI18nKey } from '@/utils/formatUtil'
|
|
import {
|
|
getLocatorIdFromNodeData,
|
|
getNodeByLocatorId
|
|
} from '@/utils/graphTraversalUtil'
|
|
|
|
import LODFallback from './LODFallback.vue'
|
|
|
|
interface NodeHeaderProps {
|
|
nodeData?: VueNodeData
|
|
readonly?: boolean
|
|
collapsed?: boolean
|
|
}
|
|
|
|
const { nodeData, readonly, collapsed } = defineProps<NodeHeaderProps>()
|
|
|
|
const emit = defineEmits<{
|
|
collapse: []
|
|
'update:title': [newTitle: string]
|
|
'enter-subgraph': []
|
|
}>()
|
|
|
|
// Error boundary implementation
|
|
const renderError = ref<string | null>(null)
|
|
const { toastErrorHandler } = useErrorHandling()
|
|
|
|
onErrorCaptured((error) => {
|
|
renderError.value = error.message
|
|
toastErrorHandler(error)
|
|
return false
|
|
})
|
|
|
|
// Editing state
|
|
const isEditing = ref(false)
|
|
|
|
const tooltipContainer =
|
|
inject<Ref<HTMLElement | undefined>>('tooltipContainer')
|
|
const { getNodeDescription, createTooltipConfig } = useNodeTooltips(
|
|
nodeData?.type || '',
|
|
tooltipContainer
|
|
)
|
|
|
|
const tooltipConfig = computed(() => {
|
|
if (readonly || isEditing.value) {
|
|
return { value: '', disabled: true }
|
|
}
|
|
const description = getNodeDescription.value
|
|
return createTooltipConfig(description)
|
|
})
|
|
|
|
const resolveTitle = (info: VueNodeData | undefined) => {
|
|
const title = (info?.title ?? '').trim()
|
|
if (title.length > 0) return title
|
|
|
|
const nodeType = (info?.type ?? '').trim() || 'Untitled'
|
|
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
|
|
return st(key, nodeType)
|
|
}
|
|
|
|
// Local state for title to provide immediate feedback
|
|
const displayTitle = ref(resolveTitle(nodeData))
|
|
|
|
// Watch for external changes to the node title or type
|
|
watch(
|
|
() => [nodeData?.title, nodeData?.type] as const,
|
|
() => {
|
|
const next = resolveTitle(nodeData)
|
|
if (next !== displayTitle.value) {
|
|
displayTitle.value = next
|
|
}
|
|
}
|
|
)
|
|
|
|
const isPinned = computed(() => Boolean(nodeData?.flags?.pinned))
|
|
|
|
// Subgraph detection
|
|
const isSubgraphNode = computed(() => {
|
|
if (!nodeData?.id) return false
|
|
|
|
// Get the underlying LiteGraph node
|
|
const graph = app.graph?.rootGraph || app.graph
|
|
if (!graph) return false
|
|
|
|
const locatorId = getLocatorIdFromNodeData(nodeData)
|
|
|
|
const litegraphNode = getNodeByLocatorId(graph, locatorId)
|
|
|
|
// Use the official type guard method
|
|
return litegraphNode?.isSubgraphNode() ?? false
|
|
})
|
|
|
|
// Event handlers
|
|
const handleCollapse = () => {
|
|
emit('collapse')
|
|
}
|
|
|
|
const handleDoubleClick = () => {
|
|
if (!readonly) {
|
|
isEditing.value = true
|
|
}
|
|
}
|
|
|
|
const handleTitleEdit = (newTitle: string) => {
|
|
isEditing.value = false
|
|
const trimmedTitle = newTitle.trim()
|
|
if (trimmedTitle && trimmedTitle !== displayTitle.value) {
|
|
// Emit for litegraph sync
|
|
emit('update:title', trimmedTitle)
|
|
}
|
|
}
|
|
|
|
const handleTitleCancel = () => {
|
|
isEditing.value = false
|
|
}
|
|
|
|
const handleEnterSubgraph = () => {
|
|
emit('enter-subgraph')
|
|
}
|
|
</script>
|