diff --git a/CLAUDE.md b/CLAUDE.md index 4a54421..62a7275 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co This is a Stable Diffusion WebUI extension that preserves UI parameters (inputs, sliders, checkboxes, etc.) after page reload. It uses localStorage for persistence and supports multiple SD extensions (ControlNet, ADetailer, Dynamic Prompting, Multidiffusion/Tiled VAE). +**Compatible with:** +- AUTOMATIC1111 Stable Diffusion WebUI +- Stable Diffusion WebUI Forge +- Gradio 3.x and 4.x + ## Development **No build system** - This is a pure extension with vanilla JavaScript and Python. Files are loaded directly by the parent Stable Diffusion WebUI. @@ -33,6 +38,15 @@ state.ext.*.js → Extension-specific handlers (ControlNet, ADetailer, et - All code lives under `window.state` namespace (`state.core`, `state.store`, `state.utils`, `state.extensions`) - Extension plugins register via `state.extensions[name] = { init: function() {} }` +**Gradio Compatibility Utilities** (`state.utils`): +- `state.utils.gradio.detectVersion()` - Detects Gradio 3.x vs 4.x +- `state.utils.getButtonClass()` - Returns appropriate button CSS classes +- `state.utils.findDropdowns(container)` - Finds dropdowns with fallback selectors +- `state.utils.findAccordions(container)` - Finds accordions with fallback selectors +- `state.utils.getDropdownValue(select)` - Gets dropdown value (works with both Gradio versions) +- `state.utils.getMultiSelectValues(select)` - Gets multi-select values +- `state.utils.isAccordionOpen(accordion)` - Checks accordion state with multiple methods + ### Backend (Python) ``` diff --git a/javascript/state.core.js b/javascript/state.core.js index 5e445f2..4da2608 100644 --- a/javascript/state.core.js +++ b/javascript/state.core.js @@ -14,6 +14,7 @@ state.core = (function () { 'hires_resize_x': 'hr_resize_x', 'hires_resize_y': 'hr_resize_y', 'hires_denoising_strength': 'denoising_strength', + 'hires_cfg_scale': 'hr_cfg', 'refiner_switch': 'switch_at', 'upscaler_2_visibility': 'extras_upscaler_2_visibility', 'upscaler_scale_by_resize': 'extras_upscaling_resize', @@ -225,13 +226,47 @@ state.core = (function () { } } + // Get tab buttons with multiple fallback selectors for compatibility + function getTabButtons() { + var root = gradioApp(); + // Try multiple selectors for compatibility with different Gradio versions + var tabs = root.querySelectorAll('#tabs > div:first-child button'); + if (!tabs.length) { + tabs = root.querySelectorAll('#tabs .tab-nav button'); + } + if (!tabs.length) { + tabs = root.querySelectorAll('#tabs > .tabs > .tab-nav button'); + } + if (!tabs.length) { + // Gradio 4.x may have different structure + tabs = root.querySelectorAll('#tabs button[role="tab"]'); + } + if (!tabs.length) { + tabs = root.querySelectorAll('#tabs > div > button'); + } + return tabs; + } + + // Get selected tab button with fallback + function getSelectedTabButton() { + var root = gradioApp(); + var selected = root.querySelector('#tabs .tab-nav button.selected'); + if (!selected) { + selected = root.querySelector('#tabs button.selected'); + } + if (!selected) { + selected = root.querySelector('#tabs button[aria-selected="true"]'); + } + return selected; + } + function restoreTabs(config) { if (! config.hasSetting('tabs')) { return; } - const tabs = gradioApp().querySelectorAll('#tabs > div:first-child button'); + const tabs = getTabButtons(); const value = store.get('tab'); if (value) { @@ -244,20 +279,23 @@ state.core = (function () { } // Use this when onUiTabChange is fixed // onUiTabChange(function () { - // store.set('tab', gradioApp().querySelector('#tabs .tab-nav button.selected').textContent); + // store.set('tab', getSelectedTabButton()?.textContent); // }); bindTabClickEvents(); } function bindTabClickEvents() { - Array.from(gradioApp().querySelectorAll('#tabs .tab-nav button')).forEach(tab => { + Array.from(getTabButtons()).forEach(tab => { tab.removeEventListener('click', storeTab); tab.addEventListener('click', storeTab); }); } function storeTab() { - store.set('tab', gradioApp().querySelector('#tabs .tab-nav button.selected').textContent); + var selected = getSelectedTabButton(); + if (selected) { + store.set('tab', selected.textContent); + } bindTabClickEvents(); // dirty hack here... } @@ -288,6 +326,12 @@ state.core = (function () { return; } + // Convert to array for easier handling + elements = Array.from(elements); + + // For sliders with both number input and range, prefer number input for storage + let primaryElement = elements.find(el => el.type === 'number') || elements[0]; + let forEach = function (action) { events.forEach(function(event) { elements.forEach(function (element) { @@ -296,6 +340,7 @@ state.core = (function () { }); }; + // Attach event listeners to all elements forEach(function (event) { this.addEventListener(event, function () { let value = this.value; @@ -306,17 +351,23 @@ state.core = (function () { }); }); - TABS.forEach(tab => { - const seedInput = gradioApp().querySelector(`#${tab}_seed input`); - ['random_seed', 'reuse_seed'].forEach(id => { - const btn = gradioApp().querySelector(`#${tab}_${id}`); - btn.addEventListener('click', () => { - setTimeout(() => { - state.utils.triggerEvent(seedInput, 'change'); - }, 100); + // Special handling for seed buttons + if (id.indexOf('seed') > -1) { + TABS.forEach(tab => { + const seedInput = gradioApp().querySelector(`#${tab}_seed input[type="number"]`); + if (!seedInput) return; + ['random_seed', 'reuse_seed'].forEach(btnId => { + const btn = gradioApp().querySelector(`#${tab}_${btnId}`); + if (btn) { + btn.addEventListener('click', () => { + setTimeout(() => { + state.utils.triggerEvent(seedInput, 'change'); + }, 100); + }); + } }); }); - }); + } let value = store.get(id); @@ -324,9 +375,67 @@ state.core = (function () { return; } - forEach(function (event) { - state.utils.setValue(this, value, event); - }); + // Delay restoration to ensure UI and updateInput are ready + // Use longer delay to ensure Gradio components are fully initialized + setTimeout(function() { + // Check if updateInput is available (it should be by now) + if (typeof updateInput !== 'function') { + state.logging.warn('updateInput not available yet, retrying...'); + setTimeout(function() { + restoreInputValue(id, elements, value); + }, 500); + } else { + restoreInputValue(id, elements, value); + } + }, 1000); + } + + function restoreInputValue(id, elements, value) { + state.logging.log(`Attempting to restore ${id} with value: ${value}`); + + // Check element types + let numberInput = elements.find(el => el.type === 'number'); + let rangeInput = elements.find(el => el.type === 'range'); + let textareaInput = elements.find(el => el.tagName === 'TEXTAREA'); + let textInput = elements.find(el => el.type === 'text'); + + state.logging.log(`Found elements for ${id}: number=${!!numberInput}, range=${!!rangeInput}, textarea=${!!textareaInput}, text=${!!textInput}`); + + // For sliders (have both number and range), use special handler + if (numberInput && rangeInput) { + // Get the container element + let container = numberInput.closest('.gradio-slider, [class*="slider"]') || + numberInput.parentElement.parentElement; + state.logging.log(`Updating slider container for ${id}`, container); + state.utils.updateGradioSlider(container, value); + + // Verify the value was set + setTimeout(function() { + state.logging.log(`After restore - ${id} number value: ${numberInput.value}, range value: ${rangeInput.value}`); + }, 100); + } else if (numberInput) { + // Just number input + state.utils.setValue(numberInput, value, 'input'); + state.logging.log(`Restored number ${id}: ${value}`); + } else if (textareaInput) { + // For textareas (prompts) + state.utils.setValue(textareaInput, value, 'input'); + state.logging.log(`Restored textarea ${id}: ${value}`); + } else if (textInput) { + // For text inputs + state.utils.setValue(textInput, value, 'input'); + state.logging.log(`Restored text ${id}: ${value}`); + } else if (rangeInput) { + // Fallback to range input + state.utils.setValue(rangeInput, value, 'input'); + state.logging.log(`Restored range ${id}: ${value}`); + } else { + // For any other elements, update all + state.logging.log(`Restoring ${id} to all ${elements.length} elements`); + elements.forEach(function (element) { + state.utils.setValue(element, value, 'input'); + }); + } } function handleSavedSelects(id, duplicateIds) { @@ -353,15 +462,29 @@ state.core = (function () { let selector = duplicateIds ? `[id="${id}"]` : `#${id}`; - elements = gradioApp().querySelectorAll(`.input-accordion${selector}>.label-wrap`); + // Try multiple selector patterns for compatibility + var elements = gradioApp().querySelectorAll(`.input-accordion${selector}>.label-wrap`); + if (!elements.length) { + elements = gradioApp().querySelectorAll(`.input-accordion${selector} .label-wrap`); + } + if (!elements.length) { + elements = gradioApp().querySelectorAll(`${selector} > .label-wrap`); + } + if (!elements.length) { + elements = gradioApp().querySelectorAll(`${selector}.input-accordion > .label-wrap`); + } elements.forEach(function (element) { if (store.get(id) === 'true') { state.utils.clickToggleMenu(element); } element.addEventListener('click', function () { - let classList = Array.from(this.parentNode.classList); - store.set(id, classList.indexOf('input-accordion-open') > -1); + var parent = this.parentNode; + // Check for open state using multiple methods for compatibility + var isOpen = parent.classList.contains('input-accordion-open') || + this.classList.contains('open') || + state.utils.isAccordionOpen(parent); + store.set(id, isOpen); }); }); } diff --git a/javascript/state.ext.adetailer.js b/javascript/state.ext.adetailer.js index 3769062..2b8b28b 100644 --- a/javascript/state.ext.adetailer.js +++ b/javascript/state.ext.adetailer.js @@ -9,7 +9,14 @@ state.extensions['adetailer'] = (function () { let cnTabs = []; function bindTabEvents() { - const tabs = container.querySelectorAll('.tabs > div > button'); + // Try multiple selectors for compatibility + let tabs = container.querySelectorAll('.tabs > div > button'); + if (!tabs.length) { + tabs = container.querySelectorAll('.tabs .tab-nav button'); + } + if (!tabs.length) { + tabs = container.querySelectorAll('button[role="tab"]'); + } tabs.forEach(tab => { // dirty hack here tab.removeEventListener('click', onTabClick); tab.addEventListener('click', onTabClick); @@ -36,6 +43,8 @@ state.extensions['adetailer'] = (function () { } function handleCheckbox(checkbox, id) { + if (!checkbox) return; + let value = store.get(id); if (value) { state.utils.setValue(checkbox, value, 'change'); @@ -81,7 +90,8 @@ state.extensions['adetailer'] = (function () { } function handleSelects(container, container_idx) { - let selects = container.querySelectorAll('.gradio-dropdown') + // Use compatibility helper to find dropdowns + let selects = state.utils.findDropdowns(container); selects.forEach(function (select, idx) { state.utils.handleSelect(select, `ad-tab-${container_idx}-select-${idx}`, store); }); @@ -107,28 +117,49 @@ state.extensions['adetailer'] = (function () { } function handleDropdown(dropdown, id) { + if (!dropdown) return; + let value = store.get(id); if (value && value === 'true') { state.utils.triggerEvent(dropdown, 'click'); } dropdown.addEventListener('click', function () { - let span = this.querySelector('.transition, .icon'); - store.set(id, span.style.transform !== 'rotate(90deg)'); + // Use multiple methods to check open state for compatibility + let isOpen = this.classList.contains('open') || + this.parentNode.classList.contains('open') || + state.utils.isAccordionOpen(this.parentNode); + store.set(id, isOpen); }); } function handleDropdowns(container, container_idx) { - let dropdowns = container.querySelectorAll('.gradio-accordion .label-wrap'); - dropdowns.forEach(function (dropdown, idx) { - handleDropdown(dropdown, `ad-tab-${container_idx}-dropdown-${idx}`); + // Use compatibility helper to find accordions + let accordions = state.utils.findAccordions(container); + accordions.forEach(function (accordion, idx) { + let labelWrap = accordion.querySelector('.label-wrap'); + if (labelWrap) { + handleDropdown(labelWrap, `ad-tab-${container_idx}-dropdown-${idx}`); + } }); } function load() { setTimeout(function () { - handleDropdown(container.querySelector('#script_txt2img_adetailer_ad_main_accordion > .label-wrap'), 'ad-dropdown-main'); - handleCheckbox(container.querySelector('#script_txt2img_adetailer_ad_enable > label > input'), 'ad-checkbox-enable'); + // Try multiple selectors for the main accordion + let mainAccordion = container.querySelector('#script_txt2img_adetailer_ad_main_accordion > .label-wrap'); + if (!mainAccordion) { + mainAccordion = container.querySelector('.label-wrap'); + } + handleDropdown(mainAccordion, 'ad-dropdown-main'); + + // Try multiple selectors for the enable checkbox + let enableCheckbox = container.querySelector('#script_txt2img_adetailer_ad_enable > label > input'); + if (!enableCheckbox) { + enableCheckbox = container.querySelector('input[type="checkbox"]'); + } + handleCheckbox(enableCheckbox, 'ad-checkbox-enable'); + cnTabs.forEach(({ container, container_idx }) => { handleTabs(container, container_idx); handleTextboxes(container, container_idx); @@ -143,14 +174,30 @@ state.extensions['adetailer'] = (function () { function init() { + // Try multiple selectors for ADetailer container (Forge vs A1111 compatibility) container = gradioApp().getElementById('script_txt2img_adetailer_ad_main_accordion'); - store = new state.Store('ext-adetailerr'); + if (!container) { + container = gradioApp().querySelector('[id*="adetailer"]'); + } + if (!container) { + container = gradioApp().querySelector('[id*="ADetailer"]'); + } - if (! container) { + store = new state.Store('ext-adetailer'); + + if (!container) { + state.logging.log('ADetailer extension not found'); return; } + // Try multiple selectors for tabs let tabs = container.querySelectorAll('.tabitem'); + if (!tabs.length) { + tabs = container.querySelectorAll('[id*="tabitem"]'); + } + if (!tabs.length) { + tabs = container.querySelectorAll('.tabs > div[role="tabpanel"]'); + } if (tabs.length) { cnTabs = []; @@ -162,7 +209,8 @@ state.extensions['adetailer'] = (function () { }); } else { cnTabs = [{ - container: container + container: container, + container_idx: 0 }]; } diff --git a/javascript/state.ext.control-net.js b/javascript/state.ext.control-net.js index 0219041..03bb05d 100644 --- a/javascript/state.ext.control-net.js +++ b/javascript/state.ext.control-net.js @@ -11,7 +11,17 @@ function ControlNetTabContext(tabName, container) { this.tabElements = []; this.cnTabs = []; + // Try multiple selectors for compatibility with different Gradio/Forge versions let tabs = this.container.querySelectorAll(':scope > div > div > .tabs > .tabitem'); + if (!tabs.length) { + tabs = this.container.querySelectorAll('.tabitem'); + } + if (!tabs.length) { + tabs = this.container.querySelectorAll('[id*="tabitem"]'); + } + if (!tabs.length) { + tabs = this.container.querySelectorAll('.tabs > div[role="tabpanel"]'); + } if (tabs.length) { tabs.forEach((tabContainer, i) => { @@ -21,10 +31,10 @@ function ControlNetTabContext(tabName, container) { }); }); } else { - this.cnTabs.push[{ + this.cnTabs.push({ container: container, - store: new state.Store(`ext-control-net-${this.tabName}-${i}`) - }]; + store: new state.Store(`ext-control-net-${this.tabName}-0`) + }); } } @@ -38,7 +48,11 @@ state.extensions['control-net'] = (function () { contexts.forEach(context => { - const elements = context.container.querySelectorAll(`:scope > .label-wrap`) + // Try multiple selectors for compatibility + let elements = context.container.querySelectorAll(`:scope > .label-wrap`); + if (!elements.length) { + elements = context.container.querySelectorAll('.label-wrap'); + } elements.forEach(element => { if (context.store.get(id) === 'true') { @@ -46,8 +60,11 @@ state.extensions['control-net'] = (function () { load(); } element.addEventListener('click', function () { - let classList = Array.from(this.classList); - context.store.set(id, classList.indexOf('open') > -1); + // Check for open state using multiple methods for compatibility + let isOpen = this.classList.contains('open') || + this.parentNode.classList.contains('open') || + state.utils.isAccordionOpen(this.parentNode); + context.store.set(id, isOpen); load(); }); }); @@ -56,7 +73,18 @@ state.extensions['control-net'] = (function () { function bindTabEvents() { contexts.forEach(context => { - const tabs = context.container.querySelectorAll(':scope > div > div > .tabs > div > button'); + // Try multiple selectors for compatibility + let tabs = context.container.querySelectorAll(':scope > div > div > .tabs > div > button'); + if (!tabs.length) { + tabs = context.container.querySelectorAll('.tabs .tab-nav button'); + } + if (!tabs.length) { + tabs = context.container.querySelectorAll('.tabs > div > button'); + } + if (!tabs.length) { + tabs = context.container.querySelectorAll('button[role="tab"]'); + } + function onTabClick() { context.store.set('tab', this.textContent); bindTabEvents(); @@ -92,12 +120,84 @@ state.extensions['control-net'] = (function () { }); } + // Helper to find checkbox label text + function getCheckboxLabel(checkbox) { + // Try nextElementSibling (common pattern) + if (checkbox.nextElementSibling && checkbox.nextElementSibling.textContent) { + return checkbox.nextElementSibling.textContent; + } + + // Try parent label + let parentLabel = checkbox.closest('label'); + if (parentLabel) { + // Get text excluding the checkbox itself + let clone = parentLabel.cloneNode(true); + let input = clone.querySelector('input'); + if (input) input.remove(); + if (clone.textContent && clone.textContent.trim()) { + return clone.textContent.trim(); + } + } + + // Try aria-label or title + if (checkbox.getAttribute('aria-label')) return checkbox.getAttribute('aria-label'); + if (checkbox.title) return checkbox.title; + + return null; + } + + // Helper to find select/dropdown label text + function getSelectLabel(select) { + // Try label inside the select container + let label = select.querySelector('label'); + if (label) { + if (label.firstChild && label.firstChild.textContent) { + return label.firstChild.textContent; + } + if (label.textContent) return label.textContent; + } + + // Try span with label class + let span = select.querySelector('span[data-testid="block-label"], span[class*="label"]'); + if (span && span.textContent) return span.textContent; + + // Try previous sibling + if (select.previousElementSibling) { + let prevLabel = select.previousElementSibling.querySelector('label, span'); + if (prevLabel && prevLabel.textContent) return prevLabel.textContent; + } + + return null; + } + + // Helper to find textarea label text + function getTextareaLabel(textarea) { + // Try previousElementSibling + if (textarea.previousElementSibling && textarea.previousElementSibling.textContent) { + return textarea.previousElementSibling.textContent; + } + + // Try parent container for label + let parent = textarea.closest('.gradio-textbox, [class*="textbox"]'); + if (parent) { + let label = parent.querySelector('label, span[data-testid="block-label"]'); + if (label && label.textContent) return label.textContent; + } + + // Try aria-label or placeholder + if (textarea.getAttribute('aria-label')) return textarea.getAttribute('aria-label'); + if (textarea.placeholder) return textarea.placeholder; + + return null; + } + function handleCheckboxes() { handleContext((container, store) => { let checkboxes = container.querySelectorAll('input[type="checkbox"]'); - checkboxes.forEach(function (checkbox) { - let label = checkbox.nextElementSibling; - let id = state.utils.txtToId(label.textContent); + checkboxes.forEach(function (checkbox, idx) { + let labelText = getCheckboxLabel(checkbox); + // Use index-based fallback if no label found + let id = labelText ? state.utils.txtToId(labelText) : `checkbox-${idx}`; let value = store.get(id); if (value) { state.utils.setValue(checkbox, value, 'change'); @@ -111,8 +211,12 @@ state.extensions['control-net'] = (function () { function handleSelects() { handleContext((container, store) => { - container.querySelectorAll('.gradio-dropdown').forEach(select => { - let id = state.utils.txtToId(select.querySelector('label').firstChild.textContent); + // Use compatibility helper to find dropdowns + let dropdowns = state.utils.findDropdowns(container); + dropdowns.forEach(function (select, idx) { + let labelText = getSelectLabel(select); + // Use index-based fallback if no label found + let id = labelText ? state.utils.txtToId(labelText) : `select-${idx}`; let value = store.get(id); state.utils.handleSelect(select, id, store); if (id === 'preprocessor' && value && value.toLowerCase() !== 'none') { @@ -122,12 +226,64 @@ state.extensions['control-net'] = (function () { }); } + // Helper to find slider label text with multiple fallback methods + function getSliderLabel(slider) { + // Try previousElementSibling first (old structure) + if (slider.previousElementSibling) { + let label = slider.previousElementSibling.querySelector('label span'); + if (label && label.textContent) return label.textContent; + + // Try just the label + label = slider.previousElementSibling.querySelector('label'); + if (label && label.textContent) return label.textContent; + + // Try span directly + label = slider.previousElementSibling.querySelector('span'); + if (label && label.textContent) return label.textContent; + } + + // Try parent container for label (Forge/Gradio 4.x structure) + let parent = slider.closest('.gradio-slider, .slider, [class*="slider"]'); + if (parent) { + let label = parent.querySelector('label span, label, span[data-testid="block-label"]'); + if (label && label.textContent) return label.textContent; + } + + // Try looking for label in parent's previous sibling + if (slider.parentElement && slider.parentElement.previousElementSibling) { + let label = slider.parentElement.previousElementSibling.querySelector('span, label'); + if (label && label.textContent) return label.textContent; + } + + return null; + } + + // Helper to find fieldset/radio label text + function getFieldsetLabel(fieldset) { + // Try firstChild.nextElementSibling (old structure) + if (fieldset.firstChild && fieldset.firstChild.nextElementSibling) { + let label = fieldset.firstChild.nextElementSibling; + if (label && label.textContent) return label.textContent; + } + + // Try legend element + let legend = fieldset.querySelector('legend'); + if (legend && legend.textContent) return legend.textContent; + + // Try label or span + let label = fieldset.querySelector('label, span[class*="label"]'); + if (label && label.textContent) return label.textContent; + + return null; + } + function handleSliders() { handleContext((container, store) => { let sliders = container.querySelectorAll('input[type="range"]'); - sliders.forEach(function (slider) { - let label = slider.previousElementSibling.querySelector('label span'); - let id = state.utils.txtToId(label.textContent); + sliders.forEach(function (slider, idx) { + let labelText = getSliderLabel(slider); + // Use index-based fallback if no label found + let id = labelText ? state.utils.txtToId(labelText) : `slider-${idx}`; let value = store.get(id); if (value) { state.utils.setValue(slider, value, 'change'); @@ -142,10 +298,11 @@ state.extensions['control-net'] = (function () { function handleRadioButtons() { handleContext((container, store) => { let fieldsets = container.querySelectorAll('fieldset'); - fieldsets.forEach(function (fieldset) { - let label = fieldset.firstChild.nextElementSibling; + fieldsets.forEach(function (fieldset, idx) { let radios = fieldset.querySelectorAll('input[type="radio"]'); - let id = state.utils.txtToId(label.textContent); + let labelText = getFieldsetLabel(fieldset); + // Use index-based fallback if no label found + let id = labelText ? state.utils.txtToId(labelText) : `radio-${idx}`; let value = store.get(id); if (value) { radios.forEach(function (radio) { @@ -164,9 +321,10 @@ state.extensions['control-net'] = (function () { function handleTextareas() { handleContext((container, store) => { let textareas = container.querySelectorAll('textarea'); - textareas.forEach(function (textarea) { - let label = textarea.previousElementSibling; - let id = state.utils.txtToId(label.textContent); + textareas.forEach(function (textarea, idx) { + let labelText = getTextareaLabel(textarea); + // Use index-based fallback if no label found + let id = labelText ? state.utils.txtToId(labelText) : `textarea-${idx}`; let value = store.get(id); if (value) { state.utils.setValue(textarea, value, 'change'); @@ -191,14 +349,32 @@ state.extensions['control-net'] = (function () { function init() { + // Try multiple selectors for ControlNet container (Forge vs A1111 compatibility) let elements = gradioApp().querySelectorAll('#controlnet'); + if (!elements.length) { + elements = gradioApp().querySelectorAll('[id*="controlnet"]'); + } + if (!elements.length) { + elements = gradioApp().querySelectorAll('#txt2img_controlnet, #img2img_controlnet'); + } + // Forge built-in ControlNet uses different IDs + if (!elements.length) { + elements = gradioApp().querySelectorAll('[id*="forge_controlnet"], [id*="sd_forge_controlnet"]'); + } - if (! elements.length) { + if (!elements.length) { + state.logging.log('ControlNet extension not found'); return; } - contexts[0] = new ControlNetTabContext('txt2img', elements[0]); - contexts[1] = new ControlNetTabContext('img2img', elements[1]); + // Handle both single container and separate txt2img/img2img containers + if (elements.length >= 2) { + contexts[0] = new ControlNetTabContext('txt2img', elements[0]); + contexts[1] = new ControlNetTabContext('img2img', elements[1]); + } else if (elements.length === 1) { + // Single container mode + contexts[0] = new ControlNetTabContext('main', elements[0]); + } handleToggle(); load(); diff --git a/javascript/state.ext.dynamic-prompting.js b/javascript/state.ext.dynamic-prompting.js index 78928b8..9aea75d 100644 --- a/javascript/state.ext.dynamic-prompting.js +++ b/javascript/state.ext.dynamic-prompting.js @@ -50,7 +50,8 @@ state.extensions['dynamic prompting'] = (function () { } function handleSelects() { - let selects = container.querySelectorAll('.gradio-dropdown') + // Use compatibility helper to find dropdowns + let selects = state.utils.findDropdowns(container); selects.forEach(function (select, idx) { state.utils.handleSelect(select, `dp-select-${idx}`, store); }); @@ -76,17 +77,24 @@ state.extensions['dynamic prompting'] = (function () { } function handleDropdowns() { - let dropdowns = container.querySelectorAll('.gradio-accordion .label-wrap'); - dropdowns.forEach(function (dropdown, idx) { + // Use compatibility helper to find accordions + let accordions = state.utils.findAccordions(container); + accordions.forEach(function (accordion, idx) { + let labelWrap = accordion.querySelector('.label-wrap'); + if (!labelWrap) return; + let id = `dp-dropdown-${idx}`; let value = store.get(id); if (value && value === 'true') { - state.utils.triggerEvent(dropdown, 'click'); + state.utils.triggerEvent(labelWrap, 'click'); } - dropdown.addEventListener('click', function () { - let span = this.querySelector('.transition, .icon'); - store.set(id, span.style.transform !== 'rotate(90deg)'); + labelWrap.addEventListener('click', function () { + // Use multiple methods to check open state for compatibility + let isOpen = this.classList.contains('open') || + this.parentNode.classList.contains('open') || + state.utils.isAccordionOpen(this.parentNode); + store.set(id, isOpen); }); }); } @@ -104,10 +112,22 @@ state.extensions['dynamic prompting'] = (function () { function init() { + // Try multiple selectors for Dynamic Prompting container (Forge vs A1111 compatibility) container = gradioApp().getElementById('sddp-dynamic-prompting'); + if (!container) { + container = gradioApp().querySelector('[id*="dynamic-prompting"]'); + } + if (!container) { + container = gradioApp().querySelector('[id*="dynamicprompts"]'); + } + if (!container) { + container = gradioApp().querySelector('[id*="dynamic_prompts"]'); + } + store = new state.Store('ext-dynamic-prompting'); - if (! container) { + if (!container) { + state.logging.log('Dynamic Prompting extension not found'); return; } diff --git a/javascript/state.ext.multidiffusion.js b/javascript/state.ext.multidiffusion.js index 4fe374d..a764b38 100644 --- a/javascript/state.ext.multidiffusion.js +++ b/javascript/state.ext.multidiffusion.js @@ -50,7 +50,8 @@ state.extensions['multidiffusion'] = (function () { } function handleSelects(container, name) { - let selects = container.querySelectorAll('.gradio-dropdown') + // Use compatibility helper to find dropdowns + let selects = state.utils.findDropdowns(container); selects.forEach(function (select, idx) { state.utils.handleSelect(select, `md-${name}-select-${idx}`, store); }); @@ -76,17 +77,24 @@ state.extensions['multidiffusion'] = (function () { } function handleDropdowns(container, name) { - let dropdowns = container.querySelectorAll('.gradio-accordion .label-wrap'); - dropdowns.forEach(function (dropdown, idx) { + // Use compatibility helper to find accordions + let accordions = state.utils.findAccordions(container); + accordions.forEach(function (accordion, idx) { + let labelWrap = accordion.querySelector('.label-wrap'); + if (!labelWrap) return; + let id = `md-${name}-dropdown-${idx}`; let value = store.get(id); if (value && value === 'true') { - state.utils.triggerEvent(dropdown, 'click'); + state.utils.triggerEvent(labelWrap, 'click'); } - dropdown.addEventListener('click', function () { - let span = this.querySelector('.transition, .icon'); - store.set(id, span.style.transform !== 'rotate(90deg)'); + labelWrap.addEventListener('click', function () { + // Use multiple methods to check open state for compatibility + let isOpen = this.classList.contains('open') || + this.parentNode.classList.contains('open') || + state.utils.isAccordionOpen(this.parentNode); + store.set(id, isOpen); }); }); } @@ -106,20 +114,47 @@ state.extensions['multidiffusion'] = (function () { function init() { + // Try to find Tiled Diffusion/VAE containers by text content let spanTags = gradioApp().getElementsByTagName("span"); for (var i = 0; i < spanTags.length; i++) { - if (spanTags[i].textContent == 'Tiled Diffusion') { - containers.push({container: spanTags[i].parentElement.parentElement,name: 'diffusion'}); + let text = spanTags[i].textContent.trim(); + if (text === 'Tiled Diffusion' || text === 'Tiled Diffusion (Multidiffusion)') { + let parent = spanTags[i].parentElement; + // Navigate up to find the accordion container + while (parent && !parent.classList.contains('gradio-accordion') && !parent.classList.contains('accordion') && !parent.id) { + parent = parent.parentElement; + } + if (parent) { + containers.push({container: parent, name: 'diffusion'}); + } } - if (spanTags[i].textContent == 'Tiled VAE') { - containers.push({container: spanTags[i].parentElement.parentElement,name: 'vae'}); - break; + if (text === 'Tiled VAE') { + let parent = spanTags[i].parentElement; + while (parent && !parent.classList.contains('gradio-accordion') && !parent.classList.contains('accordion') && !parent.id) { + parent = parent.parentElement; + } + if (parent) { + containers.push({container: parent, name: 'vae'}); + } } - }; + } + + // Also try by ID for Forge compatibility + if (!containers.length) { + let tiledDiff = gradioApp().querySelector('[id*="tiled_diffusion"], [id*="multidiffusion"]'); + if (tiledDiff) { + containers.push({container: tiledDiff, name: 'diffusion'}); + } + let tiledVae = gradioApp().querySelector('[id*="tiled_vae"]'); + if (tiledVae) { + containers.push({container: tiledVae, name: 'vae'}); + } + } store = new state.Store('ext-multidiffusion'); - if (! containers.length) { + if (!containers.length) { + state.logging.log('Multidiffusion/Tiled VAE extension not found'); return; } diff --git a/javascript/state.loggings.js b/javascript/state.loggings.js index 8f79192..428b197 100644 --- a/javascript/state.loggings.js +++ b/javascript/state.loggings.js @@ -5,15 +5,45 @@ state.logging = { name: 'state', - log: function (message) { - console.log(`[${this.name}]: `, message); + // Set to true to enable debug logging + DEBUG: false, + + log: function (message, data) { + if (!this.DEBUG) return; + if (data !== undefined) { + console.log(`[${this.name}]: `, message, data); + } else { + console.log(`[${this.name}]: `, message); + } }, - error: function (message) { - console.error(`[${this.name}]: `, message); + error: function (message, data) { + // Errors are always shown + if (data !== undefined) { + console.error(`[${this.name}]: `, message, data); + } else { + console.error(`[${this.name}]: `, message); + } }, - warn: function (message) { - console.warn(`[${this.name}]: `, message); + warn: function (message, data) { + if (!this.DEBUG) return; + if (data !== undefined) { + console.warn(`[${this.name}]: `, message, data); + } else { + console.warn(`[${this.name}]: `, message); + } + }, + + // Call this in browser console to enable debugging: state.logging.enable() + enable: function() { + this.DEBUG = true; + console.log(`[${this.name}]: Debug logging enabled`); + }, + + // Call this in browser console to disable debugging: state.logging.disable() + disable: function() { + this.DEBUG = false; + console.log(`[${this.name}]: Debug logging disabled`); } }; diff --git a/javascript/state.utils.js b/javascript/state.utils.js index a2ad234..1117ade 100644 --- a/javascript/state.utils.js +++ b/javascript/state.utils.js @@ -46,9 +46,106 @@ state.utils = { element.checked = false; } break; + case 'number': + case 'range': + // For sliders and number inputs, use Forge pattern + element.value = value; + if (typeof updateInput === 'function') { + updateInput(element); + } + break; + case 'textarea': + // Textareas (prompts) - use Forge pattern + element.value = value; + if (typeof updateInput === 'function') { + updateInput(element); + } + break; default: element.value = value; - this.triggerEvent(element, event); + // For text inputs and other types, use Forge pattern if available + if (typeof updateInput === 'function' && (element.tagName === 'TEXTAREA' || element.tagName === 'INPUT')) { + updateInput(element); + } else { + this.triggerEvent(element, event); + } + } + }, + // Update input using Gradio's expected event format (works with Forge and new Gradio) + updateGradioInput: function updateGradioInput(target) { + if (!target) return; + + // Use the global updateInput if available (Forge/A1111) + if (typeof updateInput === 'function') { + updateInput(target); + } + + // Also dispatch events manually to ensure Svelte/Gradio components update + // Input event with bubbles + let inputEvent = new Event("input", { bubbles: true, cancelable: true }); + Object.defineProperty(inputEvent, "target", { value: target }); + target.dispatchEvent(inputEvent); + + // Change event + let changeEvent = new Event("change", { bubbles: true, cancelable: true }); + target.dispatchEvent(changeEvent); + + // For Svelte components, also try InputEvent + try { + let inputEvent2 = new InputEvent("input", { + bubbles: true, + cancelable: true, + inputType: "insertText", + data: target.value + }); + target.dispatchEvent(inputEvent2); + } catch (e) { + // InputEvent might not be supported in all browsers + } + }, + + // Set value using native setter to bypass framework reactivity issues + setNativeValue: function setNativeValue(element, value) { + // Get the native value setter + const valueSetter = Object.getOwnPropertyDescriptor(element.__proto__, 'value')?.set || + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set || + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set; + + if (valueSetter) { + valueSetter.call(element, value); + } else { + element.value = value; + } + }, + + // Update a Gradio slider component - matches exact Forge pattern + updateGradioSlider: function updateGradioSlider(container, value) { + if (!container) return; + + let numberInput = container.querySelector('input[type=number]'); + let rangeInput = container.querySelector('input[type=range]'); + + // Use exact Forge pattern: set .value then call updateInput() + if (numberInput) { + numberInput.value = value; + if (typeof updateInput === 'function') { + updateInput(numberInput); + } + } + + // Also update range for visual sync + if (rangeInput) { + rangeInput.value = value; + if (typeof updateInput === 'function') { + updateInput(rangeInput); + } + + // Update visual slider fill + let min = parseFloat(rangeInput.min) || 0; + let max = parseFloat(rangeInput.max) || 100; + let val = parseFloat(value) || 0; + let percentage = ((val - min) / (max - min)) * 100; + rangeInput.style.backgroundSize = percentage + '% 100%'; } }, onContentChange: function onContentChange(targetNode, func) { @@ -91,17 +188,22 @@ state.utils = { setTimeout(() => { state.utils.onContentChange(select, function (el) { - let selected = el.querySelector('span.single-select'); - if (selected) { - store.set(id, selected.textContent); - } else { - // new gradio version... - let input = select.querySelector('input'); - if (input) { - store.set(id, input.value); - } + // Use compatibility helper to get dropdown value + var value = state.utils.getDropdownValue(el); + if (value) { + store.set(id, value); } }); + + // Also listen to input events directly for Gradio 4.x + let input = select.querySelector('input'); + if (input) { + input.addEventListener('change', function() { + if (this.value) { + store.set(id, this.value); + } + }); + } }, 150); } catch (error) { console.error('[state]: Error:', error); @@ -144,7 +246,8 @@ state.utils = { } } state.utils.onContentChange(select, function (el) { - const selected = Array.from(el.querySelectorAll('.token > span')).map(item => item.textContent); + // Use compatibility helper to get multi-select values + const selected = state.utils.getMultiSelectValues(el); store.set(id, selected); }); } catch (error) { @@ -225,7 +328,167 @@ state.utils.html = { const btn = document.createElement('button'); btn.innerHTML = text; btn.onclick = onclick || function () {}; - btn.className = 'gr-button gr-button-lg gr-button-primary'; + // Support both old Gradio 3.x and new Gradio 4.x button classes + btn.className = state.utils.getButtonClass(); return btn; + }, + // Get the appropriate button class based on Gradio version + getButtonClass: function() { + return state.utils.getButtonClass(); } }; + +// Gradio version detection and compatibility helpers +state.utils.gradio = { + _version: null, + _detected: false, + + // Detect Gradio version based on available DOM elements/classes + detectVersion: function() { + if (this._detected) return this._version; + + var root = gradioApp(); + + // Check for Gradio 4.x indicators + if (root.querySelector('.gradio-container-4-')) { + this._version = 4; + } else if (root.querySelector('[class*="gradio-container-4"]')) { + this._version = 4; + } else if (root.querySelector('.svelte-')) { + // Gradio 4.x uses svelte classes + this._version = 4; + } else { + // Default to 3.x for older versions + this._version = 3; + } + + this._detected = true; + state.logging.log('Detected Gradio version: ' + this._version + '.x'); + return this._version; + }, + + isVersion4: function() { + return this.detectVersion() >= 4; + } +}; + +// Get button class with fallback support +state.utils.getButtonClass = function() { + var root = gradioApp(); + // Try to find an existing button and copy its class + var existingBtn = root.querySelector('#quicksettings button'); + if (existingBtn && existingBtn.className) { + return existingBtn.className; + } + // Fallback class names - try both old and new + if (state.utils.gradio.isVersion4()) { + return 'lg primary gradio-button svelte-cmf5ev'; + } + return 'gr-button gr-button-lg gr-button-primary'; +}; + +// Find dropdown elements with fallback selectors +state.utils.findDropdowns = function(container) { + container = container || gradioApp(); + // Try multiple selectors for compatibility + var dropdowns = container.querySelectorAll('.gradio-dropdown'); + if (!dropdowns.length) { + dropdowns = container.querySelectorAll('[data-testid="dropdown"]'); + } + if (!dropdowns.length) { + dropdowns = container.querySelectorAll('.dropdown'); + } + return dropdowns; +}; + +// Find accordion elements with fallback selectors +state.utils.findAccordions = function(container) { + container = container || gradioApp(); + var accordions = container.querySelectorAll('.gradio-accordion'); + if (!accordions.length) { + accordions = container.querySelectorAll('.accordion'); + } + if (!accordions.length) { + accordions = container.querySelectorAll('[data-testid="accordion"]'); + } + return accordions; +}; + +// Get selected value from dropdown with version compatibility +state.utils.getDropdownValue = function(select) { + if (!select) return null; + + // Try new Gradio 4.x input method first + var input = select.querySelector('input'); + if (input && input.value) { + return input.value; + } + + // Try old Gradio 3.x span method + var selected = select.querySelector('span.single-select'); + if (selected) { + return selected.textContent; + } + + // Try other common patterns + var selectedOption = select.querySelector('.selected'); + if (selectedOption) { + return selectedOption.textContent; + } + + return null; +}; + +// Get multi-select values with version compatibility +state.utils.getMultiSelectValues = function(select) { + if (!select) return []; + + // Try token pattern (common in both versions) + var tokens = select.querySelectorAll('.token > span, .token span:first-child'); + if (tokens.length) { + return Array.from(tokens).map(item => item.textContent); + } + + // Try secondary-wrap pattern (Gradio 4.x) + var secondary = select.querySelectorAll('.secondary-wrap .token'); + if (secondary.length) { + return Array.from(secondary).map(item => item.textContent.trim()); + } + + // Try pill/tag pattern + var pills = select.querySelectorAll('.pill, .tag, [data-value]'); + if (pills.length) { + return Array.from(pills).map(item => item.textContent || item.dataset.value); + } + + return []; +}; + +// Check if accordion is open with version compatibility +state.utils.isAccordionOpen = function(accordion) { + if (!accordion) return false; + + var labelWrap = accordion.querySelector('.label-wrap'); + if (labelWrap) { + // Check for 'open' class (Forge/A1111 style) + if (labelWrap.classList.contains('open')) { + return true; + } + } + + // Check for input-accordion-open class + if (accordion.classList.contains('input-accordion-open')) { + return true; + } + + // Check icon rotation (older pattern) + var icon = accordion.querySelector('.transition, .icon'); + if (icon) { + var transform = icon.style.transform || window.getComputedStyle(icon).transform; + if (transform && transform.indexOf('90') === -1) { + return true; + } + } + + return false; +}; diff --git a/scripts/state_api.py b/scripts/state_api.py index f07246b..160da0a 100644 --- a/scripts/state_api.py +++ b/scripts/state_api.py @@ -1,5 +1,5 @@ -from fastapi import FastAPI, Body, HTTPException, Request, Response -from fastapi.responses import FileResponse +from fastapi import FastAPI +from fastapi.responses import FileResponse, JSONResponse import gradio as gr import modules.shared as shared @@ -7,6 +7,11 @@ import modules.script_callbacks as script_callbacks class StateApi(): + """ + API endpoint for the State extension. + Provides configuration data to the frontend JavaScript. + Compatible with both AUTOMATIC1111 and Forge WebUI. + """ BASE_PATH = '/state' @@ -21,11 +26,32 @@ class StateApi(): self.add_api_route('/config.json', self.get_config, methods=['GET']) def get_config(self): - return FileResponse(shared.cmd_opts.ui_settings_file) + """ + Return the UI settings file containing state configuration. + Works with both A1111 and Forge which may have different settings locations. + """ + try: + # Try standard location first (works for both A1111 and Forge) + settings_file = getattr(shared.cmd_opts, 'ui_settings_file', None) + if settings_file: + return FileResponse(settings_file) + + # Fallback: try to get settings from shared.opts + config = { + 'state': getattr(shared.opts, 'state', []), + 'state_txt2img': getattr(shared.opts, 'state_txt2img', []), + 'state_img2img': getattr(shared.opts, 'state_img2img', []), + 'state_extensions': getattr(shared.opts, 'state_extensions', []), + 'state_ui': getattr(shared.opts, 'state_ui', []), + } + return JSONResponse(content=config) + except Exception as e: + print(f"[State] Error loading config: {e}") + return JSONResponse(content={}) try: api = StateApi() script_callbacks.on_app_started(api.start) -except: - pass \ No newline at end of file +except Exception as e: + print(f"[State] Error initializing API: {e}") \ No newline at end of file diff --git a/scripts/state_settings.py b/scripts/state_settings.py index 45c781a..68ab456 100644 --- a/scripts/state_settings.py +++ b/scripts/state_settings.py @@ -1,18 +1,29 @@ +""" +State extension settings for Stable Diffusion WebUI. +Compatible with both AUTOMATIC1111 and Forge WebUI. +""" + import gradio as gr import modules.shared as shared from modules import scripts def on_ui_settings(): + """ + Register the State extension settings in the WebUI settings page. + Uses CheckboxGroup for compatibility with both Gradio 3.x and 4.x. + """ section = ("state", "State") + # Main elements (tabs) shared.opts.add_option("state", shared.OptionInfo([], "Saved main elements", gr.CheckboxGroup, lambda: { "choices": [ "tabs" ] }, section=section)) + # txt2img elements - includes Forge-specific options shared.opts.add_option("state_txt2img", shared.OptionInfo([], "Saved elements from txt2img", gr.CheckboxGroup, lambda: { "choices": [ "prompt", @@ -34,6 +45,7 @@ def on_ui_settings(): "hires_resize_x", "hires_resize_y", "hires_denoising_strength", + "hires_cfg_scale", "refiner", "refiner_checkpoint", "refiner_switch", @@ -50,6 +62,7 @@ def on_ui_settings(): ] }, section=section)) + # img2img elements shared.opts.add_option("state_img2img", shared.OptionInfo([], "Saved elements from img2img", gr.CheckboxGroup, lambda: { "choices": [ "prompt", @@ -82,6 +95,7 @@ def on_ui_settings(): ] }, section=section)) + # Extension support - includes Forge built-in extensions shared.opts.add_option("state_extensions", shared.OptionInfo([], "Saved elements from extensions", gr.CheckboxGroup, lambda: { "choices": [ "control-net", @@ -91,6 +105,7 @@ def on_ui_settings(): ] }, section=section)) + # UI buttons configuration shared.opts.add_option("state_ui", shared.OptionInfo([ "Reset Button", "Import Button", @@ -103,4 +118,8 @@ def on_ui_settings(): ], }, section=section)) -scripts.script_callbacks.on_ui_settings(on_ui_settings) + +try: + scripts.script_callbacks.on_ui_settings(on_ui_settings) +except Exception as e: + print(f"[State] Error registering settings: {e}")