/** 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 = ``; 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 = ``; const listBtn = text(viewSwitch, 'button', '', `anomalous-material-view-btn${currentMode === 'list' ? ' is-active' : ''}`); listBtn.type = 'button'; listBtn.title = t('materialViewList') || '列表视图'; listBtn.innerHTML = ``; 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'); }; // 刷新按钮 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(); 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); }