A bar over the canvas appears when an opened workflow uses models this computer does not have: how many, and how many are here under another name or folder (same hash), with one press to put those in. The doctor page is redone on the scan page's layout: what needs you first, why in plain words, and a button per row (put in, use a same-size file, pick one, look it up on Civitai); models that are fine fold away. - model_check.js: the check (no DOM, changes nothing) and the fixes, which call the node's widget hooks so ComfyUI drops its red mark; the node picker does the same now. - Opening the doctor no longer rewrites the workflow by itself. - Removed: the auto-detect switch (nothing read it), the doctor's own deep hash scan and its routes (the resolver hashes same-size files on demand; the page points to the scan page), the hash dialog, the old resolver in hash_resolver.js, the unused pick-for-node path in ui_detail.js, 45 old strings. - Node Assistant's diagnoseNode and LoRA insertion moved to ui_node_assistant.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
242 lines
10 KiB
JavaScript
242 lines
10 KiB
JavaScript
/**
|
|
* Page navigation for the shell: `owner.goTo(page)` for the rail, the home cards and the
|
|
* domain switch; which page shows its list column (and whether that list is open); the
|
|
* page title in the header; the page reopened next time.
|
|
*
|
|
* Pages: home, models, gallery, voices, audio-gallery (base pages); recipes, materials
|
|
* (workspaces over the base page, closed with `closeWorkspace()`); doctor, assistant
|
|
* (tool pages, entered through `owner.enterToolPage`).
|
|
*/
|
|
|
|
import { translate as t } from './locales.js';
|
|
import { getActiveDomain, setActiveDomain } from './ui_domain_switcher.js';
|
|
import { renderHome } from './ui_home.js';
|
|
import { renderActivityPage } from './ui_activity.js';
|
|
|
|
const LAST_PAGE_KEY = 'anomalous_last_page';
|
|
const REMEMBERED = new Set(['home', 'activity', 'models', 'gallery', 'voices', 'audio-gallery']);
|
|
const AUDIO_PAGES = new Set(['voices', 'audio-gallery']);
|
|
// Pages with a list column, and where each remembers whether you closed it.
|
|
const LIST_KEYS = {
|
|
models: 'anomalous_user_sidebar_closed',
|
|
voices: 'anomalous_audio_list_closed',
|
|
'audio-gallery': 'anomalous_audio_list_closed',
|
|
};
|
|
const TITLE_KEYS = {
|
|
home: 'shellHome', activity: 'activityTitle', models: 'shellTitleModels', gallery: 'gallery', recipes: 'recipeTitle',
|
|
materials: 'materialLibrary', voices: 'shellVoices', 'audio-gallery': 'shellAudioGallery',
|
|
doctor: 'sidebarDoctor', assistant: 'sidebarAssistant', scan: 'scanPageTitle',
|
|
};
|
|
// Below this width the list covers the page instead of sitting beside it, and starts closed.
|
|
const NARROW_PX = 760;
|
|
|
|
const read = (key) => {
|
|
try { return localStorage.getItem(key); } catch (_) { return null; }
|
|
};
|
|
const write = (key, value) => {
|
|
try { localStorage.setItem(key, value); } catch (_) { /* a per-viewer convenience only */ }
|
|
};
|
|
|
|
/** The page to open first: the last one used, else home. */
|
|
export function startPage() {
|
|
const saved = read(LAST_PAGE_KEY);
|
|
return REMEMBERED.has(saved) ? saved : 'home';
|
|
}
|
|
|
|
/**
|
|
* Wires navigation onto `owner`. `rail`: from createShellRail; `listToggle`: the header's
|
|
* list button; `title`: the header's page title element.
|
|
*/
|
|
export function installShellNavigation(owner, { container, rail, listToggle, title }) {
|
|
let current = null;
|
|
const narrow = () => container.clientWidth > 0 && container.clientWidth < NARROW_PX;
|
|
|
|
const setTitle = (page) => { title.textContent = page ? t(TITLE_KEYS[page]) : ''; };
|
|
|
|
const applyList = (page) => {
|
|
const key = LIST_KEYS[page];
|
|
listToggle.disabled = !key;
|
|
container.classList.toggle('anomalous-sidebar-closed', !key || narrow() || read(key) === 'true');
|
|
};
|
|
|
|
// Narrow window: the open list is a drawer over the page. Picking an entry, pressing the
|
|
// dimmed page or Esc closes it, without changing what the page remembers.
|
|
const drawerOpen = () => narrow() && !container.classList.contains('anomalous-sidebar-closed');
|
|
const closeDrawer = () => container.classList.add('anomalous-sidebar-closed');
|
|
const scrim = document.createElement('div');
|
|
scrim.className = 'anomalous-list-scrim';
|
|
scrim.onclick = closeDrawer;
|
|
container.insertBefore(scrim, owner.sidebarWrapper.nextSibling);
|
|
owner.sidebarWrapper.addEventListener('click', (e) => {
|
|
if (!drawerOpen() || e.target.classList.contains('anomalous-folder-toggle')) return;
|
|
if (e.target.closest('.anomalous-folder-item, .anomalous-audio-nav-item')) closeDrawer();
|
|
});
|
|
window.addEventListener('keydown', (e) => {
|
|
if (e.key !== 'Escape' || !drawerOpen() || !owner.modal?.classList.contains('visible')) return;
|
|
if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
|
|
closeDrawer();
|
|
});
|
|
|
|
const toggleList = () => {
|
|
if (listToggle.disabled) return;
|
|
const closed = !container.classList.contains('anomalous-sidebar-closed');
|
|
container.classList.toggle('anomalous-sidebar-closed', closed);
|
|
if (!narrow() && LIST_KEYS[current]) write(LIST_KEYS[current], String(closed));
|
|
};
|
|
listToggle.onclick = toggleList;
|
|
|
|
// Crossing the narrow width re-decides the list: closed when narrow, as remembered when wide.
|
|
let wasNarrow = null;
|
|
if (typeof ResizeObserver === 'function') {
|
|
new ResizeObserver(() => {
|
|
const now = narrow();
|
|
container.classList.toggle('anomalous-shell-narrow', now);
|
|
if (now !== wasNarrow && current) applyList(current);
|
|
wasNarrow = now;
|
|
}).observe(container);
|
|
}
|
|
|
|
/** Switches the domain the list column and the "!" guide follow; loads the model list once. */
|
|
const useDomain = (domain) => {
|
|
const changed = getActiveDomain() !== domain;
|
|
if (changed) setActiveDomain(domain);
|
|
if (domain === 'audio') {
|
|
if (changed) owner.renderSidebar();
|
|
return;
|
|
}
|
|
if (!owner.foldersData) owner.loadFolders();
|
|
else if (changed) {
|
|
owner.renderSidebar();
|
|
owner.loadModels();
|
|
}
|
|
};
|
|
|
|
const enter = (page) => {
|
|
current = page;
|
|
if (REMEMBERED.has(page)) write(LAST_PAGE_KEY, page);
|
|
rail.setActive(page);
|
|
setTitle(page);
|
|
};
|
|
|
|
const showModels = () => {
|
|
owner.grid.style.display = 'grid';
|
|
if (owner.detailPanel.innerHTML !== '') {
|
|
owner.stopMediaInContainer(owner.detailPanel);
|
|
owner.detailPanel.innerHTML = '';
|
|
owner.currentDetailModel = null;
|
|
owner.historyStack = [];
|
|
}
|
|
};
|
|
|
|
const showGallery = () => {
|
|
owner.gallerySelectModel = null;
|
|
owner.galleryPanel.classList.remove('is-cover-selecting');
|
|
const selectBanner = document.getElementById('anomalous-gallery-select-banner');
|
|
if (selectBanner) selectBanner.style.display = 'none';
|
|
owner.galleryPanel.style.display = 'flex';
|
|
void owner.refreshGalleryImages();
|
|
};
|
|
|
|
/** The recipe workspace over the current page; closing it comes back here. */
|
|
const openRecipes = () => {
|
|
owner.recipeSelectedTags = new Set();
|
|
if (typeof owner.recipeModelReturn !== 'function') {
|
|
owner.workspaceReturnState = Object.fromEntries([
|
|
['grid', owner.grid], ['detail', owner.detailPanel], ['gallery', owner.galleryPanel],
|
|
['doctor', owner.doctorPanel], ['assistant', owner.assistantPanel], ['home', owner.homePanel],
|
|
['activity', owner.activityPanel], ['scan', owner.scanPanel],
|
|
['audioStudio', owner.audioStudioPanel], ['audioGallery', owner.audioGalleryPanel],
|
|
].filter(([, panel]) => panel).map(([key, panel]) => [key, panel.style.display || 'none']));
|
|
} else if (!owner.workspaceReturnState) {
|
|
owner.workspaceReturnState = { grid: 'grid' };
|
|
}
|
|
owner.hideAllPanels();
|
|
owner.nbPanel.style.display = 'flex';
|
|
owner.showRecipes();
|
|
};
|
|
|
|
owner.goTo = (page, { fromRail = false } = {}) => {
|
|
const inWorkspace = owner.nbPanel?.style.display === 'flex';
|
|
// The rail entry of the page you are on opens or closes its list; from a model's
|
|
// detail it goes back to the grid instead.
|
|
const atRoot = page !== 'models' || owner.grid.style.display !== 'none';
|
|
if (fromRail && page === current && !inWorkspace && atRoot && LIST_KEYS[page]) {
|
|
toggleList();
|
|
return;
|
|
}
|
|
if (page === 'recipes' || page === 'materials') {
|
|
// Workspaces belong to the image side: from an audio page they open over the models.
|
|
if (AUDIO_PAGES.has(current)) owner.goTo('models');
|
|
rail.setActive(page);
|
|
setTitle(page);
|
|
if (page === 'recipes') openRecipes();
|
|
else void Promise.resolve(owner.openMaterialLibrary()).then(() => rail.setActive('materials'));
|
|
return;
|
|
}
|
|
if (inWorkspace) owner.closeWorkspace();
|
|
enter(page);
|
|
if (AUDIO_PAGES.has(page)) {
|
|
useDomain('audio');
|
|
applyList(page);
|
|
owner.switchAudioTab(page === 'voices' ? 'presets' : 'gallery');
|
|
return;
|
|
}
|
|
useDomain('visual');
|
|
owner.hideAllPanels();
|
|
applyList(page);
|
|
if (page === 'home') {
|
|
owner.homePanel.style.display = 'flex';
|
|
renderHome(owner, owner.homePanel);
|
|
} else if (page === 'activity') {
|
|
owner.activityPanel.style.display = 'flex';
|
|
renderActivityPage(owner, owner.activityPanel);
|
|
} else if (page === 'gallery') {
|
|
showGallery();
|
|
} else {
|
|
showModels();
|
|
}
|
|
};
|
|
|
|
/** Scan, doctor and assistant: image-side pages without a rail entry, opened from the tools. */
|
|
owner.enterToolPage = (page) => {
|
|
if (owner.nbPanel?.style.display === 'flex') owner.closeWorkspace();
|
|
useDomain('visual');
|
|
current = page;
|
|
rail.setActive(null);
|
|
setTitle(page);
|
|
applyList(page);
|
|
if (page === 'assistant') container.classList.add('anomalous-sidebar-closed');
|
|
};
|
|
|
|
// Other modules mark tabs through these; they map onto the rail now.
|
|
owner.setActiveHeaderTab = (button) => rail.setActive(button?.dataset?.page ?? null);
|
|
owner.updateHeaderTabs = () => {};
|
|
|
|
// Closing a workspace goes back to the page under it.
|
|
const closeWorkspace = owner.closeWorkspace;
|
|
owner.closeWorkspace = function (...args) {
|
|
const result = closeWorkspace.apply(this, args);
|
|
rail.setActive(REMEMBERED.has(current) ? current : null);
|
|
setTitle(current);
|
|
return result;
|
|
};
|
|
|
|
owner.refreshShellLanguage = () => {
|
|
rail.refreshLanguage();
|
|
setTitle(current);
|
|
listToggle.setAttribute('aria-label', t('sidebarToggle'));
|
|
listToggle.dataset.tooltip = t('sidebarToggle');
|
|
owner.onActivityRecorded();
|
|
};
|
|
|
|
// A new entry shows at once on the pages that list entries.
|
|
owner.onActivityRecorded = () => {
|
|
if (current === 'home' && owner.homePanel.style.display !== 'none') renderHome(owner, owner.homePanel);
|
|
if (current === 'activity' && owner.activityPanel.style.display !== 'none') renderActivityPage(owner, owner.activityPanel);
|
|
};
|
|
|
|
owner.currentShellPage = () => current;
|
|
// Pages reached another way (the audio list's own entries) still mark the rail and title.
|
|
owner.markShellPage = enter;
|
|
}
|