diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1fd1054..8998c2f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e5d60..850b81e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/architecture/material-library.md b/docs/architecture/material-library.md index cee384b..a695288 100644 --- a/docs/architecture/material-library.md +++ b/docs/architecture/material-library.md @@ -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 diff --git a/tests/combo_title.mjs b/tests/combo_title.mjs new file mode 100644 index 0000000..b02621a --- /dev/null +++ b/tests/combo_title.mjs @@ -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'); diff --git a/web/modules/image_keep.js b/web/modules/image_keep.js index d1db645..0e01161 100644 --- a/web/modules/image_keep.js +++ b/web/modules/image_keep.js @@ -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 diff --git a/web/modules/locales.js b/web/modules/locales.js index 1666b98..3ede39a 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -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.", diff --git a/web/modules/prompt_composition.js b/web/modules/prompt_composition.js index d245278..6b176a8 100644 --- a/web/modules/prompt_composition.js +++ b/web/modules/prompt_composition.js @@ -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; } diff --git a/web/modules/ui_combo_title.js b/web/modules/ui_combo_title.js new file mode 100644 index 0000000..8018e98 --- /dev/null +++ b/web/modules/ui_combo_title.js @@ -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; +} diff --git a/web/modules/ui_combos.js b/web/modules/ui_combos.js index 244b9dd..f2e932b 100644 --- a/web/modules/ui_combos.js +++ b/web/modules/ui_combos.js @@ -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, diff --git a/web/modules/ui_keep_menu.js b/web/modules/ui_keep_menu.js index b1856a9..bbb476a 100644 --- a/web/modules/ui_keep_menu.js +++ b/web/modules/ui_keep_menu.js @@ -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')); diff --git a/web/modules/ui_kept_toast.js b/web/modules/ui_kept_toast.js new file mode 100644 index 0000000..d46e75a --- /dev/null +++ b/web/modules/ui_kept_toast.js @@ -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(); +} diff --git a/web/modules/ui_notebook_editor.js b/web/modules/ui_notebook_editor.js index d442c7a..8fd5937 100644 --- a/web/modules/ui_notebook_editor.js +++ b/web/modules/ui_notebook_editor.js @@ -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'; diff --git a/web/styles.css b/web/styles.css index d08533d..e3a7f2a 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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"); diff --git a/web/styles/23-combo-structure.css b/web/styles/23-combo-structure.css index 269af52..ea609b6 100644 --- a/web/styles/23-combo-structure.css +++ b/web/styles/23-combo-structure.css @@ -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; }