Add support for pasting images into the graph

It can be useful to paste images from the clipboard directly into the node graph.
This commit modifies copy and paste handling to support this.

When an image file is found in the clipboard, we check whether an image node is selected.
If so, paste the image into that node. Otherwise, a new node is created.
If no image data are found in the clipboard, we call the original Litegraph paste.
To ensure that onCopy and onPaste events are fired, we override Litegraph's ctrl+c and ctrl+v handling.

Try to detect whether the pasted image is a real file on disk, or just pixel data copied from e.g. Photoshop.
Pasted pixel data will be called 'image.png' and have a creation time of now.
If it is simply pasted data, we store it in the subfolder /input/clipboard/.

This also adds support for the subfolder property in the IMAGEUPLOAD widget.
This commit is contained in:
Michael Abrahams 2023-09-03 11:51:50 -04:00
parent a74c5dbf37
commit 6f70227b8c
2 changed files with 102 additions and 20 deletions

View File

@ -667,11 +667,40 @@ export class ComfyApp {
}
/**
* Adds a handler on paste that extracts and loads workflows from pasted JSON data
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
*/
#addPasteHandler() {
document.addEventListener("paste", (e) => {
let data = (e.clipboardData || window.clipboardData).getData("text/plain");
let data = (e.clipboardData || window.clipboardData);
const items = data.items;
// Look for image paste data
for (const item of items) {
if (item.type.startsWith('image/')) {
var imageNode = null;
// If an image node is selected, paste into it
if (this.canvas.current_node &&
this.canvas.current_node.is_selected &&
ComfyApp.isImageNode(this.canvas.current_node)) {
imageNode = this.canvas.current_node;
}
// No image node selected: add a new one
if (!imageNode) {
const newNode = LiteGraph.createNode("LoadImage");
newNode.pos = [...this.canvas.graph_mouse];
imageNode = this.graph.add(newNode);
this.graph.change();
}
const blob = item.getAsFile();
imageNode.pasteFile(blob);
return;
}
}
// No image found. Look for node data
data = data.getData("text/plain");
let workflow;
try {
data = data.slice(data.indexOf("{"));
@ -687,9 +716,29 @@ export class ComfyApp {
if (workflow && workflow.version && workflow.nodes && workflow.extra) {
this.loadGraphData(workflow);
}
else {
// Litegraph default paste
this.canvas.pasteFromClipboard();
}
});
}
/**
* Adds a handler on copy that serializes selected nodes to JSON
*/
#addCopyHandler() {
document.addEventListener("copy", (e) => {
// copy
if (this.canvas.selected_nodes) {
this.canvas.copyToClipboard();
}
});
}
/**
* Handle mouse
*
@ -745,12 +794,6 @@ export class ComfyApp {
const self = this;
const origProcessKey = LGraphCanvas.prototype.processKey;
LGraphCanvas.prototype.processKey = function(e) {
const res = origProcessKey.apply(this, arguments);
if (res === false) {
return res;
}
if (!this.graph) {
return;
}
@ -761,9 +804,10 @@ export class ComfyApp {
return;
}
if (e.type == "keydown") {
if (e.type == "keydown" && !e.repeat) {
// Ctrl + M mute/unmute
if (e.keyCode == 77 && e.ctrlKey) {
if (e.key === 'm' && e.ctrlKey) {
if (this.selected_nodes) {
for (var i in this.selected_nodes) {
if (this.selected_nodes[i].mode === 2) { // never
@ -776,7 +820,8 @@ export class ComfyApp {
block_default = true;
}
if (e.keyCode == 66 && e.ctrlKey) {
// Ctrl + B bypass
if (e.key === 'b' && e.ctrlKey) {
if (this.selected_nodes) {
for (var i in this.selected_nodes) {
if (this.selected_nodes[i].mode === 4) { // never
@ -788,6 +833,28 @@ export class ComfyApp {
}
block_default = true;
}
// Ctrl+C Copy
if ((e.key === 'c') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
this.copyToClipboard(true);
block_default = true;
}
// Trigger default onCopy
return true;
}
// Ctrl+V Paste
if ((e.key === 'v') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
this.pasteFromClipboard(true);
block_default = true;
}
else {
// Trigger default onPaste
return true;
}
}
}
this.graph.change();
@ -798,7 +865,8 @@ export class ComfyApp {
return false;
}
return res;
// Fall through to Litegraph defaults
return origProcessKey.apply(this, arguments);
};
}
@ -1110,6 +1178,7 @@ export class ComfyApp {
this.#addDrawGroupsHandler();
this.#addApiUpdateHandlers();
this.#addDropHandler();
this.#addCopyHandler();
this.#addPasteHandler();
this.#addKeyboardHandler();

View File

@ -76,7 +76,7 @@ export function addValueControlWidget(node, targetWidget, defaultValue = "random
targetWidget.value = max;
}
}
return valueControl;
return valueControl;
};
function seedWidget(node, inputName, inputData, app) {
@ -387,11 +387,12 @@ export const ComfyWidgets = {
}
});
async function uploadFile(file, updateNode) {
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,
@ -399,15 +400,17 @@ export const ComfyWidgets = {
if (resp.status === 200) {
const data = await resp.json();
// Add the file as an option and update the widget value
if (!imageWidget.options.values.includes(data.name)) {
imageWidget.options.values.push(data.name);
// Add the file to the dropdown list and update the widget value
let path = data.name;
if (data.subfolder) path = data.subfolder + "/" + path;
if (!imageWidget.options.values.includes(path)) {
imageWidget.options.values.push(path);
}
if (updateNode) {
showImage(data.name);
imageWidget.value = data.name;
showImage(path);
imageWidget.value = path;
}
} else {
alert(resp.status + " - " + resp.statusText);
@ -460,6 +463,16 @@ export const ComfyWidgets = {
return handled;
};
node.pasteFile = function(file) {
if (file.type.startsWith("image/")) {
const is_pasted = (file.name === "image.png") &&
(file.lastModified - Date.now() < 2000);
uploadFile(file, true, is_pasted);
return true;
}
return false;
}
return { widget: uploadWidget };
},
};