Apply new code format standard (#217)

This commit is contained in:
Chenlei Hu
2024-07-25 10:10:18 -04:00
committed by GitHub
parent 19c70d95d3
commit e179f75387
121 changed files with 11898 additions and 11983 deletions

View File

@@ -1,64 +1,64 @@
import { ComfyWorkflowJSON } from "@/types/comfyWorkflow";
import { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import {
HistoryTaskItem,
PendingTaskItem,
RunningTaskItem,
ComfyNodeDef,
validateComfyNodeDef,
} from "@/types/apiTypes";
validateComfyNodeDef
} from '@/types/apiTypes'
interface QueuePromptRequestBody {
client_id: string;
client_id: string
// Mapping from node id to node info + input values
// TODO: Type this.
prompt: Record<number, any>;
prompt: Record<number, any>
extra_data: {
extra_pnginfo: {
workflow: ComfyWorkflowJSON;
};
};
front?: boolean;
number?: number;
workflow: ComfyWorkflowJSON
}
}
front?: boolean
number?: number
}
class ComfyApi extends EventTarget {
#registered = new Set();
api_host: string;
api_base: string;
initialClientId: string;
user: string;
socket?: WebSocket;
clientId?: string;
#registered = new Set()
api_host: string
api_base: string
initialClientId: string
user: string
socket?: WebSocket
clientId?: string
constructor() {
super();
this.api_host = location.host;
this.api_base = location.pathname.split("/").slice(0, -1).join("/");
this.initialClientId = sessionStorage.getItem("clientId");
super()
this.api_host = location.host
this.api_base = location.pathname.split('/').slice(0, -1).join('/')
this.initialClientId = sessionStorage.getItem('clientId')
}
apiURL(route: string): string {
return this.api_base + "/api" + route;
return this.api_base + '/api' + route
}
fileURL(route: string): string {
return this.api_base + route;
return this.api_base + route
}
fetchApi(route, options?) {
if (!options) {
options = {};
options = {}
}
if (!options.headers) {
options.headers = {};
options.headers = {}
}
options.headers["Comfy-User"] = this.user;
return fetch(this.apiURL(route), options);
options.headers['Comfy-User'] = this.user
return fetch(this.apiURL(route), options)
}
addEventListener(type, callback, options?) {
super.addEventListener(type, callback, options);
this.#registered.add(type);
super.addEventListener(type, callback, options)
this.#registered.add(type)
}
/**
@@ -67,13 +67,13 @@ class ComfyApi extends EventTarget {
#pollQueue() {
setInterval(async () => {
try {
const resp = await this.fetchApi("/prompt");
const status = await resp.json();
this.dispatchEvent(new CustomEvent("status", { detail: status }));
const resp = await this.fetchApi('/prompt')
const status = await resp.json()
this.dispatchEvent(new CustomEvent('status', { detail: status }))
} catch (error) {
this.dispatchEvent(new CustomEvent("status", { detail: null }));
this.dispatchEvent(new CustomEvent('status', { detail: null }))
}
}, 1000);
}, 1000)
}
/**
@@ -82,144 +82,144 @@ class ComfyApi extends EventTarget {
*/
#createSocket(isReconnect?) {
if (this.socket) {
return;
return
}
let opened = false;
let existingSession = window.name;
let opened = false
let existingSession = window.name
if (existingSession) {
existingSession = "?clientId=" + existingSession;
existingSession = '?clientId=' + existingSession
}
this.socket = new WebSocket(
`ws${window.location.protocol === "https:" ? "s" : ""}://${this.api_host}${this.api_base}/ws${existingSession}`
);
this.socket.binaryType = "arraybuffer";
`ws${window.location.protocol === 'https:' ? 's' : ''}://${this.api_host}${this.api_base}/ws${existingSession}`
)
this.socket.binaryType = 'arraybuffer'
this.socket.addEventListener("open", () => {
opened = true;
this.socket.addEventListener('open', () => {
opened = true
if (isReconnect) {
this.dispatchEvent(new CustomEvent("reconnected"));
this.dispatchEvent(new CustomEvent('reconnected'))
}
});
})
this.socket.addEventListener("error", () => {
if (this.socket) this.socket.close();
this.socket.addEventListener('error', () => {
if (this.socket) this.socket.close()
if (!isReconnect && !opened) {
this.#pollQueue();
this.#pollQueue()
}
});
})
this.socket.addEventListener("close", () => {
this.socket.addEventListener('close', () => {
setTimeout(() => {
this.socket = null;
this.#createSocket(true);
}, 300);
this.socket = null
this.#createSocket(true)
}, 300)
if (opened) {
this.dispatchEvent(new CustomEvent("status", { detail: null }));
this.dispatchEvent(new CustomEvent("reconnecting"));
this.dispatchEvent(new CustomEvent('status', { detail: null }))
this.dispatchEvent(new CustomEvent('reconnecting'))
}
});
})
this.socket.addEventListener("message", (event) => {
this.socket.addEventListener('message', (event) => {
try {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data);
const eventType = view.getUint32(0);
const buffer = event.data.slice(4);
const view = new DataView(event.data)
const eventType = view.getUint32(0)
const buffer = event.data.slice(4)
switch (eventType) {
case 1:
const view2 = new DataView(event.data);
const imageType = view2.getUint32(0);
let imageMime;
const view2 = new DataView(event.data)
const imageType = view2.getUint32(0)
let imageMime
switch (imageType) {
case 1:
default:
imageMime = "image/jpeg";
break;
imageMime = 'image/jpeg'
break
case 2:
imageMime = "image/png";
imageMime = 'image/png'
}
const imageBlob = new Blob([buffer.slice(4)], {
type: imageMime,
});
type: imageMime
})
this.dispatchEvent(
new CustomEvent("b_preview", { detail: imageBlob })
);
break;
new CustomEvent('b_preview', { detail: imageBlob })
)
break
default:
throw new Error(
`Unknown binary websocket message of type ${eventType}`
);
)
}
} else {
const msg = JSON.parse(event.data);
const msg = JSON.parse(event.data)
switch (msg.type) {
case "status":
case 'status':
if (msg.data.sid) {
this.clientId = msg.data.sid;
window.name = this.clientId; // use window name so it isnt reused when duplicating tabs
sessionStorage.setItem("clientId", this.clientId); // store in session storage so duplicate tab can load correct workflow
this.clientId = msg.data.sid
window.name = this.clientId // use window name so it isnt reused when duplicating tabs
sessionStorage.setItem('clientId', this.clientId) // store in session storage so duplicate tab can load correct workflow
}
this.dispatchEvent(
new CustomEvent("status", { detail: msg.data.status })
);
break;
case "progress":
new CustomEvent('status', { detail: msg.data.status })
)
break
case 'progress':
this.dispatchEvent(
new CustomEvent("progress", { detail: msg.data })
);
break;
case "executing":
new CustomEvent('progress', { detail: msg.data })
)
break
case 'executing':
this.dispatchEvent(
new CustomEvent("executing", { detail: msg.data.node })
);
break;
case "executed":
new CustomEvent('executing', { detail: msg.data.node })
)
break
case 'executed':
this.dispatchEvent(
new CustomEvent("executed", { detail: msg.data })
);
break;
case "execution_start":
new CustomEvent('executed', { detail: msg.data })
)
break
case 'execution_start':
this.dispatchEvent(
new CustomEvent("execution_start", { detail: msg.data })
);
break;
case "execution_success":
new CustomEvent('execution_start', { detail: msg.data })
)
break
case 'execution_success':
this.dispatchEvent(
new CustomEvent("execution_success", { detail: msg.data })
);
break;
case "execution_error":
new CustomEvent('execution_success', { detail: msg.data })
)
break
case 'execution_error':
this.dispatchEvent(
new CustomEvent("execution_error", { detail: msg.data })
);
break;
case "execution_cached":
new CustomEvent('execution_error', { detail: msg.data })
)
break
case 'execution_cached':
this.dispatchEvent(
new CustomEvent("execution_cached", { detail: msg.data })
);
break;
new CustomEvent('execution_cached', { detail: msg.data })
)
break
default:
if (this.#registered.has(msg.type)) {
this.dispatchEvent(
new CustomEvent(msg.type, { detail: msg.data })
);
)
} else {
throw new Error(`Unknown message type ${msg.type}`);
throw new Error(`Unknown message type ${msg.type}`)
}
}
}
} catch (error) {
console.warn("Unhandled message:", event.data, error);
console.warn('Unhandled message:', event.data, error)
}
});
})
}
/**
* Initialises sockets and realtime updates
*/
init() {
this.#createSocket();
this.#createSocket()
}
/**
@@ -227,8 +227,8 @@ class ComfyApi extends EventTarget {
* @returns An array of script urls to import
*/
async getExtensions() {
const resp = await this.fetchApi("/extensions", { cache: "no-store" });
return await resp.json();
const resp = await this.fetchApi('/extensions', { cache: 'no-store' })
return await resp.json()
}
/**
@@ -236,8 +236,8 @@ class ComfyApi extends EventTarget {
* @returns An array of script urls to import
*/
async getEmbeddings() {
const resp = await this.fetchApi("/embeddings", { cache: "no-store" });
return await resp.json();
const resp = await this.fetchApi('/embeddings', { cache: 'no-store' })
return await resp.json()
}
/**
@@ -245,18 +245,18 @@ class ComfyApi extends EventTarget {
* @returns The node definitions
*/
async getNodeDefs(): Promise<Record<string, ComfyNodeDef>> {
const resp = await this.fetchApi("/object_info", { cache: "no-store" });
const objectInfoUnsafe = await resp.json();
const objectInfo: Record<string, ComfyNodeDef> = {};
const resp = await this.fetchApi('/object_info', { cache: 'no-store' })
const objectInfoUnsafe = await resp.json()
const objectInfo: Record<string, ComfyNodeDef> = {}
for (const key in objectInfoUnsafe) {
try {
objectInfo[key] = validateComfyNodeDef(objectInfoUnsafe[key]);
objectInfo[key] = validateComfyNodeDef(objectInfoUnsafe[key])
} catch (e) {
console.warn("Ignore node definition: ", key);
console.error(e);
console.warn('Ignore node definition: ', key)
console.error(e)
}
}
return objectInfo;
return objectInfo
}
/**
@@ -268,30 +268,30 @@ class ComfyApi extends EventTarget {
const body: QueuePromptRequestBody = {
client_id: this.clientId,
prompt: output,
extra_data: { extra_pnginfo: { workflow } },
};
if (number === -1) {
body.front = true;
} else if (number != 0) {
body.number = number;
extra_data: { extra_pnginfo: { workflow } }
}
const res = await this.fetchApi("/prompt", {
method: "POST",
if (number === -1) {
body.front = true
} else if (number != 0) {
body.number = number
}
const res = await this.fetchApi('/prompt', {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json'
},
body: JSON.stringify(body),
});
body: JSON.stringify(body)
})
if (res.status !== 200) {
throw {
response: await res.json(),
};
response: await res.json()
}
}
return await res.json();
return await res.json()
}
/**
@@ -300,10 +300,10 @@ class ComfyApi extends EventTarget {
* @returns The items of the specified type grouped by their status
*/
async getItems(type) {
if (type === "queue") {
return this.getQueue();
if (type === 'queue') {
return this.getQueue()
}
return this.getHistory();
return this.getHistory()
}
/**
@@ -311,27 +311,27 @@ class ComfyApi extends EventTarget {
* @returns The currently running and queued items
*/
async getQueue(): Promise<{
Running: RunningTaskItem[];
Pending: PendingTaskItem[];
Running: RunningTaskItem[]
Pending: PendingTaskItem[]
}> {
try {
const res = await this.fetchApi("/queue");
const data = await res.json();
const res = await this.fetchApi('/queue')
const data = await res.json()
return {
// Running action uses a different endpoint for cancelling
Running: data.queue_running.map((prompt) => ({
taskType: "Running",
taskType: 'Running',
prompt,
remove: { name: "Cancel", cb: () => api.interrupt() },
remove: { name: 'Cancel', cb: () => api.interrupt() }
})),
Pending: data.queue_pending.map((prompt) => ({
taskType: "Pending",
prompt,
})),
};
taskType: 'Pending',
prompt
}))
}
} catch (error) {
console.error(error);
return { Running: [], Pending: [] };
console.error(error)
return { Running: [], Pending: [] }
}
}
@@ -343,15 +343,15 @@ class ComfyApi extends EventTarget {
max_items: number = 200
): Promise<{ History: HistoryTaskItem[] }> {
try {
const res = await this.fetchApi(`/history?max_items=${max_items}`);
const res = await this.fetchApi(`/history?max_items=${max_items}`)
return {
History: Object.values(await res.json()).map(
(item: HistoryTaskItem) => ({ ...item, taskType: "History" })
),
};
(item: HistoryTaskItem) => ({ ...item, taskType: 'History' })
)
}
} catch (error) {
console.error(error);
return { History: [] };
console.error(error)
return { History: [] }
}
}
@@ -360,8 +360,8 @@ class ComfyApi extends EventTarget {
* @returns System stats such as python version, OS, per device info
*/
async getSystemStats() {
const res = await this.fetchApi("/system_stats");
return await res.json();
const res = await this.fetchApi('/system_stats')
return await res.json()
}
/**
@@ -371,15 +371,15 @@ class ComfyApi extends EventTarget {
*/
async #postItem(type, body) {
try {
await this.fetchApi("/" + type, {
method: "POST",
await this.fetchApi('/' + type, {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined,
});
body: body ? JSON.stringify(body) : undefined
})
} catch (error) {
console.error(error);
console.error(error)
}
}
@@ -389,7 +389,7 @@ class ComfyApi extends EventTarget {
* @param {number} id The id of the item to delete
*/
async deleteItem(type, id) {
await this.#postItem(type, { delete: [id] });
await this.#postItem(type, { delete: [id] })
}
/**
@@ -397,14 +397,14 @@ class ComfyApi extends EventTarget {
* @param {string} type The type of list to clear, queue or history
*/
async clearItems(type) {
await this.#postItem(type, { clear: true });
await this.#postItem(type, { clear: true })
}
/**
* Interrupts the execution of the running prompt
*/
async interrupt() {
await this.#postItem("interrupt", null);
await this.#postItem('interrupt', null)
}
/**
@@ -412,7 +412,7 @@ class ComfyApi extends EventTarget {
* @returns { Promise<{ storage: "server" | "browser", users?: Promise<string, unknown>, migrated?: boolean }> }
*/
async getUserConfig() {
return (await this.fetchApi("/users")).json();
return (await this.fetchApi('/users')).json()
}
/**
@@ -421,13 +421,13 @@ class ComfyApi extends EventTarget {
* @returns The fetch response
*/
createUser(username) {
return this.fetchApi("/users", {
method: "POST",
return this.fetchApi('/users', {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json'
},
body: JSON.stringify({ username }),
});
body: JSON.stringify({ username })
})
}
/**
@@ -435,7 +435,7 @@ class ComfyApi extends EventTarget {
* @returns { Promise<string, unknown> } A dictionary of id -> value
*/
async getSettings() {
return (await this.fetchApi("/settings")).json();
return (await this.fetchApi('/settings')).json()
}
/**
@@ -444,7 +444,7 @@ class ComfyApi extends EventTarget {
* @returns { Promise<unknown> } The setting value
*/
async getSetting(id) {
return (await this.fetchApi(`/settings/${encodeURIComponent(id)}`)).json();
return (await this.fetchApi(`/settings/${encodeURIComponent(id)}`)).json()
}
/**
@@ -454,9 +454,9 @@ class ComfyApi extends EventTarget {
*/
async storeSettings(settings) {
return this.fetchApi(`/settings`, {
method: "POST",
body: JSON.stringify(settings),
});
method: 'POST',
body: JSON.stringify(settings)
})
}
/**
@@ -467,9 +467,9 @@ class ComfyApi extends EventTarget {
*/
async storeSetting(id, value) {
return this.fetchApi(`/settings/${encodeURIComponent(id)}`, {
method: "POST",
body: JSON.stringify(value),
});
method: 'POST',
body: JSON.stringify(value)
})
}
/**
@@ -479,7 +479,7 @@ class ComfyApi extends EventTarget {
* @returns { Promise<unknown> } The fetch response object
*/
async getUserData(file, options?) {
return this.fetchApi(`/userdata/${encodeURIComponent(file)}`, options);
return this.fetchApi(`/userdata/${encodeURIComponent(file)}`, options)
}
/**
@@ -493,26 +493,26 @@ class ComfyApi extends EventTarget {
file: string,
data: unknown,
options: RequestInit & {
overwrite?: boolean;
stringify?: boolean;
throwOnError?: boolean;
overwrite?: boolean
stringify?: boolean
throwOnError?: boolean
} = { overwrite: true, stringify: true, throwOnError: true }
): Promise<Response> {
const resp = await this.fetchApi(
`/userdata/${encodeURIComponent(file)}?overwrite=${options.overwrite}`,
{
method: "POST",
method: 'POST',
body: options?.stringify ? JSON.stringify(data) : data,
...options,
...options
}
);
)
if (resp.status !== 200 && options.throwOnError !== false) {
throw new Error(
`Error storing user data file '${file}': ${resp.status} ${(await resp).statusText}`
);
)
}
return resp;
return resp
}
/**
@@ -521,12 +521,12 @@ class ComfyApi extends EventTarget {
*/
async deleteUserData(file) {
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}`, {
method: "DELETE",
});
method: 'DELETE'
})
if (resp.status !== 204) {
throw new Error(
`Error removing user data file '${file}': ${resp.status} ${resp.statusText}`
);
)
}
}
@@ -539,10 +539,10 @@ class ComfyApi extends EventTarget {
const resp = await this.fetchApi(
`/userdata/${encodeURIComponent(source)}/move/${encodeURIComponent(dest)}?overwrite=${options?.overwrite}`,
{
method: "POST",
method: 'POST'
}
);
return resp;
)
return resp
}
/**
@@ -566,17 +566,17 @@ class ComfyApi extends EventTarget {
`/userdata?${new URLSearchParams({
recurse,
dir,
split,
split
})}`
);
if (resp.status === 404) return [];
)
if (resp.status === 404) return []
if (resp.status !== 200) {
throw new Error(
`Error getting user data list '${dir}': ${resp.status} ${resp.statusText}`
);
)
}
return resp.json();
return resp.json()
}
}
export const api = new ComfyApi();
export const api = new ComfyApi()

File diff suppressed because it is too large Load Diff

View File

@@ -1,278 +1,278 @@
import type { ComfyApp } from "./app";
import { api } from "./api";
import { clone } from "./utils";
import { LGraphCanvas, LiteGraph } from "@comfyorg/litegraph";
import { ComfyWorkflow } from "./workflows";
import type { ComfyApp } from './app'
import { api } from './api'
import { clone } from './utils'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
import { ComfyWorkflow } from './workflows'
export class ChangeTracker {
static MAX_HISTORY = 50;
#app: ComfyApp;
undo = [];
redo = [];
activeState = null;
isOurLoad = false;
workflow: ComfyWorkflow | null;
static MAX_HISTORY = 50
#app: ComfyApp
undo = []
redo = []
activeState = null
isOurLoad = false
workflow: ComfyWorkflow | null
ds: { scale: number; offset: [number, number] };
nodeOutputs: any;
ds: { scale: number; offset: [number, number] }
nodeOutputs: any
get app() {
return this.#app ?? this.workflow.manager.app;
return this.#app ?? this.workflow.manager.app
}
constructor(workflow: ComfyWorkflow) {
this.workflow = workflow;
this.workflow = workflow
}
#setApp(app) {
this.#app = app;
this.#app = app
}
store() {
this.ds = {
scale: this.app.canvas.ds.scale,
offset: [...this.app.canvas.ds.offset],
};
offset: [...this.app.canvas.ds.offset]
}
}
restore() {
if (this.ds) {
this.app.canvas.ds.scale = this.ds.scale;
this.app.canvas.ds.offset = this.ds.offset;
this.app.canvas.ds.scale = this.ds.scale
this.app.canvas.ds.offset = this.ds.offset
}
if (this.nodeOutputs) {
this.app.nodeOutputs = this.nodeOutputs;
this.app.nodeOutputs = this.nodeOutputs
}
}
checkState() {
if (!this.app.graph) return;
if (!this.app.graph) return
const currentState = this.app.graph.serialize();
const currentState = this.app.graph.serialize()
if (!this.activeState) {
this.activeState = clone(currentState);
return;
this.activeState = clone(currentState)
return
}
if (!ChangeTracker.graphEqual(this.activeState, currentState)) {
this.undo.push(this.activeState);
this.undo.push(this.activeState)
if (this.undo.length > ChangeTracker.MAX_HISTORY) {
this.undo.shift();
this.undo.shift()
}
this.activeState = clone(currentState);
this.redo.length = 0;
this.workflow.unsaved = true;
this.activeState = clone(currentState)
this.redo.length = 0
this.workflow.unsaved = true
api.dispatchEvent(
new CustomEvent("graphChanged", { detail: this.activeState })
);
new CustomEvent('graphChanged', { detail: this.activeState })
)
}
}
async updateState(source, target) {
const prevState = source.pop();
const prevState = source.pop()
if (prevState) {
target.push(this.activeState);
this.isOurLoad = true;
await this.app.loadGraphData(prevState, false, false, this.workflow);
this.activeState = prevState;
target.push(this.activeState)
this.isOurLoad = true
await this.app.loadGraphData(prevState, false, false, this.workflow)
this.activeState = prevState
}
}
async undoRedo(e) {
if (e.ctrlKey || e.metaKey) {
if (e.key === "y") {
this.updateState(this.redo, this.undo);
return true;
} else if (e.key === "z") {
this.updateState(this.undo, this.redo);
return true;
if (e.key === 'y') {
this.updateState(this.redo, this.undo)
return true
} else if (e.key === 'z') {
this.updateState(this.undo, this.redo)
return true
}
}
}
static init(app: ComfyApp) {
const changeTracker = () =>
app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker;
globalTracker.#setApp(app);
app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker
globalTracker.#setApp(app)
const loadGraphData = app.loadGraphData;
const loadGraphData = app.loadGraphData
app.loadGraphData = async function () {
const v = await loadGraphData.apply(this, arguments);
const ct = changeTracker();
const v = await loadGraphData.apply(this, arguments)
const ct = changeTracker()
if (ct.isOurLoad) {
ct.isOurLoad = false;
ct.isOurLoad = false
} else {
ct.checkState();
ct.checkState()
}
return v;
};
return v
}
let keyIgnored = false;
let keyIgnored = false
window.addEventListener(
"keydown",
'keydown',
(e) => {
requestAnimationFrame(async () => {
let activeEl;
let activeEl
// If we are auto queue in change mode then we do want to trigger on inputs
if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === "instant") {
activeEl = document.activeElement;
if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === 'instant') {
activeEl = document.activeElement
if (
activeEl?.tagName === "INPUT" ||
activeEl?.["type"] === "textarea"
activeEl?.tagName === 'INPUT' ||
activeEl?.['type'] === 'textarea'
) {
// Ignore events on inputs, they have their native history
return;
return
}
}
keyIgnored =
e.key === "Control" ||
e.key === "Shift" ||
e.key === "Alt" ||
e.key === "Meta";
if (keyIgnored) return;
e.key === 'Control' ||
e.key === 'Shift' ||
e.key === 'Alt' ||
e.key === 'Meta'
if (keyIgnored) return
// Check if this is a ctrl+z ctrl+y
if (await changeTracker().undoRedo(e)) return;
if (await changeTracker().undoRedo(e)) return
// If our active element is some type of input then handle changes after they're done
if (ChangeTracker.bindInput(app, activeEl)) return;
changeTracker().checkState();
});
if (ChangeTracker.bindInput(app, activeEl)) return
changeTracker().checkState()
})
},
true
);
)
window.addEventListener("keyup", (e) => {
window.addEventListener('keyup', (e) => {
if (keyIgnored) {
keyIgnored = false;
changeTracker().checkState();
keyIgnored = false
changeTracker().checkState()
}
});
})
// Handle clicking DOM elements (e.g. widgets)
window.addEventListener("mouseup", () => {
changeTracker().checkState();
});
window.addEventListener('mouseup', () => {
changeTracker().checkState()
})
// Handle prompt queue event for dynamic widget changes
api.addEventListener("promptQueued", () => {
changeTracker().checkState();
});
api.addEventListener('promptQueued', () => {
changeTracker().checkState()
})
api.addEventListener("graphCleared", () => {
changeTracker().checkState();
});
api.addEventListener('graphCleared', () => {
changeTracker().checkState()
})
// Handle litegraph clicks
// @ts-ignore
const processMouseUp = LGraphCanvas.prototype.processMouseUp;
const processMouseUp = LGraphCanvas.prototype.processMouseUp
// @ts-ignore
LGraphCanvas.prototype.processMouseUp = function (e) {
const v = processMouseUp.apply(this, arguments);
changeTracker().checkState();
return v;
};
const v = processMouseUp.apply(this, arguments)
changeTracker().checkState()
return v
}
// @ts-ignore
const processMouseDown = LGraphCanvas.prototype.processMouseDown;
const processMouseDown = LGraphCanvas.prototype.processMouseDown
// @ts-ignore
LGraphCanvas.prototype.processMouseDown = function (e) {
const v = processMouseDown.apply(this, arguments);
changeTracker().checkState();
return v;
};
const v = processMouseDown.apply(this, arguments)
changeTracker().checkState()
return v
}
// Handle litegraph context menu for COMBO widgets
const close = LiteGraph.ContextMenu.prototype.close;
const close = LiteGraph.ContextMenu.prototype.close
LiteGraph.ContextMenu.prototype.close = function (e) {
const v = close.apply(this, arguments);
changeTracker().checkState();
return v;
};
const v = close.apply(this, arguments)
changeTracker().checkState()
return v
}
// Detects nodes being added via the node search dialog
const onNodeAdded = LiteGraph.LGraph.prototype.onNodeAdded;
const onNodeAdded = LiteGraph.LGraph.prototype.onNodeAdded
LiteGraph.LGraph.prototype.onNodeAdded = function () {
const v = onNodeAdded?.apply(this, arguments);
const v = onNodeAdded?.apply(this, arguments)
if (!app?.configuringGraph) {
const ct = changeTracker();
const ct = changeTracker()
if (!ct.isOurLoad) {
ct.checkState();
ct.checkState()
}
}
return v;
};
return v
}
// Store node outputs
api.addEventListener("executed", ({ detail }) => {
const prompt = app.workflowManager.queuedPrompts[detail.prompt_id];
if (!prompt?.workflow) return;
const nodeOutputs = (prompt.workflow.changeTracker.nodeOutputs ??= {});
const output = nodeOutputs[detail.node];
api.addEventListener('executed', ({ detail }) => {
const prompt = app.workflowManager.queuedPrompts[detail.prompt_id]
if (!prompt?.workflow) return
const nodeOutputs = (prompt.workflow.changeTracker.nodeOutputs ??= {})
const output = nodeOutputs[detail.node]
if (detail.merge && output) {
for (const k in detail.output ?? {}) {
const v = output[k];
const v = output[k]
if (v instanceof Array) {
output[k] = v.concat(detail.output[k]);
output[k] = v.concat(detail.output[k])
} else {
output[k] = detail.output[k];
output[k] = detail.output[k]
}
}
} else {
nodeOutputs[detail.node] = detail.output;
nodeOutputs[detail.node] = detail.output
}
});
})
}
static bindInput(app, activeEl) {
if (
activeEl &&
activeEl.tagName !== "CANVAS" &&
activeEl.tagName !== "BODY"
activeEl.tagName !== 'CANVAS' &&
activeEl.tagName !== 'BODY'
) {
for (const evt of ["change", "input", "blur"]) {
for (const evt of ['change', 'input', 'blur']) {
if (`on${evt}` in activeEl) {
const listener = () => {
app.workflowManager.activeWorkflow.changeTracker.checkState();
activeEl.removeEventListener(evt, listener);
};
activeEl.addEventListener(evt, listener);
return true;
app.workflowManager.activeWorkflow.changeTracker.checkState()
activeEl.removeEventListener(evt, listener)
}
activeEl.addEventListener(evt, listener)
return true
}
}
}
}
static graphEqual(a, b, path = "") {
if (a === b) return true;
static graphEqual(a, b, path = '') {
if (a === b) return true
if (typeof a == "object" && a && typeof b == "object" && b) {
const keys = Object.getOwnPropertyNames(a);
if (typeof a == 'object' && a && typeof b == 'object' && b) {
const keys = Object.getOwnPropertyNames(a)
if (keys.length != Object.getOwnPropertyNames(b).length) {
return false;
return false
}
for (const key of keys) {
let av = a[key];
let bv = b[key];
if (!path && key === "nodes") {
let av = a[key]
let bv = b[key]
if (!path && key === 'nodes') {
// Nodes need to be sorted as the order changes when selecting nodes
av = [...av].sort((a, b) => a.id - b.id);
bv = [...bv].sort((a, b) => a.id - b.id);
} else if (path === "extra.ds") {
av = [...av].sort((a, b) => a.id - b.id)
bv = [...bv].sort((a, b) => a.id - b.id)
} else if (path === 'extra.ds') {
// Ignore view changes
continue;
continue
}
if (!ChangeTracker.graphEqual(av, bv, path + (path ? "." : "") + key)) {
return false;
if (!ChangeTracker.graphEqual(av, bv, path + (path ? '.' : '') + key)) {
return false
}
}
return true;
return true
}
return false;
return false
}
}
const globalTracker = new ChangeTracker({} as ComfyWorkflow);
const globalTracker = new ChangeTracker({} as ComfyWorkflow)

View File

@@ -1,4 +1,4 @@
import type { ComfyWorkflowJSON } from "@/types/comfyWorkflow";
import type { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
export const defaultGraph: ComfyWorkflowJSON = {
last_node_id: 9,
@@ -6,132 +6,132 @@ export const defaultGraph: ComfyWorkflowJSON = {
nodes: [
{
id: 7,
type: "CLIPTextEncode",
type: 'CLIPTextEncode',
pos: [413, 389],
size: [425.27801513671875, 180.6060791015625],
flags: {},
order: 3,
mode: 0,
inputs: [{ name: "clip", type: "CLIP", link: 5 }],
inputs: [{ name: 'clip', type: 'CLIP', link: 5 }],
outputs: [
{
name: "CONDITIONING",
type: "CONDITIONING",
name: 'CONDITIONING',
type: 'CONDITIONING',
links: [6],
slot_index: 0,
},
slot_index: 0
}
],
properties: {},
widgets_values: ["text, watermark"],
widgets_values: ['text, watermark']
},
{
id: 6,
type: "CLIPTextEncode",
type: 'CLIPTextEncode',
pos: [415, 186],
size: [422.84503173828125, 164.31304931640625],
flags: {},
order: 2,
mode: 0,
inputs: [{ name: "clip", type: "CLIP", link: 3 }],
inputs: [{ name: 'clip', type: 'CLIP', link: 3 }],
outputs: [
{
name: "CONDITIONING",
type: "CONDITIONING",
name: 'CONDITIONING',
type: 'CONDITIONING',
links: [4],
slot_index: 0,
},
slot_index: 0
}
],
properties: {},
widgets_values: [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,",
],
'beautiful scenery nature glass bottle landscape, , purple galaxy bottle,'
]
},
{
id: 5,
type: "EmptyLatentImage",
type: 'EmptyLatentImage',
pos: [473, 609],
size: [315, 106],
flags: {},
order: 1,
mode: 0,
outputs: [{ name: "LATENT", type: "LATENT", links: [2], slot_index: 0 }],
outputs: [{ name: 'LATENT', type: 'LATENT', links: [2], slot_index: 0 }],
properties: {},
widgets_values: [512, 512, 1],
widgets_values: [512, 512, 1]
},
{
id: 3,
type: "KSampler",
type: 'KSampler',
pos: [863, 186],
size: [315, 262],
flags: {},
order: 4,
mode: 0,
inputs: [
{ name: "model", type: "MODEL", link: 1 },
{ name: "positive", type: "CONDITIONING", link: 4 },
{ name: "negative", type: "CONDITIONING", link: 6 },
{ name: "latent_image", type: "LATENT", link: 2 },
{ name: 'model', type: 'MODEL', link: 1 },
{ name: 'positive', type: 'CONDITIONING', link: 4 },
{ name: 'negative', type: 'CONDITIONING', link: 6 },
{ name: 'latent_image', type: 'LATENT', link: 2 }
],
outputs: [{ name: "LATENT", type: "LATENT", links: [7], slot_index: 0 }],
outputs: [{ name: 'LATENT', type: 'LATENT', links: [7], slot_index: 0 }],
properties: {},
widgets_values: [156680208700286, true, 20, 8, "euler", "normal", 1],
widgets_values: [156680208700286, true, 20, 8, 'euler', 'normal', 1]
},
{
id: 8,
type: "VAEDecode",
type: 'VAEDecode',
pos: [1209, 188],
size: [210, 46],
flags: {},
order: 5,
mode: 0,
inputs: [
{ name: "samples", type: "LATENT", link: 7 },
{ name: "vae", type: "VAE", link: 8 },
{ name: 'samples', type: 'LATENT', link: 7 },
{ name: 'vae', type: 'VAE', link: 8 }
],
outputs: [{ name: "IMAGE", type: "IMAGE", links: [9], slot_index: 0 }],
properties: {},
outputs: [{ name: 'IMAGE', type: 'IMAGE', links: [9], slot_index: 0 }],
properties: {}
},
{
id: 9,
type: "SaveImage",
type: 'SaveImage',
pos: [1451, 189],
size: [210, 26],
flags: {},
order: 6,
mode: 0,
inputs: [{ name: "images", type: "IMAGE", link: 9 }],
properties: {},
inputs: [{ name: 'images', type: 'IMAGE', link: 9 }],
properties: {}
},
{
id: 4,
type: "CheckpointLoaderSimple",
type: 'CheckpointLoaderSimple',
pos: [26, 474],
size: [315, 98],
flags: {},
order: 0,
mode: 0,
outputs: [
{ name: "MODEL", type: "MODEL", links: [1], slot_index: 0 },
{ name: "CLIP", type: "CLIP", links: [3, 5], slot_index: 1 },
{ name: "VAE", type: "VAE", links: [8], slot_index: 2 },
{ name: 'MODEL', type: 'MODEL', links: [1], slot_index: 0 },
{ name: 'CLIP', type: 'CLIP', links: [3, 5], slot_index: 1 },
{ name: 'VAE', type: 'VAE', links: [8], slot_index: 2 }
],
properties: {},
widgets_values: ["v1-5-pruned-emaonly.ckpt"],
},
widgets_values: ['v1-5-pruned-emaonly.ckpt']
}
],
links: [
[1, 4, 0, 3, 0, "MODEL"],
[2, 5, 0, 3, 3, "LATENT"],
[3, 4, 1, 6, 0, "CLIP"],
[4, 6, 0, 3, 1, "CONDITIONING"],
[5, 4, 1, 7, 0, "CLIP"],
[6, 7, 0, 3, 2, "CONDITIONING"],
[7, 3, 0, 8, 0, "LATENT"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
[1, 4, 0, 3, 0, 'MODEL'],
[2, 5, 0, 3, 3, 'LATENT'],
[3, 4, 1, 6, 0, 'CLIP'],
[4, 6, 0, 3, 1, 'CONDITIONING'],
[5, 4, 1, 7, 0, 'CLIP'],
[6, 7, 0, 3, 2, 'CONDITIONING'],
[7, 3, 0, 8, 0, 'LATENT'],
[8, 4, 2, 8, 1, 'VAE'],
[9, 8, 0, 9, 0, 'IMAGE']
],
groups: [],
config: {},
extra: {},
version: 0.4,
};
version: 0.4
}

View File

@@ -1,60 +1,60 @@
import { app, ANIM_PREVIEW_WIDGET } from "./app";
import { LGraphCanvas, LGraphNode, LiteGraph } from "@comfyorg/litegraph";
import type { Vector4 } from "@comfyorg/litegraph";
import { app, ANIM_PREVIEW_WIDGET } from './app'
import { LGraphCanvas, LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type { Vector4 } from '@comfyorg/litegraph'
const SIZE = Symbol();
const SIZE = Symbol()
interface Rect {
height: number;
width: number;
x: number;
y: number;
height: number
width: number
x: number
y: number
}
export interface DOMWidget<T = HTMLElement> {
type: string;
name: string;
computedHeight?: number;
element?: T;
options: any;
value?: any;
y?: number;
callback?: (value: any) => void;
type: string
name: string
computedHeight?: number
element?: T
options: any
value?: any
y?: number
callback?: (value: any) => void
draw?: (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
widgetWidth: number,
y: number,
widgetHeight: number
) => void;
onRemove?: () => void;
) => void
onRemove?: () => void
}
function intersect(a: Rect, b: Rect): Vector4 | null {
const x = Math.max(a.x, b.x);
const num1 = Math.min(a.x + a.width, b.x + b.width);
const y = Math.max(a.y, b.y);
const num2 = Math.min(a.y + a.height, b.y + b.height);
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y];
else return null;
const x = Math.max(a.x, b.x)
const num1 = Math.min(a.x + a.width, b.x + b.width)
const y = Math.max(a.y, b.y)
const num2 = Math.min(a.y + a.height, b.y + b.height)
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y]
else return null
}
function getClipPath(node: LGraphNode, element: HTMLElement): string {
const selectedNode: LGraphNode = Object.values(
app.canvas.selected_nodes
)[0] as LGraphNode;
)[0] as LGraphNode
if (selectedNode && selectedNode !== node) {
const elRect = element.getBoundingClientRect();
const MARGIN = 7;
const scale = app.canvas.ds.scale;
const elRect = element.getBoundingClientRect()
const MARGIN = 7
const scale = app.canvas.ds.scale
const bounding = selectedNode.getBounding();
const bounding = selectedNode.getBounding()
const intersection = intersect(
{
x: elRect.x / scale,
y: elRect.y / scale,
width: elRect.width / scale,
height: elRect.height / scale,
height: elRect.height / scale
},
{
x: selectedNode.pos[0] + app.canvas.ds.offset[0] - MARGIN,
@@ -64,197 +64,197 @@ function getClipPath(node: LGraphNode, element: HTMLElement): string {
LiteGraph.NODE_TITLE_HEIGHT -
MARGIN,
width: bounding[2] + MARGIN + MARGIN,
height: bounding[3] + MARGIN + MARGIN,
height: bounding[3] + MARGIN + MARGIN
}
);
)
if (!intersection) {
return "";
return ''
}
const widgetRect = element.getBoundingClientRect();
const clipX = elRect.left + intersection[0] - widgetRect.x / scale + "px";
const clipY = elRect.top + intersection[1] - widgetRect.y / scale + "px";
const clipWidth = intersection[2] + "px";
const clipHeight = intersection[3] + "px";
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`;
return path;
const widgetRect = element.getBoundingClientRect()
const clipX = elRect.left + intersection[0] - widgetRect.x / scale + 'px'
const clipY = elRect.top + intersection[1] - widgetRect.y / scale + 'px'
const clipWidth = intersection[2] + 'px'
const clipHeight = intersection[3] + 'px'
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`
return path
}
return "";
return ''
}
function computeSize(size: [number, number]): void {
if (this.widgets?.[0]?.last_y == null) return;
if (this.widgets?.[0]?.last_y == null) return
let y = this.widgets[0].last_y;
let freeSpace = size[1] - y;
let y = this.widgets[0].last_y
let freeSpace = size[1] - y
let widgetHeight = 0;
let dom = [];
let widgetHeight = 0
let dom = []
for (const w of this.widgets) {
if (w.type === "converted-widget") {
if (w.type === 'converted-widget') {
// Ignore
delete w.computedHeight;
delete w.computedHeight
} else if (w.computeSize) {
widgetHeight += w.computeSize()[1] + 4;
widgetHeight += w.computeSize()[1] + 4
} else if (w.element) {
// Extract DOM widget size info
const styles = getComputedStyle(w.element);
const styles = getComputedStyle(w.element)
let minHeight =
w.options.getMinHeight?.() ??
parseInt(styles.getPropertyValue("--comfy-widget-min-height"));
parseInt(styles.getPropertyValue('--comfy-widget-min-height'))
let maxHeight =
w.options.getMaxHeight?.() ??
parseInt(styles.getPropertyValue("--comfy-widget-max-height"));
parseInt(styles.getPropertyValue('--comfy-widget-max-height'))
let prefHeight =
w.options.getHeight?.() ??
styles.getPropertyValue("--comfy-widget-height");
if (prefHeight.endsWith?.("%")) {
styles.getPropertyValue('--comfy-widget-height')
if (prefHeight.endsWith?.('%')) {
prefHeight =
size[1] *
(parseFloat(prefHeight.substring(0, prefHeight.length - 1)) / 100);
(parseFloat(prefHeight.substring(0, prefHeight.length - 1)) / 100)
} else {
prefHeight = parseInt(prefHeight);
prefHeight = parseInt(prefHeight)
if (isNaN(minHeight)) {
minHeight = prefHeight;
minHeight = prefHeight
}
}
if (isNaN(minHeight)) {
minHeight = 50;
minHeight = 50
}
if (!isNaN(maxHeight)) {
if (!isNaN(prefHeight)) {
prefHeight = Math.min(prefHeight, maxHeight);
prefHeight = Math.min(prefHeight, maxHeight)
} else {
prefHeight = maxHeight;
prefHeight = maxHeight
}
}
dom.push({
minHeight,
prefHeight,
w,
});
w
})
} else {
widgetHeight += LiteGraph.NODE_WIDGET_HEIGHT + 4;
widgetHeight += LiteGraph.NODE_WIDGET_HEIGHT + 4
}
}
freeSpace -= widgetHeight;
freeSpace -= widgetHeight
// Calculate sizes with all widgets at their min height
const prefGrow = []; // Nodes that want to grow to their prefd size
const canGrow = []; // Nodes that can grow to auto size
let growBy = 0;
const prefGrow = [] // Nodes that want to grow to their prefd size
const canGrow = [] // Nodes that can grow to auto size
let growBy = 0
for (const d of dom) {
freeSpace -= d.minHeight;
freeSpace -= d.minHeight
if (isNaN(d.prefHeight)) {
canGrow.push(d);
d.w.computedHeight = d.minHeight;
canGrow.push(d)
d.w.computedHeight = d.minHeight
} else {
const diff = d.prefHeight - d.minHeight;
const diff = d.prefHeight - d.minHeight
if (diff > 0) {
prefGrow.push(d);
growBy += diff;
d.diff = diff;
prefGrow.push(d)
growBy += diff
d.diff = diff
} else {
d.w.computedHeight = d.minHeight;
d.w.computedHeight = d.minHeight
}
}
}
if (this.imgs && !this.widgets.find((w) => w.name === ANIM_PREVIEW_WIDGET)) {
// Allocate space for image
freeSpace -= 220;
freeSpace -= 220
}
this.freeWidgetSpace = freeSpace;
this.freeWidgetSpace = freeSpace
if (freeSpace < 0) {
// Not enough space for all widgets so we need to grow
size[1] -= freeSpace;
this.graph.setDirtyCanvas(true);
size[1] -= freeSpace
this.graph.setDirtyCanvas(true)
} else {
// Share the space between each
const growDiff = freeSpace - growBy;
const growDiff = freeSpace - growBy
if (growDiff > 0) {
// All pref sizes can be fulfilled
freeSpace = growDiff;
freeSpace = growDiff
for (const d of prefGrow) {
d.w.computedHeight = d.prefHeight;
d.w.computedHeight = d.prefHeight
}
} else {
// We need to grow evenly
const shared = -growDiff / prefGrow.length;
const shared = -growDiff / prefGrow.length
for (const d of prefGrow) {
d.w.computedHeight = d.prefHeight - shared;
d.w.computedHeight = d.prefHeight - shared
}
freeSpace = 0;
freeSpace = 0
}
if (freeSpace > 0 && canGrow.length) {
// Grow any that are auto height
const shared = freeSpace / canGrow.length;
const shared = freeSpace / canGrow.length
for (const d of canGrow) {
d.w.computedHeight += shared;
d.w.computedHeight += shared
}
}
}
// Position each of the widgets
for (const w of this.widgets) {
w.y = y;
w.y = y
if (w.computedHeight) {
y += w.computedHeight;
y += w.computedHeight
} else if (w.computeSize) {
y += w.computeSize()[1] + 4;
y += w.computeSize()[1] + 4
} else {
y += LiteGraph.NODE_WIDGET_HEIGHT + 4;
y += LiteGraph.NODE_WIDGET_HEIGHT + 4
}
}
}
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
const elementWidgets = new Set();
const elementWidgets = new Set()
//@ts-ignore
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes;
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes
//@ts-ignore
LGraphCanvas.prototype.computeVisibleNodes = function (): LGraphNode[] {
const visibleNodes = computeVisibleNodes.apply(this, arguments);
const visibleNodes = computeVisibleNodes.apply(this, arguments)
// @ts-ignore
for (const node of app.graph._nodes) {
if (elementWidgets.has(node)) {
const hidden = visibleNodes.indexOf(node) === -1;
const hidden = visibleNodes.indexOf(node) === -1
for (const w of node.widgets) {
// @ts-ignore
if (w.element) {
// @ts-ignore
w.element.hidden = hidden;
w.element.hidden = hidden
// @ts-ignore
w.element.style.display = hidden ? "none" : undefined;
w.element.style.display = hidden ? 'none' : undefined
if (hidden) {
w.options.onHide?.(w);
w.options.onHide?.(w)
}
}
}
}
}
return visibleNodes;
};
return visibleNodes
}
let enableDomClipping = true;
let enableDomClipping = true
export function addDomClippingSetting(): void {
app.ui.settings.addSetting({
id: "Comfy.DOMClippingEnabled",
name: "Enable DOM element clipping (enabling may reduce performance)",
type: "boolean",
id: 'Comfy.DOMClippingEnabled',
name: 'Enable DOM element clipping (enabling may reduce performance)',
type: 'boolean',
defaultValue: enableDomClipping,
onChange(value) {
enableDomClipping = !!value;
},
});
enableDomClipping = !!value
}
})
}
//@ts-ignore
@@ -264,33 +264,33 @@ LGraphNode.prototype.addDOMWidget = function (
element: HTMLElement,
options: Record<string, any>
): DOMWidget {
options = { hideOnZoom: true, selectOn: ["focus", "click"], ...options };
options = { hideOnZoom: true, selectOn: ['focus', 'click'], ...options }
if (!element.parentElement) {
document.body.append(element);
document.body.append(element)
}
element.hidden = true;
element.style.display = "none";
element.hidden = true
element.style.display = 'none'
let mouseDownHandler;
let mouseDownHandler
if (element.blur) {
mouseDownHandler = (event) => {
if (!element.contains(event.target)) {
element.blur();
element.blur()
}
};
document.addEventListener("mousedown", mouseDownHandler);
}
document.addEventListener('mousedown', mouseDownHandler)
}
const widget: DOMWidget = {
type,
name,
get value() {
return options.getValue?.() ?? undefined;
return options.getValue?.() ?? undefined
},
set value(v) {
options.setValue?.(v);
widget.callback?.(widget.value);
options.setValue?.(v)
widget.callback?.(widget.value)
},
draw: function (
ctx: CanvasRenderingContext2D,
@@ -300,99 +300,99 @@ LGraphNode.prototype.addDOMWidget = function (
widgetHeight: number
) {
if (widget.computedHeight == null) {
computeSize.call(node, node.size);
computeSize.call(node, node.size)
}
const hidden =
node.flags?.collapsed ||
(!!options.hideOnZoom && app.canvas.ds.scale < 0.5) ||
widget.computedHeight <= 0 ||
widget.type === "converted-widget" ||
widget.type === "hidden";
element.hidden = hidden;
element.style.display = hidden ? "none" : null;
widget.type === 'converted-widget' ||
widget.type === 'hidden'
element.hidden = hidden
element.style.display = hidden ? 'none' : null
if (hidden) {
widget.options.onHide?.(widget);
return;
widget.options.onHide?.(widget)
return
}
const margin = 10;
const elRect = ctx.canvas.getBoundingClientRect();
const margin = 10
const elRect = ctx.canvas.getBoundingClientRect()
const transform = new DOMMatrix()
.scaleSelf(
elRect.width / ctx.canvas.width,
elRect.height / ctx.canvas.height
)
.multiplySelf(ctx.getTransform())
.translateSelf(margin, margin + y);
.translateSelf(margin, margin + y)
const scale = new DOMMatrix().scaleSelf(transform.a, transform.d);
const scale = new DOMMatrix().scaleSelf(transform.a, transform.d)
Object.assign(element.style, {
transformOrigin: "0 0",
transformOrigin: '0 0',
transform: scale,
left: `${transform.a + transform.e + elRect.left}px`,
top: `${transform.d + transform.f + elRect.top}px`,
width: `${widgetWidth - margin * 2}px`,
height: `${(widget.computedHeight ?? 50) - margin * 2}px`,
position: "absolute",
position: 'absolute',
// @ts-ignore
zIndex: app.graph._nodes.indexOf(node),
});
zIndex: app.graph._nodes.indexOf(node)
})
if (enableDomClipping) {
element.style.clipPath = getClipPath(node, element);
element.style.willChange = "clip-path";
element.style.clipPath = getClipPath(node, element)
element.style.willChange = 'clip-path'
}
this.options.onDraw?.(widget);
this.options.onDraw?.(widget)
},
element,
options,
onRemove() {
if (mouseDownHandler) {
document.removeEventListener("mousedown", mouseDownHandler);
document.removeEventListener('mousedown', mouseDownHandler)
}
element.remove();
},
};
element.remove()
}
}
for (const evt of options.selectOn) {
element.addEventListener(evt, () => {
app.canvas.selectNode(this);
app.canvas.bringToFront(this);
});
app.canvas.selectNode(this)
app.canvas.bringToFront(this)
})
}
this.addCustomWidget(widget);
elementWidgets.add(this);
this.addCustomWidget(widget)
elementWidgets.add(this)
const collapse = this.collapse;
const collapse = this.collapse
this.collapse = function () {
collapse.apply(this, arguments);
collapse.apply(this, arguments)
if (this.flags?.collapsed) {
element.hidden = true;
element.style.display = "none";
element.hidden = true
element.style.display = 'none'
}
};
}
const onRemoved = this.onRemoved;
const onRemoved = this.onRemoved
this.onRemoved = function () {
element.remove();
elementWidgets.delete(this);
onRemoved?.apply(this, arguments);
};
element.remove()
elementWidgets.delete(this)
onRemoved?.apply(this, arguments)
}
if (!this[SIZE]) {
this[SIZE] = true;
const onResize = this.onResize;
this[SIZE] = true
const onResize = this.onResize
this.onResize = function (size) {
options.beforeResize?.call(widget, this);
computeSize.call(this, size);
onResize?.apply(this, arguments);
options.afterResize?.call(widget, this);
};
options.beforeResize?.call(widget, this)
computeSize.call(this, size)
onResize?.apply(this, arguments)
options.afterResize?.call(widget, this)
}
}
return widget;
};
return widget
}

View File

@@ -1,43 +1,43 @@
import { useWorkspaceStore } from "@/stores/workspaceStateStore";
import { ExtensionManager, SidebarTabExtension } from "@/types/extensionTypes";
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import { ExtensionManager, SidebarTabExtension } from '@/types/extensionTypes'
export class ExtensionManagerImpl implements ExtensionManager {
private sidebarTabs: SidebarTabExtension[] = [];
private workspaceStore = useWorkspaceStore();
private sidebarTabs: SidebarTabExtension[] = []
private workspaceStore = useWorkspaceStore()
registerSidebarTab(tab: SidebarTabExtension) {
this.sidebarTabs.push(tab);
this.updateSidebarOrder();
this.sidebarTabs.push(tab)
this.updateSidebarOrder()
}
unregisterSidebarTab(id: string) {
const index = this.sidebarTabs.findIndex((tab) => tab.id === id);
const index = this.sidebarTabs.findIndex((tab) => tab.id === id)
if (index !== -1) {
const tab = this.sidebarTabs[index];
if (tab.type === "custom" && tab.destroy) {
tab.destroy();
const tab = this.sidebarTabs[index]
if (tab.type === 'custom' && tab.destroy) {
tab.destroy()
}
this.sidebarTabs.splice(index, 1);
this.updateSidebarOrder();
this.sidebarTabs.splice(index, 1)
this.updateSidebarOrder()
}
}
getSidebarTabs() {
return this.sidebarTabs.sort((a, b) => {
const orderA = this.workspaceStore.sidebarTabsOrder.indexOf(a.id);
const orderB = this.workspaceStore.sidebarTabsOrder.indexOf(b.id);
return orderA - orderB;
});
const orderA = this.workspaceStore.sidebarTabsOrder.indexOf(a.id)
const orderB = this.workspaceStore.sidebarTabsOrder.indexOf(b.id)
return orderA - orderB
})
}
private updateSidebarOrder() {
const currentOrder = this.workspaceStore.sidebarTabsOrder;
const currentOrder = this.workspaceStore.sidebarTabsOrder
const newTabs = this.sidebarTabs.filter(
(tab) => !currentOrder.includes(tab.id)
);
)
this.workspaceStore.updateSidebarOrder([
...currentOrder,
...newTabs.map((tab) => tab.id),
]);
...newTabs.map((tab) => tab.id)
])
}
}

View File

@@ -1,8 +1,8 @@
import { $el, ComfyDialog } from "./ui";
import { api } from "./api";
import type { ComfyApp } from "./app";
import { $el, ComfyDialog } from './ui'
import { api } from './api'
import type { ComfyApp } from './app'
$el("style", {
$el('style', {
textContent: `
.comfy-logging-logs {
display: grid;
@@ -23,17 +23,17 @@ $el("style", {
padding: 5px;
}
`,
parent: document.body,
});
parent: document.body
})
// Stringify function supporting max depth and removal of circular references
// https://stackoverflow.com/a/57193345
function stringify(val, depth, replacer, space, onGetObjID?) {
depth = isNaN(+depth) ? 1 : depth;
var recursMap = new WeakMap();
depth = isNaN(+depth) ? 1 : depth
var recursMap = new WeakMap()
function _build(val, depth, o?, a?, r?) {
// (JSON.stringify() has it's own rules, which we respect here by using it for property iteration)
return !val || typeof val != "object"
return !val || typeof val != 'object'
? val
: ((r = recursMap.has(val)),
recursMap.set(val, true),
@@ -42,201 +42,201 @@ function stringify(val, depth, replacer, space, onGetObjID?) {
? (o = (onGetObjID && onGetObjID(val)) || null)
: JSON.stringify(val, function (k, v) {
if (a || depth > 0) {
if (replacer) v = replacer(k, v);
if (!k) return (a = Array.isArray(v)), (val = v);
!o && (o = a ? [] : {});
o[k] = _build(v, a ? depth : depth - 1);
if (replacer) v = replacer(k, v)
if (!k) return (a = Array.isArray(v)), (val = v)
!o && (o = a ? [] : {})
o[k] = _build(v, a ? depth : depth - 1)
}
}),
o === void 0 ? (a ? [] : {}) : o);
o === void 0 ? (a ? [] : {}) : o)
}
return JSON.stringify(_build(val, depth), null, space);
return JSON.stringify(_build(val, depth), null, space)
}
const jsonReplacer = (k, v, ui) => {
if (v instanceof Array && v.length === 1) {
v = v[0];
v = v[0]
}
if (v instanceof Date) {
v = v.toISOString();
v = v.toISOString()
if (ui) {
v = v.split("T")[1];
v = v.split('T')[1]
}
}
if (v instanceof Error) {
let err = "";
if (v.name) err += v.name + "\n";
if (v.message) err += v.message + "\n";
if (v.stack) err += v.stack + "\n";
let err = ''
if (v.name) err += v.name + '\n'
if (v.message) err += v.message + '\n'
if (v.stack) err += v.stack + '\n'
if (!err) {
err = v.toString();
err = v.toString()
}
v = err;
v = err
}
return v;
};
return v
}
const fileInput: HTMLInputElement = $el("input", {
type: "file",
accept: ".json",
style: { display: "none" },
parent: document.body,
}) as HTMLInputElement;
const fileInput: HTMLInputElement = $el('input', {
type: 'file',
accept: '.json',
style: { display: 'none' },
parent: document.body
}) as HTMLInputElement
class ComfyLoggingDialog extends ComfyDialog {
logging: any;
logging: any
constructor(logging) {
super();
this.logging = logging;
super()
this.logging = logging
}
clear() {
this.logging.clear();
this.show();
this.logging.clear()
this.show()
}
export() {
const blob = new Blob(
[stringify([...this.logging.entries], 20, jsonReplacer, "\t")],
[stringify([...this.logging.entries], 20, jsonReplacer, '\t')],
{
type: "application/json",
type: 'application/json'
}
);
const url = URL.createObjectURL(blob);
const a = $el("a", {
)
const url = URL.createObjectURL(blob)
const a = $el('a', {
href: url,
download: `comfyui-logs-${Date.now()}.json`,
style: { display: "none" },
parent: document.body,
});
a.click();
style: { display: 'none' },
parent: document.body
})
a.click()
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
a.remove()
window.URL.revokeObjectURL(url)
}, 0)
}
import() {
fileInput.onchange = () => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = () => {
fileInput.remove();
fileInput.remove()
try {
const obj = JSON.parse(reader.result as string);
const obj = JSON.parse(reader.result as string)
if (obj instanceof Array) {
this.show(obj);
this.show(obj)
} else {
throw new Error("Invalid file selected.");
throw new Error('Invalid file selected.')
}
} catch (error) {
alert("Unable to load logs: " + error.message);
alert('Unable to load logs: ' + error.message)
}
};
reader.readAsText(fileInput.files[0]);
};
fileInput.click();
}
reader.readAsText(fileInput.files[0])
}
fileInput.click()
}
createButtons() {
return [
$el("button", {
type: "button",
textContent: "Clear",
onclick: () => this.clear(),
$el('button', {
type: 'button',
textContent: 'Clear',
onclick: () => this.clear()
}),
$el("button", {
type: "button",
textContent: "Export logs...",
onclick: () => this.export(),
$el('button', {
type: 'button',
textContent: 'Export logs...',
onclick: () => this.export()
}),
$el("button", {
type: "button",
textContent: "View exported logs...",
onclick: () => this.import(),
$el('button', {
type: 'button',
textContent: 'View exported logs...',
onclick: () => this.import()
}),
...super.createButtons(),
];
...super.createButtons()
]
}
getTypeColor(type) {
switch (type) {
case "error":
return "red";
case "warn":
return "orange";
case "debug":
return "dodgerblue";
case 'error':
return 'red'
case 'warn':
return 'orange'
case 'debug':
return 'dodgerblue'
}
}
show(entries?: any[]) {
if (!entries) entries = this.logging.entries;
this.element.style.width = "100%";
if (!entries) entries = this.logging.entries
this.element.style.width = '100%'
const cols = {
source: "Source",
type: "Type",
timestamp: "Timestamp",
message: "Message",
};
const keys = Object.keys(cols);
source: 'Source',
type: 'Type',
timestamp: 'Timestamp',
message: 'Message'
}
const keys = Object.keys(cols)
const headers = Object.values(cols).map((title) =>
$el("div.comfy-logging-title", {
textContent: title,
$el('div.comfy-logging-title', {
textContent: title
})
);
)
const rows = entries.map((entry, i) => {
return $el(
"div.comfy-logging-log",
'div.comfy-logging-log',
{
$: (el) =>
el.style.setProperty(
"--row-bg",
`var(--tr-${i % 2 ? "even" : "odd"}-bg-color)`
),
'--row-bg',
`var(--tr-${i % 2 ? 'even' : 'odd'}-bg-color)`
)
},
keys.map((key) => {
let v = entry[key];
let color;
if (key === "type") {
color = this.getTypeColor(v);
let v = entry[key]
let color
if (key === 'type') {
color = this.getTypeColor(v)
} else {
v = jsonReplacer(key, v, true);
v = jsonReplacer(key, v, true)
if (typeof v === "object") {
v = stringify(v, 5, jsonReplacer, " ");
if (typeof v === 'object') {
v = stringify(v, 5, jsonReplacer, ' ')
}
}
return $el("div", {
return $el('div', {
style: {
color,
color
},
textContent: v,
});
textContent: v
})
})
);
});
)
})
const grid = $el(
"div.comfy-logging-logs",
'div.comfy-logging-logs',
{
style: {
gridTemplateColumns: `repeat(${headers.length}, 1fr)`,
},
gridTemplateColumns: `repeat(${headers.length}, 1fr)`
}
},
[...headers, ...rows]
);
const els = [grid];
)
const els = [grid]
if (!this.logging.enabled) {
els.unshift(
$el("h3", {
style: { textAlign: "center" },
textContent: "Logging is disabled",
$el('h3', {
style: { textAlign: 'center' },
textContent: 'Logging is disabled'
})
);
)
}
super.show($el("div", els));
super.show($el('div', els))
}
}
@@ -244,118 +244,118 @@ export class ComfyLogging {
/**
* @type Array<{ source: string, type: string, timestamp: Date, message: any }>
*/
entries = [];
entries = []
#enabled;
#console = {};
#enabled
#console = {}
app: ComfyApp;
dialog: ComfyLoggingDialog;
app: ComfyApp
dialog: ComfyLoggingDialog
get enabled() {
return this.#enabled;
return this.#enabled
}
set enabled(value) {
if (value === this.#enabled) return;
if (value === this.#enabled) return
if (value) {
this.patchConsole();
this.patchConsole()
} else {
this.unpatchConsole();
this.unpatchConsole()
}
this.#enabled = value;
this.#enabled = value
}
constructor(app) {
this.app = app;
this.app = app
this.dialog = new ComfyLoggingDialog(this);
this.addSetting();
this.catchUnhandled();
this.addInitData();
this.dialog = new ComfyLoggingDialog(this)
this.addSetting()
this.catchUnhandled()
this.addInitData()
}
addSetting() {
const settingId: string = "Comfy.Logging.Enabled";
const htmlSettingId = settingId.replaceAll(".", "-");
const settingId: string = 'Comfy.Logging.Enabled'
const htmlSettingId = settingId.replaceAll('.', '-')
const setting = this.app.ui.settings.addSetting({
id: settingId,
name: settingId,
defaultValue: true,
onChange: (value) => {
this.enabled = value;
this.enabled = value
},
type: (name, setter, value) => {
return $el("tr", [
$el("td", [
$el("label", {
textContent: "Logging",
for: htmlSettingId,
}),
return $el('tr', [
$el('td', [
$el('label', {
textContent: 'Logging',
for: htmlSettingId
})
]),
$el("td", [
$el("input", {
$el('td', [
$el('input', {
id: htmlSettingId,
type: "checkbox",
type: 'checkbox',
checked: value,
onchange: (event) => {
setter(event.target.checked);
},
setter(event.target.checked)
}
}),
$el("button", {
textContent: "View Logs",
$el('button', {
textContent: 'View Logs',
onclick: () => {
this.app.ui.settings.element.close();
this.dialog.show();
this.app.ui.settings.element.close()
this.dialog.show()
},
style: {
fontSize: "14px",
display: "block",
marginTop: "5px",
},
}),
]),
]);
},
});
this.enabled = setting.value;
fontSize: '14px',
display: 'block',
marginTop: '5px'
}
})
])
])
}
})
this.enabled = setting.value
}
patchConsole() {
// Capture common console outputs
const self = this;
for (const type of ["log", "warn", "error", "debug"]) {
const orig = console[type];
this.#console[type] = orig;
const self = this
for (const type of ['log', 'warn', 'error', 'debug']) {
const orig = console[type]
this.#console[type] = orig
console[type] = function () {
orig.apply(console, arguments);
self.addEntry("console", type, ...arguments);
};
orig.apply(console, arguments)
self.addEntry('console', type, ...arguments)
}
}
}
unpatchConsole() {
// Restore original console functions
for (const type of Object.keys(this.#console)) {
console[type] = this.#console[type];
console[type] = this.#console[type]
}
this.#console = {};
this.#console = {}
}
catchUnhandled() {
// Capture uncaught errors
window.addEventListener("error", (e) => {
this.addEntry("window", "error", e.error ?? "Unknown error");
return false;
});
window.addEventListener('error', (e) => {
this.addEntry('window', 'error', e.error ?? 'Unknown error')
return false
})
window.addEventListener("unhandledrejection", (e) => {
this.addEntry("unhandledrejection", "error", e.reason ?? "Unknown error");
});
window.addEventListener('unhandledrejection', (e) => {
this.addEntry('unhandledrejection', 'error', e.reason ?? 'Unknown error')
})
}
clear() {
this.entries = [];
this.entries = []
}
addEntry(source, type, ...args) {
@@ -364,20 +364,20 @@ export class ComfyLogging {
source,
type,
timestamp: new Date(),
message: args,
});
message: args
})
}
}
log(source, ...args) {
this.addEntry(source, "log", ...args);
this.addEntry(source, 'log', ...args)
}
async addInitData() {
if (!this.enabled) return;
const source = "ComfyUI.Logging";
this.addEntry(source, "debug", { UserAgent: navigator.userAgent });
const systemStats = await api.getSystemStats();
this.addEntry(source, "debug", systemStats);
if (!this.enabled) return
const source = 'ComfyUI.Logging'
this.addEntry(source, 'debug', { UserAgent: navigator.userAgent })
const systemStats = await api.getSystemStats()
this.addEntry(source, 'debug', systemStats)
}
}

View File

@@ -1,76 +1,76 @@
export function getFromFlacBuffer(buffer: ArrayBuffer): Record<string, string> {
const dataView = new DataView(buffer);
const dataView = new DataView(buffer)
// Verify the FLAC signature
const signature = String.fromCharCode(...new Uint8Array(buffer, 0, 4));
if (signature !== "fLaC") {
console.error("Not a valid FLAC file");
return;
const signature = String.fromCharCode(...new Uint8Array(buffer, 0, 4))
if (signature !== 'fLaC') {
console.error('Not a valid FLAC file')
return
}
// Parse metadata blocks
let offset = 4;
let vorbisComment = null;
let offset = 4
let vorbisComment = null
while (offset < dataView.byteLength) {
const isLastBlock = dataView.getUint8(offset) & 0x80;
const blockType = dataView.getUint8(offset) & 0x7f;
const blockSize = dataView.getUint32(offset, false) & 0xffffff;
offset += 4;
const isLastBlock = dataView.getUint8(offset) & 0x80
const blockType = dataView.getUint8(offset) & 0x7f
const blockSize = dataView.getUint32(offset, false) & 0xffffff
offset += 4
if (blockType === 4) {
// Vorbis Comment block type
vorbisComment = parseVorbisComment(
new DataView(buffer, offset, blockSize)
);
)
}
offset += blockSize;
if (isLastBlock) break;
offset += blockSize
if (isLastBlock) break
}
return vorbisComment;
return vorbisComment
}
export function getFromFlacFile(file: File): Promise<Record<string, string>> {
return new Promise((r) => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = function (event) {
const arrayBuffer = event.target.result as ArrayBuffer;
r(getFromFlacBuffer(arrayBuffer));
};
reader.readAsArrayBuffer(file);
});
const arrayBuffer = event.target.result as ArrayBuffer
r(getFromFlacBuffer(arrayBuffer))
}
reader.readAsArrayBuffer(file)
})
}
// Function to parse the Vorbis Comment block
function parseVorbisComment(dataView: DataView): Record<string, string> {
let offset = 0;
const vendorLength = dataView.getUint32(offset, true);
offset += 4;
const vendorString = getString(dataView, offset, vendorLength);
offset += vendorLength;
let offset = 0
const vendorLength = dataView.getUint32(offset, true)
offset += 4
const vendorString = getString(dataView, offset, vendorLength)
offset += vendorLength
const userCommentListLength = dataView.getUint32(offset, true);
offset += 4;
const comments = {};
const userCommentListLength = dataView.getUint32(offset, true)
offset += 4
const comments = {}
for (let i = 0; i < userCommentListLength; i++) {
const commentLength = dataView.getUint32(offset, true);
offset += 4;
const comment = getString(dataView, offset, commentLength);
offset += commentLength;
const commentLength = dataView.getUint32(offset, true)
offset += 4
const comment = getString(dataView, offset, commentLength)
offset += commentLength
const [key, value] = comment.split("=");
const [key, value] = comment.split('=')
comments[key] = value;
comments[key] = value
}
return comments;
return comments
}
function getString(dataView: DataView, offset: number, length: number): string {
let string = "";
let string = ''
for (let i = 0; i < length; i++) {
string += String.fromCharCode(dataView.getUint8(offset + i));
string += String.fromCharCode(dataView.getUint8(offset + i))
}
return string;
return string
}

View File

@@ -1,53 +1,53 @@
export function getFromPngBuffer(buffer: ArrayBuffer) {
// Get the PNG data as a Uint8Array
const pngData = new Uint8Array(buffer);
const dataView = new DataView(pngData.buffer);
const pngData = new Uint8Array(buffer)
const dataView = new DataView(pngData.buffer)
// Check that the PNG signature is present
if (dataView.getUint32(0) !== 0x89504e47) {
console.error("Not a valid PNG file");
return;
console.error('Not a valid PNG file')
return
}
// Start searching for chunks after the PNG signature
let offset = 8;
let txt_chunks: Record<string, string> = {};
let offset = 8
let txt_chunks: Record<string, string> = {}
// Loop through the chunks in the PNG file
while (offset < pngData.length) {
// Get the length of the chunk
const length = dataView.getUint32(offset);
const length = dataView.getUint32(offset)
// Get the chunk type
const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8));
if (type === "tEXt" || type == "comf" || type === "iTXt") {
const type = String.fromCharCode(...pngData.slice(offset + 4, offset + 8))
if (type === 'tEXt' || type == 'comf' || type === 'iTXt') {
// Get the keyword
let keyword_end = offset + 8;
let keyword_end = offset + 8
while (pngData[keyword_end] !== 0) {
keyword_end++;
keyword_end++
}
const keyword = String.fromCharCode(
...pngData.slice(offset + 8, keyword_end)
);
)
// Get the text
const contentArraySegment = pngData.slice(
keyword_end + 1,
offset + 8 + length
);
const contentJson = new TextDecoder("utf-8").decode(contentArraySegment);
txt_chunks[keyword] = contentJson;
)
const contentJson = new TextDecoder('utf-8').decode(contentArraySegment)
txt_chunks[keyword] = contentJson
}
offset += 12 + length;
offset += 12 + length
}
return txt_chunks;
return txt_chunks
}
export function getFromPngFile(file: File) {
return new Promise<Record<string, string>>((r) => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = (event) => {
r(getFromPngBuffer(event.target.result as ArrayBuffer));
};
r(getFromPngBuffer(event.target.result as ArrayBuffer))
}
reader.readAsArrayBuffer(file);
});
reader.readAsArrayBuffer(file)
})
}

View File

@@ -1,438 +1,436 @@
import { LiteGraph } from "@comfyorg/litegraph";
import { api } from "./api";
import { getFromPngFile } from "./metadata/png";
import { getFromFlacFile } from "./metadata/flac";
import { LiteGraph } from '@comfyorg/litegraph'
import { api } from './api'
import { getFromPngFile } from './metadata/png'
import { getFromFlacFile } from './metadata/flac'
// Original functions left in for backwards compatibility
export function getPngMetadata(file: File): Promise<Record<string, string>> {
return getFromPngFile(file);
return getFromPngFile(file)
}
export function getFlacMetadata(file: File): Promise<Record<string, string>> {
return getFromFlacFile(file);
return getFromFlacFile(file)
}
function parseExifData(exifData) {
// Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian)
const isLittleEndian = String.fromCharCode(...exifData.slice(0, 2)) === "II";
const isLittleEndian = String.fromCharCode(...exifData.slice(0, 2)) === 'II'
// Function to read 16-bit and 32-bit integers from binary data
function readInt(offset, isLittleEndian, length) {
let arr = exifData.slice(offset, offset + length);
let arr = exifData.slice(offset, offset + length)
if (length === 2) {
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16(
0,
isLittleEndian
);
)
} else if (length === 4) {
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32(
0,
isLittleEndian
);
)
}
}
// Read the offset to the first IFD (Image File Directory)
const ifdOffset = readInt(4, isLittleEndian, 4);
const ifdOffset = readInt(4, isLittleEndian, 4)
function parseIFD(offset) {
const numEntries = readInt(offset, isLittleEndian, 2);
const result = {};
const numEntries = readInt(offset, isLittleEndian, 2)
const result = {}
for (let i = 0; i < numEntries; i++) {
const entryOffset = offset + 2 + i * 12;
const tag = readInt(entryOffset, isLittleEndian, 2);
const type = readInt(entryOffset + 2, isLittleEndian, 2);
const numValues = readInt(entryOffset + 4, isLittleEndian, 4);
const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4);
const entryOffset = offset + 2 + i * 12
const tag = readInt(entryOffset, isLittleEndian, 2)
const type = readInt(entryOffset + 2, isLittleEndian, 2)
const numValues = readInt(entryOffset + 4, isLittleEndian, 4)
const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4)
// Read the value(s) based on the data type
let value;
let value
if (type === 2) {
// ASCII string
value = String.fromCharCode(
...exifData.slice(valueOffset, valueOffset + numValues - 1)
);
)
}
result[tag] = value;
result[tag] = value
}
return result;
return result
}
// Parse the first IFD
const ifdData = parseIFD(ifdOffset);
return ifdData;
const ifdData = parseIFD(ifdOffset)
return ifdData
}
function splitValues(input) {
var output = {};
var output = {}
for (var key in input) {
var value = input[key];
var splitValues = value.split(":", 2);
output[splitValues[0]] = splitValues[1];
var value = input[key]
var splitValues = value.split(':', 2)
output[splitValues[0]] = splitValues[1]
}
return output;
return output
}
export function getWebpMetadata(file) {
return new Promise<Record<string, string>>((r) => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = (event) => {
const webp = new Uint8Array(event.target.result as ArrayBuffer);
const dataView = new DataView(webp.buffer);
const webp = new Uint8Array(event.target.result as ArrayBuffer)
const dataView = new DataView(webp.buffer)
// Check that the WEBP signature is present
if (
dataView.getUint32(0) !== 0x52494646 ||
dataView.getUint32(8) !== 0x57454250
) {
console.error("Not a valid WEBP file");
r({});
return;
console.error('Not a valid WEBP file')
r({})
return
}
// Start searching for chunks after the WEBP signature
let offset = 12;
let txt_chunks = {};
let offset = 12
let txt_chunks = {}
// Loop through the chunks in the WEBP file
while (offset < webp.length) {
const chunk_length = dataView.getUint32(offset + 4, true);
const chunk_length = dataView.getUint32(offset + 4, true)
const chunk_type = String.fromCharCode(
...webp.slice(offset, offset + 4)
);
if (chunk_type === "EXIF") {
)
if (chunk_type === 'EXIF') {
if (
String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) ==
"Exif\0\0"
'Exif\0\0'
) {
offset += 6;
offset += 6
}
let data = parseExifData(
webp.slice(offset + 8, offset + 8 + chunk_length)
);
)
for (var key in data) {
var value = data[key] as string;
let index = value.indexOf(":");
txt_chunks[value.slice(0, index)] = value.slice(index + 1);
var value = data[key] as string
let index = value.indexOf(':')
txt_chunks[value.slice(0, index)] = value.slice(index + 1)
}
break;
break
}
offset += 8 + chunk_length;
offset += 8 + chunk_length
}
r(txt_chunks);
};
r(txt_chunks)
}
reader.readAsArrayBuffer(file);
});
reader.readAsArrayBuffer(file)
})
}
export function getLatentMetadata(file) {
return new Promise((r) => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = (event) => {
const safetensorsData = new Uint8Array(
event.target.result as ArrayBuffer
);
const dataView = new DataView(safetensorsData.buffer);
let header_size = dataView.getUint32(0, true);
let offset = 8;
const safetensorsData = new Uint8Array(event.target.result as ArrayBuffer)
const dataView = new DataView(safetensorsData.buffer)
let header_size = dataView.getUint32(0, true)
let offset = 8
let header = JSON.parse(
new TextDecoder().decode(
safetensorsData.slice(offset, offset + header_size)
)
);
r(header.__metadata__);
};
)
r(header.__metadata__)
}
var slice = file.slice(0, 1024 * 1024 * 4);
reader.readAsArrayBuffer(slice);
});
var slice = file.slice(0, 1024 * 1024 * 4)
reader.readAsArrayBuffer(slice)
})
}
export async function importA1111(graph, parameters) {
const p = parameters.lastIndexOf("\nSteps:");
const p = parameters.lastIndexOf('\nSteps:')
if (p > -1) {
const embeddings = await api.getEmbeddings();
const embeddings = await api.getEmbeddings()
const opts = parameters
.substr(p)
.split("\n")[1]
.split('\n')[1]
.match(
new RegExp('\\s*([^:]+:\\s*([^"\\{].*?|".*?"|\\{.*?\\}))\\s*(,|$)', "g")
new RegExp('\\s*([^:]+:\\s*([^"\\{].*?|".*?"|\\{.*?\\}))\\s*(,|$)', 'g')
)
.reduce((p, n) => {
const s = n.split(":");
if (s[1].endsWith(",")) {
s[1] = s[1].substr(0, s[1].length - 1);
const s = n.split(':')
if (s[1].endsWith(',')) {
s[1] = s[1].substr(0, s[1].length - 1)
}
p[s[0].trim().toLowerCase()] = s[1].trim();
return p;
}, {});
const p2 = parameters.lastIndexOf("\nNegative prompt:", p);
p[s[0].trim().toLowerCase()] = s[1].trim()
return p
}, {})
const p2 = parameters.lastIndexOf('\nNegative prompt:', p)
if (p2 > -1) {
let positive = parameters.substr(0, p2).trim();
let negative = parameters.substring(p2 + 18, p).trim();
let positive = parameters.substr(0, p2).trim()
let negative = parameters.substring(p2 + 18, p).trim()
const ckptNode = LiteGraph.createNode("CheckpointLoaderSimple");
const clipSkipNode = LiteGraph.createNode("CLIPSetLastLayer");
const positiveNode = LiteGraph.createNode("CLIPTextEncode");
const negativeNode = LiteGraph.createNode("CLIPTextEncode");
const samplerNode = LiteGraph.createNode("KSampler");
const imageNode = LiteGraph.createNode("EmptyLatentImage");
const vaeNode = LiteGraph.createNode("VAEDecode");
const vaeLoaderNode = LiteGraph.createNode("VAELoader");
const saveNode = LiteGraph.createNode("SaveImage");
let hrSamplerNode = null;
let hrSteps = null;
const ckptNode = LiteGraph.createNode('CheckpointLoaderSimple')
const clipSkipNode = LiteGraph.createNode('CLIPSetLastLayer')
const positiveNode = LiteGraph.createNode('CLIPTextEncode')
const negativeNode = LiteGraph.createNode('CLIPTextEncode')
const samplerNode = LiteGraph.createNode('KSampler')
const imageNode = LiteGraph.createNode('EmptyLatentImage')
const vaeNode = LiteGraph.createNode('VAEDecode')
const vaeLoaderNode = LiteGraph.createNode('VAELoader')
const saveNode = LiteGraph.createNode('SaveImage')
let hrSamplerNode = null
let hrSteps = null
const ceil64 = (v) => Math.ceil(v / 64) * 64;
const ceil64 = (v) => Math.ceil(v / 64) * 64
const getWidget = (node, name) => {
return node.widgets.find((w) => w.name === name);
};
return node.widgets.find((w) => w.name === name)
}
const setWidgetValue = (node, name, value, isOptionPrefix?) => {
const w = getWidget(node, name);
const w = getWidget(node, name)
if (isOptionPrefix) {
const o = w.options.values.find((w) => w.startsWith(value));
const o = w.options.values.find((w) => w.startsWith(value))
if (o) {
w.value = o;
w.value = o
} else {
console.warn(`Unknown value '${value}' for widget '${name}'`, node);
w.value = value;
console.warn(`Unknown value '${value}' for widget '${name}'`, node)
w.value = value
}
} else {
w.value = value;
w.value = value
}
};
}
const createLoraNodes = (clipNode, text, prevClip, prevModel) => {
const loras = [];
const loras = []
text = text.replace(/<lora:([^:]+:[^>]+)>/g, function (m, c) {
const s = c.split(":");
const weight = parseFloat(s[1]);
const s = c.split(':')
const weight = parseFloat(s[1])
if (isNaN(weight)) {
console.warn("Invalid LORA", m);
console.warn('Invalid LORA', m)
} else {
loras.push({ name: s[0], weight });
loras.push({ name: s[0], weight })
}
return "";
});
return ''
})
for (const l of loras) {
const loraNode = LiteGraph.createNode("LoraLoader");
graph.add(loraNode);
setWidgetValue(loraNode, "lora_name", l.name, true);
setWidgetValue(loraNode, "strength_model", l.weight);
setWidgetValue(loraNode, "strength_clip", l.weight);
prevModel.node.connect(prevModel.index, loraNode, 0);
prevClip.node.connect(prevClip.index, loraNode, 1);
prevModel = { node: loraNode, index: 0 };
prevClip = { node: loraNode, index: 1 };
const loraNode = LiteGraph.createNode('LoraLoader')
graph.add(loraNode)
setWidgetValue(loraNode, 'lora_name', l.name, true)
setWidgetValue(loraNode, 'strength_model', l.weight)
setWidgetValue(loraNode, 'strength_clip', l.weight)
prevModel.node.connect(prevModel.index, loraNode, 0)
prevClip.node.connect(prevClip.index, loraNode, 1)
prevModel = { node: loraNode, index: 0 }
prevClip = { node: loraNode, index: 1 }
}
prevClip.node.connect(1, clipNode, 0);
prevModel.node.connect(0, samplerNode, 0);
prevClip.node.connect(1, clipNode, 0)
prevModel.node.connect(0, samplerNode, 0)
if (hrSamplerNode) {
prevModel.node.connect(0, hrSamplerNode, 0);
prevModel.node.connect(0, hrSamplerNode, 0)
}
return { text, prevModel, prevClip };
};
return { text, prevModel, prevClip }
}
const replaceEmbeddings = (text) => {
if (!embeddings.length) return text;
if (!embeddings.length) return text
return text.replaceAll(
new RegExp(
"\\b(" +
'\\b(' +
embeddings
.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("\\b|\\b") +
")\\b",
"ig"
.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('\\b|\\b') +
')\\b',
'ig'
),
"embedding:$1"
);
};
'embedding:$1'
)
}
const popOpt = (name) => {
const v = opts[name];
delete opts[name];
return v;
};
const v = opts[name]
delete opts[name]
return v
}
graph.clear();
graph.add(ckptNode);
graph.add(clipSkipNode);
graph.add(positiveNode);
graph.add(negativeNode);
graph.add(samplerNode);
graph.add(imageNode);
graph.add(vaeNode);
graph.add(vaeLoaderNode);
graph.add(saveNode);
graph.clear()
graph.add(ckptNode)
graph.add(clipSkipNode)
graph.add(positiveNode)
graph.add(negativeNode)
graph.add(samplerNode)
graph.add(imageNode)
graph.add(vaeNode)
graph.add(vaeLoaderNode)
graph.add(saveNode)
ckptNode.connect(1, clipSkipNode, 0);
clipSkipNode.connect(0, positiveNode, 0);
clipSkipNode.connect(0, negativeNode, 0);
ckptNode.connect(0, samplerNode, 0);
positiveNode.connect(0, samplerNode, 1);
negativeNode.connect(0, samplerNode, 2);
imageNode.connect(0, samplerNode, 3);
vaeNode.connect(0, saveNode, 0);
samplerNode.connect(0, vaeNode, 0);
vaeLoaderNode.connect(0, vaeNode, 1);
ckptNode.connect(1, clipSkipNode, 0)
clipSkipNode.connect(0, positiveNode, 0)
clipSkipNode.connect(0, negativeNode, 0)
ckptNode.connect(0, samplerNode, 0)
positiveNode.connect(0, samplerNode, 1)
negativeNode.connect(0, samplerNode, 2)
imageNode.connect(0, samplerNode, 3)
vaeNode.connect(0, saveNode, 0)
samplerNode.connect(0, vaeNode, 0)
vaeLoaderNode.connect(0, vaeNode, 1)
const handlers = {
model(v) {
setWidgetValue(ckptNode, "ckpt_name", v, true);
setWidgetValue(ckptNode, 'ckpt_name', v, true)
},
vae(v) {
setWidgetValue(vaeLoaderNode, "vae_name", v, true);
setWidgetValue(vaeLoaderNode, 'vae_name', v, true)
},
"cfg scale"(v) {
setWidgetValue(samplerNode, "cfg", +v);
'cfg scale'(v) {
setWidgetValue(samplerNode, 'cfg', +v)
},
"clip skip"(v) {
setWidgetValue(clipSkipNode, "stop_at_clip_layer", -v);
'clip skip'(v) {
setWidgetValue(clipSkipNode, 'stop_at_clip_layer', -v)
},
sampler(v) {
let name = v.toLowerCase().replace("++", "pp").replaceAll(" ", "_");
if (name.includes("karras")) {
name = name.replace("karras", "").replace(/_+$/, "");
setWidgetValue(samplerNode, "scheduler", "karras");
let name = v.toLowerCase().replace('++', 'pp').replaceAll(' ', '_')
if (name.includes('karras')) {
name = name.replace('karras', '').replace(/_+$/, '')
setWidgetValue(samplerNode, 'scheduler', 'karras')
} else {
setWidgetValue(samplerNode, "scheduler", "normal");
setWidgetValue(samplerNode, 'scheduler', 'normal')
}
const w = getWidget(samplerNode, "sampler_name");
const w = getWidget(samplerNode, 'sampler_name')
const o = w.options.values.find(
(w) => w === name || w === "sample_" + name
);
(w) => w === name || w === 'sample_' + name
)
if (o) {
setWidgetValue(samplerNode, "sampler_name", o);
setWidgetValue(samplerNode, 'sampler_name', o)
}
},
size(v) {
const wxh = v.split("x");
const w = ceil64(+wxh[0]);
const h = ceil64(+wxh[1]);
const hrUp = popOpt("hires upscale");
const hrSz = popOpt("hires resize");
hrSteps = popOpt("hires steps");
let hrMethod = popOpt("hires upscaler");
const wxh = v.split('x')
const w = ceil64(+wxh[0])
const h = ceil64(+wxh[1])
const hrUp = popOpt('hires upscale')
const hrSz = popOpt('hires resize')
hrSteps = popOpt('hires steps')
let hrMethod = popOpt('hires upscaler')
setWidgetValue(imageNode, "width", w);
setWidgetValue(imageNode, "height", h);
setWidgetValue(imageNode, 'width', w)
setWidgetValue(imageNode, 'height', h)
if (hrUp || hrSz) {
let uw, uh;
let uw, uh
if (hrUp) {
uw = w * hrUp;
uh = h * hrUp;
uw = w * hrUp
uh = h * hrUp
} else {
const s = hrSz.split("x");
uw = +s[0];
uh = +s[1];
const s = hrSz.split('x')
uw = +s[0]
uh = +s[1]
}
let upscaleNode;
let latentNode;
let upscaleNode
let latentNode
if (hrMethod.startsWith("Latent")) {
latentNode = upscaleNode = LiteGraph.createNode("LatentUpscale");
graph.add(upscaleNode);
samplerNode.connect(0, upscaleNode, 0);
if (hrMethod.startsWith('Latent')) {
latentNode = upscaleNode = LiteGraph.createNode('LatentUpscale')
graph.add(upscaleNode)
samplerNode.connect(0, upscaleNode, 0)
switch (hrMethod) {
case "Latent (nearest-exact)":
hrMethod = "nearest-exact";
break;
case 'Latent (nearest-exact)':
hrMethod = 'nearest-exact'
break
}
setWidgetValue(upscaleNode, "upscale_method", hrMethod, true);
setWidgetValue(upscaleNode, 'upscale_method', hrMethod, true)
} else {
const decode = LiteGraph.createNode("VAEDecodeTiled");
graph.add(decode);
samplerNode.connect(0, decode, 0);
vaeLoaderNode.connect(0, decode, 1);
const decode = LiteGraph.createNode('VAEDecodeTiled')
graph.add(decode)
samplerNode.connect(0, decode, 0)
vaeLoaderNode.connect(0, decode, 1)
const upscaleLoaderNode =
LiteGraph.createNode("UpscaleModelLoader");
graph.add(upscaleLoaderNode);
setWidgetValue(upscaleLoaderNode, "model_name", hrMethod, true);
LiteGraph.createNode('UpscaleModelLoader')
graph.add(upscaleLoaderNode)
setWidgetValue(upscaleLoaderNode, 'model_name', hrMethod, true)
const modelUpscaleNode = LiteGraph.createNode(
"ImageUpscaleWithModel"
);
graph.add(modelUpscaleNode);
decode.connect(0, modelUpscaleNode, 1);
upscaleLoaderNode.connect(0, modelUpscaleNode, 0);
'ImageUpscaleWithModel'
)
graph.add(modelUpscaleNode)
decode.connect(0, modelUpscaleNode, 1)
upscaleLoaderNode.connect(0, modelUpscaleNode, 0)
upscaleNode = LiteGraph.createNode("ImageScale");
graph.add(upscaleNode);
modelUpscaleNode.connect(0, upscaleNode, 0);
upscaleNode = LiteGraph.createNode('ImageScale')
graph.add(upscaleNode)
modelUpscaleNode.connect(0, upscaleNode, 0)
const vaeEncodeNode = (latentNode =
LiteGraph.createNode("VAEEncodeTiled"));
graph.add(vaeEncodeNode);
upscaleNode.connect(0, vaeEncodeNode, 0);
vaeLoaderNode.connect(0, vaeEncodeNode, 1);
LiteGraph.createNode('VAEEncodeTiled'))
graph.add(vaeEncodeNode)
upscaleNode.connect(0, vaeEncodeNode, 0)
vaeLoaderNode.connect(0, vaeEncodeNode, 1)
}
setWidgetValue(upscaleNode, "width", ceil64(uw));
setWidgetValue(upscaleNode, "height", ceil64(uh));
setWidgetValue(upscaleNode, 'width', ceil64(uw))
setWidgetValue(upscaleNode, 'height', ceil64(uh))
hrSamplerNode = LiteGraph.createNode("KSampler");
graph.add(hrSamplerNode);
ckptNode.connect(0, hrSamplerNode, 0);
positiveNode.connect(0, hrSamplerNode, 1);
negativeNode.connect(0, hrSamplerNode, 2);
latentNode.connect(0, hrSamplerNode, 3);
hrSamplerNode.connect(0, vaeNode, 0);
hrSamplerNode = LiteGraph.createNode('KSampler')
graph.add(hrSamplerNode)
ckptNode.connect(0, hrSamplerNode, 0)
positiveNode.connect(0, hrSamplerNode, 1)
negativeNode.connect(0, hrSamplerNode, 2)
latentNode.connect(0, hrSamplerNode, 3)
hrSamplerNode.connect(0, vaeNode, 0)
}
},
steps(v) {
setWidgetValue(samplerNode, "steps", +v);
setWidgetValue(samplerNode, 'steps', +v)
},
seed(v) {
setWidgetValue(samplerNode, "seed", +v);
},
};
setWidgetValue(samplerNode, 'seed', +v)
}
}
for (const opt in opts) {
if (opt in handlers) {
handlers[opt](popOpt(opt));
handlers[opt](popOpt(opt))
}
}
if (hrSamplerNode) {
setWidgetValue(
hrSamplerNode,
"steps",
hrSteps ? +hrSteps : getWidget(samplerNode, "steps").value
);
'steps',
hrSteps ? +hrSteps : getWidget(samplerNode, 'steps').value
)
setWidgetValue(
hrSamplerNode,
"cfg",
getWidget(samplerNode, "cfg").value
);
'cfg',
getWidget(samplerNode, 'cfg').value
)
setWidgetValue(
hrSamplerNode,
"scheduler",
getWidget(samplerNode, "scheduler").value
);
'scheduler',
getWidget(samplerNode, 'scheduler').value
)
setWidgetValue(
hrSamplerNode,
"sampler_name",
getWidget(samplerNode, "sampler_name").value
);
'sampler_name',
getWidget(samplerNode, 'sampler_name').value
)
setWidgetValue(
hrSamplerNode,
"denoise",
+(popOpt("denoising strength") || "1")
);
'denoise',
+(popOpt('denoising strength') || '1')
)
}
let n = createLoraNodes(
@@ -440,29 +438,29 @@ export async function importA1111(graph, parameters) {
positive,
{ node: clipSkipNode, index: 0 },
{ node: ckptNode, index: 0 }
);
positive = n.text;
n = createLoraNodes(negativeNode, negative, n.prevClip, n.prevModel);
negative = n.text;
)
positive = n.text
n = createLoraNodes(negativeNode, negative, n.prevClip, n.prevModel)
negative = n.text
setWidgetValue(positiveNode, "text", replaceEmbeddings(positive));
setWidgetValue(negativeNode, "text", replaceEmbeddings(negative));
setWidgetValue(positiveNode, 'text', replaceEmbeddings(positive))
setWidgetValue(negativeNode, 'text', replaceEmbeddings(negative))
graph.arrange();
graph.arrange()
for (const opt of [
"model hash",
"ensd",
"version",
"vae hash",
"ti hashes",
"lora hashes",
"hashes",
'model hash',
'ensd',
'version',
'vae hash',
'ti hashes',
'lora hashes',
'hashes'
]) {
delete opts[opt];
delete opts[opt]
}
console.warn("Unhandled parameters:", opts);
console.warn('Unhandled parameters:', opts)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,64 +1,64 @@
import { ComfyDialog } from "../dialog";
import { $el } from "../../ui";
import { ComfyDialog } from '../dialog'
import { $el } from '../../ui'
export class ComfyAsyncDialog extends ComfyDialog<HTMLDialogElement> {
#resolve: (value: any) => void;
#resolve: (value: any) => void
constructor(actions?: Array<string | { value?: any; text: string }>) {
super(
"dialog.comfy-dialog.comfyui-dialog",
'dialog.comfy-dialog.comfyui-dialog',
actions?.map((opt) => {
if (typeof opt === "string") {
opt = { text: opt };
if (typeof opt === 'string') {
opt = { text: opt }
}
return $el("button.comfyui-button", {
type: "button",
return $el('button.comfyui-button', {
type: 'button',
textContent: opt.text,
onclick: () => this.close(opt.value ?? opt.text),
});
onclick: () => this.close(opt.value ?? opt.text)
})
})
);
)
}
show(html: string | HTMLElement | HTMLElement[]) {
this.element.addEventListener("close", () => {
this.close();
});
this.element.addEventListener('close', () => {
this.close()
})
super.show(html);
super.show(html)
return new Promise((resolve) => {
this.#resolve = resolve;
});
this.#resolve = resolve
})
}
showModal(html: string | HTMLElement | HTMLElement[]) {
this.element.addEventListener("close", () => {
this.close();
});
this.element.addEventListener('close', () => {
this.close()
})
super.show(html);
this.element.showModal();
super.show(html)
this.element.showModal()
return new Promise((resolve) => {
this.#resolve = resolve;
});
this.#resolve = resolve
})
}
close(result = null) {
this.#resolve(result);
this.element.close();
super.close();
this.#resolve(result)
this.element.close()
super.close()
}
static async prompt({ title = null, message, actions }) {
const dialog = new ComfyAsyncDialog(actions);
const content = [$el("span", message)];
const dialog = new ComfyAsyncDialog(actions)
const content = [$el('span', message)]
if (title) {
content.unshift($el("h3", title));
content.unshift($el('h3', title))
}
const res = await dialog.showModal(content);
dialog.element.remove();
return res;
const res = await dialog.showModal(content)
dialog.element.remove()
return res
}
}

View File

@@ -1,40 +1,40 @@
import { $el } from "../../ui";
import { applyClasses, ClassList, toggleElement } from "../utils";
import { prop } from "../../utils";
import type { ComfyPopup } from "./popup";
import type { ComfyComponent } from ".";
import type { ComfyApp } from "@/scripts/app";
import { $el } from '../../ui'
import { applyClasses, ClassList, toggleElement } from '../utils'
import { prop } from '../../utils'
import type { ComfyPopup } from './popup'
import type { ComfyComponent } from '.'
import type { ComfyApp } from '@/scripts/app'
type ComfyButtonProps = {
icon?: string;
overIcon?: string;
iconSize?: number;
content?: string | HTMLElement;
tooltip?: string;
enabled?: boolean;
action?: (e: Event, btn: ComfyButton) => void;
classList?: ClassList;
visibilitySetting?: { id: string; showValue: any };
app?: ComfyApp;
};
icon?: string
overIcon?: string
iconSize?: number
content?: string | HTMLElement
tooltip?: string
enabled?: boolean
action?: (e: Event, btn: ComfyButton) => void
classList?: ClassList
visibilitySetting?: { id: string; showValue: any }
app?: ComfyApp
}
export class ComfyButton implements ComfyComponent<HTMLElement> {
#over = 0;
#popupOpen = false;
isOver = false;
iconElement = $el("i.mdi");
contentElement = $el("span");
popup: ComfyPopup;
element: HTMLElement;
overIcon: string;
iconSize: number;
content: string | HTMLElement;
icon: string;
tooltip: string;
classList: ClassList;
hidden: boolean;
enabled: boolean;
action: (e: Event, btn: ComfyButton) => void;
#over = 0
#popupOpen = false
isOver = false
iconElement = $el('i.mdi')
contentElement = $el('span')
popup: ComfyPopup
element: HTMLElement
overIcon: string
iconSize: number
content: string | HTMLElement
icon: string
tooltip: string
classList: ClassList
hidden: boolean
enabled: boolean
action: (e: Event, btn: ComfyButton) => void
constructor({
icon,
@@ -43,134 +43,134 @@ export class ComfyButton implements ComfyComponent<HTMLElement> {
content,
tooltip,
action,
classList = "comfyui-button",
classList = 'comfyui-button',
visibilitySetting,
app,
enabled = true,
enabled = true
}: ComfyButtonProps) {
this.element = $el(
"button",
'button',
{
onmouseenter: () => {
this.isOver = true;
this.isOver = true
if (this.overIcon) {
this.updateIcon();
this.updateIcon()
}
},
onmouseleave: () => {
this.isOver = false;
this.isOver = false
if (this.overIcon) {
this.updateIcon();
this.updateIcon()
}
},
}
},
[this.iconElement, this.contentElement]
);
)
this.icon = prop(
this,
"icon",
'icon',
icon,
toggleElement(this.iconElement, { onShow: this.updateIcon })
);
this.overIcon = prop(this, "overIcon", overIcon, () => {
)
this.overIcon = prop(this, 'overIcon', overIcon, () => {
if (this.isOver) {
this.updateIcon();
this.updateIcon()
}
});
this.iconSize = prop(this, "iconSize", iconSize, this.updateIcon);
})
this.iconSize = prop(this, 'iconSize', iconSize, this.updateIcon)
this.content = prop(
this,
"content",
'content',
content,
toggleElement(this.contentElement, {
onShow: (el, v) => {
if (typeof v === "string") {
el.textContent = v;
if (typeof v === 'string') {
el.textContent = v
} else {
el.replaceChildren(v);
el.replaceChildren(v)
}
},
}
})
);
)
this.tooltip = prop(this, "tooltip", tooltip, (v) => {
this.tooltip = prop(this, 'tooltip', tooltip, (v) => {
if (v) {
this.element.title = v;
this.element.title = v
} else {
this.element.removeAttribute("title");
this.element.removeAttribute('title')
}
});
this.classList = prop(this, "classList", classList, this.updateClasses);
this.hidden = prop(this, "hidden", false, this.updateClasses);
this.enabled = prop(this, "enabled", enabled, () => {
this.updateClasses();
(this.element as HTMLButtonElement).disabled = !this.enabled;
});
this.action = prop(this, "action", action);
this.element.addEventListener("click", (e) => {
})
this.classList = prop(this, 'classList', classList, this.updateClasses)
this.hidden = prop(this, 'hidden', false, this.updateClasses)
this.enabled = prop(this, 'enabled', enabled, () => {
this.updateClasses()
;(this.element as HTMLButtonElement).disabled = !this.enabled
})
this.action = prop(this, 'action', action)
this.element.addEventListener('click', (e) => {
if (this.popup) {
// we are either a touch device or triggered by click not hover
if (!this.#over) {
this.popup.toggle();
this.popup.toggle()
}
}
this.action?.(e, this);
});
this.action?.(e, this)
})
if (visibilitySetting?.id) {
const settingUpdated = () => {
this.hidden =
app.ui.settings.getSettingValue(visibilitySetting.id) !==
visibilitySetting.showValue;
};
visibilitySetting.showValue
}
app.ui.settings.addEventListener(
visibilitySetting.id + ".change",
visibilitySetting.id + '.change',
settingUpdated
);
settingUpdated();
)
settingUpdated()
}
}
updateIcon = () =>
(this.iconElement.className = `mdi mdi-${(this.isOver && this.overIcon) || this.icon}${this.iconSize ? " mdi-" + this.iconSize + "px" : ""}`);
(this.iconElement.className = `mdi mdi-${(this.isOver && this.overIcon) || this.icon}${this.iconSize ? ' mdi-' + this.iconSize + 'px' : ''}`)
updateClasses = () => {
const internalClasses = [];
const internalClasses = []
if (this.hidden) {
internalClasses.push("hidden");
internalClasses.push('hidden')
}
if (!this.enabled) {
internalClasses.push("disabled");
internalClasses.push('disabled')
}
if (this.popup) {
if (this.#popupOpen) {
internalClasses.push("popup-open");
internalClasses.push('popup-open')
} else {
internalClasses.push("popup-closed");
internalClasses.push('popup-closed')
}
}
applyClasses(this.element, this.classList, ...internalClasses);
};
applyClasses(this.element, this.classList, ...internalClasses)
}
withPopup(popup: ComfyPopup, mode: "click" | "hover" = "click") {
this.popup = popup;
withPopup(popup: ComfyPopup, mode: 'click' | 'hover' = 'click') {
this.popup = popup
if (mode === "hover") {
if (mode === 'hover') {
for (const el of [this.element, this.popup.element]) {
el.addEventListener("mouseenter", () => {
this.popup.open = !!++this.#over;
});
el.addEventListener("mouseleave", () => {
this.popup.open = !!--this.#over;
});
el.addEventListener('mouseenter', () => {
this.popup.open = !!++this.#over
})
el.addEventListener('mouseleave', () => {
this.popup.open = !!--this.#over
})
}
}
popup.addEventListener("change", () => {
this.#popupOpen = popup.open;
this.updateClasses();
});
popup.addEventListener('change', () => {
this.#popupOpen = popup.open
this.updateClasses()
})
return this;
return this
}
}

View File

@@ -1,37 +1,37 @@
import { $el } from "../../ui";
import { ComfyButton } from "./button";
import { prop } from "../../utils";
import { $el } from '../../ui'
import { ComfyButton } from './button'
import { prop } from '../../utils'
export class ComfyButtonGroup {
element = $el("div.comfyui-button-group");
buttons: (HTMLElement | ComfyButton)[];
element = $el('div.comfyui-button-group')
buttons: (HTMLElement | ComfyButton)[]
constructor(...buttons: (HTMLElement | ComfyButton)[]) {
this.buttons = prop(this, "buttons", buttons, () => this.update());
this.buttons = prop(this, 'buttons', buttons, () => this.update())
}
insert(button: ComfyButton, index: number) {
this.buttons.splice(index, 0, button);
this.update();
this.buttons.splice(index, 0, button)
this.update()
}
append(button: ComfyButton) {
this.buttons.push(button);
this.update();
this.buttons.push(button)
this.update()
}
remove(indexOrButton: ComfyButton | number) {
if (typeof indexOrButton !== "number") {
indexOrButton = this.buttons.indexOf(indexOrButton);
if (typeof indexOrButton !== 'number') {
indexOrButton = this.buttons.indexOf(indexOrButton)
}
if (indexOrButton > -1) {
const r = this.buttons.splice(indexOrButton, 1);
this.update();
return r;
const r = this.buttons.splice(indexOrButton, 1)
this.update()
return r
}
}
update() {
this.element.replaceChildren(...this.buttons.map((b) => b["element"] ?? b));
this.element.replaceChildren(...this.buttons.map((b) => b['element'] ?? b))
}
}

View File

@@ -1,3 +1,3 @@
export interface ComfyComponent<T extends HTMLElement = HTMLElement> {
element: T;
element: T
}

View File

@@ -1,140 +1,140 @@
import { prop } from "../../utils";
import { $el } from "../../ui";
import { applyClasses, ClassList } from "../utils";
import { prop } from '../../utils'
import { $el } from '../../ui'
import { applyClasses, ClassList } from '../utils'
export class ComfyPopup extends EventTarget {
element = $el("div.comfyui-popup");
open: boolean;
children: HTMLElement[];
target: HTMLElement;
ignoreTarget: boolean;
container: HTMLElement;
position: string;
closeOnEscape: boolean;
horizontal: string;
classList: ClassList;
element = $el('div.comfyui-popup')
open: boolean
children: HTMLElement[]
target: HTMLElement
ignoreTarget: boolean
container: HTMLElement
position: string
closeOnEscape: boolean
horizontal: string
classList: ClassList
constructor(
{
target,
container = document.body,
classList = "",
classList = '',
ignoreTarget = true,
closeOnEscape = true,
position = "absolute",
horizontal = "left",
position = 'absolute',
horizontal = 'left'
}: {
target: HTMLElement;
container?: HTMLElement;
classList?: ClassList;
ignoreTarget?: boolean;
closeOnEscape?: boolean;
position?: "absolute" | "relative";
horizontal?: "left" | "right";
target: HTMLElement
container?: HTMLElement
classList?: ClassList
ignoreTarget?: boolean
closeOnEscape?: boolean
position?: 'absolute' | 'relative'
horizontal?: 'left' | 'right'
},
...children: HTMLElement[]
) {
super();
this.target = target;
this.ignoreTarget = ignoreTarget;
this.container = container;
this.position = position;
this.closeOnEscape = closeOnEscape;
this.horizontal = horizontal;
super()
this.target = target
this.ignoreTarget = ignoreTarget
this.container = container
this.position = position
this.closeOnEscape = closeOnEscape
this.horizontal = horizontal
container.append(this.element);
container.append(this.element)
this.children = prop(this, "children", children, () => {
this.element.replaceChildren(...this.children);
this.update();
});
this.classList = prop(this, "classList", classList, () =>
applyClasses(this.element, this.classList, "comfyui-popup", horizontal)
);
this.open = prop(this, "open", false, (v, o) => {
if (v === o) return;
this.children = prop(this, 'children', children, () => {
this.element.replaceChildren(...this.children)
this.update()
})
this.classList = prop(this, 'classList', classList, () =>
applyClasses(this.element, this.classList, 'comfyui-popup', horizontal)
)
this.open = prop(this, 'open', false, (v, o) => {
if (v === o) return
if (v) {
this.#show();
this.#show()
} else {
this.#hide();
this.#hide()
}
});
})
}
toggle() {
this.open = !this.open;
this.open = !this.open
}
#hide() {
this.element.classList.remove("open");
window.removeEventListener("resize", this.update);
window.removeEventListener("click", this.#clickHandler, { capture: true });
window.removeEventListener("keydown", this.#escHandler, { capture: true });
this.element.classList.remove('open')
window.removeEventListener('resize', this.update)
window.removeEventListener('click', this.#clickHandler, { capture: true })
window.removeEventListener('keydown', this.#escHandler, { capture: true })
this.dispatchEvent(new CustomEvent("close"));
this.dispatchEvent(new CustomEvent("change"));
this.dispatchEvent(new CustomEvent('close'))
this.dispatchEvent(new CustomEvent('change'))
}
#show() {
this.element.classList.add("open");
this.update();
this.element.classList.add('open')
this.update()
window.addEventListener("resize", this.update);
window.addEventListener("click", this.#clickHandler, { capture: true });
window.addEventListener('resize', this.update)
window.addEventListener('click', this.#clickHandler, { capture: true })
if (this.closeOnEscape) {
window.addEventListener("keydown", this.#escHandler, { capture: true });
window.addEventListener('keydown', this.#escHandler, { capture: true })
}
this.dispatchEvent(new CustomEvent("open"));
this.dispatchEvent(new CustomEvent("change"));
this.dispatchEvent(new CustomEvent('open'))
this.dispatchEvent(new CustomEvent('change'))
}
#escHandler = (e) => {
if (e.key === "Escape") {
this.open = false;
e.preventDefault();
e.stopImmediatePropagation();
if (e.key === 'Escape') {
this.open = false
e.preventDefault()
e.stopImmediatePropagation()
}
};
}
#clickHandler = (e) => {
/** @type {any} */
const target = e.target;
const target = e.target
if (
!this.element.contains(target) &&
this.ignoreTarget &&
!this.target.contains(target)
) {
this.open = false;
this.open = false
}
};
}
update = () => {
const rect = this.target.getBoundingClientRect();
this.element.style.setProperty("--bottom", "unset");
if (this.position === "absolute") {
if (this.horizontal === "left") {
this.element.style.setProperty("--left", rect.left + "px");
const rect = this.target.getBoundingClientRect()
this.element.style.setProperty('--bottom', 'unset')
if (this.position === 'absolute') {
if (this.horizontal === 'left') {
this.element.style.setProperty('--left', rect.left + 'px')
} else {
this.element.style.setProperty(
"--left",
rect.right - this.element.clientWidth + "px"
);
'--left',
rect.right - this.element.clientWidth + 'px'
)
}
this.element.style.setProperty("--top", rect.bottom + "px");
this.element.style.setProperty("--limit", rect.bottom + "px");
this.element.style.setProperty('--top', rect.bottom + 'px')
this.element.style.setProperty('--limit', rect.bottom + 'px')
} else {
this.element.style.setProperty("--left", 0 + "px");
this.element.style.setProperty("--top", rect.height + "px");
this.element.style.setProperty("--limit", rect.height + "px");
this.element.style.setProperty('--left', 0 + 'px')
this.element.style.setProperty('--top', rect.height + 'px')
this.element.style.setProperty('--limit', rect.height + 'px')
}
const thisRect = this.element.getBoundingClientRect();
const thisRect = this.element.getBoundingClientRect()
if (thisRect.height < 30) {
// Move up instead
this.element.style.setProperty("--top", "unset");
this.element.style.setProperty("--bottom", rect.height + 5 + "px");
this.element.style.setProperty("--limit", rect.height + 5 + "px");
this.element.style.setProperty('--top', 'unset')
this.element.style.setProperty('--bottom', rect.height + 5 + 'px')
this.element.style.setProperty('--limit', rect.height + 5 + 'px')
}
};
}
}

View File

@@ -1,56 +1,56 @@
import { $el } from "../../ui";
import { ComfyButton } from "./button";
import { prop } from "../../utils";
import { ComfyPopup } from "./popup";
import { $el } from '../../ui'
import { ComfyButton } from './button'
import { prop } from '../../utils'
import { ComfyPopup } from './popup'
export class ComfySplitButton {
arrow: ComfyButton;
element: HTMLElement;
popup: ComfyPopup;
items: Array<HTMLElement | ComfyButton>;
arrow: ComfyButton
element: HTMLElement
popup: ComfyPopup
items: Array<HTMLElement | ComfyButton>
constructor(
{
primary,
mode,
horizontal = "left",
position = "relative",
horizontal = 'left',
position = 'relative'
}: {
primary: ComfyButton;
mode?: "hover" | "click";
horizontal?: "left" | "right";
position?: "relative" | "absolute";
primary: ComfyButton
mode?: 'hover' | 'click'
horizontal?: 'left' | 'right'
position?: 'relative' | 'absolute'
},
...items: Array<HTMLElement | ComfyButton>
) {
this.arrow = new ComfyButton({
icon: "chevron-down",
});
icon: 'chevron-down'
})
this.element = $el(
"div.comfyui-split-button" + (mode === "hover" ? ".hover" : ""),
'div.comfyui-split-button' + (mode === 'hover' ? '.hover' : ''),
[
$el("div.comfyui-split-primary", primary.element),
$el("div.comfyui-split-arrow", this.arrow.element),
$el('div.comfyui-split-primary', primary.element),
$el('div.comfyui-split-arrow', this.arrow.element)
]
);
)
this.popup = new ComfyPopup({
target: this.element,
container: position === "relative" ? this.element : document.body,
container: position === 'relative' ? this.element : document.body,
classList:
"comfyui-split-button-popup" + (mode === "hover" ? " hover" : ""),
closeOnEscape: mode === "click",
'comfyui-split-button-popup' + (mode === 'hover' ? ' hover' : ''),
closeOnEscape: mode === 'click',
position,
horizontal,
});
horizontal
})
this.arrow.withPopup(this.popup, mode);
this.arrow.withPopup(this.popup, mode)
this.items = prop(this, "items", items, () => this.update());
this.items = prop(this, 'items', items, () => this.update())
}
update() {
this.popup.element.replaceChildren(
...this.items.map((b) => ("element" in b ? b.element : b))
);
...this.items.map((b) => ('element' in b ? b.element : b))
)
}
}

View File

@@ -1,47 +1,47 @@
import { $el } from "../ui";
import { $el } from '../ui'
export class ComfyDialog<
T extends HTMLElement = HTMLElement,
T extends HTMLElement = HTMLElement
> extends EventTarget {
element: T;
textElement: HTMLElement;
#buttons: HTMLButtonElement[] | null;
element: T
textElement: HTMLElement
#buttons: HTMLButtonElement[] | null
constructor(type = "div", buttons = null) {
super();
this.#buttons = buttons;
this.element = $el(type + ".comfy-modal", { parent: document.body }, [
$el("div.comfy-modal-content", [
$el("p", { $: (p) => (this.textElement = p) }),
...this.createButtons(),
]),
]) as T;
constructor(type = 'div', buttons = null) {
super()
this.#buttons = buttons
this.element = $el(type + '.comfy-modal', { parent: document.body }, [
$el('div.comfy-modal-content', [
$el('p', { $: (p) => (this.textElement = p) }),
...this.createButtons()
])
]) as T
}
createButtons() {
return (
this.#buttons ?? [
$el("button", {
type: "button",
textContent: "Close",
onclick: () => this.close(),
}),
$el('button', {
type: 'button',
textContent: 'Close',
onclick: () => this.close()
})
]
);
)
}
close() {
this.element.style.display = "none";
this.element.style.display = 'none'
}
show(html) {
if (typeof html === "string") {
this.textElement.innerHTML = html;
if (typeof html === 'string') {
this.textElement.innerHTML = html
} else {
this.textElement.replaceChildren(
...(html instanceof Array ? html : [html])
);
)
}
this.element.style.display = "flex";
this.element.style.display = 'flex'
}
}

View File

@@ -24,9 +24,9 @@
SOFTWARE.
*/
import { $el } from "../ui";
import { $el } from '../ui'
$el("style", {
$el('style', {
parent: document.head,
textContent: `
.draggable-item {
@@ -40,261 +40,261 @@ $el("style", {
.draggable-item.is-draggable {
z-index: 10;
}
`,
});
`
})
export class DraggableList extends EventTarget {
listContainer;
draggableItem;
pointerStartX;
pointerStartY;
scrollYMax;
itemsGap = 0;
items = [];
itemSelector;
handleClass = "drag-handle";
off = [];
offDrag = [];
listContainer
draggableItem
pointerStartX
pointerStartY
scrollYMax
itemsGap = 0
items = []
itemSelector
handleClass = 'drag-handle'
off = []
offDrag = []
constructor(element, itemSelector) {
super();
this.listContainer = element;
this.itemSelector = itemSelector;
super()
this.listContainer = element
this.itemSelector = itemSelector
if (!this.listContainer) return;
if (!this.listContainer) return
this.off.push(this.on(this.listContainer, "mousedown", this.dragStart));
this.off.push(this.on(this.listContainer, "touchstart", this.dragStart));
this.off.push(this.on(document, "mouseup", this.dragEnd));
this.off.push(this.on(document, "touchend", this.dragEnd));
this.off.push(this.on(this.listContainer, 'mousedown', this.dragStart))
this.off.push(this.on(this.listContainer, 'touchstart', this.dragStart))
this.off.push(this.on(document, 'mouseup', this.dragEnd))
this.off.push(this.on(document, 'touchend', this.dragEnd))
}
getAllItems() {
if (!this.items?.length) {
this.items = Array.from(
this.listContainer.querySelectorAll(this.itemSelector)
);
)
this.items.forEach((element) => {
element.classList.add("is-idle");
});
element.classList.add('is-idle')
})
}
return this.items;
return this.items
}
getIdleItems() {
return this.getAllItems().filter((item) =>
item.classList.contains("is-idle")
);
item.classList.contains('is-idle')
)
}
isItemAbove(item) {
return item.hasAttribute("data-is-above");
return item.hasAttribute('data-is-above')
}
isItemToggled(item) {
return item.hasAttribute("data-is-toggled");
return item.hasAttribute('data-is-toggled')
}
on(source, event, listener, options?) {
listener = listener.bind(this);
source.addEventListener(event, listener, options);
return () => source.removeEventListener(event, listener);
listener = listener.bind(this)
source.addEventListener(event, listener, options)
return () => source.removeEventListener(event, listener)
}
dragStart(e) {
if (e.target.classList.contains(this.handleClass)) {
this.draggableItem = e.target.closest(this.itemSelector);
this.draggableItem = e.target.closest(this.itemSelector)
}
if (!this.draggableItem) return;
if (!this.draggableItem) return
this.pointerStartX = e.clientX || e.touches[0].clientX;
this.pointerStartY = e.clientY || e.touches[0].clientY;
this.pointerStartX = e.clientX || e.touches[0].clientX
this.pointerStartY = e.clientY || e.touches[0].clientY
this.scrollYMax =
this.listContainer.scrollHeight - this.listContainer.clientHeight;
this.listContainer.scrollHeight - this.listContainer.clientHeight
this.setItemsGap();
this.initDraggableItem();
this.initItemsState();
this.setItemsGap()
this.initDraggableItem()
this.initItemsState()
this.offDrag.push(this.on(document, "mousemove", this.drag));
this.offDrag.push(this.on(document, 'mousemove', this.drag))
this.offDrag.push(
this.on(document, "touchmove", this.drag, { passive: false })
);
this.on(document, 'touchmove', this.drag, { passive: false })
)
this.dispatchEvent(
new CustomEvent("dragstart", {
new CustomEvent('dragstart', {
detail: {
element: this.draggableItem,
position: this.getAllItems().indexOf(this.draggableItem),
},
position: this.getAllItems().indexOf(this.draggableItem)
}
})
);
)
}
setItemsGap() {
if (this.getIdleItems().length <= 1) {
this.itemsGap = 0;
return;
this.itemsGap = 0
return
}
const item1 = this.getIdleItems()[0];
const item2 = this.getIdleItems()[1];
const item1 = this.getIdleItems()[0]
const item2 = this.getIdleItems()[1]
const item1Rect = item1.getBoundingClientRect();
const item2Rect = item2.getBoundingClientRect();
const item1Rect = item1.getBoundingClientRect()
const item2Rect = item2.getBoundingClientRect()
this.itemsGap = Math.abs(item1Rect.bottom - item2Rect.top);
this.itemsGap = Math.abs(item1Rect.bottom - item2Rect.top)
}
initItemsState() {
this.getIdleItems().forEach((item, i) => {
if (this.getAllItems().indexOf(this.draggableItem) > i) {
item.dataset.isAbove = "";
item.dataset.isAbove = ''
}
});
})
}
initDraggableItem() {
this.draggableItem.classList.remove("is-idle");
this.draggableItem.classList.add("is-draggable");
this.draggableItem.classList.remove('is-idle')
this.draggableItem.classList.add('is-draggable')
}
drag(e) {
if (!this.draggableItem) return;
if (!this.draggableItem) return
e.preventDefault();
e.preventDefault()
const clientX = e.clientX || e.touches[0].clientX;
const clientY = e.clientY || e.touches[0].clientY;
const clientX = e.clientX || e.touches[0].clientX
const clientY = e.clientY || e.touches[0].clientY
const listRect = this.listContainer.getBoundingClientRect();
const listRect = this.listContainer.getBoundingClientRect()
if (clientY > listRect.bottom) {
if (this.listContainer.scrollTop < this.scrollYMax) {
this.listContainer.scrollBy(0, 10);
this.pointerStartY -= 10;
this.listContainer.scrollBy(0, 10)
this.pointerStartY -= 10
}
} else if (clientY < listRect.top && this.listContainer.scrollTop > 0) {
this.pointerStartY += 10;
this.listContainer.scrollBy(0, -10);
this.pointerStartY += 10
this.listContainer.scrollBy(0, -10)
}
const pointerOffsetX = clientX - this.pointerStartX;
const pointerOffsetY = clientY - this.pointerStartY;
const pointerOffsetX = clientX - this.pointerStartX
const pointerOffsetY = clientY - this.pointerStartY
this.updateIdleItemsStateAndPosition();
this.draggableItem.style.transform = `translate(${pointerOffsetX}px, ${pointerOffsetY}px)`;
this.updateIdleItemsStateAndPosition()
this.draggableItem.style.transform = `translate(${pointerOffsetX}px, ${pointerOffsetY}px)`
}
updateIdleItemsStateAndPosition() {
const draggableItemRect = this.draggableItem.getBoundingClientRect();
const draggableItemY = draggableItemRect.top + draggableItemRect.height / 2;
const draggableItemRect = this.draggableItem.getBoundingClientRect()
const draggableItemY = draggableItemRect.top + draggableItemRect.height / 2
// Update state
this.getIdleItems().forEach((item) => {
const itemRect = item.getBoundingClientRect();
const itemY = itemRect.top + itemRect.height / 2;
const itemRect = item.getBoundingClientRect()
const itemY = itemRect.top + itemRect.height / 2
if (this.isItemAbove(item)) {
if (draggableItemY <= itemY) {
item.dataset.isToggled = "";
item.dataset.isToggled = ''
} else {
delete item.dataset.isToggled;
delete item.dataset.isToggled
}
} else {
if (draggableItemY >= itemY) {
item.dataset.isToggled = "";
item.dataset.isToggled = ''
} else {
delete item.dataset.isToggled;
delete item.dataset.isToggled
}
}
});
})
// Update position
this.getIdleItems().forEach((item) => {
if (this.isItemToggled(item)) {
const direction = this.isItemAbove(item) ? 1 : -1;
item.style.transform = `translateY(${direction * (draggableItemRect.height + this.itemsGap)}px)`;
const direction = this.isItemAbove(item) ? 1 : -1
item.style.transform = `translateY(${direction * (draggableItemRect.height + this.itemsGap)}px)`
} else {
item.style.transform = "";
item.style.transform = ''
}
});
})
}
dragEnd() {
if (!this.draggableItem) return;
if (!this.draggableItem) return
this.applyNewItemsOrder();
this.cleanup();
this.applyNewItemsOrder()
this.cleanup()
}
applyNewItemsOrder() {
const reorderedItems = [];
const reorderedItems = []
let oldPosition = -1;
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index;
return;
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item;
return;
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1;
reorderedItems[newIndex] = item;
});
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index];
if (typeof item === "undefined") {
reorderedItems[index] = this.draggableItem;
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem
}
}
reorderedItems.forEach((item) => {
this.listContainer.appendChild(item);
});
this.listContainer.appendChild(item)
})
this.items = reorderedItems;
this.items = reorderedItems
this.dispatchEvent(
new CustomEvent("dragend", {
new CustomEvent('dragend', {
detail: {
element: this.draggableItem,
oldPosition,
newPosition: reorderedItems.indexOf(this.draggableItem),
},
newPosition: reorderedItems.indexOf(this.draggableItem)
}
})
);
)
}
cleanup() {
this.itemsGap = 0;
this.items = [];
this.unsetDraggableItem();
this.unsetItemState();
this.itemsGap = 0
this.items = []
this.unsetDraggableItem()
this.unsetItemState()
this.offDrag.forEach((f) => f());
this.offDrag = [];
this.offDrag.forEach((f) => f())
this.offDrag = []
}
unsetDraggableItem() {
this.draggableItem.style = null;
this.draggableItem.classList.remove("is-draggable");
this.draggableItem.classList.add("is-idle");
this.draggableItem = null;
this.draggableItem.style = null
this.draggableItem.classList.remove('is-draggable')
this.draggableItem.classList.add('is-idle')
this.draggableItem = null
}
unsetItemState() {
this.getIdleItems().forEach((item, i) => {
delete item.dataset.isAbove;
delete item.dataset.isToggled;
item.style.transform = "";
});
delete item.dataset.isAbove
delete item.dataset.isToggled
item.style.transform = ''
})
}
dispose() {
this.off.forEach((f) => f());
this.off.forEach((f) => f())
}
}

View File

@@ -1,72 +1,72 @@
import { app } from "../app";
import { $el } from "../ui";
import { app } from '../app'
import { $el } from '../ui'
export function calculateImageGrid(imgs, dw, dh) {
let best = 0;
let w = imgs[0].naturalWidth;
let h = imgs[0].naturalHeight;
const numImages = imgs.length;
let best = 0
let w = imgs[0].naturalWidth
let h = imgs[0].naturalHeight
const numImages = imgs.length
let cellWidth, cellHeight, cols, rows, shiftX;
let cellWidth, cellHeight, cols, rows, shiftX
// compact style
for (let c = 1; c <= numImages; c++) {
const r = Math.ceil(numImages / c);
const cW = dw / c;
const cH = dh / r;
const scaleX = cW / w;
const scaleY = cH / h;
const r = Math.ceil(numImages / c)
const cW = dw / c
const cH = dh / r
const scaleX = cW / w
const scaleY = cH / h
const scale = Math.min(scaleX, scaleY, 1);
const imageW = w * scale;
const imageH = h * scale;
const area = imageW * imageH * numImages;
const scale = Math.min(scaleX, scaleY, 1)
const imageW = w * scale
const imageH = h * scale
const area = imageW * imageH * numImages
if (area > best) {
best = area;
cellWidth = imageW;
cellHeight = imageH;
cols = c;
rows = r;
shiftX = c * ((cW - imageW) / 2);
best = area
cellWidth = imageW
cellHeight = imageH
cols = c
rows = r
shiftX = c * ((cW - imageW) / 2)
}
}
return { cellWidth, cellHeight, cols, rows, shiftX };
return { cellWidth, cellHeight, cols, rows, shiftX }
}
export function createImageHost(node) {
const el = $el("div.comfy-img-preview");
let currentImgs;
let first = true;
const el = $el('div.comfy-img-preview')
let currentImgs
let first = true
function updateSize() {
let w = null;
let h = null;
let w = null
let h = null
if (currentImgs) {
let elH = el.clientHeight;
let elH = el.clientHeight
if (first) {
first = false;
first = false
// On first run, if we are small then grow a bit
if (elH < 190) {
elH = 190;
elH = 190
}
el.style.setProperty("--comfy-widget-min-height", elH.toString());
el.style.setProperty('--comfy-widget-min-height', elH.toString())
} else {
el.style.setProperty("--comfy-widget-min-height", null);
el.style.setProperty('--comfy-widget-min-height', null)
}
const nw = node.size[0];
({ cellWidth: w, cellHeight: h } = calculateImageGrid(
const nw = node.size[0]
;({ cellWidth: w, cellHeight: h } = calculateImageGrid(
currentImgs,
nw - 20,
elH
));
w += "px";
h += "px";
))
w += 'px'
h += 'px'
el.style.setProperty("--comfy-img-preview-width", w);
el.style.setProperty("--comfy-img-preview-height", h);
el.style.setProperty('--comfy-img-preview-width', w)
el.style.setProperty('--comfy-img-preview-height', h)
}
}
return {
@@ -75,31 +75,31 @@ export function createImageHost(node) {
if (imgs !== currentImgs) {
if (currentImgs == null) {
requestAnimationFrame(() => {
updateSize();
});
updateSize()
})
}
el.replaceChildren(...imgs);
currentImgs = imgs;
node.onResize(node.size);
node.graph.setDirtyCanvas(true, true);
el.replaceChildren(...imgs)
currentImgs = imgs
node.onResize(node.size)
node.graph.setDirtyCanvas(true, true)
}
},
getHeight() {
updateSize();
updateSize()
},
onDraw() {
// Element from point uses a hittest find elements so we need to toggle pointer events
el.style.pointerEvents = "all";
el.style.pointerEvents = 'all'
const over = document.elementFromPoint(
app.canvas.mouse[0],
app.canvas.mouse[1]
);
el.style.pointerEvents = "none";
)
el.style.pointerEvents = 'none'
if (!over) return;
if (!over) return
// Set the overIndex so Open Image etc work
const idx = currentImgs.indexOf(over);
node.overIndex = idx;
},
};
const idx = currentImgs.indexOf(over)
node.overIndex = idx
}
}
}

View File

@@ -1,320 +1,308 @@
import type { ComfyApp } from "@/scripts/app";
import { api } from "../../api";
import { $el } from "../../ui";
import { downloadBlob } from "../../utils";
import { ComfyButton } from "../components/button";
import { ComfyButtonGroup } from "../components/buttonGroup";
import { ComfySplitButton } from "../components/splitButton";
import { ComfyQueueButton } from "./queueButton";
import { ComfyWorkflowsMenu } from "./workflows";
import { getInteruptButton } from "./interruptButton";
import "./menu.css";
import type { ComfySettingsDialog } from "../settings";
import type { ComfyApp } from '@/scripts/app'
import { api } from '../../api'
import { $el } from '../../ui'
import { downloadBlob } from '../../utils'
import { ComfyButton } from '../components/button'
import { ComfyButtonGroup } from '../components/buttonGroup'
import { ComfySplitButton } from '../components/splitButton'
import { ComfyQueueButton } from './queueButton'
import { ComfyWorkflowsMenu } from './workflows'
import { getInteruptButton } from './interruptButton'
import './menu.css'
import type { ComfySettingsDialog } from '../settings'
type MenuPosition = "Disabled" | "Top" | "Bottom";
type MenuPosition = 'Disabled' | 'Top' | 'Bottom'
const collapseOnMobile = (t) => {
(t.element ?? t).classList.add("comfyui-menu-mobile-collapse");
return t;
};
;(t.element ?? t).classList.add('comfyui-menu-mobile-collapse')
return t
}
const showOnMobile = (t) => {
(t.element ?? t).classList.add("lt-lg-show");
return t;
};
;(t.element ?? t).classList.add('lt-lg-show')
return t
}
export class ComfyAppMenu {
#sizeBreak = "lg";
#sizeBreak = 'lg'
#lastSizeBreaks = {
lg: null,
md: null,
sm: null,
xs: null,
};
#sizeBreaks = Object.keys(this.#lastSizeBreaks);
#cachedInnerSize = null;
#cacheTimeout = null;
app: ComfyApp;
workflows: ComfyWorkflowsMenu;
logo: HTMLElement;
saveButton: ComfySplitButton;
actionsGroup: ComfyButtonGroup;
settingsGroup: ComfyButtonGroup;
viewGroup: ComfyButtonGroup;
mobileMenuButton: ComfyButton;
element: HTMLElement;
menuPositionSetting: ReturnType<ComfySettingsDialog["addSetting"]>;
position: MenuPosition;
xs: null
}
#sizeBreaks = Object.keys(this.#lastSizeBreaks)
#cachedInnerSize = null
#cacheTimeout = null
app: ComfyApp
workflows: ComfyWorkflowsMenu
logo: HTMLElement
saveButton: ComfySplitButton
actionsGroup: ComfyButtonGroup
settingsGroup: ComfyButtonGroup
viewGroup: ComfyButtonGroup
mobileMenuButton: ComfyButton
element: HTMLElement
menuPositionSetting: ReturnType<ComfySettingsDialog['addSetting']>
position: MenuPosition
constructor(app: ComfyApp) {
this.app = app;
this.app = app
this.workflows = new ComfyWorkflowsMenu(app);
this.workflows = new ComfyWorkflowsMenu(app)
const getSaveButton = (t?: string) =>
new ComfyButton({
icon: "content-save",
tooltip: "Save the current workflow",
icon: 'content-save',
tooltip: 'Save the current workflow',
action: () => app.workflowManager.activeWorkflow.save(),
content: t,
});
content: t
})
this.logo = $el(
"h1.comfyui-logo.nlg-hide",
{ title: "ComfyUI" },
"ComfyUI"
);
this.logo = $el('h1.comfyui-logo.nlg-hide', { title: 'ComfyUI' }, 'ComfyUI')
this.saveButton = new ComfySplitButton(
{
primary: getSaveButton(),
mode: "hover",
position: "absolute",
mode: 'hover',
position: 'absolute'
},
getSaveButton("Save"),
getSaveButton('Save'),
new ComfyButton({
icon: "content-save-edit",
content: "Save As",
tooltip: "Save the current graph as a new workflow",
action: () => app.workflowManager.activeWorkflow.save(true),
icon: 'content-save-edit',
content: 'Save As',
tooltip: 'Save the current graph as a new workflow',
action: () => app.workflowManager.activeWorkflow.save(true)
}),
new ComfyButton({
icon: "download",
content: "Export",
tooltip: "Export the current workflow as JSON",
action: () => this.exportWorkflow("workflow", "workflow"),
icon: 'download',
content: 'Export',
tooltip: 'Export the current workflow as JSON',
action: () => this.exportWorkflow('workflow', 'workflow')
}),
new ComfyButton({
icon: "api",
content: "Export (API Format)",
icon: 'api',
content: 'Export (API Format)',
tooltip:
"Export the current workflow as JSON for use with the ComfyUI API",
action: () => this.exportWorkflow("workflow_api", "output"),
visibilitySetting: { id: "Comfy.DevMode", showValue: true },
app,
'Export the current workflow as JSON for use with the ComfyUI API',
action: () => this.exportWorkflow('workflow_api', 'output'),
visibilitySetting: { id: 'Comfy.DevMode', showValue: true },
app
})
);
)
this.actionsGroup = new ComfyButtonGroup(
new ComfyButton({
icon: "refresh",
content: "Refresh",
tooltip: "Refresh widgets in nodes to find new models or files",
action: () => app.refreshComboInNodes(),
icon: 'refresh',
content: 'Refresh',
tooltip: 'Refresh widgets in nodes to find new models or files',
action: () => app.refreshComboInNodes()
}),
new ComfyButton({
icon: "clipboard-edit-outline",
content: "Clipspace",
tooltip: "Open Clipspace window",
action: () => app["openClipspace"](),
icon: 'clipboard-edit-outline',
content: 'Clipspace',
tooltip: 'Open Clipspace window',
action: () => app['openClipspace']()
}),
new ComfyButton({
icon: "fit-to-page-outline",
content: "Reset View",
tooltip: "Reset the canvas view",
action: () => app.resetView(),
icon: 'fit-to-page-outline',
content: 'Reset View',
tooltip: 'Reset the canvas view',
action: () => app.resetView()
}),
new ComfyButton({
icon: "cancel",
content: "Clear",
tooltip: "Clears current workflow",
icon: 'cancel',
content: 'Clear',
tooltip: 'Clears current workflow',
action: () => {
if (
!app.ui.settings.getSettingValue("Comfy.ConfirmClear", true) ||
confirm("Clear workflow?")
!app.ui.settings.getSettingValue('Comfy.ConfirmClear', true) ||
confirm('Clear workflow?')
) {
app.clean();
app.graph.clear();
api.dispatchEvent(new CustomEvent("graphCleared"));
app.clean()
app.graph.clear()
api.dispatchEvent(new CustomEvent('graphCleared'))
}
},
}
})
);
)
// Keep the settings group as there are custom scripts attaching extra
// elements to it.
this.settingsGroup = new ComfyButtonGroup();
this.viewGroup = new ComfyButtonGroup(
getInteruptButton("nlg-hide").element
);
this.settingsGroup = new ComfyButtonGroup()
this.viewGroup = new ComfyButtonGroup(getInteruptButton('nlg-hide').element)
this.mobileMenuButton = new ComfyButton({
icon: "menu",
icon: 'menu',
action: (_, btn) => {
btn.icon = this.element.classList.toggle("expanded")
? "menu-open"
: "menu";
window.dispatchEvent(new Event("resize"));
btn.icon = this.element.classList.toggle('expanded')
? 'menu-open'
: 'menu'
window.dispatchEvent(new Event('resize'))
},
classList: "comfyui-button comfyui-menu-button",
});
classList: 'comfyui-button comfyui-menu-button'
})
this.element = $el("nav.comfyui-menu.lg", { style: { display: "none" } }, [
this.element = $el('nav.comfyui-menu.lg', { style: { display: 'none' } }, [
this.logo,
this.workflows.element,
this.saveButton.element,
collapseOnMobile(this.actionsGroup).element,
$el("section.comfyui-menu-push"),
$el('section.comfyui-menu-push'),
collapseOnMobile(this.settingsGroup).element,
collapseOnMobile(this.viewGroup).element,
getInteruptButton("lt-lg-show").element,
getInteruptButton('lt-lg-show').element,
new ComfyQueueButton(app).element,
showOnMobile(this.mobileMenuButton).element,
]);
showOnMobile(this.mobileMenuButton).element
])
let resizeHandler: () => void;
let resizeHandler: () => void
this.menuPositionSetting = app.ui.settings.addSetting({
id: "Comfy.UseNewMenu",
defaultValue: "Disabled",
name: "[Beta] Use new menu and workflow management. Note: On small screens the menu will always be at the top.",
type: "combo",
options: ["Disabled", "Top", "Bottom"],
id: 'Comfy.UseNewMenu',
defaultValue: 'Disabled',
name: '[Beta] Use new menu and workflow management. Note: On small screens the menu will always be at the top.',
type: 'combo',
options: ['Disabled', 'Top', 'Bottom'],
onChange: async (v: MenuPosition) => {
if (v && v !== "Disabled") {
if (v && v !== 'Disabled') {
if (!resizeHandler) {
resizeHandler = () => {
this.calculateSizeBreak();
};
window.addEventListener("resize", resizeHandler);
this.calculateSizeBreak()
}
window.addEventListener('resize', resizeHandler)
}
this.updatePosition(v);
this.updatePosition(v)
} else {
if (resizeHandler) {
window.removeEventListener("resize", resizeHandler);
resizeHandler = null;
window.removeEventListener('resize', resizeHandler)
resizeHandler = null
}
document.body.style.removeProperty("display");
app.ui.menuContainer.style.removeProperty("display");
this.element.style.display = "none";
app.ui.restoreMenuPosition();
document.body.style.removeProperty('display')
app.ui.menuContainer.style.removeProperty('display')
this.element.style.display = 'none'
app.ui.restoreMenuPosition()
}
window.dispatchEvent(new Event("resize"));
},
});
window.dispatchEvent(new Event('resize'))
}
})
}
updatePosition(v: MenuPosition) {
document.body.style.display = "grid";
this.app.ui.menuContainer.style.display = "none";
this.element.style.removeProperty("display");
this.position = v;
if (v === "Bottom") {
this.app.bodyBottom.append(this.element);
document.body.style.display = 'grid'
this.app.ui.menuContainer.style.display = 'none'
this.element.style.removeProperty('display')
this.position = v
if (v === 'Bottom') {
this.app.bodyBottom.append(this.element)
} else {
this.app.bodyTop.prepend(this.element);
this.app.bodyTop.prepend(this.element)
}
this.calculateSizeBreak();
this.calculateSizeBreak()
}
updateSizeBreak(idx: number, prevIdx: number, direction: number) {
const newSize = this.#sizeBreaks[idx];
if (newSize === this.#sizeBreak) return;
this.#cachedInnerSize = null;
clearTimeout(this.#cacheTimeout);
const newSize = this.#sizeBreaks[idx]
if (newSize === this.#sizeBreak) return
this.#cachedInnerSize = null
clearTimeout(this.#cacheTimeout)
this.#sizeBreak = this.#sizeBreaks[idx];
this.#sizeBreak = this.#sizeBreaks[idx]
for (let i = 0; i < this.#sizeBreaks.length; i++) {
const sz = this.#sizeBreaks[i];
const sz = this.#sizeBreaks[i]
if (sz === this.#sizeBreak) {
this.element.classList.add(sz);
this.element.classList.add(sz)
} else {
this.element.classList.remove(sz);
this.element.classList.remove(sz)
}
if (i < idx) {
this.element.classList.add("lt-" + sz);
this.element.classList.add('lt-' + sz)
} else {
this.element.classList.remove("lt-" + sz);
this.element.classList.remove('lt-' + sz)
}
}
if (idx) {
// We're on a small screen, force the menu at the top
if (this.position !== "Top") {
this.updatePosition("Top");
if (this.position !== 'Top') {
this.updatePosition('Top')
}
} else if (this.position != this.menuPositionSetting.value) {
// Restore user position
this.updatePosition(this.menuPositionSetting.value);
this.updatePosition(this.menuPositionSetting.value)
}
// Allow multiple updates, but prevent bouncing
if (!direction) {
direction = prevIdx - idx;
direction = prevIdx - idx
} else if (direction != prevIdx - idx) {
return;
return
}
this.calculateSizeBreak(direction);
this.calculateSizeBreak(direction)
}
calculateSizeBreak(direction = 0) {
let idx = this.#sizeBreaks.indexOf(this.#sizeBreak);
const currIdx = idx;
const innerSize = this.calculateInnerSize(idx);
let idx = this.#sizeBreaks.indexOf(this.#sizeBreak)
const currIdx = idx
const innerSize = this.calculateInnerSize(idx)
if (window.innerWidth >= this.#lastSizeBreaks[this.#sizeBreaks[idx - 1]]) {
if (idx > 0) {
idx--;
idx--
}
} else if (innerSize > this.element.clientWidth) {
this.#lastSizeBreaks[this.#sizeBreak] = Math.max(
window.innerWidth,
innerSize
);
)
// We need to shrink
if (idx < this.#sizeBreaks.length - 1) {
idx++;
idx++
}
}
this.updateSizeBreak(idx, currIdx, direction);
this.updateSizeBreak(idx, currIdx, direction)
}
calculateInnerSize(idx: number) {
// Cache the inner size to prevent too much calculation when resizing the window
clearTimeout(this.#cacheTimeout);
clearTimeout(this.#cacheTimeout)
if (this.#cachedInnerSize) {
// Extend cache time
this.#cacheTimeout = setTimeout(
() => (this.#cachedInnerSize = null),
100
);
this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100)
} else {
let innerSize = 0;
let count = 1;
let innerSize = 0
let count = 1
for (const c of this.element.children) {
if (c.classList.contains("comfyui-menu-push")) continue; // ignore right push
if (idx && c.classList.contains("comfyui-menu-mobile-collapse"))
continue; // ignore collapse items
innerSize += c.clientWidth;
count++;
if (c.classList.contains('comfyui-menu-push')) continue // ignore right push
if (idx && c.classList.contains('comfyui-menu-mobile-collapse'))
continue // ignore collapse items
innerSize += c.clientWidth
count++
}
innerSize += 8 * count;
this.#cachedInnerSize = innerSize;
this.#cacheTimeout = setTimeout(
() => (this.#cachedInnerSize = null),
100
);
innerSize += 8 * count
this.#cachedInnerSize = innerSize
this.#cacheTimeout = setTimeout(() => (this.#cachedInnerSize = null), 100)
}
return this.#cachedInnerSize;
return this.#cachedInnerSize
}
getFilename(defaultName: string) {
if (this.app.ui.settings.getSettingValue("Comfy.PromptFilename", true)) {
defaultName = prompt("Save workflow as:", defaultName);
if (!defaultName) return;
if (!defaultName.toLowerCase().endsWith(".json")) {
defaultName += ".json";
if (this.app.ui.settings.getSettingValue('Comfy.PromptFilename', true)) {
defaultName = prompt('Save workflow as:', defaultName)
if (!defaultName) return
if (!defaultName.toLowerCase().endsWith('.json')) {
defaultName += '.json'
}
}
return defaultName;
return defaultName
}
async exportWorkflow(
filename: string,
promptProperty: "workflow" | "output"
promptProperty: 'workflow' | 'output'
) {
if (this.app.workflowManager.activeWorkflow?.path) {
filename = this.app.workflowManager.activeWorkflow.name;
filename = this.app.workflowManager.activeWorkflow.name
}
const p = await this.app.graphToPrompt();
const json = JSON.stringify(p[promptProperty], null, 2);
const blob = new Blob([json], { type: "application/json" });
const file = this.getFilename(filename);
if (!file) return;
downloadBlob(file, blob);
const p = await this.app.graphToPrompt()
const json = JSON.stringify(p[promptProperty], null, 2)
const blob = new Blob([json], { type: 'application/json' })
const file = this.getFilename(filename)
if (!file) return
downloadBlob(file, blob)
}
}

View File

@@ -1,21 +1,21 @@
import { api } from "../../api";
import { ComfyButton } from "../components/button";
import { api } from '../../api'
import { ComfyButton } from '../components/button'
export function getInteruptButton(visibility: string) {
const btn = new ComfyButton({
icon: "close",
tooltip: "Cancel current generation",
icon: 'close',
tooltip: 'Cancel current generation',
enabled: false,
action: () => {
api.interrupt();
api.interrupt()
},
classList: ["comfyui-button", "comfyui-interrupt-button", visibility],
});
classList: ['comfyui-button', 'comfyui-interrupt-button', visibility]
})
api.addEventListener("status", ({ detail }) => {
const sz = detail?.exec_info?.queue_remaining;
btn.enabled = sz > 0;
});
api.addEventListener('status', ({ detail }) => {
const sz = detail?.exec_info?.queue_remaining
btn.enabled = sz > 0
})
return btn;
return btn
}

View File

@@ -1,107 +1,107 @@
import { ComfyButton } from "../components/button";
import { $el } from "../../ui";
import { api } from "../../api";
import { ComfySplitButton } from "../components/splitButton";
import { ComfyQueueOptions } from "./queueOptions";
import { prop } from "../../utils";
import type { ComfyApp } from "@/scripts/app";
import { ComfyButton } from '../components/button'
import { $el } from '../../ui'
import { api } from '../../api'
import { ComfySplitButton } from '../components/splitButton'
import { ComfyQueueOptions } from './queueOptions'
import { prop } from '../../utils'
import type { ComfyApp } from '@/scripts/app'
export class ComfyQueueButton {
element = $el("div.comfyui-queue-button");
#internalQueueSize = 0;
element = $el('div.comfyui-queue-button')
#internalQueueSize = 0
queuePrompt = async (e?: MouseEvent) => {
this.#internalQueueSize += this.queueOptions.batchCount;
this.#internalQueueSize += this.queueOptions.batchCount
// Hold shift to queue front, event is undefined when auto-queue is enabled
await this.app.queuePrompt(
e?.shiftKey ? -1 : 0,
this.queueOptions.batchCount
);
};
queueOptions: ComfyQueueOptions;
app: ComfyApp;
queueSizeElement: HTMLElement;
autoQueueMode: string;
graphHasChanged: boolean;
)
}
queueOptions: ComfyQueueOptions
app: ComfyApp
queueSizeElement: HTMLElement
autoQueueMode: string
graphHasChanged: boolean
constructor(app: ComfyApp) {
this.app = app;
this.queueSizeElement = $el("span.comfyui-queue-count", {
textContent: "?",
});
this.app = app
this.queueSizeElement = $el('span.comfyui-queue-count', {
textContent: '?'
})
const queue = new ComfyButton({
content: $el("div", [
$el("span", {
textContent: "Queue",
content: $el('div', [
$el('span', {
textContent: 'Queue'
}),
this.queueSizeElement,
this.queueSizeElement
]),
icon: "play",
classList: "comfyui-button",
action: this.queuePrompt,
});
icon: 'play',
classList: 'comfyui-button',
action: this.queuePrompt
})
this.queueOptions = new ComfyQueueOptions(app);
this.queueOptions = new ComfyQueueOptions(app)
const btn = new ComfySplitButton(
{
primary: queue,
mode: "click",
position: "absolute",
horizontal: "right",
mode: 'click',
position: 'absolute',
horizontal: 'right'
},
this.queueOptions.element
);
btn.element.classList.add("primary");
this.element.append(btn.element);
)
btn.element.classList.add('primary')
this.element.append(btn.element)
this.autoQueueMode = prop(this, "autoQueueMode", "", () => {
this.autoQueueMode = prop(this, 'autoQueueMode', '', () => {
switch (this.autoQueueMode) {
case "instant":
queue.icon = "infinity";
break;
case "change":
queue.icon = "auto-mode";
break;
case 'instant':
queue.icon = 'infinity'
break
case 'change':
queue.icon = 'auto-mode'
break
default:
queue.icon = "play";
break;
queue.icon = 'play'
break
}
});
})
this.queueOptions.addEventListener(
"autoQueueMode",
(e) => (this.autoQueueMode = e["detail"])
);
'autoQueueMode',
(e) => (this.autoQueueMode = e['detail'])
)
api.addEventListener("graphChanged", () => {
if (this.autoQueueMode === "change") {
api.addEventListener('graphChanged', () => {
if (this.autoQueueMode === 'change') {
if (this.#internalQueueSize) {
this.graphHasChanged = true;
this.graphHasChanged = true
} else {
this.graphHasChanged = false;
this.queuePrompt();
this.graphHasChanged = false
this.queuePrompt()
}
}
});
})
api.addEventListener("status", ({ detail }) => {
this.#internalQueueSize = detail?.exec_info?.queue_remaining;
api.addEventListener('status', ({ detail }) => {
this.#internalQueueSize = detail?.exec_info?.queue_remaining
if (this.#internalQueueSize != null) {
this.queueSizeElement.textContent =
this.#internalQueueSize > 99 ? "99+" : this.#internalQueueSize + "";
this.queueSizeElement.title = `${this.#internalQueueSize} prompts in queue`;
this.#internalQueueSize > 99 ? '99+' : this.#internalQueueSize + ''
this.queueSizeElement.title = `${this.#internalQueueSize} prompts in queue`
if (!this.#internalQueueSize && !app.lastExecutionError) {
if (
this.autoQueueMode === "instant" ||
(this.autoQueueMode === "change" && this.graphHasChanged)
this.autoQueueMode === 'instant' ||
(this.autoQueueMode === 'change' && this.graphHasChanged)
) {
this.graphHasChanged = false;
this.queuePrompt();
this.graphHasChanged = false
this.queuePrompt()
}
}
}
});
})
}
}

View File

@@ -1,82 +1,82 @@
import type { ComfyApp } from "@/scripts/app";
import { $el } from "../../ui";
import { prop } from "../../utils";
import type { ComfyApp } from '@/scripts/app'
import { $el } from '../../ui'
import { prop } from '../../utils'
export class ComfyQueueOptions extends EventTarget {
element = $el("div.comfyui-queue-options");
app: ComfyApp;
batchCountInput: HTMLInputElement;
batchCount: number;
batchCountRange: HTMLInputElement;
autoQueueMode: string;
autoQueueEl: HTMLElement;
element = $el('div.comfyui-queue-options')
app: ComfyApp
batchCountInput: HTMLInputElement
batchCount: number
batchCountRange: HTMLInputElement
autoQueueMode: string
autoQueueEl: HTMLElement
constructor(app: ComfyApp) {
super();
this.app = app;
super()
this.app = app
this.batchCountInput = $el("input", {
className: "comfyui-queue-batch-value",
type: "number",
min: "1",
value: "1",
oninput: () => (this.batchCount = +this.batchCountInput.value),
});
this.batchCountInput = $el('input', {
className: 'comfyui-queue-batch-value',
type: 'number',
min: '1',
value: '1',
oninput: () => (this.batchCount = +this.batchCountInput.value)
})
this.batchCountRange = $el("input", {
type: "range",
min: "1",
max: "100",
value: "1",
oninput: () => (this.batchCount = +this.batchCountRange.value),
});
this.batchCountRange = $el('input', {
type: 'range',
min: '1',
max: '100',
value: '1',
oninput: () => (this.batchCount = +this.batchCountRange.value)
})
this.element.append(
$el("div.comfyui-queue-batch", [
$el('div.comfyui-queue-batch', [
$el(
"label",
'label',
{
textContent: "Batch count: ",
textContent: 'Batch count: '
},
this.batchCountInput
),
this.batchCountRange,
this.batchCountRange
])
);
)
const createOption = (text, value, checked = false) =>
$el(
"label",
'label',
{ textContent: text },
$el("input", {
type: "radio",
name: "AutoQueueMode",
$el('input', {
type: 'radio',
name: 'AutoQueueMode',
checked,
value,
oninput: (e) => (this.autoQueueMode = e.target["value"]),
oninput: (e) => (this.autoQueueMode = e.target['value'])
})
);
)
this.autoQueueEl = $el("div.comfyui-queue-mode", [
$el("span", "Auto Queue:"),
createOption("Disabled", "", true),
createOption("Instant", "instant"),
createOption("On Change", "change"),
]);
this.autoQueueEl = $el('div.comfyui-queue-mode', [
$el('span', 'Auto Queue:'),
createOption('Disabled', '', true),
createOption('Instant', 'instant'),
createOption('On Change', 'change')
])
this.element.append(this.autoQueueEl);
this.element.append(this.autoQueueEl)
this.batchCount = prop(this, "batchCount", 1, () => {
this.batchCountInput.value = this.batchCount + "";
this.batchCountRange.value = this.batchCount + "";
});
this.batchCount = prop(this, 'batchCount', 1, () => {
this.batchCountInput.value = this.batchCount + ''
this.batchCountRange.value = this.batchCount + ''
})
this.autoQueueMode = prop(this, "autoQueueMode", "Disabled", () => {
this.autoQueueMode = prop(this, 'autoQueueMode', 'Disabled', () => {
this.dispatchEvent(
new CustomEvent("autoQueueMode", {
detail: this.autoQueueMode,
new CustomEvent('autoQueueMode', {
detail: this.autoQueueMode
})
);
});
)
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,60 +1,60 @@
import { $el } from "../ui";
import { api } from "../api";
import { ComfyDialog } from "./dialog";
import type { ComfyApp } from "../app";
import type { Setting, SettingParams } from "@/types/settingTypes";
import { useSettingStore } from "@/stores/settingStore";
import { $el } from '../ui'
import { api } from '../api'
import { ComfyDialog } from './dialog'
import type { ComfyApp } from '../app'
import type { Setting, SettingParams } from '@/types/settingTypes'
import { useSettingStore } from '@/stores/settingStore'
export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
app: ComfyApp;
settingsValues: any;
settingsLookup: Record<string, Setting>;
settingsParamLookup: Record<string, SettingParams>;
app: ComfyApp
settingsValues: any
settingsLookup: Record<string, Setting>
settingsParamLookup: Record<string, SettingParams>
constructor(app: ComfyApp) {
super();
const frontendVersion = window["__COMFYUI_FRONTEND_VERSION__"];
this.app = app;
this.settingsValues = {};
this.settingsLookup = {};
this.settingsParamLookup = {};
super()
const frontendVersion = window['__COMFYUI_FRONTEND_VERSION__']
this.app = app
this.settingsValues = {}
this.settingsLookup = {}
this.settingsParamLookup = {}
this.element = $el(
"dialog",
'dialog',
{
id: "comfy-settings-dialog",
parent: document.body,
id: 'comfy-settings-dialog',
parent: document.body
},
[
$el("table.comfy-modal-content.comfy-table", [
$el('table.comfy-modal-content.comfy-table', [
$el(
"caption",
'caption',
{ textContent: `Settings (v${frontendVersion})` },
$el("button.comfy-btn", {
type: "button",
textContent: "\u00d7",
$el('button.comfy-btn', {
type: 'button',
textContent: '\u00d7',
onclick: () => {
this.element.close();
},
this.element.close()
}
})
),
$el("tbody", { $: (tbody) => (this.textElement = tbody) }),
$el("button", {
type: "button",
textContent: "Close",
$el('tbody', { $: (tbody) => (this.textElement = tbody) }),
$el('button', {
type: 'button',
textContent: 'Close',
style: {
cursor: "pointer",
cursor: 'pointer'
},
onclick: () => {
this.element.close();
},
}),
]),
this.element.close()
}
})
])
]
) as HTMLDialogElement;
) as HTMLDialogElement
}
get settings() {
return Object.values(this.settingsLookup);
return Object.values(this.settingsLookup)
}
#dispatchChange<T>(id: string, value: T, oldValue?: T) {
@@ -63,84 +63,84 @@ export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
// `load` re-dispatch the change for any settings added before load so
// settingStore is always up to date.
if (this.app.vueAppReady) {
useSettingStore().settingValues[id] = value;
useSettingStore().settingValues[id] = value
}
this.dispatchEvent(
new CustomEvent(id + ".change", {
new CustomEvent(id + '.change', {
detail: {
value,
oldValue,
},
oldValue
}
})
);
)
}
async load() {
if (this.app.storageLocation === "browser") {
this.settingsValues = localStorage;
if (this.app.storageLocation === 'browser') {
this.settingsValues = localStorage
} else {
this.settingsValues = await api.getSettings();
this.settingsValues = await api.getSettings()
}
// Trigger onChange for any settings added before load
for (const id in this.settingsLookup) {
const value = this.settingsValues[this.getId(id)];
this.settingsLookup[id].onChange?.(value);
this.#dispatchChange(id, value);
const value = this.settingsValues[this.getId(id)]
this.settingsLookup[id].onChange?.(value)
this.#dispatchChange(id, value)
}
}
getId(id: string) {
if (this.app.storageLocation === "browser") {
id = "Comfy.Settings." + id;
if (this.app.storageLocation === 'browser') {
id = 'Comfy.Settings.' + id
}
return id;
return id
}
getSettingValue<T>(id: string, defaultValue?: T): T {
let value = this.settingsValues[this.getId(id)];
let value = this.settingsValues[this.getId(id)]
if (value != null) {
if (this.app.storageLocation === "browser") {
if (this.app.storageLocation === 'browser') {
try {
value = JSON.parse(value) as T;
value = JSON.parse(value) as T
} catch (error) {}
}
}
return value ?? defaultValue;
return value ?? defaultValue
}
getSettingDefaultValue(id: string) {
const param = this.settingsParamLookup[id];
return param?.defaultValue;
const param = this.settingsParamLookup[id]
return param?.defaultValue
}
async setSettingValueAsync(id: string, value: any) {
const json = JSON.stringify(value);
localStorage["Comfy.Settings." + id] = json; // backwards compatibility for extensions keep setting in storage
const json = JSON.stringify(value)
localStorage['Comfy.Settings.' + id] = json // backwards compatibility for extensions keep setting in storage
let oldValue = this.getSettingValue(id, undefined);
this.settingsValues[this.getId(id)] = value;
let oldValue = this.getSettingValue(id, undefined)
this.settingsValues[this.getId(id)] = value
if (id in this.settingsLookup) {
this.settingsLookup[id].onChange?.(value, oldValue);
this.settingsLookup[id].onChange?.(value, oldValue)
}
this.#dispatchChange(id, value, oldValue);
this.#dispatchChange(id, value, oldValue)
await api.storeSetting(id, value);
await api.storeSetting(id, value)
}
setSettingValue(id: string, value: any) {
this.setSettingValueAsync(id, value).catch((err) => {
alert(`Error saving setting '${id}'`);
console.error(err);
});
alert(`Error saving setting '${id}'`)
console.error(err)
})
}
refreshSetting(id: string) {
const value = this.getSettingValue(id);
this.settingsLookup[id].onChange?.(value);
this.#dispatchChange(id, value);
const value = this.getSettingValue(id)
this.settingsLookup[id].onChange?.(value)
this.#dispatchChange(id, value)
}
addSetting(params: SettingParams) {
@@ -151,232 +151,231 @@ export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
defaultValue,
onChange,
attrs = {},
tooltip = "",
options = undefined,
} = params;
tooltip = '',
options = undefined
} = params
if (!id) {
throw new Error("Settings must have an ID");
throw new Error('Settings must have an ID')
}
if (id in this.settingsLookup) {
throw new Error(`Setting ${id} of type ${type} must have a unique ID.`);
throw new Error(`Setting ${id} of type ${type} must have a unique ID.`)
}
let skipOnChange = false;
let value = this.getSettingValue(id);
let skipOnChange = false
let value = this.getSettingValue(id)
if (value == null) {
if (this.app.isNewUserSession) {
// Check if we have a localStorage value but not a setting value and we are a new user
const localValue = localStorage["Comfy.Settings." + id];
const localValue = localStorage['Comfy.Settings.' + id]
if (localValue) {
value = JSON.parse(localValue);
this.setSettingValue(id, value); // Store on the server
value = JSON.parse(localValue)
this.setSettingValue(id, value) // Store on the server
}
}
if (value == null) {
value = defaultValue;
value = defaultValue
}
}
// Trigger initial setting of value
if (!skipOnChange) {
onChange?.(value, undefined);
this.#dispatchChange(id, value);
onChange?.(value, undefined)
this.#dispatchChange(id, value)
}
this.settingsParamLookup[id] = params;
this.settingsParamLookup[id] = params
this.settingsLookup[id] = {
id,
onChange,
name,
render: () => {
if (type === "hidden") return;
if (type === 'hidden') return
const setter = (v) => {
if (onChange) {
onChange(v, value);
onChange(v, value)
}
this.setSettingValue(id, v);
value = v;
};
value = this.getSettingValue(id, defaultValue);
this.setSettingValue(id, v)
value = v
}
value = this.getSettingValue(id, defaultValue)
let element;
const htmlID = id.replaceAll(".", "-");
let element
const htmlID = id.replaceAll('.', '-')
const labelCell = $el("td", [
$el("label", {
const labelCell = $el('td', [
$el('label', {
for: htmlID,
classList: [tooltip !== "" ? "comfy-tooltip-indicator" : ""],
textContent: name,
}),
]);
classList: [tooltip !== '' ? 'comfy-tooltip-indicator' : ''],
textContent: name
})
])
if (typeof type === "function") {
element = type(name, setter, value, attrs);
if (typeof type === 'function') {
element = type(name, setter, value, attrs)
} else {
switch (type) {
case "boolean":
element = $el("tr", [
case 'boolean':
element = $el('tr', [
labelCell,
$el("td", [
$el("input", {
$el('td', [
$el('input', {
id: htmlID,
type: "checkbox",
type: 'checkbox',
checked: value,
onchange: (event) => {
const isChecked = event.target.checked;
const isChecked = event.target.checked
if (onChange !== undefined) {
onChange(isChecked);
onChange(isChecked)
}
this.setSettingValue(id, isChecked);
},
}),
]),
]);
break;
case "number":
element = $el("tr", [
this.setSettingValue(id, isChecked)
}
})
])
])
break
case 'number':
element = $el('tr', [
labelCell,
$el("td", [
$el("input", {
$el('td', [
$el('input', {
type,
value,
id: htmlID,
oninput: (e) => {
setter(e.target.value);
setter(e.target.value)
},
...attrs,
}),
]),
]);
break;
case "slider":
element = $el("tr", [
...attrs
})
])
])
break
case 'slider':
element = $el('tr', [
labelCell,
$el("td", [
$el('td', [
$el(
"div",
'div',
{
style: {
display: "grid",
gridAutoFlow: "column",
},
display: 'grid',
gridAutoFlow: 'column'
}
},
[
$el("input", {
$el('input', {
...attrs,
value,
type: "range",
type: 'range',
oninput: (e) => {
setter(e.target.value);
e.target.nextElementSibling.value = e.target.value;
},
setter(e.target.value)
e.target.nextElementSibling.value = e.target.value
}
}),
$el("input", {
$el('input', {
...attrs,
value,
id: htmlID,
type: "number",
style: { maxWidth: "4rem" },
type: 'number',
style: { maxWidth: '4rem' },
oninput: (e) => {
setter(e.target.value);
e.target.previousElementSibling.value =
e.target.value;
},
}),
setter(e.target.value)
e.target.previousElementSibling.value = e.target.value
}
})
]
),
]),
]);
break;
case "combo":
element = $el("tr", [
)
])
])
break
case 'combo':
element = $el('tr', [
labelCell,
$el("td", [
$el('td', [
$el(
"select",
'select',
{
oninput: (e) => {
setter(e.target.value);
},
setter(e.target.value)
}
},
(typeof options === "function"
(typeof options === 'function'
? options(value)
: options || []
).map((opt) => {
if (typeof opt === "string") {
opt = { text: opt };
if (typeof opt === 'string') {
opt = { text: opt }
}
const v = opt.value ?? opt.text;
return $el("option", {
const v = opt.value ?? opt.text
return $el('option', {
value: v,
textContent: opt.text,
selected: value + "" === v + "",
});
selected: value + '' === v + ''
})
})
),
]),
]);
break;
case "text":
)
])
])
break
case 'text':
default:
if (type !== "text") {
if (type !== 'text') {
console.warn(
`Unsupported setting type '${type}, defaulting to text`
);
)
}
element = $el("tr", [
element = $el('tr', [
labelCell,
$el("td", [
$el("input", {
$el('td', [
$el('input', {
value,
id: htmlID,
oninput: (e) => {
setter(e.target.value);
setter(e.target.value)
},
...attrs,
}),
]),
]);
break;
...attrs
})
])
])
break
}
}
if (tooltip) {
element.title = tooltip;
element.title = tooltip
}
return element;
},
} as Setting;
return element
}
} as Setting
const self = this;
const self = this
return {
get value() {
return self.getSettingValue(id, defaultValue);
return self.getSettingValue(id, defaultValue)
},
set value(v) {
self.setSettingValue(id, v);
},
};
self.setSettingValue(id, v)
}
}
}
show() {
this.textElement.replaceChildren(
$el(
"tr",
'tr',
{
style: { display: "none" },
style: { display: 'none' }
},
[$el("th"), $el("th", { style: { width: "33%" } })]
[$el('th'), $el('th', { style: { width: '33%' } })]
),
...this.settings
.sort((a, b) => a.name.localeCompare(b.name))
.map((s) => s.render())
.filter(Boolean)
);
this.element.showModal();
)
this.element.showModal()
}
}

View File

@@ -1,7 +1,7 @@
import "./spinner.css";
import './spinner.css'
export function createSpinner() {
const div = document.createElement("div");
div.innerHTML = `<div class="lds-ring"><div></div><div></div><div></div><div></div></div>`;
return div.firstElementChild;
const div = document.createElement('div')
div.innerHTML = `<div class="lds-ring"><div></div><div></div><div></div><div></div></div>`
return div.firstElementChild
}

View File

@@ -1,4 +1,4 @@
import { $el } from "../ui";
import { $el } from '../ui'
/**
* @typedef { { text: string, value?: string, tooltip?: string } } ToggleSwitchItem
@@ -11,55 +11,55 @@ import { $el } from "../ui";
* @param { (e: { item: ToggleSwitchItem, prev?: ToggleSwitchItem }) => void } [opts.onChange]
*/
export function toggleSwitch(name, items, e?) {
const onChange = e?.onChange;
const onChange = e?.onChange
let selectedIndex;
let elements;
let selectedIndex
let elements
function updateSelected(index) {
if (selectedIndex != null) {
elements[selectedIndex].classList.remove("comfy-toggle-selected");
elements[selectedIndex].classList.remove('comfy-toggle-selected')
}
onChange?.({
item: items[index],
prev: selectedIndex == null ? undefined : items[selectedIndex],
});
selectedIndex = index;
elements[selectedIndex].classList.add("comfy-toggle-selected");
prev: selectedIndex == null ? undefined : items[selectedIndex]
})
selectedIndex = index
elements[selectedIndex].classList.add('comfy-toggle-selected')
}
elements = items.map((item, i) => {
if (typeof item === "string") item = { text: item };
if (!item.value) item.value = item.text;
if (typeof item === 'string') item = { text: item }
if (!item.value) item.value = item.text
const toggle = $el(
"label",
'label',
{
textContent: item.text,
title: item.tooltip ?? "",
title: item.tooltip ?? ''
},
$el("input", {
$el('input', {
name,
type: "radio",
type: 'radio',
value: item.value ?? item.text,
checked: item.selected,
onchange: () => {
updateSelected(i);
},
updateSelected(i)
}
})
);
)
if (item.selected) {
updateSelected(i);
updateSelected(i)
}
return toggle;
});
return toggle
})
const container = $el("div.comfy-toggle-switch", elements);
const container = $el('div.comfy-toggle-switch', elements)
if (selectedIndex == null) {
elements[0].children[0].checked = true;
updateSelected(0);
elements[0].children[0].checked = true
updateSelected(0)
}
return container;
return container
}

View File

@@ -1,57 +1,57 @@
import { api } from "../api";
import { $el } from "../ui";
import { createSpinner } from "./spinner";
import "./userSelection.css";
import { api } from '../api'
import { $el } from '../ui'
import { createSpinner } from './spinner'
import './userSelection.css'
interface SelectedUser {
username: string;
userId: string;
created: boolean;
username: string
userId: string
created: boolean
}
export class UserSelectionScreen {
async show(users, user): Promise<SelectedUser> {
const userSelection = document.getElementById("comfy-user-selection");
userSelection.style.display = "";
const userSelection = document.getElementById('comfy-user-selection')
userSelection.style.display = ''
return new Promise((resolve) => {
const input = userSelection.getElementsByTagName("input")[0];
const select = userSelection.getElementsByTagName("select")[0];
const inputSection = input.closest("section");
const selectSection = select.closest("section");
const form = userSelection.getElementsByTagName("form")[0];
const error = userSelection.getElementsByClassName("comfy-user-error")[0];
const input = userSelection.getElementsByTagName('input')[0]
const select = userSelection.getElementsByTagName('select')[0]
const inputSection = input.closest('section')
const selectSection = select.closest('section')
const form = userSelection.getElementsByTagName('form')[0]
const error = userSelection.getElementsByClassName('comfy-user-error')[0]
const button = userSelection.getElementsByClassName(
"comfy-user-button-next"
)[0];
'comfy-user-button-next'
)[0]
let inputActive = null;
input.addEventListener("focus", () => {
inputSection.classList.add("selected");
selectSection.classList.remove("selected");
inputActive = true;
});
select.addEventListener("focus", () => {
inputSection.classList.remove("selected");
selectSection.classList.add("selected");
inputActive = false;
select.style.color = "";
});
select.addEventListener("blur", () => {
let inputActive = null
input.addEventListener('focus', () => {
inputSection.classList.add('selected')
selectSection.classList.remove('selected')
inputActive = true
})
select.addEventListener('focus', () => {
inputSection.classList.remove('selected')
selectSection.classList.add('selected')
inputActive = false
select.style.color = ''
})
select.addEventListener('blur', () => {
if (!select.value) {
select.style.color = "var(--descrip-text)";
select.style.color = 'var(--descrip-text)'
}
});
})
form.addEventListener("submit", async (e) => {
e.preventDefault();
form.addEventListener('submit', async (e) => {
e.preventDefault()
if (inputActive == null) {
error.textContent =
"Please enter a username or select an existing user.";
'Please enter a username or select an existing user.'
} else if (inputActive) {
const username = input.value.trim();
const username = input.value.trim()
if (!username) {
error.textContent = "Please enter a username.";
return;
error.textContent = 'Please enter a username.'
return
}
// Create new user
@@ -63,31 +63,31 @@ export class UserSelectionScreen {
input.readonly =
// @ts-ignore
select.readonly =
true;
const spinner = createSpinner();
button.prepend(spinner);
true
const spinner = createSpinner()
button.prepend(spinner)
try {
const resp = await api.createUser(username);
const resp = await api.createUser(username)
if (resp.status >= 300) {
let message =
"Error creating user: " + resp.status + " " + resp.statusText;
'Error creating user: ' + resp.status + ' ' + resp.statusText
try {
const res = await resp.json();
const res = await resp.json()
if (res.error) {
message = res.error;
message = res.error
}
} catch (error) {}
throw new Error(message);
throw new Error(message)
}
resolve({ username, userId: await resp.json(), created: true });
resolve({ username, userId: await resp.json(), created: true })
} catch (err) {
spinner.remove();
spinner.remove()
error.textContent =
err.message ??
err.statusText ??
err ??
"An unknown error occurred.";
'An unknown error occurred.'
// Property 'readonly' does not exist on type 'HTMLSelectElement'.ts(2339)
// Property 'readonly' does not exist on type 'HTMLInputElement'. Did you mean 'readOnly'?ts(2551)
input.disabled =
@@ -96,50 +96,50 @@ export class UserSelectionScreen {
input.readonly =
// @ts-ignore
select.readonly =
false;
return;
false
return
}
} else if (!select.value) {
error.textContent = "Please select an existing user.";
return;
error.textContent = 'Please select an existing user.'
return
} else {
resolve({
username: users[select.value],
userId: select.value,
created: false,
});
created: false
})
}
});
})
if (user) {
const name = localStorage["Comfy.userName"];
const name = localStorage['Comfy.userName']
if (name) {
input.value = name;
input.value = name
}
}
if (input.value) {
// Focus the input, do this separately as sometimes browsers like to fill in the value
input.focus();
input.focus()
}
const userIds = Object.keys(users ?? {});
const userIds = Object.keys(users ?? {})
if (userIds.length) {
for (const u of userIds) {
$el("option", { textContent: users[u], value: u, parent: select });
$el('option', { textContent: users[u], value: u, parent: select })
}
select.style.color = "var(--descrip-text)";
select.style.color = 'var(--descrip-text)'
if (select.value) {
// Focus the select, do this separately as sometimes browsers like to fill in the value
select.focus();
select.focus()
}
} else {
userSelection.classList.add("no-users");
input.focus();
userSelection.classList.add('no-users')
input.focus()
}
}).then((r: SelectedUser) => {
userSelection.remove();
return r;
});
userSelection.remove()
return r
})
}
}

View File

@@ -1,28 +1,28 @@
export type ClassList = string | string[] | Record<string, boolean>;
export type ClassList = string | string[] | Record<string, boolean>
export function applyClasses(
element: HTMLElement,
classList: ClassList,
...requiredClasses: string[]
) {
classList ??= "";
classList ??= ''
let str: string;
if (typeof classList === "string") {
str = classList;
let str: string
if (typeof classList === 'string') {
str = classList
} else if (classList instanceof Array) {
str = classList.join(" ");
str = classList.join(' ')
} else {
str = Object.entries(classList).reduce((p, c) => {
if (c[1]) {
p += (p.length ? " " : "") + c[0];
p += (p.length ? ' ' : '') + c[0]
}
return p;
}, "");
return p
}, '')
}
element.className = str;
element.className = str
if (requiredClasses) {
element.classList.add(...requiredClasses);
element.classList.add(...requiredClasses)
}
}
@@ -30,28 +30,28 @@ export function toggleElement(
element: HTMLElement,
{
onHide,
onShow,
onShow
}: {
onHide?: (el: HTMLElement) => void;
onShow?: (el: HTMLElement, value) => void;
onHide?: (el: HTMLElement) => void
onShow?: (el: HTMLElement, value) => void
} = {}
) {
let placeholder: HTMLElement | Comment;
let hidden: boolean;
let placeholder: HTMLElement | Comment
let hidden: boolean
return (value) => {
if (value) {
if (hidden) {
hidden = false;
placeholder.replaceWith(element);
hidden = false
placeholder.replaceWith(element)
}
onShow?.(element, value);
onShow?.(element, value)
} else {
if (!placeholder) {
placeholder = document.createComment("");
placeholder = document.createComment('')
}
hidden = true;
element.replaceWith(placeholder);
onHide?.(element);
hidden = true
element.replaceWith(placeholder)
onHide?.(element)
}
};
}
}

View File

@@ -1,6 +1,6 @@
import { api } from "./api";
import type { ComfyApp } from "./app";
import { $el } from "./ui";
import { api } from './api'
import type { ComfyApp } from './app'
import { $el } from './ui'
// Simple date formatter
const parts = {
@@ -8,88 +8,82 @@ const parts = {
M: (d) => d.getMonth() + 1,
h: (d) => d.getHours(),
m: (d) => d.getMinutes(),
s: (d) => d.getSeconds(),
};
s: (d) => d.getSeconds()
}
const format =
Object.keys(parts)
.map((k) => k + k + "?")
.join("|") + "|yyy?y?";
.map((k) => k + k + '?')
.join('|') + '|yyy?y?'
function formatDate(text: string, date: Date) {
return text.replace(new RegExp(format, "g"), (text: string): string => {
if (text === "yy") return (date.getFullYear() + "").substring(2);
if (text === "yyyy") return date.getFullYear().toString();
return text.replace(new RegExp(format, 'g'), (text: string): string => {
if (text === 'yy') return (date.getFullYear() + '').substring(2)
if (text === 'yyyy') return date.getFullYear().toString()
if (text[0] in parts) {
const p = parts[text[0]](date);
return (p + "").padStart(text.length, "0");
const p = parts[text[0]](date)
return (p + '').padStart(text.length, '0')
}
return text;
});
return text
})
}
export function clone(obj) {
try {
if (typeof structuredClone !== "undefined") {
return structuredClone(obj);
if (typeof structuredClone !== 'undefined') {
return structuredClone(obj)
}
} catch (error) {
// structuredClone is stricter than using JSON.parse/stringify so fallback to that
}
return JSON.parse(JSON.stringify(obj));
return JSON.parse(JSON.stringify(obj))
}
export function applyTextReplacements(app: ComfyApp, value: string): string {
return value.replace(/%([^%]+)%/g, function (match, text) {
const split = text.split(".");
const split = text.split('.')
if (split.length !== 2) {
// Special handling for dates
if (split[0].startsWith("date:")) {
return formatDate(split[0].substring(5), new Date());
if (split[0].startsWith('date:')) {
return formatDate(split[0].substring(5), new Date())
}
if (text !== "width" && text !== "height") {
if (text !== 'width' && text !== 'height') {
// Dont warn on standard replacements
console.warn("Invalid replacement pattern", text);
console.warn('Invalid replacement pattern', text)
}
return match;
return match
}
// Find node with matching S&R property name
// @ts-ignore
let nodes = app.graph._nodes.filter(
(n) => n.properties?.["Node name for S&R"] === split[0]
);
(n) => n.properties?.['Node name for S&R'] === split[0]
)
// If we cant, see if there is a node with that title
if (!nodes.length) {
// @ts-ignore
nodes = app.graph._nodes.filter((n) => n.title === split[0]);
nodes = app.graph._nodes.filter((n) => n.title === split[0])
}
if (!nodes.length) {
console.warn("Unable to find node", split[0]);
return match;
console.warn('Unable to find node', split[0])
return match
}
if (nodes.length > 1) {
console.warn("Multiple nodes matched", split[0], "using first match");
console.warn('Multiple nodes matched', split[0], 'using first match')
}
const node = nodes[0];
const node = nodes[0]
const widget = node.widgets?.find((w) => w.name === split[1]);
const widget = node.widgets?.find((w) => w.name === split[1])
if (!widget) {
console.warn(
"Unable to find widget",
split[1],
"on node",
split[0],
node
);
return match;
console.warn('Unable to find widget', split[1], 'on node', split[0], node)
return match
}
return ((widget.value ?? "") + "").replaceAll(/\/|\\/g, "_");
});
return ((widget.value ?? '') + '').replaceAll(/\/|\\/g, '_')
})
}
export async function addStylesheet(
@@ -97,24 +91,24 @@ export async function addStylesheet(
relativeTo?: string
): Promise<void> {
return new Promise((res, rej) => {
let url;
if (urlOrFile.endsWith(".js")) {
url = urlOrFile.substr(0, urlOrFile.length - 2) + "css";
let url
if (urlOrFile.endsWith('.js')) {
url = urlOrFile.substr(0, urlOrFile.length - 2) + 'css'
} else {
url = new URL(
urlOrFile,
relativeTo ?? `${window.location.protocol}//${window.location.host}`
).toString();
).toString()
}
$el("link", {
$el('link', {
parent: document.head,
rel: "stylesheet",
type: "text/css",
rel: 'stylesheet',
type: 'text/css',
href: url,
onload: res,
onerror: rej,
});
});
onerror: rej
})
})
}
/**
@@ -122,18 +116,18 @@ export async function addStylesheet(
* @param { Blob } blob
*/
export function downloadBlob(filename, blob) {
const url = URL.createObjectURL(blob);
const a = $el("a", {
const url = URL.createObjectURL(blob)
const a = $el('a', {
href: url,
download: filename,
style: { display: "none" },
parent: document.body,
});
a.click();
style: { display: 'none' },
parent: document.body
})
a.click()
setTimeout(function () {
a.remove();
window.URL.revokeObjectURL(url);
}, 0);
a.remove()
window.URL.revokeObjectURL(url)
}, 0)
}
export function prop<T>(
@@ -147,32 +141,32 @@ export function prop<T>(
name: string
) => void
): T {
let currentValue;
let currentValue
Object.defineProperty(target, name, {
get() {
return currentValue;
return currentValue
},
set(newValue) {
const prevValue = currentValue;
currentValue = newValue;
onChanged?.(currentValue, prevValue, target, name);
},
});
return defaultValue;
const prevValue = currentValue
currentValue = newValue
onChanged?.(currentValue, prevValue, target, name)
}
})
return defaultValue
}
export function getStorageValue(id) {
const clientId = api.clientId ?? api.initialClientId;
const clientId = api.clientId ?? api.initialClientId
return (
(clientId && sessionStorage.getItem(`${id}:${clientId}`)) ??
localStorage.getItem(id)
);
)
}
export function setStorageValue(id, value) {
const clientId = api.clientId ?? api.initialClientId;
const clientId = api.clientId ?? api.initialClientId
if (clientId) {
sessionStorage.setItem(`${id}:${clientId}`, value);
sessionStorage.setItem(`${id}:${clientId}`, value)
}
localStorage.setItem(id, value);
localStorage.setItem(id, value)
}

View File

@@ -1,8 +1,8 @@
import { api } from "./api";
import "./domWidget";
import type { ComfyApp } from "./app";
import type { IWidget, LGraphNode } from "@comfyorg/litegraph";
import { ComfyNodeDef } from "@/types/apiTypes";
import { api } from './api'
import './domWidget'
import type { ComfyApp } from './app'
import type { IWidget, LGraphNode } from '@comfyorg/litegraph'
import { ComfyNodeDef } from '@/types/apiTypes'
export type ComfyWidgetConstructor = (
node: LGraphNode,
@@ -10,20 +10,20 @@ export type ComfyWidgetConstructor = (
inputData: ComfyNodeDef,
app?: ComfyApp,
widgetName?: string
) => { widget: IWidget; minWidth?: number; minHeight?: number };
) => { widget: IWidget; minWidth?: number; minHeight?: number }
let controlValueRunBefore = false;
let controlValueRunBefore = false
export function updateControlWidgetLabel(widget) {
let replacement = "after";
let find = "before";
let replacement = 'after'
let find = 'before'
if (controlValueRunBefore) {
[find, replacement] = [replacement, find];
;[find, replacement] = [replacement, find]
}
widget.label = (widget.label ?? widget.name).replace(find, replacement);
widget.label = (widget.label ?? widget.name).replace(find, replacement)
}
const IS_CONTROL_WIDGET = Symbol();
const HAS_EXECUTED = Symbol();
const IS_CONTROL_WIDGET = Symbol()
const HAS_EXECUTED = Symbol()
function getNumberDefaults(
inputData: ComfyNodeDef,
@@ -31,41 +31,41 @@ function getNumberDefaults(
precision,
enable_rounding
) {
let defaultVal = inputData[1]["default"];
let { min, max, step, round } = inputData[1];
let defaultVal = inputData[1]['default']
let { min, max, step, round } = inputData[1]
if (defaultVal == undefined) defaultVal = 0;
if (min == undefined) min = 0;
if (max == undefined) max = 2048;
if (step == undefined) step = defaultStep;
if (defaultVal == undefined) defaultVal = 0
if (min == undefined) min = 0
if (max == undefined) max = 2048
if (step == undefined) step = defaultStep
// precision is the number of decimal places to show.
// by default, display the the smallest number of decimal places such that changes of size step are visible.
if (precision == undefined) {
precision = Math.max(-Math.floor(Math.log10(step)), 0);
precision = Math.max(-Math.floor(Math.log10(step)), 0)
}
if (enable_rounding && (round == undefined || round === true)) {
// by default, round the value to those decimal places shown.
round = Math.round(1000000 * Math.pow(0.1, precision)) / 1000000;
round = Math.round(1000000 * Math.pow(0.1, precision)) / 1000000
}
return {
val: defaultVal,
config: { min, max, step: 10.0 * step, round, precision },
};
config: { min, max, step: 10.0 * step, round, precision }
}
}
export function addValueControlWidget(
node,
targetWidget,
defaultValue = "randomize",
defaultValue = 'randomize',
values,
widgetName,
inputData: ComfyNodeDef
) {
let name = inputData[1]?.control_after_generate;
if (typeof name !== "string") {
name = widgetName;
let name = inputData[1]?.control_after_generate
if (typeof name !== 'string') {
name = widgetName
}
const widgets = addValueControlWidgets(
node,
@@ -73,200 +73,200 @@ export function addValueControlWidget(
defaultValue,
{
addFilterList: false,
controlAfterGenerateName: name,
controlAfterGenerateName: name
},
inputData
);
return widgets[0];
)
return widgets[0]
}
export function addValueControlWidgets(
node,
targetWidget,
defaultValue = "randomize",
defaultValue = 'randomize',
options,
inputData: ComfyNodeDef
) {
if (!defaultValue) defaultValue = "randomize";
if (!options) options = {};
if (!defaultValue) defaultValue = 'randomize'
if (!options) options = {}
const getName = (defaultName, optionName) => {
let name = defaultName;
let name = defaultName
if (options[optionName]) {
name = options[optionName];
} else if (typeof inputData?.[1]?.[defaultName] === "string") {
name = inputData?.[1]?.[defaultName];
name = options[optionName]
} else if (typeof inputData?.[1]?.[defaultName] === 'string') {
name = inputData?.[1]?.[defaultName]
} else if (inputData?.[1]?.control_prefix) {
name = inputData?.[1]?.control_prefix + " " + name;
name = inputData?.[1]?.control_prefix + ' ' + name
}
return name;
};
return name
}
const widgets = [];
const widgets = []
const valueControl = node.addWidget(
"combo",
getName("control_after_generate", "controlAfterGenerateName"),
'combo',
getName('control_after_generate', 'controlAfterGenerateName'),
defaultValue,
function () {},
{
values: ["fixed", "increment", "decrement", "randomize"],
serialize: false, // Don't include this in prompt.
values: ['fixed', 'increment', 'decrement', 'randomize'],
serialize: false // Don't include this in prompt.
}
);
valueControl[IS_CONTROL_WIDGET] = true;
updateControlWidgetLabel(valueControl);
widgets.push(valueControl);
)
valueControl[IS_CONTROL_WIDGET] = true
updateControlWidgetLabel(valueControl)
widgets.push(valueControl)
const isCombo = targetWidget.type === "combo";
let comboFilter;
const isCombo = targetWidget.type === 'combo'
let comboFilter
if (isCombo) {
valueControl.options.values.push("increment-wrap");
valueControl.options.values.push('increment-wrap')
}
if (isCombo && options.addFilterList !== false) {
comboFilter = node.addWidget(
"string",
getName("control_filter_list", "controlFilterListName"),
"",
'string',
getName('control_filter_list', 'controlFilterListName'),
'',
function () {},
{
serialize: false, // Don't include this in prompt.
serialize: false // Don't include this in prompt.
}
);
updateControlWidgetLabel(comboFilter);
)
updateControlWidgetLabel(comboFilter)
widgets.push(comboFilter);
widgets.push(comboFilter)
}
const applyWidgetControl = () => {
var v = valueControl.value;
var v = valueControl.value
if (isCombo && v !== "fixed") {
let values = targetWidget.options.values;
const filter = comboFilter?.value;
if (isCombo && v !== 'fixed') {
let values = targetWidget.options.values
const filter = comboFilter?.value
if (filter) {
let check;
if (filter.startsWith("/") && filter.endsWith("/")) {
let check
if (filter.startsWith('/') && filter.endsWith('/')) {
try {
const regex = new RegExp(filter.substring(1, filter.length - 1));
check = (item) => regex.test(item);
const regex = new RegExp(filter.substring(1, filter.length - 1))
check = (item) => regex.test(item)
} catch (error) {
console.error(
"Error constructing RegExp filter for node " + node.id,
'Error constructing RegExp filter for node ' + node.id,
filter,
error
);
)
}
}
if (!check) {
const lower = filter.toLocaleLowerCase();
check = (item) => item.toLocaleLowerCase().includes(lower);
const lower = filter.toLocaleLowerCase()
check = (item) => item.toLocaleLowerCase().includes(lower)
}
values = values.filter((item) => check(item));
values = values.filter((item) => check(item))
if (!values.length && targetWidget.options.values.length) {
console.warn(
"Filter for node " + node.id + " has filtered out all items",
'Filter for node ' + node.id + ' has filtered out all items',
filter
);
)
}
}
let current_index = values.indexOf(targetWidget.value);
let current_length = values.length;
let current_index = values.indexOf(targetWidget.value)
let current_length = values.length
switch (v) {
case "increment":
current_index += 1;
break;
case "increment-wrap":
current_index += 1;
case 'increment':
current_index += 1
break
case 'increment-wrap':
current_index += 1
if (current_index >= current_length) {
current_index = 0;
current_index = 0
}
break;
case "decrement":
current_index -= 1;
break;
case "randomize":
current_index = Math.floor(Math.random() * current_length);
break;
break
case 'decrement':
current_index -= 1
break
case 'randomize':
current_index = Math.floor(Math.random() * current_length)
break
default:
break;
break
}
current_index = Math.max(0, current_index);
current_index = Math.min(current_length - 1, current_index);
current_index = Math.max(0, current_index)
current_index = Math.min(current_length - 1, current_index)
if (current_index >= 0) {
let value = values[current_index];
targetWidget.value = value;
targetWidget.callback(value);
let value = values[current_index]
targetWidget.value = value
targetWidget.callback(value)
}
} else {
//number
let min = targetWidget.options.min;
let max = targetWidget.options.max;
let min = targetWidget.options.min
let max = targetWidget.options.max
// limit to something that javascript can handle
max = Math.min(1125899906842624, max);
min = Math.max(-1125899906842624, min);
let range = (max - min) / (targetWidget.options.step / 10);
max = Math.min(1125899906842624, max)
min = Math.max(-1125899906842624, min)
let range = (max - min) / (targetWidget.options.step / 10)
//adjust values based on valueControl Behaviour
switch (v) {
case "fixed":
break;
case "increment":
targetWidget.value += targetWidget.options.step / 10;
break;
case "decrement":
targetWidget.value -= targetWidget.options.step / 10;
break;
case "randomize":
case 'fixed':
break
case 'increment':
targetWidget.value += targetWidget.options.step / 10
break
case 'decrement':
targetWidget.value -= targetWidget.options.step / 10
break
case 'randomize':
targetWidget.value =
Math.floor(Math.random() * range) *
(targetWidget.options.step / 10) +
min;
break;
min
break
default:
break;
break
}
/*check if values are over or under their respective
* ranges and set them to min or max.*/
if (targetWidget.value < min) targetWidget.value = min;
if (targetWidget.value < min) targetWidget.value = min
if (targetWidget.value > max) targetWidget.value = max;
targetWidget.callback(targetWidget.value);
if (targetWidget.value > max) targetWidget.value = max
targetWidget.callback(targetWidget.value)
}
};
}
valueControl.beforeQueued = () => {
if (controlValueRunBefore) {
// Don't run on first execution
if (valueControl[HAS_EXECUTED]) {
applyWidgetControl();
applyWidgetControl()
}
}
valueControl[HAS_EXECUTED] = true;
};
valueControl[HAS_EXECUTED] = true
}
valueControl.afterQueued = () => {
if (!controlValueRunBefore) {
applyWidgetControl();
applyWidgetControl()
}
};
}
return widgets;
return widgets
}
function seedWidget(node, inputName, inputData: ComfyNodeDef, app, widgetName) {
const seed = createIntWidget(node, inputName, inputData, app, true);
const seed = createIntWidget(node, inputName, inputData, app, true)
const seedControl = addValueControlWidget(
node,
seed.widget,
"randomize",
'randomize',
undefined,
widgetName,
inputData
);
)
seed.widget.linkedWidgets = [seedControl];
return seed;
seed.widget.linkedWidgets = [seedControl]
return seed
}
function createIntWidget(
@@ -276,119 +276,116 @@ function createIntWidget(
app,
isSeedInput: boolean = false
) {
const control = inputData[1]?.control_after_generate;
const control = inputData[1]?.control_after_generate
if (!isSeedInput && control) {
return seedWidget(
node,
inputName,
inputData,
app,
typeof control === "string" ? control : undefined
);
typeof control === 'string' ? control : undefined
)
}
let widgetType = isSlider(inputData[1]["display"], app);
const { val, config } = getNumberDefaults(inputData, 1, 0, true);
Object.assign(config, { precision: 0 });
let widgetType = isSlider(inputData[1]['display'], app)
const { val, config } = getNumberDefaults(inputData, 1, 0, true)
Object.assign(config, { precision: 0 })
return {
widget: node.addWidget(
widgetType,
inputName,
val,
function (v) {
const s = this.options.step / 10;
let sh = this.options.min % s;
const s = this.options.step / 10
let sh = this.options.min % s
if (isNaN(sh)) {
sh = 0;
sh = 0
}
this.value = Math.round((v - sh) / s) * s + sh;
this.value = Math.round((v - sh) / s) * s + sh
},
config
),
};
)
}
}
function addMultilineWidget(node, name, opts, app) {
const inputEl = document.createElement("textarea");
inputEl.className = "comfy-multiline-input";
inputEl.value = opts.defaultVal;
inputEl.placeholder = opts.placeholder || name;
const inputEl = document.createElement('textarea')
inputEl.className = 'comfy-multiline-input'
inputEl.value = opts.defaultVal
inputEl.placeholder = opts.placeholder || name
const widget = node.addDOMWidget(name, "customtext", inputEl, {
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
getValue() {
return inputEl.value;
return inputEl.value
},
setValue(v) {
inputEl.value = v;
},
});
widget.inputEl = inputEl;
inputEl.value = v
}
})
widget.inputEl = inputEl
inputEl.addEventListener("input", () => {
widget.callback?.(widget.value);
});
inputEl.addEventListener('input', () => {
widget.callback?.(widget.value)
})
return { minWidth: 400, minHeight: 200, widget };
return { minWidth: 400, minHeight: 200, widget }
}
function isSlider(display, app) {
if (app.ui.settings.getSettingValue("Comfy.DisableSliders")) {
return "number";
if (app.ui.settings.getSettingValue('Comfy.DisableSliders')) {
return 'number'
}
return display === "slider" ? "slider" : "number";
return display === 'slider' ? 'slider' : 'number'
}
export function initWidgets(app) {
app.ui.settings.addSetting({
id: "Comfy.WidgetControlMode",
name: "Widget Value Control Mode",
type: "combo",
defaultValue: "after",
options: ["before", "after"],
id: 'Comfy.WidgetControlMode',
name: 'Widget Value Control Mode',
type: 'combo',
defaultValue: 'after',
options: ['before', 'after'],
tooltip:
"Controls when widget values are updated (randomize/increment/decrement), either before the prompt is queued or after.",
'Controls when widget values are updated (randomize/increment/decrement), either before the prompt is queued or after.',
onChange(value) {
controlValueRunBefore = value === "before";
controlValueRunBefore = value === 'before'
for (const n of app.graph._nodes) {
if (!n.widgets) continue;
if (!n.widgets) continue
for (const w of n.widgets) {
if (w[IS_CONTROL_WIDGET]) {
updateControlWidgetLabel(w);
updateControlWidgetLabel(w)
if (w.linkedWidgets) {
for (const l of w.linkedWidgets) {
updateControlWidgetLabel(l);
updateControlWidgetLabel(l)
}
}
}
}
}
app.graph.setDirtyCanvas(true);
},
});
app.graph.setDirtyCanvas(true)
}
})
}
export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
"INT:seed": seedWidget,
"INT:noise_seed": seedWidget,
'INT:seed': seedWidget,
'INT:noise_seed': seedWidget,
FLOAT(node, inputName, inputData: ComfyNodeDef, app) {
let widgetType: "number" | "slider" = isSlider(
inputData[1]["display"],
app
);
let widgetType: 'number' | 'slider' = isSlider(inputData[1]['display'], app)
let precision = app.ui.settings.getSettingValue(
"Comfy.FloatRoundingPrecision"
);
'Comfy.FloatRoundingPrecision'
)
let disable_rounding = app.ui.settings.getSettingValue(
"Comfy.DisableFloatRounding"
);
if (precision == 0) precision = undefined;
'Comfy.DisableFloatRounding'
)
if (precision == 0) precision = undefined
const { val, config } = getNumberDefaults(
inputData,
0.5,
precision,
!disable_rounding
);
)
return {
widget: node.addWidget(
widgetType,
@@ -397,72 +394,66 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
function (v) {
if (config.round) {
this.value =
Math.round((v + Number.EPSILON) / config.round) * config.round;
if (this.value > config.max) this.value = config.max;
if (this.value < config.min) this.value = config.min;
Math.round((v + Number.EPSILON) / config.round) * config.round
if (this.value > config.max) this.value = config.max
if (this.value < config.min) this.value = config.min
} else {
this.value = v;
this.value = v
}
},
config
),
};
)
}
},
INT(node, inputName, inputData: ComfyNodeDef, app) {
return createIntWidget(node, inputName, inputData, app);
return createIntWidget(node, inputName, inputData, app)
},
BOOLEAN(node, inputName, inputData) {
let defaultVal = false;
let options = {};
let defaultVal = false
let options = {}
if (inputData[1]) {
if (inputData[1].default) defaultVal = inputData[1].default;
if (inputData[1].label_on) options["on"] = inputData[1].label_on;
if (inputData[1].label_off) options["off"] = inputData[1].label_off;
if (inputData[1].default) defaultVal = inputData[1].default
if (inputData[1].label_on) options['on'] = inputData[1].label_on
if (inputData[1].label_off) options['off'] = inputData[1].label_off
}
return {
widget: node.addWidget(
"toggle",
inputName,
defaultVal,
() => {},
options
),
};
widget: node.addWidget('toggle', inputName, defaultVal, () => {}, options)
}
},
STRING(node, inputName, inputData: ComfyNodeDef, app) {
const defaultVal = inputData[1].default || "";
const multiline = !!inputData[1].multiline;
const defaultVal = inputData[1].default || ''
const multiline = !!inputData[1].multiline
let res;
let res
if (multiline) {
res = addMultilineWidget(
node,
inputName,
{ defaultVal, ...inputData[1] },
app
);
)
} else {
res = {
widget: node.addWidget("text", inputName, defaultVal, () => {}, {}),
};
widget: node.addWidget('text', inputName, defaultVal, () => {}, {})
}
}
if (inputData[1].dynamicPrompts != undefined)
res.widget.dynamicPrompts = inputData[1].dynamicPrompts;
res.widget.dynamicPrompts = inputData[1].dynamicPrompts
return res;
return res
},
COMBO(node, inputName, inputData: ComfyNodeDef) {
const type = inputData[0];
let defaultValue = type[0];
const type = inputData[0]
let defaultValue = type[0]
if (inputData[1] && inputData[1].default) {
defaultValue = inputData[1].default;
defaultValue = inputData[1].default
}
const res = {
widget: node.addWidget("combo", inputName, defaultValue, () => {}, {
values: type,
}),
};
widget: node.addWidget('combo', inputName, defaultValue, () => {}, {
values: type
})
}
if (inputData[1]?.control_after_generate) {
// TODO make combo handle a widget node type?
// @ts-ignore
@@ -472,9 +463,9 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
undefined,
undefined,
inputData
);
)
}
return res;
return res
},
IMAGEUPLOAD(
node: LGraphNode,
@@ -485,168 +476,168 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
// TODO make image upload handle a custom node type?
// @ts-ignore
const imageWidget = node.widgets.find(
(w) => w.name === (inputData[1]?.widget ?? "image")
);
let uploadWidget;
(w) => w.name === (inputData[1]?.widget ?? 'image')
)
let uploadWidget
function showImage(name) {
const img = new Image();
const img = new Image()
img.onload = () => {
// @ts-ignore
node.imgs = [img];
app.graph.setDirtyCanvas(true);
};
let folder_separator = name.lastIndexOf("/");
let subfolder = "";
node.imgs = [img]
app.graph.setDirtyCanvas(true)
}
let folder_separator = name.lastIndexOf('/')
let subfolder = ''
if (folder_separator > -1) {
subfolder = name.substring(0, folder_separator);
name = name.substring(folder_separator + 1);
subfolder = name.substring(0, folder_separator)
name = name.substring(folder_separator + 1)
}
img.src = api.apiURL(
`/view?filename=${encodeURIComponent(name)}&type=input&subfolder=${subfolder}${app.getPreviewFormatParam()}${app.getRandParam()}`
);
)
// @ts-ignore
node.setSizeForImage?.();
node.setSizeForImage?.()
}
var default_value = imageWidget.value;
Object.defineProperty(imageWidget, "value", {
var default_value = imageWidget.value
Object.defineProperty(imageWidget, 'value', {
set: function (value) {
this._real_value = value;
this._real_value = value
},
get: function () {
if (!this._real_value) {
return default_value;
return default_value
}
let value = this._real_value;
let value = this._real_value
if (value.filename) {
let real_value = value;
value = "";
let real_value = value
value = ''
if (real_value.subfolder) {
value = real_value.subfolder + "/";
value = real_value.subfolder + '/'
}
value += real_value.filename;
value += real_value.filename
if (real_value.type && real_value.type !== "input")
value += ` [${real_value.type}]`;
if (real_value.type && real_value.type !== 'input')
value += ` [${real_value.type}]`
}
return value;
},
});
return value
}
})
// Add our own callback to the combo widget to render an image when it changes
// TODO: Explain this?
// @ts-ignore
const cb = node.callback;
const cb = node.callback
imageWidget.callback = function () {
showImage(imageWidget.value);
showImage(imageWidget.value)
if (cb) {
return cb.apply(this, arguments);
return cb.apply(this, arguments)
}
};
}
// On load if we have a value then render the image
// The value isnt set immediately so we need to wait a moment
// No change callbacks seem to be fired on initial setting of the value
requestAnimationFrame(() => {
if (imageWidget.value) {
showImage(imageWidget.value);
showImage(imageWidget.value)
}
});
})
async function uploadFile(file, updateNode, pasted = false) {
try {
// Wrap file in formdata so it includes filename
const body = new FormData();
body.append("image", file);
if (pasted) body.append("subfolder", "pasted");
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body,
});
const body = new FormData()
body.append('image', file)
if (pasted) body.append('subfolder', 'pasted')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status === 200) {
const data = await resp.json();
const data = await resp.json()
// Add the file to the dropdown list and update the widget value
let path = data.name;
if (data.subfolder) path = data.subfolder + "/" + path;
let path = data.name
if (data.subfolder) path = data.subfolder + '/' + path
if (!imageWidget.options.values.includes(path)) {
imageWidget.options.values.push(path);
imageWidget.options.values.push(path)
}
if (updateNode) {
showImage(path);
imageWidget.value = path;
showImage(path)
imageWidget.value = path
}
} else {
alert(resp.status + " - " + resp.statusText);
alert(resp.status + ' - ' + resp.statusText)
}
} catch (error) {
alert(error);
alert(error)
}
}
const fileInput = document.createElement("input");
const fileInput = document.createElement('input')
Object.assign(fileInput, {
type: "file",
accept: "image/jpeg,image/png,image/webp",
style: "display: none",
type: 'file',
accept: 'image/jpeg,image/png,image/webp',
style: 'display: none',
onchange: async () => {
if (fileInput.files.length) {
await uploadFile(fileInput.files[0], true);
await uploadFile(fileInput.files[0], true)
}
},
});
document.body.append(fileInput);
}
})
document.body.append(fileInput)
// Create the button widget for selecting the files
uploadWidget = node.addWidget("button", inputName, "image", () => {
fileInput.click();
});
uploadWidget.label = "choose file to upload";
uploadWidget.serialize = false;
uploadWidget = node.addWidget('button', inputName, 'image', () => {
fileInput.click()
})
uploadWidget.label = 'choose file to upload'
uploadWidget.serialize = false
// Add handler to check if an image is being dragged over our node
// @ts-ignore
node.onDragOver = function (e) {
if (e.dataTransfer && e.dataTransfer.items) {
const image = [...e.dataTransfer.items].find((f) => f.kind === "file");
return !!image;
const image = [...e.dataTransfer.items].find((f) => f.kind === 'file')
return !!image
}
return false;
};
return false
}
// On drop upload files
// @ts-ignore
node.onDragDrop = function (e) {
console.log("onDragDrop called");
let handled = false;
console.log('onDragDrop called')
let handled = false
for (const file of e.dataTransfer.files) {
if (file.type.startsWith("image/")) {
uploadFile(file, !handled); // Dont await these, any order is fine, only update on first one
handled = true;
if (file.type.startsWith('image/')) {
uploadFile(file, !handled) // Dont await these, any order is fine, only update on first one
handled = true
}
}
return handled;
};
return handled
}
// @ts-ignore
node.pasteFile = function (file) {
if (file.type.startsWith("image/")) {
if (file.type.startsWith('image/')) {
const is_pasted =
file.name === "image.png" && file.lastModified - Date.now() < 2000;
uploadFile(file, true, is_pasted);
return true;
file.name === 'image.png' && file.lastModified - Date.now() < 2000
uploadFile(file, true, is_pasted)
return true
}
return false;
};
return false
}
return { widget: uploadWidget };
},
};
return { widget: uploadWidget }
}
}

View File

@@ -1,53 +1,53 @@
import type { ComfyApp } from "./app";
import { api } from "./api";
import { ChangeTracker } from "./changeTracker";
import { ComfyAsyncDialog } from "./ui/components/asyncDialog";
import { getStorageValue, setStorageValue } from "./utils";
import { LGraphCanvas, LGraph } from "@comfyorg/litegraph";
import type { ComfyApp } from './app'
import { api } from './api'
import { ChangeTracker } from './changeTracker'
import { ComfyAsyncDialog } from './ui/components/asyncDialog'
import { getStorageValue, setStorageValue } from './utils'
import { LGraphCanvas, LGraph } from '@comfyorg/litegraph'
function appendJsonExt(path: string) {
if (!path.toLowerCase().endsWith(".json")) {
path += ".json";
if (!path.toLowerCase().endsWith('.json')) {
path += '.json'
}
return path;
return path
}
export function trimJsonExt(path: string) {
return path?.replace(/\.json$/, "");
return path?.replace(/\.json$/, '')
}
export class ComfyWorkflowManager extends EventTarget {
#activePromptId: string | null = null;
#unsavedCount = 0;
#activeWorkflow: ComfyWorkflow;
#activePromptId: string | null = null
#unsavedCount = 0
#activeWorkflow: ComfyWorkflow
workflowLookup: Record<string, ComfyWorkflow> = {};
workflows: Array<ComfyWorkflow> = [];
openWorkflows: Array<ComfyWorkflow> = [];
workflowLookup: Record<string, ComfyWorkflow> = {}
workflows: Array<ComfyWorkflow> = []
openWorkflows: Array<ComfyWorkflow> = []
queuedPrompts: Record<
string,
{ workflow?: ComfyWorkflow; nodes?: Record<string, boolean> }
> = {};
app: ComfyApp;
> = {}
app: ComfyApp
get activeWorkflow() {
return this.#activeWorkflow ?? this.openWorkflows[0];
return this.#activeWorkflow ?? this.openWorkflows[0]
}
get activePromptId() {
return this.#activePromptId;
return this.#activePromptId
}
get activePrompt() {
return this.queuedPrompts[this.#activePromptId];
return this.queuedPrompts[this.#activePromptId]
}
constructor(app: ComfyApp) {
super();
this.app = app;
ChangeTracker.init(app);
super()
this.app = app
ChangeTracker.init(app)
this.#bindExecutionEvents();
this.#bindExecutionEvents()
}
#bindExecutionEvents() {
@@ -55,99 +55,99 @@ export class ComfyWorkflowManager extends EventTarget {
const emit = () =>
this.dispatchEvent(
new CustomEvent("execute", { detail: this.activePrompt })
);
let executing = null;
api.addEventListener("execution_start", (e) => {
this.#activePromptId = e.detail.prompt_id;
new CustomEvent('execute', { detail: this.activePrompt })
)
let executing = null
api.addEventListener('execution_start', (e) => {
this.#activePromptId = e.detail.prompt_id
// This event can fire before the event is stored, so put a placeholder
this.queuedPrompts[this.#activePromptId] ??= { nodes: {} };
emit();
});
api.addEventListener("execution_cached", (e) => {
if (!this.activePrompt) return;
this.queuedPrompts[this.#activePromptId] ??= { nodes: {} }
emit()
})
api.addEventListener('execution_cached', (e) => {
if (!this.activePrompt) return
for (const n of e.detail.nodes) {
this.activePrompt.nodes[n] = true;
this.activePrompt.nodes[n] = true
}
emit();
});
api.addEventListener("executed", (e) => {
if (!this.activePrompt) return;
this.activePrompt.nodes[e.detail.node] = true;
emit();
});
api.addEventListener("executing", (e) => {
if (!this.activePrompt) return;
emit()
})
api.addEventListener('executed', (e) => {
if (!this.activePrompt) return
this.activePrompt.nodes[e.detail.node] = true
emit()
})
api.addEventListener('executing', (e) => {
if (!this.activePrompt) return
if (executing) {
// Seems sometimes nodes that are cached fire executing but not executed
this.activePrompt.nodes[executing] = true;
this.activePrompt.nodes[executing] = true
}
executing = e.detail;
executing = e.detail
if (!executing) {
delete this.queuedPrompts[this.#activePromptId];
this.#activePromptId = null;
delete this.queuedPrompts[this.#activePromptId]
this.#activePromptId = null
}
emit();
});
emit()
})
}
async loadWorkflows() {
try {
let favorites;
const resp = await api.getUserData("workflows/.index.json");
let info;
let favorites
const resp = await api.getUserData('workflows/.index.json')
let info
if (resp.status === 200) {
info = await resp.json();
favorites = new Set(info?.favorites ?? []);
info = await resp.json()
favorites = new Set(info?.favorites ?? [])
} else {
favorites = new Set();
favorites = new Set()
}
const workflows = (await api.listUserData("workflows", true, true)).map(
const workflows = (await api.listUserData('workflows', true, true)).map(
(w) => {
let workflow = this.workflowLookup[w[0]];
let workflow = this.workflowLookup[w[0]]
if (!workflow) {
workflow = new ComfyWorkflow(
this,
w[0],
w.slice(1),
favorites.has(w[0])
);
this.workflowLookup[workflow.path] = workflow;
)
this.workflowLookup[workflow.path] = workflow
}
return workflow;
return workflow
}
);
)
this.workflows = workflows;
this.workflows = workflows
} catch (error) {
alert("Error loading workflows: " + (error.message ?? error));
this.workflows = [];
alert('Error loading workflows: ' + (error.message ?? error))
this.workflows = []
}
}
async saveWorkflowMetadata() {
await api.storeUserData("workflows/.index.json", {
await api.storeUserData('workflows/.index.json', {
favorites: [
...this.workflows.filter((w) => w.isFavorite).map((w) => w.path),
],
});
...this.workflows.filter((w) => w.isFavorite).map((w) => w.path)
]
})
}
/**
* @param {string | ComfyWorkflow | null} workflow
*/
setWorkflow(workflow) {
if (workflow && typeof workflow === "string") {
if (workflow && typeof workflow === 'string') {
// Selected by path, i.e. on reload of last workflow
const found = this.workflows.find((w) => w.path === workflow);
const found = this.workflows.find((w) => w.path === workflow)
if (found) {
workflow = found;
workflow = found
workflow.unsaved =
!workflow ||
getStorageValue("Comfy.PreviousWorkflowUnsaved") === "true";
getStorageValue('Comfy.PreviousWorkflowUnsaved') === 'true'
}
}
@@ -156,33 +156,33 @@ export class ComfyWorkflowManager extends EventTarget {
workflow = new ComfyWorkflow(
this,
workflow ||
"Unsaved Workflow" +
(this.#unsavedCount++ ? ` (${this.#unsavedCount})` : "")
);
'Unsaved Workflow' +
(this.#unsavedCount++ ? ` (${this.#unsavedCount})` : '')
)
}
const index = this.openWorkflows.indexOf(workflow);
const index = this.openWorkflows.indexOf(workflow)
if (index === -1) {
// Opening a new workflow
this.openWorkflows.push(workflow);
this.openWorkflows.push(workflow)
}
this.#activeWorkflow = workflow;
this.#activeWorkflow = workflow
setStorageValue("Comfy.PreviousWorkflow", this.activeWorkflow.path ?? "");
this.dispatchEvent(new CustomEvent("changeWorkflow"));
setStorageValue('Comfy.PreviousWorkflow', this.activeWorkflow.path ?? '')
this.dispatchEvent(new CustomEvent('changeWorkflow'))
}
storePrompt({ nodes, id }) {
this.queuedPrompts[id] ??= {};
this.queuedPrompts[id] ??= {}
this.queuedPrompts[id].nodes = {
...nodes.reduce((p, n) => {
p[n] = false;
return p;
p[n] = false
return p
}, {}),
...this.queuedPrompts[id].nodes,
};
this.queuedPrompts[id].workflow = this.activeWorkflow;
...this.queuedPrompts[id].nodes
}
this.queuedPrompts[id].workflow = this.activeWorkflow
}
/**
@@ -190,71 +190,71 @@ export class ComfyWorkflowManager extends EventTarget {
*/
async closeWorkflow(workflow, warnIfUnsaved = true) {
if (!workflow.isOpen) {
return true;
return true
}
if (workflow.unsaved && warnIfUnsaved) {
const res = await ComfyAsyncDialog.prompt({
title: "Save Changes?",
title: 'Save Changes?',
message: `Do you want to save changes to "${workflow.path ?? workflow.name}" before closing?`,
actions: ["Yes", "No", "Cancel"],
});
if (res === "Yes") {
const active = this.activeWorkflow;
actions: ['Yes', 'No', 'Cancel']
})
if (res === 'Yes') {
const active = this.activeWorkflow
if (active !== workflow) {
// We need to switch to the workflow to save it
await workflow.load();
await workflow.load()
}
if (!(await workflow.save())) {
// Save was canceled, restore the previous workflow
if (active !== workflow) {
await active.load();
await active.load()
}
return;
return
}
} else if (res === "Cancel") {
return;
} else if (res === 'Cancel') {
return
}
}
workflow.changeTracker = null;
this.openWorkflows.splice(this.openWorkflows.indexOf(workflow), 1);
workflow.changeTracker = null
this.openWorkflows.splice(this.openWorkflows.indexOf(workflow), 1)
if (this.openWorkflows.length) {
this.#activeWorkflow = this.openWorkflows[0];
await this.#activeWorkflow.load();
this.#activeWorkflow = this.openWorkflows[0]
await this.#activeWorkflow.load()
} else {
// Load default
await this.app.loadGraphData();
await this.app.loadGraphData()
}
}
}
export class ComfyWorkflow {
#name;
#path;
#pathParts;
#isFavorite = false;
changeTracker: ChangeTracker | null = null;
unsaved = false;
manager: ComfyWorkflowManager;
#name
#path
#pathParts
#isFavorite = false
changeTracker: ChangeTracker | null = null
unsaved = false
manager: ComfyWorkflowManager
get name() {
return this.#name;
return this.#name
}
get path() {
return this.#path;
return this.#path
}
get pathParts() {
return this.#pathParts;
return this.#pathParts
}
get isFavorite() {
return this.#isFavorite;
return this.#isFavorite
}
get isOpen() {
return !!this.changeTracker;
return !!this.changeTracker
}
constructor(
@@ -263,40 +263,40 @@ export class ComfyWorkflow {
pathParts?: string[],
isFavorite?: boolean
) {
this.manager = manager;
this.manager = manager
if (pathParts) {
this.#updatePath(path, pathParts);
this.#isFavorite = isFavorite;
this.#updatePath(path, pathParts)
this.#isFavorite = isFavorite
} else {
this.#name = path;
this.unsaved = true;
this.#name = path
this.unsaved = true
}
}
#updatePath(path: string, pathParts: string[]) {
this.#path = path;
this.#path = path
if (!pathParts) {
if (!path.includes("\\")) {
pathParts = path.split("/");
if (!path.includes('\\')) {
pathParts = path.split('/')
} else {
pathParts = path.split("\\");
pathParts = path.split('\\')
}
}
this.#pathParts = pathParts;
this.#name = trimJsonExt(pathParts[pathParts.length - 1]);
this.#pathParts = pathParts
this.#name = trimJsonExt(pathParts[pathParts.length - 1])
}
async getWorkflowData() {
const resp = await api.getUserData("workflows/" + this.path);
const resp = await api.getUserData('workflows/' + this.path)
if (resp.status !== 200) {
alert(
`Error loading workflow file '${this.path}': ${resp.status} ${resp.statusText}`
);
return;
)
return
}
return await resp.json();
return await resp.json()
}
load = async () => {
@@ -306,44 +306,44 @@ export class ComfyWorkflow {
true,
true,
this
);
)
} else {
const data = await this.getWorkflowData();
if (!data) return;
await this.manager.app.loadGraphData(data, true, true, this);
const data = await this.getWorkflowData()
if (!data) return
await this.manager.app.loadGraphData(data, true, true, this)
}
};
}
async save(saveAs = false) {
if (!this.path || saveAs) {
return !!(await this.#save(null, false));
return !!(await this.#save(null, false))
} else {
return !!(await this.#save(this.path, true));
return !!(await this.#save(this.path, true))
}
}
async favorite(value: boolean) {
try {
if (this.#isFavorite === value) return;
this.#isFavorite = value;
await this.manager.saveWorkflowMetadata();
this.manager.dispatchEvent(new CustomEvent("favorite", { detail: this }));
if (this.#isFavorite === value) return
this.#isFavorite = value
await this.manager.saveWorkflowMetadata()
this.manager.dispatchEvent(new CustomEvent('favorite', { detail: this }))
} catch (error) {
alert(
"Error favoriting workflow " +
'Error favoriting workflow ' +
this.path +
"\n" +
'\n' +
(error.message ?? error)
);
)
}
}
async rename(path: string) {
path = appendJsonExt(path);
path = appendJsonExt(path)
let resp = await api.moveUserData(
"workflows/" + this.path,
"workflows/" + path
);
'workflows/' + this.path,
'workflows/' + path
)
if (resp.status === 409) {
if (
@@ -351,50 +351,50 @@ export class ComfyWorkflow {
`Workflow '${path}' already exists, do you want to overwrite it?`
)
)
return resp;
return resp
resp = await api.moveUserData(
"workflows/" + this.path,
"workflows/" + path,
'workflows/' + this.path,
'workflows/' + path,
{ overwrite: true }
);
)
}
if (resp.status !== 200) {
alert(
`Error renaming workflow file '${this.path}': ${resp.status} ${resp.statusText}`
);
return;
)
return
}
const isFav = this.isFavorite;
const isFav = this.isFavorite
if (isFav) {
await this.favorite(false);
await this.favorite(false)
}
path = (await resp.json()).substring("workflows/".length);
this.#updatePath(path, null);
path = (await resp.json()).substring('workflows/'.length)
this.#updatePath(path, null)
if (isFav) {
await this.favorite(true);
await this.favorite(true)
}
this.manager.dispatchEvent(new CustomEvent("rename", { detail: this }));
setStorageValue("Comfy.PreviousWorkflow", this.path ?? "");
this.manager.dispatchEvent(new CustomEvent('rename', { detail: this }))
setStorageValue('Comfy.PreviousWorkflow', this.path ?? '')
}
async insert() {
const data = await this.getWorkflowData();
if (!data) return;
const data = await this.getWorkflowData()
if (!data) return
const old = localStorage.getItem("litegrapheditor_clipboard");
const old = localStorage.getItem('litegrapheditor_clipboard')
// @ts-ignore
const graph = new LGraph(data);
const graph = new LGraph(data)
const canvas = new LGraphCanvas(null, graph, {
// @ts-ignore
skip_events: true,
skip_render: true,
});
canvas.selectNodes();
canvas.copyToClipboard();
this.manager.app.canvas.pasteFromClipboard();
localStorage.setItem("litegrapheditor_clipboard", old);
skip_render: true
})
canvas.selectNodes()
canvas.copyToClipboard()
this.manager.app.canvas.pasteFromClipboard()
localStorage.setItem('litegrapheditor_clipboard', old)
}
async delete() {
@@ -402,84 +402,84 @@ export class ComfyWorkflow {
try {
if (this.isFavorite) {
await this.favorite(false);
await this.favorite(false)
}
await api.deleteUserData("workflows/" + this.path);
this.unsaved = true;
this.#path = null;
this.#pathParts = null;
this.manager.workflows.splice(this.manager.workflows.indexOf(this), 1);
this.manager.dispatchEvent(new CustomEvent("delete", { detail: this }));
await api.deleteUserData('workflows/' + this.path)
this.unsaved = true
this.#path = null
this.#pathParts = null
this.manager.workflows.splice(this.manager.workflows.indexOf(this), 1)
this.manager.dispatchEvent(new CustomEvent('delete', { detail: this }))
} catch (error) {
alert(`Error deleting workflow: ${error.message || error}`);
alert(`Error deleting workflow: ${error.message || error}`)
}
}
track() {
if (this.changeTracker) {
this.changeTracker.restore();
this.changeTracker.restore()
} else {
this.changeTracker = new ChangeTracker(this);
this.changeTracker = new ChangeTracker(this)
}
}
async #save(path: string | null, overwrite: boolean) {
if (!path) {
path = prompt(
"Save workflow as:",
trimJsonExt(this.path) ?? this.name ?? "workflow"
);
if (!path) return;
'Save workflow as:',
trimJsonExt(this.path) ?? this.name ?? 'workflow'
)
if (!path) return
}
path = appendJsonExt(path);
path = appendJsonExt(path)
const p = await this.manager.app.graphToPrompt();
const json = JSON.stringify(p.workflow, null, 2);
let resp = await api.storeUserData("workflows/" + path, json, {
const p = await this.manager.app.graphToPrompt()
const json = JSON.stringify(p.workflow, null, 2)
let resp = await api.storeUserData('workflows/' + path, json, {
stringify: false,
throwOnError: false,
overwrite,
});
overwrite
})
if (resp.status === 409) {
if (
!confirm(
`Workflow '${path}' already exists, do you want to overwrite it?`
)
)
return;
resp = await api.storeUserData("workflows/" + path, json, {
stringify: false,
});
return
resp = await api.storeUserData('workflows/' + path, json, {
stringify: false
})
}
if (resp.status !== 200) {
alert(
`Error saving workflow '${this.path}': ${resp.status} ${resp.statusText}`
);
return;
)
return
}
path = (await resp.json()).substring("workflows/".length);
path = (await resp.json()).substring('workflows/'.length)
if (!this.path) {
// Saved new workflow, patch this instance
this.#updatePath(path, null);
await this.manager.loadWorkflows();
this.unsaved = false;
this.manager.dispatchEvent(new CustomEvent("rename", { detail: this }));
setStorageValue("Comfy.PreviousWorkflow", this.path ?? "");
this.#updatePath(path, null)
await this.manager.loadWorkflows()
this.unsaved = false
this.manager.dispatchEvent(new CustomEvent('rename', { detail: this }))
setStorageValue('Comfy.PreviousWorkflow', this.path ?? '')
} else if (path !== this.path) {
// Saved as, open the new copy
await this.manager.loadWorkflows();
const workflow = this.manager.workflowLookup[path];
await workflow.load();
await this.manager.loadWorkflows()
const workflow = this.manager.workflowLookup[path]
await workflow.load()
} else {
// Normal save
this.unsaved = false;
this.manager.dispatchEvent(new CustomEvent("save", { detail: this }));
this.unsaved = false
this.manager.dispatchEvent(new CustomEvent('save', { detail: this }))
}
return true;
return true
}
}