mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-21 20:52:12 -03:00
Refactored clipspace handling into ClipspaceUtils with validateAndFixClipspace() and safeClipspacePaste() for consistent, defensive logic. Ensures full backward compatibility with all ComfyUI versions and eliminates duplicated code.
428 lines
19 KiB
JavaScript
428 lines
19 KiB
JavaScript
import { createModuleLogger } from "./LoggerUtils.js";
|
|
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
|
|
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
|
import { safeClipspacePaste } from "./ClipspaceUtils.js";
|
|
// @ts-ignore
|
|
import { api } from "../../../scripts/api.js";
|
|
const log = createModuleLogger('ClipboardManager');
|
|
export class ClipboardManager {
|
|
constructor(canvas) {
|
|
/**
|
|
* Main paste handler that delegates to appropriate methods
|
|
* @param {AddMode} addMode - The mode for adding the layer
|
|
* @param {ClipboardPreference} preference - Clipboard preference ('system' or 'clipspace')
|
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
|
*/
|
|
this.handlePaste = withErrorHandling(async (addMode = 'mouse', preference = 'system') => {
|
|
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);
|
|
}, 'ClipboardManager.handlePaste');
|
|
/**
|
|
* Attempts to paste from ComfyUI Clipspace
|
|
* @param {AddMode} addMode - The mode for adding the layer
|
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
|
*/
|
|
this.tryClipspacePaste = withErrorHandling(async (addMode) => {
|
|
log.info("Attempting to paste from ComfyUI Clipspace");
|
|
// Use the unified clipspace validation and paste function
|
|
const pasteSuccess = safeClipspacePaste(this.canvas.node);
|
|
if (!pasteSuccess) {
|
|
log.debug("Safe clipspace paste failed");
|
|
return false;
|
|
}
|
|
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;
|
|
}, 'ClipboardManager.tryClipspacePaste');
|
|
/**
|
|
* Loads a local file via the ComfyUI backend endpoint
|
|
* @param {string} filePath - The file path to load
|
|
* @param {AddMode} addMode - The mode for adding the layer
|
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
|
*/
|
|
this.loadFileViaBackend = withErrorHandling(async (filePath, addMode) => {
|
|
if (!filePath) {
|
|
throw createValidationError("File path is required", { filePath });
|
|
}
|
|
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();
|
|
throw createNetworkError(`Backend failed to load image: ${errorData.error}`, {
|
|
filePath,
|
|
status: response.status,
|
|
statusText: response.statusText
|
|
});
|
|
}
|
|
const data = await response.json();
|
|
if (!data.success) {
|
|
throw createFileError(`Backend returned error: ${data.error}`, { filePath, backendError: data.error });
|
|
}
|
|
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;
|
|
}, 'ClipboardManager.loadFileViaBackend');
|
|
this.canvas = canvas;
|
|
this.clipboardPreference = 'system'; // 'system', 'clipspace'
|
|
}
|
|
/**
|
|
* System clipboard paste - handles both image data and text paths
|
|
* @param {AddMode} 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);
|
|
};
|
|
if (event.target?.result) {
|
|
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 {AddMode} 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;
|
|
}
|
|
/**
|
|
* Prompts the user to select a file when a local path is detected
|
|
* @param {string} originalPath - The original file path from clipboard
|
|
* @param {AddMode} 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 target = event.target;
|
|
const file = 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);
|
|
};
|
|
if (e.target?.result) {
|
|
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);
|
|
};
|
|
showInfoNotification(`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}`;
|
|
showNotification(message, "#c54747", 5000);
|
|
log.info("Showed file path limitation message to user");
|
|
}
|
|
/**
|
|
* Shows a helpful message when clipboard appears empty and offers file picker
|
|
* @param {AddMode} 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");
|
|
}
|
|
}
|