mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-24 00:34:09 +00:00
merge main into rh-test
This commit is contained in:
@@ -1,59 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function useCanvasInteractions() {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isStandardNavMode = computed(
|
||||
() => settingStore.get('Comfy.Canvas.NavigationMode') === 'standard'
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles wheel events from UI components that should be forwarded to canvas
|
||||
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
|
||||
*/
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
// In standard mode, Ctrl+wheel should go to canvas for zoom
|
||||
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault() // Prevent browser zoom
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// In legacy mode, all wheel events go to canvas for zoom
|
||||
if (!isStandardNavMode.value) {
|
||||
event.preventDefault()
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, let the component handle it normally
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards an event to the LiteGraph canvas
|
||||
*/
|
||||
const forwardEventToCanvas = (
|
||||
event: WheelEvent | PointerEvent | MouseEvent
|
||||
) => {
|
||||
const canvasEl = app.canvas?.canvas
|
||||
if (!canvasEl) return
|
||||
|
||||
// Create new event with same properties
|
||||
const EventConstructor = event.constructor as typeof WheelEvent
|
||||
const newEvent = new EventConstructor(event.type, event)
|
||||
canvasEl.dispatchEvent(newEvent)
|
||||
}
|
||||
|
||||
return {
|
||||
handleWheel,
|
||||
forwardEventToCanvas
|
||||
}
|
||||
}
|
||||
22
src/composables/graph/useCanvasRefresh.ts
Normal file
22
src/composables/graph/useCanvasRefresh.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// call nextTick on all changeTracker
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
/**
|
||||
* Composable for refreshing nodes in the graph
|
||||
* */
|
||||
export function useCanvasRefresh() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const refreshCanvas = () => {
|
||||
canvasStore.canvas?.emitBeforeChange()
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
canvasStore.canvas?.graph?.afterChange()
|
||||
canvasStore.canvas?.emitAfterChange()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
return {
|
||||
refreshCanvas
|
||||
}
|
||||
}
|
||||
30
src/composables/graph/useFrameNodes.ts
Normal file
30
src/composables/graph/useFrameNodes.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTitleEditorStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
/**
|
||||
* Composable encapsulating logic for framing currently selected nodes into a group.
|
||||
*/
|
||||
export function useFrameNodes() {
|
||||
const settingStore = useSettingStore()
|
||||
const titleEditorStore = useTitleEditorStore()
|
||||
const { hasMultipleSelection } = useSelectionState()
|
||||
|
||||
const canFrame = computed(() => hasMultipleSelection.value)
|
||||
|
||||
const frameNodes = () => {
|
||||
const { canvas } = app
|
||||
if (!canvas.selectedItems?.size) return
|
||||
const group = new LGraphGroup()
|
||||
const padding = settingStore.get('Comfy.GroupSelectedNodes.Padding')
|
||||
group.resizeTo(canvas.selectedItems, padding)
|
||||
canvas.graph?.add(group)
|
||||
titleEditorStore.titleEditorTarget = group
|
||||
}
|
||||
|
||||
return { frameNodes, canFrame }
|
||||
}
|
||||
534
src/composables/graph/useGraphNodeManager.ts
Normal file
534
src/composables/graph/useGraphNodeManager.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* Vue node lifecycle management for LiteGraph integration
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
|
||||
export interface SafeWidgetData {
|
||||
name: string
|
||||
type: string
|
||||
value: WidgetValue
|
||||
label?: string
|
||||
options?: Record<string, unknown>
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
spec?: InputSpec
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
mode: number
|
||||
selected: boolean
|
||||
executing: boolean
|
||||
subgraphId?: string | null
|
||||
widgets?: SafeWidgetData[]
|
||||
inputs?: INodeInputSlot[]
|
||||
outputs?: INodeOutputSlot[]
|
||||
hasErrors?: boolean
|
||||
flags?: {
|
||||
collapsed?: boolean
|
||||
pinned?: boolean
|
||||
}
|
||||
color?: string
|
||||
bgcolor?: string
|
||||
}
|
||||
|
||||
export interface GraphNodeManager {
|
||||
// Reactive state - safe data extracted from LiteGraph nodes
|
||||
vueNodeData: ReadonlyMap<string, VueNodeData>
|
||||
|
||||
// Access to original LiteGraph nodes (non-reactive)
|
||||
getNode(id: string): LGraphNode | undefined
|
||||
|
||||
// Lifecycle methods
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Get layout mutations composable
|
||||
const { createNode, deleteNode, setSource } = useLayoutMutations()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
// Safe reactive data extracted from LiteGraph nodes
|
||||
const vueNodeData = reactive(new Map<string, VueNodeData>())
|
||||
|
||||
// Non-reactive storage for original LiteGraph nodes
|
||||
const nodeRefs = new Map<string, LGraphNode>()
|
||||
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
const extractVueNodeData = (node: LGraphNode): VueNodeData => {
|
||||
// Determine subgraph ID - null for root graph, string for subgraphs
|
||||
const subgraphId =
|
||||
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
|
||||
? String(node.graph.id)
|
||||
: null
|
||||
// Extract safe widget data
|
||||
const safeWidgets = node.widgets?.map((widget) => {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
let value = widget.value
|
||||
|
||||
// For combo widgets, if value is undefined, use the first option as default
|
||||
if (
|
||||
value === undefined &&
|
||||
widget.type === 'combo' &&
|
||||
widget.options?.values &&
|
||||
Array.isArray(widget.options.values) &&
|
||||
widget.options.values.length > 0
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: value,
|
||||
label: widget.label,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
callback: widget.callback,
|
||||
spec
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const nodeType =
|
||||
node.type ||
|
||||
node.constructor?.comfyClass ||
|
||||
node.constructor?.title ||
|
||||
node.constructor?.name ||
|
||||
'Unknown'
|
||||
|
||||
return {
|
||||
id: String(node.id),
|
||||
title: typeof node.title === 'string' ? node.title : '',
|
||||
type: nodeType,
|
||||
mode: node.mode || 0,
|
||||
selected: node.selected || false,
|
||||
executing: false, // Will be updated separately based on execution state
|
||||
subgraphId,
|
||||
hasErrors: !!node.has_errors,
|
||||
widgets: safeWidgets,
|
||||
inputs: node.inputs ? [...node.inputs] : undefined,
|
||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
bgcolor: node.bgcolor || undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Get access to original LiteGraph node (non-reactive)
|
||||
const getNode = (id: string): LGraphNode | undefined => {
|
||||
return nodeRefs.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a value is a valid WidgetValue type
|
||||
*/
|
||||
const validateWidgetValue = (value: unknown): WidgetValue => {
|
||||
if (value === null || value === undefined || value === void 0) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// Check if it's a File array
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((item): item is File => item instanceof File)
|
||||
) {
|
||||
return value
|
||||
}
|
||||
// Otherwise it's a generic object
|
||||
return value
|
||||
}
|
||||
// If none of the above, return undefined
|
||||
console.warn(`Invalid widget value type: ${typeof value}`, value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates Vue state when widget values change
|
||||
*/
|
||||
const updateVueWidgetState = (
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
value: unknown
|
||||
): void => {
|
||||
try {
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
if (!currentData?.widgets) return
|
||||
|
||||
const updatedWidgets = currentData.widgets.map((w) =>
|
||||
w.name === widgetName ? { ...w, value: validateWidgetValue(value) } : w
|
||||
)
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
widgets: updatedWidgets
|
||||
})
|
||||
} catch (error) {
|
||||
// Ignore widget update errors to prevent cascade failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
|
||||
*/
|
||||
const createWrappedWidgetCallback = (
|
||||
widget: { value?: unknown; name: string }, // LiteGraph widget with minimal typing
|
||||
originalCallback: ((value: unknown) => void) | undefined,
|
||||
nodeId: string
|
||||
) => {
|
||||
let updateInProgress = false
|
||||
|
||||
return (value: unknown) => {
|
||||
if (updateInProgress) return
|
||||
updateInProgress = true
|
||||
|
||||
try {
|
||||
// 1. Update the widget value in LiteGraph (critical for LiteGraph state)
|
||||
// Validate that the value is of an acceptable type
|
||||
if (
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
typeof value !== 'string' &&
|
||||
typeof value !== 'number' &&
|
||||
typeof value !== 'boolean' &&
|
||||
typeof value !== 'object'
|
||||
) {
|
||||
console.warn(`Invalid widget value type: ${typeof value}`)
|
||||
updateInProgress = false
|
||||
return
|
||||
}
|
||||
|
||||
// Always update widget.value to ensure sync
|
||||
widget.value = value
|
||||
|
||||
// 2. Call the original callback if it exists
|
||||
if (originalCallback) {
|
||||
originalCallback.call(widget, value)
|
||||
}
|
||||
|
||||
// 3. Update Vue state to maintain synchronization
|
||||
updateVueWidgetState(nodeId, widget.name, value)
|
||||
} finally {
|
||||
updateInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up widget callbacks for a node - now with reduced nesting
|
||||
*/
|
||||
const setupNodeWidgetCallbacks = (node: LGraphNode) => {
|
||||
if (!node.widgets) return
|
||||
|
||||
const nodeId = String(node.id)
|
||||
|
||||
node.widgets.forEach((widget) => {
|
||||
const originalCallback = widget.callback
|
||||
widget.callback = createWrappedWidgetCallback(
|
||||
widget,
|
||||
originalCallback,
|
||||
nodeId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const syncWithGraph = () => {
|
||||
if (!graph?._nodes) return
|
||||
|
||||
const currentNodes = new Set(graph._nodes.map((n) => String(n.id)))
|
||||
|
||||
// Remove deleted nodes
|
||||
for (const id of Array.from(vueNodeData.keys())) {
|
||||
if (!currentNodes.has(id)) {
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Add/update existing nodes
|
||||
graph._nodes.forEach((node) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Store non-reactive reference
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Set up widget callbacks BEFORE extracting data (critical order)
|
||||
setupNodeWidgetCallbacks(node)
|
||||
|
||||
// Extract and store safe data for Vue
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles node addition to the graph - sets up Vue state and spatial indexing
|
||||
* Defers position extraction until after potential configure() calls
|
||||
*/
|
||||
const handleNodeAdded = (
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Store non-reactive reference to original node
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Set up widget callbacks BEFORE extracting data (critical order)
|
||||
setupNodeWidgetCallbacks(node)
|
||||
|
||||
// Extract initial data for Vue (may be incomplete during graph configure)
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
const initializeVueNodeLayout = () => {
|
||||
// Extract actual positions after configure() has potentially updated them
|
||||
const nodePosition = { x: node.pos[0], y: node.pos[1] }
|
||||
const nodeSize = { width: node.size[0], height: node.size[1] }
|
||||
|
||||
// Add node to layout store with final positions
|
||||
setSource(LayoutSource.Canvas)
|
||||
void createNode(id, {
|
||||
position: nodePosition,
|
||||
size: nodeSize,
|
||||
zIndex: node.order || 0,
|
||||
visible: true
|
||||
})
|
||||
}
|
||||
|
||||
// Check if we're in the middle of configuring the graph (workflow loading)
|
||||
if (window.app?.configuringGraph) {
|
||||
// During workflow loading - defer layout initialization until configure completes
|
||||
// Chain our callback with any existing onAfterGraphConfigured callback
|
||||
node.onAfterGraphConfigured = useChainCallback(
|
||||
node.onAfterGraphConfigured,
|
||||
() => {
|
||||
// Re-extract data now that configure() has populated title/slots/widgets/etc.
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
initializeVueNodeLayout()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Not during workflow loading - initialize layout immediately
|
||||
// This handles individual node additions during normal operation
|
||||
initializeVueNodeLayout()
|
||||
}
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
void originalCallback(node)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles node removal from the graph - cleans up all references
|
||||
*/
|
||||
const handleNodeRemoved = (
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Remove node from layout store
|
||||
setSource(LayoutSource.Canvas)
|
||||
void deleteNode(id)
|
||||
|
||||
// Clean up all tracking references
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
originalCallback(node)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates cleanup function for event listeners and state
|
||||
*/
|
||||
const createCleanupFunction = (
|
||||
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnTrigger: ((action: string, param: unknown) => void) | undefined
|
||||
) => {
|
||||
return () => {
|
||||
// Restore original callbacks
|
||||
graph.onNodeAdded = originalOnNodeAdded || undefined
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
graph.onTrigger = originalOnTrigger || undefined
|
||||
|
||||
// Clear all state maps
|
||||
nodeRefs.clear()
|
||||
vueNodeData.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners - now simplified with extracted handlers
|
||||
*/
|
||||
const setupEventListeners = (): (() => void) => {
|
||||
// Store original callbacks
|
||||
const originalOnNodeAdded = graph.onNodeAdded
|
||||
const originalOnNodeRemoved = graph.onNodeRemoved
|
||||
const originalOnTrigger = graph.onTrigger
|
||||
|
||||
// Set up graph event handlers
|
||||
graph.onNodeAdded = (node: LGraphNode) => {
|
||||
handleNodeAdded(node, originalOnNodeAdded)
|
||||
}
|
||||
|
||||
graph.onNodeRemoved = (node: LGraphNode) => {
|
||||
handleNodeRemoved(node, originalOnNodeRemoved)
|
||||
}
|
||||
|
||||
// Listen for property change events from instrumented nodes
|
||||
graph.onTrigger = (action: string, param: unknown) => {
|
||||
if (
|
||||
action === 'node:property:changed' &&
|
||||
param &&
|
||||
typeof param === 'object'
|
||||
) {
|
||||
const event = param as {
|
||||
nodeId: string | number
|
||||
property: string
|
||||
oldValue: unknown
|
||||
newValue: unknown
|
||||
}
|
||||
|
||||
const nodeId = String(event.nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
if (currentData) {
|
||||
switch (event.property) {
|
||||
case 'title':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
title: String(event.newValue)
|
||||
})
|
||||
break
|
||||
case 'flags.collapsed':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
collapsed: Boolean(event.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'flags.pinned':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
pinned: Boolean(event.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'mode':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
mode: typeof event.newValue === 'number' ? event.newValue : 0
|
||||
})
|
||||
break
|
||||
case 'color':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
color:
|
||||
typeof event.newValue === 'string'
|
||||
? event.newValue
|
||||
: undefined
|
||||
})
|
||||
break
|
||||
case 'bgcolor':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
bgcolor:
|
||||
typeof event.newValue === 'string'
|
||||
? event.newValue
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
action === 'node:slot-errors:changed' &&
|
||||
param &&
|
||||
typeof param === 'object'
|
||||
) {
|
||||
const event = param as { nodeId: string | number }
|
||||
const nodeId = String(event.nodeId)
|
||||
const litegraphNode = nodeRefs.get(nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
if (litegraphNode && currentData) {
|
||||
// Re-extract slot data with updated hasErrors properties
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
inputs: litegraphNode.inputs
|
||||
? [...litegraphNode.inputs]
|
||||
: undefined,
|
||||
outputs: litegraphNode.outputs
|
||||
? [...litegraphNode.outputs]
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Call original trigger handler if it exists
|
||||
if (originalOnTrigger) {
|
||||
originalOnTrigger(action, param)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize state
|
||||
syncWithGraph()
|
||||
|
||||
// Return cleanup function
|
||||
return createCleanupFunction(
|
||||
originalOnNodeAdded || undefined,
|
||||
originalOnNodeRemoved || undefined,
|
||||
originalOnTrigger || undefined
|
||||
)
|
||||
}
|
||||
|
||||
// Set up event listeners immediately
|
||||
const cleanup = setupEventListeners()
|
||||
|
||||
// Process any existing nodes after event listeners are set up
|
||||
if (graph._nodes && graph._nodes.length > 0) {
|
||||
graph._nodes.forEach((node: LGraphNode) => {
|
||||
if (graph.onNodeAdded) {
|
||||
graph.onNodeAdded(node)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
vueNodeData,
|
||||
getNode,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
199
src/composables/graph/useGroupMenuOptions.ts
Normal file
199
src/composables/graph/useGroupMenuOptions.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
LGraphEventMode,
|
||||
type LGraphGroup,
|
||||
type LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import { useCanvasRefresh } from './useCanvasRefresh'
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
import { useNodeCustomization } from './useNodeCustomization'
|
||||
|
||||
/**
|
||||
* Composable for group-related menu operations
|
||||
*/
|
||||
export function useGroupMenuOptions() {
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
const canvasRefresh = useCanvasRefresh()
|
||||
const { shapeOptions, colorOptions, isLightTheme } = useNodeCustomization()
|
||||
|
||||
const getFitGroupToNodesOption = (groupContext: LGraphGroup): MenuOption => ({
|
||||
label: 'Fit Group To Nodes',
|
||||
icon: 'icon-[lucide--move-diagonal-2]',
|
||||
action: () => {
|
||||
try {
|
||||
groupContext.recomputeInsideNodes()
|
||||
} catch (e) {
|
||||
console.warn('Failed to recompute group nodes:', e)
|
||||
return
|
||||
}
|
||||
|
||||
const padding = settingStore.get('Comfy.GroupSelectedNodes.Padding')
|
||||
groupContext.resizeTo(groupContext.children, padding)
|
||||
groupContext.graph?.change()
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
})
|
||||
|
||||
const getGroupShapeOptions = (
|
||||
groupContext: LGraphGroup,
|
||||
bump: () => void
|
||||
): MenuOption => ({
|
||||
label: t('contextMenu.Shape'),
|
||||
icon: 'icon-[lucide--box]',
|
||||
hasSubmenu: true,
|
||||
submenu: shapeOptions.map((shape) => ({
|
||||
label: shape.localizedName,
|
||||
action: () => {
|
||||
const nodes = (groupContext.nodes || []) as LGraphNode[]
|
||||
nodes.forEach((node) => (node.shape = shape.value))
|
||||
canvasRefresh.refreshCanvas()
|
||||
bump()
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
const getGroupColorOptions = (
|
||||
groupContext: LGraphGroup,
|
||||
bump: () => void
|
||||
): MenuOption => ({
|
||||
label: t('contextMenu.Color'),
|
||||
icon: 'icon-[lucide--palette]',
|
||||
hasSubmenu: true,
|
||||
submenu: colorOptions.map((colorOption) => ({
|
||||
label: colorOption.localizedName,
|
||||
color: isLightTheme.value
|
||||
? colorOption.value.light
|
||||
: colorOption.value.dark,
|
||||
action: () => {
|
||||
groupContext.color = isLightTheme.value
|
||||
? colorOption.value.light
|
||||
: colorOption.value.dark
|
||||
canvasRefresh.refreshCanvas()
|
||||
bump()
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
const getGroupModeOptions = (
|
||||
groupContext: LGraphGroup,
|
||||
bump: () => void
|
||||
): MenuOption[] => {
|
||||
const options: MenuOption[] = []
|
||||
|
||||
try {
|
||||
groupContext.recomputeInsideNodes()
|
||||
} catch (e) {
|
||||
console.warn('Failed to recompute group nodes for mode options:', e)
|
||||
return options
|
||||
}
|
||||
|
||||
const groupNodes = (groupContext.nodes || []) as LGraphNode[]
|
||||
if (!groupNodes.length) return options
|
||||
|
||||
// Check if all nodes have the same mode
|
||||
let allSame = true
|
||||
for (let i = 1; i < groupNodes.length; i++) {
|
||||
if (groupNodes[i].mode !== groupNodes[0].mode) {
|
||||
allSame = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const createModeAction = (label: string, mode: LGraphEventMode) => ({
|
||||
label: t(`selectionToolbox.${label}`),
|
||||
icon:
|
||||
mode === LGraphEventMode.BYPASS
|
||||
? 'icon-[lucide--ban]'
|
||||
: mode === LGraphEventMode.NEVER
|
||||
? 'icon-[lucide--zap-off]'
|
||||
: 'icon-[lucide--play]',
|
||||
action: () => {
|
||||
groupNodes.forEach((n) => {
|
||||
n.mode = mode
|
||||
})
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
groupContext.graph?.change()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
bump()
|
||||
}
|
||||
})
|
||||
|
||||
if (allSame) {
|
||||
const current = groupNodes[0].mode
|
||||
switch (current) {
|
||||
case LGraphEventMode.ALWAYS:
|
||||
options.push(
|
||||
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
|
||||
)
|
||||
options.push(
|
||||
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
|
||||
)
|
||||
break
|
||||
case LGraphEventMode.NEVER:
|
||||
options.push(
|
||||
createModeAction(
|
||||
'Set Group Nodes to Always',
|
||||
LGraphEventMode.ALWAYS
|
||||
)
|
||||
)
|
||||
options.push(
|
||||
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
|
||||
)
|
||||
break
|
||||
case LGraphEventMode.BYPASS:
|
||||
options.push(
|
||||
createModeAction(
|
||||
'Set Group Nodes to Always',
|
||||
LGraphEventMode.ALWAYS
|
||||
)
|
||||
)
|
||||
options.push(
|
||||
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
|
||||
)
|
||||
break
|
||||
default:
|
||||
options.push(
|
||||
createModeAction(
|
||||
'Set Group Nodes to Always',
|
||||
LGraphEventMode.ALWAYS
|
||||
)
|
||||
)
|
||||
options.push(
|
||||
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
|
||||
)
|
||||
options.push(
|
||||
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
|
||||
)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
options.push(
|
||||
createModeAction('Set Group Nodes to Always', LGraphEventMode.ALWAYS)
|
||||
)
|
||||
options.push(
|
||||
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
|
||||
)
|
||||
options.push(
|
||||
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
return {
|
||||
getFitGroupToNodesOption,
|
||||
getGroupShapeOptions,
|
||||
getGroupColorOptions,
|
||||
getGroupModeOptions
|
||||
}
|
||||
}
|
||||
108
src/composables/graph/useImageMenuOptions.ts
Normal file
108
src/composables/graph/useImageMenuOptions.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
|
||||
/**
|
||||
* Composable for image-related menu operations
|
||||
*/
|
||||
export function useImageMenuOptions() {
|
||||
const { t } = useI18n()
|
||||
|
||||
const openMaskEditor = () => {
|
||||
const commandStore = useCommandStore()
|
||||
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
|
||||
}
|
||||
|
||||
const openImage = (node: any) => {
|
||||
if (!node?.imgs?.length) return
|
||||
const img = node.imgs[node.imageIndex ?? 0]
|
||||
if (!img) return
|
||||
const url = new URL(img.src)
|
||||
url.searchParams.delete('preview')
|
||||
window.open(url.toString(), '_blank')
|
||||
}
|
||||
|
||||
const copyImage = async (node: any) => {
|
||||
if (!node?.imgs?.length) return
|
||||
const img = node.imgs[node.imageIndex ?? 0]
|
||||
if (!img) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
ctx.drawImage(img, 0, 0)
|
||||
|
||||
try {
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, 'image/png')
|
||||
})
|
||||
|
||||
if (!blob) {
|
||||
console.warn('Failed to create image blob')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if clipboard API is available
|
||||
if (!navigator.clipboard?.write) {
|
||||
console.warn('Clipboard API not available')
|
||||
return
|
||||
}
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': blob })
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('Failed to copy image to clipboard:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const saveImage = (node: any) => {
|
||||
if (!node?.imgs?.length) return
|
||||
const img = node.imgs[node.imageIndex ?? 0]
|
||||
if (!img) return
|
||||
|
||||
try {
|
||||
const url = new URL(img.src)
|
||||
url.searchParams.delete('preview')
|
||||
downloadFile(url.toString())
|
||||
} catch (error) {
|
||||
console.error('Failed to save image:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getImageMenuOptions = (node: any): MenuOption[] => {
|
||||
if (!node?.imgs?.length) return []
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('contextMenu.Open in Mask Editor'),
|
||||
action: () => openMaskEditor()
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Open Image'),
|
||||
icon: 'icon-[lucide--external-link]',
|
||||
action: () => openImage(node)
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Copy Image'),
|
||||
icon: 'icon-[lucide--copy]',
|
||||
action: () => copyImage(node)
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Save Image'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
action: () => saveImage(node)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
getImageMenuOptions
|
||||
}
|
||||
}
|
||||
228
src/composables/graph/useMoreOptionsMenu.ts
Normal file
228
src/composables/graph/useMoreOptionsMenu.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { type Ref, computed, ref } from 'vue'
|
||||
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
import { isLGraphGroup } from '@/utils/litegraphUtil'
|
||||
|
||||
import { useGroupMenuOptions } from './useGroupMenuOptions'
|
||||
import { useImageMenuOptions } from './useImageMenuOptions'
|
||||
import { useNodeMenuOptions } from './useNodeMenuOptions'
|
||||
import { useSelectionMenuOptions } from './useSelectionMenuOptions'
|
||||
import { useSelectionState } from './useSelectionState'
|
||||
|
||||
export interface MenuOption {
|
||||
label?: string
|
||||
icon?: string
|
||||
shortcut?: string
|
||||
hasSubmenu?: boolean
|
||||
type?: 'divider'
|
||||
action?: () => void
|
||||
submenu?: SubMenuOption[]
|
||||
badge?: BadgeVariant
|
||||
}
|
||||
|
||||
export interface SubMenuOption {
|
||||
label: string
|
||||
icon?: string
|
||||
action: () => void
|
||||
color?: string
|
||||
}
|
||||
|
||||
export enum BadgeVariant {
|
||||
NEW = 'new',
|
||||
DEPRECATED = 'deprecated'
|
||||
}
|
||||
|
||||
// Global singleton for NodeOptions component reference
|
||||
let nodeOptionsInstance: null | NodeOptionsInstance = null
|
||||
|
||||
/**
|
||||
* Toggle the node options popover
|
||||
* @param event - The trigger event
|
||||
* @param element - The target element (button) that triggered the popover
|
||||
*/
|
||||
export function toggleNodeOptions(
|
||||
event: Event,
|
||||
element: HTMLElement,
|
||||
clickedFromToolbox: boolean = false
|
||||
) {
|
||||
if (nodeOptionsInstance?.toggle) {
|
||||
nodeOptionsInstance.toggle(event, element, clickedFromToolbox)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the node options popover
|
||||
*/
|
||||
interface NodeOptionsInstance {
|
||||
toggle: (
|
||||
event: Event,
|
||||
element: HTMLElement,
|
||||
clickedFromToolbox: boolean
|
||||
) => void
|
||||
hide: () => void
|
||||
isOpen: Ref<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the NodeOptions component instance
|
||||
* @param instance - The NodeOptions component instance
|
||||
*/
|
||||
export function registerNodeOptionsInstance(
|
||||
instance: null | NodeOptionsInstance
|
||||
) {
|
||||
nodeOptionsInstance = instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing the More Options menu configuration
|
||||
* Refactored to use smaller, focused composables for better maintainability
|
||||
*/
|
||||
export function useMoreOptionsMenu() {
|
||||
const {
|
||||
selectedItems,
|
||||
selectedNodes,
|
||||
nodeDef,
|
||||
showNodeHelp,
|
||||
hasSubgraphs: hasSubgraphsComputed,
|
||||
hasImageNode,
|
||||
hasOutputNodesSelected,
|
||||
hasMultipleSelection,
|
||||
computeSelectionFlags
|
||||
} = useSelectionState()
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const {
|
||||
getNodeInfoOption,
|
||||
getAdjustSizeOption,
|
||||
getNodeVisualOptions,
|
||||
getPinOption,
|
||||
getBypassOption,
|
||||
getRunBranchOption
|
||||
} = useNodeMenuOptions()
|
||||
const {
|
||||
getFitGroupToNodesOption,
|
||||
getGroupShapeOptions,
|
||||
getGroupColorOptions,
|
||||
getGroupModeOptions
|
||||
} = useGroupMenuOptions()
|
||||
const {
|
||||
getBasicSelectionOptions,
|
||||
getSubgraphOptions,
|
||||
getMultipleNodesOptions,
|
||||
getDeleteOption,
|
||||
getAlignmentOptions
|
||||
} = useSelectionMenuOptions()
|
||||
|
||||
const hasSubgraphs = hasSubgraphsComputed
|
||||
const hasMultipleNodes = hasMultipleSelection
|
||||
|
||||
// Internal version to force menu rebuild after state mutations
|
||||
const optionsVersion = ref(0)
|
||||
const bump = () => {
|
||||
optionsVersion.value++
|
||||
}
|
||||
|
||||
const menuOptions = computed((): MenuOption[] => {
|
||||
// Reference selection flags to ensure re-computation when they change
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
optionsVersion.value
|
||||
const states = computeSelectionFlags()
|
||||
|
||||
// Detect single group selection context (and no nodes explicitly selected)
|
||||
const selectedGroups = selectedItems.value.filter(
|
||||
isLGraphGroup
|
||||
) as LGraphGroup[]
|
||||
const groupContext: LGraphGroup | null =
|
||||
selectedGroups.length === 1 && selectedNodes.value.length === 0
|
||||
? selectedGroups[0]
|
||||
: null
|
||||
const hasSubgraphsSelected = hasSubgraphs.value
|
||||
const options: MenuOption[] = []
|
||||
|
||||
// Section 1: Basic selection operations (Rename, Copy, Duplicate)
|
||||
options.push(...getBasicSelectionOptions())
|
||||
options.push({ type: 'divider' })
|
||||
|
||||
// Section 2: Node Info & Size Adjustment
|
||||
if (nodeDef.value) {
|
||||
options.push(getNodeInfoOption(showNodeHelp))
|
||||
}
|
||||
|
||||
if (groupContext) {
|
||||
options.push(getFitGroupToNodesOption(groupContext))
|
||||
} else {
|
||||
options.push(getAdjustSizeOption())
|
||||
}
|
||||
|
||||
// Section 3: Collapse/Shape/Color
|
||||
if (groupContext) {
|
||||
// Group context: Shape, Color, Divider
|
||||
options.push(getGroupShapeOptions(groupContext, bump))
|
||||
options.push(getGroupColorOptions(groupContext, bump))
|
||||
options.push({ type: 'divider' })
|
||||
} else {
|
||||
// Node context: Expand/Minimize, Shape, Color, Divider
|
||||
options.push(...getNodeVisualOptions(states, bump))
|
||||
options.push({ type: 'divider' })
|
||||
}
|
||||
|
||||
// Section 4: Image operations (if image node)
|
||||
if (hasImageNode.value && selectedNodes.value.length > 0) {
|
||||
options.push(...getImageMenuOptions(selectedNodes.value[0]))
|
||||
}
|
||||
|
||||
// Section 5: Subgraph operations
|
||||
options.push(...getSubgraphOptions(hasSubgraphsSelected))
|
||||
|
||||
// Section 6: Multiple nodes operations
|
||||
if (hasMultipleNodes.value) {
|
||||
options.push(...getMultipleNodesOptions())
|
||||
}
|
||||
|
||||
// Section 7: Divider
|
||||
options.push({ type: 'divider' })
|
||||
|
||||
// Section 8: Pin/Unpin (non-group only)
|
||||
if (!groupContext) {
|
||||
options.push(getPinOption(states, bump))
|
||||
}
|
||||
|
||||
// Section 9: Alignment (if multiple nodes)
|
||||
if (hasMultipleNodes.value) {
|
||||
options.push(...getAlignmentOptions())
|
||||
}
|
||||
|
||||
// Section 10: Mode operations
|
||||
if (groupContext) {
|
||||
// Group mode operations
|
||||
options.push(...getGroupModeOptions(groupContext, bump))
|
||||
} else {
|
||||
// Bypass option for nodes
|
||||
options.push(getBypassOption(states, bump))
|
||||
}
|
||||
|
||||
// Section 11: Run Branch (if output nodes)
|
||||
if (hasOutputNodesSelected.value) {
|
||||
options.push(getRunBranchOption())
|
||||
}
|
||||
|
||||
// Section 12: Final divider and Delete
|
||||
options.push({ type: 'divider' })
|
||||
options.push(getDeleteOption())
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
// Computed property to get only menu items with submenus
|
||||
const menuOptionsWithSubmenu = computed(() =>
|
||||
menuOptions.value.filter((option) => option.hasSubmenu && option.submenu)
|
||||
)
|
||||
|
||||
return {
|
||||
menuOptions,
|
||||
menuOptionsWithSubmenu,
|
||||
bump,
|
||||
hasSubgraphs,
|
||||
registerNodeOptionsInstance
|
||||
}
|
||||
}
|
||||
106
src/composables/graph/useNodeArrangement.ts
Normal file
106
src/composables/graph/useNodeArrangement.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { Direction } from '@/lib/litegraph/src/interfaces'
|
||||
import { alignNodes, distributeNodes } from '@/lib/litegraph/src/utils/arrange'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
import { useCanvasRefresh } from './useCanvasRefresh'
|
||||
|
||||
interface AlignOption {
|
||||
name: string
|
||||
localizedName: string
|
||||
value: Direction
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface DistributeOption {
|
||||
name: string
|
||||
localizedName: string
|
||||
value: boolean // true for horizontal, false for vertical
|
||||
icon: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for handling node alignment and distribution
|
||||
*/
|
||||
export function useNodeArrangement() {
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const canvasRefresh = useCanvasRefresh()
|
||||
const alignOptions: AlignOption[] = [
|
||||
{
|
||||
name: 'top',
|
||||
localizedName: t('contextMenu.Top'),
|
||||
value: 'top',
|
||||
icon: 'icon-[lucide--align-start-vertical]'
|
||||
},
|
||||
{
|
||||
name: 'bottom',
|
||||
localizedName: t('contextMenu.Bottom'),
|
||||
value: 'bottom',
|
||||
icon: 'icon-[lucide--align-end-vertical]'
|
||||
},
|
||||
{
|
||||
name: 'left',
|
||||
localizedName: t('contextMenu.Left'),
|
||||
value: 'left',
|
||||
icon: 'icon-[lucide--align-start-horizontal]'
|
||||
},
|
||||
{
|
||||
name: 'right',
|
||||
localizedName: t('contextMenu.Right'),
|
||||
value: 'right',
|
||||
icon: 'icon-[lucide--align-end-horizontal]'
|
||||
}
|
||||
]
|
||||
|
||||
const distributeOptions: DistributeOption[] = [
|
||||
{
|
||||
name: 'horizontal',
|
||||
localizedName: t('contextMenu.Horizontal'),
|
||||
value: true,
|
||||
icon: 'icon-[lucide--align-center-horizontal]'
|
||||
},
|
||||
{
|
||||
name: 'vertical',
|
||||
localizedName: t('contextMenu.Vertical'),
|
||||
value: false,
|
||||
icon: 'icon-[lucide--align-center-vertical]'
|
||||
}
|
||||
]
|
||||
|
||||
const applyAlign = (alignOption: AlignOption) => {
|
||||
const selectedNodes = Array.from(canvasStore.selectedItems).filter((item) =>
|
||||
isLGraphNode(item)
|
||||
)
|
||||
|
||||
if (selectedNodes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
alignNodes(selectedNodes, alignOption.value)
|
||||
|
||||
canvasRefresh.refreshCanvas()
|
||||
}
|
||||
|
||||
const applyDistribute = (distributeOption: DistributeOption) => {
|
||||
const selectedNodes = Array.from(canvasStore.selectedItems).filter((item) =>
|
||||
isLGraphNode(item)
|
||||
)
|
||||
|
||||
if (selectedNodes.length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
distributeNodes(selectedNodes, distributeOption.value)
|
||||
canvasRefresh.refreshCanvas()
|
||||
}
|
||||
|
||||
return {
|
||||
alignOptions,
|
||||
distributeOptions,
|
||||
applyAlign,
|
||||
applyDistribute
|
||||
}
|
||||
}
|
||||
167
src/composables/graph/useNodeCustomization.ts
Normal file
167
src/composables/graph/useNodeCustomization.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
RenderShape,
|
||||
isColorable
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
import { useCanvasRefresh } from './useCanvasRefresh'
|
||||
|
||||
interface ColorOption {
|
||||
name: string
|
||||
localizedName: string
|
||||
value: {
|
||||
dark: string
|
||||
light: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ShapeOption {
|
||||
name: string
|
||||
localizedName: string
|
||||
value: RenderShape
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for handling node color and shape customization
|
||||
*/
|
||||
export function useNodeCustomization() {
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const canvasRefresh = useCanvasRefresh()
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
|
||||
const toLightThemeColor = (color: string) =>
|
||||
adjustColor(color, { lightness: 0.5 })
|
||||
|
||||
// Color options
|
||||
const NO_COLOR_OPTION: ColorOption = {
|
||||
name: 'noColor',
|
||||
localizedName: t('color.noColor'),
|
||||
value: {
|
||||
dark: LiteGraph.NODE_DEFAULT_BGCOLOR,
|
||||
light: toLightThemeColor(LiteGraph.NODE_DEFAULT_BGCOLOR)
|
||||
}
|
||||
}
|
||||
|
||||
const colorOptions: ColorOption[] = [
|
||||
NO_COLOR_OPTION,
|
||||
...Object.entries(LGraphCanvas.node_colors).map(([name, color]) => ({
|
||||
name,
|
||||
localizedName: t(`color.${name}`),
|
||||
value: {
|
||||
dark: color.bgcolor,
|
||||
light: toLightThemeColor(color.bgcolor)
|
||||
}
|
||||
}))
|
||||
]
|
||||
|
||||
// Shape options
|
||||
const shapeOptions: ShapeOption[] = [
|
||||
{
|
||||
name: 'default',
|
||||
localizedName: t('shape.default'),
|
||||
value: RenderShape.ROUND
|
||||
},
|
||||
{
|
||||
name: 'box',
|
||||
localizedName: t('shape.box'),
|
||||
value: RenderShape.BOX
|
||||
},
|
||||
{
|
||||
name: 'card',
|
||||
localizedName: t('shape.CARD'),
|
||||
value: RenderShape.CARD
|
||||
}
|
||||
]
|
||||
|
||||
const applyColor = (colorOption: ColorOption | null) => {
|
||||
const colorName = colorOption?.name ?? NO_COLOR_OPTION.name
|
||||
const canvasColorOption =
|
||||
colorName === NO_COLOR_OPTION.name
|
||||
? null
|
||||
: LGraphCanvas.node_colors[colorName]
|
||||
|
||||
for (const item of canvasStore.selectedItems) {
|
||||
if (isColorable(item)) {
|
||||
item.setColorOption(canvasColorOption)
|
||||
}
|
||||
}
|
||||
|
||||
canvasRefresh.refreshCanvas()
|
||||
}
|
||||
|
||||
const applyShape = (shapeOption: ShapeOption) => {
|
||||
const selectedNodes = Array.from(canvasStore.selectedItems).filter(
|
||||
(item): item is LGraphNode => item instanceof LGraphNode
|
||||
)
|
||||
|
||||
if (selectedNodes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedNodes.forEach((node) => {
|
||||
node.shape = shapeOption.value
|
||||
})
|
||||
|
||||
canvasRefresh.refreshCanvas()
|
||||
}
|
||||
|
||||
const getCurrentColor = (): ColorOption | null => {
|
||||
const selectedItems = Array.from(canvasStore.selectedItems)
|
||||
if (selectedItems.length === 0) return null
|
||||
|
||||
// Get color from first colorable item
|
||||
const firstColorableItem = selectedItems.find((item) => isColorable(item))
|
||||
if (!firstColorableItem || !isColorable(firstColorableItem)) return null
|
||||
|
||||
// Get the current color option from the colorable item
|
||||
const currentColorOption = firstColorableItem.getColorOption()
|
||||
const currentBgColor = currentColorOption?.bgcolor ?? null
|
||||
|
||||
// Find matching color option
|
||||
return (
|
||||
colorOptions.find(
|
||||
(option) =>
|
||||
option.value.dark === currentBgColor ||
|
||||
option.value.light === currentBgColor
|
||||
) ?? NO_COLOR_OPTION
|
||||
)
|
||||
}
|
||||
|
||||
const getCurrentShape = (): ShapeOption | null => {
|
||||
const selectedNodes = Array.from(canvasStore.selectedItems).filter(
|
||||
(item): item is LGraphNode => item instanceof LGraphNode
|
||||
)
|
||||
|
||||
if (selectedNodes.length === 0) return null
|
||||
|
||||
const firstNode = selectedNodes[0]
|
||||
const currentShape = firstNode.shape ?? RenderShape.ROUND
|
||||
|
||||
return (
|
||||
shapeOptions.find((option) => option.value === currentShape) ??
|
||||
shapeOptions[0]
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
colorOptions,
|
||||
shapeOptions,
|
||||
applyColor,
|
||||
applyShape,
|
||||
getCurrentColor,
|
||||
getCurrentShape,
|
||||
isLightTheme
|
||||
}
|
||||
}
|
||||
128
src/composables/graph/useNodeMenuOptions.ts
Normal file
128
src/composables/graph/useNodeMenuOptions.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
import { useNodeCustomization } from './useNodeCustomization'
|
||||
import { useSelectedNodeActions } from './useSelectedNodeActions'
|
||||
import type { NodeSelectionState } from './useSelectionState'
|
||||
|
||||
/**
|
||||
* Composable for node-related menu operations
|
||||
*/
|
||||
export function useNodeMenuOptions() {
|
||||
const { t } = useI18n()
|
||||
const { shapeOptions, applyShape, applyColor, colorOptions, isLightTheme } =
|
||||
useNodeCustomization()
|
||||
const {
|
||||
adjustNodeSize,
|
||||
toggleNodeCollapse,
|
||||
toggleNodePin,
|
||||
toggleNodeBypass,
|
||||
runBranch
|
||||
} = useSelectedNodeActions()
|
||||
|
||||
const shapeSubmenu = computed(() =>
|
||||
shapeOptions.map((shape) => ({
|
||||
label: shape.localizedName,
|
||||
action: () => applyShape(shape)
|
||||
}))
|
||||
)
|
||||
|
||||
const colorSubmenu = computed(() => {
|
||||
return colorOptions.map((colorOption) => ({
|
||||
label: colorOption.localizedName,
|
||||
color: isLightTheme.value
|
||||
? colorOption.value.light
|
||||
: colorOption.value.dark,
|
||||
action: () =>
|
||||
applyColor(colorOption.name === 'noColor' ? null : colorOption)
|
||||
}))
|
||||
})
|
||||
|
||||
const getAdjustSizeOption = (): MenuOption => ({
|
||||
label: t('contextMenu.Adjust Size'),
|
||||
icon: 'icon-[lucide--move-diagonal-2]',
|
||||
action: adjustNodeSize
|
||||
})
|
||||
|
||||
const getNodeVisualOptions = (
|
||||
states: NodeSelectionState,
|
||||
bump: () => void
|
||||
): MenuOption[] => [
|
||||
{
|
||||
label: states.collapsed
|
||||
? t('contextMenu.Expand Node')
|
||||
: t('contextMenu.Minimize Node'),
|
||||
icon: states.collapsed
|
||||
? 'icon-[lucide--maximize-2]'
|
||||
: 'icon-[lucide--minimize-2]',
|
||||
action: () => {
|
||||
toggleNodeCollapse()
|
||||
bump()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Shape'),
|
||||
icon: 'icon-[lucide--box]',
|
||||
hasSubmenu: true,
|
||||
submenu: shapeSubmenu.value,
|
||||
action: () => {}
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Color'),
|
||||
icon: 'icon-[lucide--palette]',
|
||||
hasSubmenu: true,
|
||||
submenu: colorSubmenu.value,
|
||||
action: () => {}
|
||||
}
|
||||
]
|
||||
|
||||
const getPinOption = (
|
||||
states: NodeSelectionState,
|
||||
bump: () => void
|
||||
): MenuOption => ({
|
||||
label: states.pinned ? t('contextMenu.Unpin') : t('contextMenu.Pin'),
|
||||
icon: states.pinned ? 'icon-[lucide--pin-off]' : 'icon-[lucide--pin]',
|
||||
action: () => {
|
||||
toggleNodePin()
|
||||
bump()
|
||||
}
|
||||
})
|
||||
|
||||
const getBypassOption = (
|
||||
states: NodeSelectionState,
|
||||
bump: () => void
|
||||
): MenuOption => ({
|
||||
label: states.bypassed
|
||||
? t('contextMenu.Remove Bypass')
|
||||
: t('contextMenu.Bypass'),
|
||||
icon: states.bypassed ? 'icon-[lucide--zap-off]' : 'icon-[lucide--ban]',
|
||||
shortcut: 'Ctrl+B',
|
||||
action: () => {
|
||||
toggleNodeBypass()
|
||||
bump()
|
||||
}
|
||||
})
|
||||
|
||||
const getRunBranchOption = (): MenuOption => ({
|
||||
label: t('contextMenu.Run Branch'),
|
||||
icon: 'icon-[lucide--play]',
|
||||
action: runBranch
|
||||
})
|
||||
|
||||
const getNodeInfoOption = (showNodeHelp: () => void): MenuOption => ({
|
||||
label: t('contextMenu.Node Info'),
|
||||
icon: 'icon-[lucide--info]',
|
||||
action: showNodeHelp
|
||||
})
|
||||
|
||||
return {
|
||||
getNodeInfoOption,
|
||||
getAdjustSizeOption,
|
||||
getNodeVisualOptions,
|
||||
getPinOption,
|
||||
getBypassOption,
|
||||
getRunBranchOption,
|
||||
colorSubmenu
|
||||
}
|
||||
}
|
||||
68
src/composables/graph/useSelectedNodeActions.ts
Normal file
68
src/composables/graph/useSelectedNodeActions.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
|
||||
/**
|
||||
* Composable for handling node information and utility operations
|
||||
*/
|
||||
export function useSelectedNodeActions() {
|
||||
const { getSelectedNodes, toggleSelectedNodesMode } =
|
||||
useSelectedLiteGraphItems()
|
||||
const commandStore = useCommandStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const adjustNodeSize = () => {
|
||||
const selectedNodes = getSelectedNodes()
|
||||
|
||||
selectedNodes.forEach((node) => {
|
||||
const optimalSize = node.computeSize()
|
||||
node.setSize([optimalSize[0], optimalSize[1]])
|
||||
})
|
||||
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const toggleNodeCollapse = () => {
|
||||
const selectedNodes = getSelectedNodes()
|
||||
selectedNodes.forEach((node) => {
|
||||
node.collapse()
|
||||
})
|
||||
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const toggleNodePin = () => {
|
||||
const selectedNodes = getSelectedNodes()
|
||||
selectedNodes.forEach((node) => {
|
||||
node.pin(!node.pinned)
|
||||
})
|
||||
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const toggleNodeBypass = () => {
|
||||
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
|
||||
app.canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
const runBranch = async () => {
|
||||
const selectedNodes = getSelectedNodes()
|
||||
const selectedOutputNodes = filterOutputNodes(selectedNodes)
|
||||
if (selectedOutputNodes.length === 0) return
|
||||
await commandStore.execute('Comfy.QueueSelectedOutputNodes')
|
||||
}
|
||||
|
||||
return {
|
||||
adjustNodeSize,
|
||||
toggleNodeCollapse,
|
||||
toggleNodePin,
|
||||
toggleNodeBypass,
|
||||
runBranch
|
||||
}
|
||||
}
|
||||
147
src/composables/graph/useSelectionMenuOptions.ts
Normal file
147
src/composables/graph/useSelectionMenuOptions.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import { useFrameNodes } from './useFrameNodes'
|
||||
import { BadgeVariant, type MenuOption } from './useMoreOptionsMenu'
|
||||
import { useNodeArrangement } from './useNodeArrangement'
|
||||
import { useSelectionOperations } from './useSelectionOperations'
|
||||
import { useSubgraphOperations } from './useSubgraphOperations'
|
||||
|
||||
/**
|
||||
* Composable for selection-related menu operations
|
||||
*/
|
||||
export function useSelectionMenuOptions() {
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
copySelection,
|
||||
duplicateSelection,
|
||||
deleteSelection,
|
||||
renameSelection
|
||||
} = useSelectionOperations()
|
||||
|
||||
const { alignOptions, distributeOptions, applyAlign, applyDistribute } =
|
||||
useNodeArrangement()
|
||||
|
||||
const { convertToSubgraph, unpackSubgraph, addSubgraphToLibrary } =
|
||||
useSubgraphOperations()
|
||||
|
||||
const { frameNodes } = useFrameNodes()
|
||||
|
||||
const alignSubmenu = computed(() =>
|
||||
alignOptions.map((align) => ({
|
||||
label: align.localizedName,
|
||||
icon: align.icon,
|
||||
action: () => applyAlign(align)
|
||||
}))
|
||||
)
|
||||
|
||||
const distributeSubmenu = computed(() =>
|
||||
distributeOptions.map((distribute) => ({
|
||||
label: distribute.localizedName,
|
||||
icon: distribute.icon,
|
||||
action: () => applyDistribute(distribute)
|
||||
}))
|
||||
)
|
||||
|
||||
const getBasicSelectionOptions = (): MenuOption[] => [
|
||||
{
|
||||
label: t('contextMenu.Rename'),
|
||||
action: renameSelection
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Copy'),
|
||||
shortcut: 'Ctrl+C',
|
||||
action: copySelection
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Duplicate'),
|
||||
shortcut: 'Ctrl+D',
|
||||
action: duplicateSelection
|
||||
}
|
||||
]
|
||||
|
||||
const getSubgraphOptions = (hasSubgraphs: boolean): MenuOption[] => {
|
||||
if (hasSubgraphs) {
|
||||
return [
|
||||
{
|
||||
label: t('contextMenu.Add Subgraph to Library'),
|
||||
icon: 'icon-[lucide--folder-plus]',
|
||||
action: addSubgraphToLibrary
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Unpack Subgraph'),
|
||||
icon: 'icon-[lucide--expand]',
|
||||
action: unpackSubgraph
|
||||
}
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
label: t('contextMenu.Convert to Subgraph'),
|
||||
icon: 'icon-[lucide--shrink]',
|
||||
action: convertToSubgraph,
|
||||
badge: BadgeVariant.NEW
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const getMultipleNodesOptions = (): MenuOption[] => {
|
||||
const convertToGroupNodes = () => {
|
||||
const commandStore = useCommandStore()
|
||||
void commandStore.execute(
|
||||
'Comfy.GroupNode.ConvertSelectedNodesToGroupNode'
|
||||
)
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('contextMenu.Convert to Group Node'),
|
||||
icon: 'icon-[lucide--group]',
|
||||
action: convertToGroupNodes,
|
||||
badge: BadgeVariant.DEPRECATED
|
||||
},
|
||||
{
|
||||
label: t('g.frameNodes'),
|
||||
icon: 'icon-[lucide--frame]',
|
||||
action: frameNodes
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const getAlignmentOptions = (): MenuOption[] => [
|
||||
{
|
||||
label: t('contextMenu.Align Selected To'),
|
||||
icon: 'icon-[lucide--align-start-horizontal]',
|
||||
hasSubmenu: true,
|
||||
submenu: alignSubmenu.value,
|
||||
action: () => {}
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Distribute Nodes'),
|
||||
icon: 'icon-[lucide--align-center-horizontal]',
|
||||
hasSubmenu: true,
|
||||
submenu: distributeSubmenu.value,
|
||||
action: () => {}
|
||||
}
|
||||
]
|
||||
|
||||
const getDeleteOption = (): MenuOption => ({
|
||||
label: t('contextMenu.Delete'),
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
shortcut: 'Delete',
|
||||
action: deleteSelection
|
||||
})
|
||||
|
||||
return {
|
||||
getBasicSelectionOptions,
|
||||
getSubgraphOptions,
|
||||
getMultipleNodesOptions,
|
||||
getDeleteOption,
|
||||
getAlignmentOptions,
|
||||
alignSubmenu,
|
||||
distributeSubmenu
|
||||
}
|
||||
}
|
||||
168
src/composables/graph/useSelectionOperations.ts
Normal file
168
src/composables/graph/useSelectionOperations.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' // Unused for now
|
||||
import { t } from '@/i18n'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import {
|
||||
useCanvasStore,
|
||||
useTitleEditorStore
|
||||
} from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
/**
|
||||
* Composable for handling basic selection operations like copy, paste, duplicate, delete, rename
|
||||
*/
|
||||
export function useSelectionOperations() {
|
||||
// const { getSelectedNodes } = useSelectedLiteGraphItems() // Unused for now
|
||||
const canvasStore = useCanvasStore()
|
||||
const toastStore = useToastStore()
|
||||
const dialogService = useDialogService()
|
||||
const titleEditorStore = useTitleEditorStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const copySelection = () => {
|
||||
const canvas = app.canvas
|
||||
if (!canvas.selectedItems || canvas.selectedItems.size === 0) {
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.nothingToCopy'),
|
||||
detail: t('g.selectItemsToCopy'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
canvas.copyToClipboard()
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: t('g.copied'),
|
||||
detail: t('g.itemsCopiedToClipboard'),
|
||||
life: 2000
|
||||
})
|
||||
}
|
||||
|
||||
const pasteSelection = () => {
|
||||
const canvas = app.canvas
|
||||
canvas.pasteFromClipboard({ connectInputs: false })
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const duplicateSelection = () => {
|
||||
const canvas = app.canvas
|
||||
if (!canvas.selectedItems || canvas.selectedItems.size === 0) {
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.nothingToDuplicate'),
|
||||
detail: t('g.selectItemsToDuplicate'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Copy current selection
|
||||
canvas.copyToClipboard()
|
||||
|
||||
// Clear selection to avoid confusion
|
||||
canvas.selectedItems.clear()
|
||||
canvasStore.updateSelectedItems()
|
||||
|
||||
// Paste to create duplicates
|
||||
canvas.pasteFromClipboard({ connectInputs: false })
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const deleteSelection = () => {
|
||||
const canvas = app.canvas
|
||||
if (!canvas.selectedItems || canvas.selectedItems.size === 0) {
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.nothingToDelete'),
|
||||
detail: t('g.selectItemsToDelete'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
canvas.deleteSelected()
|
||||
canvas.setDirty(true, true)
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const renameSelection = async () => {
|
||||
const selectedItems = Array.from(canvasStore.selectedItems)
|
||||
|
||||
// Handle single node selection
|
||||
if (selectedItems.length === 1) {
|
||||
const item = selectedItems[0]
|
||||
|
||||
// For nodes, use the title editor
|
||||
if (item instanceof LGraphNode) {
|
||||
titleEditorStore.titleEditorTarget = item
|
||||
return
|
||||
}
|
||||
|
||||
// For other items like groups, use prompt dialog
|
||||
const currentTitle = 'title' in item ? (item.title as string) : ''
|
||||
const newTitle = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewName'),
|
||||
defaultValue: currentTitle
|
||||
})
|
||||
|
||||
if (newTitle && newTitle !== currentTitle) {
|
||||
if ('title' in item) {
|
||||
// Type-safe assignment for items with title property
|
||||
const titledItem = item as { title: string }
|
||||
titledItem.title = newTitle
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle multiple selections - batch rename
|
||||
if (selectedItems.length > 1) {
|
||||
const baseTitle = await dialogService.prompt({
|
||||
title: t('g.batchRename'),
|
||||
message: t('g.enterBaseName'),
|
||||
defaultValue: 'Item'
|
||||
})
|
||||
|
||||
if (baseTitle) {
|
||||
selectedItems.forEach((item, index) => {
|
||||
if ('title' in item) {
|
||||
// Type-safe assignment for items with title property
|
||||
const titledItem = item as { title: string }
|
||||
titledItem.title = `${baseTitle} ${index + 1}`
|
||||
}
|
||||
})
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.nothingToRename'),
|
||||
detail: t('g.selectItemsToRename'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
copySelection,
|
||||
pasteSelection,
|
||||
duplicateSelection,
|
||||
deleteSelection,
|
||||
renameSelection
|
||||
}
|
||||
}
|
||||
143
src/composables/graph/useSelectionState.ts
Normal file
143
src/composables/graph/useSelectionState.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
|
||||
export interface NodeSelectionState {
|
||||
collapsed: boolean
|
||||
pinned: boolean
|
||||
bypassed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized computed selection state + shared helper actions to avoid duplication
|
||||
* between selection toolbox, context menus, and other UI affordances.
|
||||
*/
|
||||
export function useSelectionState() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
|
||||
|
||||
const { selectedItems } = storeToRefs(canvasStore)
|
||||
|
||||
const selectedNodes = computed(() => {
|
||||
return selectedItems.value.filter((i: unknown) =>
|
||||
isLGraphNode(i)
|
||||
) as LGraphNode[]
|
||||
})
|
||||
|
||||
const nodeDef = computed(() => {
|
||||
if (selectedNodes.value.length !== 1) return null
|
||||
return nodeDefStore.fromLGraphNode(selectedNodes.value[0])
|
||||
})
|
||||
|
||||
const hasAnySelection = computed(() => selectedItems.value.length > 0)
|
||||
const hasSingleSelection = computed(() => selectedItems.value.length === 1)
|
||||
const hasMultipleSelection = computed(() => selectedItems.value.length > 1)
|
||||
|
||||
const isSingleNode = computed(
|
||||
() => hasSingleSelection.value && isLGraphNode(selectedItems.value[0])
|
||||
)
|
||||
const isSingleSubgraph = computed(
|
||||
() =>
|
||||
isSingleNode.value &&
|
||||
(selectedItems.value[0] as LGraphNode)?.isSubgraphNode?.()
|
||||
)
|
||||
const isSingleImageNode = computed(
|
||||
() =>
|
||||
isSingleNode.value && isImageNode(selectedItems.value[0] as LGraphNode)
|
||||
)
|
||||
|
||||
const hasSubgraphs = computed(() =>
|
||||
selectedItems.value.some((i: unknown) => i instanceof SubgraphNode)
|
||||
)
|
||||
|
||||
const hasAny3DNodeSelected = computed(() => {
|
||||
const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable')
|
||||
return (
|
||||
selectedNodes.value.length === 1 &&
|
||||
selectedNodes.value.some(isLoad3dNode) &&
|
||||
enable3DViewer
|
||||
)
|
||||
})
|
||||
|
||||
const hasImageNode = computed(() => isSingleImageNode.value)
|
||||
const hasOutputNodesSelected = computed(
|
||||
() => filterOutputNodes(selectedNodes.value).length > 0
|
||||
)
|
||||
|
||||
// Helper function to compute selection flags (reused by both computed and function)
|
||||
const computeSelectionStatesFromNodes = (
|
||||
nodes: LGraphNode[]
|
||||
): NodeSelectionState => {
|
||||
if (!nodes.length)
|
||||
return { collapsed: false, pinned: false, bypassed: false }
|
||||
return {
|
||||
collapsed: nodes.some((n) => n.flags?.collapsed),
|
||||
pinned: nodes.some((n) => n.pinned),
|
||||
bypassed: nodes.some((n) => n.mode === LGraphEventMode.BYPASS)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedNodesStates = computed<NodeSelectionState>(() =>
|
||||
computeSelectionStatesFromNodes(selectedNodes.value)
|
||||
)
|
||||
|
||||
// On-demand computation (non-reactive) so callers can fetch fresh flags
|
||||
const computeSelectionFlags = (): NodeSelectionState =>
|
||||
computeSelectionStatesFromNodes(selectedNodes.value)
|
||||
|
||||
/** Toggle node help sidebar/panel for the single selected node (if any). */
|
||||
const showNodeHelp = () => {
|
||||
const def = nodeDef.value
|
||||
if (!def) return
|
||||
|
||||
const isSidebarActive =
|
||||
sidebarTabStore.activeSidebarTabId === nodeLibraryTabId
|
||||
const currentHelpNode: any = nodeHelpStore.currentHelpNode
|
||||
const isSameNodeHelpOpen =
|
||||
isSidebarActive &&
|
||||
nodeHelpStore.isHelpOpen &&
|
||||
currentHelpNode &&
|
||||
currentHelpNode.nodePath === def.nodePath
|
||||
|
||||
if (isSameNodeHelpOpen) {
|
||||
nodeHelpStore.closeHelp()
|
||||
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSidebarActive) sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
|
||||
nodeHelpStore.openHelp(def)
|
||||
}
|
||||
|
||||
return {
|
||||
selectedItems,
|
||||
selectedNodes,
|
||||
nodeDef,
|
||||
showNodeHelp,
|
||||
hasAny3DNodeSelected,
|
||||
hasAnySelection,
|
||||
hasSingleSelection,
|
||||
hasMultipleSelection,
|
||||
isSingleNode,
|
||||
isSingleSubgraph,
|
||||
isSingleImageNode,
|
||||
hasSubgraphs,
|
||||
hasImageNode,
|
||||
hasOutputNodesSelected,
|
||||
selectedNodesStates,
|
||||
computeSelectionFlags
|
||||
}
|
||||
}
|
||||
131
src/composables/graph/useSubgraphOperations.ts
Normal file
131
src/composables/graph/useSubgraphOperations.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
/**
|
||||
* Composable for handling subgraph-related operations
|
||||
*/
|
||||
export function useSubgraphOperations() {
|
||||
const { getSelectedNodes } = useSelectedLiteGraphItems()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
const convertToSubgraph = () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
if (!graph) {
|
||||
return null
|
||||
}
|
||||
|
||||
const res = graph.convertToSubgraph(canvas.selectedItems)
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const unpackSubgraph = () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
|
||||
if (!graph) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedItems = Array.from(canvas.selectedItems)
|
||||
const subgraphNodes = selectedItems.filter(
|
||||
(item): item is SubgraphNode => item instanceof SubgraphNode
|
||||
)
|
||||
|
||||
if (subgraphNodes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
subgraphNodes.forEach((subgraphNode) => {
|
||||
// Revoke any image previews for the subgraph
|
||||
nodeOutputStore.revokeSubgraphPreviews(subgraphNode)
|
||||
|
||||
// Unpack the subgraph
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
})
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const addSubgraphToLibrary = async () => {
|
||||
const selectedItems = Array.from(canvasStore.selectedItems)
|
||||
|
||||
// Handle single node selection like BookmarkButton.vue
|
||||
if (selectedItems.length === 1) {
|
||||
const item = selectedItems[0]
|
||||
if (isLGraphNode(item)) {
|
||||
const nodeDef = nodeDefStore.fromLGraphNode(item)
|
||||
if (nodeDef) {
|
||||
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle multiple nodes - convert to subgraph first then bookmark
|
||||
const selectedNodes = getSelectedNodes()
|
||||
|
||||
if (selectedNodes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if selection contains subgraph nodes
|
||||
const hasSubgraphs = selectedNodes.some(
|
||||
(node) => node instanceof SubgraphNode
|
||||
)
|
||||
|
||||
if (!hasSubgraphs) {
|
||||
// Convert regular nodes to subgraph first
|
||||
convertToSubgraph()
|
||||
return
|
||||
}
|
||||
|
||||
// For subgraph nodes, bookmark them
|
||||
let bookmarkedCount = 0
|
||||
for (const node of selectedNodes) {
|
||||
if (node instanceof SubgraphNode) {
|
||||
const nodeDef = nodeDefStore.fromLGraphNode(node)
|
||||
if (nodeDef) {
|
||||
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
|
||||
bookmarkedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isSubgraphSelected = (): boolean => {
|
||||
const selectedItems = Array.from(canvasStore.selectedItems)
|
||||
return selectedItems.some((item) => item instanceof SubgraphNode)
|
||||
}
|
||||
|
||||
const hasSelectableNodes = (): boolean => {
|
||||
return getSelectedNodes().length > 0
|
||||
}
|
||||
|
||||
return {
|
||||
convertToSubgraph,
|
||||
unpackSubgraph,
|
||||
addSubgraphToLibrary,
|
||||
isSubgraphSelected,
|
||||
hasSelectableNodes
|
||||
}
|
||||
}
|
||||
163
src/composables/graph/useSubmenuPositioning.ts
Normal file
163
src/composables/graph/useSubmenuPositioning.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
|
||||
/**
|
||||
* Composable for handling submenu positioning logic
|
||||
*/
|
||||
export function useSubmenuPositioning() {
|
||||
/**
|
||||
* Toggle submenu visibility with proper positioning
|
||||
* @param option - Menu option with submenu
|
||||
* @param event - Click event
|
||||
* @param submenu - PrimeVue Popover reference
|
||||
* @param currentSubmenu - Currently open submenu name
|
||||
* @param menuOptionsWithSubmenu - All menu options with submenus
|
||||
* @param submenuRefs - References to all submenu popovers
|
||||
*/
|
||||
const toggleSubmenu = async (
|
||||
option: MenuOption,
|
||||
event: Event,
|
||||
submenu: any, // Component instance with show/hide methods
|
||||
currentSubmenu: { value: string | null },
|
||||
menuOptionsWithSubmenu: MenuOption[],
|
||||
submenuRefs: Record<string, any> // Component instances
|
||||
): Promise<void> => {
|
||||
if (!option.label || !option.hasSubmenu) return
|
||||
|
||||
// Check if this submenu is currently open
|
||||
const isCurrentlyOpen = currentSubmenu.value === option.label
|
||||
|
||||
// Hide all submenus first
|
||||
menuOptionsWithSubmenu.forEach((opt) => {
|
||||
const sm = submenuRefs[`submenu-${opt.label}`]
|
||||
if (sm) {
|
||||
sm.hide()
|
||||
}
|
||||
})
|
||||
currentSubmenu.value = null
|
||||
|
||||
// If it wasn't open before, show it now
|
||||
if (!isCurrentlyOpen) {
|
||||
currentSubmenu.value = option.label
|
||||
await nextTick()
|
||||
|
||||
const menuItem = event.currentTarget as HTMLElement
|
||||
const menuItemRect = menuItem.getBoundingClientRect()
|
||||
|
||||
// Find the parent popover content element that contains this menu item
|
||||
const mainPopoverContent = menuItem.closest(
|
||||
'[data-pc-section="content"]'
|
||||
) as HTMLElement
|
||||
|
||||
if (mainPopoverContent) {
|
||||
const mainPopoverRect = mainPopoverContent.getBoundingClientRect()
|
||||
|
||||
// Create a temporary positioned element as the target
|
||||
const tempTarget = createPositionedTarget(
|
||||
mainPopoverRect.right + 8,
|
||||
menuItemRect.top,
|
||||
`submenu-target-${option.label}`
|
||||
)
|
||||
|
||||
// Create event using the temp target
|
||||
const tempEvent = createMouseEvent(
|
||||
mainPopoverRect.right + 8,
|
||||
menuItemRect.top
|
||||
)
|
||||
|
||||
// Show submenu relative to temp target
|
||||
submenu.show(tempEvent, tempTarget)
|
||||
|
||||
// Clean up temp target after a delay
|
||||
cleanupTempTarget(tempTarget, 100)
|
||||
} else {
|
||||
// Fallback: position to the right of the menu item
|
||||
const tempTarget = createPositionedTarget(
|
||||
menuItemRect.right + 8,
|
||||
menuItemRect.top,
|
||||
`submenu-fallback-target-${option.label}`
|
||||
)
|
||||
|
||||
// Create event using the temp target
|
||||
const tempEvent = createMouseEvent(
|
||||
menuItemRect.right + 8,
|
||||
menuItemRect.top
|
||||
)
|
||||
|
||||
// Show submenu relative to temp target
|
||||
submenu.show(tempEvent, tempTarget)
|
||||
|
||||
// Clean up temp target after a delay
|
||||
cleanupTempTarget(tempTarget, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary positioned DOM element for submenu targeting
|
||||
*/
|
||||
const createPositionedTarget = (
|
||||
left: number,
|
||||
top: number,
|
||||
id: string
|
||||
): HTMLElement => {
|
||||
const tempTarget = document.createElement('div')
|
||||
tempTarget.style.position = 'absolute'
|
||||
tempTarget.style.left = `${left}px`
|
||||
tempTarget.style.top = `${top}px`
|
||||
tempTarget.style.width = '1px'
|
||||
tempTarget.style.height = '1px'
|
||||
tempTarget.style.pointerEvents = 'none'
|
||||
tempTarget.style.visibility = 'hidden'
|
||||
tempTarget.id = id
|
||||
|
||||
document.body.appendChild(tempTarget)
|
||||
return tempTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mouse event with specific coordinates
|
||||
*/
|
||||
const createMouseEvent = (clientX: number, clientY: number): MouseEvent => {
|
||||
return new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX,
|
||||
clientY
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up temporary target element after delay
|
||||
*/
|
||||
const cleanupTempTarget = (target: HTMLElement, delay: number): void => {
|
||||
setTimeout(() => {
|
||||
if (target.parentNode) {
|
||||
target.parentNode.removeChild(target)
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide all submenus
|
||||
*/
|
||||
const hideAllSubmenus = (
|
||||
menuOptionsWithSubmenu: MenuOption[],
|
||||
submenuRefs: Record<string, any>, // Component instances
|
||||
currentSubmenu: { value: string | null }
|
||||
): void => {
|
||||
menuOptionsWithSubmenu.forEach((option) => {
|
||||
const submenu = submenuRefs[`submenu-${option.label}`]
|
||||
if (submenu) {
|
||||
submenu.hide()
|
||||
}
|
||||
})
|
||||
currentSubmenu.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
toggleSubmenu,
|
||||
hideAllSubmenus
|
||||
}
|
||||
}
|
||||
103
src/composables/graph/useViewportCulling.ts
Normal file
103
src/composables/graph/useViewportCulling.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Vue Nodes Viewport Culling
|
||||
*
|
||||
* Principles:
|
||||
* 1. Query DOM directly using data attributes (no cache to maintain)
|
||||
* 2. Set display none on element to avoid cascade resolution overhead
|
||||
* 3. Only run when transform changes (event driven)
|
||||
*/
|
||||
import { createSharedComposable, useThrottleFn } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
type Bounds = [left: number, right: number, top: number, bottom: number]
|
||||
|
||||
function getNodeBounds(node: LGraphNode): Bounds {
|
||||
const [nodeLeft, nodeTop] = node.pos
|
||||
const nodeRight = nodeLeft + node.size[0]
|
||||
const nodeBottom = nodeTop + node.size[1]
|
||||
return [nodeLeft, nodeRight, nodeTop, nodeBottom]
|
||||
}
|
||||
|
||||
function viewportEdges(
|
||||
canvas: ReturnType<typeof useCanvasStore>['canvas']
|
||||
): Bounds | undefined {
|
||||
if (!canvas) {
|
||||
return
|
||||
}
|
||||
const ds = canvas.ds
|
||||
const viewport_width = canvas.canvas.width
|
||||
const viewport_height = canvas.canvas.height
|
||||
const margin = 500 * ds.scale
|
||||
|
||||
const [xOffset, yOffset] = ds.offset
|
||||
|
||||
const leftEdge = -margin / ds.scale - xOffset
|
||||
const rightEdge = (viewport_width + margin) / ds.scale - xOffset
|
||||
const topEdge = -margin / ds.scale - yOffset
|
||||
const bottomEdge = (viewport_height + margin) / ds.scale - yOffset
|
||||
return [leftEdge, rightEdge, topEdge, bottomEdge]
|
||||
}
|
||||
|
||||
function boundsIntersect(boxA: Bounds, boxB: Bounds): boolean {
|
||||
const [aLeft, aRight, aTop, aBottom] = boxA
|
||||
const [bLeft, bRight, bTop, bBottom] = boxB
|
||||
|
||||
const leftOf = aRight < bLeft
|
||||
const rightOf = aLeft > bRight
|
||||
const above = aBottom < bTop
|
||||
const below = aTop > bBottom
|
||||
return !(leftOf || rightOf || above || below)
|
||||
}
|
||||
|
||||
function useViewportCullingIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { nodeManager } = useVueNodeLifecycle()
|
||||
|
||||
const viewport = computed(() => viewportEdges(canvasStore.canvas))
|
||||
|
||||
function inViewport(node: LGraphNode | undefined): boolean {
|
||||
if (!viewport.value || !node) {
|
||||
return true
|
||||
}
|
||||
const nodeBounds = getNodeBounds(node)
|
||||
return boundsIntersect(nodeBounds, viewport.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visibility of all nodes based on viewport
|
||||
* Queries DOM directly - no cache maintenance needed
|
||||
*/
|
||||
function updateVisibility() {
|
||||
if (!nodeManager.value || !app.canvas) return // load bearing app.canvas check for workflows being loaded.
|
||||
|
||||
const nodeElements = document.querySelectorAll('[data-node-id]')
|
||||
for (const element of nodeElements) {
|
||||
const nodeId = element.getAttribute('data-node-id')
|
||||
if (!nodeId) continue
|
||||
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
if (!node) continue
|
||||
|
||||
const displayValue = inViewport(node) ? '' : 'none'
|
||||
if (
|
||||
element instanceof HTMLElement &&
|
||||
element.style.display !== displayValue
|
||||
) {
|
||||
element.style.display = displayValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTransformUpdate = useThrottleFn(() => updateVisibility, 100, true)
|
||||
|
||||
return { handleTransformUpdate }
|
||||
}
|
||||
|
||||
export const useViewportCulling = createSharedComposable(
|
||||
useViewportCullingIndividual
|
||||
)
|
||||
172
src/composables/graph/useVueNodeLifecycle.ts
Normal file
172
src/composables/graph/useVueNodeLifecycle.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { shallowRef, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync'
|
||||
import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
|
||||
function useVueNodeLifecycleIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const layoutMutations = useLayoutMutations()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const nodeManager = shallowRef<GraphNodeManager | null>(null)
|
||||
|
||||
const { startSync } = useLayoutSync()
|
||||
const linkSyncManager = useLinkLayoutSync()
|
||||
const slotSyncManager = useSlotLayoutSync()
|
||||
|
||||
const initializeNodeManager = () => {
|
||||
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
|
||||
const activeGraph = comfyApp.canvas?.graph
|
||||
if (!activeGraph || nodeManager.value) return
|
||||
|
||||
// Initialize the core node manager
|
||||
const manager = useGraphNodeManager(activeGraph)
|
||||
nodeManager.value = manager
|
||||
|
||||
// Initialize layout system with existing nodes from active graph
|
||||
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
||||
id: node.id.toString(),
|
||||
pos: [node.pos[0], node.pos[1]] as [number, number],
|
||||
size: [node.size[0], node.size[1]] as [number, number]
|
||||
}))
|
||||
layoutStore.initializeFromLiteGraph(nodes)
|
||||
|
||||
// Seed reroutes into the Layout Store so hit-testing uses the new path
|
||||
for (const reroute of activeGraph.reroutes.values()) {
|
||||
const [x, y] = reroute.pos
|
||||
const parent = reroute.parentId ?? undefined
|
||||
const linkIds = Array.from(reroute.linkIds)
|
||||
layoutMutations.createReroute(reroute.id, { x, y }, parent, linkIds)
|
||||
}
|
||||
|
||||
// Seed existing links into the Layout Store (topology only)
|
||||
for (const link of activeGraph._links.values()) {
|
||||
layoutMutations.createLink(
|
||||
link.id,
|
||||
link.origin_id,
|
||||
link.origin_slot,
|
||||
link.target_id,
|
||||
link.target_slot
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize layout sync (one-way: Layout Store → LiteGraph)
|
||||
startSync(canvasStore.canvas)
|
||||
|
||||
if (comfyApp.canvas) {
|
||||
linkSyncManager.start(comfyApp.canvas)
|
||||
}
|
||||
}
|
||||
|
||||
const disposeNodeManagerAndSyncs = () => {
|
||||
if (!nodeManager.value) return
|
||||
|
||||
try {
|
||||
nodeManager.value.cleanup()
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
nodeManager.value = null
|
||||
|
||||
linkSyncManager.stop()
|
||||
}
|
||||
|
||||
// Watch for Vue nodes enabled state changes
|
||||
watch(
|
||||
() => shouldRenderVueNodes.value && Boolean(comfyApp.canvas?.graph),
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
initializeNodeManager()
|
||||
} else {
|
||||
disposeNodeManagerAndSyncs()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Consolidated watch for slot layout sync management
|
||||
watch(
|
||||
[() => canvasStore.canvas, () => shouldRenderVueNodes.value],
|
||||
([canvas, vueMode], [, oldVueMode]) => {
|
||||
const modeChanged = vueMode !== oldVueMode
|
||||
|
||||
// Clear stale slot layouts when switching modes
|
||||
if (modeChanged) {
|
||||
layoutStore.clearAllSlotLayouts()
|
||||
}
|
||||
|
||||
// Switching to Vue
|
||||
if (vueMode) {
|
||||
slotSyncManager.stop()
|
||||
}
|
||||
|
||||
// Switching to LG
|
||||
const shouldRun = Boolean(canvas?.graph) && !vueMode
|
||||
if (shouldRun && canvas) {
|
||||
slotSyncManager.attemptStart(canvas as LGraphCanvas)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Handle case where Vue nodes are enabled but graph starts empty
|
||||
const setupEmptyGraphListener = () => {
|
||||
const activeGraph = comfyApp.canvas?.graph
|
||||
if (
|
||||
!shouldRenderVueNodes.value ||
|
||||
nodeManager.value ||
|
||||
activeGraph?._nodes.length !== 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
const originalOnNodeAdded = activeGraph.onNodeAdded
|
||||
activeGraph.onNodeAdded = function (node: LGraphNode) {
|
||||
// Restore original handler
|
||||
activeGraph.onNodeAdded = originalOnNodeAdded
|
||||
|
||||
// Initialize node manager if needed
|
||||
if (shouldRenderVueNodes.value && !nodeManager.value) {
|
||||
initializeNodeManager()
|
||||
}
|
||||
|
||||
// Call original handler
|
||||
if (originalOnNodeAdded) {
|
||||
originalOnNodeAdded.call(this, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function for component unmounting
|
||||
const cleanup = () => {
|
||||
if (nodeManager.value) {
|
||||
nodeManager.value.cleanup()
|
||||
nodeManager.value = null
|
||||
}
|
||||
slotSyncManager.stop()
|
||||
linkSyncManager.stop()
|
||||
}
|
||||
|
||||
return {
|
||||
nodeManager,
|
||||
|
||||
// Lifecycle methods
|
||||
initializeNodeManager,
|
||||
disposeNodeManagerAndSyncs,
|
||||
setupEmptyGraphListener,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
export const useVueNodeLifecycle = createSharedComposable(
|
||||
useVueNodeLifecycleIndividual
|
||||
)
|
||||
149
src/composables/graph/useWidgetValue.ts
Normal file
149
src/composables/graph/useWidgetValue.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Composable for managing widget value synchronization between Vue and LiteGraph
|
||||
* Provides consistent pattern for immediate UI updates and LiteGraph callbacks
|
||||
*/
|
||||
import { type Ref, ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
interface UseWidgetValueOptions<T extends WidgetValue = WidgetValue, U = T> {
|
||||
/** The widget configuration from LiteGraph */
|
||||
widget: SimplifiedWidget<T>
|
||||
/** The current value from parent component */
|
||||
modelValue: T
|
||||
/** Default value if modelValue is null/undefined */
|
||||
defaultValue: T
|
||||
/** Emit function from component setup */
|
||||
emit: (event: 'update:modelValue', value: T) => void
|
||||
/** Optional value transformer before sending to LiteGraph */
|
||||
transform?: (value: U) => T
|
||||
}
|
||||
|
||||
interface UseWidgetValueReturn<T extends WidgetValue = WidgetValue, U = T> {
|
||||
/** Local value for immediate UI updates */
|
||||
localValue: Ref<T>
|
||||
/** Handler for user interactions */
|
||||
onChange: (newValue: U) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages widget value synchronization with LiteGraph
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* const { localValue, onChange } = useWidgetValue({
|
||||
* widget: props.widget,
|
||||
* modelValue: props.modelValue,
|
||||
* defaultValue: ''
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function useWidgetValue<T extends WidgetValue = WidgetValue, U = T>({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue,
|
||||
emit,
|
||||
transform
|
||||
}: UseWidgetValueOptions<T, U>): UseWidgetValueReturn<T, U> {
|
||||
// Local value for immediate UI updates
|
||||
const localValue = ref<T>(modelValue ?? defaultValue)
|
||||
|
||||
// Handle user changes
|
||||
const onChange = (newValue: U) => {
|
||||
// Handle different PrimeVue component signatures
|
||||
let processedValue: T
|
||||
if (transform) {
|
||||
processedValue = transform(newValue)
|
||||
} else {
|
||||
// Ensure type safety - only cast when types are compatible
|
||||
if (
|
||||
typeof newValue === typeof defaultValue ||
|
||||
newValue === null ||
|
||||
newValue === undefined
|
||||
) {
|
||||
processedValue = (newValue ?? defaultValue) as T
|
||||
} else {
|
||||
console.warn(
|
||||
`useWidgetValue: Type mismatch for widget ${widget.name}. Expected ${typeof defaultValue}, got ${typeof newValue}`
|
||||
)
|
||||
processedValue = defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Update local state for immediate UI feedback
|
||||
localValue.value = processedValue
|
||||
|
||||
// 2. Emit to parent component
|
||||
emit('update:modelValue', processedValue)
|
||||
}
|
||||
|
||||
// Watch for external updates from LiteGraph
|
||||
watch(
|
||||
() => modelValue,
|
||||
(newValue) => {
|
||||
localValue.value = newValue ?? defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
localValue: localValue as Ref<T>,
|
||||
onChange
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for string widgets
|
||||
*/
|
||||
export function useStringWidgetValue(
|
||||
widget: SimplifiedWidget<string>,
|
||||
modelValue: string,
|
||||
emit: (event: 'update:modelValue', value: string) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: '',
|
||||
emit,
|
||||
transform: (value: string | undefined) => String(value || '') // Handle undefined from PrimeVue
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for number widgets
|
||||
*/
|
||||
export function useNumberWidgetValue(
|
||||
widget: SimplifiedWidget<number>,
|
||||
modelValue: number,
|
||||
emit: (event: 'update:modelValue', value: number) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: 0,
|
||||
emit,
|
||||
transform: (value: number | number[]) => {
|
||||
// Handle PrimeVue Slider which can emit number | number[]
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? value[0] ?? 0 : 0
|
||||
}
|
||||
return Number(value) || 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for boolean widgets
|
||||
*/
|
||||
export function useBooleanWidgetValue(
|
||||
widget: SimplifiedWidget<boolean>,
|
||||
modelValue: boolean,
|
||||
emit: (event: 'update:modelValue', value: boolean) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: false,
|
||||
emit,
|
||||
transform: (value: boolean) => Boolean(value)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user