logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe git clone https://anongit.hacktivis.me/git/pleroma-fe.git/

theme_data_3.service.js (20825B)


  1. import { convert, brightness } from 'chromatism'
  2. import sum from 'hash-sum'
  3. import { flattenDeep, sortBy } from 'lodash'
  4. import {
  5. alphaBlend,
  6. getTextColor,
  7. rgba2css,
  8. mixrgb,
  9. relativeLuminance
  10. } from '../color_convert/color_convert.js'
  11. import {
  12. colorFunctions,
  13. shadowFunctions,
  14. process
  15. } from './theme3_slot_functions.js'
  16. import {
  17. unroll,
  18. getAllPossibleCombinations,
  19. genericRuleToSelector,
  20. normalizeCombination,
  21. findRules
  22. } from './iss_utils.js'
  23. import { deserializeShadow } from './iss_deserializer.js'
  24. // Ensuring the order of components
  25. const components = {
  26. Root: null,
  27. Text: null,
  28. FunText: null,
  29. Link: null,
  30. Icon: null,
  31. Border: null,
  32. Panel: null,
  33. Chat: null,
  34. ChatMessage: null
  35. }
  36. export const findShadow = (shadows, { dynamicVars, staticVars }) => {
  37. return (shadows || []).map(shadow => {
  38. let targetShadow
  39. if (typeof shadow === 'string') {
  40. if (shadow.startsWith('$')) {
  41. targetShadow = process(shadow, shadowFunctions, { findColor, findShadow }, { dynamicVars, staticVars })
  42. } else if (shadow.startsWith('--')) {
  43. // modifiers are completely unsupported here
  44. const variableSlot = shadow.substring(2)
  45. return findShadow(staticVars[variableSlot], { dynamicVars, staticVars })
  46. } else {
  47. targetShadow = deserializeShadow(shadow)
  48. }
  49. } else {
  50. targetShadow = shadow
  51. }
  52. const shadowArray = Array.isArray(targetShadow) ? targetShadow : [targetShadow]
  53. return shadowArray.map(s => ({
  54. ...s,
  55. color: findColor(s.color, { dynamicVars, staticVars })
  56. }))
  57. })
  58. }
  59. export const findColor = (color, { dynamicVars, staticVars }) => {
  60. try {
  61. if (typeof color !== 'string' || (!color.startsWith('--') && !color.startsWith('$'))) return color
  62. let targetColor = null
  63. if (color.startsWith('--')) {
  64. // Modifier support is pretty much for v2 themes only
  65. const [variable, modifier] = color.split(/,/g).map(str => str.trim())
  66. const variableSlot = variable.substring(2)
  67. if (variableSlot === 'stack') {
  68. const { r, g, b } = dynamicVars.stacked
  69. targetColor = { r, g, b }
  70. } else if (variableSlot.startsWith('parent')) {
  71. if (variableSlot === 'parent') {
  72. const { r, g, b } = dynamicVars.lowerLevelBackground
  73. targetColor = { r, g, b }
  74. } else {
  75. const virtualSlot = variableSlot.replace(/^parent/, '')
  76. targetColor = convert(dynamicVars.lowerLevelVirtualDirectivesRaw[virtualSlot]).rgb
  77. }
  78. } else {
  79. switch (variableSlot) {
  80. case 'inheritedBackground':
  81. targetColor = convert(dynamicVars.inheritedBackground).rgb
  82. break
  83. case 'background':
  84. targetColor = convert(dynamicVars.background).rgb
  85. break
  86. default:
  87. targetColor = convert(staticVars[variableSlot]).rgb
  88. }
  89. }
  90. if (modifier) {
  91. const effectiveBackground = dynamicVars.lowerLevelBackground ?? targetColor
  92. const isLightOnDark = relativeLuminance(convert(effectiveBackground).rgb) < 0.5
  93. const mod = isLightOnDark ? 1 : -1
  94. targetColor = brightness(Number.parseFloat(modifier) * mod, targetColor).rgb
  95. }
  96. }
  97. if (color.startsWith('$')) {
  98. try {
  99. targetColor = process(color, colorFunctions, { findColor }, { dynamicVars, staticVars })
  100. } catch (e) {
  101. console.error('Failure executing color function', e)
  102. targetColor = '#FF00FF'
  103. }
  104. }
  105. // Color references other color
  106. return targetColor
  107. } catch (e) {
  108. throw new Error(`Couldn't find color "${color}", variables are:
  109. Static:
  110. ${JSON.stringify(staticVars, null, 2)}
  111. Dynamic:
  112. ${JSON.stringify(dynamicVars, null, 2)}`)
  113. }
  114. }
  115. const getTextColorAlpha = (directives, intendedTextColor, dynamicVars, staticVars) => {
  116. const opacity = directives.textOpacity
  117. const backgroundColor = convert(dynamicVars.lowerLevelBackground).rgb
  118. const textColor = convert(findColor(intendedTextColor, { dynamicVars, staticVars })).rgb
  119. if (opacity === null || opacity === undefined || opacity >= 1) {
  120. return convert(textColor).hex
  121. }
  122. if (opacity === 0) {
  123. return convert(backgroundColor).hex
  124. }
  125. const opacityMode = directives.textOpacityMode
  126. switch (opacityMode) {
  127. case 'fake':
  128. return convert(alphaBlend(textColor, opacity, backgroundColor)).hex
  129. case 'mixrgb':
  130. return convert(mixrgb(backgroundColor, textColor)).hex
  131. default:
  132. return rgba2css({ a: opacity, ...textColor })
  133. }
  134. }
  135. // Loading all style.js[on] files dynamically
  136. const componentsContext = require.context('src', true, /\.style.js(on)?$/)
  137. componentsContext.keys().forEach(key => {
  138. const component = componentsContext(key).default
  139. if (components[component.name] != null) {
  140. console.warn(`Component in file ${key} is trying to override existing component ${component.name}! You have collisions/duplicates!`)
  141. }
  142. components[component.name] = component
  143. })
  144. const engineChecksum = sum(components)
  145. const ruleToSelector = genericRuleToSelector(components)
  146. export const getEngineChecksum = () => engineChecksum
  147. /**
  148. * Initializes and compiles the theme according to the ruleset
  149. *
  150. * @param {Object[]} inputRuleset - set of rules to compile theme against. Acts as an override to
  151. * component default rulesets
  152. * @param {string} ultimateBackgroundColor - Color that will be the "final" background for
  153. * calculating contrast ratios and making text automatically accessible. Really used for cases when
  154. * stuff is transparent.
  155. * @param {boolean} debug - print out debug information in console, mostly just performance stuff
  156. * @param {boolean} liteMode - use validInnerComponentsLite instead of validInnerComponents, meant to
  157. * generatate theme previews and such that need to be compiled faster and don't require a lot of other
  158. * components present in "normal" mode
  159. * @param {boolean} onlyNormalState - only use components 'normal' states, meant for generating theme
  160. * previews since states are the biggest factor for compilation time and are completely unnecessary
  161. * when previewing multiple themes at same time
  162. */
  163. export const init = ({
  164. inputRuleset,
  165. ultimateBackgroundColor,
  166. debug = false,
  167. liteMode = false,
  168. editMode = false,
  169. onlyNormalState = false,
  170. initialStaticVars = {}
  171. }) => {
  172. const rootComponentName = 'Root'
  173. if (!inputRuleset) throw new Error('Ruleset is null or undefined!')
  174. const staticVars = { ...initialStaticVars }
  175. const stacked = {}
  176. const computed = {}
  177. const rulesetUnsorted = [
  178. ...Object.values(components)
  179. .map(c => (c.defaultRules || []).map(r => ({ source: 'Built-in', component: c.name, ...r })))
  180. .reduce((acc, arr) => [...acc, ...arr], []),
  181. ...inputRuleset
  182. ].map(rule => {
  183. normalizeCombination(rule)
  184. let currentParent = rule.parent
  185. while (currentParent) {
  186. normalizeCombination(currentParent)
  187. currentParent = currentParent.parent
  188. }
  189. return rule
  190. })
  191. const ruleset = rulesetUnsorted
  192. .map((data, index) => ({ data, index }))
  193. .toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
  194. const parentsA = unroll(a).length
  195. const parentsB = unroll(b).length
  196. let aScore = 0
  197. let bScore = 0
  198. aScore += parentsA * 1000
  199. bScore += parentsB * 1000
  200. aScore += a.variant !== 'normal' ? 100 : 0
  201. bScore += b.variant !== 'normal' ? 100 : 0
  202. aScore += a.state.filter(x => x !== 'normal').length * 1000
  203. bScore += b.state.filter(x => x !== 'normal').length * 1000
  204. aScore += a.component === 'Text' ? 1 : 0
  205. bScore += b.component === 'Text' ? 1 : 0
  206. // Debug
  207. a._specificityScore = aScore
  208. b._specificityScore = bScore
  209. if (aScore === bScore) {
  210. return ai - bi
  211. }
  212. return aScore - bScore
  213. })
  214. .map(({ data }) => data)
  215. if (!ultimateBackgroundColor) {
  216. console.warn('No ultimate background color provided, falling back to panel color')
  217. const rootRule = ruleset.findLast((x) => (x.component === 'Root' && x.directives?.['--bg']))
  218. ultimateBackgroundColor = rootRule.directives['--bg'].split('|')[1].trim()
  219. }
  220. const virtualComponents = new Set(Object.values(components).filter(c => c.virtual).map(c => c.name))
  221. const nonEditableComponents = new Set(Object.values(components).filter(c => c.notEditable).map(c => c.name))
  222. const processCombination = (combination) => {
  223. try {
  224. const selector = ruleToSelector(combination, true)
  225. const cssSelector = ruleToSelector(combination)
  226. const parentSelector = selector.split(/ /g).slice(0, -1).join(' ')
  227. const soloSelector = selector.split(/ /g).slice(-1)[0]
  228. const lowerLevelSelector = parentSelector
  229. let lowerLevelBackground = computed[lowerLevelSelector]?.background
  230. if (editMode && !lowerLevelBackground) {
  231. // FIXME hack for editor until it supports handling component backgrounds
  232. lowerLevelBackground = '#00FFFF'
  233. }
  234. const lowerLevelVirtualDirectives = computed[lowerLevelSelector]?.virtualDirectives
  235. const lowerLevelVirtualDirectivesRaw = computed[lowerLevelSelector]?.virtualDirectivesRaw
  236. const dynamicVars = computed[selector] || {
  237. lowerLevelSelector,
  238. lowerLevelBackground,
  239. lowerLevelVirtualDirectives,
  240. lowerLevelVirtualDirectivesRaw
  241. }
  242. // Inheriting all of the applicable rules
  243. const existingRules = ruleset.filter(findRules(combination))
  244. const computedDirectives =
  245. existingRules
  246. .map(r => r.directives)
  247. .reduce((acc, directives) => ({ ...acc, ...directives }), {})
  248. const computedRule = {
  249. ...combination,
  250. directives: computedDirectives
  251. }
  252. computed[selector] = computed[selector] || {}
  253. computed[selector].computedRule = computedRule
  254. computed[selector].dynamicVars = dynamicVars
  255. // avoid putting more stuff into actual CSS
  256. computed[selector].virtualDirectives = {}
  257. // but still be able to access it i.e. from --parent
  258. computed[selector].virtualDirectivesRaw = computed[lowerLevelSelector]?.virtualDirectivesRaw || {}
  259. if (virtualComponents.has(combination.component)) {
  260. const virtualName = [
  261. '--',
  262. combination.component.toLowerCase(),
  263. combination.variant === 'normal'
  264. ? ''
  265. : combination.variant[0].toUpperCase() + combination.variant.slice(1).toLowerCase(),
  266. ...sortBy(combination.state.filter(x => x !== 'normal')).map(state => state[0].toUpperCase() + state.slice(1).toLowerCase())
  267. ].join('')
  268. let inheritedTextColor = computedDirectives.textColor
  269. let inheritedTextAuto = computedDirectives.textAuto
  270. let inheritedTextOpacity = computedDirectives.textOpacity
  271. let inheritedTextOpacityMode = computedDirectives.textOpacityMode
  272. const lowerLevelTextSelector = [...selector.split(/ /g).slice(0, -1), soloSelector].join(' ')
  273. const lowerLevelTextRule = computed[lowerLevelTextSelector]
  274. if (inheritedTextColor == null || inheritedTextOpacity == null || inheritedTextOpacityMode == null) {
  275. inheritedTextColor = computedDirectives.textColor ?? lowerLevelTextRule.textColor
  276. inheritedTextAuto = computedDirectives.textAuto ?? lowerLevelTextRule.textAuto
  277. inheritedTextOpacity = computedDirectives.textOpacity ?? lowerLevelTextRule.textOpacity
  278. inheritedTextOpacityMode = computedDirectives.textOpacityMode ?? lowerLevelTextRule.textOpacityMode
  279. }
  280. const newTextRule = {
  281. ...computedRule,
  282. directives: {
  283. ...computedRule.directives,
  284. textColor: inheritedTextColor,
  285. textAuto: inheritedTextAuto ?? 'preserve',
  286. textOpacity: inheritedTextOpacity,
  287. textOpacityMode: inheritedTextOpacityMode
  288. }
  289. }
  290. dynamicVars.inheritedBackground = lowerLevelBackground
  291. dynamicVars.stacked = convert(stacked[lowerLevelSelector]).rgb
  292. const intendedTextColor = convert(findColor(inheritedTextColor, { dynamicVars, staticVars })).rgb
  293. const textColor = newTextRule.directives.textAuto === 'no-auto'
  294. ? intendedTextColor
  295. : getTextColor(
  296. convert(stacked[lowerLevelSelector]).rgb,
  297. intendedTextColor,
  298. newTextRule.directives.textAuto === 'preserve'
  299. )
  300. const virtualDirectives = { ...(computed[lowerLevelSelector].virtualDirectives || {}) }
  301. const virtualDirectivesRaw = { ...(computed[lowerLevelSelector].virtualDirectivesRaw || {}) }
  302. // Storing color data in lower layer to use as custom css properties
  303. virtualDirectives[virtualName] = getTextColorAlpha(newTextRule.directives, textColor, dynamicVars)
  304. virtualDirectivesRaw[virtualName] = textColor
  305. computed[lowerLevelSelector].virtualDirectives = virtualDirectives
  306. computed[lowerLevelSelector].virtualDirectivesRaw = virtualDirectivesRaw
  307. return {
  308. dynamicVars,
  309. selector: cssSelector.split(/ /g).slice(0, -1).join(' '),
  310. ...combination,
  311. directives: {},
  312. virtualDirectives,
  313. virtualDirectivesRaw
  314. }
  315. } else {
  316. computed[selector] = computed[selector] || {}
  317. // TODO: DEFAULT TEXT COLOR
  318. const lowerLevelStackedBackground = stacked[lowerLevelSelector] || convert(ultimateBackgroundColor).rgb
  319. if (computedDirectives.background) {
  320. let inheritRule = null
  321. const variantRules = ruleset.filter(
  322. findRules({
  323. component: combination.component,
  324. variant: combination.variant,
  325. parent: combination.parent
  326. })
  327. )
  328. const lastVariantRule = variantRules[variantRules.length - 1]
  329. if (lastVariantRule) {
  330. inheritRule = lastVariantRule
  331. } else {
  332. const normalRules = ruleset.filter(findRules({
  333. component: combination.component,
  334. parent: combination.parent
  335. }))
  336. const lastNormalRule = normalRules[normalRules.length - 1]
  337. inheritRule = lastNormalRule
  338. }
  339. const inheritSelector = ruleToSelector({ ...inheritRule, parent: combination.parent }, true)
  340. const inheritedBackground = computed[inheritSelector].background
  341. dynamicVars.inheritedBackground = inheritedBackground
  342. const rgb = convert(findColor(computedDirectives.background, { dynamicVars, staticVars })).rgb
  343. if (!stacked[selector]) {
  344. let blend
  345. const alpha = computedDirectives.opacity ?? 1
  346. if (alpha >= 1) {
  347. blend = rgb
  348. } else if (alpha <= 0) {
  349. blend = lowerLevelStackedBackground
  350. } else {
  351. blend = alphaBlend(rgb, computedDirectives.opacity, lowerLevelStackedBackground)
  352. }
  353. stacked[selector] = blend
  354. computed[selector].background = { ...rgb, a: computedDirectives.opacity ?? 1 }
  355. }
  356. }
  357. if (computedDirectives.shadow) {
  358. dynamicVars.shadow = flattenDeep(findShadow(flattenDeep(computedDirectives.shadow), { dynamicVars, staticVars }))
  359. }
  360. if (!stacked[selector]) {
  361. computedDirectives.background = 'transparent'
  362. computedDirectives.opacity = 0
  363. stacked[selector] = lowerLevelStackedBackground
  364. computed[selector].background = { ...lowerLevelStackedBackground, a: 0 }
  365. }
  366. dynamicVars.stacked = stacked[selector]
  367. dynamicVars.background = computed[selector].background
  368. const dynamicSlots = Object.entries(computedDirectives).filter(([k, v]) => k.startsWith('--'))
  369. dynamicSlots.forEach(([k, v]) => {
  370. const [type, value] = v.split('|').map(x => x.trim()) // woah, Extreme!
  371. switch (type) {
  372. case 'color': {
  373. const color = findColor(value, { dynamicVars, staticVars })
  374. dynamicVars[k] = color
  375. if (combination.component === rootComponentName) {
  376. staticVars[k.substring(2)] = color
  377. }
  378. break
  379. }
  380. case 'shadow': {
  381. const shadow = value.split(/,/g).map(s => s.trim()).filter(x => x)
  382. dynamicVars[k] = shadow
  383. if (combination.component === rootComponentName) {
  384. staticVars[k.substring(2)] = shadow
  385. }
  386. break
  387. }
  388. case 'generic': {
  389. dynamicVars[k] = value
  390. if (combination.component === rootComponentName) {
  391. staticVars[k.substring(2)] = value
  392. }
  393. break
  394. }
  395. }
  396. })
  397. const rule = {
  398. dynamicVars,
  399. selector: cssSelector,
  400. ...combination,
  401. directives: computedDirectives
  402. }
  403. return rule
  404. }
  405. } catch (e) {
  406. const { component, variant, state } = combination
  407. throw new Error(`Error processing combination ${component}.${variant}:${state.join(':')}: ${e}`)
  408. }
  409. }
  410. const processInnerComponent = (component, parent) => {
  411. const combinations = []
  412. const {
  413. states: originalStates = {},
  414. variants: originalVariants = {}
  415. } = component
  416. let validInnerComponents
  417. if (editMode) {
  418. const temp = (component.validInnerComponentsLite || component.validInnerComponents || [])
  419. validInnerComponents = temp.filter(c => virtualComponents.has(c) && !nonEditableComponents.has(c))
  420. } else if (liteMode) {
  421. validInnerComponents = (component.validInnerComponentsLite || component.validInnerComponents || [])
  422. } else {
  423. validInnerComponents = component.validInnerComponents || []
  424. }
  425. // Normalizing states and variants to always include "normal"
  426. const states = { normal: '', ...originalStates }
  427. const variants = { normal: '', ...originalVariants }
  428. const innerComponents = (validInnerComponents).map(name => {
  429. const result = components[name]
  430. if (result === undefined) console.error(`Component ${component.name} references a component ${name} which does not exist!`)
  431. return result
  432. })
  433. // Optimization: we only really need combinations without "normal" because all states implicitly have it
  434. const permutationStateKeys = Object.keys(states).filter(s => s !== 'normal')
  435. const stateCombinations = onlyNormalState
  436. ? [
  437. ['normal']
  438. ]
  439. : [
  440. ['normal'],
  441. ...getAllPossibleCombinations(permutationStateKeys)
  442. .map(combination => ['normal', ...combination])
  443. .filter(combo => {
  444. // Optimization: filter out some hard-coded combinations that don't make sense
  445. if (combo.indexOf('disabled') >= 0) {
  446. return !(
  447. combo.indexOf('hover') >= 0 ||
  448. combo.indexOf('focused') >= 0 ||
  449. combo.indexOf('pressed') >= 0
  450. )
  451. }
  452. return true
  453. })
  454. ]
  455. const stateVariantCombination = Object.keys(variants).map(variant => {
  456. return stateCombinations.map(state => ({ variant, state }))
  457. }).reduce((acc, x) => [...acc, ...x], [])
  458. stateVariantCombination.forEach(combination => {
  459. combination.component = component.name
  460. combination.lazy = component.lazy || parent?.lazy
  461. combination.parent = parent
  462. if (!liteMode && combination.state.indexOf('hover') >= 0) {
  463. combination.lazy = true
  464. }
  465. combinations.push(combination)
  466. innerComponents.forEach(innerComponent => {
  467. combinations.push(...processInnerComponent(innerComponent, combination))
  468. })
  469. })
  470. return combinations
  471. }
  472. const t0 = performance.now()
  473. const combinations = processInnerComponent(components[rootComponentName] ?? components.Root)
  474. const t1 = performance.now()
  475. if (debug) {
  476. console.debug('Tree traveral took ' + (t1 - t0) + ' ms')
  477. }
  478. const result = combinations.map((combination) => {
  479. if (combination.lazy) {
  480. return async () => processCombination(combination)
  481. } else {
  482. return processCombination(combination)
  483. }
  484. }).filter(x => x)
  485. const t2 = performance.now()
  486. if (debug) {
  487. console.debug('Eager processing took ' + (t2 - t1) + ' ms')
  488. }
  489. // optimization to traverse big-ass array only once instead of twice
  490. const eager = []
  491. const lazy = []
  492. result.forEach(x => {
  493. if (typeof x === 'function') {
  494. lazy.push(x)
  495. } else {
  496. eager.push(x)
  497. }
  498. })
  499. return {
  500. lazy,
  501. eager,
  502. staticVars,
  503. engineChecksum,
  504. themeChecksum: sum([lazy, eager])
  505. }
  506. }