Template saving/loading

This commit is contained in:
space-nuko
2023-05-24 21:00:48 -05:00
parent 4dfa665303
commit 4ae4e71616
20 changed files with 457 additions and 120 deletions

View File

@@ -91,6 +91,7 @@
"style-mod": "^4.0.3", "style-mod": "^4.0.3",
"svelte-bootstrap-icons": "^2.3.1", "svelte-bootstrap-icons": "^2.3.1",
"svelte-feather-icons": "^4.0.0", "svelte-feather-icons": "^4.0.0",
"svelte-floating-ui": "^1.5.2",
"svelte-preprocess": "^5.0.3", "svelte-preprocess": "^5.0.3",
"svelte-select": "^5.5.3", "svelte-select": "^5.5.3",
"svelte-splitpanes": "^0.7.13", "svelte-splitpanes": "^0.7.13",

10
pnpm-lock.yaml generated
View File

@@ -151,6 +151,9 @@ importers:
svelte-feather-icons: svelte-feather-icons:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0 version: 4.0.0
svelte-floating-ui:
specifier: ^1.5.2
version: 1.5.2
svelte-preprocess: svelte-preprocess:
specifier: ^5.0.3 specifier: ^5.0.3
version: 5.0.3(sass@1.61.0)(svelte@3.58.0)(typescript@5.0.3) version: 5.0.3(sass@1.61.0)(svelte@3.58.0)(typescript@5.0.3)
@@ -8135,6 +8138,13 @@ packages:
'@floating-ui/dom': 1.2.8 '@floating-ui/dom': 1.2.8
dev: false dev: false
/svelte-floating-ui@1.5.2:
resolution: {integrity: sha512-nV50eno74CEsFfFJ6iyN/oNYDEOck1TZjGV3lmJksVRbiiUAVF6bHspyAhR7GZ7c/4qbRWp9UyX24J+UXdEpag==}
dependencies:
'@floating-ui/core': 1.2.6
'@floating-ui/dom': 1.2.8
dev: false
/svelte-hmr@0.15.1(svelte@3.58.0): /svelte-hmr@0.15.1(svelte@3.58.0):
resolution: {integrity: sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==} resolution: {integrity: sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==}
engines: {node: ^12.20 || ^14.13.1 || >= 16} engines: {node: ^12.20 || ^14.13.1 || >= 16}

BIN
public/image/graph-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

View File

@@ -40,6 +40,7 @@ export type SerializedComfyBoxTemplate = {
isComfyBoxTemplate: true, isComfyBoxTemplate: true,
version: 1, version: 1,
id: UUID, id: UUID,
commitHash: string,
/* /*
* Serialized metadata * Serialized metadata
@@ -182,6 +183,7 @@ function renderSvg(canvas: ComfyGraphCanvas, graph: LGraph, padding: number, ext
const offset = canvas.ds.offset; const offset = canvas.ds.offset;
const show_info = canvas.show_info; const show_info = canvas.show_info;
const background_image = canvas.background_image; const background_image = canvas.background_image;
const clear_background = canvas.clear_background;
const render_canvas_border = canvas.render_canvas_border; const render_canvas_border = canvas.render_canvas_border;
const render_subgraph_panels = canvas.render_subgraph_panels const render_subgraph_panels = canvas.render_subgraph_panels
const render_subgraph_stack_header = canvas.render_subgraph_stack_header const render_subgraph_stack_header = canvas.render_subgraph_stack_header
@@ -189,6 +191,7 @@ function renderSvg(canvas: ComfyGraphCanvas, graph: LGraph, padding: number, ext
canvas.openSubgraph(graph) canvas.openSubgraph(graph)
canvas.show_info = false; canvas.show_info = false;
canvas.background_image = null; canvas.background_image = null;
canvas.clear_background = false;
canvas.render_canvas_border = false; canvas.render_canvas_border = false;
canvas.render_subgraph_panels = false; canvas.render_subgraph_panels = false;
canvas.render_subgraph_stack_header = false; canvas.render_subgraph_stack_header = false;
@@ -233,12 +236,10 @@ function renderSvg(canvas: ComfyGraphCanvas, graph: LGraph, padding: number, ext
canvas.ds.offset = [-bounds[0], -bounds[1]]; canvas.ds.offset = [-bounds[0], -bounds[1]];
canvas.ctx = svgCtx; canvas.ctx = svgCtx;
let saving = false;
// Trigger saving // Trigger saving
saving = true; canvas.isExportingSVG = true;
canvas.draw(true, true); canvas.draw(true, true);
saving = false; canvas.isExportingSVG = false;
// Restore original settings // Restore original settings
canvas.closeSubgraph(); canvas.closeSubgraph();
@@ -248,6 +249,7 @@ function renderSvg(canvas: ComfyGraphCanvas, graph: LGraph, padding: number, ext
canvas.ds.offset = offset; canvas.ds.offset = offset;
canvas.ctx = ctx; canvas.ctx = ctx;
canvas.show_info = show_info; canvas.show_info = show_info;
canvas.clear_background = clear_background;
canvas.background_image = background_image; canvas.background_image = background_image;
canvas.render_canvas_border = render_canvas_border; canvas.render_canvas_border = render_canvas_border;
canvas.render_subgraph_panels = render_subgraph_panels; canvas.render_subgraph_panels = render_subgraph_panels;
@@ -323,6 +325,7 @@ export function serializeTemplate(canvas: ComfyGraphCanvas, template: ComfyBoxTe
const serTemplate: SerializedComfyBoxTemplate = { const serTemplate: SerializedComfyBoxTemplate = {
isComfyBoxTemplate: true, isComfyBoxTemplate: true,
version: 1, version: 1,
commitHash: __GIT_COMMIT_HASH__,
id: template.id, id: template.id,
metadata, metadata,
nodes, nodes,

View File

@@ -1,4 +1,4 @@
import { LConnectionKind, LGraph, LGraphNode, type INodeSlot, type SlotIndex, LiteGraph, getStaticProperty, type LGraphAddNodeOptions, LGraphCanvas, type LGraphRemoveNodeOptions, Subgraph, type LGraphAddNodeMode, type SerializedLGraphNode, type Vector2 } from "@litegraph-ts/core"; import { LConnectionKind, LGraph, LGraphNode, type INodeSlot, type SlotIndex, LiteGraph, getStaticProperty, type LGraphAddNodeOptions, LGraphCanvas, type LGraphRemoveNodeOptions, Subgraph, type LGraphAddNodeMode, type SerializedLGraphNode, type Vector2, type NodeID, reassignGraphIDs, type GraphIDMapping, type SerializedLGraph } from "@litegraph-ts/core";
import GraphSync from "./GraphSync"; import GraphSync from "./GraphSync";
import EventEmitter from "events"; import EventEmitter from "events";
import type TypedEmitter from "typed-emitter"; import type TypedEmitter from "typed-emitter";
@@ -14,6 +14,31 @@ import layoutStates from "./stores/layoutStates";
import type { ComfyBoxWorkflow, WorkflowInstID } from "./stores/workflowState"; import type { ComfyBoxWorkflow, WorkflowInstID } from "./stores/workflowState";
import workflowState from "./stores/workflowState"; import workflowState from "./stores/workflowState";
import type { SerializedComfyBoxTemplate } from "./ComfyBoxTemplate"; import type { SerializedComfyBoxTemplate } from "./ComfyBoxTemplate";
import { v4 as uuidv4 } from "uuid"
function calculateMinPosOfNodes(nodes: SerializedLGraphNode[]): Vector2 {
var posMin: Vector2 = [0, 0]
var posMinIndexes: [number, number] | null = null;
for (var i = 0; i < nodes.length; ++i) {
if (posMin) {
if (posMin[0] > nodes[i].pos[0]) {
posMin[0] = nodes[i].pos[0];
posMinIndexes[0] = i;
}
if (posMin[1] > nodes[i].pos[1]) {
posMin[1] = nodes[i].pos[1];
posMinIndexes[1] = i;
}
}
else {
posMin = [nodes[i].pos[0], nodes[i].pos[1]];
posMinIndexes = [i, i];
}
}
return posMin;
}
type ComfyGraphEvents = { type ComfyGraphEvents = {
configured: (graph: LGraph) => void configured: (graph: LGraph) => void
@@ -38,6 +63,10 @@ export default class ComfyGraph extends LGraph {
return workflowState.getWorkflow(workflowID) return workflowState.getWorkflow(workflowID)
} }
get layout(): WritableLayoutStateStore | null {
return this.workflow?.layout;
}
constructor(workflowID?: WorkflowInstID) { constructor(workflowID?: WorkflowInstID) {
super(); super();
this.workflowID = workflowID; this.workflowID = workflowID;
@@ -218,4 +247,62 @@ export default class ComfyGraph extends LGraph {
// console.debug("ConnectionChange", node); // console.debug("ConnectionChange", node);
this.eventBus.emit("nodeConnectionChanged", kind, node, slot, targetNode, targetSlot); this.eventBus.emit("nodeConnectionChanged", kind, node, slot, targetNode, targetSlot);
} }
/*
* Inserts a template.
* Layout deserialization must be handled afterwards!
*/
insertTemplate(template: SerializedComfyBoxTemplate, pos: Vector2): Record<NodeID, LGraphNode> {
const minPos = calculateMinPosOfNodes(template.nodes);
const templateNodeIDToNewNode: Record<NodeID, LGraphNode> = {}
var nodes = [];
for (var i = 0; i < template.nodes.length; ++i) {
var node_data = template.nodes[i];
var node = LiteGraph.createNode(node_data.type);
let mapping: GraphIDMapping = null;
if (node_data.type === "graph/subgraph") {
mapping = reassignGraphIDs((node_data as any).subgraph as SerializedLGraph);
}
if (node) {
const prevNodeId = node_data.id;
node_data.id = uuidv4();
templateNodeIDToNewNode[prevNodeId] = node
node.configure(node_data);
if (mapping) {
for (const subnode of (node as Subgraph).subgraph.iterateNodesInOrderRecursive()) {
const oldNodeID = mapping.nodeIDs[subnode.id];
templateNodeIDToNewNode[oldNodeID] = subnode;
}
}
node.pos[0] += pos[0] - minPos[0]; //+= 5;
node.pos[1] += pos[1] - minPos[1]; //+= 5;
this.add(node, { doProcessChange: false, addedBy: "template" as any });
nodes.push(node);
}
}
//create links
for (var i = 0; i < template.links.length; ++i) {
var link_info = template.links[i];
var origin_node = templateNodeIDToNewNode[link_info[0]];
var target_node = templateNodeIDToNewNode[link_info[2]];
if (origin_node && target_node)
origin_node.connect(link_info[1], target_node, link_info[3]);
else
console.error("[ComfyGraphCanvas] nodes missing on template insertion!", link_info);
}
this.afterChange();
return templateNodeIDToNewNode;
}
} }

View File

@@ -3,47 +3,22 @@ import { get, type Unsubscriber } from "svelte/store";
import type ComfyGraph from "./ComfyGraph"; import type ComfyGraph from "./ComfyGraph";
import type ComfyApp from "./components/ComfyApp"; import type ComfyApp from "./components/ComfyApp";
import { ComfyReroute } from "./nodes"; import { ComfyReroute } from "./nodes";
import layoutStates from "./stores/layoutStates"; import layoutStates, { type ContainerLayout } from "./stores/layoutStates";
import queueState from "./stores/queueState"; import queueState from "./stores/queueState";
import selectionState from "./stores/selectionState"; import selectionState from "./stores/selectionState";
import templateState from "./stores/templateState"; import templateState from "./stores/templateState";
import { createTemplate, type ComfyBoxTemplate, serializeTemplate, type SerializedComfyBoxTemplate } from "./ComfyBoxTemplate"; import { createTemplate, type ComfyBoxTemplate, serializeTemplate, type SerializedComfyBoxTemplate } from "./ComfyBoxTemplate";
import notify from "./notify"; import notify from "./notify";
import { v4 as uuidv4 } from "uuid"
import { download } from "./utils";
export type SerializedGraphCanvasState = { export type SerializedGraphCanvasState = {
offset: Vector2, offset: Vector2,
scale: number scale: number
} }
function getMinPos(nodes: SerializedLGraphNode[]): Vector2 {
var posMin: Vector2 = [0, 0]
var posMinIndexes: [number, number] | null = null;
for (var i = 0; i < nodes.length; ++i) {
if (posMin) {
if (posMin[0] > nodes[i].pos[0]) {
posMin[0] = nodes[i].pos[0];
posMinIndexes[0] = i;
}
if (posMin[1] > nodes[i].pos[1]) {
posMin[1] = nodes[i].pos[1];
posMinIndexes[1] = i;
}
}
else {
posMin = [nodes[i].pos[0], nodes[i].pos[1]];
posMinIndexes = [i, i];
}
}
return posMin;
}
export default class ComfyGraphCanvas extends LGraphCanvas { export default class ComfyGraphCanvas extends LGraphCanvas {
app: ComfyApp | null; app: ComfyApp | null;
private _unsubscribe: Unsubscriber; private _unsubscribe: Unsubscriber;
isExportingSVG: boolean = false;
get comfyGraph(): ComfyGraph | null { get comfyGraph(): ComfyGraph | null {
return this.graph as ComfyGraph; return this.graph as ComfyGraph;
@@ -470,45 +445,21 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
* Inserts a ComfyBox template. Logic is similar to pasting from the * Inserts a ComfyBox template. Logic is similar to pasting from the
* clipboard in vanilla litegraph. * clipboard in vanilla litegraph.
*/ */
insertTemplate(template: SerializedComfyBoxTemplate, pos: Vector2) { insertTemplate(template: SerializedComfyBoxTemplate, pos: Vector2, container: ContainerLayout, containerIndex: number): [LGraphNode[], IDragItem] {
const minPos = getMinPos(template.nodes); const comfyGraph = this.graph as ComfyGraph;
const templateNodeIDToNewNode: Record<NodeID, LGraphNode> = {} const layout = comfyGraph.layout;
if (layout == null) {
var nodes = []; console.error("[ComfyGraphCanvas] graph has no layout!", comfyGraph)
for (var i = 0; i < template.nodes.length; ++i) { return;
var node_data = template.nodes[i];
var node = LiteGraph.createNode(node_data.type);
if (node) {
const prevNodeId = node_data.id;
node_data.id = uuidv4();
templateNodeIDToNewNode[prevNodeId] = node
node.configure(node_data);
node.pos[0] += pos[0] - minPos[0]; //+= 5;
node.pos[1] += pos[1] - minPos[1]; //+= 5;
this.graph.add(node, { doProcessChange: false, addedBy: "template" as any });
nodes.push(node);
}
} }
//create links const nodeMapping = comfyGraph.insertTemplate(template, pos);
for (var i = 0; i < template.links.length; ++i) { const templateLayoutRoot = layout.insertTemplate(template, comfyGraph, nodeMapping, container, containerIndex);
var link_info = template.links[i];
var origin_node = templateNodeIDToNewNode[link_info[0]];
var target_node = templateNodeIDToNewNode[link_info[2]];
if (origin_node && target_node)
origin_node.connect(link_info[1], target_node, link_info[3]);
else
console.error("[ComfyGraphCanvas] nodes missing on template insertion!", link_info);
}
this.selectNodes(nodes); this.selectNodes(Object.values(nodeMapping).filter(n => n.graph === this.graph));
this.graph.afterChange(); return [Object.values(nodeMapping), templateLayoutRoot]
} }
saveAsTemplate(_value: IContextMenuItem, _options, mouseEvent, prevMenu, node?: LGraphNode) { saveAsTemplate(_value: IContextMenuItem, _options, mouseEvent, prevMenu, node?: LGraphNode) {
@@ -593,4 +544,17 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
return options return options
} }
override onRenderBackground(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): boolean {
if (this.isExportingSVG) {
ctx.clearRect(
this.visible_area[0],
this.visible_area[1],
this.visible_area[2],
this.visible_area[3]
);
return true;
}
return false;
}
} }

View File

@@ -1013,7 +1013,8 @@ export default class ComfyApp {
closeOnClick: false, closeOnClick: false,
showCloseButton: false, showCloseButton: false,
svelteProps: { svelteProps: {
templateAndSvg templateAndSvg,
editable: false
}, },
buttons: [ buttons: [
{ {

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { SerializedComfyBoxTemplate } from "$lib/ComfyBoxTemplate"; import { embedTemplateInSvg, type SerializedComfyBoxTemplate } from "$lib/ComfyBoxTemplate";
import templateState from "$lib/stores/templateState"; import templateState from "$lib/stores/templateState";
import uiState from "$lib/stores/uiState"; import uiState from "$lib/stores/uiState";
import { truncateString } from "$lib/utils"; import { download, truncateString } from "$lib/utils";
import type ComfyApp from "./ComfyApp"; import type ComfyApp from "./ComfyApp";
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import {fade} from 'svelte/transition'; import {fade} from 'svelte/transition';
@@ -10,7 +10,10 @@
import { dndzone, TRIGGERS, SHADOW_PLACEHOLDER_ITEM_ID, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action'; import { dndzone, TRIGGERS, SHADOW_PLACEHOLDER_ITEM_ID, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
import { defaultWidgetAttributes, type TemplateLayout } from "$lib/stores/layoutStates"; import { defaultWidgetAttributes, type TemplateLayout } from "$lib/stores/layoutStates";
import { v4 as uuidv4 } from "uuid" import { v4 as uuidv4 } from "uuid"
import { writable } from "svelte/store"; import { get, writable } from "svelte/store";
import EditTemplateModal from "./modal/EditTemplateModal.svelte";
import modalState, { type ModalData } from "$lib/stores/modalState";
import notify from "$lib/notify";
export let app: ComfyApp export let app: ComfyApp
@@ -71,6 +74,61 @@
shouldIgnoreDndEvents = false; shouldIgnoreDndEvents = false;
} }
} }
function handleClick(layout: TemplateLayout) {
const updateTemplate = (modal: ModalData) => {
const state = get(modal.state);
layout.template.metadata.title = state.name || layout.template.metadata.title
layout.template.metadata.author = state.author || layout.template.metadata.author
layout.template.metadata.description = state.description || layout.template.metadata.description
}
const saveTemplate = (modal: ModalData) => {
updateTemplate(modal);
try {
templateState.update(layout.template);
notify("Saved template!", { type: "success" })
}
catch (error) {
notify(`Failed to save template: ${error}`, { type: "error", timeout: 10000 })
}
}
const downloadTemplate = (modal: ModalData) => {
updateTemplate(modal);
const svg = embedTemplateInSvg(layout.template);
const title = layout.template.metadata.title || "template";
download(`${title}.svg`, svg, "image/svg+xml");
}
modalState.pushModal({
svelteComponent: EditTemplateModal,
svelteProps: {
templateAndSvg: layout.template
},
showCloseButton: false,
closeOnClick: false,
buttons: [
{
name: "Save",
variant: "primary",
onClick: saveTemplate
},
{
name: "Download",
variant: "secondary",
onClick: downloadTemplate,
closeOnClick: false
},
{
name: "Close",
variant: "secondary",
onClick: () => {
}
},
]
})
}
</script> </script>
<div class="template-list"> <div class="template-list">
@@ -92,9 +150,10 @@
on:consider={handleDndConsider} on:consider={handleDndConsider}
on:finalize={handleDndFinalize}> on:finalize={handleDndFinalize}>
{#each _sorted.filter(i => i.id !== SHADOW_PLACEHOLDER_ITEM_ID) as item(item.id)} {#each _sorted.filter(i => i.id !== SHADOW_PLACEHOLDER_ITEM_ID) as item(item.id)}
<div class="template-entry" class:draggable> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="template-name">{truncateString(item.template.metadata.title, 16)}</div> <div class="template-entry" class:draggable on:click={() => handleClick(item)}>
<div class="template-desc">{truncateString(item.template.metadata.description, 24)}</div> <div class="template-name">{item.template.metadata.title}</div>
<div class="template-desc">{item.template.metadata.description}</div>
</div> </div>
{#if item[SHADOW_ITEM_MARKER_PROPERTY_NAME]} {#if item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
<div in:fade={{duration:200, easing: cubicIn}} class='template-drag-item-shadow'/> <div in:fade={{duration:200, easing: cubicIn}} class='template-drag-item-shadow'/>
@@ -140,6 +199,9 @@
background: var(--panel-background-fill); background: var(--panel-background-fill);
max-height: 14rem; max-height: 14rem;
position: relative; position: relative;
user-select: none;
text-overflow: ellipsis;
overflow: hidden;
font-size: 13pt; font-size: 13pt;
.template-desc { .template-desc {
@@ -147,11 +209,19 @@
font-size: 11pt; font-size: 11pt;
} }
&.draggable {
border: 5px dashed var(--secondary-500);
margin: 0.2em;
}
&:hover:not(:has(img:hover)):not(:has(button:hover)) { &:hover:not(:has(img:hover)):not(:has(button:hover)) {
cursor: pointer;
background: var(--block-background-fill);
&.draggable { &.draggable {
cursor: grab; cursor: grab;
background: var(--secondary-700);
} }
background: var(--block-background-fill);
&.running { &.running {
background: var(--comfy-accent-soft); background: var(--comfy-accent-soft);

View File

@@ -29,9 +29,11 @@
{/if} {/if}
</div> </div>
<svelte:fragment> <svelte:fragment>
{#if modal != null && modal.svelteComponent != null} <div class="modal-body">
<svelte:component this={modal.svelteComponent} {...modal.svelteProps} _modal={modal}/> {#if modal != null && modal.svelteComponent != null}
{/if} <svelte:component this={modal.svelteComponent} {...modal.svelteProps} _modal={modal}/>
{/if}
</div>
</svelte:fragment> </svelte:fragment>
<div slot="buttons" class="buttons" let:closeDialog> <div slot="buttons" class="buttons" let:closeDialog>
{#if modal != null && modal.buttons?.length > 0} {#if modal != null && modal.buttons?.length > 0}
@@ -52,6 +54,12 @@
<style lang="scss"> <style lang="scss">
.buttons { .buttons {
gap: var(--spacing-sm); display: flex;
flex-direction: row;
gap: var(--spacing-md);
}
.modal-body {
overflow: auto;
} }
</style> </style>

View File

@@ -94,9 +94,6 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
padding-top: 0.5em; padding-top: 0.5em;
} gap: var(--spacing-md);
.button-row, .buttons {
gap: var(--spacing-sm);
} }
</style> </style>

View File

@@ -1,35 +1,73 @@
<script> <script lang="ts">
import { setContext, createEventDispatcher } from 'svelte'; import { setContext, createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition'; import { key } from './menu.ts';
import { key } from './menu.ts';
export let x; import { offset, flip, shift } from "svelte-floating-ui/dom";
export let y; import { createFloatingActions, type ClientRectObject, type VirtualElement } from "svelte-floating-ui";
import { writable, type Writable } from 'svelte/store';
// whenever x and y is changed, restrict box to be within bounds const [floatingRef, floatingContent] = createFloatingActions({
$: (() => { placement: "right-start",
if (!menuEl) return; strategy: "fixed",
middleware: [
offset({ mainAxis: 5, alignmentAxis: 4 }),
flip({
fallbackPlacements: ["left-start"]
}),
shift({ padding: 10 })
],
});
const rect = menuEl.getBoundingClientRect();
x = Math.min(window.innerWidth - rect.width, x);
if (y > window.innerHeight - rect.height) y -= rect.height;
})(x, y);
const dispatch = createEventDispatcher(); export let x;
export let y;
setContext(key, { // whenever x and y is changed, restrict box to be within bounds
dispatchClick: () => dispatch('click') $: (() => {
}); if (!menuEl) return;
let menuEl; const rect = menuEl.getBoundingClientRect();
function onPageClick(e) { x = Math.min(window.innerWidth - rect.width, x);
if (e.target === menuEl || menuEl.contains(e.target)) return; if (y > window.innerHeight - rect.height) y -= rect.height;
dispatch('clickoutside'); })();
}
const dispatch = createEventDispatcher();
setContext(key, {
dispatchClick: () => dispatch('click')
});
let menuEl;
function onPageClick(e) {
if (e.target === menuEl || menuEl.contains(e.target)) return;
dispatch('clickoutside');
}
let getBoundingClientRect: () => ClientRectObject;
$: getBoundingClientRect = (): ClientRectObject => {
return {
x,
y,
top: y,
left: x,
bottom: y,
right: x,
width: 0,
height: 0
}
}
const virtualElement: Writable<VirtualElement> = writable({ getBoundingClientRect })
$: virtualElement.set({ getBoundingClientRect })
floatingRef(virtualElement)
</script> </script>
<svelte:body on:click={onPageClick} /> <svelte:body on:click={onPageClick} />
<div class="menu" bind:this={menuEl} style="top: {y}px; left: {x}px;"> <div class="menu" bind:this={menuEl} style="top: {y}px; left: {x}px;" use:floatingContent>
<slot /> <slot />
</div> </div>

View File

@@ -8,16 +8,31 @@
import Column from "../gradio/app/Column.svelte"; import Column from "../gradio/app/Column.svelte";
import Accordion from "../gradio/app/Accordion.svelte"; import Accordion from "../gradio/app/Accordion.svelte";
import Textbox from "@gradio/form/src/Textbox.svelte"; import Textbox from "@gradio/form/src/Textbox.svelte";
import type { ModalData } from "$lib/stores/modalState";
import { writable, type Writable } from "svelte/store";
const DOMPurify = createDOMPurify(window); const DOMPurify = createDOMPurify(window);
export let templateAndSvg: SerializedComfyBoxTemplate; export let templateAndSvg: SerializedComfyBoxTemplate;
export let editable: boolean = true;
export let _modal: ModalData;
let layout: SerializedLayoutState | null let layout: SerializedLayoutState | null
let root: SerializedDragEntry | null let root: SerializedDragEntry | null
let state: Writable<any> = writable({})
$: {
state = _modal.state;
if (!("name" in $state)) {
$state.name = templateAndSvg.metadata.title;
$state.author = templateAndSvg.metadata.author;
$state.description = templateAndSvg.metadata.description;
}
}
let saneSvg: string = ""; let saneSvg: string = "";
$: saneSvg = templateAndSvg $: saneSvg = templateAndSvg
? DOMPurify.sanitize(templateAndSvg.svg, { USE_PROFILES: { svg: true, svgFilters: true } }) ? DOMPurify.sanitize(templateAndSvg.svg, { USE_PROFILES: { svg: true, svgFilters: true } })
.replace("<svg", "<svg style='background: url(\"image/graph-bg.png\")'")
: ""; : "";
$: if (templateAndSvg) { $: if (templateAndSvg) {
@@ -42,9 +57,9 @@
<Block> <Block>
<BlockTitle>Metadata</BlockTitle> <BlockTitle>Metadata</BlockTitle>
<div> <div>
<Textbox label="Name" value="Text" lines={1} max_lines={1} /> <Textbox label="Name" disabled={!editable} bind:value={$state.name} lines={1} max_lines={1} />
<Textbox label="Author" value="Text" lines={1} max_lines={1} /> <Textbox label="Author" disabled={!editable} bind:value={$state.author} lines={1} max_lines={1} />
<Textbox label="Description" value="Text" lines={5} max_lines={5} /> <Textbox label="Description" disabled={!editable} bind:value={$state.description} lines={5} max_lines={5} />
</div> </div>
</Block> </Block>
</div> </div>
@@ -75,7 +90,7 @@
<style lang="scss"> <style lang="scss">
.template-preview { .template-preview {
width: 60vw; width: 70vw;
height: 70vh; height: 70vh;
display: flex; display: flex;
@@ -92,9 +107,6 @@
} }
} }
.template-rows {
}
.template-layout-preview { .template-layout-preview {
flex-grow: 1; flex-grow: 1;
overflow: auto; overflow: auto;

View File

@@ -12,7 +12,7 @@
{#if entry.dragItem.type === "container"} {#if entry.dragItem.type === "container"}
<div class="layout-container"> <div class="layout-container">
<Block> <Block>
<Accordion label={entry.dragItem.attrs.title || "(Container)"} open={true}> <Accordion label={entry.dragItem.attrs.title || "(Container)"} open={false}>
{#each entry.children as childID} {#each entry.children as childID}
{@const child = layout.allItems[childID]} {@const child = layout.allItems[childID]}
<svelte:self {layout} entry={child} entryID={childID} /> <svelte:self {layout} entry={child} entryID={childID} />

View File

@@ -1,4 +1,7 @@
import { type ContainerLayout, type IDragItem, type WritableLayoutStateStore } from "$lib/stores/layoutStates" import type ComfyGraphCanvas from "$lib/ComfyGraphCanvas";
import { type ContainerLayout, type IDragItem, type TemplateLayout, type WritableLayoutStateStore } from "$lib/stores/layoutStates"
import type { LGraphCanvas } from "@litegraph-ts/core";
import { get } from "svelte/store";
export function handleContainerConsider(layoutState: WritableLayoutStateStore, container: ContainerLayout, evt: CustomEvent<DndEvent<IDragItem>>): IDragItem[] { export function handleContainerConsider(layoutState: WritableLayoutStateStore, container: ContainerLayout, evt: CustomEvent<DndEvent<IDragItem>>): IDragItem[] {
return layoutState.updateChildren(container, evt.detail.items) return layoutState.updateChildren(container, evt.detail.items)
@@ -11,9 +14,37 @@ export function handleContainerFinalize(layoutState: WritableLayoutStateStore, c
const isDroppingTemplate = droppedItem?.type === "template" const isDroppingTemplate = droppedItem?.type === "template"
if (isDroppingTemplate) { if (isDroppingTemplate) {
return layoutState.updateChildren(container, dnd.items.filter(i => i.id !== info.id)); return doInsertTemplate(layoutState, droppedItem as TemplateLayout, container, dnd.items)
} }
else { else {
return layoutState.updateChildren(container, dnd.items) return layoutState.updateChildren(container, dnd.items)
} }
}; };
function isComfyGraphCanvas(canvas: LGraphCanvas): canvas is ComfyGraphCanvas {
return "insertTemplate" in canvas;
}
function doInsertTemplate(layoutState: WritableLayoutStateStore, droppedTemplate: TemplateLayout, container: ContainerLayout, items: IDragItem[]): IDragItem[] {
const workflow = layoutState.workflow;
const templateItemIndex = items.findIndex(i => i.id === droppedTemplate.id)
const newChildren = items.filter(i => i.id !== droppedTemplate.id);
const canvas = workflow.canvases["app"]?.canvas
if (canvas == null || !isComfyGraphCanvas(canvas) || canvas.graph !== workflow.graph) {
console.error("Couldn't get main graph canvas!")
return newChildren;
}
layoutState.updateChildren(container, newChildren);
const rect = canvas.ds.element.getBoundingClientRect();
const width = rect?.width || 1;
const height = rect?.height || 1;
const center = canvas.convertOffsetToCanvas([width * 0.5, height * 0.5]);
canvas.insertTemplate(droppedTemplate.template, center, container, templateItemIndex);
return get(layoutState).allItems[container.id].children;
}

View File

@@ -297,6 +297,9 @@ export default abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
} }
notifyPropsChanged() { notifyPropsChanged() {
if (!this.layoutState)
return;
const layoutEntry = this.layoutState.findLayoutEntryForNode(this.id as ComfyNodeID) const layoutEntry = this.layoutState.findLayoutEntryForNode(this.id as ComfyNodeID)
if (layoutEntry && layoutEntry.parent) { if (layoutEntry && layoutEntry.parent) {
layoutEntry.parent.attrsChanged.set(get(layoutEntry.parent.attrsChanged) + 1) layoutEntry.parent.attrsChanged.set(get(layoutEntry.parent.attrsChanged) + 1)

View File

@@ -740,6 +740,7 @@ type LayoutStateOps = {
updateChildren: (parent: IDragItem, children: IDragItem[]) => IDragItem[], updateChildren: (parent: IDragItem, children: IDragItem[]) => IDragItem[],
nodeAdded: (node: LGraphNode, options: LGraphAddNodeOptions) => void, nodeAdded: (node: LGraphNode, options: LGraphAddNodeOptions) => void,
nodeRemoved: (node: LGraphNode, options: LGraphRemoveNodeOptions) => void, nodeRemoved: (node: LGraphNode, options: LGraphRemoveNodeOptions) => void,
insertTemplate: (template: SerializedComfyBoxTemplate, graph: LGraph, templateNodeIDToNode: Record<NodeID, LGraphNode>, container: ContainerLayout, childIndex: number) => IDragItem,
moveItem: (target: IDragItem, to: ContainerLayout, index?: number) => void, moveItem: (target: IDragItem, to: ContainerLayout, index?: number) => void,
groupItems: (dragItemIDs: DragItemID[], attrs?: Partial<Attributes>) => ContainerLayout, groupItems: (dragItemIDs: DragItemID[], attrs?: Partial<Attributes>) => ContainerLayout,
ungroup: (container: ContainerLayout) => void, ungroup: (container: ContainerLayout) => void,
@@ -768,7 +769,7 @@ export type SerializedDragEntry = {
export type SerializedDragItem = { export type SerializedDragItem = {
type: string, type: string,
id: DragItemID, id: DragItemID,
nodeId: UUID | null, nodeId: NodeID | null,
attrs: Attributes attrs: Attributes
} }
@@ -928,7 +929,11 @@ function createRaw(workflow: ComfyBoxWorkflow | null = null): WritableLayoutStat
let attrs: Partial<Attributes> = {} let attrs: Partial<Attributes> = {}
if (options.addedBy === "moveIntoSubgraph" || options.addedBy === "moveOutOfSubgraph") { 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. // All we need to do is update the nodeID linked to this node.
const item = state.allItemsByNode[options.prevNodeID] const item = state.allItemsByNode[options.prevNodeID]
delete state.allItemsByNode[options.prevNodeID] delete state.allItemsByNode[options.prevNodeID]
@@ -1000,6 +1005,53 @@ function createRaw(workflow: ComfyBoxWorkflow | null = null): WritableLayoutStat
store.set(state) store.set(state)
} }
function insertTemplate(template: SerializedComfyBoxTemplate, graph: LGraph, templateNodeIDToNode: Record<NodeID, LGraphNode>, container: ContainerLayout, childIndex: number): IDragItem {
const idMapping: Record<DragItemID, DragItemID> = {};
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) { function moveItem(target: IDragItem, to: ContainerLayout, index?: number) {
const state = get(store) const state = get(store)
const entry = state.allItems[target.id] const entry = state.allItems[target.id]
@@ -1164,6 +1216,13 @@ function createRaw(workflow: ComfyBoxWorkflow | null = null): WritableLayoutStat
const queue = [state.allItems[rootID]] const queue = [state.allItems[rootID]]
while (queue.length > 0) { while (queue.length > 0) {
const entry = queue.shift(); 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] = { allItems[entry.dragItem.id] = {
dragItem: { dragItem: {
type: entry.dragItem.type, type: entry.dragItem.type,
@@ -1193,6 +1252,13 @@ function createRaw(workflow: ComfyBoxWorkflow | null = null): WritableLayoutStat
const allItems: Record<DragItemID, SerializedDragEntry> = {} const allItems: Record<DragItemID, SerializedDragEntry> = {}
for (const pair of Object.entries(state.allItems)) { for (const pair of Object.entries(state.allItems)) {
const [id, entry] = pair; 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] = { allItems[id] = {
dragItem: { dragItem: {
type: entry.dragItem.type, type: entry.dragItem.type,
@@ -1211,12 +1277,19 @@ function createRaw(workflow: ComfyBoxWorkflow | null = null): WritableLayoutStat
} }
} }
function deserialize(data: SerializedLayoutState, graph: LGraph) { function deserializeRaw(data: SerializedLayoutState, graph: LGraph): LayoutState {
const allItems: Record<DragItemID, DragItemEntry> = {} const allItems: Record<DragItemID, DragItemEntry> = {}
const allItemsByNode: Record<number, DragItemEntry> = {} const allItemsByNode: Record<number, DragItemEntry> = {}
for (const pair of Object.entries(data.allItems)) { for (const pair of Object.entries(data.allItems)) {
const [id, entry] = pair; 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 = { const dragItem: IDragItem = {
type: entry.dragItem.type, type: entry.dragItem.type,
id: entry.dragItem.id, id: entry.dragItem.id,
@@ -1265,6 +1338,12 @@ function createRaw(workflow: ComfyBoxWorkflow | null = null): WritableLayoutStat
isConfiguring: false, isConfiguring: false,
} }
return state
}
function deserialize(data: SerializedLayoutState, graph: LGraph) {
const state = deserializeRaw(data, graph);
console.debug("[layoutState] deserialize", data, state, defaultWorkflowAttributes) console.debug("[layoutState] deserialize", data, state, defaultWorkflowAttributes)
store.set(state) store.set(state)
@@ -1298,6 +1377,7 @@ function createRaw(workflow: ComfyBoxWorkflow | null = null): WritableLayoutStat
updateChildren, updateChildren,
nodeAdded, nodeAdded,
nodeRemoved, nodeRemoved,
insertTemplate,
moveItem, moveItem,
groupItems, groupItems,
findLayoutEntryForNode, findLayoutEntryForNode,

View File

@@ -2,6 +2,7 @@ import type { SerializedComfyBoxTemplate } from '$lib/ComfyBoxTemplate';
import type { UUID } from '@litegraph-ts/core'; import type { UUID } from '@litegraph-ts/core';
import { get, writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
import type { Readable, Writable } from 'svelte/store'; import type { Readable, Writable } from 'svelte/store';
import { v4 as uuidv4 } from "uuid";
export type TemplateState = { export type TemplateState = {
templates: SerializedComfyBoxTemplate[] templates: SerializedComfyBoxTemplate[]
@@ -12,6 +13,7 @@ type TemplateStateOps = {
save: () => void, save: () => void,
load: () => void, load: () => void,
add: (template: SerializedComfyBoxTemplate) => boolean, add: (template: SerializedComfyBoxTemplate) => boolean,
update: (template: SerializedComfyBoxTemplate) => boolean,
remove: (templateID: UUID) => boolean, remove: (templateID: UUID) => boolean,
} }
@@ -57,6 +59,30 @@ function remove(templateID: UUID): boolean {
return true; return true;
} }
function update(template: SerializedComfyBoxTemplate): boolean {
const state = get(store);
if (!state.templatesByID[template.id]) {
return false;
}
store.update(s => {
const oldId = template.id
const index = s.templates.findIndex(t => t.id === oldId)
s.templates.splice(index, 1);
delete s.templatesByID[oldId];
template.id = uuidv4();
s.templates.push(template);
s.templatesByID[template.id] = template;
return s;
})
save();
return true;
}
function save() { function save() {
const json = JSON.stringify(get(store).templates) const json = JSON.stringify(get(store).templates)
localStorage.setItem("templates", json) localStorage.setItem("templates", json)
@@ -95,6 +121,7 @@ const templateStateStore: WritableTemplateStateStore =
...store, ...store,
add, add,
remove, remove,
update,
save, save,
load, load,
} }

View File

@@ -128,6 +128,7 @@ body {
border-color: var(--comfy-disabled-textbox-border-color); border-color: var(--comfy-disabled-textbox-border-color);
box-shadow: 0 0 0 var(--shadow-spread) transparent, rgba(0, 0, 0, 0.08) 0px 2px 4px 0px inset; box-shadow: 0 0 0 var(--shadow-spread) transparent, rgba(0, 0, 0, 0.08) 0px 2px 4px 0px inset;
cursor: not-allowed; cursor: not-allowed;
color: var(--comfy-disabled-label-color);
} }
@mixin disable-inputs { @mixin disable-inputs {

View File

@@ -6,13 +6,17 @@ import FullReload from 'vite-plugin-full-reload';
import { viteStaticCopy } from 'vite-plugin-static-copy' import { viteStaticCopy } from 'vite-plugin-static-copy'
import removeConsole from 'vite-plugin-svelte-console-remover'; import removeConsole from 'vite-plugin-svelte-console-remover';
import glsl from 'vite-plugin-glsl'; import glsl from 'vite-plugin-glsl';
import { execSync } from "child_process"
const isProduction = process.env.NODE_ENV === "production"; const isProduction = process.env.NODE_ENV === "production";
console.log("Production build: " + isProduction) console.log("Production build: " + isProduction)
const commitHash = execSync('git rev-parse HEAD').toString();
console.log("Commit: " + commitHash)
export default defineConfig({ export default defineConfig({
define: { define: {
"__GIT_COMMIT_HASH__": '"test"' "__GIT_COMMIT_HASH__": JSON.stringify(commitHash)
}, },
clearScreen: false, clearScreen: false,
base: "./", base: "./",