Templates view and store

This commit is contained in:
space-nuko
2023-05-24 17:38:37 -05:00
parent da917a2a50
commit 4dfa665303
16 changed files with 444 additions and 71 deletions

8
src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
declare type Item = import('svelte-dnd-action').Item;
declare type DndEvent<ItemType = Item> = import('svelte-dnd-action').DndEvent<ItemType>;
declare namespace svelte.JSX {
interface HTMLAttributes<T> {
onconsider?: (event: CustomEvent<DndEvent<ItemType>> & { target: EventTarget & T }) => void;
onfinalize?: (event: CustomEvent<DndEvent<ItemType>> & { target: EventTarget & T }) => void;
}
}

View File

@@ -1,9 +1,10 @@
import { Subgraph, type LGraphNode, type LLink, type SerializedLGraphNode, type SerializedLLink, LGraph, type NodeID } from "@litegraph-ts/core" import { Subgraph, type LGraphNode, type LLink, type SerializedLGraphNode, type SerializedLLink, LGraph, type NodeID, type UUID } from "@litegraph-ts/core"
import layoutStates, { isComfyWidgetNode, type ContainerLayout, type SerializedDragEntry, type WidgetLayout, type DragItemID, type WritableLayoutStateStore, type DragItemEntry, type SerializedLayoutState } from "./stores/layoutStates" import layoutStates, { isComfyWidgetNode, type ContainerLayout, type SerializedDragEntry, type WidgetLayout, type DragItemID, type WritableLayoutStateStore, type DragItemEntry, type SerializedLayoutState } from "./stores/layoutStates"
import type { ComfyWidgetNode } from "./nodes/widgets" import type { ComfyWidgetNode } from "./nodes/widgets"
import type ComfyGraphCanvas from "./ComfyGraphCanvas" import type ComfyGraphCanvas from "./ComfyGraphCanvas"
import C2S from "canvas-to-svg"; import C2S from "canvas-to-svg";
import { download } from "./utils"; import { download } from "./utils";
import { v4 as uuidv4 } from "uuid";
/* /*
* In ComfyBox a template contains a subset of nodes in the graph and the set of * In ComfyBox a template contains a subset of nodes in the graph and the set of
@@ -11,6 +12,7 @@ import { download } from "./utils";
*/ */
export type ComfyBoxTemplate = { export type ComfyBoxTemplate = {
version: 1, version: 1,
id: UUID,
metadata: ComfyBoxTemplateMetadata, metadata: ComfyBoxTemplateMetadata,
nodes: LGraphNode[], nodes: LGraphNode[],
links: LLink[], links: LLink[],
@@ -19,6 +21,17 @@ export type ComfyBoxTemplate = {
export type SerializedTemplateLink = [NodeID, number, NodeID, number]; export type SerializedTemplateLink = [NodeID, number, NodeID, number];
export type ComfyBoxTemplateMetadata = {
title: string,
author: string,
description: string,
tags: string[],
category: string,
createdAt: number
// TODO required/optional python extensions
}
/* /*
* In ComfyBox a template contains a subset of nodes in the graph and the set of * In ComfyBox a template contains a subset of nodes in the graph and the set of
* components they represent in the UI. * components they represent in the UI.
@@ -26,6 +39,7 @@ export type SerializedTemplateLink = [NodeID, number, NodeID, number];
export type SerializedComfyBoxTemplate = { export type SerializedComfyBoxTemplate = {
isComfyBoxTemplate: true, isComfyBoxTemplate: true,
version: 1, version: 1,
id: UUID,
/* /*
* Serialized metadata * Serialized metadata
@@ -46,29 +60,23 @@ export type SerializedComfyBoxTemplate = {
* Serialized container type drag item * Serialized container type drag item
*/ */
layout?: SerializedLayoutState layout?: SerializedLayoutState
/*
* SVG of the graph ndoes
*/
svg?: string
} }
function isSerializedComfyBoxTemplate(param: any): param is SerializedComfyBoxTemplate { function isSerializedComfyBoxTemplate(param: any): param is SerializedComfyBoxTemplate {
return param && param.isComfyBoxTemplate; return param && param.isComfyBoxTemplate;
} }
export type SerializedComfyBoxTemplateAndSVG = {
template: SerializedComfyBoxTemplate,
svg: string,
}
export type ComfyBoxTemplateMetadata = {
title: string,
author: string,
tags: string[],
// TODO required/optional python extensions
}
const DEFAULT_TEMPLATE_METADATA = { const DEFAULT_TEMPLATE_METADATA = {
title: "New Template", title: "New Template",
author: "Anonymous", author: "Anonymous",
tags: [] description: "A brand-new ComfyBox template",
tags: [],
category: "general"
} }
export type ComfyBoxTemplateError = { export type ComfyBoxTemplateError = {
@@ -145,7 +153,7 @@ function unescapeXml(safe) {
const TEMPLATE_SVG_PADDING: number = 50; const TEMPLATE_SVG_PADDING: number = 50;
function renderSvg(canvas: ComfyGraphCanvas, graph: LGraph, extraData: SerializedComfyBoxTemplate, padding: number): string { function renderSvg(canvas: ComfyGraphCanvas, graph: LGraph, padding: number, extraData?: SerializedComfyBoxTemplate | null): string {
// Calculate the min max bounds for the nodes on the graph // Calculate the min max bounds for the nodes on the graph
const bounds = graph._nodes.reduce( const bounds = graph._nodes.reduce(
(p, n) => { (p, n) => {
@@ -247,11 +255,16 @@ function renderSvg(canvas: ComfyGraphCanvas, graph: LGraph, extraData: Serialize
canvas.draw(true, true); canvas.draw(true, true);
// Convert to SVG, embed graph and save let svg = svgCtx.getSerializedSvg(true)
// const json = JSON.stringify(app.graph.serialize()); return svg
const json = JSON.stringify(extraData); }
const svg = svgCtx.getSerializedSvg(true).replace("</svg>", `<desc>${escapeXml(json)}</desc></svg>`);
export function embedTemplateInSvg(template: SerializedComfyBoxTemplate): string {
let oldSvg = template.svg;
template.svg = undefined;
const json = JSON.stringify(template);
const svg = oldSvg.replace("</svg>", `<desc>${escapeXml(json)}</desc></svg>`);
template.svg = oldSvg;
return svg return svg
} }
@@ -285,7 +298,7 @@ function convLinkForTemplate(link: LLink): SerializedTemplateLink {
return [link.origin_id, link.origin_slot, link.target_id, link.target_slot]; return [link.origin_id, link.origin_slot, link.target_id, link.target_slot];
} }
export function serializeTemplate(canvas: ComfyGraphCanvas, template: ComfyBoxTemplate): SerializedComfyBoxTemplateAndSVG { export function serializeTemplate(canvas: ComfyGraphCanvas, template: ComfyBoxTemplate): SerializedComfyBoxTemplate {
let graph: LGraph let graph: LGraph
if (template.nodes.length === 1 && template.nodes[0].is(Subgraph)) { if (template.nodes.length === 1 && template.nodes[0].is(Subgraph)) {
graph = template.nodes[0].subgraph graph = template.nodes[0].subgraph
@@ -305,21 +318,23 @@ export function serializeTemplate(canvas: ComfyGraphCanvas, template: ComfyBoxTe
[nodes, links] = pruneDetachedLinks(nodes, links); [nodes, links] = pruneDetachedLinks(nodes, links);
let serTemplate: SerializedComfyBoxTemplate = { const svg = renderSvg(canvas, graph, TEMPLATE_SVG_PADDING);
const serTemplate: SerializedComfyBoxTemplate = {
isComfyBoxTemplate: true, isComfyBoxTemplate: true,
version: 1, version: 1,
id: template.id,
metadata, metadata,
nodes, nodes,
links, links,
layout, layout,
svg
} }
const svg = renderSvg(canvas, graph, serTemplate, TEMPLATE_SVG_PADDING) return serTemplate;
return { svg, template: serTemplate }
} }
export function deserializeTemplate(file: File): Promise<SerializedComfyBoxTemplateAndSVG> { export function deserializeTemplateFromSVG(file: File): Promise<SerializedComfyBoxTemplate> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async () => { reader.onload = async () => {
@@ -340,10 +355,8 @@ export function deserializeTemplate(file: File): Promise<SerializedComfyBoxTempl
reject("Invalid template format!") reject("Invalid template format!")
} }
else { else {
const result: SerializedComfyBoxTemplateAndSVG = { template.svg = svg;
svg, template resolve(template)
}
resolve(result)
} }
}; };
reader.readAsText(file); reader.readAsText(file);
@@ -364,10 +377,15 @@ export function createTemplate(nodes: LGraphNode[]): ComfyBoxTemplateResult {
const layout = layoutStates.getLayoutByNode(nodes[0]) const layout = layoutStates.getLayoutByNode(nodes[0])
if (layout == null) { if (layout == null) {
return { return {
error: "Subgraph not contained in a layout!" error: "Node(s) not contained in a layout!"
} }
} }
let title = "New Template";
if (nodes.length === 1) {
title = nodes[0].title || title;
}
if (widgetNodes.length > 0) { if (widgetNodes.length > 0) {
// Find the highest-level container that contains all these nodes and // Find the highest-level container that contains all these nodes and
// contains no other widgets // contains no other widgets
@@ -386,7 +404,8 @@ export function createTemplate(nodes: LGraphNode[]): ComfyBoxTemplateResult {
return { return {
version: 1, version: 1,
metadata: { ...DEFAULT_TEMPLATE_METADATA }, id: uuidv4(),
metadata: { ...DEFAULT_TEMPLATE_METADATA, title, createdAt: Date.now() },
nodes: nodes, nodes: nodes,
links: links, links: links,
container: container container: container
@@ -396,7 +415,8 @@ export function createTemplate(nodes: LGraphNode[]): ComfyBoxTemplateResult {
// No UI to serialize. // No UI to serialize.
return { return {
version: 1, version: 1,
metadata: { ...DEFAULT_TEMPLATE_METADATA }, id: uuidv4(),
metadata: { ...DEFAULT_TEMPLATE_METADATA, title, createdAt: Date.now() },
nodes: nodes, nodes: nodes,
links: links, links: links,
} }

View File

@@ -6,6 +6,7 @@ import { ComfyReroute } from "./nodes";
import layoutStates from "./stores/layoutStates"; import layoutStates 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 { 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 { v4 as uuidv4 } from "uuid"
@@ -523,11 +524,20 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
const template = result as ComfyBoxTemplate; const template = result as ComfyBoxTemplate;
console.warn("TEMPLATEFOUND", template)
const serialized = serializeTemplate(this, template); const serialized = serializeTemplate(this, template);
download("template.svg", serialized.svg, "image/svg+xml"); try {
if (templateState.add(serialized)) {
notify("Template saved!", { type: "success" })
}
else {
notify("Failed to save template: already exists in LocalStorage", { type: "error" })
}
}
catch (error) {
// Quota exceeded?
notify(`Failed to save template: ${error}`, { type: "error", timeout: 10000 })
}
} }
override getCanvasMenuOptions(): ContextMenuItem[] { override getCanvasMenuOptions(): ContextMenuItem[] {

View File

@@ -15,6 +15,7 @@
import { startDrag, stopDrag } from "$lib/utils" import { startDrag, stopDrag } from "$lib/utils"
import { writable, type Writable } from "svelte/store"; import { writable, type Writable } from "svelte/store";
import { isHidden } from "$lib/widgets/utils"; import { isHidden } from "$lib/widgets/utils";
import { handleContainerConsider, handleContainerFinalize } from "./utils";
export let layoutState: WritableLayoutStateStore; export let layoutState: WritableLayoutStateStore;
export let container: ContainerLayout | null = null; export let container: ContainerLayout | null = null;
@@ -46,14 +47,12 @@
isOpen = container.isOpen isOpen = container.isOpen
} }
function handleConsider(evt: any) { function handleConsider(evt: CustomEvent<DndEvent<IDragItem>>) {
children = layoutState.updateChildren(container, evt.detail.items) children = handleContainerConsider(layoutState, container, evt)
// console.log(dragItems);
}; };
function handleFinalize(evt: any) { function handleFinalize(evt: CustomEvent<DndEvent<IDragItem>>) {
children = layoutState.updateChildren(container, evt.detail.items) children = handleContainerFinalize(layoutState, container, evt)
// Ensure dragging is stopped on drag finish
}; };
function handleClick(e: CustomEvent<boolean>) { function handleClick(e: CustomEvent<boolean>) {
@@ -85,6 +84,7 @@
class:empty={children.length === 0} class:empty={children.length === 0}
class:edit={edit} class:edit={edit}
use:dndzone="{{ use:dndzone="{{
type: "layout",
items: children, items: children,
flipDurationMs, flipDurationMs,
centreDraggedOnCursor: true, centreDraggedOnCursor: true,

View File

@@ -7,13 +7,13 @@
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action'; import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import {fade} from 'svelte/transition'; import {fade} from 'svelte/transition';
// notice - fade in works fine but don't add svelte's fade-out (known issue)
import {cubicIn} from 'svelte/easing'; import {cubicIn} from 'svelte/easing';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { type ContainerLayout, type WidgetLayout, type IDragItem, type WritableLayoutStateStore } from "$lib/stores/layoutStates"; import { type ContainerLayout, type WidgetLayout, type IDragItem, type WritableLayoutStateStore } from "$lib/stores/layoutStates";
import { startDrag, stopDrag } from "$lib/utils" import { startDrag, stopDrag } from "$lib/utils"
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { isHidden } from "$lib/widgets/utils"; import { isHidden } from "$lib/widgets/utils";
import { handleContainerConsider, handleContainerFinalize } from "./utils";
export let layoutState: WritableLayoutStateStore; export let layoutState: WritableLayoutStateStore;
export let container: ContainerLayout | null = null; export let container: ContainerLayout | null = null;
@@ -45,14 +45,12 @@
attrsChanged = null attrsChanged = null
} }
function handleConsider(evt: any) { function handleConsider(evt: CustomEvent<DndEvent<IDragItem>>) {
children = layoutState.updateChildren(container, evt.detail.items) children = handleContainerConsider(layoutState, container, evt);
// console.log(dragItems);
}; };
function handleFinalize(evt: any) { function handleFinalize(evt: CustomEvent<DndEvent<IDragItem>>) {
children = layoutState.updateChildren(container, evt.detail.items) children = handleContainerFinalize(layoutState, container, evt);
// Ensure dragging is stopped on drag finish
}; };
function _startDrag(e: MouseEvent | TouchEvent) { function _startDrag(e: MouseEvent | TouchEvent) {
@@ -83,6 +81,7 @@
class:empty={children.length === 0} class:empty={children.length === 0}
class:edit class:edit
use:dndzone="{{ use:dndzone="{{
type: "layout",
items: children, items: children,
flipDurationMs, flipDurationMs,
centreDraggedOnCursor: true, centreDraggedOnCursor: true,

View File

@@ -36,7 +36,8 @@ import { type SvelteComponentDev } from "svelte/internal";
import { get, writable, type Writable } from "svelte/store"; import { get, writable, type Writable } from "svelte/store";
import ComfyPromptSerializer, { isActiveBackendNode, UpstreamNodeLocator } from "./ComfyPromptSerializer"; import ComfyPromptSerializer, { isActiveBackendNode, UpstreamNodeLocator } from "./ComfyPromptSerializer";
import DanbooruTags from "$lib/DanbooruTags"; import DanbooruTags from "$lib/DanbooruTags";
import { deserializeTemplate } from "$lib/ComfyBoxTemplate"; import { deserializeTemplateFromSVG } from "$lib/ComfyBoxTemplate";
import templateState from "$lib/stores/templateState";
export const COMFYBOX_SERIAL_VERSION = 1; export const COMFYBOX_SERIAL_VERSION = 1;
@@ -234,6 +235,7 @@ export default class ComfyApp {
this.addKeyboardHandler(); this.addKeyboardHandler();
await this.updateHistoryAndQueue(); await this.updateHistoryAndQueue();
templateState.load();
await this.initFrontendFeatures(); await this.initFrontendFeatures();
@@ -1004,7 +1006,7 @@ export default class ComfyApp {
}; };
reader.readAsText(file); reader.readAsText(file);
} else if (file.type === "image/svg+xml" || file.name.endsWith(".svg")) { } else if (file.type === "image/svg+xml" || file.name.endsWith(".svg")) {
const templateAndSvg = await deserializeTemplate(file); const templateAndSvg = await deserializeTemplateFromSVG(file);
modalState.pushModal({ modalState.pushModal({
title: "ComfyBox Template Preview", title: "ComfyBox Template Preview",
svelteComponent: EditTemplateModal, svelteComponent: EditTemplateModal,

View File

@@ -140,7 +140,7 @@
{#if workflow != null} {#if workflow != null}
{#if layoutState != null} {#if layoutState != null}
<div id="comfy-workflow-view" on:contextmenu={onRightClick}> <div class="comfy-workflow-view" on:contextmenu={onRightClick}>
<WidgetContainer bind:dragItem={root} classes={["root-container"]} {layoutState} /> <WidgetContainer bind:dragItem={root} classes={["root-container"]} {layoutState} />
</div> </div>
{/if} {/if}
@@ -185,7 +185,7 @@
{/if} {/if}
<style lang="scss"> <style lang="scss">
#comfy-workflow-view { .comfy-workflow-view {
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: auto; overflow: auto;

View File

@@ -9,7 +9,7 @@
import { get, writable, type Writable } from "svelte/store"; import { get, writable, type Writable } from "svelte/store";
import uiState from "$lib/stores/uiState"; import uiState from "$lib/stores/uiState";
import interfaceState from "$lib/stores/interfaceState"; import interfaceState from "$lib/stores/interfaceState";
import workflowState, { ComfyBoxWorkflow } from "$lib/stores/workflowState"; import workflowState, { ComfyBoxWorkflow, type WorkflowInstID } from "$lib/stores/workflowState";
import selectionState from "$lib/stores/selectionState"; import selectionState from "$lib/stores/selectionState";
import type ComfyApp from './ComfyApp'; import type ComfyApp from './ComfyApp';
import { onMount } from "svelte"; import { onMount } from "svelte";
@@ -22,8 +22,10 @@
export let app: ComfyApp; export let app: ComfyApp;
export let uiTheme: string = "gradio-dark" // TODO config export let uiTheme: string = "gradio-dark" // TODO config
type OpenedWorkflow = { id: WorkflowInstID };
let workflow: ComfyBoxWorkflow | null = null; let workflow: ComfyBoxWorkflow | null = null;
let openedWorkflows = [] let openedWorkflows: OpenedWorkflow[] = []
let containerElem: HTMLDivElement; let containerElem: HTMLDivElement;
let resizeTimeout: NodeJS.Timeout | null; let resizeTimeout: NodeJS.Timeout | null;
@@ -165,7 +167,7 @@
app.closeWorkflow(workflow.id); app.closeWorkflow(workflow.id);
} }
function handleConsider(evt: any) { function handleConsider(evt: CustomEvent<DndEvent<OpenedWorkflow>>) {
console.warn(openedWorkflows.length, openedWorkflows, evt.detail.items.length, evt.detail.items) console.warn(openedWorkflows.length, openedWorkflows, evt.detail.items.length, evt.detail.items)
openedWorkflows = evt.detail.items; openedWorkflows = evt.detail.items;
// openedWorkflows = evt.detail.items.filter(item => item.id !== SHADOW_PLACEHOLDER_ITEM_ID); // openedWorkflows = evt.detail.items.filter(item => item.id !== SHADOW_PLACEHOLDER_ITEM_ID);
@@ -175,7 +177,7 @@
// }) // })
}; };
function handleFinalize(evt: any) { function handleFinalize(evt: CustomEvent<DndEvent<OpenedWorkflow>>) {
openedWorkflows = evt.detail.items; openedWorkflows = evt.detail.items;
workflowState.update(s => { workflowState.update(s => {
s.openedWorkflows = openedWorkflows.filter(w => w.id !== SHADOW_PLACEHOLDER_ITEM_ID).map(w => workflowState.getWorkflow(w.id)); s.openedWorkflows = openedWorkflows.filter(w => w.id !== SHADOW_PLACEHOLDER_ITEM_ID).map(w => workflowState.getWorkflow(w.id));
@@ -206,6 +208,7 @@
<div id="workflow-tabs"> <div id="workflow-tabs">
<div class="workflow-tab-items" <div class="workflow-tab-items"
use:dndzone="{{ use:dndzone="{{
type: "workflowTab",
items: openedWorkflows, items: openedWorkflows,
flipDurationMs: 200, flipDurationMs: 200,
type: "workflow-tab", type: "workflow-tab",

View File

@@ -1,5 +1,5 @@
<script context="module" lang="ts"> <script context="module" lang="ts">
export type ComfyPaneMode = "none" | "activeWorkflow" | "graph" | "properties" | "queue" export type ComfyPaneMode = "none" | "activeWorkflow" | "graph" | "properties" | "templates" | "queue"
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -8,12 +8,13 @@
*/ */
import workflowState from "$lib/stores/workflowState"; import workflowState from "$lib/stores/workflowState";
import type ComfyApp from "./ComfyApp"; import type ComfyApp from "./ComfyApp";
import { Sliders2, LayoutTextSidebarReverse } from "svelte-bootstrap-icons"; import { Sliders2, BoxSeam, LayoutTextSidebarReverse } from "svelte-bootstrap-icons";
import ComfyBoxWorkflowView from "./ComfyBoxWorkflowView.svelte"; import ComfyBoxWorkflowView from "./ComfyBoxWorkflowView.svelte";
import ComfyGraphView from "./ComfyGraphView.svelte"; import ComfyGraphView from "./ComfyGraphView.svelte";
import ComfyProperties from "./ComfyProperties.svelte"; import ComfyProperties from "./ComfyProperties.svelte";
import ComfyQueue from "./ComfyQueue.svelte"; import ComfyQueue from "./ComfyQueue.svelte";
import ComfyTemplates from "./ComfyTemplates.svelte";
import { SvelteComponent } from "svelte"; import { SvelteComponent } from "svelte";
export let app: ComfyApp export let app: ComfyApp
@@ -22,6 +23,7 @@
const MODES: [ComfyPaneMode, typeof SvelteComponent][] = [ const MODES: [ComfyPaneMode, typeof SvelteComponent][] = [
["properties", Sliders2], ["properties", Sliders2],
["templates", BoxSeam],
["queue", LayoutTextSidebarReverse] ["queue", LayoutTextSidebarReverse]
] ]
@@ -39,6 +41,8 @@
<ComfyGraphView {app} /> <ComfyGraphView {app} />
{:else if mode === "properties"} {:else if mode === "properties"}
<ComfyProperties workflow={$workflowState.activeWorkflow} /> <ComfyProperties workflow={$workflowState.activeWorkflow} />
{:else if mode === "templates"}
<ComfyTemplates {app} />
{:else if mode === "queue"} {:else if mode === "queue"}
<ComfyQueue {app} /> <ComfyQueue {app} />
{:else} {:else}

View File

@@ -0,0 +1,186 @@
<script lang="ts">
import type { SerializedComfyBoxTemplate } from "$lib/ComfyBoxTemplate";
import templateState from "$lib/stores/templateState";
import uiState from "$lib/stores/uiState";
import { truncateString } from "$lib/utils";
import type ComfyApp from "./ComfyApp";
import { flip } from 'svelte/animate';
import {fade} from 'svelte/transition';
import {cubicIn} from 'svelte/easing';
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 { v4 as uuidv4 } from "uuid"
import { writable } from "svelte/store";
export let app: ComfyApp
type DNDConsiderOrFinalizeEvent<T> = {
items: T[],
info: any,
el: Node,
id: string,
trigger?: string,
source?: string
}
let _sorted: TemplateLayout[] = []
$: rebuildTemplates($templateState.templates);
function rebuildTemplates(templates: SerializedComfyBoxTemplate[]) {
console.error("recreate");
_sorted = Array.from(templates).map(t => {
return {
type: "template", id: uuidv4(), template: t, attrs: {...defaultWidgetAttributes}, attrsChanged: writable(0)
}
});
_sorted.sort(t => t.template.metadata.createdAt || 0);
}
const flipDurationMs = 200;
let shouldIgnoreDndEvents = false;
function handleDndConsider(e: CustomEvent<DNDConsiderOrFinalizeEvent<TemplateLayout>>) {
// console.warn(`got consider ${JSON.stringify(e.detail, null, 2)}`);
const {trigger, id} = e.detail.info;
if (trigger === TRIGGERS.DRAG_STARTED) {
// console.warn(`copying ${id}`);
const idx = _sorted.findIndex(item => item.id === id);
const newId = `${id}_copy_${Math.round(Math.random()*1000000)}`;
e.detail.items = e.detail.items.filter(item => !item[SHADOW_ITEM_MARKER_PROPERTY_NAME]);
e.detail.items.splice(idx, 0, {..._sorted[idx], id: newId});
_sorted = e.detail.items;
shouldIgnoreDndEvents = true;
}
else if (!shouldIgnoreDndEvents) {
_sorted = e.detail.items;
}
else {
_sorted = _sorted;
}
}
function handleDndFinalize(e: CustomEvent<DNDConsiderOrFinalizeEvent<TemplateLayout>>) {
if (!shouldIgnoreDndEvents) {
_sorted = e.detail.items;
}
else {
_sorted = _sorted;
shouldIgnoreDndEvents = false;
}
}
</script>
<div class="template-list">
<div class="template-entries">
{#if _sorted.length > 0}
{@const draggable = $uiState.uiUnlocked}
<div class="template-category-group">
<div class="template-category-header">
General
</div>
<div class="template-entries-wrapper"
use:dndzone={{
type: "layout",
items: _sorted,
flipDurationMs,
dragDisabled: !draggable,
dropFromOthersDisabled: true
}}
on:consider={handleDndConsider}
on:finalize={handleDndFinalize}>
{#each _sorted.filter(i => i.id !== SHADOW_PLACEHOLDER_ITEM_ID) as item(item.id)}
<div class="template-entry" class:draggable>
<div class="template-name">{truncateString(item.template.metadata.title, 16)}</div>
<div class="template-desc">{truncateString(item.template.metadata.description, 24)}</div>
</div>
{#if item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
<div in:fade={{duration:200, easing: cubicIn}} class='template-drag-item-shadow'/>
{/if}
{/each}
</div>
</div>
{:else}
<div class="no-templates">
<span>(No templates)</span>
</div>
{/if}
</div>
</div>
<style lang="scss">
.template-list {
height: 100%;
}
.template-entries {
height: 100%;
overflow-y: auto;
display: flex;
flex-flow: column nowrap;
}
.template-category-header {
color: var(--body-text-color);
background: var(--block-background-fill);
border-color: var(--panel-border-color);
padding: 0.8rem 1.0rem;
font-weight: bold;
}
.template-entry {
padding: 1.0rem;
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--block-border-color);
border-top: 1px solid var(--table-border-color);
color: var(--body-text-color);
background: var(--panel-background-fill);
max-height: 14rem;
position: relative;
font-size: 13pt;
.template-desc {
opacity: 65%;
font-size: 11pt;
}
&:hover:not(:has(img:hover)):not(:has(button:hover)) {
&.draggable {
cursor: grab;
}
background: var(--block-background-fill);
&.running {
background: var(--comfy-accent-soft);
}
}
}
.no-templates {
display: flex;
color: var(--comfy-accent-soft);
flex-direction: row;
margin: auto;
height: 100%;
color: var(--comfy-accent-soft);
span {
margin: auto;
font-size: 32px;
font-weight: bolder;
}
}
.template-drag-item-shadow {
position: absolute;
top: 0; left:0; right: 0; bottom: 0;
visibility: visible;
border: 1px dashed grey;
background: lightblue;
opacity: 0.5;
margin: 0;
}
</style>

View File

@@ -15,6 +15,7 @@
import { startDrag, stopDrag } from "$lib/utils" import { startDrag, stopDrag } from "$lib/utils"
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { isHidden } from "$lib/widgets/utils"; import { isHidden } from "$lib/widgets/utils";
import { handleContainerConsider, handleContainerFinalize } from "./utils";
export let layoutState: WritableLayoutStateStore; export let layoutState: WritableLayoutStateStore;
export let container: ContainerLayout | null = null; export let container: ContainerLayout | null = null;
@@ -38,14 +39,12 @@
// attrsChanged = writable(0) // attrsChanged = writable(0)
} }
function handleConsider(evt: any) { function handleConsider(evt: CustomEvent<DndEvent<IDragItem>>) {
children = layoutState.updateChildren(container, evt.detail.items) children = handleContainerConsider(layoutState, container, evt)
// console.log(dragItems);
}; };
function handleFinalize(evt: any) { function handleFinalize(evt: CustomEvent<DndEvent<IDragItem>>) {
children = layoutState.updateChildren(container, evt.detail.items) children = handleContainerFinalize(layoutState, container, evt)
// Ensure dragging is stopped on drag finish
}; };
function getTabName(container: ContainerLayout, i: number): string { function getTabName(container: ContainerLayout, i: number): string {
@@ -89,6 +88,7 @@
class:empty={children.length === 0} class:empty={children.length === 0}
class:edit={edit} class:edit={edit}
use:dndzone="{{ use:dndzone="{{
type: "layout",
items: children, items: children,
flipDurationMs, flipDurationMs,
centreDraggedOnCursor: true, centreDraggedOnCursor: true,

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { ComfyBoxTemplate, SerializedComfyBoxTemplateAndSVG } from "$lib/ComfyBoxTemplate"; import type { ComfyBoxTemplate, SerializedComfyBoxTemplate } from "$lib/ComfyBoxTemplate";
import type { SerializedDragEntry, SerializedLayoutState } from "$lib/stores/layoutStates"; import type { SerializedDragEntry, SerializedLayoutState } from "$lib/stores/layoutStates";
import { Block, BlockTitle } from "@gradio/atoms"; import { Block, BlockTitle } from "@gradio/atoms";
import SerializedLayoutPreviewNode from "./SerializedLayoutPreviewNode.svelte"; import SerializedLayoutPreviewNode from "./SerializedLayoutPreviewNode.svelte";
@@ -10,7 +10,7 @@
import Textbox from "@gradio/form/src/Textbox.svelte"; import Textbox from "@gradio/form/src/Textbox.svelte";
const DOMPurify = createDOMPurify(window); const DOMPurify = createDOMPurify(window);
export let templateAndSvg: SerializedComfyBoxTemplateAndSVG; export let templateAndSvg: SerializedComfyBoxTemplate;
let layout: SerializedLayoutState | null let layout: SerializedLayoutState | null
let root: SerializedDragEntry | null let root: SerializedDragEntry | null
@@ -21,7 +21,7 @@
: ""; : "";
$: if (templateAndSvg) { $: if (templateAndSvg) {
layout = templateAndSvg.template.layout; layout = templateAndSvg.layout;
if (layout) { if (layout) {
root = layout.allItems[layout.root]; root = layout.allItems[layout.root];
} }

View File

@@ -0,0 +1,19 @@
import { type ContainerLayout, type IDragItem, type WritableLayoutStateStore } from "$lib/stores/layoutStates"
export function handleContainerConsider(layoutState: WritableLayoutStateStore, container: ContainerLayout, evt: CustomEvent<DndEvent<IDragItem>>): IDragItem[] {
return layoutState.updateChildren(container, evt.detail.items)
};
export function handleContainerFinalize(layoutState: WritableLayoutStateStore, container: ContainerLayout, evt: CustomEvent<DndEvent<IDragItem>>): IDragItem[] {
const dnd = evt.detail
const info = dnd.info;
const droppedItem = dnd.items.find(i => i.id === info.id);
const isDroppingTemplate = droppedItem?.type === "template"
if (isDroppingTemplate) {
return layoutState.updateChildren(container, dnd.items.filter(i => i.id !== info.id));
}
else {
return layoutState.updateChildren(container, dnd.items)
}
};

View File

@@ -9,6 +9,7 @@ import type { ComfyWidgetNode } from '$lib/nodes/widgets';
import type ComfyGraph from '$lib/ComfyGraph'; import type ComfyGraph from '$lib/ComfyGraph';
import type { ComfyBoxWorkflow, WorkflowAttributes, WorkflowInstID } from './workflowState'; import type { ComfyBoxWorkflow, WorkflowAttributes, WorkflowInstID } from './workflowState';
import workflowState from './workflowState'; import workflowState from './workflowState';
import type { SerializedComfyBoxTemplate } from '$lib/ComfyBoxTemplate';
export function isComfyWidgetNode(node: LGraphNode): node is ComfyWidgetNode { export function isComfyWidgetNode(node: LGraphNode): node is ComfyWidgetNode {
return "svelteComponentType" in node return "svelteComponentType" in node
@@ -635,7 +636,7 @@ for (const cat of Object.values(ALL_ATTRIBUTES)) {
export { ALL_ATTRIBUTES }; export { ALL_ATTRIBUTES };
// TODO Should be nested by category for name uniqueness? // TODO Should be nested by category for name uniqueness?
const defaultWidgetAttributes: Attributes = {} as any export const defaultWidgetAttributes: Attributes = {} as any
export const defaultWorkflowAttributes: WorkflowAttributes = {} as any export const defaultWorkflowAttributes: WorkflowAttributes = {} as any
for (const cat of Object.values(ALL_ATTRIBUTES)) { for (const cat of Object.values(ALL_ATTRIBUTES)) {
for (const spec of Object.values(cat.specs)) { for (const spec of Object.values(cat.specs)) {
@@ -657,7 +658,7 @@ export interface IDragItem {
/* /*
* Type of the item. * Type of the item.
*/ */
type: "container" | "widget", type: "container" | "widget" | "template",
/* /*
* Unique ID of the item. * Unique ID of the item.
@@ -707,6 +708,21 @@ export interface WidgetLayout extends IDragItem {
node: ComfyWidgetNode 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 = { export type DefaultLayout = {
root: ContainerLayout, root: ContainerLayout,
left: ContainerLayout, left: ContainerLayout,
@@ -874,7 +890,7 @@ function createRaw(workflow: ComfyBoxWorkflow | null = null): WritableLayoutStat
if (newChildren) if (newChildren)
state.allItems[parent.id].children = newChildren; state.allItems[parent.id].children = newChildren;
for (const child of state.allItems[parent.id].children) { for (const child of state.allItems[parent.id].children) {
if (child.id === SHADOW_PLACEHOLDER_ITEM_ID) if (child.id === SHADOW_PLACEHOLDER_ITEM_ID || child.type === "template")
continue; continue;
state.allItems[child.id].parent = parent; state.allItems[child.id].parent = parent;
} }

View File

@@ -0,0 +1,101 @@
import type { SerializedComfyBoxTemplate } from '$lib/ComfyBoxTemplate';
import type { UUID } from '@litegraph-ts/core';
import { get, writable } from 'svelte/store';
import type { Readable, Writable } from 'svelte/store';
export type TemplateState = {
templates: SerializedComfyBoxTemplate[]
templatesByID: Record<UUID, SerializedComfyBoxTemplate>
}
type TemplateStateOps = {
save: () => void,
load: () => void,
add: (template: SerializedComfyBoxTemplate) => boolean,
remove: (templateID: UUID) => boolean,
}
export type WritableTemplateStateStore = Writable<TemplateState> & TemplateStateOps;
const store: Writable<TemplateState> = writable(
{
templates: [],
templatesByID: {}
})
function add(template: SerializedComfyBoxTemplate): boolean {
const state = get(store);
if (state.templatesByID[template.id]) {
return false;
}
store.update(s => {
s.templates.push(template);
s.templatesByID[template.id] = template;
return s;
})
save();
return true;
}
function remove(templateID: UUID): boolean {
const state = get(store);
if (!state.templatesByID[templateID]) {
return false;
}
store.update(s => {
const index = s.templates.findIndex(t => t.id === templateID)
s.templates.splice(index, 1);
delete s.templatesByID[templateID];
return s;
})
save();
return true;
}
function save() {
const json = JSON.stringify(get(store).templates)
localStorage.setItem("templates", json)
store.set(get(store))
}
function load() {
const json = localStorage.getItem("templates")
if (!json) {
console.info("No templates in local storage, creating store")
save();
return;
}
const data = JSON.parse(json) as SerializedComfyBoxTemplate[];
if (Array.isArray(data)) {
const templatesByID: Record<UUID, SerializedComfyBoxTemplate> =
data.map(d => [d.id, d])
.reduce((dict, el: [UUID, SerializedComfyBoxTemplate]) => (dict[el[0]] = el[1], dict), {})
store.set({
templates: data,
templatesByID
})
}
else {
store.set({
templates: [],
templatesByID: {}
})
}
}
const templateStateStore: WritableTemplateStateStore =
{
...store,
add,
remove,
save,
load,
}
export default templateStateStore;

View File

@@ -219,3 +219,8 @@ button {
} }
} }
// fixes drop item shadow not appearing when dragging template items onto workflow
:global([data-is-dnd-shadow-item]) {
min-height: 5rem;
}