Merge branch 'themes3-grand-finale-maybe' into 'develop'

Themes 3

See merge request pleroma/pleroma-fe!1951
This commit is contained in:
HJ 2024-12-18 12:19:11 +00:00
commit cbe9427123
76 changed files with 4827 additions and 1236 deletions

View file

@ -2,15 +2,23 @@ import utf8 from 'utf8'
export const newExporter = ({
filename = 'data',
mime = 'application/json',
extension = '.json',
getExportedObject
}) => ({
exportData () {
const stringified = utf8.encode(JSON.stringify(getExportedObject(), null, 2)) // Pretty-print and indent with 2 spaces
let stringified
if (mime === 'application/json') {
stringified = utf8.encode(JSON.stringify(getExportedObject(), null, 2)) // Pretty-print and indent with 2 spaces
} else {
stringified = utf8.encode(getExportedObject()) // Pretty-print and indent with 2 spaces
}
// Create an invisible link with a data url and simulate a click
const e = document.createElement('a')
e.setAttribute('download', `${filename}.json`)
e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified))
const realFilename = typeof filename === 'function' ? filename() : filename
e.setAttribute('download', `${realFilename}.${extension}`)
e.setAttribute('href', `data:${mime};base64, ${window.btoa(stringified)}`)
e.style.display = 'none'
document.body.appendChild(e)
@ -20,6 +28,8 @@ export const newExporter = ({
})
export const newImporter = ({
accept = '.json',
parser = (string) => JSON.parse(string),
onImport,
onImportFailure,
validator = () => true
@ -27,18 +37,19 @@ export const newImporter = ({
importData () {
const filePicker = document.createElement('input')
filePicker.setAttribute('type', 'file')
filePicker.setAttribute('accept', '.json')
filePicker.setAttribute('accept', accept)
filePicker.addEventListener('change', event => {
if (event.target.files[0]) {
const filename = event.target.files[0].name
// eslint-disable-next-line no-undef
const reader = new FileReader()
reader.onload = ({ target }) => {
try {
const parsed = JSON.parse(target.result)
const validationResult = validator(parsed)
const parsed = parser(target.result, filename)
const validationResult = validator(parsed, filename)
if (validationResult === true) {
onImport(parsed)
onImport(parsed, filename)
} else {
onImportFailure({ validationResult })
}

View file

@ -1,8 +1,9 @@
import { hex2rgb } from '../color_convert/color_convert.js'
import { init, getEngineChecksum } from '../theme_data/theme_data_3.service.js'
import { getCssRules } from '../theme_data/css_utils.js'
import { defaultState } from '../../modules/config.js'
import { chunk } from 'lodash'
import pako from 'pako'
import localforage from 'localforage'
// On platforms where this is not supported, it will return undefined
// Otherwise it will return an array
@ -52,29 +53,12 @@ export const generateTheme = (inputRuleset, callbacks, debug) => {
const themes3 = init({
inputRuleset,
// Assuming that "worst case scenario background" is panel background since it's the most likely one
ultimateBackgroundColor: inputRuleset[0].directives['--bg'].split('|')[1].trim(),
debug
})
getCssRules(themes3.eager, debug).forEach(rule => {
// Hacks to support multiple selectors on same component
if (rule.match(/::-webkit-scrollbar-button/)) {
const parts = rule.split(/[{}]/g)
const newRule = [
parts[0],
', ',
parts[0].replace(/button/, 'thumb'),
', ',
parts[0].replace(/scrollbar-button/, 'resizer'),
' {',
parts[1],
'}'
].join('')
onNewRule(newRule, false)
} else {
onNewRule(rule, false)
}
onNewRule(rule, false)
})
onEagerFinished()
@ -88,22 +72,7 @@ export const generateTheme = (inputRuleset, callbacks, debug) => {
const chunk = chunks[counter]
Promise.all(chunk.map(x => x())).then(result => {
getCssRules(result.filter(x => x), debug).forEach(rule => {
if (rule.match(/\.modal-view/)) {
const parts = rule.split(/[{}]/g)
const newRule = [
parts[0],
', ',
parts[0].replace(/\.modal-view/, '#modal'),
', ',
parts[0].replace(/\.modal-view/, '.shout-panel'),
' {',
parts[1],
'}'
].join('')
onNewRule(newRule, true)
} else {
onNewRule(rule, true)
}
onNewRule(rule, true)
})
// const t1 = performance.now()
// console.debug('Chunk ' + counter + ' took ' + (t1 - t0) + 'ms')
@ -120,12 +89,15 @@ export const generateTheme = (inputRuleset, callbacks, debug) => {
return { lazyProcessFunc: processChunk }
}
export const tryLoadCache = () => {
const json = localStorage.getItem('pleroma-fe-theme-cache')
if (!json) return null
export const tryLoadCache = async () => {
console.info('Trying to load compiled theme data from cache')
const data = await localforage.getItem('pleromafe-theme-cache')
if (!data) return null
let cache
try {
cache = JSON.parse(json)
const decoded = new TextDecoder().decode(pako.inflate(data))
cache = JSON.parse(decoded)
console.info(`Loaded theme from cache, size=${cache}`)
} catch (e) {
console.error('Failed to decode theme cache:', e)
return false
@ -150,16 +122,28 @@ export const applyTheme = (input, onFinish = (data) => {}, debug) => {
const eagerStyles = createStyleSheet(EAGER_STYLE_ID)
const lazyStyles = createStyleSheet(LAZY_STYLE_ID)
const insertRule = (styles, rule) => {
if (rule.indexOf('webkit') >= 0) {
try {
styles.sheet.insertRule(rule, 'index-max')
styles.rules.push(rule)
} catch (e) {
console.warn('Can\'t insert rule due to lack of support', e)
}
} else {
styles.sheet.insertRule(rule, 'index-max')
styles.rules.push(rule)
}
}
const { lazyProcessFunc } = generateTheme(
input,
{
onNewRule (rule, isLazy) {
if (isLazy) {
lazyStyles.sheet.insertRule(rule, 'index-max')
lazyStyles.rules.push(rule)
insertRule(lazyStyles, rule)
} else {
eagerStyles.sheet.insertRule(rule, 'index-max')
eagerStyles.rules.push(rule)
insertRule(eagerStyles, rule)
}
},
onEagerFinished () {
@ -169,16 +153,10 @@ export const applyTheme = (input, onFinish = (data) => {}, debug) => {
adoptStyleSheets([eagerStyles, lazyStyles])
const cache = { engineChecksum: getEngineChecksum(), data: [eagerStyles.rules, lazyStyles.rules] }
onFinish(cache)
try {
localStorage.setItem('pleroma-fe-theme-cache', JSON.stringify(cache))
} catch (e) {
localStorage.removeItem('pleroma-fe-theme-cache')
try {
localStorage.setItem('pleroma-fe-theme-cache', JSON.stringify(cache))
} catch (e) {
console.warn('cannot save cache!', e)
}
const compress = (js) => {
return pako.deflate(JSON.stringify(js))
}
localforage.setItem('pleromafe-theme-cache', compress(cache))
}
},
debug
@ -252,64 +230,66 @@ export const applyConfig = (input, i18n) => {
styleSheet.toString()
styleSheet.insertRule(`:root { ${rules} }`, 'index-max')
// TODO find a way to make this not apply to theme previews
if (Object.prototype.hasOwnProperty.call(config, 'forcedRoundness')) {
styleSheet.insertRule(` * {
styleSheet.insertRule(` *:not(.preview-block) {
--roundness: var(--forcedRoundness) !important;
}`, 'index-max')
}
}
export const getThemes = () => {
export const getResourcesIndex = async (url, parser = JSON.parse) => {
const cache = 'no-store'
const customUrl = url.replace(/\.(\w+)$/, '.custom.$1')
let builtin
let custom
return window.fetch('/static/styles.json', { cache })
.then((data) => data.json())
.then((themes) => {
return Object.entries(themes).map(([k, v]) => {
let promise = null
const resourceTransform = (resources) => {
return Object
.entries(resources)
.map(([k, v]) => {
if (typeof v === 'object') {
promise = Promise.resolve(v)
return [k, () => Promise.resolve(v)]
} else if (typeof v === 'string') {
promise = window.fetch(v, { cache })
.then((data) => data.json())
.catch((e) => {
console.error(e)
return null
})
return [
k,
() => window
.fetch(v, { cache })
.then(data => data.text())
.then(text => parser(text))
.catch(e => {
console.error(e)
return null
})
]
} else {
console.error(`Unknown resource format - ${k} is a ${typeof v}`)
return [k, null]
}
return [k, promise]
})
})
.then((promises) => {
return promises
.reduce((acc, [k, v]) => {
acc[k] = v
return acc
}, {})
})
}
export const getPreset = (val) => {
return getThemes()
.then((themes) => themes[val] ? themes[val] : themes['pleroma-dark'])
.then((theme) => {
const isV1 = Array.isArray(theme)
const data = isV1 ? {} : theme.theme
if (isV1) {
const bg = hex2rgb(theme[1])
const fg = hex2rgb(theme[2])
const text = hex2rgb(theme[3])
const link = hex2rgb(theme[4])
const cRed = hex2rgb(theme[5] || '#FF0000')
const cGreen = hex2rgb(theme[6] || '#00FF00')
const cBlue = hex2rgb(theme[7] || '#0000FF')
const cOrange = hex2rgb(theme[8] || '#E3FF00')
data.colors = { bg, fg, text, link, cRed, cBlue, cGreen, cOrange }
}
return { theme: data, source: theme.source }
})
}
try {
const builtinData = await window.fetch(url, { cache })
const builtinResources = await builtinData.json()
builtin = resourceTransform(builtinResources)
} catch (e) {
builtin = []
console.warn(`Builtin resources at ${url} unavailable`)
}
try {
const customData = await window.fetch(customUrl, { cache })
const customResources = await customData.json()
custom = resourceTransform(customResources)
} catch (e) {
custom = []
console.warn(`Custom resources at ${customUrl} unavailable`)
}
const total = [...custom, ...builtin]
if (total.length === 0) {
return Promise.reject(new Error(`Resource at ${url} and ${customUrl} completely unavailable. Panicking`))
}
return Promise.resolve(Object.fromEntries(total))
}

View file

@ -2,25 +2,6 @@ import { convert } from 'chromatism'
import { hex2rgb, rgba2css } from '../color_convert/color_convert.js'
export const parseCssShadow = (text) => {
const dimensions = /(\d[a-z]*\s?){2,4}/.exec(text)?.[0]
const inset = /inset/.exec(text)?.[0]
const color = text.replace(dimensions, '').replace(inset, '')
const [x, y, blur = 0, spread = 0] = dimensions.split(/ /).filter(x => x).map(x => x.trim())
const isInset = inset?.trim() === 'inset'
const colorString = color.split(/ /).filter(x => x).map(x => x.trim())[0]
return {
x,
y,
blur,
spread,
inset: isInset,
color: colorString
}
}
export const getCssColorString = (color, alpha = 1) => rgba2css({ ...convert(color).rgb, a: alpha })
export const getCssShadow = (input, usesDropShadow) => {
@ -84,6 +65,9 @@ export const getCssRules = (rules, debug) => rules.map(rule => {
].join(';\n ')
}
case 'shadow': {
if (!rule.dynamicVars.shadow) {
return ''
}
return ' ' + [
'--shadow: ' + getCssShadow(rule.dynamicVars.shadow),
'--shadowFilter: ' + getCssShadowFilter(rule.dynamicVars.shadow),
@ -98,7 +82,7 @@ export const getCssRules = (rules, debug) => rules.map(rule => {
`
}
if (v === 'transparent') {
if (rule.component === 'Root') return []
if (rule.component === 'Root') return null
return [
rule.directives.backgroundNoCssColor !== 'yes' ? ('background-color: ' + v) : '',
' --background: ' + v
@ -130,7 +114,7 @@ export const getCssRules = (rules, debug) => rules.map(rule => {
}
default:
if (k.startsWith('--')) {
const [type, value] = v.split('|').map(x => x.trim()) // woah, Extreme!
const [type, value] = v.split('|').map(x => x.trim())
switch (type) {
case 'color': {
const color = rule.dynamicVars[k]
@ -143,21 +127,20 @@ export const getCssRules = (rules, debug) => rules.map(rule => {
case 'generic':
return k + ': ' + value
default:
return ''
return null
}
}
return ''
return null
}
}).filter(x => x).map(x => ' ' + x).join(';\n')
}).filter(x => x).map(x => ' ' + x + ';').join('\n')
return [
header,
directives + ';',
directives,
(rule.component === 'Text' && rule.state.indexOf('faint') < 0 && rule.directives.textNoCssColor !== 'yes') ? ' color: var(--text);' : '',
'',
virtualDirectives,
footer
].join('\n')
].filter(x => x).join('\n')
}).filter(x => x)
export const getScopedVersion = (rules, newScope) => {

View file

@ -1,10 +1,11 @@
import { flattenDeep } from 'lodash'
const parseShadow = string => {
const modes = ['_full', 'inset', 'x', 'y', 'blur', 'spread', 'color', 'alpha']
export const deserializeShadow = string => {
const modes = ['_full', 'inset', 'x', 'y', 'blur', 'spread', 'color', 'alpha', 'name']
const regexPrep = [
// inset keyword (optional)
'^(?:(inset)\\s+)?',
'^',
'(?:(inset)\\s+)?',
// x
'(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)',
// y
@ -14,19 +15,31 @@ const parseShadow = string => {
// spread (optional)
'(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)?',
// either hex, variable or function
'(#[0-9a-f]{6}|--[a-z\\-_]+|\\$[a-z\\-()_]+)',
'(#[0-9a-f]{6}|--[a-z0-9\\-_]+|\\$[a-z0-9\\-()_ ]+)',
// opacity (optional)
'(?:\\s+\\/\\s+([0-9]+(?:\\.[0-9]+)?)\\s*)?$'
'(?:\\s+\\/\\s+([0-9]+(?:\\.[0-9]+)?)\\s*)?',
// name
'(?:\\s+#(\\w+)\\s*)?',
'$'
].join('')
const regex = new RegExp(regexPrep, 'gis') // global, (stable) indices, single-string
const result = regex.exec(string)
if (result == null) {
return string
if (string.startsWith('$') || string.startsWith('--')) {
return string
} else {
throw new Error(`Invalid shadow definition: '${string}'`)
}
} else {
const numeric = new Set(['x', 'y', 'blur', 'spread', 'alpha'])
const { x, y, blur, spread, alpha, inset, color } = Object.fromEntries(modes.map((mode, i) => {
const { x, y, blur, spread, alpha, inset, color, name } = Object.fromEntries(modes.map((mode, i) => {
if (numeric.has(mode)) {
return [mode, Number(result[i])]
const number = Number(result[i])
if (Number.isNaN(number)) {
if (mode === 'alpha') return [mode, 1]
return [mode, 0]
}
return [mode, number]
} else if (mode === 'inset') {
return [mode, !!result[i]]
} else {
@ -34,7 +47,7 @@ const parseShadow = string => {
}
}).filter(([k, v]) => v !== false).slice(1))
return { x, y, blur, spread, color, alpha, inset }
return { x, y, blur, spread, color, alpha, inset, name }
}
}
// this works nearly the same as HTML tree converter
@ -136,12 +149,12 @@ export const deserialize = (input) => {
output.directives = Object.fromEntries(content.map(d => {
const [property, value] = d.split(':')
let realValue = value.trim()
let realValue = (value || '').trim()
if (property === 'shadow') {
if (realValue === 'none') {
realValue = []
} else {
realValue = value.split(',').map(v => parseShadow(v.trim()))
realValue = value.split(',').map(v => deserializeShadow(v.trim()))
}
} if (!Number.isNaN(Number(value))) {
realValue = Number(value)

View file

@ -1,8 +1,13 @@
import { unroll } from './iss_utils.js'
import { deserializeShadow } from './iss_deserializer.js'
const serializeShadow = s => {
export const serializeShadow = (s, throwOnInvalid) => {
if (typeof s === 'object') {
return `${s.inset ? 'inset ' : ''}${s.x} ${s.y} ${s.blur} ${s.spread} ${s.color} / ${s.alpha}`
const inset = s.inset ? 'inset ' : ''
const name = s.name ? ` #${s.name} ` : ''
const result = `${inset}${s.x} ${s.y} ${s.blur} ${s.spread} ${s.color} / ${s.alpha}${name}`
deserializeShadow(result) // Verify that output is valid and parseable
return result
} else {
return s
}

View file

@ -56,43 +56,74 @@ export const getAllPossibleCombinations = (array) => {
*
* @returns {String} CSS selector (or path)
*/
export const genericRuleToSelector = components => (rule, ignoreOutOfTreeSelector, isParent) => {
export const genericRuleToSelector = components => (rule, ignoreOutOfTreeSelector, liteMode, children) => {
const isParent = !!children
if (!rule && !isParent) return null
const component = components[rule.component]
const { states = {}, variants = {}, selector, outOfTreeSelector } = component
const { states = {}, variants = {}, outOfTreeSelector } = component
const applicableStates = ((rule.state || []).filter(x => x !== 'normal')).map(state => states[state])
const expand = (array = [], subArray = []) => {
if (array.length === 0) return subArray.map(x => [x])
if (subArray.length === 0) return array.map(x => [x])
return array.map(a => {
return subArray.map(b => [a, b])
}).flat()
}
let componentSelectors = Array.isArray(component.selector) ? component.selector : [component.selector]
if (ignoreOutOfTreeSelector || liteMode) componentSelectors = [componentSelectors[0]]
componentSelectors = componentSelectors.map(selector => {
if (selector === ':root') {
return ''
} else if (isParent) {
return selector
} else {
if (outOfTreeSelector && !ignoreOutOfTreeSelector) return outOfTreeSelector
return selector
}
})
const applicableVariantName = (rule.variant || 'normal')
let applicableVariant = ''
let variantSelectors = null
if (applicableVariantName !== 'normal') {
applicableVariant = variants[applicableVariantName]
variantSelectors = variants[applicableVariantName]
} else {
applicableVariant = variants?.normal ?? ''
variantSelectors = variants?.normal ?? ''
}
variantSelectors = Array.isArray(variantSelectors) ? variantSelectors : [variantSelectors]
if (ignoreOutOfTreeSelector || liteMode) variantSelectors = [variantSelectors[0]]
let realSelector
if (selector === ':root') {
realSelector = ''
} else if (isParent) {
realSelector = selector
} else {
if (outOfTreeSelector && !ignoreOutOfTreeSelector) realSelector = outOfTreeSelector
else realSelector = selector
}
const applicableStates = (rule.state || []).filter(x => x !== 'normal')
// const applicableStates = (rule.state || [])
const statesSelectors = applicableStates.map(state => {
const selector = states[state] || ''
let arraySelector = Array.isArray(selector) ? selector : [selector]
if (ignoreOutOfTreeSelector || liteMode) arraySelector = [arraySelector[0]]
arraySelector
.sort((a, b) => {
if (a.startsWith(':')) return 1
if (/^[a-z]/.exec(a)) return -1
else return 0
})
.join('')
return arraySelector
})
const selectors = [realSelector, applicableVariant, ...applicableStates]
.sort((a, b) => {
if (a.startsWith(':')) return 1
if (/^[a-z]/.exec(a)) return -1
else return 0
})
.join('')
const statesSelectorsFlat = statesSelectors.reduce((acc, s) => {
return expand(acc, s).map(st => st.join(''))
}, [])
const componentVariant = expand(componentSelectors, variantSelectors).map(cv => cv.join(''))
const componentVariantStates = expand(componentVariant, statesSelectorsFlat).map(cvs => cvs.join(''))
const selectors = expand(componentVariantStates, children).map(cvsc => cvsc.join(' '))
/*
*/
if (rule.parent) {
return (genericRuleToSelector(components)(rule.parent, ignoreOutOfTreeSelector, true) + ' ' + selectors).trim()
return genericRuleToSelector(components)(rule.parent, ignoreOutOfTreeSelector, liteMode, selectors)
}
return selectors.trim()
return selectors.join(', ').trim()
}
/**

View file

@ -354,10 +354,6 @@ export const convertTheme2To3 = (data) => {
newRules.push({ ...rule, state: ['toggled'] })
newRules.push({ ...rule, state: ['toggled', 'focus'] })
newRules.push({ ...rule, state: ['pressed', 'focus'] })
}
if (key === 'buttonHover') {
newRules.push({ ...rule, state: ['toggled', 'hover'] })
newRules.push({ ...rule, state: ['pressed', 'hover'] })
newRules.push({ ...rule, state: ['toggled', 'focus', 'hover'] })
newRules.push({ ...rule, state: ['pressed', 'focus', 'hover'] })
}

View file

@ -3,7 +3,7 @@ import { alphaBlend, getTextColor, relativeLuminance } from '../color_convert/co
export const process = (text, functions, { findColor, findShadow }, { dynamicVars, staticVars }) => {
const { funcName, argsString } = /\$(?<funcName>\w+)\((?<argsString>[#a-zA-Z0-9-,.'"\s]*)\)/.exec(text).groups
const args = argsString.split(/,/g).map(a => a.trim())
const args = argsString.split(/ /g).map(a => a.trim())
const func = functions[funcName]
if (args.length < func.argsNeeded) {
@ -15,6 +15,11 @@ export const process = (text, functions, { findColor, findShadow }, { dynamicVar
export const colorFunctions = {
alpha: {
argsNeeded: 2,
documentation: 'Changes alpha value of the color only to be used for CSS variables',
args: [
'color: source color used',
'amount: alpha value'
],
exec: (args, { findColor }, { dynamicVars, staticVars }) => {
const [color, amountArg] = args
@ -23,8 +28,32 @@ export const colorFunctions = {
return { ...colorArg, a: amount }
}
},
brightness: {
argsNeeded: 2,
document: 'Changes brightness/lightness of color in HSL colorspace',
args: [
'color: source color used',
'amount: lightness value'
],
exec: (args, { findColor }, { dynamicVars, staticVars }) => {
const [color, amountArg] = args
const colorArg = convert(findColor(color, { dynamicVars, staticVars })).hsl
colorArg.l += Number(amountArg)
return { ...convert(colorArg).rgb }
}
},
textColor: {
argsNeeded: 2,
documentation: 'Get text color with adequate contrast for given background and intended text color. Same function is used internally',
args: [
'background: color of backdrop where text will be shown',
'foreground: intended text color',
`[preserve]: (optional) intended color preservation:
'preserve' - try to preserve the color
'no-preserve' - if can't get adequate color - fall back to black or white
'no-auto' - don't do anything (useless as a color function)`
],
exec: (args, { findColor }, { dynamicVars, staticVars }) => {
const [backgroundArg, foregroundArg, preserve = 'preserve'] = args
@ -36,6 +65,12 @@ export const colorFunctions = {
},
blend: {
argsNeeded: 3,
documentation: 'Alpha blending between two colors',
args: [
'background: bottom layer color',
'amount: opacity of top layer',
'foreground: upper layer color'
],
exec: (args, { findColor }, { dynamicVars, staticVars }) => {
const [backgroundArg, amountArg, foregroundArg] = args
@ -48,6 +83,11 @@ export const colorFunctions = {
},
mod: {
argsNeeded: 2,
documentation: 'Old function that increases or decreases brightness depending if color is dark or light. Advised against using it as it might give unexpected results.',
args: [
'color: source color',
'amount: how much darken/brighten the color'
],
exec: (args, { findColor }, { dynamicVars, staticVars }) => {
const [colorArg, amountArg] = args
@ -65,6 +105,13 @@ export const colorFunctions = {
export const shadowFunctions = {
borderSide: {
argsNeeded: 3,
documentation: 'Simulate a border on a side with a shadow, best works on inset border',
args: [
'color: border color',
'side: string indicating on which side border should be, takes either one word or two words joined by dash (i.e. "left" or "bottom-right")',
'[alpha]: (Optional) border opacity, defaults to 1 (fully opaque)',
'[inset]: (Optional) whether border should be on the inside or outside, defaults to inside'
],
exec: (args, { findColor }) => {
const [color, side, alpha = '1', widthArg = '1', inset = 'inset'] = args

View file

@ -22,7 +22,7 @@ import {
normalizeCombination,
findRules
} from './iss_utils.js'
import { parseCssShadow } from './css_utils.js'
import { deserializeShadow } from './iss_deserializer.js'
// Ensuring the order of components
const components = {
@ -37,18 +37,18 @@ const components = {
ChatMessage: null
}
const findShadow = (shadows, { dynamicVars, staticVars }) => {
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('--')) {
const [variable] = shadow.split(/,/g).map(str => str.trim()) // discarding modifier since it's not supported
const variableSlot = variable.substring(2)
// modifiers are completely unsupported here
const variableSlot = shadow.substring(2)
return findShadow(staticVars[variableSlot], { dynamicVars, staticVars })
} else {
targetShadow = parseCssShadow(shadow)
targetShadow = deserializeShadow(shadow)
}
} else {
targetShadow = shadow
@ -62,54 +62,63 @@ const findShadow = (shadows, { dynamicVars, staticVars }) => {
})
}
const findColor = (color, { dynamicVars, staticVars }) => {
if (typeof color !== 'string' || (!color.startsWith('--') && !color.startsWith('$'))) return color
let targetColor = null
if (color.startsWith('--')) {
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
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 virtualSlot = variableSlot.replace(/^parent/, '')
targetColor = convert(dynamicVars.lowerLevelVirtualDirectivesRaw[virtualSlot]).rgb
switch (variableSlot) {
case 'inheritedBackground':
targetColor = convert(dynamicVars.inheritedBackground).rgb
break
case 'background':
targetColor = convert(dynamicVars.background).rgb
break
default:
targetColor = convert(staticVars[variableSlot]).rgb
}
}
} else {
switch (variableSlot) {
case 'inheritedBackground':
targetColor = convert(dynamicVars.inheritedBackground).rgb
break
case 'background':
targetColor = convert(dynamicVars.background).rgb
break
default:
targetColor = convert(staticVars[variableSlot]).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 (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)
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)}`)
}
if (color.startsWith('$')) {
try {
targetColor = process(color, colorFunctions, { findColor }, { dynamicVars, staticVars })
} catch (e) {
console.error('Failure executing color function', e)
targetColor = '#FF00FF'
}
}
// Color references other color
return targetColor
}
const getTextColorAlpha = (directives, intendedTextColor, dynamicVars, staticVars) => {
@ -164,19 +173,19 @@ export const getEngineChecksum = () => engineChecksum
* @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
* @param {string} rootComponentName - [UNTESTED] which component to start from, meant for previewing a
* part of the theme (i.e. just the button) for themes 3 editor.
*/
export const init = ({
inputRuleset,
ultimateBackgroundColor,
debug = false,
liteMode = false,
editMode = false,
onlyNormalState = false,
rootComponentName = 'Root'
initialStaticVars = {}
}) => {
const rootComponentName = 'Root'
if (!inputRuleset) throw new Error('Ruleset is null or undefined!')
const staticVars = {}
const staticVars = { ...initialStaticVars }
const stacked = {}
const computed = {}
@ -218,8 +227,8 @@ export const init = ({
bScore += b.component === 'Text' ? 1 : 0
// Debug
a.specifityScore = aScore
b.specifityScore = bScore
a._specificityScore = aScore
b._specificityScore = bScore
if (aScore === bScore) {
return ai - bi
@ -228,211 +237,227 @@ export const init = ({
})
.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 nonEditableComponents = new Set(Object.values(components).filter(c => c.notEditable).map(c => c.name))
const processCombination = (combination) => {
const selector = ruleToSelector(combination, true)
const cssSelector = ruleToSelector(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 parentSelector = selector.split(/ /g).slice(0, -1).join(' ')
const soloSelector = selector.split(/ /g).slice(-1)[0]
const lowerLevelSelector = parentSelector
const lowerLevelBackground = computed[lowerLevelSelector]?.background
const lowerLevelVirtualDirectives = computed[lowerLevelSelector]?.virtualDirectives
const lowerLevelVirtualDirectivesRaw = computed[lowerLevelSelector]?.virtualDirectivesRaw
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] || {
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
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 dynamicVars = computed[selector] || {
lowerLevelBackground,
lowerLevelVirtualDirectives,
lowerLevelVirtualDirectivesRaw
}
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: {
[virtualName]: getTextColorAlpha(newTextRule.directives, textColor, dynamicVars)
},
virtualDirectivesRaw: {
[virtualName]: textColor
}
}
} 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, v]) => 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[0], { dynamicVars, staticVars })
dynamicVars[k] = color
if (combination.component === 'Root') {
staticVars[k.substring(2)] = color
}
break
}
case 'shadow': {
const shadow = value
dynamicVars[k] = shadow
if (combination.component === 'Root') {
staticVars[k.substring(2)] = shadow
}
break
}
case 'generic': {
dynamicVars[k] = value
if (combination.component === 'Root') {
staticVars[k.substring(2)] = value
}
break
}
}
})
const rule = {
dynamicVars,
selector: cssSelector,
// 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
}
return rule
computed[selector] = computed[selector] || {}
computed[selector].computedRule = computedRule
computed[selector].dynamicVars = dynamicVars
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: {
[virtualName]: getTextColorAlpha(newTextRule.directives, textColor, dynamicVars)
},
virtualDirectivesRaw: {
[virtualName]: textColor
}
}
} 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, v]) => 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}`)
}
}
@ -443,11 +468,15 @@ export const init = ({
variants: originalVariants = {}
} = component
const validInnerComponents = (
liteMode
? (component.validInnerComponentsLite || component.validInnerComponents)
: component.validInnerComponents
) || []
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 {
validInnerComponents = component.validInnerComponents || []
}
// Normalizing states and variants to always include "normal"
const states = { normal: '', ...originalStates }
@ -489,7 +518,7 @@ export const init = ({
combination.component = component.name
combination.lazy = component.lazy || parent?.lazy
combination.parent = parent
if (combination.state.indexOf('hover') >= 0) {
if (!liteMode && combination.state.indexOf('hover') >= 0) {
combination.lazy = true
}
@ -538,6 +567,7 @@ export const init = ({
lazy,
eager,
staticVars,
engineChecksum
engineChecksum,
themeChecksum: sum([lazy, eager])
}
}