722 lines
35 KiB
JavaScript
722 lines
35 KiB
JavaScript
import { openOverlay } from "./modal.js";
|
|
import { css, makeButton, makeComfyInput, makeComfyValueSlider, familyFolder, renderFlakeLabel } from "./utils.js";
|
|
import { getCoverUrl, getVariantImageUrl, fetchBrowse, fetchLoras, fetchCheckpoints, fetchAnimFamily, pickedImageUrl } from "./api.js";
|
|
|
|
const BROWSE_TYPE_LABELS = {
|
|
checkpoints: "models/checkpoints",
|
|
loras: "models/loras",
|
|
vae: "models/vae",
|
|
text_encoders: "models/text_encoders",
|
|
diffusion_models: "models/diffusion_models",
|
|
flakes: "models/flakes",
|
|
inputs: "input",
|
|
output: "output",
|
|
flake_images: "flakes",
|
|
models: "models",
|
|
lora_images: "models/loras",
|
|
};
|
|
|
|
const withoutPresets = (names) => names.filter(n => {
|
|
const norm = n.replace(/\\/g, "/");
|
|
return !norm.startsWith("model_presets/") && norm !== "model_presets";
|
|
});
|
|
|
|
// `kind` "anim" picks animate flakes: `family` is an animate family label and
|
|
// the picker opens in its anim/<folder>.
|
|
export async function openFileLoadPicker({ flakes, directories, family = "", kind = "img", displayNames = {}, tagNames = {}, favorites = [], folderCovers = {} }) {
|
|
const animFolder = kind === "anim" ? (await fetchAnimFamily(family).catch(() => null))?.folder : null;
|
|
return openNamePicker({
|
|
title: "Load existing flake", kind: "flakes", drivesKind: kind === "anim" ? "anim" : "flakes", noun: "flake", base: "models/flakes/",
|
|
names: withoutPresets(flakes), directories: withoutPresets(directories), family, startFolder: animFolder ? `anim/${animFolder}` : null,
|
|
coverUrl: getCoverUrl, favorites, folderCovers,
|
|
// Stacked label with a dimmer tag line, matching the grid (#297).
|
|
renderLabel: (el, name) => renderFlakeLabel(el, { name: displayNames[name] || name.split("/").pop(), tags: tagNames[name] || [] }),
|
|
});
|
|
}
|
|
|
|
export async function openPresetPicker({ selected = "", family = "", kind = "img" } = {}) {
|
|
let presets = [];
|
|
let displayNames = {};
|
|
try {
|
|
const query = new URLSearchParams({ kind, ...(family ? { family } : {}) });
|
|
const d = await (await fetch(`/flakes/presets?${query}`, { cache: "no-store" })).json();
|
|
presets = d.presets || [];
|
|
displayNames = d.display_names || {};
|
|
} catch (err) {
|
|
console.error("[flakes] failed to load presets for picker:", err);
|
|
}
|
|
// Animate presets open in anim/<folder>; image ones derive img/<folder> from the family.
|
|
const animFolder = kind === "anim" ? (await fetchAnimFamily(family).catch(() => null))?.folder : null;
|
|
return openNamePicker({
|
|
title: "Select a Preset", kind: "presets", noun: "preset", base: "model_presets/",
|
|
names: presets, family, selected, startFolder: animFolder ? `anim/${animFolder}` : null,
|
|
coverUrl: (name) => `/flakes/preset_cover?name=${encodeURIComponent(name)}`,
|
|
renderLabel: (el, name) => { el.textContent = displayNames[name] || name.replace(/\\/g, "/").split("/").pop(); },
|
|
});
|
|
}
|
|
|
|
// Grid picker over slash-separated names (flakes or presets): folders first,
|
|
// natural case-insensitive order, 7 tiles per row, and drive tabs (C:, D:, ...)
|
|
// when the names span several drives. One drive is shown at a time, an extra
|
|
// model path's by default. Without `directories`, folders come from the names.
|
|
// `drivesKind` is the /flakes/drives kind when it isn't `kind`.
|
|
// Resolves to {name} or null.
|
|
function openNamePicker({ title, kind, drivesKind = kind, noun, base, names, directories = null, family, startFolder = null, selected = "", coverUrl, renderLabel, favorites = [], folderCovers = {} }) {
|
|
return new Promise((resolve) => {
|
|
const { content, footer, close, handlers } = openOverlay();
|
|
handlers.onClose = (v) => resolve(v ?? null);
|
|
css(content.parentElement, content.parentElement.style.cssText + "width:min(1100px,94vw);max-width:94vw;");
|
|
|
|
const headerRow = document.createElement("div");
|
|
css(headerRow, "display:flex;align-items:center;gap:4px;margin-bottom:8px;");
|
|
const titleEl = document.createElement("h3");
|
|
css(titleEl, "margin:0;font-size:16px;color:#fff;font-weight:500;");
|
|
titleEl.textContent = title;
|
|
headerRow.appendChild(titleEl);
|
|
const driveTabs = document.createElement("div");
|
|
css(driveTabs, "display:flex;gap:4px;margin-left:auto;");
|
|
headerRow.appendChild(driveTabs);
|
|
content.appendChild(headerRow);
|
|
let drives = { drives: [] };
|
|
let currentDrive = "";
|
|
const onDrive = (map, name) => !currentDrive || !map?.[name] || map[name].includes(currentDrive);
|
|
const visibleNames = () => names.filter(n => onDrive(drives[kind], n));
|
|
const visibleDirs = () => directories
|
|
? directories.filter(d => onDrive(drives.directories, d))
|
|
: visibleNames().flatMap(n => normPath(n).split("/").slice(0, -1).map((_, i, parts) => parts.slice(0, i + 1).join("/")));
|
|
|
|
function renderDriveTabs() {
|
|
driveTabs.replaceChildren();
|
|
if (drives.drives.length < 2) return;
|
|
for (const d of drives.drives) {
|
|
const tab = makeButton(d);
|
|
tab.style.borderColor = d === currentDrive ? "#2a6acf" : "#444";
|
|
tab.addEventListener("click", () => {
|
|
currentDrive = d;
|
|
renderDriveTabs();
|
|
renderGrid(searchInput.value);
|
|
});
|
|
driveTabs.appendChild(tab);
|
|
}
|
|
}
|
|
|
|
const pathBar = document.createElement("div");
|
|
css(pathBar, "font-size:11px;color:#888;margin-bottom:6px;word-break:break-all;cursor:pointer;");
|
|
content.appendChild(pathBar);
|
|
|
|
const searchRow = document.createElement("div");
|
|
css(searchRow, "margin-bottom:8px;");
|
|
const searchInput = makeComfyInput("", `Search ${noun}s...`);
|
|
searchRow.appendChild(searchInput);
|
|
content.appendChild(searchRow);
|
|
|
|
const grid = document.createElement("div");
|
|
css(grid, "display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;max-height:65vh;overflow:auto;align-content:start;");
|
|
content.appendChild(grid);
|
|
|
|
const folder = familyFolder(family);
|
|
let currentFolder = startFolder ?? (folder ? `img/${folder}` : "");
|
|
let selectedName = selected || null;
|
|
let selectedEl = null;
|
|
|
|
function normPath(p) {
|
|
return p.replace(/\\/g, "/");
|
|
}
|
|
|
|
function getSubfolders(folder) {
|
|
const prefix = folder ? normPath(folder) + "/" : "";
|
|
const subs = new Set();
|
|
for (const d of visibleDirs()) {
|
|
const n = normPath(d);
|
|
if (!n.startsWith(prefix)) continue;
|
|
const rest = n.slice(prefix.length);
|
|
if (!rest || rest.includes("/")) continue;
|
|
subs.add(rest);
|
|
}
|
|
return [...subs].sort(naturalOrder);
|
|
}
|
|
|
|
function getNamesInFolder(folder) {
|
|
const prefix = folder ? normPath(folder) + "/" : "";
|
|
return visibleNames().filter(f => {
|
|
const n = normPath(f);
|
|
if (!prefix) return !n.includes("/");
|
|
if (!n.startsWith(prefix)) return false;
|
|
return !n.slice(prefix.length).includes("/");
|
|
}).sort((x, y) => naturalOrder(x.split("/").pop(), y.split("/").pop()));
|
|
}
|
|
|
|
function renderBreadcrumb() {
|
|
const drive = drives.drives.length > 1 ? `${currentDrive} ` : "";
|
|
pathBar.textContent = drive + base + (currentFolder ? normPath(currentFolder) + "/" : "");
|
|
}
|
|
|
|
function showEmpty() {
|
|
const empty = document.createElement("div");
|
|
empty.textContent = `No ${noun}s found.`;
|
|
css(empty, "opacity:0.5;font-style:italic;padding:12px;text-align:center;grid-column:1 / -1;");
|
|
grid.appendChild(empty);
|
|
}
|
|
|
|
function openFolder(path) {
|
|
currentFolder = path;
|
|
searchInput.value = "";
|
|
renderGrid();
|
|
}
|
|
|
|
function renderGrid(filter = "") {
|
|
grid.replaceChildren();
|
|
selectedEl = null;
|
|
const term = filter.toLowerCase().trim();
|
|
renderBreadcrumb();
|
|
|
|
if (term) {
|
|
// Search mode: scoped to currentFolder so the breadcrumb stays meaningful
|
|
const prefix = currentFolder ? normPath(currentFolder) + "/" : "";
|
|
const filtered = visibleNames().filter(n => normPath(n).startsWith(prefix) && normPath(n).slice(prefix.length).toLowerCase().includes(term));
|
|
if (filtered.length === 0) return showEmpty();
|
|
for (const name of filtered) grid.appendChild(makeThumb(name));
|
|
return;
|
|
}
|
|
|
|
const subfolders = getSubfolders(currentFolder);
|
|
const folderNames = getNamesInFolder(currentFolder);
|
|
|
|
if (currentFolder) {
|
|
const parts = normPath(currentFolder).split("/").filter(Boolean);
|
|
parts.pop();
|
|
grid.appendChild(makeFolderTile("↑", "..", () => openFolder(parts.join("/"))));
|
|
}
|
|
for (const sub of subfolders) {
|
|
const path = currentFolder ? normPath(currentFolder) + "/" + sub : sub;
|
|
grid.appendChild(makeFolderTile("📁", sub, () => openFolder(path), folderCovers[path]));
|
|
}
|
|
for (const name of folderNames) grid.appendChild(makeThumb(name));
|
|
if (subfolders.length === 0 && folderNames.length === 0) showEmpty();
|
|
}
|
|
|
|
// `cover` names the flake whose cover dims behind the icon (#424).
|
|
function makeFolderTile(icon, label, onClick, cover) {
|
|
const item = document.createElement("div");
|
|
const bg = cover ? `background-image:linear-gradient(rgba(0,0,0,0.6),rgba(0,0,0,0.6)),url(${coverUrl(cover)});background-size:cover;background-position:center;` : "";
|
|
css(item, bg + "aspect-ratio:1/1;background-color:#252525;border:1px solid #444;border-radius:4px;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;user-select:none;box-sizing:border-box;");
|
|
const iconEl = document.createElement("div");
|
|
iconEl.textContent = icon;
|
|
css(iconEl, label === ".." ? "font-size:24px;color:#888;" : "font-size:32px;");
|
|
const labelEl = document.createElement("div");
|
|
labelEl.textContent = label;
|
|
css(labelEl, "font-size:10px;color:#ddd;text-align:center;padding:0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%;");
|
|
item.appendChild(iconEl);
|
|
item.appendChild(labelEl);
|
|
item.addEventListener("click", onClick);
|
|
item.addEventListener("dblclick", (e) => e.stopPropagation());
|
|
return item;
|
|
}
|
|
|
|
function makeThumb(name) {
|
|
const thumb = document.createElement("div");
|
|
css(thumb, `position:relative;aspect-ratio:1/1;background:#2a2a2a;border:1px solid ${name === selectedName ? "#2a6acf" : "#444"};border-radius:4px;cursor:pointer;font-size:10px;color:#ddd;user-select:none;box-sizing:border-box;overflow:hidden;background-image:url(${coverUrl(name)});background-size:cover;background-position:center;`);
|
|
if (name === selectedName) selectedEl = thumb;
|
|
|
|
const overlay = document.createElement("div");
|
|
css(overlay, "position:absolute;inset:0;background:rgba(0,0,0,0.45);pointer-events:none;z-index:0;");
|
|
thumb.appendChild(overlay);
|
|
|
|
const nameEl = document.createElement("div");
|
|
nameEl.title = name;
|
|
css(nameEl, "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500;text-align:center;line-height:1.2;text-shadow:0 1px 3px rgba(0,0,0,0.8);padding:0 4px;overflow:hidden;z-index:1;word-break:break-word;hyphens:auto;");
|
|
renderLabel(nameEl, name);
|
|
thumb.appendChild(nameEl);
|
|
|
|
if (favorites.includes(name)) {
|
|
const star = document.createElement("div");
|
|
star.textContent = "★";
|
|
css(star, "position:absolute;top:2px;right:4px;color:#f5c518;font-size:12px;line-height:1;text-shadow:0 1px 3px rgba(0,0,0,0.9);pointer-events:none;z-index:2;");
|
|
thumb.appendChild(star);
|
|
}
|
|
|
|
thumb.addEventListener("click", () => {
|
|
if (selectedEl) selectedEl.style.borderColor = "#444";
|
|
selectedName = name;
|
|
selectedEl = thumb;
|
|
thumb.style.borderColor = "#2a6acf";
|
|
});
|
|
thumb.addEventListener("dblclick", () => close({ name }));
|
|
return thumb;
|
|
}
|
|
|
|
pathBar.addEventListener("click", () => openFolder(""));
|
|
|
|
renderGrid();
|
|
searchInput.addEventListener("input", () => renderGrid(searchInput.value));
|
|
searchInput.focus();
|
|
fetch(`/flakes/drives?kind=${drivesKind}&family=${encodeURIComponent(family || "")}`).then(r => r.json()).then((d) => {
|
|
drives = d;
|
|
currentDrive = d.default || "";
|
|
renderDriveTabs();
|
|
renderGrid(searchInput.value);
|
|
}).catch(() => {});
|
|
|
|
const cancelBtn = makeButton("Cancel");
|
|
cancelBtn.addEventListener("click", () => close(undefined));
|
|
footer.appendChild(cancelBtn);
|
|
|
|
const selectBtn = makeButton("Select", true);
|
|
selectBtn.addEventListener("click", () => {
|
|
if (!selectedName) {
|
|
window.alert(`Select a ${noun} first.`);
|
|
return;
|
|
}
|
|
close({ name: selectedName });
|
|
});
|
|
footer.appendChild(selectBtn);
|
|
});
|
|
}
|
|
|
|
// Flake Combo "Multiple ↗" (#406): check several choices of one variant group.
|
|
// The slider sets how many choices Randomize checks; each press re-rolls.
|
|
// Resolves to the checked choices in group order, or null on cancel.
|
|
export function openVariantMultiPicker({ flakeName, group, choices, selected = [] }) {
|
|
return new Promise((resolve) => {
|
|
const { content, footer, close, handlers } = openOverlay();
|
|
handlers.onClose = (v) => resolve(v ?? null);
|
|
const checked = new Set(selected.filter(c => choices.includes(c)));
|
|
|
|
const titleEl = document.createElement("h3");
|
|
css(titleEl, "margin:0 0 8px;font-size:16px;color:#fff;font-weight:500;");
|
|
titleEl.textContent = group;
|
|
content.appendChild(titleEl);
|
|
|
|
const topRow = document.createElement("div");
|
|
css(topRow, "display:flex;align-items:center;gap:8px;margin-bottom:8px;");
|
|
const slider = makeComfyValueSlider(checked.size, 0, choices.length, 1);
|
|
slider.style.flex = "1";
|
|
const randomBtn = makeButton("Randomize");
|
|
randomBtn.style.height = "32px";
|
|
randomBtn.addEventListener("click", () => {
|
|
const pool = [...choices];
|
|
for (let i = pool.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[pool[i], pool[j]] = [pool[j], pool[i]];
|
|
}
|
|
checked.clear();
|
|
for (const c of pool.slice(0, slider.getValue())) checked.add(c);
|
|
refreshTiles();
|
|
});
|
|
topRow.append(slider, randomBtn);
|
|
content.appendChild(topRow);
|
|
|
|
const grid = document.createElement("div");
|
|
css(grid, "display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:6px;max-height:65vh;overflow:auto;align-content:start;");
|
|
content.appendChild(grid);
|
|
|
|
// Tiles show the choice image, or its initial when it has none.
|
|
const tiles = choices.map((choice) => {
|
|
const tile = document.createElement("div");
|
|
const initial = document.createElement("div");
|
|
initial.textContent = choice.trim().charAt(0).toUpperCase();
|
|
css(initial, "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:36px;font-weight:600;color:#555;");
|
|
const img = document.createElement("img");
|
|
img.src = getVariantImageUrl(flakeName, group, choice);
|
|
img.addEventListener("error", () => img.remove());
|
|
css(img, "position:absolute;inset:0;width:100%;height:100%;object-fit:cover;");
|
|
const nameEl = document.createElement("div");
|
|
nameEl.textContent = choice;
|
|
nameEl.title = choice;
|
|
css(nameEl, "position:absolute;left:0;right:0;bottom:0;padding:12px 4px 4px;font-size:11px;font-weight:500;text-align:center;color:#fff;background:linear-gradient(transparent,rgba(0,0,0,0.85));overflow:hidden;text-overflow:ellipsis;white-space:nowrap;");
|
|
const mark = document.createElement("div");
|
|
mark.textContent = "✓";
|
|
css(mark, "position:absolute;top:4px;right:4px;width:20px;height:20px;border-radius:50%;background:#2a6acf;color:#fff;font-size:13px;font-weight:700;align-items:center;justify-content:center;box-shadow:0 1px 3px rgba(0,0,0,0.8);");
|
|
tile.append(initial, img, nameEl, mark);
|
|
tile.addEventListener("click", () => {
|
|
if (checked.has(choice)) checked.delete(choice);
|
|
else checked.add(choice);
|
|
refreshTiles();
|
|
});
|
|
grid.appendChild(tile);
|
|
return { tile, mark, choice };
|
|
});
|
|
function refreshTiles() {
|
|
for (const { tile, mark, choice } of tiles) {
|
|
const on = checked.has(choice);
|
|
css(tile, `position:relative;aspect-ratio:1/1;background:#252525;border:2px solid ${on ? "#2a6acf" : "#444"};border-radius:4px;cursor:pointer;user-select:none;box-sizing:border-box;overflow:hidden;${on ? "" : "opacity:0.55;"}`);
|
|
mark.style.display = on ? "flex" : "none";
|
|
}
|
|
}
|
|
refreshTiles();
|
|
|
|
const cancelBtn = makeButton("Cancel");
|
|
cancelBtn.addEventListener("click", () => close(undefined));
|
|
footer.appendChild(cancelBtn);
|
|
|
|
const okBtn = makeButton("OK", true);
|
|
okBtn.addEventListener("click", () => close(choices.filter(c => checked.has(c))));
|
|
footer.appendChild(okBtn);
|
|
});
|
|
}
|
|
|
|
const BROWSE_TITLES = {
|
|
checkpoints: "Select Checkpoint", loras: "Select LoRA", flakes: "Select Flake",
|
|
vae: "Select VAE", text_encoders: "Select Text Encoder", diffusion_models: "Select Diffusion Model",
|
|
};
|
|
// Image picks can switch between these ComfyUI folders.
|
|
export const IMAGE_ROOTS = ["inputs", "output", "flake_images", "models", "lora_images"];
|
|
const ROOT_TAB_LABELS = { lora_images: "loras" };
|
|
|
|
const naturalOrder = (a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
|
|
|
|
// Grid picker in the style of Explorer's thumbnail view: folders first, natural
|
|
// case-insensitive order, 7 tiles per row. `roots` lists the browse types the
|
|
// user can switch between. Resolves to {file, path, type} — `file` relative to
|
|
// the root, `path` absolute (absent for LoRA/checkpoint search hits) — or null.
|
|
export function openFileBrowser({ type, defaultPath = "", roots = [type] }) {
|
|
return new Promise((resolve) => {
|
|
const { content, footer, close, handlers } = openOverlay();
|
|
handlers.onClose = (v) => resolve(v ?? null);
|
|
css(content.parentElement, content.parentElement.style.cssText + "width:min(1100px,94vw);max-width:94vw;");
|
|
|
|
const title = document.createElement("h3");
|
|
css(title, "margin:0 0 8px;font-size:16px;color:#fff;font-weight:500;");
|
|
title.textContent = BROWSE_TITLES[type] || "Select Image";
|
|
content.appendChild(title);
|
|
|
|
let currentType = type;
|
|
// Root tabs on the left; drive tabs (C:, D:, ...) on the right when the
|
|
// root has folders on several drives — drives are never merged. The
|
|
// server picks the extra model path's drive until one is clicked.
|
|
let currentDrive = "";
|
|
let chosenDrive = "";
|
|
const tabsRow = document.createElement("div");
|
|
css(tabsRow, "display:flex;gap:4px;margin-bottom:8px;");
|
|
const tabs = document.createElement("div");
|
|
css(tabs, "display:flex;gap:4px;");
|
|
const driveTabs = document.createElement("div");
|
|
css(driveTabs, "display:flex;gap:4px;margin-left:auto;");
|
|
tabsRow.appendChild(tabs);
|
|
tabsRow.appendChild(driveTabs);
|
|
content.appendChild(tabsRow);
|
|
if (roots.length < 2) tabs.style.display = "none";
|
|
|
|
const pathBar = document.createElement("div");
|
|
css(pathBar, "font-size:11px;color:#888;margin-bottom:8px;word-break:break-all;");
|
|
content.appendChild(pathBar);
|
|
|
|
const searchRow = document.createElement("div");
|
|
css(searchRow, "margin-bottom:8px;");
|
|
const searchInput = makeComfyInput("", "Search...");
|
|
searchRow.appendChild(searchInput);
|
|
content.appendChild(searchRow);
|
|
|
|
const grid = document.createElement("div");
|
|
css(grid, "display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;max-height:65vh;overflow:auto;align-content:start;");
|
|
content.appendChild(grid);
|
|
|
|
let currentPath = defaultPath;
|
|
let selected = null;
|
|
let selectedTile = null;
|
|
let dirEntries = [];
|
|
let allFlatItems = []; // full flat list for search (loras/checkpoints only)
|
|
|
|
const joinPath = (name) => currentPath ? currentPath.replace(/\\/g, "/") + "/" + name : name;
|
|
|
|
function iconFace(icon) {
|
|
const face = document.createElement("div");
|
|
face.textContent = icon;
|
|
css(face, "display:flex;align-items:center;justify-content:center;font-size:44px;");
|
|
return face;
|
|
}
|
|
|
|
// Images show themselves; LoRAs and checkpoints show their sibling
|
|
// preview image, falling back to a file icon.
|
|
function fileFace(file, path) {
|
|
let src = null;
|
|
if (IMAGE_ROOTS.includes(currentType)) src = `/flakes/image?path=${encodeURIComponent(path)}`;
|
|
else if (currentType === "loras") src = `/flakes/lora_sibling_image?path=${encodeURIComponent(file)}`;
|
|
else if (currentType === "checkpoints") src = `/flakes/checkpoint_sibling_image?path=${encodeURIComponent(file)}`;
|
|
if (!src) return iconFace("📄");
|
|
const img = document.createElement("img");
|
|
img.loading = "lazy";
|
|
img.src = src;
|
|
img.addEventListener("error", () => {
|
|
const icon = iconFace("📄");
|
|
css(icon, icon.style.cssText + "width:100%;aspect-ratio:1/1;background:#1a1a1a;border-radius:3px;pointer-events:none;");
|
|
img.replaceWith(icon);
|
|
});
|
|
css(img, "object-fit:contain;");
|
|
return img;
|
|
}
|
|
|
|
function makeTile(label, face, onClick, onDblClick) {
|
|
const tile = document.createElement("div");
|
|
tile.title = label;
|
|
css(tile, "display:flex;flex-direction:column;gap:4px;padding:4px;background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:4px;cursor:pointer;min-width:0;user-select:none;");
|
|
css(face, face.style.cssText + "width:100%;aspect-ratio:1/1;background:#1a1a1a;border-radius:3px;pointer-events:none;");
|
|
const cap = document.createElement("span");
|
|
cap.textContent = label;
|
|
css(cap, "font-size:10px;color:#aaa;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;");
|
|
tile.appendChild(face);
|
|
tile.appendChild(cap);
|
|
tile.addEventListener("mouseenter", () => { tile.style.background = "#333"; });
|
|
tile.addEventListener("mouseleave", () => { tile.style.background = "#2a2a2a"; });
|
|
tile.addEventListener("click", () => onClick(tile));
|
|
if (onDblClick) tile.addEventListener("dblclick", onDblClick);
|
|
return tile;
|
|
}
|
|
|
|
function fileTile(label, file, path) {
|
|
const pick = { file, path, type: currentType };
|
|
return makeTile(label, fileFace(file, path), (tile) => {
|
|
if (selectedTile) selectedTile.style.borderColor = "#444";
|
|
selected = pick;
|
|
selectedTile = tile;
|
|
tile.style.borderColor = "#2a6acf";
|
|
}, () => close(pick));
|
|
}
|
|
|
|
function showEmpty(text) {
|
|
const empty = document.createElement("div");
|
|
empty.textContent = text;
|
|
css(empty, "opacity:0.5;font-style:italic;padding:12px;text-align:center;font-size:12px;grid-column:1 / -1;");
|
|
grid.appendChild(empty);
|
|
}
|
|
|
|
function renderEntries(filter = "") {
|
|
grid.replaceChildren();
|
|
selected = null;
|
|
selectedTile = null;
|
|
const term = String(filter || "").toLowerCase().trim();
|
|
|
|
// When searching, use the full flat list if available
|
|
if (term && allFlatItems.length > 0) {
|
|
const filtered = allFlatItems.filter(p => String(p).toLowerCase().includes(term)).sort(naturalOrder);
|
|
for (const filePath of filtered) {
|
|
grid.appendChild(fileTile(filePath.replace(/\\/g, "/").split("/").pop(), filePath));
|
|
}
|
|
if (filtered.length === 0) showEmpty("No matches");
|
|
return;
|
|
}
|
|
|
|
const entries = term
|
|
? dirEntries.filter(e => String(e.name || "").toLowerCase().includes(term))
|
|
: dirEntries;
|
|
|
|
if (currentPath) {
|
|
grid.appendChild(makeTile("..", iconFace("⬆"), () => {
|
|
const parts = currentPath.replace(/\\/g, "/").split("/").filter(Boolean);
|
|
parts.pop();
|
|
loadDir(parts.join("/"));
|
|
}));
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.type === "dir") {
|
|
grid.appendChild(makeTile(entry.name, iconFace("📁"), () => loadDir(joinPath(entry.name))));
|
|
} else {
|
|
grid.appendChild(fileTile(entry.name, joinPath(entry.name), entry.path));
|
|
}
|
|
}
|
|
if (entries.length === 0) showEmpty(term ? "No matches" : "Empty folder");
|
|
}
|
|
|
|
function fmtPath(rel) {
|
|
const r = (rel || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
const baseLabel = (currentDrive && driveTabs.childElementCount ? `${currentDrive} ` : "") + (BROWSE_TYPE_LABELS[currentType] || currentType);
|
|
return r ? `${baseLabel}/${r}/` : `${baseLabel}/`;
|
|
}
|
|
|
|
function renderDriveTabs(drives) {
|
|
driveTabs.replaceChildren();
|
|
if (drives.length < 2) return;
|
|
for (const d of drives) {
|
|
const tab = makeButton(d);
|
|
tab.style.borderColor = d === currentDrive ? "#2a6acf" : "#444";
|
|
tab.addEventListener("click", () => {
|
|
chosenDrive = d;
|
|
loadDir(currentPath);
|
|
});
|
|
driveTabs.appendChild(tab);
|
|
}
|
|
}
|
|
|
|
async function loadDir(path) {
|
|
pathBar.textContent = fmtPath(path);
|
|
searchInput.value = "";
|
|
try {
|
|
const data = await fetchBrowse(currentType, path, chosenDrive);
|
|
currentDrive = data.drive || "";
|
|
renderDriveTabs(data.drives || []);
|
|
currentPath = data.path || "";
|
|
pathBar.textContent = fmtPath(currentPath);
|
|
// Folders first, then natural case-insensitive name order (Explorer).
|
|
dirEntries = (data.entries || []).sort((a, b) => (a.type === "dir" ? 0 : 1) - (b.type === "dir" ? 0 : 1)
|
|
|| naturalOrder(a.name, b.name));
|
|
renderEntries();
|
|
} catch (err) {
|
|
if (path) {
|
|
// Requested directory does not exist — open its closest parent
|
|
return loadDir(path.replace(/\\/g, "/").split("/").slice(0, -1).join("/"));
|
|
}
|
|
dirEntries = [];
|
|
grid.replaceChildren();
|
|
showEmpty(err.message || "failed to load");
|
|
}
|
|
}
|
|
|
|
function switchRoot(root, path = "") {
|
|
currentType = root;
|
|
for (const b of tabs.children) b.style.borderColor = b.dataset.root === root ? "#2a6acf" : "#444";
|
|
allFlatItems = [];
|
|
// Pre-fetch the full flat list so search works across all subdirectories
|
|
if (root === "loras") fetchLoras().then(list => { allFlatItems = list || []; }).catch(() => {});
|
|
else if (root === "checkpoints") fetchCheckpoints().then(list => { allFlatItems = list || []; }).catch(() => {});
|
|
loadDir(path);
|
|
}
|
|
|
|
for (const root of roots) {
|
|
const tab = makeButton(`${ROOT_TAB_LABELS[root] || BROWSE_TYPE_LABELS[root] || root}/`);
|
|
tab.dataset.root = root;
|
|
tab.addEventListener("click", () => switchRoot(root));
|
|
tabs.appendChild(tab);
|
|
}
|
|
|
|
searchInput.addEventListener("input", () => renderEntries(searchInput.value));
|
|
searchInput.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") {
|
|
const term = searchInput.value.toLowerCase().trim();
|
|
const match = dirEntries.find(e => e.type === "file" && e.name.toLowerCase().includes(term));
|
|
if (match) close({ file: joinPath(match.name), path: match.path, type: currentType });
|
|
}
|
|
});
|
|
switchRoot(type, defaultPath);
|
|
searchInput.focus();
|
|
|
|
const cancelBtn = makeButton("Cancel");
|
|
cancelBtn.addEventListener("click", () => close(undefined));
|
|
footer.appendChild(cancelBtn);
|
|
|
|
const selectBtn = makeButton("Select", true);
|
|
selectBtn.addEventListener("click", () => {
|
|
if (!selected) {
|
|
window.alert("Select a file first.");
|
|
return;
|
|
}
|
|
close(selected);
|
|
});
|
|
footer.appendChild(selectBtn);
|
|
});
|
|
}
|
|
|
|
// Flake images (cover, ControlNet, variant) are picked in place from any
|
|
// ComfyUI folder rather than uploaded, so the save can move the original file
|
|
// and its batch, or keep it where it is inside the flake's folder (#392, #396).
|
|
// input/ picks stay input-relative; other folders give an absolute path.
|
|
// Without an earlier pick in this session, an existing flake opens on its folder.
|
|
let lastImageDir = null;
|
|
export async function pickImage(flakeName) {
|
|
const start = lastImageDir || (flakeName ? { type: "flake_images", path: flakeName } : { type: "inputs", path: "" });
|
|
const result = await openFileBrowser({ type: start.type, defaultPath: start.path, roots: IMAGE_ROOTS });
|
|
if (!result?.file) return null;
|
|
lastImageDir = { type: result.type, path: result.file.split("/").slice(0, -1).join("/") };
|
|
return result.type === "inputs" ? result.file : result.path;
|
|
}
|
|
|
|
// Bottom-left button of a Flake Animate grid item (#441): `+` picks its first
|
|
// input image, the image count opens the item's image list. `onChanged` runs
|
|
// after every edit of `entry.images`.
|
|
export function makeItemImagesButton(entry, onChanged) {
|
|
const count = entry.images?.length || 0;
|
|
const btn = document.createElement("button");
|
|
btn.textContent = count ? String(count) : "+";
|
|
btn.title = count ? "Input images (one video each)" : "Add an input image";
|
|
css(btn, "position:absolute;bottom:2px;left:2px;z-index:6;min-width:18px;height:16px;padding:0 4px;border:none;border-radius:8px;background:rgba(0,0,0,0.65);color:#cfe6ff;font-size:10px;font-weight:700;line-height:16px;cursor:pointer;");
|
|
btn.addEventListener("click", async (e) => {
|
|
e.stopPropagation();
|
|
if (count) {
|
|
openItemImages(entry, onChanged);
|
|
return;
|
|
}
|
|
const path = await pickImage();
|
|
if (!path) return;
|
|
entry.images = [path];
|
|
onChanged();
|
|
});
|
|
btn.addEventListener("mousedown", (e) => e.stopPropagation());
|
|
btn.addEventListener("dblclick", (e) => e.stopPropagation());
|
|
return btn;
|
|
}
|
|
|
|
// Small overlay grid of an item's input images: click previews one larger,
|
|
// ✕ removes it, drag reorders, the + tile adds one.
|
|
function openItemImages(entry, onChanged) {
|
|
const { overlay, panel, content, footer } = openOverlay();
|
|
css(panel, panel.style.cssText + "width:auto;");
|
|
footer.style.display = "none";
|
|
let dragFrom = null;
|
|
|
|
function changed() {
|
|
onChanged();
|
|
render();
|
|
}
|
|
|
|
function preview(path) {
|
|
const view = document.createElement("div");
|
|
css(view, "position:fixed;inset:0;display:flex;align-items:center;justify-content:center;cursor:zoom-out;");
|
|
const img = document.createElement("img");
|
|
img.src = pickedImageUrl(path);
|
|
css(img, "max-width:80vw;max-height:80vh;border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,0.7);");
|
|
view.appendChild(img);
|
|
view.addEventListener("click", () => view.remove());
|
|
overlay.appendChild(view);
|
|
}
|
|
|
|
function makeTile() {
|
|
const tile = document.createElement("div");
|
|
css(tile, "position:relative;width:96px;height:96px;border-radius:4px;background:#2a2a2a center/cover no-repeat;box-sizing:border-box;cursor:pointer;");
|
|
return tile;
|
|
}
|
|
|
|
function render() {
|
|
const grid = document.createElement("div");
|
|
css(grid, "display:grid;grid-template-columns:repeat(4, 96px);gap:6px;");
|
|
entry.images.forEach((path, i) => {
|
|
const tile = makeTile();
|
|
tile.style.backgroundImage = `url("${pickedImageUrl(path)}")`;
|
|
tile.title = path;
|
|
tile.draggable = true;
|
|
tile.addEventListener("click", () => preview(path));
|
|
tile.addEventListener("dragstart", () => { dragFrom = i; });
|
|
tile.addEventListener("dragend", () => { dragFrom = null; });
|
|
tile.addEventListener("dragover", (e) => { if (dragFrom !== null && dragFrom !== i) e.preventDefault(); });
|
|
tile.addEventListener("drop", (e) => {
|
|
e.preventDefault();
|
|
entry.images.splice(i, 0, ...entry.images.splice(dragFrom, 1));
|
|
dragFrom = null;
|
|
changed();
|
|
});
|
|
const remove = document.createElement("button");
|
|
remove.textContent = "✕";
|
|
remove.title = "Remove image";
|
|
css(remove, "position:absolute;top:2px;right:2px;width:18px;height:18px;padding:0;border:none;border-radius:9px;background:rgba(0,0,0,0.65);color:#fff;font-size:11px;line-height:18px;cursor:pointer;");
|
|
remove.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
entry.images.splice(i, 1);
|
|
changed();
|
|
});
|
|
tile.appendChild(remove);
|
|
grid.appendChild(tile);
|
|
});
|
|
const add = makeTile();
|
|
add.textContent = "+";
|
|
add.title = "Add an input image";
|
|
css(add, add.style.cssText + "border:1px dashed #555;display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:300;color:#777;");
|
|
add.addEventListener("click", async () => {
|
|
const path = await pickImage();
|
|
if (!path) return;
|
|
entry.images.push(path);
|
|
changed();
|
|
});
|
|
grid.appendChild(add);
|
|
content.replaceChildren(grid);
|
|
}
|
|
render();
|
|
}
|