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

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

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import type { SerializedComfyBoxTemplate } from "$lib/ComfyBoxTemplate";
import { embedTemplateInSvg, type SerializedComfyBoxTemplate } from "$lib/ComfyBoxTemplate";
import templateState from "$lib/stores/templateState";
import uiState from "$lib/stores/uiState";
import { truncateString } from "$lib/utils";
import { download, truncateString } from "$lib/utils";
import type ComfyApp from "./ComfyApp";
import { flip } from 'svelte/animate';
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 { defaultWidgetAttributes, type TemplateLayout } from "$lib/stores/layoutStates";
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
@@ -71,6 +74,61 @@
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>
<div class="template-list">
@@ -92,9 +150,10 @@
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>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="template-entry" class:draggable on:click={() => handleClick(item)}>
<div class="template-name">{item.template.metadata.title}</div>
<div class="template-desc">{item.template.metadata.description}</div>
</div>
{#if item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
<div in:fade={{duration:200, easing: cubicIn}} class='template-drag-item-shadow'/>
@@ -140,6 +199,9 @@
background: var(--panel-background-fill);
max-height: 14rem;
position: relative;
user-select: none;
text-overflow: ellipsis;
overflow: hidden;
font-size: 13pt;
.template-desc {
@@ -147,11 +209,19 @@
font-size: 11pt;
}
&.draggable {
border: 5px dashed var(--secondary-500);
margin: 0.2em;
}
&:hover:not(:has(img:hover)):not(:has(button:hover)) {
cursor: pointer;
background: var(--block-background-fill);
&.draggable {
cursor: grab;
background: var(--secondary-700);
}
background: var(--block-background-fill);
&.running {
background: var(--comfy-accent-soft);

View File

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

View File

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

View File

@@ -1,35 +1,73 @@
<script>
import { setContext, createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import { key } from './menu.ts';
<script lang="ts">
import { setContext, createEventDispatcher } from 'svelte';
import { key } from './menu.ts';
export let x;
export let y;
import { offset, flip, shift } from "svelte-floating-ui/dom";
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
$: (() => {
if (!menuEl) return;
const [floatingRef, floatingContent] = createFloatingActions({
placement: "right-start",
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, {
dispatchClick: () => dispatch('click')
});
// whenever x and y is changed, restrict box to be within bounds
$: (() => {
if (!menuEl) return;
let menuEl;
function onPageClick(e) {
if (e.target === menuEl || menuEl.contains(e.target)) return;
dispatch('clickoutside');
}
const rect = menuEl.getBoundingClientRect();
x = Math.min(window.innerWidth - rect.width, x);
if (y > window.innerHeight - rect.height) y -= rect.height;
})();
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>
<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 />
</div>

View File

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

View File

@@ -12,7 +12,7 @@
{#if entry.dragItem.type === "container"}
<div class="layout-container">
<Block>
<Accordion label={entry.dragItem.attrs.title || "(Container)"} open={true}>
<Accordion label={entry.dragItem.attrs.title || "(Container)"} open={false}>
{#each entry.children as childID}
{@const child = layout.allItems[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[] {
return layoutState.updateChildren(container, evt.detail.items)
@@ -11,9 +14,37 @@ export function handleContainerFinalize(layoutState: WritableLayoutStateStore, c
const isDroppingTemplate = droppedItem?.type === "template"
if (isDroppingTemplate) {
return layoutState.updateChildren(container, dnd.items.filter(i => i.id !== info.id));
return doInsertTemplate(layoutState, droppedItem as TemplateLayout, container, dnd.items)
}
else {
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;
}