nodeTemplates.js 11.1 KB
Newer Older
1
import { app } from "../../scripts/app.js";
2
import { api } from "../../scripts/api.js";
3
import { ComfyDialog, $el } from "../../scripts/ui.js";
pythongosssss's avatar
pythongosssss committed
4
import { GroupNodeConfig, GroupNodeHandler } from "./groupNode.js";
5
6
7
8
9
10
11
12
13
14
15
16
17
18

// 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
19
20
21
//
// To rearrange:
// Open the manage dialog and Drag and drop elements using the "Name:" label as handle
22
23

const id = "Comfy.NodeTemplates";
24
const file = "comfy.templates.json";
25
26
27
28

class ManageTemplates extends ComfyDialog {
	constructor() {
		super();
29
30
31
32
		this.load().then((v) => {
			this.templates = v;
		});

33
		this.element.classList.add("comfy-manage-templates");
34
35
36
		this.draggedEl = null;
		this.saveVisualCue = null;
		this.emptyImg = new Image();
37
		this.emptyImg.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
Jairo Correa's avatar
Jairo Correa committed
38
39
40
41
42

		this.importInput = $el("input", {
			type: "file",
			accept: ".json",
			multiple: true,
pythongosssss's avatar
pythongosssss committed
43
			style: { display: "none" },
Jairo Correa's avatar
Jairo Correa committed
44
45
46
			parent: document.body,
			onchange: () => this.importAll(),
		});
47
48
49
50
	}

	createButtons() {
		const btns = super.createButtons();
51
52
53
54
55
		btns[0].textContent = "Close";
		btns[0].onclick = (e) => {
			clearTimeout(this.saveVisualCue);
			this.close();
		};
Jairo Correa's avatar
Jairo Correa committed
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
		btns.unshift(
			$el("button", {
				type: "button",
				textContent: "Export",
				onclick: () => this.exportAll(),
			})
		);
		btns.unshift(
			$el("button", {
				type: "button",
				textContent: "Import",
				onclick: () => {
					this.importInput.click();
				},
			})
		);
72
73
74
		return btns;
	}

75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
	async load() {
		let templates = [];
		if (app.storageLocation === "server") {
			if (app.isNewUserSession) {
				// New user so migrate existing templates
				const json = localStorage.getItem(id);
				if (json) {
					templates = JSON.parse(json);
				}
				await api.storeUserData(file, json, { stringify: false });
			} else {
				const res = await api.getUserData(file);
				if (res.status === 200) {
					try {
						templates = await res.json();
					} catch (error) {
					}
				} else if (res.status !== 404) {
					console.error(res.status + " " + res.statusText);
				}
			}
96
		} else {
97
98
99
100
			const json = localStorage.getItem(id);
			if (json) {
				templates = JSON.parse(json);
			}
101
		}
102
103

		return templates ?? [];
104
105
	}

106
107
108
109
110
111
112
113
114
115
116
117
118
	async store() {
		if(app.storageLocation === "server") {
			const templates = JSON.stringify(this.templates, undefined, 4);
			localStorage.setItem(id, templates); // Backwards compatibility
			try {
				await api.storeUserData(file, templates, { stringify: false });
			} catch (error) {
				console.error(error);
				alert(error.message);
			}
		} else {
			localStorage.setItem(id, JSON.stringify(this.templates));
		}
119
120
	}

Jairo Correa's avatar
Jairo Correa committed
121
122
123
124
125
	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 () => {
126
127
					const importFile = JSON.parse(reader.result);
					if (importFile?.templates) {
Jairo Correa's avatar
Jairo Correa committed
128
129
130
131
132
						for (const template of importFile.templates) {
							if (template?.name && template?.data) {
								this.templates.push(template);
							}
						}
133
						await this.store();
Jairo Correa's avatar
Jairo Correa committed
134
135
136
137
138
139
					}
				};
				await reader.readAsText(file);
			}
		}

140
141
		this.importInput.value = null;

Jairo Correa's avatar
Jairo Correa committed
142
143
144
145
146
147
148
149
150
		this.close();
	}

	exportAll() {
		if (this.templates.length == 0) {
			alert("No templates to export.");
			return;
		}

pythongosssss's avatar
pythongosssss committed
151
152
		const json = JSON.stringify({ templates: this.templates }, null, 2); // convert the data to a JSON string
		const blob = new Blob([json], { type: "application/json" });
Jairo Correa's avatar
Jairo Correa committed
153
154
155
156
		const url = URL.createObjectURL(blob);
		const a = $el("a", {
			href: url,
			download: "node_templates.json",
pythongosssss's avatar
pythongosssss committed
157
			style: { display: "none" },
Jairo Correa's avatar
Jairo Correa committed
158
159
160
161
162
163
164
165
166
			parent: document.body,
		});
		a.click();
		setTimeout(function () {
			a.remove();
			window.URL.revokeObjectURL(url);
		}, 0);
	}

167
168
169
170
171
	show() {
		// Show list of template names + delete button
		super.show(
			$el(
				"div",
172
173
				{},
				this.templates.flatMap((t,i) => {
174
175
176
					let nameInput;
					return [
						$el(
177
							"div",
178
							{
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
								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");

200
									// rearrange the elements
201
202
203
204
									this.element.querySelectorAll('.tempateManagerRow').forEach((el,i) => {
										var prev_i = el.dataset.id;

										if ( el == this.draggedEl && prev_i != i ) {
pythongosssss's avatar
pythongosssss committed
205
											this.templates.splice(i, 0, this.templates.splice(prev_i, 1)[0]);
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
										}
										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);
									}
								}
223
224
							},
							[
225
226
227
228
229
230
231
232
233
234
235
236
								$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';
										}
Jairo Correa's avatar
Jairo Correa committed
237
									},
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
									[
										$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);
											},
										}),
									]
								),
Jairo Correa's avatar
Jairo Correa committed
319
							]
320
						)
321
322
323
324
325
326
327
328
329
330
331
332
					];
				})
			)
		);
	}
}

app.registerExtension({
	name: id,
	setup() {
		const manage = new ManageTemplates();

pythongosssss's avatar
pythongosssss committed
333
		const clipboardAction = async (cb) => {
334
335
336
			// 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");
pythongosssss's avatar
pythongosssss committed
337
			await cb();
338
339
340
341
342
343
344
345
346
347
348
349
350
			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");
pythongosssss's avatar
pythongosssss committed
351
					if (!name?.trim()) return;
352
353
354

					clipboardAction(() => {
						app.canvas.copyToClipboard();
pythongosssss's avatar
pythongosssss committed
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
						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;
							}
						}

373
374
						manage.templates.push({
							name,
pythongosssss's avatar
pythongosssss committed
375
							data: JSON.stringify(data),
376
377
378
379
380
381
382
						});
						manage.store();
					});
				},
			});

			// Map each template to a menu item
pythongosssss's avatar
pythongosssss committed
383
384
385
386
387
388
389
390
391
392
393
394
395
			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();
						});
					},
				};
			});
396

Jairo Correa's avatar
Jairo Correa committed
397
398
399
400
			subItems.push(null, {
				content: "Manage",
				callback: () => manage.show(),
			});
401

Jairo Correa's avatar
Jairo Correa committed
402
403
404
405
406
407
			options.push({
				content: "Node Templates",
				submenu: {
					options: subItems,
				},
			});
408
409
410
411
412

			return options;
		};
	},
});