import { convert, brightness, contrastRatio } from 'chromatism' import { rgb2hex, rgba2css, alphaBlendLayers, getTextColor, relativeLuminance, getCssColor } from '../color_convert/color_convert.js' import { LAYERS, DEFAULT_OPACITY, SLOT_INHERITANCE } from './pleromafe.js' /* * # What's all this? * Here be theme engine for pleromafe. All of this supposed to ease look * and feel customization, making widget styles and make developer's life * easier when it comes to supporting themes. Like many other theme systems * it operates on color definitions, or "slots" - for example you define * "button" color slot and then in UI component Button's CSS you refer to * it as a CSS3 Variable. * * Some applications allow you to customize colors for certain things. * Some UI toolkits allow you to define colors for each type of widget. * Most of them are pretty barebones and have no assistance for common * problems and cases, and in general themes themselves are very hard to * maintain in all aspects. This theme engine tries to solve all of the * common problems with themes. * * You don't have redefine several similar colors if you just want to * change one color - all color slots are derived from other ones, so you * can have at least one or two "basic" colors defined and have all other * components inherit and modify basic ones. * * You don't have to test contrast ratio for colors or pick text color for * each element even if you have light-on-dark elements in dark-on-light * theme. * * You don't have to maintain order of code for inheriting slots from othet * slots - dependency graph resolving does it for you. */ /* This indicates that this version of code outputs similar theme data and * should be incremented if output changes - for instance if getTextColor * function changes and older themes no longer render text colors as * author intended previously. */ export const CURRENT_VERSION = 3 export const getLayersArray = (layer, data = LAYERS) => { const array = [layer] let parent = data[layer] while (parent) { array.unshift(parent) parent = data[parent] } return array } export const getLayers = (layer, variant = layer, opacitySlot, colors, opacity) => { return getLayersArray(layer).map((currentLayer) => ([ currentLayer === layer ? colors[variant] : colors[currentLayer], currentLayer === layer ? opacity[opacitySlot] || 1 : opacity[currentLayer] ])) } const getDependencies = (key, inheritance) => { const data = inheritance[key] if (typeof data === 'string' && data.startsWith('--')) { return [data.substring(2)] } else { if (data === null) return [] const { depends, layer, variant } = data const layerDeps = layer ? getLayersArray(layer).map(currentLayer => { return currentLayer === layer ? variant || layer : currentLayer }) : [] if (Array.isArray(depends)) { return [...depends, ...layerDeps] } else { return [...layerDeps] } } } /** * Sorts inheritance object topologically - dependant slots come after * dependencies * * @property {Object} inheritance - object defining the nodes * @property {Function} getDeps - function that returns dependencies for * given value and inheritance object. * @returns {String[]} keys of inheritance object, sorted in topological * order. Additionally, dependency-less nodes will always be first in line */ export const topoSort = ( inheritance = SLOT_INHERITANCE, getDeps = getDependencies ) => { // This is an implementation of https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm const allKeys = Object.keys(inheritance) const whites = new Set(allKeys) const grays = new Set() const blacks = new Set() const unprocessed = [...allKeys] const output = [] const step = (node) => { if (whites.has(node)) { // Make node "gray" whites.delete(node) grays.add(node) // Do step for each node connected to it (one way) getDeps(node, inheritance).forEach(step) // Make node "black" grays.delete(node) blacks.add(node) // Put it into the output list output.push(node) } else if (grays.has(node)) { output.push(node) } else if (blacks.has(node)) { // do nothing } else { throw new Error('Unintended condition in topoSort!') } } while (unprocessed.length > 0) { step(unprocessed.pop()) } // The index thing is to make sorting stable on browsers // where Array.sort() isn't stable return output.map((data, index) => ({ data, index })).sort(({ data: a, index: ai }, { data: b, index: bi }) => { const depsA = getDeps(a, inheritance).length const depsB = getDeps(b, inheritance).length if (depsA === depsB || (depsB !== 0 && depsA !== 0)) return ai - bi if (depsA === 0 && depsB !== 0) return -1 if (depsB === 0 && depsA !== 0) return 1 return 0 // failsafe, shouldn't happen? }).map(({ data }) => data) } const expandSlotValue = (value) => { if (typeof value === 'object') return value return { depends: value.startsWith('--') ? [value.substring(2)] : [], default: value.startsWith('#') ? value : undefined } } /** * retrieves opacity slot for given slot. This goes up the depenency graph * to find which parent has opacity slot defined for it. * TODO refactor this */ export const getOpacitySlot = ( k, inheritance = SLOT_INHERITANCE, getDeps = getDependencies ) => { const value = expandSlotValue(inheritance[k]) if (value.opacity === null) return if (value.opacity) return value.opacity const findInheritedOpacity = (key, visited = [k]) => { const depSlot = getDeps(key, inheritance)[0] if (depSlot === undefined) return const dependency = inheritance[depSlot] if (dependency === undefined) return if (dependency.opacity || dependency === null) { return dependency.opacity } else if (dependency.depends && visited.includes(depSlot)) { return findInheritedOpacity(depSlot, [...visited, depSlot]) } else { return null } } if (value.depends) { return findInheritedOpacity(k) } } /** * retrieves layer slot for given slot. This goes up the depenency graph * to find which parent has opacity slot defined for it. * this is basically copypaste of getOpacitySlot except it checks if key is * in LAYERS * TODO refactor this */ export const getLayerSlot = ( k, inheritance = SLOT_INHERITANCE, getDeps = getDependencies ) => { const value = expandSlotValue(inheritance[k]) if (LAYERS[k]) return k if (value.layer === null) return if (value.layer) return value.layer const findInheritedLayer = (key, visited = [k]) => { const depSlot = getDeps(key, inheritance)[0] if (depSlot === undefined) return const dependency = inheritance[depSlot] if (dependency === undefined) return if (dependency.layer || dependency === null) { return dependency.layer } else if (dependency.depends) { return findInheritedLayer(dependency, [...visited, depSlot]) } else { return null } } if (value.depends) { return findInheritedLayer(k) } } /** * topologically sorted SLOT_INHERITANCE */ export const SLOT_ORDERED = topoSort( Object.entries(SLOT_INHERITANCE) .sort(([aK, aV], [bK, bV]) => ((aV && aV.priority) || 0) - ((bV && bV.priority) || 0)) .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}) ) /** * All opacity slots used in color slots, their default values and affected * color slots. */ export const OPACITIES = Object.entries(SLOT_INHERITANCE).reduce((acc, [k, v]) => { const opacity = getOpacitySlot(k, SLOT_INHERITANCE, getDependencies) if (opacity) { return { ...acc, [opacity]: { defaultValue: DEFAULT_OPACITY[opacity] || 1, affectedSlots: [...((acc[opacity] && acc[opacity].affectedSlots) || []), k] } } } else { return acc } }, {}) /** * Handle dynamic color */ export const computeDynamicColor = (sourceColor, getColor, mod) => { if (typeof sourceColor !== 'string' || !sourceColor.startsWith('--')) return sourceColor let targetColor = null // Color references other color const [variable, modifier] = sourceColor.split(/,/g).map(str => str.trim()) const variableSlot = variable.substring(2) targetColor = getColor(variableSlot) if (modifier) { targetColor = brightness(Number.parseFloat(modifier) * mod, targetColor).rgb } return targetColor } /** * THE function you want to use. Takes provided colors and opacities * value and uses inheritance data to figure out color needed for the slot. */ export const getColors = (sourceColors, sourceOpacity) => SLOT_ORDERED.reduce(({ colors, opacity }, key) => { const sourceColor = sourceColors[key] const value = expandSlotValue(SLOT_INHERITANCE[key]) const deps = getDependencies(key, SLOT_INHERITANCE) const isTextColor = !!value.textColor const variant = value.variant || value.layer let backgroundColor = null if (isTextColor) { backgroundColor = alphaBlendLayers( { ...(colors[deps[0]] || convert(sourceColors[key] || '#FF00FF').rgb) }, getLayers( getLayerSlot(key) || 'bg', variant || 'bg', getOpacitySlot(variant), colors, opacity ) ) } else if (variant && variant !== key) { backgroundColor = colors[variant] || convert(sourceColors[variant]).rgb } else { backgroundColor = colors.bg || convert(sourceColors.bg) } const isLightOnDark = relativeLuminance(backgroundColor) < 0.5 const mod = isLightOnDark ? 1 : -1 let outputColor = null if (sourceColor) { // Color is defined in source color let targetColor = sourceColor if (targetColor === 'transparent') { // We take only layers below current one const layers = getLayers( getLayerSlot(key), key, getOpacitySlot(key) || key, colors, opacity ).slice(0, -1) targetColor = { ...alphaBlendLayers( convert('#FF00FF').rgb, layers ), a: 0 } } else if (typeof sourceColor === 'string' && sourceColor.startsWith('--')) { targetColor = computeDynamicColor( sourceColor, variableSlot => colors[variableSlot] || sourceColors[variableSlot], mod ) } else if (typeof sourceColor === 'string' && sourceColor.startsWith('#')) { targetColor = convert(targetColor).rgb } outputColor = { ...targetColor } } else if (value.default) { // same as above except in object form outputColor = convert(value.default).rgb } else { // calculate color const defaultColorFunc = (mod, dep) => ({ ...dep }) const colorFunc = value.color || defaultColorFunc if (value.textColor) { if (value.textColor === 'bw') { outputColor = contrastRatio(backgroundColor).rgb } else { let color = { ...colors[deps[0]] } if (value.color) { color = colorFunc(mod, ...deps.map((dep) => ({ ...colors[dep] }))) } outputColor = getTextColor( backgroundColor, { ...color }, value.textColor === 'preserve' ) } } else { // background color case outputColor = colorFunc( mod, ...deps.map((dep) => ({ ...colors[dep] })) ) } } if (!outputColor) { throw new Error('Couldn\'t generate color for ' + key) } const opacitySlot = value.opacity || getOpacitySlot(key) const ownOpacitySlot = value.opacity if (ownOpacitySlot === null) { outputColor.a = 1 } else if (sourceColor === 'transparent') { outputColor.a = 0 } else { const opacityOverriden = ownOpacitySlot && sourceOpacity[opacitySlot] !== undefined const dependencySlot = deps[0] const dependencyColor = dependencySlot && colors[dependencySlot] if (!ownOpacitySlot && dependencyColor && !value.textColor && ownOpacitySlot !== null) { // Inheriting color from dependency (weird, i know) // except if it's a text color or opacity slot is set to 'null' outputColor.a = dependencyColor.a } else if (!dependencyColor && !opacitySlot) { // Remove any alpha channel if no dependency and no opacitySlot found delete outputColor.a } else { // Otherwise try to assign opacity if (dependencyColor && dependencyColor.a === 0) { // transparent dependency shall make dependents transparent too outputColor.a = 0 } else { // Otherwise check if opacity is overriden and use that or default value instead outputColor.a = Number( opacityOverriden ? sourceOpacity[opacitySlot] : (OPACITIES[opacitySlot] || {}).defaultValue ) } } } if (Number.isNaN(outputColor.a) || outputColor.a === undefined) { outputColor.a = 1 } if (opacitySlot) { return { colors: { ...colors, [key]: outputColor }, opacity: { ...opacity, [opacitySlot]: outputColor.a } } } else { return { colors: { ...colors, [key]: outputColor }, opacity } } }, { colors: {}, opacity: {} }) export const composePreset = (colors, radii, shadows, fonts) => { return { rules: { ...shadows.rules, ...colors.rules, ...radii.rules, ...fonts.rules }, theme: { ...shadows.theme, ...colors.theme, ...radii.theme, ...fonts.theme } } } export const generatePreset = (input) => { const colors = generateColors(input) return composePreset( colors, generateRadii(input), generateShadows(input, colors.theme.colors, colors.mod), generateFonts(input) ) } export const getCssShadow = (input, usesDropShadow) => { if (input.length === 0) { return 'none' } return input .filter(_ => usesDropShadow ? _.inset : _) .map((shad) => [ shad.x, shad.y, shad.blur, shad.spread ].map(_ => _ + 'px').concat([ getCssColor(shad.color, shad.alpha), shad.inset ? 'inset' : '' ]).join(' ')).join(', ') } const getCssShadowFilter = (input) => { if (input.length === 0) { return 'none' } return input // drop-shadow doesn't support inset or spread .filter((shad) => !shad.inset && Number(shad.spread) === 0) .map((shad) => [ shad.x, shad.y, // drop-shadow's blur is twice as strong compared to box-shadow shad.blur / 2 ].map(_ => _ + 'px').concat([ getCssColor(shad.color, shad.alpha) ]).join(' ')) .map(_ => `drop-shadow(${_})`) .join(' ') } export const generateColors = (themeData) => { const sourceColors = !themeData.themeEngineVersion ? colors2to3(themeData.colors || themeData) : themeData.colors || themeData const { colors, opacity } = getColors(sourceColors, themeData.opacity || {}) const htmlColors = Object.entries(colors) .reduce((acc, [k, v]) => { if (!v) return acc acc.solid[k] = rgb2hex(v) acc.complete[k] = typeof v.a === 'undefined' ? rgb2hex(v) : rgba2css(v) return acc }, { complete: {}, solid: {} }) return { rules: { colors: Object.entries(htmlColors.complete) .filter(([k, v]) => v) .map(([k, v]) => `--${k}: ${v}`) .join(';') }, theme: { colors: htmlColors.solid, opacity } } } export const generateRadii = (input) => { let inputRadii = input.radii || {} // v1 -> v2 if (typeof input.btnRadius !== 'undefined') { inputRadii = Object .entries(input) .filter(([k, v]) => k.endsWith('Radius')) .reduce((acc, e) => { acc[e[0].split('Radius')[0]] = e[1]; return acc }, {}) } const radii = Object.entries(inputRadii).filter(([k, v]) => v).reduce((acc, [k, v]) => { acc[k] = v return acc }, { btn: 4, input: 4, checkbox: 2, panel: 10, avatar: 5, avatarAlt: 50, tooltip: 2, attachment: 5, chatMessage: inputRadii.panel }) return { rules: { radii: Object.entries(radii).filter(([k, v]) => v).map(([k, v]) => `--${k}Radius: ${v}px`).join(';') }, theme: { radii } } } export const generateFonts = (input) => { const fonts = Object.entries(input.fonts || {}).filter(([k, v]) => v).reduce((acc, [k, v]) => { acc[k] = Object.entries(v).filter(([k, v]) => v).reduce((acc, [k, v]) => { acc[k] = v return acc }, acc[k]) return acc }, { interface: { family: 'sans-serif' }, input: { family: 'inherit' }, post: { family: 'inherit' }, postCode: { family: 'monospace' } }) return { rules: { fonts: Object .entries(fonts) .filter(([k, v]) => v) .map(([k, v]) => `--${k}Font: ${v.family}`).join(';') }, theme: { fonts } } } const border = (top, shadow) => ({ x: 0, y: top ? 1 : -1, blur: 0, spread: 0, color: shadow ? '#000000' : '#FFFFFF', alpha: 0.2, inset: true }) const buttonInsetFakeBorders = [border(true, false), border(false, true)] const inputInsetFakeBorders = [border(true, true), border(false, false)] const hoverGlow = { x: 0, y: 0, blur: 4, spread: 0, color: '--faint', alpha: 1 } export const DEFAULT_SHADOWS = { panel: [{ x: 1, y: 1, blur: 4, spread: 0, color: '#000000', alpha: 0.6 }], topBar: [{ x: 0, y: 0, blur: 4, spread: 0, color: '#000000', alpha: 0.6 }], popup: [{ x: 2, y: 2, blur: 3, spread: 0, color: '#000000', alpha: 0.5 }], avatar: [{ x: 0, y: 1, blur: 8, spread: 0, color: '#000000', alpha: 0.7 }], avatarStatus: [], panelHeader: [], button: [{ x: 0, y: 0, blur: 2, spread: 0, color: '#000000', alpha: 1 }, ...buttonInsetFakeBorders], buttonHover: [hoverGlow, ...buttonInsetFakeBorders], buttonPressed: [hoverGlow, ...inputInsetFakeBorders], input: [...inputInsetFakeBorders, { x: 0, y: 0, blur: 2, inset: true, spread: 0, color: '#000000', alpha: 1 }] } export const generateShadows = (input, colors) => { // TODO this is a small hack for `mod` to work with shadows // this is used to get the "context" of shadow, i.e. for `mod` properly depend on background color of element const hackContextDict = { button: 'btn', panel: 'bg', top: 'topBar', popup: 'popover', avatar: 'bg', panelHeader: 'panel', input: 'input' } const cleanInputShadows = Object.fromEntries( Object.entries(input.shadows || {}) .map(([name, shadowSlot]) => [ name, // defaulting color to black to avoid potential problems shadowSlot.map(shadowDef => ({ color: '#000000', ...shadowDef })) ]) ) const inputShadows = cleanInputShadows && !input.themeEngineVersion ? shadows2to3(cleanInputShadows, input.opacity) : cleanInputShadows || {} const shadows = Object.entries({ ...DEFAULT_SHADOWS, ...inputShadows }).reduce((shadowsAcc, [slotName, shadowDefs]) => { const slotFirstWord = slotName.replace(/[A-Z].*$/, '') const colorSlotName = hackContextDict[slotFirstWord] const isLightOnDark = relativeLuminance(convert(colors[colorSlotName]).rgb) < 0.5 const mod = isLightOnDark ? 1 : -1 const newShadow = shadowDefs.reduce((shadowAcc, def) => [ ...shadowAcc, { ...def, color: rgb2hex(computeDynamicColor( def.color, (variableSlot) => convert(colors[variableSlot]).rgb, mod )) } ], []) return { ...shadowsAcc, [slotName]: newShadow } }, {}) return { rules: { shadows: Object .entries(shadows) // TODO for v2.2: if shadow doesn't have non-inset shadows with spread > 0 - optionally // convert all non-inset shadows into filter: drop-shadow() to boost performance .map(([k, v]) => [ `--${k}Shadow: ${getCssShadow(v)}`, `--${k}ShadowFilter: ${getCssShadowFilter(v)}`, `--${k}ShadowInset: ${getCssShadow(v, true)}` ].join(';')) .join(';') }, theme: { shadows } } } /** * This handles compatibility issues when importing v2 theme's shadows to current format * * Back in v2 shadows allowed you to use dynamic colors however those used pure CSS3 variables */ export const shadows2to3 = (shadows, opacity) => { return Object.entries(shadows).reduce((shadowsAcc, [slotName, shadowDefs]) => { const isDynamic = ({ color = '#000000' }) => color.startsWith('--') const getOpacity = ({ color }) => opacity[getOpacitySlot(color.substring(2).split(',')[0])] const newShadow = shadowDefs.reduce((shadowAcc, def) => [ ...shadowAcc, { ...def, alpha: isDynamic(def) ? getOpacity(def) || 1 : def.alpha } ], []) return { ...shadowsAcc, [slotName]: newShadow } }, {}) } export const colors2to3 = (colors) => { return Object.entries(colors).reduce((acc, [slotName, color]) => { const btnPositions = ['', 'Panel', 'TopBar'] switch (slotName) { case 'lightBg': return { ...acc, highlight: color } case 'btnText': return { ...acc, ...btnPositions .reduce( (statePositionAcc, position) => ({ ...statePositionAcc, ['btn' + position + 'Text']: color }) , {} ) } default: return { ...acc, [slotName]: color } } }, {}) }