aboutsummaryrefslogtreecommitdiff
path: root/src/services/theme_data/theme_data.service.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/services/theme_data/theme_data.service.js')
-rw-r--r--src/services/theme_data/theme_data.service.js373
1 files changed, 373 insertions, 0 deletions
diff --git a/src/services/theme_data/theme_data.service.js b/src/services/theme_data/theme_data.service.js
new file mode 100644
index 00000000..75768795
--- /dev/null
+++ b/src/services/theme_data/theme_data.service.js
@@ -0,0 +1,373 @@
+import { convert, brightness, contrastRatio } from 'chromatism'
+import { alphaBlendLayers, getTextColor, relativeLuminance } 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) => {
+ let 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)) {
+ console.debug('Cyclic depenency in topoSort, ignoring')
+ 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())
+ }
+ return output.sort((a, b) => {
+ const depsA = getDeps(a, inheritance).length
+ const depsB = getDeps(b, inheritance).length
+
+ if (depsA === depsB || (depsB !== 0 && depsA !== 0)) return 0
+ if (depsA === 0 && depsB !== 0) return -1
+ if (depsB === 0 && depsA !== 0) return 1
+ })
+}
+
+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 = getOpacitySlot(key)
+ if (opacitySlot && outputColor.a === undefined) {
+ const dependencySlot = deps[0]
+ if (dependencySlot && colors[dependencySlot] === 'transparent') {
+ outputColor.a = 0
+ } else {
+ outputColor.a = Number(sourceOpacity[opacitySlot]) || OPACITIES[opacitySlot].defaultValue || 1
+ }
+ }
+ if (opacitySlot) {
+ return {
+ colors: { ...colors, [key]: outputColor },
+ opacity: { ...opacity, [opacitySlot]: outputColor.a }
+ }
+ } else {
+ return {
+ colors: { ...colors, [key]: outputColor },
+ opacity
+ }
+ }
+}, { colors: {}, opacity: {} })