diff options
Diffstat (limited to 'src/services')
| -rw-r--r-- | src/services/api/api.service.js | 106 | ||||
| -rw-r--r-- | src/services/color_convert/color_convert.js | 2 | ||||
| -rw-r--r-- | src/services/entity_normalizer/entity_normalizer.service.js | 1 | ||||
| -rw-r--r-- | src/services/notifications_fetcher/notifications_fetcher.service.js | 3 | ||||
| -rw-r--r-- | src/services/style_setter/style_setter.js | 506 | ||||
| -rw-r--r-- | src/services/theme_data/css_utils.js | 163 | ||||
| -rw-r--r-- | src/services/theme_data/iss_utils.js | 129 | ||||
| -rw-r--r-- | src/services/theme_data/pleromafe.t3.js | 2 | ||||
| -rw-r--r-- | src/services/theme_data/theme2_keys.js | 177 | ||||
| -rw-r--r-- | src/services/theme_data/theme2_to_theme3.js | 536 | ||||
| -rw-r--r-- | src/services/theme_data/theme3_slot_functions.js | 103 | ||||
| -rw-r--r-- | src/services/theme_data/theme_data.service.js | 346 | ||||
| -rw-r--r-- | src/services/theme_data/theme_data_3.service.js | 474 |
13 files changed, 2184 insertions, 364 deletions
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index b99dfa92..fa417193 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -116,6 +116,15 @@ const PLEROMA_ADMIN_DESCRIPTIONS_URL = '/api/pleroma/admin/config/descriptions' const PLEROMA_ADMIN_FRONTENDS_URL = '/api/pleroma/admin/frontends' const PLEROMA_ADMIN_FRONTENDS_INSTALL_URL = '/api/pleroma/admin/frontends/install' +const PLEROMA_EMOJI_RELOAD_URL = '/api/pleroma/admin/reload_emoji' +const PLEROMA_EMOJI_IMPORT_FS_URL = '/api/pleroma/emoji/packs/import' +const PLEROMA_EMOJI_PACKS_URL = (page, pageSize) => `/api/v1/pleroma/emoji/packs?page=${page}&page_size=${pageSize}` +const PLEROMA_EMOJI_PACK_URL = (name) => `/api/v1/pleroma/emoji/pack?name=${name}` +const PLEROMA_EMOJI_PACKS_DL_REMOTE_URL = '/api/v1/pleroma/emoji/packs/download' +const PLEROMA_EMOJI_PACKS_LS_REMOTE_URL = + (url, page, pageSize) => `/api/v1/pleroma/emoji/packs/remote?url=${url}&page=${page}&page_size=${pageSize}` +const PLEROMA_EMOJI_UPDATE_FILE_URL = (name) => `/api/v1/pleroma/emoji/packs/files?name=${name}` + const oldfetch = window.fetch const fetch = (url, options) => { @@ -1800,6 +1809,90 @@ const fetchScrobbles = ({ accountId, limit = 1 }) => { }) } +const deleteEmojiPack = ({ name }) => { + return fetch(PLEROMA_EMOJI_PACK_URL(name), { method: 'DELETE' }) +} + +const reloadEmoji = () => { + return fetch(PLEROMA_EMOJI_RELOAD_URL, { method: 'POST' }) +} + +const importEmojiFromFS = () => { + return fetch(PLEROMA_EMOJI_IMPORT_FS_URL) +} + +const createEmojiPack = ({ name }) => { + return fetch(PLEROMA_EMOJI_PACK_URL(name), { method: 'POST' }) +} + +const listEmojiPacks = ({ page, pageSize }) => { + return fetch(PLEROMA_EMOJI_PACKS_URL(page, pageSize)) +} + +const listRemoteEmojiPacks = ({ instance, page, pageSize }) => { + if (!instance.startsWith('http')) { + instance = 'https://' + instance + } + + return fetch( + PLEROMA_EMOJI_PACKS_LS_REMOTE_URL(instance, page, pageSize), + { + headers: { 'Content-Type': 'application/json' } + } + ) +} + +const downloadRemoteEmojiPack = ({ instance, packName, as }) => { + return fetch( + PLEROMA_EMOJI_PACKS_DL_REMOTE_URL, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: instance, name: packName, as + }) + } + ) +} + +const saveEmojiPackMetadata = ({ name, newData }) => { + return fetch( + PLEROMA_EMOJI_PACK_URL(name), + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ metadata: newData }) + } + ) +} + +const addNewEmojiFile = ({ packName, file, shortcode, filename }) => { + const data = new FormData() + if (filename.trim() !== '') { data.set('filename', filename) } + if (shortcode.trim() !== '') { data.set('shortcode', shortcode) } + data.set('file', file) + + return fetch( + PLEROMA_EMOJI_UPDATE_FILE_URL(packName), + { method: 'POST', body: data } + ) +} + +const updateEmojiFile = ({ packName, shortcode, newShortcode, newFilename, force }) => { + return fetch( + PLEROMA_EMOJI_UPDATE_FILE_URL(packName), + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ shortcode, new_shortcode: newShortcode, new_filename: newFilename, force }) + } + ) +} + +const deleteEmojiFile = ({ packName, shortcode }) => { + return fetch(`${PLEROMA_EMOJI_UPDATE_FILE_URL(packName)}&shortcode=${shortcode}`, { method: 'DELETE' }) +} + const apiService = { verifyCredentials, fetchTimeline, @@ -1919,7 +2012,18 @@ const apiService = { fetchInstanceConfigDescriptions, fetchAvailableFrontends, pushInstanceDBConfig, - installFrontend + installFrontend, + importEmojiFromFS, + reloadEmoji, + listEmojiPacks, + createEmojiPack, + deleteEmojiPack, + saveEmojiPackMetadata, + addNewEmojiFile, + updateEmojiFile, + deleteEmojiFile, + listRemoteEmojiPacks, + downloadRemoteEmojiPack } export default apiService diff --git a/src/services/color_convert/color_convert.js b/src/services/color_convert/color_convert.js index 47d6344e..d92bbbe0 100644 --- a/src/services/color_convert/color_convert.js +++ b/src/services/color_convert/color_convert.js @@ -173,7 +173,7 @@ export const mixrgb = (a, b) => { * @returns {String} CSS rgba() color */ export const rgba2css = function (rgba) { - return `rgba(${Math.floor(rgba.r)}, ${Math.floor(rgba.g)}, ${Math.floor(rgba.b)}, ${rgba.a})` + return `rgba(${Math.floor(rgba.r)}, ${Math.floor(rgba.g)}, ${Math.floor(rgba.b)}, ${rgba.a ?? 1})` } /** diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index 860f20c6..e41e7125 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -166,6 +166,7 @@ export const parseUser = (data) => { output.show_role = data.source.pleroma.show_role output.discoverable = data.source.pleroma.discoverable output.show_birthday = data.pleroma.show_birthday + output.actor_type = data.source.pleroma.actor_type } } diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js index c91a86c8..6403693c 100644 --- a/src/services/notifications_fetcher/notifications_fetcher.service.js +++ b/src/services/notifications_fetcher/notifications_fetcher.service.js @@ -8,8 +8,11 @@ const mastoApiNotificationTypes = [ 'favourite', 'reblog', 'follow', + 'follow_request', 'move', + 'poll', 'pleroma:emoji_reaction', + 'pleroma:chat_mention', 'pleroma:report' ] diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js index 43fe3c73..369d2c9f 100644 --- a/src/services/style_setter/style_setter.js +++ b/src/services/style_setter/style_setter.js @@ -1,24 +1,151 @@ -import { convert } from 'chromatism' -import { rgb2hex, hex2rgb, rgba2css, getCssColor, relativeLuminance } from '../color_convert/color_convert.js' -import { getColors, computeDynamicColor, getOpacitySlot } from '../theme_data/theme_data.service.js' +import { hex2rgb } from '../color_convert/color_convert.js' +import { generatePreset } from '../theme_data/theme_data.service.js' +import { init, getEngineChecksum } from '../theme_data/theme_data_3.service.js' +import { convertTheme2To3 } from '../theme_data/theme2_to_theme3.js' +import { getCssRules } from '../theme_data/css_utils.js' import { defaultState } from '../../modules/config.js' +import { chunk } from 'lodash' + +export const generateTheme = async (input, callbacks) => { + const { + onNewRule = (rule, isLazy) => {}, + onLazyFinished = () => {}, + onEagerFinished = () => {} + } = callbacks + + let extraRules + if (input.themeFileVersion === 1) { + extraRules = convertTheme2To3(input) + } else { + const { theme } = generatePreset(input) + extraRules = convertTheme2To3(theme) + } -export const applyTheme = (input) => { - const { rules } = generatePreset(input) - const head = document.head - const body = document.body - body.classList.add('hidden') + // Assuming that "worst case scenario background" is panel background since it's the most likely one + const themes3 = init(extraRules, extraRules[0].directives['--bg'].split('|')[1].trim()) + + getCssRules(themes3.eager, themes3.staticVars).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) + } + }) + onEagerFinished() + + // Optimization - instead of processing all lazy rules in one go, process them in small chunks + // so that UI can do other things and be somewhat responsive while less important rules are being + // processed + let counter = 0 + const chunks = chunk(themes3.lazy, 200) + // let t0 = performance.now() + const processChunk = () => { + const chunk = chunks[counter] + Promise.all(chunk.map(x => x())).then(result => { + getCssRules(result.filter(x => x), themes3.staticVars).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) + } + }) + // const t1 = performance.now() + // console.debug('Chunk ' + counter + ' took ' + (t1 - t0) + 'ms') + // t0 = t1 + counter += 1 + if (counter < chunks.length) { + setTimeout(processChunk, 0) + } else { + onLazyFinished() + } + }) + } - const styleEl = document.createElement('style') - head.appendChild(styleEl) - const styleSheet = styleEl.sheet + return { lazyProcessFunc: processChunk } +} - styleSheet.toString() - styleSheet.insertRule(`:root { ${rules.radii} }`, 'index-max') - styleSheet.insertRule(`:root { ${rules.colors} }`, 'index-max') - styleSheet.insertRule(`:root { ${rules.shadows} }`, 'index-max') - styleSheet.insertRule(`:root { ${rules.fonts} }`, 'index-max') - body.classList.remove('hidden') +export const tryLoadCache = () => { + const json = localStorage.getItem('pleroma-fe-theme-cache') + if (!json) return null + let cache + try { + cache = JSON.parse(json) + } catch (e) { + console.error('Failed to decode theme cache:', e) + return false + } + if (cache.engineChecksum === getEngineChecksum()) { + const styleSheet = new CSSStyleSheet() + const lazyStyleSheet = new CSSStyleSheet() + + cache.data[0].forEach(rule => styleSheet.insertRule(rule, 'index-max')) + cache.data[1].forEach(rule => lazyStyleSheet.insertRule(rule, 'index-max')) + + document.adoptedStyleSheets = [styleSheet, lazyStyleSheet] + + return true + } else { + console.warn('Engine checksum doesn\'t match, cache not usable, clearing') + localStorage.removeItem('pleroma-fe-theme-cache') + } +} + +export const applyTheme = async (input, onFinish = (data) => {}) => { + const styleSheet = new CSSStyleSheet() + const styleArray = [] + const lazyStyleSheet = new CSSStyleSheet() + const lazyStyleArray = [] + + const { lazyProcessFunc } = await generateTheme( + input, + { + onNewRule (rule, isLazy) { + if (isLazy) { + lazyStyleSheet.insertRule(rule, 'index-max') + lazyStyleArray.push(rule) + } else { + styleSheet.insertRule(rule, 'index-max') + styleArray.push(rule) + } + }, + onEagerFinished () { + document.adoptedStyleSheets = [styleSheet] + }, + onLazyFinished () { + document.adoptedStyleSheets = [styleSheet, lazyStyleSheet] + const cache = { engineChecksum: getEngineChecksum(), data: [styleArray, lazyStyleArray] } + onFinish(cache) + localStorage.setItem('pleroma-fe-theme-cache', JSON.stringify(cache)) + } + } + ) + + setTimeout(lazyProcessFunc, 0) + + return Promise.resolve() } const configColumns = ({ sidebarColumnWidth, contentColumnWidth, notifsColumnWidth, emojiReactionsScale }) => @@ -51,308 +178,6 @@ export const applyConfig = (config) => { body.classList.remove('hidden') } -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 - } - } -} - -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 getThemes = () => { const cache = 'no-store' @@ -382,47 +207,6 @@ export const getThemes = () => { }, {}) }) } -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 } - } - }, {}) -} - -/** - * 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 getPreset = (val) => { return getThemes() @@ -449,4 +233,4 @@ export const getPreset = (val) => { }) } -export const setPreset = (val) => getPreset(val).then(data => applyTheme(data.theme)) +export const setPreset = (val) => getPreset(val).then(data => applyTheme(data)) diff --git a/src/services/theme_data/css_utils.js b/src/services/theme_data/css_utils.js new file mode 100644 index 00000000..a89eac3b --- /dev/null +++ b/src/services/theme_data/css_utils.js @@ -0,0 +1,163 @@ +import { convert } from 'chromatism' + +import { hex2rgb, rgba2css } from '../color_convert/color_convert.js' + +// This changes what backgrounds are used to "stacked" solid colors so you can see +// what theme engine "thinks" is actual background color is for purposes of text color +// generation and for when --stacked variable is used +const DEBUG = false + +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) => { + if (input.length === 0) { + return 'none' + } + + return input + .filter(_ => usesDropShadow ? _.inset : _) + .map((shad) => [ + shad.x, + shad.y, + shad.blur, + shad.spread + ].map(_ => _ + 'px ').concat([ + getCssColorString(shad.color, shad.alpha), + shad.inset ? 'inset' : '' + ]).join(' ')).join(', ') +} + +export 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([ + getCssColorString(shad.color, shad.alpha) + ]).join(' ')) + .map(_ => `drop-shadow(${_})`) + .join(' ') +} + +export const getCssRules = (rules) => rules.map(rule => { + let selector = rule.selector + if (!selector) { + selector = 'html' + } + const header = selector + ' {' + const footer = '}' + + const virtualDirectives = Object.entries(rule.virtualDirectives || {}).map(([k, v]) => { + return ' ' + k + ': ' + v + }).join(';\n') + + const directives = Object.entries(rule.directives).map(([k, v]) => { + switch (k) { + case 'roundness': { + return ' ' + [ + '--roundness: ' + v + 'px' + ].join(';\n ') + } + case 'shadow': { + return ' ' + [ + '--shadow: ' + getCssShadow(rule.dynamicVars.shadow), + '--shadowFilter: ' + getCssShadowFilter(rule.dynamicVars.shadow), + '--shadowInset: ' + getCssShadow(rule.dynamicVars.shadow, true) + ].join(';\n ') + } + case 'background': { + if (DEBUG) { + return ` + --background: ${getCssColorString(rule.dynamicVars.stacked)}; + background-color: ${getCssColorString(rule.dynamicVars.stacked)}; + ` + } + if (v === 'transparent') { + if (rule.component === 'Root') return [] + return [ + rule.directives.backgroundNoCssColor !== 'yes' ? ('background-color: ' + v) : '', + ' --background: ' + v + ].filter(x => x).join(';\n') + } + const color = getCssColorString(rule.dynamicVars.background, rule.directives.opacity) + const cssDirectives = ['--background: ' + color] + if (rule.directives.backgroundNoCssColor !== 'yes') { + cssDirectives.push('background-color: ' + color) + } + return cssDirectives.filter(x => x).join(';\n') + } + case 'blur': { + const cssDirectives = [] + if (rule.directives.opacity < 1) { + cssDirectives.push(`--backdrop-filter: blur(${v}) `) + if (rule.directives.backgroundNoCssColor !== 'yes') { + cssDirectives.push(`backdrop-filter: blur(${v}) `) + } + } + return cssDirectives.join(';\n') + } + case 'font': { + return 'font-family: ' + v + } + case 'textColor': { + if (rule.directives.textNoCssColor === 'yes') { return '' } + return 'color: ' + v + } + default: + if (k.startsWith('--')) { + const [type, value] = v.split('|').map(x => x.trim()) // woah, Extreme! + switch (type) { + case 'color': { + const color = rule.dynamicVars[k] + if (typeof color === 'string') { + return k + ': ' + rgba2css(hex2rgb(color)) + } else { + return k + ': ' + rgba2css(color) + } + } + case 'generic': + return k + ': ' + value + default: + return '' + } + } + return '' + } + }).filter(x => x).map(x => ' ' + x).join(';\n') + + return [ + header, + directives + ';', + (rule.component === 'Text' && rule.state.indexOf('faint') < 0 && rule.directives.textNoCssColor !== 'yes') ? ' color: var(--text);' : '', + '', + virtualDirectives, + footer + ].join('\n') +}).filter(x => x) diff --git a/src/services/theme_data/iss_utils.js b/src/services/theme_data/iss_utils.js new file mode 100644 index 00000000..2d9dd0b5 --- /dev/null +++ b/src/services/theme_data/iss_utils.js @@ -0,0 +1,129 @@ +// "Unrolls" a tree structure of item: { parent: { ...item2, parent: { ...item3, parent: {...} } }} +// into an array [item2, item3] for iterating +export const unroll = (item) => { + const out = [] + let currentParent = item + while (currentParent) { + out.push(currentParent) + currentParent = currentParent.parent + } + return out +} + +// This gives you an array of arrays of all possible unique (i.e. order-insensitive) combinations +// Can only accept primitives. Duplicates are not supported and can cause unexpected behavior +export const getAllPossibleCombinations = (array) => { + const combos = [array.map(x => [x])] + for (let comboSize = 2; comboSize <= array.length; comboSize++) { + const previous = combos[combos.length - 1] + const newCombos = previous.map(self => { + const selfSet = new Set() + self.forEach(x => selfSet.add(x)) + const nonSelf = array.filter(x => !selfSet.has(x)) + return nonSelf.map(x => [...self, x]) + }) + const flatCombos = newCombos.reduce((acc, x) => [...acc, ...x], []) + const uniqueComboStrings = new Set() + const uniqueCombos = flatCombos.map(x => x.toSorted()).filter(x => { + if (uniqueComboStrings.has(x.join())) { + return false + } else { + uniqueComboStrings.add(x.join()) + return true + } + }) + combos.push(uniqueCombos) + } + return combos.reduce((acc, x) => [...acc, ...x], []) +} + +// Converts rule, parents and their criteria into a CSS (or path if ignoreOutOfTreeSelector == true) selector +export const genericRuleToSelector = components => (rule, ignoreOutOfTreeSelector, isParent) => { + if (!rule && !isParent) return null + const component = components[rule.component] + const { states = {}, variants = {}, selector, outOfTreeSelector } = component + + const applicableStates = ((rule.state || []).filter(x => x !== 'normal')).map(state => states[state]) + + const applicableVariantName = (rule.variant || 'normal') + let applicableVariant = '' + if (applicableVariantName !== 'normal') { + applicableVariant = variants[applicableVariantName] + } else { + applicableVariant = variants?.normal ?? '' + } + + let realSelector + if (selector === ':root') { + realSelector = '' + } else if (isParent) { + realSelector = selector + } else { + if (outOfTreeSelector && !ignoreOutOfTreeSelector) realSelector = outOfTreeSelector + else realSelector = selector + } + + const selectors = [realSelector, applicableVariant, ...applicableStates] + .toSorted((a, b) => { + if (a.startsWith(':')) return 1 + if (/^[a-z]/.exec(a)) return -1 + else return 0 + }) + .join('') + + if (rule.parent) { + return (genericRuleToSelector(components)(rule.parent, ignoreOutOfTreeSelector, true) + ' ' + selectors).trim() + } + return selectors.trim() +} + +export const combinationsMatch = (criteria, subject, strict) => { + if (criteria.component !== subject.component) return false + + // All variants inherit from normal + if (subject.variant !== 'normal' || strict) { + if (criteria.variant !== subject.variant) return false + } + + // Subject states > 1 essentially means state is "normal" and therefore matches + if (subject.state.length > 1 || strict) { + const subjectStatesSet = new Set(subject.state) + const criteriaStatesSet = new Set(criteria.state) + + const setsAreEqual = + [...criteriaStatesSet].every(state => subjectStatesSet.has(state)) && + [...subjectStatesSet].every(state => criteriaStatesSet.has(state)) + + if (!setsAreEqual) return false + } + return true +} + +export const findRules = (criteria, strict) => subject => { + // If we searching for "general" rules - ignore "specific" ones + if (criteria.parent === null && !!subject.parent) return false + if (!combinationsMatch(criteria, subject, strict)) return false + + if (criteria.parent !== undefined && criteria.parent !== null) { + if (!subject.parent && !strict) return true + const pathCriteria = unroll(criteria) + const pathSubject = unroll(subject) + if (pathCriteria.length < pathSubject.length) return false + + // Search: .a .b .c + // Matches: .a .b .c; .b .c; .c; .z .a .b .c + // Does not match .a .b .c .d, .a .b .e + for (let i = 0; i < pathCriteria.length; i++) { + const criteriaParent = pathCriteria[i] + const subjectParent = pathSubject[i] + if (!subjectParent) return true + if (!combinationsMatch(criteriaParent, subjectParent, strict)) return false + } + } + return true +} + +export const normalizeCombination = rule => { + rule.variant = rule.variant ?? 'normal' + rule.state = [...new Set(['normal', ...(rule.state || [])])] +} diff --git a/src/services/theme_data/pleromafe.t3.js b/src/services/theme_data/pleromafe.t3.js new file mode 100644 index 00000000..db612a5b --- /dev/null +++ b/src/services/theme_data/pleromafe.t3.js @@ -0,0 +1,2 @@ +export const sampleRules = [ +] diff --git a/src/services/theme_data/theme2_keys.js b/src/services/theme_data/theme2_keys.js new file mode 100644 index 00000000..ffc5627c --- /dev/null +++ b/src/services/theme_data/theme2_keys.js @@ -0,0 +1,177 @@ +export default [ + 'bg', + 'wallpaper', + 'fg', + 'text', + 'underlay', + 'link', + 'accent', + 'faint', + 'faintLink', + 'postFaintLink', + + 'cBlue', + 'cRed', + 'cGreen', + 'cOrange', + + 'profileBg', + 'profileTint', + + 'highlight', + 'highlightLightText', + 'highlightPostLink', + 'highlightFaintText', + 'highlightFaintLink', + 'highlightPostFaintLink', + 'highlightText', + 'highlightLink', + 'highlightIcon', + + 'popover', + 'popoverLightText', + 'popoverPostLink', + 'popoverFaintText', + 'popoverFaintLink', + 'popoverPostFaintLink', + 'popoverText', + 'popoverLink', + 'popoverIcon', + + 'selectedPost', + 'selectedPostFaintText', + 'selectedPostLightText', + 'selectedPostPostLink', + 'selectedPostFaintLink', + 'selectedPostText', + 'selectedPostLink', + 'selectedPostIcon', + + 'selectedMenu', + 'selectedMenuLightText', + 'selectedMenuFaintText', + 'selectedMenuFaintLink', + 'selectedMenuText', + 'selectedMenuLink', + 'selectedMenuIcon', + + 'selectedMenuPopover', + 'selectedMenuPopoverLightText', + 'selectedMenuPopoverFaintText', + 'selectedMenuPopoverFaintLink', + 'selectedMenuPopoverText', + 'selectedMenuPopoverLink', + 'selectedMenuPopoverIcon', + + 'lightText', + + 'postLink', + + 'postGreentext', + + 'postCyantext', + + 'border', + + 'poll', + 'pollText', + + 'icon', + + // Foreground, + 'fgText', + 'fgLink', + + // Panel header, + 'panel', + 'panelText', + 'panelFaint', + 'panelLink', + + // Top bar, + 'topBar', + 'topBarText', + 'topBarLink', + + // Tabs, + 'tab', + 'tabText', + 'tabActiveText', + + // Buttons, + 'btn', + 'btnText', + 'btnPanelText', + 'btnTopBarText', + + // Buttons: pressed, + 'btnPressed', + 'btnPressedText', + 'btnPressedPanel', + 'btnPressedPanelText', + 'btnPressedTopBar', + 'btnPressedTopBarText', + + // Buttons: toggled, + 'btnToggled', + 'btnToggledText', + 'btnToggledPanelText', + 'btnToggledTopBarText', + + // Buttons: disabled, + 'btnDisabled', + 'btnDisabledText', + 'btnDisabledPanelText', + 'btnDisabledTopBarText', + + // Input fields, + 'input', + 'inputText', + 'inputPanelText', + 'inputTopbarText', + + 'alertError', + 'alertErrorText', + 'alertErrorPanelText', + + 'alertWarning', + 'alertWarningText', + 'alertWarningPanelText', + + 'alertSuccess', + 'alertSuccessText', + 'alertSuccessPanelText', + + 'alertNeutral', + 'alertNeutralText', + 'alertNeutralPanelText', + + 'alertPopupError', + 'alertPopupErrorText', + + 'alertPopupWarning', + 'alertPopupWarningText', + + 'alertPopupSuccess', + 'alertPopupSuccessText', + + 'alertPopupNeutral', + 'alertPopupNeutralText', + + 'badgeNeutral', + 'badgeNeutralText', + + 'badgeNotification', + 'badgeNotificationText', + + 'chatBg', + + 'chatMessageIncomingBg', + 'chatMessageIncomingText', + 'chatMessageIncomingLink', + 'chatMessageIncomingBorder', + 'chatMessageOutgoingBg', + 'chatMessageOutgoingText', + 'chatMessageOutgoingLink', + 'chatMessageOutgoingBorder' +] diff --git a/src/services/theme_data/theme2_to_theme3.js b/src/services/theme_data/theme2_to_theme3.js new file mode 100644 index 00000000..6ea12836 --- /dev/null +++ b/src/services/theme_data/theme2_to_theme3.js @@ -0,0 +1,536 @@ +import { convert } from 'chromatism' +import allKeys from './theme2_keys' + +// keys that are meant to be used globally, i.e. what's the rest of the theme is based upon. +export const basePaletteKeys = new Set([ + 'bg', + 'fg', + 'text', + 'link', + 'accent', + + 'cBlue', + 'cRed', + 'cGreen', + 'cOrange' +]) + +export const fontsKeys = new Set([ + 'interface', + 'input', + 'post', + 'postCode' +]) + +export const opacityKeys = new Set([ + 'alert', + 'alertPopup', + 'bg', + 'border', + 'btn', + 'faint', + 'input', + 'panel', + 'popover', + 'profileTint', + 'underlay' +]) + +export const shadowsKeys = new Set([ + 'panel', + 'topBar', + 'popup', + 'avatar', + 'avatarStatus', + 'panelHeader', + 'button', + 'buttonHover', + 'buttonPressed', + 'input' +]) + +export const radiiKeys = new Set([ + 'btn', + 'input', + 'checkbox', + 'panel', + 'avatar', + 'avatarAlt', + 'tooltip', + 'attachment', + 'chatMessage' +]) + +// Keys that are not available in editor and never meant to be edited +export const hiddenKeys = new Set([ + 'profileBg', + 'profileTint' +]) + +export const extendedBasePrefixes = [ + 'border', + 'icon', + 'highlight', + 'lightText', + + 'popover', + + 'panel', + 'topBar', + 'tab', + 'btn', + 'input', + 'selectedMenu', + + 'alert', + 'alertPopup', + 'badge', + + 'post', + 'selectedPost', // wrong nomenclature + 'poll', + + 'chatBg', + 'chatMessage' +] +export const nonComponentPrefixes = new Set([ + 'border', + 'icon', + 'highlight', + 'lightText', + 'chatBg' +]) + +export const extendedBaseKeys = Object.fromEntries( + extendedBasePrefixes.map(prefix => [ + prefix, + allKeys.filter(k => { + if (prefix === 'alert') { + return k.startsWith(prefix) && !k.startsWith('alertPopup') + } + return k.startsWith(prefix) + }) + ]) +) + +// Keysets that are only really used intermideately, i.e. to generate other colors +export const temporary = new Set([ + '', + 'highlight' +]) + +export const temporaryColors = {} + +export const convertTheme2To3 = (data) => { + data.colors.accent = data.colors.accent || data.colors.link + data.colors.link = data.colors.link || data.colors.accent + const generateRoot = () => { + const directives = {} + basePaletteKeys.forEach(key => { directives['--' + key] = 'color | ' + convert(data.colors[key]).hex }) + return { + component: 'Root', + directives + } + } + + const convertOpacity = () => { + const newRules = [] + Object.keys(data.opacity || {}).forEach(key => { + if (!opacityKeys.has(key) || data.opacity[key] === undefined) return null + const originalOpacity = data.opacity[key] + const rule = {} + + switch (key) { + case 'alert': + rule.component = 'Alert' + break + case 'alertPopup': + rule.component = 'Alert' + rule.parent = { component: 'Popover' } + break + case 'bg': + rule.component = 'Panel' + break + case 'border': + rule.component = 'Border' + break + case 'btn': + rule.component = 'Button' + break + case 'faint': + rule.component = 'Text' + rule.state = ['faint'] + break + case 'input': + rule.component = 'Input' + break + case 'panel': + rule.component = 'PanelHeader' + break + case 'popover': + rule.component = 'Popover' + break + case 'profileTint': + return null + case 'underlay': + rule.component = 'Underlay' + break + } + + switch (key) { + case 'alert': + case 'alertPopup': + case 'bg': + case 'btn': + case 'input': + case 'panel': + case 'popover': + case 'underlay': + rule.directives = { opacity: originalOpacity } + break + case 'faint': + case 'border': + rule.directives = { textOpacity: originalOpacity } + break + } + + newRules.push(rule) + + if (rule.component === 'Button') { + newRules.push({ ...rule, component: 'ScrollbarElement' }) + newRules.push({ ...rule, component: 'Tab' }) + newRules.push({ ...rule, component: 'Tab', state: ['active'], directives: { opacity: 0 } }) + } + if (rule.component === 'Panel') { + newRules.push({ ...rule, component: 'Post' }) + } + }) + return newRules + } + + const convertRadii = () => { + const newRules = [] + Object.keys(data.radii || {}).forEach(key => { + if (!radiiKeys.has(key) || data.radii[key] === undefined) return null + const originalRadius = data.radii[key] + const rule = {} + + switch (key) { + case 'btn': + rule.component = 'Button' + break + case 'tab': + rule.component = 'Tab' + break + case 'input': + rule.component = 'Input' + break + case 'checkbox': + rule.component = 'Input' + rule.variant = 'checkbox' + break + case 'panel': + rule.component = 'Panel' + break + case 'avatar': + rule.component = 'Avatar' + break + case 'avatarAlt': + rule.component = 'Avatar' + rule.variant = 'compact' + break + case 'tooltip': + rule.component = 'Popover' + break + case 'attachment': + rule.component = 'Attachment' + break + case 'ChatMessage': + rule.component = 'Button' + break + } + rule.directives = { + roundness: originalRadius + } + newRules.push(rule) + if (rule.component === 'Button') { + newRules.push({ ...rule, component: 'ScrollbarElement' }) + newRules.push({ ...rule, component: 'Tab' }) + } + }) + return newRules + } + + const convertFonts = () => { + const newRules = [] + Object.keys(data.fonts || {}).forEach(key => { + if (!fontsKeys.has(key)) return + const originalFont = data.fonts[key].family + const rule = {} + + switch (key) { + case 'interface': + case 'postCode': + rule.component = 'Root' + break + case 'input': + rule.component = 'Input' + break + case 'post': + rule.component = 'RichContent' + break + } + switch (key) { + case 'interface': + case 'input': + case 'post': + rule.directives = { '--font': 'generic | ' + originalFont } + break + case 'postCode': + rule.directives = { '--monoFont': 'generic | ' + originalFont } + newRules.push({ ...rule, component: 'RichContent' }) + break + } + newRules.push(rule) + }) + return newRules + } + const convertShadows = () => { + const newRules = [] + Object.keys(data.shadows || {}).forEach(key => { + if (!shadowsKeys.has(key)) return + const originalShadow = data.shadows[key] + const rule = {} + + switch (key) { + case 'panel': + rule.component = 'Panel' + break + case 'topBar': + rule.component = 'TopBar' + break + case 'popup': + rule.component = 'Popover' + break + case 'avatar': + rule.component = 'Avatar' + break + case 'avatarStatus': + rule.component = 'Avatar' + rule.parent = { component: 'Post' } + break + case 'panelHeader': + rule.component = 'PanelHeader' + break + case 'button': + rule.component = 'Button' + break + case 'buttonHover': + rule.component = 'Button' + rule.state = ['hover'] + break + case 'buttonPressed': + rule.component = 'Button' + rule.state = ['pressed'] + break + case 'input': + rule.component = 'Input' + break + } + rule.directives = { + shadow: originalShadow + } + newRules.push(rule) + if (key === 'topBar') { + newRules.push({ ...rule, component: 'PanelHeader', parent: { component: 'MobileDrawer' } }) + } + if (key === 'avatarStatus') { + newRules.push({ ...rule, parent: { component: 'Notification' } }) + } + if (key === 'buttonPressed') { + 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'] }) + } + + if (rule.component === 'Button') { + newRules.push({ ...rule, component: 'ScrollbarElement' }) + newRules.push({ ...rule, component: 'Tab' }) + } + }) + return newRules + } + + const extendedRules = Object.entries(extendedBaseKeys).map(([prefix, keys]) => { + if (nonComponentPrefixes.has(prefix)) return null + const rule = {} + if (prefix === 'alertPopup') { + rule.component = 'Alert' + rule.parent = { component: 'Popover' } + } else if (prefix === 'selectedPost') { + rule.component = 'Post' + rule.state = ['selected'] + } else if (prefix === 'selectedMenu') { + rule.component = 'MenuItem' + rule.state = ['hover'] + } else if (prefix === 'chatMessageIncoming') { + rule.component = 'ChatMessage' + } else if (prefix === 'chatMessageOutgoing') { + rule.component = 'ChatMessage' + rule.variant = 'outgoing' + } else if (prefix === 'panel') { + rule.component = 'PanelHeader' + } else if (prefix === 'topBar') { + rule.component = 'TopBar' + } else if (prefix === 'chatMessage') { + rule.component = 'ChatMessage' + } else if (prefix === 'poll') { + rule.component = 'PollGraph' + } else if (prefix === 'btn') { + rule.component = 'Button' + } else { + rule.component = prefix[0].toUpperCase() + prefix.slice(1).toLowerCase() + } + return keys.map((key) => { + if (!data.colors[key]) return null + const leftoverKey = key.replace(prefix, '') + const parts = (leftoverKey || 'Bg').match(/[A-Z][a-z]*/g) + const last = parts.slice(-1)[0] + let newRule = { directives: {} } + let variantArray = [] + + switch (last) { + case 'Text': + case 'Faint': // typo + case 'Link': + case 'Icon': + case 'Greentext': + case 'Cyantext': + case 'Border': + newRule.parent = rule + newRule.directives.textColor = data.colors[key] + newRule.directives.textAuto = 'no-auto' + variantArray = parts.slice(0, -1) + break + default: + newRule = { ...rule, directives: {} } + newRule.directives.background = data.colors[key] + variantArray = parts + break + } + + if (last === 'Text' || last === 'Link') { + const secondLast = parts.slice(-2)[0] + if (secondLast === 'Light') { + return null // unsupported + } else if (secondLast === 'Faint') { + newRule.state = ['faint'] + variantArray = parts.slice(0, -2) + } + } + + switch (last) { + case 'Text': + case 'Link': + case 'Icon': + case 'Border': + newRule.component = last + break + case 'Greentext': + case 'Cyantext': + newRule.component = 'FunText' + newRule.variant = last.toLowerCase() + break + case 'Faint': + newRule.component = 'Text' + newRule.state = ['faint'] + break + } + + variantArray = variantArray.filter(x => x !== 'Bg') + + if (last === 'Link' && prefix === 'selectedPost') { + // selectedPost has typo - duplicate 'Post' + variantArray = variantArray.filter(x => x !== 'Post') + } + + if (prefix === 'popover' && variantArray[0] === 'Post') { + newRule.component = 'Post' + newRule.parent = { component: 'Popover' } + variantArray = variantArray.filter(x => x !== 'Post') + } + + if (prefix === 'selectedMenu' && variantArray[0] === 'Popover') { + newRule.parent = { component: 'Popover' } + variantArray = variantArray.filter(x => x !== 'Popover') + } + + switch (prefix) { + case 'btn': + case 'input': + case 'alert': { + const hasPanel = variantArray.find(x => x === 'Panel') + if (hasPanel) { + newRule.parent = { component: 'PanelHeader' } + variantArray = variantArray.filter(x => x !== 'Panel') + } + const hasTop = variantArray.find(x => x === 'Top') // TopBar + if (hasTop) { + newRule.parent = { component: 'TopBar' } + variantArray = variantArray.filter(x => x !== 'Top' && x !== 'Bar') + } + break + } + } + + if (variantArray.length > 0) { + if (prefix === 'btn') { + newRule.state = variantArray.map(x => x.toLowerCase()) + } else { + newRule.variant = variantArray[0].toLowerCase() + } + } + + if (newRule.component === 'Panel') { + return [newRule, { ...newRule, component: 'MobileDrawer' }] + } else if (newRule.component === 'Button') { + const rules = [ + newRule, + { ...newRule, component: 'Tab' }, + { ...newRule, component: 'ScrollbarElement' } + ] + if (newRule.state?.indexOf('toggled') >= 0) { + rules.push({ ...newRule, state: [...newRule.state, 'focused'] }) + rules.push({ ...newRule, state: [...newRule.state, 'hover'] }) + rules.push({ ...newRule, state: [...newRule.state, 'hover', 'focused'] }) + } + if (newRule.state?.indexOf('hover') >= 0) { + rules.push({ ...newRule, state: [...newRule.state, 'focused'] }) + } + return rules + } else if (newRule.component === 'Badge') { + if (newRule.variant === 'notification') { + return [newRule, { component: 'Root', directives: { '--badgeNotification': 'color | ' + newRule.directives.background } }] + } else if (newRule.variant === 'neutral') { + return [{ ...newRule, variant: 'normal' }] + } else { + return [newRule] + } + } else if (newRule.component === 'TopBar') { + return [newRule, { ...newRule, parent: { component: 'MobileDrawer' }, component: 'PanelHeader' }] + } else { + return [newRule] + } + }) + }) + + const flatExtRules = extendedRules.filter(x => x).reduce((acc, x) => [...acc, ...x], []).filter(x => x).reduce((acc, x) => [...acc, ...x], []) + + return [generateRoot(), ...convertShadows(), ...convertRadii(), ...convertOpacity(), ...convertFonts(), ...flatExtRules] +} diff --git a/src/services/theme_data/theme3_slot_functions.js b/src/services/theme_data/theme3_slot_functions.js new file mode 100644 index 00000000..074a88f0 --- /dev/null +++ b/src/services/theme_data/theme3_slot_functions.js @@ -0,0 +1,103 @@ +import { convert, brightness } from 'chromatism' +import { alphaBlend, getTextColor, relativeLuminance } from '../color_convert/color_convert.js' + +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 func = functions[funcName] + if (args.length < func.argsNeeded) { + throw new Error(`$${funcName} requires at least ${func.argsNeeded} arguments, but ${args.length} were provided`) + } + return func.exec(args, { findColor, findShadow }, { dynamicVars, staticVars }) +} + +export const colorFunctions = { + alpha: { + argsNeeded: 2, + exec: (args, { findColor }, { dynamicVars, staticVars }) => { + const [color, amountArg] = args + + const colorArg = convert(findColor(color, { dynamicVars, staticVars })).rgb + const amount = Number(amountArg) + return { ...colorArg, a: amount } + } + }, + textColor: { + argsNeeded: 2, + exec: (args, { findColor }, { dynamicVars, staticVars }) => { + const [backgroundArg, foregroundArg, preserve = 'preserve'] = args + + const background = convert(findColor(backgroundArg, { dynamicVars, staticVars })).rgb + const foreground = convert(findColor(foregroundArg, { dynamicVars, staticVars })).rgb + + return getTextColor(background, foreground, preserve === 'preserve') + } + }, + blend: { + argsNeeded: 3, + exec: (args, { findColor }, { dynamicVars, staticVars }) => { + const [backgroundArg, amountArg, foregroundArg] = args + + const background = convert(findColor(backgroundArg, { dynamicVars, staticVars })).rgb + const foreground = convert(findColor(foregroundArg, { dynamicVars, staticVars })).rgb + const amount = Number(amountArg) + + return alphaBlend(background, amount, foreground) + } + }, + mod: { + argsNeeded: 2, + exec: (args, { findColor }, { dynamicVars, staticVars }) => { + const [colorArg, amountArg] = args + + const color = convert(findColor(colorArg, { dynamicVars, staticVars })).rgb + const amount = Number(amountArg) + + const effectiveBackground = dynamicVars.lowerLevelBackground + const isLightOnDark = relativeLuminance(convert(effectiveBackground).rgb) < 0.5 + const mod = isLightOnDark ? 1 : -1 + return brightness(amount * mod, color).rgb + } + } +} + +export const shadowFunctions = { + borderSide: { + argsNeeded: 3, + exec: (args, { findColor }) => { + const [color, side, alpha = '1', widthArg = '1', inset = 'inset'] = args + + const width = Number(widthArg) + const isInset = inset === 'inset' + + const targetShadow = { + x: 0, + y: 0, + blur: 0, + spread: 0, + color, + alpha: Number(alpha), + inset: isInset + } + + side.split('-').forEach((position) => { + switch (position) { + case 'left': + targetShadow.x = width * (inset ? 1 : -1) + break + case 'right': + targetShadow.x = -1 * width * (inset ? 1 : -1) + break + case 'top': + targetShadow.y = width * (inset ? 1 : -1) + break + case 'bottom': + targetShadow.y = -1 * width * (inset ? 1 : -1) + break + } + }) + return [targetShadow] + } + } +} diff --git a/src/services/theme_data/theme_data.service.js b/src/services/theme_data/theme_data.service.js index b376ef4d..6e477674 100644 --- a/src/services/theme_data/theme_data.service.js +++ b/src/services/theme_data/theme_data.service.js @@ -1,5 +1,5 @@ import { convert, brightness, contrastRatio } from 'chromatism' -import { alphaBlendLayers, getTextColor, relativeLuminance } from '../color_convert/color_convert.js' +import { rgb2hex, rgba2css, alphaBlendLayers, getTextColor, relativeLuminance, getCssColor } from '../color_convert/color_convert.js' import { LAYERS, DEFAULT_OPACITY, SLOT_INHERITANCE } from './pleromafe.js' /* @@ -407,3 +407,347 @@ export const getColors = (sourceColors, sourceOpacity) => SLOT_ORDERED.reduce(({ } } }, { 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 } + } + }, {}) +} diff --git a/src/services/theme_data/theme_data_3.service.js b/src/services/theme_data/theme_data_3.service.js new file mode 100644 index 00000000..15b4493e --- /dev/null +++ b/src/services/theme_data/theme_data_3.service.js @@ -0,0 +1,474 @@ +import { convert, brightness } from 'chromatism' +import sum from 'hash-sum' +import { flattenDeep } from 'lodash' +import { + alphaBlend, + getTextColor, + rgba2css, + mixrgb, + relativeLuminance +} from '../color_convert/color_convert.js' + +import { + colorFunctions, + shadowFunctions, + process +} from './theme3_slot_functions.js' + +import { + unroll, + getAllPossibleCombinations, + genericRuleToSelector, + normalizeCombination, + findRules +} from './iss_utils.js' +import { parseCssShadow } from './css_utils.js' + +// Ensuring the order of components +const components = { + Root: null, + Text: null, + FunText: null, + Link: null, + Icon: null, + Border: null, + Panel: null, + Chat: null, + ChatMessage: null +} + +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) + return findShadow(staticVars[variableSlot], { dynamicVars, staticVars }) + } else { + targetShadow = parseCssShadow(shadow) + } + } else { + targetShadow = shadow + } + + const shadowArray = Array.isArray(targetShadow) ? targetShadow : [targetShadow] + return shadowArray.map(s => ({ + ...s, + color: findColor(s.color, { 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 + targetColor = { r, g, b } + } else { + const virtualSlot = variableSlot.replace(/^parent/, '') + targetColor = convert(dynamicVars.lowerLevelVirtualDirectivesRaw[virtualSlot]).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 (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) => { + const opacity = directives.textOpacity + const backgroundColor = convert(dynamicVars.lowerLevelBackground).rgb + const textColor = convert(findColor(intendedTextColor, { dynamicVars, staticVars })).rgb + if (opacity === null || opacity === undefined || opacity >= 1) { + return convert(textColor).hex + } + if (opacity === 0) { + return convert(backgroundColor).hex + } + const opacityMode = directives.textOpacityMode + switch (opacityMode) { + case 'fake': + return convert(alphaBlend(textColor, opacity, backgroundColor)).hex + case 'mixrgb': + return convert(mixrgb(backgroundColor, textColor)).hex + default: + return rgba2css({ a: opacity, ...textColor }) + } +} + +// Loading all style.js[on] files dynamically +const componentsContext = require.context('src', true, /\.style.js(on)?$/) +componentsContext.keys().forEach(key => { + const component = componentsContext(key).default + if (components[component.name] != null) { + console.warn(`Component in file ${key} is trying to override existing component ${component.name}! You have collisions/duplicates!`) + } + components[component.name] = component +}) + +const engineChecksum = sum(components) + +const ruleToSelector = genericRuleToSelector(components) + +export const getEngineChecksum = () => engineChecksum + +export const init = (extraRuleset, ultimateBackgroundColor) => { + const staticVars = {} + const stacked = {} + const computed = {} + + const rulesetUnsorted = [ + ...Object.values(components) + .map(c => (c.defaultRules || []).map(r => ({ component: c.name, ...r }))) + .reduce((acc, arr) => [...acc, ...arr], []), + ...extraRuleset + ].map(rule => { + normalizeCombination(rule) + let currentParent = rule.parent + while (currentParent) { + normalizeCombination(currentParent) + currentParent = currentParent.parent + } + + return rule + }) + + const ruleset = rulesetUnsorted + .map((data, index) => ({ data, index })) + .sort(({ data: a, index: ai }, { data: b, index: bi }) => { + const parentsA = unroll(a).length + const parentsB = unroll(b).length + + if (parentsA === parentsB) { + if (a.component === 'Text') return -1 + if (b.component === 'Text') return 1 + return ai - bi + } + if (parentsA === 0 && parentsB !== 0) return -1 + if (parentsB === 0 && parentsA !== 0) return 1 + return parentsA - parentsB + }) + .map(({ data }) => data) + + const virtualComponents = new Set(Object.values(components).filter(c => c.virtual).map(c => c.name)) + + const processCombination = (combination) => { + const selector = ruleToSelector(combination, true) + const cssSelector = ruleToSelector(combination) + + const parentSelector = selector.split(/ /g).slice(0, -1).join(' ') + const soloSelector = selector.split(/ /g).slice(-1)[0] + + const lowerLevelSelector = parentSelector + const lowerLevelBackground = computed[lowerLevelSelector]?.background + 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(), + ...combination.state.filter(x => x !== 'normal').toSorted().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[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, + ...combination, + directives: computedDirectives + } + + return rule + } + } + + const processInnerComponent = (component, parent) => { + const combinations = [] + const { + validInnerComponents = [], + states: originalStates = {}, + variants: originalVariants = {} + } = component + + // Normalizing states and variants to always include "normal" + const states = { normal: '', ...originalStates } + const variants = { normal: '', ...originalVariants } + const innerComponents = (validInnerComponents).map(name => { + const result = components[name] + if (result === undefined) console.error(`Component ${component.name} references a component ${name} which does not exist!`) + return result + }) + + // Optimization: we only really need combinations without "normal" because all states implicitly have it + const permutationStateKeys = Object.keys(states).filter(s => s !== 'normal') + const stateCombinations = [ + ['normal'], + ...getAllPossibleCombinations(permutationStateKeys) + .map(combination => ['normal', ...combination]) + .filter(combo => { + // Optimization: filter out some hard-coded combinations that don't make sense + if (combo.indexOf('disabled') >= 0) { + return !( + combo.indexOf('hover') >= 0 || + combo.indexOf('focused') >= 0 || + combo.indexOf('pressed') >= 0 + ) + } + return true + }) + ] + + const stateVariantCombination = Object.keys(variants).map(variant => { + return stateCombinations.map(state => ({ variant, state })) + }).reduce((acc, x) => [...acc, ...x], []) + + stateVariantCombination.forEach(combination => { + combination.component = component.name + combination.lazy = component.lazy || parent?.lazy + combination.parent = parent + if (combination.state.indexOf('hover') >= 0) { + combination.lazy = true + } + + combinations.push(combination) + + innerComponents.forEach(innerComponent => { + combinations.push(...processInnerComponent(innerComponent, combination)) + }) + }) + + return combinations + } + + const t0 = performance.now() + const combinations = processInnerComponent(components.Root) + const t1 = performance.now() + console.debug('Tree traveral took ' + (t1 - t0) + ' ms') + + const result = combinations.map((combination) => { + if (combination.lazy) { + return async () => processCombination(combination) + } else { + return processCombination(combination) + } + }).filter(x => x) + const t2 = performance.now() + console.debug('Eager processing took ' + (t2 - t1) + ' ms') + + return { + lazy: result.filter(x => typeof x === 'function'), + eager: result.filter(x => typeof x !== 'function'), + staticVars, + engineChecksum + } +} |
