Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85710604
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
47 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/style_switcher/style_switcher.js b/src/components/style_switcher/style_switcher.js
index 17ae9f3000..807f9efcd4 100644
--- a/src/components/style_switcher/style_switcher.js
+++ b/src/components/style_switcher/style_switcher.js
@@ -1,753 +1,756 @@
import { set, delete as del } from 'vue'
import {
rgb2hex,
hex2rgb,
getContrastRatioLayers
} from '../../services/color_convert/color_convert.js'
import {
DEFAULT_SHADOWS,
generateColors,
generateShadows,
generateRadii,
generateFonts,
composePreset,
getThemes,
shadows2to3,
colors2to3
} from '../../services/style_setter/style_setter.js'
import {
SLOT_INHERITANCE
} from '../../services/theme_data/pleromafe.js'
import {
CURRENT_VERSION,
OPACITIES,
getLayers,
getOpacitySlot
} from '../../services/theme_data/theme_data.service.js'
import ColorInput from '../color_input/color_input.vue'
import RangeInput from '../range_input/range_input.vue'
import OpacityInput from '../opacity_input/opacity_input.vue'
import ShadowControl from '../shadow_control/shadow_control.vue'
import FontControl from '../font_control/font_control.vue'
import ContrastRatio from '../contrast_ratio/contrast_ratio.vue'
import TabSwitcher from '../tab_switcher/tab_switcher.js'
import Preview from './preview.vue'
import ExportImport from '../export_import/export_import.vue'
import Checkbox from '../checkbox/checkbox.vue'
// List of color values used in v1
const v1OnlyNames = [
'bg',
'fg',
'text',
'link',
'cRed',
'cGreen',
'cBlue',
'cOrange'
].map(_ => _ + 'ColorLocal')
const colorConvert = (color) => {
if (color.startsWith('--') || color === 'transparent') {
return color
} else {
return hex2rgb(color)
}
}
export default {
data () {
return {
availableStyles: [],
selected: this.$store.getters.mergedConfig.theme,
themeWarning: undefined,
tempImportFile: undefined,
+ engineVersion: 0,
previewShadows: {},
previewColors: {},
previewRadii: {},
previewFonts: {},
shadowsInvalid: true,
colorsInvalid: true,
radiiInvalid: true,
keepColor: false,
keepShadows: false,
keepOpacity: false,
keepRoundness: false,
keepFonts: false,
...Object.keys(SLOT_INHERITANCE)
.map(key => [key, ''])
.reduce((acc, [key, val]) => ({ ...acc, [ key + 'ColorLocal' ]: val }), {}),
...Object.keys(OPACITIES)
.map(key => [key, ''])
.reduce((acc, [key, val]) => ({ ...acc, [ key + 'OpacityLocal' ]: val }), {}),
shadowSelected: undefined,
shadowsLocal: {},
fontsLocal: {},
btnRadiusLocal: '',
inputRadiusLocal: '',
checkboxRadiusLocal: '',
panelRadiusLocal: '',
avatarRadiusLocal: '',
avatarAltRadiusLocal: '',
attachmentRadiusLocal: '',
tooltipRadiusLocal: ''
}
},
created () {
const self = this
getThemes()
.then((promises) => {
return Promise.all(
Object.entries(promises)
.map(([k, v]) => v.then(res => [k, res]))
)
})
.then(themes => themes.reduce((acc, [k, v]) => {
if (v) {
return {
...acc,
[k]: v
}
} else {
return acc
}
}, {}))
.then((themesComplete) => {
self.availableStyles = themesComplete
})
},
mounted () {
this.loadThemeFromLocalStorage()
if (typeof this.shadowSelected === 'undefined') {
this.shadowSelected = this.shadowsAvailable[0]
}
},
computed: {
themeWarningHelp () {
if (!this.themeWarning) return
const t = this.$t
const pre = 'settings.style.switcher.help.'
const {
origin,
themeEngineVersion,
type,
noActionsPossible
} = this.themeWarning
if (origin === 'file') {
// Loaded v2 theme from file
if (themeEngineVersion === 2 && type === 'wrong_version') {
return t(pre + 'v2_imported')
}
if (themeEngineVersion > CURRENT_VERSION) {
return t(pre + 'future_version_imported') + ' ' +
(
noActionsPossible
? t(pre + 'snapshot_missing')
: t(pre + 'snapshot_present')
)
}
if (themeEngineVersion < CURRENT_VERSION) {
return t(pre + 'future_version_imported') + ' ' +
(
noActionsPossible
? t(pre + 'snapshot_missing')
: t(pre + 'snapshot_present')
)
}
} else if (origin === 'localStorage') {
if (type === 'snapshot_source_mismatch') {
return t(pre + 'snapshot_source_mismatch')
}
// FE upgraded from v2
if (themeEngineVersion === 2) {
return t(pre + 'upgraded_from_v2')
}
// Admin downgraded FE
if (themeEngineVersion > CURRENT_VERSION) {
return t(pre + 'fe_downgraded') + ' ' +
(
noActionsPossible
? t(pre + 'migration_snapshot_ok')
: t(pre + 'migration_snapshot_gone')
)
}
// Admin upgraded FE
if (themeEngineVersion < CURRENT_VERSION) {
return t(pre + 'fe_upgraded') + ' ' +
(
noActionsPossible
? t(pre + 'migration_snapshot_ok')
: t(pre + 'migration_snapshot_gone')
)
}
}
},
selectedVersion () {
return Array.isArray(this.selected) ? 1 : 2
},
currentColors () {
return Object.keys(SLOT_INHERITANCE)
.map(key => [key, this[key + 'ColorLocal']])
.reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
},
currentOpacity () {
return Object.keys(OPACITIES)
.map(key => [key, this[key + 'OpacityLocal']])
.reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
},
currentRadii () {
return {
btn: this.btnRadiusLocal,
input: this.inputRadiusLocal,
checkbox: this.checkboxRadiusLocal,
panel: this.panelRadiusLocal,
avatar: this.avatarRadiusLocal,
avatarAlt: this.avatarAltRadiusLocal,
tooltip: this.tooltipRadiusLocal,
attachment: this.attachmentRadiusLocal
}
},
preview () {
return composePreset(this.previewColors, this.previewRadii, this.previewShadows, this.previewFonts)
},
previewTheme () {
if (!this.preview.theme.colors) return { colors: {}, opacity: {}, radii: {}, shadows: {}, fonts: {} }
return this.preview.theme
},
// This needs optimization maybe
previewContrast () {
try {
if (!this.previewTheme.colors.bg) return {}
const colors = this.previewTheme.colors
const opacity = this.previewTheme.opacity
if (!colors.bg) return {}
const hints = (ratio) => ({
text: ratio.toPrecision(3) + ':1',
// AA level, AAA level
aa: ratio >= 4.5,
aaa: ratio >= 7,
// same but for 18pt+ texts
laa: ratio >= 3,
laaa: ratio >= 4.5
})
const colorsConverted = Object.entries(colors).reduce((acc, [key, value]) => ({ ...acc, [key]: colorConvert(value) }), {})
const ratios = Object.entries(SLOT_INHERITANCE).reduce((acc, [key, value]) => {
const slotIsBaseText = key === 'text' || key === 'link'
const slotIsText = slotIsBaseText || (
typeof value === 'object' && value !== null && value.textColor
)
if (!slotIsText) return acc
const { layer, variant } = slotIsBaseText ? { layer: 'bg' } : value
const background = variant || layer
const opacitySlot = getOpacitySlot(background)
const textColors = [
key,
...(background === 'bg' ? ['cRed', 'cGreen', 'cBlue', 'cOrange'] : [])
]
const layers = getLayers(
layer,
variant || layer,
opacitySlot,
colorsConverted,
opacity
)
return {
...acc,
...textColors.reduce((acc, textColorKey) => {
const newKey = slotIsBaseText
? 'bg' + textColorKey[0].toUpperCase() + textColorKey.slice(1)
: textColorKey
return {
...acc,
[newKey]: getContrastRatioLayers(
colorsConverted[textColorKey],
layers,
colorsConverted[textColorKey]
)
}
}, {})
}
}, {})
return Object.entries(ratios).reduce((acc, [k, v]) => { acc[k] = hints(v); return acc }, {})
} catch (e) {
console.warn('Failure computing contrasts', e)
}
},
previewRules () {
if (!this.preview.rules) return ''
return [
...Object.values(this.preview.rules),
'color: var(--text)',
'font-family: var(--interfaceFont, sans-serif)'
].join(';')
},
shadowsAvailable () {
return Object.keys(DEFAULT_SHADOWS).sort()
},
currentShadowOverriden: {
get () {
return !!this.currentShadow
},
set (val) {
if (val) {
set(this.shadowsLocal, this.shadowSelected, this.currentShadowFallback.map(_ => Object.assign({}, _)))
} else {
del(this.shadowsLocal, this.shadowSelected)
}
}
},
currentShadowFallback () {
return (this.previewTheme.shadows || {})[this.shadowSelected]
},
currentShadow: {
get () {
return this.shadowsLocal[this.shadowSelected]
},
set (v) {
set(this.shadowsLocal, this.shadowSelected, v)
}
},
themeValid () {
return !this.shadowsInvalid && !this.colorsInvalid && !this.radiiInvalid
},
exportedTheme () {
const saveEverything = (
!this.keepFonts &&
!this.keepShadows &&
!this.keepOpacity &&
!this.keepRoundness &&
!this.keepColor
)
const source = {
themeEngineVersion: CURRENT_VERSION
}
if (this.keepFonts || saveEverything) {
source.fonts = this.fontsLocal
}
if (this.keepShadows || saveEverything) {
source.shadows = this.shadowsLocal
}
if (this.keepOpacity || saveEverything) {
source.opacity = this.currentOpacity
}
if (this.keepColor || saveEverything) {
source.colors = this.currentColors
}
if (this.keepRoundness || saveEverything) {
source.radii = this.currentRadii
}
const theme = {
themeEngineVersion: CURRENT_VERSION,
...this.previewTheme
}
return {
// To separate from other random JSON files and possible future source formats
_pleroma_theme_version: 2, theme, source
}
}
},
components: {
ColorInput,
OpacityInput,
RangeInput,
ContrastRatio,
ShadowControl,
FontControl,
TabSwitcher,
Preview,
ExportImport,
Checkbox
},
methods: {
loadTheme (
{
theme,
source,
_pleroma_theme_version: fileVersion
},
origin,
forceUseSource = false
) {
if (!source && !theme) {
throw new Error('Can\'t load theme: empty')
}
const version = (origin === 'localStorage' && !theme.colors)
? 'l1'
: fileVersion
const snapshotEngineVersion = (theme || {}).themeEngineVersion
const themeEngineVersion = (source || {}).themeEngineVersion || 2
const versionsMatch = themeEngineVersion === CURRENT_VERSION
const sourceSnapshotMismatch = (
theme !== undefined &&
source !== undefined &&
themeEngineVersion !== snapshotEngineVersion
)
// Force loading of source if user requested it or if snapshot
// is unavailable
const forcedSourceLoad = (source && forceUseSource) || !theme
if (!(versionsMatch && !sourceSnapshotMismatch) &&
!forcedSourceLoad &&
version !== 'l1' &&
origin !== 'defaults'
) {
if (sourceSnapshotMismatch && origin === 'localStorage') {
this.themeWarning = {
origin,
themeEngineVersion,
type: 'snapshot_source_mismatch'
}
} else if (!theme) {
this.themeWarning = {
origin,
noActionsPossible: true,
themeEngineVersion,
type: 'no_snapshot_old_version'
}
} else if (!versionsMatch) {
this.themeWarning = {
origin,
noActionsPossible: !source,
themeEngineVersion,
type: 'wrong_version'
}
}
}
this.normalizeLocalState(theme, version, source, forcedSourceLoad)
},
forceLoadLocalStorage () {
this.loadThemeFromLocalStorage(true)
},
dismissWarning () {
this.themeWarning = undefined
this.tempImportFile = undefined
},
forceLoad () {
const { origin } = this.themeWarning
switch (origin) {
case 'localStorage':
this.loadThemeFromLocalStorage(true)
break
case 'file':
this.onImport(this.tempImportFile, true)
break
}
this.dismissWarning()
},
forceSnapshot () {
const { origin } = this.themeWarning
switch (origin) {
case 'localStorage':
this.loadThemeFromLocalStorage(false, true)
break
case 'file':
console.err('Forcing snapshout from file is not supported yet')
break
}
this.dismissWarning()
},
loadThemeFromLocalStorage (confirmLoadSource = false, forceSnapshot = false) {
const {
customTheme: theme,
customThemeSource: source
} = this.$store.getters.mergedConfig
if (!theme && !source) {
// Anon user or never touched themes
this.loadTheme(
this.$store.state.instance.themeData,
'defaults',
confirmLoadSource
)
} else {
this.loadTheme(
{
theme,
source: forceSnapshot ? theme : source
},
'localStorage',
confirmLoadSource
)
}
},
setCustomTheme () {
this.$store.dispatch('setOption', {
name: 'customTheme',
value: {
themeEngineVersion: CURRENT_VERSION,
...this.previewTheme
}
})
this.$store.dispatch('setOption', {
name: 'customThemeSource',
value: {
themeEngineVersion: CURRENT_VERSION,
shadows: this.shadowsLocal,
fonts: this.fontsLocal,
opacity: this.currentOpacity,
colors: this.currentColors,
radii: this.currentRadii
}
})
},
updatePreviewColorsAndShadows () {
this.previewColors = generateColors({
opacity: this.currentOpacity,
colors: this.currentColors
})
this.previewShadows = generateShadows(
- { shadows: this.shadowsLocal },
+ { shadows: this.shadowsLocal, opacity: this.previewTheme.opacity, themeEngineVersion: this.engineVersion },
this.previewColors.theme.colors,
this.previewColors.mod
)
},
onImport (parsed, forceSource = false) {
this.tempImportFile = parsed
this.loadTheme(parsed, 'file', forceSource)
},
importValidator (parsed) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
},
clearAll () {
this.loadThemeFromLocalStorage()
},
// Clears all the extra stuff when loading V1 theme
clearV1 () {
Object.keys(this.$data)
.filter(_ => _.endsWith('ColorLocal') || _.endsWith('OpacityLocal'))
.filter(_ => !v1OnlyNames.includes(_))
.forEach(key => {
set(this.$data, key, undefined)
})
},
clearRoundness () {
Object.keys(this.$data)
.filter(_ => _.endsWith('RadiusLocal'))
.forEach(key => {
set(this.$data, key, undefined)
})
},
clearOpacity () {
Object.keys(this.$data)
.filter(_ => _.endsWith('OpacityLocal'))
.forEach(key => {
set(this.$data, key, undefined)
})
},
clearShadows () {
this.shadowsLocal = {}
},
clearFonts () {
this.fontsLocal = {}
},
/**
* This applies stored theme data onto form. Supports three versions of data:
* v3 (version >= 3) - newest version of themes which supports snapshots for better compatiblity
* v2 (version = 2) - newer version of themes.
* v1 (version = 1) - older version of themes (import from file)
* v1l (version = l1) - older version of theme (load from local storage)
* v1 and v1l differ because of way themes were stored/exported.
* @param {Object} theme - theme data (snapshot)
* @param {Number} version - version of data. 0 means try to guess based on data. "l1" means v1, locastorage type
* @param {Object} source - theme source - this will be used if compatible
* @param {Boolean} source - by default source won't be used if version doesn't match since it might render differently
* this allows importing source anyway
*/
normalizeLocalState (theme, version = 0, source, forceSource = false) {
let input
if (typeof source !== 'undefined') {
if (forceSource || source.themeEngineVersion === CURRENT_VERSION) {
input = source
version = source.themeEngineVersion
} else {
input = theme
}
} else {
input = theme
}
const radii = input.radii || input
const opacity = input.opacity
const shadows = input.shadows || {}
const fonts = input.fonts || {}
const colors = !input.themeEngineVersion
? colors2to3(input.colors || input)
: input.colors || input
if (version === 0) {
if (input.version) version = input.version
// Old v1 naming: fg is text, btn is foreground
if (typeof colors.text === 'undefined' && typeof colors.fg !== 'undefined') {
version = 1
}
// New v2 naming: text is text, fg is foreground
if (typeof colors.text !== 'undefined' && typeof colors.fg !== 'undefined') {
version = 2
}
}
+ this.engineVersion = version
+
// Stuff that differs between V1 and V2
if (version === 1) {
this.fgColorLocal = rgb2hex(colors.btn)
this.textColorLocal = rgb2hex(colors.fg)
}
if (!this.keepColor) {
this.clearV1()
const keys = new Set(version !== 1 ? Object.keys(colors) : [])
if (version === 1 || version === 'l1') {
keys
.add('bg')
.add('link')
.add('cRed')
.add('cBlue')
.add('cGreen')
.add('cOrange')
}
keys.forEach(key => {
const color = colors[key]
const hex = rgb2hex(colors[key])
this[key + 'ColorLocal'] = hex === '#aN' ? color : hex
})
}
if (opacity && !this.keepOpacity) {
this.clearOpacity()
Object.entries(opacity).forEach(([k, v]) => {
if (typeof v === 'undefined' || v === null || Number.isNaN(v)) return
this[k + 'OpacityLocal'] = v
})
}
if (!this.keepRoundness) {
this.clearRoundness()
Object.entries(radii).forEach(([k, v]) => {
// 'Radius' is kept mostly for v1->v2 localstorage transition
const key = k.endsWith('Radius') ? k.split('Radius')[0] : k
this[key + 'RadiusLocal'] = v
})
}
if (!this.keepShadows) {
this.clearShadows()
if (version === 2) {
- this.shadowsLocal = shadows2to3(shadows)
+ this.shadowsLocal = shadows2to3(shadows, this.previewTheme.opacity)
} else {
this.shadowsLocal = shadows
}
this.shadowSelected = this.shadowsAvailable[0]
}
if (!this.keepFonts) {
this.clearFonts()
this.fontsLocal = fonts
}
}
},
watch: {
currentRadii () {
try {
this.previewRadii = generateRadii({ radii: this.currentRadii })
this.radiiInvalid = false
} catch (e) {
this.radiiInvalid = true
console.warn(e)
}
},
shadowsLocal: {
handler () {
if (Object.getOwnPropertyNames(this.previewColors).length === 1) return
try {
this.updatePreviewColorsAndShadows()
this.shadowsInvalid = false
} catch (e) {
this.shadowsInvalid = true
console.warn(e)
}
},
deep: true
},
fontsLocal: {
handler () {
try {
this.previewFonts = generateFonts({ fonts: this.fontsLocal })
this.fontsInvalid = false
} catch (e) {
this.fontsInvalid = true
console.warn(e)
}
},
deep: true
},
currentColors () {
try {
this.updatePreviewColorsAndShadows()
this.colorsInvalid = false
this.shadowsInvalid = false
} catch (e) {
this.colorsInvalid = true
this.shadowsInvalid = true
console.warn(e)
}
},
currentOpacity () {
try {
this.updatePreviewColorsAndShadows()
} catch (e) {
console.warn(e)
}
},
selected () {
if (this.selectedVersion === 1) {
if (!this.keepRoundness) {
this.clearRoundness()
}
if (!this.keepShadows) {
this.clearShadows()
}
if (!this.keepOpacity) {
this.clearOpacity()
}
if (!this.keepColor) {
this.clearV1()
this.bgColorLocal = this.selected[1]
this.fgColorLocal = this.selected[2]
this.textColorLocal = this.selected[3]
this.linkColorLocal = this.selected[4]
this.cRedColorLocal = this.selected[5]
this.cGreenColorLocal = this.selected[6]
this.cBlueColorLocal = this.selected[7]
this.cOrangeColorLocal = this.selected[8]
}
} else if (this.selectedVersion >= 2) {
this.normalizeLocalState(this.selected.theme, 2, this.selected.source)
}
}
}
}
diff --git a/src/services/color_convert/color_convert.js b/src/services/color_convert/color_convert.js
index 3b6fdcc735..ec10426991 100644
--- a/src/services/color_convert/color_convert.js
+++ b/src/services/color_convert/color_convert.js
@@ -1,224 +1,222 @@
import { invertLightness, contrastRatio } from 'chromatism'
// useful for visualizing color when debugging
export const consoleColor = (color) => console.log('%c##########', 'background: ' + color + '; color: ' + color)
/**
* Convert r, g, b values into hex notation. All components are [0-255]
*
* @param {Number|String|Object} r - Either red component, {r,g,b} object, or hex string
* @param {Number} [g] - Green component
* @param {Number} [b] - Blue component
*/
export const rgb2hex = (r, g, b) => {
if (r === null || typeof r === 'undefined') {
return undefined
}
// TODO: clean up this mess
if (r[0] === '#' || r === 'transparent') {
return r
}
if (typeof r === 'object') {
({ r, g, b } = r)
}
[r, g, b] = [r, g, b].map(val => {
val = Math.ceil(val)
val = val < 0 ? 0 : val
val = val > 255 ? 255 : val
return val
})
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
}
/**
* Converts 8-bit RGB component into linear component
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/relative-luminance.xml
* https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation
*
* @param {Number} bit - color component [0..255]
* @returns {Number} linear component [0..1]
*/
const c2linear = (bit) => {
// W3C gives 0.03928 while wikipedia states 0.04045
// what those magical numbers mean - I don't know.
// something about gamma-correction, i suppose.
// Sticking with W3C example.
const c = bit / 255
if (c < 0.03928) {
return c / 12.92
} else {
return Math.pow((c + 0.055) / 1.055, 2.4)
}
}
/**
* Converts sRGB into linear RGB
* @param {Object} srgb - sRGB color
* @returns {Object} linear rgb color
*/
const srgbToLinear = (srgb) => {
return 'rgb'.split('').reduce((acc, c) => { acc[c] = c2linear(srgb[c]); return acc }, {})
}
/**
* Calculates relative luminance for given color
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/relative-luminance.xml
*
* @param {Object} srgb - sRGB color
* @returns {Number} relative luminance
*/
export const relativeLuminance = (srgb) => {
const { r, g, b } = srgbToLinear(srgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
/**
* Generates color ratio between two colors. Order is unimporant
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
*
* @param {Object} a - sRGB color
* @param {Object} b - sRGB color
* @returns {Number} color ratio
*/
export const getContrastRatio = (a, b) => {
const la = relativeLuminance(a)
const lb = relativeLuminance(b)
const [l1, l2] = la > lb ? [la, lb] : [lb, la]
return (l1 + 0.05) / (l2 + 0.05)
}
/**
* Same as `getContrastRatio` but for multiple layers in-between
*
* @param {Object} text - text color (topmost layer)
* @param {[Object, Number]} layers[] - layers between text and bedrock
* @param {Object} bedrock - layer at the very bottom
*/
export const getContrastRatioLayers = (text, layers, bedrock) => {
return getContrastRatio(alphaBlendLayers(bedrock, layers), text)
}
/**
* This performs alpha blending between solid background and semi-transparent foreground
*
* @param {Object} fg - top layer color
* @param {Number} fga - top layer's alpha
* @param {Object} bg - bottom layer color
* @returns {Object} sRGB of resulting color
*/
export const alphaBlend = (fg, fga, bg) => {
if (fga === 1 || typeof fga === 'undefined') return fg
return 'rgb'.split('').reduce((acc, c) => {
// Simplified https://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending
// for opaque bg and transparent fg
acc[c] = (fg[c] * fga + bg[c] * (1 - fga))
return acc
}, {})
}
/**
* Same as `alphaBlend` but for multiple layers in-between
*
* @param {Object} bedrock - layer at the very bottom
* @param {[Object, Number]} layers[] - layers between text and bedrock
*/
export const alphaBlendLayers = (bedrock, layers) => layers.reduce((acc, [color, opacity]) => {
return alphaBlend(color, opacity, acc)
}, bedrock)
export const invert = (rgb) => {
return 'rgb'.split('').reduce((acc, c) => {
acc[c] = 255 - rgb[c]
return acc
}, {})
}
/**
* Converts #rrggbb hex notation into an {r, g, b} object
*
* @param {String} hex - #rrggbb string
* @returns {Object} rgb representation of the color, values are 0-255
*/
export const hex2rgb = (hex) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null
}
/**
* Old somewhat weird function for mixing two colors together
*
* @param {Object} a - one color (rgb)
* @param {Object} b - other color (rgb)
* @returns {Object} result
*/
export const mixrgb = (a, b) => {
return 'rgb'.split('').reduce((acc, k) => {
acc[k] = (a[k] + b[k]) / 2
return acc
}, {})
}
/**
* Converts rgb object into a CSS rgba() color
*
* @param {Object} color - rgb
* @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})`
}
/**
* Get text color for given background color and intended text color
* This checks if text and background don't have enough color and inverts
* text color's lightness if needed. If text color is still not enough it
* will fall back to black or white
*
* @param {Object} bg - background color
* @param {Object} text - intended text color
* @param {Boolean} preserve - try to preserve intended text color's hue/saturation (i.e. no BW)
*/
export const getTextColor = function (bg, text, preserve) {
const contrast = getContrastRatio(bg, text)
if (contrast < 4.5) {
const base = typeof text.a !== 'undefined' ? { a: text.a } : {}
const result = Object.assign(base, invertLightness(text).rgb)
if (!preserve && getContrastRatio(bg, result) < 4.5) {
// B&W
return contrastRatio(bg, text).rgb
}
// Inverted color
return result
}
return text
}
/**
* Converts color to CSS Color value
*
* @param {Object|String} input - color
* @param {Number} [a] - alpha value
* @returns {String} a CSS Color value
*/
export const getCssColor = (input, a) => {
let rgb = {}
if (typeof input === 'object') {
rgb = input
} else if (typeof input === 'string') {
if (input.startsWith('#')) {
rgb = hex2rgb(input)
- } else if (input.startsWith('--')) {
- return `var(${input})`
} else {
return input
}
}
return rgba2css({ ...rgb, a })
}
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index 48f51c5971..c8610507e8 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -1,410 +1,411 @@
import { convert } from 'chromatism'
import { rgb2hex, hex2rgb, rgba2css, getCssColor, relativeLuminance } from '../color_convert/color_convert.js'
-import { getColors, computeDynamicColor } from '../theme_data/theme_data.service.js'
+import { getColors, computeDynamicColor, getOpacitySlot } from '../theme_data/theme_data.service.js'
export const applyTheme = (input) => {
const { rules } = generatePreset(input)
const head = document.head
const body = document.body
body.classList.add('hidden')
const styleEl = document.createElement('style')
head.appendChild(styleEl)
const styleSheet = styleEl.sheet
styleSheet.toString()
styleSheet.insertRule(`body { ${rules.radii} }`, 'index-max')
styleSheet.insertRule(`body { ${rules.colors} }`, 'index-max')
styleSheet.insertRule(`body { ${rules.shadows} }`, 'index-max')
styleSheet.insertRule(`body { ${rules.fonts} }`, 'index-max')
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
})
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 inputShadows = input.shadows && !input.themeEngineVersion
- ? shadows2to3(input.shadows)
+ ? shadows2to3(input.shadows, input.opacity)
: input.shadows || {}
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.1: 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'
return window.fetch('/static/styles.json', { cache })
.then((data) => data.json())
.then((themes) => {
return Object.entries(themes).map(([k, v]) => {
let promise = null
if (typeof v === 'object') {
promise = Promise.resolve(v)
} else if (typeof v === 'string') {
promise = window.fetch(v, { cache })
.then((data) => data.json())
.catch((e) => {
console.error(e)
return null
})
}
return [k, promise]
})
})
.then((promises) => {
return promises
.reduce((acc, [k, v]) => {
acc[k] = v
return acc
}, {})
})
}
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) => {
+export const shadows2to3 = (shadows, opacity) => {
return Object.entries(shadows).reduce((shadowsAcc, [slotName, shadowDefs]) => {
const isDynamic = ({ color }) => color.startsWith('--')
+ const getOpacity = ({ color }) => opacity[getOpacitySlot(color.substring(2).split(',')[0])]
const newShadow = shadowDefs.reduce((shadowAcc, def) => [
...shadowAcc,
{
...def,
- alpha: isDynamic(def) ? 1 : def.alpha
+ alpha: isDynamic(def) ? getOpacity(def) || 1 : def.alpha
}
], [])
return { ...shadowsAcc, [slotName]: newShadow }
}, {})
}
export const getPreset = (val) => {
return getThemes()
.then((themes) => themes[val] ? themes[val] : themes['pleroma-dark'])
.then((theme) => {
const isV1 = Array.isArray(theme)
const data = isV1 ? {} : theme.theme
if (isV1) {
const bg = hex2rgb(theme[1])
const fg = hex2rgb(theme[2])
const text = hex2rgb(theme[3])
const link = hex2rgb(theme[4])
const cRed = hex2rgb(theme[5] || '#FF0000')
const cGreen = hex2rgb(theme[6] || '#00FF00')
const cBlue = hex2rgb(theme[7] || '#0000FF')
const cOrange = hex2rgb(theme[8] || '#E3FF00')
data.colors = { bg, fg, text, link, cRed, cBlue, cGreen, cOrange }
}
return { theme: data, source: theme.source }
})
}
export const setPreset = (val) => getPreset(val).then(data => applyTheme(data.theme))
diff --git a/static/themes/breezy-dark.json b/static/themes/breezy-dark.json
index 236b94ad9b..76b962c569 100644
--- a/static/themes/breezy-dark.json
+++ b/static/themes/breezy-dark.json
@@ -1,131 +1,131 @@
{
"_pleroma_theme_version": 2,
"name": "Breezy Dark (beta)",
"source": {
"themeEngineVersion": 3,
"fonts": {},
"shadows": {
"panel": [
{
"x": "1",
"y": "2",
"blur": "6",
"spread": 0,
"color": "#000000",
"alpha": 0.6
}
],
"button": [
{
"x": 0,
"y": "0",
"blur": "0",
"spread": "1",
- "color": "#ffffff",
+ "color": "--btn,900",
"alpha": "0.15",
"inset": true
},
{
"x": "1",
"y": "1",
"blur": "1",
"spread": 0,
"color": "#000000",
"alpha": "0.3",
"inset": false
}
],
"panelHeader": [
{
"x": 0,
"y": "40",
"blur": "40",
"spread": "-40",
"inset": true,
- "color": "#ffffff",
+ "color": "--panel,900",
"alpha": "0.1"
}
],
"buttonHover": [
{
"x": 0,
"y": "0",
"blur": 0,
"spread": "1",
"color": "--accent",
"alpha": "1",
"inset": true
},
{
"x": "1",
"y": "1",
"blur": "1",
"spread": 0,
"color": "#000000",
"alpha": "0.3",
"inset": false
}
],
"buttonPressed": [
{
"x": 0,
"y": "0",
"blur": 0,
"spread": "1",
- "color": "#ffffff",
+ "color": "--btn,900",
"alpha": 0.2,
"inset": true
},
{
"x": "1",
"y": "1",
"blur": 0,
"spread": 0,
"color": "#000000",
"alpha": "0.3",
"inset": false
}
],
"input": [
{
"x": 0,
"y": "0",
"blur": 0,
"spread": "1",
- "color": "#FFFFFF",
+ "color": "--input,900",
"alpha": "0.2",
"inset": true
}
]
},
"opacity": {},
"colors": {
"bg": "#31363b",
"text": "#eff0f1",
"link": "#3daee9",
"fg": "#31363b",
"panel": "transparent",
- "input": "#232629",
- "topBarLink": "#eff0f1",
- "btn": "#31363b",
+ "input": "--bg,-6.47",
+ "topBarLink": "--topBarText",
+ "btn": "--bg",
"border": "#4c545b",
"cRed": "#da4453",
"cBlue": "#3daee9",
"cGreen": "#27ae60",
"cOrange": "#f67400",
"btnPressed": "--accent",
"selectedMenu": "--accent",
"selectedMenuPopover": "--accent"
},
"radii": {
"btn": "2",
"input": "2",
"checkbox": "1",
"panel": "2",
"avatar": "2",
"avatarAlt": "2",
"tooltip": "2",
"attachment": "2"
}
}
}
diff --git a/static/themes/breezy-light.json b/static/themes/breezy-light.json
index d3f74cec29..0968fff0cb 100644
--- a/static/themes/breezy-light.json
+++ b/static/themes/breezy-light.json
@@ -1,134 +1,131 @@
{
"_pleroma_theme_version": 2,
"name": "Breezy Light (beta)",
"source": {
"themeEngineVersion": 3,
"fonts": {},
"shadows": {
"panel": [
{
"x": "1",
"y": "2",
"blur": "6",
"spread": 0,
"color": "#000000",
"alpha": 0.6
}
],
"button": [
{
"x": 0,
"y": "0",
"blur": "0",
"spread": "1",
- "color": "#000000",
+ "color": "--btn,900",
"alpha": "0.3",
"inset": true
},
{
"x": "1",
"y": "1",
"blur": "1",
"spread": 0,
"color": "#000000",
"alpha": "0.3",
"inset": false
}
],
"panelHeader": [
{
"x": 0,
"y": "40",
"blur": "40",
"spread": "-40",
"inset": true,
- "color": "#ffffff",
+ "color": "--panel,900",
"alpha": "0.1"
}
],
"buttonHover": [
{
"x": 0,
"y": "0",
"blur": 0,
"spread": "1",
"color": "--accent",
"alpha": "1",
"inset": true
},
{
"x": "1",
"y": "1",
"blur": "1",
"spread": 0,
"color": "#000000",
"alpha": "0.3",
"inset": false
}
],
"buttonPressed": [
{
"x": 0,
"y": "0",
"blur": 0,
"spread": "1",
- "color": "#ffffff",
+ "color": "--btn,900",
"alpha": 0.2,
"inset": true
},
{
"x": "1",
"y": "1",
"blur": 0,
"spread": 0,
"color": "#000000",
"alpha": "0.3",
"inset": false
}
],
"input": [
{
"x": 0,
"y": "0",
"blur": 0,
"spread": "1",
- "color": "#000000",
+ "color": "--input,900",
"alpha": "0.2",
"inset": true
}
]
},
"opacity": {
"input": "1"
},
"colors": {
"bg": "#eff0f1",
"text": "#232627",
- "fg": "#bcc2c7",
+ "fg": "#475057",
"accent": "#2980b9",
- "panel": "#475057",
- "panelText": "#fcfcfc",
- "input": "#fcfcfc",
- "topBar": "#475057",
- "topBarLink": "#eff0f1",
- "btn": "#eff0f1",
+ "input": "--bg,-6.47",
+ "topBarLink": "--topBarText",
+ "btn": "--bg",
"cRed": "#da4453",
"cBlue": "#2980b9",
"cGreen": "#27ae60",
"cOrange": "#f67400",
"btnPressed": "--accent",
"selectedMenu": "--accent",
"selectedMenuPopover": "--accent"
},
"radii": {
"btn": "2",
"input": "2",
"checkbox": "1",
"panel": "2",
"avatar": "2",
"avatarAlt": "2",
"tooltip": "2",
"attachment": "2"
}
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Sep 19, 1:14 AM (9 h, 42 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1764874
Default Alt Text
(47 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment