Refactor slotkey from bespoke string key

This commit is contained in:
Benjamin Lu
2025-10-10 12:58:25 -07:00
parent d7796fcda4
commit 799ab5e5aa
14 changed files with 430 additions and 231 deletions

View File

@@ -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<HTMLElement>('[data-slot-key]')
const key = elWithKey?.dataset['slotKey']
if (!key) return null
const elWithSlot = target.closest<HTMLElement>(
'[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<HTMLElement>('[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 }

View File

@@ -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<string, boolean>
compatible: Map<
string,
{ input: Map<number, boolean>; output: Map<number, boolean> }
>
}
const state = reactive<SlotDragState>({
@@ -53,7 +55,7 @@ const state = reactive<SlotDragState>({
canvas: { x: 0, y: 0 }
},
candidate: null,
compatible: new Map<string, boolean>()
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<number, boolean>()),
output: reactive(new Map<number, boolean>())
}
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()
}
}

View File

@@ -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]
}

View File

@@ -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
)
}
/**

View File

@@ -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}`
}

View File

@@ -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<LinkId, LinkLayout>()
private linkSegmentLayouts = new Map<string, LinkSegmentLayout>() // Internal string key: ${linkId}:${rerouteId ?? 'final'}
private slotLayouts = new Map<string, SlotLayout>()
private slotLayoutsByNode = new Map<
NodeId,
{ input: Map<number, SlotLayout>; output: Map<number, SlotLayout> }
>()
private slotIndexKeyToIdentity = new Map<string, SlotIdentity>()
private rerouteLayouts = new Map<RerouteId, RerouteLayout>()
// 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<SlotLayout> {
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) => {

View File

@@ -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<LinkSegmentLayout, 'linkId' | 'rerouteId'>
): 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<SlotLayout>
// 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
}