mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 05:19:03 +00:00
Compare commits
5 Commits
v1.45.9
...
glary/drop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95a7db71ed | ||
|
|
a1a20c637b | ||
|
|
fd7184126a | ||
|
|
e537855b41 | ||
|
|
4485c9ccad |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -99,4 +99,6 @@ vitest.config.*.timestamp*
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
.amp
|
||||
# Glary screenshots (uploaded via PR images, not committed)
|
||||
.glary/screenshots/
|
||||
|
||||
97
docs/adr/0009-vue-node-viewport-culling-contract.md
Normal file
97
docs/adr/0009-vue-node-viewport-culling-contract.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 9. Vue-Node Viewport Culling Contract
|
||||
|
||||
Date: 2026-05-01
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
The Vue-node renderer currently mounts every `<LGraphNode>` component for every node in the graph, regardless of whether the node is on screen. `GraphCanvas.vue` does:
|
||||
|
||||
```vue
|
||||
<LGraphNode v-for="nodeData in allNodes" :key="nodeData.id" ... />
|
||||
```
|
||||
|
||||
with `allNodes` derived from `vueNodeLifecycle.nodeManager.value.vueNodeData.values()` — i.e. every node, not just the visible set. As graphs grow, this becomes the dominant performance cost: every node carries widget DOM, slot DOM, watchers, and Pinia subscriptions.
|
||||
|
||||
In contrast, the canvas-mode renderer is properly viewport-culled:
|
||||
|
||||
- `LGraphCanvas.computeVisibleNodes` (`LGraphCanvas.ts:4954`) filters nodes by AABB intersection against `visible_area` every frame.
|
||||
- `_renderAllLinkSegments` (`LGraphCanvas.ts:6214`) culls each link against `margin_area` (visible_area + 20px) before issuing draw work.
|
||||
- Link hit-testing (`_getLinkCentreOnPos`, `LGraphCanvas.ts:5150`) iterates only `renderedPaths`, the set produced by the culled draw pass.
|
||||
|
||||
A spatial index already exists for this purpose:
|
||||
|
||||
- `QuadTree` (`src/renderer/core/spatial/QuadTree.ts`) and `SpatialIndexManager` (`src/renderer/core/spatial/SpatialIndex.ts`) are instantiated by `layoutStore`.
|
||||
- `layoutStore.queryLinkAtPoint` (`layoutStore.ts:733`) and `querySlotAtPoint` (`layoutStore.ts:745`) already use the index for link/slot hit-testing.
|
||||
- `layoutStore.getNodesInBounds` (`layoutStore.ts:338`), however, ignores the quadtree and iterates `ynodes` linearly with `boundsIntersect`. The index is partially used.
|
||||
|
||||
The drop-on-link feature added in this branch (see `useDropOnLink.ts`) reuses `queryLinkAtPoint`'s quadtree-backed hit-test on every pointermove during a node drag. That feature works correctly today, but it is _load-bearing on a partially-enforced invariant_: link hit-test is fast because the index is used; node hit-test would be slow because it isn't. As more interactions move from canvas-mode to Vue-mode, this asymmetry compounds.
|
||||
|
||||
There is currently no documented contract for what is and is not culled, no shared mounting strategy for Vue-node components, and no enforcement preventing new code from regressing what culling does exist.
|
||||
|
||||
## Decision
|
||||
|
||||
Establish a single culling contract for the Vue-node renderer, enforce it through the layout store, and migrate `GraphCanvas.vue` to honor it. The contract has three parts:
|
||||
|
||||
### 1. Spatial index is the single source of truth for "what is on screen"
|
||||
|
||||
Every node, link segment, slot, and reroute that exists in the graph is registered in `SpatialIndexManager` at the moment its layout is created or moves. Every consumer that asks "what is in this rectangle?" must call `SpatialIndexManager.query(bounds)` rather than iterating the canonical map.
|
||||
|
||||
`layoutStore.getNodesInBounds` is migrated to call `nodeSpatialIndex.query(bounds)`. The linear-scan fallback is removed. This is a semantic change, not just performance: `getNodesInBounds` becomes a quadtree query with the same correctness guarantees the existing link/slot queries already provide.
|
||||
|
||||
### 2. Vue-node mounting is bounded by viewport
|
||||
|
||||
`GraphCanvas.vue` no longer iterates `allNodes`. Instead it iterates a derived set that is the union of:
|
||||
|
||||
- Nodes whose AABB intersects the current viewport, expanded by an overscan margin (initial value: 25% of viewport size in each direction, tunable per camera scale).
|
||||
- Nodes that are currently selected, being dragged, or otherwise pinned by an interaction.
|
||||
- Nodes that are ancestors of any visible node in a subgraph chain (so subgraph navigation does not race with mounting).
|
||||
|
||||
The derived set is a `computed` that depends on viewport + spatial-index version. It is recomputed on pan/zoom, on graph mutation, and on selection change — never on every frame.
|
||||
|
||||
Mounting is gated by viewport, not visibility: nodes scrolled off-screen at the same zoom are unmounted; nodes zoomed away (so small that their DOM is irrelevant) remain mounted but are eligible for a future low-quality variant. This decouples "is this node renderable" from "is this node interactable."
|
||||
|
||||
### 3. Hot paths declare their cull discipline
|
||||
|
||||
Every interaction path that reads from the graph during a hot loop (pointermove during drag, hover, marquee select) must either:
|
||||
|
||||
a) Query through the spatial index, or
|
||||
b) Operate exclusively on a set that is already viewport-bounded (e.g. mounted Vue-node DOM, `renderedPaths`).
|
||||
|
||||
Hot paths that iterate the full node/link map are a regression. A lightweight ESLint rule (or a documented pattern check) flags new uses of `layoutStore.ynodes`, `graph._nodes.values()`, or `graph._links.values()` inside `pointermove`/`pointerdown` handlers and inside any function called from `requestAnimationFrame`.
|
||||
|
||||
The `graphInteractionHooks` event bus (`src/renderer/core/canvas/hooks/graphInteractionHooks.ts`) is the canonical attach point for features that need to react to drag hot paths. Listeners receive canvas-space positions and are expected to query the spatial index for any spatial lookup. Direct subscription to pointer events on individual node components is permitted only for events that do not require graph-wide spatial queries (e.g. node-local drag start, slot interaction).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Bounded Vue render cost.** Mounting cost becomes O(visible nodes), not O(graph size). This is the highest-leverage perf fix in the renderer; large workflows (500+ nodes) currently mount ~500 components on load.
|
||||
- **Uniform hit-testing semantics.** Every spatial lookup uses the same data structure with the same correctness guarantees. New features that need spatial queries do not need to invent their own indexing.
|
||||
- **Drop-on-link and similar features become trivially scalable.** The current implementation already piggy-backs on the index for link queries; under this contract, the same applies to node queries.
|
||||
- **Existing hot paths stay valid.** Canvas-mode rendering already follows this contract; this ADR documents and extends it to Vue-mode rather than introducing a new model.
|
||||
|
||||
### Negative
|
||||
|
||||
- **`getNodesInBounds` semantics change.** Consumers that relied on the linear scan returning _every_ matching node (including nodes with stale or zero bounds) may need to ensure layout is initialized before querying. This is a one-time migration, not an ongoing burden.
|
||||
- **Mount thrash on rapid pan/zoom.** Aggressive viewport culling without overscan can churn DOM during a fling-pan. The 25% overscan and a small debounce on viewport-derived recomputation mitigate this; Storybook scenarios under pan stress are required as part of the migration.
|
||||
- **Selection invariant.** A selected node scrolled off-screen must remain mounted (otherwise its selection box and toolbox disappear). The mounting set explicitly includes selected/dragging nodes for this reason; tests should cover this.
|
||||
- **Subgraph traversal.** Subgraph IO nodes and the current subgraph's parent must remain mounted while the user is inside a subgraph. The "ancestors of visible nodes" rule covers this.
|
||||
|
||||
### Migration Path
|
||||
|
||||
1. Migrate `layoutStore.getNodesInBounds` to use `nodeSpatialIndex.query`. Smoke-test with existing marquee-selection and minimap tests.
|
||||
2. Add a `visibleNodeIds` `computed` to `useVueNodeLifecycle` that queries the spatial index against the current camera viewport with overscan.
|
||||
3. Switch `GraphCanvas.vue` `v-for` from `allNodes` to a derived `mountedNodes` that unions `visibleNodeIds` with selected/dragging/pinned IDs.
|
||||
4. Add Storybook + Playwright scenarios for: large graph mount cost, rapid pan, selection persistence off-screen, subgraph entry/exit, drag-out-of-viewport.
|
||||
5. Add ESLint check (or pre-commit hook) for the hot-path discipline rule in part 3.
|
||||
|
||||
Steps 1 and 2 are independently shippable. Step 3 is the user-visible perf change.
|
||||
|
||||
## Related
|
||||
|
||||
- ADR 0003 — Centralized Layout Management with CRDT (introduces `layoutStore` and the observer pattern that makes spatial-index integration possible).
|
||||
- ADR 0008 — Entity Component System (the long-term direction; this ADR is consistent with treating spatial data as a queryable system rather than an entity-local property).
|
||||
@@ -170,6 +170,7 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { useDropOnLink } from '@/renderer/extensions/vueNodes/composables/useDropOnLink'
|
||||
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { UnauthorizedError } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
@@ -252,6 +253,8 @@ const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
// Vue node system
|
||||
const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
|
||||
useDropOnLink()
|
||||
|
||||
// Error-clearing hooks run regardless of rendering mode (Vue or legacy canvas).
|
||||
let cleanupErrorHooks: (() => void) | null = null
|
||||
watch(
|
||||
|
||||
92
src/renderer/core/canvas/hooks/graphInteractionHooks.test.ts
Normal file
92
src/renderer/core/canvas/hooks/graphInteractionHooks.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { graphInteractionHooks } from './graphInteractionHooks'
|
||||
import type {
|
||||
NodeDragEndEvent,
|
||||
NodeDragMoveEvent
|
||||
} from './graphInteractionHooks'
|
||||
|
||||
const sampleMove: NodeDragMoveEvent = {
|
||||
nodeId: 'node-1',
|
||||
canvasPos: { x: 10, y: 20 },
|
||||
pointerEvent: new PointerEvent('pointermove'),
|
||||
selectionSize: 1
|
||||
}
|
||||
|
||||
const sampleEnd: NodeDragEndEvent = {
|
||||
nodeId: 'node-1',
|
||||
canvasPos: { x: 10, y: 20 },
|
||||
pointerEvent: new PointerEvent('pointerup'),
|
||||
selectionSize: 1
|
||||
}
|
||||
|
||||
describe('graphInteractionHooks', () => {
|
||||
afterEach(() => {
|
||||
graphInteractionHooks.clear()
|
||||
})
|
||||
|
||||
it('delivers events to subscribed listeners', () => {
|
||||
const listener = vi.fn()
|
||||
graphInteractionHooks.on('nodeDragMove', listener)
|
||||
|
||||
graphInteractionHooks.emit('nodeDragMove', sampleMove)
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(sampleMove)
|
||||
})
|
||||
|
||||
it('returns an unsubscribe function that stops delivery', () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = graphInteractionHooks.on('nodeDragMove', listener)
|
||||
|
||||
unsubscribe()
|
||||
graphInteractionHooks.emit('nodeDragMove', sampleMove)
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('isolates listeners across event types', () => {
|
||||
const moveListener = vi.fn()
|
||||
const endListener = vi.fn()
|
||||
graphInteractionHooks.on('nodeDragMove', moveListener)
|
||||
graphInteractionHooks.on('nodeDragEnd', endListener)
|
||||
|
||||
graphInteractionHooks.emit('nodeDragMove', sampleMove)
|
||||
|
||||
expect(moveListener).toHaveBeenCalledTimes(1)
|
||||
expect(endListener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues delivery after a listener throws', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const failing = vi.fn(() => {
|
||||
throw new Error('listener boom')
|
||||
})
|
||||
const succeeding = vi.fn()
|
||||
|
||||
graphInteractionHooks.on('nodeDragMove', failing)
|
||||
graphInteractionHooks.on('nodeDragMove', succeeding)
|
||||
|
||||
graphInteractionHooks.emit('nodeDragMove', sampleMove)
|
||||
|
||||
expect(failing).toHaveBeenCalledTimes(1)
|
||||
expect(succeeding).toHaveBeenCalledTimes(1)
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clear() removes all listeners across event types', () => {
|
||||
const moveListener = vi.fn()
|
||||
const endListener = vi.fn()
|
||||
graphInteractionHooks.on('nodeDragMove', moveListener)
|
||||
graphInteractionHooks.on('nodeDragEnd', endListener)
|
||||
|
||||
graphInteractionHooks.clear()
|
||||
|
||||
graphInteractionHooks.emit('nodeDragMove', sampleMove)
|
||||
graphInteractionHooks.emit('nodeDragEnd', sampleEnd)
|
||||
|
||||
expect(moveListener).not.toHaveBeenCalled()
|
||||
expect(endListener).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
77
src/renderer/core/canvas/hooks/graphInteractionHooks.ts
Normal file
77
src/renderer/core/canvas/hooks/graphInteractionHooks.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Typed event bus for graph interaction hot paths. Listeners run on every
|
||||
* pointermove during a drag, so they must be cheap and must not mutate the
|
||||
* event payload. `on(...)` returns an unsubscribe function; call it from
|
||||
* `onScopeDispose` (or equivalent) to avoid leaks.
|
||||
*/
|
||||
import type { NodeId, Point } from '@/renderer/core/layout/types'
|
||||
|
||||
export interface NodeDragMoveEvent {
|
||||
nodeId: NodeId
|
||||
/** Pointer position in canvas space (post pan/zoom transform). */
|
||||
canvasPos: Point
|
||||
/** Source pointer event. Read-only. */
|
||||
pointerEvent: PointerEvent
|
||||
selectionSize: number
|
||||
}
|
||||
|
||||
export interface NodeDragEndEvent {
|
||||
nodeId: NodeId
|
||||
canvasPos: Point
|
||||
pointerEvent: PointerEvent
|
||||
selectionSize: number
|
||||
}
|
||||
|
||||
interface GraphInteractionEventMap {
|
||||
nodeDragMove: NodeDragMoveEvent
|
||||
nodeDragEnd: NodeDragEndEvent
|
||||
}
|
||||
|
||||
type Listener<E> = (event: E) => void
|
||||
type Unsubscribe = () => void
|
||||
|
||||
function createBus() {
|
||||
const listeners: {
|
||||
[K in keyof GraphInteractionEventMap]: Set<
|
||||
Listener<GraphInteractionEventMap[K]>
|
||||
>
|
||||
} = {
|
||||
nodeDragMove: new Set(),
|
||||
nodeDragEnd: new Set()
|
||||
}
|
||||
|
||||
function on<K extends keyof GraphInteractionEventMap>(
|
||||
type: K,
|
||||
listener: Listener<GraphInteractionEventMap[K]>
|
||||
): Unsubscribe {
|
||||
listeners[type].add(listener)
|
||||
return () => {
|
||||
listeners[type].delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function emit<K extends keyof GraphInteractionEventMap>(
|
||||
type: K,
|
||||
event: GraphInteractionEventMap[K]
|
||||
): void {
|
||||
for (const listener of listeners[type]) {
|
||||
try {
|
||||
listener(event)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`graphInteractionHooks: listener for ${type} threw`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
listeners.nodeDragMove.clear()
|
||||
listeners.nodeDragEnd.clear()
|
||||
}
|
||||
|
||||
return { on, emit, clear }
|
||||
}
|
||||
|
||||
export const graphInteractionHooks = createBus()
|
||||
@@ -0,0 +1,520 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, shallowRef } from 'vue'
|
||||
|
||||
import type { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import { graphInteractionHooks } from '@/renderer/core/canvas/hooks/graphInteractionHooks'
|
||||
import type { LinkId, NodeId } from '@/renderer/core/layout/types'
|
||||
|
||||
import { useDropOnLink } from './useDropOnLink'
|
||||
|
||||
type TestNodeId = NodeId
|
||||
|
||||
const fixtures = vi.hoisted(() => {
|
||||
const links = new Map<number, unknown>()
|
||||
const nodesById = new Map<string, unknown>()
|
||||
const queryLinkAtPoint = vi.fn<
|
||||
(point: { x: number; y: number }) => number | null
|
||||
>(() => null)
|
||||
const isValidConnection = vi.fn<(a: unknown, b: unknown) => boolean>(
|
||||
(a, b) => a === b || a === '*' || b === '*'
|
||||
)
|
||||
const beforeChange = vi.fn()
|
||||
const afterChange = vi.fn()
|
||||
const setDirty = vi.fn()
|
||||
const graph = {
|
||||
_links: links,
|
||||
beforeChange,
|
||||
afterChange,
|
||||
getNodeById: (id: string) => nodesById.get(id) ?? null
|
||||
}
|
||||
const canvas = {
|
||||
highlighted_links: {} as Record<string | number, boolean>,
|
||||
setDirty,
|
||||
graph,
|
||||
ctx: undefined
|
||||
}
|
||||
return {
|
||||
links,
|
||||
nodesById,
|
||||
queryLinkAtPoint,
|
||||
isValidConnection,
|
||||
beforeChange,
|
||||
afterChange,
|
||||
setDirty,
|
||||
graph,
|
||||
canvas
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
links,
|
||||
nodesById,
|
||||
queryLinkAtPoint,
|
||||
isValidConnection,
|
||||
beforeChange,
|
||||
afterChange,
|
||||
setDirty,
|
||||
canvas
|
||||
} = fixtures as unknown as {
|
||||
links: Map<LinkId, LLink>
|
||||
nodesById: Map<NodeId, LGraphNode>
|
||||
queryLinkAtPoint: ReturnType<typeof vi.fn>
|
||||
isValidConnection: ReturnType<typeof vi.fn>
|
||||
beforeChange: ReturnType<typeof vi.fn>
|
||||
afterChange: ReturnType<typeof vi.fn>
|
||||
setDirty: ReturnType<typeof vi.fn>
|
||||
canvas: {
|
||||
highlighted_links: Record<string | number, boolean>
|
||||
setDirty: ReturnType<typeof vi.fn>
|
||||
graph: LGraph
|
||||
ctx: undefined
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: fixtures.canvas }
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: fixtures.canvas })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: { queryLinkAtPoint: fixtures.queryLinkAtPoint }
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LiteGraph: {
|
||||
isValidConnection: (a: unknown, b: unknown) =>
|
||||
fixtures.isValidConnection(a, b)
|
||||
}
|
||||
}))
|
||||
|
||||
const nodeManager = shallowRef({
|
||||
getNode: (id: NodeId) => nodesById.get(id) ?? null
|
||||
})
|
||||
|
||||
vi.mock('@/composables/graph/useVueNodeLifecycle', () => ({
|
||||
useVueNodeLifecycle: () => ({ nodeManager })
|
||||
}))
|
||||
|
||||
interface TestNode {
|
||||
id: TestNodeId
|
||||
inputs: { type: string; link: LinkId | null }[]
|
||||
outputs: { type: string; links: LinkId[] | null }[]
|
||||
connect: ReturnType<typeof vi.fn>
|
||||
disconnectInput: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function makeNode(partial: Partial<TestNode> & { id: TestNodeId }): LGraphNode {
|
||||
const node: TestNode = {
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
connect: vi.fn(() => ({})),
|
||||
disconnectInput: vi.fn(() => true),
|
||||
...partial
|
||||
}
|
||||
nodesById.set(node.id, node as unknown as LGraphNode)
|
||||
return node as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makeLink(
|
||||
id: LinkId,
|
||||
origin_id: TestNodeId,
|
||||
target_id: TestNodeId,
|
||||
type: string
|
||||
): LLink {
|
||||
const link = fromPartial<LLink>({
|
||||
id,
|
||||
origin_id,
|
||||
origin_slot: 0,
|
||||
target_id,
|
||||
target_slot: 0,
|
||||
type
|
||||
})
|
||||
links.set(id, link)
|
||||
return link
|
||||
}
|
||||
|
||||
let scope: ReturnType<typeof effectScope> | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
links.clear()
|
||||
nodesById.clear()
|
||||
graphInteractionHooks.clear()
|
||||
canvas.highlighted_links = {}
|
||||
beforeChange.mockClear()
|
||||
afterChange.mockClear()
|
||||
setDirty.mockClear()
|
||||
queryLinkAtPoint.mockReset()
|
||||
queryLinkAtPoint.mockReturnValue(null)
|
||||
isValidConnection.mockClear()
|
||||
isValidConnection.mockImplementation(
|
||||
(a, b) => a === b || a === '*' || b === '*'
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = null
|
||||
})
|
||||
|
||||
function startComposable(): { hoveredLinkId: { value: LinkId | null } } {
|
||||
scope = effectScope()
|
||||
const api = scope.run(() => useDropOnLink())
|
||||
if (!api) throw new Error('useDropOnLink returned undefined')
|
||||
return api
|
||||
}
|
||||
|
||||
function emitMove(nodeId: string | number) {
|
||||
graphInteractionHooks.emit('nodeDragMove', {
|
||||
nodeId: String(nodeId),
|
||||
canvasPos: { x: 100, y: 100 },
|
||||
pointerEvent: new PointerEvent('pointermove'),
|
||||
selectionSize: 1
|
||||
})
|
||||
}
|
||||
|
||||
function emitEnd(nodeId: string | number) {
|
||||
graphInteractionHooks.emit('nodeDragEnd', {
|
||||
nodeId: String(nodeId),
|
||||
canvasPos: { x: 100, y: 100 },
|
||||
pointerEvent: new PointerEvent('pointerup'),
|
||||
selectionSize: 1
|
||||
})
|
||||
}
|
||||
|
||||
describe('useDropOnLink', () => {
|
||||
it('does nothing when no link is under the pointer', async () => {
|
||||
makeNode({
|
||||
id: 'dragged',
|
||||
inputs: [{ type: 'IMAGE', link: null }],
|
||||
outputs: [{ type: 'IMAGE', links: null }]
|
||||
})
|
||||
queryLinkAtPoint.mockReturnValue(null)
|
||||
|
||||
const api = startComposable()
|
||||
emitMove('dragged')
|
||||
|
||||
expect(api.hoveredLinkId.value).toBeNull()
|
||||
expect(canvas.highlighted_links).toEqual({})
|
||||
})
|
||||
|
||||
it('skips when dragged node already has connections', async () => {
|
||||
makeNode({ id: 'src', outputs: [{ type: 'IMAGE', links: [1] }] })
|
||||
makeNode({ id: 'sink', inputs: [{ type: 'IMAGE', link: 1 }] })
|
||||
makeLink(1, 'src', 'sink', 'IMAGE')
|
||||
|
||||
const dragged = makeNode({
|
||||
id: 'dragged',
|
||||
inputs: [{ type: 'IMAGE', link: 99 }],
|
||||
outputs: [{ type: 'IMAGE', links: null }]
|
||||
})
|
||||
queryLinkAtPoint.mockReturnValue(1)
|
||||
|
||||
const api = startComposable()
|
||||
emitMove(dragged.id)
|
||||
|
||||
expect(api.hoveredLinkId.value).toBeNull()
|
||||
expect(queryLinkAtPoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips when no input slot matches the link type', async () => {
|
||||
makeNode({ id: 'src', outputs: [{ type: 'IMAGE', links: [1] }] })
|
||||
makeNode({ id: 'sink', inputs: [{ type: 'IMAGE', link: 1 }] })
|
||||
makeLink(1, 'src', 'sink', 'IMAGE')
|
||||
|
||||
const dragged = makeNode({
|
||||
id: 'dragged',
|
||||
inputs: [{ type: 'STRING', link: null }],
|
||||
outputs: [{ type: 'IMAGE', links: null }]
|
||||
})
|
||||
queryLinkAtPoint.mockReturnValue(1)
|
||||
|
||||
const api = startComposable()
|
||||
emitMove(dragged.id)
|
||||
|
||||
expect(api.hoveredLinkId.value).toBeNull()
|
||||
})
|
||||
|
||||
it('skips when no output slot matches the link type', async () => {
|
||||
makeNode({ id: 'src', outputs: [{ type: 'IMAGE', links: [1] }] })
|
||||
makeNode({ id: 'sink', inputs: [{ type: 'IMAGE', link: 1 }] })
|
||||
makeLink(1, 'src', 'sink', 'IMAGE')
|
||||
|
||||
const dragged = makeNode({
|
||||
id: 'dragged',
|
||||
inputs: [{ type: 'IMAGE', link: null }],
|
||||
outputs: [{ type: 'STRING', links: null }]
|
||||
})
|
||||
queryLinkAtPoint.mockReturnValue(1)
|
||||
|
||||
const api = startComposable()
|
||||
emitMove(dragged.id)
|
||||
|
||||
expect(api.hoveredLinkId.value).toBeNull()
|
||||
})
|
||||
|
||||
it('highlights the link when target is valid', async () => {
|
||||
makeNode({ id: 'src', outputs: [{ type: 'IMAGE', links: [1] }] })
|
||||
makeNode({ id: 'sink', inputs: [{ type: 'IMAGE', link: 1 }] })
|
||||
makeLink(1, 'src', 'sink', 'IMAGE')
|
||||
|
||||
const dragged = makeNode({
|
||||
id: 'dragged',
|
||||
inputs: [{ type: 'IMAGE', link: null }],
|
||||
outputs: [{ type: 'IMAGE', links: null }]
|
||||
})
|
||||
queryLinkAtPoint.mockReturnValue(1)
|
||||
|
||||
const api = startComposable()
|
||||
emitMove(dragged.id)
|
||||
|
||||
expect(api.hoveredLinkId.value).toBe(1)
|
||||
expect(canvas.highlighted_links).toEqual({ 1: true })
|
||||
expect(setDirty).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips when more than one node is in the drag selection', async () => {
|
||||
makeNode({ id: 'src', outputs: [{ type: 'IMAGE', links: [1] }] })
|
||||
makeNode({ id: 'sink', inputs: [{ type: 'IMAGE', link: 1 }] })
|
||||
makeLink(1, 'src', 'sink', 'IMAGE')
|
||||
|
||||
const dragged = makeNode({
|
||||
id: 'dragged',
|
||||
inputs: [{ type: 'IMAGE', link: null }],
|
||||
outputs: [{ type: 'IMAGE', links: null }]
|
||||
})
|
||||
queryLinkAtPoint.mockReturnValue(1)
|
||||
|
||||
const api = startComposable()
|
||||
graphInteractionHooks.emit('nodeDragMove', {
|
||||
nodeId: String(dragged.id),
|
||||
canvasPos: { x: 0, y: 0 },
|
||||
pointerEvent: new PointerEvent('pointermove'),
|
||||
selectionSize: 2
|
||||
})
|
||||
|
||||
expect(api.hoveredLinkId.value).toBeNull()
|
||||
})
|
||||
|
||||
it('inserts the dragged node into the link on drag end', async () => {
|
||||
const src = makeNode({
|
||||
id: 'src',
|
||||
outputs: [{ type: 'IMAGE', links: [1] }]
|
||||
})
|
||||
const sink = makeNode({ id: 'sink', inputs: [{ type: 'IMAGE', link: 1 }] })
|
||||
makeLink(1, 'src', 'sink', 'IMAGE')
|
||||
|
||||
const dragged = makeNode({
|
||||
id: 'dragged',
|
||||
inputs: [{ type: 'IMAGE', link: null }],
|
||||
outputs: [{ type: 'IMAGE', links: null }]
|
||||
})
|
||||
queryLinkAtPoint.mockReturnValue(1)
|
||||
|
||||
startComposable()
|
||||
emitMove(dragged.id)
|
||||
emitEnd(dragged.id)
|
||||
|
||||
expect(beforeChange).toHaveBeenCalledTimes(1)
|
||||
expect(afterChange).toHaveBeenCalledTimes(1)
|
||||
expect((sink as unknown as TestNode).disconnectInput).toHaveBeenCalledWith(
|
||||
0,
|
||||
true
|
||||
)
|
||||
expect((src as unknown as TestNode).connect).toHaveBeenCalledWith(
|
||||
0,
|
||||
dragged,
|
||||
0
|
||||
)
|
||||
expect((dragged as unknown as TestNode).connect).toHaveBeenCalledWith(
|
||||
0,
|
||||
sink,
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it('clears highlight on drag end without a target', async () => {
|
||||
makeNode({ id: 'src', outputs: [{ type: 'IMAGE', links: [1] }] })
|
||||
makeNode({ id: 'sink', inputs: [{ type: 'IMAGE', link: 1 }] })
|
||||
makeLink(1, 'src', 'sink', 'IMAGE')
|
||||
|
||||
const dragged = makeNode({
|
||||
id: 'dragged',
|
||||
inputs: [{ type: 'IMAGE', link: null }],
|
||||
outputs: [{ type: 'IMAGE', links: null }]
|
||||
})
|
||||
queryLinkAtPoint.mockReturnValue(1)
|
||||
|
||||
const api = startComposable()
|
||||
emitMove(dragged.id)
|
||||
expect(api.hoveredLinkId.value).toBe(1)
|
||||
|
||||
queryLinkAtPoint.mockReturnValue(null)
|
||||
emitMove(dragged.id)
|
||||
emitEnd(dragged.id)
|
||||
|
||||
expect(api.hoveredLinkId.value).toBeNull()
|
||||
expect(canvas.highlighted_links).toEqual({})
|
||||
expect(beforeChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a link whose endpoints are the dragged node', async () => {
|
||||
const dragged = makeNode({
|
||||
id: 'dragged',
|
||||
inputs: [{ type: 'IMAGE', link: null }],
|
||||
outputs: [{ type: 'IMAGE', links: null }]
|
||||
})
|
||||
makeLink(1, 'dragged', 'other', 'IMAGE')
|
||||
queryLinkAtPoint.mockReturnValue(1)
|
||||
|
||||
const api = startComposable()
|
||||
emitMove(dragged.id)
|
||||
|
||||
expect(api.hoveredLinkId.value).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a rerouted link', async () => {
|
||||
makeNode({ id: 'src', outputs: [{ type: 'IMAGE', links: [1] }] })
|
||||
makeNode({ id: 'sink', inputs: [{ type: 'IMAGE', link: 1 }] })
|
||||
const link = makeLink(1, 'src', 'sink', 'IMAGE')
|
||||
;(link as { parentId?: number }).parentId = 42
|
||||
|
||||
const dragged = makeNode({
|
||||
id: 'dragged',
|
||||
inputs: [{ type: 'IMAGE', link: null }],
|
||||
outputs: [{ type: 'IMAGE', links: null }]
|
||||
})
|
||||
queryLinkAtPoint.mockReturnValue(1)
|
||||
|
||||
const api = startComposable()
|
||||
emitMove(dragged.id)
|
||||
|
||||
expect(api.hoveredLinkId.value).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects subgraph IO links', async () => {
|
||||
makeNode({ id: 'sink', inputs: [{ type: 'IMAGE', link: 1 }] })
|
||||
const link = makeLink(1, 'src', 'sink', 'IMAGE')
|
||||
Object.defineProperty(link, 'originIsIoNode', { value: true })
|
||||
|
||||
const dragged = makeNode({
|
||||
id: 'dragged',
|
||||
inputs: [{ type: 'IMAGE', link: null }],
|
||||
outputs: [{ type: 'IMAGE', links: null }]
|
||||
})
|
||||
queryLinkAtPoint.mockReturnValue(1)
|
||||
|
||||
const api = startComposable()
|
||||
emitMove(dragged.id)
|
||||
|
||||
expect(api.hoveredLinkId.value).toBeNull()
|
||||
})
|
||||
|
||||
it('rolls back when the second connect call fails', async () => {
|
||||
const src = makeNode({
|
||||
id: 'src',
|
||||
outputs: [{ type: 'IMAGE', links: [1] }],
|
||||
connect: vi.fn(() => ({}))
|
||||
})
|
||||
const sink = makeNode({ id: 'sink', inputs: [{ type: 'IMAGE', link: 1 }] })
|
||||
makeLink(1, 'src', 'sink', 'IMAGE')
|
||||
|
||||
const dragged = makeNode({
|
||||
id: 'dragged',
|
||||
inputs: [{ type: 'IMAGE', link: null }],
|
||||
outputs: [{ type: 'IMAGE', links: null }],
|
||||
connect: vi.fn(() => null),
|
||||
disconnectInput: vi.fn(() => true)
|
||||
})
|
||||
queryLinkAtPoint.mockReturnValue(1)
|
||||
|
||||
startComposable()
|
||||
emitEnd(dragged.id)
|
||||
|
||||
const draggedConnect = (dragged as unknown as TestNode).connect
|
||||
const draggedDisconnect = (dragged as unknown as TestNode).disconnectInput
|
||||
const srcConnect = (src as unknown as TestNode).connect
|
||||
|
||||
expect(draggedConnect).toHaveBeenCalledTimes(1)
|
||||
expect(draggedDisconnect).toHaveBeenCalledWith(0, true)
|
||||
expect(srcConnect).toHaveBeenLastCalledWith(0, sink, 0)
|
||||
expect(afterChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('logs an error when the rollback connect also fails', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
makeNode({
|
||||
id: 'src',
|
||||
outputs: [{ type: 'IMAGE', links: [1] }],
|
||||
connect: vi.fn(() => null)
|
||||
})
|
||||
makeNode({ id: 'sink', inputs: [{ type: 'IMAGE', link: 1 }] })
|
||||
makeLink(1, 'src', 'sink', 'IMAGE')
|
||||
|
||||
const dragged = makeNode({
|
||||
id: 'dragged',
|
||||
inputs: [{ type: 'IMAGE', link: null }],
|
||||
outputs: [{ type: 'IMAGE', links: null }]
|
||||
})
|
||||
queryLinkAtPoint.mockReturnValue(1)
|
||||
|
||||
startComposable()
|
||||
emitEnd(dragged.id)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed to restore original link')
|
||||
)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not stomp a pre-existing highlight from another source', async () => {
|
||||
makeNode({ id: 'src', outputs: [{ type: 'IMAGE', links: [1] }] })
|
||||
makeNode({ id: 'sink', inputs: [{ type: 'IMAGE', link: 1 }] })
|
||||
makeLink(1, 'src', 'sink', 'IMAGE')
|
||||
|
||||
canvas.highlighted_links[1] = true
|
||||
|
||||
const dragged = makeNode({
|
||||
id: 'dragged',
|
||||
inputs: [{ type: 'IMAGE', link: null }],
|
||||
outputs: [{ type: 'IMAGE', links: null }]
|
||||
})
|
||||
queryLinkAtPoint.mockReturnValue(1)
|
||||
|
||||
startComposable()
|
||||
emitMove(dragged.id)
|
||||
emitMove(dragged.id)
|
||||
|
||||
queryLinkAtPoint.mockReturnValue(null)
|
||||
emitMove(dragged.id)
|
||||
|
||||
expect(canvas.highlighted_links[1]).toBe(true)
|
||||
})
|
||||
|
||||
it('skips on drag end when groups push effective drag count above 1', async () => {
|
||||
makeNode({ id: 'src', outputs: [{ type: 'IMAGE', links: [1] }] })
|
||||
makeNode({ id: 'sink', inputs: [{ type: 'IMAGE', link: 1 }] })
|
||||
makeLink(1, 'src', 'sink', 'IMAGE')
|
||||
|
||||
const dragged = makeNode({
|
||||
id: 'dragged',
|
||||
inputs: [{ type: 'IMAGE', link: null }],
|
||||
outputs: [{ type: 'IMAGE', links: null }]
|
||||
})
|
||||
queryLinkAtPoint.mockReturnValue(1)
|
||||
|
||||
startComposable()
|
||||
graphInteractionHooks.emit('nodeDragEnd', {
|
||||
nodeId: String(dragged.id),
|
||||
canvasPos: { x: 100, y: 100 },
|
||||
pointerEvent: new PointerEvent('pointerup'),
|
||||
selectionSize: 2
|
||||
})
|
||||
|
||||
expect(beforeChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
243
src/renderer/extensions/vueNodes/composables/useDropOnLink.ts
Normal file
243
src/renderer/extensions/vueNodes/composables/useDropOnLink.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { onScopeDispose, ref } from 'vue'
|
||||
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
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 { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { graphInteractionHooks } from '@/renderer/core/canvas/hooks/graphInteractionHooks'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { LinkId, NodeId, Point } from '@/renderer/core/layout/types'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
interface DropTarget {
|
||||
link: LLink
|
||||
inputSlotIndex: number
|
||||
outputSlotIndex: number
|
||||
}
|
||||
|
||||
export function useDropOnLink() {
|
||||
const hoveredLinkId = ref<LinkId | null>(null)
|
||||
const { nodeManager } = useVueNodeLifecycle()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
let highlightOwned = false
|
||||
|
||||
function getGraph(): LGraph | null {
|
||||
return app.canvas?.graph ?? null
|
||||
}
|
||||
|
||||
function getNode(nodeId: NodeId): LGraphNode | null {
|
||||
return nodeManager.value?.getNode(nodeId) ?? null
|
||||
}
|
||||
|
||||
function nodeHasConnections(node: LGraphNode): boolean {
|
||||
if (node.inputs?.some((input) => input.link != null)) return true
|
||||
if (node.outputs?.some((output) => (output.links?.length ?? 0) > 0))
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
function findFirstMatchingInputSlot(
|
||||
node: LGraphNode,
|
||||
linkType: string | number
|
||||
): number {
|
||||
if (!node.inputs) return -1
|
||||
for (const [index, input] of node.inputs.entries()) {
|
||||
if (input.link != null) continue
|
||||
if (LiteGraph.isValidConnection(linkType, input.type)) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function findFirstMatchingOutputSlot(
|
||||
node: LGraphNode,
|
||||
linkType: string | number
|
||||
): number {
|
||||
if (!node.outputs) return -1
|
||||
for (const [index, output] of node.outputs.entries()) {
|
||||
if (LiteGraph.isValidConnection(output.type, linkType)) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function isInsertableLink(link: LLink, draggedNode: LGraphNode): boolean {
|
||||
if (link.parentId != null) return false
|
||||
if (link.isFloating) return false
|
||||
if (link.originIsIoNode || link.targetIsIoNode) return false
|
||||
if (link.origin_id === draggedNode.id) return false
|
||||
if (link.target_id === draggedNode.id) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function resolveDropTarget(
|
||||
draggedNode: LGraphNode,
|
||||
canvasPos: Point
|
||||
): DropTarget | null {
|
||||
const graph = getGraph()
|
||||
if (!graph) return null
|
||||
|
||||
const ctx = canvasStore.canvas?.ctx
|
||||
const linkId = layoutStore.queryLinkAtPoint(canvasPos, ctx ?? undefined)
|
||||
if (linkId == null) return null
|
||||
|
||||
const link = graph._links.get(linkId)
|
||||
if (!link) return null
|
||||
if (!isInsertableLink(link, draggedNode)) return null
|
||||
|
||||
const linkType = link.type ?? ''
|
||||
const inputSlotIndex = findFirstMatchingInputSlot(draggedNode, linkType)
|
||||
if (inputSlotIndex === -1) return null
|
||||
|
||||
const outputSlotIndex = findFirstMatchingOutputSlot(draggedNode, linkType)
|
||||
if (outputSlotIndex === -1) return null
|
||||
|
||||
return { link, inputSlotIndex, outputSlotIndex }
|
||||
}
|
||||
|
||||
function setHighlight(linkId: LinkId | null) {
|
||||
if (hoveredLinkId.value === linkId) return
|
||||
|
||||
const canvas = canvasStore.canvas
|
||||
if (canvas) {
|
||||
const previous = hoveredLinkId.value
|
||||
if (
|
||||
previous != null &&
|
||||
highlightOwned &&
|
||||
canvas.highlighted_links[previous]
|
||||
) {
|
||||
delete canvas.highlighted_links[previous]
|
||||
}
|
||||
highlightOwned = false
|
||||
if (linkId != null && !canvas.highlighted_links[linkId]) {
|
||||
canvas.highlighted_links[linkId] = true
|
||||
highlightOwned = true
|
||||
}
|
||||
canvas.setDirty(true)
|
||||
}
|
||||
|
||||
hoveredLinkId.value = linkId
|
||||
}
|
||||
|
||||
function rollbackOriginalLink(
|
||||
sourceNode: LGraphNode,
|
||||
sinkNode: LGraphNode,
|
||||
originSlot: number,
|
||||
targetSlot: number
|
||||
): void {
|
||||
const restored = sourceNode.connect(originSlot, sinkNode, targetSlot)
|
||||
if (!restored) {
|
||||
console.error(
|
||||
'useDropOnLink: failed to restore original link after rollback'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function applyDrop(draggedNode: LGraphNode, target: DropTarget): boolean {
|
||||
const graph = getGraph()
|
||||
if (!graph) return false
|
||||
|
||||
const sourceNode = graph.getNodeById(target.link.origin_id)
|
||||
const sinkNode = graph.getNodeById(target.link.target_id)
|
||||
if (!sourceNode || !sinkNode) return false
|
||||
|
||||
const restoreOriginSlot = target.link.origin_slot
|
||||
const restoreTargetSlot = target.link.target_slot
|
||||
|
||||
graph.beforeChange()
|
||||
try {
|
||||
sinkNode.disconnectInput(restoreTargetSlot, true)
|
||||
|
||||
const inLink = sourceNode.connect(
|
||||
restoreOriginSlot,
|
||||
draggedNode,
|
||||
target.inputSlotIndex
|
||||
)
|
||||
if (!inLink) {
|
||||
rollbackOriginalLink(
|
||||
sourceNode,
|
||||
sinkNode,
|
||||
restoreOriginSlot,
|
||||
restoreTargetSlot
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const outLink = draggedNode.connect(
|
||||
target.outputSlotIndex,
|
||||
sinkNode,
|
||||
restoreTargetSlot
|
||||
)
|
||||
if (!outLink) {
|
||||
draggedNode.disconnectInput(target.inputSlotIndex, true)
|
||||
rollbackOriginalLink(
|
||||
sourceNode,
|
||||
sinkNode,
|
||||
restoreOriginSlot,
|
||||
restoreTargetSlot
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} finally {
|
||||
graph.afterChange()
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodeDragMove(event: {
|
||||
nodeId: NodeId
|
||||
canvasPos: Point
|
||||
selectionSize: number
|
||||
}) {
|
||||
if (event.selectionSize > 1) {
|
||||
setHighlight(null)
|
||||
return
|
||||
}
|
||||
|
||||
const node = getNode(event.nodeId)
|
||||
if (!node || nodeHasConnections(node)) {
|
||||
setHighlight(null)
|
||||
return
|
||||
}
|
||||
|
||||
const target = resolveDropTarget(node, event.canvasPos)
|
||||
setHighlight(target?.link.id ?? null)
|
||||
}
|
||||
|
||||
function handleNodeDragEnd(event: {
|
||||
nodeId: NodeId
|
||||
canvasPos: Point
|
||||
selectionSize: number
|
||||
}) {
|
||||
setHighlight(null)
|
||||
|
||||
if (event.selectionSize > 1) return
|
||||
|
||||
const node = getNode(event.nodeId)
|
||||
if (!node || nodeHasConnections(node)) return
|
||||
|
||||
const target = resolveDropTarget(node, event.canvasPos)
|
||||
if (!target) return
|
||||
|
||||
applyDrop(node, target)
|
||||
}
|
||||
|
||||
const unsubscribeMove = graphInteractionHooks.on(
|
||||
'nodeDragMove',
|
||||
handleNodeDragMove
|
||||
)
|
||||
const unsubscribeEnd = graphInteractionHooks.on(
|
||||
'nodeDragEnd',
|
||||
handleNodeDragEnd
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
unsubscribeMove()
|
||||
unsubscribeEnd()
|
||||
setHighlight(null)
|
||||
})
|
||||
|
||||
return { hoveredLinkId }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
@@ -154,6 +154,12 @@ describe('useNodeDrag', () => {
|
||||
vi.stubGlobal('cancelAnimationFrame', testState.cancelAnimationFrame)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const { graphInteractionHooks } =
|
||||
await import('@/renderer/core/canvas/hooks/graphInteractionHooks')
|
||||
graphInteractionHooks.clear()
|
||||
})
|
||||
|
||||
it('batches multi-node drag updates into one mutation call per frame', () => {
|
||||
testState.selectedNodeIds.value = new Set(['1', '2'])
|
||||
testState.nodeLayouts.set('1', {
|
||||
@@ -199,6 +205,45 @@ describe('useNodeDrag', () => {
|
||||
expect(testState.mutationFns.moveNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits selectionSize=1 when dragging an unselected node while others are selected', async () => {
|
||||
const { graphInteractionHooks } =
|
||||
await import('@/renderer/core/canvas/hooks/graphInteractionHooks')
|
||||
|
||||
testState.selectedNodeIds.value = new Set(['1', '2'])
|
||||
testState.selectedItems.value = [{ isLGraphGroup: true }]
|
||||
testState.nodeLayouts.set('1', {
|
||||
position: { x: 100, y: 100 },
|
||||
size: { width: 200, height: 120 }
|
||||
})
|
||||
testState.nodeLayouts.set('2', {
|
||||
position: { x: 300, y: 300 },
|
||||
size: { width: 200, height: 120 }
|
||||
})
|
||||
testState.nodeLayouts.set('outsider', {
|
||||
position: { x: 500, y: 500 },
|
||||
size: { width: 200, height: 120 }
|
||||
})
|
||||
|
||||
const observed: number[] = []
|
||||
graphInteractionHooks.on('nodeDragMove', (e) =>
|
||||
observed.push(e.selectionSize)
|
||||
)
|
||||
|
||||
const { startDrag, handleDrag, endDrag } = useNodeDrag()
|
||||
startDrag(pointerEvent(10, 20), 'outsider')
|
||||
handleDrag(pointerEvent(30, 40), 'outsider')
|
||||
testState.requestAnimationFrameCallback?.(0)
|
||||
|
||||
expect(observed).toEqual([1])
|
||||
|
||||
const endObserved: number[] = []
|
||||
graphInteractionHooks.on('nodeDragEnd', (e) =>
|
||||
endObserved.push(e.selectionSize)
|
||||
)
|
||||
endDrag(pointerEvent(30, 40), 'outsider')
|
||||
expect(endObserved).toEqual([1])
|
||||
})
|
||||
|
||||
it('cancels pending RAF and applies snap updates on endDrag', () => {
|
||||
testState.selectedNodeIds.value = new Set(['1'])
|
||||
testState.nodeLayouts.set('1', {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toValue } from 'vue'
|
||||
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { graphInteractionHooks } from '@/renderer/core/canvas/hooks/graphInteractionHooks'
|
||||
import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
@@ -50,6 +51,8 @@ function useNodeDragIndividual() {
|
||||
let autoPan: AutoPanController | null = null
|
||||
let lastPointerX = 0
|
||||
let lastPointerY = 0
|
||||
let lastPointerEvent: PointerEvent | null = null
|
||||
let draggedItemCount = 1
|
||||
|
||||
function startDrag(event: PointerEvent, nodeId: NodeId) {
|
||||
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
|
||||
@@ -91,9 +94,12 @@ function useNodeDragIndividual() {
|
||||
if (isDraggedNodeInSelection) {
|
||||
selectedGroups = toValue(selectedItems).filter(isLGraphGroup)
|
||||
lastCanvasDelta = { x: 0, y: 0 }
|
||||
draggedItemCount =
|
||||
Math.max(selectedNodes?.size ?? 1, 1) + selectedGroups.length
|
||||
} else {
|
||||
selectedGroups = null
|
||||
lastCanvasDelta = null
|
||||
draggedItemCount = 1
|
||||
}
|
||||
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
@@ -206,15 +212,46 @@ function useNodeDragIndividual() {
|
||||
|
||||
lastPointerX = event.clientX
|
||||
lastPointerY = event.clientY
|
||||
lastPointerEvent = event
|
||||
autoPan?.updatePointer(event.clientX, event.clientY)
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
updateNodePositions(nodeId)
|
||||
emitNodeDragMove(nodeId)
|
||||
})
|
||||
}
|
||||
|
||||
function computeDraggedItemCount(): number {
|
||||
return draggedItemCount
|
||||
}
|
||||
|
||||
function emitNodeDragMove(nodeId: NodeId) {
|
||||
if (!lastPointerEvent) return
|
||||
graphInteractionHooks.emit('nodeDragMove', {
|
||||
nodeId,
|
||||
canvasPos: transformState.screenToCanvas({
|
||||
x: lastPointerX,
|
||||
y: lastPointerY
|
||||
}),
|
||||
pointerEvent: lastPointerEvent,
|
||||
selectionSize: computeDraggedItemCount()
|
||||
})
|
||||
}
|
||||
|
||||
function endDrag(event: PointerEvent, nodeId: NodeId | undefined) {
|
||||
if (nodeId && dragStartPos) {
|
||||
graphInteractionHooks.emit('nodeDragEnd', {
|
||||
nodeId,
|
||||
canvasPos: transformState.screenToCanvas({
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
}),
|
||||
pointerEvent: event,
|
||||
selectionSize: computeDraggedItemCount()
|
||||
})
|
||||
}
|
||||
|
||||
// Apply snap to final position if snap was active (matches LiteGraph behavior)
|
||||
if (shouldSnap(event) && nodeId) {
|
||||
const boundsUpdates: NodeBoundsUpdate[] = []
|
||||
@@ -281,6 +318,8 @@ function useNodeDragIndividual() {
|
||||
otherSelectedNodesStartPositions = null
|
||||
selectedGroups = null
|
||||
lastCanvasDelta = null
|
||||
lastPointerEvent = null
|
||||
draggedItemCount = 1
|
||||
|
||||
// Stop auto-pan
|
||||
autoPan?.stop()
|
||||
|
||||
Reference in New Issue
Block a user