Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_materials.js
T
DemonGatanjieuandClaude Opus 5.5 307381c439 feat(shell): every tool opens from its page; the toolbox goes
The rail keeps scan, Model Doctor and Current node (ui_rail_tools.js,
which replaces ui_toolbox.js). The tools the toolbox held now sit where
their things are:

- Workflow share codes (AMB0/AMB1 import / export): the Workflows page's
  top bar, in place of the disabled "import" button.
- Prompt Studio and Prompt Notes: the Material Library's top bar.
- Model Sources: the end of the type chips on Models (library) and
  "where to download" in Model Doctor (the open workflow).

Removed with it: the toolbox popover, its tool catalog and custom-item
hook (no plugin used it), the generic tool dispatch (Home opens the doctor
directly), the tour step, its styles and the toolbox and old "planned
tools" strings. The scan button's tooltip no longer says "wizard".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:20:28 +08:00

406 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Curated image/workflow and Recipe parameter materials. */
import { app } from '../../../scripts/app.js';
import { selectedMaterialNode } from './node_material_actions.js';
import { translate, resolveLocale } from './locales.js';
import { showImageWorkbench } from './ui_gallery_detail.js';
import { text, jsonResponse } from './ui_dom.js';
import { renderMaterialCard } from './ui_material_cards.js';
import { leaveMaterialDetail, showMaterialDetail } from './ui_material_detail.js';
import { updateMaterialContext, watchMaterialSelection } from './ui_material_application.js';
const t = (key, params) => translate(key, params);
function hideSiblingWorkspaceViews(owner) {
if (owner.notebookContainer) owner.notebookContainer.style.display = 'none';
if (owner.notebookBody) owner.notebookBody.style.display = 'none';
if (owner.recipeView) owner.recipeView.style.display = 'none';
if (owner.recipeContainer) owner.recipeContainer.style.display = 'none';
owner.notebookNotesTab?.classList.remove('active');
owner.notebookRecipesTab?.classList.remove('active');
}
function buildMaterialTopbar(owner) {
const topbar = text(owner.materialView, 'header', '', 'anomalous-material-topbar');
owner.materialTopbar = topbar;
// 1. 左侧微胶囊分类切换
const left = text(topbar, 'div', '', 'anomalous-material-topbar-left');
const pills = text(left, 'div', '', 'anomalous-material-pills');
const categories = [
{ id: 'all', key: 'materialKindPill_all', kind: '' },
{ id: 'workflow', key: 'materialKindPill_workflow', kind: 'image_workflow_snapshot' },
{ id: 'params', key: 'materialKindPill_params', kind: 'recipe_parameter_selection' },
{ id: 'prompts', key: 'materialKindPill_prompts', kind: 'prompt_plan' },
];
owner.materialKindPills = {};
for (const cat of categories) {
const pill = text(pills, 'button', t(cat.key) || cat.id, 'anomalous-material-pill');
pill.type = 'button';
if ((!owner.materialKindCategory && cat.id === 'all') || owner.materialKindCategory === cat.id) {
pill.classList.add('is-active');
}
pill.onclick = () => {
for (const p of Object.values(owner.materialKindPills)) p.classList.remove('is-active');
pill.classList.add('is-active');
owner.materialKindCategory = cat.id;
owner.materialKind = '';
if (cat.id === 'prompts') owner.materialApplyMode = false;
owner.refreshMaterials(1);
};
owner.materialKindPills[cat.id] = pill;
}
// 2. 中间紧凑搜索与标签下拉
const center = text(topbar, 'div', '', 'anomalous-material-topbar-center');
const searchWrap = text(center, 'div', '', 'anomalous-material-search-wrap');
const searchIcon = text(searchWrap, 'span', '', 'anomalous-material-search-icon');
searchIcon.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`;
const search = text(searchWrap, 'input', '', 'anomalous-material-search-input');
search.type = 'search';
search.placeholder = t('materialSearchHint') || '搜索素材名称或标签...';
search.value = owner.materialQuery || '';
search.oninput = () => {
clearTimeout(owner.materialSearchTimer);
owner.materialQuery = search.value;
owner.materialSearchTimer = setTimeout(() => owner.refreshMaterials(1), 250);
};
owner.materialTagSelect = text(center, 'select', '', 'anomalous-material-tag-select');
owner.materialTagSelect.setAttribute('aria-label', t('materialTags'));
owner.materialTagSelect.onchange = () => {
owner.materialTag = owner.materialTagSelect.value;
owner.refreshMaterials(1);
};
// 3. 右侧操作组
const right = text(topbar, 'div', '', 'anomalous-material-topbar-right');
// 视图切换 (网格 / 列表)
const viewSwitch = text(right, 'div', '', 'anomalous-material-view-switch');
const currentMode = owner.materialViewMode || (typeof localStorage !== 'undefined' ? localStorage.getItem('anomalous_material_view_mode') : null) || 'grid';
owner.materialViewMode = currentMode;
const gridBtn = text(viewSwitch, 'button', '', `anomalous-material-view-btn${currentMode === 'grid' ? ' is-active' : ''}`);
gridBtn.type = 'button';
gridBtn.title = t('materialViewGrid') || '网格视图';
gridBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></svg>`;
const listBtn = text(viewSwitch, 'button', '', `anomalous-material-view-btn${currentMode === 'list' ? ' is-active' : ''}`);
listBtn.type = 'button';
listBtn.title = t('materialViewList') || '列表视图';
listBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>`;
gridBtn.onclick = () => {
owner.materialViewMode = 'grid';
if (typeof localStorage !== 'undefined') localStorage.setItem('anomalous_material_view_mode', 'grid');
gridBtn.classList.add('is-active');
listBtn.classList.remove('is-active');
owner.materialList?.classList.remove('is-list');
owner.materialList?.classList.add('is-grid');
};
listBtn.onclick = () => {
owner.materialViewMode = 'list';
if (typeof localStorage !== 'undefined') localStorage.setItem('anomalous_material_view_mode', 'list');
listBtn.classList.add('is-active');
gridBtn.classList.remove('is-active');
owner.materialList?.classList.remove('is-grid');
owner.materialList?.classList.add('is-list');
};
// Prompts are assembled from these materials, and prompt notes are kept beside them.
const studioBtn = text(right, 'button', t('toolPromptStudioShort'), 'anomalous-material-topbar-btn');
studioBtn.type = 'button';
studioBtn.onclick = () => owner.openPromptStudio();
const notesBtn = text(right, 'button', t('toolPromptNotesShort'), 'anomalous-material-topbar-btn');
notesBtn.type = 'button';
notesBtn.onclick = () => owner.showNotebooks();
// 刷新按钮
const refreshBtn = text(right, 'button', '↻', 'anomalous-material-topbar-btn');
refreshBtn.type = 'button';
refreshBtn.title = t('refresh');
refreshBtn.onclick = () => owner.refreshMaterials();
}
function renderMaterialPagination(owner, payload) {
owner.materialPager.replaceChildren();
const previous = text(owner.materialPager, 'button', t('materialPrevious'), 'anomalous-btn-ghost');
previous.type = 'button';
previous.disabled = payload.page <= 1;
previous.onclick = () => owner.refreshMaterials(payload.page - 1);
text(owner.materialPager, 'span', t('materialPageSummary', { page: payload.page, pages: payload.pages, count: payload.total }));
const next = text(owner.materialPager, 'button', t('materialNext'), 'anomalous-btn-ghost');
next.type = 'button';
next.disabled = payload.page >= payload.pages;
next.onclick = () => owner.refreshMaterials(payload.page + 1);
if (owner.materialTagSelect) {
const tags = owner.materialTagSelect;
tags.replaceChildren();
text(tags, 'option', t('materialAllTags')).value = '';
for (const tag of payload.tags || []) text(tags, 'option', tag).value = tag;
if (owner.materialTag && !(payload.tags || []).includes(owner.materialTag)) text(tags, 'option', owner.materialTag).value = owner.materialTag;
tags.value = owner.materialTag || '';
}
}
function ensureMaterialGuideStyles() {
if (typeof document === 'undefined') return;
if (document.querySelector?.('#anomalous-material-guide-styles')) return;
const style = document.createElement('style');
style.id = 'anomalous-material-guide-styles';
style.textContent = `
.anomalous-material-empty-guide {
grid-column: 1 / -1;
max-width: 580px;
margin: 32px auto;
padding: 28px 24px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
text-align: center;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.anomalous-material-empty-icon {
width: 52px;
height: 52px;
border-radius: 50%;
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.25);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-bottom: 2px;
}
.anomalous-material-empty-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #f1f5f9;
letter-spacing: 0.2px;
}
.anomalous-material-empty-subtitle {
margin: 0;
font-size: 12.5px;
line-height: 1.55;
color: #94a3b8;
max-width: 440px;
}
.anomalous-material-empty-steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
width: 100%;
margin-top: 14px;
text-align: left;
}
.anomalous-material-empty-step {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
padding: 14px 14px;
display: flex;
flex-direction: column;
gap: 6px;
transition: background 0.15s ease, border-color 0.15s ease;
}
.anomalous-material-empty-step:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.12);
}
.anomalous-material-step-icon {
font-size: 20px;
line-height: 1;
margin-bottom: 2px;
}
.anomalous-material-empty-step strong {
font-size: 13px;
color: #e2e8f0;
font-weight: 600;
}
.anomalous-material-empty-step p {
margin: 0;
font-size: 11.5px;
line-height: 1.5;
color: #94a3b8;
}
.anomalous-material-card {
cursor: grab !important;
}
.anomalous-material-card:active {
cursor: grabbing !important;
}
`;
(document.head || document.body)?.appendChild(style);
}
function renderMaterialEmptyGuide(owner) {
const guide = text(owner.materialList, 'div', '', 'anomalous-material-empty-guide');
text(guide, 'div', '✨', 'anomalous-material-empty-icon');
text(guide, 'h3', t('materialEmptyGuideTitle') || '素材库暂无素材', 'anomalous-material-empty-title');
text(guide, 'p', t('materialEmptyGuideSubtitle') || '集中归档带有生成参数的图片与工作流片段,支持画布直接拖拽与参数复用。', 'anomalous-material-empty-subtitle');
const steps = text(guide, 'div', '', 'anomalous-material-empty-steps');
const step1 = text(steps, 'div', '', 'anomalous-material-empty-step');
text(step1, 'div', '📥', 'anomalous-material-step-icon');
text(step1, 'strong', t('materialEmptyStep1Title') || '1. 存入素材');
text(step1, 'p', t('materialEmptyStep1Desc') || '在「图库」图片详情中点击底部“存入素材库”,或在配方工坊中保存参数。');
const step2 = text(steps, 'div', '', 'anomalous-material-empty-step');
text(step2, 'div', '🎯', 'anomalous-material-step-icon');
text(step2, 'strong', t('materialEmptyStep2Title') || '2. 画布直接拖拽');
text(step2, 'p', t('materialEmptyStep2Desc') || '按住素材卡片拖到画布节点上覆盖参数,拖到空白处直接载入完整工作流。');
const step3 = text(steps, 'div', '', 'anomalous-material-empty-step');
text(step3, 'div', '🧩', 'anomalous-material-step-icon');
text(step3, 'strong', t('materialEmptyStep3Title') || '3. 提示词工坊拼装');
text(step3, 'p', t('materialEmptyStep3Desc') || '在「提示词工坊」中,已存素材中的提示词标签会自动提炼并同步,支持一键拼装。');
}
export async function refreshMaterials(page = this.materialPage || 1) {
if (!this.materialList) return;
this.materialListController?.abort();
const controller = new AbortController();
this.materialListController = controller;
this.materialList.replaceChildren();
text(this.materialList, 'p', t('loading'), 'anomalous-material-empty');
updateMaterialContext(this);
for (const [key, pill] of Object.entries(this.materialKindPills || {})) pill.classList.toggle('is-active', key === (this.materialKindCategory || 'all'));
const query = new URLSearchParams({
page,
limit: 48,
q: this.materialQuery || '',
tag: this.materialTag || '',
kind: this.materialKind || '',
category: this.materialKindCategory || 'all',
});
if (this.materialApplyMode && this.materialTarget) query.set('node_type', this.materialTarget.type);
try {
const response = await fetch(`/anomalous/materials?${query}`, { cache: 'no-store', signal: controller.signal });
const payload = await jsonResponse(response, 'material list failed');
if (this.materialListController !== controller) return;
this.materialPage = payload.page;
this.materialList.replaceChildren();
renderMaterialPagination(this, payload);
const materials = Array.isArray(payload.materials) ? payload.materials : [];
if (!materials.length) {
ensureMaterialGuideStyles();
if (this.materialQuery || this.materialTag || this.materialKind) {
text(this.materialList, 'p', t('materialNoMatches'), 'anomalous-material-empty');
} else {
renderMaterialEmptyGuide(this);
}
return;
}
const fragment = document.createDocumentFragment();
// Apply-mode cards carry an extra button row; the grid gives them a taller row.
this.materialList.classList.toggle('is-apply-mode', Boolean(this.materialApplyMode));
for (const material of materials) fragment.appendChild(renderMaterialCard(this, material));
this.materialList.appendChild(fragment);
} catch (error) {
if (error?.name === 'AbortError' || this.materialListController !== controller) return;
console.error('Could not load materials:', error);
this.materialList.replaceChildren();
text(this.materialList, 'p', t('materialLoadError'), 'anomalous-material-empty');
} finally {
if (this.materialListController === controller) this.materialListController = null;
}
}
export async function showMaterials() {
this.nbPanel.style.display = 'flex';
if (!this.materialContainer) {
this.materialContainer = text(this.nbPanel, 'div', '', 'anomalous-nb-container anomalous-material-container');
const header = text(this.materialContainer, 'div', '', 'anomalous-nb-header');
this.materialHeading = text(header, 'h2', t('materialLibrary'));
const close = text(header, 'button', '×', 'anomalous-btn-ghost');
close.setAttribute('aria-label', t('close'));
close.onclick = () => this.closeWorkspace();
}
this.materialHeading.textContent = t('materialLibrary');
this.materialContainer.style.display = 'flex';
ensureMaterialGuideStyles();
watchMaterialSelection(this, showMaterialDetail);
hideSiblingWorkspaceViews(this);
leaveMaterialDetail(this);
if (this.materialView && this.materialViewLocale !== resolveLocale()) {
this.materialView.remove();
this.materialView = null;
}
if (!this.materialView) {
this.materialViewLocale = resolveLocale();
this.materialView = document.createElement('div');
this.materialView.className = 'anomalous-material-body';
buildMaterialTopbar(this);
const mainArea = text(this.materialView, 'div', '', 'anomalous-material-main-area');
this.materialMainArea = mainArea;
const contentArea = text(mainArea, 'div', '', 'anomalous-material-content');
this.materialContentArea = contentArea;
this.materialContext = text(contentArea, 'div', '', 'anomalous-material-target-context');
const initialMode = this.materialViewMode || localStorage.getItem('anomalous_material_view_mode') || 'grid';
this.materialList = document.createElement('div');
this.materialList.className = `anomalous-material-list is-${initialMode}`;
contentArea.appendChild(this.materialList);
this.materialPager = text(contentArea, 'nav', '', 'anomalous-material-pager');
this.materialPager.setAttribute('aria-label', t('materialPagination'));
this.materialContainer.appendChild(this.materialView);
}
this.materialView.style.display = 'flex';
updateMaterialContext(this);
await this.refreshMaterials();
}
/**
* Direct client-side PNG chunk parser to safely extract embedded ComfyUI metadata
* without requiring a server reboot or server-side re-encoding.
*/
export async function showImageMaterialDetail(owner, sourceImage, imageUrl, options = {}) {
return showImageWorkbench(owner, sourceImage, imageUrl, options);
}
export async function openSavedMaterial(material) {
this.recipeDetailFinish?.('closed');
this.modal?.classList.add('visible');
if (this.nbPanel.style.display !== 'flex' && !this.workspaceReturnState) {
this.workspaceReturnState = Object.fromEntries([
['grid', this.grid], ['detail', this.detailPanel], ['gallery', this.galleryPanel],
['doctor', this.doctorPanel], ['assistant', this.assistantPanel],
].filter(([, panel]) => panel).map(([key, panel]) => [key, panel.style.display]));
}
this.nbPanel.style.display = 'flex';
for (const panel of [this.grid, this.detailPanel, this.galleryPanel, this.doctorPanel, this.assistantPanel, this.paramPanel]) {
if (panel) panel.style.display = 'none';
}
await showMaterials.call(this);
if (material) await showMaterialDetail(this, material);
}
export async function openMaterialLibrary() {
this.show?.();
const target = selectedMaterialNode(app);
this.materialApplyMode = !!target;
this.materialTarget = target;
if (this.materialApplyMode) { this.materialKind = ''; this.materialKindCategory = 'all'; }
await openSavedMaterial.call(this, null);
}