From 799ab5e5aad1fa3dcd168ecc1f999bd36045cbfa Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 10 Oct 2025 12:58:25 -0700 Subject: [PATCH] Refactor slotkey from bespoke string key --- .../links/linkInteraction.spec.ts | 8 +- .../core/canvas/links/linkDropOrchestrator.ts | 47 +++-- .../core/canvas/links/slotLinkDragUIState.ts | 58 ++++-- .../core/canvas/litegraph/slotCalculations.ts | 8 +- src/renderer/core/layout/slots/register.ts | 11 +- .../core/layout/slots/slotIdentifier.ts | 40 ----- src/renderer/core/layout/store/layoutStore.ts | 169 +++++++++++------- src/renderer/core/layout/types.ts | 40 ++++- .../vueNodes/components/InputSlot.vue | 10 +- .../vueNodes/components/OutputSlot.vue | 10 +- .../composables/slotLinkDragContext.ts | 26 +-- .../composables/useSlotElementTracking.ts | 126 ++++++++++--- .../composables/useSlotLinkInteraction.ts | 101 +++++++---- .../vueNodes/stores/nodeSlotRegistryStore.ts | 7 +- 14 files changed, 430 insertions(+), 231 deletions(-) delete mode 100644 src/renderer/core/layout/slots/slotIdentifier.ts diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts index 8989dc632..a5bd8f6c0 100644 --- a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts @@ -1,7 +1,6 @@ import type { Locator, Page } from '@playwright/test' import type { NodeId } from '../../../../../src/platform/workflow/validation/schemas/workflowSchema' -import { getSlotKey } from '../../../../../src/renderer/core/layout/slots/slotIdentifier' import { comfyExpect as expect, comfyPageFixture as test @@ -67,8 +66,11 @@ function slotLocator( slotIndex: number, isInput: boolean ) { - const key = getSlotKey(String(nodeId), slotIndex, isInput) - return page.locator(`[data-slot-key="${key}"]`) + const type = isInput ? 'input' : 'output' + const id = String(nodeId) + return page.locator( + `[data-node-id="${id}"][data-slot-type="${type}"][data-slot-index="${slotIndex}"]` + ) } async function expectVisibleAll(...locators: Locator[]) { diff --git a/src/renderer/core/canvas/links/linkDropOrchestrator.ts b/src/renderer/core/canvas/links/linkDropOrchestrator.ts index 086a2dc8b..dc61a53ad 100644 --- a/src/renderer/core/canvas/links/linkDropOrchestrator.ts +++ b/src/renderer/core/canvas/links/linkDropOrchestrator.ts @@ -3,7 +3,6 @@ import type { NodeId } from '@/lib/litegraph/src/LGraphNode' import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter' import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState' import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragUIState' -import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import type { SlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext' @@ -17,30 +16,39 @@ export const resolveSlotTargetCandidate = ( target: EventTarget | null, { adapter, graph }: DropResolutionContext ): SlotDropCandidate | null => { - const { state: dragState, setCompatibleForKey } = useSlotLinkDragUIState() + const { setCompatibleFor, getCompatible } = useSlotLinkDragUIState() if (!(target instanceof HTMLElement)) return null - const elWithKey = target.closest('[data-slot-key]') - const key = elWithKey?.dataset['slotKey'] - if (!key) return null + const elWithSlot = target.closest( + '[data-node-id][data-slot-type][data-slot-index]' + ) + const nodeId = elWithSlot?.dataset['nodeId'] + const typeAttr = elWithSlot?.dataset['slotType'] as + | 'input' + | 'output' + | undefined + const indexAttr = elWithSlot?.dataset['slotIndex'] + if (!nodeId || !typeAttr || indexAttr == null) return null + const index = Number.parseInt(indexAttr, 10) + if (!Number.isFinite(index)) return null - const layout = layoutStore.getSlotLayout(key) + const layout = layoutStore.getSlotLayoutBy(nodeId, typeAttr, index) if (!layout) return null const candidate: SlotDropCandidate = { layout, compatible: false } if (adapter && graph) { - const cached = dragState.compatible.get(key) + const cached = getCompatible(nodeId, layout.type, layout.index) if (cached != null) { candidate.compatible = cached } else { - const nodeId: NodeId = layout.nodeId + const layoutNodeId: NodeId = layout.nodeId const compatible = layout.type === 'input' - ? adapter.isInputValidDrop(nodeId, layout.index) - : adapter.isOutputValidDrop(nodeId, layout.index) + ? adapter.isInputValidDrop(layoutNodeId, layout.index) + : adapter.isOutputValidDrop(layoutNodeId, layout.index) - setCompatibleForKey(key, compatible) + setCompatibleFor(layoutNodeId, layout.type, layout.index, compatible) candidate.compatible = compatible } } @@ -52,7 +60,7 @@ export const resolveNodeSurfaceSlotCandidate = ( target: EventTarget | null, { adapter, graph, session }: DropResolutionContext ): SlotDropCandidate | null => { - const { setCompatibleForKey } = useSlotLinkDragUIState() + const { setCompatibleFor } = useSlotLinkDragUIState() if (!(target instanceof HTMLElement)) return null const elWithNode = target.closest('[data-node-id]') @@ -92,8 +100,11 @@ export const resolveNodeSurfaceSlotCandidate = ( return null } - const key = getSlotKey(String(nodeId), index, isInput) - const layout = layoutStore.getSlotLayout(key) + const layout = layoutStore.getSlotLayoutBy( + String(nodeId), + isInput ? 'input' : 'output', + index + ) if (!layout) { session.preferredSlotForNode.set(nodeId, null) return null @@ -103,14 +114,18 @@ export const resolveNodeSurfaceSlotCandidate = ( ? adapter.isInputValidDrop(nodeId, index) : adapter.isOutputValidDrop(nodeId, index) - setCompatibleForKey(key, compatible) + setCompatibleFor(layout.nodeId, layout.type, layout.index, compatible) if (!compatible) { session.preferredSlotForNode.set(nodeId, null) return null } - const preferred = { index, key, layout } + const preferred = { + index, + identity: { nodeId: layout.nodeId, type: layout.type, index }, + layout + } session.preferredSlotForNode.set(nodeId, preferred) return { layout, compatible: true } diff --git a/src/renderer/core/canvas/links/slotLinkDragUIState.ts b/src/renderer/core/canvas/links/slotLinkDragUIState.ts index 838da1103..0ff397535 100644 --- a/src/renderer/core/canvas/links/slotLinkDragUIState.ts +++ b/src/renderer/core/canvas/links/slotLinkDragUIState.ts @@ -1,7 +1,6 @@ import { reactive, readonly } 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 { Point, SlotLayout } from '@/renderer/core/layout/types' @@ -41,7 +40,10 @@ interface SlotDragState { source: SlotDragSource | null pointer: PointerPosition candidate: SlotDropCandidate | null - compatible: Map + compatible: Map< + string, + { input: Map; output: Map } + > } const state = reactive({ @@ -53,7 +55,7 @@ const state = reactive({ canvas: { x: 0, y: 0 } }, candidate: null, - compatible: new Map() + compatible: new Map() }) function updatePointerPosition( @@ -93,8 +95,45 @@ function endDrag() { } function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) { - const slotKey = getSlotKey(nodeId, slotIndex, isInput) - return layoutStore.getSlotLayout(slotKey) + return layoutStore.getSlotLayoutBy( + nodeId, + isInput ? 'input' : 'output', + slotIndex + ) +} + +function ensureCompatibleNode(nodeId: string) { + let entry = state.compatible.get(nodeId) + if (!entry) { + entry = { + input: reactive(new Map()), + output: reactive(new Map()) + } + state.compatible.set(nodeId, entry) + } + return entry +} + +function getCompatible( + nodeId: string, + type: 'input' | 'output', + index: number +): boolean | undefined { + const nodeEntry = state.compatible.get(nodeId) + if (!nodeEntry) return undefined + const map = type === 'input' ? nodeEntry.input : nodeEntry.output + return map.get(index) +} + +function setCompatibleFor( + nodeId: string, + type: 'input' | 'output', + index: number, + value: boolean +) { + const nodeEntry = ensureCompatibleNode(nodeId) + const map = type === 'input' ? nodeEntry.input : nodeEntry.output + map.set(index, value) } export function useSlotLinkDragUIState() { @@ -105,13 +144,8 @@ export function useSlotLinkDragUIState() { updatePointerPosition, setCandidate, getSlotLayout, - setCompatibleMap: (entries: Iterable<[string, boolean]>) => { - state.compatible.clear() - for (const [key, value] of entries) state.compatible.set(key, value) - }, - setCompatibleForKey: (key: string, value: boolean) => { - state.compatible.set(key, value) - }, + getCompatible, + setCompatibleFor, clearCompatible: () => state.compatible.clear() } } diff --git a/src/renderer/core/canvas/litegraph/slotCalculations.ts b/src/renderer/core/canvas/litegraph/slotCalculations.ts index 8b66b68a8..ffb327061 100644 --- a/src/renderer/core/canvas/litegraph/slotCalculations.ts +++ b/src/renderer/core/canvas/litegraph/slotCalculations.ts @@ -13,7 +13,6 @@ import type { } from '@/lib/litegraph/src/interfaces' import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils' -import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' export interface SlotPositionContext { @@ -139,8 +138,11 @@ export function getSlotPosition( isInput: boolean ): Point { // Try to get precise position from slot layout (DOM-registered) - const slotKey = getSlotKey(String(node.id), slotIndex, isInput) - const slotLayout = layoutStore.getSlotLayout(slotKey) + const slotLayout = layoutStore.getSlotLayoutBy( + String(node.id), + isInput ? 'input' : 'output', + slotIndex + ) if (slotLayout) { return [slotLayout.position.x, slotLayout.position.y] } diff --git a/src/renderer/core/layout/slots/register.ts b/src/renderer/core/layout/slots/register.ts index 747ae4543..37c4f3ea4 100644 --- a/src/renderer/core/layout/slots/register.ts +++ b/src/renderer/core/layout/slots/register.ts @@ -15,8 +15,6 @@ import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotC import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import type { SlotLayout } from '@/renderer/core/layout/types' -import { getSlotKey } from './slotIdentifier' - /** * Register slot layout with the layout store for hit testing * @param nodeId The node ID @@ -30,8 +28,6 @@ function registerSlotLayout( isInput: boolean, position: Point ): void { - const slotKey = getSlotKey(nodeId, slotIndex, isInput) - // Calculate bounds for the slot using LiteGraph's standard slot height const slotSize = LiteGraph.NODE_SLOT_HEIGHT const halfSize = slotSize / 2 @@ -49,7 +45,12 @@ function registerSlotLayout( } } - layoutStore.updateSlotLayout(slotKey, slotLayout) + layoutStore.updateSlotLayoutBy( + nodeId, + isInput ? 'input' : 'output', + slotIndex, + slotLayout + ) } /** diff --git a/src/renderer/core/layout/slots/slotIdentifier.ts b/src/renderer/core/layout/slots/slotIdentifier.ts deleted file mode 100644 index 2600405e9..000000000 --- a/src/renderer/core/layout/slots/slotIdentifier.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Slot identifier utilities for consistent slot key generation and parsing - * - * Provides a centralized interface for slot identification across the layout system - * - * @TODO Replace this concatenated string with root cause fix - */ - -interface SlotIdentifier { - nodeId: string - index: number - isInput: boolean -} - -/** - * Generate a unique key for a slot - * Format: "{nodeId}-{in|out}-{index}" - */ -export function getSlotKey(identifier: SlotIdentifier): string -export function getSlotKey( - nodeId: string, - index: number, - isInput: boolean -): string -export function getSlotKey( - nodeIdOrIdentifier: string | SlotIdentifier, - index?: number, - isInput?: boolean -): string { - if (typeof nodeIdOrIdentifier === 'object') { - const { nodeId, index, isInput } = nodeIdOrIdentifier - return `${nodeId}-${isInput ? 'in' : 'out'}-${index}` - } - - if (index === undefined || isInput === undefined) { - throw new Error('Missing required parameters for slot key generation') - } - - return `${nodeIdOrIdentifier}-${isInput ? 'in' : 'out'}-${index}` -} diff --git a/src/renderer/core/layout/store/layoutStore.ts b/src/renderer/core/layout/store/layoutStore.ts index 0fa09f0d6..af0c95978 100644 --- a/src/renderer/core/layout/store/layoutStore.ts +++ b/src/renderer/core/layout/store/layoutStore.ts @@ -35,6 +35,7 @@ import type { RerouteLayout, ResizeNodeOperation, SetNodeZIndexOperation, + SlotIdentity, SlotLayout } from '@/renderer/core/layout/types' import { @@ -124,7 +125,11 @@ class LayoutStoreImpl implements LayoutStore { // New data structures for hit testing private linkLayouts = new Map() private linkSegmentLayouts = new Map() // Internal string key: ${linkId}:${rerouteId ?? 'final'} - private slotLayouts = new Map() + private slotLayoutsByNode = new Map< + NodeId, + { input: Map; output: Map } + >() + private slotIndexKeyToIdentity = new Map() private rerouteLayouts = new Map() // Spatial index managers @@ -431,66 +436,82 @@ class LayoutStoreImpl implements LayoutStore { } } - /** - * Update slot layout data - */ - updateSlotLayout(key: string, layout: SlotLayout): void { - const existing = this.slotLayouts.get(key) + private makeSlotIndexKey( + nodeId: NodeId, + type: 'input' | 'output', + index: number + ): string { + return `${nodeId}::${type}::${index}` + } + + private getOrCreateNodeSlotMaps(nodeId: NodeId) { + let entry = this.slotLayoutsByNode.get(nodeId) + if (!entry) { + entry = { input: new Map(), output: new Map() } + this.slotLayoutsByNode.set(nodeId, entry) + } + return entry + } + + updateSlotLayoutBy( + nodeId: NodeId, + type: 'input' | 'output', + index: number, + layout: SlotLayout + ): void { + const indexKey = this.makeSlotIndexKey(nodeId, type, index) + + const nodeMaps = this.getOrCreateNodeSlotMaps(nodeId) + const map = type === 'input' ? nodeMaps.input : nodeMaps.output + const existing = map.get(index) if (existing) { - // Short-circuit if geometry is unchanged if ( isPointEqual(existing.position, layout.position) && isBoundsEqual(existing.bounds, layout.bounds) ) { return } - // Update spatial index - this.slotSpatialIndex.update(key, layout.bounds) + this.slotSpatialIndex.update(indexKey, layout.bounds) } else { - // Insert into spatial index - this.slotSpatialIndex.insert(key, layout.bounds) + this.slotSpatialIndex.insert(indexKey, layout.bounds) } - this.slotLayouts.set(key, layout) + map.set(index, layout) + this.slotIndexKeyToIdentity.set(indexKey, { nodeId, type, index }) } /** * Batch update slot layouts and spatial index in one pass */ - batchUpdateSlotLayouts( - updates: Array<{ key: string; layout: SlotLayout }> + + batchUpdateSlotLayoutsBy( + updates: Array<{ + nodeId: NodeId + type: 'input' | 'output' + index: number + layout: SlotLayout + }> ): void { if (!updates.length) return - - // Update spatial index and map entries (skip unchanged) - for (const { key, layout } of updates) { - const existing = this.slotLayouts.get(key) - - if (existing) { - // Short-circuit if geometry is unchanged - if ( - isPointEqual(existing.position, layout.position) && - isBoundsEqual(existing.bounds, layout.bounds) - ) { - continue - } - this.slotSpatialIndex.update(key, layout.bounds) - } else { - this.slotSpatialIndex.insert(key, layout.bounds) - } - this.slotLayouts.set(key, layout) + for (const { nodeId, type, index, layout } of updates) { + this.updateSlotLayoutBy(nodeId, type, index, layout) } } - /** - * Delete slot layout data - */ - deleteSlotLayout(key: string): void { - const deleted = this.slotLayouts.delete(key) + deleteSlotLayoutBy( + nodeId: NodeId, + type: 'input' | 'output', + index: number + ): void { + const indexKey = this.makeSlotIndexKey(nodeId, type, index) + const nodeMaps = this.slotLayoutsByNode.get(nodeId) + if (!nodeMaps) return + const map = type === 'input' ? nodeMaps.input : nodeMaps.output + const deleted = map.delete(index) if (deleted) { - // Remove from spatial index - this.slotSpatialIndex.remove(key) + this.slotSpatialIndex.remove(indexKey) + this.slotIndexKeyToIdentity.delete(indexKey) } } @@ -498,17 +519,21 @@ class LayoutStoreImpl implements LayoutStore { * Delete all slot layouts for a node */ deleteNodeSlotLayouts(nodeId: NodeId): void { - const keysToDelete: string[] = [] - for (const [key, layout] of this.slotLayouts) { - if (layout.nodeId === nodeId) { - keysToDelete.push(key) - } + const nodeMaps = this.slotLayoutsByNode.get(nodeId) + if (!nodeMaps) return + for (const [idx] of nodeMaps.input) { + const indexKey = this.makeSlotIndexKey(nodeId, 'input', idx) + this.slotSpatialIndex.remove(indexKey) + this.slotIndexKeyToIdentity.delete(indexKey) } - for (const key of keysToDelete) { - this.slotLayouts.delete(key) - // Remove from spatial index - this.slotSpatialIndex.remove(key) + for (const [idx] of nodeMaps.output) { + const indexKey = this.makeSlotIndexKey(nodeId, 'output', idx) + this.slotSpatialIndex.remove(indexKey) + this.slotIndexKeyToIdentity.delete(indexKey) } + nodeMaps.input.clear() + nodeMaps.output.clear() + this.slotLayoutsByNode.delete(nodeId) } /** @@ -516,7 +541,8 @@ class LayoutStoreImpl implements LayoutStore { * Used when switching rendering modes (Vue ↔ LiteGraph) */ clearAllSlotLayouts(): void { - this.slotLayouts.clear() + this.slotLayoutsByNode.clear() + this.slotIndexKeyToIdentity.clear() this.slotSpatialIndex.clear() } @@ -563,11 +589,15 @@ class LayoutStoreImpl implements LayoutStore { return this.linkLayouts.get(linkId) || null } - /** - * Get slot layout data - */ - getSlotLayout(key: string): SlotLayout | null { - return this.slotLayouts.get(key) || null + getSlotLayoutBy( + nodeId: NodeId, + type: 'input' | 'output', + index: number + ): SlotLayout | null { + const nodeMaps = this.slotLayoutsByNode.get(nodeId) + if (!nodeMaps) return null + const map = type === 'input' ? nodeMaps.input : nodeMaps.output + return map.get(index) || null } /** @@ -581,8 +611,13 @@ class LayoutStoreImpl implements LayoutStore { * Returns all slot layout keys currently tracked by the store. * Useful for global passes without relying on spatial queries. */ - getAllSlotKeys(): string[] { - return Array.from(this.slotLayouts.keys()) + getAllSlots(): ReadonlyArray { + const result: SlotLayout[] = [] + for (const [, maps] of this.slotLayoutsByNode) { + for (const [, layout] of maps.input) result.push(layout) + for (const [, layout] of maps.output) result.push(layout) + } + return result } /** @@ -740,9 +775,14 @@ class LayoutStoreImpl implements LayoutStore { } const candidateSlotKeys = this.slotSpatialIndex.query(searchArea) - // Check precise bounds for candidates for (const key of candidateSlotKeys) { - const slotLayout = this.slotLayouts.get(key) + const identity = this.slotIndexKeyToIdentity.get(key) + if (!identity) continue + const slotLayout = this.getSlotLayoutBy( + identity.nodeId, + identity.type, + identity.index + ) if (slotLayout && pointInBounds(point, slotLayout.bounds)) { return slotLayout } @@ -799,7 +839,7 @@ class LayoutStoreImpl implements LayoutStore { queryItemsInBounds(bounds: Bounds): { nodes: NodeId[] links: LinkId[] - slots: string[] + slots: SlotIdentity[] reroutes: RerouteId[] } { // Query segments and union their linkIds @@ -812,10 +852,15 @@ class LayoutStoreImpl implements LayoutStore { } } + const slotIdentities: SlotIdentity[] = this.slotSpatialIndex + .query(bounds) + .map((key) => this.slotIndexKeyToIdentity.get(key)) + .filter((v): v is SlotIdentity => !!v) + return { nodes: this.queryNodesInBounds(bounds), links: Array.from(linkIds), - slots: this.slotSpatialIndex.query(bounds), + slots: slotIdentities, reroutes: this.rerouteSpatialIndex .query(bounds) .map((key) => asRerouteId(key)) @@ -958,10 +1003,12 @@ class LayoutStoreImpl implements LayoutStore { this.spatialIndex.clear() this.linkSegmentSpatialIndex.clear() this.slotSpatialIndex.clear() + // Clear slot identity maps to avoid stale entries when re-initializing + this.slotLayoutsByNode.clear() + this.slotIndexKeyToIdentity.clear() this.rerouteSpatialIndex.clear() this.linkLayouts.clear() this.linkSegmentLayouts.clear() - this.slotLayouts.clear() this.rerouteLayouts.clear() nodes.forEach((node, index) => { diff --git a/src/renderer/core/layout/types.ts b/src/renderer/core/layout/types.ts index 176b64396..81e7feca5 100644 --- a/src/renderer/core/layout/types.ts +++ b/src/renderer/core/layout/types.ts @@ -59,6 +59,12 @@ export interface SlotLayout { bounds: Bounds } +export interface SlotIdentity { + nodeId: NodeId + type: 'input' | 'output' + index: number +} + export interface LinkLayout { id: LinkId path: Path2D @@ -282,7 +288,7 @@ export interface LayoutStore { queryItemsInBounds(bounds: Bounds): { nodes: NodeId[] links: LinkId[] - slots: string[] + slots: SlotIdentity[] reroutes: RerouteId[] } @@ -293,24 +299,37 @@ export interface LayoutStore { rerouteId: RerouteId | null, layout: Omit ): void - updateSlotLayout(key: string, layout: SlotLayout): void + updateSlotLayoutBy( + nodeId: NodeId, + type: 'input' | 'output', + index: number, + layout: SlotLayout + ): void updateRerouteLayout(rerouteId: RerouteId, layout: RerouteLayout): void // Delete methods for cleanup deleteLinkLayout(linkId: LinkId): void deleteLinkSegmentLayout(linkId: LinkId, rerouteId: RerouteId | null): void - deleteSlotLayout(key: string): void + deleteSlotLayoutBy( + nodeId: NodeId, + type: 'input' | 'output', + index: number + ): void deleteNodeSlotLayouts(nodeId: NodeId): void deleteRerouteLayout(rerouteId: RerouteId): void clearAllSlotLayouts(): void // Get layout data getLinkLayout(linkId: LinkId): LinkLayout | null - getSlotLayout(key: string): SlotLayout | null + getSlotLayoutBy( + nodeId: NodeId, + type: 'input' | 'output', + index: number + ): SlotLayout | null getRerouteLayout(rerouteId: RerouteId): RerouteLayout | null - // Returns all slot layout keys currently tracked by the store - getAllSlotKeys(): string[] + // Returns all slot layouts currently tracked by the store + getAllSlots(): ReadonlyArray // Direct mutation API (CRDT-ready) applyOperation(operation: LayoutOperation): void @@ -334,7 +353,12 @@ export interface LayoutStore { updates: Array<{ nodeId: NodeId; bounds: Bounds }> ): void - batchUpdateSlotLayouts( - updates: Array<{ key: string; layout: SlotLayout }> + batchUpdateSlotLayoutsBy( + updates: Array<{ + nodeId: NodeId + type: 'input' | 'output' + index: number + layout: SlotLayout + }> ): void } diff --git a/src/renderer/extensions/vueNodes/components/InputSlot.vue b/src/renderer/extensions/vueNodes/components/InputSlot.vue index f739cea42..b8c6ecc4c 100644 --- a/src/renderer/extensions/vueNodes/components/InputSlot.vue +++ b/src/renderer/extensions/vueNodes/components/InputSlot.vue @@ -32,7 +32,6 @@ import { useErrorHandling } from '@/composables/useErrorHandling' import { getSlotColor } from '@/constants/slotColors' import type { INodeSlot } from '@/lib/litegraph/src/litegraph' import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState' -import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips' import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking' import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction' @@ -105,13 +104,12 @@ const slotColor = computed(() => { return getSlotColor(props.slotData.type) }) -const { state: dragState } = useSlotLinkDragUIState() -const slotKey = computed(() => - getSlotKey(props.nodeId ?? '', props.index, true) -) +const { state: dragState, getCompatible } = useSlotLinkDragUIState() const shouldDim = computed(() => { if (!dragState.active) return false - return !dragState.compatible.get(slotKey.value) + const nodeId = props.nodeId ?? '' + const value = getCompatible(nodeId, 'input', props.index) + return !value }) const slotWrapperClass = computed(() => diff --git a/src/renderer/extensions/vueNodes/components/OutputSlot.vue b/src/renderer/extensions/vueNodes/components/OutputSlot.vue index bc5be6bf7..3a70849fc 100644 --- a/src/renderer/extensions/vueNodes/components/OutputSlot.vue +++ b/src/renderer/extensions/vueNodes/components/OutputSlot.vue @@ -29,7 +29,6 @@ import { useErrorHandling } from '@/composables/useErrorHandling' import { getSlotColor } from '@/constants/slotColors' import type { INodeSlot } from '@/lib/litegraph/src/litegraph' import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState' -import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips' import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking' import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction' @@ -75,13 +74,12 @@ onErrorCaptured((error) => { // Get slot color based on type const slotColor = computed(() => getSlotColor(props.slotData.type)) -const { state: dragState } = useSlotLinkDragUIState() -const slotKey = computed(() => - getSlotKey(props.nodeId ?? '', props.index, false) -) +const { state: dragState, getCompatible } = useSlotLinkDragUIState() const shouldDim = computed(() => { if (!dragState.active) return false - return !dragState.compatible.get(slotKey.value) + const nodeId = props.nodeId ?? '' + const value = getCompatible(nodeId, 'output', props.index) + return !value }) const slotWrapperClass = computed(() => diff --git a/src/renderer/extensions/vueNodes/composables/slotLinkDragContext.ts b/src/renderer/extensions/vueNodes/composables/slotLinkDragContext.ts index 9f3986521..0f51d51ff 100644 --- a/src/renderer/extensions/vueNodes/composables/slotLinkDragContext.ts +++ b/src/renderer/extensions/vueNodes/composables/slotLinkDragContext.ts @@ -1,5 +1,5 @@ import type { NodeId } from '@/lib/litegraph/src/LGraphNode' -import type { SlotLayout } from '@/renderer/core/layout/types' +import type { SlotIdentity, SlotLayout } from '@/renderer/core/layout/types' /** * Slot link drag context @@ -17,14 +17,18 @@ interface PendingPointerMoveData { export interface SlotLinkDragContext { preferredSlotForNode: Map< NodeId, - { index: number; key: string; layout: SlotLayout } | null + { + index: number + identity: SlotIdentity + layout: SlotLayout + } | null > - lastHoverSlotKey: string | null + lastHoverSlotIdentity: SlotIdentity | null lastHoverNodeId: NodeId | null - lastCandidateKey: string | null + lastCandidateIdentity: SlotIdentity | null pendingPointerMove: PendingPointerMoveData | null lastPointerEventTarget: EventTarget | null - lastPointerTargetSlotKey: string | null + lastPointerTargetSlotIdentity: SlotIdentity | null lastPointerTargetNodeId: NodeId | null reset: () => void dispose: () => void @@ -33,21 +37,21 @@ export interface SlotLinkDragContext { export function createSlotLinkDragContext(): SlotLinkDragContext { const state: SlotLinkDragContext = { preferredSlotForNode: new Map(), - lastHoverSlotKey: null, + lastHoverSlotIdentity: null, lastHoverNodeId: null, - lastCandidateKey: null, + lastCandidateIdentity: null, pendingPointerMove: null, lastPointerEventTarget: null, - lastPointerTargetSlotKey: null, + lastPointerTargetSlotIdentity: null, lastPointerTargetNodeId: null, reset: () => { state.preferredSlotForNode = new Map() - state.lastHoverSlotKey = null + state.lastHoverSlotIdentity = null state.lastHoverNodeId = null - state.lastCandidateKey = null + state.lastCandidateIdentity = null state.pendingPointerMove = null state.lastPointerEventTarget = null - state.lastPointerTargetSlotKey = null + state.lastPointerTargetSlotIdentity = null state.lastPointerTargetNodeId = null }, dispose: () => { diff --git a/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts b/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts index c92fc8c92..0126022f1 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts @@ -10,7 +10,7 @@ import type { Ref } from 'vue' import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion' import { LiteGraph } from '@/lib/litegraph/src/litegraph' -import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' +// Slot keys migrated to identity attributes import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import type { SlotLayout } from '@/renderer/core/layout/types' import { @@ -50,9 +50,14 @@ export function syncNodeSlotLayoutsFromDOM( const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value if (!nodeLayout) return - const batch: Array<{ key: string; layout: SlotLayout }> = [] + const batch: Array<{ + nodeId: string + type: 'input' | 'output' + index: number + layout: SlotLayout + }> = [] - for (const [slotKey, entry] of node.slots) { + for (const [index, entry] of node.slots.input) { const rect = entry.el.getBoundingClientRect() const screenCenter: [number, number] = [ rect.left + rect.width / 2, @@ -73,7 +78,9 @@ export function syncNodeSlotLayoutsFromDOM( const size = LiteGraph.NODE_SLOT_HEIGHT const half = size / 2 batch.push({ - key: slotKey, + nodeId, + type: 'input', + index, layout: { nodeId, index: entry.index, @@ -88,7 +95,43 @@ export function syncNodeSlotLayoutsFromDOM( } }) } - if (batch.length) layoutStore.batchUpdateSlotLayouts(batch) + for (const [index, entry] of node.slots.output) { + const rect = entry.el.getBoundingClientRect() + const screenCenter: [number, number] = [ + rect.left + rect.width / 2, + rect.top + rect.height / 2 + ] + const [x, y] = ( + conv ?? useSharedCanvasPositionConversion() + ).clientPosToCanvasPos(screenCenter) + const centerCanvas = { x, y } + + entry.cachedOffset = { + x: centerCanvas.x - nodeLayout.position.x, + y: centerCanvas.y - nodeLayout.position.y + } + + const size = LiteGraph.NODE_SLOT_HEIGHT + const half = size / 2 + batch.push({ + nodeId, + type: 'output', + index, + layout: { + nodeId, + index: entry.index, + type: entry.type, + position: { x: centerCanvas.x, y: centerCanvas.y }, + bounds: { + x: centerCanvas.x - half, + y: centerCanvas.y - half, + width: size, + height: size + } + } + }) + } + if (batch.length) layoutStore.batchUpdateSlotLayoutsBy(batch) } function updateNodeSlotsFromCache(nodeId: string) { @@ -98,9 +141,14 @@ function updateNodeSlotsFromCache(nodeId: string) { const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value if (!nodeLayout) return - const batch: Array<{ key: string; layout: SlotLayout }> = [] + const batch: Array<{ + nodeId: string + type: 'input' | 'output' + index: number + layout: SlotLayout + }> = [] - for (const [slotKey, entry] of node.slots) { + for (const [index, entry] of node.slots.input) { if (!entry.cachedOffset) { // schedule a sync to seed offset scheduleSlotLayoutSync(nodeId) @@ -114,7 +162,39 @@ function updateNodeSlotsFromCache(nodeId: string) { const size = LiteGraph.NODE_SLOT_HEIGHT const half = size / 2 batch.push({ - key: slotKey, + nodeId, + type: 'input', + index, + layout: { + nodeId, + index: entry.index, + type: entry.type, + position: { x: centerCanvas.x, y: centerCanvas.y }, + bounds: { + x: centerCanvas.x - half, + y: centerCanvas.y - half, + width: size, + height: size + } + } + }) + } + for (const [index, entry] of node.slots.output) { + if (!entry.cachedOffset) { + scheduleSlotLayoutSync(nodeId) + continue + } + + const centerCanvas = { + x: nodeLayout.position.x + entry.cachedOffset.x, + y: nodeLayout.position.y + entry.cachedOffset.y + } + const size = LiteGraph.NODE_SLOT_HEIGHT + const half = size / 2 + batch.push({ + nodeId, + type: 'output', + index, layout: { nodeId, index: entry.index, @@ -130,7 +210,7 @@ function updateNodeSlotsFromCache(nodeId: string) { }) } - if (batch.length) layoutStore.batchUpdateSlotLayouts(batch) + if (batch.length) layoutStore.batchUpdateSlotLayoutsBy(batch) } export function useSlotElementTracking(options: { @@ -180,11 +260,14 @@ export function useSlotElementTracking(options: { } } - // Register slot - const slotKey = getSlotKey(nodeId, index, type === 'input') - - el.dataset.slotKey = slotKey - node.slots.set(slotKey, { el, index, type }) + el.dataset.nodeId = nodeId + el.dataset.slotType = type + el.dataset.slotIndex = String(index) + if (type === 'input') { + node.slots.input.set(index, { el, index, type }) + } else { + node.slots.output.set(index, { el, index, type }) + } // Seed initial sync from DOM scheduleSlotLayoutSync(nodeId) @@ -201,17 +284,18 @@ export function useSlotElementTracking(options: { const node = nodeSlotRegistryStore.getNode(nodeId) if (!node) return - // Remove this slot from registry and layout - const slotKey = getSlotKey(nodeId, index, type === 'input') - const entry = node.slots.get(slotKey) + const collection = type === 'input' ? node.slots.input : node.slots.output + const entry = collection.get(index) if (entry) { - delete entry.el.dataset.slotKey - node.slots.delete(slotKey) + delete entry.el.dataset.nodeId + delete entry.el.dataset.slotType + delete entry.el.dataset.slotIndex + collection.delete(index) } - layoutStore.deleteSlotLayout(slotKey) + layoutStore.deleteSlotLayoutBy(nodeId, type, index) // If node has no more slots, clean up - if (node.slots.size === 0) { + if (node.slots.input.size === 0 && node.slots.output.size === 0) { if (node.stopWatch) node.stopWatch() nodeSlotRegistryStore.deleteNode(nodeId) } diff --git a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts index 902c5a34e..35628fc2c 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts @@ -25,9 +25,8 @@ import { } from '@/renderer/core/canvas/links/linkDropOrchestrator' import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState' import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragUIState' -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 type { Point, SlotIdentity } from '@/renderer/core/layout/types' import { toPoint } from '@/renderer/core/layout/utils/geometry' import { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext' import { app } from '@/scripts/app' @@ -94,7 +93,7 @@ export function useSlotLinkInteraction({ endDrag, updatePointerPosition, setCandidate, - setCompatibleForKey, + setCompatibleFor, clearCompatible } = useSlotLinkDragUIState() const conversion = useSharedCanvasPositionConversion() @@ -115,8 +114,11 @@ export function useSlotLinkInteraction({ const nodeId = link.node.id if (nodeId != null) { const isInputFrom = link.toType === 'output' - const key = getSlotKey(String(nodeId), link.fromSlotIndex, isInputFrom) - const layout = layoutStore.getSlotLayout(key) + const layout = layoutStore.getSlotLayoutBy( + String(nodeId), + isInputFrom ? 'input' : 'output', + link.fromSlotIndex + ) if (layout) return layout.position } @@ -192,8 +194,11 @@ export function useSlotLinkInteraction({ ): { position: Point; direction: LinkDirection } | null => { if (!link) return null - const slotKey = getSlotKey(String(link.origin_id), link.origin_slot, false) - const layout = layoutStore.getSlotLayout(slotKey) + const layout = layoutStore.getSlotLayoutBy( + String(link.origin_id), + 'output', + link.origin_slot + ) if (!layout) return null return { position: { ...layout.position }, direction: LinkDirection.NONE } @@ -295,28 +300,50 @@ export function useSlotLinkInteraction({ syncRenderLinkOrigins() - let hoveredSlotKey: string | null = null + let hoveredSlotIdentity: SlotIdentity | null = null let hoveredNodeId: NodeId | null = null const target = data.target if (target === dragContext.lastPointerEventTarget) { - hoveredSlotKey = dragContext.lastPointerTargetSlotKey + hoveredSlotIdentity = dragContext.lastPointerTargetSlotIdentity hoveredNodeId = dragContext.lastPointerTargetNodeId } else if (target instanceof HTMLElement) { - const elWithSlot = target.closest('[data-slot-key]') + const elWithSlot = target.closest( + '[data-node-id][data-slot-type][data-slot-index]' + ) const elWithNode = elWithSlot ? null : target.closest('[data-node-id]') - hoveredSlotKey = elWithSlot?.dataset['slotKey'] ?? null - hoveredNodeId = hoveredSlotKey + if (elWithSlot) { + const nodeIdAttr = elWithSlot.dataset['nodeId'] + const typeAttr = elWithSlot.dataset['slotType'] as + | 'input' + | 'output' + | undefined + const indexAttr = elWithSlot.dataset['slotIndex'] + if (nodeIdAttr && typeAttr && indexAttr != null) { + const indexVal = Number.parseInt(indexAttr, 10) + if (Number.isFinite(indexVal)) { + hoveredSlotIdentity = { + nodeId: nodeIdAttr, + type: typeAttr, + index: indexVal + } + } + } + } + hoveredNodeId = hoveredSlotIdentity ? null : elWithNode?.dataset['nodeId'] ?? null dragContext.lastPointerEventTarget = target - dragContext.lastPointerTargetSlotKey = hoveredSlotKey + dragContext.lastPointerTargetSlotIdentity = hoveredSlotIdentity dragContext.lastPointerTargetNodeId = hoveredNodeId } const hoverChanged = - hoveredSlotKey !== dragContext.lastHoverSlotKey || + hoveredSlotIdentity?.nodeId !== + dragContext.lastHoverSlotIdentity?.nodeId || + hoveredSlotIdentity?.type !== dragContext.lastHoverSlotIdentity?.type || + hoveredSlotIdentity?.index !== dragContext.lastHoverSlotIdentity?.index || hoveredNodeId !== dragContext.lastHoverNodeId let candidate: SlotDropCandidate | null = state.candidate @@ -330,39 +357,43 @@ export function useSlotLinkInteraction({ ? null : resolveNodeSurfaceSlotCandidate(target, context) candidate = slotCandidate ?? nodeCandidate - dragContext.lastHoverSlotKey = hoveredSlotKey + dragContext.lastHoverSlotIdentity = hoveredSlotIdentity dragContext.lastHoverNodeId = hoveredNodeId if (slotCandidate) { - const key = getSlotKey( + setCompatibleFor( slotCandidate.layout.nodeId, + slotCandidate.layout.type, slotCandidate.layout.index, - slotCandidate.layout.type === 'input' + !!slotCandidate.compatible ) - setCompatibleForKey(key, !!slotCandidate.compatible) } else if (nodeCandidate) { - const key = getSlotKey( + setCompatibleFor( nodeCandidate.layout.nodeId, + nodeCandidate.layout.type, nodeCandidate.layout.index, - nodeCandidate.layout.type === 'input' + !!nodeCandidate.compatible ) - setCompatibleForKey(key, !!nodeCandidate.compatible) } } const newCandidate = candidate?.compatible ? candidate : null - const newCandidateKey = newCandidate - ? getSlotKey( - newCandidate.layout.nodeId, - newCandidate.layout.index, - newCandidate.layout.type === 'input' - ) + const newCandidateIdentity: SlotIdentity | null = newCandidate + ? { + nodeId: newCandidate.layout.nodeId, + type: newCandidate.layout.type, + index: newCandidate.layout.index + } : null - const candidateChanged = newCandidateKey !== dragContext.lastCandidateKey + const candidateChanged = + newCandidateIdentity?.nodeId !== + dragContext.lastCandidateIdentity?.nodeId || + newCandidateIdentity?.type !== dragContext.lastCandidateIdentity?.type || + newCandidateIdentity?.index !== dragContext.lastCandidateIdentity?.index if (candidateChanged) { setCandidate(newCandidate) - dragContext.lastCandidateKey = newCandidateKey + dragContext.lastCandidateIdentity = newCandidateIdentity } let snapPosChanged = false @@ -565,9 +596,7 @@ export function useSlotLinkInteraction({ raf.cancel() dragContext.reset() - const layout = layoutStore.getSlotLayout( - getSlotKey(nodeId, index, type === 'input') - ) + const layout = layoutStore.getSlotLayoutBy(nodeId, type, index) if (!layout) return const localNodeId: NodeId = nodeId @@ -682,18 +711,16 @@ export function useSlotLinkInteraction({ }) ) const targetType: 'input' | 'output' = type === 'input' ? 'output' : 'input' - const allKeys = layoutStore.getAllSlotKeys() + const allSlots = layoutStore.getAllSlots() clearCompatible() - for (const key of allKeys) { - const slotLayout = layoutStore.getSlotLayout(key) - if (!slotLayout) continue + for (const slotLayout of allSlots) { if (slotLayout.type !== targetType) continue const idx = slotLayout.index const ok = targetType === 'input' ? activeAdapter.isInputValidDrop(slotLayout.nodeId, idx) : activeAdapter.isOutputValidDrop(slotLayout.nodeId, idx) - setCompatibleForKey(key, ok) + setCompatibleFor(slotLayout.nodeId, slotLayout.type, idx, ok) } app.canvas?.setDirty(true, true) event.preventDefault() diff --git a/src/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore.ts b/src/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore.ts index c5e76d4b4..c4c0aa22e 100644 --- a/src/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore.ts +++ b/src/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore.ts @@ -10,7 +10,7 @@ type SlotEntry = { type NodeEntry = { nodeId: string - slots: Map + slots: { input: Map; output: Map } stopWatch?: () => void } @@ -26,7 +26,10 @@ export const useNodeSlotRegistryStore = defineStore('nodeSlotRegistry', () => { if (!node) { node = { nodeId, - slots: markRaw(new Map()) + slots: markRaw({ + input: new Map(), + output: new Map() + }) } registry.set(nodeId, node) }