diff --git a/Rayko_Image_Prompt.py b/Rayko_Image_Prompt.py new file mode 100644 index 0000000..9c64752 --- /dev/null +++ b/Rayko_Image_Prompt.py @@ -0,0 +1,337 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2025-2026 Raykosan (RaykoStudio) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import json +from PIL import Image +import folder_paths +from server import PromptServer +from aiohttp import web + +WEB_DIRECTORY = "./web" + +KNOWN_PROMPT_NODES = {"CLIPTextEncode", "RSPrompts", "RS_ImagePrompt"} +TEXT_KEYS = ["text", "string", "parameters", "Comment", "Description", "Title"] + +def get_image_files(): + input_dir = folder_paths.get_input_directory() + files = [] + for f in os.listdir(input_dir): + if os.path.isfile(os.path.join(input_dir, f)) and f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')): + files.append(f) + files.sort() + return files + +def _get_text_from_node(prompt_data, node_id, visited): + node_id = str(node_id) + if node_id in visited or node_id not in prompt_data: + return "" + visited.add(node_id) + + node = prompt_data[node_id] + inputs = node.get("inputs", {}) + class_type = node.get("class_type", "") + + if inputs.get("enable_text_input"): + ti = inputs.get("text_input") + if isinstance(ti, list) and len(ti) >= 1: + upstream = _get_text_from_node(prompt_data, ti[0], visited) + if upstream: + return upstream + + text = inputs.get("text") + if isinstance(text, str) and text: + return text + + if class_type == "RS_ImagePrompt": + prompt_preview = inputs.get("prompt_preview") + if isinstance(prompt_preview, str) and prompt_preview: + return prompt_preview + + for key in ("conditioning", "conditioning_1", "conditioning_2", "clip", "text_input", "text"): + ref = inputs.get(key) + if isinstance(ref, list) and len(ref) >= 1: + result = _get_text_from_node(prompt_data, ref[0], visited) + if result: + return result + return "" + +def _extract_prompt_from_dict(prompt_data): + if not isinstance(prompt_data, dict): + return "" + + negative_refs = set() + for node_data in prompt_data.values(): + if not isinstance(node_data, dict): + continue + ref = node_data.get("inputs", {}).get("negative") + if isinstance(ref, list) and len(ref) >= 1: + negative_refs.add((str(ref[0]), ref[1] if len(ref) > 1 else 0)) + + for node_id, node_data in prompt_data.items(): + if not isinstance(node_data, dict): + continue + inputs = node_data.get("inputs", {}) + if "positive" in inputs: + pos_ref = inputs.get("positive") + if isinstance(pos_ref, list) and pos_ref: + text = _get_text_from_node(prompt_data, pos_ref[0], set()) + if text: + return text + + for node_id, node_data in prompt_data.items(): + if not isinstance(node_data, dict): + continue + if node_data.get("class_type", "") in KNOWN_PROMPT_NODES: + if (str(node_id), 0) in negative_refs: + continue + text = node_data.get("inputs", {}).get("text") + if isinstance(text, str) and text: + return text + prompt_preview = node_data.get("inputs", {}).get("prompt_preview") + if isinstance(prompt_preview, str) and prompt_preview: + return prompt_preview + + return "" + +def _extract_prompt(prompt_json_str): + try: + data = json.loads(prompt_json_str) + except (json.JSONDecodeError, TypeError): + return "" + return _extract_prompt_from_dict(data) + +def _extract_prompt_from_workflow(workflow_json_str): + try: + wf = json.loads(workflow_json_str) + except (json.JSONDecodeError, TypeError): + return "" + if not isinstance(wf, dict): + return "" + + nodes = wf.get("nodes", []) + links = wf.get("links", []) + if not isinstance(nodes, list): + return "" + + nodes_by_id = {} + for n in nodes: + if isinstance(n, dict) and "id" in n: + nodes_by_id[str(n["id"])] = n + + link_by_target = {} + for lk in links: + if isinstance(lk, list) and len(lk) >= 5: + link_by_target[(str(lk[3]), lk[4])] = (str(lk[1]), lk[2]) + + def widget_text(node): + wv = node.get("widgets_values", []) + if isinstance(wv, list) and wv and isinstance(wv[0], str): + return wv[0] + return "" + + def trace(node_id, slot_index, visited): + key = (node_id, slot_index) + if key in visited: + return "" + visited.add(key) + src = link_by_target.get(key) + if not src: + return "" + src_id, _ = src + src_node = nodes_by_id.get(src_id) + if not src_node: + return "" + if src_node.get("type", "") in KNOWN_PROMPT_NODES: + t = widget_text(src_node) + if t: + return t + for idx in range(len(src_node.get("inputs", []))): + res = trace(src_id, idx, visited) + if res: + return res + return "" + + for nid, node in nodes_by_id.items(): + ntype = str(node.get("type", "")) + if "ampler" in ntype: + for idx, inp in enumerate(node.get("inputs", [])): + if isinstance(inp, dict) and inp.get("name") == "positive": + t = trace(nid, idx, set()) + if t: + return t + + for nid, node in nodes_by_id.items(): + if node.get("type", "") in KNOWN_PROMPT_NODES: + t = widget_text(node) + if t: + return t + return "" + +def _looks_like_settings(line): + keys = ("Steps:", "Sampler:", "CFG scale:", "Seed:", "Size:", "Model hash:", + "Model:", "Denoising strength:", "Clip skip:", "ENSAM:", "TI_hashes:") + return any(k in line for k in keys) + + +def _clean_text_prompt(text): + if not isinstance(text, str): + return "" + text = text.strip() + if not text: + return "" + + if text.startswith("{"): + try: + data = json.loads(text) + res = _extract_prompt_from_dict(data) + if res: + return res + except (json.JSONDecodeError, TypeError): + pass + + idx = text.find("Negative prompt:") + if idx != -1: + text = text[:idx] + lines = text.split("\n") + while lines and _looks_like_settings(lines[-1]): + lines.pop() + return "\n".join(lines).strip() + +def _decode_user_comment(data): + if isinstance(data, str): + return data + if not isinstance(data, (bytes, bytearray)): + return str(data) + + if data.startswith(b"ASCII\x00\x00\x00"): + return data[8:].decode("latin-1", errors="replace") + if data.startswith(b"UNICODE\x00"): + return data[8:].decode("utf-16", errors="replace") + if data.startswith(b"JIS\x00\x00\x00\x00\x00"): + return data[8:].decode("shift_jis", errors="replace") + + for encoding in ("utf-8", "latin-1"): + try: + return data.decode(encoding) + except (UnicodeDecodeError, UnicodeError): + continue + return data.decode("latin-1", errors="replace") + + +def _extract_a1111_from_exif(pil_img): + try: + exif = pil_img.getexif() + if not exif: + return "" + exif_ifd = exif.get_ifd(0x8769) + if not exif_ifd: + return "" + user_comment = exif_ifd.get(0x9286) + if not user_comment: + return "" + return _decode_user_comment(user_comment) + except Exception as e: + print(f"[RS Image-Prompt] EXIF error: {e}") + return "" + +def extract_prompt_from_file(input_path): + try: + with Image.open(input_path) as pil_img: + info = dict(pil_img.info) + exif_text = _extract_a1111_from_exif(pil_img) + except Exception as e: + print(f"[RS Image-Prompt] Cannot open image: {e}") + return "" + + prompt_json = info.get("prompt", "") + if prompt_json: + result = _extract_prompt(prompt_json) + if result: + return result + + workflow_json = info.get("workflow", "") + if workflow_json: + result = _extract_prompt_from_workflow(workflow_json) + if result: + return result + + for key in TEXT_KEYS: + value = info.get(key) + if isinstance(value, str) and value.strip(): + result = _clean_text_prompt(value) + if result: + return result + + if exif_text: + result = _clean_text_prompt(exif_text) + if result: + return result + + return "" + + +@PromptServer.instance.routes.get("/rayko/get_prompt") +async def rayko_get_prompt(request): + filename = request.rel_url.query.get("filename", "") + if not filename: + return web.json_response({"prompt": ""}) + try: + path = folder_paths.get_annotated_filepath(filename) + return web.json_response({"prompt": extract_prompt_from_file(path)}) + except Exception as e: + return web.json_response({"prompt": "", "error": str(e)}) + + +class RS_ImagePrompt: + @classmethod + def INPUT_TYPES(cls): + files = get_image_files() + return { + "required": { + "image": ("STRING", {"default": ""}), + "prompt_preview": ("STRING", { + "default": "", + "multiline": True, + "tooltip": "Extracted prompt preview (updates on file select)", + }), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("prompt",) + FUNCTION = "process" + CATEGORY = "🦊 RaykoStudio" + DESCRIPTION = "Extracts the positive prompt from image metadata (ComfyUI, RS Prompts, RS Image-Prompt, A1111 PNG/JPG/WebP)." + + def process(self, image, prompt_preview=""): + try: + input_path = folder_paths.get_annotated_filepath(image) + if not os.path.exists(input_path): + raise FileNotFoundError(f"File not found: {input_path}") + return (extract_prompt_from_file(input_path),) + except Exception as e: + print(f"[RS Image-Prompt] Error: {e}") + import traceback + traceback.print_exc() + return ("",) + + +NODE_CLASS_MAPPINGS = { + "RS_ImagePrompt": RS_ImagePrompt, +} +NODE_DISPLAY_NAME_MAPPINGS = { + "RS_ImagePrompt": "🦊 RS Image-Prompt", +} \ No newline at end of file diff --git a/__init__.py b/__init__.py index 76481a2..79bcc1f 100644 --- a/__init__.py +++ b/__init__.py @@ -29,7 +29,7 @@ IGNORED_MODULES: Set[str] = {'__init__.py'} REQUIRED_MODULES: Set[str] = set() PACKAGE_DISPLAY_NAME = "ComfyUI_RaykoStudio" -__version__ = "0.34.1" +__version__ = "0.35.0" def load_modules(): print(f"🦊 {PACKAGE_DISPLAY_NAME}: Version: {__version__}") diff --git a/package.json b/package.json index c62345e..f362cfa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ComfyUI_RaykoStudio", - "version": "0.34.1", + "version": "0.35.0", "description": "Set of custom nodes for ComfyUI providing additional image processing capabilities", "author": "Raykosan", "license": "Apache License 2.0", @@ -17,6 +17,7 @@ "Rayko_Color_Picker.py" "Rayko_Crop_Image.py" "Rayko_Image_Compare.py" + "Rayko_Image_Prompt.py" "Rayko_Image_Selector.py" "Rayko_Image_Text.py" "Rayko_Image_To_Latent.py" diff --git a/pyproject.toml b/pyproject.toml index afd9665..a246a0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ComfyUI_RaykoStudio" description = "Set of custom nodes for ComfyUI providing additional image processing capabilities" -version = "0.34.1" +version = "0.35.0" license = { file = "LICENSE" } dependencies = ["torch", "numpy", "Pillow>=10.0.0", "freetype-py>=2.5.1", "pycairo>=1.29.0", "scipy>=1.10.0"] diff --git a/web/rs_image-prompt.js b/web/rs_image-prompt.js new file mode 100644 index 0000000..47f54ad --- /dev/null +++ b/web/rs_image-prompt.js @@ -0,0 +1,476 @@ +import { app } from "../../scripts/app.js"; + +app.registerExtension({ + name: "RaykoStudio.ImagePrompt", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name !== "RS_ImagePrompt") return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + const node = this; + + node.data = { + selected_image: "", + prompt: "" + }; + + const MIN_WIDTH = 360; + const MIN_HEIGHT = 460; + const PREVIEW_FIXED_HEIGHT = 250; + + node.MIN_WIDTH = MIN_WIDTH; + node.MIN_HEIGHT = MIN_HEIGHT; + node.setSize([MIN_WIDTH, MIN_HEIGHT]); + + const w_image = node.widgets?.find(w => w.name === "image"); + const w_prompt = node.widgets?.find(w => w.name === "prompt_preview"); + + const syncWidget = (widget, dataKey) => { + if (widget) { + widget.value = node.data[dataKey]; + widget.serializeValue = () => node.data[dataKey]; + } + }; + + if (node.widgets) { + node.widgets.forEach(w => { + if (w.name === "image" && w.value) node.data.selected_image = w.value; + if (w.name === "prompt_preview" && w.value) node.data.prompt = w.value; + + w.hidden = true; + w.type = "hidden"; + w.computeSize = () => [0, 0]; + w.computedHeight = 0; + w.draw = function () {}; + + if (w.element && w.element.parentNode) { + w.element.parentNode.removeChild(w.element); + } + }); + } + + syncWidget(w_image, "selected_image"); + syncWidget(w_prompt, "prompt"); + + node.syncData = function () { + if (node.graph) { + node.graph.setDirtyCanvas(true, true); + node.graph.changeTracker?.dispatchEvent(new Event("change")); + } + }; + + node.imageLoaded = false; + node.imageLoading = false; + + let promptRequestId = 0; + + const mainContainer = document.createElement("div"); + mainContainer.style.cssText = ` + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 !important; + margin: 0 !important; + box-sizing: border-box; + pointer-events: none; + position: relative; + `; + + const toast = document.createElement("div"); + toast.style.cssText = "position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); padding:6px 14px; border-radius:6px; color:#fff; font:bold 14px Arial; z-index:10; pointer-events:none; display:none; white-space:nowrap;"; + mainContainer.appendChild(toast); + + let toastTimer = null; + node.showToast = function (text, type) { + let color, borderColor, borderWidth; + if (type === "success") { + color = "rgba(34, 197, 94, 0.97)"; + borderColor = "rgba(255, 255, 255, 0.95)"; + borderWidth = 2; + } else if (type === "warning") { + color = "rgba(249, 115, 22, 0.97)"; + borderColor = "rgba(220, 38, 38, 1)"; + borderWidth = 3; + } else { + color = "rgba(239, 68, 68, 0.97)"; + borderColor = "rgba(255, 255, 255, 0.95)"; + borderWidth = 2; + } + toast.textContent = text; + toast.style.background = color; + toast.style.border = `${borderWidth}px solid ${borderColor}`; + toast.style.display = "block"; + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + toast.style.display = "none"; + }, 2000); + }; + + const onDocumentDragOver = (e) => { + if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { + e.preventDefault(); + } + }; + const onDocumentDrop = (e) => { + if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { + e.preventDefault(); + } + }; + document.addEventListener('dragover', onDocumentDragOver); + document.addEventListener('drop', onDocumentDrop); + node._docDragOver = onDocumentDragOver; + node._docDrop = onDocumentDrop; + + const textContainer = document.createElement("div"); + textContainer.style.cssText = "width:100%; pointer-events:auto;"; + + const customTextArea = document.createElement("textarea"); + customTextArea.readOnly = true; + customTextArea.value = node.data.prompt; + customTextArea.placeholder = "Prompt will appear here after loading an image..."; + customTextArea.style.cssText = "width:100%; min-height:50px; padding:6px; background:#1e1e1e; color:#eee; border:1px solid #444; border-radius:4px; resize:none; font-family:monospace; font-size:12px; box-sizing:border-box; pointer-events:auto; cursor:text;"; + textContainer.appendChild(customTextArea); + + const buttonsContainer = document.createElement("div"); + buttonsContainer.style.cssText = "display:flex; gap:6px; width:100%; pointer-events:auto;"; + + const btnUpload = document.createElement("button"); + btnUpload.textContent = "📂 UPLOAD IMAGE"; + btnUpload.style.cssText = "flex:3; height:28px; background:#2a2a2a; color:#4CAF50; border:2px solid #4CAF50; border-radius:4px; cursor:pointer; font-weight:normal; font-size:12px; transition:all 0.2s; pointer-events:auto;"; + btnUpload.onmouseover = () => { btnUpload.style.backgroundColor = "#3a3a3a"; }; + btnUpload.onmouseout = () => { btnUpload.style.backgroundColor = "#2a2a2a"; }; + btnUpload.onclick = () => node.triggerFileUpload(); + + const btnCopy = document.createElement("button"); + btnCopy.textContent = "📝 COPY"; + btnCopy.title = "Copy prompt to clipboard"; + btnCopy.style.cssText = "flex:1; height:28px; background:#2a2a2a; color:#64B5F6; border:2px solid #2196F3; border-radius:4px; cursor:pointer; font-weight:normal; font-size:12px; transition:all 0.2s; pointer-events:auto;"; + btnCopy.onmouseover = () => { if (customTextArea.value) btnCopy.style.backgroundColor = "#3a3a3a"; }; + btnCopy.onmouseout = () => { btnCopy.style.backgroundColor = "#2a2a2a"; }; + btnCopy.onclick = () => copyPrompt(); + + buttonsContainer.appendChild(btnUpload); + buttonsContainer.appendChild(btnCopy); + + const previewWrap = document.createElement("div"); + previewWrap.style.cssText = ` + width: 100%; + height: ${PREVIEW_FIXED_HEIGHT}px; + background: #353535; + border: 1px dashed #555; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; + pointer-events: auto; + cursor: pointer; + box-sizing: border-box; + `; + + const placeholder = document.createElement("div"); + placeholder.textContent = "📁 Drop Image Here or Click to Upload"; + placeholder.style.cssText = "color:#888; font-size:14px; font-family:Arial,sans-serif; text-align:center; pointer-events:none; user-select:none;"; + previewWrap.appendChild(placeholder); + + const previewImg = document.createElement("img"); + previewImg.style.cssText = "display:none; width:100%; height:100%; object-fit:contain; pointer-events:none; user-select:none;"; + previewImg.alt = "Image preview"; + previewWrap.appendChild(previewImg); + + mainContainer.appendChild(textContainer); + mainContainer.appendChild(buttonsContainer); + mainContainer.appendChild(previewWrap); + + const copyPrompt = async () => { + const text = customTextArea.value; + if (!text) return; + + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + node.showToast("Copy done!", "success"); + return; + } catch (e) { } + } + + try { + customTextArea.focus(); + customTextArea.select(); + const ok = document.execCommand("copy"); + customTextArea.setSelectionRange(0, 0); + customTextArea.blur(); + if (ok) node.showToast("Copy done!", "success"); + else node.showToast("Copy error!", "error"); + } catch (e) { + node.showToast("Copy error!", "error"); + } + }; + + const updateCopyBtnState = () => { + if (!btnCopy) return; + const has = !!customTextArea.value; + btnCopy.style.opacity = has ? "1" : "0.4"; + btnCopy.style.cursor = has ? "pointer" : "not-allowed"; + }; + + const fetchPrompt = async (imagePath, notifyNotFound) => { + if (!imagePath) return; + + const requestId = ++promptRequestId; + + node.data.prompt = ""; + customTextArea.value = ""; + customTextArea.placeholder = "⏳ Loading prompt..."; + syncWidget(w_prompt, "prompt"); + updateCopyBtnState(); + node.syncData(); + + try { + const response = await fetch(`/rayko/get_prompt?filename=${encodeURIComponent(imagePath)}`); + + if (requestId !== promptRequestId) return; + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + + if (requestId !== promptRequestId) return; + + node.data.prompt = result.prompt || ""; + customTextArea.value = node.data.prompt; + customTextArea.placeholder = node.data.prompt + ? "" + : "⚠ Prompt not found in this image"; + syncWidget(w_prompt, "prompt"); + updateCopyBtnState(); + node.syncData(); + + if (!node.data.prompt && notifyNotFound) { + node.showToast("⚠ Prompt not found", "warning"); + } + } catch (err) { + if (requestId !== promptRequestId) return; + customTextArea.placeholder = "⚠ Prompt not found in this image"; + } + }; + + const buildViewUrl = (imagePath) => { + let filename = imagePath; + let subfolder = ""; + if (imagePath.includes("/")) { + const parts = imagePath.split("/"); + subfolder = parts[0]; + filename = parts.slice(1).join("/"); + } + let url = `/view?filename=${encodeURIComponent(filename)}&type=input`; + if (subfolder) url += `&subfolder=${encodeURIComponent(subfolder)}`; + return url + `&t=${Date.now()}`; + }; + + node.loadImage = function (imagePath, notifyNotFound) { + if (!imagePath || node.imageLoading) return; + + node.imageLoading = true; + node.data.selected_image = imagePath; + syncWidget(w_image, "selected_image"); + + previewImg.onload = () => { + node.imageLoading = false; + node.imageLoaded = true; + previewImg.style.display = "block"; + placeholder.style.display = "none"; + node.syncData(); + fetchPrompt(imagePath, notifyNotFound); + }; + previewImg.onerror = () => { + node.imageLoading = false; + node.imageLoaded = false; + previewImg.style.display = "none"; + placeholder.style.display = "block"; + node.syncData(); + }; + previewImg.src = buildViewUrl(imagePath); + }; + + const uploadFileAndLoad = async (file) => { + if (!file) return false; + + const SUPPORTED_EXT = /\.(png|jpe?g|webp)$/i; + const SUPPORTED_MIME = ['image/png', 'image/jpeg', 'image/webp']; + const isSupported = SUPPORTED_EXT.test(file.name) || SUPPORTED_MIME.includes(file.type); + + if (!isSupported) { + node.showToast("⚠ Only PNG/JPG/WebP supported", "warning"); + return false; + } + + const formData = new FormData(); + formData.append('image', file); + formData.append('subfolder', ''); + formData.append('type', 'input'); + + try { + const response = await fetch('/upload/image', { method: 'POST', body: formData }); + if (response.ok) { + const result = await response.json(); + const imageName = result.name || result.filename; + const subfolder = result.subfolder || ''; + const finalName = subfolder ? `${subfolder}/${imageName}` : imageName; + node.loadImage(finalName, true); + return true; + } + } catch (err) { + console.error("[RS Image-Prompt] Upload error:", err); + } + return false; + }; + + node.triggerFileUpload = function () { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'image/png,image/jpeg,image/webp'; + fileInput.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + await uploadFileAndLoad(file); + fileInput.remove(); + }; + fileInput.click(); + }; + + previewWrap.addEventListener('dragover', (e) => { + if (!e.dataTransfer || !e.dataTransfer.types.includes('Files')) return; + e.preventDefault(); + e.stopPropagation(); + previewWrap.style.borderColor = '#4CAF50'; + previewWrap.style.backgroundColor = '#2a2a2a'; + }); + + previewWrap.addEventListener('dragleave', (e) => { + e.preventDefault(); + e.stopPropagation(); + previewWrap.style.borderColor = '#555'; + previewWrap.style.backgroundColor = '#353535'; + }); + + previewWrap.addEventListener('drop', (e) => { + e.preventDefault(); + e.stopPropagation(); + previewWrap.style.borderColor = '#555'; + previewWrap.style.backgroundColor = '#353535'; + + const file = e.dataTransfer.files[0]; + if (file) { + uploadFileAndLoad(file); + } + }); + + previewWrap.addEventListener('click', () => { + node.triggerFileUpload(); + }); + + node.addDOMWidget("custom_widgets", "customtext", mainContainer); + node.customTextArea = customTextArea; + + const updateUIFromData = () => { + customTextArea.value = node.data.prompt || ""; + updateCopyBtnState(); + }; + + node.onSerialize = function (o) { + syncWidget(w_image, "selected_image"); + syncWidget(w_prompt, "prompt"); + o.data = node.data; + }; + + node.onConfigure = function (o) { + if (o && o.data) { + node.data = { ...node.data, ...o.data }; + } + + node.imageLoading = false; + node.imageLoaded = false; + + if (node.data.selected_image) { + node.loadImage(node.data.selected_image); + } + + updateUIFromData(); + + syncWidget(w_image, "selected_image"); + syncWidget(w_prompt, "prompt"); + + requestAnimationFrame(() => { + if (node.size) { + node.onResize([...node.size]); + } + }); + }; + + node.visibilityHandler = function () { + if (!document.hidden && node.graph) { + node.graph.setDirtyCanvas(true, true); + node.syncData(); + + if (node.size) { + node.onResize([...node.size]); + } + } + }; + document.addEventListener("visibilitychange", node.visibilityHandler); + + const originalOnResize = node.onResize; + node.onResize = function (size) { + if (size[0] < MIN_WIDTH) size[0] = MIN_WIDTH; + if (size[1] < MIN_HEIGHT) size[1] = MIN_HEIGHT; + + const titleBarHeight = 30; + const topPadding = 18; + const gap = 8; + const bottomPadding = 20; + const previewSpacing = 20; + + const fixedElements = titleBarHeight + topPadding + + 28 + gap + + previewSpacing + + PREVIEW_FIXED_HEIGHT + + bottomPadding; + + const textareaHeight = Math.max(50, size[1] - fixedElements); + customTextArea.style.height = textareaHeight + 'px'; + + if (originalOnResize) originalOnResize.apply(this, arguments); + node.setDirtyCanvas(true, true); + }; + + const originalOnRemoved = node.onRemoved; + node.onRemoved = function () { + document.removeEventListener("visibilitychange", node.visibilityHandler); + if (node._docDragOver) { + document.removeEventListener('dragover', node._docDragOver); + } + if (node._docDrop) { + document.removeEventListener('drop', node._docDrop); + } + if (originalOnRemoved) originalOnRemoved.apply(this, arguments); + }; + + if (node.data.selected_image) { + node.loadImage(node.data.selected_image); + } + + updateCopyBtnState(); + + requestAnimationFrame(() => { + node.onResize([...node.size]); + }); + + return result; + }; + }, + setup() {} +}); \ No newline at end of file