import { convert, brightness } from 'chromatism' import sum from 'hash-sum' import { flattenDeep, sortBy } from 'lodash' import { alphaBlend, getTextColor, rgba2css, mixrgb, relativeLuminance, } from '../color_convert/color_convert.js' import { colorFunctions, shadowFunctions, process, } from './theme3_slot_functions.js' import { unroll, getAllPossibleCombinations, genericRuleToSelector, normalizeCombination, findRules, } from './iss_utils.js' import { deserializeShadow } from './iss_deserializer.js' // Ensuring the order of components const components = { Root: null, Text: null, FunText: null, Link: null, Icon: null, Border: null, PanelHeader: null, Panel: null, Chat: null, ChatMessage: null, Button: null, } export const findShadow = (shadows, { dynamicVars, staticVars }) => { return (shadows || []).map((shadow) => { let targetShadow if (typeof shadow === 'string') { if (shadow.startsWith('$')) { targetShadow = process( shadow, shadowFunctions, { findColor, findShadow }, { dynamicVars, staticVars }, ) } else if (shadow.startsWith('--')) { // modifiers are completely unsupported here const variableSlot = shadow.substring(2) return findShadow(staticVars[variableSlot], { dynamicVars, staticVars }) } else { targetShadow = deserializeShadow(shadow) } } else { targetShadow = shadow } const shadowArray = Array.isArray(targetShadow) ? targetShadow : [targetShadow] return shadowArray.map((s) => ({ ...s, color: findColor(s.color, { dynamicVars, staticVars }), })) }) } export const findColor = (color, { dynamicVars, staticVars }) => { try { if ( typeof color !== 'string' || (!color.startsWith('--') && !color.startsWith('$')) ) return color let targetColor = null if (color.startsWith('--')) { // Modifier support is pretty much for v2 themes only const [variable, modifier] = color.split(/,/g).map((str) => str.trim()) const variableSlot = variable.substring(2) if (variableSlot === 'stack') { const { r, g, b } = dynamicVars.stacked targetColor = { r, g, b } } else if (variableSlot.startsWith('parent')) { if (variableSlot === 'parent') { const { r, g, b } = dynamicVars.lowerLevelBackground ?? {} targetColor = { r, g, b } } else { const virtualSlot = variableSlot.replace(/^parent/, '') targetColor = convert( dynamicVars.lowerLevelVirtualDirectivesRaw[virtualSlot], ).rgb } } else { const staticVar = staticVars[variableSlot] const dynamicVar = dynamicVars[variableSlot] if (!staticVar && !dynamicVar) { console.warn(dynamicVars, variableSlot, dynamicVars[variableSlot]) console.warn(`Couldn't find variable "${variableSlot}", falling back to magenta. Variables are: Static: ${JSON.stringify(staticVars, null, 2)} Dynamic: ${JSON.stringify(dynamicVars, null, 2)}`) } targetColor = convert(staticVar ?? dynamicVar ?? '#FF00FF').rgb } if (modifier) { const effectiveBackground = dynamicVars.lowerLevelBackground ?? targetColor const isLightOnDark = relativeLuminance(convert(effectiveBackground).rgb) < 0.5 const mod = isLightOnDark ? 1 : -1 targetColor = brightness( Number.parseFloat(modifier) * mod, targetColor, ).rgb } } if (color.startsWith('$')) { try { targetColor = process( color, colorFunctions, { findColor }, { dynamicVars, staticVars }, ) } catch (e) { console.error( 'Failure executing color function', e, '\n Function: ' + color, ) targetColor = '#FF00FF' } } // Color references other color return targetColor } catch (e) { throw new Error(`Couldn't find color "${color}", variables are: Static: ${JSON.stringify(staticVars, null, 2)} Dynamic: ${JSON.stringify(dynamicVars, null, 2)}\nError: ${e}`) } } const getTextColorAlpha = ( directives, intendedTextColor, dynamicVars, staticVars, ) => { const opacity = directives.textOpacity const backgroundColor = convert(dynamicVars.lowerLevelBackground).rgb const textColor = convert( findColor(intendedTextColor, { dynamicVars, staticVars }), ).rgb if (opacity === null || opacity === undefined || opacity >= 1) { return convert(textColor).hex } if (opacity === 0) { return convert(backgroundColor).hex } const opacityMode = directives.textOpacityMode switch (opacityMode) { case 'fake': return convert(alphaBlend(textColor, opacity, backgroundColor)).hex case 'mixrgb': return convert(mixrgb(backgroundColor, textColor)).hex default: return rgba2css({ a: opacity, ...textColor }) } } // Loading all style.js[on] files dynamically const componentsContext = import.meta.glob( ['/src/**/*.style.js', '/src/**/*.style.json'], { eager: true }, ) Object.keys(componentsContext).forEach((key) => { const component = componentsContext[key].default if (components[component.name] != null) { console.warn( `Component in file ${key} is trying to override existing component ${component.name}! You have collisions/duplicates!`, ) } components[component.name] = component }) Object.keys(components).forEach((key) => { if (key === 'Root') return components.Root.validInnerComponents = components.Root.validInnerComponents || [] components.Root.validInnerComponents.push(key) }) Object.keys(components).forEach((key) => { const component = components[key] const { validInnerComponents = [] } = component validInnerComponents.forEach((inner) => { const child = components[inner] component.possibleChildren = component.possibleChildren || [] component.possibleChildren.push(child) child.possibleParents = child.possibleParents || [] child.possibleParents.push(component) }) }) const engineChecksum = sum(components) const ruleToSelector = genericRuleToSelector(components) export const getEngineChecksum = () => engineChecksum /** * Initializes and compiles the theme according to the ruleset * * @param {Object[]} inputRuleset - set of rules to compile theme against. Acts as an override to * component default rulesets * @param {string} ultimateBackgroundColor - Color that will be the "final" background for * calculating contrast ratios and making text automatically accessible. Really used for cases when * stuff is transparent. * @param {boolean} debug - print out debug information in console, mostly just performance stuff * @param {boolean} liteMode - use validInnerComponentsLite instead of validInnerComponents, meant to * generatate theme previews and such that need to be compiled faster and don't require a lot of other * components present in "normal" mode * @param {boolean} onlyNormalState - only use components 'normal' states, meant for generating theme * previews since states are the biggest factor for compilation time and are completely unnecessary * when previewing multiple themes at same time */ export const init = ({ inputRuleset, ultimateBackgroundColor, debug = false, liteMode = false, editMode = false, onlyNormalState = false, initialStaticVars = {}, }) => { const rootComponentName = 'Root' if (!inputRuleset) throw new Error('Ruleset is null or undefined!') const staticVars = { ...initialStaticVars } const stacked = {} const computed = {} const rulesetUnsorted = [ ...Object.values(components) .map((c) => (c.defaultRules || []).map((r) => ({ source: 'Built-in', component: c.name, ...r, })), ) .reduce((acc, arr) => [...acc, ...arr], []), ...inputRuleset, ].map((rule) => { normalizeCombination(rule) let currentParent = rule.parent while (currentParent) { normalizeCombination(currentParent) currentParent = currentParent.parent } return rule }) const ruleset = rulesetUnsorted .map((data, index) => ({ data, index })) .toSorted(({ data: a, index: ai }, { data: b, index: bi }) => { const parentsA = unroll(a).length const parentsB = unroll(b).length let aScore = 0 let bScore = 0 aScore += parentsA * 1000 bScore += parentsB * 1000 aScore += a.variant !== 'normal' ? 100 : 0 bScore += b.variant !== 'normal' ? 100 : 0 aScore += a.state.filter((x) => x !== 'normal').length * 1000 bScore += b.state.filter((x) => x !== 'normal').length * 1000 aScore += a.component === 'Text' ? 1 : 0 bScore += b.component === 'Text' ? 1 : 0 // Debug a._specificityScore = aScore b._specificityScore = bScore if (aScore === bScore) { return ai - bi } return aScore - bScore }) .map(({ data }) => data) if (!ultimateBackgroundColor) { console.warn( 'No ultimate background color provided, falling back to panel color', ) const rootRule = ruleset.findLast( (x) => x.component === 'Root' && x.directives?.['--bg'], ) ultimateBackgroundColor = rootRule.directives['--bg'].split('|')[1].trim() } const virtualComponents = new Set( Object.values(components) .filter((c) => c.virtual) .map((c) => c.name), ) const transparentComponents = new Set( Object.values(components) .filter((c) => c.transparent) .map((c) => c.name), ) const nonEditableComponents = new Set( Object.values(components) .filter((c) => c.notEditable) .map((c) => c.name), ) const extraCompileComponents = new Set([]) Object.values(components).forEach((component) => { const relevantRules = ruleset.filter((r) => r.component === component.name) const backgrounds = relevantRules .map((r) => r.directives.background) .filter((x) => x) const opacities = relevantRules .map((r) => r.directives.opacity) .filter((x) => x) if ( backgrounds.some((x) => x.match(/--parent/)) || opacities.some((x) => x != null && x < 1) ) { extraCompileComponents.add(component.name) } }) const processCombination = (combination) => { try { const selector = ruleToSelector(combination, true) const cssSelector = ruleToSelector(combination) const parentSelector = selector.split(/ /g).slice(0, -1).join(' ') const soloSelector = selector.split(/ /g).slice(-1)[0] const lowerLevelSelector = parentSelector let lowerLevelBackground = computed[lowerLevelSelector]?.background if (editMode && !lowerLevelBackground) { // FIXME hack for editor until it supports handling component backgrounds lowerLevelBackground = '#00FFFF' } const lowerLevelVirtualDirectives = computed[lowerLevelSelector]?.virtualDirectives const lowerLevelVirtualDirectivesRaw = computed[lowerLevelSelector]?.virtualDirectivesRaw const dynamicVars = computed[selector] || { lowerLevelSelector, lowerLevelBackground, lowerLevelVirtualDirectives, lowerLevelVirtualDirectivesRaw, } // Inheriting all of the applicable rules const existingRules = ruleset.filter(findRules(combination)) const computedDirectives = existingRules .map((r) => r.directives) .reduce((acc, directives) => ({ ...acc, ...directives }), {}) const computedRule = { ...combination, directives: computedDirectives, } computed[selector] = computed[selector] || {} computed[selector].computedRule = computedRule computed[selector].dynamicVars = dynamicVars // avoid putting more stuff into actual CSS computed[selector].virtualDirectives = {} // but still be able to access it i.e. from --parent computed[selector].virtualDirectivesRaw = computed[lowerLevelSelector]?.virtualDirectivesRaw || {} if (virtualComponents.has(combination.component)) { const virtualName = [ '--', combination.component.toLowerCase(), combination.variant === 'normal' ? '' : combination.variant[0].toUpperCase() + combination.variant.slice(1).toLowerCase(), ...sortBy(combination.state.filter((x) => x !== 'normal')).map( (state) => state[0].toUpperCase() + state.slice(1).toLowerCase(), ), ].join('') let inheritedTextColor = computedDirectives.textColor let inheritedTextAuto = computedDirectives.textAuto let inheritedTextOpacity = computedDirectives.textOpacity let inheritedTextOpacityMode = computedDirectives.textOpacityMode const lowerLevelTextSelector = [ ...selector.split(/ /g).slice(0, -1), soloSelector, ].join(' ') const lowerLevelTextRule = computed[lowerLevelTextSelector] if ( inheritedTextColor == null || inheritedTextOpacity == null || inheritedTextOpacityMode == null ) { inheritedTextColor = computedDirectives.textColor ?? lowerLevelTextRule.textColor inheritedTextAuto = computedDirectives.textAuto ?? lowerLevelTextRule.textAuto inheritedTextOpacity = computedDirectives.textOpacity ?? lowerLevelTextRule.textOpacity inheritedTextOpacityMode = computedDirectives.textOpacityMode ?? lowerLevelTextRule.textOpacityMode } const newTextRule = { ...computedRule, directives: { ...computedRule.directives, textColor: inheritedTextColor, textAuto: inheritedTextAuto ?? 'preserve', textOpacity: inheritedTextOpacity, textOpacityMode: inheritedTextOpacityMode, }, } dynamicVars.inheritedBackground = lowerLevelBackground dynamicVars.stacked = convert(stacked[lowerLevelSelector]).rgb const intendedTextColor = convert( findColor(inheritedTextColor, { dynamicVars, staticVars }), ).rgb const textColor = newTextRule.directives.textAuto === 'no-auto' ? intendedTextColor : getTextColor( convert(stacked[lowerLevelSelector]).rgb, intendedTextColor, newTextRule.directives.textAuto === 'preserve', ) const virtualDirectives = { ...(computed[lowerLevelSelector].virtualDirectives || {}), } const virtualDirectivesRaw = { ...(computed[lowerLevelSelector].virtualDirectivesRaw || {}), } // Storing color data in lower layer to use as custom css properties virtualDirectives[virtualName] = getTextColorAlpha( newTextRule.directives, textColor, dynamicVars, ) virtualDirectivesRaw[virtualName] = textColor computed[lowerLevelSelector].virtualDirectives = virtualDirectives computed[lowerLevelSelector].virtualDirectivesRaw = virtualDirectivesRaw return { dynamicVars, selector: cssSelector.split(/ /g).slice(0, -1).join(' '), ...combination, directives: {}, virtualDirectives, virtualDirectivesRaw, } } else { computed[selector] = computed[selector] || {} // TODO: DEFAULT TEXT COLOR const lowerLevelStackedBackground = stacked[lowerLevelSelector] || convert(ultimateBackgroundColor).rgb if (computedDirectives.background) { let inheritRule = null const variantRules = ruleset.filter( findRules({ component: combination.component, variant: combination.variant, parent: combination.parent, }), ) const lastVariantRule = variantRules[variantRules.length - 1] if (lastVariantRule) { inheritRule = lastVariantRule } else { const normalRules = ruleset.filter( findRules({ component: combination.component, parent: combination.parent, }), ) const lastNormalRule = normalRules[normalRules.length - 1] inheritRule = lastNormalRule } const inheritSelector = ruleToSelector( { ...inheritRule, parent: combination.parent }, true, ) const inheritedBackground = computed[inheritSelector].background dynamicVars.inheritedBackground = inheritedBackground const rgb = convert( findColor(computedDirectives.background, { dynamicVars, staticVars, }), ).rgb if (!stacked[selector]) { let blend const alpha = computedDirectives.opacity ?? 1 if (alpha >= 1) { blend = rgb } else if (alpha <= 0) { blend = lowerLevelStackedBackground } else { blend = alphaBlend( rgb, computedDirectives.opacity, lowerLevelStackedBackground, ) } stacked[selector] = blend computed[selector].background = { ...rgb, a: computedDirectives.opacity ?? 1, } } } if (computedDirectives.shadow) { dynamicVars.shadow = flattenDeep( findShadow(flattenDeep(computedDirectives.shadow), { dynamicVars, staticVars, }), ) } if (!stacked[selector]) { computedDirectives.background = 'transparent' computedDirectives.opacity = 0 stacked[selector] = lowerLevelStackedBackground computed[selector].background = { ...lowerLevelStackedBackground, a: 0, } } dynamicVars.stacked = stacked[selector] dynamicVars.background = computed[selector].background const dynamicSlots = Object.entries(computedDirectives).filter(([k]) => k.startsWith('--'), ) dynamicSlots.forEach(([k, v]) => { const [type, value] = v.split('|').map((x) => x.trim()) // woah, Extreme! switch (type) { case 'color': { const color = findColor(value, { dynamicVars, staticVars }) dynamicVars[k] = color if (combination.component === rootComponentName) { staticVars[k.substring(2)] = color } break } case 'shadow': { const shadow = value .split(/,/g) .map((s) => s.trim()) .filter((x) => x) dynamicVars[k] = shadow if (combination.component === rootComponentName) { staticVars[k.substring(2)] = shadow } break } case 'generic': { dynamicVars[k] = value if (combination.component === rootComponentName) { staticVars[k.substring(2)] = value } break } } }) const rule = { dynamicVars, selector: cssSelector, ...combination, directives: computedDirectives, } return rule } } catch (e) { const { component, variant, state } = combination throw new Error( `Error processing combination ${component}.${variant}:${state.join(':')}: ${e}`, ) } } const processInnerComponent = (component, parent) => { const combinations = [] const { states: originalStates = {}, variants: originalVariants = {} } = component let validInnerComponents if (editMode) { const temp = component.validInnerComponentsLite || component.validInnerComponents || [] validInnerComponents = temp.filter( (c) => virtualComponents.has(c) && !nonEditableComponents.has(c), ) } else if (liteMode) { validInnerComponents = component.validInnerComponentsLite || component.validInnerComponents || [] } else if ( component.name === 'Root' || component.states != null || component.background?.includes('--parent') ) { validInnerComponents = component.validInnerComponents || [] } else { validInnerComponents = component.validInnerComponents?.filter( (c) => virtualComponents.has(c) || transparentComponents.has(c) || extraCompileComponents.has(c), ) || [] } // Normalizing states and variants to always include "normal" const states = { normal: '', ...originalStates } const variants = { normal: '', ...originalVariants } const innerComponents = validInnerComponents.map((name) => { const result = components[name] if (result === undefined) console.error( `Component ${component.name} references a component ${name} which does not exist!`, ) return result }) // Optimization: we only really need combinations without "normal" because all states implicitly have it const permutationStateKeys = Object.keys(states).filter( (s) => s !== 'normal', ) const stateCombinations = onlyNormalState && !virtualComponents.has(component.name) ? [['normal']] : [ ['normal'], ...getAllPossibleCombinations(permutationStateKeys) .map((combination) => ['normal', ...combination]) .filter((combo) => { // Optimization: filter out some hard-coded combinations that don't make sense if (combo.indexOf('disabled') >= 0) { return !( combo.indexOf('hover') >= 0 || combo.indexOf('focused') >= 0 || combo.indexOf('pressed') >= 0 ) } return true }), ] const stateVariantCombination = Object.keys(variants) .map((variant) => { return stateCombinations.map((state) => ({ variant, state })) }) .reduce((acc, x) => [...acc, ...x], []) stateVariantCombination.forEach((combination) => { combination.component = component.name combination.lazy = component.lazy || parent?.lazy combination.parent = parent if (!liteMode && combination.state.indexOf('hover') >= 0) { combination.lazy = true } if ( !liteMode && parent?.component !== 'Root' && !virtualComponents.has(component.name) && !transparentComponents.has(component.name) && extraCompileComponents.has(component.name) ) { combination.lazy = true } combinations.push(combination) innerComponents.forEach((innerComponent) => { combinations.push(...processInnerComponent(innerComponent, combination)) }) }) return combinations } const t0 = performance.now() const combinations = processInnerComponent( components[rootComponentName] ?? components.Root, ) const t1 = performance.now() if (debug) { console.debug('Tree traveral took ' + (t1 - t0) + ' ms') } const result = combinations .map((combination) => { if (combination.lazy) { return async () => processCombination(combination) } else { return processCombination(combination) } }) .filter((x) => x) const t2 = performance.now() if (debug) { console.debug('Eager processing took ' + (t2 - t1) + ' ms') } // optimization to traverse big-ass array only once instead of twice const eager = [] const lazy = [] result.forEach((x) => { if (typeof x === 'function') { lazy.push(x) } else { eager.push(x) } }) return { lazy, eager, staticVars, engineChecksum, themeChecksum: sum([lazy, eager]), } }