/**
* ui_sidebar.js
* The browser window (rail, list column, header, page panels) and the model folder list.
*/
import { markRemoteView } from './remote_view.js';
import { translate } from './locales.js';
import { showUpdateGuide } from './ui_update_guide.js';
import { AUDIO_USAGE_GUIDE, CURRENT_UPDATE_GUIDE } from './update_guide_data.js';
import { createSettingsHub } from './ui_settings_hub.js';
import { renderRailTools } from './ui_rail_tools.js';
import { getActiveDomain } from './ui_domain_switcher.js';
import { renderAudioSidebar } from './ui_audio_sidebar.js';
import { createGallerySearchBar } from './ui_gallery.js';
import { bindBrowserEscape } from './ui_browser_navigation.js';
import { createShellRail } from './ui_shell_rail.js';
import { installShellNavigation } from './ui_shell_nav.js';
import { bindShellDrag, bindShellMaximize, bindShellResize } from './ui_shell_frame.js';
import { watchScans } from './ui_scan_watch.js';
import { watchCanvasChanges } from './activity_canvas.js';
import { watchWorkflowLoads } from './ui_doctor_banner.js';
import { watchOpenedWorkflows } from './usage_notes.js';
import { settleModelScope } from './ui_model_types.js';
import { renderFolderColumn } from './ui_folder_list.js';
import { createModelSearch } from './ui_model_search.js';
import { attachModelDrop } from './ui_model_import.js';
const t = (key, params) => translate(key, params);
const SIDEBAR_ICONS = {
DOCK: ``,
LIST: ``,
SETTINGS: ``,
};
/** Five quick clicks on the rail's logo toggle the Abyssal Scarlet theme. */
function themeEasterEgg(badge) {
let clicks = 0;
let resetTimer = null;
return (e) => {
e.stopPropagation();
clicks++;
clearTimeout(resetTimer);
resetTimer = setTimeout(() => {
clicks = 0;
badge.removeAttribute('title');
}, 2500);
badge.style.transform = 'scale(0.92)';
setTimeout(() => { badge.style.transform = ''; }, 120);
if (clicks >= 5) {
clicks = 0;
badge.removeAttribute('title');
const active = document.documentElement.classList.contains('theme-abyssal-scarlet');
if (typeof window.setAbyssalScarletTheme === 'function') window.setAbyssalScarletTheme(!active, true);
} else if (clicks >= 2) {
badge.title = t('themeEasterEggHint', { count: clicks });
}
};
}
function headerButton(id, html, tooltip) {
const button = document.createElement('button');
button.type = 'button';
button.id = id;
button.className = 'anomalous-tooltip-target';
button.innerHTML = html; // static markup only
button.setAttribute('aria-label', tooltip);
button.setAttribute('data-tooltip', tooltip);
button.setAttribute('data-tooltip-pos', 'bottom');
return button;
}
function panel(id, display = 'none') {
const node = document.createElement('div');
node.id = id;
node.style.display = display;
return node;
}
export function createDOM() {
localStorage.removeItem('anomalous_api_key');
localStorage.removeItem('anomalous_civitai_api_key');
this.modal = document.createElement('div');
this.modal.id = 'anomalous-modal';
const container = document.createElement('div');
container.id = 'anomalous-container';
container.classList.add('anomalous-has-rail');
const updateLangClass = () => {
container.classList.toggle('anomalous-lang-en', (window.anomalous_browser_lang || 'zh') === 'en');
};
updateLangClass();
const savedScale = localStorage.getItem('anomalous_ui_scale') || 1;
container.style.setProperty('--anomalous-scale', savedScale);
const savedBgOpacity = localStorage.getItem('anomalous_bg_opacity') || '0.2';
container.style.setProperty('--anomalous-bg-opacity', savedBgOpacity);
const savedViewMode = localStorage.getItem('anomalous_view_mode') || 'compact';
container.classList.add(`view-mode-${savedViewMode}`);
// Rail: pages, tools, settings. ui_rail_tools.js fills its tool slots below.
const rail = createShellRail(this);
const brand = rail.root.querySelector('.anomalous-rail-brand');
brand.addEventListener('click', themeEasterEgg(brand));
this.modelsBtn = rail.items.get('models');
this.galleryBtn = rail.items.get('gallery');
this.nbBtn = rail.items.get('recipes');
this.sidebarActions = this.railTools; // tools used to sit under the list
// List column: the model folders, or the characters on the audio pages.
this.sidebarWrapper = panel('anomalous-sidebar-wrapper', '');
this.sidebar = panel('anomalous-sidebar', '');
this.sidebarWrapper.appendChild(this.sidebar);
// Content: header (list button, page title, guide, dock, close) and the page panels.
const content = panel('anomalous-content', '');
const header = panel('anomalous-header', '');
bindShellDrag(container, header);
const leftGroup = document.createElement('div');
leftGroup.className = 'anomalous-header-group anomalous-header-left';
const centerGroup = document.createElement('div');
centerGroup.className = 'anomalous-header-group anomalous-header-center';
const rightGroup = document.createElement('div');
rightGroup.className = 'anomalous-header-group anomalous-header-right';
const listToggle = headerButton('anomalous-list-toggle', SIDEBAR_ICONS.LIST, t('sidebarToggle'));
const pageTitle = document.createElement('span');
pageTitle.className = 'anomalous-page-title';
leftGroup.append(listToggle, pageTitle);
const updateNoticeBtn = headerButton('anomalous-update-notice-btn', '', t('updateGuideNoticeTooltip'));
updateNoticeBtn.classList.add('anomalous-update-notice-btn');
const updateNoticeIcon = document.createElement('span');
updateNoticeIcon.className = 'anomalous-update-notice-icon';
updateNoticeIcon.setAttribute('aria-hidden', 'true');
updateNoticeIcon.textContent = '!';
updateNoticeBtn.appendChild(updateNoticeIcon);
// On the audio pages "!" explains the audio studio; elsewhere it shows what's new.
updateNoticeBtn.onclick = () => showUpdateGuide(this, {
force: true,
guide: getActiveDomain() === 'audio' ? AUDIO_USAGE_GUIDE : CURRENT_UPDATE_GUIDE,
});
const dockBtn = headerButton('anomalous-dock-btn', SIDEBAR_ICONS.DOCK, t('dockTitle'));
if (localStorage.getItem('anomalous_docked') === 'true') container.classList.add('anomalous-docked');
const maxBtn = headerButton('anomalous-max-btn', '', t('maximizeTitle'));
const setMaximized = bindShellMaximize(container, header, maxBtn);
dockBtn.onclick = () => {
const docked = container.classList.toggle('anomalous-docked');
localStorage.setItem('anomalous_docked', docked ? 'true' : 'false');
if (docked) setMaximized(false);
};
const closeBtn = document.createElement('div');
closeBtn.id = 'anomalous-close';
closeBtn.innerHTML = '×';
closeBtn.onclick = () => this.close();
rightGroup.append(updateNoticeBtn, maxBtn, dockBtn, closeBtn);
centerGroup.append(createModelSearch(this));
header.append(leftGroup, centerGroup, rightGroup);
markRemoteView(container, leftGroup); // opened from another device: "view only"
this.grid = panel('anomalous-grid', '');
attachModelDrop(this, this.grid); // model files dropped on the models page are imported
this.detailPanel = panel('anomalous-detail');
this.galleryPanel = panel('anomalous-gallery-panel', '');
this.homePanel = panel('anomalous-home-panel');
this.activityPanel = panel('anomalous-activity-panel');
this.promptPanel = panel('anomalous-prompt-panel'); // Prompt Studio as a page (ui_prompt_composer.js)
this.scanPanel = panel('anomalous-scan-panel');
this.settingsPanel = panel('anomalous-settings-panel');
this.doctorPanel = panel('anomalous-doctor-panel');
this.assistantPanel = panel('anomalous-assistant-panel');
this.assistantPanel.style.flexDirection = 'column';
this.assistantPanel.style.flex = '1';
this.assistantPanel.style.overflowY = 'auto';
this.assistantPanel.style.boxSizing = 'border-box';
this.assistantPanelInitialized = false;
this.audioStudioPanel = panel('anomalous-audio-studio-panel');
this.audioStudioPanel.style.flex = '1';
this.audioStudioPanel.style.height = '100%';
this.audioStudioPanel.style.overflow = 'hidden';
this.audioStudioPanel.style.position = 'relative';
this.scriptPanel = panel('anomalous-script-panel');
this.audioGalleryPanel = panel('anomalous-audio-gallery-panel');
this.audioGalleryPanel.style.flex = '1';
this.audioGalleryPanel.style.height = '100%';
this.audioGalleryPanel.style.overflow = 'hidden';
this.galleryGrid = document.createElement('div');
this.galleryGrid.className = 'anomalous-gallery-grid';
this.galleryPanel.append(createGallerySearchBar(this), this.galleryGrid);
this.gallerySentinel = document.createElement('div');
this.gallerySentinel.className = 'anomalous-gallery-sentinel';
this.galleryGrid.appendChild(this.gallerySentinel);
this.galleryCurrentPage = 1;
this.galleryLoaded = false;
this.galleryLoading = false;
this.galleryHasMore = true;
if (typeof IntersectionObserver === 'function') {
this.galleryObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.galleryLoading && this.galleryHasMore) {
this.loadGalleryImages(this.galleryCurrentPage + 1);
}
}, { root: this.galleryGrid, rootMargin: '100px' });
this.galleryObserver.observe(this.gallerySentinel);
} else {
// Some embedded ComfyUI webviews do not expose IntersectionObserver.
// The gallery still opens and loads its first page; do not abort the
// whole extension setup when infinite scroll is unavailable.
this.galleryObserver = null;
}
// Workspaces (recipes, notes, materials) cover the page but not the rail or header.
this.nbPanel = document.createElement('div');
this.nbPanel.className = 'anomalous-nb-modal';
this.nbPanel.style.display = 'none';
this.nbPanel.onclick = (e) => {
if (e.target === this.nbPanel) this.closeWorkspace();
};
bindBrowserEscape(this);
content.append(header, this.grid, this.detailPanel, this.galleryPanel, this.homePanel, this.activityPanel, this.promptPanel, this.scanPanel, this.settingsPanel, this.doctorPanel,
this.assistantPanel, this.audioStudioPanel, this.scriptPanel, this.audioGalleryPanel);
container.append(rail.root, this.sidebarWrapper, content, this.nbPanel);
// The list column steps aside while a workspace is open. Several modules open it by
// setting its display, so the container gets a class for it; CSS matching the style
// attribute inside :has() makes every inline-style write on the page recalc all
// styles (issue #22, canvas at 2-3 fps).
const syncWorkspaceOpen = () => container.classList.toggle('anomalous-workspace-open', this.nbPanel.style.display === 'flex');
new MutationObserver(syncWorkspaceOpen).observe(this.nbPanel, { attributes: true, attributeFilter: ['style'] });
this.modal.appendChild(container);
installShellNavigation(this, { container, rail, listToggle, title: pageTitle });
watchCanvasChanges(this);
watchWorkflowLoads(this);
watchOpenedWorkflows();
const isScanning = watchScans(this);
const settingsHubControl = createSettingsHub(this, {
container,
savedScale,
savedBgOpacity,
updateLangClass,
dockBtn,
maxBtn,
updateNoticeBtn,
icons: SIDEBAR_ICONS,
});
this.renderRailTools = () => renderRailTools(this, { isScanning, settingsButton: settingsHubControl.button });
this.renderRailTools();
bindShellResize(container);
document.body.appendChild(this.modal);
}
export function renderSidebar() {
if (getActiveDomain() === 'audio') {
renderAudioSidebar(this);
return;
}
this.sidebar.replaceChildren(renderFolderColumn(this));
}
export async function loadFolders() {
try {
const res = await fetch('/anomalous/folders');
const data = await res.json();
this.foldersData = data.folders || [];
settleModelScope(this);
this.renderSidebar();
this.loadModels();
} catch (e) { }
}