diff --git a/src/LGraphNode.ts b/src/LGraphNode.ts index dd81cb7cc..1f82da943 100644 --- a/src/LGraphNode.ts +++ b/src/LGraphNode.ts @@ -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" @@ -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} @@ -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. */ diff --git a/src/LGraphNodeProperties.ts b/src/LGraphNodeProperties.ts new file mode 100644 index 000000000..c486415c8 --- /dev/null +++ b/src/LGraphNodeProperties.ts @@ -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() + + 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 + } +} diff --git a/src/LiteGraphGlobal.ts b/src/LiteGraphGlobal.ts index 72b2be01b..f99508000 100644 --- a/src/LiteGraphGlobal.ts +++ b/src/LiteGraphGlobal.ts @@ -521,6 +521,7 @@ export class LiteGraphGlobal { // callback node.onNodeCreated?.() + return node } diff --git a/src/litegraph.ts b/src/litegraph.ts index 080152021..20e654a2c 100644 --- a/src/litegraph.ts +++ b/src/litegraph.ts @@ -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" diff --git a/test/LGraphNodeProperties.test.ts b/test/LGraphNodeProperties.test.ts new file mode 100644 index 000000000..6e21183cd --- /dev/null +++ b/test/LGraphNodeProperties.test.ts @@ -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() + }) + }) +}) diff --git a/test/__snapshots__/ConfigureGraph.test.ts.snap b/test/__snapshots__/ConfigureGraph.test.ts.snap index 37d51774f..a90c8d3de 100644 --- a/test/__snapshots__/ConfigureGraph.test.ts.snap +++ b/test/__snapshots__/ConfigureGraph.test.ts.snap @@ -62,6 +62,7 @@ LGraph { "bgcolor": undefined, "block_delete": undefined, "boxcolor": undefined, + "changeTracker": undefined, "clip_area": undefined, "clonable": undefined, "color": undefined, @@ -130,6 +131,7 @@ LGraph { "bgcolor": undefined, "block_delete": undefined, "boxcolor": undefined, + "changeTracker": undefined, "clip_area": undefined, "clonable": undefined, "color": undefined, @@ -199,6 +201,7 @@ LGraph { "bgcolor": undefined, "block_delete": undefined, "boxcolor": undefined, + "changeTracker": undefined, "clip_area": undefined, "clonable": undefined, "color": undefined, diff --git a/test/__snapshots__/LGraph.test.ts.snap b/test/__snapshots__/LGraph.test.ts.snap index d1f520005..acf54ccee 100644 --- a/test/__snapshots__/LGraph.test.ts.snap +++ b/test/__snapshots__/LGraph.test.ts.snap @@ -62,6 +62,7 @@ LGraph { "bgcolor": undefined, "block_delete": undefined, "boxcolor": undefined, + "changeTracker": undefined, "clip_area": undefined, "clonable": undefined, "color": undefined, @@ -132,6 +133,7 @@ LGraph { "bgcolor": undefined, "block_delete": undefined, "boxcolor": undefined, + "changeTracker": undefined, "clip_area": undefined, "clonable": undefined, "color": undefined, @@ -203,6 +205,7 @@ LGraph { "bgcolor": undefined, "block_delete": undefined, "boxcolor": undefined, + "changeTracker": undefined, "clip_area": undefined, "clonable": undefined, "color": undefined, diff --git a/test/__snapshots__/LGraph_constructor.test.ts.snap b/test/__snapshots__/LGraph_constructor.test.ts.snap index a0fea6132..abbe6bdfd 100644 --- a/test/__snapshots__/LGraph_constructor.test.ts.snap +++ b/test/__snapshots__/LGraph_constructor.test.ts.snap @@ -62,6 +62,7 @@ LGraph { "bgcolor": undefined, "block_delete": undefined, "boxcolor": undefined, + "changeTracker": undefined, "clip_area": undefined, "clonable": undefined, "color": undefined, @@ -130,6 +131,7 @@ LGraph { "bgcolor": undefined, "block_delete": undefined, "boxcolor": undefined, + "changeTracker": undefined, "clip_area": undefined, "clonable": undefined, "color": undefined, @@ -199,6 +201,7 @@ LGraph { "bgcolor": undefined, "block_delete": undefined, "boxcolor": undefined, + "changeTracker": undefined, "clip_area": undefined, "clonable": undefined, "color": undefined,