diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8998c2f..39cea99 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -443,13 +443,21 @@ covered by the `styles.css` manifest. `canvas_undo.js` keeps what each canvas entry did, in full, while the page stays open, and undoes an entry from the log while nothing has changed it since (entries that removed nodes or rewired existing ones are never offered). +- `canvas_menu.js` is AMB's part of the canvas's right-click menus (the extension's `getNodeMenuItems` + and `getCanvasMenuItems`, browser_entry.js): for a node, Open in AMB (`showForNode`: the page + opening with it selected shows; its saved values on Current node), change its model by picture + (`_openGalleryReplacer`), insert a LoRA before / after, the model's pictures and details + (`/anomalous/find_model`, `openModelDetail`), save its values or the picked nodes as a combo, each + only where it applies; for the empty canvas, AMB's pages. `canvas_menu_settings.js` keeps which + entries are on, their order and whether they sit in one "AMB" submenu or right in the menu + (localStorage, no DOM); `ui_canvas_menu_settings.js` is its Settings -> Canvas group. `ui_settings_hub.js` owns the display preferences (view mode, scale, atmosphere, window layout) and their application, the gear and language redraws; `ui_settings_page.js` is the settings page the gear opens (a tool page with Back), in tabs: Look (look and language, model cards and memory with the card image cache, opening mode and window), Models (folders, where downloads and imports go, where the side files go through `sidecar_layout.js`), Workflows (fingerprints), Node packs - (those not used lately, `ui_pack_usage.js`), Prompts + (those not used lately, `ui_pack_usage.js`), Canvas (the right-click menus, `ui_canvas_menu_settings.js`), Prompts (translation), Backup (`ui_backup.js`), Help; `ui_feedback_dialog.js` is the feedback window (Home and the settings page open it): one text box, then `feedback.js` opens a GitHub issue with it in the browser's language, the environment folded at the end when attached (versions and diff --git a/CHANGELOG.md b/CHANGELOG.md index 850b81e..ba9d01e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## v1.61.0 — unreleased +### 🖱️ AMB in the canvas's right-click menu (画布右键菜单) +- **Right-click a node → AMB** offers what Current node does for it, without opening the browser first: **Open in AMB** (a prompt node opens Prompts on it, any other node Current node), **Change model by picture**, **Insert a LoRA before / after**, **Apply saved values**, **Pictures made with this model**, **Model details**, **Save this node's values** and **Save as combo**. Each entry shows only where it applies: a checkpoint loader has no "before", a prompt node no model entries. +- **Right-click the empty canvas → AMB** opens any of AMB's pages. +- **Make it yours: Settings → Canvas → Right-click menu**. Switch each entry on or off, move it up or down, and choose whether AMB's entries sit in one **AMB** submenu (the default, ComfyUI's menu stays tidy) or right in the menu. *Adjust this menu…* at the end of the submenu leads there. **Save as combo** moved into the AMB submenu with the rest. +- In the model picker, Esc now closes the picker wherever the focus is, and only the picker: it used to close the browser behind it when the search box had lost the focus. + ### ⭐ 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*…). diff --git a/README.md b/README.md index a575084..921098f 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@

Gallery search, then drag an image onto the canvas

-**4. The same model, LoRAs and prompt, again and again.** Pick those nodes on the canvas, right-click → **Save as combo**, and they are kept as a card beside the canvas. Next time press **Put on canvas** (or drag the card) and the wired group comes back where you click; nothing already on the canvas changes. Models and prompts in a combo can be changed later. +**4. The same model, LoRAs and prompt, again and again.** Pick those nodes on the canvas, right-click → **AMB** → **Save as combo**, and they are kept as a card beside the canvas. Next time press **Put on canvas** (or drag the card) and the wired group comes back where you click; nothing already on the canvas changes. Models and prompts in a combo can be changed later.

Picked nodes saved as a combo, then put back on the canvas as wired nodes

@@ -157,7 +157,7 @@ Open the browser with **Shift + B** (again to close), the floating button on the

图库搜索后把图拖到画布

-**4. 每次都要重新配一遍模型、LoRA 和提示词。** 在画布上选中这几个节点,右键 **存为搭配**,它们就成了画布旁的一张卡片。下次点 **放到画布**(或者直接拖卡片),点哪里,那组连好线的节点就出现在哪里,画布上原有的东西不动。搭配里的模型和提示词以后还能换。 +**4. 每次都要重新配一遍模型、LoRA 和提示词。** 在画布上选中这几个节点,右键 → **AMB** → **存为搭配**,它们就成了画布旁的一张卡片。下次点 **放到画布**(或者直接拖卡片),点哪里,那组连好线的节点就出现在哪里,画布上原有的东西不动。搭配里的模型和提示词以后还能换。

选中的节点存为搭配,再放回画布变成连好线的节点

diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 05eddc8..e6395e7 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -106,7 +106,7 @@ The main surfaces are: name in their order, fills the slots by name, then links by port name and type; a slot or link that cannot be restored is reported after placing, never guessed. Values other than the slots cannot be edited: a combo puts down exactly what was saved. The canvas's node menu - (`getNodeMenuItems`, browser_entry.js) offers "Save as combo" for the picked nodes, or the + (`getNodeMenuItems`, browser_entry.js → `canvas_menu.js`) offers "Save as combo" for the picked nodes, or the right-clicked node when it is not among them, when any of them is not a subgraph. Nodes are kept in flow order (`chainOrder`, combo_slots.js) so slot numbers read along the chain; the editor groups slots into models, LoRAs and prompts in that order, renumbers names diff --git a/tests/canvas_menu_settings.mjs b/tests/canvas_menu_settings.mjs new file mode 100644 index 0000000..c6e0678 --- /dev/null +++ b/tests/canvas_menu_settings.mjs @@ -0,0 +1,48 @@ +// What the right-click menus offer, as the user set it (web/modules/canvas_menu_settings.js). +import assert from 'node:assert/strict'; +import { CANVAS_ENTRIES, NODE_ENTRIES, menuSettings, moveEntry, resetMenuSettings, saveMenuSettings } from '../web/modules/canvas_menu_settings.js'; + +const memory = () => { + const items = new Map(); + return { getItem: key => items.get(key) ?? null, setItem: (key, value) => items.set(key, String(value)), removeItem: key => items.delete(key) }; +}; + +// Nothing saved: the defaults, in a submenu. +let store = memory(); +let settings = menuSettings(store); +assert.equal(settings.placement, 'submenu'); +assert.deepEqual(settings.node.map(entry => entry.id), NODE_ENTRIES.map(entry => entry.id)); +assert.deepEqual(settings.canvas, CANVAS_ENTRIES.map(entry => ({ ...entry }))); + +// Saved and read back: order, switches and placement kept. +settings.node = moveEntry(settings.node, 'saveCombo', -1); +settings.node.find(entry => entry.id === 'open').on = false; +settings.placement = 'top'; +assert.equal(saveMenuSettings(settings, store), true); +const back = menuSettings(store); +assert.equal(back.placement, 'top'); +assert.equal(back.node.at(-2).id, 'saveCombo'); +assert.equal(back.node.find(entry => entry.id === 'open').on, false); + +// Moving past an end changes nothing. +assert.equal(moveEntry(back.node, back.node[0].id, -1), back.node); + +// An old list: unknown entries and doubles dropped, new ones at their default place. +store = memory(); +store.setItem('anomalous_canvas_menu', JSON.stringify({ placement: 'odd', node: [ + { id: 'saveCombo', on: true }, { id: 'gone', on: true }, { id: 'open', on: false }, { id: 'open', on: true }, +] })); +settings = menuSettings(store); +assert.equal(settings.placement, 'submenu'); +assert.equal(settings.node.length, NODE_ENTRIES.length); +assert.equal(settings.node[0].id, 'saveCombo'); +assert.equal(settings.node[1].id, 'open'); +assert.equal(settings.node[1].on, false); +assert.equal(settings.node[2].id, 'replaceModel'); // right after "open", as by default + +// Bad JSON, no storage, reset. +store.setItem('anomalous_canvas_menu', '{'); +assert.equal(menuSettings(store).placement, 'submenu'); +assert.equal(menuSettings(null).node.length, NODE_ENTRIES.length); +assert.equal(resetMenuSettings(store).node[0].id, 'open'); +console.log('canvas menu settings: ok'); diff --git a/web/modules/browser.js b/web/modules/browser.js index cc5c6e4..52c585b 100644 --- a/web/modules/browser.js +++ b/web/modules/browser.js @@ -62,25 +62,27 @@ export class AnomalousBrowser { /** * Opens the browser. `followSelection`: opened by the user (button, shortcut, menu), so a * node selected on the canvas picks the page: a text node opens the Prompts page on it, any - * other node Current node (Settings -> Opening and window can turn this off). + * other node Current node (Settings -> Opening and window can turn this off). `node`: that + * page for this node whatever the setting; `page`: this rail page (or 'assistant'). */ - show({ followSelection = false } = {}) { + show({ followSelection = false, node = null, page: wanted = null } = {}) { followComfyLanguage(); if (this._idleReleaseTimer) { clearTimeout(this._idleReleaseTimer); this._idleReleaseTimer = null; } - const page = followSelection && followsSelection() ? pageForNode(selectedCanvasNode(), NODE_KINDS) : null; + const followed = followSelection && followsSelection() ? selectedCanvasNode() : null; + const page = wanted || pageForNode(node || followed, NODE_KINDS); this.setTriggerVisible(false); this.modal.classList.add('visible'); - if (page === 'prompts') { - this.goTo('prompts'); - return; - } if (page === 'assistant') { this.openCurrentNode(); return; } + if (page) { + this.goTo(page); + return; + } if (!this.currentShellPage()) { this.goTo(startPage()); return; @@ -99,6 +101,28 @@ export class AnomalousBrowser { if (this.currentShellPage() === 'scan') void renderScanPage(this, this.scanPanel); } + /** + * The canvas menu's "Open in AMB": selects `node` and opens its page; `section: + * 'parameters'` opens Current node at the node's saved values. + */ + showForNode(node, { section = '' } = {}) { + app.canvas?.selectNode?.(node, false); + const parameters = section === 'parameters'; + this.show({ node, page: parameters ? 'assistant' : null }); + if (parameters) this.assistantPanel?.querySelector('.anomalous-node-params')?.scrollIntoView({ block: 'start' }); + } + + /** The details of `model` (GET /anomalous/find_model's: with its type, path_idx and subfolder). */ + openModelDetail(model) { + this.currentType = model.type || this.currentType; + this.currentPathIdx = model.path_idx ?? 0; + this.currentSubfolder = model.subfolder || '/'; + this.show({ page: 'models' }); + this.historyStack = []; + this.showDetail(model); + if (this.foldersData) this.renderSidebar(); + } + /** The image / audio switch: the first page of that side. */ handleDomainChange(domain) { this.goTo(domain === 'audio' ? 'voices' : 'models'); diff --git a/web/modules/browser_entry.js b/web/modules/browser_entry.js index a0f6622..4d5c6ae 100644 --- a/web/modules/browser_entry.js +++ b/web/modules/browser_entry.js @@ -1,7 +1,7 @@ import { app } from '../../../scripts/app.js'; import { AnomalousBrowser } from './browser.js'; import { watchMcpActions } from './mcp_bridge.js'; -import { hasComboNodes, menuNodes, saveSelectionAsCombo } from './ui_combo_structure.js'; +import { canvasMenuItems, nodeMenuItems } from './canvas_menu.js'; import { clampFloatingTriggerPosition, clearSavedTriggerPosition, @@ -434,18 +434,9 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) { commands: [OPEN_BROWSER_COMMAND_ID, RESET_TRIGGER_POSITION_COMMAND_ID] }], setup, - // The canvas's node menu: the picked nodes (or this one) as a combo, when it could keep any. - getNodeMenuItems(node) { - const nodes = menuNodes(node); - if (!hasComboNodes(nodes)) return []; - return [null, { - content: nodes.length > 1 ? t('comboMenuSaveMany', { count: nodes.length }) : t('comboMenuSave'), - callback: () => { - const owner = ensureBrowser(); - if (owner) void saveSelectionAsCombo(owner, nodes); - }, - }]; - }, + // The canvas's right-click menus: AMB's entries as Settings -> Canvas sets them (canvas_menu.js). + getNodeMenuItems: node => nodeMenuItems(node, ensureBrowser), + getCanvasMenuItems: () => canvasMenuItems(ensureBrowser), open, ensureBrowser, resetPosition, diff --git a/web/modules/canvas_menu.js b/web/modules/canvas_menu.js new file mode 100644 index 0000000..86be05b --- /dev/null +++ b/web/modules/canvas_menu.js @@ -0,0 +1,132 @@ +/** + * AMB's entries in the canvas's right-click menus (the extension's getNodeMenuItems and + * getCanvasMenuItems hooks, browser_entry.js), as Settings -> Canvas sets them + * (canvas_menu_settings.js). A node's menu offers what Current node does for it — open it in + * AMB, change its model by picture, insert a LoRA before / after, its saved values, the + * model's pictures and details, save its values or the picked nodes as a combo — each only + * where it applies; the empty canvas's menu opens AMB's pages. `browser()` gives the browser, + * made on first use. + */ + +import { translate as t } from './locales.js'; +import { menuSettings } from './canvas_menu_settings.js'; +import { loraInsertionCapabilities } from './graph_splice.js'; +import { modelComboWidgets } from './ui_node_model_picker.js'; +import { hasComboNodes, menuNodes, saveSelectionAsCombo } from './ui_combo_structure.js'; +import { saveNodeParameters } from './node_parameter_sets.js'; +import { anomalousPrompt } from './ui_dialog.js'; +import { showWorkbenchToast } from './ui_prompt_toast.js'; +import { openSettingsPage } from './ui_settings_page.js'; +import { RAIL_PAGES } from './ui_shell_rail.js'; + +const item = (content, run) => ({ content, callback: () => void run() }); + +/** The widgets naming a model file that is set (not empty, not "None"). */ +const setModels = node => modelComboWidgets(node).filter(widget => typeof widget.value === 'string' && widget.value && widget.value !== 'None'); + +/** The model AMB knows for `widget`'s file (with its type, path_idx and subfolder), or null. */ +async function findModel(widget) { + try { + const response = await fetch(`/anomalous/find_model?search=${encodeURIComponent(widget.value.replace(/\\/g, '/'))}`); + const found = await response.json(); + if (found.status === 'success' && found.model) return found.model; + } catch (error) { + console.warn('[AMB] Could not look up the model:', error); + } + showWorkbenchToast(t('canvasMenuModelUnknown')); + return null; +} + +/** One label per model widget: the widget's name when the node has several. */ +const perModel = (widgets, key) => widgets.map(widget => [widget, widgets.length > 1 ? `${t(key)}: ${widget.name}` : t(key)]); + +// Entry id -> (node, browser) => its menu items ([] where it does not apply). +const NODE_ITEMS = { + open: (node, browser) => [item(t('canvasMenuOpen'), () => browser()?.showForNode(node))], + replaceModel: (node, browser) => perModel(modelComboWidgets(node), 'canvasMenuReplace') + .map(([widget, label]) => item(label, () => browser()?._openGalleryReplacer(node, widget))), + loraBefore: (node, browser) => loraInsertionCapabilities(node.graph, node).before.supported + ? [item(t('canvasMenuLoraBefore'), () => browser()?.openLoraInsertionPicker(node, 'before'))] : [], + loraAfter: (node, browser) => loraInsertionCapabilities(node.graph, node).after.supported + ? [item(t('canvasMenuLoraAfter'), () => browser()?.openLoraInsertionPicker(node, 'after'))] : [], + parameters: (node, browser) => node.widgets?.length + ? [item(t('canvasMenuParameters'), () => browser()?.showForNode(node, { section: 'parameters' }))] : [], + modelOutputs: (node, browser) => perModel(setModels(node), 'canvasMenuOutputs').map(([widget, label]) => item(label, async () => { + const model = await findModel(widget); + const owner = model && browser(); + if (!owner) return; + owner.show(); + owner.showGeneratedGallery(model); + })), + modelDetails: (node, browser) => perModel(setModels(node), 'canvasMenuDetails').map(([widget, label]) => item(label, async () => { + const model = await findModel(widget); + if (model) browser()?.openModelDetail(model); + })), + saveParameters: node => node.widgets?.length ? [item(t('canvasMenuSaveParams'), async () => { + const name = await anomalousPrompt(t('currentNodeSaveName'), node.title || node.type, t('currentNodeSave')); + if (name === null) return; + try { + const result = await saveNodeParameters(node, name || node.title || node.type); + showWorkbenchToast(t(result.status === 'duplicate' ? 'currentNodeSavedBefore' : 'currentNodeSaved', { name: result.name })); + } catch (error) { + console.error('[AMB] Could not save the node values:', error); + showWorkbenchToast(t('currentNodeSaveFailed')); + } + })] : [], + saveCombo: (node, browser) => { + const nodes = menuNodes(node); + if (!hasComboNodes(nodes)) return []; + const label = nodes.length > 1 ? t('comboMenuSaveMany', { count: nodes.length }) : t('comboMenuSave'); + return [item(label, () => { + const owner = browser(); + if (owner) return saveSelectionAsCombo(owner, nodes); + })]; + }, +}; + +// Entry id -> (browser, placement) => the empty canvas's items. +const CANVAS_ITEMS = { + quickSearch: () => [], + // The pages themselves inside AMB's submenu; right in the menu, a submenu of them. + openPage: (browser, placement) => { + const pages = RAIL_PAGES.map(({ page, labelKey }) => item(t(labelKey), () => browser()?.show({ page }))); + return placement === 'top' ? [{ content: t('canvasMenuOpenPage'), has_submenu: true, submenu: { options: pages } }] : pages; + }, +}; + +/** The items of `list` that are on and apply, built by `builders`; a broken one is left out. */ +function build(list, builders, ...args) { + return list.filter(entry => entry.on).flatMap((entry) => { + try { + return builders[entry.id]?.(...args) || []; + } catch (error) { + console.warn(`[AMB] Canvas menu entry ${entry.id} failed:`, error); + return []; + } + }); +} + +/** `items` as AMB's part of a menu: one submenu (ending in "Adjust this menu"), or right in it. */ +function place(items, placement, browser) { + if (!items.length) return []; + if (placement === 'top') return [null, ...items]; + const adjust = item(t('canvasMenuAdjust'), () => { + const owner = browser(); + if (!owner) return; + owner.show(); + openSettingsPage(owner, { tab: 'canvas' }); + }); + return [null, { content: t('canvasMenuTitle'), has_submenu: true, submenu: { options: [...items, null, adjust] } }]; +} + +/** AMB's items for the menu of `node`. */ +export function nodeMenuItems(node, browser) { + const settings = menuSettings(); + return place(build(settings.node, NODE_ITEMS, node, browser), settings.placement, browser); +} + +/** AMB's items for the empty canvas's menu. */ +export function canvasMenuItems(browser) { + const settings = menuSettings(); + return place(build(settings.canvas, CANVAS_ITEMS, browser, settings.placement), settings.placement, browser); +} diff --git a/web/modules/canvas_menu_settings.js b/web/modules/canvas_menu_settings.js new file mode 100644 index 0000000..7c1ab84 --- /dev/null +++ b/web/modules/canvas_menu_settings.js @@ -0,0 +1,95 @@ +/** + * What the canvas's right-click menus offer from AMB, as the user set it (Settings -> Canvas): + * the entries of a node's menu and of the empty canvas's menu, each on or off, in the user's + * order, and whether they sit in one "Anomalous" submenu or right in the menu. A per-viewer + * convenience in localStorage; entries added in a later version join at their default place. + * No DOM. + */ + +const KEY = 'anomalous_canvas_menu'; + +// Each list in its default order, with whether an entry is on at first. +export const NODE_ENTRIES = Object.freeze([ + { id: 'open', on: true }, + { id: 'replaceModel', on: true }, + { id: 'loraBefore', on: true }, + { id: 'loraAfter', on: true }, + { id: 'parameters', on: true }, + { id: 'modelOutputs', on: true }, + { id: 'modelDetails', on: false }, + { id: 'saveParameters', on: false }, + { id: 'saveCombo', on: true }, +]); +export const CANVAS_ENTRIES = Object.freeze([ + { id: 'quickSearch', on: true }, + { id: 'openPage', on: true }, +]); +export const PLACEMENTS = Object.freeze(['submenu', 'top']); + +function storage() { + try { + return globalThis.localStorage || null; + } catch { + return null; + } +} + +/** The saved `list` in its order, entries it does not know dropped and new ones added at their place. */ +function merged(saved, defaults) { + const known = new Map(defaults.map(entry => [entry.id, entry])); + const kept = (Array.isArray(saved) ? saved : []) + .filter(entry => known.has(entry?.id)) + .filter((entry, index, all) => all.findIndex(other => other.id === entry.id) === index) + .map(entry => ({ id: entry.id, on: entry.on !== false })); + defaults.forEach((entry, index) => { + if (kept.some(other => other.id === entry.id)) return; + // After the entry that comes before it by default, else first. + const before = defaults.slice(0, index).reverse().find(other => kept.some(item => item.id === other.id)); + const at = before ? kept.findIndex(item => item.id === before.id) + 1 : 0; + kept.splice(at, 0, { ...entry }); + }); + return kept; +} + +/** { placement, node: [{ id, on }], canvas: [{ id, on }] }. */ +export function menuSettings(store = storage()) { + let saved = {}; + try { + saved = JSON.parse(store?.getItem(KEY) || '{}') || {}; + } catch { + saved = {}; + } + return { + placement: PLACEMENTS.includes(saved.placement) ? saved.placement : 'submenu', + node: merged(saved.node, NODE_ENTRIES), + canvas: merged(saved.canvas, CANVAS_ENTRIES), + }; +} + +/** Keeps `settings` (as menuSettings returns them); false when the browser would not keep it. */ +export function saveMenuSettings(settings, store = storage()) { + try { + store.setItem(KEY, JSON.stringify({ placement: settings.placement, node: settings.node, canvas: settings.canvas })); + return true; + } catch { + return false; + } +} + +/** Back to the defaults. */ +export function resetMenuSettings(store = storage()) { + try { + store?.removeItem(KEY); + } catch { /* the defaults show anyway */ } + return menuSettings(store); +} + +/** `list` with the entry `id` moved by `step` (-1 up, +1 down), or as it was at an end. */ +export function moveEntry(list, id, step) { + const from = list.findIndex(entry => entry.id === id); + const to = from + step; + if (from < 0 || to < 0 || to >= list.length) return list; + const next = [...list]; + [next[from], next[to]] = [next[to], next[from]]; + return next; +} diff --git a/web/modules/locales.js b/web/modules/locales.js index 3ede39a..2c7bd84 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -627,8 +627,53 @@ export const i18n = { comboFromSelectionNoSlots: "没有可以换的空位,节点都按现在的设置原样放回。", comboFromSelectionSave: "保存搭配", comboNewDefault: "默认搭配", - comboMenuSave: "存为搭配(Anomalous)", - comboMenuSaveMany: "把选中的 {count} 个节点存为搭配(Anomalous)", + comboMenuSave: "存为搭配", + comboMenuSaveMany: "把选中的 {count} 个节点存为搭配", + canvasMenuTitle: "AMB", + canvasMenuOpen: "在 AMB 里打开它", + canvasMenuReplace: "看图换模型", + canvasMenuLoraBefore: "前面插一个 LoRA", + canvasMenuLoraAfter: "后面插一个 LoRA", + canvasMenuParameters: "套用参数方案…", + canvasMenuOutputs: "这个模型出过的图", + canvasMenuDetails: "查看模型详情", + canvasMenuSaveParams: "存这个节点的参数…", + canvasMenuOpenPage: "打开 AMB", + canvasMenuAdjust: "调整这个菜单…", + canvasMenuModelUnknown: "AMB 没找到这个模型,可能还没扫描,或者文件不在模型文件夹里。", + canvasMenuGroup: "右键菜单", + canvasMenuPlacement: "放在哪里", + canvasMenuPlacementHelp: "收进一个“AMB”子菜单,ComfyUI 的菜单保持清爽;或者直接放在菜单里,少点一下。", + canvasMenuPlacementSubmenu: "收进 AMB 子菜单", + canvasMenuPlacementTop: "直接放外层", + canvasMenuList_node: "右键一个节点", + canvasMenuList_canvas: "右键画布空白处", + canvasMenuUp: "上移", + canvasMenuDown: "下移", + canvasMenuReset: "恢复默认", + canvasMenuResetHelp: "所有项回到默认的开关和顺序。", + canvasMenuEntry_open: "在 AMB 里打开它", + canvasMenuEntryHelp_open: "提示词节点打开提示词页,其他节点打开“当前节点”", + canvasMenuEntry_replaceModel: "看图换模型", + canvasMenuEntryHelp_replaceModel: "节点有模型下拉框时,看着封面挑一个换上", + canvasMenuEntry_loraBefore: "前面插一个 LoRA", + canvasMenuEntryHelp_loraBefore: "挑一个 LoRA,接在这个节点的输入前面", + canvasMenuEntry_loraAfter: "后面插一个 LoRA", + canvasMenuEntryHelp_loraAfter: "挑一个 LoRA,接在这个节点的输出后面", + canvasMenuEntry_parameters: "套用参数方案", + canvasMenuEntryHelp_parameters: "打开“当前节点”,直接看这类节点存过的参数", + canvasMenuEntry_modelOutputs: "这个模型出过的图", + canvasMenuEntryHelp_modelOutputs: "用节点里这个模型生成过的图", + canvasMenuEntry_modelDetails: "查看模型详情", + canvasMenuEntryHelp_modelDetails: "节点里这个模型在 AMB 里的详情页", + canvasMenuEntry_saveParameters: "存这个节点的参数", + canvasMenuEntryHelp_saveParameters: "起个名字存下来,以后同类节点可以套用", + canvasMenuEntry_saveCombo: "存为搭配", + canvasMenuEntryHelp_saveCombo: "把这个节点(或选中的几个节点)存成搭配", + canvasMenuEntry_quickSearch: "快速搜索", + canvasMenuEntryHelp_quickSearch: "搜模型、搭配、提示词,回车放到画布(也可以按 Alt+B)", + canvasMenuEntry_openPage: "打开 AMB 的某一页", + canvasMenuEntryHelp_openPage: "主页、模型、图库、工作流、搭配、提示词……", comboNewDefaultHint: "一个主模型 + 几个 LoRA + 提示词", comboNewWithLoraNode: "默认搭配,LoRA 用 {node}", comboLoraNodeLabel: "放到画布时 LoRA 用:", @@ -1505,6 +1550,7 @@ export const i18n = { settingsTabLook: '外观', settingsTabModels: '模型', settingsTabWorkflows: '工作流', + settingsTabCanvas: '画布', settingsTabPacks: '节点包', settingsTabPrompts: '提示词', settingsTabBackup: '备份', @@ -3357,8 +3403,53 @@ export const i18n = { comboFromSelectionNoSlots: "No slots to change; the nodes go back with the values they have now.", comboFromSelectionSave: "Save combo", comboNewDefault: "Default combo", - comboMenuSave: "Save as combo (Anomalous)", - comboMenuSaveMany: "Save the {count} picked nodes as a combo (Anomalous)", + comboMenuSave: "Save as combo", + comboMenuSaveMany: "Save the {count} picked nodes as a combo", + canvasMenuTitle: "AMB", + canvasMenuOpen: "Open in AMB", + canvasMenuReplace: "Change model by picture", + canvasMenuLoraBefore: "Insert a LoRA before", + canvasMenuLoraAfter: "Insert a LoRA after", + canvasMenuParameters: "Apply saved values…", + canvasMenuOutputs: "Pictures made with this model", + canvasMenuDetails: "Model details", + canvasMenuSaveParams: "Save this node's values…", + canvasMenuOpenPage: "Open AMB", + canvasMenuAdjust: "Adjust this menu…", + canvasMenuModelUnknown: "AMB does not know this model: maybe it is not scanned yet, or the file is outside the model folders.", + canvasMenuGroup: "Right-click menu", + canvasMenuPlacement: "Where", + canvasMenuPlacementHelp: "In one “AMB” submenu, keeping ComfyUI's menu tidy, or right in the menu, one click less.", + canvasMenuPlacementSubmenu: "In an AMB submenu", + canvasMenuPlacementTop: "Right in the menu", + canvasMenuList_node: "Right-click on a node", + canvasMenuList_canvas: "Right-click on the empty canvas", + canvasMenuUp: "Move up", + canvasMenuDown: "Move down", + canvasMenuReset: "Reset", + canvasMenuResetHelp: "Every entry back to its default switch and place.", + canvasMenuEntry_open: "Open in AMB", + canvasMenuEntryHelp_open: "A prompt node opens Prompts, any other node Current node", + canvasMenuEntry_replaceModel: "Change model by picture", + canvasMenuEntryHelp_replaceModel: "For a node with a model drop-down: pick another by its cover", + canvasMenuEntry_loraBefore: "Insert a LoRA before", + canvasMenuEntryHelp_loraBefore: "Pick a LoRA, wired in before this node's inputs", + canvasMenuEntry_loraAfter: "Insert a LoRA after", + canvasMenuEntryHelp_loraAfter: "Pick a LoRA, wired in after this node's outputs", + canvasMenuEntry_parameters: "Apply saved values", + canvasMenuEntryHelp_parameters: "Current node, at the values saved for this kind of node", + canvasMenuEntry_modelOutputs: "Pictures made with this model", + canvasMenuEntryHelp_modelOutputs: "The pictures made with the node's model", + canvasMenuEntry_modelDetails: "Model details", + canvasMenuEntryHelp_modelDetails: "The node's model's page in AMB", + canvasMenuEntry_saveParameters: "Save this node's values", + canvasMenuEntryHelp_saveParameters: "Name and keep them, to apply to nodes of this kind later", + canvasMenuEntry_saveCombo: "Save as combo", + canvasMenuEntryHelp_saveCombo: "This node (or the picked nodes) as a combo", + canvasMenuEntry_quickSearch: "Quick search", + canvasMenuEntryHelp_quickSearch: "Find a model, combo or prompt; Enter puts it on the canvas (or press Alt+B)", + canvasMenuEntry_openPage: "Open a page of AMB", + canvasMenuEntryHelp_openPage: "Home, Models, Gallery, Workflows, Combos, Prompts…", comboNewDefaultHint: "A main model + a few LoRAs + a prompt", comboNewWithLoraNode: "Default combo, LoRAs in {node}", comboLoraNodeLabel: "On the canvas, LoRAs go in:", @@ -4235,6 +4326,7 @@ export const i18n = { settingsTabLook: 'Look', settingsTabModels: 'Models', settingsTabWorkflows: 'Workflows', + settingsTabCanvas: 'Canvas', settingsTabPacks: 'Node packs', settingsTabPrompts: 'Prompts', settingsTabBackup: 'Backup', diff --git a/web/modules/ui_canvas_menu_settings.js b/web/modules/ui_canvas_menu_settings.js new file mode 100644 index 0000000..7a31526 --- /dev/null +++ b/web/modules/ui_canvas_menu_settings.js @@ -0,0 +1,87 @@ +/** + * Settings -> Canvas -> Right-click menu: where AMB's entries sit (one "AMB" submenu or right + * in ComfyUI's menu) and, for a node's menu and the empty canvas's, each entry on or off and + * moved up or down, with Reset. Saved at once (canvas_menu_settings.js); the menu reads it each + * time it opens. + */ + +import { translate as t } from './locales.js'; +import { menuSettings, moveEntry, resetMenuSettings, saveMenuSettings } from './canvas_menu_settings.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; +} + +/** The rows of one menu's entries (`which`: 'node' or 'canvas'). */ +function entryList(settings, which, redraw) { + const list = el('div', 'anomalous-menu-entries'); + list.setAttribute('aria-label', t(`canvasMenuList_${which}`)); + const entries = settings[which]; + entries.forEach((entry, index) => { + const line = el('div', `anomalous-menu-entry${entry.on ? '' : ' is-off'}`); + const copy = el('span', 'anomalous-menu-entry-copy'); + copy.append(el('span', 'anomalous-menu-entry-name', t(`canvasMenuEntry_${entry.id}`)), + el('small', 'anomalous-menu-entry-help', t(`canvasMenuEntryHelp_${entry.id}`))); + const move = (step) => { + settings[which] = moveEntry(entries, entry.id, step); + saveMenuSettings(settings); + redraw(); + }; + const up = button('anomalous-menu-entry-move', '↑', () => move(-1)); + up.disabled = index === 0; + up.title = t('canvasMenuUp'); + up.setAttribute('aria-label', `${t('canvasMenuUp')}: ${t(`canvasMenuEntry_${entry.id}`)}`); + const down = button('anomalous-menu-entry-move', '↓', () => move(1)); + down.disabled = index === entries.length - 1; + down.title = t('canvasMenuDown'); + down.setAttribute('aria-label', `${t('canvasMenuDown')}: ${t(`canvasMenuEntry_${entry.id}`)}`); + const toggle = el('input', 'anomalous-scan-switch'); + toggle.type = 'checkbox'; + toggle.checked = entry.on; + toggle.setAttribute('aria-label', t(`canvasMenuEntry_${entry.id}`)); + toggle.onchange = () => { + entry.on = toggle.checked; + line.classList.toggle('is-off', !entry.on); + saveMenuSettings(settings); + }; + line.append(copy, up, down, toggle); + list.append(line); + }); + return list; +} + +/** + * The group's rows: `row(titleKey, helpKey, control)` and `segment(choices, current, onPick)` + * are the settings page's own helpers; `redraw()` draws the page again. + */ +export function canvasMenuRows({ row, segment, redraw }) { + const settings = menuSettings(); + const placement = row('canvasMenuPlacement', 'canvasMenuPlacementHelp', segment( + [['submenu', 'canvasMenuPlacementSubmenu'], ['top', 'canvasMenuPlacementTop']], settings.placement, (value) => { + settings.placement = value; + saveMenuSettings(settings); + redraw(); + })); + const reset = row('canvasMenuReset', 'canvasMenuResetHelp', button('anomalous-scan-secondary anomalous-scan-small-btn', t('settingsReset'), () => { + resetMenuSettings(); + redraw(); + })); + return [ + placement, + el('h4', 'anomalous-menu-entries-title', t('canvasMenuList_node')), + entryList(settings, 'node', redraw), + el('h4', 'anomalous-menu-entries-title', t('canvasMenuList_canvas')), + entryList(settings, 'canvas', redraw), + reset, + ]; +} diff --git a/web/modules/ui_node_model_picker.js b/web/modules/ui_node_model_picker.js index 0f5d617..3697048 100644 --- a/web/modules/ui_node_model_picker.js +++ b/web/modules/ui_node_model_picker.js @@ -35,12 +35,17 @@ async function loraTrainsTextEncoder(name) { } } -export function findModelComboWidget(node) { - return (node?.widgets || []).find(widget => { +/** The node's drop-downs that list model files. */ +export function modelComboWidgets(node) { + return (node?.widgets || []).filter(widget => { if (widget?.type !== 'combo') return false; const values = getNativeWidgetValues(node, widget); return values.some(value => /\.(safetensors|ckpt|pt|bin|pth|sft|gguf)$/i.test(value)); - }) || null; + }); +} + +export function findModelComboWidget(node) { + return modelComboWidgets(node)[0] || null; } export function setWidgetValue(node, widget, value) { @@ -86,14 +91,23 @@ export function _openGalleryReplacer(node, w, options = {}) { let applying = false; const modal = document.createElement('div'); + modal.className = 'anomalous-picker-overlay'; // the browser's Esc leaves it to the picker (ui_browser_navigation.js) modal.style.cssText = 'position:fixed;inset:0;background:var(--amb-lt-page,radial-gradient(circle at 15% 0%,rgba(53,73,118,0.34),transparent 35%),rgba(7,8,12,0.965));z-index:999999;display:flex;flex-direction:column;padding:22px 24px;box-sizing:border-box;color:var(--amb-lt-text,#fff);font-family:Inter,Arial,sans-serif;'; const stopMedia = container => container?.querySelectorAll?.('video,audio').forEach(media => { media.pause(); media.removeAttribute('src'); media.load?.(); }); + // Esc closes the picker wherever the focus is, and only the picker (not the browser behind). + const onEscape = (event) => { + if (event.key !== 'Escape' || !modal.isConnected) return; + event.preventDefault(); + event.stopImmediatePropagation(); + closeModal(); + }; const closeModal = () => { renderGeneration += 1; + window.removeEventListener('keydown', onEscape, true); stopMedia(modal); modal.remove(); }; @@ -493,7 +507,7 @@ export function _openGalleryReplacer(node, w, options = {}) { renderCards(); }; sortSelect.onchange = renderCards; - modal.onkeydown = event => { if (event.key === 'Escape') closeModal(); }; + window.addEventListener('keydown', onEscape, true); renderFolders(); updateSelection(); renderCards(); diff --git a/web/modules/ui_node_parameters.js b/web/modules/ui_node_parameters.js index 2cea52b..0d61199 100644 --- a/web/modules/ui_node_parameters.js +++ b/web/modules/ui_node_parameters.js @@ -392,7 +392,7 @@ function keepView(view) { /** Appends the parameters section for `node` to `container` and loads its entries. */ export function renderNodeParameters(node, container, owner) { - const section = el('section', 'anomalous-node-section'); + const section = el('section', 'anomalous-node-section anomalous-node-params'); const head = el('div', 'anomalous-node-section-head'); const views = el('div', 'anomalous-node-param-views'); views.setAttribute('role', 'radiogroup'); diff --git a/web/modules/ui_settings_page.js b/web/modules/ui_settings_page.js index 8b1f248..2aed419 100644 --- a/web/modules/ui_settings_page.js +++ b/web/modules/ui_settings_page.js @@ -31,6 +31,7 @@ import { failureWords } from './net_failure.js'; import { applySidecars, planSidecars } from './sidecar_layout.js'; import { anomalousConfirm } from './ui_dialog.js'; import { renderPackUsage } from './ui_pack_usage.js'; +import { canvasMenuRows } from './ui_canvas_menu_settings.js'; const t = (key, params) => translate(key, params); // The full written guide: the README, at its Chinese half for Chinese. @@ -493,6 +494,7 @@ const TABS = [ ['look', 'settingsTabLook', (owner, redraw) => [appearanceGroup(owner, redraw), cardsGroup(owner, redraw), galleryGroup(owner), openingGroup(owner, redraw)]], ['models', 'settingsTabModels', (owner, redraw) => [foldersGroup(owner), networkGroup(), downloadGroup(owner, redraw), sidecarGroup()]], ['workflows', 'settingsTabWorkflows', (owner) => [workflowGroup(owner)]], + ['canvas', 'settingsTabCanvas', (owner, redraw) => [group('canvasMenuGroup', ...canvasMenuRows({ row, segment, redraw }))]], ['packs', 'settingsTabPacks', (owner, redraw) => [renderPackUsage(owner, redraw)]], ['prompts', 'settingsTabPrompts', () => [translationGroup()]], ['backup', 'settingsTabBackup', (owner) => [backupGroup(owner)]], diff --git a/web/styles.css b/web/styles.css index e3a7f2a..12aaa31 100644 --- a/web/styles.css +++ b/web/styles.css @@ -30,3 +30,4 @@ @import url("./styles/29-workflow-detail.css?v=20261011-wfd-3"); @import url("./styles/30-scan-picker.css?v=20261016-lists"); @import url("./styles/31-model-info.css?v=20261014-desc-2"); +@import url("./styles/32-canvas-tools.css?v=20261019-menu"); diff --git a/web/styles/32-canvas-tools.css b/web/styles/32-canvas-tools.css new file mode 100644 index 0000000..f76c5f6 --- /dev/null +++ b/web/styles/32-canvas-tools.css @@ -0,0 +1,54 @@ +/* Settings -> Canvas -> Right-click menu (ui_canvas_menu_settings.js). */ +.anomalous-menu-entries-title { + margin: 14px 0 4px; + color: var(--amb-text-muted); + font-size: 12px; + font-weight: 700; +} + +.anomalous-menu-entries { + display: flex; + flex-direction: column; + border: 1px solid var(--amb-border); + border-radius: var(--amb-radius-control); +} + +.anomalous-menu-entry { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; +} + +.anomalous-menu-entry + .anomalous-menu-entry { border-top: 1px solid var(--amb-border); } + +.anomalous-menu-entry-copy { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.anomalous-menu-entry-name { color: var(--amb-text-main); font-size: 13px; } + +.anomalous-menu-entry-help { color: var(--amb-text-muted); font-size: 11px; } + +.anomalous-menu-entry.is-off .anomalous-menu-entry-name { color: var(--amb-text-muted); } + +.anomalous-menu-entry-move { + width: 26px; + height: 26px; + padding: 0; + border: 1px solid var(--amb-border); + border-radius: var(--amb-radius-control); + background: none; + color: var(--amb-text-soft); + cursor: pointer; +} + +.anomalous-menu-entry-move:hover:not(:disabled) { border-color: var(--amb-border-hover); color: var(--amb-text-main); } + +.anomalous-menu-entry-move:disabled { opacity: 0.35; cursor: default; } + +.anomalous-menu-entries + .anomalous-scan-setting { margin-top: 12px; }