Files
DemonGatanjieuandClaude Opus 5.5 2836cceb7c A combo's page redone; the gallery's Kept view
The combo editor gets a head with its picture (See the picture), a large name with a
Rename button, chips of what it holds and Put on canvas; models show only what is chosen,
Change / Add open the grid of compatible ones. No Save button: everything saves itself
(a tag changed in the bilingual view now saves too). The gallery's ★ Kept lists only the
pictures kept as a workflow, combo or prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:52:03 +08:00

176 lines
7.3 KiB
JavaScript

/**
* The head of a combo's editor (ui_notebook_editor.js, and the node-structure combos of
* ui_combo_structure.js): its picture (the output image it was kept from, with See the
* picture; else its main model's cover), its name with a Rename button (a click on the name
* renames too; saved at once), what it holds as small chips (base model, main model, LoRAs;
* a structure's models), Put on canvas and More (delete, asked twice). Every change in the
* editor saves itself, so there is no Save button.
*/
import { translate as t } from './locales.js';
import { anomalousPrompt } from './ui_dialog.js';
import { outputThumbnail } from './ui_kept_toast.js';
import { STRUCTURED } from './combo_slots.js';
import { structuredComboMeta } from './ui_combo_structure.js';
import { UNLABELED_BASE_MODEL } from './notebook_canvas.js';
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick) {
const node = el('button', className, label);
node.type = 'button';
node.onclick = onClick;
return node;
}
/** A model's name as its card shows it. */
export const comboModelName = model => String(model?.metadata?.custom_name || '').trim()
|| String(model?.filename || '').split(/[\\/]/).pop().replace(/\.[^.]+$/, '');
/** A model's cover at card size ('' without one; a video's still frame). */
export function comboModelCover(model) {
const url = model?.preview_url;
if (!url) return '';
return `${url}${url.includes('?') ? '&' : '?'}${/\.(mp4|webm)(?:&|$)/i.test(url) ? 'variant=poster' : 'variant=card'}`;
}
/** The chips: what the combo holds. */
function chipsOf(data) {
if (data.kind === STRUCTURED) {
const meta = structuredComboMeta(data);
return [[meta.meta, ''], [meta.hint, 'is-warn']].filter(([text]) => text);
}
const base = data.baseModel === UNLABELED_BASE_MODEL ? t('notebookUnlabeledBase') : data.baseModel;
const loras = (data.loras || []).length;
return [
[base, 'is-base'],
[data.mainModel ? comboModelName(data.mainModel) : t('comboNoMainModel'), data.mainModel ? '' : 'is-warn'],
[loras ? t('comboLoraCount', { count: loras }) : t('comboNoLora'), ''],
].filter(([text]) => text);
}
/** More: delete, which asks once more on the button itself. */
function moreMenu(ctx, hero) {
const wrapper = el('div', 'anomalous-nb-dropdown-wrapper');
const more = button('anomalous-nb-more-btn anomalous-combo-hero-more', `${t('notebookMore')} ▾`, (event) => {
event.stopPropagation();
const open = menu.classList.toggle('show');
more.classList.toggle('active', open);
if (!open) reset();
});
const menu = el('div', 'anomalous-nb-dropdown-menu');
const remove = button('anomalous-nb-dropdown-item anomalous-nb-dropdown-item-danger', `🗑️ ${t('deleteNotebook')}`, (event) => {
event.stopPropagation();
if (!remove.classList.contains('confirming')) {
remove.classList.add('confirming');
remove.textContent = `⚠️ ${t('delSure').replace(/^⚠️\s*/, '')}`;
timer = setTimeout(reset, 4000);
return;
}
reset();
close();
ctx.deleteCurrentNotebook();
});
let timer = 0;
const reset = () => {
clearTimeout(timer);
remove.classList.remove('confirming');
remove.textContent = `🗑️ ${t('deleteNotebook')}`;
};
const close = () => {
menu.classList.remove('show');
more.classList.remove('active');
};
const onClick = (event) => {
if (!hero.isConnected) {
document.removeEventListener('click', onClick);
return;
}
if (!wrapper.contains(event.target)) {
close();
reset();
}
};
document.addEventListener('click', onClick);
menu.append(remove);
wrapper.append(more, menu);
return wrapper;
}
/** The head for `notebook` ({ filename, name, data }); `ctx` is the browser. */
export function createComboHero(ctx, notebook) {
const data = notebook.data || {};
const hero = el('header', 'anomalous-combo-hero');
const image = data.source_image?.filename ? data.source_image : null;
const pictureBox = el('div', 'anomalous-combo-hero-picture');
const frame = image ? button('anomalous-combo-hero-frame is-image', undefined, () => openImage()) : el('div', 'anomalous-combo-hero-frame');
if (image) frame.title = t('comboFromImageOpen');
const openImage = () => {
const query = new URLSearchParams({ filename: image.filename, subfolder: image.subfolder || '' });
ctx.showGalleryViewer?.(`/view?${query}&type=output`);
};
const drawPicture = () => {
const src = image ? outputThumbnail(image)
: data.kind === STRUCTURED ? structuredComboMeta(data).cover : comboModelCover(data.mainModel);
frame.replaceChildren();
if (!src) {
frame.append(el('span', 'anomalous-combo-hero-empty', '🧩'));
return;
}
const img = el('img');
img.alt = '';
img.src = src;
img.onerror = () => frame.replaceChildren(el('span', 'anomalous-combo-hero-empty', '🧩'));
frame.append(img);
};
pictureBox.append(frame);
if (image) pictureBox.append(button('anomalous-combo-hero-view', `🖼 ${t('comboSeePicture')}`, openImage));
const info = el('div', 'anomalous-combo-hero-info');
const eyebrow = el('span', 'anomalous-combo-hero-eyebrow', image ? t('comboEyebrowKept') : t('comboEyebrow'));
const nameRow = el('div', 'anomalous-combo-hero-name-row');
const name = el('h2', 'anomalous-combo-hero-name', notebook.name);
name.title = t('comboRenameHint');
const renameButton = button('anomalous-combo-hero-rename', `✎ ${t('comboRename')}`, () => void rename());
const rename = async () => {
const wanted = String(await anomalousPrompt(t('comboRenameAsk'), notebook.name, t('comboRename')) || '').trim().slice(0, 100);
if (!wanted || wanted === notebook.name) return;
const before = notebook.name;
// In place: the editor below holds this same data object.
const named = (value) => {
notebook.name = value;
data.name = value;
name.textContent = value;
};
named(wanted);
if (!await ctx.saveCurrentNotebook()) named(before);
};
name.onclick = () => void rename();
nameRow.append(name, renameButton);
const chips = el('div', 'anomalous-combo-hero-chips');
const drawChips = () => chips.replaceChildren(...chipsOf(data).map(([text, kind]) => el('span', `anomalous-combo-hero-chip ${kind}`.trim(), text)));
const actions = el('div', 'anomalous-combo-hero-actions');
const use = button('anomalous-btn-success anomalous-combo-hero-use', t('comboUse'), () => ctx.sendNotebookToCanvas());
use.title = t('comboUseHint');
actions.append(use, moreMenu(ctx, hero));
info.append(eyebrow, nameRow, chips, actions);
hero.append(pictureBox, info);
// The models part below calls this after each change.
ctx.updateNotebookModelsSummary = () => {
drawChips();
if (!image) drawPicture();
};
drawPicture();
drawChips();
return hero;
}