dikdimon's picture
Upload sd-webui-tabs-extension using SD-Hub
2b0bc5f verified
// TabsExtension — организация вкладок расширений + поиск по вкладкам
// ECMAScript 2020+, A1111/Forge совместим
class TabsExtension {
/** @type {TabsExtensionConfigs} */
static #config;
/** Активные вкладки по режимам */
/** @type {Object.<string, string|null>} */
static #activeExtension = { "txt": null, "img": null };
/** Пары "чекбокс включения" <-> "кнопка вкладки" для подсветки active */
/** @type {Array.<[HTMLInputElement, HTMLButtonElement]>} */
static #enablePairs = [];
/** Таймер отложенного обновления подсветки active */
/** @type {number|null} */
static #refreshQueue = null;
/** @param {HTMLDivElement} extension @returns {HTMLInputElement|null} */
static #tryFindEnableToggle(extension) {
let temp = null;
for (const checkbox of extension.querySelectorAll('input[type=checkbox]')) {
const labelText = checkbox.parentNode?.querySelector('span')?.textContent?.toLowerCase();
if (labelText?.includes('enable')) return checkbox;
if (!temp && labelText?.includes('active')) temp = checkbox;
}
return temp;
}
/** Синхронизация active-подсветки кнопок с реальным состоянием чекбоксов */
static #refreshEnableCheckbox() {
if (this.#refreshQueue) clearTimeout(this.#refreshQueue);
this.#refreshQueue = window.setTimeout(() => {
for (const [toggle, button] of this.#enablePairs) {
if (toggle.checked) button.classList.add('active');
else button.classList.remove('active');
}
}, 250);
}
/**
* Сортировка расширений по конфигу, если включено
* @param {Object.<string, HTMLDivElement>} extensions
* @param {Object.<string, string>} configs
* @returns {Object.<string, HTMLDivElement>}
*/
static #sort_extensions(extensions, configs) {
if (!this.#config.sort) return extensions;
const sorted = {};
for (const key of Object.keys(configs)) {
if (Object.prototype.hasOwnProperty.call(extensions, key)) {
sorted[key] = extensions[key];
delete extensions[key];
}
}
for (const key of Object.keys(extensions)) sorted[key] = extensions[key];
return sorted;
}
/**
* Главная раскладка
* @param {'txt'|'img'} mode
* @param {Object.<string, HTMLDivElement>} extensions
* @param {Object.<string, string>} configs
* @returns {Object.<string, string>}
*/
static #setup_tabs(mode, extensions, configs) {
const container = {
'left': document.getElementById(`${mode}2img_script_container`),
'right': document.getElementById(`${mode}2img_results`)
};
/** @type {string} */ const mainSide = configs['tabs'];
/** @type {string} */ const oppSide = (mainSide === 'left') ? 'right' : 'left';
// Если вдруг контейнеров нет — выходим аккуратно
if (!container[mainSide] || !container[oppSide]) return configs;
// Заготовим области
const extensionContainers = {};
for (const side of ['above', 'left', 'below', 'right']) {
const div = document.createElement("div");
div.id = `tabs_ex_content-${mode}2img-${side}`;
div.style.overflow = "visible";
extensionContainers[side] = div;
}
const buttonContainer = document.createElement("div");
extensionContainers[mainSide].appendChild(buttonContainer);
buttonContainer.id = `tabs_ex_${mode}`;
container[mainSide].appendChild(extensionContainers['above']);
container[mainSide].appendChild(extensionContainers[mainSide]);
container[mainSide].appendChild(extensionContainers['below']);
container[oppSide].appendChild(extensionContainers[oppSide]);
/** Карта: ключ вкладки -> кнопка */
/** @type {Object.<string, HTMLButtonElement>} */
const allButtons = {};
// === Поиск по вкладкам (компактный) ===
const searchWrapper = document.createElement("div");
searchWrapper.style.display = "flex";
searchWrapper.style.justifyContent = "center";
searchWrapper.style.width = "100%";
searchWrapper.style.marginBottom = "6px";
const searchInput = document.createElement("input");
searchInput.type = "text";
searchInput.placeholder = "Filter extensions...";
searchInput.autocomplete = "off";
searchInput.style.width = "100%";
searchInput.style.maxWidth = "480px"; // чтобы не расползался как на скрине
searchInput.style.minWidth = "240px";
searchInput.style.padding = "6px 10px";
searchInput.style.borderRadius = "6px";
searchInput.style.border = "1px solid var(--block-border-color, #444)";
searchWrapper.appendChild(searchInput);
buttonContainer.appendChild(searchWrapper);
const self = this; // нужен доступ к #activeExtension внутри коллбэка
const applyFilter = (queryText) => {
const q = (queryText || "").trim().toLowerCase();
for (const [tabKey, btn] of Object.entries(allButtons)) {
const label = (btn.textContent || "").toLowerCase();
btn.style.display = (q.length === 0 || label.includes(q)) ? "" : "none";
}
// если активная вкладка скрыта фильтром — мягко гасим её контент
const active = self.#activeExtension[mode];
if (active && allButtons[active] && allButtons[active].style.display === "none") {
allButtons[active].classList.remove('selected');
if (extensions[active]) extensions[active].style.display = "none";
self.#activeExtension[mode] = null;
}
};
searchInput.addEventListener("input", (e) => applyFilter(e.target.value));
// Кнопки и раскладка
for (const tabKey of Object.keys(extensions)) {
if (!Object.prototype.hasOwnProperty.call(configs, tabKey)) {
configs[tabKey] = configs['default']; // новое расширение — дефолтная зона
}
const pos = configs[tabKey];
if (pos === "hide") continue;
if (pos === "above" || pos === "below") {
extensionContainers[pos].appendChild(extensions[tabKey]);
extensions[tabKey].style.display = "block";
continue;
}
// Подпись кнопки
const btnSpan = document.createElement('span');
btnSpan.className = 'tab_label';
const extensionName = (this.#config.version)
? tabKey
: extensions[tabKey].getAttribute("ext-label");
btnSpan.textContent = (!this.#config.forge)
? extensionName
: (extensionName || "").split('Integrated')[0].trim();
// Кнопка вкладки
const tabButton = document.createElement("button");
tabButton.classList.add('tab_button');
tabButton.appendChild(btnSpan);
buttonContainer.appendChild(tabButton);
allButtons[tabKey] = tabButton;
tabButton.addEventListener("click", (e) => {
// режим RMB: Ctrl/Cmd не перехватываем
if (!this.#config.rmb && (e.ctrlKey || e.metaKey)) return;
if (this.#activeExtension[mode] != null) {
allButtons[this.#activeExtension[mode]]?.classList.remove('selected');
const prev = this.#activeExtension[mode];
if (prev && extensions[prev]) extensions[prev].style.display = "none";
}
this.#activeExtension[mode] = (
(this.#config.toggle) && (this.#activeExtension[mode] === tabKey)
) ? null : tabKey;
if (this.#activeExtension[mode] != null) {
allButtons[this.#activeExtension[mode]]?.classList.add('selected');
extensions[this.#activeExtension[mode]].style.display = "block";
}
});
// Переносим содержимое вкладки в нужную сторону
extensionContainers[configs[tabKey]].appendChild(extensions[tabKey]);
// Особый случай: Scripts
if (tabKey === 'Scripts') {
const scriptsDropdown = extensions[tabKey].querySelector('input');
const observer = new MutationObserver(() => {
if (!scriptsDropdown) return;
if (scriptsDropdown.value !== 'None') allButtons['Scripts']?.classList.add('active');
else allButtons['Scripts']?.classList.remove('active');
});
observer.observe(extensions[tabKey], { childList: true, subtree: true });
if (this.#config.scripts_toggle) {
const btn = document.getElementById(`TABSEX_${mode}2img_s_toggle`);
if (btn) {
if (this.#config.rmb) {
allButtons[tabKey].addEventListener("contextmenu", (e) => {
e.preventDefault(); btn.click(); return false;
});
} else {
allButtons[tabKey].addEventListener("click", (e) => {
if (e.ctrlKey || e.metaKey) btn.click();
});
}
}
}
continue;
}
// Подсветка активных по чекбоксу Enable/Active
const enableToggle = this.#tryFindEnableToggle(extensions[tabKey]);
if (!enableToggle) continue;
if (this.#config.rmb) {
allButtons[tabKey].addEventListener("contextmenu", (e) => {
e.preventDefault(); enableToggle.click(); return false;
});
} else {
allButtons[tabKey].addEventListener("click", (e) => {
if (e.ctrlKey || e.metaKey) enableToggle.click();
});
}
if (enableToggle.checked) allButtons[tabKey].classList.add('active');
this.#enablePairs.push([enableToggle, allButtons[tabKey]]);
}
// Чистим пустые области
for (const side of ['above', 'left', 'below', 'right']) {
if (extensionContainers[side].children.length === 0) {
extensionContainers[side].remove();
}
}
if (this.#config.open) {
const firstBtn = Object.values(allButtons)[0];
if (firstBtn) firstBtn.click();
}
return configs;
}
/** Точка входа */
static init() {
this.#config = new TabsExtensionConfigs();
const configs = this.#config.parseConfigs();
const processedConfigs = {};
for (const mode of ['txt', 'img']) {
let extensions = null;
const keepExtensions = Object.keys(configs[mode])
.filter((ext) => configs[mode][ext] === "keep");
try {
const parsed = TabsExtensionParser.parse(mode, keepExtensions);
extensions = this.#sort_extensions(parsed, configs[mode]);
} catch (e) {
alert(`[TabsExtension] Something went wrong while parsing ${mode}2img extensions:\n${e}`);
continue;
}
try {
processedConfigs[mode] = this.#setup_tabs(mode, extensions, configs[mode]);
if (this.#config.container) {
const styler = document.getElementById(`${mode}2img_script_container`)
?.querySelector(".styler");
if (styler) styler.style.display = "none";
}
} catch (e) {
alert(`[TabsExtension] Something went wrong during ${mode}2img setup:\n${e}`);
continue;
}
}
try {
this.#config.saveConfigs(processedConfigs);
} catch (e) {
alert(`[TabsExtension] Something went wrong while saving configs:\n${e}`);
}
document.addEventListener("click", () => this.#refreshEnableCheckbox());
}
}
// Инициализация после загрузки UI
(function () {
onUiLoaded(() => {
const slider = document.getElementById("setting_tabs_ex_delay")
?.querySelector("input[type=range]");
const delay = parseInt(slider?.value ?? "50", 10);
setTimeout(() => {
if (typeof TabsExtension?.init === "function") TabsExtension.init();
}, Number.isNaN(delay) ? 50 : delay);
});
})();