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
+
Browser preview unavailable. This file can still be loaded.
+
+
+
+
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();
+ },
+});