import { get, writable } from 'svelte/store'; import type { Writable } from 'svelte/store'; import type ComfyApp from "$lib/components/ComfyApp" import { type LGraphNode, type IWidget, type LGraph, NodeMode, type LGraphRemoveNodeOptions, type LGraphAddNodeOptions, type UUID, type NodeID, LiteGraph, type GraphIDMapping } from "@litegraph-ts/core" import { SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action'; import type { ComfyNodeID } from '$lib/api'; import { v4 as uuidv4 } from "uuid"; import type { ComfyWidgetNode } from '$lib/nodes/widgets'; import type ComfyGraph from '$lib/ComfyGraph'; import type { ComfyBoxWorkflow, WorkflowAttributes, WorkflowInstID } from './workflowState'; import workflowState from './workflowState'; import type { SerializedComfyBoxTemplate } from '$lib/ComfyBoxTemplate'; export function isComfyWidgetNode(node: LGraphNode): node is ComfyWidgetNode { return "svelteComponentType" in node } export type DragItemEntry = { /* * Drag item. */ dragItem: IDragItem, /* * Children of this drag item. * Only applies if the drag item's type is "container" */ children: IDragItem[] | null, /* * Parent of this drag item. */ parent: IDragItem | null } /* * Keeps track of the tree of UI components - widgets and the containers that * group them together. */ export type LayoutState = { /* * Root of the UI tree */ root: IDragItem | null, /* * All items indexed by their own ID */ allItems: Record, /* * Items indexed by the litegraph node they're bound to * Only contains drag items of type "widget" */ allItemsByNode: Record, /* * If true, a saved workflow is being deserialized, so ignore any * nodeAdded/nodeRemoved events. * * TODO: instead use LGraphAddNodeOptions.addedBy */ isConfiguring: boolean, /* * If true, the right-click context menu is open */ isMenuOpen: boolean, } /** * Attributes for both containers and nodes. **/ export type Attributes = { /* * Flex direction for containers. */ direction: "horizontal" | "vertical", /* * Display name of this item. */ title: string, /* * List of CSS classes to apply to the component. */ classes: string, /* * Variant for containers. "hidden" hides margin/borders. */ containerVariant?: "block" | "hidden", /* * Tags for hiding containers with * For WidgetLayouts this will be ignored, it will use node.properties.tags instead */ tags: string[], /* * If true, don't show this component in the UI */ hidden?: boolean, /* * If true, grey out this component in the UI */ disabled?: boolean, /* * CSS styles */ style?: string, /** * Display variant for widgets/containers (e.g. number widget can act as slider/knob/dial) * Valid values depend on the widget in question. */ variant?: string, /* * What state to set this widget to in the frontend if its corresponding * node is disabled in the graph. */ nodeDisabledState: "visible" | "disabled" | "hidden", /*********************************************/ /* Special attributes for widgets/containers */ /*********************************************/ // Accordion openOnStartup?: boolean // Button buttonVariant?: "primary" | "secondary", buttonSize?: "large" | "small" } /* * Defines something that can be edited in the properties side panel. */ export type AttributesSpec = { /* * ID necessary for svelte's keyed each, autoset at the top level in this source file. */ id?: number, /* * Attribute name. Corresponds to the name of the instance variable in the * hashmap/class instance, which depends on `location`. */ name: string, /* * Type of this attribute. * If you want to support a custom type, use "string" combined with * `serialize` and `deserialize`. */ type: "string" | "enum" | "number" | "boolean", /* * Location of this attribute. * - "widget": inside IDragNode.attrs * - "nodeProps": inside LGraphNode.properties * - "nodeVars": an instance variable directly on an LGraphNode * - "workflow": inside $workflowState.activeWorkflow.attrs */ location: "widget" | "nodeProps" | "nodeVars" | "workflow" /* * Can this attribute be edited in the properties pane? */ editable: boolean, /* * Default value to supply to this attribute if it is null when the properties pane is opened. * NOTE: This means that any attribute can't have a default null value! */ defaultValue: any, /* * If `type` is "enum", the valid values for the combo widget. */ values?: string[], /* * If `type` is "number", step for the slider that edits this attribute */ step?: number, /* * If `type` is "number", min for the slider that edits this attribute */ min?: number, /* * If `type` is "number", max for the slider that edits this attribute */ max?: number, /* * If `type` is "string", display as a textarea instead of an input. */ multiline?: boolean, /* * Valid `LGraphNode.type`s this property applies to if it's located in a node. * These are like "ui/button", "ui/slider". */ validNodeTypes?: string[], /* * Callback: if false, don't show the property in the pane. * Useful if you need to show the property based on another property. * Example: If the IDragItem is a container (not a widget), show its flex `direction`. */ canShow?: (arg: IDragItem | LGraphNode) => boolean, /* * If the type of this spec is "string", but the underlying type is something else, * convert the value to a string here so it can be edited in the textbox. */ serialize?: (arg: any) => string, /* * If the type of this spec is "string", but the underlying type is something else, * convert the textbox value into the underlying value. */ deserialize?: (arg: string) => any, /* * If true, when this property is changed the properties pane will be rebuilt. * This should be used if there's a canShow dependent on this property so * the pane can be updated with the new list of valid properties. */ refreshPanelOnChange?: boolean, /* * Callback run when this value is changed. */ onChanged?: (arg: IDragItem | LGraphNode | LayoutState, value: any, prevValue: any) => void, } /* * A list of `AttributesSpec`s grouped under a category. */ export type AttributesCategorySpec = { categoryName: string, specs: AttributesSpec[] } export type AttributesSpecList = AttributesCategorySpec[] const serializeStringArray = (arg: string[]) => { if (arg == null) arg = [] return arg.join(",") } const deserializeStringArray = (arg: string) => { if (arg === "" || arg == null) return [] return arg.split(",").map(s => s.trim()) } const setNodeTitle = (arg: IDragItem, value: any) => { if (arg.type !== "widget") return const widget = arg as WidgetLayout; if (widget.node == null) return; const reg = LiteGraph.registered_node_types[widget.node.type]; if (reg == null) return if (value && value !== reg.title) widget.node.title = `${reg.title} (${value})` else widget.node.title = reg.title } /* * Attributes that will show up in the properties panel. * Their order in the list is the order they'll appear in the panel. */ const ALL_ATTRIBUTES: AttributesSpecList = [ { categoryName: "appearance", specs: [ { name: "title", type: "string", location: "widget", defaultValue: "", editable: true, // onChanged: setNodeTitle }, { name: "hidden", type: "boolean", location: "widget", defaultValue: false, editable: true }, { name: "disabled", type: "boolean", location: "widget", defaultValue: false, editable: true }, { name: "direction", type: "enum", location: "widget", editable: true, values: ["horizontal", "vertical"], defaultValue: "vertical", canShow: (di: IDragItem) => di.type === "container" }, { name: "classes", type: "string", location: "widget", defaultValue: "", editable: true, }, { name: "style", type: "string", location: "widget", defaultValue: "", editable: true }, { name: "nodeDisabledState", type: "enum", location: "widget", editable: true, values: ["visible", "disabled", "hidden"], defaultValue: "hidden", canShow: (di: IDragItem) => di.type === "widget" }, // Container variants { name: "variant", type: "enum", location: "widget", editable: true, values: ["block", "accordion", "tabs"], defaultValue: "block", canShow: (di: IDragItem) => di.type === "container", refreshPanelOnChange: true }, { name: "containerVariant", type: "enum", location: "widget", editable: true, values: ["block", "hidden"], defaultValue: "hidden", canShow: (di: IDragItem) => di.type === "container" }, // Accordion { name: "openOnStartup", type: "boolean", location: "widget", editable: true, defaultValue: false, canShow: (di: IDragItem) => di.type === "container" && di.attrs.variant === "accordion" }, // Button { name: "buttonVariant", type: "enum", location: "widget", editable: true, validNodeTypes: ["ui/button"], values: ["primary", "secondary"], defaultValue: "primary" }, { name: "buttonSize", type: "enum", location: "widget", editable: true, validNodeTypes: ["ui/button"], values: ["large", "small"], defaultValue: "large" }, // Combo { name: "convertValueToLabelCode", type: "string", location: "nodeProps", editable: true, multiline: true, validNodeTypes: ["ui/combo"], defaultValue: "" }, // Image Editor { name: "variant", type: "enum", location: "widget", editable: true, validNodeTypes: ["ui/image_editor"], values: ["inlineEditor", "fileUpload"], defaultValue: "inlineEditor", refreshPanelOnChange: true }, // Gallery { name: "variant", type: "enum", location: "widget", editable: true, validNodeTypes: ["ui/gallery"], values: ["gallery", "image"], defaultValue: "gallery", refreshPanelOnChange: true }, // Text { name: "variant", type: "enum", location: "widget", editable: true, validNodeTypes: ["ui/text"], values: ["text", "code"], defaultValue: "text", refreshPanelOnChange: true }, { name: "multiline", type: "boolean", location: "nodeProps", editable: true, validNodeTypes: ["ui/text"], defaultValue: false }, { name: "lines", type: "number", location: "nodeProps", editable: true, validNodeTypes: ["ui/text"], defaultValue: 5, min: 1, max: 100, step: 1 }, { name: "maxLines", type: "number", location: "nodeProps", editable: true, validNodeTypes: ["ui/text"], defaultValue: 5, min: 1, max: 100, step: 1 }, ] }, { categoryName: "behavior", specs: [ // Node variables { name: "saveUserState", type: "boolean", location: "nodeVars", editable: true, defaultValue: true, }, { name: "mode", type: "enum", location: "nodeVars", editable: true, values: ["ALWAYS", "NEVER"], defaultValue: "ALWAYS", serialize: (s) => s === NodeMode.ALWAYS ? "ALWAYS" : "NEVER", deserialize: (m) => m === "ALWAYS" ? NodeMode.ALWAYS : NodeMode.NEVER }, { name: "horizontal", type: "boolean", location: "nodeVars", editable: true, defaultValue: false }, // Node properties { name: "tags", type: "string", location: "nodeProps", editable: true, defaultValue: [], serialize: serializeStringArray, deserialize: deserializeStringArray }, { name: "excludeFromJourney", type: "boolean", location: "nodeProps", editable: true, defaultValue: false, canShow: isComfyWidgetNode }, // Container tags are contained in the widget attributes { name: "tags", type: "string", location: "widget", editable: true, defaultValue: [], serialize: serializeStringArray, deserialize: deserializeStringArray, canShow: (di: IDragItem) => di.type === "container" }, // Combo { name: "defaultValue", type: "string", location: "nodeProps", editable: true, defaultValue: "A", validNodeTypes: ["ui/combo"], }, // Checkbox { name: "defaultValue", type: "boolean", location: "nodeProps", editable: true, defaultValue: true, validNodeTypes: ["ui/checkbox"], }, // Range { name: "defaultValue", type: "number", location: "nodeProps", editable: true, defaultValue: 0, min: -2 ^ 16, max: 2 ^ 16, validNodeTypes: ["ui/number"], }, { name: "min", type: "number", location: "nodeProps", editable: true, defaultValue: 0, min: -2 ^ 16, max: 2 ^ 16, validNodeTypes: ["ui/number"], }, { name: "max", type: "number", location: "nodeProps", editable: true, defaultValue: 10, min: -2 ^ 16, max: 2 ^ 16, validNodeTypes: ["ui/number"], }, { name: "step", type: "number", location: "nodeProps", editable: true, defaultValue: 1, min: -2 ^ 16, max: 2 ^ 16, validNodeTypes: ["ui/number"], }, // Button { name: "param", type: "string", location: "nodeProps", editable: true, validNodeTypes: ["ui/button"], defaultValue: "bang" }, // Gallery { name: "updateMode", type: "enum", location: "nodeProps", editable: true, validNodeTypes: ["ui/gallery"], values: ["replace", "append"], defaultValue: "replace" }, { name: "autoSelectOnUpdate", type: "boolean", location: "nodeProps", editable: true, validNodeTypes: ["ui/gallery"], defaultValue: true }, // ImageUpload { name: "maskCount", type: "number", location: "nodeProps", editable: true, validNodeTypes: ["ui/image_upload"], defaultValue: 0, min: 0, max: 8, step: 1 }, // Radio { name: "defaultValue", type: "string", location: "nodeProps", editable: true, defaultValue: "Choice A", validNodeTypes: ["ui/radio"], }, { name: "choices", type: "string", location: "nodeProps", editable: true, validNodeTypes: ["ui/radio"], defaultValue: ["Choice A", "Choice B", "Choice C"], serialize: serializeStringArray, deserialize: deserializeStringArray, }, // Text { name: "defaultValue", type: "string", location: "nodeProps", editable: true, defaultValue: "", validNodeTypes: ["ui/text"], }, // Workflow { name: "title", type: "string", location: "workflow", editable: true, defaultValue: "New Workflow" }, { name: "queuePromptButtonName", type: "string", location: "workflow", editable: true, defaultValue: "Queue Prompt" }, { name: "queuePromptButtonRunWorkflow", type: "boolean", location: "workflow", editable: true, defaultValue: true }, { name: "queuePromptButtonDefaultWorkflow", type: "string", location: "workflow", editable: true, defaultValue: "" }, { name: "showDefaultNotifications", type: "boolean", location: "workflow", editable: true, defaultValue: true } ] } ] as const; // This is needed so the specs can be iterated with svelte's keyed #each. let i = 0; for (const cat of Object.values(ALL_ATTRIBUTES)) { for (const spec of Object.values(cat.specs)) { spec.id = i; i += 1; } } export { ALL_ATTRIBUTES }; // TODO Should be nested by category for name uniqueness? export const defaultWidgetAttributes: Attributes = {} as any export const defaultWorkflowAttributes: WorkflowAttributes = {} as any for (const cat of Object.values(ALL_ATTRIBUTES)) { for (const spec of Object.values(cat.specs)) { if (spec.defaultValue != null) { if (spec.location === "widget") { defaultWidgetAttributes[spec.name] = spec.defaultValue; } else if (spec.location === "workflow") { defaultWorkflowAttributes[spec.name] = spec.defaultValue; } } } } /* * Something that can be dragged around in the frontend - a widget or a container. */ export interface IDragItem { /* * Type of the item. */ type: "container" | "widget" | "template", /* * Unique ID of the item. */ id: DragItemID, /* * If true, the node associated with this drag item is executing. * Used to show an indicator on the widget/container. */ isNodeExecuting?: boolean, /* * Attributes for this drag item. */ attrs: Attributes, /* * Hackish thing to indicate to Svelte that an attribute changed. * TODO Use Writeable instead! */ attrsChanged: Writable } /* * A container (block, accordion, tabs). Has child drag items. */ export interface ContainerLayout extends IDragItem { type: "container", // Ephemeral state to preserve when the component gets recreated by Svelte // (not serialized) // Accordion isOpen?: Writable, } /* * A widget (slider, dropdown, textbox...) */ export interface WidgetLayout extends IDragItem { type: "widget", /* * litegraph node this widget is bound to. */ node: ComfyWidgetNode } /* * A template being dragged into the workflow UI. * * These will never be saved, instead they will be expanded inside * updateChildren() and then removed. */ export interface TemplateLayout extends IDragItem { type: "template", /* * Template to expand */ template: SerializedComfyBoxTemplate } export type DefaultLayout = { root: ContainerLayout, left: ContainerLayout, right: ContainerLayout, } export type DragItemID = UUID; type LayoutStateOps = { workflow: ComfyBoxWorkflow | null, addContainer: (parent: ContainerLayout | null, attrs?: Partial, index?: number) => ContainerLayout, addWidget: (parent: ContainerLayout, node: ComfyWidgetNode, attrs?: Partial, index?: number) => WidgetLayout, findDefaultContainerForInsertion: () => ContainerLayout | null, updateChildren: (parent: IDragItem, children: IDragItem[]) => IDragItem[], nodeAdded: (node: LGraphNode, options: LGraphAddNodeOptions) => void, nodeRemoved: (node: LGraphNode, options: LGraphRemoveNodeOptions) => void, insertTemplate: (template: SerializedComfyBoxTemplate, graph: LGraph, templateNodeIDToNode: Record, container: ContainerLayout, childIndex: number) => IDragItem, moveItem: (target: IDragItem, to: ContainerLayout, index?: number) => void, groupItems: (dragItemIDs: DragItemID[], attrs?: Partial) => ContainerLayout, ungroup: (container: ContainerLayout) => void, findLayoutEntryForNode: (nodeId: NodeID) => DragItemEntry | null, findLayoutForNode: (nodeId: NodeID) => IDragItem | null, iterateBreadthFirst: (id?: DragItemID | null) => Iterable, serialize: () => SerializedLayoutState, serializeAtRoot: (rootID: DragItemID) => SerializedLayoutState, deserialize: (data: SerializedLayoutState, graph: LGraph) => void, initDefaultLayout: () => DefaultLayout, onStartConfigure: () => void notifyWorkflowModified: () => void } export type SerializedLayoutState = { root: DragItemID | null, allItems: Record, } export type SerializedDragEntry = { dragItem: SerializedDragItem, children: DragItemID[], parent: DragItemID | null } export type SerializedDragItem = { type: string, id: DragItemID, nodeId: NodeID | null, attrs: Attributes } export type WritableLayoutStateStore = Writable & LayoutStateOps; function createRaw(workflow: ComfyBoxWorkflow | null = null): WritableLayoutStateStore { const store: Writable = writable({ root: null, allItems: {}, allItemsByNode: {}, isMenuOpen: false, isConfiguring: true, }) function clear() { store.set({ root: null, allItems: {}, allItemsByNode: {}, isMenuOpen: false, isConfiguring: true, }) } function findDefaultContainerForInsertion(): ContainerLayout | null { const state = get(store); if (state.root === null) { // Should never happen throw "Root container was null!"; } if (state.root.type === "container") { const container = state.root as ContainerLayout; const children: IDragItem[] = state.allItems[container.id]?.children || [] const found = children.find((di) => di.type === "container") if (found && found.type === "container") return found as ContainerLayout; return container; } return null } function runOnChangedForWidgetDefaults(dragItem: IDragItem) { for (const cat of Object.values(ALL_ATTRIBUTES)) { for (const spec of Object.values(cat.specs)) { if (defaultWidgetAttributes[spec.name] !== undefined && spec.onChanged != null) { spec.onChanged(dragItem, dragItem.attrs[spec.name], dragItem.attrs[spec.name]) } } } } function addContainer(parent: ContainerLayout | null, attrs: Partial = {}, index?: number): ContainerLayout { const state = get(store); const dragItem: ContainerLayout = { type: "container", id: uuidv4(), attrsChanged: writable(0), attrs: { ...defaultWidgetAttributes, title: "Container", ...attrs } } const entry: DragItemEntry = { dragItem, children: [], parent: null }; if (state.allItemsByNode[dragItem.id] != null) throw new Error(`Container with ID ${dragItem.id} already registered!!!`) state.allItems[dragItem.id] = entry; if (parent) { moveItem(dragItem, parent, index) } console.debug("[layoutState] addContainer", state) store.set(state) notifyWorkflowModified(); // runOnChangedForWidgetDefaults(dragItem) return dragItem; } function addWidget(parent: ContainerLayout, node: ComfyWidgetNode, attrs: Partial = {}, index?: number): WidgetLayout { const state = get(store); const widgetName = node.title || "Widget" const dragItem: WidgetLayout = { type: "widget", id: uuidv4(), node: node, attrsChanged: writable(0), attrs: { ...defaultWidgetAttributes, title: widgetName, ...attrs } } const entry: DragItemEntry = { dragItem, children: [], parent: null }; if (state.allItems[dragItem.id] != null) throw new Error(`Widget with ID ${dragItem.id} already registered!!!`) state.allItems[dragItem.id] = entry; if (state.allItemsByNode[node.id] != null) throw new Error(`Widget's node with ID ${node.id} already registered!!!`) state.allItemsByNode[node.id] = entry; console.debug("[layoutState] addWidget", state) moveItem(dragItem, parent, index) notifyWorkflowModified(); // runOnChangedForWidgetDefaults(dragItem) return dragItem; } function updateChildren(parent: IDragItem, newChildren?: IDragItem[]): IDragItem[] { const state = get(store); if (newChildren) state.allItems[parent.id].children = newChildren; for (const child of state.allItems[parent.id].children) { if (child.id === SHADOW_PLACEHOLDER_ITEM_ID || child.type === "template") continue; state.allItems[child.id].parent = parent; } store.set(state) return state.allItems[parent.id].children } function removeEntry(state: LayoutState, id: DragItemID) { const entry = state.allItems[id] if (entry.children && entry.children.length > 0) { console.error(entry) throw `Tried removing entry ${id} but it still had children!` } const parent = entry.parent; if (parent) { const parentEntry = state.allItems[parent.id]; parentEntry.children = parentEntry.children.filter(item => item.id !== id) } if (entry.dragItem.type === "widget") { const widget = entry.dragItem as WidgetLayout; delete state.allItemsByNode[widget.node.id] } delete state.allItems[id] notifyWorkflowModified(); } function nodeAdded(node: LGraphNode, options: LGraphAddNodeOptions) { // Only concern ourselves with widget nodes if (!isComfyWidgetNode(node)) return; const state = get(store) if (state.isConfiguring) return; let attrs: Partial = {} if ((options.addedBy as any) === "template") { // Template layout will be deserialized shortly return; } else if (options.addedBy === "moveIntoSubgraph" || options.addedBy === "moveOutOfSubgraph") { // All we need to do is update the nodeID linked to this node. const item = state.allItemsByNode[options.prevNodeID] delete state.allItemsByNode[options.prevNodeID] state.allItemsByNode[node.id] = item return; } else if ((options.addedBy === "cloneSelection" || options.addedBy === "paste") && options.prevNodeID != null) { console.warn("WASCLONED", options.addedBy, options.prevNodeID, Object.keys(state.allItemsByNode), options.prevNode, options.subgraphs) // Grab layout state information and clone it too. let prevWidget = state.allItemsByNode[node.id] if (prevWidget == null) { // If a subgraph was cloned, try looking for the original widget node corresponding to the new widget node being added. // `node` is the new ComfyWidgetNode instance to copy layout attrs to. // `options.cloneData` should contain the results of Subgraph.clone(), which is named "subgraphNewIDMapping" in an // entry of the `forNode` Record. // `options.cloneData` is attached to the onNodeAdded options if a node is added to a graph after being // selection-cloned or pasted, as they both call clone() internally. const cloneData = options.cloneData.forNode[options.prevNodeID] if (cloneData && cloneData.subgraphNewIDMapping != null) { // At this point we know options.prevNodeID points to a subgraph. const mapping = cloneData.subgraphNewIDMapping as GraphIDMapping // This mapping is two-way, so oldID -> newID *and* newID -> oldID are supported. // Take the cloned node's ID and look up what the original node's ID was. const nodeIDInLayoutState = mapping.nodeIDs[node.id]; if (nodeIDInLayoutState) { // Gottem. prevWidget = state.allItemsByNode[nodeIDInLayoutState] // console.warn("FOUND CLONED SUBGRAPH NODE", node.id, "=>", nodeIDInLayoutState, prevWidget) } } } if (prevWidget) { console.warn("FOUND", prevWidget.dragItem.attrs) // XXX: Will this work for most properties? attrs = structuredClone(prevWidget.dragItem.attrs) } } const parent = findDefaultContainerForInsertion(); console.debug("[layoutState] nodeAdded", node.id) addWidget(parent, node, attrs); } function nodeRemoved(node: LGraphNode, options: LGraphRemoveNodeOptions) { if (options.removedBy === "moveIntoSubgraph" || options.removedBy === "moveOutOfSubgraph") { // This node is being moved into a subgraph, so it will be readded under // a new node ID shortly. return } const state = get(store) console.debug("[layoutState] nodeRemoved", node) let del = Object.entries(state.allItems).filter(pair => pair[1].dragItem.type === "widget" && (pair[1].dragItem as WidgetLayout).node.id === node.id) for (const pair of del) { const [id, dragItem] = pair; removeEntry(state, id) } store.set(state) } /* * NOTE: Modifies the template in-place, be sure you cloned it beforehand! */ function insertTemplate(template: SerializedComfyBoxTemplate, graph: LGraph, templateNodeIDToNode: Record, container: ContainerLayout, childIndex: number): IDragItem { const idMapping: Record = {}; const getDragItemID = (id: DragItemID): DragItemID => { idMapping[id] ||= uuidv4(); return idMapping[id]; } // Ensure all IDs are unique, and rewrite node IDs in widgets to point // to newly created nodes for (const [id, entry] of Object.entries(template.layout.allItems)) { const newId = getDragItemID(id); template.layout.allItems[newId] = entry; entry.dragItem.id = newId; if (entry.parent) entry.parent = getDragItemID(entry.parent) entry.children = entry.children.map(getDragItemID); if (entry.dragItem.type === "widget") { entry.dragItem.nodeId = templateNodeIDToNode[entry.dragItem.nodeId].id; } } if (template.layout.root) { template.layout.root = getDragItemID(template.layout.root) // make sure the new root doesn't have a parent since that parent // was detached from the serialized layout and won't be found in // template.layout.allItems template.layout.allItems[template.layout.root].parent = null; } const raw = deserializeRaw(template.layout, graph); // merge the template's detached layout tree into this layout store.update(s => { s.allItems = { ...s.allItems, ...raw.allItems } s.allItemsByNode = { ...s.allItemsByNode, ...raw.allItemsByNode } return s; }) moveItem(raw.root, container, childIndex); return raw.root } function moveItem(target: IDragItem, to: ContainerLayout, index?: number) { const state = get(store) const entry = state.allItems[target.id] if (!entry || (entry.parent && entry.parent.id === to.id && entry.children.indexOf(target) === index)) return; if (entry.parent) { const parentEntry = state.allItems[entry.parent.id]; const parentIndex = parentEntry.children.findIndex(c => c.id === target.id) if (parentIndex !== -1) { parentEntry.children.splice(parentIndex, 1) } else { console.error(parentEntry) console.error(target) throw "Child not found in parent!" } } const toEntry = state.allItems[to.id]; if (index != null && index >= 0) toEntry.children.splice(index, 0, target) else toEntry.children.push(target) state.allItems[target.id].parent = toEntry.dragItem; console.debug("[layoutState] Move child", target, toEntry, index) notifyWorkflowModified(); store.set(state) } function groupItems(dragItemIDs: DragItemID[], attrs: Partial = {}): ContainerLayout { if (dragItemIDs.length === 0) return; const state = get(store) const parent = state.allItems[dragItemIDs[0]].parent || findDefaultContainerForInsertion(); if (parent === null || parent.type !== "container") return; let index = undefined; if (parent) { const indexFound = state.allItems[parent.id].children.findIndex(c => c.id === dragItemIDs[0]) if (indexFound !== -1) index = indexFound } const container = addContainer(parent as ContainerLayout, { title: "", containerVariant: "block", ...attrs }, index) for (const itemID of dragItemIDs) { const item = state.allItems[itemID].dragItem; moveItem(item, container) } console.debug("[layoutState] Grouped", container, parent, state.allItems[container.id].children, index) notifyWorkflowModified(); store.set(state) return container } function ungroup(container: ContainerLayout) { const state = get(store) const parent = state.allItems[container.id].parent; if (!parent || parent.type !== "container") { console.warn("No parent to ungroup into!", container) return; } let index = undefined; const parentChildren = state.allItems[parent.id].children; const indexFound = parentChildren.findIndex(c => c.id === container.id) if (indexFound !== -1) index = indexFound const containerEntry = state.allItems[container.id] console.debug("[layoutState] About to ungroup", containerEntry, parent, parentChildren, index) const children = [...containerEntry.children] for (const item of children) { moveItem(item, parent as ContainerLayout, index) } removeEntry(state, container.id) console.debug("[layoutState] Ungrouped", containerEntry, parent, parentChildren, index) store.set(state) } function findLayoutEntryForNode(nodeId: NodeID): DragItemEntry | null { const state = get(store) const found = Object.entries(state.allItems).find(pair => pair[1].dragItem.type === "widget" && (pair[1].dragItem as WidgetLayout).node.id === nodeId) if (found) return found[1] return null; } function findLayoutForNode(nodeId: NodeID): WidgetLayout | null { const found = findLayoutEntryForNode(nodeId); if (!found) return null; return found.dragItem as WidgetLayout } function* iterateBreadthFirst(id?: DragItemID | null): Iterable { const state = get(store); id ||= state.root?.id; if (id == null) return; const queue = [state.allItems[id]]; while (queue.length > 0) { const node = queue.shift(); yield node; if (node.children) { for (const child of node.children) { queue.push(state.allItems[child.id]); } } } } function initDefaultLayout(): DefaultLayout { store.set({ root: null, allItems: {}, allItemsByNode: {}, isMenuOpen: false, isConfiguring: true, }) const root = addContainer(null, { direction: "horizontal", title: "" }); const left = addContainer(root, { direction: "vertical", title: "" }); const right = addContainer(root, { direction: "vertical", title: "" }); store.update(s => { s.root = root; s.isConfiguring = false; return s; }) console.debug("[layoutState] initDefault") return { root, left, right } } function serializeAtRoot(rootID: DragItemID): SerializedLayoutState { const state = get(store); if (!state.allItems[rootID]) throw "Root not contained in layout!" const allItems: Record = {} const queue = [state.allItems[rootID]] while (queue.length > 0) { const entry = queue.shift(); if (entry.dragItem.type === "template") { // If this happens then there's a bug somewhere console.error("[layoutState] Found template drag item in current layout, skipping!") continue; } allItems[entry.dragItem.id] = { dragItem: { type: entry.dragItem.type, id: entry.dragItem.id, nodeId: (entry.dragItem as any).node?.id, attrs: entry.dragItem.attrs }, children: entry.children.map((di) => di.id), parent: entry.parent?.id } if (entry.children) { for (const child of entry.children) { queue.push(state.allItems[child.id]); } } } return { root: rootID, allItems } } function serialize(): SerializedLayoutState { const state = get(store) const allItems: Record = {} for (const pair of Object.entries(state.allItems)) { const [id, entry] = pair; if (entry.dragItem.type === "template") { // If this happens then there's a bug somewhere console.error("[layoutState] Found template drag item in current layout, skipping!") continue; } allItems[id] = { dragItem: { type: entry.dragItem.type, id: entry.dragItem.id, nodeId: (entry.dragItem as any).node?.id, attrs: entry.dragItem.attrs }, children: entry.children.map((di) => di.id), parent: entry.parent?.id } } return { root: state.root?.id, allItems, } } function deserializeRaw(data: SerializedLayoutState, graph: LGraph): LayoutState { const allItems: Record = {} const allItemsByNode: Record = {} for (const pair of Object.entries(data.allItems)) { const [id, entry] = pair; if (entry.dragItem.type === "template") { // If this happens then there's a bug somewhere console.error("[layoutState] Found template drag item in serialized layout, skipping!") continue; } const dragItem: IDragItem = { type: entry.dragItem.type, id: entry.dragItem.id, attrs: { ...defaultWidgetAttributes, ...entry.dragItem.attrs }, attrsChanged: writable(0) }; const dragEntry: DragItemEntry = { dragItem, children: [], parent: null } allItems[id] = dragEntry if (dragItem.type === "widget") { const widget = dragItem as WidgetLayout; widget.node = graph.getNodeByIdRecursive(entry.dragItem.nodeId) as ComfyWidgetNode if (widget.node == null) throw (`Node in litegraph not found! ${entry.dragItem.nodeId}`) allItemsByNode[entry.dragItem.nodeId] = dragEntry } } // reconnect parent/child tree for (const pair of Object.entries(data.allItems)) { const [id, entry] = pair; for (const childId of entry.children) { allItems[id].children.push(allItems[childId].dragItem) } if (entry.parent) { allItems[id].parent = allItems[entry.parent].dragItem; } } let root: IDragItem = null; if (data.root) root = allItems[data.root].dragItem const state: LayoutState = { root, allItems, allItemsByNode, isMenuOpen: false, isConfiguring: false, } return state } function deserialize(data: SerializedLayoutState, graph: LGraph) { const state = deserializeRaw(data, graph); console.debug("[layoutState] deserialize", data, state, defaultWorkflowAttributes) store.set(state) // Ensure properties panel is updated with new state layoutStates.update(s => { s.refreshPropsPanel += 1; return s; }) } function onStartConfigure() { store.update(s => { s.isConfiguring = true; return s }) } function notifyWorkflowModified() { if (!get(store).isConfiguring) workflow?.notifyModified(); } const layoutStateStore: WritableLayoutStateStore = { ...store, workflow, addContainer, addWidget, findDefaultContainerForInsertion, updateChildren, nodeAdded, nodeRemoved, insertTemplate, moveItem, groupItems, findLayoutEntryForNode, findLayoutForNode, iterateBreadthFirst, ungroup, initDefaultLayout, onStartConfigure, serialize, serializeAtRoot, deserialize, notifyWorkflowModified } return layoutStateStore } function create(workflow: ComfyBoxWorkflow): WritableLayoutStateStore { if (get(layoutStates).all[workflow.id] != null) { throw new Error(`Layout state already created! ${id}`) } const layoutStateStore = createRaw(workflow); layoutStates.update(s => { s.all[workflow.id] = layoutStateStore; return s; }) return layoutStateStore } function remove(workflowID: WorkflowInstID) { const state = get(layoutStates) if (state.all[workflowID] == null) throw new Error(`No workflow with ID registered! ${workflowID}`) delete state.all[workflowID]; } function getLayout(workflowID: WorkflowInstID): WritableLayoutStateStore | null { return get(layoutStates).all[workflowID] } function getLayoutByGraph(graph: LGraph): WritableLayoutStateStore | null { if ("workflowID" in graph && graph.workflowID != null) { return get(layoutStates).all[(graph as ComfyGraph).workflowID] } return null; } function getLayoutByNode(node: LGraphNode): WritableLayoutStateStore | null { const rootGraph = node.getRootGraph(); if (rootGraph == null) return null; return getLayoutByGraph(rootGraph); } function getLayoutByDragItemID(dragItemID: DragItemID): WritableLayoutStateStore | null { return Object.values(get(layoutStates).all).find(l => get(l).allItems[dragItemID] != null) } function getDragItemByNode(node: LGraphNode): IDragItem | null { const layout = getLayoutByNode(node); if (layout == null) return null; return layout.findLayoutForNode(node.id); } export type LayoutStateStores = { /* * Layouts associated with opened workflows */ all: Record, /* * Increment to force Svelte to re-render the props panel */ refreshPropsPanel: number } export type LayoutStateStoresOps = { create: (workflow: ComfyBoxWorkflow) => WritableLayoutStateStore, createRaw: (workflow?: ComfyBoxWorkflow | null) => WritableLayoutStateStore, remove: (workflowID: WorkflowInstID) => void, getLayout: (workflowID: WorkflowInstID) => WritableLayoutStateStore | null, getLayoutByGraph: (graph: LGraph) => WritableLayoutStateStore | null, getLayoutByNode: (node: LGraphNode) => WritableLayoutStateStore | null, getLayoutByDragItemID: (dragItemID: DragItemID) => WritableLayoutStateStore | null, getDragItemByNode: (node: LGraphNode) => IDragItem | null, } export type WritableLayoutStateStores = Writable & LayoutStateStoresOps; const store = writable({ all: {}, refreshPropsPanel: 0 }) const layoutStates: WritableLayoutStateStores = { ...store, create, createRaw, remove, getLayout, getLayoutByGraph, getLayoutByNode, getLayoutByDragItemID, getDragItemByNode } export default layoutStates;