Compare commits

..

43 Commits

Author SHA1 Message Date
Benjamin Lu
20eb500020 Switch to onDrawForeground 2025-09-18 14:44:08 -07:00
Benjamin Lu
da3a467f61 Decisively fix the undecisive fix for the decisive fix for the numerical enum bug 2025-09-18 12:47:45 -07:00
Benjamin Lu
75d3788a86 Undecsively fix the decisive fix for the numerical enum bug 2025-09-18 12:45:48 -07:00
Benjamin Lu
6eb1f5e9c6 Decisively fix numerical enum bug 2025-09-17 21:14:11 -07:00
Benjamin Lu
bcce2c8ab2 Revert "tentatively fix numerical enum bug"
This reverts commit 402f73467f.
2025-09-17 21:00:50 -07:00
Benjamin Lu
402f73467f tentatively fix numerical enum bug 2025-09-17 20:03:02 -07:00
Benjamin Lu
196c0c4f50 excessively unhelpful commit message 2025-09-17 19:05:35 -07:00
Benjamin Lu
9d559545e9 nit 2025-09-17 18:53:34 -07:00
Benjamin Lu
1e0bb7567c nit 2025-09-17 18:26:35 -07:00
Benjamin Lu
f26d1e71d5 nit 2025-09-17 16:56:59 -07:00
Benjamin Lu
ef709d1a50 nit 2025-09-17 16:52:13 -07:00
Benjamin Lu
6586ef4195 nit 2025-09-17 16:48:07 -07:00
Benjamin Lu
f91dc82c94 knip 2025-09-17 15:39:01 -07:00
Benjamin Lu
777e419a50 Merge remote-tracking branch 'origin/main' into bl-make-slots-work 2025-09-17 15:13:41 -07:00
Benjamin Lu
16ddd4d481 allow dragging out links and creating connections 2025-09-17 14:03:23 -07:00
Benjamin Lu
9203ed5311 Merge remote-tracking branch 'origin/main' into bl-update-slots 2025-09-15 15:31:06 -07:00
Benjamin Lu
69f5391fce address review comments 2025-09-15 14:09:42 -07:00
Benjamin Lu
1dfa72cf19 remove unused 2025-09-11 17:57:19 -07:00
Benjamin Lu
cb211eb987 Merge remote-tracking branch 'origin/main' into bl-update-slots 2025-09-11 17:55:48 -07:00
Benjamin Lu
57a1359201 rename from measure 2025-09-11 17:55:26 -07:00
Benjamin Lu
4de2c2fdb9 Merge remote-tracking branch 'origin/main' into bl-update-slots 2025-09-11 16:36:45 -07:00
Benjamin Lu
121221d781 Cache canvas offset 2025-09-10 19:38:39 -07:00
Benjamin Lu
ed7a4e9c24 Improve churn 2025-09-10 19:02:48 -07:00
Benjamin Lu
0ba660f8ff Rely on RO for resize, and batch 2025-09-10 02:13:55 -07:00
Benjamin Lu
3a7cc3f548 revert churn reducings from layoutStore.ts 2025-09-09 22:53:04 -07:00
Benjamin Lu
a0ed9d902a Readd padding 2025-09-09 22:53:04 -07:00
Benjamin Lu
cb2069cf13 Fix conversion 2025-09-09 22:52:55 -07:00
Benjamin Lu
f63118b8c7 refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates 2025-09-09 22:52:55 -07:00
bymyself
5022f14265 move from resize observer to selection toolbox bounds 2025-09-09 20:32:01 -07:00
bymyself
ffede776cb fix title offset on y 2025-09-09 20:00:49 -07:00
bymyself
ed94827978 fix bounds collection when vue nodes turned off 2025-09-09 19:18:39 -07:00
bymyself
ea9c1215a3 remove change log 2025-09-09 17:09:39 -07:00
bymyself
ea93135dbb add interfaces for bounds mutations 2025-09-09 17:08:15 -07:00
bymyself
d39bf36ce0 remove inline import 2025-09-09 17:08:15 -07:00
bymyself
c39cdaf36c convert to functional bounds collection 2025-09-09 17:08:15 -07:00
bymyself
db5a68bc69 remove typo comment 2025-09-09 17:08:15 -07:00
bymyself
d4c2e83e45 [refactor] Improve resize tracking composable documentation and test utilities
- Rename parameters in useVueElementTracking for clarity (appIdentifier, trackingType)
- Add comprehensive docstring with examples to prevent DOM attribute confusion
- Extract mountLGraphNode test utility to eliminate repetitive mock setup
- Add technical implementation notes documenting optimization decisions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 17:08:15 -07:00
Benjamin Lu
dbacbc548d Revert "refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates"
This reverts commit 428752619c.
2025-09-09 17:05:40 -07:00
Benjamin Lu
9786ecfb97 Revert "chore: make TransformState interface non-exported to satisfy knip pre-push"
This reverts commit 110ecf31da.
2025-09-09 17:05:40 -07:00
Benjamin Lu
110ecf31da chore: make TransformState interface non-exported to satisfy knip pre-push 2025-09-09 16:30:25 -07:00
Benjamin Lu
428752619c refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates 2025-09-09 16:30:25 -07:00
Christian Byrne
b6269c0e37 Update src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts
Co-authored-by: AustinMroz <austin@comfy.org>
2025-09-09 12:17:25 -07:00
bymyself
3112dba454 add dom element resize observer registry for vue node components 2025-09-08 13:33:43 -07:00
22 changed files with 545 additions and 4214 deletions

View File

@@ -27,8 +27,7 @@ const config: KnipConfig = {
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Staged for for use with subgraph widget promotion
'src/lib/litegraph/src/widgets/DisconnectedWidget.ts',
'src/core/graph/operations/types.ts'
'src/lib/litegraph/src/widgets/DisconnectedWidget.ts'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -119,6 +119,7 @@ import { useWorkflowPersistence } from '@/platform/workflow/persistence/composab
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { attachSlotLinkPreviewRenderer } from '@/renderer/core/linkInteractions/slotLinkPreviewRenderer'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
@@ -404,6 +405,7 @@ onMounted(async () => {
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
attachSlotLinkPreviewRenderer(comfyApp.canvas)
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
workspaceStore.spinner = false

View File

@@ -1,532 +0,0 @@
# GraphMutationService Design and Implementation
## Overview
GraphMutationService is the centralized service layer for all graph modification operations in ComfyUI Frontend. It provides a unified, command-based API for graph mutations with built-in error handling through the Result pattern, serving as the single entry point for all graph modification operations.
## Project Background
### Current System Analysis
ComfyUI Frontend uses the LiteGraph library for graph operations, with main components including:
1. **LGraph** (`src/lib/litegraph/src/LGraph.ts`)
- Core graph management class
- Provides basic operations like `add()`, `remove()`
- Supports `beforeChange()`/`afterChange()` transaction mechanism
2. **LGraphNode** (`src/lib/litegraph/src/LGraphNode.ts`)
- Node class containing position, connections, and other properties
- Provides methods like `connect()`, `disconnectInput()`, `disconnectOutput()`
3. **ChangeTracker** (`src/scripts/changeTracker.ts`)
- Existing undo/redo system
- Snapshot-based history tracking
- Supports up to 50 history states
**Primary Goals:**
- Single entry point for all graph modifications via command pattern
- Built-in validation and error handling through Result pattern
- Transaction support for atomic operations
- Natural undo/redo through existing ChangeTracker
- Clean architecture for future extensibility (CRDT support ready)
- Comprehensive error context preservation
## Architecture Patterns
### Command Pattern
All operations are executed through a unified command interface:
```typescript
interface GraphMutationOperation {
type: string // Operation type identifier
timestamp: number // For ordering and CRDT support
origin: CommandOrigin // Source of the command
params?: any // Operation-specific parameters
}
```
### Result Pattern
All operations return a discriminated union Result type instead of throwing exceptions:
```typescript
type Result<T, E> =
| { success: true; data: T }
| { success: false; error: E }
```
### Error Handling
Custom error class with rich context:
```typescript
class GraphMutationError extends Error {
code: string
context: Record<string, any> // Contains operation details and original error
}
```
### Interface-Based Architecture
The GraphMutationService follows an **interface-based design pattern** with singleton state management:
- **IGraphMutationService Interface**: Defines the complete contract for all graph operations
- **GraphMutationService Class**: Implements the interface with LiteGraph integration
- **Singleton State**: Shared clipboard and transaction state across components
```typescript
interface IGraphMutationService {
// Central command dispatcher
applyOperation(
operation: GraphMutationOperation
): Promise<Result<any, GraphMutationError>>
// Direct operation methods (all return Result types)
createNode(params: createNodeParams): Promise<Result<NodeId, GraphMutationError>>
removeNode(nodeId: NodeId): Promise<Result<void, GraphMutationError>>
// ... 40+ total operations
// Undo/Redo
undo(): Promise<Result<void, GraphMutationError>>
redo(): Promise<Result<void, GraphMutationError>>
}
```
### Core Components
```typescript
// Implementation Class
class GraphMutationService implements IGraphMutationService {
private workflowStore = useWorkflowStore()
private static readonly CLIPBOARD_KEY = 'litegrapheditor_clipboard'
// Command dispatcher
async applyOperation(
operation: GraphMutationOperation
): Promise<Result<any, GraphMutationError>> {
switch (operation.type) {
case 'createNode':
return await this.createNode(operation.params)
case 'removeNode':
return await this.removeNode(operation.params)
// ... handle all operation types
default:
return {
success: false,
error: new GraphMutationError('Unknown operation type', {
operation: operation.type
})
}
}
}
// All operations wrapped with error handling
async createNode(params: createNodeParams): Promise<Result<NodeId, GraphMutationError>> {
try {
const graph = this.getGraph()
graph.beforeChange()
// ... perform operation
graph.afterChange()
return { success: true, data: nodeId }
} catch (error) {
return {
success: false,
error: new GraphMutationError('Failed to create node', {
operation: 'createNode',
params,
cause: error
})
}
}
}
}
// Singleton Hook
export const useGraphMutationService = (): IGraphMutationService => {
if (!graphMutationServiceInstance) {
graphMutationServiceInstance = new GraphMutationService()
}
return graphMutationServiceInstance
}
```
## Implemented Operations
### Node Operations (8 operations)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `createNode` | Create a new node in the graph | `Result<NodeId, GraphMutationError>` |
| `removeNode` | Remove a node from the graph | `Result<void, GraphMutationError>` |
| `updateNodeProperty` | Update a custom node property | `Result<void, GraphMutationError>` |
| `updateNodeTitle` | Change the node's title | `Result<void, GraphMutationError>` |
| `changeNodeMode` | Change execution mode (ALWAYS/BYPASS/etc) | `Result<void, GraphMutationError>` |
| `cloneNode` | Create a copy of a node | `Result<NodeId, GraphMutationError>` |
| `bypassNode` | Set node to bypass mode | `Result<void, GraphMutationError>` |
| `unbypassNode` | Remove bypass mode from node | `Result<void, GraphMutationError>` |
### Connection Operations (3 operations)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `connect` | Create a connection between nodes | `Result<LinkId, GraphMutationError>` |
| `disconnect` | Disconnect a node input/output slot | `Result<boolean, GraphMutationError>` |
| `disconnectLink` | Disconnect by link ID | `Result<void, GraphMutationError>` |
### Group Operations (5 operations)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `createGroup` | Create a new node group | `Result<GroupId, GraphMutationError>` |
| `removeGroup` | Delete a group (nodes remain) | `Result<void, GraphMutationError>` |
| `updateGroupTitle` | Change group title | `Result<void, GraphMutationError>` |
| `addNodesToGroup` | Add nodes to group and auto-resize | `Result<void, GraphMutationError>` |
| `recomputeGroupNodes` | Recalculate which nodes are in group | `Result<void, GraphMutationError>` |
### Clipboard Operations (3 operations)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `copyNodes` | Copy nodes to clipboard | `Result<void, GraphMutationError>` |
| `cutNodes` | Cut nodes to clipboard | `Result<void, GraphMutationError>` |
| `pasteNodes` | Paste nodes from clipboard | `Result<NodeId[], GraphMutationError>` |
### Reroute Operations (2 operations)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `addReroute` | Add a reroute point on a connection | `Result<RerouteId, GraphMutationError>` |
| `removeReroute` | Remove a reroute point | `Result<void, GraphMutationError>` |
### Subgraph Operations (10 operations)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `createSubgraph` | Create a subgraph from selected items | `Result<{subgraph, node}, GraphMutationError>` |
| `unpackSubgraph` | Unpack a subgraph node back into regular nodes | `Result<void, GraphMutationError>` |
| `addSubgraphNodeInput` | Add input slot to subgraph node | `Result<number, GraphMutationError>` |
| `addSubgraphNodeOutput` | Add output slot to subgraph node | `Result<number, GraphMutationError>` |
| `removeSubgraphNodeInput` | Remove input slot from subgraph node | `Result<void, GraphMutationError>` |
| `removeSubgraphNodeOutput` | Remove output slot from subgraph node | `Result<void, GraphMutationError>` |
| `addSubgraphInput` | Add an input to a subgraph | `Result<void, GraphMutationError>` |
| `addSubgraphOutput` | Add an output to a subgraph | `Result<void, GraphMutationError>` |
| `removeSubgraphInput` | Remove a subgraph input | `Result<void, GraphMutationError>` |
| `removeSubgraphOutput` | Remove a subgraph output | `Result<void, GraphMutationError>` |
### Graph-level Operations (1 operation)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `clearGraph` | Clear all nodes and connections | `Result<void, GraphMutationError>` |
### History Operations (2 operations)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `undo` | Undo the last operation | `Result<void, GraphMutationError>` |
| `redo` | Redo the previously undone operation | `Result<void, GraphMutationError>` |
## Usage Examples
### Command Pattern Usage
```typescript
import { useGraphMutationService, CommandOrigin } from '@/core/graph/operations'
import type { GraphMutationOperation } from '@/core/graph/operations/types'
const service = useGraphMutationService()
// Execute operations via command pattern
const operation: GraphMutationOperation = {
type: 'createNode',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
type: 'LoadImage',
title: 'My Image Loader',
properties: { seed: 12345 }
}
}
const result = await service.applyOperation(operation)
if (result.success) {
console.log('Node created with ID:', result.data)
} else {
console.error('Failed:', result.error.message)
console.error('Context:', result.error.context)
}
```
### Direct Method Usage with Result Pattern
```typescript
// All methods return Result<T, GraphMutationError>
const result = await service.createNode({
type: 'LoadImage',
title: 'Image Loader'
})
if (result.success) {
const nodeId = result.data
// Update node properties
const updateResult = await service.updateNodeProperty({
nodeId,
property: 'seed',
value: 12345
})
if (!updateResult.success) {
console.error('Update failed:', updateResult.error)
}
} else {
// Access detailed error context
const { operation, params, cause } = result.error.context
console.error(`Operation ${operation} failed:`, cause)
}
```
### Connection Management
```typescript
// Create a connection
const connectOp: GraphMutationOperation = {
type: 'connect',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
sourceNodeId: node1Id,
sourceSlot: 0,
targetNodeId: node2Id,
targetSlot: 0
}
}
const result = await service.applyOperation(connectOp)
if (result.success) {
const linkId = result.data
// Later disconnect by link ID
const disconnectResult = await service.disconnectLink(linkId)
if (!disconnectResult.success) {
console.error('Disconnect failed:', disconnectResult.error)
}
}
```
### Group Management
```typescript
// Create a group via command
const createGroupOp: GraphMutationOperation = {
type: 'createGroup',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
title: 'Image Processing',
size: [400, 300],
color: '#335577'
}
}
const groupResult = await service.applyOperation(createGroupOp)
if (groupResult.success) {
const groupId = groupResult.data
// Add nodes to group
const addNodesResult = await service.addNodesToGroup({
groupId,
nodeIds: [node1Id, node2Id]
})
if (!addNodesResult.success) {
console.error('Failed to add nodes:', addNodesResult.error)
}
}
```
### Clipboard Operations
```typescript
// Copy nodes
const copyResult = await service.copyNodes([node1Id, node2Id])
if (copyResult.success) {
// Paste at a different location
const pasteResult = await service.pasteNodes()
if (pasteResult.success) {
console.log('Pasted nodes:', pasteResult.data)
} else {
console.error('Paste failed:', pasteResult.error)
}
}
// Cut operation
const cutResult = await service.cutNodes([node3Id])
// Original nodes marked for deletion after paste
```
### Error Context Preservation
```typescript
const result = await service.updateNodeProperty({
nodeId: 'invalid-node',
property: 'seed',
value: 12345
})
if (!result.success) {
// Rich error context available
console.error('Error:', result.error.message)
console.error('Code:', result.error.code)
console.error('Operation:', result.error.context.operation)
console.error('Parameters:', result.error.context.params)
console.error('Original error:', result.error.context.cause)
}
```
### Subgraph Operations
```typescript
// Create subgraph from selected items
const subgraphOp: GraphMutationOperation = {
type: 'createSubgraph',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
selectedItems: new Set([node1, node2, node3])
}
}
const result = await service.applyOperation(subgraphOp)
if (result.success) {
const { subgraph, node } = result.data
// Add I/O to subgraph
await service.addSubgraphInput({
subgraphId: subgraph.id,
name: 'image',
type: 'IMAGE'
})
}
```
### History Operations
```typescript
// All operations are undoable
const result = await service.undo()
if (result.success) {
console.log('Undo successful')
} else {
console.error('Undo failed:', result.error.message)
// Might fail if no history or change tracker unavailable
}
// Redo
const redoResult = await service.redo()
```
## Implementation Details
### Integration Points
1. **LiteGraph Integration**
- Uses `app.graph` for graph access
- Calls `beforeChange()`/`afterChange()` for all mutations
- Integrates with existing LiteGraph node/connection APIs
2. **ChangeTracker Integration**
- Maintains compatibility with existing undo/redo system
- Transactions wrapped with `beforeChange()`/`afterChange()`
- No longer calls `checkState()` directly (removed from new implementation)
3. **Error Handling**
- All operations wrapped in try-catch blocks
- Errors converted to GraphMutationError with context
- Original errors preserved in context.cause
## Technical Decisions
### Why Command Pattern?
- **Uniformity**: Single entry point for all operations
- **Extensibility**: Easy to add new operations
- **CRDT Ready**: Commands include timestamp and origin for future sync
- **Testing**: Easy to test command dispatch and execution
### Why Result Pattern?
- **Explicit Error Handling**: Forces consumers to handle errors
- **No Exceptions**: Predictable control flow
- **Rich Context**: Errors carry operation context
- **Type Safety**: TypeScript discriminated unions
### Why GraphMutationError?
- **Context Preservation**: Maintains full operation context
- **Debugging**: Detailed information for troubleshooting
- **Standardization**: Consistent error structure
- **Traceability**: Links errors to specific operations
## Related Files
- **Interface Definition**: `src/core/graph/operations/IGraphMutationService.ts`
- **Implementation**: `src/core/graph/operations/graphMutationService.ts`
- **Types**: `src/core/graph/operations/types.ts`
- **Error Class**: `src/core/graph/operations/GraphMutationError.ts`
- **Tests**: `tests-ui/tests/services/graphMutationService.test.ts`
- **LiteGraph Core**: `src/lib/litegraph/src/LGraph.ts`
- **Node Implementation**: `src/lib/litegraph/src/LGraphNode.ts`
- **Change Tracking**: `src/scripts/changeTracker.ts`
## Implementation Compatibility Notes
### Critical Implementation Details to Maintain:
1. **beforeChange/afterChange Pattern**
- All mutations MUST be wrapped with `graph.beforeChange()` and `graph.afterChange()`
- This enables undo/redo functionality through ChangeTracker
- Reference: Pattern used consistently throughout service
2. **Node ID Management**
- Node IDs use NodeId type from schemas
- Custom IDs can be provided during creation (for workflow loading)
3. **Clipboard Implementation**
- Uses localStorage with key 'litegrapheditor_clipboard'
- Maintains node connections during copy/paste
- Cut operation marks nodes for deletion after paste
4. **Group Management**
- Groups auto-resize when adding nodes using `recomputeInsideNodes()`
- Visual operations call `graph.setDirtyCanvas(true, false)`
5. **Error Handling**
- All operations return Result<T, GraphMutationError>
- Never throw exceptions from public methods
- Preserve original error in context.cause
6. **Subgraph Support**
- Uses instanceof checks for SubgraphNode detection
- Iterates through graph._nodes to find subgraphs
## Migration Strategy
1. Replace direct graph method calls with service operations
2. Update error handling from try-catch to Result pattern checking
3. Convert operation calls to use command pattern where beneficial
4. Leverage error context for better debugging
5. Ensure all operations maintain existing beforeChange/afterChange patterns
## Important Notes
1. **Always use GraphMutationService** - Never call graph methods directly
2. **Handle Result types** - Check success before using data
3. **Preserve error context** - Log full error context for debugging
4. **Command pattern ready** - Can easily add CRDT sync in future
5. **Performance** - Result pattern and command recording have minimal overhead
6. **Type safety** - Use TypeScript types for all operations

View File

@@ -1,14 +0,0 @@
export class GraphMutationError extends Error {
public readonly code: string
public readonly context: Record<string, any>
constructor(
message: string,
context: Record<string, any>,
code = 'GRAPH_MUTATION_ERROR'
) {
super(message)
this.code = code
this.context = context
}
}

View File

@@ -1,144 +0,0 @@
import type { GraphMutationError } from '@/core/graph/operations/GraphMutationError'
import type {
AddNodeInputParams,
AddNodeOutputParams,
AddNodesToGroupParams,
AddRerouteParams,
ChangeNodeModeParams,
ConnectParams,
CreateGroupParams,
CreateNodeParams,
CreateSubgraphParams,
CreateSubgraphResult,
DisconnectParams,
GraphMutationOperation,
NodeInputSlotParams,
OperationResultType,
Result,
SubgraphIndexParams,
SubgraphNameTypeParams,
UpdateGroupTitleParams,
UpdateNodePropertyParams,
UpdateNodeTitleParams
} from '@/core/graph/operations/types'
import type { GroupId } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LinkId } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
export interface IGraphMutationService {
applyOperation<T extends GraphMutationOperation>(
operation: T
): Promise<Result<OperationResultType<T>, GraphMutationError>>
createNode(
params: CreateNodeParams
): Promise<Result<NodeId, GraphMutationError>>
getNodeById(nodeId: NodeId): Promise<Result<LGraphNode, GraphMutationError>>
removeNode(nodeId: NodeId): Promise<Result<void, GraphMutationError>>
updateNodeProperty(
params: UpdateNodePropertyParams
): Promise<Result<void, GraphMutationError>>
updateNodeTitle(
params: UpdateNodeTitleParams
): Promise<Result<void, GraphMutationError>>
changeNodeMode(
params: ChangeNodeModeParams
): Promise<Result<void, GraphMutationError>>
cloneNode(nodeId: NodeId): Promise<Result<NodeId, GraphMutationError>>
connect(params: ConnectParams): Promise<Result<LinkId, GraphMutationError>>
disconnect(
params: DisconnectParams
): Promise<Result<boolean, GraphMutationError>>
disconnectLink(linkId: LinkId): Promise<Result<void, GraphMutationError>>
createGroup(
params: CreateGroupParams
): Promise<Result<GroupId, GraphMutationError>>
removeGroup(groupId: GroupId): Promise<Result<void, GraphMutationError>>
updateGroupTitle(
params: UpdateGroupTitleParams
): Promise<Result<void, GraphMutationError>>
addNodesToGroup(
params: AddNodesToGroupParams
): Promise<Result<void, GraphMutationError>>
recomputeGroupNodes(
groupId: GroupId
): Promise<Result<void, GraphMutationError>>
addReroute(
params: AddRerouteParams
): Promise<Result<RerouteId, GraphMutationError>>
removeReroute(rerouteId: RerouteId): Promise<Result<void, GraphMutationError>>
copyNodes(nodeIds: NodeId[]): Promise<Result<void, GraphMutationError>>
cutNodes(nodeIds: NodeId[]): Promise<Result<void, GraphMutationError>>
pasteNodes(): Promise<Result<NodeId[], GraphMutationError>>
addSubgraphNodeInput(
params: AddNodeInputParams
): Promise<Result<number, GraphMutationError>>
addSubgraphNodeOutput(
params: AddNodeOutputParams
): Promise<Result<number, GraphMutationError>>
removeSubgraphNodeInput(
params: NodeInputSlotParams
): Promise<Result<void, GraphMutationError>>
removeSubgraphNodeOutput(
params: NodeInputSlotParams
): Promise<Result<void, GraphMutationError>>
createSubgraph(
params: CreateSubgraphParams
): Promise<Result<CreateSubgraphResult, GraphMutationError>>
unpackSubgraph(
subgraphNodeId: NodeId
): Promise<Result<void, GraphMutationError>>
addSubgraphInput(
params: SubgraphNameTypeParams
): Promise<Result<void, GraphMutationError>>
addSubgraphOutput(
params: SubgraphNameTypeParams
): Promise<Result<void, GraphMutationError>>
removeSubgraphInput(
params: SubgraphIndexParams
): Promise<Result<void, GraphMutationError>>
removeSubgraphOutput(
params: SubgraphIndexParams
): Promise<Result<void, GraphMutationError>>
clearGraph(): Promise<Result<void, GraphMutationError>>
bypassNode(nodeId: NodeId): Promise<Result<void, GraphMutationError>>
unbypassNode(nodeId: NodeId): Promise<Result<void, GraphMutationError>>
undo(): Promise<Result<void, GraphMutationError>>
redo(): Promise<Result<void, GraphMutationError>>
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,367 +0,0 @@
/**
* Graph Mutation Command System - Type Definitions
*
* Defines command types for graph mutation operations with CRDT support.
* Each command represents an atomic operation that can be applied, undone, and synchronized.
*/
import type { Subgraph } from '@/lib/litegraph/src/LGraph'
import type { GroupId, LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LinkId } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type {
SubgraphId,
SubgraphNode
} from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
export type Result<T, E> =
| { success: true; data: T }
| { success: false; error: E }
export interface CreateNodeParams {
type: string
properties?: Record<string, string | number | boolean | object>
title?: string
}
export interface UpdateNodePropertyParams {
nodeId: NodeId
property: string
value: string | number | boolean | object | string[] | undefined
}
export interface UpdateNodeTitleParams {
nodeId: NodeId
title: string
}
export interface ChangeNodeModeParams {
nodeId: NodeId
mode: number
}
export interface ConnectParams {
sourceNodeId: NodeId
sourceSlot: number | string
targetNodeId: NodeId
targetSlot: number | string
}
export interface DisconnectParams {
nodeId: NodeId
slot: number | string
slotType: 'input' | 'output'
targetNodeId?: NodeId
}
export interface CreateGroupParams {
title?: string
size?: [number, number]
color?: string
fontSize?: number
}
export interface UpdateGroupTitleParams {
groupId: GroupId
title: string
}
export interface AddNodesToGroupParams {
groupId: GroupId
nodeIds: NodeId[]
}
export interface AddRerouteParams {
pos: [number, number]
linkId?: LinkId
parentRerouteId?: RerouteId
}
export interface AddNodeInputParams {
nodeId: NodeId
name: string
type: string
extra_info?: Record<string, unknown>
}
export interface AddNodeOutputParams {
nodeId: NodeId
name: string
type: string
extra_info?: Record<string, unknown>
}
export interface CreateSubgraphParams {
selectedItems: Set<LGraphNode | LGraphGroup>
}
export interface NodeInputSlotParams {
nodeId: NodeId
slot: number
}
export interface SubgraphNameTypeParams {
subgraphId: SubgraphId
name: string
type: string
}
export interface SubgraphIndexParams {
subgraphId: SubgraphId
index: number
}
export enum CommandOrigin {
Local = 'local'
}
export type GraphMutationOperation =
| CreateNodeCommand
| RemoveNodeCommand
| UpdateNodePropertyCommand
| UpdateNodeTitleCommand
| ChangeNodeModeCommand
| CloneNodeCommand
| BypassNodeCommand
| UnbypassNodeCommand
| ConnectCommand
| DisconnectCommand
| DisconnectLinkCommand
| CreateGroupCommand
| RemoveGroupCommand
| UpdateGroupTitleCommand
| AddNodesToGroupCommand
| RecomputeGroupNodesCommand
| AddRerouteCommand
| RemoveRerouteCommand
| CopyNodesCommand
| CutNodesCommand
| PasteNodesCommand
| CreateSubgraphCommand
| UnpackSubgraphCommand
| AddSubgraphNodeInputCommand
| AddSubgraphNodeOutputCommand
| RemoveSubgraphNodeInputCommand
| RemoveSubgraphNodeOutputCommand
| AddSubgraphInputCommand
| AddSubgraphOutputCommand
| RemoveSubgraphInputCommand
| RemoveSubgraphOutputCommand
| ClearGraphCommand
| UndoCommand
| RedoCommand
interface GraphOpBase {
/** Timestamp for ordering commands */
timestamp: number
/** Origin of the command */
origin: CommandOrigin
}
export interface CreateNodeCommand extends GraphOpBase {
type: 'createNode'
params: CreateNodeParams
}
export interface RemoveNodeCommand extends GraphOpBase {
type: 'removeNode'
params: NodeId
}
export interface UpdateNodePropertyCommand extends GraphOpBase {
type: 'updateNodeProperty'
params: UpdateNodePropertyParams
}
export interface UpdateNodeTitleCommand extends GraphOpBase {
type: 'updateNodeTitle'
params: UpdateNodeTitleParams
}
export interface ChangeNodeModeCommand extends GraphOpBase {
type: 'changeNodeMode'
params: ChangeNodeModeParams
}
export interface CloneNodeCommand extends GraphOpBase {
type: 'cloneNode'
params: NodeId
}
export interface BypassNodeCommand extends GraphOpBase {
type: 'bypassNode'
params: NodeId
}
export interface UnbypassNodeCommand extends GraphOpBase {
type: 'unbypassNode'
params: NodeId
}
export interface ConnectCommand extends GraphOpBase {
type: 'connect'
params: ConnectParams
}
export interface DisconnectCommand extends GraphOpBase {
type: 'disconnect'
params: DisconnectParams
}
export interface DisconnectLinkCommand extends GraphOpBase {
type: 'disconnectLink'
params: LinkId
}
export interface CreateGroupCommand extends GraphOpBase {
type: 'createGroup'
params: CreateGroupParams
}
export interface RemoveGroupCommand extends GraphOpBase {
type: 'removeGroup'
params: GroupId
}
export interface UpdateGroupTitleCommand extends GraphOpBase {
type: 'updateGroupTitle'
params: UpdateGroupTitleParams
}
export interface AddNodesToGroupCommand extends GraphOpBase {
type: 'addNodesToGroup'
params: AddNodesToGroupParams
}
export interface RecomputeGroupNodesCommand extends GraphOpBase {
type: 'recomputeGroupNodes'
params: GroupId
}
// Reroute Commands
export interface AddRerouteCommand extends GraphOpBase {
type: 'addReroute'
params: AddRerouteParams
}
export interface RemoveRerouteCommand extends GraphOpBase {
type: 'removeReroute'
params: RerouteId
}
export interface CopyNodesCommand extends GraphOpBase {
type: 'copyNodes'
nodeIds: NodeId[]
}
export interface CutNodesCommand extends GraphOpBase {
type: 'cutNodes'
params: NodeId[]
}
export interface PasteNodesCommand extends GraphOpBase {
type: 'pasteNodes'
}
export interface CreateSubgraphCommand extends GraphOpBase {
type: 'createSubgraph'
params: CreateSubgraphParams
}
export interface UnpackSubgraphCommand extends GraphOpBase {
type: 'unpackSubgraph'
params: NodeId
}
export interface AddSubgraphNodeInputCommand extends GraphOpBase {
type: 'addSubgraphNodeInput'
params: AddNodeInputParams
}
export interface AddSubgraphNodeOutputCommand extends GraphOpBase {
type: 'addSubgraphNodeOutput'
params: AddNodeOutputParams
}
export interface RemoveSubgraphNodeInputCommand extends GraphOpBase {
type: 'removeSubgraphNodeInput'
params: NodeInputSlotParams
}
export interface RemoveSubgraphNodeOutputCommand extends GraphOpBase {
type: 'removeSubgraphNodeOutput'
params: NodeInputSlotParams
}
export interface AddSubgraphInputCommand extends GraphOpBase {
type: 'addSubgraphInput'
params: SubgraphNameTypeParams
}
export interface AddSubgraphOutputCommand extends GraphOpBase {
type: 'addSubgraphOutput'
params: SubgraphNameTypeParams
}
export interface RemoveSubgraphInputCommand extends GraphOpBase {
type: 'removeSubgraphInput'
params: SubgraphIndexParams
}
export interface RemoveSubgraphOutputCommand extends GraphOpBase {
type: 'removeSubgraphOutput'
params: SubgraphIndexParams
}
export interface ClearGraphCommand extends GraphOpBase {
type: 'clearGraph'
}
export interface UndoCommand extends GraphOpBase {
type: 'undo'
}
export interface RedoCommand extends GraphOpBase {
type: 'redo'
}
export type NodeIdReturnOperations = CreateNodeCommand | CloneNodeCommand
export type LinkIdReturnOperations = ConnectCommand
export type BooleanReturnOperations = DisconnectCommand
export type GroupIdReturnOperations = CreateGroupCommand
export type RerouteIdReturnOperations = AddRerouteCommand
export type NodeIdArrayReturnOperations = PasteNodesCommand
export type NumberReturnOperations =
| AddSubgraphNodeInputCommand
| AddSubgraphNodeOutputCommand
export interface CreateSubgraphResult {
subgraph: Subgraph
node: SubgraphNode
}
export type OperationResultType<T extends GraphMutationOperation> =
T extends NodeIdReturnOperations
? NodeId
: T extends LinkIdReturnOperations
? LinkId
: T extends BooleanReturnOperations
? boolean
: T extends GroupIdReturnOperations
? GroupId
: T extends RerouteIdReturnOperations
? RerouteId
: T extends NodeIdArrayReturnOperations
? NodeId[]
: T extends NumberReturnOperations
? number
: T extends CreateSubgraphCommand
? CreateSubgraphResult
: void

View File

@@ -87,6 +87,7 @@ import type { PickNevers } from './types/utility'
import type { IBaseWidget } from './types/widgets'
import { alignNodes, distributeNodes, getBoundaryNodes } from './utils/arrange'
import { findFirstNode, getAllNestedItems } from './utils/collections'
import { resolveConnectingLinkColor } from './utils/linkColors'
import type { UUID } from './utils/uuid'
import { BaseWidget } from './widgets/BaseWidget'
import { toConcreteWidget } from './widgets/widgetMap'
@@ -4716,29 +4717,20 @@ export class LGraphCanvas
const connShape = fromSlot.shape
const connType = fromSlot.type
const colour =
connType === LiteGraph.EVENT
? LiteGraph.EVENT_LINK_COLOR
: LiteGraph.CONNECTING_LINK_COLOR
const colour = resolveConnectingLinkColor(connType)
// the connection being dragged by the mouse
if (this.linkRenderer) {
this.linkRenderer.renderLinkDirect(
this.linkRenderer.renderDraggingLink(
ctx,
pos,
highlightPos,
null,
false,
null,
colour,
fromDirection,
dragDirection,
{
...this.buildLinkRenderContext(),
linkMarkerShape: LinkMarkerShape.None
},
{
disabled: false
}
)
}

View File

@@ -24,8 +24,6 @@ import {
} from './measure'
import type { ISerialisedGroup } from './types/serialisation'
export type GroupId = number
export interface IGraphGroupFlags extends Record<string, unknown> {
pinned?: true
}

View File

@@ -31,8 +31,6 @@ import {
} from './ExecutableNodeDTO'
import type { SubgraphInput } from './SubgraphInput'
export type SubgraphId = string
/**
* An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph.
*/

View File

@@ -0,0 +1,13 @@
import type { CanvasColour, ISlotType } from '../interfaces'
import { LiteGraph } from '../litegraph'
/**
* Resolve the colour used while rendering or previewing a connection of a given slot type.
*/
export function resolveConnectingLinkColor(
type: ISlotType | undefined
): CanvasColour {
return type === LiteGraph.EVENT
? LiteGraph.EVENT_LINK_COLOR
: LiteGraph.CONNECTING_LINK_COLOR
}

View File

@@ -7,13 +7,10 @@
* Maintains backward compatibility with existing litegraph integration.
*/
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type {
CanvasColour,
INodeInputSlot,
INodeOutputSlot,
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -27,7 +24,6 @@ import {
type ArrowShape,
CanvasPathRenderer,
type Direction,
type DragLinkData,
type LinkRenderData,
type RenderContext as PathRenderContext,
type Point,
@@ -209,7 +205,6 @@ export class LitegraphLinkAdapter {
case LinkDirection.DOWN:
return 'down'
case LinkDirection.CENTER:
case LinkDirection.NONE:
return 'none'
default:
return 'right'
@@ -502,57 +497,33 @@ export class LitegraphLinkAdapter {
}
}
/**
* Render a link being dragged from a slot to mouse position
* Used during link creation/reconnection
*/
renderDraggingLink(
ctx: CanvasRenderingContext2D,
fromNode: LGraphNode | null,
fromSlot: INodeOutputSlot | INodeInputSlot,
fromSlotIndex: number,
toPosition: ReadOnlyPoint,
context: LinkRenderContext,
options: {
fromInput?: boolean
color?: CanvasColour
disabled?: boolean
} = {}
from: ReadOnlyPoint,
to: ReadOnlyPoint,
colour: CanvasColour,
startDir: LinkDirection,
endDir: LinkDirection,
context: LinkRenderContext
): void {
if (!fromNode) return
// Get slot position using layout tree if available
const slotPos = getSlotPosition(
fromNode,
fromSlotIndex,
options.fromInput || false
this.renderLinkDirect(
ctx,
from,
to,
null,
false,
null,
colour,
startDir,
endDir,
{
...context,
linkMarkerShape: LinkMarkerShape.None
},
{
disabled: false
}
)
if (!slotPos) return
// Get slot direction
const slotDir =
fromSlot.dir ||
(options.fromInput ? LinkDirection.LEFT : LinkDirection.RIGHT)
// Create drag data
const dragData: DragLinkData = {
fixedPoint: { x: slotPos[0], y: slotPos[1] },
fixedDirection: this.convertDirection(slotDir),
dragPoint: { x: toPosition[0], y: toPosition[1] },
color: options.color ? String(options.color) : undefined,
type: fromSlot.type !== undefined ? String(fromSlot.type) : undefined,
disabled: options.disabled || false,
fromInput: options.fromInput || false
}
// Convert context
const pathContext = this.convertToPathRenderContext(context)
// Hide center marker when dragging links
pathContext.style.showCenterMarker = false
// Render using pure renderer
this.pathRenderer.drawDraggingLink(ctx, dragData, pathContext)
}
/**

View File

@@ -70,7 +70,7 @@ export interface RenderContext {
highlightedIds?: Set<string>
}
export interface DragLinkData {
interface DragLinkData {
/** Fixed end - the slot being dragged from */
fixedPoint: Point
fixedDirection: Direction

View File

@@ -0,0 +1,83 @@
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
SlotDragSource,
SlotDropCandidate
} from '@/renderer/core/linkInteractions/slotLinkDragState'
import { app } from '@/scripts/app'
interface CompatibilityResult {
allowable: boolean
targetNode?: LGraphNode
targetSlot?: INodeInputSlot | INodeOutputSlot
}
function resolveNode(nodeId: string | number) {
const canvas = app.canvas
const graph = canvas?.graph
if (!graph) return null
const id = typeof nodeId === 'string' ? Number(nodeId) : nodeId
if (Number.isNaN(id)) return null
return graph.getNodeById(id)
}
export function evaluateCompatibility(
source: SlotDragSource,
candidate: SlotDropCandidate
): CompatibilityResult {
if (
candidate.layout.nodeId === source.nodeId &&
candidate.layout.index === source.slotIndex
) {
return { allowable: false }
}
const isOutputToInput =
source.type === 'output' && candidate.layout.type === 'input'
const isInputToOutput =
source.type === 'input' && candidate.layout.type === 'output'
if (!isOutputToInput && !isInputToOutput) {
return { allowable: false }
}
const sourceNode = resolveNode(source.nodeId)
const targetNode = resolveNode(candidate.layout.nodeId)
if (!sourceNode || !targetNode) {
return { allowable: false }
}
const sourceSlot = isOutputToInput
? sourceNode.outputs?.[source.slotIndex]
: sourceNode.inputs?.[source.slotIndex]
const targetSlot = isOutputToInput
? targetNode.inputs?.[candidate.layout.index]
: targetNode.outputs?.[candidate.layout.index]
if (!sourceSlot || !targetSlot) {
return { allowable: false }
}
if (isOutputToInput) {
const outputSlot = sourceSlot as INodeOutputSlot | undefined
const inputSlot = targetSlot as INodeInputSlot | undefined
if (!outputSlot || !inputSlot) {
return { allowable: false }
}
const allowable = sourceNode.canConnectTo(targetNode, inputSlot, outputSlot)
return { allowable, targetNode, targetSlot: inputSlot }
}
const inputSlot = sourceSlot as INodeInputSlot | undefined
const outputSlot = targetSlot as INodeOutputSlot | undefined
if (!inputSlot || !outputSlot) {
return { allowable: false }
}
const allowable = targetNode.canConnectTo(sourceNode, inputSlot, outputSlot)
return { allowable, targetNode, targetSlot: outputSlot }
}

View File

@@ -0,0 +1,89 @@
import { reactive, readonly, shallowReactive } from 'vue'
import type { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLayout } from '@/renderer/core/layout/types'
type SlotDragType = 'input' | 'output'
export interface SlotDragSource {
nodeId: string
slotIndex: number
type: SlotDragType
direction: LinkDirection
position: Readonly<{ x: number; y: number }>
}
export interface SlotDropCandidate {
layout: SlotLayout
compatible: boolean
}
interface PointerPosition {
client: Readonly<{ x: number; y: number }>
canvas: Readonly<{ x: number; y: number }>
}
interface SlotDragState {
active: boolean
pointerId: number | null
source: SlotDragSource | null
pointer: PointerPosition
candidate: SlotDropCandidate | null
}
const defaultPointer: PointerPosition = Object.freeze({
client: { x: 0, y: 0 },
canvas: { x: 0, y: 0 }
})
const state = reactive<SlotDragState>({
active: false,
pointerId: null,
source: null,
pointer: defaultPointer,
candidate: null
})
function updatePointerPosition(position: PointerPosition) {
state.pointer = shallowReactive({
client: position.client,
canvas: position.canvas
})
}
function setCandidate(candidate: SlotDropCandidate | null) {
state.candidate = candidate
}
function beginDrag(source: SlotDragSource, pointerId: number) {
state.active = true
state.source = source
state.pointerId = pointerId
state.candidate = null
}
function endDrag() {
state.active = false
state.pointerId = null
state.source = null
state.pointer = defaultPointer
state.candidate = null
}
function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) {
const slotKey = getSlotKey(nodeId, slotIndex, isInput)
return layoutStore.getSlotLayout(slotKey)
}
export function useSlotLinkDragState() {
return {
state: readonly(state),
beginDrag,
endDrag,
updatePointerPosition,
setCandidate,
getSlotLayout
}
}

View File

@@ -0,0 +1,95 @@
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type {
INodeInputSlot,
INodeOutputSlot,
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { resolveConnectingLinkColor } from '@/lib/litegraph/src/utils/linkColors'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import {
type SlotDragSource,
useSlotLinkDragState
} from '@/renderer/core/linkInteractions/slotLinkDragState'
function buildContext(canvas: LGraphCanvas): LinkRenderContext {
return {
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,
highlightedLinks: new Set(Object.keys(canvas.highlighted_links)),
defaultLinkColor: canvas.default_link_color,
linkTypeColors: (canvas.constructor as typeof LGraphCanvas)
.link_type_colors,
disabledPattern: canvas._pattern
}
}
export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) {
const originalOnDrawForeground = canvas.onDrawForeground?.bind(canvas)
const patched = (
ctx: CanvasRenderingContext2D,
area: LGraphCanvas['visible_area']
) => {
originalOnDrawForeground?.(ctx, area)
const { state } = useSlotLinkDragState()
if (!state.active || !state.source) return
const { pointer, source } = state
const start = source.position
const sourceSlot = resolveSourceSlot(canvas, source)
const linkRenderer = canvas.linkRenderer
if (!linkRenderer) return
const context = buildContext(canvas)
const from: ReadOnlyPoint = [start.x, start.y]
const to: ReadOnlyPoint = [pointer.canvas.x, pointer.canvas.y]
const startDir = source.direction ?? LinkDirection.RIGHT
const endDir = LinkDirection.CENTER
const colour = resolveConnectingLinkColor(sourceSlot?.type)
ctx.save()
linkRenderer.renderDraggingLink(
ctx,
from,
to,
colour,
startDir,
endDir,
context
)
ctx.restore()
}
canvas.onDrawForeground = patched
}
function resolveSourceSlot(
canvas: LGraphCanvas,
source: SlotDragSource
): INodeInputSlot | INodeOutputSlot | undefined {
const graph = canvas.graph
if (!graph) return undefined
const nodeId = Number(source.nodeId)
if (!Number.isFinite(nodeId)) return undefined
const node = graph.getNodeById(nodeId)
if (!node) return undefined
return source.type === 'output'
? node.outputs?.[source.slotIndex]
: node.inputs?.[source.slotIndex]
}

View File

@@ -1,89 +0,0 @@
import { type ComponentMountingOptions, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import InputSlot from './InputSlot.vue'
import OutputSlot from './OutputSlot.vue'
// Mock composable used by InputSlot/OutputSlot so we can assert call params
vi.mock(
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
() => ({
useSlotElementTracking: vi.fn(() => ({ stop: vi.fn() }))
})
)
type InputSlotProps = ComponentMountingOptions<typeof InputSlot>['props']
type OutputSlotProps = ComponentMountingOptions<typeof OutputSlot>['props']
const mountInputSlot = (props: InputSlotProps) =>
mount(InputSlot, {
global: {
plugins: [
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
}),
createPinia()
]
},
props
})
const mountOutputSlot = (props: OutputSlotProps) =>
mount(OutputSlot, {
global: {
plugins: [
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
}),
createPinia()
]
},
props
})
describe('InputSlot/OutputSlot', () => {
beforeEach(() => {
vi.mocked(useSlotElementTracking).mockClear()
})
it('InputSlot registers with correct options', () => {
mountInputSlot({
nodeId: 'node-1',
index: 3,
slotData: { name: 'A', type: 'any', boundingRect: [0, 0, 0, 0] }
})
expect(useSlotElementTracking).toHaveBeenLastCalledWith(
expect.objectContaining({
nodeId: 'node-1',
index: 3,
type: 'input'
})
)
})
it('OutputSlot registers with correct options', () => {
mountOutputSlot({
nodeId: 'node-2',
index: 1,
slotData: { name: 'B', type: 'any', boundingRect: [0, 0, 0, 0] }
})
expect(useSlotElementTracking).toHaveBeenLastCalledWith(
expect.objectContaining({
nodeId: 'node-2',
index: 1,
type: 'output'
})
)
})
})

View File

@@ -2,8 +2,10 @@
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--input flex items-center cursor-crosshair group rounded-r-lg h-6"
class="lg-slot lg-slot--input flex items-center group rounded-r-lg h-6"
:class="{
'cursor-crosshair': !readonly,
'cursor-default': readonly,
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible,
@@ -16,6 +18,7 @@
ref="connectionDotRef"
:color="slotColor"
class="-translate-x-1/2"
v-on="readonly ? {} : { pointerdown: onPointerDown }"
/>
<!-- Slot Name -->
@@ -41,6 +44,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import SlotConnectionDot from './SlotConnectionDot.vue'
@@ -88,4 +92,10 @@ useSlotElementTracking({
type: 'input',
element: slotElRef
})
const { onPointerDown } = useSlotLinkInteraction({
nodeId: props.nodeId ?? '',
index: props.index,
type: 'input'
})
</script>

View File

@@ -2,8 +2,10 @@
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--output flex items-center cursor-crosshair justify-end group rounded-l-lg h-6"
class="lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6"
:class="{
'cursor-crosshair': !readonly,
'cursor-default': readonly,
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible,
@@ -25,6 +27,7 @@
ref="connectionDotRef"
:color="slotColor"
class="translate-x-1/2"
v-on="readonly ? {} : { pointerdown: onPointerDown }"
/>
</div>
</template>
@@ -42,6 +45,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import SlotConnectionDot from './SlotConnectionDot.vue'
@@ -90,4 +94,10 @@ useSlotElementTracking({
type: 'output',
element: slotElRef
})
const { onPointerDown } = useSlotLinkInteraction({
nodeId: props.nodeId ?? '',
index: props.index,
type: 'output'
})
</script>

View File

@@ -150,7 +150,6 @@ export function useSlotElementTracking(options: {
(el) => {
if (!el) return
// Ensure node entry
const node = nodeSlotRegistryStore.ensureNode(nodeId)
if (!node.stopWatch) {
@@ -184,6 +183,8 @@ export function useSlotElementTracking(options: {
// Register slot
const slotKey = getSlotKey(nodeId, index, type === 'input')
el.dataset.slotKey = slotKey
node.slots.set(slotKey, { el, index, type })
// Seed initial sync from DOM
@@ -203,12 +204,15 @@ export function useSlotElementTracking(options: {
// Remove this slot from registry and layout
const slotKey = getSlotKey(nodeId, index, type === 'input')
node.slots.delete(slotKey)
const entry = node.slots.get(slotKey)
if (entry) {
delete entry.el.dataset.slotKey
node.slots.delete(slotKey)
}
layoutStore.deleteSlotLayout(slotKey)
// If node has no more slots, clean up
if (node.slots.size === 0) {
// Stop the node-level watcher when the last slot is gone
if (node.stopWatch) node.stopWatch()
nodeSlotRegistryStore.deleteNode(nodeId)
}

View File

@@ -0,0 +1,206 @@
import { onBeforeUnmount } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLayout } from '@/renderer/core/layout/types'
import { evaluateCompatibility } from '@/renderer/core/linkInteractions/slotLinkCompatibility'
import {
type SlotDropCandidate,
useSlotLinkDragState
} from '@/renderer/core/linkInteractions/slotLinkDragState'
import { app } from '@/scripts/app'
interface SlotInteractionOptions {
nodeId: string
index: number
type: 'input' | 'output'
}
export function useSlotLinkInteraction({
nodeId,
index,
type
}: SlotInteractionOptions) {
const { state, beginDrag, endDrag, updatePointerPosition } =
useSlotLinkDragState()
function candidateFromTarget(
target: EventTarget | null
): SlotDropCandidate | null {
if (!(target instanceof HTMLElement)) return null
const key = target.dataset['slotKey']
if (!key) return null
const layout = layoutStore.getSlotLayout(key)
if (!layout) return null
const candidate: SlotDropCandidate = { layout, compatible: false }
if (state.source) {
candidate.compatible = evaluateCompatibility(
state.source,
candidate
).allowable
}
return candidate
}
const conversion = useSharedCanvasPositionConversion()
let activePointerId: number | null = null
const cleanupListeners = () => {
window.removeEventListener('pointermove', handlePointerMove, true)
window.removeEventListener('pointerup', handlePointerUp, true)
window.removeEventListener('pointercancel', handlePointerCancel, true)
activePointerId = null
endDrag()
}
const updatePointerState = (event: PointerEvent) => {
const client = { x: event.clientX, y: event.clientY }
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
client.x,
client.y
])
updatePointerPosition({
client,
canvas: { x: canvasX, y: canvasY }
})
}
const handlePointerMove = (event: PointerEvent) => {
if (event.pointerId !== activePointerId) return
updatePointerState(event)
app.canvas?.setDirty(true)
}
const connectSlots = (slotLayout: SlotLayout) => {
const canvas = app.canvas
const graph = canvas?.graph
const source = state.source
if (!canvas || !graph || !source) return
const sourceNode = graph.getNodeById(Number(source.nodeId))
const targetNode = graph.getNodeById(Number(slotLayout.nodeId))
if (!sourceNode || !targetNode) return
const sourceSlot =
source.type === 'output'
? sourceNode.outputs?.[source.slotIndex]
: sourceNode.inputs?.[source.slotIndex]
const targetSlot =
slotLayout.type === 'input'
? targetNode.inputs?.[slotLayout.index]
: targetNode.outputs?.[slotLayout.index]
if (!sourceSlot || !targetSlot) return
if (source.type === 'output' && slotLayout.type === 'input') {
const outputSlot = sourceSlot as INodeOutputSlot | undefined
const inputSlot = targetSlot as INodeInputSlot | undefined
if (!outputSlot || !inputSlot) return
graph.beforeChange()
sourceNode.connectSlots(outputSlot, targetNode, inputSlot, undefined)
return
}
if (source.type === 'input' && slotLayout.type === 'output') {
const inputSlot = sourceSlot as INodeInputSlot | undefined
const outputSlot = targetSlot as INodeOutputSlot | undefined
if (!inputSlot || !outputSlot) return
graph.beforeChange()
sourceNode.disconnectInput(source.slotIndex, true)
targetNode.connectSlots(outputSlot, sourceNode, inputSlot, undefined)
}
}
const finishInteraction = (event: PointerEvent) => {
if (event.pointerId !== activePointerId) return
event.preventDefault()
if (state.source) {
const candidate = candidateFromTarget(event.target)
if (candidate?.compatible) {
connectSlots(candidate.layout)
}
}
cleanupListeners()
app.canvas?.setDirty(true)
}
const handlePointerUp = (event: PointerEvent) => {
finishInteraction(event)
}
const handlePointerCancel = (event: PointerEvent) => {
if (event.pointerId !== activePointerId) return
cleanupListeners()
app.canvas?.setDirty(true)
}
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0) return
if (!nodeId) return
if (activePointerId !== null) return
const canvas = app.canvas
const graph = canvas?.graph
if (!canvas || !graph) return
const layout = layoutStore.getSlotLayout(
getSlotKey(nodeId, index, type === 'input')
)
if (!layout) return
const resolvedNode = graph.getNodeById(Number(nodeId))
const slot =
type === 'input'
? resolvedNode?.inputs?.[index]
: resolvedNode?.outputs?.[index]
const direction =
slot?.dir ?? (type === 'input' ? LinkDirection.LEFT : LinkDirection.RIGHT)
beginDrag(
{
nodeId,
slotIndex: index,
type,
direction,
position: layout.position
},
event.pointerId
)
activePointerId = event.pointerId
updatePointerState(event)
window.addEventListener('pointermove', handlePointerMove, true)
window.addEventListener('pointerup', handlePointerUp, true)
window.addEventListener('pointercancel', handlePointerCancel, true)
app.canvas?.setDirty(true)
event.preventDefault()
event.stopPropagation()
}
onBeforeUnmount(() => {
if (activePointerId !== null) {
cleanupListeners()
}
})
return {
onPointerDown
}
}

File diff suppressed because it is too large Load Diff