Three fixes from the user's use of the current-node panel: - ⇄ Compare on a saved set listed the whole workflow's values. It now compares that node alone: a copy of the node holding the set's values, nothing wired, against the canvas, under the node's id so the two pair (a set's workflow numbered otherwise, with two prompt nodes, paired neither). compareParams takes `only` for this; the comparison window takes `nodeId` and leaves out the structure badge. Every set can now be compared; the unused "could not read the set's workflow" text is gone. - The Change model picker drew cards of its own: full-size covers and videos without a still, so most showed blank, and only file names. It now shows the models page's card face (modelCardFace, split out of ui_grid.js): the cover thumbnail, a video's still until pointed at, the base badge, version, readable name and file name; "current" and the chosen outline stay. Versions are not folded. Its type and base chips and their texts are gone. - In the generated-pictures gallery (current node, View all), once scrolled, "Close gallery" opened the picture scrolled up under it: Chrome's hit-testing again (lessons.md). Its scroll box and the picker's card area get clip-path: inset(0). Checked in an isolated ComfyUI: the prompt node's compare lists only its text; the picker shows covers and names, selects, replaces; the models page's cards keep their buttons and open the details; a 13-step scroll of the gallery leaves every click on the close button. Full suite and structure check pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
428 lines
20 KiB
JavaScript
428 lines
20 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 face: its cover (a video's still until it plays), the base or source badge, the
|
|
* version, the name and the file name. The models page and the model picker (ui_node_model_picker.js)
|
|
* both show it; `pin`: the version pin of an open model (ui_model_versions.js), beside its version name.
|
|
*/
|
|
export function modelCardFace(owner, model, index, pin = null) {
|
|
const card = document.createElement('div');
|
|
card.className = 'anomalous-card';
|
|
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);
|
|
return card;
|
|
}
|
|
|
|
/** A model's card on the models page: its face, opening the details, with add, edit and scan. */
|
|
function modelCard(owner, model, index, pin = null) {
|
|
const card = modelCardFace(owner, model, index, pin);
|
|
cardModels.set(card, model);
|
|
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();
|
|
});
|
|
}
|