530 lines
16 KiB
Svelte
530 lines
16 KiB
Svelte
<script lang="ts">
|
|
import { Pane, Splitpanes } from 'svelte-splitpanes';
|
|
import { PlusSquareDotted } from 'svelte-bootstrap-icons';
|
|
import { Button } from "@gradio/button";
|
|
import { BlockTitle } from "@gradio/atoms";
|
|
import { Checkbox, TextBox } from "@gradio/form"
|
|
import ComfyQueue from "./ComfyQueue.svelte";
|
|
import ComfyUnlockUIButton from "./ComfyUnlockUIButton.svelte";
|
|
import { get, writable, type Writable } from "svelte/store";
|
|
import uiState from "$lib/stores/uiState";
|
|
import interfaceState from "$lib/stores/interfaceState";
|
|
import workflowState, { ComfyBoxWorkflow } from "$lib/stores/workflowState";
|
|
import selectionState from "$lib/stores/selectionState";
|
|
import type ComfyApp from './ComfyApp';
|
|
import { onMount } from "svelte";
|
|
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
|
|
import { fade } from 'svelte/transition';
|
|
import { cubicIn } from 'svelte/easing';
|
|
import { truncateString } from '$lib/utils';
|
|
import ComfyPaneView from './ComfyPaneView.svelte';
|
|
|
|
export let app: ComfyApp;
|
|
export let uiTheme: string = "gradio-dark" // TODO config
|
|
|
|
let workflow: ComfyBoxWorkflow | null = null;
|
|
let openedWorkflows = []
|
|
|
|
let containerElem: HTMLDivElement;
|
|
let resizeTimeout: NodeJS.Timeout | null;
|
|
let fileInput: HTMLInputElement = undefined;
|
|
let loading = true;
|
|
|
|
let appSetupPromise: Promise<void> = null;
|
|
|
|
$: workflow = $workflowState.activeWorkflow;
|
|
$: openedWorkflows = $workflowState.openedWorkflows.map(w => { return { id: w.id } })
|
|
|
|
onMount(async () => {
|
|
appSetupPromise = app.setup().then(() => {
|
|
loading = false;
|
|
refreshView();
|
|
}
|
|
);
|
|
refreshView();
|
|
})
|
|
|
|
async function doRefreshCombos() {
|
|
await app.refreshComboInNodes(undefined, undefined, true)
|
|
}
|
|
|
|
function refreshView(event?: Event) {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(app.resizeCanvas.bind(app), 250);
|
|
}
|
|
|
|
$: if (app?.lCanvas) {
|
|
app.lCanvas.allow_dragnodes = $uiState.uiUnlocked;
|
|
app.lCanvas.allow_interaction = $uiState.uiUnlocked;
|
|
|
|
if (!$uiState.uiUnlocked) {
|
|
app.lCanvas.deselectAllNodes();
|
|
$selectionState.currentSelectionNodes = []
|
|
}
|
|
}
|
|
|
|
$: if ($uiState.uiEditMode)
|
|
$selectionState.currentSelection = []
|
|
|
|
let graphSize = 0;
|
|
|
|
$: if (containerElem) {
|
|
const canvas = containerElem.querySelector<HTMLDivElement>("#graph-canvas")
|
|
if (canvas) {
|
|
const paneNode = canvas.closest(".splitpanes__pane")
|
|
if (paneNode) {
|
|
(paneNode as HTMLElement).ontransitionstart = () => {
|
|
$interfaceState.graphTransitioning = true
|
|
}
|
|
(paneNode as HTMLElement).ontransitionend = () => {
|
|
$interfaceState.graphTransitioning = false
|
|
app.resizeCanvas()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function queuePrompt() {
|
|
app.runDefaultQueueAction()
|
|
}
|
|
|
|
function toggleGraph() {
|
|
if (graphSize == 0) {
|
|
graphSize = 50;
|
|
app.resizeCanvas();
|
|
}
|
|
else {
|
|
graphSize = 0;
|
|
}
|
|
}
|
|
|
|
let propsSidebarSize = 0;
|
|
|
|
function toggleProps() {
|
|
if (propsSidebarSize == 0) {
|
|
propsSidebarSize = 15;
|
|
app.resizeCanvas();
|
|
}
|
|
else {
|
|
propsSidebarSize = 0;
|
|
}
|
|
}
|
|
|
|
let queueSidebarSize = 20;
|
|
|
|
function toggleQueue() {
|
|
if (queueSidebarSize == 0) {
|
|
queueSidebarSize = 20;
|
|
app.resizeCanvas();
|
|
}
|
|
else {
|
|
queueSidebarSize = 0;
|
|
}
|
|
}
|
|
|
|
function doSave(): void {
|
|
app.querySave()
|
|
}
|
|
|
|
function doLoad(): void {
|
|
if (!fileInput)
|
|
return;
|
|
|
|
fileInput.value = null;
|
|
fileInput.click();
|
|
}
|
|
|
|
function loadWorkflow(): void {
|
|
app.handleFile(fileInput.files[0]);
|
|
}
|
|
|
|
function doSaveLocal(): void {
|
|
app.saveStateToLocalStorage();
|
|
}
|
|
|
|
async function doLoadDefault() {
|
|
var confirmed = confirm("Would you like to load the default workflow in a new tab?");
|
|
if (confirmed) {
|
|
await app.initDefaultWorkflow();
|
|
}
|
|
}
|
|
|
|
function createNewWorkflow() {
|
|
app.createNewWorkflow();
|
|
}
|
|
|
|
function closeWorkflow(event: Event, workflow: ComfyBoxWorkflow) {
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation()
|
|
|
|
if (workflow.isModified) {
|
|
if (!confirm("This workflow has unsaved changes. Are you sure you want to close it?"))
|
|
return;
|
|
}
|
|
|
|
app.closeWorkflow(workflow.id);
|
|
}
|
|
|
|
function handleConsider(evt: any) {
|
|
console.warn(openedWorkflows.length, openedWorkflows, evt.detail.items.length, evt.detail.items)
|
|
openedWorkflows = evt.detail.items;
|
|
// openedWorkflows = evt.detail.items.filter(item => item.id !== SHADOW_PLACEHOLDER_ITEM_ID);
|
|
// workflowState.update(s => {
|
|
// s.openedWorkflows = openedWorkflows.map(w => workflowState.getWorkflow(w.id));
|
|
// return s;
|
|
// })
|
|
};
|
|
|
|
function handleFinalize(evt: any) {
|
|
openedWorkflows = evt.detail.items;
|
|
workflowState.update(s => {
|
|
s.openedWorkflows = openedWorkflows.filter(w => w.id !== SHADOW_PLACEHOLDER_ITEM_ID).map(w => workflowState.getWorkflow(w.id));
|
|
return s;
|
|
})
|
|
};
|
|
</script>
|
|
|
|
<div id="comfy-content" bind:this={containerElem} class:loading>
|
|
<Splitpanes theme="comfy" on:resize={refreshView}>
|
|
<Pane bind:size={propsSidebarSize}>
|
|
<ComfyPaneView {app} mode="properties"/>
|
|
</Pane>
|
|
<Pane>
|
|
<Splitpanes theme="comfy" on:resize={refreshView} horizontal="{true}">
|
|
<Pane>
|
|
<ComfyPaneView {app} mode="activeWorkflow"/>
|
|
</Pane>
|
|
<Pane bind:size={graphSize}>
|
|
<ComfyPaneView {app} mode="graph"/>
|
|
</Pane>
|
|
</Splitpanes>
|
|
</Pane>
|
|
<Pane bind:size={queueSidebarSize}>
|
|
<ComfyPaneView {app} mode="queue"/>
|
|
</Pane>
|
|
</Splitpanes>
|
|
<div id="workflow-tabs">
|
|
<div class="workflow-tab-items"
|
|
use:dndzone="{{
|
|
items: openedWorkflows,
|
|
flipDurationMs: 200,
|
|
type: "workflow-tab",
|
|
morphDisabled: true,
|
|
dropFromOthersDisabled: true,
|
|
dropTargetStyle: {outline: "none"},
|
|
}}"
|
|
on:consider={handleConsider}
|
|
on:finalize={handleFinalize}>
|
|
{#each openedWorkflows.filter(item => item.id !== SHADOW_PLACEHOLDER_ITEM_ID) as item(item.id)}
|
|
{@const workflow = workflowState.getWorkflow(item.id)}
|
|
<button class="workflow-tab"
|
|
class:selected={item.id === $workflowState.activeWorkflowID}
|
|
on:click={() => app.setActiveWorkflow(item.id)}>
|
|
<span class="workflow-tab-title">
|
|
{truncateString(workflow.attrs.title, 32)}
|
|
{#if workflow.isModified}
|
|
*
|
|
{/if}
|
|
</span>
|
|
<button class="workflow-close-button"
|
|
on:click={(e) => closeWorkflow(e, workflow)}>
|
|
✕
|
|
</button>
|
|
{#if workflow[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
|
|
<div in:fade={{duration:200, easing: cubicIn}} class='drag-item-shadow'/>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<button class="workflow-add-new-button"
|
|
on:click={createNewWorkflow}>
|
|
<PlusSquareDotted width="100%" height="100%" />
|
|
</button>
|
|
</div>
|
|
<div id="bottombar">
|
|
<div class="bottombar-content">
|
|
<div class="left">
|
|
{#if workflow != null && workflow.attrs.queuePromptButtonName != ""}
|
|
<Button variant="primary" disabled={loading} on:click={queuePrompt}>
|
|
{workflow.attrs.queuePromptButtonName}
|
|
</Button>
|
|
{/if}
|
|
<Button variant="secondary" disabled={loading} on:click={toggleGraph}>
|
|
Toggle Graph
|
|
</Button>
|
|
<Button variant="secondary" disabled={loading} on:click={toggleProps}>
|
|
Toggle Props
|
|
</Button>
|
|
<Button variant="secondary" disabled={loading} on:click={toggleQueue}>
|
|
Toggle Queue
|
|
</Button>
|
|
<Button variant="secondary" disabled={loading} on:click={doSave}>
|
|
Save
|
|
</Button>
|
|
<Button variant="secondary" disabled={loading} on:click={doSaveLocal}>
|
|
Save Local
|
|
</Button>
|
|
<Button variant="secondary" disabled={loading} on:click={doLoad}>
|
|
Load
|
|
</Button>
|
|
<Button variant="secondary" disabled={loading} on:click={doLoadDefault}>
|
|
Load Default
|
|
</Button>
|
|
<Button variant="secondary" disabled={loading} on:click={doRefreshCombos}>
|
|
🔄
|
|
</Button>
|
|
<!-- <Checkbox label="Lock Nodes" bind:value={$uiState.nodesLocked}/>
|
|
<Checkbox label="Disable Interaction" bind:value={$uiState.graphLocked}/> -->
|
|
<span style="display: inline-flex !important">
|
|
<Checkbox label="Auto-Add UI" bind:value={$uiState.autoAddUI}/>
|
|
</span>
|
|
<span class="label" for="ui-edit-mode">
|
|
<BlockTitle>UI Edit mode</BlockTitle>
|
|
<select id="ui-edit-mode" name="ui-edit-mode" bind:value={$uiState.uiEditMode}>
|
|
<option value="widgets">Widgets</option>
|
|
</select>
|
|
</span>
|
|
<span class="label" for="ui-theme">
|
|
<BlockTitle>Theme</BlockTitle>
|
|
<select id="ui-theme" name="ui-theme" bind:value={uiTheme}>
|
|
<option value="gradio-dark">Gradio Dark</option>
|
|
<option value="gradio-light">Gradio Light</option>
|
|
<option value="anapnoe">Anapnoe</option>
|
|
</select>
|
|
</span>
|
|
</div>
|
|
<div class="right">
|
|
<ComfyUnlockUIButton bind:toggled={$uiState.uiUnlocked} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<input bind:this={fileInput} id="comfy-file-input" type="file" accept="application/json,image/png" on:change={loadWorkflow} />
|
|
|
|
{#if appSetupPromise}
|
|
{#await appSetupPromise}
|
|
<div class="comfy-app-loading">
|
|
<span>Loading...</span>
|
|
</div>
|
|
{:catch error}
|
|
<div class="comfy-loading-error">
|
|
<div>
|
|
Error loading app
|
|
</div>
|
|
<div>{error}</div>
|
|
{#if error != null && error.stack}
|
|
{@const lines = error.stack.split("\n")}
|
|
{#each lines as line}
|
|
<div style:font-size="16px">{line}</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
{/await}
|
|
{/if}
|
|
|
|
<style lang="scss">
|
|
$top-bar-height: 3.5rem;
|
|
$workflow-tabs-height: 2.5rem;
|
|
$bottom-bar-height: 5rem;
|
|
|
|
.comfy-app-loading, .comfy-loading-error {
|
|
font-size: 40px;
|
|
color: var(--body-text-color);
|
|
justify-content: center;
|
|
margin: auto;
|
|
width: 100%;
|
|
height: 100%;
|
|
text-align: center;
|
|
flex-direction: column;
|
|
display: flex;
|
|
position: absolute;
|
|
z-index: 100000000;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
pointer-events: none;
|
|
user-select: none;
|
|
top: 0px;
|
|
}
|
|
|
|
.comfy-app-loading > span {
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: center;
|
|
}
|
|
|
|
#comfy-content {
|
|
grid-area: content;
|
|
height: calc(100vh - $bottom-bar-height - $workflow-tabs-height);
|
|
|
|
&.loading {
|
|
pointer-events: none;
|
|
user-select: none;
|
|
}
|
|
}
|
|
|
|
#workflow-tabs, .workflow-tab-items {
|
|
display: flex;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
#workflow-tabs {
|
|
background: var(--neutral-800);
|
|
padding-right: 1em;
|
|
height: 3rem;
|
|
.workflow-tab {
|
|
background: var(--neutral-800);
|
|
color: var(--neutral-500);
|
|
padding: 0.5rem 1rem;
|
|
border-top: 3px solid var(--neutral-600);
|
|
border-left: 1px solid var(--neutral-600);
|
|
height: 100%;
|
|
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: center;
|
|
text-align: center;
|
|
gap: var(--size-4);
|
|
cursor: pointer !important;
|
|
|
|
> span {
|
|
margin: auto;
|
|
}
|
|
|
|
&:hover:not(:has(.workflow-close-button:hover)) {
|
|
background: var(--neutral-700);
|
|
color: var(--neutral-300);
|
|
}
|
|
|
|
&.selected {
|
|
background: var(--neutral-700);
|
|
color: var(--neutral-300);
|
|
border-top-color: var(--primary-500);
|
|
font-weight: bold;
|
|
}
|
|
|
|
> .workflow-close-button {
|
|
display:block;
|
|
width: 1.2rem;
|
|
height: 1.2rem;
|
|
font-size: 13px;
|
|
margin: auto;
|
|
border-radius: 50%;
|
|
opacity: 50%;
|
|
background: var(--neutral-500);
|
|
color: var(--neutral-300);
|
|
|
|
&:hover {
|
|
opacity: 100%;
|
|
color: var(--neutral-100);
|
|
}
|
|
}
|
|
}
|
|
|
|
.workflow-add-new-button {
|
|
background: var(--neutral-700);
|
|
color: var(--neutral-400);
|
|
opacity: 50%;
|
|
padding: 0.5rem;
|
|
border-top: 3px solid var(--neutral-600);
|
|
aspect-ratio: 1/1;
|
|
height: 100%;
|
|
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: center;
|
|
gap: var(--size-4);
|
|
|
|
&:hover {
|
|
filter: brightness(100%);
|
|
opacity: 100%;
|
|
border-top-color: var(--neutral-600);
|
|
}
|
|
}
|
|
}
|
|
|
|
#bottombar {
|
|
background: var(--neutral-900);
|
|
border-left: 2px solid var(--neutral-700);
|
|
display: flex;
|
|
width: 100%;
|
|
gap: var(--layout-gap);
|
|
overflow-x: hidden;
|
|
flex-wrap: nowrap;
|
|
height: $bottom-bar-height;
|
|
|
|
> .bottombar-content {
|
|
display: flex;
|
|
flex-wrap: nowrap;
|
|
width: 100%;
|
|
overflow-x: auto;
|
|
margin: auto 0;
|
|
padding-left: 1rem;
|
|
|
|
> .left {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
> .right {
|
|
margin-left: auto;
|
|
margin-right: 1rem;
|
|
padding-left: 1rem;
|
|
}
|
|
}
|
|
}
|
|
|
|
:global(.splitpanes.comfy>.splitpanes__splitter) {
|
|
background: var(--comfy-splitpanes-background-fill);
|
|
|
|
&:hover:not([disabled]) {
|
|
background: var(--comfy-splitpanes-background-fill-hover);
|
|
}
|
|
&:active:not([disabled]) {
|
|
background: var(--comfy-splitpanes-background-fill-active);
|
|
}
|
|
}
|
|
|
|
|
|
$splitter-size: 1rem;
|
|
|
|
:global(.splitpanes.comfy.splitpanes--horizontal>.splitpanes__splitter) {
|
|
min-height: $splitter-size;
|
|
cursor: row-resize;
|
|
}
|
|
|
|
:global(.splitpanes.comfy.splitpanes--vertical>.splitpanes__splitter) {
|
|
min-width: $splitter-size;
|
|
cursor: col-resize;
|
|
}
|
|
|
|
:global(.splitpanes.comfy) {
|
|
max-height: calc(100vh - $bottom-bar-height);
|
|
max-width: 100vw;
|
|
}
|
|
|
|
:global(.splitpanes__pane) {
|
|
box-shadow: 0 0 3px rgba(0, 0, 0, .2) inset;
|
|
justify-content: center;
|
|
align-items: center;
|
|
display: flex;
|
|
position: relative;
|
|
}
|
|
|
|
label.label > :global(span) {
|
|
top: 20%;
|
|
}
|
|
|
|
span.left {
|
|
right: 0px;
|
|
}
|
|
|
|
#comfy-file-input {
|
|
display: none;
|
|
}
|
|
|
|
.drag-item-shadow {
|
|
visibility: visible;
|
|
border: 1px dashed grey;
|
|
background: lightblue;
|
|
opacity: 0.5;
|
|
margin: 0;
|
|
}
|
|
</style>
|