|
|
|
import { app } from "../../scripts/app.js";
|
|
|
|
import { ComfyDialog, $el } from "../../scripts/ui.js";
|
|
|
|
import { GroupNodeConfig, GroupNodeHandler } from "./groupNode.js";
|
|
|
|
|
|
|
|
// Adds the ability to save and add multiple nodes as a template
|
|
|
|
// To save:
|
|
|
|
// Select multiple nodes (ctrl + drag to select a region or ctrl+click individual nodes)
|
|
|
|
// Right click the canvas
|
|
|
|
// Save Node Template -> give it a name
|
|
|
|
//
|
|
|
|
// To add:
|
|
|
|
// Right click the canvas
|
|
|
|
// Node templates -> click the one to add
|
|
|
|
//
|
|
|
|
// To delete/rename:
|
|
|
|
// Right click the canvas
|
|
|
|
// Node templates -> Manage
|
|
|
|
//
|
|
|
|
// To rearrange:
|
|
|
|
// Open the manage dialog and Drag and drop elements using the "Name:" label as handle
|
|
|
|
|
|
|
|
const id = "Comfy.NodeTemplates";
|
|
|
|
|
|
|
|
class ManageTemplates extends ComfyDialog {
|
|
|
|
constructor() {
|
|
|
|
super();
|
|
|
|
this.element.classList.add("comfy-manage-templates");
|
|
|
|
this.templates = this.load();
|
|
|
|
this.draggedEl = null;
|
|
|
|
this.saveVisualCue = null;
|
|
|
|
this.emptyImg = new Image();
|
|
|
|
this.emptyImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=';
|
|
|
|
|
|
|
|
this.importInput = $el("input", {
|
|
|
|
type: "file",
|
|
|
|
accept: ".json",
|
|
|
|
multiple: true,
|
|
|
|
style: { display: "none" },
|
|
|
|
parent: document.body,
|
|
|
|
onchange: () => this.importAll(),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
createButtons() {
|
|
|
|
const btns = super.createButtons();
|
|
|
|
btns[0].textContent = "Close";
|
|
|
|
btns[0].onclick = (e) => {
|
|
|
|
clearTimeout(this.saveVisualCue);
|
|
|
|
this.close();
|
|
|
|
};
|
|
|
|
btns.unshift(
|
|
|
|
$el("button", {
|
|
|
|
type: "button",
|
|
|
|
textContent: "Export",
|
|
|
|
onclick: () => this.exportAll(),
|
|
|
|
})
|
|
|
|
);
|
|
|
|
btns.unshift(
|
|
|
|
$el("button", {
|
|
|
|
type: "button",
|
|
|
|
textContent: "Import",
|
|
|
|
onclick: () => {
|
|
|
|
this.importInput.click();
|
|
|
|
},
|
|
|
|
})
|
|
|
|
);
|
|
|
|
return btns;
|
|
|
|
}
|
|
|
|
|
|
|
|
load() {
|
|
|
|
const templates = localStorage.getItem(id);
|
|
|
|
if (templates) {
|
|
|
|
return JSON.parse(templates);
|
|
|
|
} else {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
store() {
|
|
|
|
localStorage.setItem(id, JSON.stringify(this.templates));
|
|
|
|
}
|
|
|
|
|
|
|
|
async importAll() {
|
|
|
|
for (const file of this.importInput.files) {
|
|
|
|
if (file.type === "application/json" || file.name.endsWith(".json")) {
|
|
|
|
const reader = new FileReader();
|
|
|
|
reader.onload = async () => {
|
|
|
|
var importFile = JSON.parse(reader.result);
|
|
|
|
if (importFile && importFile?.templates) {
|
|
|
|
for (const template of importFile.templates) {
|
|
|
|
if (template?.name && template?.data) {
|
|
|
|
this.templates.push(template);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
this.store();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
await reader.readAsText(file);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.importInput.value = null;
|
|
|
|
|
|
|
|
this.close();
|
|
|
|
}
|
|
|
|
|
|
|
|
exportAll() {
|
|
|
|
if (this.templates.length == 0) {
|
|
|
|
alert("No templates to export.");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const json = JSON.stringify({ templates: this.templates }, null, 2); // convert the data to a JSON string
|
|
|
|
const blob = new Blob([json], { type: "application/json" });
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const a = $el("a", {
|
|
|
|
href: url,
|
|
|
|
download: "node_templates.json",
|
|
|
|
style: { display: "none" },
|
|
|
|
parent: document.body,
|
|
|
|
});
|
|
|
|
a.click();
|
|
|
|
setTimeout(function () {
|
|
|
|
a.remove();
|
|
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
}, 0);
|
|
|
|
}
|
|
|
|
|
|
|
|
show() {
|
|
|
|
// Show list of template names + delete button
|
|
|
|
super.show(
|
|
|
|
$el(
|
|
|
|
"div",
|
|
|
|
{},
|
|
|
|
this.templates.flatMap((t,i) => {
|
|
|
|
let nameInput;
|
|
|
|
return [
|
|
|
|
$el(
|
|
|
|
"div",
|
|
|
|
{
|
|
|
|
dataset: { id: i },
|
|
|
|
className: "tempateManagerRow",
|
|
|
|
style: {
|
|
|
|
display: "grid",
|
|
|
|
gridTemplateColumns: "1fr auto",
|
|
|
|
border: "1px dashed transparent",
|
|
|
|
gap: "5px",
|
|
|
|
backgroundColor: "var(--comfy-menu-bg)"
|
|
|
|
},
|
|
|
|
ondragstart: (e) => {
|
|
|
|
this.draggedEl = e.currentTarget;
|
|
|
|
e.currentTarget.style.opacity = "0.6";
|
|
|
|
e.currentTarget.style.border = "1px dashed yellow";
|
|
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
|
|
e.dataTransfer.setDragImage(this.emptyImg, 0, 0);
|
|
|
|
},
|
|
|
|
ondragend: (e) => {
|
|
|
|
e.target.style.opacity = "1";
|
|
|
|
e.currentTarget.style.border = "1px dashed transparent";
|
|
|
|
e.currentTarget.removeAttribute("draggable");
|
|
|
|
|
|
|
|
// rearrange the elements in the localStorage
|
|
|
|
this.element.querySelectorAll('.tempateManagerRow').forEach((el,i) => {
|
|
|
|
var prev_i = el.dataset.id;
|
|
|
|
|
|
|
|
if ( el == this.draggedEl && prev_i != i ) {
|
|
|
|
[this.templates[i], this.templates[prev_i]] = [this.templates[prev_i], this.templates[i]];
|
|
|
|
}
|
|
|
|
el.dataset.id = i;
|
|
|
|
});
|
|
|
|
this.store();
|
|
|
|
},
|
|
|
|
ondragover: (e) => {
|
|
|
|
e.preventDefault();
|
|
|
|
if ( e.currentTarget == this.draggedEl )
|
|
|
|
return;
|
|
|
|
|
|
|
|
let rect = e.currentTarget.getBoundingClientRect();
|
|
|
|
if (e.clientY > rect.top + rect.height / 2) {
|
|
|
|
e.currentTarget.parentNode.insertBefore(this.draggedEl, e.currentTarget.nextSibling);
|
|
|
|
} else {
|
|
|
|
e.currentTarget.parentNode.insertBefore(this.draggedEl, e.currentTarget);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
[
|
|
|
|
$el(
|
|
|
|
"label",
|
|
|
|
{
|
|
|
|
textContent: "Name: ",
|
|
|
|
style: {
|
|
|
|
cursor: "grab",
|
|
|
|
},
|
|
|
|
onmousedown: (e) => {
|
|
|
|
// enable dragging only from the label
|
|
|
|
if (e.target.localName == 'label')
|
|
|
|
e.currentTarget.parentNode.draggable = 'true';
|
|
|
|
}
|
|
|
|
},
|
|
|
|
[
|
|
|
|
$el("input", {
|
|
|
|
value: t.name,
|
|
|
|
dataset: { name: t.name },
|
|
|
|
style: {
|
|
|
|
transitionProperty: 'background-color',
|
|
|
|
transitionDuration: '0s',
|
|
|
|
},
|
|
|
|
onchange: (e) => {
|
|
|
|
clearTimeout(this.saveVisualCue);
|
|
|
|
var el = e.target;
|
|
|
|
var row = el.parentNode.parentNode;
|
|
|
|
this.templates[row.dataset.id].name = el.value.trim() || 'untitled';
|
|
|
|
this.store();
|
|
|
|
el.style.backgroundColor = 'rgb(40, 95, 40)';
|
|
|
|
el.style.transitionDuration = '0s';
|
|
|
|
this.saveVisualCue = setTimeout(function () {
|
|
|
|
el.style.transitionDuration = '.7s';
|
|
|
|
el.style.backgroundColor = 'var(--comfy-input-bg)';
|
|
|
|
}, 15);
|
|
|
|
},
|
|
|
|
onkeypress: (e) => {
|
|
|
|
var el = e.target;
|
|
|
|
clearTimeout(this.saveVisualCue);
|
|
|
|
el.style.transitionDuration = '0s';
|
|
|
|
el.style.backgroundColor = 'var(--comfy-input-bg)';
|
|
|
|
},
|
|
|
|
$: (el) => (nameInput = el),
|
|
|
|
})
|
|
|
|
]
|
|
|
|
),
|
|
|
|
$el(
|
|
|
|
"div",
|
|
|
|
{},
|
|
|
|
[
|
|
|
|
$el("button", {
|
|
|
|
textContent: "Export",
|
|
|
|
style: {
|
|
|
|
fontSize: "12px",
|
|
|
|
fontWeight: "normal",
|
|
|
|
},
|
|
|
|
onclick: (e) => {
|
|
|
|
const json = JSON.stringify({templates: [t]}, null, 2); // convert the data to a JSON string
|
|
|
|
const blob = new Blob([json], {type: "application/json"});
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const a = $el("a", {
|
|
|
|
href: url,
|
|
|
|
download: (nameInput.value || t.name) + ".json",
|
|
|
|
style: {display: "none"},
|
|
|
|
parent: document.body,
|
|
|
|
});
|
|
|
|
a.click();
|
|
|
|
setTimeout(function () {
|
|
|
|
a.remove();
|
|
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
}, 0);
|
|
|
|
},
|
|
|
|
}),
|
|
|
|
$el("button", {
|
|
|
|
textContent: "Delete",
|
|
|
|
style: {
|
|
|
|
fontSize: "12px",
|
|
|
|
color: "red",
|
|
|
|
fontWeight: "normal",
|
|
|
|
},
|
|
|
|
onclick: (e) => {
|
|
|
|
const item = e.target.parentNode.parentNode;
|
|
|
|
item.parentNode.removeChild(item);
|
|
|
|
this.templates.splice(item.dataset.id*1, 1);
|
|
|
|
this.store();
|
|
|
|
// update the rows index, setTimeout ensures that the list is updated
|
|
|
|
var that = this;
|
|
|
|
setTimeout(function (){
|
|
|
|
that.element.querySelectorAll('.tempateManagerRow').forEach((el,i) => {
|
|
|
|
el.dataset.id = i;
|
|
|
|
});
|
|
|
|
}, 0);
|
|
|
|
},
|
|
|
|
}),
|
|
|
|
]
|
|
|
|
),
|
|
|
|
]
|
|
|
|
)
|
|
|
|
];
|
|
|
|
})
|
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
app.registerExtension({
|
|
|
|
name: id,
|
|
|
|
setup() {
|
|
|
|
const manage = new ManageTemplates();
|
|
|
|
|
|
|
|
const clipboardAction = async (cb) => {
|
|
|
|
// We use the clipboard functions but dont want to overwrite the current user clipboard
|
|
|
|
// Restore it after we've run our callback
|
|
|
|
const old = localStorage.getItem("litegrapheditor_clipboard");
|
|
|
|
await cb();
|
|
|
|
localStorage.setItem("litegrapheditor_clipboard", old);
|
|
|
|
};
|
|
|
|
|
|
|
|
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
|
|
|
|
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
|
|
|
const options = orig.apply(this, arguments);
|
|
|
|
|
|
|
|
options.push(null);
|
|
|
|
options.push({
|
|
|
|
content: `Save Selected as Template`,
|
|
|
|
disabled: !Object.keys(app.canvas.selected_nodes || {}).length,
|
|
|
|
callback: () => {
|
|
|
|
const name = prompt("Enter name");
|
|
|
|
if (!name?.trim()) return;
|
|
|
|
|
|
|
|
clipboardAction(() => {
|
|
|
|
app.canvas.copyToClipboard();
|
|
|
|
let data = localStorage.getItem("litegrapheditor_clipboard");
|
|
|
|
data = JSON.parse(data);
|
|
|
|
const nodeIds = Object.keys(app.canvas.selected_nodes);
|
|
|
|
for (let i = 0; i < nodeIds.length; i++) {
|
|
|
|
const node = app.graph.getNodeById(nodeIds[i]);
|
|
|
|
const nodeData = node?.constructor.nodeData;
|
|
|
|
|
|
|
|
let groupData = GroupNodeHandler.getGroupData(node);
|
|
|
|
if (groupData) {
|
|
|
|
groupData = groupData.nodeData;
|
|
|
|
if (!data.groupNodes) {
|
|
|
|
data.groupNodes = {};
|
|
|
|
}
|
|
|
|
data.groupNodes[nodeData.name] = groupData;
|
|
|
|
data.nodes[i].type = nodeData.name;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
manage.templates.push({
|
|
|
|
name,
|
|
|
|
data: JSON.stringify(data),
|
|
|
|
});
|
|
|
|
manage.store();
|
|
|
|
});
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
// Map each template to a menu item
|
|
|
|
const subItems = manage.templates.map((t) => {
|
|
|
|
return {
|
|
|
|
content: t.name,
|
|
|
|
callback: () => {
|
|
|
|
clipboardAction(async () => {
|
|
|
|
const data = JSON.parse(t.data);
|
|
|
|
await GroupNodeConfig.registerFromWorkflow(data.groupNodes, {});
|
|
|
|
localStorage.setItem("litegrapheditor_clipboard", t.data);
|
|
|
|
app.canvas.pasteFromClipboard();
|
|
|
|
});
|
|
|
|
},
|
|
|
|
};
|
|
|
|
});
|
|
|
|
|
|
|
|
subItems.push(null, {
|
|
|
|
content: "Manage",
|
|
|
|
callback: () => manage.show(),
|
|
|
|
});
|
|
|
|
|
|
|
|
options.push({
|
|
|
|
content: "Node Templates",
|
|
|
|
submenu: {
|
|
|
|
options: subItems,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
return options;
|
|
|
|
};
|
|
|
|
},
|
|
|
|
});
|