import { createModuleLogger } from "./LoggerUtils.js"; import { showNotification, showInfoNotification } from "./NotificationUtils.js"; import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js"; // @ts-ignore import { api } from "../../../scripts/api.js"; // @ts-ignore import { ComfyApp } from "../../../scripts/app.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} - 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} - True if successful, false otherwise */ this.tryClipspacePaste = withErrorHandling(async (addMode) => { log.info("Attempting to paste from ComfyUI Clipspace"); 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; }, '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} - 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} - 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} - 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} - 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 = `
📁 ${message}
💡 Tip: You can also drag & drop files directly onto the canvas
`; 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"); } }