Compare commits

...

45 commits

Author SHA1 Message Date
Henry Jameson
e7406974d5 Merge branch 'themes3-grand-finale-maybe' into shigusegubu-themes3 2024-10-03 02:21:42 +03:00
Henry Jameson
13838a75a9 import of v2 on appearance tab works now 2024-10-03 02:16:55 +03:00
Henry Jameson
20e6382df6 fix states not showing, move palette out of the way 2024-10-03 00:53:50 +03:00
Henry Jameson
dee3cf7883 migrate all other function calls to new format 2024-10-03 00:44:16 +03:00
Henry Jameson
ab295c588d better input styles 2024-10-03 00:12:40 +03:00
Henry Jameson
02ecd8bb6c remove old shadow parser, fix only first shadow applying 2024-10-02 23:59:56 +03:00
Henry Jameson
424da4c311 lint 2024-10-02 16:31:30 +03:00
Henry Jameson
e876c98d5e lint 2024-10-02 16:30:07 +03:00
Henry Jameson
2eb8e1e095 fix incorrect blue/green colors 2024-10-02 16:29:33 +03:00
Henry Jameson
e8d0e45b5b it is working. finally. 2024-10-02 16:22:28 +03:00
Henry Jameson
ba4be2cb22 yet another massive overhaul on how themes are loaded/applied 2024-10-02 02:35:52 +03:00
Henry Jameson
f0957bdb4f palettes that actually work 2024-10-01 00:42:33 +03:00
Henry Jameson
07a48315a1 popover and palette 2024-09-30 15:11:43 +03:00
Henry Jameson
89b05cfc57 export/import PoC works 2024-09-30 00:16:47 +03:00
Henry Jameson
d2cce99086 palette editor done 2024-09-29 21:14:31 +03:00
Henry Jameson
d5571216fe comments 2024-09-29 19:37:53 +03:00
Henry Jameson
596a1e4961 Merge branch 'shadow-control-2.0' into themes3-grand-finale-maybe 2024-09-29 19:24:02 +03:00
Henry Jameson
05ab57a8e6 better disabled indication 2024-09-29 19:23:32 +03:00
Henry Jameson
c677bbf102 fallback 2024-09-29 19:20:58 +03:00
Henry Jameson
c2dfe62481 subshadow select event + better styles for preview 2024-09-29 19:20:09 +03:00
Henry Jameson
b599407b67 Shadows work now 2024-09-29 19:18:25 +03:00
Henry Jameson
9753db1c67 it works! 2024-09-29 03:20:14 +03:00
Henry Jameson
2a98ea6ddc Merge branch 'shadow-control-2.0' into themes3-grand-finale-maybe 2024-09-29 00:57:39 +03:00
Henry Jameson
6230edcbec small fixes 2024-09-29 00:57:33 +03:00
Henry Jameson
dd4cab74d1 small fixes 2024-09-29 00:57:00 +03:00
Henry Jameson
58ac749755 Merge branch 'shadow-control-2.0' into themes3-grand-finale-maybe 2024-09-28 20:33:50 +03:00
Henry Jameson
0cec6dc356 better small shadow-tweak styles 2024-09-28 20:33:30 +03:00
Henry Jameson
4ddc191928 Merge branch 'shadow-control-2.0' into themes3-grand-finale-maybe 2024-09-28 20:28:22 +03:00
Henry Jameson
d234ad8672 properly support "disabled" state of contoller 2024-09-28 20:27:55 +03:00
Henry Jameson
e541e2c682 Merge branch 'shadow-control-2.0' into themes3-grand-finale-maybe 2024-09-28 20:26:42 +03:00
Henry Jameson
45f6e003c4 properly support "disabled" state of contoller 2024-09-28 20:23:02 +03:00
Henry Jameson
487d9c447d fallback values 2024-09-28 15:22:59 +03:00
Henry Jameson
aa922faf62 small cleanup 2024-09-27 16:37:57 +03:00
Henry Jameson
415180e8fa Adding rules now works 2024-09-27 16:25:39 +03:00
Henry Jameson
fb40694e8e basic colors / settings present 2024-09-26 22:31:28 +03:00
Henry Jameson
ef795becf6 shadow editor now can display shadow information 2024-09-26 01:06:14 +03:00
Henry Jameson
b4a1bcd070 Merge branch 'shadow-control-2.0' into themes3-grand-finale-maybe 2024-09-25 23:20:31 +03:00
Henry Jameson
f1468a3f5d Merge branch 'shadow-control-2.0' into themes3-grand-finale-maybe 2024-09-25 00:48:35 +03:00
Henry Jameson
e7eb1059c3 better display and also temporary fallback for lowerLevelBackground 2024-09-25 00:46:58 +03:00
Henry Jameson
e1d3ebc943 some initial drafts of component editor 2024-09-24 21:32:13 +03:00
Henry Jameson
d5549ac1ee Merge branch 'shadow-control-2.0' into themes3-grand-finale-maybe 2024-09-24 19:32:21 +03:00
Henry Jameson
9db5552f30 Merge branch 'shadow-control-2.0' into themes3-grand-finale-maybe 2024-09-24 19:20:40 +03:00
Henry Jameson
22d3c13135 Merge branch 'shadow-control-2.0' into themes3-grand-finale-maybe 2024-09-24 18:09:44 +03:00
Henry Jameson
c73965bee0 Merge remote-tracking branch 'origin/develop' into themes3-grand-finale-maybe 2024-09-24 12:01:19 +03:00
Henry Jameson
144d426864 some initial work on theme editor 2024-09-24 03:07:27 +03:00
48 changed files with 2299 additions and 558 deletions

View file

@ -327,7 +327,11 @@ const setConfig = async ({ store }) => {
const checkOAuthToken = async ({ store }) => { const checkOAuthToken = async ({ store }) => {
if (store.getters.getUserToken()) { if (store.getters.getUserToken()) {
return store.dispatch('loginUser', store.getters.getUserToken()) try {
await store.dispatch('loginUser', store.getters.getUserToken())
} catch (e) {
console.error(e)
}
} }
return Promise.resolve() return Promise.resolve()
} }
@ -345,26 +349,19 @@ const afterStoreSetup = async ({ store, i18n }) => {
const server = (typeof overrides.target !== 'undefined') ? overrides.target : window.location.origin const server = (typeof overrides.target !== 'undefined') ? overrides.target : window.location.origin
store.dispatch('setInstanceOption', { name: 'server', value: server }) store.dispatch('setInstanceOption', { name: 'server', value: server })
document.querySelector('#status').textContent = i18n.global.t('splash.settings')
await setConfig({ store }) await setConfig({ store })
document.querySelector('#status').textContent = i18n.global.t('splash.theme') await store.dispatch('applyTheme', { recompile: false })
try {
await store.dispatch('setTheme').catch((e) => { console.error('Error setting theme', e) })
} catch (e) {
return Promise.reject(e)
}
applyConfig(store.state.config, i18n.global) applyConfig(store.state.config)
// Now we can try getting the server settings and logging in // Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized // Most of these are preloaded into the index.html so blocking is minimized
document.querySelector('#status').textContent = i18n.global.t('splash.instance')
await Promise.all([ await Promise.all([
checkOAuthToken({ store }), checkOAuthToken({ store }),
getInstancePanel({ store }), getInstancePanel({ store }),
getNodeInfo({ store }), getNodeInfo({ store }),
getInstanceConfig({ store }) getInstanceConfig({ store })
]).catch(e => Promise.reject(e)) ])
// Start fetching things that don't need to block the UI // Start fetching things that don't need to block the UI
store.dispatch('fetchMutes') store.dispatch('fetchMutes')
@ -398,9 +395,9 @@ const afterStoreSetup = async ({ store, i18n }) => {
// remove after vue 3.3 // remove after vue 3.3
app.config.unwrapInjectedRef = true app.config.unwrapInjectedRef = true
document.querySelector('#status').textContent = i18n.global.t('splash.almost')
app.mount('#app') app.mount('#app')
return app return app
} }

View file

@ -14,6 +14,10 @@ export default {
warning: '.warning', warning: '.warning',
success: '.success' success: '.success'
}, },
editor: {
border: 1,
aspect: '3 / 1'
},
defaultRules: [ defaultRules: [
{ {
directives: { directives: {

View file

@ -5,7 +5,7 @@ export default {
defaultRules: [ defaultRules: [
{ {
directives: { directives: {
textColor: '$mod(--parent, 10)', textColor: '$mod(--parent 10)',
textAuto: 'no-auto' textAuto: 'no-auto'
} }
} }

View file

@ -9,9 +9,9 @@ export default {
// However, cascading still works, so resulting state will be result of merging of all relevant states/variants // However, cascading still works, so resulting state will be result of merging of all relevant states/variants
// normal: '' // normal state is implicitly added, it is always included // normal: '' // normal state is implicitly added, it is always included
toggled: '.toggled', toggled: '.toggled',
pressed: ':active', focused: ':focus-visible',
pressed: ':focus:active',
hover: ':hover:not(:disabled)', hover: ':hover:not(:disabled)',
focused: ':focus-within',
disabled: ':disabled' disabled: ':disabled'
}, },
// Variants are mutually exclusive, each component implicitly has "normal" variant, and all other variants inherit from it. // Variants are mutually exclusive, each component implicitly has "normal" variant, and all other variants inherit from it.
@ -22,6 +22,9 @@ export default {
// Overall the compuation difficulty is N*((1/6)M^3+M) where M is number of distinct states and N is number of variants. // Overall the compuation difficulty is N*((1/6)M^3+M) where M is number of distinct states and N is number of variants.
// This (currently) is further multipled by number of places where component can exist. // This (currently) is further multipled by number of places where component can exist.
}, },
editor: {
aspect: '2 / 1'
},
// This lists all other components that can possibly exist within one. Recursion is currently not supported (and probably won't be supported ever). // This lists all other components that can possibly exist within one. Recursion is currently not supported (and probably won't be supported ever).
validInnerComponents: [ validInnerComponents: [
'Text', 'Text',
@ -32,10 +35,11 @@ export default {
{ {
component: 'Root', component: 'Root',
directives: { directives: {
'--defaultButtonHoverGlow': 'shadow | 0 0 4 --text', '--defaultButtonHoverGlow': 'shadow | 0 0 4 --text / 0.5',
'--defaultButtonFocusGlow': 'shadow | 0 0 4 4 --link / 0.5',
'--defaultButtonShadow': 'shadow | 0 0 2 #000000', '--defaultButtonShadow': 'shadow | 0 0 2 #000000',
'--defaultButtonBevel': 'shadow | $borderSide(#FFFFFF, top, 0.2), $borderSide(#000000, bottom, 0.2)', '--defaultButtonBevel': 'shadow | $borderSide(#FFFFFF top 0.2 2), $borderSide(#000000 bottom 0.2 2)',
'--pressedButtonBevel': 'shadow | $borderSide(#FFFFFF, bottom, 0.2), $borderSide(#000000, top, 0.2)' '--pressedButtonBevel': 'shadow | $borderSide(#FFFFFF bottom 0.2 2), $borderSide(#000000 top 0.2 2)'
} }
}, },
{ {
@ -53,6 +57,12 @@ export default {
shadow: ['--defaultButtonHoverGlow', '--defaultButtonBevel'] shadow: ['--defaultButtonHoverGlow', '--defaultButtonBevel']
} }
}, },
{
state: ['focused'],
directives: {
shadow: ['--defaultButtonFocusGlow', '--defaultButtonBevel']
}
},
{ {
state: ['pressed'], state: ['pressed'],
directives: { directives: {
@ -60,9 +70,9 @@ export default {
} }
}, },
{ {
state: ['hover', 'pressed'], state: ['pressed', 'hover'],
directives: { directives: {
shadow: ['--defaultButtonHoverGlow', '--pressedButtonBevel'] shadow: ['--pressedButtonBevel', '--defaultButtonHoverGlow']
} }
}, },
{ {
@ -82,7 +92,7 @@ export default {
{ {
state: ['disabled'], state: ['disabled'],
directives: { directives: {
background: '$blend(--inheritedBackground, 0.25, --parent)', background: '$blend(--inheritedBackground 0.25 --parent)',
shadow: ['--defaultButtonBevel'] shadow: ['--defaultButtonBevel']
} }
}, },

View file

@ -1,6 +1,7 @@
export default { export default {
name: 'ButtonUnstyled', name: 'ButtonUnstyled',
selector: '.button-unstyled', selector: '.button-unstyled',
notEditable: true,
states: { states: {
toggled: '.toggled', toggled: '.toggled',
disabled: ':disabled', disabled: ':disabled',

View file

@ -15,7 +15,7 @@
:model-value="present" :model-value="present"
:disabled="disabled" :disabled="disabled"
class="opt" class="opt"
@update:modelValue="update(typeof modelValue === 'undefined' ? fallback : undefined)" @update:modelValue="updateValue(typeof modelValue === 'undefined' ? fallback : undefined)"
/> />
<div <div
class="input color-input-field" class="input color-input-field"

View file

@ -1,73 +1,105 @@
<template> <template>
<div class="ComponentPreview">
<label
class="header"
v-show="shadowControl"
:class="{ faint: disabled }"
>
{{ $t('settings.style.shadows.offset') }}
</label>
<input
v-show="shadowControl"
:value="shadow?.y"
:disabled="disabled"
:class="{ disabled }"
class="input input-number y-shift-number"
type="number"
@input="e => updateProperty('y', e.target.value)"
>
<input
v-show="shadowControl"
:value="shadow?.y"
:disabled="disabled"
:class="{ disabled }"
class="input input-range y-shift-slider"
type="range"
max="20"
min="-20"
@input="e => updateProperty('y', e.target.value)"
>
<div <div
class="preview-window" class="ComponentPreview"
:class="{ disabled: disabled && shadowControl, '-light-grid': lightGrid }" :class="{ '-shadow-controls': shadowControl }"
> >
<label
v-show="shadowControl"
class="header"
:class="{ faint: disabled }"
>
{{ $t('settings.style.shadows.offset') }}
</label>
<input
v-show="shadowControl"
:value="shadow?.y"
:disabled="disabled"
:class="{ disabled }"
class="input input-number y-shift-number"
type="number"
@input="e => updateProperty('y', e.target.value)"
>
<input
v-show="shadowControl"
:value="shadow?.y"
:disabled="disabled"
:class="{ disabled }"
class="input input-range y-shift-slider"
type="range"
max="20"
min="-20"
@input="e => updateProperty('y', e.target.value)"
>
<div <div
class="preview-block" class="preview-window"
:style="previewStyle" :class="{ '-light-grid': lightGrid }"
/> >
<div
class="preview-block"
:class="previewClass"
:style="previewStyle"
>
TEST
</div>
</div>
<input
v-show="shadowControl"
:value="shadow?.x"
:disabled="disabled"
:class="{ disabled }"
class="input input-number x-shift-number"
type="number"
@input="e => updateProperty('x', e.target.value)"
>
<input
v-show="shadowControl"
:value="shadow?.x"
:disabled="disabled"
:class="{ disabled }"
class="input input-range x-shift-slider"
type="range"
max="20"
min="-20"
@input="e => updateProperty('x', e.target.value)"
>
<Checkbox
id="lightGrid"
v-model="lightGrid"
name="lightGrid"
class="input-light-grid"
>
{{ $t('settings.style.shadows.light_grid') }}
</Checkbox>
</div> </div>
<input
v-show="shadowControl"
:value="shadow?.x"
:disabled="disabled"
:class="{ disabled }"
class="input input-number x-shift-number"
type="number"
@input="e => updateProperty('x', e.target.value)"
>
<input
v-show="shadowControl"
:value="shadow?.x"
:disabled="disabled"
:class="{ disabled }"
class="input input-range x-shift-slider"
type="range"
max="20"
min="-20"
@input="e => updateProperty('x', e.target.value)"
>
<Checkbox
id="lightGrid"
v-model="lightGrid"
:disabled="shadow == null"
name="lightGrid"
class="input-light-grid"
>
{{ $t('settings.style.shadows.light_grid') }}
</Checkbox>
</div>
</template> </template>
<script>
import Checkbox from 'src/components/checkbox/checkbox.vue'
export default {
components: {
Checkbox
},
props: [
'shadow',
'shadowControl',
'previewClass',
'previewStyle',
'disabled'
],
emits: ['update:shadow'],
data () {
return {
lightGrid: false
}
},
methods: {
updateProperty (axis, value) {
this.$emit('update:shadow', { axis, value })
}
}
}
</script>
<style lang="scss"> <style lang="scss">
.ComponentPreview { .ComponentPreview {
display: grid; display: grid;
@ -163,40 +195,20 @@
} }
.preview-block { .preview-block {
width: 33%; background: var(--background, var(--bg));
height: 33%; display: flex;
justify-content: center;
align-items: center;
min-width: 33%;
min-height: 33%;
max-width: 80%;
max-height: 80%;
border-width: 0;
border-style: solid;
border-color: var(--border);
border-radius: var(--roundness); border-radius: var(--roundness);
background: var(--background);
box-shadow: var(--shadow); box-shadow: var(--shadow);
border: 1px solid var(--border);
} }
} }
} }
</style> </style>
<script>
import Checkbox from 'src/components/checkbox/checkbox.vue'
export default {
props: [
'shadow',
'shadowControl',
'previewClass',
'previewStyle',
'disabled'
],
data () {
return {
lightGrid: false
}
},
emits: ['update:shadow'],
components: {
Checkbox
},
methods: {
updateProperty (axis, value) {
this.$emit('update:shadow', { axis, value })
}
}
}
</script>

View file

@ -6,7 +6,7 @@ export default {
{ {
component: 'Icon', component: 'Icon',
directives: { directives: {
textColor: '$blend(--stack, 0.5, --parent--text)', textColor: '$blend(--stack 0.5 --parent--text)',
textAuto: 'no-auto' textAuto: 'no-auto'
} }
} }

View file

@ -1,12 +1,3 @@
const hoverGlow = {
x: 0,
y: 0,
blur: 4,
spread: 0,
color: '--text',
alpha: 1
}
export default { export default {
name: 'Input', name: 'Input',
selector: '.input', selector: '.input',
@ -27,7 +18,9 @@ export default {
{ {
component: 'Root', component: 'Root',
directives: { directives: {
'--defaultInputBevel': 'shadow | $borderSide(#FFFFFF, bottom, 0.2), $borderSide(#000000, top, 0.2)' '--defaultInputBevel': 'shadow | $borderSide(#FFFFFF bottom 0.2), $borderSide(#000000 top 0.2)',
'--defaultInputHoverGlow': 'shadow | 0 0 4 --text / 0.5',
'--defaultInputFocusGlow': 'shadow | 0 0 4 4 --link / 0.5'
} }
}, },
{ {
@ -54,7 +47,19 @@ export default {
{ {
state: ['hover'], state: ['hover'],
directives: { directives: {
shadow: [hoverGlow, '--defaultInputBevel'] shadow: ['--defaultInputHoverGlow', '--defaultInputBevel']
}
},
{
state: ['focused'],
directives: {
shadow: ['--defaultInputFocusGlow', '--defaultInputBevel']
}
},
{
state: ['focused', 'hover'],
directives: {
shadow: ['--defaultInputFocusGlow', '--defaultInputHoverGlow', '--defaultInputBevel']
} }
}, },
{ {

View file

@ -24,21 +24,21 @@ export default {
{ {
state: ['hover'], state: ['hover'],
directives: { directives: {
background: '$mod(--bg, 5)', background: '$mod(--bg 5)',
opacity: 1 opacity: 1
} }
}, },
{ {
state: ['active'], state: ['active'],
directives: { directives: {
background: '$mod(--bg, 10)', background: '$mod(--bg 10)',
opacity: 1 opacity: 1
} }
}, },
{ {
state: ['active', 'hover'], state: ['active', 'hover'],
directives: { directives: {
background: '$mod(--bg, 15)', background: '$mod(--bg 15)',
opacity: 1 opacity: 1
} }
}, },

View file

@ -2,6 +2,7 @@ export default {
name: 'Modals', name: 'Modals',
selector: '.modal-view', selector: '.modal-view',
lazy: true, lazy: true,
notEditable: true,
validInnerComponents: [ validInnerComponents: [
'Panel' 'Panel'
], ],

View file

@ -0,0 +1,123 @@
<template>
<div class="PaletteEditor">
<div class="colors">
<ColorInput
v-for="key in paletteKeys"
:key="key"
:model-value="props.modelValue[key]"
:fallback="fallback(key)"
:label="$t('settings.style.themes3.palette.' + key)"
@update:modelValue="value => updatePalette(key, value)"
/>
</div>
<div class="controls">
<button
class="btn button-default"
@click="importPalette"
>
<FAIcon icon="file-import" />
{{ $t('settings.style.themes3.palette.import') }}
</button>
<button
class="btn button-default"
@click="exportPalette"
>
<FAIcon icon="file-export" />
{{ $t('settings.style.themes3.palette.export') }}
</button>
</div>
</div>
</template>
<script setup>
import ColorInput from 'src/components/color_input/color_input.vue'
import {
// newImporter,
newExporter
} from 'src/services/export_import/export_import.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faFileImport,
faFileExport
} from '@fortawesome/free-solid-svg-icons'
library.add(
faFileImport,
faFileExport
)
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const paletteExporter = newExporter({
filename: 'pleroma.palette.json',
getExportedObject: () => props.modelValue
})
/*
const themeImporter = newImporter({
validator: importValidator,
onImport,
onImportFailure,
})
*/
const exportPalette = () => {
paletteExporter.exportData()
}
const importPalette = () => {
// TODO
}
const paletteKeys = [
'bg',
'fg',
'text',
'link',
'accent',
'cRed',
'cBlue',
'cGreen',
'cOrange',
'extra1',
'extra2',
'extra3'
]
const fallback = (key) => {
if (key === 'accent') {
return props.modelValue.link
}
if (key === 'link') {
return props.modelValue.accent
}
if (key.startsWith('extra')) {
return '#008080'
}
}
const updatePalette = (paletteKey, value) => {
emit('update:modelValue', {
...props.modelValue,
[paletteKey]: value
})
}
</script>
<style lang="scss">
.PaletteEditor {
.colors {
display: grid;
justify-content: space-around;
grid-template-columns: repeat(4, min-content);
grid-template-rows: repeat(auto-fit, min-content);
grid-gap: 0.5em;
}
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 0.5em;
}
}
</style>

View file

@ -1,6 +1,7 @@
export default { export default {
name: 'Root', name: 'Root',
selector: ':root', selector: ':root',
notEditable: true,
validInnerComponents: [ validInnerComponents: [
'Underlay', 'Underlay',
'Modals', 'Modals',
@ -42,7 +43,7 @@ export default {
// Selection colors // Selection colors
'--selectionBackground': 'color | --accent', '--selectionBackground': 'color | --accent',
'--selectionText': 'color | $textColor(--accent, --text, no-preserve)' '--selectionText': 'color | $textColor(--accent --text no-preserve)'
} }
} }
] ]

View file

@ -1,6 +1,7 @@
export default { export default {
name: 'Scrollbar', name: 'Scrollbar',
selector: '::-webkit-scrollbar', selector: '::-webkit-scrollbar',
notEditable: true, // for now
defaultRules: [ defaultRules: [
{ {
directives: { directives: {

View file

@ -31,6 +31,7 @@ const hoverGlow = {
export default { export default {
name: 'ScrollbarElement', name: 'ScrollbarElement',
selector: '::-webkit-scrollbar-button', selector: '::-webkit-scrollbar-button',
notEditable: true, // for now
states: { states: {
pressed: ':active', pressed: ':active',
hover: ':hover:not(:disabled)', hover: ':hover:not(:disabled)',
@ -82,7 +83,7 @@ export default {
{ {
state: ['disabled'], state: ['disabled'],
directives: { directives: {
background: '$blend(--inheritedBackground, 0.25, --parent)', background: '$blend(--inheritedBackground 0.25 --parent)',
shadow: [...buttonInsetFakeBorders] shadow: [...buttonInsetFakeBorders]
} }
}, },

View file

@ -61,12 +61,13 @@ label.Select {
&:disabled { &:disabled {
background-color: var(--background); background-color: var(--background);
opacity: 1; /* override browser */ opacity: 1; /* override browser */
color: var(--faint);
select { select {
&[multiple], &[multiple],
&[size] { &[size] {
option.-active { option.-active {
color: var(--text); color: var(--faint);
background: transparent; background: transparent;
} }
} }

View file

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

View file

@ -15,6 +15,7 @@
</template> </template>
<slot v-else /> <slot v-else />
</label> </label>
{{ ' ' }}
<input <input
:id="path" :id="path"
class="input string-input" class="input string-input"

View file

@ -10,6 +10,10 @@
list-style-type: none; list-style-type: none;
padding-left: 2em; padding-left: 2em;
.btn:not(.dropdown-button) {
padding: 0 2em;
}
li { li {
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
@ -54,10 +58,6 @@
.btn { .btn {
min-height: 2em; min-height: 2em;
} }
.btn:not(.dropdown-button) {
padding: 0 2em;
}
} }
} }

View file

@ -10,6 +10,7 @@ import GeneralTab from './tabs/general_tab.vue'
import AppearanceTab from './tabs/appearance_tab.vue' import AppearanceTab from './tabs/appearance_tab.vue'
import VersionTab from './tabs/version_tab.vue' import VersionTab from './tabs/version_tab.vue'
import ThemeTab from './tabs/theme_tab/theme_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 { library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -17,6 +18,7 @@ import {
faUser, faUser,
faFilter, faFilter,
faPaintBrush, faPaintBrush,
faPalette,
faBell, faBell,
faDownload, faDownload,
faEyeSlash, faEyeSlash,
@ -29,6 +31,7 @@ library.add(
faUser, faUser,
faFilter, faFilter,
faPaintBrush, faPaintBrush,
faPalette,
faBell, faBell,
faDownload, faDownload,
faEyeSlash, faEyeSlash,
@ -48,6 +51,7 @@ const SettingsModalContent = {
ProfileTab, ProfileTab,
GeneralTab, GeneralTab,
AppearanceTab, AppearanceTab,
StyleTab,
VersionTab, VersionTab,
ThemeTab ThemeTab
}, },

View file

@ -8,7 +8,6 @@
> div, > div,
> label { > label {
display: block;
margin-bottom: 0.5em; margin-bottom: 0.5em;
&:last-child { &:last-child {

View file

@ -20,6 +20,13 @@
> >
<AppearanceTab /> <AppearanceTab />
</div> </div>
<div
:label="$t('settings.style.themes3.editor.title')"
icon="palette"
data-tab-name="style"
>
<StyleTab />
</div>
<div <div
:label="$t('settings.theme')" :label="$t('settings.theme')"
icon="paint-brush" icon="paint-brush"

View file

@ -8,9 +8,7 @@ import FontControl from 'src/components/font_control/font_control.vue'
import { normalizeThemeData } from 'src/modules/interface' import { normalizeThemeData } from 'src/modules/interface'
import { import { newImporter } from 'src/services/export_import/export_import.js'
getThemes
} from 'src/services/style_setter/style_setter.js'
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 { import {
@ -35,6 +33,23 @@ const AppearanceTab = {
data () { data () {
return { return {
availableStyles: [], availableStyles: [],
availablePalettes: [],
themeImporter: newImporter({
accept: '.json, .piss',
validator: this.importValidator,
onImport: this.onImport,
onImportFailure: this.onImportFailure
}),
palettesKeys: [
'background',
'foreground',
'link',
'text',
'cRed',
'cGreen',
'cBlue',
'cOrange'
],
intersectionObserver: null, intersectionObserver: null,
thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({ thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({
key: mode, key: mode,
@ -64,30 +79,50 @@ const AppearanceTab = {
Preview Preview
}, },
mounted () { mounted () {
getThemes() const updateIndex = (resource) => {
.then((promises) => { const capitalizedResource = resource[0].toUpperCase() + resource.slice(1)
return Promise.all( const currentIndex = this.$store.state.instance[`${resource}sIndex`]
Object.entries(promises)
.map(([k, v]) => v.then(res => [k, res])) 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 [ updateIndex('theme').then(themes => {
...acc, themes.forEach(([key, themePromise]) => themePromise.then(data => {
{ this.availableStyles.push({ key, data, name: data.name, version: 'v2' })
name: v.name || v[0], }))
key: k, })
data: v
} updateIndex('palette').then(palettes => {
] palettes.forEach(([key, palettePromise]) => palettePromise.then(v => {
if (Array.isArray(v)) {
const [
name,
background,
foreground,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00'
] = v
this.availablePalettes.push({ key, name, background, foreground, text, link, cRed, cBlue, cGreen, cOrange })
} else { } else {
return acc this.availablePalettes.push({ key, ...v })
} }
}, [])) }))
.then((themesComplete) => { })
this.availableStyles = themesComplete
})
if (window.IntersectionObserver) { if (window.IntersectionObserver) {
this.intersectionObserver = new IntersectionObserver((entries, observer) => { this.intersectionObserver = new IntersectionObserver((entries, observer) => {
@ -146,13 +181,16 @@ const AppearanceTab = {
}, },
isCustomThemeUsed () { isCustomThemeUsed () {
const { theme } = this.mergedConfig const { theme } = this.mergedConfig
return theme === 'custom' || theme === null return theme === 'custom'
},
isCustomStyleUsed (name) {
const { style } = this.mergedConfig
return style === 'custom'
}, },
...SharedComputedObject() ...SharedComputedObject()
}, },
methods: { methods: {
updateFont (key, value) { updateFont (key, value) {
console.log(key, value)
this.$store.dispatch('setOption', { this.$store.dispatch('setOption', {
name: 'theme3hacks', name: 'theme3hacks',
value: { value: {
@ -164,25 +202,76 @@ const AppearanceTab = {
} }
}) })
}, },
importTheme () {
this.themeImporter.importData()
},
onImport (parsed, filename) {
if (filename.endsWith('.json')) {
this.$store.dispatch('setThemeCustom', parsed.source || parsed.theme)
this.$store.dispatch('applyTheme')
}
// this.loadTheme(parsed, 'file', forceSource)
},
onImportFailure (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
}
},
isThemeActive (key) { isThemeActive (key) {
const { theme } = this.mergedConfig const { theme } = this.mergedConfig
return key === theme return key === theme
},
isStyleActive (key) {
const { style } = this.mergedConfig
return key === style
},
isPaletteActive (key) {
const { palette } = this.mergedConfig
return key === palette
},
importStyle () {
}, },
setTheme (name) { setTheme (name) {
this.$store.dispatch('setTheme', { themeName: name, saveData: true, recompile: true }) this.$store.dispatch('setTheme', name)
this.$store.dispatch('applyTheme')
},
setPalette (name) {
this.$store.dispatch('setPalette', name)
this.$store.dispatch('applyTheme')
},
resetTheming (name) {
this.$store.dispatch('resetThemeV2')
this.$store.dispatch('resetThemeV3')
this.$store.dispatch('setStyle', 'stock')
this.$store.dispatch('applyTheme')
}, },
previewTheme (key, input) { previewTheme (key, input) {
const style = normalizeThemeData(input) let theme3
const x = 2 if (input) {
if (x === 1) return const style = normalizeThemeData(input)
const theme2 = convertTheme2To3(style) const theme2 = convertTheme2To3(style)
const theme3 = init({ theme3 = init({
inputRuleset: theme2, inputRuleset: theme2,
ultimateBackgroundColor: '#000000', ultimateBackgroundColor: '#000000',
liteMode: true, liteMode: true,
debug: true, debug: true,
onlyNormalState: true onlyNormalState: true
}) })
} else {
theme3 = init({
inputRuleset: [],
ultimateBackgroundColor: '#000000',
liteMode: true,
debug: true,
onlyNormalState: true
})
}
return getScopedVersion( return getScopedVersion(
getCssRules(theme3.eager), getCssRules(theme3.eager),

View file

@ -0,0 +1,91 @@
.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;
}
.palette-entry {
display: flex;
align-items: center;
> label {
flex: 1 0 auto;
}
.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;
}
.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;
}
}
}
}

View file

@ -1,23 +1,56 @@
<template> <template>
<div class="appearance-tab" :label="$t('settings.general')"> <div
<div class="setting-item"> class="appearance-tab"
:label="$t('settings.general')"
>
<div class="setting-item heading">
<h2>{{ $t('settings.theme') }}</h2> <h2>{{ $t('settings.theme') }}</h2>
<ul <button
class="theme-list" class="btn button-default"
ref="themeList" @click="importTheme"
> >
<FAIcon icon="folder-open" />
{{ $t('settings.style.themes3.editor.load_style') }}
</button>
</div>
<div class="setting-item">
<ul
ref="themeList"
class="theme-list"
>
<button
class="button-default theme-preview"
data-theme-key="stock"
:class="{ toggled: isStyleActive('stock') }"
@click="resetTheming"
>
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
<component
:is="'style'"
v-html="previewTheme('stock')"
/>
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
<preview />
<h4 class="theme-name">
{{ $t('settings.style.stock_theme_used') }}
<span class="alert neutral version">v3</span>
</h4>
</button>
<button <button
v-if="isCustomThemeUsed" v-if="isCustomThemeUsed"
disabled disabled
class="button-default theme-preview" class="button-default theme-preview"
> >
<preview /> <preview />
<h4 class="theme-name">{{ $t('settings.style.custom_theme_used') }}</h4> <h4 class="theme-name">
{{ $t('settings.style.custom_theme_used') }}
<span class="alert neutral version">v2</span>
</h4>
</button> </button>
<button <button
v-for="style in availableStyles" v-for="style in availableStyles"
:data-theme-key="style.key"
:key="style.key" :key="style.key"
:data-theme-key="style.key"
class="button-default theme-preview" class="button-default theme-preview"
:class="{ toggled: isThemeActive(style.key) }" :class="{ toggled: isThemeActive(style.key) }"
@click="setTheme(style.key)" @click="setTheme(style.key)"
@ -29,10 +62,33 @@
v-html="previewTheme(style.key, style.data)" v-html="previewTheme(style.key, style.data)"
/> />
<!-- eslint-enable vue/no-v-text-v-html-on-component --> <!-- eslint-enable vue/no-v-text-v-html-on-component -->
<preview :class="{ placeholder: ready }" :id="'theme-preview-' + style.key"/> <preview :id="'theme-preview-' + style.key" />
<h4 class="theme-name">{{ style.name }}</h4> <h4 class="theme-name">
{{ style.name }}
<span class="alert neutral version">{{ style.version }}</span>
</h4>
</button> </button>
</ul> </ul>
<h3>{{ $t('settings.style.themes3.palette.label') }}</h3>
<div class="palettes">
<button
v-for="p in availablePalettes"
:key="p.name"
class="btn button-default palette-entry"
:class="{ toggled: isPaletteActive(p.key) }"
@click="() => setPalette(p.key)"
>
<label>
{{ p.name }}
</label>
<span
v-for="c in palettesKeys"
:key="c"
class="palette-square"
:style="{ backgroundColor: p[c], border: '1px solid ' + (p[c] ?? 'var(--text)') }"
/>
</button>
</div>
</div> </div>
<div class="alert neutral theme-notice"> <div class="alert neutral theme-notice">
{{ $t("settings.style.appearance_tab_note") }} {{ $t("settings.style.appearance_tab_note") }}
@ -60,7 +116,7 @@
<code>px</code> <code>px</code>
<code>rem</code> <code>rem</code>
</i18n-t> </i18n-t>
<br/> <br>
<i18n-t <i18n-t
scope="global" scope="global"
keypath="settings.text_size_tip2" keypath="settings.text_size_tip2"
@ -256,58 +312,4 @@
<script src="./appearance_tab.js"></script> <script src="./appearance_tab.js"></script>
<style lang="scss"> <style lang="scss" src="./appearance_tab.scss"></style>
.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>

View file

@ -0,0 +1,550 @@
import { ref, reactive, computed, watch } from 'vue'
import { get, set } from 'lodash'
import Select from 'src/components/select/select.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 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 { init } from 'src/services/theme_data/theme_data_3.service.js'
import { getCssRules } from 'src/services/theme_data/css_utils.js'
import { serialize } from 'src/services/theme_data/iss_serializer.js'
import { deserialize } from 'src/services/theme_data/iss_deserializer.js'
import {
// rgb2hex,
hex2rgb,
getContrastRatio
} from 'src/services/color_convert/color_convert.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faFloppyDisk, faFolderOpen, faFile } 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
)
export default {
components: {
Select,
Checkbox,
Tooltip,
StringSetting,
ComponentPreview,
TabSwitcher,
ShadowControl,
ColorInput,
PaletteEditor,
OpacityInput,
ContrastRatio
},
setup () {
// ### Meta stuff
const name = ref('')
const author = ref('')
const license = ref('')
const website = ref('')
const metaOut = computed(() => {
return `@meta {
name: ${name.value};
author: ${author.value};
license: ${license.value};
website: ${website.value};
}`
})
// ### Palette stuff
const palettes = reactive({
light: {
bg: '#f2f6f9',
fg: '#d6dfed',
text: '#304055',
underlay: '#5d6086',
accent: '#f55b1b',
cBlue: '#0095ff',
cRed: '#d31014',
cGreen: '#0fa00f',
cOrange: '#ffa500',
border: '#d8e6f9'
},
dark: {
bg: '#121a24',
fg: '#182230',
text: '#b9b9ba',
link: '#d8a070',
accent: '#d8a070',
cRed: '#FF0000',
cBlue: '#0095ff',
cGreen: '#0fa00f',
cOrange: '#ffa500'
}
})
const palettesOut = computed(() => {
return Object.entries(palettes).map(([name, palette]) => {
const entries = Object
.entries(palette)
.map(([slot, data]) => ` ${slot}: ${data};`)
.join('\n')
return `@palette.${name} {\n${entries}\n}`
}).join('\n\n')
})
const editedPalette = ref('dark')
const palette = computed({
get () {
return palettes[editedPalette.value]
},
set (newPalette) {
palettes[editedPalette.value] = newPalette
}
})
// ### I18n stuff
// The paths in i18n are getting ridicously long, this effectively shortens them
const getI18nPath = (componentName) => `settings.style.themes3.editor.components.${componentName}`
// vue i18n doesn't seem to have (working) mechanic to have a fallback so we have to
// make do ourselves
const fallbackI18n = (translated, fallback) => {
if (translated.startsWith('settings.style.themes3')) {
return fallback
}
return translated
}
const getFriendlyNamePath = (componentName) => getI18nPath(componentName) + '.friendlyName'
const getVariantPath = (componentName, variant) => {
return variant === 'normal'
? 'settings.style.themes3.editor.components.normal.variant'
: `${getI18nPath(componentName)}.variants.${variant}`
}
const getStatePath = (componentName, state) => {
return state === 'normal'
? 'settings.style.themes3.editor.components.normal.state'
: `${getI18nPath(componentName)}.states.${state}`
}
// ### Initialization 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)
)
const componentKeys = [...componentsMap.keys()]
// Initializing selected component and its computed descendants
const selectedComponentKey = ref(componentsMap.keys().next().value)
const selectedComponent = computed(() => componentsMap.get(selectedComponentKey.value))
const selectedComponentName = computed(() => selectedComponent.value.name)
const selectedVariant = ref('normal')
const selectedComponentVariantsAll = computed(() => {
return Object.keys({ normal: null, ...(selectedComponent.value.variants || {}) })
})
const selectedState = reactive(new Set())
const selectedComponentStatesAll = computed(() => {
return Object.keys({ normal: null, ...(selectedComponent.value.states || {}) })
})
const selectedComponentStates = computed(() => {
return selectedComponentStatesAll.value.filter(x => x !== 'normal')
})
const updateSelectedStates = (state, v) => {
if (v) {
selectedState.add(state)
} else {
selectedState.delete(state)
}
}
// ### Preview stuff
const 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('; ')
})
// Apart from "hover" we can't really show how component looks like in
// certain states, so we have to fake them.
const simulatePseudoSelectors = css => css
.replace(selectedComponent.value.selector, '.ComponentPreview .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 = reactive([])
const 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('')
})
const previewCss = computed(() => {
const scoped = getCssRules(previewRules)
.map(simulatePseudoSelectors)
return scoped.join('\n')
})
// ### 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 editorFriendlyFallbackStructure = computed(() => {
const root = {}
componentKeys.forEach((componentKey) => {
const componentValue = componentsMap.get(componentKey)
const { defaultRules } = componentValue
defaultRules.forEach((rule) => {
const { parent: rParent } = 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 : componentValue.name}.${pVariant}.${normalizeStates(pState)}`
let output = get(root, pPath)
if (!output) {
set(root, pPath, {})
output = get(root, 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 root
})
// All rules that are made by editor
const allEditedRules = reactive({})
// Checkging whether component can support some "directives" which
// are actually virtual subcomponents, i.e. Text, Link etc
const componentHas = (subComponent) => {
return !!selectedComponent.value.validInnerComponents?.find(x => x === subComponent)
}
// Path is 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, getPath(component, directive)) != null
},
set (value) {
if (value) {
const fallback = get(
editorFriendlyFallbackStructure.value,
getPath(component, directive)
)
set(allEditedRules, getPath(component, directive), fallback ?? defaultValue)
} else {
set(allEditedRules, getPath(component, directive), null)
}
}
})
const getEditedElement = (component, directive) => computed({
get () {
let usedRule
const fallback = editorFriendlyFallbackStructure.value
const real = allEditedRules
const path = getPath(component, directive)
usedRule = get(real, path) // get real
if (!usedRule) {
usedRule = get(fallback, path)
}
return usedRule
},
set (value) {
set(allEditedRules, getPath(component, directive), value)
}
})
// All the editable stuff for the component
const editedBackgroundColor = getEditedElement(null, 'background')
const editedOpacity = getEditedElement(null, 'opacity')
const editedTextColor = getEditedElement('Text', 'textColor')
const editedTextAuto = getEditedElement('Text', 'textAuto')
const editedLinkColor = getEditedElement('Link', 'textColor')
const editedIconColor = getEditedElement('Icon', 'textColor')
const editedShadow = getEditedElement(null, 'shadow')
// Shadow is partially edited outside the ShadowControl
// for better space utilization
const editedSubShadowId = ref(null)
const editedSubShadow = computed(() => {
if (editedShadow.value == null || editedSubShadowId.value == null) return null
return editedShadow.value[editedSubShadowId.value]
})
const 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
}
const onSubShadow = (id) => {
if (id != null) {
editedSubShadowId.value = id
} else {
editedSubShadow.value = null
}
}
// Whether specific directives present in the edited rule or not
// Somewhat serves double-duty as it creates/removes the directive
// when set
const isBackgroundColorPresent = isElementPresent(null, 'background', '#FFFFFF')
const isOpacityPresent = isElementPresent(null, 'opacity', 1)
const isTextColorPresent = isElementPresent('Text', 'textColor', '#000000')
const isTextAutoPresent = isElementPresent('Text', 'textAuto', '#000000')
const isLinkColorPresent = isElementPresent('Link', 'textColor', '#000080')
const isIconColorPresent = isElementPresent('Icon', 'textColor', '#909090')
const isShadowPresent = isElementPresent(null, 'shadow', [])
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))
}
})
})
}
convert(selectedComponentName.value, allEditedRules[selectedComponentName.value])
return resultRules
})
const updatePreview = () => {
previewRules.splice(0, previewRules.length)
previewRules.push(...init({
inputRuleset: editorFriendlyToOriginal.value,
initialStaticVars: {
...palette.value
},
ultimateBackgroundColor: '#000000',
rootComponentName: selectedComponentName.value,
editMode: true,
debug: true
}).eager)
}
const updateSelectedComponent = () => {
selectedVariant.value = 'normal'
selectedState.clear()
updatePreview()
}
updateSelectedComponent()
watch(
allEditedRules,
updatePreview
)
watch(
palettes,
updatePreview
)
watch(
editedPalette,
updatePreview
)
watch(
selectedComponentName,
updateSelectedComponent
)
// TODO this is VERY primitive right now, need to make it
// support variables, fallbacks etc.
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 isShadowTabOpen = ref(false)
const onTabSwitch = (tab) => {
isShadowTabOpen.value = tab === 'shadow'
}
const exportStyle = () => {
console.log('ORIG', toValue(editorFriendlyToOriginal.value))
console.log('SERI', serialize(editorFriendlyToOriginal.value))
const result = [
metaOut.value,
palettesOut.value,
serialize(editorFriendlyToOriginal.value)
].join('\n\n')
console.log('RESULT', result)
console.log('DESERI', deserialize(result))
}
return {
name,
author,
license,
website,
palette,
editedPalette,
componentKeys,
componentsMap,
selectedComponent,
selectedComponentName,
selectedComponentKey,
selectedComponentVariantsAll,
selectedComponentStates,
selectedVariant,
selectedState,
updateSelectedStates,
editedBackgroundColor,
editedOpacity,
editedTextColor,
editedTextAuto,
editedLinkColor,
editedIconColor,
editedShadow,
editedSubShadow,
onSubShadow,
updateSubShadow,
getContrast,
isBackgroundColorPresent,
isOpacityPresent,
isTextColorPresent,
isTextAutoPresent,
isLinkColorPresent,
isIconColorPresent,
isShadowPresent,
previewCss,
previewClass,
editorHintStyle,
getFriendlyNamePath,
fallbackI18n,
getVariantPath,
getStatePath,
componentHas,
isShadowTabOpen,
onTabSwitch,
exportStyle
}
}
}

View file

@ -0,0 +1,178 @@
.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;
}
}
}
.setting-item {
padding-bottom: 0;
.btn {
padding: 0 0.5em;
}
&:not(:first-child) {
margin-top: 0.5em;
}
&:not(:last-child) {
margin-bottom: 0.5em;
}
&.heading {
display: grid;
align-items: baseline;
grid-template-columns: 1fr auto auto auto;
grid-gap: 0.5em;
h2 {
flex: 1 0 auto;
}
}
&.metadata {
display: flex;
.setting-item {
flex: 2 0 auto;
}
li {
text-align: right;
}
}
}
.palette-editor {
> .label:not(.Select) {
font-weight: bold;
justify-self: right;
}
}
.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;
}
}
}

View file

@ -0,0 +1,282 @@
<script src="./style_tab.js">
</script>
<template>
<div class="StyleTab">
<div class="setting-item heading">
<h2>{{ $t('settings.style.themes3.editor.title') }}</h2>
<button
class="btn button-default"
@click="clearTheme"
>
<FAIcon icon="file" />
{{ $t('settings.style.themes3.editor.new_style') }}
</button>
<button
class="btn button-default"
@click="importStyle"
>
<FAIcon icon="folder-open" />
{{ $t('settings.style.themes3.editor.load_style') }}
</button>
<button
class="btn button-default"
@click="exportStyle"
>
<FAIcon icon="floppy-disk" />
{{ $t('settings.style.themes3.editor.save_style') }}
</button>
</div>
<div class="setting-item metadata">
<ul class="setting-list">
<li>
<StringSetting v-model="name">
{{ $t('settings.style.themes3.editor.style_name') }}
</StringSetting>
</li>
<li>
<StringSetting v-model="author">
{{ $t('settings.style.themes3.editor.style_author') }}
</StringSetting>
</li>
<li>
<StringSetting v-model="license">
{{ $t('settings.style.themes3.editor.style_license') }}
</StringSetting>
</li>
<li>
<StringSetting v-model="website">
{{ $t('settings.style.themes3.editor.style_website') }}
</StringSetting>
</li>
</ul>
</div>
<div class="setting-item component-editor">
<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"
>
{{ fallbackI18n($t(getFriendlyNamePath(componentsMap.get(key).name)), componentsMap.get(key).name) }}
</option>
</Select>
</div>
<div
v-if="selectedComponentVariantsAll.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 selectedComponentVariantsAll"
:key="'component-variant-' + variant"
:value="variant"
>
{{ fallbackI18n($t(getVariantPath(selectedComponentName, 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)"
>
{{ fallbackI18n($t(getStatePath(selectedComponentName, state)), 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"
:disabled="!editedSubShadow"
:shadow="editedSubShadow"
@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"
:disabled="!isBackgroundColorPresent"
:label="$t('settings.style.themes3.editor.background')"
/>
<Tooltip :text="$t('settings.style.themes3.editor.include_in_rule')">
<Checkbox v-model="isBackgroundColorPresent" />
</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>
<ColorInput
v-if="componentHas('Text')"
v-model="editedTextColor"
:label="$t('settings.style.themes3.editor.text_color')"
:disabled="!isTextColorPresent"
/>
<Tooltip
v-if="componentHas('Text')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isTextColorPresent" />
</Tooltip>
<div 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>
<ContrastRatio :contrast="getContrast(editedBackgroundColor, editedTextColor)" />
</div>
<div>
<!-- spacer for missing checkbox -->
</div>
<ColorInput
v-if="componentHas('Link')"
v-model="editedLinkColor"
:label="$t('settings.style.themes3.editor.link_color')"
:disabled="!isLinkColorPresent"
/>
<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"
:label="$t('settings.style.themes3.editor.icon_color')"
:disabled="!isIconColorPresent"
/>
<Tooltip
v-if="componentHas('Icon')"
:text="$t('settings.style.themes3.editor.include_in_rule')"
>
<Checkbox v-model="isIconColorPresent" />
</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"
:separate-inset="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'"
@subShadowSelected="onSubShadow"
/>
</div>
</tab-switcher>
</div>
<div class="setting-item palette-editor">
<div class="label">
<label for="palette-selector">
{{ $t('settings.style.themes3.palette.label') }}
{{ ' ' }}
</label>
<Select
id="palette-selector"
v-model="editedPalette"
>
<option
key="dark"
value="dark"
>
{{ $t('settings.style.themes3.palette.dark') }}
</option>
<option
key="light"
value="light"
>
{{ $t('settings.style.themes3.palette.light') }}
</option>
</Select>
</div>
<PaletteEditor v-model="palette" />
</div>
</div>
</template>
<style src="./style_tab.scss" lang="scss"></style>

View file

@ -4,9 +4,6 @@ import {
getContrastRatioLayers, getContrastRatioLayers,
relativeLuminance relativeLuminance
} from 'src/services/color_convert/color_convert.js' } from 'src/services/color_convert/color_convert.js'
import {
getThemes
} from 'src/services/style_setter/style_setter.js'
import { import {
newImporter, newImporter,
newExporter newExporter
@ -123,28 +120,22 @@ export default {
} }
}, },
created () { created () {
const self = this const currentIndex = this.$store.state.instance.themesIndex
getThemes() let promise
.then((promises) => { if (currentIndex) {
return Promise.all( promise = Promise.resolve(currentIndex)
Object.entries(promises) } else {
.map(([k, v]) => v.then(res => [k, res])) promise = this.$store.dispatch('fetchThemesIndex')
) }
})
.then(themes => themes.reduce((acc, [k, v]) => { promise.then(themesIndex => {
if (v) { Object
return { .values(themesIndex)
...acc, .forEach(themeFunc => {
[k]: v themeFunc().then(themeData => this.availableStyles.push(themeData))
} })
} else { })
return acc
}
}, {}))
.then((themesComplete) => {
self.availableStyles = themesComplete
})
}, },
mounted () { mounted () {
this.loadThemeFromLocalStorage() this.loadThemeFromLocalStorage()
@ -412,9 +403,6 @@ export default {
forceUseSource = false forceUseSource = false
) { ) {
this.dismissWarning() this.dismissWarning()
if (!source && !theme) {
throw new Error('Can\'t load theme: empty')
}
const version = (origin === 'localStorage' && !theme.colors) const version = (origin === 'localStorage' && !theme.colors)
? 'l1' ? 'l1'
: fileVersion : fileVersion
@ -494,14 +482,7 @@ export default {
customTheme: theme, customTheme: theme,
customThemeSource: source customThemeSource: source
} = this.$store.getters.mergedConfig } = this.$store.getters.mergedConfig
if (!theme && !source) { if (theme || source) {
// Anon user or never touched themes
this.loadTheme(
this.$store.state.instance.themeData,
'defaults',
confirmLoadSource
)
} else {
this.loadTheme( this.loadTheme(
{ {
theme, theme,

View file

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

View file

@ -34,9 +34,9 @@ const toModel = (object = {}) => ({
export default { export default {
props: [ props: [
'modelValue', 'fallback', 'separateInset', 'noPreview' 'modelValue', 'fallback', 'separateInset', 'noPreview', 'disabled'
], ],
emits: ['update:modelValue'], emits: ['update:modelValue', 'subShadowSelected'],
data () { data () {
return { return {
selectedId: 0, selectedId: 0,
@ -93,9 +93,13 @@ export default {
} }
} }
}, },
watch: {
selected (value) {
this.$emit('subShadowSelected', this.selectedId)
}
},
methods: { methods: {
updateProperty: throttle(function (prop, value) { updateProperty: throttle(function (prop, value) {
console.log(prop, value)
this.cValue[this.selectedId][prop] = value this.cValue[this.selectedId][prop] = value
if (prop === 'inset' && value === false && this.separateInset) { if (prop === 'inset' && value === false && this.separateInset) {
this.cValue[this.selectedId].spread = 0 this.cValue[this.selectedId].spread = 0

View file

@ -71,9 +71,13 @@
&.-no-preview { &.-no-preview {
.shadow-tweak { .shadow-tweak {
order: 0; order: 0;
flex: 2 0 20em; flex: 2 0 8em;
max-width: 100%; max-width: 100%;
} }
.input-range {
min-width: 5em;
}
} }
.inset-alert { .inset-alert {

View file

@ -1,7 +1,7 @@
<template> <template>
<div <div
class="label shadow-control" class="label shadow-control"
:class="{ disabled: !present, '-no-preview': noPreview }" :class="{ disabled: disabled || !present, '-no-preview': noPreview }"
> >
<ComponentPreview <ComponentPreview
v-if="!noPreview" v-if="!noPreview"
@ -9,7 +9,7 @@
:shadow-control="true" :shadow-control="true"
:shadow="selected" :shadow="selected"
:preview-style="style" :preview-style="style"
:disabled="!present" :disabled="disabled || !present"
@update:shadow="({ axis, value }) => updateProperty(axis, value)" @update:shadow="({ axis, value }) => updateProperty(axis, value)"
/> />
<div class="shadow-switcher"> <div class="shadow-switcher">
@ -34,7 +34,7 @@
> >
<button <button
class="btn button-default" class="btn button-default"
:disabled="shadowsAreNull" :disabled="disabled || shadowsAreNull"
@click="add" @click="add"
> >
<FAIcon <FAIcon
@ -44,8 +44,8 @@
</button> </button>
<button <button
class="btn button-default" class="btn button-default"
:disabled="!moveUpValid" :disabled="disabled || !moveUpValid"
:class="{ disabled: !moveUpValid }" :class="{ disabled: disabled || !moveUpValid }"
@click="moveUp" @click="moveUp"
> >
<FAIcon <FAIcon
@ -55,8 +55,8 @@
</button> </button>
<button <button
class="btn button-default" class="btn button-default"
:disabled="!moveDnValid" :disabled="disabled || !moveDnValid"
:class="{ disabled: !moveDnValid }" :class="{ disabled: disabled || !moveDnValid }"
@click="moveDn" @click="moveDn"
> >
<FAIcon <FAIcon
@ -66,8 +66,8 @@
</button> </button>
<button <button
class="btn button-default" class="btn button-default"
:disabled="!present" :disabled="disabled || !present"
:class="{ disabled: !present }" :class="{ disabled: disabled || !present }"
@click="del" @click="del"
> >
<FAIcon <FAIcon
@ -79,34 +79,34 @@
</div> </div>
<div class="shadow-tweak"> <div class="shadow-tweak">
<div <div
:class="{ disabled: !present }" :class="{ disabled: disabled || !present }"
class="name-control style-control" class="name-control style-control"
> >
<label <label
for="name" for="name"
class="label" class="label"
:class="{ faint: !present }" :class="{ faint: disabled || !present }"
> >
{{ $t('settings.style.shadows.name') }} {{ $t('settings.style.shadows.name') }}
</label> </label>
<input <input
id="name" id="name"
:value="selected?.name" :value="selected?.name"
:disabled="!present" :disabled="disabled || !present"
:class="{ disabled: !present }" :class="{ disabled: disabled || !present }"
name="name" name="name"
class="input input-string" class="input input-string"
@input="e => updateProperty('name', e.target.value)" @input="e => updateProperty('name', e.target.value)"
> >
</div> </div>
<div <div
:disabled="!present" :disabled="disabled || !present"
class="inset-control style-control" class="inset-control style-control"
> >
<Checkbox <Checkbox
id="inset" id="inset"
:value="selected?.inset" :value="selected?.inset"
:disabled="!present" :disabled="disabled || !present"
name="inset" name="inset"
class="input-inset input-boolean" class="input-inset input-boolean"
@input="e => updateProperty('inset', e.target.checked)" @input="e => updateProperty('inset', e.target.checked)"
@ -117,22 +117,22 @@
</Checkbox> </Checkbox>
</div> </div>
<div <div
:disabled="!present" :disabled="disabled || !present"
:class="{ disabled: !present }" :class="{ disabled: disabled || !present }"
class="blur-control style-control" class="blur-control style-control"
> >
<label <label
for="blur" for="blur"
class="label" class="label"
:class="{ faint: !present }" :class="{ faint: disabled || !present }"
> >
{{ $t('settings.style.shadows.blur') }} {{ $t('settings.style.shadows.blur') }}
</label> </label>
<input <input
id="blur" id="blur"
:value="selected?.blur" :value="selected?.blur"
:disabled="!present" :disabled="disabled || !present"
:class="{ disabled: !present }" :class="{ disabled: disabled || !present }"
name="blur" name="blur"
class="input input-range" class="input input-range"
type="range" type="range"
@ -142,9 +142,9 @@
> >
<input <input
:value="selected?.blur" :value="selected?.blur"
:disabled="!present" class="input input-number -small"
:class="{ disabled: !present }" :disabled="disabled || !present"
class="input input-number" :class="{ disabled: disabled || !present }"
type="number" type="number"
min="0" min="0"
@input="e => updateProperty('blur', e.target.value)" @input="e => updateProperty('blur', e.target.value)"
@ -152,20 +152,20 @@
</div> </div>
<div <div
class="spread-control style-control" class="spread-control style-control"
:class="{ disabled: !present || (separateInset && !selected?.inset) }" :class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
> >
<label <label
for="spread" for="spread"
class="label" class="label"
:class="{ faint: !present || (separateInset && !selected?.inset) }" :class="{ faint: disabled || !present || (separateInset && !selected?.inset) }"
> >
{{ $t('settings.style.shadows.spread') }} {{ $t('settings.style.shadows.spread') }}
</label> </label>
<input <input
id="spread" id="spread"
:value="selected?.spread" :value="selected?.spread"
:disabled="!present || (separateInset && !selected?.inset)" :disabled="disabled || !present || (separateInset && !selected?.inset)"
:class="{ disabled: !present || (separateInset && !selected?.inset) }" :class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
name="spread" name="spread"
class="input input-range" class="input input-range"
type="range" type="range"
@ -175,16 +175,16 @@
> >
<input <input
:value="selected?.spread" :value="selected?.spread"
:disabled="{ disabled: !present || (separateInset && !selected?.inset) }" class="input input-number -small"
:class="{ disabled: !present || (separateInset && !selected?.inset) }" :class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
class="input input-number" :disabled="{ disabled: disabled || !present || (separateInset && !selected?.inset) }"
type="number" type="number"
@input="e => updateProperty('spread', e.target.value)" @input="e => updateProperty('spread', e.target.value)"
> >
</div> </div>
<ColorInput <ColorInput
:model-value="selected?.color" :model-value="selected?.color"
:disabled="!present" :disabled="disabled || !present"
:label="$t('settings.style.common.color')" :label="$t('settings.style.common.color')"
:fallback="currentFallback?.color" :fallback="currentFallback?.color"
:show-optional-tickbox="false" :show-optional-tickbox="false"
@ -193,13 +193,13 @@
/> />
<OpacityInput <OpacityInput
:model-value="selected?.alpha" :model-value="selected?.alpha"
:disabled="!present" :disabled="disabled || !present"
@update:modelValue="e => updateProperty('alpha', e)" @update:modelValue="e => updateProperty('alpha', e)"
/> />
<i18n-t <i18n-t
scope="global" scope="global"
keypath="settings.style.shadows.hintV3" keypath="settings.style.shadows.hintV3"
:class="{ faint: !present }" :class="{ faint: disabled || !present }"
tag="p" tag="p"
> >
<code>--variable,mod</code> <code>--variable,mod</code>

View file

@ -39,7 +39,7 @@ export default {
{ {
state: ['disabled'], state: ['disabled'],
directives: { directives: {
background: '$blend(--inheritedBackground, 0.25, --parent)', background: '$blend(--inheritedBackground 0.25 --parent)',
shadow: ['--defaultButtonBevel'] shadow: ['--defaultButtonBevel']
} }
}, },

View file

@ -0,0 +1,24 @@
<template>
<Popover trigger="hover">
<template #trigger>
<slot />
</template>
<template #content>
<div class="tooltip">
{{ props.text }}
</div>
</template>
</Popover>
</template>
<script setup>
import Popover from 'src/components/popover/popover.vue'
const props = defineProps(['text'])
</script>
<style lang="scss">
.tooltip {
margin: 0.5em 1em;
}
</style>

View file

@ -25,7 +25,7 @@ export default {
color: '#000000', color: '#000000',
alpha: 0.6 alpha: 0.6
}], }],
'--profileTint': 'color | $alpha(--background, 0.5)' '--profileTint': 'color | $alpha(--background 0.5)'
} }
}, },
{ {

View file

@ -748,11 +748,96 @@
"more_settings": "More settings", "more_settings": "More settings",
"style": { "style": {
"custom_theme_used": "(Custom theme)", "custom_theme_used": "(Custom theme)",
"stock_theme_used": "(Stock theme)",
"themes2_outdated": "Editor for Themes V2 is being phased out and will eventually be replaced with a new one that takes advantage of new Themes V3 engine. It should still work but experience might be degraded and inconsistent.", "themes2_outdated": "Editor for Themes V2 is being phased out and will eventually be replaced with a new one that takes advantage of new Themes V3 engine. It should still work but experience might be degraded and inconsistent.",
"appearance_tab_note": "Changes on this tab do not affect the theme used, so exported theme will be different from what seen in the UI", "appearance_tab_note": "Changes on this tab do not affect the theme used, so exported theme will be different from what seen in the UI",
"update_preview": "Update preview", "update_preview": "Update preview",
"themes3": { "themes3": {
"define": "Override", "define": "Override",
"palette": {
"label": "Palette",
"import": "Import",
"export": "Export",
"dark": "Dark mode",
"light": "Light mode",
"bg": "Panel background",
"fg": "Buttons etc.",
"text": "Text",
"link": "Links",
"accent": "Accent color",
"cRed": "Red color",
"cBlue": "Blue color",
"cGreen": "Green color",
"cOrange": "Orange color",
"extra1": "Extra 1",
"extra2": "Extra 2",
"extra3": "Extra 3"
},
"editor": {
"title": "Style",
"new_style": "New",
"load_style": "Open",
"save_style": "Save",
"style_name": "Stylesheet name",
"style_author": "Made by",
"style_license": "License",
"style_website": "Website",
"component_selector": "Component",
"variant_selector": "Variant",
"states_selector": "States",
"main_tab": "Main",
"shadows_tab": "Shadows",
"background": "Background color",
"text_color": "Text color",
"icon_color": "Icon color",
"link_color": "Link color",
"include_in_rule": "Add to rule",
"text_auto": {
"label": "Auto-contrast",
"no-preserve": "Black or White",
"preserve": "Keep color",
"no-auto": "Disabled"
},
"components": {
"normal": {
"state": "Normal",
"variant": "Default"
},
"Alert": {
"friendlyName": "Alert",
"variants": {
"error": "Error",
"warning": "Warning",
"success": "Success"
}
},
"Button": {
"friendlyName": "Button",
"variants": {
"danger": "Dangerous"
},
"states": {
"toggled": "Toggled",
"pressed": "Pressed",
"hover": "Hovered",
"focused": "Has focus",
"disabled": "Disabled"
}
},
"Input": {
"friendlyName": "Input fields",
"variants": {
"checkbox": "Checkbox",
"radio": "Radio"
},
"states": {
"hover": "Hovered",
"focus": "Focused",
"disabled": "Disabled"
}
}
}
},
"hacks": { "hacks": {
"underlay_overrides": "Change underlay", "underlay_overrides": "Change underlay",
"underlay_override_mode_none": "Theme default", "underlay_override_mode_none": "Theme default",

View file

@ -47,6 +47,8 @@ export const defaultState = {
customThemeSource: undefined, // "source", stores original theme data customThemeSource: undefined, // "source", stores original theme data
// V3 // V3
style: null,
palette: null,
themeDebug: false, // debug mode that uses computed backgrounds instead of real ones to debug contrast functions themeDebug: false, // debug mode that uses computed backgrounds instead of real ones to debug contrast functions
forceThemeRecompilation: false, // flag that forces recompilation on boot even if cache exists forceThemeRecompilation: false, // flag that forces recompilation on boot even if cache exists
theme3hacks: { // Hacks, user overrides that are independent of theme used theme3hacks: { // Hacks, user overrides that are independent of theme used

View file

@ -42,6 +42,9 @@ const defaultState = {
registrationOpen: true, registrationOpen: true,
server: 'http://localhost:4040/', server: 'http://localhost:4040/',
textlimit: 5000, textlimit: 5000,
themesIndex: undefined,
stylesIndex: undefined,
palettesIndex: undefined,
themeData: undefined, // used for theme editor v2 themeData: undefined, // used for theme editor v2
vapidPublicKey: undefined, vapidPublicKey: undefined,

View file

@ -1,4 +1,4 @@
import { getPreset, applyTheme, tryLoadCache } from '../services/style_setter/style_setter.js' import { getResourcesIndex, applyTheme, tryLoadCache } from '../services/style_setter/style_setter.js'
import { CURRENT_VERSION, generatePreset } from 'src/services/theme_data/theme_data.service.js' import { CURRENT_VERSION, generatePreset } from 'src/services/theme_data/theme_data.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js' import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
@ -212,142 +212,391 @@ const interfaceMod = {
setLastTimeline ({ commit }, value) { setLastTimeline ({ commit }, value) {
commit('setLastTimeline', value) commit('setLastTimeline', value)
}, },
setTheme ({ commit, rootState }, { themeName, themeData, recompile, saveData } = {}) { async fetchPalettesIndex ({ commit, state }) {
try {
const value = await getResourcesIndex('/static/palettes/index.json')
commit('setInstanceOption', { name: 'palettesIndex', value })
return value
} catch (e) {
console.error('Could not fetch palettes index', e)
return {}
}
},
setPalette ({ dispatch, commit }, value) {
dispatch('resetThemeV3Palette')
dispatch('resetThemeV2')
commit('setOption', { name: 'palette', value })
dispatch('applyTheme')
},
setPaletteCustom ({ dispatch, commit }, value) {
dispatch('resetThemeV3Palette')
dispatch('resetThemeV2')
commit('setOption', { name: 'paletteCustomData', value })
dispatch('applyTheme')
},
async fetchStylesIndex ({ commit, state }) {
try {
const value = await getResourcesIndex('/static/styles/index.json')
commit('setInstanceOption', { name: 'stylesIndex', value })
return value
} catch (e) {
console.error('Could not fetch styles index', e)
return Promise.resolve({})
}
},
setStyle ({ dispatch, commit }, value) {
dispatch('resetThemeV3')
dispatch('resetThemeV2')
commit('setOption', { name: 'style', value })
dispatch('applyTheme')
},
setStyleCustom ({ dispatch, commit }, value) {
dispatch('resetThemeV3')
dispatch('resetThemeV2')
commit('setOption', { name: 'styleCustomData', value })
dispatch('applyTheme')
},
async fetchThemesIndex ({ commit, state }) {
try {
const value = await getResourcesIndex('/static/styles.json')
commit('setInstanceOption', { name: 'themesIndex', value })
return value
} catch (e) {
console.error('Could not fetch themes index', e)
return Promise.resolve({})
}
},
setTheme ({ dispatch, commit }, value) {
dispatch('resetThemeV3')
dispatch('resetThemeV3Palette')
dispatch('resetThemeV2')
commit('setOption', { name: 'theme', value })
dispatch('applyTheme')
},
setThemeCustom ({ dispatch, commit }, value) {
dispatch('resetThemeV3')
dispatch('resetThemeV3Palette')
dispatch('resetThemeV2')
commit('setOption', { name: 'customTheme', value })
commit('setOption', { name: 'customThemeSource', value })
dispatch('applyTheme')
},
resetThemeV3 ({ dispatch, commit }) {
commit('setOption', { name: 'style', value: null })
commit('setOption', { name: 'styleCustomData', value: null })
},
resetThemeV3Palette ({ dispatch, commit }) {
commit('setOption', { name: 'palette', value: null })
commit('setOption', { name: 'paletteCustomData', value: null })
},
resetThemeV2 ({ dispatch, commit }) {
commit('setOption', { name: 'theme', value: null })
commit('setOption', { name: 'customTheme', value: null })
commit('setOption', { name: 'customThemeSource', value: null })
},
async applyTheme (
{ dispatch, commit, rootState },
{ recompile = true } = {}
) {
// If we're not not forced to recompile try using
// cache (tryLoadCache return true if load successful)
const { const {
theme: instanceThemeName style: instanceStyleName,
palette: instancePaletteName
} = rootState.instance
let {
theme: instanceThemeV2Name,
themesIndex,
stylesIndex,
palettesIndex
} = rootState.instance } = rootState.instance
const { const {
theme: userThemeName, style: userStyleName,
customTheme: userThemeSnapshot, styleCustomData: userStyleCustomData,
customThemeSource: userThemeSource, palette: userPaletteName,
paletteCustomData: userPaletteCustomData,
forceThemeRecompilation, forceThemeRecompilation,
themeDebug, themeDebug,
theme3hacks theme3hacks
} = rootState.config } = rootState.config
let {
const actualThemeName = userThemeName || instanceThemeName theme: userThemeV2Name,
customTheme: userThemeV2Snapshot,
customThemeSource: userThemeV2Source
} = rootState.config
const forceRecompile = forceThemeRecompilation || recompile const forceRecompile = forceThemeRecompilation || recompile
let promise = null
if (themeData) {
promise = Promise.resolve(normalizeThemeData(themeData))
} else if (themeName) {
promise = getPreset(themeName).then(themeData => normalizeThemeData(themeData))
} else if (userThemeSource || userThemeSnapshot) {
promise = Promise.resolve(normalizeThemeData({
_pleroma_theme_version: 2,
theme: userThemeSnapshot,
source: userThemeSource
}))
} else if (actualThemeName && actualThemeName !== 'custom') {
promise = getPreset(actualThemeName).then(themeData => {
const realThemeData = normalizeThemeData(themeData)
if (actualThemeName === instanceThemeName) {
// This sole line is the reason why this whole block is above the recompilation check
commit('setInstanceOption', { name: 'themeData', value: { theme: realThemeData } })
}
return realThemeData
})
} else {
throw new Error('Cannot load any theme!')
}
// If we're not not forced to recompile try using
// cache (tryLoadCache return true if load successful)
if (!forceRecompile && !themeDebug && tryLoadCache()) { if (!forceRecompile && !themeDebug && tryLoadCache()) {
commit('setThemeApplied') return commit('setThemeApplied')
return
} }
promise let majorVersionUsed
.then(realThemeData => {
const theme2ruleset = convertTheme2To3(realThemeData)
if (saveData) { console.log(
commit('setOption', { name: 'theme', value: themeName || actualThemeName }) `USER V3 palette: ${userPaletteName}, style: ${userStyleName} `
commit('setOption', { name: 'customTheme', value: realThemeData }) )
commit('setOption', { name: 'customThemeSource', value: realThemeData }) console.log(
} `USER V2 name: ${userThemeV2Name}, source: ${userThemeV2Source}, snapshot: ${userThemeV2Snapshot}`
const hacks = [] )
Object.entries(theme3hacks).forEach(([key, value]) => { console.log(`INST V3 palette: ${instancePaletteName}, style: ${instanceStyleName}`)
switch (key) { console.log('INST V2 theme: ' + instanceThemeV2Name)
case 'fonts': {
Object.entries(theme3hacks.fonts).forEach(([fontKey, font]) => {
if (!font?.family) return
switch (fontKey) {
case 'interface':
hacks.push({
component: 'Root',
directives: {
'--font': 'generic | ' + font.family
}
})
break
case 'input':
hacks.push({
component: 'Input',
directives: {
'--font': 'generic | ' + font.family
}
})
break
case 'post':
hacks.push({
component: 'RichContent',
directives: {
'--font': 'generic | ' + font.family
}
})
break
case 'monospace':
hacks.push({
component: 'Root',
directives: {
'--monoFont': 'generic | ' + font.family
}
})
break
}
})
break
}
case 'underlay': {
if (value !== 'none') {
const newRule = {
component: 'Underlay',
directives: {}
}
if (value === 'opaque') {
newRule.directives.opacity = 1
newRule.directives.background = '--wallpaper'
}
if (value === 'transparent') {
newRule.directives.opacity = 0
}
hacks.push(newRule)
}
break
}
}
})
const ruleset = [ if (userPaletteName || userPaletteCustomData ||
...theme2ruleset, userStyleName || userStyleCustomData ||
...hacks (
] // User V2 overrides instance V3
(instancePaletteName ||
applyTheme( instanceStyleName) &&
ruleset, instanceThemeV2Name == null &&
() => commit('setThemeApplied'), userThemeV2Name == null
themeDebug
) )
}) ) {
// Palette and/or style overrides V2 themes
instanceThemeV2Name = null
userThemeV2Name = null
userThemeV2Source = null
userThemeV2Snapshot = null
return promise majorVersionUsed = 'v3'
if (!palettesIndex || !stylesIndex) {
const result = await Promise.all([
dispatch('fetchPalettesIndex'),
dispatch('fetchStylesIndex')
])
palettesIndex = result[0]
stylesIndex = result[1]
}
} else if (
userThemeV2Name ||
userThemeV2Snapshot ||
userThemeV2Source ||
instanceThemeV2Name
) {
majorVersionUsed = 'v2'
// Promise.all just to be uniform with v3
const result = await Promise.all([
dispatch('fetchThemesIndex')
])
themesIndex = result[0]
} else {
majorVersionUsed = 'v3'
}
let styleDataUsed = null
let styleNameUsed = null
let paletteDataUsed = null
let paletteNameUsed = null
let themeNameUsed = null
let themeDataUsed = null
const getData = async (resource, index, customData, name) => {
const capitalizedResource = resource[0].toUpperCase() + resource.slice(1)
const result = {}
if (customData) {
result.nameUsed = 'custom' // custom data overrides name
result.dataUsed = customData
} else {
result.nameUsed = name
if (result.nameUsed === 'stock') {
result.dataUsed = null
return result
}
let fetchFunc = index[result.nameUsed]
// Fallbacks
if (!fetchFunc) {
const newName = Object.keys(index)[0]
fetchFunc = index[newName]
console.warn(`${capitalizedResource} with id '${styleNameUsed}' not found, trying back to '${newName}'`)
if (!fetchFunc) {
console.warn(`${capitalizedResource} doesn't have a fallback, defaulting to stock.`)
fetchFunc = () => Promise.resolve(null)
}
}
result.dataUsed = await fetchFunc()
}
return result
}
console.log('VERSION', majorVersionUsed)
if (majorVersionUsed === 'v3') {
const palette = await getData(
'palette',
palettesIndex,
userPaletteCustomData,
userPaletteName || instancePaletteName
)
paletteNameUsed = palette.nameUsed
paletteDataUsed = palette.dataUsed
if (Array.isArray(paletteDataUsed)) {
const [
name,
background,
foreground,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00'
] = paletteDataUsed
paletteDataUsed = { name, background, foreground, text, link, cRed, cBlue, cGreen, cOrange }
}
console.log('PAL', userPaletteName, paletteNameUsed)
console.log('PAL', paletteDataUsed)
const style = await getData(
'style',
stylesIndex,
userStyleCustomData,
userStyleName || instanceStyleName
)
styleNameUsed = style.nameUsed
styleDataUsed = style.dataUsed
} else {
const theme = await getData(
'theme',
themesIndex,
userThemeV2Source || userThemeV2Snapshot,
userThemeV2Name || instanceThemeV2Name
)
themeNameUsed = theme.nameUsed
themeDataUsed = theme.dataUsed
// Themes v2 editor support
commit('setInstanceOption', { name: 'themeData', value: themeDataUsed })
}
console.log('STYLE', styleNameUsed, paletteNameUsed, themeNameUsed)
// commit('setOption', { name: 'palette', value: paletteNameUsed })
// commit('setOption', { name: 'style', value: styleNameUsed })
// commit('setOption', { name: 'theme', value: themeNameUsed })
const paletteIss = (() => {
if (!paletteDataUsed) return null
const result = {
component: 'Root',
directives: {}
}
console.log('PALETTE', paletteDataUsed)
Object
.entries(paletteDataUsed)
.filter(([k]) => k !== 'name')
.forEach(([k, v]) => {
let issRootDirectiveName
switch (k) {
case 'background':
issRootDirectiveName = 'bg'
break
case 'foreground':
issRootDirectiveName = 'fg'
break
default:
issRootDirectiveName = k
}
result.directives['--' + issRootDirectiveName] = 'color | ' + v
})
return result
})()
const theme2ruleset = themeDataUsed && convertTheme2To3(normalizeThemeData(themeDataUsed))
const hacks = []
Object.entries(theme3hacks).forEach(([key, value]) => {
switch (key) {
case 'fonts': {
Object.entries(theme3hacks.fonts).forEach(([fontKey, font]) => {
if (!font?.family) return
switch (fontKey) {
case 'interface':
hacks.push({
component: 'Root',
directives: {
'--font': 'generic | ' + font.family
}
})
break
case 'input':
hacks.push({
component: 'Input',
directives: {
'--font': 'generic | ' + font.family
}
})
break
case 'post':
hacks.push({
component: 'RichContent',
directives: {
'--font': 'generic | ' + font.family
}
})
break
case 'monospace':
hacks.push({
component: 'Root',
directives: {
'--monoFont': 'generic | ' + font.family
}
})
break
}
})
break
}
case 'underlay': {
if (value !== 'none') {
const newRule = {
component: 'Underlay',
directives: {}
}
if (value === 'opaque') {
newRule.directives.opacity = 1
newRule.directives.background = '--wallpaper'
}
if (value === 'transparent') {
newRule.directives.opacity = 0
}
hacks.push(newRule)
}
break
}
}
})
const rulesetArray = [
theme2ruleset,
styleDataUsed,
paletteIss,
hacks
].filter(x => x)
return applyTheme(
rulesetArray.flat(),
() => commit('setThemeApplied'),
themeDebug
)
} }
} }
} }
@ -365,7 +614,7 @@ export const normalizeThemeData = (input) => {
themeData.colors.cGreen = input[6] themeData.colors.cGreen = input[6]
themeData.colors.cBlue = input[7] themeData.colors.cBlue = input[7]
themeData.colors.cOrange = input[8] themeData.colors.cOrange = input[8]
return generatePreset(themeData).theme return generatePreset(themeData).source || generatePreset(themeData).theme
} }
let themeData, themeSource let themeData, themeSource

View file

@ -20,6 +20,7 @@ export const newExporter = ({
}) })
export const newImporter = ({ export const newImporter = ({
accept = '.json',
onImport, onImport,
onImportFailure, onImportFailure,
validator = () => true validator = () => true
@ -27,18 +28,19 @@ export const newImporter = ({
importData () { importData () {
const filePicker = document.createElement('input') const filePicker = document.createElement('input')
filePicker.setAttribute('type', 'file') filePicker.setAttribute('type', 'file')
filePicker.setAttribute('accept', '.json') filePicker.setAttribute('accept', accept)
filePicker.addEventListener('change', event => { filePicker.addEventListener('change', event => {
if (event.target.files[0]) { if (event.target.files[0]) {
const filename = event.target.files[0].name
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const reader = new FileReader() const reader = new FileReader()
reader.onload = ({ target }) => { reader.onload = ({ target }) => {
try { try {
const parsed = JSON.parse(target.result) const parsed = JSON.parse(target.result)
const validationResult = validator(parsed) const validationResult = validator(parsed, filename)
if (validationResult === true) { if (validationResult === true) {
onImport(parsed) onImport(parsed, filename)
} else { } else {
onImportFailure({ validationResult }) onImportFailure({ validationResult })
} }

View file

@ -1,4 +1,3 @@
import { hex2rgb } from '../color_convert/color_convert.js'
import { init, getEngineChecksum } from '../theme_data/theme_data_3.service.js' import { init, getEngineChecksum } from '../theme_data/theme_data_3.service.js'
import { getCssRules } from '../theme_data/css_utils.js' import { getCssRules } from '../theme_data/css_utils.js'
import { defaultState } from '../../modules/config.js' import { defaultState } from '../../modules/config.js'
@ -43,17 +42,16 @@ const adoptStyleSheets = (styles) => {
// is nothing to do here. // is nothing to do here.
} }
export const generateTheme = (inputRuleset, callbacks, debug) => { export const generateTheme = async (inputRuleset, callbacks, debug) => {
const { const {
onNewRule = (rule, isLazy) => {}, onNewRule = (rule, isLazy) => {},
onLazyFinished = () => {}, onLazyFinished = () => {},
onEagerFinished = () => {} onEagerFinished = () => {}
} = callbacks } = callbacks
// Assuming that "worst case scenario background" is panel background since it's the most likely one
const themes3 = init({ const themes3 = init({
inputRuleset, inputRuleset,
// Assuming that "worst case scenario background" is panel background since it's the most likely one
ultimateBackgroundColor: inputRuleset[0].directives['--bg'].split('|')[1].trim(),
debug debug
}) })
@ -146,11 +144,12 @@ export const tryLoadCache = () => {
} }
} }
export const applyTheme = (input, onFinish = (data) => {}, debug) => { export const applyTheme = async (input, onFinish = (data) => {}, debug) => {
console.log('INPUT', input)
const eagerStyles = createStyleSheet(EAGER_STYLE_ID) const eagerStyles = createStyleSheet(EAGER_STYLE_ID)
const lazyStyles = createStyleSheet(LAZY_STYLE_ID) const lazyStyles = createStyleSheet(LAZY_STYLE_ID)
const { lazyProcessFunc } = generateTheme( const { lazyProcessFunc } = await generateTheme(
input, input,
{ {
onNewRule (rule, isLazy) { onNewRule (rule, isLazy) {
@ -169,22 +168,15 @@ export const applyTheme = (input, onFinish = (data) => {}, debug) => {
adoptStyleSheets([eagerStyles, lazyStyles]) adoptStyleSheets([eagerStyles, lazyStyles])
const cache = { engineChecksum: getEngineChecksum(), data: [eagerStyles.rules, lazyStyles.rules] } const cache = { engineChecksum: getEngineChecksum(), data: [eagerStyles.rules, lazyStyles.rules] }
onFinish(cache) onFinish(cache)
try { localStorage.setItem('pleroma-fe-theme-cache', JSON.stringify(cache))
localStorage.setItem('pleroma-fe-theme-cache', JSON.stringify(cache))
} catch (e) {
localStorage.removeItem('pleroma-fe-theme-cache')
try {
localStorage.setItem('pleroma-fe-theme-cache', JSON.stringify(cache))
} catch (e) {
console.warn('cannot save cache!', e)
}
}
} }
}, },
debug debug
) )
setTimeout(lazyProcessFunc, 0) setTimeout(lazyProcessFunc, 0)
return Promise.resolve()
} }
const extractStyleConfig = ({ const extractStyleConfig = ({
@ -229,7 +221,7 @@ const extractStyleConfig = ({
const defaultStyleConfig = extractStyleConfig(defaultState) const defaultStyleConfig = extractStyleConfig(defaultState)
export const applyConfig = (input, i18n) => { export const applyConfig = (input) => {
const config = extractStyleConfig(input) const config = extractStyleConfig(input)
if (config === defaultStyleConfig) { if (config === defaultStyleConfig) {
@ -237,6 +229,8 @@ export const applyConfig = (input, i18n) => {
} }
const head = document.head const head = document.head
const body = document.body
body.classList.add('hidden')
const rules = Object const rules = Object
.entries(config) .entries(config)
@ -257,59 +251,40 @@ export const applyConfig = (input, i18n) => {
--roundness: var(--forcedRoundness) !important; --roundness: var(--forcedRoundness) !important;
}`, 'index-max') }`, 'index-max')
} }
body.classList.remove('hidden')
} }
export const getThemes = () => { export const getResourcesIndex = async (url) => {
const cache = 'no-store' const cache = 'no-store'
return window.fetch('/static/styles.json', { cache }) try {
.then((data) => data.json()) const data = await window.fetch(url, { cache })
.then((themes) => { const resources = await data.json()
return Object.entries(themes).map(([k, v]) => { return Object.fromEntries(
let promise = null Object
if (typeof v === 'object') { .entries(resources)
promise = Promise.resolve(v) .map(([k, v]) => {
} else if (typeof v === 'string') { if (typeof v === 'object') {
promise = window.fetch(v, { cache }) return [k, () => Promise.resolve(v)]
.then((data) => data.json()) } else if (typeof v === 'string') {
.catch((e) => { return [
console.error(e) k,
return null () => window
}) .fetch(v, { cache })
} .then((data) => data.json())
return [k, promise] .catch((e) => {
}) console.error(e)
}) return null
.then((promises) => { })
return promises ]
.reduce((acc, [k, v]) => { } else {
acc[k] = v console.error(`Unknown resource format - ${k} is a ${typeof v}`)
return acc return [k, null]
}, {}) }
}) })
} )
} catch (e) {
export const getPreset = (val) => { return Promise.reject(e)
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 }
})
} }

View file

@ -2,25 +2,6 @@ import { convert } from 'chromatism'
import { hex2rgb, rgba2css } from '../color_convert/color_convert.js' import { hex2rgb, rgba2css } from '../color_convert/color_convert.js'
export const parseCssShadow = (text) => {
const dimensions = /(\d[a-z]*\s?){2,4}/.exec(text)?.[0]
const inset = /inset/.exec(text)?.[0]
const color = text.replace(dimensions, '').replace(inset, '')
const [x, y, blur = 0, spread = 0] = dimensions.split(/ /).filter(x => x).map(x => x.trim())
const isInset = inset?.trim() === 'inset'
const colorString = color.split(/ /).filter(x => x).map(x => x.trim())[0]
return {
x,
y,
blur,
spread,
inset: isInset,
color: colorString
}
}
export const getCssColorString = (color, alpha = 1) => rgba2css({ ...convert(color).rgb, a: alpha }) export const getCssColorString = (color, alpha = 1) => rgba2css({ ...convert(color).rgb, a: alpha })
export const getCssShadow = (input, usesDropShadow) => { export const getCssShadow = (input, usesDropShadow) => {
@ -84,6 +65,9 @@ export const getCssRules = (rules, debug) => rules.map(rule => {
].join(';\n ') ].join(';\n ')
} }
case 'shadow': { case 'shadow': {
if (!rule.dynamicVars.shadow) {
return ''
}
return ' ' + [ return ' ' + [
'--shadow: ' + getCssShadow(rule.dynamicVars.shadow), '--shadow: ' + getCssShadow(rule.dynamicVars.shadow),
'--shadowFilter: ' + getCssShadowFilter(rule.dynamicVars.shadow), '--shadowFilter: ' + getCssShadowFilter(rule.dynamicVars.shadow),

View file

@ -1,6 +1,6 @@
import { flattenDeep } from 'lodash' import { flattenDeep } from 'lodash'
const parseShadow = string => { export const parseShadow = string => {
const modes = ['_full', 'inset', 'x', 'y', 'blur', 'spread', 'color', 'alpha'] const modes = ['_full', 'inset', 'x', 'y', 'blur', 'spread', 'color', 'alpha']
const regexPrep = [ const regexPrep = [
// inset keyword (optional) // inset keyword (optional)
@ -26,7 +26,12 @@ const parseShadow = string => {
const numeric = new Set(['x', 'y', 'blur', 'spread', 'alpha']) const numeric = new Set(['x', 'y', 'blur', 'spread', 'alpha'])
const { x, y, blur, spread, alpha, inset, color } = Object.fromEntries(modes.map((mode, i) => { const { x, y, blur, spread, alpha, inset, color } = Object.fromEntries(modes.map((mode, i) => {
if (numeric.has(mode)) { if (numeric.has(mode)) {
return [mode, Number(result[i])] const number = Number(result[i])
if (Number.isNaN(number)) {
if (mode === 'alpha') return [mode, 1]
return [mode, 0]
}
return [mode, number]
} else if (mode === 'inset') { } else if (mode === 'inset') {
return [mode, !!result[i]] return [mode, !!result[i]]
} else { } else {
@ -136,7 +141,7 @@ export const deserialize = (input) => {
output.directives = Object.fromEntries(content.map(d => { output.directives = Object.fromEntries(content.map(d => {
const [property, value] = d.split(':') const [property, value] = d.split(':')
let realValue = value.trim() let realValue = (value || '').trim()
if (property === 'shadow') { if (property === 'shadow') {
if (realValue === 'none') { if (realValue === 'none') {
realValue = [] realValue = []

View file

@ -3,7 +3,7 @@ import { alphaBlend, getTextColor, relativeLuminance } from '../color_convert/co
export const process = (text, functions, { findColor, findShadow }, { dynamicVars, staticVars }) => { export const process = (text, functions, { findColor, findShadow }, { dynamicVars, staticVars }) => {
const { funcName, argsString } = /\$(?<funcName>\w+)\((?<argsString>[#a-zA-Z0-9-,.'"\s]*)\)/.exec(text).groups const { funcName, argsString } = /\$(?<funcName>\w+)\((?<argsString>[#a-zA-Z0-9-,.'"\s]*)\)/.exec(text).groups
const args = argsString.split(/,/g).map(a => a.trim()) const args = argsString.split(/ /g).map(a => a.trim())
const func = functions[funcName] const func = functions[funcName]
if (args.length < func.argsNeeded) { if (args.length < func.argsNeeded) {

View file

@ -22,7 +22,7 @@ import {
normalizeCombination, normalizeCombination,
findRules findRules
} from './iss_utils.js' } from './iss_utils.js'
import { parseCssShadow } from './css_utils.js' import { parseShadow } from './iss_deserializer.js'
// Ensuring the order of components // Ensuring the order of components
const components = { const components = {
@ -48,7 +48,7 @@ const findShadow = (shadows, { dynamicVars, staticVars }) => {
const variableSlot = variable.substring(2) const variableSlot = variable.substring(2)
return findShadow(staticVars[variableSlot], { dynamicVars, staticVars }) return findShadow(staticVars[variableSlot], { dynamicVars, staticVars })
} else { } else {
targetShadow = parseCssShadow(shadow) targetShadow = parseShadow(shadow)
} }
} else { } else {
targetShadow = shadow targetShadow = shadow
@ -172,11 +172,13 @@ export const init = ({
ultimateBackgroundColor, ultimateBackgroundColor,
debug = false, debug = false,
liteMode = false, liteMode = false,
editMode = false,
onlyNormalState = false, onlyNormalState = false,
rootComponentName = 'Root' rootComponentName = 'Root',
initialStaticVars = {}
}) => { }) => {
if (!inputRuleset) throw new Error('Ruleset is null or undefined!') if (!inputRuleset) throw new Error('Ruleset is null or undefined!')
const staticVars = {} const staticVars = { ...initialStaticVars }
const stacked = {} const stacked = {}
const computed = {} const computed = {}
@ -228,7 +230,14 @@ export const init = ({
}) })
.map(({ data }) => data) .map(({ data }) => data)
if (!ultimateBackgroundColor) {
console.warn('No ultimate background color provided, falling back to panel color')
const rootRule = ruleset.findLast((x) => (x.component === 'Root' && x.directives?.['--bg']))
ultimateBackgroundColor = rootRule.directives['--bg'].split('|')[1].trim()
}
const virtualComponents = new Set(Object.values(components).filter(c => c.virtual).map(c => c.name)) const virtualComponents = new Set(Object.values(components).filter(c => c.virtual).map(c => c.name))
const nonEditableComponents = new Set(Object.values(components).filter(c => c.notEditable).map(c => c.name))
const processCombination = (combination) => { const processCombination = (combination) => {
const selector = ruleToSelector(combination, true) const selector = ruleToSelector(combination, true)
@ -238,7 +247,11 @@ export const init = ({
const soloSelector = selector.split(/ /g).slice(-1)[0] const soloSelector = selector.split(/ /g).slice(-1)[0]
const lowerLevelSelector = parentSelector const lowerLevelSelector = parentSelector
const lowerLevelBackground = computed[lowerLevelSelector]?.background let lowerLevelBackground = computed[lowerLevelSelector]?.background
if (editMode && !lowerLevelBackground) {
// FIXME hack for editor until it supports handling component backgrounds
lowerLevelBackground = '#00FFFF'
}
const lowerLevelVirtualDirectives = computed[lowerLevelSelector]?.virtualDirectives const lowerLevelVirtualDirectives = computed[lowerLevelSelector]?.virtualDirectives
const lowerLevelVirtualDirectivesRaw = computed[lowerLevelSelector]?.virtualDirectivesRaw const lowerLevelVirtualDirectivesRaw = computed[lowerLevelSelector]?.virtualDirectivesRaw
@ -397,27 +410,27 @@ export const init = ({
const dynamicSlots = Object.entries(computedDirectives).filter(([k, v]) => k.startsWith('--')) const dynamicSlots = Object.entries(computedDirectives).filter(([k, v]) => k.startsWith('--'))
dynamicSlots.forEach(([k, v]) => { dynamicSlots.forEach(([k, v]) => {
const [type, ...value] = v.split('|').map(x => x.trim()) // woah, Extreme! const [type, value] = v.split('|').map(x => x.trim()) // woah, Extreme!
switch (type) { switch (type) {
case 'color': { case 'color': {
const color = findColor(value[0], { dynamicVars, staticVars }) const color = findColor(value, { dynamicVars, staticVars })
dynamicVars[k] = color dynamicVars[k] = color
if (combination.component === 'Root') { if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = color staticVars[k.substring(2)] = color
} }
break break
} }
case 'shadow': { case 'shadow': {
const shadow = value const shadow = value.split(/,/g).map(s => s.trim())
dynamicVars[k] = shadow dynamicVars[k] = shadow
if (combination.component === 'Root') { if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = shadow staticVars[k.substring(2)] = shadow
} }
break break
} }
case 'generic': { case 'generic': {
dynamicVars[k] = value dynamicVars[k] = value
if (combination.component === 'Root') { if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = value staticVars[k.substring(2)] = value
} }
break break
@ -443,11 +456,15 @@ export const init = ({
variants: originalVariants = {} variants: originalVariants = {}
} = component } = component
const validInnerComponents = ( let validInnerComponents
liteMode if (editMode) {
? (component.validInnerComponentsLite || component.validInnerComponents) const temp = (component.validInnerComponentsLite || component.validInnerComponents || [])
: component.validInnerComponents validInnerComponents = temp.filter(c => virtualComponents.has(c) && !nonEditableComponents.has(c))
) || [] } else if (liteMode) {
validInnerComponents = (component.validInnerComponentsLite || component.validInnerComponents || [])
} else {
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 }

View file

@ -0,0 +1,32 @@
{
"pleroma-light": [ "Pleroma Light", "#f2f4f6", "#dbe0e8", "#304055", "#f86f0f", "#d31014", "#0fa00f", "#0095ff", "#ffa500" ],
"pleroma-dark": [ "Pleroma Dark", "#121a24", "#182230", "#b9b9ba", "#d8a070", "#d31014", "#0fa00f", "#0095ff", "#ffa500" ],
"classic-dark": {
"name": "Classic Dark",
"background": "#161c20",
"foreground": "#282e32",
"text": "#b9b9b9",
"link": "#baaa9c",
"cRed": "#d31014",
"cGreen": "#0fa00f",
"cBlue": "#0095ff",
"cOrange": "#ffa500"
},
"pleroma-amoled": [ "Pleroma Dark AMOLED", "#000000", "#111111", "#b0b0b1", "#d8a070", "#aa0000", "#0fa00f", "#0095ff", "#d59500"],
"tomorrow-night": {
"name": "Tomorrow Night",
"background": "#1d1f21",
"foreground": "#373b41",
"link": "#81a2be",
"text": "#c5c8c6",
"cRed": "#cc6666",
"cBlue": "#8abeb7",
"cGreen": "#b5bd68",
"cOrange": "#de935f",
"_cYellow": "#f0c674",
"_cPurple": "#b294bb"
},
"bird": [ "Bird", "#f8fafd", "#e6ecf0", "#14171a", "#0084b8", "#e0245e", "#17bf63", "#1b95e0", "#fab81e"],
"ir-black": [ "Ir Black", "#000000", "#242422", "#b5b3aa", "#ff6c60", "#FF6C60", "#A8FF60", "#96CBFE", "#FFFFB6" ],
"monokai": [ "Monokai", "#272822", "#383830", "#f8f8f2", "#f92672", "#F92672", "#a6e22e", "#66d9ef", "#f4bf75" ]
}

View file

@ -3,12 +3,6 @@
"sigsegv2": [ "SigSeg部", "#003238", "#00616c", "#e8f9fb", "#81ffff", "#ff7b66", "#4ae619", "#00ddff", "#ccef53" ], "sigsegv2": [ "SigSeg部", "#003238", "#00616c", "#e8f9fb", "#81ffff", "#ff7b66", "#4ae619", "#00ddff", "#ccef53" ],
"pleroma-dark": "/static/themes/pleroma-dark.json", "pleroma-dark": "/static/themes/pleroma-dark.json",
"pleroma-light": "/static/themes/pleroma-light.json", "pleroma-light": "/static/themes/pleroma-light.json",
"pleroma-amoled": [ "Pleroma Dark AMOLED", "#000000", "#111111", "#b0b0b1", "#d8a070", "#aa0000", "#0fa00f", "#0095ff", "#d59500"],
"classic-dark": [ "Classic Dark", "#161c20", "#282e32", "#b9b9b9", "#baaa9c", "#d31014", "#0fa00f", "#0095ff", "#ffa500" ],
"bird": [ "Bird", "#f8fafd", "#e6ecf0", "#14171a", "#0084b8", "#e0245e", "#17bf63", "#1b95e0", "#fab81e"],
"ir-black": [ "Ir Black", "#000000", "#242422", "#b5b3aa", "#ff6c60", "#FF6C60", "#A8FF60", "#96CBFE", "#FFFFB6" ],
"monokai": [ "Monokai", "#272822", "#383830", "#f8f8f2", "#f92672", "#F92672", "#a6e22e", "#66d9ef", "#f4bf75" ],
"redmond-xx": "/static/themes/redmond-xx.json", "redmond-xx": "/static/themes/redmond-xx.json",
"redmond-xx-se": "/static/themes/redmond-xx-se.json", "redmond-xx-se": "/static/themes/redmond-xx-se.json",
"redmond-xxi": "/static/themes/redmond-xxi.json", "redmond-xxi": "/static/themes/redmond-xxi.json",