Decouple link and slot hit-testing out of Litegraph (#5134)

* [feat] TransformPane - Viewport synchronization layer for Vue nodes (#4304)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
Co-authored-by: github-actions <github-actions@github.com>

* Update locales [skip ci]

* Update locales [skip ci]

* Add vue node feature flag (#4927)

* feat: Implement CRDT-based layout system for Vue nodes (#4959)

* feat: Implement CRDT-based layout system for Vue nodes

Major refactor to solve snap-back issues and create single source of truth for node positions:

- Add Yjs-based CRDT layout store for conflict-free position management
- Implement layout mutations service with clean API
- Create Vue composables for layout access and node dragging
- Add one-way sync from layout store to LiteGraph
- Disable LiteGraph dragging when Vue nodes mode is enabled
- Add z-index management with bring-to-front on node interaction
- Add comprehensive TypeScript types for layout system
- Include unit tests for layout store operations
- Update documentation to reflect CRDT architecture

This provides a solid foundation for both single-user performance and future real-time collaboration features.

Co-Authored-By: Claude <noreply@anthropic.com>

* style: Apply linter fixes to layout system

* fix: Remove unnecessary README files and revert services README

- Remove unnecessary types/README.md file
- Revert unrelated changes to services/README.md
- Keep only relevant documentation for the layout system implementation

These were issues identified during PR review that needed to be addressed.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Clean up layout store and implement proper CRDT operations

- Created dedicated layoutOperations.ts with production-grade CRDT interfaces
- Integrated existing QuadTree spatial index instead of simple cache
- Split composables into separate files (useLayout, useNodeLayout, useLayoutSync)
- Cleaned up operation handlers using specific types instead of Extract
- Added proper operation interfaces with type guards and extensibility
- Updated all type references to use new operation structure

The layout store now properly uses the existing QuadTree infrastructure for
efficient spatial queries and follows CRDT best practices with well-defined
operation interfaces.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Extract services and split composables for better organization

- Created SpatialIndexManager to handle QuadTree operations separately
- Added LayoutAdapter interface for CRDT abstraction (Yjs, mock implementations)
- Split GraphNodeManager into focused composables:
  - useNodeWidgets: Widget state and callback management
  - useNodeChangeDetection: RAF-based geometry change detection
  - useNodeState: Node visibility and reactive state management
- Extracted constants for magic numbers and configuration values
- Updated layout store to use SpatialIndexManager and constants

This improves code organization, testability, and makes it easier to swap
CRDT implementations or mock services for testing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Revert "Totally not scuffed renderer and adapter"

This reverts commit 2b9d83efb8.

* Revert "Remove slots from layoutTypes"

This reverts commit 18f78ff786.

* Reapply "Add node slots to layout tree"

This reverts commit 236fecb549.

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* docs: Replace architecture docs with comprehensive ADR

- Add ADR-0002 for CRDT-based layout system decision
- Follow established ADR template with persuasive reasoning
- Include performance benefits, collaboration readiness, and architectural advantages
- Update ADR index

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>

* [chore] Extract link rendering out of LGraphCanvas (#4994)

* feat: Implement CRDT-based layout system for Vue nodes

Major refactor to solve snap-back issues and create single source of truth for node positions:

- Add Yjs-based CRDT layout store for conflict-free position management
- Implement layout mutations service with clean API
- Create Vue composables for layout access and node dragging
- Add one-way sync from layout store to LiteGraph
- Disable LiteGraph dragging when Vue nodes mode is enabled
- Add z-index management with bring-to-front on node interaction
- Add comprehensive TypeScript types for layout system
- Include unit tests for layout store operations
- Update documentation to reflect CRDT architecture

This provides a solid foundation for both single-user performance and future real-time collaboration features.

Co-Authored-By: Claude <noreply@anthropic.com>

* style: Apply linter fixes to layout system

* fix: Remove unnecessary README files and revert services README

- Remove unnecessary types/README.md file
- Revert unrelated changes to services/README.md
- Keep only relevant documentation for the layout system implementation

These were issues identified during PR review that needed to be addressed.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Clean up layout store and implement proper CRDT operations

- Created dedicated layoutOperations.ts with production-grade CRDT interfaces
- Integrated existing QuadTree spatial index instead of simple cache
- Split composables into separate files (useLayout, useNodeLayout, useLayoutSync)
- Cleaned up operation handlers using specific types instead of Extract
- Added proper operation interfaces with type guards and extensibility
- Updated all type references to use new operation structure

The layout store now properly uses the existing QuadTree infrastructure for
efficient spatial queries and follows CRDT best practices with well-defined
operation interfaces.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Extract services and split composables for better organization

- Created SpatialIndexManager to handle QuadTree operations separately
- Added LayoutAdapter interface for CRDT abstraction (Yjs, mock implementations)
- Split GraphNodeManager into focused composables:
  - useNodeWidgets: Widget state and callback management
  - useNodeChangeDetection: RAF-based geometry change detection
  - useNodeState: Node visibility and reactive state management
- Extracted constants for magic numbers and configuration values
- Updated layout store to use SpatialIndexManager and constants

This improves code organization, testability, and makes it easier to swap
CRDT implementations or mock services for testing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Revert "Totally not scuffed renderer and adapter"

This reverts commit 2b9d83efb8.

* Revert "Remove slots from layoutTypes"

This reverts commit 18f78ff786.

* Reapply "Add node slots to layout tree"

This reverts commit 236fecb549.

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* docs: Replace architecture docs with comprehensive ADR

- Add ADR-0002 for CRDT-based layout system decision
- Follow established ADR template with persuasive reasoning
- Include performance benefits, collaboration readiness, and architectural advantages
- Update ADR index

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Remove unused methods in LGLA

* Extract slot position calculations to shared utility

- Create slotCalculations.ts utility for centralized slot position logic
- Update LGraphNode to delegate to helper while maintaining compatibility
- Modify LitegraphLinkAdapter to use layout tree positions when available
- Enable link rendering to use layout system coordinates instead of litegraph positions

This allows the layout tree to control link rendering positions, enabling proper
synchronization between Vue components and canvas rendering.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [fix] Restore original link rendering behavior after refactor

This commit fixes several rendering discrepancies introduced during the link rendering refactor to ensure exact parity with the original litegraph implementation:

Path Shape Fixes:
- STRAIGHT_LINK: Now correctly applies l=10 offset to create innerA/innerB points and uses midX=(innerA.x+innerB.x)*0.5 for elbow placement, matching the original 6-segment path
- LINEAR_LINK: Restored 4-point path with l=15 directional offsets (start → innerA → innerB → end)

Arrow Rendering:
- computeConnectionPoint: Now always uses bezier math with 0.25 factor spline offsets regardless of render mode, ensuring arrow positions match original
- Arrow positions: Fixed to render at 0.25 and 0.75 positions along the path
- Arrow gating: Moved scale>=0.6 and highQuality checks to adapter layer to maintain PathRenderer purity
- Arrow shape: Restored original triangle dimensions (-5,-3) to (0,+7) to (+5,-3)

Center Marker:
- Fixed 'None' option: Center marker now correctly hidden when LinkMarkerShape.None is selected
- Center point calculation: Updated for all render modes to match original positions
- STRAIGHT_LINK center: Uses midX and average of innerA/innerB y-coordinates
- LINEAR_LINK center: Uses midpoint between innerA and innerB control points

These fixes ensure backward compatibility while maintaining the clean separation between the pure PathRenderer and litegraph-specific LitegraphLinkAdapter.

Fixes #Issue-Number

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>

* feat: Add slot registration and spatial indexing for hit detection

- Implement slot registration for all nodes (Vue and LiteGraph)
- Add spatial indexes for slots and reroutes to improve hit detection performance
- Register slots when nodes are drawn via new registerSlots() method
- Update LayoutStore to use spatial indexing for O(log n) queries instead of O(n)

Resolves #5125

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Revert "feat: Add slot registration and spatial indexing for hit detection"

This reverts commit 70fbfd0f5e.

* feat: Add slot registration and spatial indexing for hit detection

- Implement slot registration for all nodes (Vue and LiteGraph)
- Add spatial indexes for slots and reroutes to improve hit detection performance
- Register slots when nodes are drawn via new registerSlots() method
- Update LayoutStore to use spatial indexing for O(log n) queries instead of O(n)

Resolves #5125

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* relocate slot update to layoutstore

* Revert "relocate slot update to layoutstore"

This reverts commit 0b17ef148bdded35cb231bef25b8d5c77dc14c1f.

* add useSlotLayoutSync

* feat: Extend Layout Store with CRDT support for links and reroutes

Move links and reroutes to be first-class CRDT entities in the Layout Store,
eliminating per-frame registration during rendering. This provides a ~100x
reduction in spatial index operations by using event-driven updates instead
of polling.

Key changes:
- Add CRDT maps for links and reroutes with automatic observers
- Add mutation operations for link/reroute lifecycle management
- Update LiteGraph to use mutations instead of direct store calls
- Remove per-frame updateLinkLayout and updateRerouteLayout calls

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Scuffed diff, change to dirty later

* Fix reroute move desync

* Terrible reroute fixes

* Use LinkId for LinkLayout

* refactor: Remove unused duplicate layout type files

Deleted src/types/layoutTypes.ts and src/types/layoutOperations.ts
which were duplicates of src/renderer/core/layout/types.ts. These
files had zero imports and were creating confusion in the codebase.

The active types are in src/renderer/core/layout/types.ts which
is properly integrated with the current architecture.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Extract layout source strings into LayoutSource enum

Replace hardcoded 'canvas' | 'vue' | 'external' string literals with a proper TypeScript enum for better type safety and maintainability. This change provides a single source of truth for layout source types and makes future modifications easier.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Unify CRDT layout operations under type-safe entity bases

Replace node-centric BaseOperation with a clean hierarchy:
- Add OperationMeta base containing common fields (timestamp, actor, source, type)
- Introduce entity-specific bases (NodeOpBase, LinkOpBase, RerouteOpBase)
- Each operation now extends its appropriate entity base with proper typing
- Add entity discriminator field for runtime type narrowing

Benefits:
- Eliminates duplicate meta fields across link/reroute operations
- Provides type-safe discriminated unions for each entity type
- Enables clean extension path for future operation types
- Zero breaking changes - type-only refactor with no runtime impact

Also adds helper functions:
- getAffectedNodeIds() to extract node IDs affected by any operation
- Entity-specific helper checks for operation classification

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix initial link seeding

* fix: Fix reroute hit detection and type consistency issues

- Use instanceof Reroute type guard instead of structural 'linkIds' check
- Remove unnecessary Number() conversions for reroute IDs (already numeric)
- Fix parentId truthiness bug (0 is valid parent ID)
- Pass numeric IDs directly in GraphCanvas seeding
- Add missing link/reroute methods to LayoutMutations interface
- Make hit test tolerance scale-aware using ctx.lineWidth and DPI

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add debug logs

* Add missing reroute path

* cleanup

* feat: Implement event-driven link layout sync

Remove layout store writes from render loop and update link geometry only on
actual changes (node move/resize, link/reroute operations, collapse toggles).

Key improvements:
- No layout writes during canvas render (decoupled from draw cycle)
- Link layouts update only on causal events via useLinkLayoutSync
- Hit testing remains precise using stored Path2D objects
- Optimized adapter: calculations only when enableLayoutStoreWrites=true
- Store-level deduplication prevents spatial index churn

Performance impact:
- Render path: Zero layout work, no equality checks, no store writes
- Event path: Direct writes with cheap store-level dedup
- Significant CPU savings per frame on complex graphs

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: Implement DOM-based slot registration with unified position system

- Add centralized getSlotPosition() function in SlotCalculations
- Create SlotIdentifier utilities for consistent slot key generation
- Implement DOM-based slot registration composable with performance optimizations:
  - Cache slot offsets to avoid DOM reads during drag operations
  - Batch measurements via requestAnimationFrame
  - Skip redundant updates when bounds unchanged
- Update Vue slot components to register DOM positions
- Fix widget-to-input index mapping in NodeWidgets
- Prevent double registration when Vue nodes enabled

This improves slot hit-detection accuracy by using actual DOM positions
while maintaining performance through intelligent caching and batching.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove unused files

* Remove duplicated markdown file

* Remove duplicated files and address knip concerns

* Remove outdated test

* warning comment

* Update test snapshots

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Benjamin Lu
2025-09-01 17:01:17 -04:00
committed by GitHub
parent 08309595e0
commit 2a5e0d231e
89 changed files with 2464 additions and 5731 deletions

View File

@@ -142,6 +142,9 @@ import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
import { useLayout } from '@/renderer/core/layout/sync/useLayout'
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 { LayoutSource } from '@/renderer/core/layout/types'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
@@ -283,6 +286,10 @@ watch(canvasRef, () => {
// Vue node lifecycle management - initialize after graph is ready
let nodeManager: ReturnType<typeof useGraphNodeManager> | null = null
let cleanupNodeManager: (() => void) | null = null
// Slot layout sync management
let slotSync: ReturnType<typeof useSlotLayoutSync> | null = null
let linkSync: ReturnType<typeof useLinkLayoutSync> | null = null
const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map())
const nodeState = ref<ReadonlyMap<string, NodeState>>(new Map())
const nodePositions = ref<ReadonlyMap<string, { x: number; y: number }>>(
@@ -324,15 +331,46 @@ const initializeNodeManager = () => {
}))
layoutStore.initializeFromLiteGraph(nodes)
// Seed reroutes into the Layout Store so hit-testing uses the new path
for (const reroute of comfyApp.graph.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 comfyApp.graph._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)
const { startSync } = useLayoutSync()
startSync(canvasStore.canvas)
// Initialize slot layout sync for hit detection
slotSync = useSlotLayoutSync()
if (canvasStore.canvas) {
slotSync.start(canvasStore.canvas as LGraphCanvas)
}
// Initialize link layout sync for event-driven updates
linkSync = useLinkLayoutSync()
if (canvasStore.canvas) {
linkSync.start(canvasStore.canvas as LGraphCanvas)
}
// Force computed properties to re-evaluate
nodeDataTrigger.value++
}
const disposeNodeManager = () => {
const disposeNodeManagerAndSyncs = () => {
if (!nodeManager) return
try {
cleanupNodeManager?.()
@@ -341,6 +379,19 @@ const disposeNodeManager = () => {
}
nodeManager = null
cleanupNodeManager = null
// Clean up slot layout sync
if (slotSync) {
slotSync.stop()
slotSync = null
}
// Clean up link layout sync
if (linkSync) {
linkSync.stop()
linkSync = null
}
// Reset reactive maps to inert defaults
vueNodeData.value = new Map()
nodeState.value = new Map()
@@ -360,7 +411,7 @@ watch(
if (enabled) {
initializeNodeManager()
} else {
disposeNodeManager()
disposeNodeManagerAndSyncs()
}
},
{ immediate: true }
@@ -509,7 +560,7 @@ const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
// Bring node to front when clicked (similar to LiteGraph behavior)
// Skip if node is pinned
if (!node.flags?.pinned) {
layoutMutations.setSource('vue')
layoutMutations.setSource(LayoutSource.Vue)
layoutMutations.bringNodeToFront(nodeData.id)
}
node.selected = true
@@ -827,5 +878,17 @@ onUnmounted(() => {
nodeManager.cleanup()
nodeManager = null
}
// Clean up slot layout sync
if (slotSync) {
slotSync.stop()
slotSync = null
}
// Clean up link layout sync
if (linkSync) {
linkSync.stop()
linkSync = null
}
})
</script>

View File

@@ -1,112 +0,0 @@
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<template>
<div class="pt-2 border-t border-surface-200 dark-theme:border-surface-700">
<h4 class="font-semibold mb-1">QuadTree Spatial Index</h4>
<!-- Enable/Disable Toggle -->
<div class="mb-2">
<label class="flex items-center gap-2">
<input
:checked="enabled"
type="checkbox"
@change="$emit('toggle', ($event.target as HTMLInputElement).checked)"
/>
<span>Enable Spatial Indexing</span>
</label>
</div>
<!-- Status Message -->
<p v-if="!enabled" class="text-muted text-xs italic">
{{ statusMessage }}
</p>
<!-- Metrics when enabled -->
<template v-if="enabled && metrics">
<p class="text-muted">Strategy: {{ strategy }}</p>
<p class="text-muted">Total Nodes: {{ metrics.totalNodes }}</p>
<p class="text-muted">Visible Nodes: {{ metrics.visibleNodes }}</p>
<p class="text-muted">Query Time: {{ metrics.queryTime.toFixed(2) }}ms</p>
<p class="text-muted">Tree Depth: {{ metrics.treeDepth }}</p>
<p class="text-muted">Culling Efficiency: {{ cullingEfficiency }}</p>
<p class="text-muted">Rebuilds: {{ metrics.rebuildCount }}</p>
<!-- Show debug visualization toggle -->
<div class="mt-2">
<label class="flex items-center gap-2">
<input
:checked="showVisualization"
type="checkbox"
@change="
$emit(
'toggle-visualization',
($event.target as HTMLInputElement).checked
)
"
/>
<span>Show QuadTree Boundaries</span>
</label>
</div>
</template>
<!-- Performance Comparison -->
<template v-if="enabled && performanceComparison">
<div class="mt-2 text-xs">
<p class="text-muted font-semibold">Performance vs Linear:</p>
<p class="text-muted">Speedup: {{ performanceComparison.speedup }}x</p>
<p class="text-muted">
Break-even: ~{{ performanceComparison.breakEvenNodeCount }} nodes
</p>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
enabled: boolean
metrics?: {
totalNodes: number
visibleNodes: number
queryTime: number
treeDepth: number
rebuildCount: number
}
strategy?: string
threshold?: number
showVisualization?: boolean
performanceComparison?: {
speedup: number
breakEvenNodeCount: number
}
}
const props = withDefaults(defineProps<Props>(), {
metrics: undefined,
strategy: 'quadtree',
threshold: 100,
showVisualization: false,
performanceComparison: undefined
})
defineEmits<{
toggle: [enabled: boolean]
'toggle-visualization': [show: boolean]
}>()
const statusMessage = computed(() => {
if (!props.enabled && props.metrics) {
return `Disabled (threshold: ${props.threshold} nodes, current: ${props.metrics.totalNodes})`
}
return `Spatial indexing will enable at ${props.threshold}+ nodes`
})
const cullingEfficiency = computed(() => {
if (!props.metrics || props.metrics.totalNodes === 0) return 'N/A'
const culled = props.metrics.totalNodes - props.metrics.visibleNodes
const percentage = ((culled / props.metrics.totalNodes) * 100).toFixed(1)
return `${culled} nodes (${percentage}%)`
})
</script>

View File

@@ -1,112 +0,0 @@
<template>
<svg
v-if="visible && debugInfo"
:width="svgSize.width"
:height="svgSize.height"
:style="svgStyle"
class="quadtree-visualization"
>
<!-- QuadTree boundaries -->
<g v-for="(node, index) in flattenedNodes" :key="`quad-${index}`">
<rect
:x="node.bounds.x"
:y="node.bounds.y"
:width="node.bounds.width"
:height="node.bounds.height"
:stroke="getDepthColor(node.depth)"
:stroke-width="getStrokeWidth(node.depth)"
fill="none"
:opacity="0.3 + node.depth * 0.05"
/>
</g>
<!-- Viewport bounds (optional) -->
<rect
v-if="viewportBounds"
:x="viewportBounds.x"
:y="viewportBounds.y"
:width="viewportBounds.width"
:height="viewportBounds.height"
stroke="#00ff00"
stroke-width="3"
fill="none"
stroke-dasharray="10,5"
opacity="0.8"
/>
</svg>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Bounds } from '@/utils/spatial/QuadTree'
interface Props {
visible: boolean
debugInfo: any | null
transformStyle: any
viewportBounds?: Bounds
}
const props = defineProps<Props>()
// Flatten the tree structure for rendering
const flattenedNodes = computed(() => {
if (!props.debugInfo?.tree) return []
const nodes: any[] = []
const traverse = (node: any, depth = 0) => {
nodes.push({
bounds: node.bounds,
depth,
itemCount: node.itemCount,
divided: node.divided
})
if (node.children) {
node.children.forEach((child: any) => traverse(child, depth + 1))
}
}
traverse(props.debugInfo.tree)
return nodes
})
// SVG size (matches the transform pane size)
const svgSize = ref({ width: 20000, height: 20000 })
// Apply the same transform as the TransformPane
const svgStyle = computed(() => ({
...props.transformStyle,
position: 'absolute',
top: 0,
left: 0,
pointerEvents: 'none'
}))
// Color based on depth
const getDepthColor = (depth: number): string => {
const colors = [
'#ff6b6b', // Red
'#ffa500', // Orange
'#ffd93d', // Yellow
'#6bcf7f', // Green
'#4da6ff', // Blue
'#a78bfa' // Purple
]
return colors[depth % colors.length]
}
// Stroke width based on depth
const getStrokeWidth = (depth: number): number => {
return Math.max(0.5, 2 - depth * 0.3)
}
</script>
<style scoped>
.quadtree-visualization {
position: absolute;
overflow: visible;
z-index: 10; /* Above nodes but below UI */
}
</style>

View File

@@ -1,43 +0,0 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Button
v-bind="filteredProps"
:disabled="readonly"
size="small"
@click="handleClick"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
BADGE_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
// Button widgets don't have a v-model value, they trigger actions
const props = defineProps<{
widget: SimplifiedWidget<void>
readonly?: boolean
}>()
// Button specific excluded props
const BUTTON_EXCLUDED_PROPS = [...BADGE_EXCLUDED_PROPS, 'iconClass'] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, BUTTON_EXCLUDED_PROPS)
)
const handleClick = () => {
if (!props.readonly && props.widget.callback) {
props.widget.callback()
}
}
</script>

View File

@@ -1,78 +0,0 @@
<template>
<div class="flex flex-col gap-1">
<div
class="p-4 border border-gray-300 dark-theme:border-gray-600 rounded max-h-[48rem]"
>
<Chart :type="chartType" :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script setup lang="ts">
import type { ChartData } from 'chart.js'
import Chart from 'primevue/chart'
import { computed } from 'vue'
import type { ChartInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
type ChartWidgetOptions = NonNullable<ChartInputSpec['options']>
const value = defineModel<ChartData>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<ChartData, ChartWidgetOptions>
readonly?: boolean
}>()
const chartType = computed(() => props.widget.options?.type ?? 'line')
const chartData = computed(() => value.value || { labels: [], datasets: [] })
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: '#FFF',
usePointStyle: true,
pointStyle: 'circle'
}
}
},
scales: {
x: {
ticks: {
color: '#9FA2BD'
},
grid: {
display: true,
color: '#9FA2BD',
drawTicks: false,
drawOnChartArea: true,
drawBorder: false
},
border: {
display: true,
color: '#9FA2BD'
}
},
y: {
ticks: {
color: '#9FA2BD'
},
grid: {
display: false,
drawTicks: false,
drawOnChartArea: false,
drawBorder: false
},
border: {
display: true,
color: '#9FA2BD'
}
}
}
}))
</script>

View File

@@ -1,52 +0,0 @@
<!-- Needs custom color picker for alpha support -->
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<ColorPicker
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
inline
@update:model-value="onChange"
/>
</div>
</template>
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: '#000000',
emit
})
// ColorPicker specific excluded props include panel/overlay classes
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, COLOR_PICKER_EXCLUDED_PROPS)
)
</script>

View File

@@ -1,324 +0,0 @@
<template>
<!-- Replace entire widget with image preview when image is loaded -->
<!-- Edge-to-edge: -mx-2 removes the parent's p-2 (8px) padding on each side -->
<div
v-if="hasImageFile"
class="relative -mx-2"
style="width: calc(100% + 1rem)"
>
<!-- Select section above image -->
<div class="flex items-center justify-between gap-4 mb-2 px-2">
<label
v-if="widget.name"
class="text-xs opacity-80 min-w-[4em] truncate"
>{{ widget.name }}</label
>
<!-- Group select and folder button together on the right -->
<div class="flex items-center gap-1">
<!-- TODO: finish once we finish value bindings with Litegraph -->
<Select
:model-value="selectedFile?.name"
:options="[selectedFile?.name || '']"
:disabled="true"
class="min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
/>
<Button
icon="pi pi-folder"
size="small"
class="!w-8 !h-8"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
</div>
<!-- Image preview -->
<!-- TODO: change hardcoded colors when design system incorporated -->
<div class="relative group">
<img :src="imageUrl" :alt="selectedFile?.name" class="w-full h-auto" />
<!-- Darkening overlay on hover -->
<div
class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200 pointer-events-none"
/>
<!-- Control buttons in top right on hover -->
<div
v-if="!readonly"
class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
>
<!-- Edit button -->
<button
class="w-6 h-6 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none"
style="background-color: #262729"
@click="handleEdit"
>
<i class="pi pi-pencil text-white text-xs"></i>
</button>
<!-- Delete button -->
<button
class="w-6 h-6 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none"
style="background-color: #262729"
@click="clearFile"
>
<i class="pi pi-times text-white text-xs"></i>
</button>
</div>
</div>
</div>
<!-- Audio preview when audio file is loaded -->
<div
v-else-if="hasAudioFile"
class="relative -mx-2"
style="width: calc(100% + 1rem)"
>
<!-- Select section above audio player -->
<div class="flex items-center justify-between gap-4 mb-2 px-2">
<label
v-if="widget.name"
class="text-xs opacity-80 min-w-[4em] truncate"
>{{ widget.name }}</label
>
<!-- Group select and folder button together on the right -->
<div class="flex items-center gap-1">
<Select
:model-value="selectedFile?.name"
:options="[selectedFile?.name || '']"
:disabled="true"
class="min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
/>
<Button
icon="pi pi-folder"
size="small"
class="!w-8 !h-8"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
</div>
<!-- Audio player -->
<div class="relative group px-2">
<div
class="bg-[#1a1b1e] rounded-lg p-4 flex items-center gap-4"
style="border: 1px solid #262729"
>
<!-- Audio icon -->
<div class="flex-shrink-0">
<i class="pi pi-volume-up text-2xl opacity-60"></i>
</div>
<!-- File info and controls -->
<div class="flex-1">
<div class="text-sm font-medium mb-1">{{ selectedFile?.name }}</div>
<div class="text-xs opacity-60">
{{
selectedFile ? (selectedFile.size / 1024).toFixed(1) + ' KB' : ''
}}
</div>
</div>
<!-- Control buttons -->
<div v-if="!readonly" class="flex gap-1">
<!-- Delete button -->
<button
class="w-8 h-8 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none hover:bg-[#262729]"
@click="clearFile"
>
<i class="pi pi-times text-white text-sm"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Show normal file upload UI when no image or audio is loaded -->
<div
v-else
class="flex flex-col gap-1 w-full border border-solid p-1 rounded-lg"
:style="{ borderColor: '#262729' }"
>
<div
class="border border-dashed p-1 rounded-md transition-colors duration-200 hover:border-[#5B5E7D]"
:style="{ borderColor: '#262729' }"
>
<div class="flex flex-col items-center gap-2 w-full py-4">
<!-- Quick and dirty file type detection for testing -->
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span class="text-xs opacity-60"> Drop your file or </span>
<div>
<Button
label="Browse Files"
size="small"
class="text-xs"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
</div>
</div>
</div>
<!-- Hidden file input always available for both states -->
<input
ref="fileInputRef"
type="file"
class="hidden"
:accept="widget.options?.accept"
:multiple="false"
:disabled="readonly"
@change="handleFileChange"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Select from 'primevue/select'
import { computed, onUnmounted, ref, watch } from 'vue'
// import { useI18n } from 'vue-i18n' // Commented out for testing
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
// const { t } = useI18n() // Commented out for testing
const props = defineProps<{
widget: SimplifiedWidget<File[] | null>
modelValue: File[] | null
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: File[] | null]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: null,
emit
})
const fileInputRef = ref<HTMLInputElement | null>(null)
// Since we only support single file, get the first file
const selectedFile = computed(() => {
const files = localValue.value || []
return files.length > 0 ? files[0] : null
})
// Quick file type detection for testing
const detectFileType = (file: File) => {
const type = file.type?.toLowerCase() || ''
const name = file.name?.toLowerCase() || ''
if (
type.startsWith('image/') ||
name.match(/\.(jpg|jpeg|png|gif|webp|svg)$/)
) {
return 'image'
}
if (type.startsWith('video/') || name.match(/\.(mp4|webm|ogg|mov)$/)) {
return 'video'
}
if (type.startsWith('audio/') || name.match(/\.(mp3|wav|ogg|flac)$/)) {
return 'audio'
}
if (type === 'application/pdf' || name.endsWith('.pdf')) {
return 'pdf'
}
if (type.includes('zip') || name.match(/\.(zip|rar|7z|tar|gz)$/)) {
return 'archive'
}
return 'file'
}
// Check if we have an image file
const hasImageFile = computed(() => {
return selectedFile.value && detectFileType(selectedFile.value) === 'image'
})
// Check if we have an audio file
const hasAudioFile = computed(() => {
return selectedFile.value && detectFileType(selectedFile.value) === 'audio'
})
// Get image URL for preview
const imageUrl = computed(() => {
if (hasImageFile.value && selectedFile.value) {
return URL.createObjectURL(selectedFile.value)
}
return ''
})
// // Get audio URL for playback
// const audioUrl = computed(() => {
// if (hasAudioFile.value && selectedFile.value) {
// return URL.createObjectURL(selectedFile.value)
// }
// return ''
// })
// Clean up image URL when file changes
watch(imageUrl, (newUrl, oldUrl) => {
if (oldUrl && oldUrl !== newUrl) {
URL.revokeObjectURL(oldUrl)
}
})
const triggerFileInput = () => {
fileInputRef.value?.click()
}
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
if (!props.readonly && target.files && target.files.length > 0) {
// Since we only support single file, take the first one
const file = target.files[0]
// Use the composable's onChange handler with an array
onChange([file])
// Reset input to allow selecting same file again
target.value = ''
}
}
const clearFile = () => {
// Clear the file
onChange(null)
// Reset file input
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
const handleEdit = () => {
// TODO: hook up with maskeditor
}
// Clear file input when value is cleared externally
watch(localValue, (newValue) => {
if (!newValue || newValue.length === 0) {
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
})
// Clean up image URL on unmount
onUnmounted(() => {
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value)
}
})
</script>

View File

@@ -1,123 +0,0 @@
<template>
<div class="flex flex-col gap-1">
<Galleria
v-model:activeIndex="activeIndex"
:value="galleryImages"
v-bind="filteredProps"
:disabled="readonly"
:show-thumbnails="showThumbnails"
:show-nav-buttons="showNavButtons"
class="max-w-full"
:pt="{
thumbnails: {
class: 'overflow-hidden'
},
thumbnailContent: {
class: 'py-4 px-2'
},
thumbnailPrevButton: {
class: 'm-0'
},
thumbnailNextButton: {
class: 'm-0'
}
}"
>
<template #item="{ item }">
<img
:src="item.itemImageSrc || item.src || item"
:alt="item.alt || 'Gallery image'"
class="w-full h-auto max-h-64 object-contain"
/>
</template>
<template #thumbnail="{ item }">
<div class="p-1 w-full h-full">
<img
:src="item.thumbnailImageSrc || item.src || item"
:alt="item.alt || 'Gallery thumbnail'"
class="w-full h-full object-cover rounded-lg"
/>
</div>
</template>
</Galleria>
</div>
</template>
<script setup lang="ts">
import Galleria from 'primevue/galleria'
import { computed, ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
GALLERIA_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
interface GalleryImage {
itemImageSrc?: string
thumbnailImageSrc?: string
src?: string
alt?: string
}
type GalleryValue = string[] | GalleryImage[]
const value = defineModel<GalleryValue>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<GalleryValue>
readonly?: boolean
}>()
const activeIndex = ref(0)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, GALLERIA_EXCLUDED_PROPS)
)
const galleryImages = computed(() => {
if (!value.value || !Array.isArray(value.value)) return []
return value.value.map((item, index) => {
if (typeof item === 'string') {
return {
itemImageSrc: item,
thumbnailImageSrc: item,
alt: `Image ${index + 1}`
}
}
return item
})
})
const showThumbnails = computed(() => {
return (
props.widget.options?.showThumbnails !== false &&
galleryImages.value.length > 1
)
})
const showNavButtons = computed(() => {
return (
props.widget.options?.showNavButtons !== false &&
galleryImages.value.length > 1
)
})
</script>
<style scoped>
/* Ensure thumbnail container doesn't overflow */
:deep(.p-galleria-thumbnails) {
overflow: hidden;
}
/* Constrain thumbnail items to prevent overlap */
:deep(.p-galleria-thumbnail-item) {
flex-shrink: 0;
}
/* Ensure thumbnail wrapper maintains aspect ratio */
:deep(.p-galleria-thumbnail) {
overflow: hidden;
}
</style>

View File

@@ -1,29 +0,0 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Image v-bind="filteredProps" :src="widget.value" />
</div>
</template>
<script setup lang="ts">
import Image from 'primevue/image'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
IMAGE_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
// Image widgets typically don't have v-model, they display a source URL/path
const props = defineProps<{
widget: SimplifiedWidget<string>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, IMAGE_EXCLUDED_PROPS)
)
</script>

View File

@@ -1,70 +0,0 @@
<template>
<ImageCompare
:tabindex="widget.options?.tabindex ?? 0"
:aria-label="widget.options?.ariaLabel"
:aria-labelledby="widget.options?.ariaLabelledby"
:pt="widget.options?.pt"
:pt-options="widget.options?.ptOptions"
:unstyled="widget.options?.unstyled"
>
<template #left>
<img
:src="beforeImage"
:alt="beforeAlt"
class="w-full h-full object-cover"
/>
</template>
<template #right>
<img
:src="afterImage"
:alt="afterAlt"
class="w-full h-full object-cover"
/>
</template>
</ImageCompare>
</template>
<script setup lang="ts">
import ImageCompare from 'primevue/imagecompare'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
interface ImageCompareValue {
before: string
after: string
beforeAlt?: string
afterAlt?: string
initialPosition?: number
}
// Image compare widgets typically don't have v-model, they display comparison
const props = defineProps<{
widget: SimplifiedWidget<ImageCompareValue | string>
readonly?: boolean
}>()
const beforeImage = computed(() => {
const value = props.widget.value
return typeof value === 'string' ? value : value?.before || ''
})
const afterImage = computed(() => {
const value = props.widget.value
return typeof value === 'string' ? '' : value?.after || ''
})
const beforeAlt = computed(() => {
const value = props.widget.value
return typeof value === 'object' && value?.beforeAlt
? value.beforeAlt
: 'Before image'
})
const afterAlt = computed(() => {
const value = props.widget.value
return typeof value === 'object' && value?.afterAlt
? value.afterAlt
: 'After image'
})
</script>

View File

@@ -1,46 +0,0 @@
<template>
<WidgetLayoutField :widget="widget">
<InputText
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="w-full text-xs"
size="small"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useStringWidgetValue(
props.widget,
props.modelValue,
emit
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
</script>

View File

@@ -1,95 +0,0 @@
<template>
<div
class="widget-markdown relative w-full cursor-text"
@click="startEditing"
>
<!-- Display mode: Rendered markdown -->
<div
v-if="!isEditing"
class="comfy-markdown-content text-xs min-h-[60px] rounded-lg px-4 py-2 overflow-y-auto"
v-html="renderedHtml"
/>
<!-- Edit mode: Textarea -->
<Textarea
v-else
ref="textareaRef"
v-model="localValue"
:disabled="readonly"
class="w-full text-xs"
size="small"
rows="6"
:pt="{
root: {
onBlur: handleBlur
}
}"
@update:model-value="onChange"
@click.stop
@keydown.stop
/>
</div>
</template>
<script setup lang="ts">
import Textarea from 'primevue/textarea'
import { computed, nextTick, ref } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// State
const isEditing = ref(false)
const textareaRef = ref<InstanceType<typeof Textarea> | undefined>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useStringWidgetValue(
props.widget,
props.modelValue,
emit
)
// Computed
const renderedHtml = computed(() => {
return renderMarkdownToHtml(localValue.value || '')
})
// Methods
const startEditing = async () => {
if (props.readonly || isEditing.value) return
isEditing.value = true
await nextTick()
// Focus the textarea
// @ts-expect-error - $el is an internal property of the Textarea component
textareaRef.value?.$el?.focus()
}
const handleBlur = () => {
isEditing.value = false
}
</script>
<style scoped>
.widget-markdown {
background-color: var(--p-muted-color);
border: 1px solid var(--p-border-color);
border-radius: var(--p-border-radius);
}
.widget-markdown:hover:not(:has(textarea)) {
background-color: var(--p-content-hover-background);
}
</style>

View File

@@ -1,56 +0,0 @@
<template>
<WidgetLayoutField :widget="widget">
<MultiSelect
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="w-full text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import MultiSelect from 'primevue/multiselect'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<any[]>
modelValue: any[]
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any[]]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: [],
emit
})
// MultiSelect specific excluded props include overlay styles
const MULTISELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'overlayStyle'
] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, MULTISELECT_EXCLUDED_PROPS)
)
</script>

View File

@@ -1,72 +0,0 @@
<template>
<div
class="flex items-center justify-between gap-4"
:style="{ height: widgetHeight + 'px' }"
>
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<Select
v-model="localValue"
:options="selectOptions"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
@update:model-value="onChange"
/>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { COMFY_VUE_NODE_DIMENSIONS } from '../../../lib/litegraph/src/litegraph'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: props.widget.options?.values?.[0] || '',
emit
})
// Get widget height from litegraph constants
const widgetHeight = COMFY_VUE_NODE_DIMENSIONS.components.STANDARD_WIDGET_HEIGHT
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
)
// Extract select options from widget options
const selectOptions = computed(() => {
const options = props.widget.options
if (options?.values && Array.isArray(options.values)) {
return options.values
}
return []
})
</script>

View File

@@ -1,63 +0,0 @@
<template>
<div class="flex items-center justify-between gap-4">
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<SelectButton
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
:pt="{
pcToggleButton: {
label: 'text-xs'
}
}"
@update:model-value="onChange"
/>
</div>
</template>
<script setup lang="ts">
import SelectButton from 'primevue/selectbutton'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<any>
modelValue: any
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: null,
emit
})
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>
<style scoped>
:deep(.p-selectbutton) {
border: 1px solid transparent;
}
:deep(.p-selectbutton:hover) {
border-color: currentColor;
}
</style>

View File

@@ -1,163 +0,0 @@
<template>
<WidgetLayoutField :widget="widget">
<div class="flex items-center gap-2 w-full">
<Slider
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow text-xs"
@update:model-value="onChange"
/>
<InputText
v-model="inputDisplayValue"
:disabled="readonly"
type="number"
:min="widget.options?.min"
:max="widget.options?.max"
:step="stepValue"
class="w-[4em] text-center text-xs px-0"
size="small"
@blur="handleInputBlur"
@keydown="handleInputKeydown"
/>
</div>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import Slider from 'primevue/slider'
import { computed, ref, watch } from 'vue'
import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<number>
modelValue: number
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useNumberWidgetValue(
props.widget,
props.modelValue,
emit
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
// Get the precision value for proper number formatting
const precision = computed(() => {
const p = props.widget.options?.precision
// Treat negative or non-numeric precision as undefined
return typeof p === 'number' && p >= 0 ? p : undefined
})
// Calculate the step value based on precision or widget options
const stepValue = computed(() => {
// If step is explicitly defined in options, use it
if (props.widget.options?.step !== undefined) {
return String(props.widget.options.step)
}
// Otherwise, derive from precision
if (precision.value !== undefined) {
if (precision.value === 0) {
return '1'
}
// For precision > 0, step = 1 / (10^precision)
// precision 1 → 0.1, precision 2 → 0.01, etc.
return (1 / Math.pow(10, precision.value)).toFixed(precision.value)
}
// Default to 'any' for unrestricted stepping
return 'any'
})
// Format a number according to the widget's precision
const formatNumber = (value: number): string => {
if (precision.value === undefined) {
// No precision specified, return as-is
return String(value)
}
// Use toFixed to ensure correct decimal places
return value.toFixed(precision.value)
}
// Apply precision-based rounding to a number
const applyPrecision = (value: number): number => {
if (precision.value === undefined) {
// No precision specified, return as-is
return value
}
if (precision.value === 0) {
// Integer precision
return Math.round(value)
}
// Round to the specified decimal places
const multiplier = Math.pow(10, precision.value)
return Math.round(value * multiplier) / multiplier
}
// Keep a separate display value for the input field
const inputDisplayValue = ref(formatNumber(localValue.value))
// Update display value when localValue changes from external sources
watch(localValue, (newValue) => {
inputDisplayValue.value = formatNumber(newValue)
})
const handleInputBlur = (event: Event) => {
const target = event.target as HTMLInputElement
const value = target.value || '0'
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
// Apply precision-based rounding
const roundedValue = applyPrecision(parsed)
onChange(roundedValue)
// Update display value with proper formatting
inputDisplayValue.value = formatNumber(roundedValue)
}
}
const handleInputKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
const target = event.target as HTMLInputElement
const value = target.value || '0'
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
// Apply precision-based rounding
const roundedValue = applyPrecision(parsed)
onChange(roundedValue)
// Update display value with proper formatting
inputDisplayValue.value = formatNumber(roundedValue)
}
}
}
</script>
<style scoped>
/* Remove number input spinners */
:deep(input[type='number']::-webkit-inner-spin-button),
:deep(input[type='number']::-webkit-outer-spin-button) {
-webkit-appearance: none;
margin: 0;
}
:deep(input[type='number']) {
-moz-appearance: textfield;
appearance: textfield;
}
</style>

View File

@@ -1,44 +0,0 @@
<template>
<Textarea
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="w-full text-xs"
size="small"
rows="3"
@update:model-value="onChange"
/>
</template>
<script setup lang="ts">
import Textarea from 'primevue/textarea'
import { computed } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useStringWidgetValue(
props.widget,
props.modelValue,
emit
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
</script>

View File

@@ -1,54 +0,0 @@
<template>
<WidgetLayoutField :widget="widget">
<ToggleSwitch
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import { useBooleanWidgetValue } from '@/composables/graph/useWidgetValue'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<boolean>
modelValue: boolean
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useBooleanWidgetValue(
props.widget,
props.modelValue,
emit
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>
<style scoped>
:deep(.p-toggleswitch .p-toggleswitch-slider) {
border: 1px solid transparent;
}
:deep(.p-toggleswitch:hover .p-toggleswitch-slider) {
border-color: currentColor;
}
</style>

View File

@@ -1,54 +0,0 @@
<template>
<WidgetLayoutField :widget="widget">
<TreeSelect
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="w-full text-xs"
size="small"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import TreeSelect from 'primevue/treeselect'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<any>
modelValue: any
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: null,
emit
})
// TreeSelect specific excluded props
const TREE_SELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'inputClass',
'inputStyle'
] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, TREE_SELECT_EXCLUDED_PROPS)
)
</script>

View File

@@ -5,6 +5,7 @@
import { nextTick, reactive, readonly } from 'vue'
import { layoutMutations } from '@/renderer/core/layout/operations/LayoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { WidgetValue } from '@/types/simplifiedWidget'
import type { SpatialIndexDebugInfo } from '@/types/spatialIndex'
@@ -564,7 +565,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
let sizeUpdates = 0
// Set source for all canvas-driven updates
layoutMutations.setSource('canvas')
layoutMutations.setSource(LayoutSource.Canvas)
// Process each node for changes
for (const node of graph._nodes) {
@@ -624,7 +625,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
spatialIndex.insert(id, bounds, id)
// Add node to layout store
layoutMutations.setSource('canvas')
layoutMutations.setSource(LayoutSource.Canvas)
void layoutMutations.createNode(id, {
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
@@ -651,7 +652,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
spatialIndex.remove(id)
// Remove node from layout store
layoutMutations.setSource('canvas')
layoutMutations.setSource(LayoutSource.Canvas)
void layoutMutations.deleteNode(id)
// Clean up all tracking references

View File

@@ -1,186 +0,0 @@
/**
* Level of Detail (LOD) composable for Vue-based node rendering
*
* Provides dynamic quality adjustment based on zoom level to maintain
* performance with large node graphs. Uses zoom thresholds to determine
* how much detail to render for each node component.
*
* ## LOD Levels
*
* - **FULL** (zoom > 0.8): Complete rendering with all widgets, slots, and content
* - **REDUCED** (0.4 < zoom <= 0.8): Essential widgets only, simplified slots
* - **MINIMAL** (zoom <= 0.4): Title only, no widgets or slots
*
* ## Performance Benefits
*
* - Reduces DOM element count by up to 80% at low zoom levels
* - Minimizes layout calculations and paint operations
* - Enables smooth performance with 1000+ nodes
* - Maintains visual fidelity when detail is actually visible
*
* @example
* ```typescript
* const { lodLevel, shouldRenderWidgets, shouldRenderSlots } = useLOD(zoomRef)
*
* // In template
* <NodeWidgets v-if="shouldRenderWidgets" />
* <NodeSlots v-if="shouldRenderSlots" />
* ```
*/
import { type Ref, computed, readonly } from 'vue'
export enum LODLevel {
MINIMAL = 'minimal', // zoom <= 0.4
REDUCED = 'reduced', // 0.4 < zoom <= 0.8
FULL = 'full' // zoom > 0.8
}
export interface LODConfig {
renderWidgets: boolean
renderSlots: boolean
renderContent: boolean
renderSlotLabels: boolean
renderWidgetLabels: boolean
cssClass: string
}
// LOD configuration for each level
const LOD_CONFIGS: Record<LODLevel, LODConfig> = {
[LODLevel.FULL]: {
renderWidgets: true,
renderSlots: true,
renderContent: true,
renderSlotLabels: true,
renderWidgetLabels: true,
cssClass: 'lg-node--lod-full'
},
[LODLevel.REDUCED]: {
renderWidgets: true,
renderSlots: true,
renderContent: false,
renderSlotLabels: false,
renderWidgetLabels: false,
cssClass: 'lg-node--lod-reduced'
},
[LODLevel.MINIMAL]: {
renderWidgets: false,
renderSlots: false,
renderContent: false,
renderSlotLabels: false,
renderWidgetLabels: false,
cssClass: 'lg-node--lod-minimal'
}
}
/**
* Create LOD (Level of Detail) state based on zoom level
*
* @param zoomRef - Reactive reference to current zoom level (camera.z)
* @returns LOD state and configuration
*/
export function useLOD(zoomRef: Ref<number>) {
// Continuous LOD score (0-1) for smooth transitions
const lodScore = computed(() => {
const zoom = zoomRef.value
return Math.max(0, Math.min(1, zoom))
})
// Determine current LOD level based on zoom
const lodLevel = computed<LODLevel>(() => {
const zoom = zoomRef.value
if (zoom > 0.8) return LODLevel.FULL
if (zoom > 0.4) return LODLevel.REDUCED
return LODLevel.MINIMAL
})
// Get configuration for current LOD level
const lodConfig = computed<LODConfig>(() => LOD_CONFIGS[lodLevel.value])
// Convenience computed properties for common rendering decisions
const shouldRenderWidgets = computed(() => lodConfig.value.renderWidgets)
const shouldRenderSlots = computed(() => lodConfig.value.renderSlots)
const shouldRenderContent = computed(() => lodConfig.value.renderContent)
const shouldRenderSlotLabels = computed(
() => lodConfig.value.renderSlotLabels
)
const shouldRenderWidgetLabels = computed(
() => lodConfig.value.renderWidgetLabels
)
// CSS class for styling based on LOD level
const lodCssClass = computed(() => lodConfig.value.cssClass)
// Get essential widgets for reduced LOD (only interactive controls)
const getEssentialWidgets = (widgets: unknown[]): unknown[] => {
if (lodLevel.value === LODLevel.FULL) return widgets
if (lodLevel.value === LODLevel.MINIMAL) return []
// For reduced LOD, filter to essential widget types only
return widgets.filter((widget: any) => {
const type = widget?.type?.toLowerCase()
return [
'combo',
'select',
'toggle',
'boolean',
'slider',
'number'
].includes(type)
})
}
// Performance metrics for debugging
const lodMetrics = computed(() => ({
level: lodLevel.value,
zoom: zoomRef.value,
widgetCount: shouldRenderWidgets.value ? 'full' : 'none',
slotCount: shouldRenderSlots.value ? 'full' : 'none'
}))
return {
// Core LOD state
lodLevel: readonly(lodLevel),
lodConfig: readonly(lodConfig),
lodScore: readonly(lodScore),
// Rendering decisions
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
shouldRenderSlotLabels,
shouldRenderWidgetLabels,
// Styling
lodCssClass,
// Utilities
getEssentialWidgets,
lodMetrics
}
}
/**
* Get LOD level thresholds for configuration or debugging
*/
export const LOD_THRESHOLDS = {
FULL_THRESHOLD: 0.8,
REDUCED_THRESHOLD: 0.4,
MINIMAL_THRESHOLD: 0.0
} as const
/**
* Check if zoom level supports a specific feature
*/
export function supportsFeatureAtZoom(
zoom: number,
feature: keyof LODConfig
): boolean {
const level =
zoom > 0.8
? LODLevel.FULL
: zoom > 0.4
? LODLevel.REDUCED
: LODLevel.MINIMAL
return LOD_CONFIGS[level][feature] as boolean
}

View File

@@ -1,180 +0,0 @@
/**
* Node Change Detection
*
* RAF-based change detection for node positions and sizes.
* Syncs LiteGraph changes to the layout system.
*/
import { reactive } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { layoutMutations } from '@/renderer/core/layout/operations/LayoutMutations'
export interface ChangeDetectionMetrics {
updateTime: number
positionUpdates: number
sizeUpdates: number
rafUpdateCount: number
}
/**
* Change detection for node geometry
*/
export function useNodeChangeDetection(graph: LGraph) {
const metrics = reactive<ChangeDetectionMetrics>({
updateTime: 0,
positionUpdates: 0,
sizeUpdates: 0,
rafUpdateCount: 0
})
// Track last known positions/sizes
const lastSnapshot = new Map<
string,
{ pos: [number, number]; size: [number, number] }
>()
/**
* Detects position changes for a single node
*/
const detectPositionChanges = (
node: LGraphNode,
nodePositions: Map<string, { x: number; y: number }>
): boolean => {
const id = String(node.id)
const currentPos = nodePositions.get(id)
if (
!currentPos ||
currentPos.x !== node.pos[0] ||
currentPos.y !== node.pos[1]
) {
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
// Push position change to layout store
void layoutMutations.moveNode(id, { x: node.pos[0], y: node.pos[1] })
return true
}
return false
}
/**
* Detects size changes for a single node
*/
const detectSizeChanges = (
node: LGraphNode,
nodeSizes: Map<string, { width: number; height: number }>
): boolean => {
const id = String(node.id)
const currentSize = nodeSizes.get(id)
if (
!currentSize ||
currentSize.width !== node.size[0] ||
currentSize.height !== node.size[1]
) {
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
// Push size change to layout store
void layoutMutations.resizeNode(id, {
width: node.size[0],
height: node.size[1]
})
return true
}
return false
}
/**
* Main RAF change detection function
*/
const detectChanges = (
nodePositions: Map<string, { x: number; y: number }>,
nodeSizes: Map<string, { width: number; height: number }>,
onSpatialChange?: (node: LGraphNode, id: string) => void
) => {
const startTime = performance.now()
if (!graph?._nodes) return
let positionUpdates = 0
let sizeUpdates = 0
// Set source for all canvas-driven updates
layoutMutations.setSource('canvas')
// Process each node for changes
for (const node of graph._nodes) {
const id = String(node.id)
const posChanged = detectPositionChanges(node, nodePositions)
const sizeChanged = detectSizeChanges(node, nodeSizes)
if (posChanged) positionUpdates++
if (sizeChanged) sizeUpdates++
// Notify spatial change if needed
if ((posChanged || sizeChanged) && onSpatialChange) {
onSpatialChange(node, id)
}
}
// Update metrics
const endTime = performance.now()
metrics.updateTime = endTime - startTime
metrics.positionUpdates = positionUpdates
metrics.sizeUpdates = sizeUpdates
if (positionUpdates > 0 || sizeUpdates > 0) {
metrics.rafUpdateCount++
}
}
/**
* Take a snapshot of current node positions/sizes
*/
const takeSnapshot = () => {
if (!graph?._nodes) return
lastSnapshot.clear()
for (const node of graph._nodes) {
lastSnapshot.set(String(node.id), {
pos: [node.pos[0], node.pos[1]],
size: [node.size[0], node.size[1]]
})
}
}
/**
* Check if any nodes have changed since last snapshot
*/
const hasChangedSinceSnapshot = (): boolean => {
if (!graph?._nodes) return false
for (const node of graph._nodes) {
const id = String(node.id)
const last = lastSnapshot.get(id)
if (!last) continue
if (
last.pos[0] !== node.pos[0] ||
last.pos[1] !== node.pos[1] ||
last.size[0] !== node.size[0] ||
last.size[1] !== node.size[1]
) {
return true
}
}
return false
}
return {
metrics,
detectChanges,
detectPositionChanges,
detectSizeChanges,
takeSnapshot,
hasChangedSinceSnapshot
}
}

View File

@@ -1,260 +0,0 @@
/**
* Node State Management
*
* Manages node visibility, dirty state, and other UI state.
* Provides reactive state for Vue components.
*/
import { nextTick, reactive, readonly } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { PERFORMANCE_CONFIG } from '@/renderer/core/layout/constants'
import type { SafeWidgetData, VueNodeData, WidgetValue } from './useNodeWidgets'
export interface NodeState {
visible: boolean
dirty: boolean
lastUpdate: number
culled: boolean
}
export interface NodeMetadata {
lastRenderTime: number
cachedBounds: DOMRect | null
lodLevel: 'high' | 'medium' | 'low'
}
/**
* Extract safe Vue data from LiteGraph node
*/
export function extractVueNodeData(
node: LGraphNode,
widgets?: SafeWidgetData[]
): VueNodeData {
return {
id: String(node.id),
title: node.title || 'Untitled',
type: node.type || 'Unknown',
mode: node.mode || 0,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
widgets,
inputs: node.inputs ? [...node.inputs] : undefined,
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined
}
}
/**
* Node state management composable
*/
export function useNodeState() {
// Reactive state maps
const vueNodeData = reactive(new Map<string, VueNodeData>())
const nodeState = reactive(new Map<string, NodeState>())
const nodePositions = reactive(new Map<string, { x: number; y: number }>())
const nodeSizes = reactive(
new Map<string, { width: number; height: number }>()
)
// Non-reactive node references
const nodeRefs = new Map<string, LGraphNode>()
// WeakMap for heavy metadata that auto-GCs
const nodeMetadata = new WeakMap<LGraphNode, NodeMetadata>()
// Update batching
const pendingUpdates = new Set<string>()
const criticalUpdates = new Set<string>()
const lowPriorityUpdates = new Set<string>()
let updateScheduled = false
let batchTimeoutId: number | null = null
/**
* Attach metadata to a node
*/
const attachMetadata = (node: LGraphNode) => {
nodeMetadata.set(node, {
lastRenderTime: performance.now(),
cachedBounds: null,
lodLevel: 'high'
})
}
/**
* Get access to original LiteGraph node
*/
const getNode = (id: string): LGraphNode | undefined => {
return nodeRefs.get(id)
}
/**
* Schedule an update for a node
*/
const scheduleUpdate = (
nodeId?: string,
priority: 'critical' | 'normal' | 'low' = 'normal'
) => {
if (nodeId) {
const state = nodeState.get(nodeId)
if (state) state.dirty = true
// Priority queuing
if (priority === 'critical') {
criticalUpdates.add(nodeId)
flush() // Immediate flush for critical updates
return
} else if (priority === 'low') {
lowPriorityUpdates.add(nodeId)
} else {
pendingUpdates.add(nodeId)
}
}
if (!updateScheduled) {
updateScheduled = true
// Adaptive batching strategy
if (pendingUpdates.size > 10) {
// Many updates - batch in nextTick
void nextTick(() => flush())
} else {
// Few updates - small delay for more batching
batchTimeoutId = window.setTimeout(
() => flush(),
PERFORMANCE_CONFIG.BATCH_UPDATE_DELAY
)
}
}
}
/**
* Flush all pending updates
*/
const flush = () => {
if (batchTimeoutId !== null) {
clearTimeout(batchTimeoutId)
batchTimeoutId = null
}
// Clear all pending updates
criticalUpdates.clear()
pendingUpdates.clear()
lowPriorityUpdates.clear()
updateScheduled = false
// Trigger any additional update logic here
}
/**
* Initialize node state
*/
const initializeNode = (node: LGraphNode, vueData: VueNodeData): void => {
const id = String(node.id)
// Store references
nodeRefs.set(id, node)
vueNodeData.set(id, vueData)
// Initialize state
nodeState.set(id, {
visible: true,
dirty: false,
lastUpdate: performance.now(),
culled: false
})
// Initialize position and size
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
// Attach metadata
attachMetadata(node)
}
/**
* Clean up node state
*/
const cleanupNode = (nodeId: string): void => {
nodeRefs.delete(nodeId)
vueNodeData.delete(nodeId)
nodeState.delete(nodeId)
nodePositions.delete(nodeId)
nodeSizes.delete(nodeId)
}
/**
* Update node property
*/
const updateNodeProperty = (
nodeId: string,
property: string,
value: unknown
): void => {
const currentData = vueNodeData.get(nodeId)
if (!currentData) return
if (property === 'title') {
vueNodeData.set(nodeId, {
...currentData,
title: String(value)
})
} else if (property === 'flags.collapsed') {
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
collapsed: Boolean(value)
}
})
}
}
/**
* Update widget state
*/
const updateWidgetState = (
nodeId: string,
widgetName: string,
value: unknown
): void => {
const currentData = vueNodeData.get(nodeId)
if (!currentData?.widgets) return
const updatedWidgets = currentData.widgets.map((w) =>
w.name === widgetName ? { ...w, value: value as WidgetValue } : w
)
vueNodeData.set(nodeId, {
...currentData,
widgets: updatedWidgets
})
}
return {
// State maps (read-only)
vueNodeData: readonly(vueNodeData) as ReadonlyMap<string, VueNodeData>,
nodeState: readonly(nodeState) as ReadonlyMap<string, NodeState>,
nodePositions: readonly(nodePositions) as ReadonlyMap<
string,
{ x: number; y: number }
>,
nodeSizes: readonly(nodeSizes) as ReadonlyMap<
string,
{ width: number; height: number }
>,
// Methods
getNode,
attachMetadata,
scheduleUpdate,
flush,
initializeNode,
cleanupNode,
updateNodeProperty,
updateWidgetState,
// Mutable access for internal use
_mutableNodePositions: nodePositions,
_mutableNodeSizes: nodeSizes
}
}

View File

@@ -1,182 +0,0 @@
/**
* Node Widget Management
*
* Handles widget state synchronization between LiteGraph and Vue.
* Provides wrapped callbacks to maintain consistency.
*/
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { WidgetValue } from '@/types/simplifiedWidget'
export type { WidgetValue }
export interface SafeWidgetData {
name: string
type: string
value: WidgetValue
options?: Record<string, unknown>
callback?: ((value: unknown) => void) | undefined
}
export interface VueNodeData {
id: string
title: string
type: string
mode: number
selected: boolean
executing: boolean
widgets?: SafeWidgetData[]
inputs?: unknown[]
outputs?: unknown[]
flags?: {
collapsed?: boolean
}
}
/**
* Validates that a value is a valid WidgetValue type
*/
export function 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.every((item) => item instanceof File)) {
return value as File[]
}
// Otherwise it's a generic object
return value as object
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
/**
* Extract safe widget data from LiteGraph widgets
*/
export function extractWidgetData(
widgets?: any[]
): SafeWidgetData[] | undefined {
if (!widgets) return undefined
return widgets.map((widget) => {
try {
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]
}
return {
name: widget.name,
type: widget.type,
value: validateWidgetValue(value),
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined,
options: undefined,
callback: undefined
}
}
})
}
/**
* Widget callback management for LiteGraph/Vue sync
*/
export function useNodeWidgets() {
/**
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
*/
const createWrappedCallback = (
widget: { value?: unknown; name: string },
originalCallback: ((value: unknown) => void) | undefined,
nodeId: string,
onUpdate: (nodeId: string, widgetName: string, value: unknown) => void
) => {
let updateInProgress = false
return (value: unknown) => {
if (updateInProgress) return
updateInProgress = true
try {
// 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
// Call the original callback if it exists
if (originalCallback) {
originalCallback.call(widget, value)
}
// Update Vue state to maintain synchronization
onUpdate(nodeId, widget.name, value)
} finally {
updateInProgress = false
}
}
}
/**
* Sets up widget callbacks for a node
*/
const setupNodeWidgetCallbacks = (
node: LGraphNode,
onUpdate: (nodeId: string, widgetName: string, value: unknown) => void
) => {
if (!node.widgets) return
const nodeId = String(node.id)
node.widgets.forEach((widget) => {
const originalCallback = widget.callback
widget.callback = createWrappedCallback(
widget,
originalCallback,
nodeId,
onUpdate
)
})
}
return {
validateWidgetValue,
extractWidgetData,
createWrappedCallback,
setupNodeWidgetCallbacks
}
}

View File

@@ -1,117 +0,0 @@
/**
* Widget renderer composable for Vue node system
* Maps LiteGraph widget types to Vue components
*/
import {
WidgetType,
widgetTypeToComponent
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
/**
* Static mapping of LiteGraph widget types to Vue widget component names
* Moved outside function to prevent recreation on every call
*/
const TYPE_TO_ENUM_MAP: Record<string, string> = {
// Number inputs
number: WidgetType.NUMBER,
slider: WidgetType.SLIDER,
INT: WidgetType.INT,
FLOAT: WidgetType.FLOAT,
// Text inputs
text: WidgetType.STRING,
string: WidgetType.STRING,
STRING: WidgetType.STRING,
// Selection
combo: WidgetType.COMBO,
COMBO: WidgetType.COMBO,
// Boolean
toggle: WidgetType.TOGGLESWITCH,
boolean: WidgetType.BOOLEAN,
BOOLEAN: WidgetType.BOOLEAN,
// Multiline text
multiline: WidgetType.TEXTAREA,
textarea: WidgetType.TEXTAREA,
customtext: WidgetType.TEXTAREA,
MARKDOWN: WidgetType.MARKDOWN,
// Advanced widgets
color: WidgetType.COLOR,
COLOR: WidgetType.COLOR,
image: WidgetType.IMAGE,
IMAGE: WidgetType.IMAGE,
imagecompare: WidgetType.IMAGECOMPARE,
IMAGECOMPARE: WidgetType.IMAGECOMPARE,
galleria: WidgetType.GALLERIA,
GALLERIA: WidgetType.GALLERIA,
file: WidgetType.FILEUPLOAD,
fileupload: WidgetType.FILEUPLOAD,
FILEUPLOAD: WidgetType.FILEUPLOAD,
// Button widget
button: WidgetType.BUTTON,
BUTTON: WidgetType.BUTTON,
// Chart widget
chart: WidgetType.CHART,
CHART: WidgetType.CHART
} as const
/**
* Pre-computed widget support map for O(1) lookups
* Maps widget type directly to boolean for fast shouldRenderAsVue checks
*/
const WIDGET_SUPPORT_MAP = new Map(
Object.entries(TYPE_TO_ENUM_MAP).map(([type, enumValue]) => [
type,
widgetTypeToComponent[enumValue] !== undefined
])
)
export const ESSENTIAL_WIDGET_TYPES = new Set([
'combo',
'COMBO',
'select',
'toggle',
'boolean',
'BOOLEAN',
'slider',
'number',
'INT',
'FLOAT'
])
export const useWidgetRenderer = () => {
const getWidgetComponent = (widgetType: string): string => {
const enumKey = TYPE_TO_ENUM_MAP[widgetType]
if (enumKey && widgetTypeToComponent[enumKey]) {
return enumKey
}
return WidgetType.STRING
}
const shouldRenderAsVue = (widget: {
type?: string
options?: Record<string, unknown>
}): boolean => {
if (widget.options?.canvasOnly) return false
if (!widget.type) return false
// Check if widget type is explicitly supported
const isSupported = WIDGET_SUPPORT_MAP.get(widget.type)
if (isSupported !== undefined) return isSupported
// Fallback: unknown types are rendered as STRING widget
return widgetTypeToComponent[WidgetType.STRING] !== undefined
}
return {
getWidgetComponent,
shouldRenderAsVue
}
}

View File

@@ -1,33 +0,0 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
type InputSpec,
isBooleanInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useBooleanWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isBooleanInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const defaultVal = inputSpec.default ?? false
const options = {
on: inputSpec.label_on,
off: inputSpec.label_off
}
return node.addWidget(
'toggle',
inputSpec.name,
defaultVal,
() => {},
options
)
}
return widgetConstructor
}

View File

@@ -1,29 +0,0 @@
import {
type ChartInputSpec,
type InputSpec as InputSpecV2,
isChartInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { IChartWidget } from '../../lib/litegraph/src/types/widgets'
export const useChartWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IChartWidget => {
if (!isChartInputSpec(inputSpec)) {
throw new Error('Invalid input spec for chart widget')
}
const { name, options = {} } = inputSpec as ChartInputSpec
const chartType = options.type || 'line'
const widget = node.addWidget('chart', name, options.data || {}, () => {}, {
serialize: true,
type: chartType,
...options
}) as IChartWidget
return widget
}
}

View File

@@ -1,52 +0,0 @@
import { ref } from 'vue'
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
ComponentWidgetImpl,
type ComponentWidgetStandardProps,
addWidget
} from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
type ChatHistoryCustomProps = Omit<
InstanceType<typeof ChatHistoryWidget>['$props'],
ComponentWidgetStandardProps
>
const PADDING = 16
export const useChatHistoryWidget = (
options: {
props?: ChatHistoryCustomProps
} = {}
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
ChatHistoryCustomProps
>({
node,
name: inputSpec.name,
component: ChatHistoryWidget,
props: options.props,
inputSpec,
options: {
getValue: () => widgetValue.value,
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => 400 + PADDING
}
})
addWidget(node, widget)
return widget
}
return widgetConstructor
}

View File

@@ -1,21 +0,0 @@
import type {
ColorInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { IColorWidget } from '../../lib/litegraph/src/types/widgets'
export const useColorWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IColorWidget => {
const { name, options } = inputSpec as ColorInputSpec
const defaultValue = options?.default || '#000000'
const widget = node.addWidget('color', name, defaultValue, () => {}, {
serialize: true
}) as IColorWidget
return widget
}
}

View File

@@ -1,111 +0,0 @@
import { ref } from 'vue'
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import {
ComboInputSpec,
type InputSpec,
isComboInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
type BaseDOMWidget,
ComponentWidgetImpl,
addWidget
} from '@/scripts/domWidget'
import {
type ComfyWidgetConstructorV2,
addValueControlWidgets
} from '@/scripts/widgets'
import { useRemoteWidget } from './useRemoteWidget'
const getDefaultValue = (inputSpec: ComboInputSpec) => {
if (inputSpec.default) return inputSpec.default
if (inputSpec.options?.length) return inputSpec.options[0]
if (inputSpec.remote) return 'Loading...'
return undefined
}
const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const widgetValue = ref<string[]>([])
const widget = new ComponentWidgetImpl({
node,
name: inputSpec.name,
component: MultiSelectWidget,
inputSpec,
options: {
getValue: () => widgetValue.value,
setValue: (value: string[]) => {
widgetValue.value = value
}
}
})
addWidget(node, widget as BaseDOMWidget<object | string>)
// TODO: Add remote support to multi-select widget
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
return widget
}
const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const defaultValue = getDefaultValue(inputSpec)
const comboOptions = inputSpec.options ?? []
const widget = node.addWidget(
'combo',
inputSpec.name,
defaultValue,
() => {},
{
values: comboOptions
}
) as IComboWidget
if (inputSpec.remote) {
const remoteWidget = useRemoteWidget({
remoteConfig: inputSpec.remote,
defaultValue,
node,
widget
})
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
const origOptions = widget.options
widget.options = new Proxy(origOptions, {
get(target, prop) {
// Assertion: Proxy handler passthrough
return prop !== 'values'
? target[prop as keyof typeof target]
: remoteWidget.getValue()
}
})
}
if (inputSpec.control_after_generate) {
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
return widget
}
export const useComboWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isComboInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
return inputSpec.multi_select
? addMultiSelectWidget(node, inputSpec)
: addComboWidget(node, inputSpec)
}
return widgetConstructor
}

View File

@@ -1,21 +0,0 @@
import type {
FileUploadInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { IFileUploadWidget } from '../../lib/litegraph/src/types/widgets'
export const useFileUploadWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IFileUploadWidget => {
const { name, options = {} } = inputSpec as FileUploadInputSpec
const widget = node.addWidget('fileupload', name, '', () => {}, {
serialize: true,
...(options as Record<string, unknown>)
}) as IFileUploadWidget
return widget
}
}

View File

@@ -1,81 +0,0 @@
import _ from 'es-toolkit/compat'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import {
type InputSpec,
isFloatInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
function onFloatValueChange(this: INumericWidget, v: number) {
const round = this.options.round
if (round) {
const precision =
this.options.precision ?? Math.max(0, -Math.floor(Math.log10(round)))
const rounded = Math.round(v / round) * round
this.value = _.clamp(
Number(rounded.toFixed(precision)),
this.options.min ?? -Infinity,
this.options.max ?? Infinity
)
} else {
this.value = v
}
}
export const _for_testing = {
onFloatValueChange
}
export const useFloatWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isFloatInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const settingStore = useSettingStore()
const sliderEnabled = !settingStore.get('Comfy.DisableSliders')
const display_type = inputSpec.display
const widgetType =
sliderEnabled && display_type == 'slider'
? 'slider'
: display_type == 'knob'
? 'knob'
: 'number'
const step = inputSpec.step ?? 0.5
const precision =
settingStore.get('Comfy.FloatRoundingPrecision') ||
Math.max(0, -Math.floor(Math.log10(step)))
const enableRounding = !settingStore.get('Comfy.DisableFloatRounding')
/** Assertion {@link inputSpec.default} */
const defaultValue = (inputSpec.default as number | undefined) ?? 0
return node.addWidget(
widgetType,
inputSpec.name,
defaultValue,
onFloatValueChange,
{
min: inputSpec.min ?? 0,
max: inputSpec.max ?? 2048,
round:
enableRounding && precision && !inputSpec.round
? Math.pow(10, -precision)
: (inputSpec.round as number),
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
step: step * 10.0,
step2: step,
precision
}
)
}
return widgetConstructor
}

View File

@@ -1,27 +0,0 @@
import type {
GalleriaInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { IGalleriaWidget } from '../../lib/litegraph/src/types/widgets'
export const useGalleriaWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IGalleriaWidget => {
const { name, options = {} } = inputSpec as GalleriaInputSpec
const widget = node.addWidget(
'galleria',
name,
options.images || [],
() => {},
{
serialize: true,
...options
}
) as IGalleriaWidget
return widget
}
}

View File

@@ -1,21 +0,0 @@
import type {
ImageCompareInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { IImageCompareWidget } from '../../lib/litegraph/src/types/widgets'
export const useImageCompareWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IImageCompareWidget => {
const { name, options = {} } = inputSpec as ImageCompareInputSpec
const widget = node.addWidget('imagecompare', name, ['', ''], () => {}, {
serialize: true,
...options
}) as IImageCompareWidget
return widget
}
}

View File

@@ -1,317 +0,0 @@
import {
BaseWidget,
type CanvasPointer,
type LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
const renderPreview = (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
shiftY: number
) => {
const canvas = useCanvasStore().getCanvas()
const mouse = canvas.graph_mouse
if (!canvas.pointer_is_down && node.pointerDown) {
if (
mouse[0] === node.pointerDown.pos[0] &&
mouse[1] === node.pointerDown.pos[1]
) {
node.imageIndex = node.pointerDown.index
}
node.pointerDown = null
}
const imgs = node.imgs ?? []
let { imageIndex } = node
const numImages = imgs.length
if (numImages === 1 && !imageIndex) {
// This skips the thumbnail render section below
node.imageIndex = imageIndex = 0
}
const settingStore = useSettingStore()
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
const dw = node.size[0]
const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT
if (imageIndex == null) {
// No image selected; draw thumbnails of all
let cellWidth: number
let cellHeight: number
let shiftX: number
let cell_padding: number
let cols: number
const compact_mode = is_all_same_aspect_ratio(imgs)
if (!compact_mode) {
// use rectangle cell style and border line
cell_padding = 2
// Prevent infinite canvas2d scale-up
const largestDimension = imgs.reduce(
(acc, current) =>
Math.max(acc, current.naturalWidth, current.naturalHeight),
0
)
const fakeImgs = []
fakeImgs.length = imgs.length
fakeImgs[0] = {
naturalWidth: largestDimension,
naturalHeight: largestDimension
}
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
fakeImgs,
dw,
dh
))
} else {
cell_padding = 0
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
imgs,
dw,
dh
))
}
let anyHovered = false
node.imageRects = []
for (let i = 0; i < numImages; i++) {
const img = imgs[i]
const row = Math.floor(i / cols)
const col = i % cols
const x = col * cellWidth + shiftX
const y = row * cellHeight + shiftY
if (!anyHovered) {
anyHovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + node.pos[0],
y + node.pos[1],
cellWidth,
cellHeight
)
if (anyHovered) {
node.overIndex = i
let value = 110
if (canvas.pointer_is_down) {
if (!node.pointerDown || node.pointerDown.index !== i) {
node.pointerDown = { index: i, pos: [...mouse] }
}
value = 125
}
ctx.filter = `contrast(${value}%) brightness(${value}%)`
canvas.canvas.style.cursor = 'pointer'
}
}
node.imageRects.push([x, y, cellWidth, cellHeight])
const wratio = cellWidth / img.width
const hratio = cellHeight / img.height
const ratio = Math.min(wratio, hratio)
const imgHeight = ratio * img.height
const imgY = row * cellHeight + shiftY + (cellHeight - imgHeight) / 2
const imgWidth = ratio * img.width
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
ctx.drawImage(
img,
imgX + cell_padding,
imgY + cell_padding,
imgWidth - cell_padding * 2,
imgHeight - cell_padding * 2
)
if (!compact_mode) {
// rectangle cell and border line style
ctx.strokeStyle = '#8F8F8F'
ctx.lineWidth = 1
ctx.strokeRect(
x + cell_padding,
y + cell_padding,
cellWidth - cell_padding * 2,
cellHeight - cell_padding * 2
)
}
ctx.filter = 'none'
}
if (!anyHovered) {
node.pointerDown = null
node.overIndex = null
}
return
}
// Draw individual
const img = imgs[imageIndex]
let w = img.naturalWidth
let h = img.naturalHeight
const scaleX = dw / w
const scaleY = dh / h
const scale = Math.min(scaleX, scaleY, 1)
w *= scale
h *= scale
const x = (dw - w) / 2
const y = (dh - h) / 2 + shiftY
ctx.drawImage(img, x, y, w, h)
// Draw image size text below the image
if (allowImageSizeDraw) {
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
ctx.textAlign = 'center'
ctx.font = '10px sans-serif'
const sizeText = `${Math.round(img.naturalWidth)} × ${Math.round(img.naturalHeight)}`
const textY = y + h + 10
ctx.fillText(sizeText, x + w / 2, textY)
}
const drawButton = (
x: number,
y: number,
sz: number,
text: string
): boolean => {
const hovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + node.pos[0],
y + node.pos[1],
sz,
sz
)
let fill = '#333'
let textFill = '#fff'
let isClicking = false
if (hovered) {
canvas.canvas.style.cursor = 'pointer'
if (canvas.pointer_is_down) {
fill = '#1e90ff'
isClicking = true
} else {
fill = '#eee'
textFill = '#000'
}
}
ctx.fillStyle = fill
ctx.beginPath()
ctx.roundRect(x, y, sz, sz, [4])
ctx.fill()
ctx.fillStyle = textFill
ctx.font = '12px Arial'
ctx.textAlign = 'center'
ctx.fillText(text, x + 15, y + 20)
return isClicking
}
if (!(numImages > 1)) return
const imageNum = (node.imageIndex ?? 0) + 1
if (drawButton(dw - 40, dh + shiftY - 40, 30, `${imageNum}/${numImages}`)) {
const i = imageNum >= numImages ? 0 : imageNum
if (!node.pointerDown || node.pointerDown.index !== i) {
node.pointerDown = { index: i, pos: [...mouse] }
}
}
if (drawButton(dw - 40, shiftY + 10, 30, `x`)) {
if (!node.pointerDown || node.pointerDown.index !== null) {
node.pointerDown = { index: null, pos: [...mouse] }
}
}
}
class ImagePreviewWidget extends BaseWidget {
constructor(
node: LGraphNode,
name: string,
options: IWidgetOptions<string | object>
) {
const widget: IBaseWidget = {
name,
options,
type: 'custom',
/** Dummy value to satisfy type requirements. */
value: '',
y: 0
}
super(widget, node)
// Don't serialize the widget value
this.serialize = false
}
override drawWidget(ctx: CanvasRenderingContext2D): void {
renderPreview(ctx, this.node, this.y)
}
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
pointer.onDragStart = () => {
const { canvas } = app
const { graph } = canvas
canvas.emitBeforeChange()
graph?.beforeChange()
// Ensure that dragging is properly cleaned up, on success or failure.
pointer.finally = () => {
canvas.isDragging = false
graph?.afterChange()
canvas.emitAfterChange()
}
canvas.processSelect(node, pointer.eDown)
canvas.isDragging = true
}
pointer.onDragEnd = (e) => {
const { canvas } = app
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
canvas.graph?.snapToGrid(canvas.selectedItems)
canvas.setDirty(true, true)
}
return true
}
override onClick(): void {}
override computeLayoutSize() {
return {
minHeight: 220,
minWidth: 1
}
}
}
export const useImagePreviewWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
return node.addCustomWidget(
new ImagePreviewWidget(node, inputSpec.name, {
serialize: false
})
)
}
return widgetConstructor
}

View File

@@ -1,121 +0,0 @@
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
import { useValueTransform } from '@/composables/useValueTransform'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { isImageUploadInput } from '@/types/nodeDefAugmentation'
import { createAnnotatedPath } from '@/utils/formatUtil'
import { addToComboValues } from '@/utils/litegraphUtil'
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
type InternalFile = string | ResultItem
type InternalValue = InternalFile | InternalFile[]
type ExposedValue = string | string[]
const isImageFile = (file: File) => file.type.startsWith('image/')
const isVideoFile = (file: File) => file.type.startsWith('video/')
const findFileComboWidget = (node: LGraphNode, inputName: string) =>
node.widgets!.find((w) => w.name === inputName) as IComboWidget & {
value: ExposedValue
}
export const useImageUploadWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
node: LGraphNode,
inputName: string,
inputData: InputSpec
) => {
if (!isImageUploadInput(inputData)) {
throw new Error(
'Image upload widget requires imageInputName augmentation'
)
}
const inputOptions = inputData[1]
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
const folder: ResultItemType | undefined = image_folder
const nodeOutputStore = useNodeOutputStore()
const isAnimated = !!inputOptions.animated_image_upload
const isVideo = !!inputOptions.video_upload
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
const fileFilter = isVideo ? isVideoFile : isImageFile
const fileComboWidget = findFileComboWidget(node, imageInputName)
const initialFile = `${fileComboWidget.value}`
const formatPath = (value: InternalFile) =>
createAnnotatedPath(value, { rootFolder: image_folder })
const transform = (internalValue: InternalValue): ExposedValue => {
if (!internalValue) return initialFile
if (Array.isArray(internalValue))
return allow_batch
? internalValue.map(formatPath)
: formatPath(internalValue[0])
return formatPath(internalValue)
}
Object.defineProperty(
fileComboWidget,
'value',
useValueTransform(transform, initialFile)
)
// Setup file upload handling
const { openFileSelection } = useNodeImageUpload(node, {
allow_batch,
fileFilter,
accept,
folder,
onUploadComplete: (output) => {
output.forEach((path) => addToComboValues(fileComboWidget, path))
// @ts-expect-error litegraph combo value type does not support arrays yet
fileComboWidget.value = output
fileComboWidget.callback?.(output)
}
})
// Create the button widget for selecting the files
const uploadWidget = node.addWidget(
'button',
inputName,
'image',
() => openFileSelection(),
{
serialize: false
}
)
uploadWidget.label = t('g.choose_file_to_upload')
// Add our own callback to the combo widget to render an image when it changes
fileComboWidget.callback = function () {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
node.graph?.setDirtyCanvas(true)
}
// On load if we have a value then render the image
// The value isnt set immediately so we need to wait a moment
// No change callbacks seem to be fired on initial setting of the value
requestAnimationFrame(() => {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
showPreview({ block: false })
})
return { widget: uploadWidget }
}
return widgetConstructor
}

View File

@@ -1,21 +0,0 @@
import type {
ImageInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { IImageWidget } from '../../lib/litegraph/src/types/widgets'
export const useImageWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IImageWidget => {
const { name, options = {} } = inputSpec as ImageInputSpec
const widget = node.addWidget('image', name, '', () => {}, {
serialize: true,
...options
}) as IImageWidget
return widget
}
}

View File

@@ -1,97 +0,0 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import {
type InputSpec,
isIntInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
type ComfyWidgetConstructorV2,
addValueControlWidget
} from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
function onValueChange(this: INumericWidget, v: number) {
// For integers, always round to the nearest step
// step === 0 is invalid, assign 1 if options.step is 0
const step = this.options.step2 || 1
if (step === 1) {
// Simple case: round to nearest integer
this.value = Math.round(v)
} else {
// Round to nearest multiple of step
// First, determine if min value creates an offset
const min = this.options.min ?? 0
const offset = min % step
// Round to nearest step, accounting for offset
this.value = Math.round((v - offset) / step) * step + offset
}
}
export const _for_testing = {
onValueChange
}
export const useIntWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isIntInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const settingStore = useSettingStore()
const sliderEnabled = !settingStore.get('Comfy.DisableSliders')
const display_type = inputSpec.display
const widgetType =
sliderEnabled && display_type == 'slider'
? 'slider'
: display_type == 'knob'
? 'knob'
: 'number'
const step = inputSpec.step ?? 1
/** Assertion {@link inputSpec.default} */
const defaultValue = (inputSpec.default as number | undefined) ?? 0
const widget = node.addWidget(
widgetType,
inputSpec.name,
defaultValue,
onValueChange,
{
min: inputSpec.min ?? 0,
max: inputSpec.max ?? 2048,
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
step: step * 10,
step2: step,
precision: 0
}
)
const controlAfterGenerate =
inputSpec.control_after_generate ??
/**
* Compatibility with legacy node convention. Int input with name
* 'seed' or 'noise_seed' get automatically added a control widget.
*/
['seed', 'noise_seed'].includes(inputSpec.name)
if (controlAfterGenerate) {
const seedControl = addValueControlWidget(
node,
widget,
'randomize',
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
widget.linkedWidgets = [seedControl]
}
return widget
}
return widgetConstructor
}

View File

@@ -1,115 +0,0 @@
import { Editor as TiptapEditor } from '@tiptap/core'
import TiptapLink from '@tiptap/extension-link'
import TiptapTable from '@tiptap/extension-table'
import TiptapTableCell from '@tiptap/extension-table-cell'
import TiptapTableHeader from '@tiptap/extension-table-header'
import TiptapTableRow from '@tiptap/extension-table-row'
import TiptapStarterKit from '@tiptap/starter-kit'
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { type InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
function addMarkdownWidget(
node: LGraphNode,
name: string,
opts: { defaultVal: string }
) {
TiptapMarkdown.configure({
html: false,
breaks: true,
transformPastedText: true
})
const editor = new TiptapEditor({
extensions: [
TiptapStarterKit,
TiptapMarkdown,
TiptapLink,
TiptapTable,
TiptapTableCell,
TiptapTableHeader,
TiptapTableRow
],
content: opts.defaultVal,
editable: false
})
const inputEl = editor.options.element as HTMLElement
inputEl.classList.add('comfy-markdown')
const textarea = document.createElement('textarea')
inputEl.append(textarea)
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
getValue(): string {
return textarea.value
},
setValue(v: string) {
textarea.value = v
editor.commands.setContent(v)
}
})
widget.inputEl = inputEl
widget.options.minNodeSize = [400, 200]
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button !== 0) {
app.canvas.processMouseDown(event)
return
}
if (event.target instanceof HTMLAnchorElement) {
return
}
inputEl.classList.add('editing')
setTimeout(() => {
textarea.focus()
}, 0)
})
textarea.addEventListener('blur', () => {
inputEl.classList.remove('editing')
})
textarea.addEventListener('change', () => {
editor.commands.setContent(textarea.value)
widget.callback?.(widget.value)
})
inputEl.addEventListener('keydown', (event: KeyboardEvent) => {
event.stopPropagation()
})
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseDown(event)
}
})
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
if ((event.buttons & 4) === 4) {
app.canvas.processMouseMove(event)
}
})
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseUp(event)
}
})
return widget
}
export const useMarkdownWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
return addMarkdownWidget(node, inputSpec.name, {
defaultVal: inputSpec.default ?? ''
})
}
return widgetConstructor
}

View File

@@ -1,22 +0,0 @@
import type {
InputSpec as InputSpecV2,
MultiSelectInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { IMultiSelectWidget } from '../../lib/litegraph/src/types/widgets'
export const useMultiSelectWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IMultiSelectWidget => {
const { name, options = {} } = inputSpec as MultiSelectInputSpec
const widget = node.addWidget('multiselect', name, [], () => {}, {
serialize: true,
values: options.values || [],
...options
}) as IMultiSelectWidget
return widget
}
}

View File

@@ -1,55 +0,0 @@
import { ref } from 'vue'
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
ComponentWidgetImpl,
type ComponentWidgetStandardProps,
addWidget
} from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
type TextPreviewCustomProps = Omit<
InstanceType<typeof TextPreviewWidget>['$props'],
ComponentWidgetStandardProps
>
const PADDING = 16
export const useTextPreviewWidget = (
options: {
minHeight?: number
} = {}
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
TextPreviewCustomProps
>({
node,
name: inputSpec.name,
component: TextPreviewWidget,
inputSpec,
props: {
nodeId: node.id
},
options: {
getValue: () => widgetValue.value,
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => options.minHeight ?? 42 + PADDING,
serialize: false
}
})
addWidget(node, widget)
return widget
}
return widgetConstructor
}

View File

@@ -1,274 +0,0 @@
import axios from 'axios'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { IWidget } from '@/lib/litegraph/src/litegraph'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
const MAX_RETRIES = 5
const TIMEOUT = 4096
export interface CacheEntry<T> {
data: T
timestamp?: number
error?: Error | null
fetchPromise?: Promise<T>
controller?: AbortController
lastErrorTime?: number
retryCount?: number
failed?: boolean
}
const dataCache = new Map<string, CacheEntry<any>>()
const createCacheKey = (config: RemoteWidgetConfig): string => {
const { route, query_params = {}, refresh = 0 } = config
const paramsKey = Object.entries(query_params)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&')
return [route, `r=${refresh}`, paramsKey].join(';')
}
const getBackoff = (retryCount: number) =>
Math.min(1000 * Math.pow(2, retryCount), 512)
const isInitialized = (entry: CacheEntry<unknown> | undefined) =>
entry?.data && entry?.timestamp && entry.timestamp > 0
const isStale = (entry: CacheEntry<unknown> | undefined, ttl: number) =>
entry?.timestamp && Date.now() - entry.timestamp >= ttl
const isFetching = (entry: CacheEntry<unknown> | undefined) =>
entry?.fetchPromise !== undefined
const isFailed = (entry: CacheEntry<unknown> | undefined) =>
entry?.failed === true
const isBackingOff = (entry: CacheEntry<unknown> | undefined) =>
entry?.error &&
entry?.lastErrorTime &&
Date.now() - entry.lastErrorTime < getBackoff(entry.retryCount || 0)
const fetchData = async (
config: RemoteWidgetConfig,
controller: AbortController
) => {
const { route, response_key, query_params, timeout = TIMEOUT } = config
const res = await axios.get(route, {
params: query_params,
signal: controller.signal,
timeout
})
return response_key ? res.data[response_key] : res.data
}
export function useRemoteWidget<
T extends string | number | boolean | object
>(options: {
remoteConfig: RemoteWidgetConfig
defaultValue: T
node: LGraphNode
widget: IWidget
}) {
const { remoteConfig, defaultValue, node, widget } = options
const { refresh = 0, max_retries = MAX_RETRIES } = remoteConfig
const isPermanent = refresh <= 0
const cacheKey = createCacheKey(remoteConfig)
let isLoaded = false
let refreshQueued = false
const setSuccess = (entry: CacheEntry<T>, data: T) => {
entry.retryCount = 0
entry.lastErrorTime = 0
entry.error = null
entry.timestamp = Date.now()
entry.data = data ?? defaultValue
}
const setError = (entry: CacheEntry<T>, error: Error | unknown) => {
entry.retryCount = (entry.retryCount || 0) + 1
entry.lastErrorTime = Date.now()
entry.error = error instanceof Error ? error : new Error(String(error))
entry.data ??= defaultValue
entry.fetchPromise = undefined
if (entry.retryCount >= max_retries) {
setFailed(entry)
}
}
const setFailed = (entry: CacheEntry<T>) => {
dataCache.set(cacheKey, {
data: entry.data ?? defaultValue,
failed: true
})
}
const isFirstLoad = () => {
return !isLoaded && isInitialized(dataCache.get(cacheKey))
}
const onFirstLoad = (data: T[]) => {
isLoaded = true
widget.value = data[0]
widget.callback?.(widget.value)
node.graph?.setDirtyCanvas(true)
}
const fetchValue = async () => {
const entry = dataCache.get(cacheKey)
if (isFailed(entry)) return entry!.data
const isValid =
isInitialized(entry) && (isPermanent || !isStale(entry, refresh))
if (isValid || isBackingOff(entry) || isFetching(entry)) return entry!.data
const currentEntry: CacheEntry<T> = entry || { data: defaultValue }
dataCache.set(cacheKey, currentEntry)
try {
currentEntry.controller = new AbortController()
currentEntry.fetchPromise = fetchData(
remoteConfig,
currentEntry.controller
)
const data = await currentEntry.fetchPromise
setSuccess(currentEntry, data)
return currentEntry.data
} catch (err) {
setError(currentEntry, err)
return currentEntry.data
} finally {
currentEntry.fetchPromise = undefined
currentEntry.controller = undefined
}
}
const onRefresh = () => {
if (remoteConfig.control_after_refresh) {
const data = getCachedValue()
if (!Array.isArray(data)) return // control_after_refresh is only supported for array values
switch (remoteConfig.control_after_refresh) {
case 'first':
widget.value = data[0] ?? defaultValue
break
case 'last':
widget.value = data.at(-1) ?? defaultValue
break
}
widget.callback?.(widget.value)
node.graph?.setDirtyCanvas(true)
}
}
/**
* Clear the widget's cached value, forcing a refresh on next access (e.g., a new render)
*/
const clearCachedValue = () => {
const entry = dataCache.get(cacheKey)
if (!entry) return
if (entry.fetchPromise) entry.controller?.abort() // Abort in-flight request
dataCache.delete(cacheKey)
}
/**
* Get the cached value of the widget without starting a new fetch.
* @returns the most recently computed value of the widget.
*/
function getCachedValue() {
return dataCache.get(cacheKey)?.data as T
}
/**
* Getter of the remote property of the widget (e.g., options.values, value, etc.).
* Starts the fetch process then returns the cached value immediately.
* @returns the most recent value of the widget.
*/
function getValue(onFulfilled?: () => void) {
void fetchValue()
.then((data) => {
if (isFirstLoad()) onFirstLoad(data)
if (refreshQueued && data !== defaultValue) {
onRefresh()
refreshQueued = false
}
onFulfilled?.()
})
.catch((err) => {
console.error(err)
})
return getCachedValue() ?? defaultValue
}
/**
* Force the widget to refresh its value
*/
widget.refresh = function () {
refreshQueued = true
clearCachedValue()
getValue()
}
/**
* Add a refresh button to the node that, when clicked, will force the widget to refresh
*/
function addRefreshButton() {
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
}
/**
* Add auto-refresh toggle widget and execution success listener
*/
function addAutoRefreshToggle() {
let autoRefreshEnabled = false
// Handler for execution success
const handleExecutionSuccess = () => {
if (autoRefreshEnabled && widget.refresh) {
widget.refresh()
}
}
// Add toggle widget
const autoRefreshWidget = node.addWidget(
'toggle',
'Auto-refresh after generation',
false,
(value: boolean) => {
autoRefreshEnabled = value
},
{
serialize: false
}
)
// Register event listener
api.addEventListener('execution_success', handleExecutionSuccess)
// Cleanup on node removal
node.onRemoved = useChainCallback(node.onRemoved, function () {
api.removeEventListener('execution_success', handleExecutionSuccess)
})
return autoRefreshWidget
}
// Always add auto-refresh toggle for remote widgets
addAutoRefreshToggle()
return {
getCachedValue,
getValue,
refreshValue: widget.refresh,
addRefreshButton,
getCacheEntry: () => dataCache.get(cacheKey),
cacheKey
}
}

View File

@@ -1,29 +0,0 @@
import type {
InputSpec as InputSpecV2,
SelectButtonInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { ISelectButtonWidget } from '../../lib/litegraph/src/types/widgets'
export const useSelectButtonWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): ISelectButtonWidget => {
const { name, options = {} } = inputSpec as SelectButtonInputSpec
const values = options.values || []
const widget = node.addWidget(
'selectbutton',
name,
values[0] || '',
(_value: string) => {},
{
serialize: true,
values,
...options
}
) as ISelectButtonWidget
return widget
}
}

View File

@@ -1,139 +0,0 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
type InputSpec,
isStringInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
const TRACKPAD_DETECTION_THRESHOLD = 50
function addMultilineWidget(
node: LGraphNode,
name: string,
opts: { defaultVal: string; placeholder?: string }
) {
const inputEl = document.createElement('textarea')
inputEl.className = 'comfy-multiline-input'
inputEl.value = opts.defaultVal
inputEl.placeholder = opts.placeholder || name
inputEl.spellcheck = useSettingStore().get('Comfy.TextareaWidget.Spellcheck')
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
getValue(): string {
return inputEl.value
},
setValue(v: string) {
inputEl.value = v
}
})
widget.inputEl = inputEl
widget.options.minNodeSize = [400, 200]
inputEl.addEventListener('input', () => {
widget.callback?.(widget.value)
})
// Allow middle mouse button panning
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseDown(event)
}
})
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
if ((event.buttons & 4) === 4) {
app.canvas.processMouseMove(event)
}
})
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseUp(event)
}
})
inputEl.addEventListener('wheel', (event: WheelEvent) => {
const gesturesEnabled = useSettingStore().get(
'LiteGraph.Pointer.TrackpadGestures'
)
const deltaX = event.deltaX
const deltaY = event.deltaY
const canScrollY = inputEl.scrollHeight > inputEl.clientHeight
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY)
// Prevent pinch zoom from zooming the page
if (event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// Detect if this is likely a trackpad gesture vs mouse wheel
// Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD)
// Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD)
const isLikelyTrackpad =
Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD
// Trackpad gestures: when enabled, trackpad panning goes to canvas
if (gesturesEnabled && isLikelyTrackpad) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea)
if (isHorizontal) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// Vertical scrolling when gestures disabled: let textarea scroll if scrollable
if (canScrollY) {
event.stopPropagation()
return
}
// If textarea can't scroll vertically, pass to canvas
event.preventDefault()
app.canvas.processMouseWheel(event)
})
return widget
}
export const useStringWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isStringInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const defaultVal = inputSpec.default ?? ''
const multiline = inputSpec.multiline
const widget = multiline
? addMultilineWidget(node, inputSpec.name, {
defaultVal,
placeholder: inputSpec.placeholder
})
: node.addWidget('text', inputSpec.name, defaultVal, () => {}, {})
if (typeof inputSpec.dynamicPrompts === 'boolean') {
widget.dynamicPrompts = inputSpec.dynamicPrompts
}
return widget
}
return widgetConstructor
}

View File

@@ -1,29 +0,0 @@
import type {
InputSpec as InputSpecV2,
TextareaInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { ITextareaWidget } from '../../lib/litegraph/src/types/widgets'
export const useTextareaWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): ITextareaWidget => {
const { name, options = {} } = inputSpec as TextareaInputSpec
const widget = node.addWidget(
'textarea',
name,
options.default || '',
() => {},
{
serialize: true,
rows: options.rows || 5,
cols: options.cols || 50,
...options
}
) as ITextareaWidget
return widget
}
}

View File

@@ -1,25 +0,0 @@
import type {
InputSpec as InputSpecV2,
TreeSelectInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { ITreeSelectWidget } from '../../lib/litegraph/src/types/widgets'
export const useTreeSelectWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): ITreeSelectWidget => {
const { name, options = {} } = inputSpec as TreeSelectInputSpec
const isMultiple = options.multiple || false
const defaultValue = isMultiple ? [] : ''
const widget = node.addWidget('treeselect', name, defaultValue, () => {}, {
serialize: true,
values: options.values || [],
multiple: isMultiple,
...options
}) as ITreeSelectWidget
return widget
}
}

View File

@@ -6,6 +6,8 @@ import {
} from '@/lib/litegraph/src/constants'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { layoutMutations } from '@/renderer/core/layout/operations/LayoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { DragAndScaleState } from './DragAndScale'
import { LGraphCanvas } from './LGraphCanvas'
@@ -1349,6 +1351,16 @@ export class LGraph
floatingLinkIds
)
this.reroutes.set(rerouteId, reroute)
// Register reroute in Layout Store for spatial tracking
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.createReroute(
String(rerouteId),
{ x: pos[0], y: pos[1] },
before.parentId ? String(before.parentId) : undefined,
Array.from(linkIds)
)
for (const linkId of linkIds) {
const link = this._links.get(linkId)
if (!link) continue
@@ -1422,6 +1434,11 @@ export class LGraph
}
reroutes.delete(id)
// Delete reroute from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(id)
// This does not belong here; it should be handled by the caller, or run by a remove-many API.
// https://github.com/Comfy-Org/litegraph.js/issues/898
this.setDirtyCanvas(false, true)
@@ -2245,6 +2262,9 @@ export class LGraph
// Drop broken links, and ignore reroutes with no valid links
if (!reroute.validateLinks(this._links, this.floatingLinks)) {
this.reroutes.delete(reroute.id)
// Clean up layout store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(reroute.id)
}
}

View File

@@ -6,6 +6,7 @@ import {
type LinkRenderContext,
LitegraphLinkAdapter
} from '@/renderer/core/canvas/litegraph/LitegraphLinkAdapter'
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu'
@@ -706,6 +707,8 @@ export class LGraphCanvas
// Initialize link renderer if graph is available
if (graph) {
this.linkRenderer = new LitegraphLinkAdapter(graph)
// Disable layout writes during render
this.linkRenderer.enableLayoutStoreWrites = false
}
this.linkConnector.events.addEventListener('link-created', () =>
@@ -1803,6 +1806,8 @@ export class LGraphCanvas
// Re-initialize link renderer with new graph
this.linkRenderer = new LitegraphLinkAdapter(newGraph)
// Disable layout writes during render
this.linkRenderer.enableLayoutStoreWrites = false
this.dispatch('litegraph:set-graph', { newGraph, oldGraph: graph })
this.#dirty()
@@ -2197,11 +2202,22 @@ export class LGraphCanvas
this.processSelect(node, e, true)
} else if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
// Reroutes
const reroute = graph.getRerouteOnPos(
e.canvasX,
e.canvasY,
this.#visibleReroutes
)
// Try layout store first, fallback to old method
const rerouteLayout = layoutStore.queryRerouteAtPoint({
x: e.canvasX,
y: e.canvasY
})
let reroute: Reroute | undefined
if (rerouteLayout) {
reroute = graph.getReroute(rerouteLayout.id)
} else {
reroute = graph.getRerouteOnPos(
e.canvasX,
e.canvasY,
this.#visibleReroutes
)
}
if (reroute) {
if (e.altKey) {
pointer.onClick = (upEvent) => {
@@ -2367,8 +2383,18 @@ export class LGraphCanvas
// Reroutes
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
// Try layout store first for hit detection
const rerouteLayout = layoutStore.queryRerouteAtPoint({ x, y })
let foundReroute: Reroute | undefined
if (rerouteLayout) {
foundReroute = graph.getReroute(rerouteLayout.id)
}
// Fallback to checking visible reroutes directly
for (const reroute of this.#visibleReroutes) {
const overReroute = reroute.containsPoint([x, y])
const overReroute =
foundReroute === reroute || reroute.containsPoint([x, y])
if (!reroute.isSlotHovered && !overReroute) continue
if (overReroute) {
@@ -2402,16 +2428,32 @@ export class LGraphCanvas
this.ctx.lineWidth = this.connections_width + 7
const dpi = Math.max(window?.devicePixelRatio ?? 1, 1)
// Try layout store for segment hit testing first (more precise)
const hitSegment = layoutStore.queryLinkSegmentAtPoint({ x, y }, this.ctx)
for (const linkSegment of this.renderedPaths) {
const centre = linkSegment._pos
if (!centre) continue
// Check if this link segment was hit
let isLinkHit =
hitSegment &&
linkSegment.id ===
(linkSegment instanceof Reroute
? hitSegment.rerouteId
: hitSegment.linkId)
if (!isLinkHit && linkSegment.path) {
// Fallback to direct path hit testing if not found in layout store
isLinkHit = this.ctx.isPointInStroke(
linkSegment.path,
x * dpi,
y * dpi
)
}
// If we shift click on a link then start a link from that input
if (
(e.shiftKey || e.altKey) &&
linkSegment.path &&
this.ctx.isPointInStroke(linkSegment.path, x * dpi, y * dpi)
) {
if ((e.shiftKey || e.altKey) && isLinkHit) {
this.ctx.lineWidth = lineWidth
if (e.shiftKey && !e.altKey) {
@@ -3142,8 +3184,27 @@ export class LGraphCanvas
// For input/output hovering
// to store the output of isOverNodeInput
const pos: Point = [0, 0]
const inputId = isOverNodeInput(node, x, y, pos)
const outputId = isOverNodeOutput(node, x, y, pos)
// Try to use layout store for hit testing first, fallback to old method
let inputId: number = -1
let outputId: number = -1
const slotLayout = layoutStore.querySlotAtPoint({ x, y })
if (slotLayout && slotLayout.nodeId === String(node.id)) {
if (slotLayout.type === 'input') {
inputId = slotLayout.index
pos[0] = slotLayout.position.x
pos[1] = slotLayout.position.y
} else {
outputId = slotLayout.index
pos[0] = slotLayout.position.x
pos[1] = slotLayout.position.y
}
} else {
// Fallback to old method
inputId = isOverNodeInput(node, x, y, pos)
outputId = isOverNodeOutput(node, x, y, pos)
}
const overWidget = node.getWidgetOnPos(x, y, true) ?? undefined
if (!node.mouseOver) {
@@ -6048,6 +6109,8 @@ export class LGraphCanvas
: segment.id
if (linkId !== undefined) {
graph.removeLink(linkId)
// Clean up layout store
layoutStore.deleteLinkLayout(linkId)
}
break
}
@@ -8125,11 +8188,26 @@ export class LGraphCanvas
// Check for reroutes
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
const reroute = this.graph.getRerouteOnPos(
event.canvasX,
event.canvasY,
this.#visibleReroutes
)
// Try layout store first, fallback to old method
const rerouteLayout = layoutStore.queryRerouteAtPoint({
x: event.canvasX,
y: event.canvasY
})
let reroute: Reroute | undefined
if (rerouteLayout) {
console.debug('✅ Using LayoutStore for reroute query', {
rerouteLayout
})
reroute = this.graph.getReroute(rerouteLayout.id)
} else {
console.debug('⚠️ Falling back to old reroute query method')
reroute = this.graph.getRerouteOnPos(
event.canvasX,
event.canvasY,
this.#visibleReroutes
)
}
if (reroute) {
menu_info.unshift(
{

View File

@@ -5,6 +5,8 @@ import {
calculateInputSlotPosFromSlot,
calculateOutputSlotPos
} from '@/renderer/core/canvas/litegraph/SlotCalculations'
import { layoutMutations } from '@/renderer/core/layout/operations/LayoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { DragAndScale } from './DragAndScale'
import type { LGraph } from './LGraph'
@@ -2842,6 +2844,16 @@ export class LGraphNode
// add to graph links list
graph._links.set(link.id, link)
// Register link in Layout Store for spatial tracking
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.createLink(
link.id,
this.id,
outputIndex,
inputNode.id,
inputIndex
)
// connect in output
output.links ??= []
output.links.push(link.id)

View File

@@ -2,6 +2,8 @@ import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import { layoutMutations } from '@/renderer/core/layout/operations/LayoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { LGraphNode, NodeId } from './LGraphNode'
import type { Reroute, RerouteId } from './Reroute'
@@ -459,9 +461,15 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
reroute.linkIds.delete(this.id)
if (!keepReroutes && !reroute.totalLinks) {
network.reroutes.delete(reroute.id)
// Delete reroute from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(reroute.id)
}
}
network.links.delete(this.id)
// Delete link from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteLink(this.id)
}
/**

View File

@@ -106,6 +106,7 @@ export class LiteGraphGlobal {
* These values ensure both systems can independently calculate node, slot, and widget positions
* to place them in identical locations.
*/
// WARNING THIS WILL BE REMOVED IN FAVOR OF THE SLOTS LAYOUT TREE useDomSlotRegistration
COMFY_VUE_NODE_DIMENSIONS = COMFY_VUE_NODE_DIMENSIONS
LINK_COLOR = '#9A9'

View File

@@ -1,3 +1,6 @@
import { layoutMutations } from '@/renderer/core/layout/operations/LayoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { LGraphBadge } from './LGraphBadge'
import type { LGraphNode, NodeId } from './LGraphNode'
import { LLink, type LinkId } from './LLink'
@@ -407,8 +410,17 @@ export class Reroute
/** @inheritdoc */
move(deltaX: number, deltaY: number) {
const previousPos = { x: this.#pos[0], y: this.#pos[1] }
this.#pos[0] += deltaX
this.#pos[1] += deltaY
// Update Layout Store with new position
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.moveReroute(
this.id,
{ x: this.#pos[0], y: this.#pos[1] },
previousPos
)
}
/** @inheritdoc */

View File

@@ -1059,7 +1059,11 @@
"Toggle Model Library Sidebar": "Toggle Model Library Sidebar",
"Toggle Node Library Sidebar": "Toggle Node Library Sidebar",
"Toggle Queue Sidebar": "Toggle Queue Sidebar",
"Toggle Workflows Sidebar": "Toggle Workflows Sidebar"
"Toggle Workflows Sidebar": "Toggle Workflows Sidebar",
"sideToolbar_modelLibrary": "sideToolbar.modelLibrary",
"sideToolbar_nodeLibrary": "sideToolbar.nodeLibrary",
"sideToolbar_queue": "sideToolbar.queue",
"sideToolbar_workflows": "sideToolbar.workflows"
},
"desktopMenu": {
"reinstall": "Reinstall",

View File

@@ -877,7 +877,11 @@
"renderBypassState": "Mostrar estado de omisión",
"renderErrorState": "Mostrar estado de error",
"showGroups": "Mostrar marcos/grupos",
"showLinks": "Mostrar enlaces"
"showLinks": "Mostrar enlaces",
"sideToolbar_modelLibrary": "sideToolbar.bibliotecaDeModelos",
"sideToolbar_nodeLibrary": "sideToolbar.bibliotecaDeNodos",
"sideToolbar_queue": "sideToolbar.cola",
"sideToolbar_workflows": "sideToolbar.flujosDeTrabajo"
},
"missingModelsDialog": {
"doNotAskAgain": "No mostrar esto de nuevo",

View File

@@ -878,7 +878,11 @@
"renderErrorState": "Afficher l'état d'erreur",
"showGroups": "Afficher les cadres/groupes",
"showLinks": "Afficher les liens",
"Zoom Out": "Zoom arrière"
"Zoom Out": "Zoom arrière",
"sideToolbar_modelLibrary": "Bibliothèque de modèles",
"sideToolbar_nodeLibrary": "Bibliothèque de nœuds",
"sideToolbar_queue": "File d'attente",
"sideToolbar_workflows": "Flux de travail"
},
"missingModelsDialog": {
"doNotAskAgain": "Ne plus afficher ce message",

View File

@@ -862,7 +862,6 @@
"Toggle Search Box": "検索ボックスの切り替え",
"Toggle Terminal Bottom Panel": "ターミナル下部パネルの切り替え",
"Toggle Theme (Dark/Light)": "テーマを切り替え(ダーク/ライト)",
"Toggle View Controls Bottom Panel": "ビューコントロール下部パネルの切り替え",
"Toggle Workflows Sidebar": "ワークフローサイドバーを切り替え",
"Toggle the Custom Nodes Manager": "カスタムノードマネージャーを切り替え",
"Toggle the Custom Nodes Manager Progress Bar": "カスタムノードマネージャーの進行状況バーを切り替え",
@@ -880,7 +879,11 @@
"renderBypassState": "バイパス状態を表示",
"renderErrorState": "エラー状態を表示",
"showGroups": "フレーム/グループを表示",
"showLinks": "リンクを表示"
"showLinks": "リンクを表示",
"sideToolbar_modelLibrary": "モデルライブラリ",
"sideToolbar_nodeLibrary": "ノードライブラリ",
"sideToolbar_queue": "キュー",
"sideToolbar_workflows": "ワークフロー"
},
"missingModelsDialog": {
"doNotAskAgain": "再度表示しない",

View File

@@ -859,12 +859,13 @@
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
"Toggle Queue Sidebar": "대기열 사이드바 전환",
"Toggle Workflows Sidebar": "워크플로우 사이드바 전환",
"Toggle View Controls Bottom Panel": "뷰 컨트롤 하단 패널 전환",
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
"Toggle View Controls Bottom Panel": "뷰 컨트롤 하단 패널 전환",
"Toggle Workflows Sidebar": "워크플로우 사이드바 전환",
"Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
"Toggle Workflows Sidebar": "워크플로우 사이드바 전환", "Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
"Undo": "실행 취소",
"Ungroup selected group nodes": "선택한 그룹 노드 그룹 해제",
@@ -880,7 +881,11 @@
"renderBypassState": "바이패스 상태 렌더링",
"renderErrorState": "에러 상태 렌더링",
"showGroups": "프레임/그룹 표시",
"showLinks": "링크 표시"
"showLinks": "링크 표시",
"sideToolbar_modelLibrary": "sideToolbar.모델 라이브러리",
"sideToolbar_nodeLibrary": "sideToolbar.노드 라이브러리",
"sideToolbar_queue": "sideToolbar.대기열",
"sideToolbar_workflows": "sideToolbar.워크플로우"
},
"missingModelsDialog": {
"doNotAskAgain": "다시 보지 않기",

View File

@@ -880,7 +880,11 @@
"renderBypassState": "Отображать состояние обхода",
"renderErrorState": "Отображать состояние ошибки",
"showGroups": "Показать фреймы/группы",
"showLinks": "Показать связи"
"showLinks": "Показать связи",
"sideToolbar_modelLibrary": "sideToolbar.каталогМоделей",
"sideToolbar_nodeLibrary": "sideToolbar.каталогУзлов",
"sideToolbar_queue": "sideToolbar.очередь",
"sideToolbar_workflows": "sideToolbar.рабочиеПроцессы"
},
"missingModelsDialog": {
"doNotAskAgain": "Больше не показывать это",

View File

@@ -261,13 +261,13 @@
"label": "切换日志底部面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "切换基础底部面板"
"label": "切換基本下方面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "切换视图控制底部面板"
"label": "切換檢視控制底部面板"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "示快捷键对话框"
"label": "示快捷鍵對話框"
},
"Workspace_ToggleFocusMode": {
"label": "切换焦点模式"

View File

@@ -877,7 +877,11 @@
"renderBypassState": "渲染绕过状态",
"renderErrorState": "渲染错误状态",
"showGroups": "显示框架/分组",
"showLinks": "显示连接"
"showLinks": "显示连接",
"sideToolbar_modelLibrary": "侧边工具栏.模型库",
"sideToolbar_nodeLibrary": "侧边工具栏.节点库",
"sideToolbar_queue": "侧边工具栏.队列",
"sideToolbar_workflows": "侧边工具栏.工作流"
},
"missingModelsDialog": {
"doNotAskAgain": "不再显示此消息",

View File

@@ -32,12 +32,9 @@ import {
type Point,
type RenderMode
} from '@/renderer/core/canvas/PathRenderer'
import {
type SlotPositionContext,
calculateInputSlotPos,
calculateOutputSlotPos
} from '@/renderer/core/canvas/litegraph/SlotCalculations'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/SlotCalculations'
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
import type { Bounds } from '@/renderer/core/layout/types'
export interface LinkRenderContext {
// Canvas settings
@@ -71,6 +68,7 @@ export interface LinkRenderOptions {
export class LitegraphLinkAdapter {
private graph: LGraph
private pathRenderer: CanvasPathRenderer
public enableLayoutStoreWrites = true
constructor(graph: LGraph) {
this.graph = graph
@@ -106,12 +104,12 @@ export class LitegraphLinkAdapter {
}
// Get positions using layout tree data if available
const startPos = this.getSlotPosition(
const startPos = getSlotPosition(
sourceNode,
link.origin_slot,
false // output
)
const endPos = this.getSlotPosition(
const endPos = getSlotPosition(
targetNode,
link.target_slot,
true // input
@@ -139,6 +137,34 @@ export class LitegraphLinkAdapter {
// Store path for hit detection
link.path = path
// Update layout store when writes are enabled (event-driven path)
if (this.enableLayoutStoreWrites && link.id !== -1) {
// Calculate bounds and center only when writing
const bounds = this.calculateLinkBounds(startPos, endPos, linkData)
const centerPos = linkData.centerPos || {
x: (startPos[0] + endPos[0]) / 2,
y: (startPos[1] + endPos[1]) / 2
}
layoutStore.updateLinkLayout(link.id, {
id: link.id,
path: path,
bounds: bounds,
centerPos: centerPos,
sourceNodeId: String(link.origin_id),
targetNodeId: String(link.target_id),
sourceSlot: link.origin_slot,
targetSlot: link.target_slot
})
// Also update segment layout for the whole link (null rerouteId means final segment)
layoutStore.updateLinkSegmentLayout(link.id, null, {
path: path,
bounds: bounds,
centerPos: centerPos
})
}
}
/**
@@ -434,43 +460,43 @@ export class LitegraphLinkAdapter {
linkSegment._centreAngle = linkData.centerAngle
}
}
}
}
/**
* Get slot position using layout tree if available, fallback to node's position
*/
private getSlotPosition(
node: LGraphNode,
slotIndex: number,
isInput: boolean
): ReadOnlyPoint {
// Try to get position from layout tree
const nodeLayout = layoutStore.getNodeLayoutRef(String(node.id)).value
// Update layout store when writes are enabled (event-driven path)
if (this.enableLayoutStoreWrites && link && link.id !== -1) {
// Calculate bounds and center only when writing
const bounds = this.calculateLinkBounds(
[linkData.startPoint.x, linkData.startPoint.y] as ReadOnlyPoint,
[linkData.endPoint.x, linkData.endPoint.y] as ReadOnlyPoint,
linkData
)
const centerPos = linkData.centerPos || {
x: (linkData.startPoint.x + linkData.endPoint.x) / 2,
y: (linkData.startPoint.y + linkData.endPoint.y) / 2
}
if (nodeLayout) {
// Create context from layout tree data
const context: SlotPositionContext = {
nodeX: nodeLayout.position.x,
nodeY: nodeLayout.position.y,
nodeWidth: nodeLayout.size.width,
nodeHeight: nodeLayout.size.height,
collapsed: node.flags.collapsed || false,
collapsedWidth: node._collapsed_width,
slotStartY: node.constructor.slot_start_y,
inputs: node.inputs,
outputs: node.outputs,
widgets: node.widgets
// Update whole link layout (only if not a reroute segment)
if (!extras.reroute) {
layoutStore.updateLinkLayout(link.id, {
id: link.id,
path: path,
bounds: bounds,
centerPos: centerPos,
sourceNodeId: String(link.origin_id),
targetNodeId: String(link.target_id),
sourceSlot: link.origin_slot,
targetSlot: link.target_slot
})
}
// Always update segment layout (for both regular links and reroute segments)
const rerouteId = extras.reroute ? extras.reroute.id : null
layoutStore.updateLinkSegmentLayout(link.id, rerouteId, {
path: path,
bounds: bounds,
centerPos: centerPos
})
}
// Use helper to calculate position
return isInput
? calculateInputSlotPos(context, slotIndex)
: calculateOutputSlotPos(context, slotIndex)
}
// Fallback to node's own methods if layout not available
return isInput ? node.getInputPos(slotIndex) : node.getOutputPos(slotIndex)
}
/**
@@ -493,7 +519,7 @@ export class LitegraphLinkAdapter {
if (!fromNode) return
// Get slot position using layout tree if available
const slotPos = this.getSlotPosition(
const slotPos = getSlotPosition(
fromNode,
fromSlotIndex,
options.fromInput || false
@@ -525,4 +551,39 @@ export class LitegraphLinkAdapter {
// Render using pure renderer
this.pathRenderer.drawDraggingLink(ctx, dragData, pathContext)
}
/**
* Calculate bounding box for a link
* Includes padding for line width and control points
*/
private calculateLinkBounds(
startPos: ReadOnlyPoint,
endPos: ReadOnlyPoint,
linkData: LinkRenderData
): Bounds {
let minX = Math.min(startPos[0], endPos[0])
let maxX = Math.max(startPos[0], endPos[0])
let minY = Math.min(startPos[1], endPos[1])
let maxY = Math.max(startPos[1], endPos[1])
// Include control points if they exist (for spline links)
if (linkData.controlPoints) {
for (const cp of linkData.controlPoints) {
minX = Math.min(minX, cp.x)
maxX = Math.max(maxX, cp.x)
minY = Math.min(minY, cp.y)
maxY = Math.max(maxY, cp.y)
}
}
// Add padding for line width and hit tolerance
const padding = 20
return {
x: minX - padding,
y: minY - padding,
width: maxX - minX + 2 * padding,
height: maxY - minY + 2 * padding
}
}
}

View File

@@ -5,14 +5,18 @@
* This allows both litegraph nodes and the layout system to use the same
* calculation logic while providing their own position data.
*/
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
INodeInputSlot,
INodeOutputSlot,
INodeSlot,
Point
Point,
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils'
import { getSlotKey } from '@/renderer/core/layout/slots/SlotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
export interface SlotPositionContext {
/** Node's X position in graph coordinates */
@@ -147,6 +151,54 @@ export function calculateOutputSlotPos(
return [nodeX + nodeWidth + 1 - offsetX, nodeY + slotY + nodeOffsetY]
}
/**
* Get slot position using layout tree if available, fallback to node's position
* Unified implementation used by both LitegraphLinkAdapter and useLinkLayoutSync
* @param node The LGraphNode
* @param slotIndex The slot index
* @param isInput Whether this is an input slot
* @returns Position of the slot center in graph coordinates
*/
export function getSlotPosition(
node: LGraphNode,
slotIndex: number,
isInput: boolean
): ReadOnlyPoint {
// Try to get precise position from slot layout (DOM-registered)
const slotKey = getSlotKey(String(node.id), slotIndex, isInput)
const slotLayout = layoutStore.getSlotLayout(slotKey)
if (slotLayout) {
return [slotLayout.position.x, slotLayout.position.y]
}
// Fallback: derive position from node layout tree and slot model
const nodeLayout = layoutStore.getNodeLayoutRef(String(node.id)).value
if (nodeLayout) {
// Create context from layout tree data
const context: SlotPositionContext = {
nodeX: nodeLayout.position.x,
nodeY: nodeLayout.position.y,
nodeWidth: nodeLayout.size.width,
nodeHeight: nodeLayout.size.height,
collapsed: node.flags.collapsed || false,
collapsedWidth: node._collapsed_width,
slotStartY: node.constructor.slot_start_y,
inputs: node.inputs,
outputs: node.outputs,
widgets: node.widgets
}
// Use helper to calculate position
return isInput
? calculateInputSlotPos(context, slotIndex)
: calculateOutputSlotPos(context, slotIndex)
}
// Fallback to node's own methods if layout not available
return isInput ? node.getInputPos(slotIndex) : node.getOutputPos(slotIndex)
}
/**
* Get the inputs that are not positioned with absolute coordinates
*/

View File

@@ -1,137 +0,0 @@
/**
* Mock Layout Adapter
*
* Simple in-memory implementation for testing without CRDT overhead.
*/
import type { LayoutOperation } from '@/renderer/core/layout/types'
import type { NodeId, NodeLayout } from '@/renderer/core/layout/types'
import type { AdapterChange, LayoutAdapter } from './layoutAdapter'
/**
* Mock implementation for testing
*/
export class MockLayoutAdapter implements LayoutAdapter {
private nodes = new Map<NodeId, NodeLayout>()
private operations: LayoutOperation[] = []
private changeCallbacks = new Set<(change: AdapterChange) => void>()
private currentActor?: string
setNode(nodeId: NodeId, layout: NodeLayout): void {
this.nodes.set(nodeId, { ...layout })
this.notifyChange({
type: 'set',
nodeIds: [nodeId],
actor: this.currentActor
})
}
getNode(nodeId: NodeId): NodeLayout | null {
const layout = this.nodes.get(nodeId)
return layout ? { ...layout } : null
}
deleteNode(nodeId: NodeId): void {
const existed = this.nodes.delete(nodeId)
if (existed) {
this.notifyChange({
type: 'delete',
nodeIds: [nodeId],
actor: this.currentActor
})
}
}
getAllNodes(): Map<NodeId, NodeLayout> {
// Return a copy to prevent external mutations
const copy = new Map<NodeId, NodeLayout>()
for (const [id, layout] of this.nodes) {
copy.set(id, { ...layout })
}
return copy
}
clear(): void {
const nodeIds = Array.from(this.nodes.keys())
this.nodes.clear()
this.operations = []
if (nodeIds.length > 0) {
this.notifyChange({
type: 'clear',
nodeIds,
actor: this.currentActor
})
}
}
addOperation(operation: LayoutOperation): void {
this.operations.push({ ...operation })
}
getOperationsSince(timestamp: number): LayoutOperation[] {
return this.operations
.filter((op) => op.timestamp > timestamp)
.map((op) => ({ ...op }))
}
getOperationsByActor(actor: string): LayoutOperation[] {
return this.operations
.filter((op) => op.actor === actor)
.map((op) => ({ ...op }))
}
subscribe(callback: (change: AdapterChange) => void): () => void {
this.changeCallbacks.add(callback)
return () => this.changeCallbacks.delete(callback)
}
transaction(fn: () => void, actor?: string): void {
const previousActor = this.currentActor
this.currentActor = actor
try {
fn()
} finally {
this.currentActor = previousActor
}
}
// Mock network sync methods
getStateVector(): Uint8Array {
return new Uint8Array([1, 2, 3]) // Mock data
}
getStateAsUpdate(): Uint8Array {
// Simple serialization for testing
const json = JSON.stringify({
nodes: Array.from(this.nodes.entries()),
operations: this.operations
})
return new TextEncoder().encode(json)
}
applyUpdate(update: Uint8Array): void {
// Simple deserialization for testing
const json = new TextDecoder().decode(update)
const data = JSON.parse(json) as {
nodes: Array<[NodeId, NodeLayout]>
operations: LayoutOperation[]
}
this.nodes.clear()
for (const [id, layout] of data.nodes) {
this.nodes.set(id, layout)
}
this.operations = data.operations
}
private notifyChange(change: AdapterChange): void {
this.changeCallbacks.forEach((callback) => {
try {
callback(change)
} catch (error) {
console.error('Error in mock adapter change callback:', error)
}
})
}
}

View File

@@ -1,207 +0,0 @@
/**
* Yjs Layout Adapter
*
* Implements the LayoutAdapter interface using Yjs as the CRDT backend.
* Provides efficient local state management with future collaboration support.
*/
import * as Y from 'yjs'
import type { LayoutOperation } from '@/renderer/core/layout/types'
import type {
Bounds,
NodeId,
NodeLayout,
Point
} from '@/renderer/core/layout/types'
import type { AdapterChange, LayoutAdapter } from './layoutAdapter'
/**
* Yjs implementation of the layout adapter
*/
export class YjsLayoutAdapter implements LayoutAdapter {
private ydoc: Y.Doc
private ynodes: Y.Map<Y.Map<unknown>>
private yoperations: Y.Array<LayoutOperation>
private changeCallbacks = new Set<(change: AdapterChange) => void>()
constructor() {
this.ydoc = new Y.Doc()
this.ynodes = this.ydoc.getMap('nodes')
this.yoperations = this.ydoc.getArray('operations')
// Set up change observation
this.ynodes.observe((event, transaction) => {
const change: AdapterChange = {
type: 'set', // Yjs doesn't distinguish set/delete in observe
nodeIds: [],
actor: transaction.origin as string | undefined
}
// Collect affected node IDs
event.changes.keys.forEach((changeType, key) => {
change.nodeIds.push(key)
if (changeType.action === 'delete') {
change.type = 'delete'
}
})
// Notify subscribers
this.notifyChange(change)
})
}
/**
* Set a node's layout data
*/
setNode(nodeId: NodeId, layout: NodeLayout): void {
const ynode = this.layoutToYNode(layout)
this.ynodes.set(nodeId, ynode)
}
/**
* Get a node's layout data
*/
getNode(nodeId: NodeId): NodeLayout | null {
const ynode = this.ynodes.get(nodeId)
return ynode ? this.yNodeToLayout(ynode) : null
}
/**
* Delete a node
*/
deleteNode(nodeId: NodeId): void {
this.ynodes.delete(nodeId)
}
/**
* Get all nodes
*/
getAllNodes(): Map<NodeId, NodeLayout> {
const result = new Map<NodeId, NodeLayout>()
for (const [nodeId] of this.ynodes) {
const ynode = this.ynodes.get(nodeId)
if (ynode) {
result.set(nodeId, this.yNodeToLayout(ynode))
}
}
return result
}
/**
* Clear all nodes
*/
clear(): void {
this.ynodes.clear()
}
/**
* Add an operation to the log
*/
addOperation(operation: LayoutOperation): void {
this.yoperations.push([operation])
}
/**
* Get operations since a timestamp
*/
getOperationsSince(timestamp: number): LayoutOperation[] {
const operations: LayoutOperation[] = []
this.yoperations.forEach((op) => {
if (op && op.timestamp > timestamp) {
operations.push(op)
}
})
return operations
}
/**
* Get operations by a specific actor
*/
getOperationsByActor(actor: string): LayoutOperation[] {
const operations: LayoutOperation[] = []
this.yoperations.forEach((op) => {
if (op && op.actor === actor) {
operations.push(op)
}
})
return operations
}
/**
* Subscribe to changes
*/
subscribe(callback: (change: AdapterChange) => void): () => void {
this.changeCallbacks.add(callback)
return () => this.changeCallbacks.delete(callback)
}
/**
* Transaction support for atomic updates
*/
transaction(fn: () => void, actor?: string): void {
this.ydoc.transact(fn, actor)
}
/**
* Get the current state vector for sync
*/
getStateVector(): Uint8Array {
return Y.encodeStateVector(this.ydoc)
}
/**
* Get state as update for sending to peers
*/
getStateAsUpdate(): Uint8Array {
return Y.encodeStateAsUpdate(this.ydoc)
}
/**
* Apply updates from remote peers
*/
applyUpdate(update: Uint8Array): void {
Y.applyUpdate(this.ydoc, update)
}
/**
* Convert layout to Yjs structure
*/
private layoutToYNode(layout: NodeLayout): Y.Map<unknown> {
const ynode = new Y.Map<unknown>()
ynode.set('id', layout.id)
ynode.set('position', layout.position)
ynode.set('size', layout.size)
ynode.set('zIndex', layout.zIndex)
ynode.set('visible', layout.visible)
ynode.set('bounds', layout.bounds)
return ynode
}
/**
* Convert Yjs structure to layout
*/
private yNodeToLayout(ynode: Y.Map<unknown>): NodeLayout {
return {
id: ynode.get('id') as string,
position: ynode.get('position') as Point,
size: ynode.get('size') as { width: number; height: number },
zIndex: ynode.get('zIndex') as number,
visible: ynode.get('visible') as boolean,
bounds: ynode.get('bounds') as Bounds
}
}
/**
* Notify all change subscribers
*/
private notifyChange(change: AdapterChange): void {
this.changeCallbacks.forEach((callback) => {
try {
callback(change)
} catch (error) {
console.error('Error in adapter change callback:', error)
}
})
}
}

View File

@@ -1,82 +0,0 @@
/**
* Layout Adapter Interface
*
* Abstracts the underlying CRDT implementation to allow for different
* backends (Yjs, Automerge, etc.) and easier testing.
*/
import type { LayoutOperation } from '@/renderer/core/layout/types'
import type { NodeId, NodeLayout } from '@/renderer/core/layout/types'
/**
* Change event emitted by the adapter
*/
export interface AdapterChange {
/** Type of change */
type: 'set' | 'delete' | 'clear'
/** Affected node IDs */
nodeIds: NodeId[]
/** Actor who made the change */
actor?: string
}
/**
* Layout adapter interface for CRDT abstraction
*/
export interface LayoutAdapter {
/**
* Set a node's layout data
*/
setNode(nodeId: NodeId, layout: NodeLayout): void
/**
* Get a node's layout data
*/
getNode(nodeId: NodeId): NodeLayout | null
/**
* Delete a node
*/
deleteNode(nodeId: NodeId): void
/**
* Get all nodes
*/
getAllNodes(): Map<NodeId, NodeLayout>
/**
* Clear all nodes
*/
clear(): void
/**
* Add an operation to the log
*/
addOperation(operation: LayoutOperation): void
/**
* Get operations since a timestamp
*/
getOperationsSince(timestamp: number): LayoutOperation[]
/**
* Get operations by a specific actor
*/
getOperationsByActor(actor: string): LayoutOperation[]
/**
* Subscribe to changes
*/
subscribe(callback: (change: AdapterChange) => void): () => void
/**
* Transaction support for atomic updates
*/
transaction(fn: () => void, actor?: string): void
/**
* Network sync methods (for future use)
*/
getStateVector(): Uint8Array
getStateAsUpdate(): Uint8Array
applyUpdate(update: Uint8Array): void
}

View File

@@ -4,6 +4,7 @@
* Centralized configuration values for the layout system.
* These values control spatial indexing, performance, and behavior.
*/
import { LayoutSource } from '@/renderer/core/layout/types'
/**
* QuadTree configuration for spatial indexing
@@ -57,5 +58,5 @@ export const ACTOR_CONFIG = {
/** Length of random suffix for actor IDs */
ID_LENGTH: 9,
/** Default source when not specified */
DEFAULT_SOURCE: 'external' as const
DEFAULT_SOURCE: LayoutSource.External
} as const

View File

@@ -4,20 +4,25 @@
* Provides a clean API for layout operations that are CRDT-ready.
* Operations are synchronous and applied directly to the store.
*/
import log from 'loglevel'
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
import type {
LayoutMutations,
NodeId,
NodeLayout,
Point,
Size
import {
type LayoutMutations,
LayoutSource,
type NodeId,
type NodeLayout,
type Point,
type Size
} from '@/renderer/core/layout/types'
const logger = log.getLogger('LayoutMutations')
class LayoutMutationsImpl implements LayoutMutations {
/**
* Set the current mutation source
*/
setSource(source: 'canvas' | 'vue' | 'external'): void {
setSource(source: LayoutSource): void {
layoutStore.setSource(source)
}
@@ -37,6 +42,7 @@ class LayoutMutationsImpl implements LayoutMutations {
layoutStore.applyOperation({
type: 'moveNode',
entity: 'node',
nodeId,
position,
previousPosition: existing.position,
@@ -55,6 +61,7 @@ class LayoutMutationsImpl implements LayoutMutations {
layoutStore.applyOperation({
type: 'resizeNode',
entity: 'node',
nodeId,
size,
previousSize: existing.size,
@@ -73,6 +80,7 @@ class LayoutMutationsImpl implements LayoutMutations {
layoutStore.applyOperation({
type: 'setNodeZIndex',
entity: 'node',
nodeId,
zIndex,
previousZIndex: existing.zIndex,
@@ -102,6 +110,7 @@ class LayoutMutationsImpl implements LayoutMutations {
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout: fullLayout,
timestamp: Date.now(),
@@ -119,6 +128,7 @@ class LayoutMutationsImpl implements LayoutMutations {
layoutStore.applyOperation({
type: 'deleteNode',
entity: 'node',
nodeId,
previousLayout: existing,
timestamp: Date.now(),
@@ -144,6 +154,122 @@ class LayoutMutationsImpl implements LayoutMutations {
// Set this node's z-index to be one higher than the current max
this.setNodeZIndex(nodeId, maxZIndex + 1)
}
/**
* Create a new link
*/
createLink(
linkId: string | number,
sourceNodeId: string | number,
sourceSlot: number,
targetNodeId: string | number,
targetSlot: number
): void {
// Normalize node IDs to strings
const normalizedSourceNodeId = String(sourceNodeId)
const normalizedTargetNodeId = String(targetNodeId)
logger.debug('Creating link:', {
linkId: Number(linkId),
from: `${normalizedSourceNodeId}[${sourceSlot}]`,
to: `${normalizedTargetNodeId}[${targetSlot}]`
})
layoutStore.applyOperation({
type: 'createLink',
entity: 'link',
linkId: Number(linkId),
sourceNodeId: normalizedSourceNodeId,
sourceSlot,
targetNodeId: normalizedTargetNodeId,
targetSlot,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Delete a link
*/
deleteLink(linkId: string | number): void {
logger.debug('Deleting link:', Number(linkId))
layoutStore.applyOperation({
type: 'deleteLink',
entity: 'link',
linkId: Number(linkId),
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Create a new reroute
*/
createReroute(
rerouteId: string | number,
position: Point,
parentId?: string | number,
linkIds: (string | number)[] = []
): void {
logger.debug('Creating reroute:', {
rerouteId: Number(rerouteId),
position,
parentId: parentId != null ? Number(parentId) : undefined,
linkCount: linkIds.length
})
layoutStore.applyOperation({
type: 'createReroute',
entity: 'reroute',
rerouteId: Number(rerouteId),
position,
parentId: parentId != null ? Number(parentId) : undefined,
linkIds: linkIds.map((id) => Number(id)),
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Delete a reroute
*/
deleteReroute(rerouteId: string | number): void {
logger.debug('Deleting reroute:', Number(rerouteId))
layoutStore.applyOperation({
type: 'deleteReroute',
entity: 'reroute',
rerouteId: Number(rerouteId),
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Move a reroute
*/
moveReroute(
rerouteId: string | number,
position: Point,
previousPosition: Point
): void {
logger.debug('Moving reroute:', {
rerouteId: Number(rerouteId),
from: previousPosition,
to: position
})
layoutStore.applyOperation({
type: 'moveReroute',
entity: 'reroute',
rerouteId: Number(rerouteId),
position,
previousPosition,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
}
// Create singleton instance

View File

@@ -0,0 +1,76 @@
/**
* Slot identifier utilities for consistent slot key generation and parsing
*
* Provides a centralized interface for slot identification across the layout system
*
* @TODO Replace this concatenated string with root cause fix
*/
export interface SlotIdentifier {
nodeId: string
index: number
isInput: boolean
}
/**
* Generate a unique key for a slot
* Format: "{nodeId}-{in|out}-{index}"
*/
export function getSlotKey(identifier: SlotIdentifier): string
export function getSlotKey(
nodeId: string,
index: number,
isInput: boolean
): string
export function getSlotKey(
nodeIdOrIdentifier: string | SlotIdentifier,
index?: number,
isInput?: boolean
): string {
if (typeof nodeIdOrIdentifier === 'object') {
const { nodeId, index, isInput } = nodeIdOrIdentifier
return `${nodeId}-${isInput ? 'in' : 'out'}-${index}`
}
if (index === undefined || isInput === undefined) {
throw new Error('Missing required parameters for slot key generation')
}
return `${nodeIdOrIdentifier}-${isInput ? 'in' : 'out'}-${index}`
}
/**
* Parse a slot key back into its components
*/
export function parseSlotKey(key: string): SlotIdentifier | null {
const match = key.match(/^(.+)-(in|out)-(\d+)$/)
if (!match) return null
return {
nodeId: match[1],
isInput: match[2] === 'in',
index: parseInt(match[3], 10)
}
}
/**
* Check if a key represents an input slot
*/
export function isInputSlotKey(key: string): boolean {
return key.includes('-in-')
}
/**
* Check if a key represents an output slot
*/
export function isOutputSlotKey(key: string): boolean {
return key.includes('-out-')
}
/**
* Get the node ID from a slot key
*/
export function getNodeIdFromSlotKey(key: string): string | null {
const parsed = parseSlotKey(key)
return parsed?.nodeId ?? null
}

View File

@@ -0,0 +1,75 @@
/**
* Slot Registration
*
* Handles registration of slot layouts with the layout store for hit testing.
* This module manages the state mutation side of slot layout management,
* while pure calculations are handled separately in SlotCalculations.ts.
*/
import type { Point } from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
type SlotPositionContext,
calculateInputSlotPos,
calculateOutputSlotPos
} from '@/renderer/core/canvas/litegraph/SlotCalculations'
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
import type { SlotLayout } from '@/renderer/core/layout/types'
import { getSlotKey } from './SlotIdentifier'
/**
* Register slot layout with the layout store for hit testing
* @param nodeId The node ID
* @param slotIndex The slot index
* @param isInput Whether this is an input slot
* @param position The slot position in graph coordinates
*/
export function registerSlotLayout(
nodeId: string,
slotIndex: number,
isInput: boolean,
position: Point
): void {
const slotKey = getSlotKey(nodeId, slotIndex, isInput)
// Calculate bounds for the slot using LiteGraph's standard slot height
const slotSize = LiteGraph.NODE_SLOT_HEIGHT
const halfSize = slotSize / 2
const slotLayout: SlotLayout = {
nodeId,
index: slotIndex,
type: isInput ? 'input' : 'output',
position: { x: position[0], y: position[1] },
bounds: {
x: position[0] - halfSize,
y: position[1] - halfSize,
width: slotSize,
height: slotSize
}
}
layoutStore.updateSlotLayout(slotKey, slotLayout)
}
/**
* Register all slots for a node
* @param nodeId The node ID
* @param context The slot position context
*/
export function registerNodeSlots(
nodeId: string,
context: SlotPositionContext
): void {
// Register input slots
context.inputs.forEach((_, index) => {
const position = calculateInputSlotPos(context, index)
registerSlotLayout(nodeId, index, true, position)
})
// Register output slots
context.outputs.forEach((_, index) => {
const position = calculateOutputSlotPos(context, index)
registerSlotLayout(nodeId, index, false, position)
})
}

View File

@@ -0,0 +1,228 @@
/**
* DOM-based slot registration with performance optimization
*
* Measures the actual DOM position of a Vue slot connector and registers it
* into the LayoutStore so hit-testing and link rendering use the true position.
*
* Performance strategy:
* - Cache slot offset relative to node (avoids DOM reads during drag)
* - No measurements during pan/zoom (camera transforms don't change canvas coords)
* - Batch DOM reads via requestAnimationFrame
* - Only remeasure on structural changes (resize, collapse, LOD)
*/
import {
type Ref,
type WatchStopHandle,
nextTick,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
import type { Point as LayoutPoint } from '@/renderer/core/layout/types'
import { getSlotKey } from './SlotIdentifier'
export type TransformState = {
screenToCanvas: (p: LayoutPoint) => LayoutPoint
}
// Shared RAF queue for batching measurements
const measureQueue = new Set<() => void>()
let rafId: number | null = null
// Track mounted components to prevent execution on unmounted ones
const mountedComponents = new WeakSet<object>()
function scheduleMeasurement(fn: () => void) {
measureQueue.add(fn)
if (rafId === null) {
rafId = requestAnimationFrame(() => {
rafId = null
const batch = Array.from(measureQueue)
measureQueue.clear()
batch.forEach((measure) => measure())
})
}
}
const cleanupFunctions = new WeakMap<
Ref<HTMLElement | null>,
{
stopWatcher?: WatchStopHandle
handleResize?: () => void
}
>()
export function useDomSlotRegistration(
nodeId: string,
slotIndex: number,
isInput: boolean,
transform?: TransformState
) {
// Early return if no nodeId
if (!nodeId || nodeId === '') {
return {
slotElRef: ref<HTMLElement | null>(null),
remeasure: () => {}
}
}
const elRef = ref<HTMLElement | null>(null)
const slotKey = getSlotKey(nodeId, slotIndex, isInput)
// Track if this component is mounted
const componentToken = {}
// Cached offset from node position (avoids DOM reads during drag)
const cachedOffset = ref<LayoutPoint | null>(null)
const lastMeasuredBounds = ref<DOMRect | null>(null)
// Measure DOM and cache offset (expensive, minimize calls)
const measureAndCacheOffset = () => {
// Skip if component was unmounted
if (!mountedComponents.has(componentToken)) return
const el = elRef.value
if (!el || !transform?.screenToCanvas) return
const rect = el.getBoundingClientRect()
// Skip if bounds haven't changed significantly (within 0.5px)
if (lastMeasuredBounds.value) {
const prev = lastMeasuredBounds.value
if (
Math.abs(rect.left - prev.left) < 0.5 &&
Math.abs(rect.top - prev.top) < 0.5 &&
Math.abs(rect.width - prev.width) < 0.5 &&
Math.abs(rect.height - prev.height) < 0.5
) {
return // No significant change - skip update
}
}
lastMeasuredBounds.value = rect
// Center of the visual connector (dot) in screen coords
const centerScreen = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
}
const centerCanvas = transform.screenToCanvas(centerScreen)
// Cache offset from node position for fast updates during drag
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (nodeLayout) {
cachedOffset.value = {
x: centerCanvas.x - nodeLayout.position.x,
y: centerCanvas.y - nodeLayout.position.y
}
}
updateSlotPosition(centerCanvas)
}
// Fast update using cached offset (no DOM read)
const updateFromCachedOffset = () => {
if (!cachedOffset.value) {
// No cached offset yet, need to measure
scheduleMeasurement(measureAndCacheOffset)
return
}
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (!nodeLayout) {
return
}
// Calculate absolute position from node position + cached offset
const centerCanvas = {
x: nodeLayout.position.x + cachedOffset.value.x,
y: nodeLayout.position.y + cachedOffset.value.y
}
updateSlotPosition(centerCanvas)
}
// Update slot position in layout store
const updateSlotPosition = (centerCanvas: LayoutPoint) => {
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
layoutStore.updateSlotLayout(slotKey, {
nodeId,
index: slotIndex,
type: isInput ? 'input' : 'output',
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
})
}
onMounted(async () => {
// Mark component as mounted
mountedComponents.add(componentToken)
// Initial measure after mount
await nextTick()
measureAndCacheOffset()
// Subscribe to node position changes for fast cached updates
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
const stopWatcher = watch(
nodeRef,
(newLayout) => {
if (newLayout) {
// Node moved/resized - update using cached offset
updateFromCachedOffset()
}
},
{ immediate: false }
)
// Store cleanup functions without type assertions
const cleanup = cleanupFunctions.get(elRef) || {}
cleanup.stopWatcher = stopWatcher
// Window resize - remeasure as viewport changed
const handleResize = () => {
scheduleMeasurement(measureAndCacheOffset)
}
window.addEventListener('resize', handleResize, { passive: true })
cleanup.handleResize = handleResize
cleanupFunctions.set(elRef, cleanup)
})
onUnmounted(() => {
// Mark component as unmounted
mountedComponents.delete(componentToken)
// Clean up watchers and listeners
const cleanup = cleanupFunctions.get(elRef)
if (cleanup) {
if (cleanup.stopWatcher) cleanup.stopWatcher()
if (cleanup.handleResize) {
window.removeEventListener('resize', cleanup.handleResize)
}
cleanupFunctions.delete(elRef)
}
// Remove from layout store
layoutStore.deleteSlotLayout(slotKey)
// Remove from measurement queue if pending
measureQueue.delete(measureAndCacheOffset)
})
return {
slotElRef: elRef,
// Expose for forced remeasure on structural changes
remeasure: () => scheduleMeasurement(measureAndCacheOffset)
}
}

View File

@@ -4,41 +4,58 @@
* Uses Yjs for efficient local state management and future collaboration.
* CRDT ensures conflict-free operations for both single and multi-user scenarios.
*/
import log from 'loglevel'
import { type ComputedRef, type Ref, computed, customRef } from 'vue'
import * as Y from 'yjs'
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
import type {
CreateLinkOperation,
CreateNodeOperation,
CreateRerouteOperation,
DeleteLinkOperation,
DeleteNodeOperation,
DeleteRerouteOperation,
LayoutOperation,
MoveNodeOperation,
MoveRerouteOperation,
ResizeNodeOperation,
SetNodeZIndexOperation
} from '@/renderer/core/layout/types'
import type {
Bounds,
LayoutChange,
LayoutStore,
NodeId,
NodeLayout,
Point
import {
type Bounds,
type LayoutChange,
LayoutSource,
type LayoutStore,
type LinkId,
type LinkLayout,
type LinkSegmentLayout,
type NodeId,
type NodeLayout,
type Point,
type RerouteId,
type RerouteLayout,
type SlotLayout
} from '@/renderer/core/layout/types'
import { SpatialIndexManager } from '@/renderer/core/spatial/SpatialIndex'
const logger = log.getLogger('LayoutStore')
class LayoutStoreImpl implements LayoutStore {
// Yjs document and shared data structures
private ydoc = new Y.Doc()
private ynodes: Y.Map<Y.Map<unknown>> // Maps nodeId -> Y.Map containing NodeLayout data
private ylinks: Y.Map<Y.Map<unknown>> // Maps linkId -> Y.Map containing link data
private yreroutes: Y.Map<Y.Map<unknown>> // Maps rerouteId -> Y.Map containing reroute data
private yoperations: Y.Array<LayoutOperation> // Operation log
// Vue reactivity layer
private version = 0
private currentSource: 'canvas' | 'vue' | 'external' =
ACTOR_CONFIG.DEFAULT_SOURCE
private currentSource: LayoutSource =
ACTOR_CONFIG.DEFAULT_SOURCE as LayoutSource
private currentActor = `${ACTOR_CONFIG.USER_PREFIX}${Math.random()
.toString(36)
.substr(2, ACTOR_CONFIG.ID_LENGTH)}`
.substring(2, 2 + ACTOR_CONFIG.ID_LENGTH)}`
// Change listeners
private changeListeners = new Set<(change: LayoutChange) => void>()
@@ -47,16 +64,30 @@ class LayoutStoreImpl implements LayoutStore {
private nodeRefs = new Map<NodeId, Ref<NodeLayout | null>>()
private nodeTriggers = new Map<NodeId, () => void>()
// Spatial index manager
private spatialIndex: SpatialIndexManager
// New data structures for hit testing
private linkLayouts = new Map<LinkId, LinkLayout>()
private linkSegmentLayouts = new Map<string, LinkSegmentLayout>() // Internal string key: ${linkId}:${rerouteId ?? 'final'}
private slotLayouts = new Map<string, SlotLayout>()
private rerouteLayouts = new Map<RerouteId, RerouteLayout>()
// Spatial index managers
private spatialIndex: SpatialIndexManager // For nodes
private linkSegmentSpatialIndex: SpatialIndexManager // For link segments (single index for all link geometry)
private slotSpatialIndex: SpatialIndexManager // For slots
private rerouteSpatialIndex: SpatialIndexManager // For reroutes
constructor() {
// Initialize Yjs data structures
this.ynodes = this.ydoc.getMap('nodes')
this.ylinks = this.ydoc.getMap('links')
this.yreroutes = this.ydoc.getMap('reroutes')
this.yoperations = this.ydoc.getArray('operations')
// Initialize spatial index manager
// Initialize spatial index managers
this.spatialIndex = new SpatialIndexManager()
this.linkSegmentSpatialIndex = new SpatialIndexManager() // Single index for all link geometry
this.slotSpatialIndex = new SpatialIndexManager()
this.rerouteSpatialIndex = new SpatialIndexManager()
// Listen for Yjs changes and trigger Vue reactivity
this.ynodes.observe((event) => {
@@ -70,6 +101,65 @@ class LayoutStoreImpl implements LayoutStore {
}
})
})
// Listen for link changes and update spatial indexes
this.ylinks.observe((event) => {
this.version++
event.changes.keys.forEach((change, linkIdStr) => {
const linkId = Number(linkIdStr) as LinkId
if (change.action === 'delete') {
this.linkLayouts.delete(linkId)
// Clean up any segment layouts for this link
const keysToDelete: string[] = []
for (const [key] of this.linkSegmentLayouts) {
if (key.startsWith(`${linkId}:`)) {
keysToDelete.push(key)
}
}
for (const key of keysToDelete) {
this.linkSegmentLayouts.delete(key)
this.linkSegmentSpatialIndex.remove(key)
}
} else {
// Link was added or updated - geometry will be computed separately
// This just tracks that the link exists in CRDT
}
})
})
// Listen for reroute changes and update spatial indexes
this.yreroutes.observe((event) => {
this.version++
event.changes.keys.forEach((change, rerouteIdStr) => {
// Yjs Map keys are strings, convert to number for layout operations
const rerouteId = Number(rerouteIdStr) as RerouteId
if (change.action === 'delete') {
this.rerouteLayouts.delete(rerouteId) // Use numeric ID for layout map
this.rerouteSpatialIndex.remove(rerouteIdStr) // Use string for spatial index
} else if (change.action === 'update' || change.action === 'add') {
const rerouteData = this.yreroutes.get(rerouteIdStr) // Use string for Yjs
if (rerouteData) {
const pos = rerouteData.get('position') as Point
if (pos) {
// Update reroute layout when position changes
const layout: RerouteLayout = {
id: rerouteId, // Use numeric ID
position: pos,
radius: 8,
bounds: {
x: pos.x - 8,
y: pos.y - 8,
width: 16,
height: 16
}
}
this.updateRerouteLayout(rerouteId, layout)
}
}
}
})
})
}
/**
@@ -97,6 +187,7 @@ class LayoutStoreImpl implements LayoutStore {
if (existing) {
this.applyOperation({
type: 'deleteNode',
entity: 'node',
nodeId,
timestamp: Date.now(),
source: this.currentSource,
@@ -111,6 +202,7 @@ class LayoutStoreImpl implements LayoutStore {
// Create operation
this.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout: newLayout,
timestamp: Date.now(),
@@ -127,6 +219,7 @@ class LayoutStoreImpl implements LayoutStore {
) {
this.applyOperation({
type: 'moveNode',
entity: 'node',
nodeId,
position: newLayout.position,
previousPosition: existingLayout.position,
@@ -141,6 +234,7 @@ class LayoutStoreImpl implements LayoutStore {
) {
this.applyOperation({
type: 'resizeNode',
entity: 'node',
nodeId,
size: newLayout.size,
previousSize: existingLayout.size,
@@ -152,6 +246,7 @@ class LayoutStoreImpl implements LayoutStore {
if (existingLayout.zIndex !== newLayout.zIndex) {
this.applyOperation({
type: 'setNodeZIndex',
entity: 'node',
nodeId,
zIndex: newLayout.zIndex,
previousZIndex: existingLayout.zIndex,
@@ -259,6 +354,414 @@ class LayoutStoreImpl implements LayoutStore {
return this.spatialIndex.query(bounds)
}
/**
* Update link layout data (for geometry/debug, no separate spatial index)
*/
updateLinkLayout(linkId: LinkId, layout: LinkLayout): void {
const existing = this.linkLayouts.get(linkId)
// Short-circuit if bounds and centerPos unchanged
if (
existing &&
existing.bounds.x === layout.bounds.x &&
existing.bounds.y === layout.bounds.y &&
existing.bounds.width === layout.bounds.width &&
existing.bounds.height === layout.bounds.height &&
existing.centerPos.x === layout.centerPos.x &&
existing.centerPos.y === layout.centerPos.y
) {
// Only update path if provided (for hit detection)
if (layout.path) {
existing.path = layout.path
}
return
}
this.linkLayouts.set(linkId, layout)
}
/**
* Delete link layout data
*/
deleteLinkLayout(linkId: LinkId): void {
const deleted = this.linkLayouts.delete(linkId)
if (deleted) {
// Clean up any segment layouts for this link
const keysToDelete: string[] = []
for (const [key] of this.linkSegmentLayouts) {
if (key.startsWith(`${linkId}:`)) {
keysToDelete.push(key)
}
}
for (const key of keysToDelete) {
this.linkSegmentLayouts.delete(key)
this.linkSegmentSpatialIndex.remove(key)
}
}
}
/**
* Update slot layout data
*/
updateSlotLayout(key: string, layout: SlotLayout): void {
const existing = this.slotLayouts.get(key)
if (!existing) {
logger.debug('Adding slot:', {
nodeId: layout.nodeId,
type: layout.type,
index: layout.index,
bounds: layout.bounds
})
}
if (existing) {
// Update spatial index
this.slotSpatialIndex.update(key, layout.bounds)
} else {
// Insert into spatial index
this.slotSpatialIndex.insert(key, layout.bounds)
}
this.slotLayouts.set(key, layout)
}
/**
* Delete slot layout data
*/
deleteSlotLayout(key: string): void {
const deleted = this.slotLayouts.delete(key)
if (deleted) {
// Remove from spatial index
this.slotSpatialIndex.remove(key)
}
}
/**
* Delete all slot layouts for a node
*/
deleteNodeSlotLayouts(nodeId: NodeId): void {
const keysToDelete: string[] = []
for (const [key, layout] of this.slotLayouts) {
if (layout.nodeId === nodeId) {
keysToDelete.push(key)
}
}
for (const key of keysToDelete) {
this.slotLayouts.delete(key)
// Remove from spatial index
this.slotSpatialIndex.remove(key)
}
}
/**
* Update reroute layout data
*/
updateRerouteLayout(rerouteId: RerouteId, layout: RerouteLayout): void {
const existing = this.rerouteLayouts.get(rerouteId)
if (!existing) {
logger.debug('Adding reroute layout:', {
rerouteId,
position: layout.position,
bounds: layout.bounds
})
}
if (existing) {
// Update spatial index
this.rerouteSpatialIndex.update(String(rerouteId), layout.bounds) // Spatial index uses strings
} else {
// Insert into spatial index
this.rerouteSpatialIndex.insert(String(rerouteId), layout.bounds) // Spatial index uses strings
}
this.rerouteLayouts.set(rerouteId, layout)
}
/**
* Delete reroute layout data
*/
deleteRerouteLayout(rerouteId: RerouteId): void {
const deleted = this.rerouteLayouts.delete(rerouteId)
if (deleted) {
// Remove from spatial index
this.rerouteSpatialIndex.remove(String(rerouteId)) // Spatial index uses strings
}
}
/**
* Get link layout data
*/
getLinkLayout(linkId: LinkId): LinkLayout | null {
return this.linkLayouts.get(linkId) || null
}
/**
* Get slot layout data
*/
getSlotLayout(key: string): SlotLayout | null {
return this.slotLayouts.get(key) || null
}
/**
* Get reroute layout data
*/
getRerouteLayout(rerouteId: RerouteId): RerouteLayout | null {
return this.rerouteLayouts.get(rerouteId) || null
}
/**
* Helper to create internal key for link segment
*/
private makeLinkSegmentKey(
linkId: LinkId,
rerouteId: RerouteId | null
): string {
return `${linkId}:${rerouteId ?? 'final'}`
}
/**
* Update link segment layout data
*/
updateLinkSegmentLayout(
linkId: LinkId,
rerouteId: RerouteId | null,
layout: Omit<LinkSegmentLayout, 'linkId' | 'rerouteId'>
): void {
const key = this.makeLinkSegmentKey(linkId, rerouteId)
const existing = this.linkSegmentLayouts.get(key)
// Short-circuit if bounds and centerPos unchanged (prevents spatial index churn)
if (
existing &&
existing.bounds.x === layout.bounds.x &&
existing.bounds.y === layout.bounds.y &&
existing.bounds.width === layout.bounds.width &&
existing.bounds.height === layout.bounds.height &&
existing.centerPos.x === layout.centerPos.x &&
existing.centerPos.y === layout.centerPos.y
) {
// Only update path if provided (for hit detection)
if (layout.path) {
existing.path = layout.path
}
return
}
const fullLayout: LinkSegmentLayout = {
...layout,
linkId,
rerouteId
}
if (!existing) {
logger.debug('Adding link segment:', {
linkId,
rerouteId,
bounds: layout.bounds,
hasPath: !!layout.path
})
}
if (existing) {
// Update spatial index
this.linkSegmentSpatialIndex.update(key, layout.bounds)
} else {
// Insert into spatial index
this.linkSegmentSpatialIndex.insert(key, layout.bounds)
}
this.linkSegmentLayouts.set(key, fullLayout)
}
/**
* Delete link segment layout data
*/
deleteLinkSegmentLayout(linkId: LinkId, rerouteId: RerouteId | null): void {
const key = this.makeLinkSegmentKey(linkId, rerouteId)
const deleted = this.linkSegmentLayouts.delete(key)
if (deleted) {
// Remove from spatial index
this.linkSegmentSpatialIndex.remove(key)
}
}
/**
* Query link segment at point (returns structured data)
*/
queryLinkSegmentAtPoint(
point: Point,
ctx?: CanvasRenderingContext2D
): { linkId: LinkId; rerouteId: RerouteId | null } | null {
// Determine tolerance from current canvas state (if available)
// - Use the caller-provided ctx.lineWidth (LGraphCanvas sets this to connections_width + padding)
// - Fall back to a sensible default when ctx is not provided
const hitWidth = ctx?.lineWidth ?? 10
const halfSize = Math.max(10, hitWidth) // keep a minimum window for spatial index
// Use spatial index to get candidate segments
const searchArea = {
x: point.x - halfSize,
y: point.y - halfSize,
width: halfSize * 2,
height: halfSize * 2
}
const candidateKeys = this.linkSegmentSpatialIndex.query(searchArea)
if (candidateKeys.length > 0) {
logger.debug('Checking link segments at point:', {
point,
candidateCount: candidateKeys.length,
tolerance: hitWidth
})
}
// Precise hit test only on candidates
for (const key of candidateKeys) {
const segmentLayout = this.linkSegmentLayouts.get(key)
if (!segmentLayout) continue
if (ctx && segmentLayout.path) {
// Match LiteGraph behavior: hit test uses device pixel ratio for coordinates
const dpi =
(typeof window !== 'undefined' && window?.devicePixelRatio) || 1
const hit = ctx.isPointInStroke(
segmentLayout.path,
point.x * dpi,
point.y * dpi
)
if (hit) {
logger.debug('Link segment hit:', {
linkId: segmentLayout.linkId,
rerouteId: segmentLayout.rerouteId,
point
})
return {
linkId: segmentLayout.linkId,
rerouteId: segmentLayout.rerouteId
}
}
} else if (this.pointInBounds(point, segmentLayout.bounds)) {
// Fallback to bounding box test
return {
linkId: segmentLayout.linkId,
rerouteId: segmentLayout.rerouteId
}
}
}
return null
}
/**
* Query link at point (derived from segment query)
*/
queryLinkAtPoint(
point: Point,
ctx?: CanvasRenderingContext2D
): LinkId | null {
// Invoke segment query and return just the linkId
const segment = this.queryLinkSegmentAtPoint(point, ctx)
return segment ? segment.linkId : null
}
/**
* Query slot at point
*/
querySlotAtPoint(point: Point): SlotLayout | null {
// Use spatial index to get candidate slots
const searchArea = {
x: point.x - 10, // Tolerance for slot size
y: point.y - 10,
width: 20,
height: 20
}
const candidateSlotKeys = this.slotSpatialIndex.query(searchArea)
// Check precise bounds for candidates
for (const key of candidateSlotKeys) {
const slotLayout = this.slotLayouts.get(key)
if (slotLayout && this.pointInBounds(point, slotLayout.bounds)) {
return slotLayout
}
}
return null
}
/**
* Query reroute at point
*/
queryRerouteAtPoint(point: Point): RerouteLayout | null {
// Use spatial index to get candidate reroutes
const maxRadius = 20 // Maximum expected reroute radius
const searchArea = {
x: point.x - maxRadius,
y: point.y - maxRadius,
width: maxRadius * 2,
height: maxRadius * 2
}
const candidateRerouteKeys = this.rerouteSpatialIndex.query(searchArea)
if (candidateRerouteKeys.length > 0) {
logger.debug('Checking reroutes at point:', {
point,
candidateCount: candidateRerouteKeys.length
})
}
// Check precise distance for candidates
for (const rerouteKey of candidateRerouteKeys) {
const rerouteId = Number(rerouteKey) as RerouteId // Convert string key back to numeric
const rerouteLayout = this.rerouteLayouts.get(rerouteId)
if (rerouteLayout) {
const dx = point.x - rerouteLayout.position.x
const dy = point.y - rerouteLayout.position.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance <= rerouteLayout.radius) {
logger.debug('Reroute hit:', {
rerouteId: rerouteLayout.id,
position: rerouteLayout.position,
distance
})
return rerouteLayout
}
}
}
return null
}
/**
* Query all items in bounds
*/
queryItemsInBounds(bounds: Bounds): {
nodes: NodeId[]
links: LinkId[]
slots: string[]
reroutes: RerouteId[]
} {
// Query segments and union their linkIds
const segmentKeys = this.linkSegmentSpatialIndex.query(bounds)
const linkIds = new Set<LinkId>()
for (const key of segmentKeys) {
const segment = this.linkSegmentLayouts.get(key)
if (segment) {
linkIds.add(segment.linkId)
}
}
return {
nodes: this.queryNodesInBounds(bounds),
links: Array.from(linkIds),
slots: this.slotSpatialIndex.query(bounds),
reroutes: this.rerouteSpatialIndex
.query(bounds)
.map((key) => Number(key) as RerouteId) // Convert string keys to numeric
}
}
/**
* Apply a layout operation using Yjs transactions
*/
@@ -308,6 +811,21 @@ class LayoutStoreImpl implements LayoutStore {
case 'deleteNode':
this.handleDeleteNode(operation as DeleteNodeOperation, change)
break
case 'createLink':
this.handleCreateLink(operation as CreateLinkOperation, change)
break
case 'deleteLink':
this.handleDeleteLink(operation as DeleteLinkOperation, change)
break
case 'createReroute':
this.handleCreateReroute(operation as CreateRerouteOperation, change)
break
case 'deleteReroute':
this.handleDeleteReroute(operation as DeleteRerouteOperation, change)
break
case 'moveReroute':
this.handleMoveReroute(operation as MoveRerouteOperation, change)
break
}
}
@@ -342,7 +860,7 @@ class LayoutStoreImpl implements LayoutStore {
/**
* Set the current operation source
*/
setSource(source: 'canvas' | 'vue' | 'external'): void {
setSource(source: LayoutSource): void {
this.currentSource = source
}
@@ -356,7 +874,7 @@ class LayoutStoreImpl implements LayoutStore {
/**
* Get the current operation source
*/
getCurrentSource(): 'canvas' | 'vue' | 'external' {
getCurrentSource(): LayoutSource {
return this.currentSource
}
@@ -378,6 +896,13 @@ class LayoutStoreImpl implements LayoutStore {
this.nodeRefs.clear()
this.nodeTriggers.clear()
this.spatialIndex.clear()
this.linkSegmentSpatialIndex.clear()
this.slotSpatialIndex.clear()
this.rerouteSpatialIndex.clear()
this.linkLayouts.clear()
this.linkSegmentLayouts.clear()
this.slotLayouts.clear()
this.rerouteLayouts.clear()
nodes.forEach((node, index) => {
const layout: NodeLayout = {
@@ -487,10 +1012,110 @@ class LayoutStoreImpl implements LayoutStore {
// Remove from spatial index
this.spatialIndex.remove(operation.nodeId)
// Clean up associated slot layouts
this.deleteNodeSlotLayouts(operation.nodeId)
change.type = 'delete'
change.nodeIds.push(operation.nodeId)
}
private handleCreateLink(
operation: CreateLinkOperation,
change: LayoutChange
): void {
const linkData = new Y.Map<unknown>()
linkData.set('id', operation.linkId)
linkData.set('sourceNodeId', operation.sourceNodeId)
linkData.set('sourceSlot', operation.sourceSlot)
linkData.set('targetNodeId', operation.targetNodeId)
linkData.set('targetSlot', operation.targetSlot)
this.ylinks.set(String(operation.linkId), linkData)
// Link geometry will be computed separately when nodes move
// This just tracks that the link exists
change.type = 'create'
}
private handleDeleteLink(
operation: DeleteLinkOperation,
change: LayoutChange
): void {
if (!this.ylinks.has(String(operation.linkId))) return
this.ylinks.delete(String(operation.linkId))
this.linkLayouts.delete(operation.linkId)
// Clean up any segment layouts for this link
const keysToDelete: string[] = []
for (const [key] of this.linkSegmentLayouts) {
if (key.startsWith(`${operation.linkId}:`)) {
keysToDelete.push(key)
}
}
for (const key of keysToDelete) {
this.linkSegmentLayouts.delete(key)
this.linkSegmentSpatialIndex.remove(key)
}
change.type = 'delete'
}
private handleCreateReroute(
operation: CreateRerouteOperation,
change: LayoutChange
): void {
const rerouteData = new Y.Map<unknown>()
rerouteData.set('id', operation.rerouteId)
rerouteData.set('position', operation.position)
rerouteData.set('parentId', operation.parentId)
rerouteData.set('linkIds', operation.linkIds)
this.yreroutes.set(String(operation.rerouteId), rerouteData) // Yjs Map keys must be strings
// The observer will automatically update the spatial index
change.type = 'create'
}
private handleDeleteReroute(
operation: DeleteRerouteOperation,
change: LayoutChange
): void {
if (!this.yreroutes.has(String(operation.rerouteId))) return // Yjs Map keys are strings
this.yreroutes.delete(String(operation.rerouteId)) // Yjs Map keys are strings
this.rerouteLayouts.delete(operation.rerouteId) // Layout map uses numeric ID
this.rerouteSpatialIndex.remove(String(operation.rerouteId)) // Spatial index uses strings
change.type = 'delete'
}
private handleMoveReroute(
operation: MoveRerouteOperation,
change: LayoutChange
): void {
const yreroute = this.yreroutes.get(String(operation.rerouteId)) // Yjs Map keys are strings
if (!yreroute) return
yreroute.set('position', operation.position)
const pos = operation.position
const layout: RerouteLayout = {
id: operation.rerouteId,
position: pos,
radius: 8,
bounds: {
x: pos.x - 8,
y: pos.y - 8,
width: 16,
height: 16
}
}
this.updateRerouteLayout(operation.rerouteId, layout)
// Mark as update for listeners
change.type = 'update'
}
/**
* Update node bounds helper
*/

View File

@@ -0,0 +1,365 @@
/**
* Composable for event-driven link layout synchronization
*
* Implements event-driven link layout updates decoupled from the render cycle.
* Updates link geometry only when it actually changes (node move/resize, link create/delete,
* reroute create/delete/move, collapse toggles).
*/
import log from 'loglevel'
import { onUnmounted } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LLink } from '@/lib/litegraph/src/LLink'
import { Reroute } from '@/lib/litegraph/src/Reroute'
import type { ReadOnlyPoint } from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/LitegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/LitegraphLinkAdapter'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/SlotCalculations'
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
import type { LayoutChange } from '@/renderer/core/layout/types'
const logger = log.getLogger('useLinkLayoutSync')
/**
* Composable for managing link layout synchronization
*/
export function useLinkLayoutSync() {
let canvas: LGraphCanvas | null = null
let graph: LGraph | null = null
let offscreenCtx: CanvasRenderingContext2D | null = null
let adapter: LitegraphLinkAdapter | null = null
let unsubscribeLayoutChange: (() => void) | null = null
let restoreHandlers: (() => void) | null = null
/**
* Build link render context from canvas properties
*/
function buildLinkRenderContext(): LinkRenderContext {
if (!canvas) {
throw new Error('Canvas not initialized')
}
return {
// Canvas settings
renderMode: canvas.links_render_mode,
connectionWidth: canvas.connections_width,
renderBorder: canvas.render_connections_border,
lowQuality: canvas.low_quality,
highQualityRender: canvas.highquality_render,
scale: canvas.ds.scale,
linkMarkerShape: canvas.linkMarkerShape,
renderConnectionArrows: canvas.render_connection_arrows,
// State
highlightedLinks: new Set(Object.keys(canvas.highlighted_links)),
// Colors
defaultLinkColor: canvas.default_link_color,
linkTypeColors: (canvas.constructor as any).link_type_colors || {},
// Pattern for disabled links
disabledPattern: canvas._pattern
}
}
/**
* Recompute a single link and all its segments
*
* Note: This logic mirrors LGraphCanvas#renderAllLinkSegments but:
* - Works with offscreen context for event-driven updates
* - No visibility checks (always computes full geometry)
* - No dragging state handling (pure geometry computation)
*/
function recomputeLinkById(linkId: number): void {
if (!graph || !adapter || !offscreenCtx || !canvas) return
const link = graph.links.get(linkId)
if (!link || link.id === -1) return // Skip floating/temp links
// Get source and target nodes
const sourceNode = graph.getNodeById(link.origin_id)
const targetNode = graph.getNodeById(link.target_id)
if (!sourceNode || !targetNode) return
// Get slots
const sourceSlot = sourceNode.outputs?.[link.origin_slot]
const targetSlot = targetNode.inputs?.[link.target_slot]
if (!sourceSlot || !targetSlot) return
// Get positions
const startPos = getSlotPosition(sourceNode, link.origin_slot, false)
const endPos = getSlotPosition(targetNode, link.target_slot, true)
// Get directions
const startDir = sourceSlot.dir || LinkDirection.RIGHT
const endDir = targetSlot.dir || LinkDirection.LEFT
// Get reroutes for this link
const reroutes = LLink.getReroutes(graph, link)
// Build render context
const context = buildLinkRenderContext()
if (reroutes.length > 0) {
// Render segmented link with reroutes
let segmentStartPos = startPos
let segmentStartDir = startDir
for (let i = 0; i < reroutes.length; i++) {
const reroute = reroutes[i]
// Calculate reroute angle
reroute.calculateAngle(Date.now(), graph, [
segmentStartPos[0],
segmentStartPos[1]
])
// Calculate control points
const distance = Math.sqrt(
(reroute.pos[0] - segmentStartPos[0]) ** 2 +
(reroute.pos[1] - segmentStartPos[1]) ** 2
)
const dist = Math.min(Reroute.maxSplineOffset, distance * 0.25)
// Special handling for floating input chain
const isFloatingInputChain = !sourceNode && targetNode
const startControl: ReadOnlyPoint = isFloatingInputChain
? [0, 0]
: [dist * reroute.cos, dist * reroute.sin]
// Render segment to this reroute
adapter.renderLinkDirect(
offscreenCtx,
segmentStartPos,
reroute.pos,
link,
true, // skip_border
0, // flow
null, // color
segmentStartDir,
LinkDirection.CENTER,
context,
{
startControl,
endControl: reroute.controlPoint,
reroute,
disabled: false
}
)
// Prepare for next segment
segmentStartPos = reroute.pos
segmentStartDir = LinkDirection.CENTER
}
// Render final segment from last reroute to target
const lastReroute = reroutes[reroutes.length - 1]
const finalDistance = Math.sqrt(
(endPos[0] - lastReroute.pos[0]) ** 2 +
(endPos[1] - lastReroute.pos[1]) ** 2
)
const finalDist = Math.min(Reroute.maxSplineOffset, finalDistance * 0.25)
const finalStartControl: ReadOnlyPoint = [
finalDist * lastReroute.cos,
finalDist * lastReroute.sin
]
adapter.renderLinkDirect(
offscreenCtx,
lastReroute.pos,
endPos,
link,
true, // skip_border
0, // flow
null, // color
LinkDirection.CENTER,
endDir,
context,
{
startControl: finalStartControl,
disabled: false
}
)
} else {
// No reroutes - render direct link
adapter.renderLinkDirect(
offscreenCtx,
startPos,
endPos,
link,
true, // skip_border
0, // flow
null, // color
startDir,
endDir,
context,
{
disabled: false
}
)
}
}
/**
* Recompute all links connected to a node
*/
function recomputeLinksForNode(nodeId: number): void {
if (!graph) return
const node = graph.getNodeById(nodeId)
if (!node) return
const linkIds = new Set<number>()
// Collect output links
if (node.outputs) {
for (const output of node.outputs) {
if (output.links) {
for (const linkId of output.links) {
linkIds.add(linkId)
}
}
}
}
// Collect input links
if (node.inputs) {
for (const input of node.inputs) {
if (input.link !== null && input.link !== undefined) {
linkIds.add(input.link)
}
}
}
// Recompute each link
for (const linkId of linkIds) {
recomputeLinkById(linkId)
}
}
/**
* Recompute all links associated with a reroute
*/
function recomputeLinksForReroute(rerouteId: number): void {
if (!graph) return
const reroute = graph.reroutes.get(rerouteId)
if (!reroute) return
// Recompute all links that pass through this reroute
for (const linkId of reroute.linkIds) {
recomputeLinkById(linkId)
}
}
/**
* Start link layout sync with event-driven functionality
*/
function start(canvasInstance: LGraphCanvas): void {
canvas = canvasInstance
graph = canvas.graph
if (!graph) return
// Create offscreen canvas context
const offscreenCanvas = document.createElement('canvas')
offscreenCtx = offscreenCanvas.getContext('2d')
if (!offscreenCtx) {
logger.error('Failed to create offscreen canvas context')
return
}
// Create dedicated adapter with layout writes enabled
adapter = new LitegraphLinkAdapter(graph)
adapter.enableLayoutStoreWrites = true
// Initial computation for all existing links
for (const link of graph._links.values()) {
if (link.id !== -1) {
recomputeLinkById(link.id)
}
}
// Subscribe to layout store changes
unsubscribeLayoutChange = layoutStore.onChange((change: LayoutChange) => {
switch (change.operation.type) {
case 'moveNode':
case 'resizeNode':
recomputeLinksForNode(parseInt(change.operation.nodeId))
break
case 'createLink':
recomputeLinkById(change.operation.linkId)
break
case 'deleteLink':
// No-op - store already cleaned by existing code
break
case 'createReroute':
case 'deleteReroute':
// Recompute all affected links
if ('linkIds' in change.operation) {
for (const linkId of change.operation.linkIds) {
recomputeLinkById(linkId)
}
}
break
case 'moveReroute':
recomputeLinksForReroute(change.operation.rerouteId)
break
}
})
// Hook collapse events
const origTrigger = graph.onTrigger
graph.onTrigger = (action: string, param: any) => {
if (
action === 'node:property:changed' &&
param?.property === 'flags.collapsed'
) {
const nodeId = parseInt(String(param.nodeId))
if (!isNaN(nodeId)) {
recomputeLinksForNode(nodeId)
}
}
if (origTrigger) {
origTrigger.call(graph, action, param)
}
}
// Store cleanup function
restoreHandlers = () => {
if (graph) {
graph.onTrigger = origTrigger || undefined
}
}
}
/**
* Stop link layout sync and cleanup all resources
*/
function stop(): void {
if (unsubscribeLayoutChange) {
unsubscribeLayoutChange()
unsubscribeLayoutChange = null
}
if (restoreHandlers) {
restoreHandlers()
restoreHandlers = null
}
canvas = null
graph = null
offscreenCtx = null
adapter = null
}
// Auto-cleanup on unmount
onUnmounted(() => {
stop()
})
return {
start,
stop
}
}

View File

@@ -0,0 +1,163 @@
/**
* Composable for managing slot layout registration
*
* Implements event-driven slot registration decoupled from the draw cycle.
* Registers slots once on initial load and keeps them updated when necessary.
*/
import { onUnmounted } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { type SlotPositionContext } from '@/renderer/core/canvas/litegraph/SlotCalculations'
import { registerNodeSlots } from '@/renderer/core/layout/slots/register'
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
/**
* Compute and register slot layouts for a node
* @param node LiteGraph node to process
*/
function computeAndRegisterSlots(node: LGraphNode): void {
const nodeId = String(node.id)
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
// Fallback to live node values if layout not ready
const nodeX = nodeLayout?.position.x ?? node.pos[0]
const nodeY = nodeLayout?.position.y ?? node.pos[1]
const nodeWidth = nodeLayout?.size.width ?? node.size[0]
const nodeHeight = nodeLayout?.size.height ?? node.size[1]
// Ensure concrete slots & arrange when needed for accurate positions
node._setConcreteSlots()
const collapsed = node.flags.collapsed ?? false
if (!collapsed) {
node.arrange()
}
const context: SlotPositionContext = {
nodeX,
nodeY,
nodeWidth,
nodeHeight,
collapsed,
collapsedWidth: node._collapsed_width,
slotStartY: node.constructor.slot_start_y,
inputs: node.inputs,
outputs: node.outputs,
widgets: node.widgets
}
registerNodeSlots(nodeId, context)
}
/**
* Composable for managing slot layout registration
*/
export function useSlotLayoutSync() {
let unsubscribeLayoutChange: (() => void) | null = null
let restoreHandlers: (() => void) | null = null
/**
* Start slot layout sync with full event-driven functionality
* @param canvas LiteGraph canvas instance
*/
function start(canvas: LGraphCanvas): void {
// When Vue nodes are enabled, slot DOM registers exact positions.
// Skip calculated registration to avoid conflicts.
if (LiteGraph.vueNodesMode) {
return
}
const graph = canvas?.graph
if (!graph) return
// Initial registration for all nodes in the current graph
for (const node of graph._nodes) {
computeAndRegisterSlots(node)
}
// Layout changes → recompute slots for changed nodes
unsubscribeLayoutChange = layoutStore.onChange((change) => {
for (const nodeId of change.nodeIds) {
const node = graph.getNodeById(parseInt(nodeId))
if (node) {
computeAndRegisterSlots(node)
}
}
})
// LiteGraph event hooks
const origNodeAdded = graph.onNodeAdded
const origNodeRemoved = graph.onNodeRemoved
const origTrigger = graph.onTrigger
const origAfterChange = graph.onAfterChange
graph.onNodeAdded = (node: LGraphNode) => {
computeAndRegisterSlots(node)
if (origNodeAdded) {
origNodeAdded.call(graph, node)
}
}
graph.onNodeRemoved = (node: LGraphNode) => {
layoutStore.deleteNodeSlotLayouts(String(node.id))
if (origNodeRemoved) {
origNodeRemoved.call(graph, node)
}
}
graph.onTrigger = (action: string, param: any) => {
if (
action === 'node:property:changed' &&
param?.property === 'flags.collapsed'
) {
const node = graph.getNodeById(parseInt(String(param.nodeId)))
if (node) {
computeAndRegisterSlots(node)
}
}
if (origTrigger) {
origTrigger.call(graph, action, param)
}
}
graph.onAfterChange = (graph: any, node?: any) => {
if (node && node.id) {
computeAndRegisterSlots(node)
}
if (origAfterChange) {
origAfterChange.call(graph, graph, node)
}
}
// Store cleanup function
restoreHandlers = () => {
graph.onNodeAdded = origNodeAdded || undefined
graph.onNodeRemoved = origNodeRemoved || undefined
graph.onTrigger = origTrigger || undefined
graph.onAfterChange = origAfterChange || undefined
}
}
/**
* Stop slot layout sync and cleanup all subscriptions
*/
function stop(): void {
if (unsubscribeLayoutChange) {
unsubscribeLayoutChange()
unsubscribeLayoutChange = null
}
if (restoreHandlers) {
restoreHandlers()
restoreHandlers = null
}
}
// Auto-cleanup on unmount
onUnmounted(() => {
stop()
})
return {
start,
stop
}
}

View File

@@ -6,6 +6,13 @@
*/
import type { ComputedRef, Ref } from 'vue'
// Enum for layout source types
export enum LayoutSource {
Canvas = 'canvas',
Vue = 'vue',
External = 'external'
}
// Basic geometric types
export interface Point {
x: number
@@ -28,6 +35,8 @@ export interface Bounds {
export type NodeId = string
export type SlotId = string
export type ConnectionId = string
export type LinkId = number // Aligned with Litegraph's numeric LinkId
export type RerouteId = number // Aligned with Litegraph's numeric RerouteId
// Layout data structures
export interface NodeLayout {
@@ -41,11 +50,38 @@ export interface NodeLayout {
}
export interface SlotLayout {
id: SlotId
nodeId: NodeId
position: Point // Relative to node
type: 'input' | 'output'
index: number
type: 'input' | 'output'
position: Point
bounds: Bounds
}
export interface LinkLayout {
id: LinkId
path: Path2D
bounds: Bounds
centerPos: Point
sourceNodeId: NodeId
targetNodeId: NodeId
sourceSlot: number
targetSlot: number
}
// Layout for individual link segments (for precise hit-testing)
export interface LinkSegmentLayout {
linkId: LinkId
rerouteId: RerouteId | null // null for final segment to target
path: Path2D
bounds: Bounds
centerPos: Point
}
export interface RerouteLayout {
id: RerouteId
position: Point
radius: number
bounds: Bounds
}
export interface ConnectionLayout {
@@ -68,7 +104,7 @@ export type LayoutMutationType =
export interface LayoutMutation {
type: LayoutMutationType
timestamp: number
source: 'canvas' | 'vue' | 'external'
source: LayoutSource
}
export interface MoveNodeMutation extends LayoutMutation {
@@ -119,10 +155,11 @@ export type AnyLayoutMutation =
| BatchMutation
// CRDT Operation Types
/**
* Base operation interface that all operations extend
* Meta-only base for all operations - contains common fields
*/
export interface BaseOperation {
export interface OperationMeta {
/** Unique operation ID for deduplication */
id?: string
/** Timestamp for ordering operations */
@@ -130,9 +167,19 @@ export interface BaseOperation {
/** Actor who performed the operation (for CRDT) */
actor: string
/** Source system that initiated the operation */
source: 'canvas' | 'vue' | 'external'
/** Node this operation affects */
nodeId: NodeId
source: LayoutSource
/** Operation type discriminator */
type: OperationType
}
/**
* Entity-specific base types for proper type discrimination
*/
export type NodeOpBase = OperationMeta & { entity: 'node'; nodeId: NodeId }
export type LinkOpBase = OperationMeta & { entity: 'link'; linkId: LinkId }
export type RerouteOpBase = OperationMeta & {
entity: 'reroute'
rerouteId: RerouteId
}
/**
@@ -146,11 +193,16 @@ export type OperationType =
| 'deleteNode'
| 'setNodeVisibility'
| 'batchUpdate'
| 'createLink'
| 'deleteLink'
| 'createReroute'
| 'deleteReroute'
| 'moveReroute'
/**
* Move node operation
*/
export interface MoveNodeOperation extends BaseOperation {
export interface MoveNodeOperation extends NodeOpBase {
type: 'moveNode'
position: Point
previousPosition: Point
@@ -159,7 +211,7 @@ export interface MoveNodeOperation extends BaseOperation {
/**
* Resize node operation
*/
export interface ResizeNodeOperation extends BaseOperation {
export interface ResizeNodeOperation extends NodeOpBase {
type: 'resizeNode'
size: { width: number; height: number }
previousSize: { width: number; height: number }
@@ -168,7 +220,7 @@ export interface ResizeNodeOperation extends BaseOperation {
/**
* Set node z-index operation
*/
export interface SetNodeZIndexOperation extends BaseOperation {
export interface SetNodeZIndexOperation extends NodeOpBase {
type: 'setNodeZIndex'
zIndex: number
previousZIndex: number
@@ -177,7 +229,7 @@ export interface SetNodeZIndexOperation extends BaseOperation {
/**
* Create node operation
*/
export interface CreateNodeOperation extends BaseOperation {
export interface CreateNodeOperation extends NodeOpBase {
type: 'createNode'
layout: NodeLayout
}
@@ -185,7 +237,7 @@ export interface CreateNodeOperation extends BaseOperation {
/**
* Delete node operation
*/
export interface DeleteNodeOperation extends BaseOperation {
export interface DeleteNodeOperation extends NodeOpBase {
type: 'deleteNode'
previousLayout: NodeLayout
}
@@ -193,7 +245,7 @@ export interface DeleteNodeOperation extends BaseOperation {
/**
* Set node visibility operation
*/
export interface SetNodeVisibilityOperation extends BaseOperation {
export interface SetNodeVisibilityOperation extends NodeOpBase {
type: 'setNodeVisibility'
visible: boolean
previousVisible: boolean
@@ -202,12 +254,56 @@ export interface SetNodeVisibilityOperation extends BaseOperation {
/**
* Batch update operation for atomic multi-property changes
*/
export interface BatchUpdateOperation extends BaseOperation {
export interface BatchUpdateOperation extends NodeOpBase {
type: 'batchUpdate'
updates: Partial<NodeLayout>
previousValues: Partial<NodeLayout>
}
/**
* Create link operation
*/
export interface CreateLinkOperation extends LinkOpBase {
type: 'createLink'
sourceNodeId: NodeId
sourceSlot: number
targetNodeId: NodeId
targetSlot: number
}
/**
* Delete link operation
*/
export interface DeleteLinkOperation extends LinkOpBase {
type: 'deleteLink'
}
/**
* Create reroute operation
*/
export interface CreateRerouteOperation extends RerouteOpBase {
type: 'createReroute'
position: Point
parentId?: RerouteId
linkIds: LinkId[]
}
/**
* Delete reroute operation
*/
export interface DeleteRerouteOperation extends RerouteOpBase {
type: 'deleteReroute'
}
/**
* Move reroute operation
*/
export interface MoveRerouteOperation extends RerouteOpBase {
type: 'moveReroute'
position: Point
previousPosition: Point
}
/**
* Union of all operation types
*/
@@ -219,6 +315,11 @@ export type LayoutOperation =
| DeleteNodeOperation
| SetNodeVisibilityOperation
| BatchUpdateOperation
| CreateLinkOperation
| DeleteLinkOperation
| CreateRerouteOperation
| DeleteRerouteOperation
| MoveRerouteOperation
// Legacy alias for compatibility
export type AnyLayoutOperation = LayoutOperation
@@ -226,17 +327,32 @@ export type AnyLayoutOperation = LayoutOperation
/**
* Type guards for operations
*/
export const isBaseOperation = (op: unknown): op is BaseOperation => {
export const isOperationMeta = (op: unknown): op is OperationMeta => {
return (
typeof op === 'object' &&
op !== null &&
'timestamp' in op &&
'actor' in op &&
'source' in op &&
'nodeId' in op
'type' in op
)
}
/**
* Entity-specific helper functions
*/
export const isNodeOperation = (op: LayoutOperation): boolean => {
return 'entity' in op && (op as any).entity === 'node'
}
export const isLinkOperation = (op: LayoutOperation): boolean => {
return 'entity' in op && (op as any).entity === 'link'
}
export const isRerouteOperation = (op: LayoutOperation): boolean => {
return 'entity' in op && (op as any).entity === 'reroute'
}
export const isMoveNodeOperation = (
op: LayoutOperation
): op is MoveNodeOperation => op.type === 'moveNode'
@@ -253,6 +369,65 @@ export const isDeleteNodeOperation = (
op: LayoutOperation
): op is DeleteNodeOperation => op.type === 'deleteNode'
export const isSetNodeVisibilityOperation = (
op: LayoutOperation
): op is SetNodeVisibilityOperation => op.type === 'setNodeVisibility'
export const isBatchUpdateOperation = (
op: LayoutOperation
): op is BatchUpdateOperation => op.type === 'batchUpdate'
export const isCreateLinkOperation = (
op: LayoutOperation
): op is CreateLinkOperation => op.type === 'createLink'
export const isDeleteLinkOperation = (
op: LayoutOperation
): op is DeleteLinkOperation => op.type === 'deleteLink'
export const isCreateRerouteOperation = (
op: LayoutOperation
): op is CreateRerouteOperation => op.type === 'createReroute'
export const isDeleteRerouteOperation = (
op: LayoutOperation
): op is DeleteRerouteOperation => op.type === 'deleteReroute'
export const isMoveRerouteOperation = (
op: LayoutOperation
): op is MoveRerouteOperation => op.type === 'moveReroute'
/**
* Helper function to get affected node IDs from any operation
* Useful for change notifications and cache invalidation
*/
export const getAffectedNodeIds = (op: LayoutOperation): NodeId[] => {
switch (op.type) {
case 'moveNode':
case 'resizeNode':
case 'setNodeZIndex':
case 'createNode':
case 'deleteNode':
case 'setNodeVisibility':
case 'batchUpdate':
return [(op as NodeOpBase).nodeId]
case 'createLink': {
const createLink = op as CreateLinkOperation
return [createLink.sourceNodeId, createLink.targetNodeId]
}
case 'deleteLink':
// Link deletion doesn't directly affect nodes
return []
case 'createReroute':
case 'deleteReroute':
case 'moveReroute':
// Reroute operations don't directly affect nodes
return []
default:
return []
}
}
/**
* Operation application interface
*/
@@ -284,7 +459,7 @@ export interface LayoutChange {
type: 'create' | 'update' | 'delete'
nodeIds: NodeId[]
timestamp: number
source: 'canvas' | 'vue' | 'external'
source: LayoutSource
operation: LayoutOperation
}
@@ -300,6 +475,43 @@ export interface LayoutStore {
queryNodeAtPoint(point: Point): NodeId | null
queryNodesInBounds(bounds: Bounds): NodeId[]
// Hit testing queries for links, slots, and reroutes
queryLinkAtPoint(point: Point, ctx?: CanvasRenderingContext2D): LinkId | null
queryLinkSegmentAtPoint(
point: Point,
ctx?: CanvasRenderingContext2D
): { linkId: LinkId; rerouteId: RerouteId | null } | null
querySlotAtPoint(point: Point): SlotLayout | null
queryRerouteAtPoint(point: Point): RerouteLayout | null
queryItemsInBounds(bounds: Bounds): {
nodes: NodeId[]
links: LinkId[]
slots: string[]
reroutes: RerouteId[]
}
// Update methods for link, slot, and reroute layouts
updateLinkLayout(linkId: LinkId, layout: LinkLayout): void
updateLinkSegmentLayout(
linkId: LinkId,
rerouteId: RerouteId | null,
layout: Omit<LinkSegmentLayout, 'linkId' | 'rerouteId'>
): void
updateSlotLayout(key: string, layout: SlotLayout): void
updateRerouteLayout(rerouteId: RerouteId, layout: RerouteLayout): void
// Delete methods for cleanup
deleteLinkLayout(linkId: LinkId): void
deleteLinkSegmentLayout(linkId: LinkId, rerouteId: RerouteId | null): void
deleteSlotLayout(key: string): void
deleteNodeSlotLayouts(nodeId: NodeId): void
deleteRerouteLayout(rerouteId: RerouteId): void
// Get layout data
getLinkLayout(linkId: LinkId): LinkLayout | null
getSlotLayout(key: string): SlotLayout | null
getRerouteLayout(rerouteId: RerouteId): RerouteLayout | null
// Direct mutation API (CRDT-ready)
applyOperation(operation: LayoutOperation): void
@@ -312,9 +524,9 @@ export interface LayoutStore {
): void
// Source and actor management
setSource(source: 'canvas' | 'vue' | 'external'): void
setSource(source: LayoutSource): void
setActor(actor: string): void
getCurrentSource(): 'canvas' | 'vue' | 'external'
getCurrentSource(): LayoutSource
getCurrentActor(): string
}
@@ -325,15 +537,39 @@ export interface LayoutMutations {
resizeNode(nodeId: NodeId, size: Size): void
setNodeZIndex(nodeId: NodeId, zIndex: number): void
// Lifecycle operations
// Node lifecycle operations
createNode(nodeId: NodeId, layout: Partial<NodeLayout>): void
deleteNode(nodeId: NodeId): void
// Link operations
createLink(
linkId: string | number,
sourceNodeId: string | number,
sourceSlot: number,
targetNodeId: string | number,
targetSlot: number
): void
deleteLink(linkId: string | number): void
// Reroute operations
createReroute(
rerouteId: string | number,
position: Point,
parentId?: string | number,
linkIds?: (string | number)[]
): void
deleteReroute(rerouteId: string | number): void
moveReroute(
rerouteId: string | number,
position: Point,
previousPosition: Point
): void
// Stacking operations
bringNodeToFront(nodeId: NodeId): void
// Source tracking
setSource(source: 'canvas' | 'vue' | 'external'): void
setSource(source: LayoutSource): void
setActor(actor: string): void // For CRDT
}

View File

@@ -18,7 +18,8 @@
<!-- Connection Dot -->
<div class="w-5 h-5 flex items-center justify-center group/slot">
<div
class="w-2.5 h-2.5 rounded-full bg-white transition-all duration-150 group-hover/slot:w-3 group-hover/slot:h-3 group-hover/slot:border-2 group-hover/slot:border-white"
ref="slotElRef"
class="w-2 h-2 rounded-full bg-white transition-all duration-150 group-hover/slot:w-2.5 group-hover/slot:h-2.5 group-hover/slot:border-2 group-hover/slot:border-white"
:style="{
backgroundColor: slotColor
}"
@@ -36,7 +37,7 @@
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref } from 'vue'
import { computed, inject, onErrorCaptured, ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
@@ -45,9 +46,15 @@ import {
INodeSlot,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
// DOM-based slot registration for arbitrary positioning
import {
type TransformState,
useDomSlotRegistration
} from '@/renderer/core/layout/slots/useDomSlotRegistration'
interface InputSlotProps {
node?: LGraphNode
nodeId?: string
slotData: INodeSlot
index: number
connected?: boolean
@@ -84,4 +91,16 @@ const handleClick = (event: PointerEvent) => {
emit('slot-click', event)
}
}
const transformState = inject<TransformState | undefined>(
'transformState',
undefined
)
const { slotElRef } = useDomSlotRegistration(
props.nodeId ?? '',
props.index,
true,
transformState
)
</script>

View File

@@ -9,6 +9,7 @@
v-for="(input, index) in filteredInputs"
:key="`input-${index}`"
:slot-data="input"
:node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''"
:index="getActualInputIndex(input, index)"
:readonly="readonly"
@slot-click="
@@ -22,6 +23,7 @@
v-for="(output, index) in filteredOutputs"
:key="`output-${index}`"
:slot-data="output"
:node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''"
:index="index"
:readonly="readonly"
@slot-click="handleOutputSlotClick(index, $event)"
@@ -52,7 +54,7 @@ interface NodeSlotsProps {
const props = defineProps<NodeSlotsProps>()
const nodeInfo = computed(() => props.nodeData || props.node)
const nodeInfo = computed(() => props.nodeData || props.node || null)
// Filter out input slots that have corresponding widgets
const filteredInputs = computed(() => {

View File

@@ -19,7 +19,8 @@
type: widget.type,
boundingRect: [0, 0, 0, 0]
}"
:index="index"
:node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''"
:index="getWidgetInputIndex(widget)"
:readonly="readonly"
:dot-only="true"
@slot-click="handleWidgetSlotClick($event, widget)"
@@ -151,6 +152,21 @@ const processedWidgets = computed((): ProcessedWidget[] => {
return result
})
// TODO: Refactor to avoid O(n) lookup - consider storing input index on widget creation
// or restructuring data model to unify widgets and inputs
// Map a widget to its corresponding input slot index
const getWidgetInputIndex = (widget: ProcessedWidget): number => {
const inputs = nodeInfo.value?.inputs
if (!inputs) return 0
const idx = inputs.findIndex((input: any) => {
if (!input || typeof input !== 'object') return false
if (!('name' in input && 'type' in input)) return false
return 'widget' in input && input.widget?.name === widget.name
})
return idx >= 0 ? idx : 0
}
// Handle widget slot click
const handleWidgetSlotClick = (
event: PointerEvent,

View File

@@ -27,7 +27,8 @@
<!-- Connection Dot -->
<div class="w-5 h-5 flex items-center justify-center group/slot">
<div
class="w-2.5 h-2.5 rounded-full bg-white transition-all duration-150 group-hover/slot:w-3 group-hover/slot:h-3 group-hover/slot:border-2 group-hover/slot:border-white"
ref="slotElRef"
class="w-2 h-2 rounded-full bg-white transition-all duration-150 group-hover/slot:w-2.5 group-hover/slot:h-2.5 group-hover/slot:border-2 group-hover/slot:border-white"
:style="{
backgroundColor: slotColor
}"
@@ -37,15 +38,21 @@
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref } from 'vue'
import { computed, inject, onErrorCaptured, ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { COMFY_VUE_NODE_DIMENSIONS } from '@/lib/litegraph/src/litegraph'
// DOM-based slot registration for arbitrary positioning
import {
type TransformState,
useDomSlotRegistration
} from '@/renderer/core/layout/slots/useDomSlotRegistration'
interface OutputSlotProps {
node?: LGraphNode
nodeId?: string
slotData: INodeSlot
index: number
connected?: boolean
@@ -83,4 +90,16 @@ const handleClick = (event: PointerEvent) => {
emit('slot-click', event)
}
}
const transformState = inject<TransformState | undefined>(
'transformState',
undefined
)
const { slotElRef } = useDomSlotRegistration(
props.nodeId ?? '',
props.index,
false,
transformState
)
</script>

View File

@@ -1,34 +0,0 @@
/**
* Vue Nodes Renderer Extension
*
* This extension provides Vue-based node rendering capabilities for ComfyUI.
* Domain-driven architecture organizing concerns by function rather than technical layers.
*
* Architecture:
* - components/ - Vue node UI components (LGraphNode, NodeHeader, etc.)
* - widgets/ - Widget rendering system (components, composables, registry)
* - lod/ - Level of Detail system for performance
* - layout/ - Node positioning and layout logic
* - interaction/ - User interaction handling (planned)
*/
// Main node components
export { default as LGraphNode } from './components/LGraphNode.vue'
export { default as NodeHeader } from './components/NodeHeader.vue'
export { default as NodeContent } from './components/NodeContent.vue'
export { default as NodeSlots } from './components/NodeSlots.vue'
export { default as NodeWidgets } from './components/NodeWidgets.vue'
export { default as InputSlot } from './components/InputSlot.vue'
export { default as OutputSlot } from './components/OutputSlot.vue'
// Widget system exports
export * from './widgets/registry/widgetRegistry'
export * from './widgets/composables/useWidgetRenderer'
export * from './widgets/composables/useWidgetValue'
export * from './widgets/useNodeWidgets'
// Level of Detail system
export * from './lod/useLOD'
// Layout system exports
export * from './layout/useNodeLayout'

View File

@@ -8,7 +8,7 @@ import { computed, inject } from 'vue'
import { layoutMutations } from '@/renderer/core/layout/operations/LayoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
import type { Point } from '@/renderer/core/layout/types'
import { LayoutSource, type Point } from '@/renderer/core/layout/types'
/**
* Composable for individual Vue node components
@@ -66,7 +66,7 @@ export function useNodeLayout(nodeId: string) {
dragStartMouse = { x: event.clientX, y: event.clientY }
// Set mutation source
mutations.setSource('vue')
mutations.setSource(LayoutSource.Vue)
// Capture pointer
const target = event.target as HTMLElement
@@ -124,7 +124,7 @@ export function useNodeLayout(nodeId: string) {
* Update node position directly (without drag)
*/
function moveTo(position: Point) {
mutations.setSource('vue')
mutations.setSource(LayoutSource.Vue)
mutations.moveNode(nodeId, position)
}
@@ -132,7 +132,7 @@ export function useNodeLayout(nodeId: string) {
* Update node size
*/
function resize(newSize: { width: number; height: number }) {
mutations.setSource('vue')
mutations.setSource(LayoutSource.Vue)
mutations.resizeNode(nodeId, newSize)
}

View File

@@ -1,155 +0,0 @@
/**
* 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'
export 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
}
export 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)
})
}

View File

@@ -1,182 +0,0 @@
/**
* Node Widget Management
*
* Handles widget state synchronization between LiteGraph and Vue.
* Provides wrapped callbacks to maintain consistency.
*/
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { WidgetValue } from '@/types/simplifiedWidget'
export type { WidgetValue }
export interface SafeWidgetData {
name: string
type: string
value: WidgetValue
options?: Record<string, unknown>
callback?: ((value: unknown) => void) | undefined
}
export interface VueNodeData {
id: string
title: string
type: string
mode: number
selected: boolean
executing: boolean
widgets?: SafeWidgetData[]
inputs?: unknown[]
outputs?: unknown[]
flags?: {
collapsed?: boolean
}
}
/**
* Validates that a value is a valid WidgetValue type
*/
export function 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.every((item) => item instanceof File)) {
return value as File[]
}
// Otherwise it's a generic object
return value as object
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
/**
* Extract safe widget data from LiteGraph widgets
*/
export function extractWidgetData(
widgets?: any[]
): SafeWidgetData[] | undefined {
if (!widgets) return undefined
return widgets.map((widget) => {
try {
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]
}
return {
name: widget.name,
type: widget.type,
value: validateWidgetValue(value),
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined,
options: undefined,
callback: undefined
}
}
})
}
/**
* Widget callback management for LiteGraph/Vue sync
*/
export function useNodeWidgets() {
/**
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
*/
const createWrappedCallback = (
widget: { value?: unknown; name: string },
originalCallback: ((value: unknown) => void) | undefined,
nodeId: string,
onUpdate: (nodeId: string, widgetName: string, value: unknown) => void
) => {
let updateInProgress = false
return (value: unknown) => {
if (updateInProgress) return
updateInProgress = true
try {
// 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
// Call the original callback if it exists
if (originalCallback) {
originalCallback.call(widget, value)
}
// Update Vue state to maintain synchronization
onUpdate(nodeId, widget.name, value)
} finally {
updateInProgress = false
}
}
}
/**
* Sets up widget callbacks for a node
*/
const setupNodeWidgetCallbacks = (
node: LGraphNode,
onUpdate: (nodeId: string, widgetName: string, value: unknown) => void
) => {
if (!node.widgets) return
const nodeId = String(node.id)
node.widgets.forEach((widget) => {
const originalCallback = widget.callback
widget.callback = createWrappedCallback(
widget,
originalCallback,
nodeId,
onUpdate
)
})
}
return {
validateWidgetValue,
extractWidgetData,
createWrappedCallback,
setupNodeWidgetCallbacks
}
}

View File

@@ -1,168 +0,0 @@
/**
* Layout Operation Types
*
* Defines the operation interface for the CRDT-based layout system.
* Each operation is immutable and contains all information needed for:
* - Application (forward)
* - Undo/redo (reverse)
* - Conflict resolution (CRDT)
* - Debugging (actor, timestamp, source)
*/
import type { NodeId, NodeLayout, Point } from './layoutTypes'
/**
* Base operation interface that all operations extend
*/
export interface BaseOperation {
/** Unique operation ID for deduplication */
id?: string
/** Timestamp for ordering operations */
timestamp: number
/** Actor who performed the operation (for CRDT) */
actor: string
/** Source system that initiated the operation */
source: 'canvas' | 'vue' | 'external'
/** Node this operation affects */
nodeId: NodeId
}
/**
* Operation type discriminator for type narrowing
*/
export type OperationType =
| 'moveNode'
| 'resizeNode'
| 'setNodeZIndex'
| 'createNode'
| 'deleteNode'
| 'setNodeVisibility'
| 'batchUpdate'
/**
* Move node operation
*/
export interface MoveNodeOperation extends BaseOperation {
type: 'moveNode'
position: Point
previousPosition: Point
}
/**
* Resize node operation
*/
export interface ResizeNodeOperation extends BaseOperation {
type: 'resizeNode'
size: { width: number; height: number }
previousSize: { width: number; height: number }
}
/**
* Set node z-index operation
*/
export interface SetNodeZIndexOperation extends BaseOperation {
type: 'setNodeZIndex'
zIndex: number
previousZIndex: number
}
/**
* Create node operation
*/
export interface CreateNodeOperation extends BaseOperation {
type: 'createNode'
layout: NodeLayout
}
/**
* Delete node operation
*/
export interface DeleteNodeOperation extends BaseOperation {
type: 'deleteNode'
previousLayout: NodeLayout
}
/**
* Set node visibility operation
*/
export interface SetNodeVisibilityOperation extends BaseOperation {
type: 'setNodeVisibility'
visible: boolean
previousVisible: boolean
}
/**
* Batch update operation for atomic multi-property changes
*/
export interface BatchUpdateOperation extends BaseOperation {
type: 'batchUpdate'
updates: Partial<NodeLayout>
previousValues: Partial<NodeLayout>
}
/**
* Union of all operation types
*/
export type LayoutOperation =
| MoveNodeOperation
| ResizeNodeOperation
| SetNodeZIndexOperation
| CreateNodeOperation
| DeleteNodeOperation
| SetNodeVisibilityOperation
| BatchUpdateOperation
/**
* Type guards for operations
*/
export const isBaseOperation = (op: unknown): op is BaseOperation => {
return (
typeof op === 'object' &&
op !== null &&
'timestamp' in op &&
'actor' in op &&
'source' in op &&
'nodeId' in op
)
}
export const isMoveNodeOperation = (
op: LayoutOperation
): op is MoveNodeOperation => op.type === 'moveNode'
export const isResizeNodeOperation = (
op: LayoutOperation
): op is ResizeNodeOperation => op.type === 'resizeNode'
export const isCreateNodeOperation = (
op: LayoutOperation
): op is CreateNodeOperation => op.type === 'createNode'
export const isDeleteNodeOperation = (
op: LayoutOperation
): op is DeleteNodeOperation => op.type === 'deleteNode'
/**
* Operation application interface
*/
export interface OperationApplicator<
T extends LayoutOperation = LayoutOperation
> {
canApply(operation: T): boolean
apply(operation: T): void
reverse(operation: T): void
}
/**
* Operation serialization for network/storage
*/
export interface OperationSerializer {
serialize(operation: LayoutOperation): string
deserialize(data: string): LayoutOperation
}
/**
* Conflict resolution strategy
*/
export interface ConflictResolver {
resolve(op1: LayoutOperation, op2: LayoutOperation): LayoutOperation[]
}

View File

@@ -1,204 +0,0 @@
/**
* Layout System - Type Definitions
*
* This file contains all type definitions for the layout system
* that manages node positions, bounds, and spatial data.
*/
import type { ComputedRef, Ref } from 'vue'
import type { LayoutOperation } from './layoutOperations'
// Basic geometric types
export interface Point {
x: number
y: number
}
export interface Size {
width: number
height: number
}
export interface Bounds {
x: number
y: number
width: number
height: number
}
// ID types for type safety
export type NodeId = string
export type SlotId = string
export type ConnectionId = string
// Layout data structures
export interface NodeLayout {
id: NodeId
position: Point
size: Size
zIndex: number
visible: boolean
// Computed bounds for hit testing
bounds: Bounds
}
export interface SlotLayout {
id: SlotId
nodeId: NodeId
position: Point // Relative to node
type: 'input' | 'output'
index: number
}
export interface ConnectionLayout {
id: ConnectionId
sourceSlot: SlotId
targetSlot: SlotId
// Control points for curved connections
controlPoints?: Point[]
}
// Mutation types
export type LayoutMutationType =
| 'moveNode'
| 'resizeNode'
| 'setNodeZIndex'
| 'createNode'
| 'deleteNode'
| 'batch'
export interface LayoutMutation {
type: LayoutMutationType
timestamp: number
source: 'canvas' | 'vue' | 'external'
}
export interface MoveNodeMutation extends LayoutMutation {
type: 'moveNode'
nodeId: NodeId
position: Point
previousPosition?: Point
}
export interface ResizeNodeMutation extends LayoutMutation {
type: 'resizeNode'
nodeId: NodeId
size: Size
previousSize?: Size
}
export interface SetNodeZIndexMutation extends LayoutMutation {
type: 'setNodeZIndex'
nodeId: NodeId
zIndex: number
previousZIndex?: number
}
export interface CreateNodeMutation extends LayoutMutation {
type: 'createNode'
nodeId: NodeId
layout: NodeLayout
}
export interface DeleteNodeMutation extends LayoutMutation {
type: 'deleteNode'
nodeId: NodeId
previousLayout?: NodeLayout
}
export interface BatchMutation extends LayoutMutation {
type: 'batch'
mutations: AnyLayoutMutation[]
}
// Union type for all mutations
export type AnyLayoutMutation =
| MoveNodeMutation
| ResizeNodeMutation
| SetNodeZIndexMutation
| CreateNodeMutation
| DeleteNodeMutation
| BatchMutation
// Change notification types
export interface LayoutChange {
type: 'create' | 'update' | 'delete'
nodeIds: NodeId[]
timestamp: number
source: 'canvas' | 'vue' | 'external'
operation: LayoutOperation
}
// Store interfaces
export interface LayoutStore {
// CustomRef accessors for shared write access
getNodeLayoutRef(nodeId: NodeId): Ref<NodeLayout | null>
getNodesInBounds(bounds: Bounds): ComputedRef<NodeId[]>
getAllNodes(): ComputedRef<ReadonlyMap<NodeId, NodeLayout>>
getVersion(): ComputedRef<number>
// Spatial queries (non-reactive)
queryNodeAtPoint(point: Point): NodeId | null
queryNodesInBounds(bounds: Bounds): NodeId[]
// Direct mutation API (CRDT-ready)
applyOperation(operation: LayoutOperation): void
// Change subscription
onChange(callback: (change: LayoutChange) => void): () => void
// Initialization
initializeFromLiteGraph(
nodes: Array<{ id: string; pos: [number, number]; size: [number, number] }>
): void
// Source and actor management
setSource(source: 'canvas' | 'vue' | 'external'): void
setActor(actor: string): void
getCurrentSource(): 'canvas' | 'vue' | 'external'
getCurrentActor(): string
}
// Re-export operation types from dedicated operations file
export type {
LayoutOperation as AnyLayoutOperation,
BaseOperation,
MoveNodeOperation,
ResizeNodeOperation,
SetNodeZIndexOperation,
CreateNodeOperation,
DeleteNodeOperation,
SetNodeVisibilityOperation,
BatchUpdateOperation,
OperationType,
OperationApplicator,
OperationSerializer,
ConflictResolver
} from './layoutOperations'
// Simplified mutation API
export interface LayoutMutations {
// Single node operations (synchronous, CRDT-ready)
moveNode(nodeId: NodeId, position: Point): void
resizeNode(nodeId: NodeId, size: Size): void
setNodeZIndex(nodeId: NodeId, zIndex: number): void
// Lifecycle operations
createNode(nodeId: NodeId, layout: Partial<NodeLayout>): void
deleteNode(nodeId: NodeId): void
// Stacking operations
bringNodeToFront(nodeId: NodeId): void
// Source tracking
setSource(source: 'canvas' | 'vue' | 'external'): void
setActor(actor: string): void // For CRDT
}
// CRDT-ready operation log (for future CRDT integration)
export interface OperationLog {
operations: LayoutOperation[]
addOperation(operation: LayoutOperation): void
getOperationsSince(timestamp: number): LayoutOperation[]
getOperationsByActor(actor: string): LayoutOperation[]
}

View File

@@ -622,38 +622,4 @@ describe('LGraphNode', () => {
delete (node.constructor as any).slot_start_y
})
})
describe('getInputPos', () => {
test('should call getInputSlotPos with the correct input slot from inputs array', () => {
const input0: INodeInputSlot = {
name: 'in0',
type: 'string',
link: null,
boundingRect: new Float32Array([0, 0, 0, 0])
}
const input1: INodeInputSlot = {
name: 'in1',
type: 'number',
link: null,
boundingRect: new Float32Array([0, 0, 0, 0]),
pos: [5, 45]
}
node.inputs = [input0, input1]
const spy = vi.spyOn(node, 'getInputSlotPos')
node.getInputPos(1)
expect(spy).toHaveBeenCalledWith(input1)
const expectedPos: Point = [100 + 5, 200 + 45]
expect(node.getInputPos(1)).toEqual(expectedPos)
spy.mockClear()
node.getInputPos(0)
expect(spy).toHaveBeenCalledWith(input0)
const slotIndex = 0
const nodeOffsetY = (node.constructor as any).slot_start_y || 0
const expectedDefaultY =
200 + (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY
const expectedDefaultX = 100 + LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(node.getInputPos(0)).toEqual([expectedDefaultX, expectedDefaultY])
spy.mockRestore()
})
})
})

View File

@@ -62,6 +62,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -83,6 +84,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
@@ -133,6 +135,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -154,6 +157,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
@@ -205,6 +209,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -226,6 +231,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,

View File

@@ -11,6 +11,17 @@ LiteGraphGlobal {
"CARD_SHAPE": 4,
"CENTER": 5,
"CIRCLE_SHAPE": 3,
"COMFY_VUE_NODE_DIMENSIONS": {
"components": {
"HEADER_HEIGHT": 34,
"SLOT_HEIGHT": 24,
"STANDARD_WIDGET_HEIGHT": 30,
},
"spacing": {
"BETWEEN_SLOTS_AND_BODY": 8,
"BETWEEN_WIDGETS": 8,
},
},
"CONNECTING_LINK_COLOR": "#AFA",
"Classes": {
"InputIndicators": [Function],
@@ -199,5 +210,6 @@ LiteGraphGlobal {
"truncateWidgetValuesFirst": false,
"use_uuids": false,
"uuidv4": [Function],
"vueNodesMode": false,
}
`;

View File

@@ -1,225 +0,0 @@
/**
* Performance benchmark for QuadTree vs linear culling
* Measures query performance at different node counts and zoom levels
*/
import { type Bounds, QuadTree } from '../../../src/utils/spatial/QuadTree'
export interface BenchmarkResult {
nodeCount: number
queryCount: number
linearTime: number
quadTreeTime: number
speedup: number
culledPercentage: number
}
export interface NodeData {
id: string
bounds: Bounds
}
export class QuadTreeBenchmark {
private worldBounds: Bounds = {
x: -5000,
y: -5000,
width: 10000,
height: 10000
}
// Generate random nodes with realistic clustering
generateNodes(count: number): NodeData[] {
const nodes: NodeData[] = []
for (let i = 0; i < count; i++) {
// 70% clustered, 30% scattered
const isClustered = Math.random() < 0.7
let x: number, y: number
if (isClustered) {
// Pick a cluster center
const clusterX = (Math.random() - 0.5) * 8000
const clusterY = (Math.random() - 0.5) * 8000
// Add node near cluster with gaussian distribution
x = clusterX + (Math.random() - 0.5) * 500
y = clusterY + (Math.random() - 0.5) * 500
} else {
// Scattered randomly
x = (Math.random() - 0.5) * 9000
y = (Math.random() - 0.5) * 9000
}
nodes.push({
id: `node_${i}`,
bounds: {
x,
y,
width: 200 + Math.random() * 100,
height: 100 + Math.random() * 50
}
})
}
return nodes
}
// Linear viewport culling (baseline)
linearCulling(nodes: NodeData[], viewport: Bounds): string[] {
const visible: string[] = []
for (const node of nodes) {
if (this.boundsIntersect(node.bounds, viewport)) {
visible.push(node.id)
}
}
return visible
}
// QuadTree viewport culling
quadTreeCulling(quadTree: QuadTree<string>, viewport: Bounds): string[] {
return quadTree.query(viewport)
}
// Check if two bounds intersect
private boundsIntersect(a: Bounds, b: Bounds): boolean {
return !(
a.x + a.width < b.x ||
b.x + b.width < a.x ||
a.y + a.height < b.y ||
b.y + b.height < a.y
)
}
// Run benchmark for specific configuration
runBenchmark(
nodeCount: number,
viewportSize: { width: number; height: number },
queryCount: number = 100
): BenchmarkResult {
// Generate nodes
const nodes = this.generateNodes(nodeCount)
// Build QuadTree
const quadTree = new QuadTree<string>(this.worldBounds, {
maxDepth: Math.ceil(Math.log2(nodeCount / 4)),
maxItemsPerNode: 4
})
for (const node of nodes) {
quadTree.insert(node.id, node.bounds, node.id)
}
// Generate random viewports for testing
const viewports: Bounds[] = []
for (let i = 0; i < queryCount; i++) {
const x =
(Math.random() - 0.5) * (this.worldBounds.width - viewportSize.width)
const y =
(Math.random() - 0.5) * (this.worldBounds.height - viewportSize.height)
viewports.push({
x,
y,
width: viewportSize.width,
height: viewportSize.height
})
}
// Benchmark linear culling
const linearStart = performance.now()
let linearVisibleTotal = 0
for (const viewport of viewports) {
const visible = this.linearCulling(nodes, viewport)
linearVisibleTotal += visible.length
}
const linearTime = performance.now() - linearStart
// Benchmark QuadTree culling
const quadTreeStart = performance.now()
let quadTreeVisibleTotal = 0
for (const viewport of viewports) {
const visible = this.quadTreeCulling(quadTree, viewport)
quadTreeVisibleTotal += visible.length
}
const quadTreeTime = performance.now() - quadTreeStart
// Calculate metrics
const avgVisible = linearVisibleTotal / queryCount
const culledPercentage = ((nodeCount - avgVisible) / nodeCount) * 100
return {
nodeCount,
queryCount,
linearTime,
quadTreeTime,
speedup: linearTime / quadTreeTime,
culledPercentage
}
}
// Run comprehensive benchmark suite
runBenchmarkSuite(): BenchmarkResult[] {
const nodeCounts = [50, 100, 200, 500, 1000, 2000, 5000]
const viewportSizes = [
{ width: 1920, height: 1080 }, // Full HD
{ width: 800, height: 600 }, // Zoomed in
{ width: 4000, height: 3000 } // Zoomed out
]
const results: BenchmarkResult[] = []
for (const nodeCount of nodeCounts) {
for (const viewportSize of viewportSizes) {
const result = this.runBenchmark(nodeCount, viewportSize)
results.push(result)
console.log(
`Nodes: ${nodeCount}, ` +
`Viewport: ${viewportSize.width}x${viewportSize.height}, ` +
`Linear: ${result.linearTime.toFixed(2)}ms, ` +
`QuadTree: ${result.quadTreeTime.toFixed(2)}ms, ` +
`Speedup: ${result.speedup.toFixed(2)}x, ` +
`Culled: ${result.culledPercentage.toFixed(1)}%`
)
}
}
return results
}
// Find optimal maxDepth for given node count
findOptimalDepth(nodeCount: number): number {
const nodes = this.generateNodes(nodeCount)
const viewport = { x: 0, y: 0, width: 1920, height: 1080 }
let bestDepth = 1
let bestTime = Infinity
for (let depth = 1; depth <= 10; depth++) {
const quadTree = new QuadTree<string>(this.worldBounds, {
maxDepth: depth,
maxItemsPerNode: 4
})
// Build tree
for (const node of nodes) {
quadTree.insert(node.id, node.bounds, node.id)
}
// Measure query time
const start = performance.now()
for (let i = 0; i < 100; i++) {
quadTree.query(viewport)
}
const time = performance.now() - start
if (time < bestTime) {
bestTime = time
bestDepth = depth
}
}
return bestDepth
}
}

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
import type { NodeLayout } from '@/renderer/core/layout/types'
import { LayoutSource, type NodeLayout } from '@/renderer/core/layout/types'
describe('layoutStore CRDT operations', () => {
beforeEach(() => {
@@ -23,13 +23,14 @@ describe('layoutStore CRDT operations', () => {
const layout = createTestNode(nodeId)
// Create node
layoutStore.setSource('external')
layoutStore.setSource(LayoutSource.External)
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: 'external',
source: LayoutSource.External,
actor: 'test'
})
@@ -45,10 +46,11 @@ describe('layoutStore CRDT operations', () => {
// Create node first
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: 'external',
source: LayoutSource.External,
actor: 'test'
})
@@ -56,11 +58,12 @@ describe('layoutStore CRDT operations', () => {
const newPosition = { x: 200, y: 300 }
layoutStore.applyOperation({
type: 'moveNode',
entity: 'node',
nodeId,
position: newPosition,
previousPosition: layout.position,
timestamp: Date.now(),
source: 'vue',
source: LayoutSource.Vue,
actor: 'test'
})
@@ -76,10 +79,11 @@ describe('layoutStore CRDT operations', () => {
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: 'external',
source: LayoutSource.External,
actor: 'test'
})
@@ -87,11 +91,12 @@ describe('layoutStore CRDT operations', () => {
const newSize = { width: 300, height: 150 }
layoutStore.applyOperation({
type: 'resizeNode',
entity: 'node',
nodeId,
size: newSize,
previousSize: layout.size,
timestamp: Date.now(),
source: 'canvas',
source: LayoutSource.Canvas,
actor: 'test'
})
@@ -107,20 +112,22 @@ describe('layoutStore CRDT operations', () => {
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: 'external',
source: LayoutSource.External,
actor: 'test'
})
// Delete node
layoutStore.applyOperation({
type: 'deleteNode',
entity: 'node',
nodeId,
previousLayout: layout,
timestamp: Date.now(),
source: 'external',
source: LayoutSource.External,
actor: 'test'
})
@@ -134,7 +141,7 @@ describe('layoutStore CRDT operations', () => {
const layout = createTestNode(nodeId)
// Set source and actor
layoutStore.setSource('vue')
layoutStore.setSource(LayoutSource.Vue)
layoutStore.setActor('user-123')
// Track change notifications AFTER setting source/actor
@@ -146,6 +153,7 @@ describe('layoutStore CRDT operations', () => {
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
@@ -185,10 +193,11 @@ describe('layoutStore CRDT operations', () => {
}
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId: id,
layout,
timestamp: Date.now(),
source: 'external',
source: LayoutSource.External,
actor: 'test'
})
})
@@ -217,21 +226,23 @@ describe('layoutStore CRDT operations', () => {
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: startTime,
source: 'external',
source: LayoutSource.External,
actor: 'test-actor'
})
// Move node
layoutStore.applyOperation({
type: 'moveNode',
entity: 'node',
nodeId,
position: { x: 150, y: 150 },
previousPosition: { x: 100, y: 100 },
timestamp: startTime + 100,
source: 'vue',
source: LayoutSource.Vue,
actor: 'test-actor'
})