Combos kept from a picture show it and get a telling name
The card shows the output image with the main model's cover as a badge; the name is the first LoRA (else the main model) and two telling tags; the toast after keeping shows the picture and renames the combo on the spot, and the editor's title renames on a click and opens the picture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b899348435
commit
74beded02c
+5
-3
@@ -506,9 +506,11 @@ covered by the `styles.css` manifest.
|
||||
`ui_keep_menu.js` is the star's keep menu (also the workbench's Keep): the whole workflow, the combo or the prompts,
|
||||
each saying where it goes, opening what was kept already or removing it (to the Recycle Bin through the recipe,
|
||||
notebook or material delete route); `image_keep.js` does the keeping (an image as a combo,
|
||||
its prompts as a saved prompt, reading what was kept) without DOM.
|
||||
named by `comboTitle` — first LoRA or main model · two telling tags — and renamed by `renameCombo`;
|
||||
its prompts as a saved prompt, reading what was kept) without DOM. A new combo's toast is
|
||||
`ui_kept_toast.js` (the image's thumbnail, the name in a box to rename it on the spot, Open).
|
||||
- `ui_combos.js` renders the Combos page's list (搭配: a main model, LoRAs and a prompt, formerly Prompt Notes): search,
|
||||
? (what a combo is), New, and a card per combo with its model's cover and Put on canvas; a card opens the combo's editor, and dragged onto the
|
||||
? (what a combo is), New, and a card per combo with its model's cover (one kept from an output image shows that image, the model's cover as a badge) and Put on canvas; a card opens the combo's editor, and dragged onto the
|
||||
canvas (`material_drag.js`) becomes a new group of nodes where it is dropped. Combos keep the notes' files.
|
||||
A combo can instead hold a node structure (`data.kind === 'nodes'`): `combo_structure.js`
|
||||
captures the canvas's picked nodes (subgraphs aside) with the links between them and puts
|
||||
@@ -579,7 +581,7 @@ covered by the `styles.css` manifest.
|
||||
- `ui_recipes.js` / `ui_recipe_detail.js` and `ui_notebooks.js`
|
||||
own their respective workspace surfaces and persistence flows. `ui_recipes.js` hands the
|
||||
Workflows page (its catalog) the recipe services (save, edit, fetch) and opens a recipe by name. `ui_notebooks.js` owns the Combos workspace (filling the page; its list or one combo's editor,
|
||||
with Back to the list) and combo persistence; `ui_notebook_editor.js` owns unfolded card editing (modularized into single-responsibility
|
||||
with Back to the list) and combo persistence; `ui_combo_title.js` is the editor toolbar's title (a click renames; a combo kept from an image shows its thumbnail, which opens the viewer); `ui_notebook_editor.js` owns unfolded card editing (modularized into single-responsibility
|
||||
sub-functions adhering to the 50-line rule: sticky top action toolbar with floating More popover dropdown and timed two-step delete safety guard,
|
||||
unfolded companion models card with unconstrained multi-column tile flow eliminating nested gallery scrollbars,
|
||||
prompt composer with dynamic field-sizing and compact inline find & replace toolbar, and unified dark slim scrollbar ergonomics with complete bilingual dictionary coverage in `locales.js`), and
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## v1.61.0 — unreleased
|
||||
|
||||
### ⭐ Combos kept from a picture are easy to tell apart (图片存的搭配一眼认得出)
|
||||
- **The card shows the picture you kept it from**, with the main model's cover as a small round badge in its corner, instead of the model's cover alone (several combos of one model used to look the same).
|
||||
- **A name that says which one it is**: the first LoRA (or the main model) and the prompt's first two telling tags, like *Ahri · fox ears, kimono*, skipping words nearly every picture has (*1girl*, *solo*, *looking at viewer*…).
|
||||
- **Rename it right away**: the message after keeping shows the picture and the name in a box; type a new one and press Enter. In a combo's editor a click on its name renames it too, and the picture beside the name opens it large.
|
||||
|
||||
### 📒 Prompt cards look like notebook pages (提示词卡片换成笔记本样式)
|
||||
- In Prompt Studio each card is a page with a bound spine in its colour (green positive, red negative), its title, a Positive / Negative label and its first words as small tags instead of a cut-off line of text. The card open as the reference wears a ribbon; Common and My prompts are index tabs with how many cards they hold. Dark and light themes alike.
|
||||
|
||||
|
||||
@@ -119,7 +119,9 @@ as one of three things, each recording the image as its source: the whole workfl
|
||||
Workflow Recipe (`recipe_save.js`, `source_image`), its main model, LoRAs and positive
|
||||
prompt as a combo (`image_keep.js`; models are looked up among this computer's files with
|
||||
`/anomalous/resolve_paths_to_previews`, missing ones are left out; the notebook file keeps
|
||||
`source_image`), or its positive and negative prompts as a `prompt_plan` material
|
||||
`source_image`, which the Combos card and the editor's title show; the name is the first
|
||||
LoRA's (else the main model's) · the prompt's first two tags that are neither quality words
|
||||
nor ones nearly every picture has, and the toast lets the user rename it at once), or its positive and negative prompts as a `prompt_plan` material
|
||||
(`/anomalous/save_prompt_plan` stores `source.image`; the same plan saved again answers 409
|
||||
with the existing one). The image's workflow is laid on a detached graph and read with
|
||||
`extractRecipeMetadata`, so prompts follow the wiring as in a recipe. `GET
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// The name a combo kept from an output image gets (web/modules/image_keep.js comboTitle).
|
||||
import assert from 'node:assert/strict';
|
||||
import { comboTitle } from '../web/modules/image_keep.js';
|
||||
|
||||
const lora = { filename: 'Ahri_v2.safetensors', metadata: {} };
|
||||
const main = { filename: 'illustriousXL.safetensors', metadata: { custom_name: 'Illustrious XL' } };
|
||||
const prompt = 'masterpiece, best quality, 1girl, solo, ahri, fox ears, kimono, looking at viewer';
|
||||
|
||||
// The first LoRA, then two tags that are neither quality words nor ones every picture has.
|
||||
assert.equal(comboTitle({ prompt, mainModel: main, loras: [lora] }), 'Ahri_v2 · ahri, fox ears');
|
||||
// No LoRA: the main model's own name.
|
||||
assert.equal(comboTitle({ prompt: '1girl, (red hair:1.2), beach', mainModel: main }), 'Illustrious XL · red hair, beach');
|
||||
// A LoRA's name from its card wins over its file's; a path keeps only the file.
|
||||
assert.equal(comboTitle({ loras: [{ filename: 'chars\\x.safetensors', metadata: { custom_name: 'Nahida' } }] }), 'Nahida');
|
||||
assert.equal(comboTitle({ mainModel: { filename: 'sdxl\\base.safetensors' } }), 'base');
|
||||
assert.equal(comboTitle({ mainModel: { filename: 'sdxl/base.safetensors' } }), 'base');
|
||||
// Nothing to go on: the fallback.
|
||||
assert.equal(comboTitle({ prompt: 'masterpiece, 1girl, solo', fallback: 'ComfyUI_00012_' }), 'ComfyUI_00012_');
|
||||
console.log('combo title: ok');
|
||||
@@ -14,6 +14,8 @@ import { jsonResponse } from './ui_dom.js';
|
||||
import { detachedGraph } from './detached_graph.js';
|
||||
|
||||
const MAIN_TYPES = ['checkpoints', 'unet', 'diffusion_models'];
|
||||
// Tags nearly every picture has: a combo's name skips them so two combos read apart.
|
||||
const COMMON_TAG = /^(?:\d+\s*(?:girls?|boys?|others?)|solo|solo focus|looking at viewer|simple background|white background|smile|blush|open mouth|upper body|full body|cowboy shot)$/i;
|
||||
// The combo editor lists models without a base model under this value (notebook_canvas.js).
|
||||
const UNLABELED_BASE_MODEL = '__unlabeled__';
|
||||
|
||||
@@ -33,14 +35,29 @@ export async function loadKeptImages(signal) {
|
||||
return new Map(images.map(item => [keptKey(item), item]));
|
||||
}
|
||||
|
||||
const fileStem = name => String(name || '').split(/[\\/]/).pop().replace(/\.[^.]+$/, '');
|
||||
|
||||
/** What the image's workflow uses (extractRecipeMetadata) and a name: its prompt's first tags, else the file's. */
|
||||
async function readImage(sourceImage) {
|
||||
const info = await jsonResponse(await post('/anomalous/inspect_image_material', { source_image: sourceImage }), 'read image workflow');
|
||||
const metadata = extractRecipeMetadata(detachedGraph(info.workflow));
|
||||
const name = promptTitle(metadata.promptPositive[0]) || String(sourceImage?.filename || '').replace(/\.[^.]+$/, '');
|
||||
const name = promptTitle(metadata.promptPositive[0]) || fileStem(sourceImage?.filename);
|
||||
return { name, metadata };
|
||||
}
|
||||
|
||||
/** A model's name as its card shows it: the name the user gave it, else the file's. */
|
||||
const modelLabel = model => String(model?.metadata?.custom_name || '').trim() || fileStem(model?.filename);
|
||||
|
||||
/**
|
||||
* A combo's name that tells combos apart: its first LoRA (else its main model), then the
|
||||
* prompt's first two telling tags — "Ahri · fox ears, kimono"; the file's name when neither.
|
||||
*/
|
||||
export function comboTitle({ prompt = '', mainModel = null, loras = [], fallback = '' } = {}) {
|
||||
const model = modelLabel(loras[0] || mainModel).slice(0, 40);
|
||||
const subject = promptTitle(prompt, { count: 2, skip: COMMON_TAG });
|
||||
return [model, subject].filter(Boolean).join(' · ') || fallback;
|
||||
}
|
||||
|
||||
/** { path → this computer's model } for the paths found among `types`. */
|
||||
async function localModels(paths, types) {
|
||||
if (!paths.length) return {};
|
||||
@@ -85,13 +102,15 @@ export async function keepImageAsCombo(sourceImage, { name = '' } = {}) {
|
||||
localModels(loraPaths, ['loras']),
|
||||
]);
|
||||
const mainModel = mains[main] || null;
|
||||
const comboName = freeComboName(name || image.name, combos);
|
||||
const localLoras = loraPaths.filter(path => loras[path]).map(path => loras[path]);
|
||||
const prompt = image.metadata.promptPositive.join('\n');
|
||||
const comboName = freeComboName(name || comboTitle({ prompt, mainModel, loras: localLoras, fallback: image.name }), combos);
|
||||
const data = {
|
||||
name: comboName,
|
||||
baseModel: mainModel ? String(mainModel.metadata?.baseModel || '').trim() || UNLABELED_BASE_MODEL : '',
|
||||
mainModel,
|
||||
loras: loraPaths.filter(path => loras[path]).map(path => loras[path]),
|
||||
promptEn: image.metadata.promptPositive.join('\n'),
|
||||
loras: localLoras,
|
||||
promptEn: prompt,
|
||||
promptZh: '',
|
||||
source_image: sourceImage,
|
||||
};
|
||||
@@ -102,6 +121,16 @@ export async function keepImageAsCombo(sourceImage, { name = '' } = {}) {
|
||||
return { filename, name: comboName, existed: false, missing };
|
||||
}
|
||||
|
||||
/** Gives the combo kept in `filename` the name `name` (its file keeps its name). Returns the name. */
|
||||
export async function renameCombo(filename, name) {
|
||||
const wanted = String(name || '').trim().slice(0, 100);
|
||||
const note = (await listCombos()).find(item => item.filename === filename);
|
||||
if (!wanted || !note) throw new Error('combo rename failed');
|
||||
const response = await post('/anomalous/save_notebook', { filename, data: { ...note.data, name: wanted } });
|
||||
if ((await jsonResponse(response, 'rename combo')).status !== 'success') throw new Error('combo rename failed');
|
||||
return wanted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the image's positive and negative prompts as one saved prompt; the same prompts
|
||||
* saved before give that one back (`existed`). Returns { filename, name, existed }, or
|
||||
|
||||
@@ -762,6 +762,15 @@ export const i18n = {
|
||||
keepKeptAs: "已存:{name},点这里打开",
|
||||
keptAs_recipe: "已存成工作流配方:{name}",
|
||||
keptAs_combo: "已存成搭配:{name}",
|
||||
keptAs_comboShort: "已存成搭配,名字可以直接改:",
|
||||
comboFromImage: "从这张图存下的搭配",
|
||||
comboFromImageOpen: "来自这张图,点开看大图",
|
||||
comboRename: "改名",
|
||||
comboRenameAsk: "这个搭配叫什么?",
|
||||
comboRenameHint: "点一下改名",
|
||||
keepRenameHint: "搭配的名字,改完按回车",
|
||||
keepRenamed: "已改名:{name}",
|
||||
keepRenameFailed: "没能改名,还是原来的名字。",
|
||||
keptAs_prompt: "已存下提示词:{name}",
|
||||
keepExisted: "之前就存过了:{name}",
|
||||
keepComboMissing: "有 {count} 个模型本机没有,搭配里先空着。",
|
||||
@@ -3483,6 +3492,15 @@ export const i18n = {
|
||||
keepKeptAs: "Kept as {name}; open it",
|
||||
keptAs_recipe: "Kept as a Workflow Recipe: {name}",
|
||||
keptAs_combo: "Kept as a combo: {name}",
|
||||
keptAs_comboShort: "Kept as a combo. Rename it here if you like:",
|
||||
comboFromImage: "Kept from this image",
|
||||
comboFromImageOpen: "Kept from this image; click to see it large",
|
||||
comboRename: "Rename",
|
||||
comboRenameAsk: "What should this combo be called?",
|
||||
comboRenameHint: "Click to rename",
|
||||
keepRenameHint: "The combo's name; press Enter when done",
|
||||
keepRenamed: "Renamed to {name}",
|
||||
keepRenameFailed: "Could not rename it; it keeps its name.",
|
||||
keptAs_prompt: "Kept the prompts: {name}",
|
||||
keepExisted: "Kept before: {name}",
|
||||
keepComboMissing: "{count} of its models are not on this computer, so the combo leaves them out.",
|
||||
|
||||
@@ -53,12 +53,15 @@ const STYLE_KEYWORDS = [
|
||||
// Quality words most prompts start with: they say nothing about the picture.
|
||||
const QUALITY_TAG = /^(?:masterpiece|best quality|high quality|highest quality|amazing quality|very aesthetic|aesthetic|newest|highres|absurdres|ultra[- ]?detailed|highly detailed|extremely detailed|detailed|8k|4k|hdr|uhd|score_\d+(?:_up)?|source_\w+|rating_\w+)$/i;
|
||||
|
||||
/** A short name from a prompt: its first few tags that are not quality words ("" when none). */
|
||||
export function promptTitle(text = '') {
|
||||
/**
|
||||
* A short name from a prompt: its first `count` tags that are not quality words, nor ones
|
||||
* `skip` matches ("" when none).
|
||||
*/
|
||||
export function promptTitle(text = '', { count = 3, skip = null } = {}) {
|
||||
const tags = String(text).split(/[,\n]/)
|
||||
.map(tag => tag.replace(/^[\s(\[{]+|[\s)\]}]+$/g, '').replace(/:[\d.]+$/, '').trim())
|
||||
.filter(tag => tag && !QUALITY_TAG.test(tag));
|
||||
const title = tags.slice(0, 3).join(', ');
|
||||
.filter(tag => tag && !QUALITY_TAG.test(tag) && !skip?.test(tag));
|
||||
const title = tags.slice(0, count).join(', ');
|
||||
return title.length > 48 ? `${title.slice(0, 47)}…` : title;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* The title of the combo editor's toolbar (ui_notebook_editor.js): the combo's name, which a
|
||||
* click renames (saved at once), and for a combo kept from an output image that image's
|
||||
* thumbnail, which opens the image in the viewer.
|
||||
*/
|
||||
|
||||
import { translate as t } from './locales.js';
|
||||
import { anomalousPrompt } from './ui_dialog.js';
|
||||
import { outputThumbnail } from './ui_kept_toast.js';
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The title box for `notebook` ({ filename, name, data }); `ctx` is the browser. */
|
||||
export function createComboTitle(ctx, notebook) {
|
||||
const box = el('div', 'anomalous-combo-title');
|
||||
const image = notebook.data?.source_image;
|
||||
if (image?.filename) {
|
||||
const thumb = el('button', 'anomalous-combo-title-image');
|
||||
thumb.type = 'button';
|
||||
thumb.title = t('comboFromImageOpen');
|
||||
thumb.setAttribute('aria-label', t('comboFromImageOpen'));
|
||||
const img = el('img');
|
||||
img.alt = '';
|
||||
img.src = outputThumbnail(image);
|
||||
thumb.append(img);
|
||||
const query = new URLSearchParams({ filename: image.filename, subfolder: image.subfolder || '' });
|
||||
thumb.onclick = () => ctx.showGalleryViewer?.(`/view?${query}&type=output`);
|
||||
box.append(thumb);
|
||||
} else {
|
||||
box.append(el('span', 'anomalous-combo-title-icon', '📝'));
|
||||
}
|
||||
const name = el('h3', 'anomalous-combo-title-name', notebook.name);
|
||||
name.tabIndex = 0;
|
||||
name.title = t('comboRenameHint');
|
||||
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;
|
||||
notebook.data.name = value;
|
||||
name.textContent = value;
|
||||
};
|
||||
named(wanted);
|
||||
if (!await ctx.saveCurrentNotebook()) named(before);
|
||||
};
|
||||
name.onclick = () => void rename();
|
||||
name.onkeydown = (event) => {
|
||||
if (!['Enter', 'F2'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
void rename();
|
||||
};
|
||||
box.append(name);
|
||||
return box;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
* The Combos page (搭配): each saved combo of a main model, LoRAs and a prompt (the files
|
||||
* GET /anomalous/notebooks keeps, formerly Prompt Notes), or of the model and text nodes saved
|
||||
* from the canvas with their links (ui_combo_structure.js), as a card with its model's cover
|
||||
* (a combo kept from an output image shows that image, its model's cover in a corner badge)
|
||||
* and Put on canvas (notebook_canvas.js: a new group of nodes that follows the pointer). A card
|
||||
* opens the combo in its editor (ui_notebook_editor.js), and dragged onto the canvas becomes a
|
||||
* new group of nodes where it is dropped. New names one and opens it.
|
||||
@@ -13,6 +14,7 @@ import { freeComboName, listCombos } from './image_keep.js';
|
||||
import { bindMaterialDrag } from './material_drag.js';
|
||||
import { STRUCTURED } from './combo_slots.js';
|
||||
import { chooseNewCombo, structuredComboMeta } from './ui_combo_structure.js';
|
||||
import { outputThumbnail } from './ui_kept_toast.js';
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
@@ -31,17 +33,31 @@ function button(className, label, onClick) {
|
||||
const modelName = model => String(model?.metadata?.custom_name || model?.filename || '').replace(/\.[^.]+$/, '');
|
||||
const withQuery = (url, query) => `${url}${url.includes('?') ? '&' : '?'}${query}`;
|
||||
|
||||
/** The main model's cover, as the model cards show it (a video's still first frame). */
|
||||
function cover(model) {
|
||||
const box = el('div', 'anomalous-combo-cover');
|
||||
const url = model?.preview_url;
|
||||
if (!url) return box;
|
||||
const img = el('img');
|
||||
function picture(src, className = '') {
|
||||
const img = el('img', className);
|
||||
img.loading = 'lazy';
|
||||
img.alt = '';
|
||||
img.src = withQuery(url, /\.(mp4|webm)(?:&|$)/i.test(url) ? 'variant=poster' : 'variant=card');
|
||||
img.src = src;
|
||||
img.onerror = () => img.remove();
|
||||
box.append(img);
|
||||
return img;
|
||||
}
|
||||
|
||||
/**
|
||||
* The main model's cover, as the model cards show it (a video's still first frame); with
|
||||
* `image` (the output it was kept from) that image, and the model's cover as a small badge.
|
||||
*/
|
||||
function cover(model, image = null) {
|
||||
const box = el('div', 'anomalous-combo-cover');
|
||||
const url = model?.preview_url;
|
||||
const modelCover = url ? withQuery(url, /\.(mp4|webm)(?:&|$)/i.test(url) ? 'variant=poster' : 'variant=card') : '';
|
||||
if (image?.filename) {
|
||||
box.classList.add('is-kept-image');
|
||||
box.title = t('comboFromImage');
|
||||
box.append(picture(outputThumbnail(image)));
|
||||
if (modelCover) box.append(picture(modelCover, 'anomalous-combo-cover-model'));
|
||||
return box;
|
||||
}
|
||||
if (modelCover) box.append(picture(modelCover));
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -75,7 +91,7 @@ function comboCard(owner, note) {
|
||||
});
|
||||
use.title = t('comboUseHint');
|
||||
body.append(use);
|
||||
card.append(cover(structured ? { preview_url: structured.cover } : data.mainModel), body);
|
||||
card.append(structured ? cover({ preview_url: structured.cover }) : cover(data.mainModel, data.source_image), body);
|
||||
bindMaterialDrag(card, owner, {
|
||||
payload: () => ({ type: 'combo', filename: note.filename, dragHint: t('comboDragHint'), dragTargetHint: t('comboDragCanvas') }),
|
||||
accepts: () => false,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { translate as t } from './locales.js';
|
||||
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
|
||||
import { jsonResponse } from './ui_dom.js';
|
||||
import { showWorkbenchToast } from './ui_prompt_toast.js';
|
||||
import { showKeptComboToast } from './ui_kept_toast.js';
|
||||
import { keepImageAsRecipe } from './recipe_save.js';
|
||||
import { keepImageAsCombo, keepImagePrompts, keptKey, loadKeptImages } from './image_keep.js';
|
||||
|
||||
@@ -55,13 +56,24 @@ async function keep(owner, spec, sourceImage, kept, { name, onKept, beforeOpen }
|
||||
}
|
||||
kept[spec.kind] = { filename: result.filename, name: result.name };
|
||||
onKept?.(kept);
|
||||
showWorkbenchToast(keptMessage(spec, result), {
|
||||
label: t('keepOpen'),
|
||||
run: () => {
|
||||
beforeOpen?.();
|
||||
spec.open(owner, kept[spec.kind]);
|
||||
},
|
||||
});
|
||||
const openKept = () => {
|
||||
beforeOpen?.();
|
||||
spec.open(owner, kept[spec.kind]);
|
||||
};
|
||||
// A new combo's name is a guess (image_keep.js comboTitle): it can be renamed right here.
|
||||
if (spec.kind === 'combo' && !result.existed) {
|
||||
const missing = result.missing?.length ? t('keepComboMissing', { count: result.missing.length }) : '';
|
||||
showKeptComboToast(sourceImage, kept.combo, {
|
||||
message: [t('keptAs_comboShort'), missing].filter(Boolean).join(' '),
|
||||
onRenamed: (name) => {
|
||||
kept.combo = { ...kept.combo, name };
|
||||
onKept?.(kept);
|
||||
},
|
||||
onOpen: openKept,
|
||||
});
|
||||
return;
|
||||
}
|
||||
showWorkbenchToast(keptMessage(spec, result), { label: t('keepOpen'), run: openKept });
|
||||
} catch (error) {
|
||||
console.error(`[AMB] Could not keep the image as ${spec.kind}:`, error);
|
||||
await anomalousAlert(t(error?.message === NO_WORKFLOW ? 'keepNoWorkflow' : 'keepFailed'));
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* The toast after an output image was kept as a new combo (ui_keep_menu.js): the image's
|
||||
* thumbnail, what happened, the combo's name in a box to rename it on the spot (Enter or
|
||||
* leaving the box saves it, image_keep.js renameCombo) and Open. It stays while the pointer
|
||||
* or the focus is on it.
|
||||
*/
|
||||
|
||||
import { translate as t } from './locales.js';
|
||||
import { renameCombo } from './image_keep.js';
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The thumbnail URL of an output image ({ filename, subfolder }). */
|
||||
export const outputThumbnail = image => `/anomalous/output_thumbnail?${new URLSearchParams({ filename: image.filename, subfolder: image.subfolder || '' })}`;
|
||||
|
||||
/**
|
||||
* Shows the toast for the combo `kept` ({ filename, name }) kept from `image`. `onRenamed(name)`
|
||||
* runs after a rename; `onOpen()` opens the combo.
|
||||
*/
|
||||
export function showKeptComboToast(image, kept, { message, onRenamed, onOpen }) {
|
||||
const toast = el('div', 'anomalous-mixer-toast has-action anomalous-kept-toast');
|
||||
toast.setAttribute('role', 'status');
|
||||
const thumb = el('img', 'anomalous-kept-toast-thumb');
|
||||
thumb.alt = '';
|
||||
thumb.src = outputThumbnail(image);
|
||||
thumb.onerror = () => thumb.remove();
|
||||
const body = el('div', 'anomalous-kept-toast-body');
|
||||
const line = el('span', 'anomalous-kept-toast-line', message);
|
||||
const input = el('input', 'anomalous-kept-toast-name');
|
||||
input.type = 'text';
|
||||
input.value = kept.name;
|
||||
input.maxLength = 100;
|
||||
input.title = t('keepRenameHint');
|
||||
input.setAttribute('aria-label', t('keepRenameHint'));
|
||||
body.append(line, input);
|
||||
const open = el('button', 'anomalous-mixer-toast-action', t('keepOpen'));
|
||||
open.type = 'button';
|
||||
toast.append(thumb, body, open);
|
||||
|
||||
let timer = null;
|
||||
let saved = kept.name;
|
||||
const close = () => {
|
||||
clearTimeout(timer);
|
||||
toast.classList.remove('is-show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
};
|
||||
const wait = () => {
|
||||
clearTimeout(timer);
|
||||
if (!toast.matches(':hover') && !toast.contains(document.activeElement)) timer = setTimeout(close, 12000);
|
||||
};
|
||||
const rename = async () => {
|
||||
const wanted = input.value.trim();
|
||||
if (!wanted || wanted === saved) {
|
||||
input.value = saved;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
saved = await renameCombo(kept.filename, wanted);
|
||||
input.value = saved;
|
||||
line.textContent = t('keepRenamed', { name: saved });
|
||||
onRenamed?.(saved);
|
||||
} catch (error) {
|
||||
console.error('[AMB] Could not rename the kept combo:', error);
|
||||
input.value = saved;
|
||||
line.textContent = t('keepRenameFailed');
|
||||
}
|
||||
};
|
||||
input.onkeydown = (event) => {
|
||||
event.stopPropagation(); // the canvas and the browser keep their keys
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
input.blur();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
input.value = saved;
|
||||
input.blur();
|
||||
}
|
||||
};
|
||||
input.onblur = () => void rename();
|
||||
open.onclick = async () => {
|
||||
await rename();
|
||||
close();
|
||||
onOpen?.();
|
||||
};
|
||||
toast.onpointerenter = () => clearTimeout(timer);
|
||||
toast.onpointerleave = wait;
|
||||
toast.addEventListener('focusin', () => clearTimeout(timer));
|
||||
toast.addEventListener('focusout', () => setTimeout(wait, 0));
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.classList.add('is-show'), 10);
|
||||
wait();
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { isUnetModel, UNLABELED_BASE_MODEL } from './notebook_canvas.js';
|
||||
import { STRUCTURED } from './combo_slots.js';
|
||||
import { renderStructuredCombo } from './ui_combo_structure.js';
|
||||
import { createLoraNodeChoice } from './ui_lora_node_choice.js';
|
||||
import { createComboTitle } from './ui_combo_title.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
|
||||
@@ -19,21 +20,7 @@ export function createNotebookToolbar(ctx, notebook) {
|
||||
const tb = document.createElement('div');
|
||||
tb.className = 'anomalous-nb-toolbar';
|
||||
|
||||
const titleBox = document.createElement('div');
|
||||
titleBox.style.display = 'flex';
|
||||
titleBox.style.alignItems = 'center';
|
||||
titleBox.style.gap = '8px';
|
||||
|
||||
const titleIcon = document.createElement('span');
|
||||
titleIcon.textContent = '📝';
|
||||
titleIcon.style.fontSize = '1.2rem';
|
||||
|
||||
const titleArea = document.createElement('h3');
|
||||
titleArea.textContent = notebook.name;
|
||||
titleArea.style.margin = '0';
|
||||
titleArea.style.fontSize = '1.15rem';
|
||||
titleArea.style.fontWeight = '700';
|
||||
titleBox.append(titleIcon, titleArea);
|
||||
const titleBox = createComboTitle(ctx, notebook);
|
||||
|
||||
const rightBtns = document.createElement('div');
|
||||
rightBtns.className = 'anomalous-notebook-actions';
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
@import url("./styles/20-current-node.css?v=20261011-last");
|
||||
@import url("./styles/21-prompt-studio.css?v=20261007-theme-2");
|
||||
@import url("./styles/22-model-download.css?v=20261007-theme-2");
|
||||
@import url("./styles/23-combo-structure.css?v=20261017-covers");
|
||||
@import url("./styles/23-combo-structure.css?v=20261019-kept");
|
||||
@import url("./styles/24-backup.css?v=20261007-theme-2");
|
||||
@import url("./styles/25-model-import.css?v=20261008-import");
|
||||
@import url("./styles/26-model-tidy.css?v=20261008-tidy");
|
||||
|
||||
@@ -473,6 +473,95 @@
|
||||
border-top: 1px solid var(--amb-border);
|
||||
}
|
||||
|
||||
/* A combo kept from an output image: that image, its main model's cover as a badge. */
|
||||
.anomalous-combo-cover.is-kept-image { position: relative; }
|
||||
|
||||
.anomalous-combo-cover img.anomalous-combo-cover-model {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 2px solid var(--amb-bg-panel);
|
||||
border-radius: 50%;
|
||||
object-position: center 20%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* The toast after keeping an image as a combo: thumbnail, message + name box, Open. */
|
||||
.anomalous-mixer-toast.anomalous-kept-toast {
|
||||
max-width: min(440px, calc(100vw - 48px));
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.anomalous-kept-toast-thumb {
|
||||
flex: none;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--amb-radius-control);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.anomalous-kept-toast-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.anomalous-kept-toast-line { color: var(--amb-text-muted); font-size: 0.92em; }
|
||||
|
||||
.anomalous-kept-toast-name {
|
||||
width: 100%;
|
||||
min-width: 180px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
/* The combo editor's title: the image it was kept from, the name a click renames. */
|
||||
.anomalous-combo-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.anomalous-combo-title-icon { font-size: 1.2rem; }
|
||||
|
||||
.anomalous-combo-title-image {
|
||||
flex: none;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--amb-border-strong);
|
||||
border-radius: var(--amb-radius-control);
|
||||
background: none;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.anomalous-combo-title-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.anomalous-combo-title-name {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: text;
|
||||
border-bottom: 1px dashed transparent;
|
||||
}
|
||||
|
||||
.anomalous-combo-title-name:hover,
|
||||
.anomalous-combo-title-name:focus-visible { border-bottom-color: var(--amb-border-hover); }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.anomalous-structure-slot { grid-template-columns: auto 1fr; }
|
||||
.anomalous-structure-slot-where { grid-column: 2; }
|
||||
|
||||
Reference in New Issue
Block a user