Initial commit

Project scaffolding and initial file setup.
This commit is contained in:
Dariusz L
2025-07-01 17:02:15 +02:00
parent 038dad759a
commit 562b0db042
15 changed files with 2569 additions and 759 deletions

View File

@@ -168,6 +168,7 @@ class CanvasNode:
return {
"required": {
"fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}),
"show_preview": ("BOOLEAN", {"default": False, "label_on": "Show Preview", "label_off": "Hide Preview"}),
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1, "hidden": True}),
"node_id": ("STRING", {"default": "0", "hidden": True}),
},
@@ -231,7 +232,7 @@ class CanvasNode:
_processing_lock = threading.Lock()
def process_canvas_image(self, fit_on_add, trigger, node_id, prompt=None, unique_id=None):
def process_canvas_image(self, fit_on_add, show_preview, trigger, node_id, prompt=None, unique_id=None):
try:
@@ -470,6 +471,70 @@ class CanvasNode:
'error': str(e)
}, status=500)
@PromptServer.instance.routes.post("/ycnode/load_image_from_path")
async def load_image_from_path_route(request):
try:
data = await request.json()
file_path = data.get('file_path')
if not file_path:
return web.json_response({
'success': False,
'error': 'file_path is required'
}, status=400)
log_info(f"Attempting to load image from path: {file_path}")
# Check if file exists and is accessible
if not os.path.exists(file_path):
log_warn(f"File not found: {file_path}")
return web.json_response({
'success': False,
'error': f'File not found: {file_path}'
}, status=404)
# Check if it's an image file
valid_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.avif')
if not file_path.lower().endswith(valid_extensions):
return web.json_response({
'success': False,
'error': f'Invalid image file extension. Supported: {valid_extensions}'
}, status=400)
# Try to load and convert the image
try:
with Image.open(file_path) as img:
# Convert to RGB if necessary
if img.mode != 'RGB':
img = img.convert('RGB')
# Convert to base64
buffered = io.BytesIO()
img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode('utf-8')
log_info(f"Successfully loaded image from path: {file_path}")
return web.json_response({
'success': True,
'image_data': f"data:image/png;base64,{img_str}",
'width': img.width,
'height': img.height
})
except Exception as img_error:
log_error(f"Error processing image file {file_path}: {str(img_error)}")
return web.json_response({
'success': False,
'error': f'Error processing image file: {str(img_error)}'
}, status=500)
except Exception as e:
log_error(f"Error in load_image_from_path_route: {str(e)}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
def store_image(self, image_data):
if isinstance(image_data, str) and image_data.startswith('data:image'):

File diff suppressed because it is too large Load Diff

View File

@@ -46,7 +46,7 @@ export class CanvasIO {
log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`);
return Promise.resolve(true);
}
await this.canvas.saveStateToDB(true);
await this.canvas.canvasState.saveStateToDB(true);
const nodeId = this.canvas.node.id;
const delay = (nodeId % 10) * 50;
if (delay > 0) {
@@ -102,7 +102,7 @@ export class CanvasIO {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d');
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
@@ -279,7 +279,7 @@ export class CanvasIO {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d');
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
@@ -374,7 +374,7 @@ export class CanvasIO {
this.canvas.height / inputImage.height * 0.8
);
const layer = await this.canvas.addLayerWithImage(image, {
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
x: (this.canvas.width - inputImage.width * scale) / 2,
y: (this.canvas.height - inputImage.height * scale) / 2,
width: inputImage.width * scale,
@@ -403,7 +403,7 @@ export class CanvasIO {
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = tensor.width;
canvas.height = tensor.height;
@@ -611,7 +611,7 @@ export class CanvasIO {
const canvas = document.createElement('canvas');
canvas.width = imageData.width;
canvas.height = imageData.height;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.putImageData(imageData, 0, 0);
const img = new Image();
@@ -684,7 +684,7 @@ export class CanvasIO {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = img.width;
tempCanvas.height = img.height;
const tempCtx = tempCanvas.getContext('2d');
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCtx.drawImage(img, 0, 0);
@@ -693,7 +693,7 @@ export class CanvasIO {
const maskCanvas = document.createElement('canvas');
maskCanvas.width = img.width;
maskCanvas.height = img.height;
const maskCtx = maskCanvas.getContext('2d');
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
maskCtx.drawImage(mask, 0, 0);
const maskData = maskCtx.getImageData(0, 0, img.width, img.height);
@@ -744,7 +744,7 @@ export class CanvasIO {
img.src = result.image_data;
});
await this.canvas.addLayerWithImage(img, {
await this.canvas.canvasLayers.addLayerWithImage(img, {
x: 0,
y: 0,
width: this.canvas.width,

View File

@@ -34,6 +34,8 @@ export class CanvasInteractions {
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
document.addEventListener('paste', this.handlePasteEvent.bind(this));
this.canvas.canvas.addEventListener('mouseenter', (e) => {
this.canvas.isMouseOver = true;
this.handleMouseEnter(e);
@@ -42,6 +44,13 @@ export class CanvasInteractions {
this.canvas.isMouseOver = false;
this.handleMouseLeave(e);
});
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this));
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this));
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this));
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this));
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this));
}
resetInteractionState() {
@@ -86,26 +95,34 @@ export class CanvasInteractions {
}
this.interaction.lastClickTime = currentTime;
const transformTarget = this.canvas.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (e.button === 2) {
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
if (clickedLayerResult && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) {
e.preventDefault(); // Prevent context menu
this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x ,viewCoords.y);
return;
}
}
if (e.shiftKey) {
this.startCanvasResize(worldCoords);
this.canvas.render();
return;
}
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) {
this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords);
return;
}
const clickedLayerResult = this.canvas.getLayerAtPosition(worldCoords.x, worldCoords.y);
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
if (clickedLayerResult) {
if (e.shiftKey && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) {
this.canvas.showBlendModeMenu(e.clientX, e.clientY);
return;
}
this.startLayerDrag(clickedLayerResult.layer, worldCoords);
return;
}
if (e.shiftKey) {
this.startCanvasResize(worldCoords);
} else {
this.startPanning(e);
}
this.canvas.render();
}
@@ -176,7 +193,7 @@ export class CanvasInteractions {
if (interactionEnded) {
this.canvas.saveState();
this.canvas.saveStateToDB(true);
this.canvas.canvasState.saveStateToDB(true);
}
}
@@ -194,6 +211,11 @@ export class CanvasInteractions {
this.resetInteractionState();
this.canvas.render();
}
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.internalClipboard = [];
log.info("Internal clipboard cleared - mouse left canvas");
}
}
handleMouseEnter(e) {
@@ -202,6 +224,11 @@ export class CanvasInteractions {
}
}
handleContextMenu(e) {
e.preventDefault();
}
handleWheel(e) {
e.preventDefault();
if (this.canvas.maskTool.isActive) {
@@ -297,16 +324,16 @@ export class CanvasInteractions {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) {
this.canvas.redo();
this.canvas.canvasState.redo();
} else {
this.canvas.undo();
this.canvas.canvasState.undo();
}
return;
}
if (e.key.toLowerCase() === 'y') {
e.preventDefault();
e.stopPropagation();
this.canvas.redo();
this.canvas.canvasState.redo();
return;
}
}
@@ -324,30 +351,27 @@ export class CanvasInteractions {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) {
this.canvas.redo();
this.canvas.canvasState.redo();
} else {
this.canvas.undo();
this.canvas.canvasState.undo();
}
return;
}
if (e.key.toLowerCase() === 'y') {
e.preventDefault();
e.stopPropagation();
this.canvas.redo();
this.canvas.canvasState.redo();
return;
}
if (e.key.toLowerCase() === 'c') {
if (this.canvas.selectedLayers.length > 0) {
e.preventDefault();
e.stopPropagation();
this.canvas.copySelectedLayers();
this.canvas.canvasLayers.copySelectedLayers();
}
return;
}
if (e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
this.canvas.handlePaste('mouse');
return;
}
}
@@ -399,7 +423,7 @@ export class CanvasInteractions {
}
updateCursor(worldCoords) {
const transformTarget = this.canvas.getHandleAtPosition(worldCoords.x, worldCoords.y);
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) {
const handleName = transformTarget.handle;
@@ -409,7 +433,7 @@ export class CanvasInteractions {
'rot': 'grab'
};
this.canvas.canvas.style.cursor = cursorMap[handleName];
} else if (this.canvas.getLayerAtPosition(worldCoords.x, worldCoords.y)) {
} else if (this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y)) {
this.canvas.canvas.style.cursor = 'move';
} else {
this.canvas.canvas.style.cursor = 'default';
@@ -432,7 +456,7 @@ export class CanvasInteractions {
} else {
this.interaction.mode = 'resizing';
this.interaction.resizeHandle = handle;
const handles = this.canvas.getHandles(layer);
const handles = this.canvas.canvasLayers.getHandles(layer);
const oppositeHandleKey = {
'n': 's', 's': 'n', 'e': 'w', 'w': 'e',
'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne'
@@ -712,4 +736,130 @@ export class CanvasInteractions {
this.canvas.viewport.y -= rectY;
}
}
handleDragOver(e) {
e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event
e.dataTransfer.dropEffect = 'copy';
}
handleDragEnter(e) {
e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event
this.canvas.canvas.style.backgroundColor = 'rgba(45, 90, 160, 0.1)';
this.canvas.canvas.style.border = '2px dashed #2d5aa0';
}
handleDragLeave(e) {
e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event
if (!this.canvas.canvas.contains(e.relatedTarget)) {
this.canvas.canvas.style.backgroundColor = '';
this.canvas.canvas.style.border = '';
}
}
async handleDrop(e) {
e.preventDefault();
e.stopPropagation(); // CRITICAL: Prevent ComfyUI from handling this event and loading workflow
log.info("Canvas drag & drop event intercepted - preventing ComfyUI workflow loading");
this.canvas.canvas.style.backgroundColor = '';
this.canvas.canvas.style.border = '';
const files = Array.from(e.dataTransfer.files);
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
log.info(`Dropped ${files.length} file(s) onto canvas at position (${worldCoords.x}, ${worldCoords.y})`);
for (const file of files) {
if (file.type.startsWith('image/')) {
try {
await this.loadDroppedImageFile(file, worldCoords);
log.info(`Successfully loaded dropped image: ${file.name}`);
} catch (error) {
log.error(`Failed to load dropped image ${file.name}:`, error);
}
} else {
log.warn(`Skipped non-image file: ${file.name} (${file.type})`);
}
}
}
async loadDroppedImageFile(file, worldCoords) {
const reader = new FileReader();
reader.onload = async (e) => {
const img = new Image();
img.onload = async () => {
const fitOnAddWidget = this.canvas.node.widgets.find(w => w.name === "fit_on_add");
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
await this.canvas.addLayer(img, {}, addMode);
};
img.onerror = () => {
log.error(`Failed to load dropped image: ${file.name}`);
};
img.src = e.target.result;
};
reader.onerror = () => {
log.error(`Failed to read dropped file: ${file.name}`);
};
reader.readAsDataURL(file);
}
async handlePasteEvent(e) {
const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas ||
document.activeElement === document.body;
if (!shouldHandle) {
log.debug("Paste event ignored - not focused on canvas");
return;
}
log.info("Paste event detected, checking clipboard preference");
const preference = this.canvas.canvasLayers.clipboardPreference;
if (preference === 'clipspace') {
log.info("Clipboard preference is clipspace, delegating to ClipboardManager");
e.preventDefault();
e.stopPropagation();
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
return;
}
const clipboardData = e.clipboardData;
if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
e.stopPropagation();
const file = item.getAsFile();
if (file) {
log.info("Found direct image data in paste event");
const reader = new FileReader();
reader.onload = async (event) => {
const img = new Image();
img.onload = async () => {
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'mouse');
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
return;
}
}
}
}
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
}
}

View File

@@ -2,12 +2,15 @@ import {saveImage, removeImage} from "./db.js";
import {createModuleLogger} from "./utils/LoggerUtils.js";
import {generateUUID, generateUniqueFileName} from "./utils/CommonUtils.js";
import {withErrorHandling, createValidationError} from "./ErrorHandler.js";
import {app, ComfyApp} from "../../scripts/app.js";
import {ClipboardManager} from "./utils/ClipboardManager.js";
const log = createModuleLogger('CanvasLayers');
export class CanvasLayers {
constructor(canvasLayers) {
this.canvasLayers = canvasLayers;
constructor(canvas) {
this.canvas = canvas;
this.clipboardManager = new ClipboardManager(canvas);
this.blendModes = [
{name: 'normal', label: 'Normal'},
{name: 'multiply', label: 'Multiply'},
@@ -26,85 +29,120 @@ export class CanvasLayers {
this.blendOpacity = 100;
this.isAdjustingOpacity = false;
this.internalClipboard = [];
this.clipboardPreference = 'system'; // 'system', 'clipspace'
}
async copySelectedLayers() {
if (this.canvasLayers.selectedLayers.length === 0) return;
this.internalClipboard = this.canvasLayers.selectedLayers.map(layer => ({...layer}));
if (this.canvas.selectedLayers.length === 0) return;
this.internalClipboard = this.canvas.selectedLayers.map(layer => ({...layer}));
log.info(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`);
try {
const blob = await this.getFlattenedSelectionAsBlob();
if (blob) {
if (!blob) {
log.warn("Failed to create flattened selection blob");
return;
}
if (this.clipboardPreference === 'clipspace') {
try {
const dataURL = await new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
const img = new Image();
img.onload = () => {
if (this.canvas.node.imgs) {
this.canvas.node.imgs = [img];
} else {
this.canvas.node.imgs = [img];
}
if (ComfyApp.copyToClipspace) {
ComfyApp.copyToClipspace(this.canvas.node);
log.info("Flattened selection copied to ComfyUI Clipspace.");
} else {
log.warn("ComfyUI copyToClipspace not available");
}
};
img.src = dataURL;
} catch (error) {
log.error("Failed to copy image to ComfyUI Clipspace:", error);
try {
const item = new ClipboardItem({'image/png': blob});
await navigator.clipboard.write([item]);
log.info("Flattened selection copied to the system clipboard.");
log.info("Fallback: Flattened selection copied to system clipboard.");
} catch (fallbackError) {
log.error("Failed to copy to system clipboard as fallback:", fallbackError);
}
}
} else {
try {
const item = new ClipboardItem({'image/png': blob});
await navigator.clipboard.write([item]);
log.info("Flattened selection copied to system clipboard.");
} catch (error) {
log.error("Failed to copy image to system clipboard:", error);
}
}
}
pasteLayers() {
if (this.internalClipboard.length === 0) return;
this.canvasLayers.saveState();
this.canvas.saveState();
const newLayers = [];
const pasteOffset = 20;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.internalClipboard.forEach(layer => {
minX = Math.min(minX, layer.x);
minY = Math.min(minY, layer.y);
maxX = Math.max(maxX, layer.x + layer.width);
maxY = Math.max(maxY, layer.y + layer.height);
});
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const mouseX = this.canvas.lastMousePosition.x;
const mouseY = this.canvas.lastMousePosition.y;
const offsetX = mouseX - centerX;
const offsetY = mouseY - centerY;
this.internalClipboard.forEach(clipboardLayer => {
const newLayer = {
...clipboardLayer,
x: clipboardLayer.x + pasteOffset / this.canvasLayers.viewport.zoom,
y: clipboardLayer.y + pasteOffset / this.canvasLayers.viewport.zoom,
zIndex: this.canvasLayers.layers.length
x: clipboardLayer.x + offsetX,
y: clipboardLayer.y + offsetY,
zIndex: this.canvas.layers.length
};
this.canvasLayers.layers.push(newLayer);
this.canvas.layers.push(newLayer);
newLayers.push(newLayer);
});
this.canvasLayers.updateSelection(newLayers);
this.canvasLayers.render();
log.info(`Pasted ${newLayers.length} layer(s).`);
this.canvas.updateSelection(newLayers);
this.canvas.render();
log.info(`Pasted ${newLayers.length} layer(s) at mouse position (${mouseX}, ${mouseY}).`);
}
async handlePaste(addMode = 'mouse') {
try {
if (!navigator.clipboard?.read) {
log.info("Browser does not support clipboard read API. Falling back to internal paste.");
this.pasteLayers();
return;
}
log.info(`Paste operation started with preference: ${this.clipboardPreference}`);
const clipboardItems = await navigator.clipboard.read();
let imagePasted = false;
for (const item of clipboardItems) {
const imageType = item.types.find(type => type.startsWith('image/'));
if (imageType) {
const blob = await item.getType(imageType);
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = async () => {
await this.addLayerWithImage(img, {}, addMode);
};
img.src = event.target.result;
};
reader.readAsDataURL(blob);
imagePasted = true;
break;
}
}
if (!imagePasted) {
this.pasteLayers();
}
await this.clipboardManager.handlePaste(addMode, this.clipboardPreference);
} catch (err) {
log.error("Paste operation failed, falling back to internal paste. Error:", err);
this.pasteLayers();
log.error("Paste operation failed:", err);
}
}
addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default') => {
if (!image) {
throw createValidationError("Image is required for layer creation");
@@ -113,24 +151,24 @@ export class CanvasLayers {
log.debug("Adding layer with image:", image, "with mode:", addMode);
const imageId = generateUUID();
await saveImage(imageId, image.src);
this.canvasLayers.imageCache.set(imageId, image.src);
this.canvas.imageCache.set(imageId, image.src);
let finalWidth = image.width;
let finalHeight = image.height;
let finalX, finalY;
if (addMode === 'fit') {
const scale = Math.min(this.canvasLayers.width / image.width, this.canvasLayers.height / image.height);
const scale = Math.min(this.canvas.width / image.width, this.canvas.height / image.height);
finalWidth = image.width * scale;
finalHeight = image.height * scale;
finalX = (this.canvasLayers.width - finalWidth) / 2;
finalY = (this.canvasLayers.height - finalHeight) / 2;
finalX = (this.canvas.width - finalWidth) / 2;
finalY = (this.canvas.height - finalHeight) / 2;
} else if (addMode === 'mouse') {
finalX = this.canvasLayers.lastMousePosition.x - finalWidth / 2;
finalY = this.canvasLayers.lastMousePosition.y - finalHeight / 2;
finalX = this.canvas.lastMousePosition.x - finalWidth / 2;
finalY = this.canvas.lastMousePosition.y - finalHeight / 2;
} else { // 'center' or 'default'
finalX = (this.canvasLayers.width - finalWidth) / 2;
finalY = (this.canvasLayers.height - finalHeight) / 2;
finalX = (this.canvas.width - finalWidth) / 2;
finalY = (this.canvas.height - finalHeight) / 2;
}
const layer = {
@@ -143,16 +181,16 @@ export class CanvasLayers {
originalWidth: image.width,
originalHeight: image.height,
rotation: 0,
zIndex: this.canvasLayers.layers.length,
zIndex: this.canvas.layers.length,
blendMode: 'normal',
opacity: 1,
...layerProps
};
this.canvasLayers.layers.push(layer);
this.canvasLayers.updateSelection([layer]);
this.canvasLayers.render();
this.canvasLayers.saveState();
this.canvas.layers.push(layer);
this.canvas.updateSelection([layer]);
this.canvas.render();
this.canvas.saveState();
log.info("Layer added successfully");
return layer;
@@ -162,53 +200,27 @@ export class CanvasLayers {
return this.addLayerWithImage(image);
}
async removeLayer(index) {
if (index >= 0 && index < this.canvasLayers.layers.length) {
const layer = this.canvasLayers.layers[index];
if (layer.imageId) {
const isImageUsedElsewhere = this.canvasLayers.layers.some((l, i) => i !== index && l.imageId === layer.imageId);
if (!isImageUsedElsewhere) {
await removeImage(layer.imageId);
this.canvasLayers.imageCache.delete(layer.imageId);
}
}
this.canvasLayers.layers.splice(index, 1);
this.canvasLayers.selectedLayer = this.canvasLayers.layers[this.canvasLayers.layers.length - 1] || null;
this.canvasLayers.render();
this.canvasLayers.saveState();
}
}
moveLayer(fromIndex, toIndex) {
if (fromIndex >= 0 && fromIndex < this.canvasLayers.layers.length &&
toIndex >= 0 && toIndex < this.canvasLayers.layers.length) {
const layer = this.canvasLayers.layers.splice(fromIndex, 1)[0];
this.canvasLayers.layers.splice(toIndex, 0, layer);
this.canvasLayers.render();
}
}
moveLayerUp() {
if (this.canvasLayers.selectedLayers.length === 0) return;
const selectedIndicesSet = new Set(this.canvasLayers.selectedLayers.map(layer => this.canvasLayers.layers.indexOf(layer)));
if (this.canvas.selectedLayers.length === 0) return;
const selectedIndicesSet = new Set(this.canvas.selectedLayers.map(layer => this.canvas.layers.indexOf(layer)));
const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => b - a);
sortedIndices.forEach(index => {
const targetIndex = index + 1;
if (targetIndex < this.canvasLayers.layers.length && !selectedIndicesSet.has(targetIndex)) {
[this.canvasLayers.layers[index], this.canvasLayers.layers[targetIndex]] = [this.canvasLayers.layers[targetIndex], this.canvasLayers.layers[index]];
if (targetIndex < this.canvas.layers.length && !selectedIndicesSet.has(targetIndex)) {
[this.canvas.layers[index], this.canvas.layers[targetIndex]] = [this.canvas.layers[targetIndex], this.canvas.layers[index]];
}
});
this.canvasLayers.layers.forEach((layer, i) => layer.zIndex = i);
this.canvasLayers.render();
this.canvasLayers.saveState();
this.canvas.layers.forEach((layer, i) => layer.zIndex = i);
this.canvas.render();
this.canvas.saveState();
}
moveLayerDown() {
if (this.canvasLayers.selectedLayers.length === 0) return;
const selectedIndicesSet = new Set(this.canvasLayers.selectedLayers.map(layer => this.canvasLayers.layers.indexOf(layer)));
if (this.canvas.selectedLayers.length === 0) return;
const selectedIndicesSet = new Set(this.canvas.selectedLayers.map(layer => this.canvas.layers.indexOf(layer)));
const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => a - b);
@@ -216,17 +228,46 @@ export class CanvasLayers {
const targetIndex = index - 1;
if (targetIndex >= 0 && !selectedIndicesSet.has(targetIndex)) {
[this.canvasLayers.layers[index], this.canvasLayers.layers[targetIndex]] = [this.canvasLayers.layers[targetIndex], this.canvasLayers.layers[index]];
[this.canvas.layers[index], this.canvas.layers[targetIndex]] = [this.canvas.layers[targetIndex], this.canvas.layers[index]];
}
});
this.canvasLayers.layers.forEach((layer, i) => layer.zIndex = i);
this.canvasLayers.render();
this.canvasLayers.saveState();
this.canvas.layers.forEach((layer, i) => layer.zIndex = i);
this.canvas.render();
this.canvas.saveState();
}
/**
* Zmienia rozmiar wybranych warstw
* @param {number} scale - Skala zmiany rozmiaru
*/
resizeLayer(scale) {
if (this.canvas.selectedLayers.length === 0) return;
this.canvas.selectedLayers.forEach(layer => {
layer.width *= scale;
layer.height *= scale;
});
this.canvas.render();
this.canvas.saveState();
}
/**
* Obraca wybrane warstwy
* @param {number} angle - Kąt obrotu w stopniach
*/
rotateLayer(angle) {
if (this.canvas.selectedLayers.length === 0) return;
this.canvas.selectedLayers.forEach(layer => {
layer.rotation += angle;
});
this.canvas.render();
this.canvas.saveState();
}
getLayerAtPosition(worldX, worldY) {
for (let i = this.canvasLayers.layers.length - 1; i >= 0; i--) {
const layer = this.canvasLayers.layers[i];
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
const layer = this.canvas.layers[i];
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
@@ -253,12 +294,12 @@ export class CanvasLayers {
}
async mirrorHorizontal() {
if (this.canvasLayers.selectedLayers.length === 0) return;
if (this.canvas.selectedLayers.length === 0) return;
const promises = this.canvasLayers.selectedLayers.map(layer => {
const promises = this.canvas.selectedLayers.map(layer => {
return new Promise(resolve => {
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCanvas.width = layer.image.width;
tempCanvas.height = layer.image.height;
@@ -276,17 +317,17 @@ export class CanvasLayers {
});
await Promise.all(promises);
this.canvasLayers.render();
this.canvasLayers.saveState();
this.canvas.render();
this.canvas.saveState();
}
async mirrorVertical() {
if (this.canvasLayers.selectedLayers.length === 0) return;
if (this.canvas.selectedLayers.length === 0) return;
const promises = this.canvasLayers.selectedLayers.map(layer => {
const promises = this.canvas.selectedLayers.map(layer => {
return new Promise(resolve => {
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCanvas.width = layer.image.width;
tempCanvas.height = layer.image.height;
@@ -304,14 +345,14 @@ export class CanvasLayers {
});
await Promise.all(promises);
this.canvasLayers.render();
this.canvasLayers.saveState();
this.canvas.render();
this.canvas.saveState();
}
async getLayerImageData(layer) {
try {
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCanvas.width = layer.width;
tempCanvas.height = layer.height;
@@ -342,52 +383,24 @@ export class CanvasLayers {
}
}
updateOutputAreaSize(width, height, saveHistory = true) {
if (saveHistory) {
this.canvasLayers.saveState();
this.canvas.saveState();
}
this.canvasLayers.width = width;
this.canvasLayers.height = height;
this.canvasLayers.maskTool.resize(width, height);
this.canvas.width = width;
this.canvas.height = height;
this.canvas.maskTool.resize(width, height);
this.canvasLayers.canvasLayers.width = width;
this.canvasLayers.canvasLayers.height = height;
this.canvas.canvas.width = width;
this.canvas.canvas.height = height;
this.canvasLayers.render();
this.canvas.render();
if (saveHistory) {
this.canvasLayers.saveStateToDB();
this.canvas.canvasState.saveStateToDB();
}
}
addMattedLayer(image, mask) {
const layer = {
image: image,
mask: mask,
x: 0,
y: 0,
width: image.width,
height: image.height,
rotation: 0,
zIndex: this.canvasLayers.layers.length
};
this.canvasLayers.layers.push(layer);
this.canvasLayers.selectedLayer = layer;
this.canvasLayers.render();
}
isRotationHandle(x, y) {
if (!this.canvasLayers.selectedLayer) return false;
const handleX = this.canvasLayers.selectedLayer.x + this.canvasLayers.selectedLayer.width / 2;
const handleY = this.canvasLayers.selectedLayer.y - 20;
const handleRadius = 5;
return Math.sqrt(Math.pow(x - handleX, 2) + Math.pow(y - handleY, 2)) <= handleRadius;
}
getHandles(layer) {
if (!layer) return {};
@@ -408,7 +421,7 @@ export class CanvasLayers {
'sw': {x: -halfW, y: halfH},
'w': {x: -halfW, y: 0},
'nw': {x: -halfW, y: -halfH},
'rot': {x: 0, y: -halfH - 20 / this.canvasLayers.viewport.zoom}
'rot': {x: 0, y: -halfH - 20 / this.canvas.viewport.zoom}
};
const worldHandles = {};
@@ -423,11 +436,11 @@ export class CanvasLayers {
}
getHandleAtPosition(worldX, worldY) {
if (this.canvasLayers.selectedLayers.length === 0) return null;
if (this.canvas.selectedLayers.length === 0) return null;
const handleRadius = 8 / this.canvasLayers.viewport.zoom;
for (let i = this.canvasLayers.selectedLayers.length - 1; i >= 0; i--) {
const layer = this.canvasLayers.selectedLayers[i];
const handleRadius = 8 / this.canvas.viewport.zoom;
for (let i = this.canvas.selectedLayers.length - 1; i >= 0; i--) {
const layer = this.canvas.selectedLayers[i];
const handles = this.getHandles(layer);
for (const key in handles) {
@@ -442,34 +455,6 @@ export class CanvasLayers {
return null;
}
getResizeHandle(x, y) {
if (!this.canvasLayers.selectedLayer) return null;
const handleRadius = 5;
const handles = {
'nw': {x: this.canvasLayers.selectedLayer.x, y: this.canvasLayers.selectedLayer.y},
'ne': {
x: this.canvasLayers.selectedLayer.x + this.canvasLayers.selectedLayer.width,
y: this.canvasLayers.selectedLayer.y
},
'se': {
x: this.canvasLayers.selectedLayer.x + this.canvasLayers.selectedLayer.width,
y: this.canvasLayers.selectedLayer.y + this.canvasLayers.selectedLayer.height
},
'sw': {
x: this.canvasLayers.selectedLayer.x,
y: this.canvasLayers.selectedLayer.y + this.canvasLayers.selectedLayer.height
}
};
for (const [position, point] of Object.entries(handles)) {
if (Math.sqrt(Math.pow(x - point.x, 2) + Math.pow(y - point.y, 2)) <= handleRadius) {
return position;
}
}
return null;
}
showBlendModeMenu(x, y) {
this.closeBlendModeMenu();
@@ -482,11 +467,69 @@ export class CanvasLayers {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 5px;
z-index: 10000;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
min-width: 200px;
`;
const titleBar = document.createElement('div');
titleBar.style.cssText = `
background: #3a3a3a;
color: white;
padding: 8px 10px;
cursor: move;
user-select: none;
border-radius: 3px 3px 0 0;
font-size: 12px;
font-weight: bold;
border-bottom: 1px solid #4a4a4a;
`;
titleBar.textContent = 'Blend Mode';
const content = document.createElement('div');
content.style.cssText = `
padding: 5px;
`;
menu.appendChild(titleBar);
menu.appendChild(content);
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
const handleMouseMove = (e) => {
if (isDragging) {
const newX = e.clientX - dragOffset.x;
const newY = e.clientY - dragOffset.y;
const maxX = window.innerWidth - menu.offsetWidth;
const maxY = window.innerHeight - menu.offsetHeight;
menu.style.left = Math.max(0, Math.min(newX, maxX)) + 'px';
menu.style.top = Math.max(0, Math.min(newY, maxY)) + 'px';
}
};
const handleMouseUp = () => {
if (isDragging) {
isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
};
titleBar.addEventListener('mousedown', (e) => {
isDragging = true;
dragOffset.x = e.clientX - parseInt(menu.style.left);
dragOffset.y = e.clientY - parseInt(menu.style.top);
e.preventDefault();
e.stopPropagation();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
this.blendModes.forEach(mode => {
const container = document.createElement('div');
container.className = 'blend-mode-container';
@@ -508,58 +551,58 @@ export class CanvasLayers {
slider.min = '0';
slider.max = '100';
slider.value = this.canvasLayers.selectedLayer.opacity ? Math.round(this.canvasLayers.selectedLayer.opacity * 100) : 100;
slider.value = this.canvas.selectedLayer.opacity ? Math.round(this.canvas.selectedLayer.opacity * 100) : 100;
slider.style.cssText = `
width: 100%;
margin: 5px 0;
display: none;
`;
if (this.canvasLayers.selectedLayer.blendMode === mode.name) {
if (this.canvas.selectedLayer.blendMode === mode.name) {
slider.style.display = 'block';
option.style.backgroundColor = '#3a3a3a';
}
option.onclick = () => {
menu.querySelectorAll('input[type="range"]').forEach(s => {
content.querySelectorAll('input[type="range"]').forEach(s => {
s.style.display = 'none';
});
menu.querySelectorAll('.blend-mode-container div').forEach(d => {
content.querySelectorAll('.blend-mode-container div').forEach(d => {
d.style.backgroundColor = '';
});
slider.style.display = 'block';
option.style.backgroundColor = '#3a3a3a';
if (this.canvasLayers.selectedLayer) {
this.canvasLayers.selectedLayer.blendMode = mode.name;
this.canvasLayers.render();
if (this.canvas.selectedLayer) {
this.canvas.selectedLayer.blendMode = mode.name;
this.canvas.render();
}
};
slider.addEventListener('input', () => {
if (this.canvasLayers.selectedLayer) {
this.canvasLayers.selectedLayer.opacity = slider.value / 100;
this.canvasLayers.render();
if (this.canvas.selectedLayer) {
this.canvas.selectedLayer.opacity = slider.value / 100;
this.canvas.render();
}
});
slider.addEventListener('change', async () => {
if (this.canvasLayers.selectedLayer) {
this.canvasLayers.selectedLayer.opacity = slider.value / 100;
this.canvasLayers.render();
if (this.canvas.selectedLayer) {
this.canvas.selectedLayer.opacity = slider.value / 100;
this.canvas.render();
const saveWithFallback = async (fileName) => {
try {
const uniqueFileName = generateUniqueFileName(fileName, this.canvasLayers.node.id);
return await this.canvasLayers.saveToServer(uniqueFileName);
const uniqueFileName = generateUniqueFileName(fileName, this.canvas.node.id);
return await this.canvas.saveToServer(uniqueFileName);
} catch (error) {
console.warn(`Failed to save with unique name, falling back to original: ${fileName}`, error);
return await this.canvasLayers.saveToServer(fileName);
return await this.canvas.saveToServer(fileName);
}
};
await saveWithFallback(this.canvasLayers.widget.value);
if (this.canvasLayers.node) {
await saveWithFallback(this.canvas.widget.value);
if (this.canvas.node) {
app.graph.runStep();
}
}
@@ -567,14 +610,14 @@ export class CanvasLayers {
container.appendChild(option);
container.appendChild(slider);
menu.appendChild(container);
content.appendChild(container);
});
const container = this.canvasLayers.canvas.parentElement || document.body;
const container = this.canvas.canvas.parentElement || document.body;
container.appendChild(menu);
const closeMenu = (e) => {
if (!menu.contains(e.target)) {
if (!menu.contains(e.target) && !isDragging) {
this.closeBlendModeMenu();
document.removeEventListener('mousedown', closeMenu);
}
@@ -591,17 +634,6 @@ export class CanvasLayers {
}
}
handleBlendModeSelection(mode) {
if (this.selectedBlendMode === mode && !this.isAdjustingOpacity) {
this.applyBlendMode(mode, this.blendOpacity);
this.closeBlendModeMenu();
} else {
this.selectedBlendMode = mode;
this.isAdjustingOpacity = true;
this.showOpacitySlider(mode);
}
}
showOpacitySlider(mode) {
const slider = document.createElement('input');
slider.type = 'range';
@@ -623,11 +655,11 @@ export class CanvasLayers {
async getFlattenedCanvasAsBlob() {
return new Promise((resolve, reject) => {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvasLayers.width;
tempCanvas.height = this.canvasLayers.height;
const tempCtx = tempCanvas.getContext('2d');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
const sortedLayers = [...this.canvasLayers.layers].sort((a, b) => a.zIndex - b.zIndex);
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => {
if (!layer.image) return;
@@ -660,14 +692,216 @@ export class CanvasLayers {
});
}
async getFlattenedCanvasWithMaskAsBlob() {
return new Promise((resolve, reject) => {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => {
if (!layer.image) return;
tempCtx.save();
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
tempCtx.translate(centerX, centerY);
tempCtx.rotate(layer.rotation * Math.PI / 180);
tempCtx.drawImage(
layer.image,
-layer.width / 2,
-layer.height / 2,
layer.width,
layer.height
);
tempCtx.restore();
});
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data;
const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
const maskX = this.canvas.maskTool.x;
const maskY = this.canvas.maskTool.y;
const sourceX = Math.max(0, -maskX); // Where in the mask canvas to start reading
const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX); // Where in the output canvas to start writing
const destY = Math.max(0, maskY);
const copyWidth = Math.min(
toolMaskCanvas.width - sourceX, // Available width in source
this.canvas.width - destX // Available width in destination
);
const copyHeight = Math.min(
toolMaskCanvas.height - sourceY, // Available height in source
this.canvas.height - destY // Available height in destination
);
if (copyWidth > 0 && copyHeight > 0) {
tempMaskCtx.drawImage(
toolMaskCanvas,
sourceX, sourceY, copyWidth, copyHeight, // Source rectangle
destX, destY, copyWidth, copyHeight // Destination rectangle
);
}
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
tempMaskData.data[i + 3] = alpha;
}
tempMaskCtx.putImageData(tempMaskData, 0, 0);
const maskImageData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskImageData.data;
for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
const maskAlpha = maskData[i + 3] / 255; // Użyj kanału alpha maski
const invertedMaskAlpha = 1 - maskAlpha;
data[i + 3] = originalAlpha * invertedMaskAlpha;
}
tempCtx.putImageData(imageData, 0, 0);
}
tempCanvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Canvas toBlob failed.'));
}
}, 'image/png');
});
}
async getFlattenedCanvasForMaskEditor() {
return new Promise((resolve, reject) => {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => {
if (!layer.image) return;
tempCtx.save();
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
tempCtx.translate(centerX, centerY);
tempCtx.rotate(layer.rotation * Math.PI / 180);
tempCtx.drawImage(
layer.image,
-layer.width / 2,
-layer.height / 2,
layer.width,
layer.height
);
tempCtx.restore();
});
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data;
const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
const maskX = this.canvas.maskTool.x;
const maskY = this.canvas.maskTool.y;
const sourceX = Math.max(0, -maskX);
const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX);
const destY = Math.max(0, maskY);
const copyWidth = Math.min(
toolMaskCanvas.width - sourceX,
this.canvas.width - destX
);
const copyHeight = Math.min(
toolMaskCanvas.height - sourceY,
this.canvas.height - destY
);
if (copyWidth > 0 && copyHeight > 0) {
tempMaskCtx.drawImage(
toolMaskCanvas,
sourceX, sourceY, copyWidth, copyHeight,
destX, destY, copyWidth, copyHeight
);
}
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
tempMaskData.data[i + 3] = alpha;
}
tempMaskCtx.putImageData(tempMaskData, 0, 0);
const maskImageData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskImageData.data;
for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
const maskAlpha = maskData[i + 3] / 255;
const invertedMaskAlpha = 1 - maskAlpha;
data[i + 3] = originalAlpha * invertedMaskAlpha;
}
tempCtx.putImageData(imageData, 0, 0);
}
tempCanvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Canvas toBlob failed.'));
}
}, 'image/png');
});
}
async getFlattenedSelectionAsBlob() {
if (this.canvasLayers.selectedLayers.length === 0) {
if (this.canvas.selectedLayers.length === 0) {
return null;
}
return new Promise((resolve) => {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.canvasLayers.selectedLayers.forEach(layer => {
this.canvas.selectedLayers.forEach(layer => {
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
const rad = layer.rotation * Math.PI / 180;
@@ -705,11 +939,11 @@ export class CanvasLayers {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = newWidth;
tempCanvas.height = newHeight;
const tempCtx = tempCanvas.getContext('2d');
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCtx.translate(-minX, -minY);
const sortedSelection = [...this.canvasLayers.selectedLayers].sort((a, b) => a.zIndex - b.zIndex);
const sortedSelection = [...this.canvas.selectedLayers].sort((a, b) => a.zIndex - b.zIndex);
sortedSelection.forEach(layer => {
if (!layer.image) return;

View File

@@ -301,7 +301,7 @@ export class CanvasRenderer {
ctx.moveTo(0, -layer.height / 2);
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
ctx.stroke();
const handles = this.canvas.getHandles(layer);
const handles = this.canvas.canvasLayers.getHandles(layer);
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1 / this.canvas.viewport.zoom;

View File

@@ -216,6 +216,7 @@ export class CanvasState {
await setCanvasState(this.canvas.node.id, state);
log.info("Canvas state saved to IndexedDB.");
this.lastSavedStateSignature = currentStateSignature;
this.canvas.render();
}, 'CanvasState.saveStateToDB');
if (immediate) {
@@ -292,7 +293,7 @@ export class CanvasState {
const clonedCanvas = document.createElement('canvas');
clonedCanvas.width = maskCanvas.width;
clonedCanvas.height = maskCanvas.height;
const clonedCtx = clonedCanvas.getContext('2d');
const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true });
clonedCtx.drawImage(maskCanvas, 0, 0);
this.maskUndoStack.push(clonedCanvas);
@@ -352,7 +353,7 @@ export class CanvasState {
if (this.maskUndoStack.length > 0) {
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
const maskCanvas = this.canvas.maskTool.getMask();
const maskCtx = maskCanvas.getContext('2d');
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(prevState, 0, 0);
@@ -368,7 +369,7 @@ export class CanvasState {
const nextState = this.maskRedoStack.pop();
this.maskUndoStack.push(nextState);
const maskCanvas = this.canvas.maskTool.getMask();
const maskCtx = maskCanvas.getContext('2d');
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(nextState, 0, 0);

View File

@@ -96,6 +96,33 @@ async function createCanvasWidget(node, widget, app) {
border-radius: 6px;
}
.painter-clipboard-group {
display: flex;
align-items: center;
gap: 2px;
background-color: rgba(0,0,0,0.15);
padding: 3px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
position: relative;
}
.painter-clipboard-group::before {
content: "";
position: absolute;
top: -2px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(74, 108, 212, 0.6), transparent);
border-radius: 1px;
}
.painter-clipboard-group .painter-button {
margin: 1px;
}
.painter-separator {
width: 1px;
height: 28px;
@@ -214,12 +241,13 @@ async function createCanvasWidget(node, widget, app) {
}
.painter-tooltip table td:first-child {
width: 45%;
width: auto;
white-space: nowrap;
min-width: fit-content;
}
.painter-tooltip table td:last-child {
width: 55%;
width: auto;
}
.painter-tooltip table tr:nth-child(odd) td {
@@ -368,7 +396,7 @@ async function createCanvasWidget(node, widget, app) {
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.8);
z-index: 9998;
z-index: 111;
display: flex;
align-items: center;
justify-content: center;
@@ -385,6 +413,8 @@ async function createCanvasWidget(node, widget, app) {
flex-direction: column;
position: relative;
}
`;
document.head.appendChild(style);
@@ -484,7 +514,6 @@ async function createCanvasWidget(node, widget, app) {
helpTooltip.innerHTML = standardShortcuts;
}
// Najpierw wyświetlamy tooltip z visibility: hidden aby obliczyć jego wymiary
helpTooltip.style.visibility = 'hidden';
helpTooltip.style.display = 'block';
@@ -493,28 +522,22 @@ async function createCanvasWidget(node, widget, app) {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Obliczamy pozycję
let left = buttonRect.left;
let top = buttonRect.bottom + 5;
// Sprawdzamy czy tooltip wychodzi poza prawy brzeg ekranu
if (left + tooltipRect.width > viewportWidth) {
left = viewportWidth - tooltipRect.width - 10;
}
// Sprawdzamy czy tooltip wychodzi poza dolny brzeg ekranu
if (top + tooltipRect.height > viewportHeight) {
// Wyświetlamy nad przyciskiem zamiast pod
top = buttonRect.top - tooltipRect.height - 5;
}
// Upewniamy się, że tooltip nie wychodzi poza lewy brzeg
if (left < 10) left = 10;
// Upewniamy się, że tooltip nie wychodzi poza górny brzeg
if (top < 10) top = 10;
// Ustawiamy finalną pozycję i pokazujemy tooltip
helpTooltip.style.left = `${left}px`;
helpTooltip.style.top = `${top}px`;
helpTooltip.style.visibility = 'visible';
@@ -539,7 +562,7 @@ async function createCanvasWidget(node, widget, app) {
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
canvas.addLayer(img, addMode);
canvas.addLayer(img, {}, addMode);
};
img.src = event.target.result;
};
@@ -552,17 +575,116 @@ async function createCanvasWidget(node, widget, app) {
$el("button.painter-button.primary", {
textContent: "Import Input",
title: "Import image from another node",
onclick: () => canvas.importLatestImage()
onclick: () => canvas.canvasIO.importLatestImage()
}),
$el("div.painter-clipboard-group", {}, [
$el("button.painter-button.primary", {
textContent: "Paste Image",
title: "Paste image from clipboard",
onclick: () => {
const fitOnAddWidget = node.widgets.find(w => w.name === "fit_on_add");
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
canvas.handlePaste(addMode);
canvas.canvasLayers.handlePaste(addMode);
}
}),
$el("button.painter-button", {
id: `clipboard-toggle-${node.id}`,
textContent: "📋 System",
title: "Toggle clipboard source: System Clipboard",
style: {
minWidth: "100px",
fontSize: "11px",
backgroundColor: "#4a4a4a"
},
onclick: (e) => {
const button = e.target;
if (canvas.canvasLayers.clipboardPreference === 'system') {
canvas.canvasLayers.clipboardPreference = 'clipspace';
button.textContent = "📋 Clipspace";
button.title = "Toggle clipboard source: ComfyUI Clipspace";
button.style.backgroundColor = "#4a6cd4";
} else {
canvas.canvasLayers.clipboardPreference = 'system';
button.textContent = "📋 System";
button.title = "Toggle clipboard source: System Clipboard";
button.style.backgroundColor = "#4a4a4a";
}
log.info(`Clipboard preference toggled to: ${canvas.canvasLayers.clipboardPreference}`);
},
onmouseenter: (e) => {
const currentPreference = canvas.canvasLayers.clipboardPreference;
let tooltipContent = '';
if (currentPreference === 'system') {
tooltipContent = `
<h4>📋 System Clipboard Mode</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>system clipboard</strong> as flattened image</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
<tr><td></td><td>1⃣ Internal clipboard (copied layers)</td></tr>
<tr><td></td><td>2⃣ System clipboard (images, screenshots)</td></tr>
<tr><td></td><td>3⃣ System clipboard (file paths, URLs)</td></tr>
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
</table>
<div style="margin-top: 8px; padding: 6px; background: rgba(255,165,0,0.2); border: 1px solid rgba(255,165,0,0.4); border-radius: 4px; font-size: 11px;">
⚠️ <strong>Security Note:</strong> "Paste Image" button for external images may not work due to browser security restrictions. Use Ctrl+V instead or Drag & Drop.
</div>
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
💡 <strong>Best for:</strong> Working with screenshots, copied images, file paths, and urls.
</div>
`;
} else {
tooltipContent = `
<h4>📋 ComfyUI Clipspace Mode</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>ComfyUI Clipspace</strong> as flattened image</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
<tr><td></td><td>1⃣ Internal clipboard (copied layers)</td></tr>
<tr><td></td><td>2⃣ ComfyUI Clipspace (workflow images)</td></tr>
<tr><td></td><td>3⃣ System clipboard (fallback)</td></tr>
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
</table>
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
💡 <strong>Best for:</strong> ComfyUI workflow integration and node-to-node image transfer
</div>
`;
}
helpTooltip.innerHTML = tooltipContent;
helpTooltip.style.visibility = 'hidden';
helpTooltip.style.display = 'block';
const buttonRect = e.target.getBoundingClientRect();
const tooltipRect = helpTooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = buttonRect.left;
let top = buttonRect.bottom + 5;
if (left + tooltipRect.width > viewportWidth) {
left = viewportWidth - tooltipRect.width - 10;
}
if (top + tooltipRect.height > viewportHeight) {
top = buttonRect.top - tooltipRect.height - 5;
}
if (left < 10) left = 10;
if (top < 10) top = 10;
helpTooltip.style.left = `${left}px`;
helpTooltip.style.top = `${top}px`;
helpTooltip.style.visibility = 'visible';
},
onmouseleave: () => {
helpTooltip.style.display = 'none';
}
})
]),
]),
$el("div.painter-separator"),
@@ -644,7 +766,7 @@ async function createCanvasWidget(node, widget, app) {
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 = () => {
@@ -660,12 +782,12 @@ async function createCanvasWidget(node, widget, app) {
$el("button.painter-button.requires-selection", {
textContent: "Layer Up",
title: "Move selected layer(s) up",
onclick: () => canvas.moveLayerUp()
onclick: () => canvas.canvasLayers.moveLayerUp()
}),
$el("button.painter-button.requires-selection", {
textContent: "Layer Down",
title: "Move selected layer(s) down",
onclick: () => canvas.moveLayerDown()
onclick: () => canvas.canvasLayers.moveLayerDown()
}),
]),
@@ -674,27 +796,27 @@ async function createCanvasWidget(node, widget, app) {
$el("button.painter-button.requires-selection", {
textContent: "Rotate +90°",
title: "Rotate selected layer(s) by +90 degrees",
onclick: () => canvas.rotateLayer(90)
onclick: () => canvas.canvasLayers.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)
onclick: () => canvas.canvasLayers.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)
onclick: () => canvas.canvasLayers.resizeLayer(0.95)
}),
$el("button.painter-button.requires-selection", {
textContent: "Mirror H",
title: "Mirror selected layer(s) horizontally",
onclick: () => canvas.mirrorHorizontal()
onclick: () => canvas.canvasLayers.mirrorHorizontal()
}),
$el("button.painter-button.requires-selection", {
textContent: "Mirror V",
title: "Mirror selected layer(s) vertically",
onclick: () => canvas.mirrorVertical()
onclick: () => canvas.canvasLayers.mirrorVertical()
}),
]),
@@ -716,7 +838,7 @@ async function createCanvasWidget(node, widget, app) {
const selectedLayer = canvas.selectedLayers[0];
const selectedLayerIndex = canvas.layers.indexOf(selectedLayer);
const imageData = await canvas.getLayerImageData(selectedLayer);
const imageData = await canvas.canvasLayers.getLayerImageData(selectedLayer);
const response = await fetch("/matting", {
method: "POST",
headers: {"Content-Type": "application/json"},
@@ -749,18 +871,25 @@ async function createCanvasWidget(node, widget, app) {
textContent: "Undo",
title: "Undo last action",
disabled: true,
onclick: () => canvas.undo()
onclick: () => canvas.canvasState.undo()
}),
$el("button.painter-button", {
id: `redo-button-${node.id}`,
textContent: "Redo",
title: "Redo last undone action",
disabled: true,
onclick: () => canvas.redo()
onclick: () => canvas.canvasState.redo()
}),
]),
$el("div.painter-separator"),
$el("div.painter-button-group", {id: "mask-controls"}, [
$el("button.painter-button", {
textContent: "Edit Mask",
title: "Open the current canvas view in the mask editor",
onclick: () => {
canvas.startMaskEditor();
}
}),
$el("button.painter-button", {
id: "mask-mode-btn",
textContent: "Draw Mask",
@@ -838,15 +967,15 @@ async function createCanvasWidget(node, widget, app) {
style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"},
onclick: async () => {
try {
const stats = canvas.getGarbageCollectionStats();
const stats = canvas.imageReferenceManager.getStats();
log.info("GC Stats before cleanup:", stats);
await canvas.runGarbageCollection();
await canvas.imageReferenceManager.manualGarbageCollection();
const newStats = canvas.getGarbageCollectionStats();
const newStats = canvas.imageReferenceManager.getStats();
log.info("GC Stats after cleanup:", newStats);
alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${newStats.operationCount}/${newStats.operationThreshold}`);
alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${canvas.imageReferenceManager.operationCount}/${canvas.imageReferenceManager.operationThreshold}`);
} catch (e) {
log.error("Failed to run garbage collection:", e);
alert("Error running garbage collection. Check the console for details.");
@@ -910,9 +1039,23 @@ async function createCanvasWidget(node, widget, app) {
const triggerWidget = node.widgets.find(w => w.name === "trigger");
const updateOutput = () => {
const updateOutput = async () => {
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
// app.graph.runStep(); // Potentially not needed if we just want to mark dirty
try {
const new_preview = new Image();
const blob = await canvas.getFlattenedCanvasWithMaskAsBlob();
if (blob) {
new_preview.src = URL.createObjectURL(blob);
await new Promise(r => new_preview.onload = r);
node.imgs = [new_preview];
} else {
node.imgs = [];
}
} catch (error) {
console.error("Error updating node preview:", error);
}
};
const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
@@ -948,70 +1091,8 @@ async function createCanvasWidget(node, widget, app) {
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);
@@ -1072,14 +1153,34 @@ async function createCanvasWidget(node, widget, app) {
if (!window.canvasExecutionStates) {
window.canvasExecutionStates = new Map();
}
node.canvasWidget = canvas;
setTimeout(() => {
canvas.loadInitialState();
}, 100);
const showPreviewWidget = node.widgets.find(w => w.name === "show_preview");
if (showPreviewWidget) {
const originalCallback = showPreviewWidget.callback;
showPreviewWidget.callback = function (value) {
if (originalCallback) {
originalCallback.call(this, value);
}
if (canvas && canvas.setPreviewVisibility) {
canvas.setPreviewVisibility(value);
}
if (node.graph && node.graph.canvas) {
node.setDirtyCanvas(true, true);
}
};
}
return {
canvas: canvas,
panel: controlPanel
@@ -1154,7 +1255,6 @@ app.registerExtension({
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}`);
});
@@ -1206,7 +1306,32 @@ app.registerExtension({
originalGetExtraMenuOptions?.apply(this, arguments);
const self = this;
const maskEditorIndex = options.findIndex(option =>
option && option.content === "Open in MaskEditor"
);
if (maskEditorIndex !== -1) {
options.splice(maskEditorIndex, 1);
}
const newOptions = [
{
content: "Open in MaskEditor",
callback: async () => {
try {
log.info("Opening LayerForge canvas in MaskEditor");
if (self.canvasWidget && self.canvasWidget.startMaskEditor) {
await self.canvasWidget.startMaskEditor();
} else {
log.error("Canvas widget not available");
alert("Canvas not ready. Please try again.");
}
} catch (e) {
log.error("Error opening MaskEditor:", e);
alert(`Failed to open MaskEditor: ${e.message}`);
}
},
},
{
content: "Open Image",
callback: async () => {
@@ -1220,6 +1345,19 @@ app.registerExtension({
}
},
},
{
content: "Open Image with Mask Alpha",
callback: async () => {
try {
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
log.error("Error opening image with mask:", e);
}
},
},
{
content: "Copy Image",
callback: async () => {
@@ -1234,6 +1372,20 @@ app.registerExtension({
}
},
},
{
content: "Copy Image with Mask Alpha",
callback: async () => {
try {
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
const item = new ClipboardItem({'image/png': blob});
await navigator.clipboard.write([item]);
log.info("Image with mask alpha copied to clipboard.");
} catch (e) {
log.error("Error copying image with mask:", e);
alert("Failed to copy image with mask to clipboard.");
}
},
},
{
content: "Save Image",
callback: async () => {
@@ -1252,6 +1404,24 @@ app.registerExtension({
}
},
},
{
content: "Save Image with Mask Alpha",
callback: async () => {
try {
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'canvas_output_with_mask.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
log.error("Error saving image with mask:", e);
}
},
},
];
if (options.length > 0) {
options.unshift({content: "___", disabled: true});

View File

@@ -8,7 +8,7 @@ export class MaskTool {
this.mainCanvas = canvasInstance.canvas;
this.onStateChange = callbacks.onStateChange || null;
this.maskCanvas = document.createElement('canvas');
this.maskCtx = this.maskCanvas.getContext('2d');
this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
this.x = 0;
this.y = 0;
@@ -21,7 +21,7 @@ export class MaskTool {
this.lastPosition = null;
this.previewCanvas = document.createElement('canvas');
this.previewCtx = this.previewCanvas.getContext('2d');
this.previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true });
this.previewVisible = false;
this.previewCanvasInitialized = false;
@@ -162,7 +162,7 @@ export class MaskTool {
if (this.brushHardness === 1) {
this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
} else {
// hardness: 1 = hard edge, 0 = soft edge
const innerRadius = gradientRadius * this.brushHardness;
const gradient = this.maskCtx.createRadialGradient(
canvasX, canvasY, innerRadius,
@@ -220,7 +220,7 @@ export class MaskTool {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.maskCanvas.width;
tempCanvas.height = this.maskCanvas.height;
const tempCtx = tempCanvas.getContext('2d');
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCtx.drawImage(this.maskCanvas, 0, 0);
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data;
@@ -258,7 +258,7 @@ export class MaskTool {
this.maskCanvas.width = newWidth;
this.maskCanvas.height = newHeight;
this.maskCtx = this.maskCanvas.getContext('2d');
this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
if (oldMask.width > 0 && oldMask.height > 0) {
@@ -279,4 +279,23 @@ export class MaskTool {
this.y += dy;
log.info(`Mask position updated to (${this.x}, ${this.y})`);
}
setMask(image) {
const destX = -this.x;
const destY = -this.y;
this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height);
this.maskCtx.drawImage(image, destX, destY);
if (this.onStateChange) {
this.onStateChange();
}
this.canvasInstance.render(); // Wymuś odświeżenie, aby zobaczyć zmianę
log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`);
}
}

View File

@@ -0,0 +1,510 @@
import {createModuleLogger} from "./LoggerUtils.js";
import {api} from "../../../scripts/api.js";
import {ComfyApp} from "../../../scripts/app.js";
const log = createModuleLogger('ClipboardManager');
export class ClipboardManager {
constructor(canvas) {
this.canvas = canvas;
this.clipboardPreference = 'system'; // 'system', 'clipspace'
}
/**
* Main paste handler that delegates to appropriate methods
* @param {string} addMode - The mode for adding the layer
* @param {string} preference - Clipboard preference ('system' or 'clipspace')
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async handlePaste(addMode = 'mouse', preference = 'system') {
try {
log.info(`ClipboardManager handling paste with preference: ${preference}`);
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
log.info("Found layers in internal clipboard, pasting layers");
this.canvas.canvasLayers.pasteLayers();
return true;
}
if (preference === 'clipspace') {
log.info("Attempting paste from ComfyUI Clipspace");
const success = await this.tryClipspacePaste(addMode);
if (success) {
return true;
}
log.info("No image found in ComfyUI Clipspace");
}
log.info("Attempting paste from system clipboard");
return await this.trySystemClipboardPaste(addMode);
} catch (err) {
log.error("ClipboardManager paste operation failed:", err);
return false;
}
}
/**
* Attempts to paste from ComfyUI Clipspace
* @param {string} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async tryClipspacePaste(addMode) {
try {
log.info("Attempting to paste from ComfyUI Clipspace");
const clipspaceResult = ComfyApp.pasteFromClipspace(this.canvas.node);
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
const clipspaceImage = this.canvas.node.imgs[0];
if (clipspaceImage && clipspaceImage.src) {
log.info("Successfully got image from ComfyUI Clipspace");
const img = new Image();
img.onload = async () => {
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
};
img.src = clipspaceImage.src;
return true;
}
}
return false;
} catch (clipspaceError) {
log.warn("ComfyUI Clipspace paste failed:", clipspaceError);
return false;
}
}
/**
* System clipboard paste - handles both image data and text paths
* @param {string} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async trySystemClipboardPaste(addMode) {
log.info("ClipboardManager: Checking system clipboard for images and paths");
if (navigator.clipboard?.read) {
try {
const clipboardItems = await navigator.clipboard.read();
for (const item of clipboardItems) {
log.debug("Clipboard item types:", item.types);
const imageType = item.types.find(type => type.startsWith('image/'));
if (imageType) {
try {
const blob = await item.getType(imageType);
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = async () => {
log.info("Successfully loaded image from system clipboard");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
};
img.src = event.target.result;
};
reader.readAsDataURL(blob);
log.info("Found image data in system clipboard");
return true;
} catch (error) {
log.debug("Error reading image data:", error);
}
}
const textTypes = ['text/plain', 'text/uri-list'];
for (const textType of textTypes) {
if (item.types.includes(textType)) {
try {
const textBlob = await item.getType(textType);
const text = await textBlob.text();
if (this.isValidImagePath(text)) {
log.info("Found image path in clipboard:", text);
const success = await this.loadImageFromPath(text, addMode);
if (success) {
return true;
}
}
} catch (error) {
log.debug(`Error reading ${textType}:`, error);
}
}
}
}
} catch (error) {
log.debug("Modern clipboard API failed:", error);
}
}
if (navigator.clipboard?.readText) {
try {
const text = await navigator.clipboard.readText();
log.debug("Found text in clipboard:", text);
if (text && this.isValidImagePath(text)) {
log.info("Found valid image path in clipboard:", text);
const success = await this.loadImageFromPath(text, addMode);
if (success) {
return true;
}
}
} catch (error) {
log.debug("Could not read text from clipboard:", error);
}
}
log.debug("No images or valid image paths found in system clipboard");
return false;
}
/**
* Validates if a text string is a valid image file path or URL
* @param {string} text - The text to validate
* @returns {boolean} - True if the text appears to be a valid image file path or URL
*/
isValidImagePath(text) {
if (!text || typeof text !== 'string') {
return false;
}
text = text.trim();
if (!text) {
return false;
}
if (text.startsWith('http://') || text.startsWith('https://') || text.startsWith('file://')) {
try {
new URL(text);
log.debug("Detected valid URL:", text);
return true;
} catch (e) {
log.debug("Invalid URL format:", text);
return false;
}
}
const imageExtensions = [
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp',
'.svg', '.tiff', '.tif', '.ico', '.avif'
];
const hasImageExtension = imageExtensions.some(ext =>
text.toLowerCase().endsWith(ext)
);
if (!hasImageExtension) {
log.debug("No valid image extension found in:", text);
return false;
}
const pathPatterns = [
/^[a-zA-Z]:[\\\/]/, // Windows absolute path (C:\... or C:/...)
/^[\\\/]/, // Unix absolute path (/...)
/^\.{1,2}[\\\/]/, // Relative path (./... or ../...)
/^[^\\\/]*[\\\/]/ // Contains path separators
];
const isValidPath = pathPatterns.some(pattern => pattern.test(text)) ||
(!text.includes('/') && !text.includes('\\') && text.includes('.')); // Simple filename
if (isValidPath) {
log.debug("Detected valid local file path:", text);
} else {
log.debug("Invalid local file path format:", text);
}
return isValidPath;
}
/**
* Attempts to load an image from a file path using simplified methods
* @param {string} filePath - The file path to load
* @param {string} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async loadImageFromPath(filePath, addMode) {
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
try {
const img = new Image();
img.crossOrigin = 'anonymous';
return new Promise((resolve) => {
img.onload = async () => {
log.info("Successfully loaded image from URL");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
resolve(true);
};
img.onerror = () => {
log.warn("Failed to load image from URL:", filePath);
resolve(false);
};
img.src = filePath;
});
} catch (error) {
log.warn("Error loading image from URL:", error);
return false;
}
}
try {
log.info("Attempting to load local file via backend");
const success = await this.loadFileViaBackend(filePath, addMode);
if (success) {
return true;
}
} catch (error) {
log.warn("Backend loading failed:", error);
}
try {
log.info("Falling back to file picker");
const success = await this.promptUserForFile(filePath, addMode);
if (success) {
return true;
}
} catch (error) {
log.warn("File picker failed:", error);
}
this.showFilePathMessage(filePath);
return false;
}
/**
* Loads a local file via the ComfyUI backend endpoint
* @param {string} filePath - The file path to load
* @param {string} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async loadFileViaBackend(filePath, addMode) {
try {
log.info("Loading file via ComfyUI backend:", filePath);
const response = await api.fetchApi("/ycnode/load_image_from_path", {
method: "POST",
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath
})
});
if (!response.ok) {
const errorData = await response.json();
log.debug("Backend failed to load image:", errorData.error);
return false;
}
const data = await response.json();
if (!data.success) {
log.debug("Backend returned error:", data.error);
return false;
}
log.info("Successfully loaded image via ComfyUI backend:", filePath);
const img = new Image();
const success = await new Promise((resolve) => {
img.onload = async () => {
log.info("Successfully loaded image from backend response");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
resolve(true);
};
img.onerror = () => {
log.warn("Failed to load image from backend response");
resolve(false);
};
img.src = data.image_data;
});
return success;
} catch (error) {
log.debug("Error loading file via ComfyUI backend:", error);
return false;
}
}
/**
* Prompts the user to select a file when a local path is detected
* @param {string} originalPath - The original file path from clipboard
* @param {string} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async promptUserForFile(originalPath, addMode) {
return new Promise((resolve) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
const fileName = originalPath.split(/[\\\/]/).pop();
fileInput.onchange = async (event) => {
const file = event.target.files[0];
if (file && file.type.startsWith('image/')) {
try {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = async () => {
log.info("Successfully loaded image from file picker");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
resolve(true);
};
img.onerror = () => {
log.warn("Failed to load selected image");
resolve(false);
};
img.src = e.target.result;
};
reader.onerror = () => {
log.warn("Failed to read selected file");
resolve(false);
};
reader.readAsDataURL(file);
} catch (error) {
log.warn("Error processing selected file:", error);
resolve(false);
}
} else {
log.warn("Selected file is not an image");
resolve(false);
}
document.body.removeChild(fileInput);
};
fileInput.oncancel = () => {
log.info("File selection cancelled by user");
document.body.removeChild(fileInput);
resolve(false);
};
this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
document.body.appendChild(fileInput);
fileInput.click();
});
}
/**
* Shows a message to the user about file path limitations
* @param {string} filePath - The file path that couldn't be loaded
*/
showFilePathMessage(filePath) {
const fileName = filePath.split(/[\\\/]/).pop();
const message = `Cannot load local file directly due to browser security restrictions. File detected: ${fileName}`;
this.showNotification(message, 5000);
log.info("Showed file path limitation message to user");
}
/**
* Shows a helpful message when clipboard appears empty and offers file picker
* @param {string} addMode - The mode for adding the layer
*/
showEmptyClipboardMessage(addMode) {
const message = `Copied a file? Browser can't access file paths for security. Click here to select the file manually.`;
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #2d5aa0;
color: white;
padding: 14px 18px;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
z-index: 10001;
max-width: 320px;
font-size: 14px;
line-height: 1.4;
cursor: pointer;
border: 2px solid #4a7bc8;
transition: all 0.2s ease;
font-weight: 500;
`;
notification.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 18px;">📁</span>
<span>${message}</span>
</div>
<div style="font-size: 12px; opacity: 0.9; margin-top: 4px;">
💡 Tip: You can also drag & drop files directly onto the canvas
</div>
`;
notification.onmouseenter = () => {
notification.style.backgroundColor = '#3d6bb0';
notification.style.borderColor = '#5a8bd8';
notification.style.transform = 'translateY(-1px)';
};
notification.onmouseleave = () => {
notification.style.backgroundColor = '#2d5aa0';
notification.style.borderColor = '#4a7bc8';
notification.style.transform = 'translateY(0)';
};
notification.onclick = async () => {
document.body.removeChild(notification);
try {
const success = await this.promptUserForFile('image_file.jpg', addMode);
if (success) {
log.info("Successfully loaded image via empty clipboard file picker");
}
} catch (error) {
log.warn("Error with empty clipboard file picker:", error);
}
};
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 12000);
log.info("Showed enhanced empty clipboard message with file picker option");
}
/**
* Shows a temporary notification to the user
* @param {string} message - The message to show
* @param {number} duration - Duration in milliseconds
*/
showNotification(message, duration = 3000) {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #333;
color: white;
padding: 12px 16px;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
z-index: 10001;
max-width: 300px;
font-size: 14px;
line-height: 1.4;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, duration);
}
}

View File

@@ -1,5 +1,6 @@
import {createModuleLogger} from "./LoggerUtils.js";
import {withErrorHandling, createValidationError} from "../ErrorHandler.js";
const log = createModuleLogger('ImageUtils');
export function validateImageData(data) {
@@ -167,7 +168,7 @@ export const imageToTensor = withErrorHandling(async function(image) {
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = image.width || image.naturalWidth;
canvas.height = image.height || image.naturalHeight;
@@ -204,7 +205,7 @@ export const tensorToImage = withErrorHandling(async function(tensor) {
const [, height, width, channels] = tensor.shape;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width;
canvas.height = height;
@@ -245,7 +246,7 @@ export const resizeImage = withErrorHandling(async function(image, maxWidth, max
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const originalWidth = image.width || image.naturalWidth;
const originalHeight = image.height || image.naturalHeight;
@@ -291,7 +292,7 @@ export const imageToBase64 = withErrorHandling(function(image, format = 'png', q
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = image.width || image.naturalWidth;
canvas.height = image.height || image.naturalHeight;
@@ -373,7 +374,7 @@ export function createImageFromSource(source) {
*/
export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width;
canvas.height = height;

View File

@@ -130,7 +130,6 @@ class WebSocketManager {
log.warn("WebSocket not open. Queuing message.");
this.messageQueue.push(message);
if (!this.isConnecting) {
this.connect();
@@ -147,7 +146,6 @@ class WebSocketManager {
log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
this.socket.send(message);

174
js/utils/mask_utils.js Normal file
View File

@@ -0,0 +1,174 @@
import {createModuleLogger} from "./LoggerUtils.js";
const log = createModuleLogger('MaskUtils');
export function new_editor(app) {
if (!app) return false;
return app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor')
}
function get_mask_editor_element(app) {
return new_editor(app) ? document.getElementById('maskEditor') : document.getElementById('maskCanvas')?.parentElement
}
export function mask_editor_showing(app) {
const editor = get_mask_editor_element(app);
return editor && editor.style.display !== "none";
}
export function hide_mask_editor() {
if (mask_editor_showing()) document.getElementById('maskEditor').style.display = 'none'
}
function get_mask_editor_cancel_button(app) {
const cancelButton = document.getElementById("maskEditor_topBarCancelButton");
if (cancelButton) {
log.debug("Found cancel button by ID: maskEditor_topBarCancelButton");
return cancelButton;
}
const cancelSelectors = [
'button[onclick*="cancel"]',
'button[onclick*="Cancel"]',
'input[value="Cancel"]'
];
for (const selector of cancelSelectors) {
try {
const button = document.querySelector(selector);
if (button) {
log.debug("Found cancel button with selector:", selector);
return button;
}
} catch (e) {
log.warn("Invalid selector:", selector, e);
}
}
const allButtons = document.querySelectorAll('button, input[type="button"]');
for (const button of allButtons) {
const text = button.textContent || button.value || '';
if (text.toLowerCase().includes('cancel')) {
log.debug("Found cancel button by text content:", text);
return button;
}
}
const editorElement = get_mask_editor_element(app);
if (editorElement) {
return editorElement?.parentElement?.lastChild?.childNodes[2];
}
return null;
}
function get_mask_editor_save_button(app) {
if (document.getElementById("maskEditor_topBarSaveButton")) return document.getElementById("maskEditor_topBarSaveButton")
return get_mask_editor_element(app)?.parentElement?.lastChild?.childNodes[2]
}
export function mask_editor_listen_for_cancel(app, callback) {
let attempts = 0;
const maxAttempts = 50; // 5 sekund
const findAndAttachListener = () => {
attempts++;
const cancel_button = get_mask_editor_cancel_button(app);
if (cancel_button && !cancel_button.filter_listener_added) {
log.info("Cancel button found, attaching listener");
cancel_button.addEventListener('click', callback);
cancel_button.filter_listener_added = true;
return true; // Znaleziono i podłączono
} else if (attempts < maxAttempts) {
setTimeout(findAndAttachListener, 100);
} else {
log.warn("Could not find cancel button after", maxAttempts, "attempts");
const globalClickHandler = (event) => {
const target = event.target;
const text = target.textContent || target.value || '';
if (text.toLowerCase().includes('cancel') ||
target.id.toLowerCase().includes('cancel') ||
target.className.toLowerCase().includes('cancel')) {
log.info("Cancel detected via global click handler");
callback();
document.removeEventListener('click', globalClickHandler);
}
};
document.addEventListener('click', globalClickHandler);
log.debug("Added global click handler for cancel detection");
}
};
findAndAttachListener();
}
export function press_maskeditor_save(app) {
get_mask_editor_save_button(app)?.click()
}
export function press_maskeditor_cancel(app) {
get_mask_editor_cancel_button(app)?.click()
}
/**
* Uruchamia mask editor z predefiniowaną maską
* @param {Object} canvasInstance - Instancja Canvas
* @param {Image|HTMLCanvasElement} maskImage - Obraz maski do nałożenia
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski)
*/
export function start_mask_editor_with_predefined_mask(canvasInstance, maskImage, sendCleanImage = true) {
if (!canvasInstance || !maskImage) {
log.error('Canvas instance and mask image are required');
return;
}
canvasInstance.startMaskEditor(maskImage, sendCleanImage);
}
/**
* Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska)
* @param {Object} canvasInstance - Instancja Canvas
*/
export function start_mask_editor_auto(canvasInstance) {
if (!canvasInstance) {
log.error('Canvas instance is required');
return;
}
canvasInstance.startMaskEditor();
}
/**
* Tworzy maskę z obrazu dla użycia w mask editorze
* @param {string} imageSrc - Źródło obrazu (URL lub data URL)
* @returns {Promise<Image>} Promise zwracający obiekt Image
*/
export function create_mask_from_image_src(imageSrc) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = imageSrc;
});
}
/**
* Konwertuje canvas do Image dla użycia jako maska
* @param {HTMLCanvasElement} canvas - Canvas do konwersji
* @returns {Promise<Image>} Promise zwracający obiekt Image
*/
export function canvas_to_mask_image(canvas) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = canvas.toDataURL();
});
}