From 808ecb34ffc3b8a7ac3a48d67df049322bb27386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=8B=E3=81=BF=E3=81=AA=E3=83=BC=E5=85=88=E7=94=9F?= Date: Tue, 16 Jun 2026 23:27:25 +0900 Subject: [PATCH] =?UTF-8?q?ImageDirPreview=E3=81=AE=E5=B7=A6=E3=82=AF?= =?UTF-8?q?=E3=83=AA=E3=83=83=E3=82=AF=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=81=A8=E3=83=97=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E7=8A=B6?= =?UTF-8?q?=E6=85=8B=E3=81=AE=E3=82=BD=E3=83=BC=E3=82=B9=E3=83=91=E3=82=B9?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3=E3=81=97=E3=80=81=E3=83=97=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E3=82=BB=E3=83=83=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3ID=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suite_nodes.py | 16 ++- web/checkpoint_handpicker_suite.js | 162 +++++++++++++++++++---------- 2 files changed, 118 insertions(+), 60 deletions(-) diff --git a/suite_nodes.py b/suite_nodes.py index 09b2a9c..22a9430 100644 --- a/suite_nodes.py +++ b/suite_nodes.py @@ -22,6 +22,7 @@ from server import PromptServer logger = logging.getLogger(__name__) +# v9i: ImageDirPreview left-click menu, hover frame, and preview-state source path fix. # v9h: ImageDirPreview right-click event separation and blank-area suppression. # v9g: ImageDirPreview right-click capture fix for ComfyUI/LiteGraph context menu. # v9f: ImageDirPreview context menu foundation and Set as checkpoint thumbnail. @@ -838,10 +839,12 @@ def _preferred_checkpoint_thumbnail_path(safetensors_path: Path) -> Path: return Path(safetensors_path).resolve().with_suffix(".jpg") -def _get_preview_state_item(tab_id, node_id, item_index): +def _get_preview_state_item(tab_id, node_id, item_index, preview_session_id=None): key = _state_key(tab_id, node_id) with _STATE_LOCK: state = dict(_PREVIEW_STATES.get(key, {})) + if preview_session_id and state.get("preview_session_id") != str(preview_session_id): + return state, None, None, None source_paths = state.get("source_paths") if not isinstance(source_paths, list): return None, None, None, None @@ -1451,6 +1454,7 @@ def _load_image_dir_preview(node_id, relpath, search_directory=None, tab_id="", "search_directory": str(_safe_search_root(search_directory) or ""), "max_preview_images": max_preview_images, "source_paths": [str(p) for p in loaded_paths], + "preview_session_id": str(uuid.uuid4()), }) return sheet, extra @@ -2020,7 +2024,8 @@ class ImageDirPreview: relpath = _normalize_relpath(ckpt_name_str) sheet, extra = _load_image_dir_preview(unique_id, relpath, search_directory, tab_id=hps_tab_id, send_progress=True, max_preview_images=max_preview_images) status = _get_status(relpath) - _store_preview_state(hps_tab_id, unique_id, relpath, status, node_class="ImageDirPreview") + state_extra = {k: v for k, v in extra.items() if k not in ("ckpt_name_str", "status")} + _store_preview_state(hps_tab_id, unique_id, relpath, status, **state_extra) _send_preview(unique_id, _image_dir_title(relpath), sheet, extra, tab_id=hps_tab_id) return () @@ -2085,7 +2090,7 @@ async def image_dir_preview_context_menu(request): data = await request.json() node_id = str(data.get("node_id", "")) tab_id = _clean_tab_id(data.get("tab_id", "")) - state, index, source_path, relpath = _get_preview_state_item(tab_id, node_id, data.get("item_index")) + state, index, source_path, relpath = _get_preview_state_item(tab_id, node_id, data.get("item_index"), data.get("preview_session_id")) if not state or index is None or source_path is None or not relpath: return web.json_response({"ok": False, "error": "Preview item not found."}, status=404) exists = _preview_item_exists_for_state(state, source_path) @@ -2115,7 +2120,7 @@ async def image_dir_preview_set_thumbnail(request): data = await request.json() node_id = str(data.get("node_id", "")) tab_id = _clean_tab_id(data.get("tab_id", "")) - state, index, source_path, relpath = _get_preview_state_item(tab_id, node_id, data.get("item_index")) + state, index, source_path, relpath = _get_preview_state_item(tab_id, node_id, data.get("item_index"), data.get("preview_session_id")) if not state or index is None or source_path is None or not relpath: return web.json_response({"ok": False, "error": "Preview item not found."}, status=404) if not _preview_item_exists_for_state(state, source_path): @@ -2401,7 +2406,8 @@ async def review_sync_checkpoint(request): True, max_preview_images, ) - _store_preview_state(tab_id, node_id, relpath, status, node_class="ImageDirPreview") + state_extra = {k: v for k, v in extra.items() if k not in ("ckpt_name_str", "status")} + _store_preview_state(tab_id, node_id, relpath, status, **state_extra) _send_preview(node_id, _image_dir_title(relpath), sheet, extra, tab_id=tab_id) preview_count += 1 except Exception: diff --git a/web/checkpoint_handpicker_suite.js b/web/checkpoint_handpicker_suite.js index 2b063e1..ebedf32 100644 --- a/web/checkpoint_handpicker_suite.js +++ b/web/checkpoint_handpicker_suite.js @@ -774,9 +774,11 @@ async function restoreNodeStateFromBackend(node, nodeClass) { } + // ---------- Preview ---------- let hpsContextMenuEl = null; let hpsContextMenuHideInstalled = false; +let imageDirHoverCaptureInstalled = false; function ensureHpsContextMenu() { if (hpsContextMenuEl) return hpsContextMenuEl; @@ -806,7 +808,10 @@ function hideHpsContextMenu() { function installHpsContextMenuHide() { if (hpsContextMenuHideInstalled) return; hpsContextMenuHideInstalled = true; - document.addEventListener("mousedown", () => hideHpsContextMenu(), true); + document.addEventListener("mousedown", (event) => { + if (hpsContextMenuEl?.contains?.(event.target)) return; + hideHpsContextMenu(); + }, true); document.addEventListener("scroll", () => hideHpsContextMenu(), true); window.addEventListener("blur", () => hideHpsContextMenu()); } @@ -907,20 +912,91 @@ function imageDirPreviewItemAtLocal(node, local) { return { index, sourcePath: String(sourcePaths[index] || ""), ckptName: String(st.ckpt_name_str || "") }; } +function imageDirPreviewTileRect(node, index) { + const st = node.__hpsPreviewState || {}; + const cols = Number(st.columns || 0); + const tileW = Number(st.tile_width || 0); + const tileH = Number(st.tile_height || 0); + const gap = Number(st.gap || 0); + if (!cols || !tileW || !tileH || index == null || index < 0) return null; + const box = previewDrawBox(node); + if (!box) return null; + const row = Math.floor(index / cols); + const col = index % cols; + const sx = col * (tileW + gap); + const sy = row * (tileH + gap); + const scaleX = box.w / box.imageW; + const scaleY = box.h / box.imageH; + return { x: box.x + sx * scaleX, y: box.y + sy * scaleY, w: tileW * scaleX, h: tileH * scaleY }; +} + +function clearImageDirHover(node) { + if (!node) return; + if (node.__hpsHoveredPreviewIndex != null) { + node.__hpsHoveredPreviewIndex = null; + app.graph?.setDirtyCanvas?.(true, true); + } +} + +function setImageDirHover(node, item) { + const index = item?.index ?? null; + if (node.__hpsHoveredPreviewIndex !== index) { + node.__hpsHoveredPreviewIndex = index; + app.graph?.setDirtyCanvas?.(true, true); + } +} + +function updateImageDirHoverFromEvent(event) { + let handled = false; + const nodes = [...(app.graph?._nodes || [])].reverse(); + for (const node of nodes) { + if (!node || node.flags?.collapsed || !isNodeClass(node, "ImageDirPreview")) continue; + const local = graphEventToLocal(node, event); + if (!hpsLocalInsideNode(node, local)) { + clearImageDirHover(node); + continue; + } + const item = imageDirPreviewItemAtLocal(node, local); + setImageDirHover(node, item); + handled = true; + break; + } + if (!handled) { + for (const node of app.graph?._nodes || []) { + if (isNodeClass(node, "ImageDirPreview")) clearImageDirHover(node); + } + } +} + +function installImageDirHoverCapture() { + if (imageDirHoverCaptureInstalled) return; + const canvasEl = app.canvas?.canvas; + if (!canvasEl) return; + imageDirHoverCaptureInstalled = true; + canvasEl.addEventListener("mousemove", (event) => updateImageDirHoverFromEvent(event), true); + canvasEl.addEventListener("mouseleave", () => { + for (const node of app.graph?._nodes || []) { + if (isNodeClass(node, "ImageDirPreview")) clearImageDirHover(node); + } + }); +} + async function fetchImageDirContextMenu(node, itemIndex) { + const st = node.__hpsPreviewState || {}; const response = await api.fetchApi(`/${EXTENSION_PREFIX}/image_dir_preview/context_menu`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ node_id: node.id, item_index: itemIndex, tab_id: HPS_TAB_ID }), + body: JSON.stringify({ node_id: node.id, item_index: itemIndex, preview_session_id: st.preview_session_id || "", tab_id: HPS_TAB_ID }), }); return await response.json(); } async function setImageDirCheckpointThumbnail(node, itemIndex) { + const st = node.__hpsPreviewState || {}; const response = await api.fetchApi(`/${EXTENSION_PREFIX}/image_dir_preview/set_thumbnail`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ node_id: node.id, item_index: itemIndex, tab_id: HPS_TAB_ID }), + body: JSON.stringify({ node_id: node.id, item_index: itemIndex, preview_session_id: st.preview_session_id || "", tab_id: HPS_TAB_ID }), }); const result = await response.json(); if (result?.ok) { @@ -954,54 +1030,11 @@ async function openImageDirContextMenu(node, item, event) { } } - -let imageDirContextCaptureInstalled = false; - -function findImageDirContextTarget(event) { - const nodes = [...(app.graph?._nodes || [])].reverse(); - for (const node of nodes) { - if (!node || node.flags?.collapsed || !isNodeClass(node, "ImageDirPreview")) continue; - const local = graphEventToLocal(node, event); - if (!hpsLocalInsideNode(node, local)) continue; - const item = imageDirPreviewItemAtLocal(node, local); - // Return the node even when the click is on ImageDirPreview blank space. - // Blank-space right-clicks should suppress the standard ComfyUI menu but - // must not open an action menu. - return { node, item }; - } - return null; -} - -function installImageDirContextMenuCapture() { - if (imageDirContextCaptureInstalled) return; - const canvasEl = app.canvas?.canvas; - if (!canvasEl) return; - imageDirContextCaptureInstalled = true; - - canvasEl.addEventListener("contextmenu", (event) => { - const target = findImageDirContextTarget(event); - if (!target) return; - - // Any right-click inside ImageDirPreview is owned by HPS. This prevents - // browser/LiteGraph menus from leaking through blank areas. - event.preventDefault?.(); - event.stopPropagation?.(); - event.stopImmediatePropagation?.(); - - if (target.item) { - openImageDirContextMenu(target.node, target.item, event); - } else { - hideHpsContextMenu(); - } - return false; - }, true); -} - function setupPreviewNode(nodeType) { installMinSize(nodeType, 340, 300); installTabIdSupport(nodeType); installCursorCapture(); - installImageDirContextMenuCapture(); + installImageDirHoverCapture(); const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const r = origCreated ? origCreated.apply(this, arguments) : undefined; @@ -1054,6 +1087,19 @@ function setupPreviewNode(nodeType) { const drawBox = previewDrawBox(this); if (img && drawBox) { ctx.drawImage(img, drawBox.x, drawBox.y, drawBox.w, drawBox.h); + if (g.isImageDir && this.__hpsHoveredPreviewIndex != null) { + const rect = imageDirPreviewTileRect(this, this.__hpsHoveredPreviewIndex); + if (rect) { + ctx.save(); + ctx.strokeStyle = "rgba(255,255,255,0.95)"; + ctx.lineWidth = 2; + ctx.strokeRect(rect.x + 1, rect.y + 1, Math.max(1, rect.w - 2), Math.max(1, rect.h - 2)); + ctx.strokeStyle = "rgba(0,0,0,0.75)"; + ctx.lineWidth = 1; + ctx.strokeRect(rect.x + 3, rect.y + 3, Math.max(1, rect.w - 6), Math.max(1, rect.h - 6)); + ctx.restore(); + } + } } else { const st = this.__hpsPreviewState || {}; const isLoading = st.status === "loading" || !!st.progress; @@ -1080,17 +1126,22 @@ function setupPreviewNode(nodeType) { ctx.restore(); }; - const origMouseDown = nodeType.prototype.onRightClick; - nodeType.prototype.onRightClick = function (event, local_pos) { - // LiteGraphの onRightClick は第2引数にすでにローカル座標 (local_pos) が渡ってくる - if (isNodeClass(this, "ImageDirPreview")) { - if (hpsLocalInsideNode(this, local_pos)) { - // true を返すことで、LiteGraphの標準ノードメニュー展開を完全にブロックする! - // (実際の独自メニュー表示は、既存の contextmenu イベントキャプチャ側が担当する) + const origMouseDown = nodeType.prototype.onMouseDown; + nodeType.prototype.onMouseDown = function (event, pos, canvas) { + if (isNodeClass(this, "ImageDirPreview") && event?.button === 0) { + const local = previewLocalFromEventOrPos(this, event, pos); + const item = imageDirPreviewItemAtLocal(this, local); + if (item) { + event.preventDefault?.(); + event.stopPropagation?.(); + event.stopImmediatePropagation?.(); + setImageDirHover(this, item); + openImageDirContextMenu(this, item, event); return true; } + hideHpsContextMenu(); } - return origRightClick ? origRightClick.apply(this, arguments) : false; + return origMouseDown ? origMouseDown.apply(this, arguments) : false; }; } @@ -1118,6 +1169,7 @@ api.addEventListener(PREVIEW_EVENT, ({ detail }) => { } node.__hpsPreviewState = { ...(node.__hpsPreviewState || {}), ...detail }; + if (isNodeClass(node, "ImageDirPreview")) node.__hpsHoveredPreviewIndex = null; const caption = detail.progress_message || detail.message || `${detail.count ?? 0} img · ${detail.columns ?? 0}×${detail.rows ?? 0} · ${detail.width ?? 0}×${detail.height ?? 0}`; node.__hpsPreviewCaption = caption; if (!detail.image) {