With the AI workbench the rail had ten entries for nine number keys, so the audio gallery had none. It is now the third tab of Voices (Characters, Voice-over, Audio); 9 reopens Voices on the view used last, and a test keeps the rail within nine entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
294 lines
13 KiB
JavaScript
294 lines
13 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; the number keys 1-9 to the rail's
|
|
* pages while the browser is open (shell_open_rules.js).
|
|
*
|
|
* Pages: home, activity, ai (the AI workbench), models, gallery, prompts (Prompt Studio, which can also move beside the
|
|
* canvas), voices, script, audio-gallery (base pages); recipes, combos (workspaces over the base
|
|
* page, closed with `closeWorkspace()`); doctor, assistant, scan, settings (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';
|
|
import { renderAiWorkbench, watchAiTasks } from './ui_ai_workbench.js';
|
|
import { showPromptPage } from './ui_prompt_composer.js';
|
|
import { RAIL_PAGES } from './ui_shell_rail.js';
|
|
import { railPageForKey } from './shell_open_rules.js';
|
|
|
|
const LAST_PAGE_KEY = 'anomalous_last_page';
|
|
const REMEMBERED = new Set(['home', 'activity', 'ai', 'models', 'gallery', 'prompts', 'voices', 'script', 'audio-gallery']);
|
|
const AUDIO_PAGES = new Set(['voices', 'script', 'audio-gallery']);
|
|
// The audio tab (browser.switchAudioTab) of each audio page.
|
|
const AUDIO_TABS = { voices: 'presets', script: 'script', 'audio-gallery': 'gallery' };
|
|
// Voice-over and Audio are views of the Voices page: same rail entry, same list column.
|
|
const RAIL_OF = { script: 'voices', 'audio-gallery': 'voices' };
|
|
const railOf = page => RAIL_OF[page] || page;
|
|
// 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',
|
|
script: 'anomalous_audio_list_closed',
|
|
'audio-gallery': 'anomalous_audio_list_closed',
|
|
};
|
|
const TITLE_KEYS = {
|
|
home: 'shellHome', activity: 'activityTitle', ai: 'shellAi', models: 'shellTitleModels', gallery: 'gallery', prompts: 'shellPrompts', recipes: 'recipeTitle',
|
|
combos: 'shellCombos', voices: 'shellVoices', script: 'shellVoices', 'audio-gallery': 'shellVoices',
|
|
doctor: 'sidebarDoctor', assistant: 'sidebarAssistant', scan: 'scanPageTitle', settings: 'sidebarSettings',
|
|
};
|
|
// 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;
|
|
let lastVoiceView = 'voices'; // the rail's Voices entry reopens the view used last
|
|
const narrow = () => container.clientWidth > 0 && container.clientWidth < NARROW_PX;
|
|
|
|
const setTitle = (page) => {
|
|
title.textContent = page ? t(TITLE_KEYS[page]) : '';
|
|
container.dataset.page = page || ''; // what the header shows per page (the models search)
|
|
};
|
|
|
|
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.closest('.anomalous-folder-toggle, .anomalous-folder-open')) 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();
|
|
});
|
|
|
|
// 1-9: the rail's pages from the top, while the browser is open and no dialog is over it.
|
|
// ComfyUI binds no plain digits; a number typed into a box stays in the box.
|
|
const dialogOpen = () => [...document.querySelectorAll('[aria-modal="true"], [role="dialog"]')]
|
|
.some(node => node.getClientRects().length > 0);
|
|
window.addEventListener('keydown', (e) => {
|
|
if (!owner.modal?.classList.contains('visible')) return;
|
|
const page = railPageForKey(e, RAIL_PAGES);
|
|
if (!page || dialogOpen()) return;
|
|
e.preventDefault();
|
|
owner.goTo(page === 'voices' ? lastVoiceView : page);
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
// The side the list column was last drawn for. The stored side survives a reload, so it
|
|
// cannot tell whether the list was drawn yet (opening straight onto an audio page left it empty).
|
|
let listDomain = null;
|
|
|
|
/** Switches the domain the list column and the "!" guide follow; loads the model list once. */
|
|
const useDomain = (domain) => {
|
|
if (getActiveDomain() !== domain) setActiveDomain(domain);
|
|
const changed = listDomain !== domain;
|
|
listDomain = 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 (railOf(page) === 'voices') lastVoiceView = page;
|
|
if (REMEMBERED.has(page)) write(LAST_PAGE_KEY, page);
|
|
rail.setActive(railOf(page));
|
|
setTitle(page);
|
|
};
|
|
|
|
const showModels = () => {
|
|
owner.grid.style.display = 'grid';
|
|
// Emptied after the browser sat closed (browser.js close()); reopened onto another page, nothing redrew it.
|
|
if (!owner.grid.childElementCount && owner.foldersData) owner.loadModels();
|
|
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], ['ai', owner.aiPanel], ['scan', owner.scanPanel], ['settings', owner.settingsPanel],
|
|
['audioStudio', owner.audioStudioPanel], ['script', owner.scriptPanel], ['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 && railOf(page) === railOf(current) && !inWorkspace && atRoot && LIST_KEYS[page]) {
|
|
toggleList();
|
|
return;
|
|
}
|
|
if (fromRail && page === 'voices') page = lastVoiceView;
|
|
if (page === 'recipes' || page === 'combos') {
|
|
// 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 owner.showNotebooks();
|
|
return;
|
|
}
|
|
if (inWorkspace) owner.closeWorkspace();
|
|
enter(page);
|
|
if (AUDIO_PAGES.has(page)) {
|
|
useDomain('audio');
|
|
applyList(page);
|
|
owner.switchAudioTab(AUDIO_TABS[page]);
|
|
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 === 'ai') {
|
|
owner.aiPanel.style.display = 'flex';
|
|
renderAiWorkbench(owner, owner.aiPanel);
|
|
} else if (page === 'gallery') {
|
|
showGallery();
|
|
} else if (page === 'prompts') {
|
|
showPromptPage(owner);
|
|
} 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');
|
|
};
|
|
|
|
// A workspace opened another way (a combo from elsewhere) marks the rail and title.
|
|
owner.markWorkspace = (page) => {
|
|
rail.setActive(page);
|
|
setTitle(page);
|
|
};
|
|
|
|
// 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) ? railOf(current) : null);
|
|
setTitle(current);
|
|
if (current === 'prompts') owner.goTo('prompts'); // its studio stopped when the workspace opened
|
|
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;
|
|
// A dot on a rail entry: something new there (the AI workbench while the AI works elsewhere).
|
|
owner.railNews = (page, on) => rail.items.get(page)?.classList.toggle('has-news', Boolean(on));
|
|
watchAiTasks(owner);
|
|
// Pages reached another way (the audio list's own entries) still mark the rail and title.
|
|
owner.markShellPage = enter;
|
|
}
|