diff --git a/web_src/load_mesh_batch_path.js b/web_src/load_mesh_batch_path.js index 4269431a..45eed1fe 100644 --- a/web_src/load_mesh_batch_path.js +++ b/web_src/load_mesh_batch_path.js @@ -1,13 +1,13 @@ // Load Mesh Batch: folder-path autocomplete (type to navigate directories), // inspired by VHS "Load Video (Path)" but using GeometryPack's own /gpack/getpath -// route and restricted to FOLDERS. Replaces the plain folder_path text widget -// with a real + so the browser shows live folder suggestions. +// route and restricted to FOLDERS. Uses a CUSTOM floating dropdown (native +// is unreliable inside ComfyUI's transformed DOM widgets). import { app } from "../../../scripts/app.js"; const TAG = "[MeshBatchPath]"; console.log(`${TAG} script loaded`); -// Split "output/meshes/fo" -> ["output/meshes/", "fo"] (dir, remainder) +// "output/meshes/fo" -> ["output/meshes/", "fo"] (dir, remainder) function pathStem(value) { const v = String(value || ""); const i = v.lastIndexOf("/"); @@ -25,8 +25,8 @@ app.registerExtension({ const r = onCreated?.apply(this, arguments); const node = this; - // Remove the original folder_path string widget; we replace it with a - // DOM input of the same name so it still serializes as folder_path. + // Replace the plain folder_path string widget with a DOM input of the + // same name (so it still serializes as folder_path). const idx = (node.widgets || []).findIndex((w) => w.name === "folder_path"); const initial = idx >= 0 ? (node.widgets[idx].value ?? "3d") : "3d"; if (idx >= 0) node.widgets.splice(idx, 1); @@ -36,15 +36,10 @@ app.registerExtension({ const input = document.createElement("input"); input.type = "text"; input.value = initial; - input.placeholder = "folder path — type to autocomplete (e.g. output/…)"; - const listId = `gpack_paths_${node.id}_${Math.floor(performance.now())}`; - input.setAttribute("list", listId); + input.placeholder = "folder path — type to autocomplete (input/… output/… or absolute)"; input.style.cssText = "width:100%;box-sizing:border-box;background:#222;color:#ddd;" + "border:1px solid #444;border-radius:4px;padding:3px 6px;font:12px monospace;"; - const dl = document.createElement("datalist"); - dl.id = listId; wrap.appendChild(input); - wrap.appendChild(dl); const widget = node.addDOMWidget("folder_path", "gpack_path", wrap, { getValue() { return input.value; }, @@ -53,27 +48,63 @@ app.registerExtension({ }); widget.computeSize = (w) => [w, 30]; - // Refetch directory contents only when the directory part changes. + // Floating dropdown attached to body (avoids canvas clipping/transform). + const menu = document.createElement("div"); + menu.style.cssText = "position:fixed;z-index:10000;max-height:240px;overflow-y:auto;" + + "background:#1e1e1e;border:1px solid #555;border-radius:4px;" + + "box-shadow:0 4px 14px rgba(0,0,0,0.55);font:12px monospace;display:none;"; + document.body.appendChild(menu); + + let items = []; let lastDir = null; - const refresh = async () => { + const hide = () => { menu.style.display = "none"; }; + const place = () => { + const b = input.getBoundingClientRect(); + menu.style.left = b.left + "px"; + menu.style.top = (b.bottom + 2) + "px"; + menu.style.minWidth = b.width + "px"; + }; + const render = (rem) => { + const remL = rem.toLowerCase(); const [dir] = pathStem(input.value); - if (dir === lastDir) return; + const matches = items.filter( + (it) => it.endsWith("/") && it.toLowerCase().startsWith(remL)); + menu.innerHTML = ""; + if (!matches.length) { hide(); return; } + for (const it of matches) { + const row = document.createElement("div"); + row.textContent = it; + row.style.cssText = "padding:4px 9px;color:#ddd;cursor:pointer;white-space:nowrap;"; + row.addEventListener("mouseenter", () => { row.style.background = "#37506b"; }); + row.addEventListener("mouseleave", () => { row.style.background = ""; }); + row.addEventListener("mousedown", (e) => { + e.preventDefault(); // keep input focused + input.value = dir + it; // descend into the folder + fetchDir(true); // list its contents + }); + menu.appendChild(row); + } + place(); + menu.style.display = "block"; + }; + const fetchDir = async (force) => { + const [dir, rem] = pathStem(input.value); + if (!force && dir === lastDir) { render(rem); return; } lastDir = dir; try { const resp = await fetch(`/gpack/getpath?path=${encodeURIComponent(dir || ".")}`); - if (!resp.ok) return; - const items = await resp.json(); - dl.innerHTML = ""; - for (const it of items) { - if (!it.endsWith("/")) continue; // folders only - const opt = document.createElement("option"); - opt.value = dir + it; // full path so it chains - dl.appendChild(opt); - } - } catch (e) { /* route missing / offline -> stays plain text */ } + items = resp.ok ? await resp.json() : []; + } catch (e) { items = []; } + render(rem); }; - input.addEventListener("input", refresh); - input.addEventListener("focus", () => { lastDir = null; refresh(); }); + + input.addEventListener("input", () => fetchDir(false)); + input.addEventListener("focus", () => { lastDir = null; fetchDir(true); }); + input.addEventListener("blur", () => setTimeout(hide, 200)); + input.addEventListener("keydown", (e) => { if (e.key === "Escape") hide(); }); + + const origRemoved = node.onRemoved; + node.onRemoved = function () { menu.remove(); return origRemoved?.apply(this, arguments); }; return r; };