Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/LGraphNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { NullGraphError } from "./infrastructure/NullGraphError"
import { Rectangle } from "./infrastructure/Rectangle"
import { BadgePosition, LGraphBadge } from "./LGraphBadge"
import { LGraphCanvas } from "./LGraphCanvas"
import { LGraphNodeProperties } from "./LGraphNodeProperties"
import { type LGraphNodeConstructor, LiteGraph, type Subgraph, type SubgraphNode } from "./litegraph"
import { LLink } from "./LLink"
import { createBounds, isInRect, isInRectangle, isPointInRect, snapPoint } from "./measure"
Expand Down Expand Up @@ -233,6 +234,9 @@ export class LGraphNode implements NodeLike, Positionable, IPinnable, IColorable
properties_info: INodePropertyInfo[] = []
flags: INodeFlags = {}
widgets?: IBaseWidget[]

/** Property manager for this node */
changeTracker: LGraphNodeProperties
/**
* The amount of space available for widgets to grow into.
* @see {@link layoutWidgets}
Expand Down Expand Up @@ -687,6 +691,9 @@ export class LGraphNode implements NodeLike, Positionable, IPinnable, IColorable
error: this.#getErrorStrokeStyle,
selected: this.#getSelectedStrokeStyle,
}

// Initialize property manager with tracked properties
this.changeTracker = new LGraphNodeProperties(this)
}

/** Internal callback for subgraph nodes. Do not implement externally. */
Expand Down
163 changes: 163 additions & 0 deletions src/LGraphNodeProperties.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import type { LGraphNode } from "./LGraphNode"

/**
* Default properties to track
*/
const DEFAULT_TRACKED_PROPERTIES: string[] = [
"title",
"flags.collapsed",
]

/**
* Manages node properties with optional change tracking and instrumentation.
*/
export class LGraphNodeProperties {
/** The node this property manager belongs to */
node: LGraphNode

/** Set of property paths that have been instrumented */
#instrumentedPaths = new Set<string>()

constructor(node: LGraphNode) {
this.node = node

this.#setupInstrumentation()
}

/**
* Sets up property instrumentation for all tracked properties
*/
#setupInstrumentation(): void {
for (const path of DEFAULT_TRACKED_PROPERTIES) {
this.#instrumentProperty(path)
}
}

/**
* Instruments a single property to track changes
*/
#instrumentProperty(path: string): void {
const parts = path.split(".")

if (parts.length > 1) {
this.#ensureNestedPath(path)
}

let targetObject: any = this.node
let propertyName = parts[0]

if (parts.length > 1) {
for (let i = 0; i < parts.length - 1; i++) {
targetObject = targetObject[parts[i]]
}
propertyName = parts.at(-1)!
}

const hasProperty = Object.prototype.hasOwnProperty.call(targetObject, propertyName)
const currentValue = targetObject[propertyName]

if (!hasProperty) {
let value: any = undefined

Object.defineProperty(targetObject, propertyName, {
get: () => value,
set: (newValue: any) => {
const oldValue = value
value = newValue
this.#emitPropertyChange(path, oldValue, newValue)

// Update enumerable: true for non-undefined values, false for undefined
const shouldBeEnumerable = newValue !== undefined
const currentDescriptor = Object.getOwnPropertyDescriptor(targetObject, propertyName)
if (currentDescriptor && currentDescriptor.enumerable !== shouldBeEnumerable) {
Object.defineProperty(targetObject, propertyName, {
...currentDescriptor,
enumerable: shouldBeEnumerable,
})
}
},
enumerable: false,
configurable: true,
})
} else {
Object.defineProperty(
targetObject,
propertyName,
this.#createInstrumentedDescriptor(path, currentValue),
)
}

this.#instrumentedPaths.add(path)
}

/**
* Creates a property descriptor that emits change events
*/
#createInstrumentedDescriptor(propertyPath: string, initialValue: any): PropertyDescriptor {
let value = initialValue

return {
get: () => value,
set: (newValue: any) => {
const oldValue = value
value = newValue
this.#emitPropertyChange(propertyPath, oldValue, newValue)
},
enumerable: true,
configurable: true,
}
}

/**
* Emits a property change event if the node is connected to a graph
*/
#emitPropertyChange(propertyPath: string, oldValue: any, newValue: any): void {
if (oldValue !== newValue && this.node.graph) {
this.node.graph.trigger("node:property:changed", {
nodeId: this.node.id,
property: propertyPath,
oldValue,
newValue,
})
}
}

/**
* Ensures parent objects exist for nested properties
*/
#ensureNestedPath(path: string): void {
const parts = path.split(".")
let current: any = this.node

// Create all parent objects except the last property
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]
if (!current[part]) {
current[part] = {}
}
current = current[part]
}
}

/**
* Checks if a property is being tracked
*/
isTracked(path: string): boolean {
return this.#instrumentedPaths.has(path)
}

/**
* Gets the list of tracked properties
*/
getTrackedProperties(): string[] {
return [...DEFAULT_TRACKED_PROPERTIES]
}

/**
* Custom toJSON method for JSON.stringify
* Returns undefined to exclude from serialization since we only use defaults
*/
toJSON(): any {
return undefined
}
}
1 change: 1 addition & 0 deletions src/LiteGraphGlobal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ export class LiteGraphGlobal {

// callback
node.onNodeCreated?.()

return node
}

Expand Down
1 change: 1 addition & 0 deletions src/litegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export { BadgePosition, LGraphBadge, type LGraphBadgeOptions } from "./LGraphBad
export { LGraphCanvas, type LGraphCanvasState } from "./LGraphCanvas"
export { LGraphGroup } from "./LGraphGroup"
export { LGraphNode, type NodeId } from "./LGraphNode"
export { LGraphNodeProperties } from "./LGraphNodeProperties"
export { type LinkId, LLink } from "./LLink"
export { clamp, createBounds } from "./measure"
export { Reroute, type RerouteId } from "./Reroute"
Expand Down
151 changes: 151 additions & 0 deletions test/LGraphNodeProperties.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, vi } from "vitest"

import { LGraphNodeProperties } from "@/LGraphNodeProperties"

describe("LGraphNodeProperties", () => {
let mockNode: any
let mockGraph: any

beforeEach(() => {
mockGraph = {
trigger: vi.fn(),
}

mockNode = {
id: 123,
title: "Test Node",
flags: {},
graph: mockGraph,
}
})

describe("constructor", () => {
it("should initialize with default tracked properties", () => {
const propManager = new LGraphNodeProperties(mockNode)
const tracked = propManager.getTrackedProperties()

expect(tracked).toHaveLength(2)
expect(tracked).toContain("title")
expect(tracked).toContain("flags.collapsed")
})
})

describe("property tracking", () => {
it("should track changes to existing properties", () => {
new LGraphNodeProperties(mockNode)

mockNode.title = "New Title"

expect(mockGraph.trigger).toHaveBeenCalledWith("node:property:changed", {
nodeId: mockNode.id,
property: "title",
oldValue: "Test Node",
newValue: "New Title",
})
})

it("should track changes to nested properties", () => {
new LGraphNodeProperties(mockNode)

mockNode.flags.collapsed = true

expect(mockGraph.trigger).toHaveBeenCalledWith("node:property:changed", {
nodeId: mockNode.id,
property: "flags.collapsed",
oldValue: undefined,
newValue: true,
})
})

it("should not emit events when value doesn't change", () => {
new LGraphNodeProperties(mockNode)

mockNode.title = "Test Node" // Same value as original

expect(mockGraph.trigger).toHaveBeenCalledTimes(0)
})

it("should not emit events when node has no graph", () => {
mockNode.graph = null
new LGraphNodeProperties(mockNode)

// Should not throw
expect(() => {
mockNode.title = "New Title"
}).not.toThrow()
})
})

describe("isTracked", () => {
it("should correctly identify tracked properties", () => {
const propManager = new LGraphNodeProperties(mockNode)

expect(propManager.isTracked("title")).toBe(true)
expect(propManager.isTracked("flags.collapsed")).toBe(true)
expect(propManager.isTracked("untracked")).toBe(false)
})
})

describe("serialization behavior", () => {
it("should not make non-existent properties enumerable", () => {
new LGraphNodeProperties(mockNode)

// flags.collapsed doesn't exist initially
const descriptor = Object.getOwnPropertyDescriptor(mockNode.flags, "collapsed")
expect(descriptor?.enumerable).toBe(false)
})

it("should make properties enumerable when set to non-default values", () => {
new LGraphNodeProperties(mockNode)

mockNode.flags.collapsed = true

const descriptor = Object.getOwnPropertyDescriptor(mockNode.flags, "collapsed")
expect(descriptor?.enumerable).toBe(true)
})

it("should make properties non-enumerable when set back to undefined", () => {
new LGraphNodeProperties(mockNode)

mockNode.flags.collapsed = true
mockNode.flags.collapsed = undefined

const descriptor = Object.getOwnPropertyDescriptor(mockNode.flags, "collapsed")
expect(descriptor?.enumerable).toBe(false)
})

it("should keep existing properties enumerable", () => {
// title exists initially
const initialDescriptor = Object.getOwnPropertyDescriptor(mockNode, "title")
expect(initialDescriptor?.enumerable).toBe(true)

new LGraphNodeProperties(mockNode)

const afterDescriptor = Object.getOwnPropertyDescriptor(mockNode, "title")
expect(afterDescriptor?.enumerable).toBe(true)
})

it("should only include non-undefined values in JSON.stringify", () => {
new LGraphNodeProperties(mockNode)

// Initially, flags.collapsed shouldn't appear
let json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBeUndefined()

// After setting to true, it should appear
mockNode.flags.collapsed = true
json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBe(true)

// After setting to false, it should still appear (false is not undefined)
mockNode.flags.collapsed = false
json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBe(false)

// After setting back to undefined, it should disappear
mockNode.flags.collapsed = undefined
json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBeUndefined()
})
})
})
3 changes: 3 additions & 0 deletions test/__snapshots__/ConfigureGraph.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
Expand Down Expand Up @@ -130,6 +131,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
Expand Down Expand Up @@ -199,6 +201,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
Expand Down
Loading