mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-06 13:40:25 +00:00
## Summary Replaced reactive (Vue-based) widget LOD with CSS visibility control. Performance doesn't dramatically improve, but we avoid the mount/unmount overhead during zoom/pan operations. This PR implements the visual component of LOD—complex widgets that need lifecycle management will be addressed separately. ### Problem & Solution Problem: we want LOD to improve rendering performance and visual feedback but discovered using reactivity in the current setup for it meant mounting/unmounting caused worse lag than the performance it aimed to fix. Switching to render all the details all the time but using css visibility proved to be the best solution. However, it doesn't improve rendering performance by much because the GPU texture size is the bottleneck (from TransformPane.vue CSS transforms) and not rasterization. Solution: Keep all nodes/widgets mounted, use CSS visibility: hidden for LOD. Trade memory for performance stability during zoom/pan/drag operations. ### Technical Decision We chose Performance > Memory: - CSS transforms create a single GPU texture whose size depends on node count, not widget complexity - Mounting/unmounting hundreds of widgets during zoom = noticeable lag from Vue VDOM diffing (since all components are mounted all the time because of viewport culling challenge/trade off see https://github.com/Comfy-Org/ComfyUI_frontend/pull/5510.) - CSS visibility changes = no reactivity overhead, smooth interactions - Result: Similar performance, but without interaction stutters This is the visual layer only. If we want a hook into the LOD state per node / widget that would be the next follow up system to implement. ### Next Steps (maybe) - Chunked (split up single Transform Pane transform layer) when rendering 1000+ nodes (maybe) - ~~Selective unmounting API for widgets that register as "expensive"~~ - ~~Client bound hydration system~~ ## Screenshots (if applicable) <!-- Add screenshots or video recording to help explain your changes --> <img width="1355" height="960" alt="image" src="https://github.com/user-attachments/assets/41474d1b-9dbe-4240-a8cf-f4c9ff51d8e0" /> <img width="1354" height="963" alt="image" src="https://github.com/user-attachments/assets/9f55edaa-5858-41b9-b6a8-c2d37e1649bd" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5631-feat-vue-nodes-LOD-system-2726d73d365081c6a6c4e14aa634f19c) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
184 lines
4.8 KiB
Vue
184 lines
4.8 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"
|
|
data-testid="node-title"
|
|
>
|
|
<EditableText
|
|
:model-value="displayTitle"
|
|
:is-editing="isEditing"
|
|
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
|
@edit="handleTitleEdit"
|
|
@cancel="handleTitleCancel"
|
|
/>
|
|
</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 { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
|
import { app } from '@/scripts/app'
|
|
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 type = (info?.type ?? '').trim()
|
|
return type.length > 0 ? type : 'Untitled'
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
)
|
|
|
|
// 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>
|