diff --git a/src/renderer/extensions/vueNodes/composables/slotLinkDragSession.ts b/src/renderer/extensions/vueNodes/composables/slotLinkDragSession.ts new file mode 100644 index 000000000..4eddfd9a1 --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/slotLinkDragSession.ts @@ -0,0 +1,45 @@ +import type { SlotLayout } from '@/renderer/core/layout/types' + +export interface PendingMoveData { + clientX: number + clientY: number + target: EventTarget | null +} + +export interface SlotLinkDragSession { + compatCache: Map + nodePreferred: Map< + number, + { index: number; key: string; layout: SlotLayout } | null + > + lastHoverSlotKey: string | null + lastHoverNodeId: number | null + lastCandidateKey: string | null + pendingMove: PendingMoveData | null + reset: () => void + dispose: () => void +} + +export function createSlotLinkDragSession(): SlotLinkDragSession { + const state: SlotLinkDragSession = { + compatCache: new Map(), + nodePreferred: new Map(), + lastHoverSlotKey: null, + lastHoverNodeId: null, + lastCandidateKey: null, + pendingMove: null, + reset: () => { + state.compatCache = new Map() + state.nodePreferred = new Map() + state.lastHoverSlotKey = null + state.lastHoverNodeId = null + state.lastCandidateKey = null + state.pendingMove = null + }, + dispose: () => { + state.reset() + } + } + + return state +} diff --git a/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts b/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts index 4b6cbf811..3b859cee2 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts @@ -17,19 +17,17 @@ import { isSizeEqual } from '@/renderer/core/layout/utils/geometry' import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore' +import { createRafBatch } from '@/utils/rafBatch' // RAF batching const pendingNodes = new Set() -let rafId: number | null = null +const raf = createRafBatch(() => { + flushScheduledSlotLayoutSync() +}) function scheduleSlotLayoutSync(nodeId: string) { pendingNodes.add(nodeId) - if (rafId == null) { - rafId = requestAnimationFrame(() => { - rafId = null - flushScheduledSlotLayoutSync() - }) - } + raf.schedule() } function flushScheduledSlotLayoutSync() { diff --git a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts index ec08b7ad9..afebae4c5 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts @@ -22,7 +22,9 @@ import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import type { Point } from '@/renderer/core/layout/types' import { toPoint } from '@/renderer/core/layout/utils/geometry' +import { createSlotLinkDragSession } from '@/renderer/extensions/vueNodes/composables/slotLinkDragSession' import { app } from '@/scripts/app' +import { createRafBatch } from '@/utils/rafBatch' interface SlotInteractionOptions { nodeId: string @@ -89,6 +91,12 @@ export function useSlotLinkInteraction({ const { state, beginDrag, endDrag, updatePointerPosition, setCandidate } = useSlotLinkDragState() + const conversion = useSharedCanvasPositionConversion() + const pointerSession = createPointerSession() + let activeAdapter: LinkConnectorAdapter | null = null + + // Per-drag drag-state cache + const dragSession = createSlotLinkDragSession() function candidateFromTarget( target: EventTarget | null @@ -106,16 +114,16 @@ export function useSlotLinkInteraction({ const graph = app.canvas?.graph const adapter = ensureActiveAdapter() if (graph && adapter) { - if (layout.type === 'input') { - candidate.compatible = adapter.isInputValidDrop( - layout.nodeId, - layout.index - ) - } else if (layout.type === 'output') { - candidate.compatible = adapter.isOutputValidDrop( - layout.nodeId, - layout.index - ) + const cached = dragSession.compatCache.get(key) + if (cached != null) { + candidate.compatible = cached + } else { + const compatible = + layout.type === 'input' + ? adapter.isInputValidDrop(layout.nodeId, layout.index) + : adapter.isOutputValidDrop(layout.nodeId, layout.index) + dragSession.compatCache.set(key, compatible) + candidate.compatible = compatible } } @@ -135,6 +143,15 @@ export function useSlotLinkInteraction({ if (!adapter || !graph) return null const nodeId = Number(nodeIdStr) + + // Cached preferred slot for this node within this drag + const cachedPreferred = dragSession.nodePreferred.get(nodeId) + if (cachedPreferred !== undefined) { + return cachedPreferred + ? { layout: cachedPreferred.layout, compatible: true } + : null + } + const node = graph.getNodeById(nodeId) if (!node) return null @@ -162,16 +179,18 @@ export function useSlotLinkInteraction({ ? adapter.isInputValidDrop(nodeId, index) : adapter.isOutputValidDrop(nodeId, index) - return compatible ? { layout, compatible: true } : null - - return null + if (compatible) { + dragSession.compatCache.set(key, true) + const preferred = { index, key, layout } + dragSession.nodePreferred.set(nodeId, preferred) + return { layout, compatible: true } + } else { + dragSession.compatCache.set(key, false) + dragSession.nodePreferred.set(nodeId, null) + return null + } } - const conversion = useSharedCanvasPositionConversion() - - const pointerSession = createPointerSession() - let activeAdapter: LinkConnectorAdapter | null = null - const ensureActiveAdapter = (): LinkConnectorAdapter | null => { if (!activeAdapter) activeAdapter = createLinkConnectorAdapter() return activeAdapter @@ -302,6 +321,8 @@ export function useSlotLinkInteraction({ pointerSession.clear() endDrag() activeAdapter = null + raf.cancel() + dragSession.dispose() } const updatePointerState = (event: PointerEvent) => { @@ -315,27 +336,74 @@ export function useSlotLinkInteraction({ updatePointerPosition(clientX, clientY, canvasX, canvasY) } - const handlePointerMove = (event: PointerEvent) => { - if (!pointerSession.matches(event)) return - updatePointerState(event) + const processPointerMoveFrame = () => { + const data = dragSession.pendingMove + if (!data) return + dragSession.pendingMove = null - const adapter = ensureActiveAdapter() - // Resolve a candidate from slot under cursor, else from node - const slotCandidate = candidateFromTarget(event.target) - const nodeCandidate = slotCandidate - ? null - : candidateFromNodeTarget(event.target) - const candidate = slotCandidate ?? nodeCandidate + const [canvasX, canvasY] = conversion.clientPosToCanvasPos([ + data.clientX, + data.clientY + ]) + updatePointerPosition(data.clientX, data.clientY, canvasX, canvasY) - // Update drag-state candidate; Vue preview renderer reads this - if (candidate?.compatible && adapter) { - setCandidate(candidate) - } else { - setCandidate(null) + let hoveredSlotKey: string | null = null + let hoveredNodeId: number | null = null + const target = data.target + if (target instanceof HTMLElement) { + hoveredSlotKey = + target.closest('[data-slot-key]')?.dataset['slotKey'] ?? + null + if (!hoveredSlotKey) { + const nodeIdStr = + target.closest('[data-node-id]')?.dataset['nodeId'] + hoveredNodeId = nodeIdStr != null ? Number(nodeIdStr) : null + } + } + + const hoverChanged = + hoveredSlotKey !== dragSession.lastHoverSlotKey || + hoveredNodeId !== dragSession.lastHoverNodeId + + let candidate: SlotDropCandidate | null = state.candidate + + if (hoverChanged) { + const slotCandidate = candidateFromTarget(target) + const nodeCandidate = slotCandidate + ? null + : candidateFromNodeTarget(target) + candidate = slotCandidate ?? nodeCandidate + dragSession.lastHoverSlotKey = hoveredSlotKey + dragSession.lastHoverNodeId = hoveredNodeId + } + + const newCandidate = candidate?.compatible ? candidate : null + const newCandidateKey = newCandidate + ? getSlotKey( + newCandidate.layout.nodeId, + newCandidate.layout.index, + newCandidate.layout.type === 'input' + ) + : null + + if (newCandidateKey !== dragSession.lastCandidateKey) { + setCandidate(newCandidate) + dragSession.lastCandidateKey = newCandidateKey } app.canvas?.setDirty(true) } + const raf = createRafBatch(processPointerMoveFrame) + + const handlePointerMove = (event: PointerEvent) => { + if (!pointerSession.matches(event)) return + dragSession.pendingMove = { + clientX: event.clientX, + clientY: event.clientY, + target: event.target + } + raf.schedule() + } // Attempt to finalize by connecting to a DOM slot candidate const tryConnectToCandidate = ( @@ -426,6 +494,8 @@ export function useSlotLinkInteraction({ if (!pointerSession.matches(event)) return event.preventDefault() + raf.flush() + if (!state.source) { cleanupInteraction() app.canvas?.setDirty(true) @@ -467,6 +537,8 @@ export function useSlotLinkInteraction({ const handlePointerCancel = (event: PointerEvent) => { if (!pointerSession.matches(event)) return + + raf.flush() cleanupInteraction() app.canvas?.setDirty(true) } @@ -481,6 +553,8 @@ export function useSlotLinkInteraction({ if (!canvas || !graph) return ensureActiveAdapter() + raf.cancel() + dragSession.reset() const layout = layoutStore.getSlotLayout( getSlotKey(nodeId, index, type === 'input') diff --git a/src/utils/rafBatch.ts b/src/utils/rafBatch.ts new file mode 100644 index 000000000..a8756ef24 --- /dev/null +++ b/src/utils/rafBatch.ts @@ -0,0 +1,29 @@ +export function createRafBatch(run: () => void) { + let rafId: number | null = null + + const schedule = () => { + if (rafId != null) return + rafId = requestAnimationFrame(() => { + rafId = null + run() + }) + } + + const cancel = () => { + if (rafId != null) { + cancelAnimationFrame(rafId) + rafId = null + } + } + + const flush = () => { + if (rafId == null) return + cancelAnimationFrame(rafId) + rafId = null + run() + } + + const isScheduled = () => rafId != null + + return { schedule, cancel, flush, isScheduled } +}