style_switcher.js (22319B)
1 import { set, delete as del } from 'vue'
2 import {
3 rgb2hex,
4 hex2rgb,
5 getContrastRatioLayers
6 } from '../../services/color_convert/color_convert.js'
7 import {
8 DEFAULT_SHADOWS,
9 generateColors,
10 generateShadows,
11 generateRadii,
12 generateFonts,
13 composePreset,
14 getThemes,
15 shadows2to3,
16 colors2to3
17 } from '../../services/style_setter/style_setter.js'
18 import {
19 SLOT_INHERITANCE
20 } from '../../services/theme_data/pleromafe.js'
21 import {
22 CURRENT_VERSION,
23 OPACITIES,
24 getLayers,
25 getOpacitySlot
26 } from '../../services/theme_data/theme_data.service.js'
27 import ColorInput from '../color_input/color_input.vue'
28 import RangeInput from '../range_input/range_input.vue'
29 import OpacityInput from '../opacity_input/opacity_input.vue'
30 import ShadowControl from '../shadow_control/shadow_control.vue'
31 import FontControl from '../font_control/font_control.vue'
32 import ContrastRatio from '../contrast_ratio/contrast_ratio.vue'
33 import TabSwitcher from '../tab_switcher/tab_switcher.js'
34 import Preview from './preview.vue'
35 import ExportImport from '../export_import/export_import.vue'
36 import Checkbox from '../checkbox/checkbox.vue'
37
38 // List of color values used in v1
39 const v1OnlyNames = [
40 'bg',
41 'fg',
42 'text',
43 'link',
44 'cRed',
45 'cGreen',
46 'cBlue',
47 'cOrange'
48 ].map(_ => _ + 'ColorLocal')
49
50 const colorConvert = (color) => {
51 if (color.startsWith('--') || color === 'transparent') {
52 return color
53 } else {
54 return hex2rgb(color)
55 }
56 }
57
58 export default {
59 data () {
60 return {
61 availableStyles: [],
62 selected: this.$store.getters.mergedConfig.theme,
63 themeWarning: undefined,
64 tempImportFile: undefined,
65 engineVersion: 0,
66
67 previewShadows: {},
68 previewColors: {},
69 previewRadii: {},
70 previewFonts: {},
71
72 shadowsInvalid: true,
73 colorsInvalid: true,
74 radiiInvalid: true,
75
76 keepColor: false,
77 keepShadows: false,
78 keepOpacity: false,
79 keepRoundness: false,
80 keepFonts: false,
81
82 ...Object.keys(SLOT_INHERITANCE)
83 .map(key => [key, ''])
84 .reduce((acc, [key, val]) => ({ ...acc, [ key + 'ColorLocal' ]: val }), {}),
85
86 ...Object.keys(OPACITIES)
87 .map(key => [key, ''])
88 .reduce((acc, [key, val]) => ({ ...acc, [ key + 'OpacityLocal' ]: val }), {}),
89
90 shadowSelected: undefined,
91 shadowsLocal: {},
92 fontsLocal: {},
93
94 btnRadiusLocal: '',
95 inputRadiusLocal: '',
96 checkboxRadiusLocal: '',
97 panelRadiusLocal: '',
98 avatarRadiusLocal: '',
99 avatarAltRadiusLocal: '',
100 attachmentRadiusLocal: '',
101 tooltipRadiusLocal: '',
102 chatMessageRadiusLocal: ''
103 }
104 },
105 created () {
106 const self = this
107
108 getThemes()
109 .then((promises) => {
110 return Promise.all(
111 Object.entries(promises)
112 .map(([k, v]) => v.then(res => [k, res]))
113 )
114 })
115 .then(themes => themes.reduce((acc, [k, v]) => {
116 if (v) {
117 return {
118 ...acc,
119 [k]: v
120 }
121 } else {
122 return acc
123 }
124 }, {}))
125 .then((themesComplete) => {
126 self.availableStyles = themesComplete
127 })
128 },
129 mounted () {
130 this.loadThemeFromLocalStorage()
131 if (typeof this.shadowSelected === 'undefined') {
132 this.shadowSelected = this.shadowsAvailable[0]
133 }
134 },
135 computed: {
136 themeWarningHelp () {
137 if (!this.themeWarning) return
138 const t = this.$t
139 const pre = 'settings.style.switcher.help.'
140 const {
141 origin,
142 themeEngineVersion,
143 type,
144 noActionsPossible
145 } = this.themeWarning
146 if (origin === 'file') {
147 // Loaded v2 theme from file
148 if (themeEngineVersion === 2 && type === 'wrong_version') {
149 return t(pre + 'v2_imported')
150 }
151 if (themeEngineVersion > CURRENT_VERSION) {
152 return t(pre + 'future_version_imported') + ' ' +
153 (
154 noActionsPossible
155 ? t(pre + 'snapshot_missing')
156 : t(pre + 'snapshot_present')
157 )
158 }
159 if (themeEngineVersion < CURRENT_VERSION) {
160 return t(pre + 'future_version_imported') + ' ' +
161 (
162 noActionsPossible
163 ? t(pre + 'snapshot_missing')
164 : t(pre + 'snapshot_present')
165 )
166 }
167 } else if (origin === 'localStorage') {
168 if (type === 'snapshot_source_mismatch') {
169 return t(pre + 'snapshot_source_mismatch')
170 }
171 // FE upgraded from v2
172 if (themeEngineVersion === 2) {
173 return t(pre + 'upgraded_from_v2')
174 }
175 // Admin downgraded FE
176 if (themeEngineVersion > CURRENT_VERSION) {
177 return t(pre + 'fe_downgraded') + ' ' +
178 (
179 noActionsPossible
180 ? t(pre + 'migration_snapshot_ok')
181 : t(pre + 'migration_snapshot_gone')
182 )
183 }
184 // Admin upgraded FE
185 if (themeEngineVersion < CURRENT_VERSION) {
186 return t(pre + 'fe_upgraded') + ' ' +
187 (
188 noActionsPossible
189 ? t(pre + 'migration_snapshot_ok')
190 : t(pre + 'migration_snapshot_gone')
191 )
192 }
193 }
194 },
195 selectedVersion () {
196 return Array.isArray(this.selected) ? 1 : 2
197 },
198 currentColors () {
199 return Object.keys(SLOT_INHERITANCE)
200 .map(key => [key, this[key + 'ColorLocal']])
201 .reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
202 },
203 currentOpacity () {
204 return Object.keys(OPACITIES)
205 .map(key => [key, this[key + 'OpacityLocal']])
206 .reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
207 },
208 currentRadii () {
209 return {
210 btn: this.btnRadiusLocal,
211 input: this.inputRadiusLocal,
212 checkbox: this.checkboxRadiusLocal,
213 panel: this.panelRadiusLocal,
214 avatar: this.avatarRadiusLocal,
215 avatarAlt: this.avatarAltRadiusLocal,
216 tooltip: this.tooltipRadiusLocal,
217 attachment: this.attachmentRadiusLocal,
218 chatMessage: this.chatMessageRadiusLocal
219 }
220 },
221 preview () {
222 return composePreset(this.previewColors, this.previewRadii, this.previewShadows, this.previewFonts)
223 },
224 previewTheme () {
225 if (!this.preview.theme.colors) return { colors: {}, opacity: {}, radii: {}, shadows: {}, fonts: {} }
226 return this.preview.theme
227 },
228 // This needs optimization maybe
229 previewContrast () {
230 try {
231 if (!this.previewTheme.colors.bg) return {}
232 const colors = this.previewTheme.colors
233 const opacity = this.previewTheme.opacity
234 if (!colors.bg) return {}
235 const hints = (ratio) => ({
236 text: ratio.toPrecision(3) + ':1',
237 // AA level, AAA level
238 aa: ratio >= 4.5,
239 aaa: ratio >= 7,
240 // same but for 18pt+ texts
241 laa: ratio >= 3,
242 laaa: ratio >= 4.5
243 })
244 const colorsConverted = Object.entries(colors).reduce((acc, [key, value]) => ({ ...acc, [key]: colorConvert(value) }), {})
245
246 const ratios = Object.entries(SLOT_INHERITANCE).reduce((acc, [key, value]) => {
247 const slotIsBaseText = key === 'text' || key === 'link'
248 const slotIsText = slotIsBaseText || (
249 typeof value === 'object' && value !== null && value.textColor
250 )
251 if (!slotIsText) return acc
252 const { layer, variant } = slotIsBaseText ? { layer: 'bg' } : value
253 const background = variant || layer
254 const opacitySlot = getOpacitySlot(background)
255 const textColors = [
256 key,
257 ...(background === 'bg' ? ['cRed', 'cGreen', 'cBlue', 'cOrange'] : [])
258 ]
259
260 const layers = getLayers(
261 layer,
262 variant || layer,
263 opacitySlot,
264 colorsConverted,
265 opacity
266 )
267
268 return {
269 ...acc,
270 ...textColors.reduce((acc, textColorKey) => {
271 const newKey = slotIsBaseText
272 ? 'bg' + textColorKey[0].toUpperCase() + textColorKey.slice(1)
273 : textColorKey
274 return {
275 ...acc,
276 [newKey]: getContrastRatioLayers(
277 colorsConverted[textColorKey],
278 layers,
279 colorsConverted[textColorKey]
280 )
281 }
282 }, {})
283 }
284 }, {})
285
286 return Object.entries(ratios).reduce((acc, [k, v]) => { acc[k] = hints(v); return acc }, {})
287 } catch (e) {
288 console.warn('Failure computing contrasts', e)
289 }
290 },
291 previewRules () {
292 if (!this.preview.rules) return ''
293 return [
294 ...Object.values(this.preview.rules),
295 'color: var(--text)',
296 'font-family: var(--interfaceFont, sans-serif)'
297 ].join(';')
298 },
299 shadowsAvailable () {
300 return Object.keys(DEFAULT_SHADOWS).sort()
301 },
302 currentShadowOverriden: {
303 get () {
304 return !!this.currentShadow
305 },
306 set (val) {
307 if (val) {
308 set(this.shadowsLocal, this.shadowSelected, this.currentShadowFallback.map(_ => Object.assign({}, _)))
309 } else {
310 del(this.shadowsLocal, this.shadowSelected)
311 }
312 }
313 },
314 currentShadowFallback () {
315 return (this.previewTheme.shadows || {})[this.shadowSelected]
316 },
317 currentShadow: {
318 get () {
319 return this.shadowsLocal[this.shadowSelected]
320 },
321 set (v) {
322 set(this.shadowsLocal, this.shadowSelected, v)
323 }
324 },
325 themeValid () {
326 return !this.shadowsInvalid && !this.colorsInvalid && !this.radiiInvalid
327 },
328 exportedTheme () {
329 const saveEverything = (
330 !this.keepFonts &&
331 !this.keepShadows &&
332 !this.keepOpacity &&
333 !this.keepRoundness &&
334 !this.keepColor
335 )
336
337 const source = {
338 themeEngineVersion: CURRENT_VERSION
339 }
340
341 if (this.keepFonts || saveEverything) {
342 source.fonts = this.fontsLocal
343 }
344 if (this.keepShadows || saveEverything) {
345 source.shadows = this.shadowsLocal
346 }
347 if (this.keepOpacity || saveEverything) {
348 source.opacity = this.currentOpacity
349 }
350 if (this.keepColor || saveEverything) {
351 source.colors = this.currentColors
352 }
353 if (this.keepRoundness || saveEverything) {
354 source.radii = this.currentRadii
355 }
356
357 const theme = {
358 themeEngineVersion: CURRENT_VERSION,
359 ...this.previewTheme
360 }
361
362 return {
363 // To separate from other random JSON files and possible future source formats
364 _pleroma_theme_version: 2, theme, source
365 }
366 }
367 },
368 components: {
369 ColorInput,
370 OpacityInput,
371 RangeInput,
372 ContrastRatio,
373 ShadowControl,
374 FontControl,
375 TabSwitcher,
376 Preview,
377 ExportImport,
378 Checkbox
379 },
380 methods: {
381 loadTheme (
382 {
383 theme,
384 source,
385 _pleroma_theme_version: fileVersion
386 },
387 origin,
388 forceUseSource = false
389 ) {
390 this.dismissWarning()
391 if (!source && !theme) {
392 throw new Error('Can\'t load theme: empty')
393 }
394 const version = (origin === 'localStorage' && !theme.colors)
395 ? 'l1'
396 : fileVersion
397 const snapshotEngineVersion = (theme || {}).themeEngineVersion
398 const themeEngineVersion = (source || {}).themeEngineVersion || 2
399 const versionsMatch = themeEngineVersion === CURRENT_VERSION
400 const sourceSnapshotMismatch = (
401 theme !== undefined &&
402 source !== undefined &&
403 themeEngineVersion !== snapshotEngineVersion
404 )
405 // Force loading of source if user requested it or if snapshot
406 // is unavailable
407 const forcedSourceLoad = (source && forceUseSource) || !theme
408 if (!(versionsMatch && !sourceSnapshotMismatch) &&
409 !forcedSourceLoad &&
410 version !== 'l1' &&
411 origin !== 'defaults'
412 ) {
413 if (sourceSnapshotMismatch && origin === 'localStorage') {
414 this.themeWarning = {
415 origin,
416 themeEngineVersion,
417 type: 'snapshot_source_mismatch'
418 }
419 } else if (!theme) {
420 this.themeWarning = {
421 origin,
422 noActionsPossible: true,
423 themeEngineVersion,
424 type: 'no_snapshot_old_version'
425 }
426 } else if (!versionsMatch) {
427 this.themeWarning = {
428 origin,
429 noActionsPossible: !source,
430 themeEngineVersion,
431 type: 'wrong_version'
432 }
433 }
434 }
435 this.normalizeLocalState(theme, version, source, forcedSourceLoad)
436 },
437 forceLoadLocalStorage () {
438 this.loadThemeFromLocalStorage(true)
439 },
440 dismissWarning () {
441 this.themeWarning = undefined
442 this.tempImportFile = undefined
443 },
444 forceLoad () {
445 const { origin } = this.themeWarning
446 switch (origin) {
447 case 'localStorage':
448 this.loadThemeFromLocalStorage(true)
449 break
450 case 'file':
451 this.onImport(this.tempImportFile, true)
452 break
453 }
454 this.dismissWarning()
455 },
456 forceSnapshot () {
457 const { origin } = this.themeWarning
458 switch (origin) {
459 case 'localStorage':
460 this.loadThemeFromLocalStorage(false, true)
461 break
462 case 'file':
463 console.err('Forcing snapshout from file is not supported yet')
464 break
465 }
466 this.dismissWarning()
467 },
468 loadThemeFromLocalStorage (confirmLoadSource = false, forceSnapshot = false) {
469 const {
470 customTheme: theme,
471 customThemeSource: source
472 } = this.$store.getters.mergedConfig
473 if (!theme && !source) {
474 // Anon user or never touched themes
475 this.loadTheme(
476 this.$store.state.instance.themeData,
477 'defaults',
478 confirmLoadSource
479 )
480 } else {
481 this.loadTheme(
482 {
483 theme,
484 source: forceSnapshot ? theme : source
485 },
486 'localStorage',
487 confirmLoadSource
488 )
489 }
490 },
491 setCustomTheme () {
492 this.$store.dispatch('setOption', {
493 name: 'customTheme',
494 value: {
495 themeEngineVersion: CURRENT_VERSION,
496 ...this.previewTheme
497 }
498 })
499 this.$store.dispatch('setOption', {
500 name: 'customThemeSource',
501 value: {
502 themeEngineVersion: CURRENT_VERSION,
503 shadows: this.shadowsLocal,
504 fonts: this.fontsLocal,
505 opacity: this.currentOpacity,
506 colors: this.currentColors,
507 radii: this.currentRadii
508 }
509 })
510 },
511 updatePreviewColorsAndShadows () {
512 this.previewColors = generateColors({
513 opacity: this.currentOpacity,
514 colors: this.currentColors
515 })
516 this.previewShadows = generateShadows(
517 { shadows: this.shadowsLocal, opacity: this.previewTheme.opacity, themeEngineVersion: this.engineVersion },
518 this.previewColors.theme.colors,
519 this.previewColors.mod
520 )
521 },
522 onImport (parsed, forceSource = false) {
523 this.tempImportFile = parsed
524 this.loadTheme(parsed, 'file', forceSource)
525 },
526 importValidator (parsed) {
527 const version = parsed._pleroma_theme_version
528 return version >= 1 || version <= 2
529 },
530 clearAll () {
531 this.loadThemeFromLocalStorage()
532 },
533
534 // Clears all the extra stuff when loading V1 theme
535 clearV1 () {
536 Object.keys(this.$data)
537 .filter(_ => _.endsWith('ColorLocal') || _.endsWith('OpacityLocal'))
538 .filter(_ => !v1OnlyNames.includes(_))
539 .forEach(key => {
540 set(this.$data, key, undefined)
541 })
542 },
543
544 clearRoundness () {
545 Object.keys(this.$data)
546 .filter(_ => _.endsWith('RadiusLocal'))
547 .forEach(key => {
548 set(this.$data, key, undefined)
549 })
550 },
551
552 clearOpacity () {
553 Object.keys(this.$data)
554 .filter(_ => _.endsWith('OpacityLocal'))
555 .forEach(key => {
556 set(this.$data, key, undefined)
557 })
558 },
559
560 clearShadows () {
561 this.shadowsLocal = {}
562 },
563
564 clearFonts () {
565 this.fontsLocal = {}
566 },
567
568 /**
569 * This applies stored theme data onto form. Supports three versions of data:
570 * v3 (version >= 3) - newest version of themes which supports snapshots for better compatiblity
571 * v2 (version = 2) - newer version of themes.
572 * v1 (version = 1) - older version of themes (import from file)
573 * v1l (version = l1) - older version of theme (load from local storage)
574 * v1 and v1l differ because of way themes were stored/exported.
575 * @param {Object} theme - theme data (snapshot)
576 * @param {Number} version - version of data. 0 means try to guess based on data. "l1" means v1, locastorage type
577 * @param {Object} source - theme source - this will be used if compatible
578 * @param {Boolean} source - by default source won't be used if version doesn't match since it might render differently
579 * this allows importing source anyway
580 */
581 normalizeLocalState (theme, version = 0, source, forceSource = false) {
582 let input
583 if (typeof source !== 'undefined') {
584 if (forceSource || source.themeEngineVersion === CURRENT_VERSION) {
585 input = source
586 version = source.themeEngineVersion
587 } else {
588 input = theme
589 }
590 } else {
591 input = theme
592 }
593
594 const radii = input.radii || input
595 const opacity = input.opacity
596 const shadows = input.shadows || {}
597 const fonts = input.fonts || {}
598 const colors = !input.themeEngineVersion
599 ? colors2to3(input.colors || input)
600 : input.colors || input
601
602 if (version === 0) {
603 if (input.version) version = input.version
604 // Old v1 naming: fg is text, btn is foreground
605 if (typeof colors.text === 'undefined' && typeof colors.fg !== 'undefined') {
606 version = 1
607 }
608 // New v2 naming: text is text, fg is foreground
609 if (typeof colors.text !== 'undefined' && typeof colors.fg !== 'undefined') {
610 version = 2
611 }
612 }
613
614 this.engineVersion = version
615
616 // Stuff that differs between V1 and V2
617 if (version === 1) {
618 this.fgColorLocal = rgb2hex(colors.btn)
619 this.textColorLocal = rgb2hex(colors.fg)
620 }
621
622 if (!this.keepColor) {
623 this.clearV1()
624 const keys = new Set(version !== 1 ? Object.keys(SLOT_INHERITANCE) : [])
625 if (version === 1 || version === 'l1') {
626 keys
627 .add('bg')
628 .add('link')
629 .add('cRed')
630 .add('cBlue')
631 .add('cGreen')
632 .add('cOrange')
633 }
634
635 keys.forEach(key => {
636 const color = colors[key]
637 const hex = rgb2hex(colors[key])
638 this[key + 'ColorLocal'] = hex === '#aN' ? color : hex
639 })
640 }
641
642 if (opacity && !this.keepOpacity) {
643 this.clearOpacity()
644 Object.entries(opacity).forEach(([k, v]) => {
645 if (typeof v === 'undefined' || v === null || Number.isNaN(v)) return
646 this[k + 'OpacityLocal'] = v
647 })
648 }
649
650 if (!this.keepRoundness) {
651 this.clearRoundness()
652 Object.entries(radii).forEach(([k, v]) => {
653 // 'Radius' is kept mostly for v1->v2 localstorage transition
654 const key = k.endsWith('Radius') ? k.split('Radius')[0] : k
655 this[key + 'RadiusLocal'] = v
656 })
657 }
658
659 if (!this.keepShadows) {
660 this.clearShadows()
661 if (version === 2) {
662 this.shadowsLocal = shadows2to3(shadows, this.previewTheme.opacity)
663 } else {
664 this.shadowsLocal = shadows
665 }
666 this.shadowSelected = this.shadowsAvailable[0]
667 }
668
669 if (!this.keepFonts) {
670 this.clearFonts()
671 this.fontsLocal = fonts
672 }
673 }
674 },
675 watch: {
676 currentRadii () {
677 try {
678 this.previewRadii = generateRadii({ radii: this.currentRadii })
679 this.radiiInvalid = false
680 } catch (e) {
681 this.radiiInvalid = true
682 console.warn(e)
683 }
684 },
685 shadowsLocal: {
686 handler () {
687 if (Object.getOwnPropertyNames(this.previewColors).length === 1) return
688 try {
689 this.updatePreviewColorsAndShadows()
690 this.shadowsInvalid = false
691 } catch (e) {
692 this.shadowsInvalid = true
693 console.warn(e)
694 }
695 },
696 deep: true
697 },
698 fontsLocal: {
699 handler () {
700 try {
701 this.previewFonts = generateFonts({ fonts: this.fontsLocal })
702 this.fontsInvalid = false
703 } catch (e) {
704 this.fontsInvalid = true
705 console.warn(e)
706 }
707 },
708 deep: true
709 },
710 currentColors () {
711 try {
712 this.updatePreviewColorsAndShadows()
713 this.colorsInvalid = false
714 this.shadowsInvalid = false
715 } catch (e) {
716 this.colorsInvalid = true
717 this.shadowsInvalid = true
718 console.warn(e)
719 }
720 },
721 currentOpacity () {
722 try {
723 this.updatePreviewColorsAndShadows()
724 } catch (e) {
725 console.warn(e)
726 }
727 },
728 selected () {
729 this.dismissWarning()
730 if (this.selectedVersion === 1) {
731 if (!this.keepRoundness) {
732 this.clearRoundness()
733 }
734
735 if (!this.keepShadows) {
736 this.clearShadows()
737 }
738
739 if (!this.keepOpacity) {
740 this.clearOpacity()
741 }
742
743 if (!this.keepColor) {
744 this.clearV1()
745
746 this.bgColorLocal = this.selected[1]
747 this.fgColorLocal = this.selected[2]
748 this.textColorLocal = this.selected[3]
749 this.linkColorLocal = this.selected[4]
750 this.cRedColorLocal = this.selected[5]
751 this.cGreenColorLocal = this.selected[6]
752 this.cBlueColorLocal = this.selected[7]
753 this.cOrangeColorLocal = this.selected[8]
754 }
755 } else if (this.selectedVersion >= 2) {
756 this.normalizeLocalState(this.selected.theme, 2, this.selected.source)
757 }
758 }
759 }
760 }