Merge branch 'themes3-grand-finale-maybe' into 'develop'

Themes 3

See merge request pleroma/pleroma-fe!1951
This commit is contained in:
HJ 2024-12-18 12:19:11 +00:00
commit cbe9427123
76 changed files with 4827 additions and 1236 deletions

View file

@ -10,9 +10,13 @@ export default {
ProfileSettingIndicator
},
props: {
modelValue: {
type: String,
default: null
},
path: {
type: [String, Array],
required: true
required: false
},
disabled: {
type: Boolean,
@ -68,7 +72,7 @@ export default {
}
},
created () {
if (this.realDraftMode && this.realSource !== 'admin') {
if (this.realDraftMode && (this.realSource !== 'admin' || this.path == null)) {
this.draft = this.state
}
},
@ -76,14 +80,14 @@ export default {
draft: {
// TODO allow passing shared draft object?
get () {
if (this.realSource === 'admin') {
if (this.realSource === 'admin' || this.path == null) {
return get(this.$store.state.adminSettings.draft, this.canonPath)
} else {
return this.localDraft
}
},
set (value) {
if (this.realSource === 'admin') {
if (this.realSource === 'admin' || this.path == null) {
this.$store.commit('updateAdminDraft', { path: this.canonPath, value })
} else {
this.localDraft = value
@ -91,6 +95,9 @@ export default {
}
},
state () {
if (this.path == null) {
return this.modelValue
}
const value = get(this.configSource, this.canonPath)
if (value === undefined) {
return this.defaultState
@ -145,6 +152,9 @@ export default {
return this.backendDescription?.suggestions
},
shouldBeDisabled () {
if (this.path == null) {
return this.disabled
}
const parentValue = this.parentPath !== undefined ? get(this.configSource, this.parentPath) : null
return this.disabled || (parentValue !== null ? (this.parentInvert ? parentValue : !parentValue) : false)
},
@ -159,6 +169,9 @@ export default {
}
},
configSink () {
if (this.path == null) {
return (k, v) => this.$emit('update:modelValue', v)
}
switch (this.realSource) {
case 'profile':
return (k, v) => this.$store.dispatch('setProfileOption', { name: k, value: v })
@ -184,6 +197,7 @@ export default {
return this.realSource === 'profile'
},
isChanged () {
if (this.path == null) return false
switch (this.realSource) {
case 'profile':
case 'admin':
@ -193,9 +207,11 @@ export default {
}
},
canonPath () {
if (this.path == null) return null
return Array.isArray(this.path) ? this.path : this.path.split('.')
},
isDirty () {
if (this.path == null) return false
if (this.realSource === 'admin' && this.canonPath.length > 3) {
return false // should not show draft buttons for "grouped" values
} else {

View file

@ -5,6 +5,7 @@
>
<label
:for="path"
class="setting-label"
:class="{ 'faint': shouldBeDisabled }"
>
<template v-if="backendDescriptionLabel">
@ -15,6 +16,7 @@
</template>
<slot v-else />
</label>
{{ ' ' }}
<input
:id="path"
class="input string-input"

View file

@ -10,31 +10,33 @@
<slot />
</label>
{{ ' ' }}
<input
:id="path"
class="input number-input"
type="number"
:step="step"
:disabled="disabled"
:min="min || 0"
:value="stateValue"
@change="updateValue"
>
<Select
:id="path"
:model-value="stateUnit"
:disabled="disabled"
class="unit-input unstyled"
@change="updateUnit"
>
<option
v-for="option in units"
:key="option"
:value="option"
<span class="no-break">
<input
:id="path"
class="input number-input"
type="number"
:step="step"
:disabled="disabled"
:min="min || 0"
:value="stateValue"
@change="updateValue"
>
{{ getUnitString(option) }}
</option>
</Select>
<Select
:id="path"
:model-value="stateUnit"
:disabled="disabled"
class="unit-input unstyled"
@change="updateUnit"
>
<option
v-for="option in units"
:key="option"
:value="option"
>
{{ getUnitString(option) }}
</option>
</Select>
</span>
{{ ' ' }}
<ModifiedIndicator
:changed="isChanged"
@ -47,6 +49,10 @@
<style lang="scss">
.UnitSetting {
.no-break {
display: inline-block;
}
.number-input {
max-width: 6.5em;
text-align: right;

View file

@ -10,6 +10,10 @@
list-style-type: none;
padding-left: 2em;
.btn:not(.dropdown-button) {
padding: 0 2em;
}
li {
margin-bottom: 0.5em;
}
@ -54,10 +58,6 @@
.btn {
min-height: 2em;
}
.btn:not(.dropdown-button) {
padding: 0 2em;
}
}
}
@ -76,6 +76,23 @@
}
}
&.-mobile {
.setting-list,
.option-list {
padding-left: 0.25em;
> li {
margin: 1em 0;
line-height: 1.5em;
vertical-align: center;
}
&.two-column {
column-count: 1;
}
}
}
&.peek {
.settings-modal-panel {
/* Explanation:

View file

@ -17,10 +17,13 @@
}
.select-multiple {
margin-top: 0.5em;
display: flex;
flex-direction: column;
.option-list {
margin: 0;
margin-top: 0.5em;
padding-left: 0.5em;
}
}

View file

@ -10,6 +10,7 @@ import GeneralTab from './tabs/general_tab.vue'
import AppearanceTab from './tabs/appearance_tab.vue'
import VersionTab from './tabs/version_tab.vue'
import ThemeTab from './tabs/theme_tab/theme_tab.vue'
import StyleTab from './tabs/style_tab/style_tab.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@ -17,6 +18,7 @@ import {
faUser,
faFilter,
faPaintBrush,
faPalette,
faBell,
faDownload,
faEyeSlash,
@ -29,6 +31,7 @@ library.add(
faUser,
faFilter,
faPaintBrush,
faPalette,
faBell,
faDownload,
faEyeSlash,
@ -48,6 +51,7 @@ const SettingsModalContent = {
ProfileTab,
GeneralTab,
AppearanceTab,
StyleTab,
VersionTab,
ThemeTab
},
@ -60,6 +64,12 @@ const SettingsModalContent = {
},
bodyLock () {
return this.$store.state.interface.settingsModalState === 'visible'
},
expertLevel () {
return this.$store.state.config.expertLevel
},
isMobileLayout () {
return this.$store.state.interface.layoutType === 'mobile'
}
},
methods: {

View file

@ -1,6 +1,21 @@
.settings_tab-switcher {
height: 100%;
h1 {
margin-bottom: 0.5em;
margin-top: 0.5em;
}
h4 {
margin-bottom: 0;
margin-top: 0.25em;
}
h5 {
margin-bottom: 0;
margin-top: 0.25em;
}
.setting-item {
border-bottom: 2px solid var(--border);
margin: 1em 1em 1.4em;
@ -8,7 +23,6 @@
> div,
> label {
display: block;
margin-bottom: 0.5em;
&:last-child {
@ -17,10 +31,13 @@
}
.select-multiple {
margin-top: 1em;
display: flex;
flex-direction: column;
.option-list {
margin: 0;
margin-top: 0.5em;
padding-left: 0.5em;
}
}

View file

@ -21,7 +21,16 @@
<AppearanceTab />
</div>
<div
:label="$t('settings.theme')"
v-if="expertLevel > 0 && !isMobileLayout"
:label="$t('settings.style.themes3.editor.title')"
icon="palette"
data-tab-name="style"
>
<StyleTab />
</div>
<div
v-if="expertLevel > 0 && !isMobileLayout"
:label="$t('settings.theme_old')"
icon="paint-brush"
data-tab-name="theme"
>

View file

@ -3,20 +3,20 @@ import ChoiceSetting from '../helpers/choice_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import UnitSetting, { defaultHorizontalUnits } from '../helpers/unit_setting.vue'
import PaletteEditor from 'src/components/palette_editor/palette_editor.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 { newImporter } from 'src/services/export_import/export_import.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 { deserialize } from 'src/services/theme_data/iss_deserializer.js'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
@ -27,6 +27,10 @@ import {
import Preview from './theme_tab/theme_preview.vue'
// helper for debugging
// eslint-disable-next-line no-unused-vars
const toValue = (x) => JSON.parse(JSON.stringify(x === undefined ? 'null' : x))
library.add(
faGlobe
)
@ -34,7 +38,28 @@ library.add(
const AppearanceTab = {
data () {
return {
availableStyles: [],
availableThemesV3: [],
availableThemesV2: [],
bundledPalettes: [],
compilationCache: {},
fileImporter: newImporter({
accept: '.json, .piss',
validator: this.importValidator,
onImport: this.onImport,
parser: this.importParser,
onImportFailure: this.onImportFailure
}),
palettesKeys: [
'bg',
'fg',
'link',
'text',
'cRed',
'cGreen',
'cBlue',
'cOrange'
],
userPalette: {},
intersectionObserver: null,
thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({
key: mode,
@ -61,33 +86,69 @@ const AppearanceTab = {
UnitSetting,
ProfileSettingIndicator,
FontControl,
Preview
Preview,
PaletteEditor
},
mounted () {
getThemes()
.then((promises) => {
return Promise.all(
Object.entries(promises)
.map(([k, v]) => v.then(res => [k, res]))
)
this.$store.dispatch('getThemeData')
const updateIndex = (resource) => {
const capitalizedResource = resource[0].toUpperCase() + resource.slice(1)
const currentIndex = this.$store.state.instance[`${resource}sIndex`]
let promise
if (currentIndex) {
promise = Promise.resolve(currentIndex)
} else {
promise = this.$store.dispatch(`fetch${capitalizedResource}sIndex`)
}
return promise.then(index => {
return Object
.entries(index)
.map(([k, func]) => [k, func()])
})
.then(themes => themes.reduce((acc, [k, v]) => {
if (v) {
return [
...acc,
{
name: v.name || v[0],
key: k,
data: v
}
]
}
updateIndex('style').then(styles => {
styles.forEach(([key, stylePromise]) => stylePromise.then(data => {
const meta = data.find(x => x.component === '@meta')
this.availableThemesV3.push({ key, data, name: meta.directives.name, version: 'v3' })
}))
})
updateIndex('theme').then(themes => {
themes.forEach(([key, themePromise]) => themePromise.then(data => {
this.availableThemesV2.push({ key, data, name: data.name, version: 'v2' })
}))
})
this.userPalette = this.$store.state.interface.paletteDataUsed || {}
updateIndex('palette').then(bundledPalettes => {
bundledPalettes.forEach(([key, palettePromise]) => palettePromise.then(v => {
let palette
if (Array.isArray(v)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00'
] = v
palette = { key, name, bg, fg, text, link, cRed, cBlue, cGreen, cOrange }
} else {
return acc
palette = { key, ...v }
}
}, []))
.then((themesComplete) => {
this.availableStyles = themesComplete
})
if (!palette.key.startsWith('style.')) {
this.bundledPalettes.push(palette)
}
}))
})
if (window.IntersectionObserver) {
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
@ -111,7 +172,65 @@ const AppearanceTab = {
})
})
},
watch: {
paletteDataUsed () {
this.userPalette = this.paletteDataUsed || {}
}
},
computed: {
paletteDataUsed () {
return this.$store.state.interface.paletteDataUsed
},
availableStyles () {
return [
...this.availableThemesV3,
...this.availableThemesV2
]
},
availablePalettes () {
return [
...this.bundledPalettes,
...this.stylePalettes
]
},
stylePalettes () {
const ruleset = this.$store.state.interface.styleDataUsed || []
if (!ruleset && ruleset.length === 0) return
const meta = ruleset.find(x => x.component === '@meta')
const result = ruleset.filter(x => x.component.startsWith('@palette'))
.map(x => {
const { variant, directives } = x
const {
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper
} = directives
const result = {
name: `${meta.directives.name || this.$t('settings.style.themes3.palette.imported')}: ${variant}`,
key: `style.${variant.toLowerCase().replace(/ /g, '_')}`,
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper
}
return Object.fromEntries(Object.entries(result).filter(([k, v]) => v))
})
return result
},
noIntersectionObserver () {
return !window.IntersectionObserver
},
@ -144,15 +263,22 @@ const AppearanceTab = {
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
}
},
customThemeVersion () {
const { themeVersion } = this.$store.state.interface
return themeVersion
},
isCustomThemeUsed () {
const { theme } = this.mergedConfig
return theme === 'custom' || theme === null
const { customTheme, customThemeSource } = this.mergedConfig
return customTheme != null || customThemeSource != null
},
isCustomStyleUsed (name) {
const { styleCustomData } = this.mergedConfig
return styleCustomData != null
},
...SharedComputedObject()
},
methods: {
updateFont (key, value) {
console.log(key, value)
this.$store.dispatch('setOption', {
name: 'theme3hacks',
value: {
@ -164,25 +290,120 @@ const AppearanceTab = {
}
})
},
importFile () {
this.fileImporter.importData()
},
importParser (file, filename) {
if (filename.endsWith('.json')) {
return JSON.parse(file)
} else if (filename.endsWith('.piss')) {
return deserialize(file)
}
},
onImport (parsed, filename) {
if (filename.endsWith('.json')) {
this.$store.dispatch('setThemeCustom', parsed.source || parsed.theme)
} else if (filename.endsWith('.piss')) {
this.$store.dispatch('setStyleCustom', parsed)
}
},
onImportFailure (result) {
console.error('Failure importing theme:', result)
this.$store.dispatch('pushGlobalNotice', { messageKey: 'settings.invalid_theme_imported', level: 'error' })
},
importValidator (parsed, filename) {
if (filename.endsWith('.json')) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
} else if (filename.endsWith('.piss')) {
if (!Array.isArray(parsed)) return false
if (parsed.length < 1) return false
if (parsed.find(x => x.component === '@meta') == null) return false
return true
}
},
isThemeActive (key) {
const { theme } = this.mergedConfig
return key === theme
return key === (this.mergedConfig.theme || this.$store.state.instance.theme)
},
isStyleActive (key) {
return key === (this.mergedConfig.style || this.$store.state.instance.style)
},
isPaletteActive (key) {
return key === (this.mergedConfig.palette || this.$store.state.instance.palette)
},
setStyle (name) {
this.$store.dispatch('setStyle', name)
},
setTheme (name) {
this.$store.dispatch('setTheme', { themeName: name, saveData: true, recompile: true })
this.$store.dispatch('setTheme', name)
},
previewTheme (key, 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,
debug: true,
onlyNormalState: true
})
setPalette (name, data) {
this.$store.dispatch('setPalette', name)
this.userPalette = data
},
setPaletteCustom (data) {
this.$store.dispatch('setPaletteCustom', data)
this.userPalette = data
},
resetTheming (name) {
this.$store.dispatch('setStyle', 'stock')
},
previewTheme (key, version, input) {
let theme3
if (this.compilationCache[key]) {
theme3 = this.compilationCache[key]
} else if (input) {
if (version === 'v2') {
const style = normalizeThemeData(input)
const theme2 = convertTheme2To3(style)
theme3 = init({
inputRuleset: theme2,
ultimateBackgroundColor: '#000000',
liteMode: true,
debug: true,
onlyNormalState: true
})
} else if (version === 'v3') {
const palette = input.find(x => x.component === '@palette')
let paletteRule
if (palette) {
const { directives } = palette
directives.link = directives.link || directives.accent
directives.accent = directives.accent || directives.link
paletteRule = {
component: 'Root',
directives: Object.fromEntries(
Object
.entries(directives)
.filter(([k, v]) => k && k !== 'name')
.map(([k, v]) => ['--' + k, 'color | ' + v])
)
}
} else {
paletteRule = null
}
theme3 = init({
inputRuleset: [...input, paletteRule].filter(x => x),
ultimateBackgroundColor: '#000000',
liteMode: true,
debug: true,
onlyNormalState: true
})
}
} else {
theme3 = init({
inputRuleset: [],
ultimateBackgroundColor: '#000000',
liteMode: true,
debug: true,
onlyNormalState: true
})
}
if (!this.compilationCache[key]) {
this.compilationCache[key] = theme3
}
return getScopedVersion(
getCssRules(theme3.eager),

View file

@ -0,0 +1,120 @@
.appearance-tab {
.palette,
.theme-notice {
padding: 0.5em;
margin: 1em;
}
.setting-item {
padding-bottom: 0;
&.heading {
display: grid;
align-items: baseline;
grid-template-columns: 1fr auto auto auto;
grid-gap: 0.5em;
h2 {
flex: 1 0 auto;
}
}
}
.palettes {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 0.5em;
h4,
.unsupported-theme-v2,
.userPalette {
grid-column: 1 / span 2;
}
}
.palette-entry {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.5em;
.palette-label label {
text-align: center;
}
.palette-square {
flex: 0 0 auto;
display: inline-block;
min-width: 1em;
min-height: 1em;
}
}
.column-settings {
display: flex;
justify-content: space-evenly;
flex-wrap: wrap;
}
.column-settings .size-label {
display: block;
margin-bottom: 0.5em;
margin-top: 0.5em;
}
.modal-view.-mobile & {
.palette-entry {
flex-wrap: wrap;
justify-content: center;
}
.palette-label {
line-height: 1.5em;
margin-top: 0.5em;
width: 100%;
}
.palette-preview {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-template-rows: 1em 1em;
margin-bottom: 0.5em;
}
}
.theme-list {
list-style: none;
display: flex;
flex-wrap: wrap;
margin: -0.5em 0;
height: 25em;
overflow-x: hidden;
overflow-y: auto;
scrollbar-gutter: stable;
border-radius: var(--roundness);
border: 1px solid var(--border);
padding: 0;
margin-bottom: 1em;
.theme-preview {
font-size: 1rem; // fix for firefox
width: 19rem;
display: flex;
flex-direction: column;
align-items: center;
margin: 0.5em;
&.placeholder {
opacity: 0.2;
}
.theme-preview-container {
pointer-events: none;
zoom: 0.5;
border: none;
border-radius: var(--roundness);
text-align: left;
}
}
}
}

View file

@ -1,44 +1,161 @@
<template>
<div class="appearance-tab" :label="$t('settings.general')">
<div
class="appearance-tab"
:label="$t('settings.general')"
>
<div class="setting-item">
<h2>{{ $t('settings.theme') }}</h2>
<ul
class="theme-list"
ref="themeList"
class="theme-list"
>
<button
v-if="isCustomThemeUsed"
disabled
class="button-default theme-preview"
>
<preview />
<h4 class="theme-name">{{ $t('settings.style.custom_theme_used') }}</h4>
</button>
<button
v-for="style in availableStyles"
:data-theme-key="style.key"
:key="style.key"
class="button-default theme-preview"
:class="{ toggled: isThemeActive(style.key) }"
@click="setTheme(style.key)"
data-theme-key="stock"
:class="{ toggled: isStyleActive('stock') }"
@click="resetTheming"
>
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
<component
:is="'style'"
v-if="style.ready || noIntersectionObserver"
v-html="previewTheme(style.key, style.data)"
v-html="previewTheme('stock', 'v3')"
/>
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
<preview :class="{ placeholder: ready }" :id="'theme-preview-' + style.key"/>
<h4 class="theme-name">{{ style.name }}</h4>
<preview id="theme-preview-stock" />
<h4 class="theme-name">
{{ $t('settings.style.stock_theme_used') }}
<span class="alert neutral version">v3</span>
</h4>
</button>
<button
v-if="isCustomThemeUsed"
disabled
class="button-default theme-preview toggled"
>
<preview />
<h4 class="theme-name">
{{ $t('settings.style.custom_theme_used') }}
<span class="alert neutral version">v2</span>
</h4>
</button>
<button
v-if="isCustomStyleUsed"
disabled
class="button-default theme-preview toggled"
>
<preview />
<h4 class="theme-name">
{{ $t('settings.style.custom_style_used') }}
<span class="alert neutral version">v3</span>
</h4>
</button>
<button
v-for="style in availableStyles"
:key="style.key"
:data-theme-key="style.key"
class="button-default theme-preview"
:class="{ toggled: isStyleActive(style.key) }"
@click="style.version === 'v2' ? setTheme(style.key) : setStyle(style.key)"
>
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
<div v-if="style.ready || noIntersectionObserver">
<component
:is="'style'"
v-html="previewTheme(style.key, style.version, style.data)"
/>
</div>
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
<preview :id="'theme-preview-' + style.key" />
<h4 class="theme-name">
{{ style.name }}
<span class="alert neutral version">{{ style.version }}</span>
</h4>
</button>
</ul>
</div>
<div class="alert neutral theme-notice">
{{ $t("settings.style.appearance_tab_note") }}
<div class="import-file-container">
<button
class="btn button-default"
@click="importFile"
>
<FAIcon icon="folder-open" />
{{ $t('settings.style.themes3.editor.load_style') }}
</button>
</div>
<div class="setting-item">
<h2>{{ $t('settings.style.themes3.palette.label') }}</h2>
<div class="palettes">
<template v-if="customThemeVersion === 'v3'">
<h4>{{ $t('settings.style.themes3.palette.bundled') }}</h4>
<button
v-for="p in bundledPalettes"
:key="p.name"
class="btn button-default palette-entry"
:class="{ toggled: isPaletteActive(p.key) }"
@click="() => setPalette(p.key, p)"
>
<div class="palette-label">
<label>
{{ p.name }}
</label>
</div>
<div class="palette-preview">
<span
v-for="c in palettesKeys"
:key="c"
class="palette-square"
:style="{ backgroundColor: p[c], border: '1px solid ' + (p[c] ?? 'var(--text)') }"
/>
</div>
</button>
<h4 v-if="stylePalettes?.length > 0">
{{ $t('settings.style.themes3.palette.style') }}
</h4>
<button
v-for="p in stylePalettes || []"
:key="p.name"
class="btn button-default palette-entry"
:class="{ toggled: isPaletteActive(p.key) }"
@click="() => setPalette(p.key, p)"
>
<div class="palette-label">
<label>
{{ p.name ?? $t('settings.style.themes3.palette.user') }}
</label>
</div>
<div class="palette-preview">
<span
v-for="c in palettesKeys"
:key="c"
class="palette-square"
:style="{ backgroundColor: p[c], border: '1px solid ' + (p[c] ?? 'var(--text)') }"
/>
</div>
</button>
<h4 v-if="expertLevel > 0">
{{ $t('settings.style.themes3.palette.user') }}
</h4>
<PaletteEditor
v-if="expertLevel > 0"
class="userPalette"
v-model="userPalette"
:compact="true"
:apply="true"
@applyPalette="data => setPaletteCustom(data)"
/>
</template>
<template v-else-if="customThemeVersion === 'v2'">
<div class="alert neutral theme-notice unsupported-theme-v2">
{{ $t('settings.style.themes3.palette.v2_unsupported') }}
</div>
</template>
</div>
</div>
</div>
<div class="setting-item">
<h2>{{ $t('settings.scale_and_layout') }}</h2>
<div class="alert neutral theme-notice">
{{ $t("settings.style.appearance_tab_note") }}
</div>
<ul class="setting-list">
<li>
<UnitSetting
@ -60,7 +177,7 @@
<code>px</code>
<code>rem</code>
</i18n-t>
<br/>
<br>
<i18n-t
scope="global"
keypath="settings.text_size_tip2"
@ -256,58 +373,4 @@
<script src="./appearance_tab.js"></script>
<style lang="scss">
.appearance-tab {
.theme-notice {
padding: 0.5em;
margin: 1em;
}
.column-settings {
display: flex;
justify-content: space-evenly;
flex-wrap: wrap;
}
.column-settings .size-label {
display: block;
margin-bottom: 0.5em;
margin-top: 0.5em;
}
.theme-list {
list-style: none;
display: flex;
flex-wrap: wrap;
margin: -0.5em 0;
height: 25em;
overflow-x: hidden;
overflow-y: auto;
scrollbar-gutter: stable;
border-radius: var(--roundness);
border: 1px solid var(--border);
padding: 0;
.theme-preview {
font-size: 1rem; // fix for firefox
width: 19rem;
display: flex;
flex-direction: column;
align-items: center;
margin: 0.5em;
&.placeholder {
opacity: 0.2;
}
.theme-preview-container {
pointer-events: none;
zoom: 0.5;
border: none;
border-radius: var(--roundness);
text-align: left;
}
}
}
}
</style>
<style lang="scss" src="./appearance_tab.scss"></style>

View file

@ -0,0 +1,835 @@
import { ref, reactive, computed, watch, watchEffect, provide, getCurrentInstance } from 'vue'
import { useStore } from 'vuex'
import { get, set, unset, throttle } from 'lodash'
import Select from 'src/components/select/select.vue'
import SelectMotion from 'src/components/select/select_motion.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ComponentPreview from 'src/components/component_preview/component_preview.vue'
import StringSetting from '../../helpers/string_setting.vue'
import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import PaletteEditor from 'src/components/palette_editor/palette_editor.vue'
import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
import RoundnessInput from 'src/components/roundness_input/roundness_input.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import Tooltip from 'src/components/tooltip/tooltip.vue'
import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
import Preview from '../theme_tab/theme_preview.vue'
import VirtualDirectivesTab from './virtual_directives_tab.vue'
import { init, findColor } from 'src/services/theme_data/theme_data_3.service.js'
import {
getCssRules,
getScopedVersion
} from 'src/services/theme_data/css_utils.js'
import { serialize } from 'src/services/theme_data/iss_serializer.js'
import { deserializeShadow, deserialize } from 'src/services/theme_data/iss_deserializer.js'
import {
rgb2hex,
hex2rgb,
getContrastRatio
} from 'src/services/color_convert/color_convert.js'
import {
newImporter,
newExporter
} from 'src/services/export_import/export_import.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faFloppyDisk,
faFolderOpen,
faFile,
faArrowsRotate,
faCheck
} from '@fortawesome/free-solid-svg-icons'
// helper for debugging
// eslint-disable-next-line no-unused-vars
const toValue = (x) => JSON.parse(JSON.stringify(x === undefined ? 'null' : x))
// helper to make states comparable
const normalizeStates = (states) => ['normal', ...(states?.filter(x => x !== 'normal') || [])].join(':')
library.add(
faFile,
faFloppyDisk,
faFolderOpen,
faArrowsRotate,
faCheck
)
export default {
components: {
Select,
SelectMotion,
Checkbox,
Tooltip,
StringSetting,
ComponentPreview,
TabSwitcher,
ShadowControl,
ColorInput,
PaletteEditor,
OpacityInput,
RoundnessInput,
ContrastRatio,
Preview,
VirtualDirectivesTab
},
setup (props, context) {
const exports = {}
const store = useStore()
// All rules that are made by editor
const allEditedRules = ref(store.state.interface.styleDataUsed || {})
const styleDataUsed = computed(() => store.state.interface.styleDataUsed)
watch([styleDataUsed], (value) => {
onImport(store.state.interface.styleDataUsed)
}, { once: true })
exports.isActive = computed(() => {
const tabSwitcher = getCurrentInstance().parent.ctx
return tabSwitcher ? tabSwitcher.isActive('style') : false
})
// ## Meta stuff
exports.name = ref('')
exports.author = ref('')
exports.license = ref('')
exports.website = ref('')
const metaOut = computed(() => {
return [
'@meta {',
` name: ${exports.name.value};`,
` author: ${exports.author.value};`,
` license: ${exports.license.value};`,
` website: ${exports.website.value};`,
'}'
].join('\n')
})
const metaRule = computed(() => ({
component: '@meta',
directives: {
name: exports.name.value,
author: exports.author.value,
license: exports.license.value,
website: exports.website.value
}
}))
// ## Palette stuff
const palettes = reactive([
{
name: 'default',
bg: '#121a24',
fg: '#182230',
text: '#b9b9ba',
link: '#d8a070',
accent: '#d8a070',
cRed: '#FF0000',
cBlue: '#0095ff',
cGreen: '#0fa00f',
cOrange: '#ffa500'
},
{
name: 'light',
bg: '#f2f6f9',
fg: '#d6dfed',
text: '#304055',
underlay: '#5d6086',
accent: '#f55b1b',
cBlue: '#0095ff',
cRed: '#d31014',
cGreen: '#0fa00f',
cOrange: '#ffa500',
border: '#d8e6f9'
}
])
exports.palettes = palettes
// This is kinda dumb but you cannot "replace" reactive() object
// and so v-model simply fails when you try to chage (increase only?)
// length of the array. Since linter complains about mutating modelValue
// inside SelectMotion, the next best thing is to just wipe existing array
// and replace it with new one.
const onPalettesUpdate = (e) => {
palettes.splice(0, palettes.length)
palettes.push(...e)
}
exports.onPalettesUpdate = onPalettesUpdate
const selectedPaletteId = ref(0)
const selectedPalette = computed({
get () {
return palettes[selectedPaletteId.value]
},
set (newPalette) {
palettes[selectedPaletteId.value] = newPalette
}
})
exports.selectedPaletteId = selectedPaletteId
exports.selectedPalette = selectedPalette
provide('selectedPalette', selectedPalette)
watch([selectedPalette], () => updateOverallPreview())
exports.getNewPalette = () => ({
name: 'new palette',
bg: '#121a24',
fg: '#182230',
text: '#b9b9ba',
link: '#d8a070',
accent: '#d8a070',
cRed: '#FF0000',
cBlue: '#0095ff',
cGreen: '#0fa00f',
cOrange: '#ffa500'
})
// Raw format
const palettesRule = computed(() => {
return palettes.map(palette => {
const { name, ...rest } = palette
return {
component: '@palette',
variant: name,
directives: Object
.entries(rest)
.filter(([k, v]) => v && k)
.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {})
}
})
})
// Text format
const palettesOut = computed(() => {
return palettes.map(({ name, ...palette }) => {
const entries = Object
.entries(palette)
.filter(([k, v]) => v && k)
.map(([slot, data]) => ` ${slot}: ${data};`)
.join('\n')
return `@palette.${name} {\n${entries}\n}`
}).join('\n\n')
})
// ## Components stuff
// Getting existing components
const componentsContext = require.context('src', true, /\.style.js(on)?$/)
const componentKeysAll = componentsContext.keys()
const componentsMap = new Map(
componentKeysAll
.map(
key => [key, componentsContext(key).default]
).filter(([key, component]) => !component.virtual && !component.notEditable)
)
exports.componentsMap = componentsMap
const componentKeys = [...componentsMap.keys()]
exports.componentKeys = componentKeys
// Component list and selection
const selectedComponentKey = ref(componentsMap.keys().next().value)
exports.selectedComponentKey = selectedComponentKey
const selectedComponent = computed(() => componentsMap.get(selectedComponentKey.value))
const selectedComponentName = computed(() => selectedComponent.value.name)
// Selection basis
exports.selectedComponentVariants = computed(() => {
return Object.keys({ normal: null, ...(selectedComponent.value.variants || {}) })
})
exports.selectedComponentStates = computed(() => {
const all = Object.keys({ normal: null, ...(selectedComponent.value.states || {}) })
return all.filter(x => x !== 'normal')
})
// selection
const selectedVariant = ref('normal')
exports.selectedVariant = selectedVariant
const selectedState = reactive(new Set())
exports.selectedState = selectedState
exports.updateSelectedStates = (state, v) => {
if (v) {
selectedState.add(state)
} else {
selectedState.delete(state)
}
}
// Reset variant and state on component change
const updateSelectedComponent = () => {
selectedVariant.value = 'normal'
selectedState.clear()
}
watch(
selectedComponentName,
updateSelectedComponent
)
// ### Rules stuff aka meat and potatoes
// The native structure of separate rules and the child -> parent
// relation isn't very convenient for editor, we replace the array
// and child -> parent structure with map and parent -> child structure
const rulesToEditorFriendly = (rules, root = {}) => rules.reduce((acc, rule) => {
const { parent: rParent, component: rComponent } = rule
const parent = rParent ?? rule
const hasChildren = !!rParent
const child = hasChildren ? rule : null
const {
component: pComponent,
variant: pVariant = 'normal',
state: pState = [] // no relation to Intel CPUs whatsoever
} = parent
const pPath = `${hasChildren ? pComponent : rComponent}.${pVariant}.${normalizeStates(pState)}`
let output = get(acc, pPath)
if (!output) {
set(acc, pPath, {})
output = get(acc, pPath)
}
if (hasChildren) {
output._children = output._children ?? {}
const {
component: cComponent,
variant: cVariant = 'normal',
state: cState = [],
directives
} = child
const cPath = `${cComponent}.${cVariant}.${normalizeStates(cState)}`
set(output._children, cPath, { directives })
} else {
output.directives = parent.directives
}
return acc
}, root)
const editorFriendlyFallbackStructure = computed(() => {
const root = {}
componentKeys.forEach((componentKey) => {
const componentValue = componentsMap.get(componentKey)
const { defaultRules, name } = componentValue
rulesToEditorFriendly(
defaultRules.map((rule) => ({ ...rule, component: name })),
root
)
})
return root
})
// Checking whether component can support some "directives" which
// are actually virtual subcomponents, i.e. Text, Link etc
exports.componentHas = (subComponent) => {
return !!selectedComponent.value.validInnerComponents?.find(x => x === subComponent)
}
// Path for lodash's get and set
const getPath = (component, directive) => {
const pathSuffix = component ? `._children.${component}.normal.normal` : ''
const path = `${selectedComponentName.value}.${selectedVariant.value}.${normalizeStates([...selectedState])}${pathSuffix}.directives.${directive}`
return path
}
// Templates for directives
const isElementPresent = (component, directive, defaultValue = '') => computed({
get () {
return get(allEditedRules.value, getPath(component, directive)) != null
},
set (value) {
if (value) {
const fallback = get(
editorFriendlyFallbackStructure.value,
getPath(component, directive)
)
set(allEditedRules.value, getPath(component, directive), fallback ?? defaultValue)
} else {
unset(allEditedRules.value, getPath(component, directive))
}
exports.updateOverallPreview()
}
})
const getEditedElement = (component, directive, postProcess = x => x) => computed({
get () {
let usedRule
const fallback = editorFriendlyFallbackStructure.value
const real = allEditedRules.value
const path = getPath(component, directive)
usedRule = get(real, path) // get real
if (!usedRule) {
usedRule = get(fallback, path)
}
return postProcess(usedRule)
},
set (value) {
if (value) {
set(allEditedRules.value, getPath(component, directive), value)
} else {
unset(allEditedRules.value, getPath(component, directive))
}
exports.updateOverallPreview()
}
})
// All the editable stuff for the component
exports.editedBackgroundColor = getEditedElement(null, 'background')
exports.isBackgroundColorPresent = isElementPresent(null, 'background', '#FFFFFF')
exports.editedOpacity = getEditedElement(null, 'opacity')
exports.isOpacityPresent = isElementPresent(null, 'opacity', 1)
exports.editedRoundness = getEditedElement(null, 'roundness')
exports.isRoundnessPresent = isElementPresent(null, 'roundness', '0')
exports.editedTextColor = getEditedElement('Text', 'textColor')
exports.isTextColorPresent = isElementPresent('Text', 'textColor', '#000000')
exports.editedTextAuto = getEditedElement('Text', 'textAuto')
exports.isTextAutoPresent = isElementPresent('Text', 'textAuto', '#000000')
exports.editedLinkColor = getEditedElement('Link', 'textColor')
exports.isLinkColorPresent = isElementPresent('Link', 'textColor', '#000080')
exports.editedIconColor = getEditedElement('Icon', 'textColor')
exports.isIconColorPresent = isElementPresent('Icon', 'textColor', '#909090')
exports.editedBorderColor = getEditedElement('Border', 'textColor')
exports.isBorderColorPresent = isElementPresent('Border', 'textColor', '#909090')
const getContrast = (bg, text) => {
try {
const bgRgb = hex2rgb(bg)
const textRgb = hex2rgb(text)
const ratio = getContrastRatio(bgRgb, textRgb)
return {
// TODO this ideally should be part of <ContractRatio />
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
}
} catch (e) {
console.warn('Failure computing contrast', e)
return { error: e }
}
}
const normalizeShadows = (shadows) => {
return shadows?.map(shadow => {
if (typeof shadow === 'object') {
return shadow
}
if (typeof shadow === 'string') {
try {
return deserializeShadow(shadow)
} catch (e) {
console.warn(e)
return shadow
}
}
return null
})
}
provide('normalizeShadows', normalizeShadows)
// Shadow is partially edited outside the ShadowControl
// for better space utilization
const editedShadow = getEditedElement(null, 'shadow', normalizeShadows)
exports.editedShadow = editedShadow
const editedSubShadowId = ref(null)
exports.editedSubShadowId = editedSubShadowId
const editedSubShadow = computed(() => {
if (editedShadow.value == null || editedSubShadowId.value == null) return null
return editedShadow.value[editedSubShadowId.value]
})
exports.editedSubShadow = editedSubShadow
exports.isShadowPresent = isElementPresent(null, 'shadow', [])
exports.onSubShadow = (id) => {
if (id != null) {
editedSubShadowId.value = id
} else {
editedSubShadow.value = null
}
}
exports.updateSubShadow = (axis, value) => {
if (!editedSubShadow.value || editedSubShadowId.value == null) return
const newEditedShadow = [...editedShadow.value]
newEditedShadow[editedSubShadowId.value] = {
...newEditedShadow[editedSubShadowId.value],
[axis]: value
}
editedShadow.value = newEditedShadow
}
exports.isShadowTabOpen = ref(false)
exports.onTabSwitch = (tab) => {
exports.isShadowTabOpen.value = tab === 'shadow'
}
// component preview
exports.editorHintStyle = computed(() => {
const editorHint = selectedComponent.value.editor
const styles = []
if (editorHint && Object.keys(editorHint).length > 0) {
if (editorHint.aspect != null) {
styles.push(`aspect-ratio: ${editorHint.aspect} !important;`)
}
if (editorHint.border != null) {
styles.push(`border-width: ${editorHint.border}px !important;`)
}
}
return styles.join('; ')
})
const editorFriendlyToOriginal = computed(() => {
const resultRules = []
const convert = (component, data = {}, parent) => {
const variants = Object.entries(data || {})
variants.forEach(([variant, variantData]) => {
const states = Object.entries(variantData)
states.forEach(([jointState, stateData]) => {
const state = jointState.split(/:/g)
const result = {
component,
variant,
state,
directives: stateData.directives || {}
}
if (parent) {
result.parent = {
component: parent
}
}
resultRules.push(result)
// Currently we only support single depth for simplicity's sake
if (!parent) {
Object.entries(stateData._children || {}).forEach(([cName, child]) => convert(cName, child, component))
}
})
})
}
[...componentsMap.values()].forEach(({ name }) => {
convert(name, allEditedRules.value[name])
})
return resultRules
})
const allCustomVirtualDirectives = [...componentsMap.values()]
.map(c => {
return c
.defaultRules
.filter(c => c.component === 'Root')
.map(x => Object.entries(x.directives))
.flat()
})
.filter(x => x)
.flat()
.map(([name, value]) => {
const [valType, valVal] = value.split('|')
return {
name: name.substring(2),
valType: valType?.trim(),
value: valVal?.trim()
}
})
const virtualDirectives = ref(allCustomVirtualDirectives)
exports.virtualDirectives = virtualDirectives
exports.updateVirtualDirectives = (value) => {
virtualDirectives.value = value
}
// Raw format
const virtualDirectivesRule = computed(() => ({
component: 'Root',
directives: Object.fromEntries(
virtualDirectives.value.map(vd => [`--${vd.name}`, `${vd.valType} | ${vd.value}`])
)
}))
// Text format
const virtualDirectivesOut = computed(() => {
return [
'Root {',
...virtualDirectives.value
.filter(vd => vd.name && vd.valType && vd.value)
.map(vd => ` --${vd.name}: ${vd.valType} | ${vd.value};`),
'}'
].join('\n')
})
exports.computeColor = (color) => {
let computedColor
try {
computedColor = findColor(color, { dynamicVars: dynamicVars.value, staticVars: staticVars.value })
if (computedColor) {
return rgb2hex(computedColor)
}
} catch (e) {
console.warn(e)
}
return null
}
provide('computeColor', exports.computeColor)
exports.contrast = computed(() => {
return getContrast(
exports.computeColor(previewColors.value.background),
exports.computeColor(previewColors.value.text)
)
})
// ## Export and Import
const styleExporter = newExporter({
filename: () => exports.name.value ?? 'pleroma_theme',
mime: 'text/plain',
extension: 'piss',
getExportedObject: () => exportStyleData.value
})
const onImport = parsed => {
const editorComponents = parsed.filter(x => x.component.startsWith('@'))
const rootComponent = parsed.find(x => x.component === 'Root')
const rules = parsed.filter(x => !x.component.startsWith('@') && x.component !== 'Root')
const metaIn = editorComponents.find(x => x.component === '@meta').directives
const palettesIn = editorComponents.filter(x => x.component === '@palette')
exports.name.value = metaIn.name
exports.license.value = metaIn.license
exports.author.value = metaIn.author
exports.website.value = metaIn.website
const newVirtualDirectives = Object
.entries(rootComponent.directives)
.map(([name, value]) => {
const [valType, valVal] = value.split('|').map(x => x.trim())
return { name: name.substring(2), valType, value: valVal }
})
virtualDirectives.value = newVirtualDirectives
onPalettesUpdate(palettesIn.map(x => ({ name: x.variant, ...x.directives })))
allEditedRules.value = rulesToEditorFriendly(rules)
exports.updateOverallPreview()
}
const styleImporter = newImporter({
accept: '.piss',
parser (string) { return deserialize(string) },
onImportFailure (result) {
console.error('Failure importing style:', result)
this.$store.dispatch('pushGlobalNotice', { messageKey: 'settings.invalid_theme_imported', level: 'error' })
},
onImport
})
// Raw format
const exportRules = computed(() => [
metaRule.value,
...palettesRule.value,
virtualDirectivesRule.value,
...editorFriendlyToOriginal.value
])
// Text format
const exportStyleData = computed(() => {
return [
metaOut.value,
palettesOut.value,
virtualDirectivesOut.value,
serialize(editorFriendlyToOriginal.value)
].join('\n\n')
})
exports.clearStyle = () => {
onImport(store.state.interface.styleDataUsed)
}
exports.exportStyle = () => {
styleExporter.exportData()
}
exports.importStyle = () => {
styleImporter.importData()
}
exports.applyStyle = () => {
store.dispatch('setStyleCustom', exportRules.value)
}
const overallPreviewRules = ref([])
exports.overallPreviewRules = overallPreviewRules
const overallPreviewCssRules = ref([])
watchEffect(throttle(() => {
try {
overallPreviewCssRules.value = getScopedVersion(
getCssRules(overallPreviewRules.value),
'#edited-style-preview'
).join('\n')
} catch (e) {
console.error(e)
}
}, 500))
exports.overallPreviewCssRules = overallPreviewCssRules
const updateOverallPreview = throttle(() => {
try {
overallPreviewRules.value = init({
inputRuleset: [
...exportRules.value,
{
component: 'Root',
directives: Object.fromEntries(
Object
.entries(selectedPalette.value)
.filter(([k, v]) => k && v && k !== 'name')
.map(([k, v]) => [`--${k}`, `color | ${v}`])
)
}
],
ultimateBackgroundColor: '#000000',
debug: true
}).eager
} catch (e) {
console.error('Could not compile preview theme', e)
return null
}
}, 5000)
//
// Apart from "hover" we can't really show how component looks like in
// certain states, so we have to fake them.
const simulatePseudoSelectors = (css, prefix) => css
.replace(prefix, '.component-preview .preview-block')
.replace(':active', '.preview-active')
.replace(':hover', '.preview-hover')
.replace(':active', '.preview-active')
.replace(':focus', '.preview-focus')
.replace(':focus-within', '.preview-focus-within')
.replace(':disabled', '.preview-disabled')
const previewRules = computed(() => {
const filtered = overallPreviewRules.value.filter(r => {
const componentMatch = r.component === selectedComponentName.value
const parentComponentMatch = r.parent?.component === selectedComponentName.value
if (!componentMatch && !parentComponentMatch) return false
const rule = parentComponentMatch ? r.parent : r
if (rule.component !== selectedComponentName.value) return false
if (rule.variant !== selectedVariant.value) return false
const ruleState = new Set(rule.state.filter(x => x !== 'normal'))
const differenceA = [...ruleState].filter(x => !selectedState.has(x))
const differenceB = [...selectedState].filter(x => !ruleState.has(x))
return (differenceA.length + differenceB.length) === 0
})
const sorted = [...filtered]
.filter(x => x.component === selectedComponentName.value)
.sort((a, b) => {
const aSelectorLength = a.selector.split(/ /g).length
const bSelectorLength = b.selector.split(/ /g).length
return aSelectorLength - bSelectorLength
})
const prefix = sorted[0].selector
return filtered.filter(x => x.selector.startsWith(prefix))
})
exports.previewClass = computed(() => {
const selectors = []
if (!!selectedComponent.value.variants?.normal || selectedVariant.value !== 'normal') {
selectors.push(selectedComponent.value.variants[selectedVariant.value])
}
if (selectedState.size > 0) {
selectedState.forEach(state => {
const original = selectedComponent.value.states[state]
selectors.push(simulatePseudoSelectors(original))
})
}
return selectors.map(x => x.substring(1)).join('')
})
exports.previewCss = computed(() => {
try {
const prefix = previewRules.value[0].selector
const scoped = getCssRules(previewRules.value).map(x => simulatePseudoSelectors(x, prefix))
return scoped.join('\n')
} catch (e) {
console.error('Invalid ruleset', e)
return null
}
})
const dynamicVars = computed(() => {
return previewRules.value[0].dynamicVars
})
const staticVars = computed(() => {
const rootComponent = overallPreviewRules.value.find(r => {
return r.component === 'Root'
})
const rootDirectivesEntries = Object.entries(rootComponent.directives)
const directives = {}
rootDirectivesEntries
.filter(([k, v]) => k.startsWith('--') && v.startsWith('color | '))
.map(([k, v]) => [k.substring(2), v.substring('color | '.length)])
.forEach(([k, v]) => {
directives[k] = findColor(v, { dynamicVars: {}, staticVars: directives })
})
return directives
})
provide('staticVars', staticVars)
exports.staticVars = staticVars
const previewColors = computed(() => {
const stacked = dynamicVars.value.stacked
const background = typeof stacked === 'string' ? stacked : rgb2hex(stacked)
return {
text: previewRules.value.find(r => r.component === 'Text')?.virtualDirectives['--text'],
link: previewRules.value.find(r => r.component === 'Link')?.virtualDirectives['--link'],
border: previewRules.value.find(r => r.component === 'Border')?.virtualDirectives['--border'],
icon: previewRules.value.find(r => r.component === 'Icon')?.virtualDirectives['--icon'],
background
}
})
exports.previewColors = previewColors
exports.updateOverallPreview = updateOverallPreview
updateOverallPreview()
watch(
[
allEditedRules.value,
palettes,
selectedPalette,
selectedState,
selectedVariant
],
updateOverallPreview
)
return exports
}
}

View file

@ -0,0 +1,264 @@
.StyleTab {
.style-control {
display: flex;
flex-wrap: wrap;
align-items: baseline;
margin-bottom: 0.5em;
.label {
margin-right: 0.5em;
flex: 1 1 0;
line-height: 2;
min-height: 2em;
}
&.suboption {
margin-left: 1em;
}
.color-input {
flex: 0 0 0;
}
input,
select {
min-width: 3em;
margin: 0;
flex: 0;
&[type="number"] {
min-width: 9em;
&.-small {
min-width: 5em;
}
}
&[type="range"] {
flex: 1;
min-width: 9em;
align-self: center;
margin: 0 0.25em;
}
&[type="checkbox"] + i {
height: 1.1em;
align-self: center;
}
}
}
.meta-preview {
display: grid;
grid-template:
"meta meta preview preview"
"meta meta preview preview"
"meta meta preview preview"
"meta meta preview preview";
grid-gap: 0.5em;
grid-template-columns: min-content min-content 6fr max-content;
ul.setting-list {
padding: 0;
margin: 0;
display: grid;
grid-template-rows: subgrid;
grid-area: meta;
> li {
margin: 0;
}
.meta-field {
margin: 0;
.setting-label {
display: inline-block;
margin-bottom: 0.5em;
}
}
}
#edited-style-preview {
grid-area: preview;
}
}
.setting-item {
padding-bottom: 0;
.btn {
padding: 0 0.5em;
}
&:not(:first-child) {
margin-top: 0.5em;
}
&:not(:last-child) {
margin-bottom: 0.5em;
}
}
.list-editor {
display: grid;
grid-template-areas:
"label editor"
"selector editor"
"movement editor";
grid-template-columns: 10em 1fr;
grid-template-rows: auto 1fr auto;
grid-gap: 0.5em;
.list-edit-area {
grid-area: editor;
}
.list-select {
grid-area: selector;
margin: 0;
&-label {
font-weight: bold;
grid-area: label;
margin: 0;
align-self: baseline;
}
&-movement {
grid-area: movement;
margin: 0;
}
}
}
.palette-editor {
width: min-content;
.list-edit-area {
display: grid;
align-self: baseline;
grid-template-rows: subgrid;
grid-template-columns: 1fr;
}
.palette-editor-single {
grid-row: 2 / span 2;
}
}
.variables-editor {
.variable-selector {
display: grid;
grid-template-columns: auto 1fr auto 10em;
grid-template-rows: subgrid;
align-items: baseline;
grid-gap: 0 0.5em;
}
.list-edit-area {
display: grid;
grid-template-rows: subgrid;
}
.shadow-control {
grid-row: 2 / span 2;
}
}
.component-editor {
display: grid;
grid-template-columns: 6fr 3fr 4fr;
grid-template-rows: auto auto 1fr;
grid-gap: 0.5em;
grid-template-areas:
"component component variant"
"state state state"
"preview settings settings";
.component-selector {
grid-area: component;
align-self: center;
}
.component-selector,
.state-selector,
.variant-selector {
display: grid;
grid-template-columns: 1fr minmax(1fr, 10em);
grid-template-rows: auto;
grid-auto-flow: column;
grid-gap: 0.5em;
align-items: baseline;
> label:not(.Select) {
font-weight: bold;
justify-self: right;
}
}
.state-selector {
grid-area: state;
grid-template-columns: minmax(min-content, 7em) 1fr;
}
.variant-selector {
grid-area: variant;
}
.state-selector-list {
display: grid;
list-style: none;
grid-auto-flow: dense;
grid-template-columns: repeat(5, minmax(min-content, 1fr));
grid-auto-rows: 1fr;
grid-gap: 0.5em;
padding: 0;
margin: 0;
}
.preview-container {
--border: none;
--shadow: none;
--roundness: none;
grid-area: preview;
}
.component-settings {
grid-area: settings;
}
.editor-tab {
display: grid;
grid-template-columns: 1fr 2em;
grid-column-gap: 0.5em;
align-items: center;
grid-auto-rows: min-content;
grid-auto-flow: dense;
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 0.5em;
}
.shadow-tab {
grid-template-columns: 1fr;
justify-items: center;
}
}
}
.extra-content {
.style-actions-container {
width: 100%;
display: flex;
justify-content: end;
.style-actions {
display: grid;
grid-template-columns: repeat(4, minmax(7em, 1fr));
grid-gap: 0.25em;
}
}
}

View file

@ -0,0 +1,383 @@
<script src="./style_tab.js">
</script>
<template>
<div class="StyleTab">
<div class="setting-item heading">
<h2> {{ $t('settings.style.themes3.editor.title') }} </h2>
<div class="meta-preview">
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
<component
:is="'style'"
v-html="overallPreviewCssRules"
/>
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
<Preview id="edited-style-preview" />
<teleport
v-if="isActive"
to="#unscrolled-content"
>
<div class="style-actions-container">
<div class="style-actions">
<button
class="btn button-default button-new"
@click="clearStyle"
>
<FAIcon icon="arrows-rotate" />
{{ $t('settings.style.themes3.editor.reset_style') }}
</button>
<button
class="btn button-default button-load"
@click="importStyle"
>
<FAIcon icon="folder-open" />
{{ $t('settings.style.themes3.editor.load_style') }}
</button>
<button
class="btn button-default button-save"
@click="exportStyle"
>
<FAIcon icon="floppy-disk" />
{{ $t('settings.style.themes3.editor.save_style') }}
</button>
<button
class="btn button-default button-apply"
@click="applyStyle"
>
<FAIcon icon="check" />
{{ $t('settings.style.themes3.editor.apply_preview') }}
</button>
</div>
</div>
</teleport>
<ul class="setting-list style-metadata">
<li>
<StringSetting class="meta-field" v-model="name">
{{ $t('settings.style.themes3.editor.style_name') }}
</StringSetting>
</li>
<li>
<StringSetting class="meta-field" v-model="author">
{{ $t('settings.style.themes3.editor.style_author') }}
</StringSetting>
</li>
<li>
<StringSetting class="meta-field" v-model="license">
{{ $t('settings.style.themes3.editor.style_license') }}
</StringSetting>
</li>
<li>
<StringSetting class="meta-field" v-model="website">
{{ $t('settings.style.themes3.editor.style_website') }}
</StringSetting>
</li>
</ul>
</div>
</div>
<tab-switcher>
<div
key="component"
class="setting-item component-editor"
:label="$t('settings.style.themes3.editor.component_tab')"
>
<div class="component-selector">
<label for="component-selector">
{{ $t('settings.style.themes3.editor.component_selector') }}
{{ ' ' }}
</label>
<Select
id="component-selector"
v-model="selectedComponentKey"
>
<option
v-for="key in componentKeys"
:key="'component-' + key"
:value="key"
>
{{ componentsMap.get(key).name }}
</option>
</Select>
</div>
<div
v-if="selectedComponentVariants.length > 1"
class="variant-selector"
>
<label for="variant-selector">
{{ $t('settings.style.themes3.editor.variant_selector') }}
</label>
<Select
v-model="selectedVariant"
>
<option
v-for="variant in selectedComponentVariants"
:key="'component-variant-' + variant"
:value="variant"
>
{{ variant }}
</option>
</Select>
</div>
<div
v-if="selectedComponentStates.length > 0"
class="state-selector"
>
<label>
{{ $t('settings.style.themes3.editor.states_selector') }}
</label>
<ul
class="state-selector-list"
>
<li
v-for="state in selectedComponentStates"
:key="'component-state-' + state"
>
<Checkbox
:value="selectedState.has(state)"
@update:modelValue="(v) => updateSelectedStates(state, v)"
>
{{ state }}
</Checkbox>
</li>
</ul>
</div>
<div class="preview-container">
<!-- eslint-disable vue/no-v-html vue/no-v-text-v-html-on-component -->
<component
:is="'style'"
v-html="previewCss"
/>
<!-- eslint-enable vue/no-v-html vue/no-v-text-v-html-on-component -->
<ComponentPreview
class="component-preview"
:show-text="componentHas('Text')"
:shadow-control="isShadowTabOpen"
:preview-class="previewClass"
:preview-style="editorHintStyle"
:preview-css="previewCss"
:disabled="!editedSubShadow && typeof editedShadow !== 'string'"
:shadow="editedSubShadow"
:no-color-control="true"
@update:shadow="({ axis, value }) => updateSubShadow(axis, value)"
/>
</div>
<tab-switcher
ref="tabSwitcher"
class="component-settings"
:on-switch="onTabSwitch"
>
<div
key="main"
class="editor-tab"
:label="$t('settings.style.themes3.editor.main_tab')"
>
<ColorInput
v-model="editedBackgroundColor"
:fallback="computeColor(editedBackgroundColor) ?? previewColors.background"
:disabled="!isBackgroundColorPresent"
:label="$t('settings.style.themes3.editor.background')"
:hide-optional-checkbox="true"
/>
<Tooltip :text="$t('settings.style.themes3.editor.include_in_rule')">
<Checkbox v-model="isBackgroundColorPresent" />
</Tooltip>
<ColorInput
v-if="componentHas('Text')"
v-model="editedTextColor"
:fallback="computeColor(editedTextColor) ?? previewColors.text"
:label="$t('settings.style.themes3.editor.text_color')"
:disabled="!isTextColorPresent"
:hide-optional-checkbox="true"
/>
<Tooltip
v-if="componentHas('Text')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isTextColorPresent" />
</Tooltip>
<div
v-if="componentHas('Text')"
class="style-control suboption"
>
<label
for="textAuto"
class="label"
:class="{ faint: !isTextAutoPresent }"
>
{{ $t('settings.style.themes3.editor.text_auto.label') }}
</label>
<Select
id="textAuto"
v-model="editedTextAuto"
:disabled="!isTextAutoPresent"
>
<option value="no-preserve">
{{ $t('settings.style.themes3.editor.text_auto.no-preserve') }}
</option>
<option value="no-auto">
{{ $t('settings.style.themes3.editor.text_auto.no-auto') }}
</option>
<option value="preserve">
{{ $t('settings.style.themes3.editor.text_auto.preserve') }}
</option>
</Select>
</div>
<Tooltip
v-if="componentHas('Text')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isTextAutoPresent" />
</Tooltip>
<div
class="style-control suboption"
v-if="componentHas('Text')"
>
<label class="label">
{{$t('settings.style.themes3.editor.contrast') }}
</label>
<ContrastRatio
:show-ratio="true"
:contrast="contrast"
/>
</div>
<div v-if="componentHas('Text')">
</div>
<ColorInput
v-if="componentHas('Link')"
v-model="editedLinkColor"
:fallback="computeColor(editedLinkColor) ?? previewColors.link"
:label="$t('settings.style.themes3.editor.link_color')"
:disabled="!isLinkColorPresent"
:hide-optional-checkbox="true"
/>
<Tooltip
v-if="componentHas('Link')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isLinkColorPresent" />
</Tooltip>
<ColorInput
v-if="componentHas('Icon')"
v-model="editedIconColor"
:fallback="computeColor(editedIconColor) ?? previewColors.icon"
:label="$t('settings.style.themes3.editor.icon_color')"
:disabled="!isIconColorPresent"
:hide-optional-checkbox="true"
/>
<Tooltip
v-if="componentHas('Icon')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isIconColorPresent" />
</Tooltip>
<ColorInput
v-if="componentHas('Border')"
v-model="editedBorderColor"
:fallback="computeColor(editedBorderColor) ?? previewColors.border"
:label="$t('settings.style.themes3.editor.border_color')"
:disabled="!isBorderColorPresent"
:hide-optional-checkbox="true"
/>
<Tooltip
v-if="componentHas('Border')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isBorderColorPresent" />
</Tooltip>
<OpacityInput
v-model="editedOpacity"
:disabled="!isOpacityPresent"
:label="$t('settings.style.themes3.editor.opacity')"
/>
<Tooltip :text="$t('settings.style.themes3.editor.include_in_rule')">
<Checkbox v-model="isOpacityPresent" />
</Tooltip>
<RoundnessInput
v-model="editedRoundness"
:disabled="!isRoundnessPresent"
:label="$t('settings.style.themes3.editor.roundness')"
/>
<Tooltip :text="$t('settings.style.themes3.editor.include_in_rule')">
<Checkbox v-model="isRoundnessPresent" />
</Tooltip>
</div>
<div
key="shadow"
class="editor-tab shadow-tab"
:label="$t('settings.style.themes3.editor.shadows_tab')"
>
<Checkbox
v-model="isShadowPresent"
class="style-control"
>
{{ $t('settings.style.themes3.editor.include_in_rule') }}
</checkbox>
<ShadowControl
v-model="editedShadow"
:disabled="!isShadowPresent"
:no-preview="true"
:compact="true"
:static-vars="staticVars"
@subShadowSelected="onSubShadow"
/>
</div>
</tab-switcher>
</div>
<div
key="palette"
:label="$t('settings.style.themes3.editor.palette_tab')"
class="setting-item list-editor palette-editor"
>
<label
class="list-select-label"
for="palette-selector"
>
{{ $t('settings.style.themes3.palette.label') }}
{{ ' ' }}
</label>
<Select
id="palette-selector"
v-model="selectedPaletteId"
class="list-select"
size="4"
>
<option
v-for="(p, index) in palettes"
:key="p.name"
:value="index"
>
{{ p.name }}
</option>
</Select>
<SelectMotion
class="list-select-movement"
:modelValue="palettes"
@update:modelValue="onPalettesUpdate"
:selected-id="selectedPaletteId"
:get-add-value="getNewPalette"
@update:selectedId="e => selectedPaletteId = e"
/>
<div class="list-edit-area">
<StringSetting
class="palette-name-input"
v-model="selectedPalette.name"
>
{{ $t('settings.style.themes3.palette.name_label') }}
</StringSetting>
<PaletteEditor
class="palette-editor-single"
v-model="selectedPalette"
/>
</div>
</div>
<VirtualDirectivesTab
key="variables"
:label="$t('settings.style.themes3.editor.variables_tab')"
:model-value="virtualDirectives"
@update:modelValue="updateVirtualDirectives"
:normalize-shadows="normalizeShadows"
/>
</tab-switcher>
</div>
</template>
<style src="./style_tab.scss" lang="scss"></style>

View file

@ -0,0 +1,132 @@
import { ref, computed, watch, inject } from 'vue'
import Select from 'src/components/select/select.vue'
import SelectMotion from 'src/components/select/select_motion.vue'
import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import { serializeShadow } from 'src/services/theme_data/iss_serializer.js'
// helper for debugging
// eslint-disable-next-line no-unused-vars
const toValue = (x) => JSON.parse(JSON.stringify(x === undefined ? 'null' : x))
export default {
components: {
Select,
SelectMotion,
ShadowControl,
ColorInput
},
props: ['modelValue'],
emits: ['update:modelValue'],
setup (props, context) {
const exports = {}
const emit = context.emit
exports.emit = emit
exports.computeColor = inject('computeColor')
exports.staticVars = inject('staticVars')
const selectedVirtualDirectiveId = ref(0)
exports.selectedVirtualDirectiveId = selectedVirtualDirectiveId
const selectedVirtualDirective = computed({
get () {
return props.modelValue[selectedVirtualDirectiveId.value]
},
set (value) {
const newVD = [...props.modelValue]
newVD[selectedVirtualDirectiveId.value] = value
emit('update:modelValue', newVD)
}
})
exports.selectedVirtualDirective = selectedVirtualDirective
exports.selectedVirtualDirectiveValType = computed({
get () {
return props.modelValue[selectedVirtualDirectiveId.value].valType
},
set (value) {
const newValType = value
let newValue
switch (value) {
case 'shadow':
newValue = '0 0 0 #000000 / 1'
break
case 'color':
newValue = '#000000'
break
default:
newValue = 'none'
}
const newName = props.modelValue[selectedVirtualDirectiveId.value].name
props.modelValue[selectedVirtualDirectiveId.value] = {
name: newName,
value: newValue,
valType: newValType
}
}
})
const draftVirtualDirectiveValid = ref(true)
const draftVirtualDirective = ref({})
exports.draftVirtualDirective = draftVirtualDirective
const normalizeShadows = inject('normalizeShadows')
watch(
selectedVirtualDirective,
(directive) => {
switch (directive.valType) {
case 'shadow': {
if (Array.isArray(directive.value)) {
draftVirtualDirective.value = normalizeShadows(directive.value)
} else {
const splitShadow = directive.value.split(/,/g).map(x => x.trim())
draftVirtualDirective.value = normalizeShadows(splitShadow)
}
break
}
case 'color':
draftVirtualDirective.value = directive.value
break
default:
draftVirtualDirective.value = directive.value
break
}
},
{ immediate: true }
)
watch(
draftVirtualDirective,
(directive) => {
try {
switch (selectedVirtualDirective.value.valType) {
case 'shadow': {
props.modelValue[selectedVirtualDirectiveId.value].value =
directive.map(x => serializeShadow(x)).join(', ')
break
}
default:
props.modelValue[selectedVirtualDirectiveId.value].value = directive
}
draftVirtualDirectiveValid.value = true
} catch (e) {
console.error('Invalid virtual directive value', e)
draftVirtualDirectiveValid.value = false
}
},
{ immediate: true }
)
exports.getNewVirtualDirective = () => ({
name: 'newDirective',
valType: 'generic',
value: 'foobar'
})
return exports
}
}

View file

@ -0,0 +1,83 @@
<script src="./virtual_directives_tab.js"></script>
<template>
<div class="setting-item list-editor variables-editor">
<label
class="list-select-label"
for="variables-selector"
>
{{ $t('settings.style.themes3.editor.variables.label') }}
{{ ' ' }}
</label>
<Select
id="variables-selector"
v-model="selectedVirtualDirectiveId"
class="list-select"
size="20"
>
<option
v-for="(p, index) in modelValue"
:key="p.name"
:value="index"
>
{{ p.name }}
</option>
</Select>
<SelectMotion
class="list-select-movement"
:model-value="modelValue"
@update:modelValue="e => emit('update:modelValue', e)"
:selected-id="selectedVirtualDirectiveId"
@update:selectedId="e => selectedVirtualDirectiveId = e"
:get-add-value="getNewVirtualDirective"
/>
<div class="list-edit-area">
<div class="variable-selector">
<label
class="variable-name-label"
for="variables-selector"
>
{{ $t('settings.style.themes3.editor.variables.name_label') }}
{{ ' ' }}
</label>
<input
class="input"
v-model="selectedVirtualDirective.name"
>
<label
class="variable-type-label"
for="variables-selector"
>
{{ $t('settings.style.themes3.editor.variables.type_label') }}
{{ ' ' }}
</label>
<Select
v-model="selectedVirtualDirectiveValType"
>
<option value='shadow'>
{{ $t('settings.style.themes3.editor.variables.type_shadow') }}
</option>
<option value='color'>
{{ $t('settings.style.themes3.editor.variables.type_color') }}
</option>
<option value='generic'>
{{ $t('settings.style.themes3.editor.variables.type_generic') }}
</option>
</Select>
</div>
<ShadowControl
v-if="selectedVirtualDirectiveValType === 'shadow'"
v-model="draftVirtualDirective"
:static-vars="staticVars"
:compact="true"
/>
<ColorInput
v-if="selectedVirtualDirectiveValType === 'color'"
v-model="draftVirtualDirective"
:fallback="computeColor(draftVirtualDirective)"
:label="$t('settings.style.themes3.editor.variables.virtual_color')"
:hide-optional-checkbox="true"
/>
</div>
</div>
</template>

View file

@ -4,9 +4,6 @@ import {
getContrastRatioLayers,
relativeLuminance
} from 'src/services/color_convert/color_convert.js'
import {
getThemes
} from 'src/services/style_setter/style_setter.js'
import {
newImporter,
newExporter
@ -123,31 +120,24 @@ export default {
}
},
created () {
const self = this
const currentIndex = this.$store.state.instance.themesIndex
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
})
let promise
if (currentIndex) {
promise = Promise.resolve(currentIndex)
} else {
promise = this.$store.dispatch('fetchThemesIndex')
}
promise.then(themesIndex => {
Object
.values(themesIndex)
.forEach(themeFunc => {
themeFunc().then(themeData => this.availableStyles.push(themeData))
})
})
},
mounted () {
this.loadThemeFromLocalStorage()
if (typeof this.shadowSelected === 'undefined') {
this.shadowSelected = this.shadowsAvailable[0]
}
@ -305,6 +295,9 @@ export default {
return {}
}
},
themeDataUsed () {
return this.$store.state.interface.themeDataUsed
},
shadowsAvailable () {
return Object.keys(DEFAULT_SHADOWS).sort()
},
@ -412,9 +405,6 @@ export default {
forceUseSource = false
) {
this.dismissWarning()
if (!source && !theme) {
throw new Error('Can\'t load theme: empty')
}
const version = (origin === 'localStorage' && !theme.colors)
? 'l1'
: fileVersion
@ -490,22 +480,11 @@ export default {
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 {
const theme = this.themeDataUsed?.source
if (theme) {
this.loadTheme(
{
theme,
source: forceSnapshot ? theme : source
theme
},
'localStorage',
confirmLoadSource
@ -724,6 +703,9 @@ export default {
}
},
watch: {
themeDataUsed () {
this.loadThemeFromLocalStorage()
},
currentRadii () {
try {
this.previewTheme.radii = generateRadii({ radii: this.currentRadii }).theme.radii

View file

@ -45,12 +45,16 @@
flex: 0;
&[type="number"] {
min-width: 5em;
min-width: 9em;
&.-small {
min-width: 5em;
}
}
&[type="range"] {
flex: 1;
min-width: 2em;
min-width: 9em;
align-self: center;
margin: 0 0.5em;
}

View file

@ -187,14 +187,14 @@
name="accentColor"
:fallback="previewTheme.colors?.link"
:label="$t('settings.accent')"
:show-optional-tickbox="typeof linkColorLocal !== 'undefined'"
:show-optional-checkbox="typeof linkColorLocal !== 'undefined'"
/>
<ColorInput
v-model="linkColorLocal"
name="linkColor"
:fallback="previewTheme.colors?.accent"
:label="$t('settings.links')"
:show-optional-tickbox="typeof accentColorLocal !== 'undefined'"
:show-optional-checkbox="typeof accentColorLocal !== 'undefined'"
/>
<ContrastRatio :contrast="previewContrast.bgLink" />
</div>
@ -957,6 +957,8 @@
v-model="currentShadow"
:separate-inset="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'"
:fallback="currentShadowFallback"
:static-vars="previewTheme.colors"
:compact="true"
/>
</div>
<div