mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 05:02:11 -03:00
Split help tooltip content into standard and mask mode shortcuts. Tooltip now dynamically displays relevant shortcuts based on whether the mask tool is active, improving user guidance during mask editing.
1123 lines
43 KiB
JavaScript
1123 lines
43 KiB
JavaScript
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, {
|
|
onStateChange: () => updateOutput()
|
|
});
|
|
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}`,
|
|
});
|
|
|
|
const standardShortcuts = `
|
|
<h4>Canvas Control</h4>
|
|
<ul>
|
|
<li><kbd>Click + Drag</kbd> - Pan canvas view</li>
|
|
<li><kbd>Mouse Wheel</kbd> - Zoom view in/out</li>
|
|
<li><kbd>Shift + Click (background)</kbd> - Start resizing canvas area</li>
|
|
<li><kbd>Shift + Ctrl + Click</kbd> - Start moving entire canvas</li>
|
|
<li><kbd>Double Click (background)</kbd> - Deselect all layers</li>
|
|
</ul>
|
|
<h4>Clipboard & I/O</h4>
|
|
<ul>
|
|
<li><kbd>Ctrl + C</kbd> - Copy selected layer(s)</li>
|
|
<li><kbd>Ctrl + V</kbd> - Paste from clipboard (image or internal layers)</li>
|
|
<li><kbd>Drag & Drop Image File</kbd> - Add image as a new layer</li>
|
|
</ul>
|
|
<h4>Layer Interaction</h4>
|
|
<ul>
|
|
<li><kbd>Click + Drag</kbd> - Move selected layer(s)</li>
|
|
<li><kbd>Ctrl + Click</kbd> - Add/Remove layer from selection</li>
|
|
<li><kbd>Alt + Drag</kbd> - Clone selected layer(s)</li>
|
|
<li><kbd>Shift + Click</kbd> - Show blend mode & opacity menu</li>
|
|
<li><kbd>Mouse Wheel</kbd> - Scale layer (snaps to grid)</li>
|
|
<li><kbd>Ctrl + Mouse Wheel</kbd> - Fine-scale layer</li>
|
|
<li><kbd>Shift + Mouse Wheel</kbd> - Rotate layer by 5°</li>
|
|
<li><kbd>Arrow Keys</kbd> - Nudge layer by 1px</li>
|
|
<li><kbd>Shift + Arrow Keys</kbd> - Nudge layer by 10px</li>
|
|
<li><kbd>[</kbd> or <kbd>]</kbd> - Rotate by 1°</li>
|
|
<li><kbd>Shift + [</kbd> or <kbd>]</kbd> - Rotate by 10°</li>
|
|
<li><kbd>Delete</kbd> - Delete selected layer(s)</li>
|
|
</ul>
|
|
<h4>Transform Handles (on selected layer)</h4>
|
|
<ul>
|
|
<li><kbd>Drag Corner/Side</kbd> - Resize layer</li>
|
|
<li><kbd>Drag Rotation Handle</kbd> - Rotate layer</li>
|
|
<li><kbd>Hold Shift</kbd> - Keep aspect ratio / Snap rotation to 15°</li>
|
|
<li><kbd>Hold Ctrl</kbd> - Snap to grid</li>
|
|
</ul>
|
|
`;
|
|
|
|
const maskShortcuts = `
|
|
<h4>Draw Mask Mode</h4>
|
|
<ul>
|
|
<li><kbd>Click + Drag</kbd> - Paint on the mask</li>
|
|
<li>Use the sliders to control brush <strong>Size</strong>, <strong>Strength</strong>, and <strong>Softness</strong>.</li>
|
|
<li><strong>Clear Mask</strong> will remove the entire mask.</li>
|
|
<li>The mask is applied to the final image composition.</li>
|
|
<li><kbd>Middle Mouse Button + Drag</kbd> - Pan canvas view</li>
|
|
<li><kbd>Mouse Wheel</kbd> - Zoom view in/out</li>
|
|
</ul>
|
|
<h4>Exiting Mask Mode</h4>
|
|
<ul>
|
|
<li>Click the "Draw Mask" button again to exit the mode.</li>
|
|
</ul>
|
|
`;
|
|
|
|
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) => {
|
|
if (canvas.maskTool.isActive) {
|
|
helpTooltip.innerHTML = maskShortcuts;
|
|
} else {
|
|
helpTooltip.innerHTML = standardShortcuts;
|
|
}
|
|
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",
|
|
title: "Add image from file",
|
|
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 = (event) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
canvas.addLayer(img);
|
|
};
|
|
img.src = event.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
};
|
|
input.click();
|
|
}
|
|
}),
|
|
$el("button.painter-button.primary", {
|
|
textContent: "Import Input",
|
|
title: "Import image from another node",
|
|
onclick: () => canvas.importLatestImage()
|
|
}),
|
|
$el("button.painter-button.primary", {
|
|
textContent: "Paste Image",
|
|
title: "Paste image from clipboard",
|
|
onclick: () => canvas.handlePaste()
|
|
}),
|
|
]),
|
|
|
|
$el("div.painter-separator"),
|
|
$el("div.painter-button-group", {}, [
|
|
$el("button.painter-button", {
|
|
textContent: "Output Area Size",
|
|
title: "Set the size of the output area",
|
|
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);
|
|
// updateOutput is triggered by saveState in updateOutputAreaSize
|
|
};
|
|
|
|
document.getElementById('cancel-size').onclick = () => {
|
|
document.body.removeChild(dialog);
|
|
};
|
|
}
|
|
}),
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Remove Layer",
|
|
title: "Remove selected layer(s)",
|
|
onclick: () => canvas.removeSelectedLayers()
|
|
}),
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Layer Up",
|
|
title: "Move selected layer(s) up",
|
|
onclick: () => canvas.moveLayerUp()
|
|
}),
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Layer Down",
|
|
title: "Move selected layer(s) down",
|
|
onclick: () => canvas.moveLayerDown()
|
|
}),
|
|
]),
|
|
|
|
$el("div.painter-separator"),
|
|
$el("div.painter-button-group", {}, [
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Rotate +90°",
|
|
title: "Rotate selected layer(s) by +90 degrees",
|
|
onclick: () => canvas.rotateLayer(90)
|
|
}),
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Scale +5%",
|
|
title: "Increase size of selected layer(s) by 5%",
|
|
onclick: () => canvas.resizeLayer(1.05)
|
|
}),
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Scale -5%",
|
|
title: "Decrease size of selected layer(s) by 5%",
|
|
onclick: () => canvas.resizeLayer(0.95)
|
|
}),
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Mirror H",
|
|
title: "Mirror selected layer(s) horizontally",
|
|
onclick: () => canvas.mirrorHorizontal()
|
|
}),
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Mirror V",
|
|
title: "Mirror selected layer(s) vertically",
|
|
onclick: () => canvas.mirrorVertical()
|
|
}),
|
|
]),
|
|
|
|
$el("div.painter-separator"),
|
|
$el("div.painter-button-group", {}, [
|
|
$el("button.painter-button.requires-selection.matting-button", {
|
|
textContent: "Matting",
|
|
title: "Perform background removal on the selected layer",
|
|
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();
|
|
} 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",
|
|
title: "Undo last action",
|
|
disabled: true,
|
|
onclick: () => canvas.undo()
|
|
}),
|
|
$el("button.painter-button", {
|
|
id: `redo-button-${node.id}`,
|
|
textContent: "Redo",
|
|
title: "Redo last undone action",
|
|
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",
|
|
title: "Toggle mask drawing mode",
|
|
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",
|
|
title: "Clear the entire 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",
|
|
title: "Clear all saved canvas states from browser storage",
|
|
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 = () => {
|
|
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
|
|
// app.graph.runStep(); // Potentially not needed if we just want to mark dirty
|
|
};
|
|
|
|
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();
|
|
};
|
|
|
|
|
|
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.");
|
|
};
|
|
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() {
|
|
|
|
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()) {
|
|
|
|
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
|
log.debug(`Sending data for canvas node ${nodeId}`);
|
|
|
|
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
|
|
} else {
|
|
|
|
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
|
|
canvasNodeInstances.delete(nodeId);
|
|
}
|
|
}
|
|
|
|
try {
|
|
|
|
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);
|
|
|
|
|
|
alert(`CanvasNode Error: ${error.message}`);
|
|
return; // Stop execution
|
|
}
|
|
}
|
|
|
|
log.info("All pre-prompt tasks complete. Proceeding with original queuePrompt.");
|
|
|
|
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.");
|
|
|
|
const r = onNodeCreated?.apply(this, arguments);
|
|
|
|
return r;
|
|
};
|
|
|
|
nodeType.prototype.onAdded = async function () {
|
|
log.info(`CanvasNode onAdded, ID: ${this.id}`);
|
|
log.debug(`Available widgets in onAdded:`, this.widgets.map(w => w.name));
|
|
|
|
if (this.canvasWidget) {
|
|
log.warn(`CanvasNode ${this.id} already initialized. Skipping onAdded setup.`);
|
|
return;
|
|
}
|
|
|
|
// Iterate through every widget attached to this node
|
|
this.widgets.forEach(w => {
|
|
log.debug(`Widget name: ${w.name}, type: ${w.type}, value: ${w.value}`);
|
|
});
|
|
|
|
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!");
|
|
}
|
|
|
|
|
|
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}`);
|
|
|
|
canvasNodeInstances.delete(this.id);
|
|
log.info(`Deregistered CanvasNode instance for ID: ${this.id}`);
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|