From 4df9ae993c41ff2022da8c5c052c242ffd02adeb Mon Sep 17 00:00:00 2001 From: Fillip Date: Fri, 7 Aug 2026 19:40:16 -0700 Subject: [PATCH] Improve FL Load Image controls --- nodes/image/FL_LoadImage.py | 351 +++++-- tests/test_load_image.py | 290 ++++++ web/nodes/image/FL_LoadImage.js | 1672 +++++++++++++++++++++---------- 3 files changed, 1693 insertions(+), 620 deletions(-) create mode 100644 tests/test_load_image.py diff --git a/nodes/image/FL_LoadImage.py b/nodes/image/FL_LoadImage.py index 90b3a10..5125d61 100644 --- a/nodes/image/FL_LoadImage.py +++ b/nodes/image/FL_LoadImage.py @@ -1,123 +1,278 @@ -import os import json -from PIL import Image -import torch +import os +from pathlib import Path + import numpy as np -from server import PromptServer -from aiohttp import web -import io +from PIL import Image, ImageOps +import torch + +import folder_paths +from comfy.utils import common_upscale + + +IMAGE_EXTENSIONS = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"} +DEFAULT_LOAD_SETTINGS = { + "version": 1, + "resize_mode": "original", + "width": 0, + "height": 0, +} +DEFAULT_SETTINGS_JSON = json.dumps(DEFAULT_LOAD_SETTINGS, separators=(",", ":")) + + +def available_image_files(): + input_dir = Path(folder_paths.get_input_directory()).resolve() + os.makedirs(input_dir, exist_ok=True) + files, _ = folder_paths.recursive_search(str(input_dir)) + result = [] + for filename in files: + if Path(filename).suffix.lower() not in IMAGE_EXTENSIONS: + continue + path = (input_dir / filename).resolve() + try: + path.relative_to(input_dir) + except ValueError: + continue + if path.is_file(): + result.append(Path(filename).as_posix()) + return sorted(result, key=str.casefold) + + +def _resolve_input_image(filename): + if not isinstance(filename, str) or not filename.strip(): + raise ValueError("Choose an image file.") + + filename = filename.strip() + name, annotated_dir = folder_paths.annotated_filepath(filename) + input_dir = Path(folder_paths.get_input_directory()).resolve() + if annotated_dir is not None and Path(annotated_dir).resolve() != input_dir: + raise ValueError("FL Load Image files must be inside the ComfyUI input directory.") + + try: + path = Path(folder_paths.get_annotated_filepath(name, str(input_dir))).resolve() + except ValueError as e: + raise ValueError("FL Load Image files must be inside the ComfyUI input directory.") from e + try: + path.relative_to(input_dir) + except ValueError as e: + raise ValueError("FL Load Image files must be inside the ComfyUI input directory.") from e + if not path.is_file(): + raise ValueError(f"Image file does not exist: {filename}") + if path.suffix.lower() not in IMAGE_EXTENSIONS: + raise ValueError(f"Unsupported image format: {path.suffix or filename}") + return path + + +def _resolve_legacy_image(root_directory, selected_file): + if not isinstance(root_directory, str) or not root_directory.strip(): + raise ValueError("FL Load Image legacy root directory is empty.") + if not isinstance(selected_file, str) or not selected_file.strip(): + raise ValueError("Choose an image file.") + + root = Path(root_directory).expanduser().resolve() + path = Path(selected_file).expanduser() + if not path.is_absolute(): + path = root / path + path = path.resolve() + if not root.is_dir(): + raise ValueError("FL Load Image legacy root directory does not exist.") + try: + path.relative_to(root) + except ValueError as e: + raise ValueError("FL Load Image legacy file must be inside its root directory.") from e + if not path.is_file(): + raise ValueError(f"Image file does not exist: {selected_file}") + if path.suffix.lower() not in IMAGE_EXTENSIONS: + raise ValueError(f"Unsupported image format: {path.suffix or selected_file}") + return path + + +def resolve_image_path(root_directory, selected_file="", image=""): + if image: + return _resolve_input_image(image) + return _resolve_legacy_image(root_directory, selected_file) + + +def _parse_settings(load_settings, width_override=None, height_override=None): + try: + configured = json.loads(load_settings) + except (TypeError, json.JSONDecodeError) as e: + raise ValueError("FL Load Image settings are not valid JSON.") from e + if not isinstance(configured, dict): + raise ValueError("FL Load Image settings must be a JSON object.") + + settings = DEFAULT_LOAD_SETTINGS.copy() + settings.update(configured) + version = settings["version"] + if not isinstance(version, int) or isinstance(version, bool) or version != 1: + raise ValueError(f"FL Load Image settings version {version} is unsupported.") + + for name in ("width", "height"): + value = settings[name] + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError(f"FL Load Image {name} must be an integer.") + if not 0 <= value <= 16384: + raise ValueError("FL Load Image width and height must be between 0 and 16384.") + + resize_mode = settings["resize_mode"] + if resize_mode not in ("original", "fit", "crop"): + raise ValueError("FL Load Image resize_mode must be original, fit, or crop.") + + for name, value in (("width", width_override), ("height", height_override)): + if value is None: + continue + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError(f"FL Load Image {name} override must be an integer.") + if not 0 <= value <= 16384: + raise ValueError("FL Load Image width and height overrides must be between 0 and 16384.") + settings[name] = value + + if resize_mode == "fit" and settings["width"] == 0 and settings["height"] == 0: + raise ValueError("FL Load Image fit resize requires a width or height.") + if resize_mode == "crop" and (settings["width"] == 0 or settings["height"] == 0): + raise ValueError("FL Load Image crop resize requires both width and height.") + return settings + + +def _target_dimensions(source_width, source_height, settings): + if settings["resize_mode"] == "original": + return source_width, source_height + if settings["resize_mode"] == "crop": + return settings["width"], settings["height"] + + width = settings["width"] + height = settings["height"] + if width == 0: + scale = height / source_height + elif height == 0: + scale = width / source_width + else: + scale = min(width / source_width, height / source_height) + return max(1, round(source_width * scale)), max(1, round(source_height * scale)) + + +def _load_image(path): + with Image.open(path) as source: + source = ImageOps.exif_transpose(source) + source_width, source_height = source.size + has_alpha = "A" in source.getbands() or "transparency" in source.info + if has_alpha: + rgba = source.convert("RGBA") + image = Image.new("RGB", rgba.size, (255, 255, 255)) + image.paste(rgba, mask=rgba.getchannel("A")) + else: + image = source.convert("RGB") + image_tensor = torch.from_numpy(np.array(image, dtype=np.float32) / 255.0).unsqueeze(0) + return image_tensor, source_width, source_height, has_alpha + + +def _resize_image(image, settings): + source_height = int(image.shape[1]) + source_width = int(image.shape[2]) + width, height = _target_dimensions(source_width, source_height, settings) + if (width, height) == (source_width, source_height): + return image + + crop = "center" if settings["resize_mode"] == "crop" else "disabled" + image = common_upscale(image.movedim(-1, 1), width, height, "lanczos", crop) + return image.movedim(1, -1) + + +def _preview_reference(path): + input_dir = Path(folder_paths.get_input_directory()).resolve() + try: + relative = path.relative_to(input_dir) + except ValueError: + return None + return relative.name, relative.parent.as_posix() if relative.parent != Path(".") else "" class FL_LoadImage: @classmethod def INPUT_TYPES(cls): + files = available_image_files() return { "required": { "root_directory": ("STRING", {"default": "./"}), }, "optional": { "selected_file": ("STRING", {"default": ""}), - } + "image": ([""] + files,), + "load_settings": ("STRING", {"default": DEFAULT_SETTINGS_JSON, "multiline": False}), + "width_override": ("INT", {"min": 0, "max": 16384, "forceInput": True}), + "height_override": ("INT", {"min": 0, "max": 16384, "forceInput": True}), + }, } RETURN_TYPES = ("IMAGE", "STRING") + RETURN_NAMES = ("image", "file_path") FUNCTION = "browse_files" - OUTPUT_NODE = True CATEGORY = "🏵️Fill Nodes/Image" + DESCRIPTION = "Loads, previews, and resizes an image from ComfyUI input." - def browse_files(self, root_directory, selected_file=""): - if selected_file and os.path.isfile(selected_file): - image = Image.open(selected_file) - # Convert RGBA to RGB if needed (for WebP with transparency) - if image.mode == 'RGBA': - # Create white background and composite - background = Image.new('RGB', image.size, (255, 255, 255)) - background.paste(image, mask=image.split()[-1]) # Use alpha channel as mask - image = background - elif image.mode != 'RGB': - # Convert other modes (P, L, etc.) to RGB - image = image.convert('RGB') - - image_tensor = torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0) - return (image_tensor, selected_file) + def browse_files( + self, + root_directory, + selected_file="", + image="", + load_settings=DEFAULT_SETTINGS_JSON, + width_override=None, + height_override=None, + ): + settings = _parse_settings(load_settings, width_override, height_override) + path = resolve_image_path(root_directory, selected_file, image) + loaded, source_width, source_height, has_alpha = _load_image(path) + loaded = _resize_image(loaded, settings) + + preview = { + "filename": path.name, + "source_width": source_width, + "source_height": source_height, + "loaded_width": int(loaded.shape[2]), + "loaded_height": int(loaded.shape[1]), + "resize_mode": settings["resize_mode"], + "requested_width": settings["width"], + "requested_height": settings["height"], + "source_has_alpha": has_alpha, + } + reference = _preview_reference(path) + if reference is not None: + preview.update({"filename": reference[0], "subfolder": reference[1], "type": "input"}) else: - return (torch.zeros(1, 1, 1, 3), "") + preview["type"] = "legacy" + + return { + "ui": {"fl_load_image": [preview]}, + "result": (loaded, str(path)), + } @classmethod - def IS_CHANGED(cls, root_directory, selected_file=""): - return selected_file + def IS_CHANGED( + cls, + root_directory, + selected_file="", + image="", + load_settings=DEFAULT_SETTINGS_JSON, + width_override=None, + height_override=None, + ): + path = resolve_image_path(root_directory, selected_file, image) + stat = path.stat() + return f"{stat.st_mtime_ns}:{stat.st_size}" @classmethod - def VALIDATE_INPUTS(cls, root_directory, selected_file=""): - if not os.path.isdir(root_directory): - return "Root directory does not exist" - if selected_file and not os.path.isfile(selected_file): - return "Selected file does not exist" + def VALIDATE_INPUTS( + cls, + root_directory, + selected_file="", + image="", + load_settings=DEFAULT_SETTINGS_JSON, + width_override=None, + height_override=None, + ): + try: + resolve_image_path(root_directory, selected_file, image) + _parse_settings(load_settings, width_override, height_override) + except ValueError as e: + return str(e) return True - - -def get_directory_structure(path): - structure = {"name": os.path.basename(path), "children": [], "path": path, "expanded": False} - try: - with os.scandir(path) as entries: - for entry in entries: - if entry.is_dir(): - structure["children"].append(get_directory_structure(entry.path)) - except PermissionError: - pass - return structure - - -def get_file_list(path): - return [f for f in os.listdir(path) if - os.path.isfile(os.path.join(path, f)) and f.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'))] - - -@PromptServer.instance.routes.post("/fl_file_browser/get_directory_structure") -async def api_get_directory_structure(request): - data = await request.json() - path = data.get("path", "./") - - if not os.path.isabs(path): - path = os.path.abspath(path) - - if not os.path.exists(path): - return web.json_response({"error": "Directory does not exist"}, status=400) - - structure = get_directory_structure(path) - files = get_file_list(path) - return web.json_response({"structure": structure, "files": files}) - - -@PromptServer.instance.routes.post("/fl_file_browser/get_thumbnail") -async def api_get_thumbnail(request): - data = await request.json() - path = data.get("path", "./") - file = data.get("file", "") - - if not os.path.isabs(path): - path = os.path.abspath(path) - - full_path = os.path.join(path, file) - if not os.path.exists(full_path): - return web.json_response({"error": "File does not exist"}, status=400) - - try: - with Image.open(full_path) as img: - # Convert RGBA to RGB if needed (for WebP with transparency) - if img.mode == 'RGBA': - # Create white background and composite - background = Image.new('RGB', img.size, (255, 255, 255)) - background.paste(img, mask=img.split()[-1]) # Use alpha channel as mask - img = background - elif img.mode != 'RGB': - # Convert other modes (P, L, etc.) to RGB - img = img.convert('RGB') - - img.thumbnail((80, 80)) - buf = io.BytesIO() - img.save(buf, format='PNG') - buf.seek(0) - return web.Response(body=buf.read(), content_type='image/png') - except Exception as e: - return web.json_response({"error": str(e)}, status=500) \ No newline at end of file diff --git a/tests/test_load_image.py b/tests/test_load_image.py new file mode 100644 index 0000000..9c0a06f --- /dev/null +++ b/tests/test_load_image.py @@ -0,0 +1,290 @@ +import importlib.util +import json +import pathlib +import tempfile +import unittest +from unittest import mock + +import numpy as np +from PIL import Image +import torch + + +MODULE_PATH = pathlib.Path(__file__).parents[1] / "nodes" / "image" / "FL_LoadImage.py" +SPEC = importlib.util.spec_from_file_location("fl_load_image_tests", MODULE_PATH) +load_image = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(load_image) + + +def settings(**updates): + configured = load_image.DEFAULT_LOAD_SETTINGS.copy() + configured.update(updates) + return configured + + +class LoadImageSettingsTests(unittest.TestCase): + def test_defaults_and_missing_fields_parse(self): + self.assertEqual(load_image._parse_settings(load_image.DEFAULT_SETTINGS_JSON), load_image.DEFAULT_LOAD_SETTINGS) + self.assertEqual( + load_image._parse_settings('{"version":1,"resize_mode":"crop","width":64,"height":32}'), + settings(resize_mode="crop", width=64, height=32), + ) + + def test_invalid_json_version_and_values_fail(self): + for value in ("{", "[]", "null"): + with self.subTest(value=value), self.assertRaises(ValueError): + load_image._parse_settings(value) + with self.assertRaisesRegex(ValueError, "version 2 is unsupported"): + load_image._parse_settings('{"version":2}') + + cases = { + "resize_mode": "stretch", + "width": -1, + "height": 20000, + } + for name, value in cases.items(): + with self.subTest(name=name), self.assertRaises(ValueError): + load_image._parse_settings(json.dumps(settings(**{name: value}))) + + def test_resize_requirements_are_validated(self): + with self.assertRaisesRegex(ValueError, "requires a width or height"): + load_image._parse_settings(json.dumps(settings(resize_mode="fit"))) + with self.assertRaisesRegex(ValueError, "requires both width and height"): + load_image._parse_settings(json.dumps(settings(resize_mode="crop", width=512))) + + def test_connected_dimensions_override_gui_values(self): + configured = json.dumps(settings(resize_mode="fit", width=640, height=480)) + + effective = load_image._parse_settings(configured, width_override=320, height_override=0) + + self.assertEqual((effective["width"], effective["height"]), (320, 0)) + with self.assertRaisesRegex(ValueError, "requires both width and height"): + load_image._parse_settings( + json.dumps(settings(resize_mode="crop", width=640, height=480)), + width_override=0, + ) + + def test_override_inputs_are_optional_connection_sockets(self): + with tempfile.TemporaryDirectory() as input_directory: + with mock.patch.object(load_image.folder_paths, "get_input_directory", return_value=input_directory): + optional = load_image.FL_LoadImage.INPUT_TYPES()["optional"] + + for name in ("width_override", "height_override"): + with self.subTest(name=name): + self.assertEqual(optional[name][0], "INT") + self.assertTrue(optional[name][1]["forceInput"]) + + +class LoadImagePathTests(unittest.TestCase): + def test_input_image_must_stay_inside_input_directory(self): + with tempfile.TemporaryDirectory() as input_directory, tempfile.TemporaryDirectory() as outside: + root = pathlib.Path(input_directory) + nested = root / "nested" / "image.png" + nested.parent.mkdir() + nested.touch() + outside_image = pathlib.Path(outside) / "image.png" + outside_image.touch() + + with mock.patch.object(load_image.folder_paths, "get_input_directory", return_value=input_directory): + self.assertEqual(load_image._resolve_input_image("nested/image.png"), nested.resolve()) + with self.assertRaisesRegex(ValueError, "inside the ComfyUI input"): + load_image._resolve_input_image(str(outside_image)) + + def test_legacy_image_must_stay_inside_its_root(self): + with tempfile.TemporaryDirectory() as root_directory, tempfile.TemporaryDirectory() as outside: + root = pathlib.Path(root_directory) + nested = root / "nested" / "image.png" + nested.parent.mkdir() + nested.touch() + outside_image = pathlib.Path(outside) / "image.png" + outside_image.touch() + + self.assertEqual(load_image._resolve_legacy_image(root_directory, "nested/image.png"), nested.resolve()) + with self.assertRaisesRegex(ValueError, "inside its root"): + load_image._resolve_legacy_image(root_directory, str(outside_image)) + + def test_available_files_are_recursive_supported_and_sorted(self): + with tempfile.TemporaryDirectory() as input_directory: + root = pathlib.Path(input_directory) + (root / "nested").mkdir() + (root / "nested" / "B.WEBP").touch() + (root / "a.png").touch() + (root / "notes.txt").touch() + + with mock.patch.object(load_image.folder_paths, "get_input_directory", return_value=input_directory): + files = load_image.available_image_files() + + self.assertEqual(files, ["a.png", "nested/B.WEBP"]) + + def test_no_source_has_an_actionable_error(self): + with self.assertRaisesRegex(ValueError, "Choose an image"): + load_image.resolve_image_path(".") + + +class LoadImageProcessingTests(unittest.TestCase): + def test_fit_and_crop_dimensions(self): + fit = load_image._target_dimensions(1920, 1080, settings(resize_mode="fit", width=512, height=512)) + crop = load_image._target_dimensions(1920, 1080, settings(resize_mode="crop", width=512, height=512)) + + self.assertEqual(fit, (512, 288)) + self.assertEqual(crop, (512, 512)) + + def test_resize_preserves_or_changes_shape(self): + image = torch.rand((1, 6, 10, 3)) + + self.assertIs(load_image._resize_image(image, settings()), image) + fit = load_image._resize_image(image, settings(resize_mode="fit", width=5, height=5)) + crop = load_image._resize_image(image, settings(resize_mode="crop", width=4, height=4)) + + self.assertEqual(fit.shape, (1, 3, 5, 3)) + self.assertEqual(crop.shape, (1, 4, 4, 3)) + + def test_alpha_is_composited_over_white(self): + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "alpha.png" + source = Image.new("RGBA", (2, 1)) + source.putdata([(255, 0, 0, 0), (255, 0, 0, 255)]) + source.save(path) + + loaded, width, height, has_alpha = load_image._load_image(path) + + self.assertEqual((width, height), (2, 1)) + self.assertTrue(has_alpha) + np.testing.assert_allclose(loaded[0, 0, 0].numpy(), [1, 1, 1]) + np.testing.assert_allclose(loaded[0, 0, 1].numpy(), [1, 0, 0]) + + def test_grayscale_is_converted_to_rgb_and_exif_is_applied(self): + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "gray.png" + Image.new("L", (3, 2), 64).save(path) + + with mock.patch.object(load_image.ImageOps, "exif_transpose", wraps=load_image.ImageOps.exif_transpose) as transpose: + loaded, width, height, has_alpha = load_image._load_image(path) + + transpose.assert_called_once() + self.assertEqual((width, height), (3, 2)) + self.assertFalse(has_alpha) + self.assertEqual(loaded.shape, (1, 2, 3, 3)) + torch.testing.assert_close(loaded[..., 0], loaded[..., 1]) + torch.testing.assert_close(loaded[..., 1], loaded[..., 2]) + + +class LoadImageExecutionTests(unittest.TestCase): + def test_execution_returns_path_and_preview_metadata(self): + with tempfile.TemporaryDirectory() as input_directory: + path = pathlib.Path(input_directory) / "nested" / "image.png" + path.parent.mkdir() + Image.new("RGBA", (8, 4), (0, 0, 0, 128)).save(path) + configured = settings(resize_mode="crop", width=3, height=2) + + with mock.patch.object(load_image.folder_paths, "get_input_directory", return_value=input_directory): + result = load_image.FL_LoadImage().browse_files( + ".", + image="nested/image.png", + load_settings=json.dumps(configured), + ) + + loaded, returned_path = result["result"] + self.assertEqual(loaded.shape, (1, 2, 3, 3)) + self.assertEqual(returned_path, str(path.resolve())) + preview = result["ui"]["fl_load_image"][0] + self.assertEqual(preview["filename"], "image.png") + self.assertEqual(preview["subfolder"], "nested") + self.assertEqual(preview["type"], "input") + self.assertEqual((preview["source_width"], preview["source_height"]), (8, 4)) + self.assertEqual((preview["loaded_width"], preview["loaded_height"]), (3, 2)) + self.assertEqual(preview["resize_mode"], "crop") + self.assertEqual((preview["requested_width"], preview["requested_height"]), (3, 2)) + self.assertTrue(preview["source_has_alpha"]) + + def test_execution_uses_connected_dimensions_over_gui_dimensions(self): + with tempfile.TemporaryDirectory() as input_directory: + path = pathlib.Path(input_directory) / "image.png" + Image.new("RGB", (8, 4), "blue").save(path) + configured = settings(resize_mode="crop", width=7, height=6) + + with mock.patch.object(load_image.folder_paths, "get_input_directory", return_value=input_directory): + result = load_image.FL_LoadImage().browse_files( + ".", + image="image.png", + load_settings=json.dumps(configured), + width_override=3, + height_override=2, + ) + + self.assertEqual(result["result"][0].shape, (1, 2, 3, 3)) + preview = result["ui"]["fl_load_image"][0] + self.assertEqual((preview["loaded_width"], preview["loaded_height"]), (3, 2)) + self.assertEqual((preview["requested_width"], preview["requested_height"]), (3, 2)) + + def test_legacy_execution_remains_supported(self): + with tempfile.TemporaryDirectory() as root_directory, tempfile.TemporaryDirectory() as input_directory: + path = pathlib.Path(root_directory) / "image.jpg" + Image.new("RGB", (2, 2), "blue").save(path) + + with mock.patch.object(load_image.folder_paths, "get_input_directory", return_value=input_directory): + result = load_image.FL_LoadImage().browse_files(root_directory, selected_file=str(path)) + + self.assertEqual(result["result"][1], str(path.resolve())) + self.assertEqual(result["ui"]["fl_load_image"][0]["type"], "legacy") + + def test_change_fingerprint_uses_file_metadata(self): + with tempfile.TemporaryDirectory() as input_directory: + path = pathlib.Path(input_directory) / "image.png" + path.write_bytes(b"image") + stat = path.stat() + + with mock.patch.object(load_image.folder_paths, "get_input_directory", return_value=input_directory): + fingerprint = load_image.FL_LoadImage.IS_CHANGED(".", image="image.png") + + self.assertEqual(fingerprint, f"{stat.st_mtime_ns}:{stat.st_size}") + + +class LoadImageFrontendTests(unittest.TestCase): + def test_frontend_contains_source_preview_resize_and_lifecycle_contract(self): + script = (pathlib.Path(__file__).parents[1] / "web" / "nodes" / "image" / "FL_LoadImage.js").read_text(encoding="utf-8") + + for name in load_image.DEFAULT_LOAD_SETTINGS: + with self.subTest(setting=name): + self.assertIn(f"{name}:", script) + for behavior in ( + 'data-role="drop-zone"', + 'data-role="image"', + 'data-role="settings-menu"', + 'data-action="replace"', + 'data-setting="resize_mode"', + 'data-setting="width"', + 'data-setting="height"', + 'body.append("image", file)', + 'api.fetchApi("/upload/image"', + 'api.apiURL(`/view?', + "migrateLegacySource()", + 'input.name === `${name}_override`', + "handleConnectionsChanged()", + "connectedOverrideValue(name)", + "requested_${name}", + "applyPreviewGeometry()", + 'data-role="image-stage"', + 'data-resize-mode="crop"', + 'container.className = "flli-container"', + "object-position: center center", + "grid-template-columns: minmax(0, .55fr)", + 'message?.fl_load_image?.[0]', + "URL.revokeObjectURL", + "MIN_NODE_WIDTH = 240", + "MIN_NODE_HEIGHT = 230", + ): + with self.subTest(behavior=behavior): + self.assertIn(behavior, script) + + def test_backend_no_longer_registers_arbitrary_file_browser_routes(self): + script = MODULE_PATH.read_text(encoding="utf-8") + + self.assertNotIn("PromptServer", script) + self.assertNotIn("/fl_file_browser/", script) + self.assertNotIn("get_directory_structure", script) + self.assertNotIn("get_thumbnail", script) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/nodes/image/FL_LoadImage.js b/web/nodes/image/FL_LoadImage.js index b8997e2..4abef96 100644 --- a/web/nodes/image/FL_LoadImage.js +++ b/web/nodes/image/FL_LoadImage.js @@ -1,542 +1,1170 @@ import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; -app.registerExtension({ - name: "Comfy.FL_LoadImage", - async nodeCreated(node) { - if (node.comfyClass === "FL_LoadImage") { - addFileBrowserUI(node); - } +const DEFAULT_SETTINGS = { + version: 1, + resize_mode: "original", + width: 0, + height: 0, +}; + +const MIN_NODE_WIDTH = 240; +const MIN_NODE_HEIGHT = 230; +const MIN_PANEL_HEIGHT = 160; +const IMAGE_EXTENSIONS = new Set(["bmp", "gif", "jpeg", "jpg", "png", "webp"]); + +const STYLES = ` + .flli-container { + container-type: size; + height: 100%; + min-height: 0; + min-width: 0; + overflow: hidden; + width: 100%; + } + .flli-panel { + --flli-accent: #8b5cf6; + --flli-border: var(--border-color, #343741); + --flli-control: var(--comfy-input-bg, #24262d); + --flli-muted: var(--descrip-text, #979cab); + background: var(--comfy-menu-bg, #17181d); + border: 1px solid var(--flli-border); + border-radius: 9px; + box-sizing: border-box; + color: var(--input-text, #f4f4f5); + display: grid; + font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: clamp(7px, 2.6cqw, 11px); + gap: clamp(3px, 1.4cqw, 6px); + grid-template-rows: clamp(21px, 9cqh, 29px) minmax(0, 1fr) clamp(36px, 16cqh, 48px); + height: 100%; + min-height: 0; + overflow: hidden; + padding: clamp(3px, 1.4cqw, 6px); + position: relative; + width: 100%; + } + .flli-panel * { box-sizing: border-box; } + .flli-source-bar { + align-items: center; + display: flex; + gap: 5px; + min-width: 0; + overflow: hidden; + padding: 0 1px; + } + .flli-filename { + color: #d8dbe2; + flex: 1; + font-size: clamp(7px, 2.5cqw, 10.5px); + font-weight: 650; + min-width: 0; + overflow: hidden; + padding: 0 4px; + text-overflow: ellipsis; + white-space: nowrap; + } + .flli-button, + .flli-more-button, + .flli-menu-close, + .flli-reset-button { + background: var(--flli-control); + border: 1px solid var(--flli-border); + color: inherit; + cursor: pointer; + font: inherit; + } + .flli-button { + border-radius: 5px; + height: clamp(20px, 8cqh, 26px); + min-width: 0; + padding: 0 clamp(3px, 2cqw, 9px); + white-space: nowrap; + } + .flli-more-button { + border-radius: 5px; + font-size: 16px; + flex: 0 0 clamp(20px, 7cqw, 28px); + height: clamp(20px, 8cqh, 26px); + line-height: 18px; + padding: 0; + width: clamp(20px, 7cqw, 28px); + } + .flli-button:hover, + .flli-more-button:hover, + .flli-menu-close:hover, + .flli-reset-button:hover { + background: color-mix(in srgb, var(--flli-control) 82%, var(--flli-accent)); + border-color: var(--flli-accent); + } + .flli-button:focus-visible, + .flli-more-button:focus-visible, + .flli-menu-close:focus-visible, + .flli-reset-button:focus-visible, + .flli-drop-zone:focus-visible, + .flli-control-row select:focus-visible, + .flli-control-row input:focus-visible { + outline: 2px solid var(--flli-accent); + outline-offset: 1px; + } + .flli-preview { + background: #050507; + border: 1px solid var(--flli-border); + border-radius: 7px; + display: grid; + height: 100%; + min-height: 0; + min-width: 0; + overflow: hidden; + position: relative; + place-items: center; + width: 100%; + } + .flli-image-stage { + display: grid; + height: 100%; + margin: auto; + max-height: 100%; + max-width: 100%; + min-height: 0; + min-width: 0; + overflow: hidden; + place-items: center; + width: 100%; + } + .flli-image-stage img { + display: block; + height: 100%; + margin: auto; + max-height: 100%; + max-width: 100%; + min-height: 0; + min-width: 0; + object-fit: contain; + object-position: center center; + width: 100%; + } + .flli-image-stage[data-resize-mode="crop"] img { object-fit: cover; } + .flli-drop-zone { + align-items: center; + background: + radial-gradient(circle at 50% 44%, rgba(139, 92, 246, .13), transparent 45%), + #08090d; + border: 1px dashed #565b68; + color: #a3a8b4; + cursor: pointer; + display: flex; + flex-direction: column; + gap: clamp(2px, 1.5cqw, 7px); + inset: clamp(2px, 1.5cqw, 7px); + justify-content: center; + padding: clamp(4px, 4cqw, 18px); + position: absolute; + text-align: center; + z-index: 4; + } + .flli-drop-zone[hidden] { display: none; } + .flli-drop-icon { + color: #c4b5fd; + font-size: clamp(14px, 6cqw, 24px); + line-height: 1; + } + .flli-drop-title { + color: #e6e7eb; + font-size: clamp(8px, 2.8cqw, 12px); + font-weight: 700; + } + .flli-drop-help { + color: var(--flli-muted); + font-size: clamp(7px, 2.2cqw, 9px); + } + .flli-panel[data-dragging="true"] .flli-drop-zone { + background: rgba(76, 29, 149, .93); + border-color: #c4b5fd; + display: flex; + inset: 4px; + } + .flli-panel[data-dragging="true"] .flli-drop-title { color: white; } + .flli-preview-info, + .flli-status { + backdrop-filter: blur(5px); + background: rgba(20, 21, 26, .78); + border: 1px solid rgba(255, 255, 255, .07); + border-radius: 999px; + max-width: calc(75% - 8px); + overflow: hidden; + padding: clamp(1px, .7cqw, 3px) clamp(3px, 1.6cqw, 7px); + position: absolute; + text-overflow: ellipsis; + top: 7px; + white-space: nowrap; + z-index: 3; + } + .flli-preview-info { left: 7px; } + .flli-status { + max-width: 80px; + right: 7px; + } + .flli-status[data-state="ready"] { color: #86efac; } + .flli-status[data-state="busy"] { color: #fde68a; } + .flli-status[data-state="stale"] { color: #fdba74; } + .flli-status[data-state="error"] { color: #fda4af; } + .flli-error { + background: rgba(127, 29, 29, .96); + border: 1px solid #ef4444; + border-radius: 6px; + color: #fee2e2; + left: 8px; + padding: 7px 9px; + position: absolute; + right: 8px; + top: 40px; + z-index: 8; + } + .flli-error[hidden] { display: none; } + .flli-browser-error { + background: rgba(24, 24, 27, .92); + bottom: 8px; + color: #fda4af; + left: 8px; + padding: 5px 7px; + position: absolute; + right: 8px; + text-align: center; + z-index: 3; + } + .flli-browser-error[hidden] { display: none; } + .flli-control-row { + align-items: end; + background: rgba(255, 255, 255, .025); + border: 1px solid var(--flli-border); + border-radius: 6px; + display: grid; + gap: clamp(2px, 1.2cqw, 5px); + grid-template-columns: minmax(0, .55fr) minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); + min-width: 0; + overflow: hidden; + padding: clamp(2px, 1.2cqw, 5px) clamp(2px, 1.4cqw, 6px); + } + .flli-group-label { + align-self: center; + color: #c4b5fd; + font-size: clamp(6px, 1.9cqw, 8px); + min-width: 0; + overflow: hidden; + font-weight: 800; + letter-spacing: .08em; + text-transform: uppercase; + } + .flli-param { + display: grid; + gap: 2px; + min-width: 0; + } + .flli-param-label { + color: var(--flli-muted); + font-size: clamp(6px, 1.9cqw, 8px); + font-weight: 700; + } + .flli-param input, + .flli-param select, + .flli-menu-field select { + background: var(--flli-control); + border: 1px solid var(--flli-border); + border-radius: 4px; + color: inherit; + font: inherit; + appearance: textfield; + height: clamp(20px, 8cqh, 24px); + min-width: 0; + padding: 0 5px; + width: 100%; + } + .flli-param input::-webkit-inner-spin-button, + .flli-param input::-webkit-outer-spin-button { appearance: none; margin: 0; } + .flli-param input:disabled { color: #696d78; } + .flli-param[data-overridden="true"] .flli-param-label { color: #c4b5fd; } + .flli-menu { + background: rgba(20, 21, 26, .985); + border: 1px solid var(--flli-border); + border-radius: 8px; + box-shadow: 0 12px 32px rgba(0, 0, 0, .45); + display: grid; + gap: 8px; + left: 7px; + padding: 10px; + position: absolute; + right: 7px; + top: 38px; + z-index: 10; + } + .flli-menu[hidden] { display: none; } + .flli-menu-header { + align-items: center; + display: flex; + justify-content: space-between; + } + .flli-menu-title { font-weight: 750; } + .flli-menu-close { + border-radius: 4px; + height: 24px; + padding: 0; + width: 24px; + } + .flli-menu-field { + display: grid; + gap: 4px; + } + .flli-menu-field > span, + .flli-menu-help { + color: var(--flli-muted); + font-size: 9px; + } + .flli-menu-divider { border-top: 1px solid var(--flli-border); } + .flli-reset-button { + border-radius: 5px; + height: 27px; + } + .flli-upload-progress { + background: rgba(255, 255, 255, .12); + border-radius: 999px; + height: 3px; + overflow: hidden; + width: 120px; + } + .flli-upload-progress[hidden] { display: none; } + .flli-upload-progress span { + animation: flli-upload 900ms ease-in-out infinite; + background: #a78bfa; + display: block; + height: 100%; + width: 45%; + } + @keyframes flli-upload { + from { transform: translateX(-100%); } + to { transform: translateX(220%); } + } + @container (max-height: 220px) { + .flli-preview-info, + .flli-status { top: 3px; } + .flli-panel { border-radius: 6px; } + .flli-control-row { border-radius: 4px; } + } +`; + +function injectStyles() { + if (document.getElementById("flli-styles")) return; + const style = document.createElement("style"); + style.id = "flli-styles"; + style.textContent = STYLES; + document.head.appendChild(style); +} + +function hideWidget(widget) { + if (!widget) return; + widget.hidden = true; + widget.computeSize = () => [0, 0]; + widget.computedHeight = 0; + widget.type = "converted-widget"; + if (widget.element) widget.element.style.display = "none"; +} + +function enforceMinimumNodeSize(node) { + node.min_size = [ + Math.max(node.min_size?.[0] || 0, MIN_NODE_WIDTH), + Math.max(node.min_size?.[1] || 0, MIN_NODE_HEIGHT), + ]; + const width = Math.max(node.size[0], MIN_NODE_WIDTH); + const height = Math.max(node.size[1], MIN_NODE_HEIGHT); + if (width !== node.size[0] || height !== node.size[1]) node.setSize([width, height]); +} + +function filenameFromPath(path) { + return String(path || "").replace(/ \[(input|output|temp)\]$/, "").replace(/\\/g, "/").split("/").pop() || ""; +} + +function previewReference(path) { + const parts = String(path || "").replace(/ \[(input|output|temp)\]$/, "").replace(/\\/g, "/").split("/"); + return { filename: parts.pop() || "", subfolder: parts.join("/") }; +} + +function supportedImageFile(file) { + const extension = file?.name?.split(".").pop()?.toLowerCase(); + return Boolean(file && IMAGE_EXTENSIONS.has(extension)); +} + +class LoadImagePanel { + constructor(node, rootWidget, legacyWidget, imageWidget, settingsWidget, container) { + this.node = node; + this.rootWidget = rootWidget; + this.legacyWidget = legacyWidget; + this.imageWidget = imageWidget; + this.settingsWidget = settingsWidget; + this.container = container; + this.settings = { ...DEFAULT_SETTINGS }; + this.sourceInfo = null; + this.executionInfo = null; + this.configError = ""; + this.objectUrl = null; + this.dragDepth = 0; + this.uploading = false; + this.disposed = false; + this.overrideValues = { width: null, height: null }; + this.overrideTimer = null; + + this.readSettings(); + this.build(); + this.bind(); + this.refreshOverrideValues(false); + this.syncControls(); + this.restoreSource(); + this.scheduleOverrideTracking(); + } + + readSettings() { + try { + const parsed = JSON.parse(this.settingsWidget.value); + if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") { + throw new Error("Load settings must be a JSON object."); + } + if (parsed.version !== undefined && parsed.version !== 1) { + throw new Error(`Settings version ${parsed.version} is unsupported.`); + } + this.settings = { ...DEFAULT_SETTINGS, ...parsed, version: 1 }; + this.configError = ""; + } catch (error) { + this.settings = { ...DEFAULT_SETTINGS }; + this.configError = error.message; } -}); + } -function addFileBrowserUI(node) { - // Tweakable variables - const DIRECTORY_Y_OFFSET = 30; - const CLICK_Y_OFFSET = 0; - const CLICK_X_OFFSET = -2; - const FAVORITES_Y_OFFSET = 160; - const DIVIDER_WIDTH = 13; + build() { + injectStyles(); + this.container.innerHTML = ` +
+
+
No image selected
+ + +
- const rootDirectoryWidget = node.widgets.find(w => w.name === "root_directory"); - const selectedFileWidget = node.widgets.find(w => w.name === "selected_file"); + - rootDirectoryWidget.hidden = false; - selectedFileWidget.hidden = true; +
+
+ Selected image preview +
+
Choose an image
+
empty
+ +
+
+
+
Drop an image here
+
or click to browse
+ +
+
- const MIN_WIDTH = 430; - const MIN_HEIGHT = 550; - const TOP_PADDING = 210; - const BOTTOM_PADDING = 20; - const FOLDER_HEIGHT = 30; - const INDENT_WIDTH = 20; - const TOP_BAR_HEIGHT = 50; - const THUMBNAIL_SIZE = 100; - const THUMBNAIL_PADDING = 10; - const SCROLLBAR_WIDTH = 13; +
+ Output + + + +
- const COLORS = { - background: "#1e1e1e", - topBar: "#252526", - folder: "#2d2d30", - folderHover: "#3e3e42", - folderSelected: "#0e639c", - text: "#cccccc", - scrollbar: "#3e3e42", - scrollbarHover: "#505050", - thumbnailBorder: "#007acc", - thumbnailBackground: "#252526", - favoritesTab: "#5e5e5e", - favoriteButton: "#0e639c", - favoriteButtonHover: "#1177bb", - divider: "#4f0074", - dividerHover: "#16727c" - }; + +
+ `; - let currentDirectory = rootDirectoryWidget.value; - let selectedFile = selectedFileWidget.value; - let directoryStructure = { name: "root", children: [], expanded: true, path: currentDirectory }; - let fileList = []; - let thumbnails = {}; - let scrollOffsetLeft = 0; - let scrollOffsetRight = 0; - let isDraggingLeft = false; - let isDraggingRight = false; - let hoveredFolder = null; - let hoveredFavorite = null; - let scrollStartY = 0; - let scrollStartOffset = 0; - let showFavorites = false; - let favorites = JSON.parse(localStorage.getItem('fileBrowserFavorites') || '[]'); - let leftColumnWidth = node.size[0] / 2; - let isDraggingDivider = false; - let isHoveringDivider = false; + this.panel = this.container.querySelector(".flli-panel"); + this.preview = this.container.querySelector(".flli-preview"); + this.imageStage = this.container.querySelector('[data-role="image-stage"]'); + this.filename = this.container.querySelector('[data-role="filename"]'); + this.image = this.container.querySelector('[data-role="image"]'); + this.previewInfo = this.container.querySelector('[data-role="preview-info"]'); + this.status = this.container.querySelector('[data-role="status"]'); + this.browserError = this.container.querySelector('[data-role="browser-error"]'); + this.dropZone = this.container.querySelector('[data-role="drop-zone"]'); + this.dropTitle = this.container.querySelector('[data-role="drop-title"]'); + this.dropHelp = this.container.querySelector('[data-role="drop-help"]'); + this.uploadProgress = this.container.querySelector('[data-role="upload-progress"]'); + this.error = this.container.querySelector('[data-role="error"]'); + this.moreButton = this.container.querySelector('[data-action="more"]'); + this.settingsMenu = this.container.querySelector('[data-role="settings-menu"]'); + this.inputImageSelect = this.container.querySelector('[data-role="input-image"]'); + this.settingControls = [...this.container.querySelectorAll("[data-setting]")]; - async function updateDirectoryStructure() { - try { - const response = await fetch('/fl_file_browser/get_directory_structure', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: currentDirectory }) - }); - const data = await response.json(); - mergeDirectoryStructure(directoryStructure, data.structure); - fileList = data.files; - loadThumbnails(); - node.setDirtyCanvas(true); - } catch (error) { - console.error("Error updating directory structure:", error); - } - } + this.fileInput = document.createElement("input"); + this.fileInput.type = "file"; + this.fileInput.accept = "image/*,.bmp,.gif,.jpeg,.jpg,.png,.webp"; + this.fileInput.hidden = true; + this.container.appendChild(this.fileInput); + } - function mergeDirectoryStructure(existing, updated) { - existing.name = updated.name; - existing.path = updated.path; - - const existingChildren = new Map(existing.children.map(child => [child.name, child])); - existing.children = updated.children.map(updatedChild => { - const existingChild = existingChildren.get(updatedChild.name); - if (existingChild) { - mergeDirectoryStructure(existingChild, updatedChild); - return existingChild; - } else { - return { ...updatedChild, expanded: false }; - } - }); - } - - async function loadThumbnails() { - thumbnails = {}; - for (const file of fileList) { - const response = await fetch('/fl_file_browser/get_thumbnail', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: currentDirectory, file: file }) - }); - const blob = await response.blob(); - thumbnails[file] = await createImageBitmap(blob); - } - node.setDirtyCanvas(true); - } - - function updateSelectedFile(file) { - selectedFile = file; - selectedFileWidget.value = currentDirectory + '/' + file; - node.setDirtyCanvas(true); - } - - function goUpDirectory() { - const parentDir = currentDirectory.split(/(\\|\/)/g).slice(0, -2).join(''); - if (parentDir) { - currentDirectory = parentDir; - rootDirectoryWidget.value = currentDirectory; - updateDirectoryStructure(); - } - } - - function toggleFavorites() { - showFavorites = !showFavorites; - node.setDirtyCanvas(true); - } - - function addToFavorites() { - if (!favorites.includes(currentDirectory)) { - favorites.push(currentDirectory); - localStorage.setItem('fileBrowserFavorites', JSON.stringify(favorites)); - node.setDirtyCanvas(true); - } - } - - function removeFromFavorites(directory) { - favorites = favorites.filter(fav => fav !== directory); - localStorage.setItem('fileBrowserFavorites', JSON.stringify(favorites)); - node.setDirtyCanvas(true); - } - - const refreshButton = node.addWidget("button", "Refresh", null, () => { - currentDirectory = rootDirectoryWidget.value; - updateDirectoryStructure(); + bind() { + this.container.querySelector('[data-action="replace"]').addEventListener("click", () => this.chooseFile()); + this.dropZone.addEventListener("click", () => this.chooseFile()); + this.dropZone.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this.chooseFile(); + } + }); + this.fileInput.addEventListener("change", () => { + const file = this.fileInput.files?.[0]; + if (file) this.uploadFile(file); + this.fileInput.value = ""; }); - const favoritesButton = node.addWidget("button", "Favorites", null, toggleFavorites); - const addFavoriteButton = node.addWidget("button", "Add to Favorites", null, addToFavorites); + for (const control of this.settingControls) { + control.addEventListener(control.type === "number" ? "input" : "change", () => { + let value = control.value; + if (control.type === "number") { + value = Math.trunc(Number(value)); + if (!Number.isFinite(value)) return; + } + this.updateSetting(control.dataset.setting, value); + }); + } - rootDirectoryWidget.callback = () => { - currentDirectory = rootDirectoryWidget.value; - updateDirectoryStructure(); + this.moreButton.addEventListener("click", () => this.setMenuOpen(this.settingsMenu.hidden)); + this.container.querySelector('[data-action="menu-close"]').addEventListener("click", () => this.setMenuOpen(false)); + this.container.querySelector('[data-action="reset"]').addEventListener("click", () => this.resetSettings()); + this.container.querySelector('[data-action="remove"]').addEventListener("click", () => this.removeSource()); + this.inputImageSelect.addEventListener("change", () => { + if (this.inputImageSelect.value) this.selectSource(this.inputImageSelect.value); + else this.removeSource(); + }); + + this.handleDocumentPointerDown = (event) => { + if (!this.settingsMenu.hidden && !this.settingsMenu.contains(event.target) && !this.moreButton.contains(event.target)) { + this.setMenuOpen(false); + } + }; + this.handleDocumentKeyDown = (event) => { + if (event.key === "Escape" && !this.settingsMenu.hidden) { + this.setMenuOpen(false); + this.moreButton.focus(); + } + }; + document.addEventListener("pointerdown", this.handleDocumentPointerDown); + document.addEventListener("keydown", this.handleDocumentKeyDown); + + this.panel.addEventListener("dragenter", (event) => { + if (!event.dataTransfer?.types?.includes("Files")) return; + event.preventDefault(); + this.dragDepth += 1; + this.panel.dataset.dragging = "true"; + }); + this.panel.addEventListener("dragover", (event) => { + if (!event.dataTransfer?.types?.includes("Files")) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }); + this.panel.addEventListener("dragleave", (event) => { + if (!event.dataTransfer?.types?.includes("Files")) return; + this.dragDepth = Math.max(0, this.dragDepth - 1); + if (this.dragDepth === 0) this.panel.dataset.dragging = "false"; + }); + this.panel.addEventListener("drop", (event) => { + event.preventDefault(); + this.dragDepth = 0; + this.panel.dataset.dragging = "false"; + const file = [...(event.dataTransfer?.files || [])].find(supportedImageFile); + if (!file) { + this.showError("Drop a supported image file."); + return; + } + this.uploadFile(file); + }); + + this.image.addEventListener("load", () => this.handleImageLoad()); + this.image.addEventListener("error", () => { + if (!this.image.src) return; + this.browserError.hidden = false; + this.setStatus("error", "preview error"); + }); + this.previewResizeObserver = new ResizeObserver(() => this.applyPreviewGeometry()); + this.previewResizeObserver.observe(this.preview); + } + + chooseFile() { + this.fileInput.click(); + } + + updateSourceAction(hasSource) { + const button = this.container.querySelector('[data-action="replace"]'); + button.textContent = hasSource ? "Replace" : "Choose"; + button.title = hasSource ? "Replace the selected image" : "Choose an image"; + } + + async uploadFile(file) { + if (!supportedImageFile(file)) { + this.showError("Choose a supported image file."); + return; + } + + const previousSource = this.imageWidget.value; + this.uploading = true; + this.clearError(); + this.setStatus("busy", "uploading"); + this.dropZone.hidden = false; + this.dropTitle.textContent = `Uploading ${file.name}`; + this.dropHelp.textContent = "Copying into ComfyUI input…"; + this.uploadProgress.hidden = false; + this.setObjectPreview(file); + + try { + const body = new FormData(); + body.append("image", file); + body.append("type", "input"); + const response = await api.fetchApi("/upload/image", { method: "POST", body }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || `Upload failed (${response.status}).`); + const path = [payload.subfolder, payload.name].filter(Boolean).join("/").replace(/\\/g, "/"); + this.addImageOption(path); + this.uploading = false; + this.selectSource(path); + } catch (error) { + this.uploading = false; + if (previousSource) this.selectSource(previousSource); + else this.removeSource(false); + this.showError(error.message || "Image upload failed."); + } finally { + this.uploadProgress.hidden = true; + } + } + + setObjectPreview(file) { + this.revokeObjectUrl(); + this.sourceInfo = null; + this.objectUrl = URL.createObjectURL(file); + this.image.src = this.objectUrl; + this.filename.textContent = file.name; + this.updateSourceAction(true); + } + + addImageOption(path) { + const values = this.imageWidget.options?.values; + if (Array.isArray(values) && !values.includes(path)) values.push(path); + } + + selectSource(path, markGraph = true) { + if (!path) { + this.removeSource(markGraph); + return; + } + this.addImageOption(path); + this.imageWidget.value = path; + this.imageWidget.callback?.(path); + this.legacyWidget.value = ""; + this.legacyWidget.callback?.(""); + if (markGraph) this.node.graph?.change?.(); + this.executionInfo = null; + this.sourceInfo = null; + this.filename.textContent = filenameFromPath(path); + this.updateSourceAction(true); + this.syncInputImageOptions(); + this.loadServerPreview(path); + } + + removeSource(markGraph = true) { + this.uploading = false; + this.revokeObjectUrl(); + this.image.removeAttribute("src"); + this.imageWidget.value = ""; + this.imageWidget.callback?.(""); + this.legacyWidget.value = ""; + this.legacyWidget.callback?.(""); + if (markGraph) this.node.graph?.change?.(); + this.sourceInfo = null; + this.executionInfo = null; + this.filename.textContent = "No image selected"; + this.updateSourceAction(false); + this.previewInfo.textContent = "Choose an image"; + this.browserError.hidden = true; + this.dropTitle.textContent = "Drop an image here"; + this.dropHelp.textContent = "or click to browse"; + this.dropZone.hidden = false; + this.setStatus("idle", "empty"); + this.syncInputImageOptions(); + this.applyPreviewGeometry(); + } + + loadServerPreview(path) { + this.revokeObjectUrl(); + const reference = previewReference(path); + const params = new URLSearchParams({ + filename: reference.filename, + subfolder: reference.subfolder, + type: "input", + timestamp: Date.now(), + }); + this.image.src = api.apiURL(`/view?${params.toString()}`); + this.browserError.hidden = true; + this.dropZone.hidden = true; + this.setStatus("busy", "loading"); + } + + showLegacySource(path) { + this.revokeObjectUrl(); + this.image.removeAttribute("src"); + this.sourceInfo = null; + this.filename.textContent = filenameFromPath(path); + this.updateSourceAction(true); + this.previewInfo.textContent = "Legacy external source"; + this.dropTitle.textContent = "Legacy external image"; + this.dropHelp.textContent = "Preview unavailable. Replace to copy it into ComfyUI input."; + this.dropZone.hidden = false; + this.setStatus("stale", "legacy"); + } + + handleImageLoad() { + if (this.disposed) return; + this.sourceInfo = { + width: this.image.naturalWidth, + height: this.image.naturalHeight, + }; + const path = this.imageWidget.value; + if (path) this.node.properties.lastSourceInfo = { ...this.sourceInfo, filename: path }; + this.browserError.hidden = true; + this.updateSourceSummary(); + this.applyPreviewGeometry(); + if (this.uploading) return; + this.dropZone.hidden = true; + this.setStatus("ready", "ready"); + this.clearError(); + } + + migrateLegacySource() { + if (this.imageWidget.value || !this.legacyWidget.value) return this.imageWidget.value; + const legacy = String(this.legacyWidget.value).replace(/\\/g, "/").toLowerCase(); + const match = this.imageOptions().find((value) => { + const normalized = value.toLowerCase(); + return legacy === `input/${normalized}` || legacy.endsWith(`/input/${normalized}`); + }); + if (match) { + this.imageWidget.value = match; + this.imageWidget.callback?.(match); + } + return match || ""; + } + + restoreSource() { + this.syncInputImageOptions(); + const path = this.migrateLegacySource() || this.imageWidget.value; + if (!path) { + if (this.legacyWidget.value) this.showLegacySource(this.legacyWidget.value); + else this.removeSource(false); + if (this.configError) this.showError(this.configError); + return; + } + + const cached = this.node.properties.lastSourceInfo; + if (cached?.filename === path) { + this.sourceInfo = { width: cached.width, height: cached.height }; + } + this.filename.textContent = filenameFromPath(path); + this.updateSourceAction(true); + this.loadServerPreview(path); + this.updateSourceSummary(); + if (this.configError) this.showError(this.configError); + } + + imageOptions() { + const values = this.imageWidget.options?.values; + return Array.isArray(values) ? values.filter(Boolean) : []; + } + + syncInputImageOptions() { + const current = this.imageWidget.value || ""; + const values = this.imageOptions(); + if (current && !values.includes(current)) values.push(current); + values.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + this.inputImageSelect.replaceChildren(); + const empty = document.createElement("option"); + empty.value = ""; + empty.textContent = "Choose an image…"; + this.inputImageSelect.appendChild(empty); + for (const value of values) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + this.inputImageSelect.appendChild(option); + } + this.inputImageSelect.value = current; + } + + updateSetting(name, value) { + this.settings[name] = value; + this.settingsWidget.value = JSON.stringify(this.settings); + this.configError = ""; + if (this.executionInfo) { + this.executionInfo = null; + this.setStatus("stale", "settings changed"); + } + this.syncControls(name); + this.updateSourceSummary(); + this.applyPreviewGeometry(); + this.node.graph?.change?.(); + this.node.setDirtyCanvas(true, true); + } + + resetSettings() { + this.settings = { ...DEFAULT_SETTINGS }; + this.settingsWidget.value = JSON.stringify(this.settings); + this.configError = ""; + if (this.executionInfo) { + this.executionInfo = null; + this.setStatus("stale", "settings changed"); + } + this.syncControls(); + this.updateSourceSummary(); + this.applyPreviewGeometry(); + this.node.graph?.change?.(); + this.node.setDirtyCanvas(true, true); + } + + syncControls(changedName = null) { + for (const control of this.settingControls) { + const name = control.dataset.setting; + if (changedName && name !== changedName) continue; + control.value = this.settings[name]; + } + this.syncOverrideControls(); + } + + isOverrideConnected(name) { + return this.node.inputs?.some((input) => input.name === `${name}_override` && input.link != null) || false; + } + + connectedOverrideValue(name) { + const input = this.node.inputs?.find((candidate) => candidate.name === `${name}_override`); + if (input?.link == null) return null; + return this.numberFromLink(input.link, new Set()); + } + + numberFromLink(linkId, visited) { + if (visited.has(linkId)) return null; + visited.add(linkId); + const graph = this.node.graph || app.graph; + const link = graph?.links?.[linkId]; + const source = link && graph?.getNodeById?.(link.origin_id); + if (!source) return null; + + const output = source.outputs?.[link.origin_slot]; + const outputName = String(output?.name || "").toLowerCase(); + const widgets = source.widgets || []; + let widget = widgets.find((candidate) => String(candidate.name || "").toLowerCase() === outputName); + const sourceType = `${source.type || ""} ${source.comfyClass || ""}`.toLowerCase(); + if (!widget && sourceType.includes("primitive")) { + widget = widgets.find((candidate) => Number.isFinite(Number(candidate.value))); + } + if (widget) { + const value = Number(widget.value); + if (Number.isFinite(value)) { + const integer = Math.trunc(value); + return integer >= 0 && integer <= 16384 ? integer : null; + } + } + + if (sourceType.includes("reroute") && source.inputs?.[0]?.link != null) { + return this.numberFromLink(source.inputs[0].link, visited); + } + return null; + } + + refreshOverrideValues(markStale = true) { + const next = { + width: this.connectedOverrideValue("width"), + height: this.connectedOverrideValue("height"), + }; + const changed = next.width !== this.overrideValues.width || next.height !== this.overrideValues.height; + this.overrideValues = next; + if (!changed) return; + if (markStale && this.executionInfo) { + this.executionInfo = null; + this.setStatus("stale", "inputs changed"); + } + this.syncOverrideControls(); + this.updateSourceSummary(); + this.applyPreviewGeometry(); + this.node.setDirtyCanvas(true, true); + } + + scheduleOverrideTracking() { + if (this.disposed || this.overrideTimer != null || !["width", "height"].some((name) => this.isOverrideConnected(name))) return; + this.overrideTimer = window.setTimeout(() => { + this.overrideTimer = null; + this.refreshOverrideValues(); + this.scheduleOverrideTracking(); + }, 100); + } + + overrideDisplayValue(name) { + if (this.overrideValues[name] != null) return this.overrideValues[name]; + const executed = this.executionInfo?.[`requested_${name}`]; + return Number.isInteger(executed) ? executed : null; + } + + syncOverrideControls() { + const originalSize = this.settings.resize_mode === "original"; + for (const name of ["width", "height"]) { + const control = this.container.querySelector(`[data-setting="${name}"]`); + const label = this.container.querySelector(`[data-role="${name}-label"]`); + const connected = this.isOverrideConnected(name); + const value = connected ? this.overrideDisplayValue(name) : null; + control.value = value ?? this.settings[name]; + control.disabled = originalSize || connected; + control.closest(".flli-param").dataset.overridden = String(connected); + label.textContent = connected ? `${name[0].toUpperCase()}${name.slice(1)} · input` : `${name[0].toUpperCase()}${name.slice(1)}`; + control.title = connected + ? originalSize + ? `Connected override${value == null ? "" : ` (${value})`} is ignored while Resize is Original.` + : value == null ? "Connected value will appear after its source or this node executes." : `Connected input value: ${value}` + : ""; + } + } + + handleConnectionsChanged() { + if (this.executionInfo) { + this.executionInfo = null; + this.setStatus("stale", "inputs changed"); + } + this.refreshOverrideValues(false); + this.syncOverrideControls(); + this.updateSourceSummary(); + this.applyPreviewGeometry(); + this.scheduleOverrideTracking(); + this.node.setDirtyCanvas(true, true); + } + + effectiveResizeSettings() { + const settings = { ...this.settings }; + for (const name of ["width", "height"]) { + if (!this.isOverrideConnected(name)) continue; + const value = this.overrideDisplayValue(name); + if (value == null) return null; + settings[name] = value; + } + return settings; + } + + targetDimensions(width, height) { + if (!width || !height || this.settings.resize_mode === "original") return { width, height }; + const settings = this.effectiveResizeSettings(); + if (!settings) return null; + if (settings.resize_mode === "crop") { + if (!settings.width || !settings.height) return null; + return { width: settings.width, height: settings.height }; + } + let scale; + if (!settings.width && !settings.height) return null; + if (!settings.width) scale = settings.height / height; + else if (!settings.height) scale = settings.width / width; + else scale = Math.min(settings.width / width, settings.height / height); + if (!Number.isFinite(scale) || scale <= 0) return { width, height }; + return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) }; + } + + applyPreviewGeometry() { + if (!this.preview || !this.imageStage) return; + const info = this.executionInfo || this.sourceInfo; + const sourceWidth = info?.source_width || info?.width; + const sourceHeight = info?.source_height || info?.height; + const target = sourceWidth && sourceHeight ? this.targetDimensions(sourceWidth, sourceHeight) : null; + const crop = this.settings.resize_mode === "crop" && target; + const aspectWidth = crop ? target.width : sourceWidth; + const aspectHeight = crop ? target.height : sourceHeight; + this.imageStage.dataset.resizeMode = crop ? "crop" : this.settings.resize_mode; + if (!aspectWidth || !aspectHeight || !this.preview.clientWidth || !this.preview.clientHeight) { + this.imageStage.style.width = "100%"; + this.imageStage.style.height = "100%"; + return; + } + + const targetRatio = aspectWidth / aspectHeight; + const previewRatio = this.preview.clientWidth / this.preview.clientHeight; + if (targetRatio >= previewRatio) { + this.imageStage.style.width = "100%"; + this.imageStage.style.height = `${this.preview.clientWidth / targetRatio}px`; + } else { + this.imageStage.style.width = `${this.preview.clientHeight * targetRatio}px`; + this.imageStage.style.height = "100%"; + } + } + + updateSourceSummary() { + const info = this.executionInfo || this.sourceInfo; + if (!info) return; + const sourceWidth = info.source_width || info.width; + const sourceHeight = info.source_height || info.height; + const loadedWidth = info.loaded_width; + const loadedHeight = info.loaded_height; + if (loadedWidth && loadedHeight) { + this.previewInfo.textContent = sourceWidth === loadedWidth && sourceHeight === loadedHeight + ? `${loadedWidth}×${loadedHeight}` + : `${sourceWidth}×${sourceHeight} → ${loadedWidth}×${loadedHeight}`; + return; + } + const target = this.targetDimensions(sourceWidth, sourceHeight); + if (!target) { + this.previewInfo.textContent = `${sourceWidth}×${sourceHeight} → connected size`; + return; + } + this.previewInfo.textContent = sourceWidth === target.width && sourceHeight === target.height + ? `${sourceWidth}×${sourceHeight}` + : `${sourceWidth}×${sourceHeight} → ${target.width}×${target.height}`; + } + + updateFromExecution(message) { + const info = message?.fl_load_image?.[0]; + if (!info) return; + this.executionInfo = { ...info }; + this.node.properties.lastExecutionInfo = { ...info }; + this.node.properties.lastLoadSettings = this.settingsWidget.value; + this.syncOverrideControls(); + this.updateSourceSummary(); + this.applyPreviewGeometry(); + this.setStatus("ready", "loaded"); + } + + setStatus(state, label) { + this.status.dataset.state = state; + this.status.textContent = label; + } + + setMenuOpen(open) { + this.settingsMenu.hidden = !open; + this.moreButton.setAttribute("aria-expanded", String(open)); + if (open) this.syncInputImageOptions(); + } + + showError(message) { + this.error.textContent = message; + this.error.hidden = false; + this.setStatus("error", "error"); + } + + clearError() { + this.error.textContent = ""; + this.error.hidden = true; + } + + configure() { + hideWidget(this.rootWidget); + hideWidget(this.legacyWidget); + hideWidget(this.imageWidget); + hideWidget(this.settingsWidget); + this.readSettings(); + this.executionInfo = this.node.properties.lastExecutionInfo || null; + this.refreshOverrideValues(false); + this.syncControls(); + this.setMenuOpen(false); + this.restoreSource(); + this.scheduleOverrideTracking(); + if (this.executionInfo && this.node.properties.lastLoadSettings !== this.settingsWidget.value) { + this.setStatus("stale", "settings changed"); + } + } + + revokeObjectUrl() { + if (!this.objectUrl) return; + URL.revokeObjectURL(this.objectUrl); + this.objectUrl = null; + } + + dispose() { + this.disposed = true; + if (this.overrideTimer != null) window.clearTimeout(this.overrideTimer); + this.previewResizeObserver.disconnect(); + document.removeEventListener("pointerdown", this.handleDocumentPointerDown); + document.removeEventListener("keydown", this.handleDocumentKeyDown); + this.revokeObjectUrl(); + this.image.removeAttribute("src"); + this.container.replaceChildren(); + } +} + +app.registerExtension({ + name: "ComfyUI.FL_LoadImage", + nodeCreated(node) { + if (node.comfyClass !== "FL_LoadImage") return; + + const rootWidget = node.widgets?.find((widget) => widget.name === "root_directory"); + const legacyWidget = node.widgets?.find((widget) => widget.name === "selected_file"); + const imageWidget = node.widgets?.find((widget) => widget.name === "image"); + const settingsWidget = node.widgets?.find((widget) => widget.name === "load_settings"); + if (!rootWidget || !legacyWidget || !imageWidget || !settingsWidget) return; + hideWidget(rootWidget); + hideWidget(legacyWidget); + hideWidget(imageWidget); + hideWidget(settingsWidget); + + const container = document.createElement("div"); + container.className = "flli-container"; + container.style.width = "100%"; + container.style.height = "100%"; + container.style.minHeight = `${MIN_PANEL_HEIGHT}px`; + container.style.overflow = "hidden"; + + const domWidget = node.addDOMWidget("fl_load_image_panel", "fl-load-image", container, { + getMinHeight: () => MIN_PANEL_HEIGHT, + hideOnZoom: false, + serialize: false, + }); + enforceMinimumNodeSize(node); + requestAnimationFrame(() => enforceMinimumNodeSize(node)); + + const panel = new LoadImagePanel(node, rootWidget, legacyWidget, imageWidget, settingsWidget, container); + + const originalOnExecuted = node.onExecuted; + node.onExecuted = function (message) { + originalOnExecuted?.apply(this, arguments); + panel.updateFromExecution(message); }; - node.onDrawBackground = function(ctx) { - if (!this.flags.collapsed) { - const pos = TOP_PADDING - TOP_BAR_HEIGHT; - ctx.fillStyle = COLORS.background; - ctx.fillRect(0, pos, this.size[0], this.size[1] - pos); - - // Draw top bar - ctx.fillStyle = COLORS.topBar; - ctx.fillRect(0, pos, this.size[0], TOP_BAR_HEIGHT); - - // Draw back button - drawRoundedRect(ctx, 10, pos + 10, 80, TOP_BAR_HEIGHT - 20, 5, COLORS.folder); - ctx.fillStyle = COLORS.text; - ctx.font = "14px Arial"; - ctx.fillText("← Back", 30, pos + 32); - - // Draw current directory - ctx.fillStyle = COLORS.text; - ctx.font = "14px Arial"; - ctx.fillText(currentDirectory, 100, pos + 32); - - if (showFavorites) { - drawFavoritesTab(ctx); - } else { - // Set up clipping regions for scrolling - ctx.save(); - ctx.beginPath(); - ctx.rect(0, TOP_PADDING, leftColumnWidth - SCROLLBAR_WIDTH, this.size[1] - TOP_PADDING - BOTTOM_PADDING); - ctx.clip(); - drawDirectoryStructure(ctx, 10, TOP_PADDING - scrollOffsetLeft + DIRECTORY_Y_OFFSET, directoryStructure); - ctx.restore(); - - ctx.save(); - ctx.beginPath(); - ctx.rect(leftColumnWidth + DIVIDER_WIDTH, TOP_PADDING, this.size[0] - leftColumnWidth - DIVIDER_WIDTH - SCROLLBAR_WIDTH, this.size[1] - TOP_PADDING - BOTTOM_PADDING); - ctx.clip(); - drawThumbnails(ctx, leftColumnWidth + DIVIDER_WIDTH, TOP_PADDING - scrollOffsetRight, this.size[0] - leftColumnWidth - DIVIDER_WIDTH - SCROLLBAR_WIDTH - 10, this.size[1] - TOP_PADDING - BOTTOM_PADDING); - ctx.restore(); - - // Draw scrollbars - drawScrollbar(ctx, leftColumnWidth - SCROLLBAR_WIDTH, TOP_PADDING, SCROLLBAR_WIDTH, this.size[1] - TOP_PADDING - BOTTOM_PADDING, scrollOffsetLeft, getTotalDirectoryHeight()); - drawScrollbar(ctx, this.size[0] - SCROLLBAR_WIDTH, TOP_PADDING, SCROLLBAR_WIDTH, this.size[1] - TOP_PADDING - BOTTOM_PADDING, scrollOffsetRight, getTotalThumbnailHeight()); - - // Draw divider - ctx.fillStyle = isHoveringDivider ? COLORS.dividerHover : COLORS.divider; - ctx.fillRect(leftColumnWidth, TOP_PADDING, DIVIDER_WIDTH, this.size[1] - TOP_PADDING - BOTTOM_PADDING); - } - } + const originalOnConfigure = node.onConfigure; + node.onConfigure = function (...args) { + const result = originalOnConfigure?.apply(this, args); + panel.configure(); + requestAnimationFrame(() => enforceMinimumNodeSize(this)); + return result; }; - function drawFavoritesTab(ctx) { - const tabX = 0; - const tabY = TOP_PADDING; - const tabWidth = node.size[0]; - const tabHeight = node.size[1] - TOP_PADDING - BOTTOM_PADDING; - - ctx.fillStyle = COLORS.favoritesTab; - ctx.fillRect(tabX, tabY, tabWidth, tabHeight); - - ctx.fillStyle = COLORS.text; - ctx.font = "16px Arial"; - ctx.fillText("Favorites", tabX + 10, tabY + 30); - - favorites.forEach((fav, index) => { - const y = tabY + 60 + index * 40; - const isHovered = hoveredFavorite === index; - - drawRoundedRect(ctx, tabX + 10, y, tabWidth - 20, 30, 5, isHovered ? COLORS.folderHover : COLORS.folder); - ctx.fillStyle = COLORS.text; - ctx.font = "12px Arial"; - ctx.fillText(fav, tabX + 15, y + 20); - - // Draw remove button - drawRoundedRect(ctx, tabX + tabWidth - 40, y + 5, 20, 20, 3, COLORS.favoriteButton); - ctx.fillStyle = COLORS.text; - ctx.font = "16px Arial"; - ctx.fillText("×", tabX + tabWidth - 34, y + 21); - }); - } - - function drawRoundedRect(ctx, x, y, width, height, radius, color) { - ctx.beginPath(); - ctx.moveTo(x + radius, y); - ctx.lineTo(x + width - radius, y); - ctx.quadraticCurveTo(x + width, y, x + width, y + radius); - ctx.lineTo(x + width, y + height - radius); - ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); - ctx.lineTo(x + radius, y + height); - ctx.quadraticCurveTo(x, y + height, x, y + height - radius); - ctx.lineTo(x, y + radius); - ctx.quadraticCurveTo(x, y, x + radius, y); - ctx.closePath(); - ctx.fillStyle = color; - ctx.fill(); - } - - function drawScrollbar(ctx, x, y, width, height, offset, totalHeight) { - drawRoundedRect(ctx, x, y, width, height, width / 2, COLORS.scrollbar); - - const visibleHeight = height; - const scrollHeight = Math.max(height * (visibleHeight / totalHeight), 20); - const maxOffset = Math.max(0, totalHeight - visibleHeight); - const scrollY = y + (offset / maxOffset) * (height - scrollHeight); - - drawRoundedRect(ctx, x, scrollY, width, scrollHeight, width / 2, COLORS.scrollbarHover); - } - - function getTotalDirectoryHeight() { - return countItems(directoryStructure) * FOLDER_HEIGHT; - } - - function getTotalThumbnailHeight() { - const thumbnailsPerRow = Math.floor((node.size[0] - leftColumnWidth - DIVIDER_WIDTH - SCROLLBAR_WIDTH - 10) / (THUMBNAIL_SIZE + THUMBNAIL_PADDING)); - return Math.ceil(fileList.length / thumbnailsPerRow) * (THUMBNAIL_SIZE + THUMBNAIL_PADDING); - } - - function drawDirectoryStructure(ctx, x, y, structure, level = 0) { - const folderIcon = structure.expanded ? "📂" : "📁"; - const xPos = x + INDENT_WIDTH * level; - const yPos = y + FOLDER_HEIGHT / 2; - - const isHovered = structure === hoveredFolder; - const isSelected = structure.path === currentDirectory; - - if (isSelected || isHovered) { - drawRoundedRect(ctx, xPos - 5, y, leftColumnWidth - xPos, FOLDER_HEIGHT, 5, isSelected ? COLORS.folderSelected : COLORS.folderHover); - } - - ctx.font = "14px Arial"; - ctx.fillStyle = COLORS.text; - ctx.fillText(`${folderIcon} ${structure.name}`, xPos, yPos); - - y += FOLDER_HEIGHT; - - if (structure.expanded && structure.children) { - for (const child of structure.children) { - y = drawDirectoryStructure(ctx, x, y, child, level + 1); - } - } - - return y; - } - - function drawThumbnails(ctx, x, y, width, height) { - ctx.fillStyle = COLORS.background; - ctx.fillRect(x, y, width, height); - - const thumbnailsPerRow = Math.floor(width / (THUMBNAIL_SIZE + THUMBNAIL_PADDING)); - fileList.forEach((file, index) => { - const row = Math.floor(index / thumbnailsPerRow); - const col = index % thumbnailsPerRow; - const xPos = x + col * (THUMBNAIL_SIZE + THUMBNAIL_PADDING) + THUMBNAIL_PADDING; - const yPos = y + row * (THUMBNAIL_SIZE + THUMBNAIL_PADDING) + THUMBNAIL_PADDING; - - drawRoundedRect(ctx, xPos, yPos, THUMBNAIL_SIZE, THUMBNAIL_SIZE, 5, COLORS.thumbnailBackground); - - if (thumbnails[file]) { - ctx.drawImage(thumbnails[file], xPos, yPos, THUMBNAIL_SIZE, THUMBNAIL_SIZE); - } - - if (file === selectedFile) { - ctx.strokeStyle = COLORS.thumbnailBorder; - ctx.lineWidth = 3; - ctx.strokeRect(xPos, yPos, THUMBNAIL_SIZE, THUMBNAIL_SIZE); - } - }); - } - - node.onMouseDown = function(event) { - const pos = TOP_PADDING - TOP_BAR_HEIGHT; - const localY = event.canvasY - this.pos[1] - pos + CLICK_Y_OFFSET; - const localX = event.canvasX - this.pos[0] + CLICK_X_OFFSET; // Apply X offset - - if (localY < 0 || localY > this.size[1] || localX < 0 || localX > this.size[0]) { - return false; // Allow default behavior for dragging the node - } - - if (localY >= 0 && localY <= TOP_BAR_HEIGHT) { - // Click on top bar - if (localX >= 10 && localX <= 90 && localY >= 10 && localY <= TOP_BAR_HEIGHT - 10) { - // Click on back button - goUpDirectory(); - return true; - } - } else if (localY > TOP_BAR_HEIGHT) { - if (showFavorites) { - // Handle clicks on favorites tab - const tabY = TOP_PADDING; - const favoritesLocalY = localY + FAVORITES_Y_OFFSET; // Apply favorites offset - favorites.forEach((fav, index) => { - const y = tabY + 60 + index * 40; - if (favoritesLocalY >= y && favoritesLocalY <= y + 30) { - if (localX >= this.size[0] - 40 && localX <= this.size[0] - 20) { - // Click on remove button - removeFromFavorites(fav); - } else { - // Click on favorite directory - currentDirectory = fav; - rootDirectoryWidget.value = currentDirectory; - updateDirectoryStructure(); - showFavorites = false; - } - return true; - } - }); - } else { - if (Math.abs(localX - leftColumnWidth) <= DIVIDER_WIDTH / 2) { - // Click on divider - isDraggingDivider = true; - return true; - } else if (localX < leftColumnWidth - SCROLLBAR_WIDTH) { - // Click on directory structure - const clickedItem = findClickedItem(directoryStructure, localY - TOP_BAR_HEIGHT + scrollOffsetLeft - DIRECTORY_Y_OFFSET); - if (clickedItem) { - if (clickedItem.children) { - clickedItem.expanded = !clickedItem.expanded; - this.setDirtyCanvas(true); - } - if (clickedItem.path !== currentDirectory) { - currentDirectory = clickedItem.path; - rootDirectoryWidget.value = currentDirectory; - updateDirectoryStructure(); - } - } - return true; - } else if (localX >= leftColumnWidth + DIVIDER_WIDTH && localX < this.size[0] - SCROLLBAR_WIDTH) { - // Click on thumbnails - const thumbnailsPerRow = Math.floor((this.size[0] - leftColumnWidth - DIVIDER_WIDTH - SCROLLBAR_WIDTH - 10) / (THUMBNAIL_SIZE + THUMBNAIL_PADDING)); - const clickedRow = Math.floor((localY - TOP_BAR_HEIGHT + scrollOffsetRight) / (THUMBNAIL_SIZE + THUMBNAIL_PADDING)); - const clickedCol = Math.floor((localX - leftColumnWidth - DIVIDER_WIDTH) / (THUMBNAIL_SIZE + THUMBNAIL_PADDING)); - const clickedIndex = clickedRow * thumbnailsPerRow + clickedCol; - if (clickedIndex >= 0 && clickedIndex < fileList.length) { - updateSelectedFile(fileList[clickedIndex]); - } - return true; - } else if (localX >= leftColumnWidth - SCROLLBAR_WIDTH && localX < leftColumnWidth) { - // Click on left scrollbar - isDraggingLeft = true; - scrollStartY = event.canvasY; - scrollStartOffset = scrollOffsetLeft; - return true; - } else if (localX >= this.size[0] - SCROLLBAR_WIDTH) { - // Click on right scrollbar - isDraggingRight = true; - scrollStartY = event.canvasY; - scrollStartOffset = scrollOffsetRight; - return true; - } - } - } - - return false; // Allow default behavior for dragging the node + const originalOnConnectionsChange = node.onConnectionsChange; + node.onConnectionsChange = function (...args) { + const result = originalOnConnectionsChange?.apply(this, args); + panel.handleConnectionsChanged(); + return result; }; - node.onMouseMove = function(event) { - const pos = TOP_PADDING - TOP_BAR_HEIGHT; - const localY = event.canvasY - this.pos[1] - pos + CLICK_Y_OFFSET; - const localX = event.canvasX - this.pos[0] + CLICK_X_OFFSET; // Apply X offset - - if (isDraggingDivider) { - leftColumnWidth = Math.max(100, Math.min(this.size[0] - 100, localX)); - this.setDirtyCanvas(true); - return true; - } - - if (isDraggingLeft) { - const totalHeight = getTotalDirectoryHeight(); - const visibleHeight = this.size[1] - TOP_PADDING - BOTTOM_PADDING; - const maxOffset = Math.max(0, totalHeight - visibleHeight); - const scrollbarHeight = visibleHeight * (visibleHeight / totalHeight); - const scrollMove = (event.canvasY - scrollStartY) * (totalHeight / visibleHeight); - scrollOffsetLeft = Math.max(0, Math.min(maxOffset, scrollStartOffset + scrollMove)); - this.setDirtyCanvas(true); - return true; - } else if (isDraggingRight) { - const totalHeight = getTotalThumbnailHeight(); - const visibleHeight = this.size[1] - TOP_PADDING - BOTTOM_PADDING; - const maxOffset = Math.max(0, totalHeight - visibleHeight); - const scrollbarHeight = visibleHeight * (visibleHeight / totalHeight); - const scrollMove = (event.canvasY - scrollStartY) * (totalHeight / visibleHeight); - scrollOffsetRight = Math.max(0, Math.min(maxOffset, scrollStartOffset + scrollMove)); - this.setDirtyCanvas(true); - return true; - } - - if (showFavorites) { - // Hover effect for favorites - const tabY = TOP_PADDING; - const favoritesLocalY = localY + FAVORITES_Y_OFFSET; // Apply favorites offset - let newHoveredFavorite = null; - favorites.forEach((fav, index) => { - const y = tabY + 60 + index * 40; - if (favoritesLocalY >= y && favoritesLocalY <= y + 30) { - newHoveredFavorite = index; - } - }); - if (newHoveredFavorite !== hoveredFavorite) { - hoveredFavorite = newHoveredFavorite; - this.setDirtyCanvas(true); - } - } else { - // Hover effect for divider - const newIsHoveringDivider = Math.abs(localX - leftColumnWidth) <= DIVIDER_WIDTH / 2; - if (newIsHoveringDivider !== isHoveringDivider) { - isHoveringDivider = newIsHoveringDivider; - this.setDirtyCanvas(true); - } - - // Hover effect for folders - if (localX < leftColumnWidth - SCROLLBAR_WIDTH && localY > TOP_BAR_HEIGHT) { - const newHoveredFolder = findClickedItem(directoryStructure, localY - TOP_BAR_HEIGHT + scrollOffsetLeft - DIRECTORY_Y_OFFSET); - if (newHoveredFolder !== hoveredFolder) { - hoveredFolder = newHoveredFolder; - this.setDirtyCanvas(true); - } - } else if (hoveredFolder) { - hoveredFolder = null; - this.setDirtyCanvas(true); - } - } - - // Update cursor style - if (isHoveringDivider) { - document.body.style.cursor = 'ew-resize'; - } else { - document.body.style.cursor = 'default'; - } - - return false; - }; - - node.onMouseUp = function(event) { - isDraggingLeft = false; - isDraggingRight = false; - isDraggingDivider = false; - document.body.style.cursor = 'default'; - return false; - }; - - function findClickedItem(structure, y, currentY = 0) { - if (y >= currentY && y < currentY + FOLDER_HEIGHT) { - return structure; - } - currentY += FOLDER_HEIGHT; - - if (structure.expanded && structure.children) { - for (const child of structure.children) { - const found = findClickedItem(child, y, currentY); - if (found) return found; - currentY += FOLDER_HEIGHT * (child.expanded ? countItems(child) : 1); - } - } - return null; - } - - function countItems(item) { - if (!item.expanded || !item.children) return 1; - return 1 + item.children.reduce((sum, child) => sum + countItems(child), 0); - } - - function updateNodeSize() { - const width = Math.max(MIN_WIDTH, node.size[0]); - const height = Math.max(MIN_HEIGHT, node.size[1]); - node.size[0] = width; - node.size[1] = height; - } - - node.onResize = function() { - updateNodeSize(); - this.setDirtyCanvas(true); - }; - - updateDirectoryStructure(); - updateNodeSize(); -} \ No newline at end of file + domWidget.onRemove = () => panel.dispose(); + }, +});