optimizations, WIP theme selector

appearance-tab
Henry Jameson 2 months ago
parent 9d76fcc425
commit 40c9163d21

@ -20,6 +20,16 @@ export default {
'Tab', 'Tab',
'ListItem' 'ListItem'
], ],
validInnerComponentsLite: [
'Text',
'Link',
'Icon',
'Border',
'Button',
'Input',
'PanelHeader',
'Alert'
],
defaultRules: [ defaultRules: [
{ {
directives: { directives: {

@ -12,6 +12,11 @@ export default {
'Alert', 'Alert',
'Button' // mobile post button 'Button' // mobile post button
], ],
validInnerComponentsLite: [
'Underlay',
'Scrollbar',
'ScrollbarElement'
],
defaultRules: [ defaultRules: [
{ {
directives: { directives: {

@ -6,6 +6,18 @@ import UnitSetting, { defaultHorizontalUnits } from '../helpers/unit_setting.vue
import FontControl from 'src/components/font_control/font_control.vue' import FontControl from 'src/components/font_control/font_control.vue'
import { normalizeThemeData } from 'src/modules/interface'
import {
getThemes
} from 'src/services/style_setter/style_setter.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
import { init } from 'src/services/theme_data/theme_data_3.service.js'
import {
getCssRules,
getScopedVersion
} from 'src/services/theme_data/css_utils.js'
import SharedComputedObject from '../helpers/shared_computed_object.js' import SharedComputedObject from '../helpers/shared_computed_object.js'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue' import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
@ -13,6 +25,8 @@ import {
faGlobe faGlobe
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import Preview from './theme_tab/preview.vue'
library.add( library.add(
faGlobe faGlobe
) )
@ -20,6 +34,7 @@ library.add(
const AppearanceTab = { const AppearanceTab = {
data () { data () {
return { return {
availableStyles: [],
thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({ thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({
key: mode, key: mode,
value: mode, value: mode,
@ -44,7 +59,32 @@ const AppearanceTab = {
FloatSetting, FloatSetting,
UnitSetting, UnitSetting,
ProfileSettingIndicator, ProfileSettingIndicator,
FontControl FontControl,
Preview
},
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
})
}, },
computed: { computed: {
horizontalUnits () { horizontalUnits () {
@ -77,6 +117,25 @@ const AppearanceTab = {
} }
}, },
...SharedComputedObject() ...SharedComputedObject()
},
methods: {
previewTheme (input) {
const style = normalizeThemeData(input)
const x = 2
if (x === 1) return
const theme2 = convertTheme2To3(style)
const theme3 = init({
inputRuleset: theme2,
ultimateBackgroundColor: '#000000',
liteMode: true,
onlyNormalState: true
})
return getScopedVersion(
getCssRules(theme3.eager),
'#theme-preview-' + (input.name || input[0]).replace(/ /g, '_')
).join('\n')
}
} }
} }

@ -1,5 +1,21 @@
<template> <template>
<div :label="$t('settings.general')"> <div :label="$t('settings.general')">
<div class="setting-item">
<h2>{{ $t('settings.theme') }}</h2>
<ul class="theme-list">
<li
v-for="style in availableStyles"
:key="style.name || style[0]"
class="theme-preview"
>
<h6>{{ style[0] || style.name }}</h6>
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
<component :is="'style'" v-html="previewTheme(style)"/>
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
<preview :id="'theme-preview-' + (style[0] || style.name).replace(/ /g,'_')"/>
</li>
</ul>
</div>
<div class="setting-item"> <div class="setting-item">
<h2>{{ $t('settings.scale_and_layout') }}</h2> <h2>{{ $t('settings.scale_and_layout') }}</h2>
<ul class="setting-list"> <ul class="setting-list">
@ -231,4 +247,18 @@
margin-bottom: 0.5em; margin-bottom: 0.5em;
margin-top: 0.5em; margin-top: 0.5em;
} }
.theme-list {
list-style: none;
display: flex;
flex-wrap: wrap;
}
.theme-preview {
width: 10rem;
.preview-container {
zoom: 0.33;
}
}
</style> </style>

@ -139,6 +139,108 @@ export default {}
<style lang="scss"> <style lang="scss">
.preview-container { .preview-container {
position: relative; position: relative;
border-top: 1px dashed;
border-bottom: 1px dashed;
border-color: var(--border);
margin: 1em 0;
padding: 1em;
background-color: var(--wallpaper);
background-image: var(--body-background-image);
background-size: cover;
background-position: 50% 50%;
.theme-preview-content {
padding: 20px;
}
.dummy {
.post {
font-family: var(--postFont);
display: flex;
.content {
flex: 1;
h4 {
margin-bottom: 0.25em;
}
.icons {
margin-top: 0.5em;
display: flex;
i {
margin-right: 1em;
}
}
}
}
.after-post {
margin-top: 1em;
display: flex;
align-items: center;
}
.avatar,
.avatar-alt {
background:
linear-gradient(
135deg,
#b8e1fc 0%,
#a9d2f3 10%,
#90bae4 25%,
#90bcea 37%,
#90bff0 50%,
#6ba8e5 51%,
#a2daf5 83%,
#bdf3fd 100%
);
color: black;
font-family: sans-serif;
text-align: center;
margin-right: 1em;
}
.avatar-alt {
flex: 0 auto;
margin-left: 28px;
font-size: 12px;
min-width: 20px;
min-height: 20px;
line-height: 20px;
}
.avatar {
flex: 0 auto;
width: 48px;
height: 48px;
font-size: 14px;
line-height: 48px;
}
.actions {
display: flex;
align-items: baseline;
.checkbox {
display: inline-flex;
align-items: baseline;
margin-right: 1em;
flex: 1;
}
}
.separator {
margin: 1em;
border-bottom: 1px solid;
border-color: var(--border);
}
.btn {
min-width: 3em;
}
}
} }
.underlay-preview { .underlay-preview {
@ -148,4 +250,4 @@ export default {}
left: 10px; left: 10px;
right: 10px; right: 10px;
} }
</style> </style>

@ -30,7 +30,10 @@ import {
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js' import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
import { init } from 'src/services/theme_data/theme_data_3.service.js' import { init } from 'src/services/theme_data/theme_data_3.service.js'
import { getCssRules } from 'src/services/theme_data/css_utils.js' import {
getCssRules,
getScopedVersion
} from 'src/services/theme_data/css_utils.js'
import ColorInput from 'src/components/color_input/color_input.vue' import ColorInput from 'src/components/color_input/color_input.vue'
import RangeInput from 'src/components/range_input/range_input.vue' import RangeInput from 'src/components/range_input/range_input.vue'
@ -703,17 +706,10 @@ export default {
liteMode: true liteMode: true
}) })
this.themeV3Preview = getCssRules(theme3.eager) this.themeV3Preview = getScopedVersion(
.map(x => { getCssRules(theme3.eager),
if (x.startsWith('html')) { '#theme-preview'
return x.replace('html', '#theme-preview') ).join('\n')
} else if (x.startsWith('#content')) {
return x.replace('#content', '#theme-preview')
} else {
return '#theme-preview > ' + x
}
})
.join('\n')
} }
}, },
watch: { watch: {

@ -161,107 +161,6 @@
} }
} }
.preview-container {
border-top: 1px dashed;
border-bottom: 1px dashed;
border-color: var(--border);
margin: 1em 0;
padding: 1em;
background-color: var(--wallpaper);
background-image: var(--body-background-image);
background-size: cover;
background-position: 50% 50%;
.dummy {
.post {
font-family: var(--postFont);
display: flex;
.content {
flex: 1;
h4 {
margin-bottom: 0.25em;
}
.icons {
margin-top: 0.5em;
display: flex;
i {
margin-right: 1em;
}
}
}
}
.after-post {
margin-top: 1em;
display: flex;
align-items: center;
}
.avatar,
.avatar-alt {
background:
linear-gradient(
135deg,
#b8e1fc 0%,
#a9d2f3 10%,
#90bae4 25%,
#90bcea 37%,
#90bff0 50%,
#6ba8e5 51%,
#a2daf5 83%,
#bdf3fd 100%
);
color: black;
font-family: sans-serif;
text-align: center;
margin-right: 1em;
}
.avatar-alt {
flex: 0 auto;
margin-left: 28px;
font-size: 12px;
min-width: 20px;
min-height: 20px;
line-height: 20px;
}
.avatar {
flex: 0 auto;
width: 48px;
height: 48px;
font-size: 14px;
line-height: 48px;
}
.actions {
display: flex;
align-items: baseline;
.checkbox {
display: inline-flex;
align-items: baseline;
margin-right: 1em;
flex: 1;
}
}
.separator {
margin: 1em;
border-bottom: 1px solid;
border-color: var(--border);
}
.btn {
min-width: 3em;
}
}
}
.radius-item { .radius-item {
flex-basis: auto; flex-basis: auto;
} }
@ -314,10 +213,6 @@
max-width: 50em; max-width: 50em;
} }
.theme-preview-content {
padding: 20px;
}
.theme-warning { .theme-warning {
display: flex; display: flex;
align-items: baseline; align-items: baseline;

@ -17,6 +17,15 @@ export default {
'Attachment', 'Attachment',
'PollGraph' 'PollGraph'
], ],
validInnerComponentsLite: [
'Text',
'Link',
'Icon',
'Border',
'ButtonUnstyled',
'RichContent',
'Avatar'
],
defaultRules: [ defaultRules: [
{ {
directives: { directives: {

@ -234,25 +234,6 @@ const interfaceMod = {
return return
} }
const normalizeThemeData = (themeData) => {
if (themeData.themeFileVerison === 1) {
return generatePreset(themeData).theme
}
// New theme presets don't have 'theme' property, they use 'source'
const themeSource = themeData.source
let out // shout, shout let it all out
if (!themeData.theme || (themeSource && themeSource.themeEngineVersion === CURRENT_VERSION)) {
out = themeSource || themeData
} else {
out = themeData.theme
}
// generatePreset here basically creates/updates "snapshot",
// while also fixing the 2.2 -> 2.3 colors/shadows/etc
return generatePreset(out).theme
}
let promise = null let promise = null
if (themeName) { if (themeName) {
@ -320,3 +301,38 @@ const interfaceMod = {
} }
export default interfaceMod export default interfaceMod
export const normalizeThemeData = (input) => {
let themeData = input
if (Array.isArray(themeData)) {
themeData = { colors: {} }
themeData.colors.bg = input[1]
themeData.colors.fg = input[2]
themeData.colors.text = input[3]
themeData.colors.link = input[4]
themeData.colors.cRed = input[5]
themeData.colors.cGreen = input[6]
themeData.colors.cBlue = input[7]
themeData.colors.cOrange = input[8]
return generatePreset(themeData).theme
}
if (themeData.themeFileVerison === 1) {
return generatePreset(themeData).theme
}
// New theme presets don't have 'theme' property, they use 'source'
const themeSource = themeData.source
let out // shout, shout let it all out
if (!themeData.theme || (themeSource && themeSource.themeEngineVersion === CURRENT_VERSION)) {
out = themeSource || themeData
} else {
out = themeData.theme
}
// generatePreset here basically creates/updates "snapshot",
// while also fixing the 2.2 -> 2.3 colors/shadows/etc
return generatePreset(out).theme
}

@ -159,3 +159,15 @@ export const getCssRules = (rules, debug) => rules.map(rule => {
footer footer
].join('\n') ].join('\n')
}).filter(x => x) }).filter(x => x)
export const getScopedVersion = (rules, newScope) => {
return rules.map(x => {
if (x.startsWith('html')) {
return x.replace('html', newScope)
} else if (x.startsWith('#content')) {
return x.replace('#content', newScope)
} else {
return newScope + ' > ' + x
}
})
}

@ -39,7 +39,23 @@ export const getAllPossibleCombinations = (array) => {
return combos.reduce((acc, x) => [...acc, ...x], []) return combos.reduce((acc, x) => [...acc, ...x], [])
} }
// Converts rule, parents and their criteria into a CSS (or path if ignoreOutOfTreeSelector == true) selector /**
* Converts rule, parents and their criteria into a CSS (or path if ignoreOutOfTreeSelector == true)
* selector.
*
* "path" here refers to "fake" selector that cannot be actually used in UI but is used for internal
* purposes
*
* @param {Object} components - object containing all components definitions
*
* @returns {Function}
* @param {Object} rule - rule in question to convert to CSS selector
* @param {boolean} ignoreOutOfTreeSelector - wthether to ignore aformentioned field in
* component definition and use selector
* @param {boolean} isParent - (mostly) internal argument used when recursing
*
* @returns {String} CSS selector (or path)
*/
export const genericRuleToSelector = components => (rule, ignoreOutOfTreeSelector, isParent) => { export const genericRuleToSelector = components => (rule, ignoreOutOfTreeSelector, isParent) => {
if (!rule && !isParent) return null if (!rule && !isParent) return null
const component = components[rule.component] const component = components[rule.component]
@ -79,6 +95,17 @@ export const genericRuleToSelector = components => (rule, ignoreOutOfTreeSelecto
return selectors.trim() return selectors.trim()
} }
/**
* Check if combination matches
*
* @param {Object} criteria - criteria to match against
* @param {Object} subject - rule/combination to check match
* @param {boolean} strict - strict checking:
* By default every variant and state inherits from "normal" state/variant
* so when checking if combination matches, it WILL match against "normal"
* state/variant. In strict mode inheritance is ignored an "normal" does
* not match
*/
export const combinationsMatch = (criteria, subject, strict) => { export const combinationsMatch = (criteria, subject, strict) => {
if (criteria.component !== subject.component) return false if (criteria.component !== subject.component) return false
@ -101,6 +128,15 @@ export const combinationsMatch = (criteria, subject, strict) => {
return true return true
} }
/**
* Search for rule that matches `criteria` in set of rules
* meant to be used in a ruleset.filter() function
*
* @param {Object} criteria - criteria to search for
* @param {boolean} strict - whether search strictly or not (see combinationsMatch)
*
* @return function that returns true/false if subject matches
*/
export const findRules = (criteria, strict) => subject => { export const findRules = (criteria, strict) => subject => {
// If we searching for "general" rules - ignore "specific" ones // If we searching for "general" rules - ignore "specific" ones
if (criteria.parent === null && !!subject.parent) return false if (criteria.parent === null && !!subject.parent) return false
@ -125,6 +161,7 @@ export const findRules = (criteria, strict) => subject => {
return true return true
} }
// Pre-fills 'normal' state/variant if missing
export const normalizeCombination = rule => { export const normalizeCombination = rule => {
rule.variant = rule.variant ?? 'normal' rule.variant = rule.variant ?? 'normal'
rule.state = [...new Set(['normal', ...(rule.state || [])])] rule.state = [...new Set(['normal', ...(rule.state || [])])]

@ -149,11 +149,30 @@ const ruleToSelector = genericRuleToSelector(components)
export const getEngineChecksum = () => engineChecksum export const getEngineChecksum = () => engineChecksum
/**
* Initializes and compiles the theme according to the ruleset
*
* @param {Object[]} inputRuleset - set of rules to compile theme against. Acts as an override to
* component default rulesets
* @param {string} ultimateBackgroundColor - Color that will be the "final" background for
* calculating contrast ratios and making text automatically accessible. Really used for cases when
* stuff is transparent.
* @param {boolean} debug - print out debug information in console, mostly just performance stuff
* @param {boolean} liteMode - use validInnerComponentsLite instead of validInnerComponents, meant to
* generatate theme previews and such that need to be compiled faster and don't require a lot of other
* components present in "normal" mode
* @param {boolean} onlyNormalState - only use components 'normal' states, meant for generating theme
* previews since states are the biggest factor for compilation time and are completely unnecessary
* when previewing multiple themes at same time
* @param {string} rootComponentName - [UNTESTED] which component to start from, meant for previewing a
* part of the theme (i.e. just the button) for themes 3 editor.
*/
export const init = ({ export const init = ({
inputRuleset, inputRuleset,
ultimateBackgroundColor, ultimateBackgroundColor,
debug = false, debug = false,
liteMode = false, liteMode = false,
onlyNormalState = false,
rootComponentName = 'Root' rootComponentName = 'Root'
}) => { }) => {
if (!inputRuleset) throw new Error('Ruleset is null or undefined!') if (!inputRuleset) throw new Error('Ruleset is null or undefined!')
@ -402,11 +421,16 @@ export const init = ({
const processInnerComponent = (component, parent) => { const processInnerComponent = (component, parent) => {
const combinations = [] const combinations = []
const { const {
validInnerComponents = [],
states: originalStates = {}, states: originalStates = {},
variants: originalVariants = {} variants: originalVariants = {}
} = component } = component
const validInnerComponents = (
liteMode
? (component.validInnerComponentsLite || component.validInnerComponents)
: component.validInnerComponents
) || []
// Normalizing states and variants to always include "normal" // Normalizing states and variants to always include "normal"
const states = { normal: '', ...originalStates } const states = { normal: '', ...originalStates }
const variants = { normal: '', ...originalVariants } const variants = { normal: '', ...originalVariants }
@ -418,22 +442,26 @@ export const init = ({
// Optimization: we only really need combinations without "normal" because all states implicitly have it // Optimization: we only really need combinations without "normal" because all states implicitly have it
const permutationStateKeys = Object.keys(states).filter(s => s !== 'normal') const permutationStateKeys = Object.keys(states).filter(s => s !== 'normal')
const stateCombinations = [ const stateCombinations = onlyNormalState
['normal'], ? [
...getAllPossibleCombinations(permutationStateKeys) ['normal']
.map(combination => ['normal', ...combination]) ]
.filter(combo => { : [
// Optimization: filter out some hard-coded combinations that don't make sense ['normal'],
if (combo.indexOf('disabled') >= 0) { ...getAllPossibleCombinations(permutationStateKeys)
return !( .map(combination => ['normal', ...combination])
combo.indexOf('hover') >= 0 || .filter(combo => {
combo.indexOf('focused') >= 0 || // Optimization: filter out some hard-coded combinations that don't make sense
combo.indexOf('pressed') >= 0 if (combo.indexOf('disabled') >= 0) {
) return !(
} combo.indexOf('hover') >= 0 ||
return true combo.indexOf('focused') >= 0 ||
}) combo.indexOf('pressed') >= 0
] )
}
return true
})
]
const stateVariantCombination = Object.keys(variants).map(variant => { const stateVariantCombination = Object.keys(variants).map(variant => {
return stateCombinations.map(state => ({ variant, state })) return stateCombinations.map(state => ({ variant, state }))
@ -460,7 +488,9 @@ export const init = ({
const t0 = performance.now() const t0 = performance.now()
const combinations = processInnerComponent(components[rootComponentName] ?? components.Root) const combinations = processInnerComponent(components[rootComponentName] ?? components.Root)
const t1 = performance.now() const t1 = performance.now()
console.debug('Tree traveral took ' + (t1 - t0) + ' ms') if (debug) {
console.debug('Tree traveral took ' + (t1 - t0) + ' ms')
}
const result = combinations.map((combination) => { const result = combinations.map((combination) => {
if (combination.lazy) { if (combination.lazy) {
@ -470,7 +500,9 @@ export const init = ({
} }
}).filter(x => x) }).filter(x => x)
const t2 = performance.now() const t2 = performance.now()
console.debug('Eager processing took ' + (t2 - t1) + ' ms') if (debug) {
console.debug('Eager processing took ' + (t2 - t1) + ' ms')
}
return { return {
lazy: result.filter(x => typeof x === 'function'), lazy: result.filter(x => typeof x === 'function'),

Loading…
Cancel
Save