import {app} from "../../scripts/app.js";
import {api} from "../../scripts/api.js";
import {$el} from "../../scripts/ui.js";
import {Canvas} from "./Canvas.js";
import {clearAllCanvasStates} from "./db.js";
import {ImageCache} from "./ImageCache.js";
import {generateUniqueFileName} from "./utils/CommonUtils.js";
import {createModuleLogger} from "./utils/LoggerUtils.js";
const log = createModuleLogger('Canvas_view');
async function createCanvasWidget(node, widget, app) {
const canvas = new Canvas(node, widget);
const imageCache = new ImageCache();
const style = document.createElement('style');
style.textContent = `
.painter-button {
background: linear-gradient(to bottom, #4a4a4a, #3a3a3a);
border: 1px solid #2a2a2a;
border-radius: 4px;
color: #ffffff;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
min-width: 80px;
text-align: center;
margin: 2px;
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
}
.painter-button:hover {
background: linear-gradient(to bottom, #5a5a5a, #4a4a4a);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.painter-button:active {
background: linear-gradient(to bottom, #3a3a3a, #4a4a4a);
transform: translateY(1px);
}
.painter-button:disabled,
.painter-button:disabled:hover {
background: #555;
color: #888;
cursor: not-allowed;
transform: none;
box-shadow: none;
border-color: #444;
}
.painter-button.primary {
background: linear-gradient(to bottom, #4a6cd4, #3a5cc4);
border-color: #2a4cb4;
}
.painter-button.primary:hover {
background: linear-gradient(to bottom, #5a7ce4, #4a6cd4);
}
.painter-controls {
background: linear-gradient(to bottom, #404040, #383838);
border-bottom: 1px solid #2a2a2a;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 8px;
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
}
.painter-slider-container {
display: flex;
align-items: center;
gap: 8px;
color: #fff;
font-size: 12px;
}
.painter-slider-container input[type="range"] {
width: 80px;
}
.painter-button-group {
display: flex;
align-items: center;
gap: 6px;
background-color: rgba(0,0,0,0.2);
padding: 4px;
border-radius: 6px;
}
.painter-separator {
width: 1px;
height: 28px;
background-color: #2a2a2a;
margin: 0 8px;
}
.painter-container {
background: #607080; /* 带蓝色的灰色背景 */
border: 1px solid #4a5a6a;
border-radius: 6px;
box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
transition: border-color 0.3s ease; /* Dodano dla płynnej zmiany ramki */
}
.painter-container.drag-over {
border-color: #00ff00; /* Zielona ramka podczas przeciągania */
border-style: dashed;
}
.painter-dialog {
background: #404040;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
padding: 20px;
color: #ffffff;
}
.painter-dialog input {
background: #303030;
border: 1px solid #505050;
border-radius: 4px;
color: #ffffff;
padding: 4px 8px;
margin: 4px;
width: 80px;
}
.painter-dialog button {
background: #505050;
border: 1px solid #606060;
border-radius: 4px;
color: #ffffff;
padding: 4px 12px;
margin: 4px;
cursor: pointer;
}
.painter-dialog button:hover {
background: #606060;
}
.blend-opacity-slider {
width: 100%;
margin: 5px 0;
display: none;
}
.blend-mode-active .blend-opacity-slider {
display: block;
}
.blend-mode-item {
padding: 5px;
cursor: pointer;
position: relative;
}
.blend-mode-item.active {
background-color: rgba(0,0,0,0.1);
}
.blend-mode-item.active {
background-color: rgba(0,0,0,0.1);
}
.painter-tooltip {
position: fixed; /* Pozycjonowanie względem okna przeglądarki */
display: none; /* Domyślnie ukryty */
background: #3a3a3a;
color: #f0f0f0;
border: 1px solid #555;
border-radius: 8px;
padding: 12px 18px;
z-index: 9999; /* Wyżej niż modal backdrop */
font-size: 13px;
line-height: 1.7;
max-width: 400px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
pointer-events: none; /* Zapobiega interakcji myszy z dymkiem */
}
.painter-tooltip h4 {
margin-top: 10px;
margin-bottom: 5px;
color: #4a90e2; /* Jasnoniebieski akcent */
border-bottom: 1px solid #555;
padding-bottom: 4px;
}
.painter-tooltip ul {
list-style: none;
padding-left: 10px;
margin: 0;
}
.painter-tooltip kbd {
background-color: #2a2a2a;
border: 1px solid #1a1a1a;
border-radius: 3px;
padding: 2px 6px;
font-family: monospace;
font-size: 12px;
color: #d0d0d0;
}
.painter-container.has-focus {
/* Używamy box-shadow, aby stworzyć efekt zewnętrznej ramki,
która nie wpłynie na rozmiar ani pozycję elementu. */
box-shadow: 0 0 0 2px white;
/* Możesz też zmienić kolor istniejącej ramki, ale box-shadow jest bardziej wyrazisty */
/* border-color: white; */
}
.painter-button.matting-button {
position: relative;
transition: all 0.3s ease;
}
.painter-button.matting-button.loading {
padding-right: 36px; /* Make space for spinner */
cursor: wait;
}
.painter-button.matting-button .matting-spinner {
display: none;
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #fff;
animation: matting-spin 1s linear infinite;
}
.painter-button.matting-button.loading .matting-spinner {
display: block;
}
@keyframes matting-spin {
to {
transform: translateY(-50%) rotate(360deg);
}
}
`;
style.textContent += `
.painter-modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.8);
z-index: 9998;
display: flex;
align-items: center;
justify-content: center;
}
.painter-modal-content {
width: 90vw;
height: 90vh;
background-color: #353535;
border: 1px solid #222;
border-radius: 8px;
box-shadow: 0 5px 25px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
position: relative;
}
`;
document.head.appendChild(style);
const helpTooltip = $el("div.painter-tooltip", {
id: `painter-help-tooltip-${node.id}`,
innerHTML: `
Canvas Control
- Click + Drag - Pan canvas view
- Mouse Wheel - Zoom view in/out
- Shift + Click (background) - Start resizing canvas area
- Shift + Ctrl + Click - Start moving entire canvas
- Double Click (background) - Deselect all layers
Clipboard & I/O
- Ctrl + C - Copy selected layer(s)
- Ctrl + V - Paste from clipboard (image or internal layers)
- Drag & Drop Image File - Add image as a new layer
Layer Interaction
- Click + Drag - Move selected layer(s)
- Ctrl + Click - Add/Remove layer from selection
- Alt + Drag - Clone selected layer(s)
- Shift + Click - Show blend mode & opacity menu
- Mouse Wheel - Scale layer (snaps to grid)
- Ctrl + Mouse Wheel - Fine-scale layer
- Shift + Mouse Wheel - Rotate layer by 5°
- Arrow Keys - Nudge layer by 1px
- Shift + Arrow Keys - Nudge layer by 10px
- [ or ] - Rotate by 1°
- Shift + [ or ] - Rotate by 10°
- Delete - Delete selected layer(s)
Transform Handles (on selected layer)
- Drag Corner/Side - Resize layer
- Drag Rotation Handle - Rotate layer
- Hold Shift - Keep aspect ratio / Snap rotation to 15°
- Hold Ctrl - Snap to grid
`
});
document.body.appendChild(helpTooltip);
const controlPanel = $el("div.painterControlPanel", {}, [
$el("div.controls.painter-controls", {
style: {
position: "absolute",
top: "0",
left: "0",
right: "0",
zIndex: "10",
},
onresize: (entries) => {
const controlsHeight = entries[0].target.offsetHeight;
canvasContainer.style.top = (controlsHeight + 10) + "px";
}
}, [
$el("div.painter-button-group", {}, [
$el("button.painter-button", {
id: `open-editor-btn-${node.id}`,
textContent: "⛶",
title: "Open in Editor",
style: {minWidth: "40px", maxWidth: "40px", fontWeight: "bold"},
}),
$el("button.painter-button", {
textContent: "?",
title: "Show shortcuts",
style: {
minWidth: "30px",
maxWidth: "30px",
fontWeight: "bold",
},
onmouseenter: (e) => {
const rect = e.target.getBoundingClientRect();
helpTooltip.style.left = `${rect.left}px`;
helpTooltip.style.top = `${rect.bottom + 5}px`;
helpTooltip.style.display = 'block';
},
onmouseleave: () => {
helpTooltip.style.display = 'none';
}
}),
$el("button.painter-button.primary", {
textContent: "Add Image",
onclick: () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.multiple = true;
input.onchange = async (e) => {
for (const file of e.target.files) {
const reader = new FileReader();
reader.onload = async (event) => {
const img = new Image();
img.onload = async () => {
canvas.addLayer(img);
await updateOutput();
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
}
};
input.click();
}
}),
$el("button.painter-button.primary", {
textContent: "Import Input",
onclick: async () => {
if (await canvas.importLatestImage()) {
await updateOutput();
}
}
}),
$el("button.painter-button.primary", {
textContent: "Paste Image",
onclick: () => canvas.handlePaste()
}),
]),
$el("div.painter-separator"),
$el("div.painter-button-group", {}, [
$el("button.painter-button", {
textContent: "Output Area Size",
onclick: () => {
const dialog = $el("div.painter-dialog", {
style: {
position: 'fixed',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
zIndex: '9999'
}
}, [
$el("div", {
style: {
color: "white",
marginBottom: "10px"
}
}, [
$el("label", {
style: {
marginRight: "5px"
}
}, [
$el("span", {}, ["Width: "])
]),
$el("input", {
type: "number",
id: "canvas-width",
value: canvas.width,
min: "1",
max: "4096"
})
]),
$el("div", {
style: {
color: "white",
marginBottom: "10px"
}
}, [
$el("label", {
style: {
marginRight: "5px"
}
}, [
$el("span", {}, ["Height: "])
]),
$el("input", {
type: "number",
id: "canvas-height",
value: canvas.height,
min: "1",
max: "4096"
})
]),
$el("div", {
style: {
textAlign: "right"
}
}, [
$el("button", {
id: "cancel-size",
textContent: "Cancel"
}),
$el("button", {
id: "confirm-size",
textContent: "OK"
})
])
]);
document.body.appendChild(dialog);
document.getElementById('confirm-size').onclick = () => {
const width = parseInt(document.getElementById('canvas-width').value) || canvas.width;
const height = parseInt(document.getElementById('canvas-height').value) || canvas.height;
canvas.updateOutputAreaSize(width, height);
document.body.removeChild(dialog);
};
document.getElementById('cancel-size').onclick = () => {
document.body.removeChild(dialog);
};
}
}),
$el("button.painter-button.requires-selection", {
textContent: "Remove Layer",
onclick: () => {
if (canvas.selectedLayers.length > 0) {
canvas.saveState();
canvas.layers = canvas.layers.filter(l => !canvas.selectedLayers.includes(l));
canvas.updateSelection([]);
canvas.render();
canvas.saveState();
}
}
}),
$el("button.painter-button.requires-selection", {
textContent: "Layer Up",
onclick: async () => {
canvas.moveLayerUp();
}
}),
$el("button.painter-button.requires-selection", {
textContent: "Layer Down",
onclick: async () => {
canvas.moveLayerDown();
}
}),
]),
$el("div.painter-separator"),
$el("div.painter-button-group", {}, [
$el("button.painter-button.requires-selection", {
textContent: "Rotate +90°",
onclick: () => canvas.rotateLayer(90)
}),
$el("button.painter-button.requires-selection", {
textContent: "Scale +5%",
onclick: () => canvas.resizeLayer(1.05)
}),
$el("button.painter-button.requires-selection", {
textContent: "Scale -5%",
onclick: () => canvas.resizeLayer(0.95)
}),
$el("button.painter-button.requires-selection", {
textContent: "Mirror H",
onclick: () => canvas.mirrorHorizontal()
}),
$el("button.painter-button.requires-selection", {
textContent: "Mirror V",
onclick: () => canvas.mirrorVertical()
}),
]),
$el("div.painter-separator"),
$el("div.painter-button-group", {}, [
$el("button.painter-button.requires-selection.matting-button", {
textContent: "Matting",
onclick: async (e) => {
const button = e.target.closest('.matting-button');
if (button.classList.contains('loading')) return;
const spinner = $el("div.matting-spinner");
button.appendChild(spinner);
button.classList.add('loading');
try {
if (canvas.selectedLayers.length !== 1) throw new Error("Please select exactly one image layer for matting.");
const selectedLayer = canvas.selectedLayers[0];
const selectedLayerIndex = canvas.layers.indexOf(selectedLayer);
const imageData = await canvas.getLayerImageData(selectedLayer);
const response = await fetch("/matting", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({image: imageData})
});
if (!response.ok) throw new Error(`Server error: ${response.status} - ${response.statusText}`);
const result = await response.json();
const mattedImage = new Image();
mattedImage.src = result.matted_image;
await mattedImage.decode();
const newLayer = {...selectedLayer, image: mattedImage};
delete newLayer.imageId;
canvas.layers[selectedLayerIndex] = newLayer;
canvas.updateSelection([newLayer]);
canvas.render();
canvas.saveState();
await updateOutput();
} catch (error) {
log.error("Matting error:", error);
alert(`Error during matting process: ${error.message}`);
} finally {
button.classList.remove('loading');
button.removeChild(spinner);
}
}
}),
$el("button.painter-button", {
id: `undo-button-${node.id}`,
textContent: "Undo",
disabled: true,
onclick: () => canvas.undo()
}),
$el("button.painter-button", {
id: `redo-button-${node.id}`,
textContent: "Redo",
disabled: true,
onclick: () => canvas.redo()
}),
]),
$el("div.painter-separator"),
$el("div.painter-button-group", {id: "mask-controls"}, [
$el("button.painter-button", {
id: "mask-mode-btn",
textContent: "Draw Mask",
onclick: () => {
const maskBtn = controlPanel.querySelector('#mask-mode-btn');
const maskControls = controlPanel.querySelector('#mask-controls');
if (canvas.maskTool.isActive) {
canvas.maskTool.deactivate();
maskBtn.classList.remove('primary');
maskControls.querySelectorAll('.mask-control').forEach(c => c.style.display = 'none');
} else {
canvas.maskTool.activate();
maskBtn.classList.add('primary');
maskControls.querySelectorAll('.mask-control').forEach(c => c.style.display = 'flex');
}
}
}),
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
$el("label", {for: "brush-size-slider", textContent: "Size:"}),
$el("input", {
id: "brush-size-slider",
type: "range",
min: "1",
max: "200",
value: "20",
oninput: (e) => canvas.maskTool.setBrushSize(parseInt(e.target.value))
})
]),
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
$el("label", {for: "brush-strength-slider", textContent: "Strength:"}),
$el("input", {
id: "brush-strength-slider",
type: "range",
min: "0",
max: "1",
step: "0.05",
value: "0.5",
oninput: (e) => canvas.maskTool.setBrushStrength(parseFloat(e.target.value))
})
]),
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
$el("label", {for: "brush-softness-slider", textContent: "Softness:"}),
$el("input", {
id: "brush-softness-slider",
type: "range",
min: "0",
max: "1",
step: "0.05",
value: "0.5",
oninput: (e) => canvas.maskTool.setBrushSoftness(parseFloat(e.target.value))
})
]),
$el("button.painter-button.mask-control", {
textContent: "Clear Mask",
style: {display: 'none'},
onclick: () => {
if (confirm("Are you sure you want to clear the mask?")) {
canvas.maskTool.clear();
canvas.render();
}
}
})
]),
$el("div.painter-separator"),
$el("div.painter-button-group", {}, [
$el("button.painter-button", {
textContent: "Run GC",
title: "Run Garbage Collection to clean unused images",
style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"},
onclick: async () => {
try {
const stats = canvas.getGarbageCollectionStats();
log.info("GC Stats before cleanup:", stats);
await canvas.runGarbageCollection();
const newStats = canvas.getGarbageCollectionStats();
log.info("GC Stats after cleanup:", newStats);
alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${newStats.operationCount}/${newStats.operationThreshold}`);
} catch (e) {
log.error("Failed to run garbage collection:", e);
alert("Error running garbage collection. Check the console for details.");
}
}
}),
$el("button.painter-button", {
textContent: "Clear Cache",
style: {backgroundColor: "#c54747", borderColor: "#a53737"},
onclick: async () => {
if (confirm("Are you sure you want to clear all saved canvas states? This action cannot be undone.")) {
try {
await clearAllCanvasStates();
alert("Canvas cache cleared successfully!");
} catch (e) {
log.error("Failed to clear canvas cache:", e);
alert("Error clearing canvas cache. Check the console for details.");
}
}
}
})
])
]),
$el("div.painter-separator")
]);
const updateButtonStates = () => {
const selectionCount = canvas.selectedLayers.length;
const hasSelection = selectionCount > 0;
controlPanel.querySelectorAll('.requires-selection').forEach(btn => {
btn.disabled = !hasSelection;
});
const mattingBtn = controlPanel.querySelector('.matting-button');
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
mattingBtn.disabled = selectionCount !== 1;
}
};
canvas.onSelectionChange = updateButtonStates;
const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`);
const redoButton = controlPanel.querySelector(`#redo-button-${node.id}`);
canvas.onHistoryChange = ({canUndo, canRedo}) => {
if (undoButton) undoButton.disabled = !canUndo;
if (redoButton) redoButton.disabled = !canRedo;
};
updateButtonStates();
canvas.updateHistoryButtons();
const resizeObserver = new ResizeObserver((entries) => {
const controlsHeight = entries[0].target.offsetHeight;
canvasContainer.style.top = (controlsHeight + 10) + "px";
});
resizeObserver.observe(controlPanel.querySelector('.controls'));
const triggerWidget = node.widgets.find(w => w.name === "trigger");
const updateOutput = async () => {
// Only increment trigger and run step - don't save to disk here
// Saving to disk will happen during execution_start event
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
app.graph.runStep();
};
const addUpdateToButton = (button) => {
if (button.textContent === "Undo" || button.textContent === "Redo" || button.title === "Open in Editor") {
return;
}
const origClick = button.onclick;
button.onclick = async (...args) => {
if (origClick) {
await origClick(...args);
}
await updateOutput();
};
};
controlPanel.querySelectorAll('button').forEach(addUpdateToButton);
const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
style: {
position: "absolute",
top: "60px",
left: "10px",
right: "10px",
bottom: "10px",
overflow: "hidden"
}
}, [canvas.canvas]);
canvas.canvas.addEventListener('focus', () => {
canvasContainer.classList.add('has-focus');
});
canvas.canvas.addEventListener('blur', () => {
canvasContainer.classList.remove('has-focus');
});
node.onResize = function () {
canvas.render();
};
// Remove automatic saving on mouse events - only save during execution
// canvas.canvas.addEventListener('mouseup', updateOutput);
// canvas.canvas.addEventListener('mouseleave', updateOutput);
const mainContainer = $el("div.painterMainContainer", {
style: {
position: "relative",
width: "100%",
height: "100%"
}
}, [controlPanel, canvasContainer]);
const handleFileLoad = async (file) => {
log.info("File dropped:", file.name);
if (!file.type.startsWith('image/')) {
log.info("Dropped file is not an image.");
return;
}
const reader = new FileReader();
reader.onload = async (event) => {
log.debug("FileReader finished loading dropped file as data:URL.");
const img = new Image();
img.onload = async () => {
log.debug("Image object loaded from dropped data:URL.");
const scale = Math.min(
canvas.width / img.width,
canvas.height / img.height
);
const layer = {
image: img,
x: (canvas.width - img.width * scale) / 2,
y: (canvas.height - img.height * scale) / 2,
width: img.width * scale,
height: img.height * scale,
rotation: 0,
zIndex: canvas.layers.length,
blendMode: 'normal',
opacity: 1
};
canvas.layers.push(layer);
canvas.updateSelection([layer]);
canvas.render();
canvas.saveState();
log.info("Dropped layer added and state saved.");
await updateOutput();
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
};
mainContainer.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
canvasContainer.classList.add('drag-over');
});
mainContainer.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
canvasContainer.classList.remove('drag-over');
});
mainContainer.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
canvasContainer.classList.remove('drag-over');
if (e.dataTransfer.files) {
for (const file of e.dataTransfer.files) {
await handleFileLoad(file);
}
}
});
const mainWidget = node.addDOMWidget("mainContainer", "widget", mainContainer);
node.size = [500, 500];
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`);
let backdrop = null;
let modalContent = null;
let originalParent = null;
let isEditorOpen = false;
const closeEditor = () => {
originalParent.appendChild(mainContainer);
document.body.removeChild(backdrop);
isEditorOpen = false;
openEditorBtn.textContent = "⛶";
openEditorBtn.title = "Open in Editor";
canvas.render();
if (node.onResize) {
node.onResize();
}
};
openEditorBtn.onclick = () => {
if (isEditorOpen) {
closeEditor();
return;
}
originalParent = mainContainer.parentNode;
if (!originalParent) {
log.error("Could not find original parent of the canvas container!");
return;
}
backdrop = $el("div.painter-modal-backdrop");
modalContent = $el("div.painter-modal-content");
modalContent.appendChild(mainContainer);
backdrop.appendChild(modalContent);
document.body.appendChild(backdrop);
isEditorOpen = true;
openEditorBtn.textContent = "X";
openEditorBtn.title = "Close Editor";
canvas.render();
if (node.onResize) {
node.onResize();
}
};
if (!window.canvasExecutionStates) {
window.canvasExecutionStates = new Map();
}
node.canvasWidget = canvas;
setTimeout(() => {
canvas.loadInitialState();
}, 100);
return {
canvas: canvas,
panel: controlPanel
};
}
const canvasNodeInstances = new Map();
app.registerExtension({
name: "Comfy.CanvasNode",
init() {
// Monkey-patch the queuePrompt function to send canvas data via WebSocket before sending the prompt
const originalQueuePrompt = app.queuePrompt;
app.queuePrompt = async function(number, prompt) {
log.info("Preparing to queue prompt...");
if (canvasNodeInstances.size > 0) {
log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`);
const sendPromises = [];
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
// Ensure the node still exists on the graph before sending data
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
log.debug(`Sending data for canvas node ${nodeId}`);
// This now returns a promise that resolves upon server ACK
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
} else {
// If node doesn't exist, it might have been deleted, so we can clean up the map
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
canvasNodeInstances.delete(nodeId);
}
}
try {
// Wait for all WebSocket messages to be acknowledged
await Promise.all(sendPromises);
log.info("All canvas data has been sent and acknowledged by the server.");
} catch (error) {
log.error("Failed to send canvas data for one or more nodes. Aborting prompt.", error);
// IMPORTANT: Stop the prompt from queueing if data transfer fails.
// You might want to show a user-facing error here.
alert(`CanvasNode Error: ${error.message}`);
return; // Stop execution
}
}
log.info("All pre-prompt tasks complete. Proceeding with original queuePrompt.");
// Proceed with the original queuePrompt logic
return originalQueuePrompt.apply(this, arguments);
};
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass === "CanvasNode") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
log.debug("CanvasNode onNodeCreated: Base widget setup.");
// Call original onNodeCreated to ensure widgets are created
const r = onNodeCreated?.apply(this, arguments);
// The main initialization is moved to onAdded
return r;
};
// onAdded is the most reliable callback for when a node is fully added to the graph and has an ID
nodeType.prototype.onAdded = async function() {
log.info(`CanvasNode onAdded, ID: ${this.id}`);
log.debug(`Available widgets in onAdded:`, this.widgets.map(w => w.name));
// Prevent re-initialization if the widget already exists
if (this.canvasWidget) {
log.warn(`CanvasNode ${this.id} already initialized. Skipping onAdded setup.`);
return;
}
// Now that we are in onAdded, this.id is guaranteed to be correct.
// Set the hidden node_id widget's value for backend communication.
const nodeIdWidget = this.widgets.find(w => w.name === "node_id");
if (nodeIdWidget) {
nodeIdWidget.value = String(this.id);
log.debug(`Set hidden node_id widget to: ${nodeIdWidget.value}`);
} else {
log.error("Could not find the hidden node_id widget!");
}
// Create the main canvas widget and register it in our global map
// We pass `null` for the widget parameter as we are not using a pre-defined widget.
const canvasWidget = await createCanvasWidget(this, null, app);
canvasNodeInstances.set(this.id, canvasWidget);
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
log.info(`Cleaning up canvas node ${this.id}`);
// Clean up from our instance map
canvasNodeInstances.delete(this.id);
log.info(`Deregistered CanvasNode instance for ID: ${this.id}`);
// Clean up execution state
if (window.canvasExecutionStates) {
window.canvasExecutionStates.delete(this.id);
}
const tooltip = document.getElementById(`painter-help-tooltip-${this.id}`);
if (tooltip) {
tooltip.remove();
}
const backdrop = document.querySelector('.painter-modal-backdrop');
if (backdrop && backdrop.contains(this.canvasWidget?.canvas)) {
document.body.removeChild(backdrop);
}
// Cleanup canvas resources including garbage collection
if (this.canvasWidget && this.canvasWidget.destroy) {
this.canvasWidget.destroy();
}
return onRemoved?.apply(this, arguments);
};
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
originalGetExtraMenuOptions?.apply(this, arguments);
const self = this;
const newOptions = [
{
content: "Open Image",
callback: async () => {
try {
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob();
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
log.error("Error opening image:", e);
}
},
},
{
content: "Copy Image",
callback: async () => {
try {
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob();
const item = new ClipboardItem({'image/png': blob});
await navigator.clipboard.write([item]);
log.info("Image copied to clipboard.");
} catch (e) {
log.error("Error copying image:", e);
alert("Failed to copy image to clipboard.");
}
},
},
{
content: "Save Image",
callback: async () => {
try {
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'canvas_output.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
log.error("Error saving image:", e);
}
},
},
];
if (options.length > 0) {
options.unshift({content: "___", disabled: true});
}
options.unshift(...newOptions);
};
}
}
});
async function handleImportInput(data) {
if (data && data.image) {
const imageData = data.image;
await importImage(imageData);
}
}