Spaces:
Running
Running
Download app.js from waveshaker/loras_gallery: direct link, hf CLI and curl.
- Browser
- Download file 73.9 kB
-
https://huggingface.co/spaces/waveshaker/loras_gallery/resolve/main/app.js
- Command line
-
hf download hf://spaces/waveshaker/loras_gallery/app.js
-
curl -L -o app.js https://huggingface.co/spaces/waveshaker/loras_gallery/resolve/main/app.js
73.9 kB
| // ========================================== | |
| // ⚙️ Config: 参数配置 | |
| // ========================================== | |
| // -------------------------------------------------- | |
| // 🌟 动画与物理参数配置区 | |
| // -------------------------------------------------- | |
| const ANIM_CONFIG = { | |
| startY: 24, | |
| stiffness: 280, | |
| damping: 0.7, | |
| staggerDelay: 30, | |
| opacitySpeed: 0.1, | |
| // 🌟 悬停缩放配置 | |
| zoomScale: 1.05, | |
| zoomDuration: "0.6s", | |
| zoomEasing: "cubic-bezier(0.25, 0.1, 0.25, 1)" // 🌟 新增:平滑减速曲线 | |
| }; | |
| // -------------------------------------------------- | |
| // 基础仓库配置 | |
| // -------------------------------------------------- | |
| const REPO_ID = "waveshaker/loras"; | |
| const CONFIG_URL = "./models.json"; | |
| const RESOLVE_BASE = `https://huggingface.co/${REPO_ID}/resolve/main/`; | |
| const HF_BASE = "https://huggingface.co"; | |
| const DATASET_BASE = `${HF_BASE}/datasets/waveshaker/dataset_collections/tree/main/`; | |
| const SHOWCASE_BASE = `${HF_BASE}/waveshaker/loras/tree/main/comfyui_showcase/`; | |
| const GUIDE_BASE = `${HF_BASE}/waveshaker/loras/tree/main/usage_guide/`; | |
| // 全局动态配置 | |
| let siteConfig = { | |
| models: [], | |
| categories: [] | |
| }; | |
| // -------------------------------------------------- | |
| // 配置全局状态与分页配置 | |
| // -------------------------------------------------- | |
| const ITEMS_PER_PAGE = 30; | |
| let currentPage = 1; | |
| let allItems = []; | |
| let filteredItems = []; | |
| let isLoading = false; | |
| let isFiltering = false; | |
| // 🌟 核心状态:只有这里声明一次 | |
| let currentFilter = { model: [], category: [] }; | |
| let currentSort = "newest"; // 排序状态 | |
| let searchQuery = ""; // 用于保存搜索关键词; | |
| // ========================================== | |
| // 🚀 Core: 核心入口 | |
| // ========================================== | |
| async function main() { | |
| const container = document.getElementById("app"); | |
| const navContainer = document.getElementById("nav-container"); | |
| if (!container || !navContainer) return; | |
| renderFullSkeletons(container, navContainer); | |
| // 🌟 核心:必须把变量声明在 try 外面 | |
| let itemsConfig = {}; | |
| try { | |
| const siteConfigRes = await fetchWithRetry("./site_config.json", { cache: "no-store" }); | |
| if (siteConfigRes.ok) siteConfig = await siteConfigRes.json(); | |
| } catch (e) { | |
| console.warn("site_config.json 未找到"); | |
| } | |
| if (!container) return; | |
| async function fetchWithRetry(url, options, retries = 3, delay = 1000) { | |
| for (let i = 0; i < retries; i++) { | |
| try { | |
| const res = await fetch(url, options); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| return res; | |
| } catch (err) { | |
| if (i === retries - 1) throw err; | |
| await new Promise(r => setTimeout(r, delay)); | |
| } | |
| } | |
| } | |
| try { | |
| let tree = []; | |
| let nextUrl = `https://huggingface.co/api/models/${REPO_ID}/tree/main?recursive=true`; | |
| while (nextUrl) { | |
| const res = await fetchWithRetry(nextUrl, { cache: "no-store" }); | |
| if (!res.ok) throw new Error(`HF API: ${res.status}`); | |
| const data = await res.json(); | |
| tree = tree.concat(data); | |
| const link = res.headers.get("Link"); | |
| if (link && link.includes('rel="next"')) { | |
| const match = link.match(/<([^>]+)>;\s*rel="next"/); | |
| nextUrl = match ? match[1] : null; | |
| } else { | |
| nextUrl = null; | |
| } | |
| } | |
| // 1. 缓存获取 (避免 JSON.stringify 的巨大性能开销) | |
| try { | |
| const cachedData = localStorage.getItem("loras_config_cache"); | |
| if (cachedData) itemsConfig = JSON.parse(cachedData); | |
| const configRes = await fetchWithRetry(`${CONFIG_URL}?t=${Date.now()}`, { cache: "no-store" }); | |
| if (configRes.ok) { | |
| const newItems = (await configRes.json()).items || {}; | |
| // 🌟 修复:直接覆盖,不再做深比对 | |
| itemsConfig = newItems; | |
| localStorage.setItem("loras_config_cache", JSON.stringify(itemsConfig)); | |
| console.log("✅ 已加载最新 models.json"); | |
| } | |
| } catch (e) { | |
| console.warn("models.json 加载失败,使用旧缓存数据"); | |
| } | |
| // 2. 获取下载量 | |
| try { | |
| const statRes = await fetchWithRetry(`https://huggingface.co/api/models/${REPO_ID}`); | |
| if (statRes.ok) { | |
| const statData = await statRes.json(); | |
| const totalEl = document.getElementById("total-downloads"); | |
| if (totalEl) { | |
| totalEl.textContent = `${(statData.downloads || 0).toLocaleString()} Total Downloads`; | |
| totalEl.style.display = "inline-block"; | |
| } | |
| } | |
| } catch (e) {} | |
| // 4. 解析模型数据 | |
| const safetensors = tree.filter(i => i.type === "file" && i.path.endsWith(".safetensors")); | |
| if (safetensors.length === 0) throw new Error("No safetensors files found."); | |
| const allImagePaths = new Set(tree.filter(i => i.type === "file" && /\.(jpg|jpeg|webp)$/i.test(i.path)).map(i => i.path)); | |
| allItems = safetensors.map(file => { | |
| const parts = file.path.split("/"); | |
| const filename = parts[parts.length - 1]; | |
| const dirs = parts.slice(0, -1); | |
| const modelRaw = dirs.length >= 1 ? dirs[0].toLowerCase() : "other"; | |
| const categoryRaw = dirs.length >= 2 ? dirs[1] : "unknown"; | |
| const fileData = itemsConfig[file.path] || {}; | |
| const title = (typeof fileData.title === "string" && fileData.title.trim() !== "") ? fileData.title : filename.replace(/\.safetensors$/i, ""); | |
| const baseCoverPath = file.path.replace(/\.safetensors$/i, ""); | |
| let finalCoverExt = ".jpg"; | |
| if (allImagePaths.has(baseCoverPath + ".webp")) finalCoverExt = ".webp"; | |
| else if (allImagePaths.has(baseCoverPath + ".jpeg")) finalCoverExt = ".jpeg"; | |
| const rawUrl = RESOLVE_BASE + encodePath(baseCoverPath + finalCoverExt); | |
| let finalPreviewUrl = rawUrl; | |
| const cleanTitle = (str) => { | |
| if (!str) return ""; | |
| const match = str.match(/^.*?[::]\s*(.*)/); | |
| return match ? match[1].trim() : str.trim(); | |
| }; | |
| return { | |
| path: file.path, filename, title, | |
| cleanTitle: cleanTitle(title), | |
| // 🌟 核心:这里的 commitTimes 终于能正确读取到了 | |
| lastModified: fileData.update_time || 0, | |
| modelRaw, modelLabel: formatModelName(modelRaw), | |
| categoryRaw, categoryLabel: formatCategory(categoryRaw), | |
| // 分流后的 URL | |
| previewUrl: finalPreviewUrl, | |
| downloadUrl: RESOLVE_BASE + encodePath(file.path) + "?download=true", | |
| trigger: fileData.trigger || "-", | |
| dataset: fileData.dataset ? DATASET_BASE + fileData.dataset : "", | |
| showcase: fileData.showcase ? SHOWCASE_BASE + fileData.showcase : "", | |
| guide: fileData.guide ? GUIDE_BASE + fileData.guide : "" | |
| }; | |
| }); | |
| // 5. 排序、渲染等后续逻辑 | |
| allItems.sort((a, b) => { | |
| const idxA = siteConfig.models.findIndex(m => m.key.toLowerCase() === a.modelRaw.toLowerCase()); | |
| const idxB = siteConfig.models.findIndex(m => m.key.toLowerCase() === b.modelRaw.toLowerCase()); | |
| if (idxA !== idxB) return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB); | |
| const catA = siteConfig.categories.findIndex(c => c.key.toLowerCase() === a.categoryRaw.toLowerCase()); | |
| const catB = siteConfig.categories.findIndex(c => c.key.toLowerCase() === b.categoryRaw.toLowerCase()); | |
| if (catA !== catB) return (catA === -1 ? 999 : catA) - (catB === -1 ? 999 : catB); | |
| return a.title.localeCompare(b.title); | |
| }); | |
| const uniqueModels = [...new Set(allItems.map(i => i.modelRaw))]; | |
| const uniqueCategories = [...new Set(allItems.map(i => i.categoryRaw))]; | |
| // 🌟 加上这一句,强行按 JSON 配置排序 | |
| uniqueCategories.sort((a, b) => { | |
| const idxA = siteConfig.categories.findIndex(c => c.key.toLowerCase() === a.toLowerCase()); | |
| const idxB = siteConfig.categories.findIndex(c => c.key.toLowerCase() === b.toLowerCase()); | |
| return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB); | |
| }); | |
| // if (currentFilter.model.length === 0) currentFilter.model = [...uniqueModels]; | |
| // if (currentFilter.category.length === 0) currentFilter.category = [...uniqueCategories ]; | |
| // ----------------------------------------- | |
| // 🌟 默认选中的配置 (首次加载时生效) | |
| // ----------------------------------------- | |
| if (currentFilter.model.length === 0) { | |
| // 默认选中:Krea 2, ANIMA, Flux 1 Dev | |
| currentFilter.model = ["moss", "krea2", "anima", "flux1_dev"]; | |
| } | |
| if (currentFilter.category.length === 0) { | |
| currentFilter.category = ["CN", "KR", "JP", "WE", "Fictional"]; | |
| } | |
| renderNav(navContainer, uniqueModels, uniqueCategories); | |
| document.querySelectorAll('.skeleton-hide').forEach(el => { | |
| el.classList.remove('skeleton-hide'); | |
| }); | |
| const searchInput = document.getElementById("search-input"); | |
| if (searchInput) { | |
| searchInput.disabled = false; | |
| searchInput.style.opacity = "1"; | |
| searchInput.style.animation = "none"; | |
| searchInput.style.pointerEvents = "auto"; | |
| searchInput.style.background = "#1e1e1e"; | |
| searchInput.style.borderColor = "#333"; | |
| searchInput.style.color = "#fff"; | |
| searchInput.placeholder = "Search title or LoRA..."; | |
| searchInput.addEventListener("input", (e) => { | |
| searchQuery = e.target.value.toLowerCase().trim(); | |
| applyFilter(); | |
| }); | |
| } | |
| const sortSelect = document.getElementById("sort-select"); | |
| if (sortSelect) { | |
| sortSelect.addEventListener("change", (e) => { | |
| currentSort = e.target.value; | |
| applyFilter(); | |
| }); | |
| } | |
| isFiltering = false; | |
| applyFilter(); | |
| } catch (err) { | |
| console.error(err); | |
| container.innerHTML = `<div class="error" style="text-align:center; padding:2rem; color:#e55;"> | |
| <h3>Failed to load</h3> | |
| <p>${escapeHtml(err.message)}</p> | |
| <button onclick="location.reload()" style="margin-top:1rem; padding:0.5rem 1rem; cursor:pointer;">Refresh Page</button> | |
| </div>`; | |
| } | |
| } | |
| // ========================================== | |
| // 🧩 Component: 骨架屏 (彻底防位移版) | |
| // ========================================== | |
| function renderFullSkeletons(container, navContainer) { | |
| if (navContainer && navContainer.innerHTML === "") { | |
| let fakeNavHtml = ` | |
| <!-- 1. Title 骨架 --> | |
| <div class="sidebar-header" style="display: block; opacity: 1; "> | |
| <div style="height: 26px; width: 85%; background: #222; border-radius: 4px; animation: pulse 1.5s infinite;"></div> | |
| </div> | |
| <!-- 2. Search 骨架 --> | |
| <div class="search-box" style="display: block; opacity: 1; margin-bottom: 0.6rem;"> | |
| <div style="height: 34px; width: 100%; background: #222; border-radius: 8px; animation: pulse 1.5s infinite;"></div> | |
| </div> | |
| <!-- 3. Base Model 骨架 --> | |
| <div class="nav-section" style="margin-bottom: 0.5rem;"> | |
| <div style="height: 14px; width: 70px; background: #222; border-radius: 4px; margin-bottom: 0rem; animation: pulse 1.5s infinite;"></div> | |
| <div class="nav-buttons" style="display: flex; flex-direction: column; gap: 0.4rem;"> | |
| `; | |
| for (let i = 0; i < 6; i++) { | |
| fakeNavHtml += `<div class="nav-btn-skeleton" style="background: #222; border: none; height: 30px; border-radius: 6px; animation: pulse 1.5s infinite; pointer-events: none;"></div>`; | |
| } | |
| fakeNavHtml += `</div></div>`; | |
| // 4. Category 骨架 | |
| fakeNavHtml += ` | |
| <div class="nav-section" style="margin-bottom: -0.85rem;"> | |
| <div style="height: 14px; width: 60px; background: #222; border-radius: 4px; margin-bottom: 0rem; animation: pulse 1.5s infinite;"></div> | |
| <div class="nav-buttons" style="display: flex; flex-direction: column; gap: 0.4rem;"> | |
| `; | |
| for (let i = 0; i < 7; i++) { | |
| fakeNavHtml += `<div class="nav-btn-skeleton" style="background: #222; border: none; height: 30px; border-radius: 6px; animation: pulse 1.5s infinite; pointer-events: none;"></div>`; | |
| } | |
| fakeNavHtml += `</div></div>`; | |
| // 5. Footer 骨架 (完全对齐真实的 margin-top 1rem 及内部 gap) | |
| fakeNavHtml += ` | |
| <div style="display: flex; flex-direction: column; padding-top: 1.25rem; border-top: 1px solid #2a2a2a; margin-top: 1rem;"> | |
| <div style="height: 14px; width: 75%; background: #222; border-radius: 4px; margin-bottom: 0.3rem; animation: pulse 1.5s infinite;"></div> | |
| <div style="height: 14px; width: 85%; background: #222; border-radius: 4px; margin-bottom: 0.3rem; animation: pulse 1.5s infinite;"></div> | |
| <div style="height: 14px; width: 60%; background: #222; border-radius: 4px; margin-bottom: 0.7rem; animation: pulse 1.5s infinite;"></div> | |
| <div style="height: 12px; width: 90px; background: #222; border-radius: 6px; margin-left: auto; margin-top: 0.5rem; animation: pulse 1.5s infinite;"></div> | |
| </div> | |
| `; | |
| navContainer.innerHTML = fakeNavHtml; | |
| } | |
| // 主内容区卡片骨架 | |
| let html = `<div class="grid">`; | |
| for (let i = 0; i < 15; i++) { | |
| html += ` | |
| <div class="card skeleton-card" style="pointer-events: none; padding: 0; overflow: hidden;"> | |
| <!-- 1. 图片骨架:严格贴合 3:4 比例,只有顶部圆角 --> | |
| <div class="card-image-wrap" style="background: #1a1a1a; aspect-ratio: 3/4; border-radius: 10px 10px 0 0; margin-bottom: 0; animation: pulse 1.5s infinite;"></div> | |
| <!-- 2. 信息区骨架:完美对齐你 CSS 里的 card-info 内边距 (0.6rem) --> | |
| <div style="padding-top:0.6rem; padding-left: 0.6rem; padding-right: 0.6rem; padding-bottom: 0.6rem; display: flex; flex-direction: column; flex: 1;"> | |
| <!-- 模拟 LoRA 和 Trigger 区域 --> | |
| <div style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 0.6rem;"> | |
| <!-- LoRA --> | |
| <div> | |
| <div style="height: 10px; width: 30%; background: #222; border-radius: 4px; animation: pulse 1.5s infinite; margin-bottom: 6px;"></div> | |
| <div style="height: 32px; width: 100%; background: #2a2a2a; border-radius: 6px; animation: pulse 1.5s infinite;"></div> | |
| </div> | |
| <!-- Trigger --> | |
| <div> | |
| <div style="height: 10px; width: 30%; background: #222; border-radius: 4px; animation: pulse 1.5s infinite; margin-bottom: 6px;"></div> | |
| <div style="height: 32px; width: 100%; background: #2a2a2a; border-radius: 6px; animation: pulse 1.5s infinite;"></div> | |
| </div> | |
| </div> | |
| <!-- 模拟底部按钮区 (Margin-top: auto 保证推到最底部) --> | |
| <div style="margin-top: auto; padding-top: 0.8rem; border-top: 1.1px solid #2a2a2a;"> | |
| <div style="height: 34px; width: 100%; background: #222; border-radius: 6px; animation: pulse 1.5s infinite;"></div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| html += `</div>`; | |
| container.innerHTML = html; | |
| } | |
| // ========================================== | |
| // 📊 Data: 筛选与渲染主流程 | |
| // ========================================== | |
| function applyFilter() { | |
| // 1. 核心过滤 | |
| const itemsUnderCurrentNav = allItems.filter(c => { | |
| const matchModel = currentFilter.model.length > 0 && currentFilter.model.includes(c.modelRaw); | |
| const matchCat = currentFilter.category.length > 0 && currentFilter.category.includes(c.categoryRaw); | |
| return matchModel && matchCat; | |
| }); | |
| // 2. 搜索词过滤 | |
| filteredItems = itemsUnderCurrentNav.filter(c => { | |
| if (searchQuery === "") return true; | |
| const matchTitle = c.title.toLowerCase().includes(searchQuery); | |
| const loraName = c.filename.replace(/\.safetensors$/i, "").toLowerCase(); | |
| const matchLora = loraName.includes(searchQuery); | |
| return matchTitle || matchLora; | |
| }); | |
| // 🌟 3. 排序逻辑 (支持 Default, A-Z, Newest) | |
| // 🌟 3. 排序逻辑 (完善版:支持 Default, A-Z, Newest) | |
| filteredItems.sort((a, b) => { | |
| // 1. 默认排序 (Default) | |
| if (currentSort === "default") { | |
| // 返回 0 意味着保留数组原本的顺序(即来自 siteConfig 配置或 HF 仓库默认拉取的自然顺序) | |
| return 0; | |
| } | |
| // 2. 按名字 A-Z 排序 | |
| else if (currentSort === "a-z") { | |
| const strA = a.cleanTitle; | |
| const strB = b.cleanTitle; | |
| // 提取首字符 | |
| const charA = strA.charAt(0); | |
| const charB = strB.charAt(0); | |
| // 定义正则,用于判断字符类型 | |
| const isNum = /[0-9]/; | |
| const isEn = /[a-zA-Z]/; | |
| const isZh = /[\u4e00-\u9fa5]/; | |
| // 辅助函数:获取权重 (数字: 1, 英文: 2, 中文: 3, 其他: 4) | |
| const getWeight = (char) => { | |
| if (isNum.test(char)) return 1; | |
| if (isEn.test(char)) return 2; | |
| if (isZh.test(char)) return 3; | |
| return 4; | |
| }; | |
| const weightA = getWeight(charA); | |
| const weightB = getWeight(charB); | |
| // 优先按权重排序(数字排最前,英文其次,中文最后) | |
| if (weightA !== weightB) { | |
| return weightA - weightB; | |
| } | |
| // 如果权重相同,说明处于同一类别,再调用 localeCompare 进行内部排序 | |
| if (weightA === 1 || weightA === 2) { | |
| // 都是数字或都是英文:忽略大小写进行严格 A-Z 排序 | |
| return strA.localeCompare(strB, 'en', { numeric: true, sensitivity: 'base' }); | |
| } else if (weightA === 3) { | |
| // 都是中文:按拼音 A-Z 排序 | |
| return strA.localeCompare(strB, 'zh-CN'); | |
| } else { | |
| // 都是其他符号 | |
| return strA.localeCompare(strB); | |
| } | |
| } | |
| // 3. 按最新上传时间排序 | |
| else if (currentSort === "newest") { | |
| // 这里根据 HF 文件最后修改时间(时间戳)降序排列,最新的在最前面 | |
| const timeA = a.lastModified || 0; | |
| const timeB = b.lastModified || 0; | |
| return timeB - timeA; | |
| } | |
| // 兜底:如果 currentSort 出现未知的值,保持原样 | |
| return 0; | |
| }); | |
| // 4. 渲染卡片 | |
| currentPage = 1; | |
| isFiltering = true; | |
| // 🌟 更新底部动态筛选结果 | |
| const countText = document.getElementById("selected-count-text"); | |
| if (countText) countText.textContent = filteredItems.length; | |
| const container = document.getElementById("app"); | |
| if (!container) return; | |
| if (filteredItems.length === 0) { | |
| container.innerHTML = '<div class="error" style="text-align:center; padding:2rem;">No models found for this query.</div>'; | |
| isFiltering = false; | |
| } else { | |
| container.style.transition = 'opacity 0.2s ease-in-out'; | |
| container.style.opacity = '0'; | |
| setTimeout(() => { | |
| renderGrid(); | |
| container.style.opacity = '1'; | |
| }, 200); | |
| } | |
| } | |
| // ========================================== | |
| // 🧩 Component: 渲染卡片网格 | |
| // ========================================== | |
| function renderGrid() { | |
| const container = document.getElementById("app"); | |
| if (!container) return; | |
| container.innerHTML = `<div class="grid" id="card-grid"></div>`; | |
| const grid = document.getElementById("card-grid"); | |
| const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; | |
| const itemsToRender = filteredItems.slice(startIndex, startIndex + ITEMS_PER_PAGE); | |
| let html = ""; | |
| itemsToRender.forEach((item, index) => { | |
| const baseBadge = `<div class="card-badge badge-tl">${escapeHtml(item.modelLabel)}</div>`; | |
| // -------------------------------------------------- | |
| // 🌟 按钮定义区 (集中在一起,方便管理) | |
| // -------------------------------------------------- | |
| const datasetHtml = item.dataset | |
| ? `<button class="action-btn" onclick="openDatasetModal('${escapeAttr(item.dataset)}')">📊 Dataset</button>` | |
| : ""; | |
| const usageHtml = item.guide | |
| ? `<button class="action-btn" onclick="openUsageModal('${escapeAttr(item.guide)}')">📖 Usage</button>` | |
| : ""; | |
| // Showcase 单独占一行的样式 | |
| const showcaseRow = item.showcase | |
| ? `<button class="action-btn showcase-full-btn" onclick="openShowcaseModal('${escapeAttr(item.showcase)}')">✨ ComfyUI Showcase</button>` | |
| : ""; | |
| // -------------------------------------------------- | |
| // 🌟 文本与内容处理区 | |
| // -------------------------------------------------- | |
| let triggerHtml = ""; | |
| // 辅助函数:检查末尾逗号 | |
| const formatTrigger = (text) => { | |
| if (!text) return text; | |
| let t = text.trim(); | |
| if (!t.endsWith(",")) { | |
| t += ","; | |
| } | |
| return t; | |
| }; | |
| if (item.trigger && item.trigger !== "-") { | |
| if (Array.isArray(item.trigger)) { | |
| item.trigger.forEach(t => { | |
| const formattedT = formatTrigger(t); | |
| triggerHtml += `<div class="trigger-box" onclick="navigator.clipboard.writeText('${escapeAttr(formattedT)}'); const el=this.querySelector('.trigger-text'); const o=el.innerText; el.innerText='Copied!'; setTimeout(()=>{el.innerText=o;}, 800);"><span class="trigger-text">${escapeHtml(formattedT)}</span></div>`; | |
| }); | |
| } else { | |
| const formattedTrigger = formatTrigger(item.trigger); | |
| triggerHtml = `<div class="trigger-box" onclick="navigator.clipboard.writeText('${escapeAttr(formattedTrigger)}'); const el=this.querySelector('.trigger-text'); const o=el.innerText; el.innerText='Copied!'; setTimeout(()=>{el.innerText=o;}, 800);"><span class="trigger-text">${escapeHtml(formattedTrigger)}</span></div>`; | |
| } | |
| } else { | |
| triggerHtml = `<span class="trigger-empty">-</span>`; | |
| } | |
| const loraName = item.filename.replace(/\.safetensors$/i, ""); | |
| const loraHtml = `<div class="path-box" onclick="navigator.clipboard.writeText('${escapeAttr(loraName)}'); const el=this.querySelector('.path-text'); const o=el.innerText; el.innerText='Copied!'; setTimeout(()=>{el.innerText=o;}, 800);"><span class="path-text">${escapeHtml(loraName)}</span></div>`; | |
| // 首屏图片优先级判断 | |
| const isFirstScreen = currentPage === 1 && index < 8; | |
| // const loadingAttr = isFirstScreen ? 'fetchpriority="high" loading="eager"' : 'loading="lazy"'; | |
| const loadingAttr = isFirstScreen ? 'fetchpriority="high" loading="eager"' : ''; | |
| // 注入弹簧动画初始状态 | |
| const springStyle = isFiltering ? `opacity: 0; transform: translateY(${ANIM_CONFIG.startY}px); will-change: transform, opacity;` : ''; | |
| const springAttr = isFiltering ? `data-spring="true" data-index="${index}"` : ''; | |
| // -------------------------------------------------- | |
| // 🌟 HTML 模板拼接 | |
| // -------------------------------------------------- | |
| html += ` | |
| <div class="card" style="${springStyle}" ${springAttr} onmouseenter="if(this.dataset.animating !== 'true') { const img = this.querySelector('.preview'); if(img) img.style.transform = 'scale(${ANIM_CONFIG.zoomScale})'; }" | |
| onmouseleave="const img = this.querySelector('.preview'); if(img) img.style.transform = 'scale(1)';"> | |
| <div class="card-image-wrap img-skeleton"> | |
| ${baseBadge} | |
| <img class="preview reveal-img" data-src="${escapeAttr(item.previewUrl)}" alt="${escapeAttr(item.title)}" ${loadingAttr} decoding="async" onload="this.classList.add('loaded'); this.parentElement.classList.remove('img-skeleton');" onerror="this.classList.remove('reveal-img'); this.classList.add('preview-missing'); this.parentElement.classList.remove('img-skeleton');" style="transition: transform ${ANIM_CONFIG.zoomDuration} ${ANIM_CONFIG.zoomEasing};" /> | |
| <div class="title-overlay"></div> | |
| <div class="image-bottom-info"><div class="card-title">${escapeHtml(item.title)}</div><div class="card-category">${escapeHtml(item.categoryLabel)}</div></div> | |
| </div> | |
| <div class="card-info"> | |
| <div class="card-details"> | |
| <div class="detail-block"><div class="detail-label">LoRA:</div>${loraHtml}</div> | |
| <div class="detail-block"><div class="detail-label">Trigger:</div><div class="trigger-list">${triggerHtml}</div></div> | |
| </div> | |
| <div class="card-actions"> | |
| ${showcaseRow} | |
| ${datasetHtml} | |
| ${usageHtml} | |
| <a href="${escapeAttr(item.downloadUrl)}" download class="action-btn primary">⬇️ Download</a> | |
| </div> | |
| </div> | |
| </div>`; | |
| }); | |
| grid.innerHTML = html; | |
| observeImages(grid); | |
| if (isFiltering) { | |
| const cards = grid.querySelectorAll('.card[data-spring="true"]'); | |
| requestAnimationFrame(() => { | |
| cards.forEach(card => { | |
| setTimeout(() => { | |
| applySpringAnimation(card); | |
| }, parseInt(card.getAttribute('data-index')) * ANIM_CONFIG.staggerDelay); | |
| }); | |
| }); | |
| isFiltering = false; | |
| } | |
| } | |
| // 🌟 新增:全局图片懒加载监听器 | |
| let imgObserver = null; | |
| function observeImages(container) { | |
| if (!('IntersectionObserver' in window)) { | |
| // 老浏览器兜底:直接加载 | |
| container.querySelectorAll('img[data-src]').forEach(img => { | |
| if (img.dataset.src) img.src = img.dataset.src; | |
| }); | |
| return; | |
| } | |
| // 只创建一次 Observer 实例,复用性能更好 | |
| if (!imgObserver) { | |
| imgObserver = new IntersectionObserver((entries, observer) => { | |
| entries.forEach(entry => { | |
| if (entry.isIntersecting) { | |
| const img = entry.target; | |
| if (img.dataset.src) { | |
| img.src = img.dataset.src; // 真正开始加载图片 | |
| } | |
| imgObserver.unobserve(img); // 加载完就不再监听 | |
| } | |
| }); | |
| }, { rootMargin: "600px" }); // 提前 200px 开始加载 | |
| } | |
| // 把新渲染出来的图片加入监听 | |
| container.querySelectorAll('img[data-src]').forEach(img => imgObserver.observe(img)); | |
| } | |
| // ========================================== | |
| // 🪄 Animation: 物理弹簧动画 | |
| // ========================================== | |
| function applySpringAnimation(element) { | |
| if (!element || element.dataset.animating === 'true') return; | |
| element.dataset.animating = 'true'; | |
| const startY = ANIM_CONFIG.startY; | |
| const omega0 = Math.sqrt(ANIM_CONFIG.stiffness); | |
| const zeta = ANIM_CONFIG.damping; | |
| const omegaD = omega0 * Math.sqrt(1 - zeta * zeta); | |
| const startTime = performance.now(); | |
| element.style.transition = ''; | |
| element.style.opacity = '0'; | |
| element.style.transform = `translate3d(0, ${startY}px, 0)`; | |
| element.style.willChange = 'transform, opacity'; | |
| element.style.backfaceVisibility = 'hidden'; | |
| void element.offsetWidth; | |
| function step(currentTime) { | |
| const t = (currentTime - startTime) / 1000; | |
| const envelope = Math.exp(-zeta * omega0 * t); | |
| if (Math.abs(envelope * startY) < 0.1) { | |
| // 🌟 核心修复:不要用 translate3d,直接清空 transform,让父级彻底“干干净净” | |
| element.style.transform = ''; | |
| element.style.opacity = 1; | |
| element.style.willChange = 'auto'; | |
| element.style.transition = ''; | |
| element.dataset.animating = 'false'; | |
| return; | |
| } | |
| const positionY = startY * envelope * Math.cos(omegaD * t); | |
| const opacity = Math.min(t / 0.25, 1); | |
| element.style.transform = `translate3d(0, ${positionY}px, 0)`; | |
| element.style.opacity = opacity; | |
| requestAnimationFrame(step); | |
| } | |
| requestAnimationFrame(step); | |
| } | |
| // 🌟 新增:滑块的专用动画 | |
| function animateIndicator(indicator, targetY, targetHeight) { | |
| if (!indicator) return; | |
| // 取消正在进行的旧动画 | |
| if (indicator._anim) { | |
| indicator._anim.cancel(); | |
| } | |
| const startY = indicator.dataset.currentY ? parseFloat(indicator.dataset.currentY) : targetY; | |
| // 高度直接瞬间改变,保证边框顺滑 | |
| indicator.style.height = `${targetHeight}px`; | |
| indicator.style.opacity = "1"; | |
| // 🌟 使用原生 WAAPI,配合 ease-in-out 曲线 | |
| indicator._anim = indicator.animate( | |
| [ | |
| { transform: `translate3d(0, ${startY}px, 0)` }, | |
| { transform: `translate3d(0, ${targetY}px, 0)` } | |
| ], | |
| { | |
| duration: 200, // 🌟 改这里:0.2s,干脆利落 | |
| easing: "cubic-bezier(0.25, 0.1, 0.25, 1)", // 🌟 偏向 ease-out 的平滑减速,比 pure ease-in-out 更敏捷 | |
| fill: "forwards" | |
| } | |
| ); | |
| // 记录最新的 Y 坐标 | |
| indicator.dataset.currentY = targetY; | |
| } | |
| // ========================================== | |
| // 🧩 Component: 渲染导航 | |
| // ========================================== | |
| function renderNav(container, models, categories) { | |
| if (!container) return; | |
| // 🌟 强制排序,确保按 JSON 顺序渲染 | |
| categories.sort((a, b) => { | |
| const idxA = siteConfig.categories.findIndex(c => c.key.toLowerCase() === a.toLowerCase()); | |
| const idxB = siteConfig.categories.findIndex(c => c.key.toLowerCase() === b.toLowerCase()); | |
| return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB); | |
| }); | |
| // Base Model 区域 | |
| let modelHtml = `<div class="nav-section"> | |
| <div class="nav-label">BASE MODEL</div> | |
| <div class="nav-buttons" id="nav-models">`; | |
| models.forEach(m => { | |
| const isActive = currentFilter.model.includes(m); | |
| const count = allItems.filter(c => c.modelRaw === m).length; | |
| const matchModel = siteConfig.models.find(x => x.key.toLowerCase() === m.toLowerCase()); | |
| const label = matchModel ? matchModel.label : m; | |
| modelHtml += `<button class="nav-btn ${isActive ? 'active' : ''}" data-type="model" data-value="${escapeAttr(m)}">${escapeHtml(label)} <span>(${count})</span></button>`; | |
| }); | |
| modelHtml += `</div></div>`; | |
| // ========================================== | |
| // Category 区域 | |
| // ========================================== | |
| // 算出当前勾选的 Base Model 下有多少数据,用于动态更新 Category 的数字 | |
| const itemsUnderCurrentModel = allItems.filter(c => currentFilter.model.length === 0 || currentFilter.model.includes(c.modelRaw)); | |
| let catHtml = `<div class="nav-section"> | |
| <div class="nav-label">CATEGORY</div> | |
| <div class="nav-buttons" id="nav-cats">`; | |
| categories.forEach(cat => { | |
| const count = itemsUnderCurrentModel.filter(c => c.categoryRaw === cat).length; | |
| const isActive = currentFilter.category.includes(cat); | |
| const matchCat = siteConfig.categories.find(c => c.key.toLowerCase() === cat.toLowerCase()); | |
| const label = matchCat ? matchCat.label : cat; | |
| catHtml += `<button class="nav-btn ${isActive ? 'active' : ''}" data-type="category" data-value="${escapeAttr(cat)}">${escapeHtml(label)} <span>(${count})</span></button>`; | |
| }); | |
| catHtml += `</div></div>`; | |
| // 🌟 核心:必须把生成的 HTML 塞进容器里,不然页面什么都不显示! | |
| container.innerHTML = modelHtml + catHtml; | |
| // 绑定点击事件 (智能兜底) | |
| container.querySelectorAll('.nav-btn').forEach(btn => { | |
| btn.onclick = (e) => { | |
| const targetBtn = e.target.closest('.nav-btn'); | |
| if (!targetBtn) return; | |
| const type = targetBtn.getAttribute('data-type'); | |
| const value = targetBtn.getAttribute('data-value'); | |
| // 切换选中状态 | |
| if (currentFilter[type].includes(value)) { | |
| currentFilter[type] = currentFilter[type].filter(v => v !== value); | |
| } else { | |
| currentFilter[type].push(value); | |
| } | |
| // 🌟 核心智能兜底逻辑 (互相联动) | |
| if (type === 'model') { | |
| // 选了底座,如果分类为空,自动全选分类 | |
| if (currentFilter.model.length > 0 && currentFilter.category.length === 0) { | |
| currentFilter.category = [...categories]; | |
| } | |
| // 底座全空,分类也跟着清空 | |
| if (currentFilter.model.length === 0) { | |
| currentFilter.category = []; | |
| } | |
| } | |
| if (type === 'category') { | |
| // 选了分类,如果底座为空,自动全选底座 | |
| if (currentFilter.category.length > 0 && currentFilter.model.length === 0) { | |
| currentFilter.model = [...models]; | |
| } | |
| // 分类全空,底座也跟着清空 | |
| if (currentFilter.category.length === 0) { | |
| currentFilter.model = []; | |
| } | |
| } | |
| // 重新渲染界面(刷新高亮) | |
| renderNav(container, models, categories); | |
| // 触发过滤 | |
| applyFilter(); | |
| }; | |
| }); | |
| } | |
| // ========================================== | |
| // 📍 Event: 滚动无限加载 | |
| // ========================================== | |
| let lastScrollY = window.scrollY; | |
| window.addEventListener('scroll', () => { | |
| // 🌟 2. 处理拦截逻辑(注意:这里 return 只拦截“无限加载”,不影响上面的游标) | |
| if (isLoading || isFiltering) return; | |
| const currentScrollY = window.scrollY; | |
| if (currentScrollY <= lastScrollY) { | |
| lastScrollY = currentScrollY; | |
| return; // 向上滚动,不触发无限加载 | |
| } | |
| lastScrollY = currentScrollY; | |
| if (filteredItems.length === 0 || allItems.length === 0) return; | |
| // 🌟 3. 触底判定与无限加载 | |
| const scrollPosition = window.innerHeight + currentScrollY; | |
| const threshold = document.body.offsetHeight - 500; | |
| if (scrollPosition >= threshold) { | |
| const endIndex = currentPage * ITEMS_PER_PAGE; | |
| if (endIndex < filteredItems.length) { | |
| isLoading = true; | |
| currentPage++; | |
| const grid = document.getElementById("card-grid"); | |
| if (grid) { | |
| const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; | |
| const itemsToRender = filteredItems.slice(startIndex, startIndex + ITEMS_PER_PAGE); | |
| let html = ""; | |
| itemsToRender.forEach(item => { | |
| const baseBadge = `<div class="card-badge badge-tl">${escapeHtml(item.modelLabel)}</div>`; | |
| // -------------------------------------------------- | |
| // 🌟 按钮定义区 (集中、工整) | |
| // -------------------------------------------------- | |
| const datasetHtml = item.dataset | |
| ? `<button class="action-btn" onclick="openDatasetModal('${escapeAttr(item.dataset)}')">📊 Dataset</button>` | |
| : ""; | |
| const usageHtml = item.guide | |
| ? `<button class="action-btn" onclick="openUsageModal('${escapeAttr(item.guide)}')">📖 Usage</button>` | |
| : ""; | |
| const showcaseRow = item.showcase | |
| ? `<button class="action-btn showcase-full-btn" onclick="openShowcaseModal('${escapeAttr(item.showcase)}')">✨ ComfyUI Showcase</button>` | |
| : ""; | |
| // -------------------------------------------------- | |
| // 🌟 文本与内容处理区 | |
| // -------------------------------------------------- | |
| let triggerHtml = ""; | |
| const formatTrigger = (text) => { | |
| if (!text) return text; | |
| let t = text.trim(); | |
| if (!t.endsWith(",")) { | |
| t += ","; | |
| } | |
| return t; | |
| }; | |
| if (item.trigger && item.trigger !== "-") { | |
| if (Array.isArray(item.trigger)) { | |
| item.trigger.forEach(t => { | |
| const formattedT = formatTrigger(t); | |
| triggerHtml += `<div class="trigger-box" onclick="navigator.clipboard.writeText('${escapeAttr(formattedT)}'); const el=this.querySelector('.trigger-text'); const o=el.innerText; el.innerText='Copied!'; setTimeout(()=>{el.innerText=o;}, 800);"><span class="trigger-text">${escapeHtml(formattedT)}</span></div>`; | |
| }); | |
| } else { | |
| const formattedTrigger = formatTrigger(item.trigger); | |
| triggerHtml = `<div class="trigger-box" onclick="navigator.clipboard.writeText('${escapeAttr(formattedTrigger)}'); const el=this.querySelector('.trigger-text'); const o=el.innerText; el.innerText='Copied!'; setTimeout(()=>{el.innerText=o;}, 800);"><span class="trigger-text">${escapeHtml(formattedTrigger)}</span></div>`; | |
| } | |
| } else { | |
| triggerHtml = `<span class="trigger-empty">-</span>`; | |
| } | |
| const loraName = item.filename.replace(/\.safetensors$/i, ""); | |
| const loraHtml = `<div class="path-box" onclick="navigator.clipboard.writeText('${escapeAttr(loraName)}'); const el=this.querySelector('.path-text'); const o=el.innerText; el.innerText='Copied!'; setTimeout(()=>{el.innerText=o;}, 800);"><span class="path-text">${escapeHtml(loraName)}</span></div>`; | |
| // -------------------------------------------------- | |
| // 🌟 HTML 模板拼接 | |
| // -------------------------------------------------- | |
| html += ` | |
| <div class="card"> | |
| <div class="card-image-wrap img-skeleton" onmouseenter="if(this.dataset.animating !== 'true') { const img = this.querySelector('.preview'); if(img) img.style.transform = 'scale(${ANIM_CONFIG.zoomScale})'; }" onmouseleave="const img = this.querySelector('.preview'); if(img) img.style.transform = 'scale(1)';"> | |
| ${baseBadge} | |
| <img class="preview reveal-img" data-src="${escapeAttr(item.previewUrl)}" alt="${escapeAttr(item.title)}" decoding="async" onload="this.classList.add('loaded'); this.parentElement.classList.remove('img-skeleton');" onerror="this.classList.remove('reveal-img'); this.classList.add('preview-missing'); this.parentElement.classList.remove('img-skeleton');" style="transition: opacity 0.5s ease-in-out, transform ${ANIM_CONFIG.zoomDuration} ${ANIM_CONFIG.zoomEasing};" /> | |
| <div class="title-overlay"></div> | |
| <div class="image-bottom-info"><div class="card-title">${escapeHtml(item.title)}</div><div class="card-category">${escapeHtml(item.categoryLabel)}</div></div> | |
| </div> | |
| <div class="card-info"> | |
| <div class="card-details"> | |
| <div class="detail-block"><div class="detail-label">LoRA:</div>${loraHtml}</div> | |
| <div class="detail-block"><div class="detail-label">Trigger:</div><div class="trigger-list">${triggerHtml}</div></div> | |
| </div> | |
| <div class="card-actions"> | |
| ${showcaseRow} | |
| ${datasetHtml} | |
| ${usageHtml} | |
| <a href="${escapeAttr(item.downloadUrl)}" download class="action-btn primary">⬇️ Download</a> | |
| </div> | |
| </div> | |
| </div>`; | |
| }); | |
| grid.insertAdjacentHTML('beforeend', html); | |
| observeImages(grid); | |
| } | |
| setTimeout(() => { isLoading = false; }, 500); | |
| } | |
| } | |
| }); | |
| // ========================================== | |
| // 🪟 Modal: Dataset/Usage/Showcase 弹窗逻辑 (彻底解决缩略图卡顿 + 滚动锁定) | |
| // ========================================== | |
| let currentModalData = { repoId: "", branch: "", images: [], currentIndex: 0 }; | |
| let currentGridPage = 1; | |
| let GRID_ITEMS_PER_PAGE = 30; | |
| // 🌟 动态计算列数和每页数量 (监听容器真实宽度) | |
| function calculateGridLayout() { | |
| const gridView = document.getElementById("grid-view"); | |
| if (!gridView) return; | |
| // 使用 getBoundingClientRect 获取精确宽度 | |
| const containerWidth = gridView.getBoundingClientRect().width || 1200; | |
| const minImgWidth = 120; | |
| const gap = 12.8; | |
| const columns = Math.max(1, Math.floor((containerWidth + gap) / (minImgWidth + gap))); | |
| // 每页显示 3 行,正好填满,不会留空 | |
| GRID_ITEMS_PER_PAGE = columns * 3; | |
| } | |
| window.openShowcaseModal = async function(showcaseUrl) { | |
| // 🌟 顶部动态 UI 更新 | |
| document.querySelector(".modal-header h3").textContent = "ComfyUI Showcase Viewer"; | |
| const infoLink = document.getElementById("modal-info-link"); | |
| const compareLink = document.getElementById("modal-compare-link"); | |
| // 设置右侧外链(真实的 Showcase 目录链接) | |
| infoLink.href = showcaseUrl; | |
| infoLink.target = "_blank"; | |
| infoLink.innerHTML = "🔗 View Showcase Folder ↗"; | |
| // 智能反查对应的 Dataset 链接 | |
| let inferredDatasetUrl = ""; | |
| try { | |
| const currentItem = allItems.find(item => item.showcase === showcaseUrl); | |
| if (currentItem && currentItem.dataset) { | |
| inferredDatasetUrl = currentItem.dataset; | |
| } | |
| } catch (e) { console.warn("查找 Dataset 失败", e); } | |
| if (inferredDatasetUrl) { | |
| compareLink.href = `javascript:openDatasetModal('${inferredDatasetUrl}')`; | |
| compareLink.innerHTML = "📊 Compare with Training Dataset"; | |
| compareLink.style.display = "inline-block"; | |
| } else { | |
| compareLink.style.display = "none"; | |
| } | |
| const modal = document.getElementById("dataset-modal"); | |
| //const repoLink = document.getElementById("modal-repo-link"); | |
| if (!modal) return; | |
| modal.style.display = "flex"; | |
| document.body.style.overflow = "hidden"; | |
| // UI 切换:强制显示网格,隐藏详情,隐藏子文件夹选择器 | |
| document.getElementById("grid-view-container").style.display = "flex"; | |
| document.getElementById("detail-view").style.display = "none"; | |
| document.getElementById("back-to-grid").style.display = "none"; | |
| document.getElementById("select-container").style.display = "none"; | |
| // 🌟 核心修复:重新严谨解析 URL | |
| let repoId = "waveshaker/loras"; // 默认兜底 | |
| let branch = "main"; | |
| let basePath = ""; | |
| try { | |
| // 把 URL 去掉域名,变成 "waveshaker/loras/tree/main/comfyui_showcase/..." | |
| const cleanPath = showcaseUrl.replace("https://huggingface.co/", "").replace("http://huggingface.co/", ""); | |
| const pathParts = cleanPath.split("/"); | |
| // pathParts 应为: ["waveshaker", "loras", "tree", "main", "comfyui_showcase", "krea2", "LeeJian_KR2"] | |
| const treeIndex = pathParts.indexOf("tree"); | |
| if (treeIndex !== -1) { | |
| // repoId 是 tree 前面的部分(例如 waveshaker/loras) | |
| repoId = pathParts.slice(0, treeIndex).join("/"); | |
| // branch 是 tree 后面的第一个元素(例如 main) | |
| branch = pathParts[treeIndex + 1] || "main"; | |
| // basePath 是 branch 后面的所有部分 | |
| basePath = pathParts.slice(treeIndex + 2).join("/"); | |
| } | |
| } catch (e) { | |
| console.error("URL 解析失败:", e); | |
| } | |
| //repoLink.href = showcaseUrl; | |
| console.log("解析结果 -> Repo:", repoId, "| Branch:", branch, "| BasePath:", basePath); | |
| // 初始化当前弹窗的状态 | |
| currentModalData = { | |
| repoId: repoId, | |
| branch: branch, | |
| images: [], | |
| currentIndex: 0, | |
| mode: 'showcase', | |
| jsonData: [] | |
| }; | |
| const gridView = document.getElementById("grid-view"); | |
| gridView.innerHTML = `<div style="grid-column: 1/-1; width: 100%; text-align: center; color: #888; padding: 3rem; box-sizing: border-box;">Loading Showcase...</div>`; | |
| try { | |
| // 1. 获取 gallery.json | |
| // 🌟 这里必须加上 encodeURIComponent 处理,防止中文或空格导致 404 | |
| // 拼出的 URL 示例:https://huggingface.co/waveshaker/loras/resolve/main/comfyui_showcase/krea2/LeeJian_KR2/gallery.json | |
| const jsonUrl = `https://huggingface.co/${repoId}/resolve/${branch}/${basePath}/gallery.json`; | |
| console.log("正在请求:", jsonUrl); // 你在 F12 控制台里应该能看到这个 URL 是否正确 | |
| const res = await fetch(jsonUrl); | |
| if (!res.ok) throw new Error(`Failed to load gallery.json (HTTP ${res.status})`); | |
| const galleryData = await res.json(); | |
| if (!Array.isArray(galleryData) || galleryData.length === 0) { | |
| throw new Error("Gallery is empty or invalid format."); | |
| } | |
| // 2. 映射图片信息 (拼凑完整路径) | |
| currentModalData.jsonData = galleryData; | |
| currentModalData.images = galleryData.map(item => ({ | |
| path: `${basePath}/${item.file_name}`, // 组合成 相对路径,供大图展示 | |
| jsonInfo: item // 把该图片的专属参数存起来 | |
| })); | |
| // 3. 调用现有的渲染函数 | |
| currentGridPage = 1; | |
| document.getElementById("grid-pagination").style.display = "flex"; // 显示分页器 | |
| calculateGridLayout(); | |
| renderGridPage(); | |
| } catch (err) { | |
| console.error(err); | |
| gridView.innerHTML = `<div style="grid-column: 1/-1; text-align: center; color: #e55; padding: 2rem;">Failed: ${err.message}</div>`; | |
| document.getElementById("grid-pagination").style.display = "none"; | |
| } | |
| }; | |
| window.openDatasetModal = async function(datasetUrl) { | |
| // 🌟 顶部动态 UI 更新 | |
| document.querySelector(".modal-header h3").textContent = "Dataset Viewer"; | |
| const infoLink = document.getElementById("modal-info-link"); | |
| const compareLink = document.getElementById("modal-compare-link"); | |
| // 设置右侧外链(真实的 Dataset 仓库链接) | |
| infoLink.href = datasetUrl; | |
| infoLink.target = "_blank"; | |
| infoLink.innerHTML = "🔗 View Dataset Repo ↗"; | |
| // 智能反查对应的 Showcase 链接 | |
| let inferredShowcaseUrl = ""; | |
| try { | |
| const currentItem = allItems.find(item => item.dataset === datasetUrl); | |
| if (currentItem && currentItem.showcase) { | |
| inferredShowcaseUrl = currentItem.showcase; | |
| } | |
| } catch (e) { console.warn("查找 Showcase 失败", e); } | |
| if (inferredShowcaseUrl) { | |
| compareLink.href = `javascript:openShowcaseModal('${inferredShowcaseUrl}')`; | |
| compareLink.innerHTML = "✨ Compare with ComfyUI Showcase"; | |
| compareLink.style.display = "inline-block"; | |
| } else { | |
| compareLink.style.display = "none"; | |
| } | |
| const modal = document.getElementById("dataset-modal"); | |
| const select = document.getElementById("folder-select"); | |
| //const repoLink = document.getElementById("modal-repo-link"); | |
| if (!modal) return; | |
| modal.style.display = "flex"; | |
| document.body.style.overflow = "hidden"; | |
| document.getElementById("grid-view-container").style.display = "none"; | |
| document.getElementById("detail-view").style.display = "none"; | |
| document.getElementById("back-to-grid").style.display = "none"; | |
| document.getElementById("select-container").style.display = "flex"; | |
| select.innerHTML = `<option>Loading...</option>`; | |
| let parts; | |
| if (datasetUrl.startsWith("http")) { | |
| const pathParts = new URL(datasetUrl).pathname.split("/").filter(Boolean); | |
| parts = [pathParts[1], pathParts[2], pathParts[4], ...pathParts.slice(5)]; | |
| } else { parts = datasetUrl.split("/"); } | |
| const repoId = `${parts[0]}/${parts[1]}`, branch = parts[2] || "main", basePath = parts.slice(3).join("/"); | |
| //repoLink.href = `https://huggingface.co/datasets/${repoId}/tree/${branch}/${basePath}`; | |
| currentModalData = { repoId, branch, images: [], currentIndex: 0 }; | |
| try { | |
| const res = await fetch(`https://huggingface.co/api/datasets/${repoId}/tree/${branch}/${basePath}`); | |
| if (!res.ok) throw new Error(`API: ${res.status}`); | |
| const files = await res.json(); | |
| const subfolders = files.filter(f => f.type === "directory").map(f => f.path); | |
| // 🌟 修复 1:智能排序,处理 NaN 的情况 | |
| subfolders.sort((a, b) => { | |
| const nameA = a.split("/").pop(); | |
| const nameB = b.split("/").pop(); | |
| // 提取开头的数字,如果没有数字则视为 0 | |
| const matchA = nameA.match(/^(\d+)/); | |
| const matchB = nameB.match(/^(\d+)/); | |
| const numA = matchA ? parseInt(matchA[1], 10) : 0; | |
| const numB = matchB ? parseInt(matchB[1], 10) : 0; | |
| // 1. 优先按数字降序排列 (核心:让最新的 5_Portrait 排在最上面) | |
| if (numA !== numB) { | |
| return numB - numA; | |
| } | |
| // 2. 如果数字相同,或者都没有数字,再按名字字母升序排列 (A-Z) | |
| return nameA.localeCompare(nameB); | |
| }); | |
| if (subfolders.length === 0) { | |
| select.innerHTML = `<option value="${basePath}">Root</option>`; | |
| loadFolderImages(basePath); | |
| } else { | |
| // 🌟 修复 2:给渲染出来的 option 绑定是否默认选中的状态 | |
| select.innerHTML = subfolders.map((f, index) => { | |
| const isSelected = index === 0 ? "selected" : ""; // 默认选中第一个(也就是最大的) | |
| return `<option value="${f}" ${isSelected}>${f.split("/").pop()}</option>`; | |
| }).join(''); | |
| select.onchange = (e) => loadFolderImages(e.target.value); | |
| // 触发加载第一个文件夹 | |
| loadFolderImages(subfolders[0]); | |
| } | |
| } catch (err) { | |
| alert(`Failed to load dataset: ${err.message}`); | |
| closeModal(); | |
| } | |
| }; | |
| async function loadFolderImages(targetFolder) { | |
| const { repoId, branch } = currentModalData; | |
| const gridContainer = document.getElementById("grid-view-container"); | |
| const detailView = document.getElementById("detail-view"); | |
| const gridView = document.getElementById("grid-view"); | |
| gridContainer.style.display = "flex"; | |
| detailView.style.display = "none"; | |
| gridView.innerHTML = `<div style="grid-column: 1/-1; text-align: center; color: #888; padding: 3rem; box-sizing: border-box;">Loading Dataset...</div>`; | |
| currentGridPage = 1; | |
| try { | |
| const res = await fetch(`https://huggingface.co/api/datasets/${repoId}/tree/${branch}/${targetFolder}`); | |
| if (!res.ok) throw new Error(`Access failed (${res.status})`); | |
| const files = await res.json(); | |
| const images = files.filter(f => f.type === "file" && /\.(jpg|jpeg)$/i.test(f.path)); | |
| if (images.length === 0) { | |
| gridView.innerHTML = `<div style="grid-column: 1/-1; text-align: center; color: #888; padding: 2rem;">No .jpg images found.</div>`; | |
| document.getElementById("grid-pagination").style.display = "none"; | |
| return; | |
| } | |
| currentModalData.images = images; | |
| document.getElementById("grid-pagination").style.display = "flex"; | |
| // 🌟 关键:等待一帧,确保 DOM 已经渲染出真实的宽度,再执行计算和渲染 | |
| requestAnimationFrame(() => { | |
| calculateGridLayout(); | |
| renderGridPage(); | |
| }); | |
| } catch (err) { | |
| gridView.innerHTML = `<div style="grid-column: 1/-1; text-align: center; color: #e55; padding: 2rem;">Failed: ${err.message}</div>`; | |
| } | |
| } | |
| window.openUsageModal = async function(guideUrl) { | |
| const modal = document.getElementById("usage-modal"); | |
| const body = document.getElementById("usage-modal-body"); | |
| const title = document.getElementById("usage-modal-title"); | |
| const repoLink = document.getElementById("usage-repo-link"); | |
| if (!modal) return; | |
| modal.style.display = "flex"; | |
| document.body.style.overflow = "hidden"; | |
| body.innerHTML = `<div style="text-align: center; color: #888; padding: 2rem;">Loading guide...</div>`; | |
| // 🌟 核心修复:解析完整 URL,提取 repoId, branch 和 path | |
| let repoId = "waveshaker/loras"; | |
| let branch = "main"; | |
| let basePath = ""; | |
| try { | |
| // 变成 "waveshaker/loras/tree/main/usage_guide/10inPH_KR2" | |
| const cleanPath = guideUrl.replace("https://huggingface.co/", "").replace("http://huggingface.co/", ""); | |
| const pathParts = cleanPath.split("/"); | |
| const treeIndex = pathParts.indexOf("tree"); | |
| if (treeIndex !== -1) { | |
| repoId = pathParts.slice(0, treeIndex).join("/"); | |
| branch = pathParts[treeIndex + 1] || "main"; | |
| basePath = pathParts.slice(treeIndex + 2).join("/"); | |
| } | |
| } catch (e) { | |
| console.error("URL 解析失败:", e); | |
| } | |
| // 正确拼凑出 Resolve 下载链接 | |
| const mdUrl = `https://huggingface.co/${repoId}/resolve/${branch}/${basePath}/guide.md`; | |
| repoLink.href = guideUrl; // 打开新窗口用的还是原网页链接 | |
| try { | |
| const res = await fetch(mdUrl); | |
| if (!res.ok) throw new Error("Guide not found"); | |
| const mdText = await res.text(); | |
| // 使用 marked.js 解析 Markdown 为 HTML | |
| const htmlContent = marked.parse(mdText, { breaks: true }); | |
| // 提取路径的最后一部分作为标题 | |
| const guideName = basePath.split("/").pop(); | |
| title.textContent = `Usage Guide: ${guideName}`; | |
| // 渲染 HTML | |
| body.innerHTML = `<div class="markdown-body">${htmlContent}</div>`; | |
| // 🌟 核心修复:处理 Usage 里的图片,加骨架和防抖动 | |
| const mdImages = body.querySelectorAll('.markdown-body img'); | |
| mdImages.forEach(img => { | |
| // 1. 获取原始相对路径,并拼接成完整的绝对路径 | |
| const rawSrc = img.getAttribute('src'); | |
| if (!rawSrc) return; | |
| // 如果是相对路径,根据你的 mdUrl 来拼凑 | |
| // 假设 mdUrl 是 https://huggingface.co/.../guide.md | |
| // 那么图片应该去 https://huggingface.co/.../ 目录下找 | |
| const baseDir = mdUrl.substring(0, mdUrl.lastIndexOf('/') + 1); | |
| const realSrc = rawSrc.startsWith('http') ? rawSrc : baseDir + rawSrc; | |
| // 2. 移除原来的 src | |
| img.removeAttribute('src'); | |
| // 3. 给图片包裹一个外层容器 | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'md-img-wrapper img-skeleton'; | |
| // 给个默认的占位高度,或者你可以根据图片原始宽高比例来算 | |
| wrapper.style.minHeight = '200px'; | |
| // 4. 替换 DOM 结构 | |
| img.parentNode.insertBefore(wrapper, img); | |
| wrapper.appendChild(img); | |
| // 5. 定义加载逻辑 | |
| const loadImg = () => { | |
| img.src = realSrc; | |
| img.onload = () => { | |
| wrapper.classList.remove('img-skeleton'); | |
| img.classList.add('loaded'); | |
| }; | |
| img.onerror = () => { | |
| wrapper.classList.remove('img-skeleton'); | |
| img.classList.add('preview-missing'); | |
| console.warn("Usage image failed to load:", realSrc); | |
| }; | |
| }; | |
| // 6. 监听 wrapper(因为 img 现在没有高度,监听 img 没用) | |
| const observer = new IntersectionObserver((entries) => { | |
| entries.forEach(entry => { | |
| if (entry.isIntersecting) { | |
| loadImg(); | |
| observer.unobserve(wrapper); | |
| } | |
| }); | |
| }, { rootMargin: "400px" }); | |
| observer.observe(wrapper); // 监听包裹容器 | |
| }); | |
| } catch (err) { | |
| body.innerHTML = `<div style="text-align: center; color: #e55; padding: 2rem;">❌ Failed to load guide: ${err.message}</div>`; | |
| } | |
| }; | |
| // 关闭弹窗 | |
| document.getElementById("close-usage-modal").onclick = () => { | |
| document.getElementById("usage-modal").style.display = "none"; | |
| document.body.style.overflow = ""; | |
| }; | |
| document.getElementById("usage-modal").onclick = (e) => { | |
| if (e.target.id === "usage-modal") { | |
| document.getElementById("usage-modal").style.display = "none"; | |
| document.body.style.overflow = ""; | |
| } | |
| }; | |
| function renderGridPage() { | |
| const { repoId, branch, images } = currentModalData; | |
| const gridView = document.getElementById("grid-view"); | |
| const paginationContainer = document.getElementById("grid-pagination"); | |
| calculateGridLayout(); | |
| const totalPages = Math.ceil(images.length / GRID_ITEMS_PER_PAGE); | |
| const startIndex = (currentGridPage - 1) * GRID_ITEMS_PER_PAGE; | |
| const endIndex = startIndex + GRID_ITEMS_PER_PAGE; | |
| const itemsToRender = images.slice(startIndex, endIndex); | |
| let html = ""; | |
| itemsToRender.forEach((img, idx) => { | |
| const globalIndex = startIndex + idx; | |
| // 🌟 修改点 1:套上 wsrv.nl 代理,限制宽度,转 WebP | |
| // 强制把空格转义,防止 Frame 412.jpg 这种文件名导致 403/404 | |
| // const safePath = img.path.replace(/ /g, "%20"); | |
| // const rawUrl = `https://huggingface.co/datasets/${repoId}/resolve/${branch}/${safePath}`; | |
| // const proxiedUrl = `https://wsrv.nl/?url=${encodeURIComponent(rawUrl)}&w=200&output=webp&q=80`; | |
| const safePath = img.path.replace(/ /g, "%20"); | |
| //const proxiedUrl = `https://huggingface.co/datasets/${repoId}/resolve/${branch}/${safePath}`; | |
| const repoType = currentModalData.mode === 'showcase' ? '' : 'datasets/'; | |
| const proxiedUrl = `https://huggingface.co/${repoType}${repoId}/resolve/${branch}/${safePath}`; | |
| // 🌟 修改点 2:把 src 换成 data-src | |
| html += ` | |
| <div class="grid-thumb-container img-skeleton"> | |
| <img class="grid-thumb reveal-img" data-src="${proxiedUrl}" onclick="openDetailView(${globalIndex})" alt="thumb" | |
| onload="this.classList.add('loaded'); this.parentElement.classList.remove('img-skeleton');" | |
| onerror="this.classList.remove('reveal-img'); this.classList.add('preview-missing'); this.parentElement.classList.remove('img-skeleton');" /> | |
| </div> | |
| `; | |
| }); | |
| gridView.innerHTML = html; | |
| // 🌟 修改点 3:调用懒加载监听器 | |
| observeImages(gridView); | |
| // 渲染高级分页器(保持原样) | |
| renderPagination(paginationContainer, currentGridPage, totalPages, (newPage) => { | |
| currentGridPage = newPage; | |
| renderGridPage(); | |
| gridView.scrollTop = 0; | |
| }); | |
| } | |
| // 🌟 核心:智能分页器生成逻辑 | |
| function renderPagination(container, currentPage, totalPages, onPageChange) { | |
| let html = ""; | |
| // 上一页按钮 | |
| html += `<button class="page-btn nav-arrow" ${currentPage === 1 ? 'disabled' : ''} data-page="${currentPage - 1}">❮</button>`; | |
| // 生成页码逻辑(1 ... 4 5 6 ... 20) | |
| let startPage = Math.max(1, currentPage - 2); | |
| let endPage = Math.min(totalPages, currentPage + 2); | |
| if (startPage > 1) { | |
| html += `<button class="page-btn" data-page="1">1</button>`; | |
| if (startPage > 2) html += `<span class="page-dots">...</span>`; | |
| } | |
| for (let i = startPage; i <= endPage; i++) { | |
| html += `<button class="page-btn ${i === currentPage ? 'active' : ''}" data-page="${i}">${i}</button>`; | |
| } | |
| if (endPage < totalPages) { | |
| if (endPage < totalPages - 1) html += `<span class="page-dots">...</span>`; | |
| html += `<button class="page-btn" data-page="${totalPages}">${totalPages}</button>`; | |
| } | |
| // 下一页按钮 | |
| html += `<button class="page-btn nav-arrow" ${currentPage === totalPages ? 'disabled' : ''} data-page="${currentPage + 1}">❯</button>`; | |
| // 底部小字显示总页数 | |
| html += `<span style="color: #888; font-size: 0.8rem; margin-left: 1rem;">Total: ${totalPages} pages</span>`; | |
| container.innerHTML = html; | |
| // 绑定点击事件 | |
| container.querySelectorAll('.page-btn').forEach(btn => { | |
| btn.onclick = (e) => { | |
| const page = parseInt(e.target.getAttribute('data-page'), 10); | |
| if (page && page !== currentPage && !e.target.disabled) { | |
| onPageChange(page); | |
| } | |
| }; | |
| }); | |
| } | |
| window.openDetailView = function(index) { | |
| // 🌟 核心:必须彻底隐藏网格容器! | |
| document.getElementById("grid-view-container").style.display = "none"; | |
| // 显示详情页,变为 Flex 布局 | |
| document.getElementById("detail-view").style.display = "flex"; | |
| // 显示返回按钮,隐藏下拉框 | |
| document.getElementById("back-to-grid").style.display = "inline-block"; | |
| document.getElementById("select-container").style.display = "none"; | |
| currentModalData.currentIndex = index; | |
| if (currentModalData.images.length > 1) { | |
| document.getElementById("btn-prev").style.display = "flex"; | |
| document.getElementById("btn-next").style.display = "flex"; | |
| } | |
| renderCurrentImage(); | |
| }; | |
| document.getElementById("back-to-grid").onclick = () => { | |
| document.getElementById("grid-view-container").style.display = "flex"; | |
| document.getElementById("detail-view").style.display = "none"; | |
| document.getElementById("back-to-grid").style.display = "none"; | |
| // 🌟 关键:如果是 Dataset 模式才显示下拉框,Showcase 模式保持隐藏 | |
| if (currentModalData.mode !== 'showcase') { | |
| document.getElementById("select-container").style.display = "flex"; | |
| } | |
| }; | |
| // 在全局声明一个变量,记录当前正在预加载的临时图片对象 | |
| let currentPreloadImg = null; | |
| function renderCurrentImage() { | |
| const { repoId, branch, images, currentIndex, mode } = currentModalData; | |
| const img = document.getElementById("viewer-img"); | |
| const txt = document.getElementById("viewer-txt"); | |
| const loading = document.getElementById("viewer-loading"); | |
| const copyBtn = document.getElementById("copy-caption-btn"); | |
| const charCountEl = document.getElementById("char-count"); | |
| const currentImage = images[currentIndex]; | |
| document.getElementById("image-counter").textContent = `${currentIndex + 1} / ${images.length}`; | |
| // 1. 重置 UI 状态,显示扫光 | |
| loading.classList.add("viewer-loading-skeleton"); | |
| loading.style.display = "flex"; | |
| loading.innerHTML = "<span>Loading...</span>"; | |
| txt.textContent = "Loading..."; | |
| charCountEl.textContent = "0 Tokens"; | |
| copyBtn.textContent = "Copy"; | |
| copyBtn.classList.remove("copied"); | |
| // 2. 彻底隐藏旧图 | |
| img.style.display = "none"; | |
| // 3. 准备 URL (区分 Model Repo 和 Dataset Repo) | |
| const safePath = currentImage.path.replace(/ /g, "%20"); | |
| // 🌟 核心修复:Showcase 在 Model Repo,Dataset 在 Dataset Repo | |
| const repoType = mode === 'showcase' ? '' : 'datasets/'; | |
| const rawUrl = `https://huggingface.co/${repoType}${repoId}/resolve/${branch}/${safePath}`; | |
| let imgUrl = rawUrl; | |
| const requestedPath = currentImage.path; | |
| // 🌟 核心保护:中断上一次未加载完的图片请求 | |
| if (currentPreloadImg) { | |
| currentPreloadImg.onload = null; // 🌟 关键:移除加载完成的事件监听 | |
| currentPreloadImg.onerror = null; // 🌟 关键:移除加载失败的事件监听 | |
| currentPreloadImg.src = ""; // 清空 src 中断网络请求 | |
| } | |
| currentPreloadImg = new Image(); | |
| currentPreloadImg.src = imgUrl; | |
| currentPreloadImg.onload = () => { | |
| // 如果加载完时,用户已经切到了别的图,则放弃这次渲染 | |
| if (currentModalData.images[currentModalData.currentIndex].path !== requestedPath) return; | |
| // 🌟 第一步:把 src 赋给真实的 img 标签,但先别显示它 | |
| img.src = imgUrl; | |
| // 🌟 第二步:等待浏览器把这个图彻底解码完毕 | |
| img.decode().then(() => { | |
| // 🌟 第三步:解码完毕,立刻瞬间显示,并隐藏 loading 扫光 | |
| img.style.display = "block"; | |
| loading.style.display = "none"; | |
| currentPreloadImg = null; | |
| }).catch((err) => { | |
| // 如果解码失败(极少情况),也让它显示出来 | |
| img.style.display = "block"; | |
| loading.style.display = "none"; | |
| currentPreloadImg = null; | |
| console.warn("图片解码失败", err); | |
| }); | |
| }; | |
| // 4. 获取文本 (区分 Showcase JSON 和 Dataset TXT) | |
| const paramsPanel = document.getElementById("params-panel"); | |
| const dragTip = document.getElementById("drag-tip"); | |
| const promptLabel = document.querySelector(".caption-container label"); | |
| if (mode === 'showcase') { | |
| txt.style.fontSize = "0.7rem"; | |
| txt.style.lineHeight = "1.4"; | |
| if (promptLabel) promptLabel.textContent = "PROMPT:"; | |
| if (dragTip) dragTip.style.display = "flex"; | |
| const info = currentImage.jsonInfo; | |
| if (info) { | |
| const promptText = info.pos || info.prompt || "No prompt provided."; | |
| // txt.textContent = promptText; | |
| txt.innerText = promptText; | |
| const estimatedTokens = Math.ceil(promptText.trim().length / 4); | |
| charCountEl.textContent = `~${estimatedTokens} Tokens`; | |
| // 🌟 渲染参数面板 | |
| if (paramsPanel) { | |
| let html = ""; | |
| // 辅助函数:生成统一的一行 | |
| const addRow = (key, value) => { | |
| if (!value) return; | |
| html += ` | |
| <div class="param-row"> | |
| <div class="param-key">${key}:</div> | |
| <div class="param-value">${value}</div> | |
| </div>`; | |
| }; | |
| addRow("Size", info.size); | |
| addRow("Model", info.model); | |
| addRow("VAE", info.vae); | |
| addRow("CLIP", info.clip); | |
| addRow("Seed", info.seed); | |
| addRow("Steps", info.steps); | |
| addRow("CFG", info.cfg); | |
| addRow("Sampler", info.sampler); | |
| addRow("Scheduler", info.scheduler); | |
| addRow("Denoise", info.denoise); | |
| // LoRAs 特殊处理 | |
| if (info.loras && info.loras.length > 0) { | |
| const loraList = Array.isArray(info.loras) ? info.loras : info.loras.split(',').map(s => s.trim()); | |
| let loraHtml = ` | |
| <div class="param-row"> | |
| <div class="param-key">LoRAs:</div> | |
| <div class="lora-container">`; | |
| loraList.forEach(lora => { | |
| loraHtml += `<div class="param-value">${lora}</div>`; | |
| }); | |
| loraHtml += `</div></div>`; | |
| html += loraHtml; | |
| } | |
| paramsPanel.innerHTML = html; | |
| paramsPanel.style.display = "flex"; | |
| } | |
| } else { | |
| txt.textContent = "No JSON data for this image."; | |
| } | |
| } else { | |
| // 🌟 Dataset 模式:彻底隐藏专属组件,恢复默认状态 | |
| txt.style.fontSize = "0.75rem"; | |
| txt.style.lineHeight = "1.5"; | |
| if (promptLabel) promptLabel.textContent = "CAPTION:"; | |
| if (paramsPanel) paramsPanel.style.display = "none"; | |
| if (dragTip) dragTip.style.display = "none"; | |
| // 原有的 Dataset fetch .txt 逻辑 | |
| const txtFileName = currentImage.path.replace(/\.[^/.]+$/, ".txt"); | |
| fetch(`https://huggingface.co/${repoType}${repoId}/resolve/${branch}/${txtFileName}`) | |
| .then(res => { | |
| if (!res.ok) return "Caption not found."; | |
| return res.text(); | |
| }) | |
| .then(text => { | |
| if (currentModalData.images[currentModalData.currentIndex].path !== requestedPath) return; | |
| txt.textContent = text; | |
| if (text !== "Caption not found.") { | |
| const estimatedTokens = Math.ceil(text.trim().length / 4); | |
| charCountEl.textContent = `~${estimatedTokens} Tokens`; | |
| } | |
| }) | |
| .catch(() => { | |
| if (currentModalData.images[currentModalData.currentIndex].path === requestedPath) { | |
| txt.textContent = "Failed to read text."; | |
| } | |
| }); | |
| } | |
| // 5. 静默预加载前后张图片 | |
| // 🌟 清理上一次的全部缓存 | |
| if (!window._preloadImgs) window._preloadImgs = []; | |
| window._preloadImgs.forEach(img => { img.src = ""; }); | |
| window._preloadImgs = []; | |
| const nextIndex = (currentIndex + 1) % images.length; | |
| const prevIndex = (currentIndex - 1 + images.length) % images.length; | |
| const preloadImage = (path) => { | |
| if (!path) return; | |
| const safeP = path.replace(/ /g, "%20"); | |
| const raw = `https://huggingface.co/${repoType}${repoId}/resolve/${branch}/${safeP}`; | |
| const img = new Image(); | |
| img.src = raw; | |
| window._preloadImgs.push(img); // 🌟 存入数组等待下一次清理 | |
| }; | |
| if (images[nextIndex]) preloadImage(images[nextIndex].path); | |
| if (images[prevIndex]) preloadImage(images[prevIndex].path); | |
| } | |
| document.getElementById("copy-caption-btn").onclick = function() { | |
| const txt = document.getElementById("viewer-txt").textContent; | |
| if (txt === "Loading..." || txt === "Caption not found.") return; | |
| navigator.clipboard.writeText(txt).then(() => { | |
| this.textContent = "Copied!"; | |
| this.classList.add("copied"); | |
| setTimeout(() => { | |
| this.textContent = "Copy"; | |
| this.classList.remove("copied"); | |
| }, 1500); | |
| }); | |
| }; | |
| function changeImage(dir) { | |
| const { images, currentIndex } = currentModalData; | |
| let n = currentIndex + dir; | |
| if (n < 0) n = images.length - 1; | |
| if (n >= images.length) n = 0; | |
| currentModalData.currentIndex = n; | |
| renderCurrentImage(); | |
| } | |
| // 🌟 统一的关闭弹窗函数 | |
| function closeModal() { | |
| document.getElementById("dataset-modal").style.display = "none"; | |
| document.getElementById("viewer-img").src = ""; | |
| document.body.style.overflow = ""; // 🌟 恢复主屏滚动 | |
| } | |
| document.getElementById("btn-prev").onclick = () => changeImage(-1); | |
| document.getElementById("btn-next").onclick = () => changeImage(1); | |
| document.getElementById("close-modal").onclick = closeModal; | |
| document.getElementById("dataset-modal").onclick = (e) => { | |
| if (e.target.id === "dataset-modal") closeModal(); | |
| }; | |
| // 🌟 记录上一次的窗口宽度 | |
| let lastWindowWidth = window.innerWidth; | |
| // 监听容器尺寸变化,自动重新排版(防抖处理) | |
| let resizeTimer; | |
| const resizeObserver = new ResizeObserver(() => { | |
| clearTimeout(resizeTimer); | |
| resizeTimer = setTimeout(() => { | |
| const gridContainer = document.getElementById("grid-view-container"); | |
| // 🌟 核心:只有窗口宽度真正变了才重绘,避免 display 切换引发闪烁 | |
| const currentWindowWidth = window.innerWidth; | |
| if (currentWindowWidth === lastWindowWidth) return; | |
| if (gridContainer && gridContainer.style.display !== "none" && currentModalData.images.length > 0) { | |
| calculateGridLayout(); | |
| renderGridPage(); | |
| } | |
| }, 150); | |
| }); | |
| window.addEventListener('resize', () => { | |
| lastWindowWidth = window.innerWidth; | |
| }); | |
| window.addEventListener('DOMContentLoaded', () => { | |
| const gridView = document.getElementById("grid-view"); | |
| if (gridView) resizeObserver.observe(gridView); | |
| }); | |
| // ========================================== | |
| // 🪟 弹窗: Disclaimer 弹窗逻辑 | |
| // ========================================== | |
| const discModal = document.getElementById("disclaimer-modal"); | |
| const openDiscBtn = document.getElementById("open-disclaimer-btn"); | |
| const closeDiscBtn = document.getElementById("close-disclaimer-modal"); | |
| if (openDiscBtn && discModal) { | |
| openDiscBtn.onclick = () => { | |
| discModal.style.display = "flex"; | |
| document.body.style.overflow = "hidden"; // 🌟 锁定主屏滚动 | |
| }; | |
| } | |
| if (closeDiscBtn && discModal) { | |
| closeDiscBtn.onclick = () => { | |
| discModal.style.display = "none"; | |
| document.body.style.overflow = ""; // 🌟 恢复主屏滚动 | |
| }; | |
| } | |
| if (discModal) { | |
| discModal.onclick = (e) => { | |
| if (e.target.id === "disclaimer-modal") { | |
| discModal.style.display = "none"; | |
| document.body.style.overflow = ""; // 🌟 恢复主屏滚动 | |
| } | |
| }; | |
| } | |
| // ========================================== | |
| // 📍 Event:键盘左右键切换图片支持 | |
| // ========================================== | |
| // 🌟 新增一个全局变量记录上次按键时间 | |
| let lastKeyTime = 0; | |
| // 🌟 核心:定义一个全局标志位,记录是否在拖拽 | |
| window.isDragging = false; | |
| // 监听全局拖拽状态(兜底) | |
| window.addEventListener('dragstart', () => { window.isDragging = true; }); | |
| window.addEventListener('dragend', () => { | |
| // 延迟 100ms 解除状态,防止拖拽释放瞬间按下按键 | |
| setTimeout(() => { window.isDragging = false; }, 100); | |
| }); | |
| window.addEventListener('keydown', (e) => { | |
| // 1. 如果弹窗没开,或者没进详情页,直接退出 | |
| const modal = document.getElementById("dataset-modal"); | |
| if (!modal || modal.style.display === "none") return; | |
| const detailView = document.getElementById("detail-view"); | |
| if (!detailView || detailView.style.display === "none") return; | |
| // 2. 如果是输入框,退出 | |
| const activeTag = document.activeElement.tagName; | |
| if (activeTag === 'INPUT' || activeTag === 'TEXTAREA') return; | |
| // 🌟 3. 核心修复:如果正在拖拽图片,绝对不响应键盘! | |
| if (window.isDragging) { | |
| console.warn("正在拖拽中,忽略键盘事件防止卡死"); | |
| return; | |
| } | |
| // 4. 300ms 防连点 | |
| const now = Date.now(); | |
| if (now - lastKeyTime < 300) return; | |
| // 5. 执行切图 | |
| if (e.key === "ArrowLeft") { | |
| e.preventDefault(); | |
| lastKeyTime = now; | |
| changeImage(-1); | |
| } else if (e.key === "ArrowRight") { | |
| e.preventDefault(); | |
| lastKeyTime = now; | |
| changeImage(1); | |
| } else if (e.key === "Escape") { | |
| closeModal(); | |
| } | |
| }); | |
| // ========================================== | |
| // 📍 Event: 移动端侧边栏抽屉交互 | |
| // ========================================== | |
| const mobileMenuBtn = document.getElementById("mobile-menu-btn"); | |
| const mainSidebar = document.getElementById("main-sidebar"); | |
| if (mobileMenuBtn && mainSidebar) { | |
| // 1. 点击按钮切换 | |
| mobileMenuBtn.onclick = (e) => { | |
| e.stopPropagation(); // 阻止事件冒泡到 document | |
| mainSidebar.classList.toggle("open"); | |
| }; | |
| // 2. 点击页面其他地方(比如卡片),自动关闭侧边栏 | |
| document.addEventListener("click", (e) => { | |
| // 如果侧边栏是打开的,且用户点击的不是侧边栏内部,也不是菜单按钮 | |
| if (mainSidebar.classList.contains("open") && | |
| !mainSidebar.contains(e.target) && | |
| e.target !== mobileMenuBtn) { | |
| mainSidebar.classList.remove("open"); | |
| } | |
| }); | |
| } | |
| // ========================================== | |
| // 🛠️ Utils:核心工具函数 | |
| // ========================================== | |
| async function fetchWithRetry(url, options, retries = 3, delay = 1000) { | |
| for (let i = 0; i < retries; i++) { | |
| try { | |
| const res = await fetch(url, options); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| return res; | |
| } catch (err) { | |
| if (i === retries - 1) throw err; | |
| console.warn(`Fetch failed for ${url}, retrying in ${delay}ms... (Attempt ${i + 1}/${retries})`); | |
| await new Promise(r => setTimeout(r, delay)); | |
| } | |
| } | |
| } | |
| function formatModelName(dir) { | |
| if (!dir) return "Other"; | |
| // 去动态配置里找对应的 label | |
| const match = siteConfig.models.find(m => m.key.toLowerCase() === dir.toLowerCase()); | |
| if (match) return match.label; | |
| return dir.replace(/[_-]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); | |
| } | |
| function formatCategory(dir) { | |
| if (!dir) return "Unknown"; | |
| const match = siteConfig.categories.find(c => c.key.toLowerCase() === dir.toLowerCase()); | |
| if (match) return match.label; | |
| return dir; | |
| } | |
| function encodePath(path) { | |
| // 🌟 核心修复:先把原路径里的空格全部替换成 %20,然后再进行 encodeURIComponent | |
| return path.split("/").map(seg => encodeURIComponent(seg.replace(/ /g, "%20"))).join("/"); | |
| } | |
| function escapeHtml(str) { | |
| if (str === null || str === undefined) return ""; | |
| return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | |
| } | |
| function escapeAttr(str) { return escapeHtml(str); } | |
| // ========================================== | |
| // 🚀 Launch: 启动 | |
| // ========================================== | |
| main(); |