Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_grid.js
T
DemonGatanjieuandClaude Opus 5.5 e956ad8dea Fold the versions of a model behind a tab on its card
A model with other versions downloaded is one card with a numbered tab on its right edge;
a click slides the versions out beside it, under one accent edge, the tab on the last folding
them back. A pin on each picks the version shown when folded. Open models stay open, and
"Open all versions" opens or folds every one. Replaces the version label menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:06:27 +08:00

419 lines
19 KiB
JavaScript

/**
* ui_grid.js
* Extracted Grid methods.
*/
import { app } from "../../../scripts/app.js";
import { translate } from './locales.js';
import { focusModel, modelListUrl, renderBaseBar, renderModelTabs, renderTypeBar } from './ui_model_types.js';
import { countBases, inFamily } from './model_bases.js';
import { matchesModelQuery } from './ui_model_search.js';
import { renderSourceBadge } from './model_source.js';
import { renderModelSourcesView } from './ui_model_sources.js';
import { renderModelTidyView } from './ui_model_tidy.js';
import { recordCanvasStep } from './canvas_history.js';
import { MODEL_LOADERS } from './quick_search_data.js';
import { groupByPage, pageId } from './model_versions.js';
import { chosenVersion, isOpen, moveCards, openAllSwitch, setOpen, versionLabel, versionPin, versionsTab } from './ui_model_versions.js';
const t = (key, params) => translate(key, params);
export function cardPreviewUrl(previewUrl, thumbnailMode) {
if (!previewUrl || thumbnailMode === 'original') return previewUrl;
return `${previewUrl}${previewUrl.includes('?') ? '&' : '?'}variant=card`;
}
/** A video cover's still first frame (made once on the server), shown until the video plays. */
export function cardPosterUrl(previewUrl) {
return `${previewUrl}${previewUrl.includes('?') ? '&' : '?'}variant=poster`;
}
// The first screen of cards loads first; the rest waits until scrolled near.
const EAGER_CARDS = 12;
function modelCardDisplayName(model) {
const metadata = model?.metadata || {};
return metadata.custom_name || model?.filename || '';
}
// The model each card shows, so that moving cards can tell one across a redraw.
const cardModels = new WeakMap();
/** A model's card; `pin`: the version pin of an open model (ui_model_versions.js), beside its version name. */
function modelCard(owner, model, index, pin = null) {
const card = document.createElement('div');
card.className = 'anomalous-card';
cardModels.set(card, model);
if (model.preview_url) {
const isVideo = model.preview_url.match(/\.mp4(?:&|$)/i) || model.preview_url.match(/\.webm(?:&|$)/i);
if (isVideo) {
const video = document.createElement('video');
video.dataset.anomalousSrc = model.preview_url;
video.muted = true; video.loop = true; video.playsInline = true;
// Nothing of the video is fetched until it plays; the poster shows meanwhile.
video.preload = 'none';
video.poster = cardPosterUrl(model.preview_url);
const ensureVideoSource = () => {
if (!video.getAttribute('src')) video.src = video.dataset.anomalousSrc;
};
if (owner.energySaving) {
video.pause();
card.addEventListener('mouseenter', () => {
ensureVideoSource();
video.play().catch(() => {});
});
card.addEventListener('mouseleave', () => { video.pause(); video.currentTime = 0; });
} else {
video.dataset.anomalousAutoplay = 'true';
}
if (owner._modelMediaObserver) {
owner._modelMediaObserver.observe(video);
} else {
ensureVideoSource();
if (!owner.energySaving) video.autoplay = true;
}
card.appendChild(video);
} else {
const img = document.createElement('img');
const early = index < EAGER_CARDS;
img.loading = early ? 'eager' : 'lazy';
if (early) img.fetchPriority = 'high';
img.decoding = 'async';
img.className = 'anomalous-skeleton-shimmer';
img.onload = () => { img.classList.remove('anomalous-skeleton-shimmer'); };
img.onerror = () => { img.classList.remove('anomalous-skeleton-shimmer'); };
img.src = cardPreviewUrl(model.preview_url, owner.cardThumbnailMode);
card.appendChild(img);
}
} else {
const ph = document.createElement('div');
ph.className = 'anomalous-card-placeholder';
ph.style.display = 'flex';
ph.style.flexDirection = 'column';
ph.style.alignItems = 'center';
ph.style.justifyContent = 'center';
ph.style.height = '100%';
ph.style.minHeight = '180px';
ph.style.color = 'var(--amb-lt-muted, #888)';
ph.style.userSelect = 'none';
ph.innerHTML = `
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.35;margin-bottom:6px;">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
<circle cx="9" cy="9" r="2"/>
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
</svg>
<div style="font-size:0.85em;font-weight:500;opacity:0.7;">${t('noPreview')}</div>
`;
card.appendChild(ph);
}
const badge = renderSourceBadge(model);
if (badge) card.appendChild(badge);
const labels = document.createElement('div');
labels.className = 'anomalous-card-labels';
// Civitai's version name: versions of one model often share a file name.
if (pin || model.metadata?.version_name) {
const row = document.createElement('div');
row.className = 'anomalous-card-version-row';
const version = document.createElement('span');
version.className = 'anomalous-card-version';
version.textContent = versionLabel(model);
row.appendChild(version);
if (pin) row.appendChild(pin);
labels.appendChild(row);
}
const title = document.createElement('div');
title.className = 'anomalous-card-title';
title.textContent = modelCardDisplayName(model);
labels.appendChild(title);
const physicalName = document.createElement('div');
physicalName.className = 'anomalous-card-filename';
physicalName.textContent = model.filename;
physicalName.removeAttribute('title');
labels.appendChild(physicalName);
card.appendChild(labels);
card.onclick = () => {
owner.recipeModelReturn = null;
focusModel(owner, model);
owner.historyStack = [];
owner.currentDetailModel = model;
owner.showDetail(model);
};
const applyBtn = document.createElement('button');
applyBtn.type = 'button';
applyBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="display:block;"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
applyBtn.className = 'anomalous-card-action-btn action-apply anomalous-tooltip-target';
applyBtn.removeAttribute('title');
applyBtn.setAttribute('data-tooltip', window.anomalous_browser_lang === 'zh' ? '一键发布到画布' : 'Add Node to Canvas');
applyBtn.setAttribute('data-tooltip-pos', 'bottom');
applyBtn.onclick = (e) => {
e.stopPropagation();
focusModel(owner, model);
owner.applyModelToCanvas(owner.currentType, owner.currentSubfolder, model);
};
card.appendChild(applyBtn);
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.innerHTML = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="display:block;"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>`;
editBtn.className = 'anomalous-card-action-btn action-edit anomalous-tooltip-target';
editBtn.removeAttribute('title');
editBtn.setAttribute('data-tooltip', window.anomalous_browser_lang === 'zh' ? '编辑模型信息' : 'Edit Model Info');
editBtn.setAttribute('data-tooltip-pos', 'bottom');
editBtn.onclick = (e) => {
e.stopPropagation();
focusModel(owner, model);
owner.showEditModal(model);
};
card.appendChild(editBtn);
const singleScanBtn = document.createElement('button');
singleScanBtn.type = 'button';
singleScanBtn.innerHTML = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>`;
singleScanBtn.className = 'anomalous-card-action-btn action-scan anomalous-tooltip-target';
singleScanBtn.removeAttribute('title');
singleScanBtn.setAttribute('data-tooltip', window.anomalous_browser_lang === 'zh' ? '立即精准扫描此模型' : 'Scan Model Directly');
singleScanBtn.setAttribute('data-tooltip-pos', 'bottom');
singleScanBtn.onclick = (e) => {
e.stopPropagation();
focusModel(owner, model);
owner.scanSingleModel(model, singleScanBtn);
};
card.appendChild(singleScanBtn);
return card;
}
/**
* The cards of one list entry (model_versions.js): a model alone; a model with other versions folded,
* one card with a tab to open them; or open, its versions side by side, the shown one first, the
* tab on the last to fold them. Opening, folding and pinning redraw only these cards, moving the rest.
*/
function entryCards(owner, entry, index) {
const { versions } = entry;
if (versions.length < 2) return [modelCard(owner, entry.model, index)];
const page = pageId(entry.model);
let cards = [];
const redraw = (update, leaving = []) => moveCards(owner.grid, leaving, () => {
update();
const next = draw();
cards.forEach(stopMediaInContainer);
cards[0].replaceWith(...next);
cards.slice(1).forEach(card => card.remove());
cards = next;
}, card => cardModels.get(card));
const draw = () => {
const open = isOpen(page);
const shown = entry.model;
const members = open ? [shown, ...versions.filter(model => model !== shown)] : [shown];
return members.map((model, at) => {
const pin = open && versionPin(model, model === shown, () => redraw(() => { entry.model = model; }));
const card = modelCard(owner, model, index + at, pin || null);
card.classList.add(open ? 'is-version-open' : 'is-version-folded');
if (!open || at === members.length - 1) {
card.classList.add('has-versions-tab');
card.appendChild(versionsTab(open, versions.length,
() => redraw(() => setOpen(page, !open), open ? cards.slice(1) : [])));
}
return card;
});
};
cards = draw();
return cards;
}
export async function loadModels() {
try {
if (this._modelLoadController) this._modelLoadController.abort();
this.modelSources?.controller?.abort();
this.modelTidy?.controller?.abort();
// Cards of an earlier load still being drawn in chunks stop here.
this._modelRenderGeneration = (this._modelRenderGeneration || 0) + 1;
if (this.modelView === 'tidy') { // the tabs, then what is in the wrong folder or twice
stopMediaInContainer(this.grid);
this.grid.replaceChildren(renderModelTabs(this));
renderModelTidyView(this, this.grid);
return;
}
if (this.modelView === 'sources') { // the tabs, then where each model is downloaded
stopMediaInContainer(this.grid);
this.grid.replaceChildren(renderModelTabs(this));
renderModelSourcesView(this, this.grid);
return;
}
const loadController = new AbortController();
this._modelLoadController = loadController;
const res = await fetch(modelListUrl(this), { signal: loadController.signal });
const data = await res.json();
if (this._modelLoadController !== loadController) return;
if (window.anomalous_update_hash_cache && data.models) {
window.anomalous_update_hash_cache(data.models);
}
this.models = data.models || [];
const renderGeneration = this._modelRenderGeneration;
if (this._modelMediaObserver) this._modelMediaObserver.disconnect();
this._modelMediaObserver = typeof IntersectionObserver === 'function'
? new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
const media = entry.target;
const shouldAutoplay = media.tagName === 'VIDEO' && media.dataset.anomalousAutoplay === 'true';
if (!entry.isIntersecting) {
if (media.tagName === 'VIDEO') {
media.pause();
if (media.getAttribute('src')) {
media.removeAttribute('src');
media.load();
}
}
return;
}
if (media.tagName === 'VIDEO' && !shouldAutoplay) return;
const pendingSrc = media.dataset.anomalousSrc;
if (pendingSrc && !media.getAttribute('src')) media.src = pendingSrc;
if (shouldAutoplay) media.play().catch(() => {});
});
}, { rootMargin: '300px' })
: null;
stopMediaInContainer(this.grid);
this.grid.replaceChildren(renderModelTabs(this), renderTypeBar(this, (data.models || []).length));
const query = this.modelQuery || '';
if (data.models?.length && query) data.models = data.models.filter(model => matchesModelQuery(model, query));
const bases = renderBaseBar(this, countBases(data.models));
if (bases) {
this.grid.appendChild(bases);
data.models = data.models.filter(model => inFamily(model, this.modelBase));
}
if (!data.models || data.models.length === 0) {
const empty = document.createElement('div');
empty.className = 'anomalous-grid-empty';
empty.textContent = query && this.models.length ? t('modelSearchNone', { query }) : t('noModels');
this.grid.appendChild(empty);
return;
}
// One entry a model: the versions of one model page together (model_versions.js).
const entries = groupByPage(data.models, chosenVersion);
if (entries.some(entry => entry.versions.length > 1)) {
const bar = bases || this.grid.appendChild(Object.assign(document.createElement('div'), { className: 'anomalous-model-bases' }));
bar.appendChild(openAllSwitch(() => this.loadModels()));
}
let renderIndex = 0;
let cardIndex = 0;
const renderChunk = () => {
if (this._modelRenderGeneration !== renderGeneration) return;
const fragment = document.createDocumentFragment();
const end = Math.min(renderIndex + 40, entries.length);
for (; renderIndex < end; renderIndex++) {
const cards = entryCards(this, entries[renderIndex], cardIndex);
cardIndex += cards.length;
fragment.append(...cards);
}
this.grid.appendChild(fragment);
if (renderIndex < entries.length) requestAnimationFrame(renderChunk);
};
requestAnimationFrame(renderChunk);
} catch (e) {
if (e && e.name !== 'AbortError') console.error('[Anomalous] Failed to load models', e);
}
}
export function applyModelToCanvas(type, subfolder, model) {
const nodeType = MODEL_LOADERS[type];
if (!nodeType) {
alert(window.anomalous_browser_lang === 'zh' ? '当前类型暂不支持自动发布到画布' : 'Unsupported auto apply for this type');
return;
}
const node = LiteGraph.createNode(nodeType);
if (!node) {
alert((window.anomalous_browser_lang === 'zh' ? '创建节点失败: ' : 'Failed to create node: ') + nodeType);
return;
}
if (app.canvas && app.canvas.graph_mouse) {
node.pos = [
app.canvas.graph_mouse[0] || (window.innerWidth / 2),
app.canvas.graph_mouse[1] || (window.innerHeight / 2)
];
} else {
node.pos = [window.innerWidth / 2, window.innerHeight / 2];
}
app.graph.add(node);
const sub = (subfolder || '').replace(/^\/+/, '').replace(/\/+$/, '');
const relPath = sub ? `${sub}/${model.filename}` : model.filename;
this.setWidgetValuePath(node, relPath);
recordCanvasStep(app);
const isDocked = this.container?.classList.contains('anomalous-docked');
if (!isDocked) {
this.close();
}
// 粘到鼠标上的逻辑
let isSticking = true;
const stickHandler = (e) => {
if (!isSticking || !app.canvas) return;
const canvas = app.canvas;
// LiteGraph内置了坐标转换,它会完美处理缩放和偏移带来的坐标偏移问题
let canvasX, canvasY;
if (canvas.convertEventToCanvasOffset) {
const pos = canvas.convertEventToCanvasOffset(e);
canvasX = pos[0];
canvasY = pos[1];
} else {
// 如果API不可用,使用标准降级计算
const rect = canvas.canvas.getBoundingClientRect();
canvasX = (e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale;
canvasY = (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale;
}
const w = (node.size && node.size[0]) ? node.size[0] : 200;
node.pos = [canvasX - w / 2, canvasY - 20];
canvas.setDirty(true, true);
};
const dropHandler = (e) => {
if (!isSticking) return;
isSticking = false;
window.removeEventListener('mousemove', stickHandler, true);
window.removeEventListener('pointerdown', dropHandler, true);
window.removeEventListener('mousedown', dropHandler, true);
window.removeEventListener('click', dropHandler, true);
e.preventDefault();
e.stopPropagation();
};
window.addEventListener('mousemove', stickHandler, true);
setTimeout(() => {
window.addEventListener('pointerdown', dropHandler, true);
window.addEventListener('mousedown', dropHandler, true);
window.addEventListener('click', dropHandler, true);
}, 100);
}
export function stopMediaInContainer(container) {
if (!container) return;
const mediaElements = container.querySelectorAll('video, audio');
mediaElements.forEach(media => {
media.pause();
media.removeAttribute('src');
media.load();
});
}