Spaces:
Running
on
Zero
Running
on
Zero
/** | |
* Adds a named stylesheet to the document with an optional ability to replace an existing one. | |
* | |
* @param {string} name - The unique name (ID) of the stylesheet. | |
* @param {string} css - The CSS rules as a string. | |
* @param {boolean} [force=false] - Whether to replace the existing stylesheet if it exists. | |
* @returns {void} | |
*/ | |
export function addNamedStyleSheet(name, css, force = false) { | |
const existingStyleSheet = document.getElementById(name) | |
if (existingStyleSheet && !force) { | |
console.debug( | |
`Stylesheet with name "${name}" already exists. Skipping addition.`, | |
) | |
return | |
} | |
if (existingStyleSheet && force) { | |
console.debug(`Stylesheet with name "${name}" exists. Replacing...`) | |
existingStyleSheet.remove() | |
} | |
const styleElement = document.createElement('style') | |
styleElement.id = name | |
styleElement.type = 'text/css' | |
styleElement.appendChild(document.createTextNode(css)) | |
document.head.appendChild(styleElement) | |
console.debug(`Stylesheet with name "${name}" added.`) | |
} | |
export const ensureMTBStyles = () => { | |
const S = { | |
fg: 'var(--fg-color)', | |
bgi: 'var(--comfy-input-bg)', | |
bgm: 'var(--comfy-menu-bg)', | |
border: 'var(--comfy-border)', | |
borderHover: 'var(--comfy-border-hover)', | |
box: 'var(--comfy-box)', | |
accent: 'var(--p-button-text-primary-color)', | |
} | |
const common = ` | |
.mtb_sidebar { | |
display: flex; | |
flex-direction: column; | |
background: ${S.bgm}; | |
} | |
.mtb_img_grid { | |
display: flex; | |
flex-wrap: wrap; | |
overflow: scroll; | |
gap: 1em; | |
align-items: center; | |
justify-content: center; | |
height: 100%; | |
width: 100%; | |
} | |
.mtb_tools { | |
display: flex; | |
flex-direction: row; | |
align-items: center; | |
justify-content: space-between; | |
width: 100%; | |
} | |
` | |
const inputs = ` | |
/* SELECT */ | |
.mtb_select { | |
appearance: none; | |
display: grid; | |
grid-template-areas: "select"; | |
padding: 10px; | |
background-color: ${S.bgi}; | |
border: none; | |
border-radius: 5px; | |
font-size: 14px; | |
color: ${S.fg}; | |
cursor: pointer; | |
width: 100%; | |
} | |
@supports (-moz-appearance:none) { | |
.mtb_select{ | |
grid-area: select; | |
background: ${S.bgi} url('data:image/gif;base64,R0lGODlhBgAGAKEDAFVVVX9/f9TU1CgmNyH5BAEKAAMALAAAAAAGAAYAAAIODA4hCDKWxlhNvmCnGwUAOw==') right center no-repeat !important; | |
background-position: calc(100% - 5px) center !important; | |
-moz-appearance:none !important; | |
} | |
/* styling the dropdown arrow for browsers that support it */ | |
.mtb_select:after { | |
content: ""; | |
width: 0.8em; | |
height: 0.5em; | |
background-color: ${S.fg}; | |
clip-path: polygon(100% 0%, 0 0%, 50% 100%); | |
} | |
.mtb_select:focus { | |
outline: none; | |
border-color: #0056b3; | |
} | |
.mtb_select > option { | |
padding: 10px; | |
background-color: ${S.bgi}; | |
border:none; | |
color: ${S.fg}; | |
} | |
.mtb_select > option:hover { | |
background-color: red; | |
color: ${S.fg}; | |
} | |
/* SLIDER */ | |
.mtb_slider[type="range"] { | |
-webkit-appearance: none; | |
appearance: none; | |
width: 100%; | |
height: 10px; | |
background: ${S.bgm}; | |
border-radius: 5px; | |
outline: none; | |
opacity: 0.7; | |
transition: opacity .2s; | |
padding: 1em; | |
} | |
/* slider track */ | |
.mtb_slider[type="range"]::-webkit-slider-runnable-track, | |
.mtb_slider[type="range"]::-moz-range-track { | |
width: 100%; | |
height: 10px; | |
background: ${S.bgi}; | |
border-radius: 5px; | |
} | |
/* progress */ | |
.mtb_slider[type="range"]::-moz-range-progress { | |
background-color: ${S.accent}; | |
height:10px; | |
border-radius: 5px; | |
} | |
/* slider thumb (the handle) */ | |
.mtb_slider[type="range"]::-webkit-slider-thumb, | |
.mtb_slider[type="range"]::-moz-range-thumb | |
{ | |
-webkit-appearance: none; | |
appearance: none; | |
width: 15px; | |
height: 15px; | |
border-radius: 50%; | |
background: ${S.fg}; | |
border: none; | |
cursor: pointer; | |
filter: drop-shadow(1px 1px 4px black); | |
} | |
.mtb_slider[type="range"]:focus { | |
opacity: 1; | |
} | |
.mtb_slider[type=range]:-moz-focusring{ | |
outline: 1px solid red; | |
outline-offset: -1px; | |
} | |
.mtb_slider[type="range"]:hover::-webkit-slider-thumb, | |
.mtb_slider[type="range"]:active::-webkit-slider-thumb { | |
background-color: ${S.accent}; | |
} | |
` | |
addNamedStyleSheet( | |
'mtb_ui', | |
` | |
${common} | |
${inputs} | |
`, | |
) | |
} | |
/** | |
* Creates a DOM element with optional styles, class, and id. | |
* | |
* @param {string} kind - The tag name of the element. Supports class and id syntax (e.g. 'div.class#id'). | |
* @param {Object} [style] - CSS styles to apply to the element. | |
* @returns {HTMLElement} - The created DOM element. | |
*/ | |
export const makeElement = (kind, style) => { | |
let [real_kind, className] = kind.split('.') | |
let id | |
if (className?.includes('#')) { | |
;[className, id] = className.split('#') | |
} | |
const el = document.createElement(real_kind) | |
if (style) { | |
Object.assign(el.style, style) | |
} | |
if (className) { | |
el.classList.add(...className.split(' ')) // Support multiple classes | |
} | |
if (id) { | |
el.id = id | |
} | |
return el | |
} | |
/** | |
* Clears all child elements of the given parent element. | |
* | |
* @param {HTMLElement} el - The parent element whose children should be removed. | |
*/ | |
export const clearElement = (el) => { | |
while (el.firstChild) { | |
el.removeChild(el.firstChild) | |
} | |
} | |
/** | |
* Creates a labeled element (input, select, etc.). | |
* | |
* @param {HTMLElement} el - The element to label. | |
* @param {string} labelText - The label text. | |
* @returns {HTMLDivElement} - A div containing the label and the element. | |
*/ | |
export const makeLabeledElement = (el, labelText) => { | |
const wrapper = makeElement('div.mtb_labeled_element', { | |
marginBottom: '1em', | |
}) | |
const label = makeElement('label', { | |
display: 'block', | |
marginBottom: '0.5em', | |
}) | |
label.textContent = labelText | |
wrapper.appendChild(label) | |
wrapper.appendChild(el) | |
return wrapper | |
} | |
/** | |
* Converts a camelCase CSS property to kebab-case. | |
* | |
* @param {string} prop - The camelCase CSS property. | |
* @returns {string} - The kebab-case CSS property. | |
*/ | |
const camelToKebab = (prop) => | |
prop.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`) | |
/** | |
* Parses the style string into an object of CSS property-value pairs. | |
* | |
* @param {string} styleString - The CSS rule text (e.g., "color: red; background-color: blue;"). | |
* @returns {Object} - An object with camelCase CSS properties. | |
*/ | |
const parseStyleString = (styleString) => { | |
const styleObj = {} | |
for (const rule of styleString.split(';')) { | |
const [property, value] = rule.split(':').map((item) => item.trim()) | |
if (property && value) { | |
const camelProp = property.replace(/-([a-z])/g, (g) => g[1].toUpperCase()) | |
styleObj[camelProp] = value | |
} | |
} | |
return styleObj | |
} | |
/** | |
* Defines a new CSS class with the provided styles, or skips if the class already exists. | |
* | |
* @param {string} className - The name of the CSS class to define. | |
* @param {Object} classStyles - An object containing camelCase CSS property-value pairs. | |
*/ | |
export function defineCSSClass(className, classStyles) { | |
const styleSheets = document.styleSheets | |
let classExists = false | |
let existingStyleString = '' | |
const classExistsInStyleSheet = (styleSheet) => { | |
const rules = styleSheet.rules || styleSheet.cssRules | |
for (const rule of rules) { | |
if (rule.selectorText === `.${className}`) { | |
classExists = true | |
existingStyleString = rule.style.cssText // Capture existing styles | |
return true | |
} | |
} | |
return false | |
} | |
for (const styleSheet of styleSheets) { | |
if (classExistsInStyleSheet(styleSheet)) { | |
console.debug(`Class ${className} already exists, merging styles...`) | |
break | |
} | |
} | |
const existingStyles = classExists | |
? parseStyleString(existingStyleString) | |
: {} | |
const mergedStyles = { ...existingStyles, ...classStyles } | |
const stylesString = Object.entries(mergedStyles) | |
.map(([key, value]) => `${camelToKebab(key)}: ${value};`) | |
.join(' ') | |
if (!classExists) { | |
console.debug(`Defining new class ${className}...`) | |
if (styleSheets[0].insertRule) { | |
styleSheets[0].insertRule(`.${className} { ${stylesString} }`, 0) | |
} else if (styleSheets[0].addRule) { | |
styleSheets[0].addRule(`.${className}`, stylesString, 0) | |
} | |
} else { | |
console.debug(`Updating existing class ${className} with merged styles...`) | |
for (const styleSheet of styleSheets) { | |
const rules = styleSheet.rules || styleSheet.cssRules | |
for (const rule of rules) { | |
if (rule.selectorText === `.${className}`) { | |
rule.style.cssText = stylesString // Update the existing rule | |
} | |
} | |
} | |
} | |
console.debug( | |
`Class ${className} has been defined/updated with styles:`, | |
mergedStyles, | |
) | |
} | |
/** | |
* Renders a sidebar and ensures it resizes correctly when the window is resized. | |
* | |
* @param {HTMLElement} el - The element where the sidebar is rendered. | |
* @param {HTMLElement} cont - The content container of the sidebar. | |
* @param {HTMLElement[]} elems - Array of elements to append to the sidebar. | |
* @returns {Object} - A handle with a method to unregister the resize event. | |
*/ | |
export const renderSidebar = (el, cont, elems) => { | |
el.appendChild(cont) | |
if (!el.parentNode) { | |
return | |
} | |
el.parentNode.style.overflowY = 'clip' | |
cont.style.height = `${el.parentNode.offsetHeight}px` | |
const resizeHandler = () => { | |
cont.style.height = `${el.parentNode.offsetHeight}px` | |
} | |
window.addEventListener('resize', resizeHandler) | |
for (const elem of elems) { | |
cont.appendChild(elem) | |
} | |
return { | |
unregister: () => { | |
window.removeEventListener('resize', resizeHandler) | |
}, | |
} | |
} | |
/** | |
* Creates a <select> dropdown with given options. | |
* | |
* @param {string[]} options - The options for the select element. | |
* @param {string} [current] - The currently selected option (optional). | |
* @returns {HTMLSelectElement} - The created <select> element. | |
*/ | |
export const makeSelect = (options, current = undefined) => { | |
const selector = makeElement('select.mtb_select', { | |
width: 'auto', | |
margin: '1em', | |
}) | |
for (const option of options) { | |
const opt = makeElement('option') | |
opt.value = option | |
opt.innerHTML = option | |
selector.appendChild(opt) | |
} | |
if (current !== undefined) { | |
if (options.includes(current)) { | |
selector.value = current | |
} else { | |
console.error( | |
`You tried to select an option that doesn't exist (${current}). Options: ${options}`, | |
) | |
} | |
} | |
return selector | |
} | |
/** | |
* Creates an <input type="range"> slider element with given parameters. | |
* | |
* @param {number} min - Minimum value of the slider. | |
* @param {number} max - Maximum value of the slider. | |
* @param {number} [value] - Initial value of the slider. | |
* @param {number} [step] - Step value for the slider. | |
* @returns {HTMLInputElement} - The created slider element. | |
*/ | |
export const makeSlider = (min, max, value = undefined, step = undefined) => { | |
const slider = makeElement('input.mtb_slider', { | |
width: '100%', | |
}) | |
slider.type = 'range' | |
slider.min = min || 0 | |
slider.max = max || 100 | |
slider.value = value || slider.min | |
slider.step = step || 1 | |
return slider | |
} | |
/** | |
* Creates a button element. | |
* | |
* @param {string} label - The label for the button. | |
* @param {Object} [style] - Optional styles to apply to the button. | |
* @param {Function} [onClick] - Optional click handler. | |
* @returns {HTMLButtonElement} - The created button element. | |
*/ | |
export const makeButton = (label, style = {}, onClick = undefined) => { | |
const button = makeElement('button.mtb_button', style) | |
button.textContent = label | |
if (onClick) { | |
button.addEventListener('click', onClick) | |
} | |
return button | |
} | |
/** | |
* Creates a resizable splitter between two elements. | |
* | |
* @param {HTMLElement} el1 - The first element. | |
* @param {HTMLElement} el2 - The second element. | |
* @param {'vertical' | 'horizontal'} direction - Splitter direction (vertical or horizontal). | |
* @param {'absolute' | 'normal'} mode - Splitter mode: 'absolute' for free resizing, 'normal' for layout-based resizing. | |
* @returns {HTMLDivElement} - The container with resizable splitter. | |
*/ | |
export const makeSplitter = ( | |
el1, | |
el2, | |
direction = 'vertical', | |
mode = 'normal', | |
) => { | |
const container = makeElement('div.mtb_splitter_container', { | |
display: mode === 'absolute' ? 'block' : 'flex', | |
flexDirection: direction === 'vertical' ? 'row' : 'column', | |
position: mode === 'absolute' ? 'relative' : 'static', | |
height: '100%', | |
width: '100%', | |
}) | |
const handle = makeElement('div.mtb_splitter_handle', { | |
backgroundColor: '#ccc', | |
cursor: direction === 'vertical' ? 'col-resize' : 'row-resize', | |
width: direction === 'vertical' ? '5px' : '100%', | |
height: direction === 'horizontal' ? '5px' : '100%', | |
}) | |
let isResizing = false | |
handle.addEventListener('mousedown', () => { | |
isResizing = true | |
}) | |
window.addEventListener('mouseup', () => { | |
isResizing = false | |
}) | |
window.addEventListener('mousemove', (e) => { | |
if (!isResizing) return | |
if (direction === 'vertical') { | |
const newWidth = e.clientX - container.offsetLeft | |
el1.style.width = `${newWidth}px` | |
el2.style.width = `${container.offsetWidth - newWidth}px` | |
} else { | |
const newHeight = e.clientY - container.offsetTop | |
el1.style.height = `${newHeight}px` | |
el2.style.height = `${container.offsetHeight - newHeight}px` | |
} | |
}) | |
container.appendChild(el1) | |
container.appendChild(handle) | |
container.appendChild(el2) | |
return container | |
} | |