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 }