mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-17 10:57:34 +00:00
Compare commits
43 Commits
graphMutat
...
bl-fix-mon
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20eb500020 | ||
|
|
da3a467f61 | ||
|
|
75d3788a86 | ||
|
|
6eb1f5e9c6 | ||
|
|
bcce2c8ab2 | ||
|
|
402f73467f | ||
|
|
196c0c4f50 | ||
|
|
9d559545e9 | ||
|
|
1e0bb7567c | ||
|
|
f26d1e71d5 | ||
|
|
ef709d1a50 | ||
|
|
6586ef4195 | ||
|
|
f91dc82c94 | ||
|
|
777e419a50 | ||
|
|
16ddd4d481 | ||
|
|
9203ed5311 | ||
|
|
69f5391fce | ||
|
|
1dfa72cf19 | ||
|
|
cb211eb987 | ||
|
|
57a1359201 | ||
|
|
4de2c2fdb9 | ||
|
|
121221d781 | ||
|
|
ed7a4e9c24 | ||
|
|
0ba660f8ff | ||
|
|
3a7cc3f548 | ||
|
|
a0ed9d902a | ||
|
|
cb2069cf13 | ||
|
|
f63118b8c7 | ||
|
|
5022f14265 | ||
|
|
ffede776cb | ||
|
|
ed94827978 | ||
|
|
ea9c1215a3 | ||
|
|
ea93135dbb | ||
|
|
d39bf36ce0 | ||
|
|
c39cdaf36c | ||
|
|
db5a68bc69 | ||
|
|
d4c2e83e45 | ||
|
|
dbacbc548d | ||
|
|
9786ecfb97 | ||
|
|
110ecf31da | ||
|
|
428752619c | ||
|
|
b6269c0e37 | ||
|
|
3112dba454 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
13
src/lib/litegraph/src/utils/linkColors.ts
Normal file
13
src/lib/litegraph/src/utils/linkColors.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
83
src/renderer/core/linkInteractions/slotLinkCompatibility.ts
Normal file
83
src/renderer/core/linkInteractions/slotLinkCompatibility.ts
Normal 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 }
|
||||
}
|
||||
89
src/renderer/core/linkInteractions/slotLinkDragState.ts
Normal file
89
src/renderer/core/linkInteractions/slotLinkDragState.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user