logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe git clone https://anongit.hacktivis.me/git/pleroma-fe.git/
commit: cbe9427123566e1f196a459184313c0e93ba169a
parent e4a1a56dbdc1a2636b544378da890335dbafcf93
Author: HJ <30-hj@users.noreply.git.pleroma.social>
Date:   Wed, 18 Dec 2024 12:19:11 +0000

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

Themes 3

See merge request pleroma/pleroma-fe!1951

Diffstat:

Achangelog.d/custom.add1+
Achangelog.d/tabs.change1+
Achangelog.d/themes3.add1+
Mpackage.json1+
Msrc/App.js3+++
Msrc/App.vue2+-
Msrc/boot/after_store.js11++++++++++-
Msrc/components/alert.style.js4++++
Msrc/components/attachment/attachment.style.js1+
Msrc/components/border.style.js2+-
Msrc/components/button.style.js47++++++++++++++++++++++++++++++++---------------
Msrc/components/button_unstyled.style.js1+
Msrc/components/color_input/color_input.scss4++++
Msrc/components/color_input/color_input.vue14++++++++++----
Msrc/components/component_preview/component_preview.vue316+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Msrc/components/contrast_ratio/contrast_ratio.vue41++++++++++++++++++++++++++---------------
Msrc/components/icon.style.js2+-
Msrc/components/input.style.js27++++++++++++++++-----------
Msrc/components/menu_item.style.js6+++---
Msrc/components/modal/modals.style.js3++-
Msrc/components/opacity_input/opacity_input.vue4++--
Asrc/components/palette_editor/palette_editor.vue192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/rich_content/rich_content.style.js1+
Msrc/components/root.style.js3++-
Asrc/components/roundness_input/roundness_input.vue51+++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/scrollbar.style.js3++-
Msrc/components/scrollbar_element.style.js3++-
Msrc/components/select/select.vue1+
Asrc/components/select/select_motion.vue136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/settings_modal/helpers/setting.js24++++++++++++++++++++----
Msrc/components/settings_modal/helpers/string_setting.vue2++
Msrc/components/settings_modal/helpers/unit_setting.vue54++++++++++++++++++++++++++++++------------------------
Msrc/components/settings_modal/settings_modal.scss25+++++++++++++++++++++----
Msrc/components/settings_modal/settings_modal_admin_content.scss3+++
Msrc/components/settings_modal/settings_modal_user_content.js10++++++++++
Msrc/components/settings_modal/settings_modal_user_content.scss19++++++++++++++++++-
Msrc/components/settings_modal/settings_modal_user_content.vue11++++++++++-
Msrc/components/settings_modal/tabs/appearance_tab.js311+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Asrc/components/settings_modal/tabs/appearance_tab.scss120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/settings_modal/tabs/appearance_tab.vue209+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Asrc/components/settings_modal/tabs/style_tab/style_tab.js835+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/settings_modal/tabs/style_tab/style_tab.scss264+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/settings_modal/tabs/style_tab/style_tab.vue383+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/settings_modal/tabs/style_tab/virtual_directives_tab.js132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/settings_modal/tabs/style_tab/virtual_directives_tab.vue83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/settings_modal/tabs/theme_tab/theme_tab.js66++++++++++++++++++++++++------------------------------------------
Msrc/components/settings_modal/tabs/theme_tab/theme_tab.scss8++++++--
Msrc/components/settings_modal/tabs/theme_tab/theme_tab.vue6++++--
Msrc/components/shadow_control/shadow_control.js172++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Msrc/components/shadow_control/shadow_control.scss61+++++++++++++++++++++++++++++++++++++++----------------------
Msrc/components/shadow_control/shadow_control.vue400++++++++++++++++++++++++++++++++++++++-----------------------------------------
Msrc/components/tab_switcher/tab.style.js10+++++-----
Msrc/components/tab_switcher/tab_switcher.scss13++++++++++++-
Asrc/components/tooltip/tooltip.vue24++++++++++++++++++++++++
Msrc/components/user_card/user_card.style.js3++-
Msrc/i18n/en.json78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/modules/config.js4++++
Msrc/modules/instance.js5+++++
Msrc/modules/interface.js567+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Msrc/services/export_import/export_import.js25++++++++++++++++++-------
Msrc/services/style_setter/style_setter.js178+++++++++++++++++++++++++++++++++++--------------------------------------------
Msrc/services/theme_data/css_utils.js37++++++++++---------------------------
Msrc/services/theme_data/iss_deserializer.js35++++++++++++++++++++++++-----------
Msrc/services/theme_data/iss_serializer.js9+++++++--
Msrc/services/theme_data/iss_utils.js83++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Msrc/services/theme_data/theme2_to_theme3.js4----
Msrc/services/theme_data/theme3_slot_functions.js49++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/services/theme_data/theme_data_3.service.js492++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Astatic/.gitignore1+
Mstatic/config.json4+++-
Astatic/palettes/index.json32++++++++++++++++++++++++++++++++
Mstatic/styles.json6------
Astatic/styles/Breezy DX.piss80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/styles/Redmond DX.piss169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astatic/styles/index.json4++++
Myarn.lock5+++++
76 files changed, 4794 insertions(+), 1203 deletions(-)

diff --git a/changelog.d/custom.add b/changelog.d/custom.add @@ -0,0 +1 @@ +Added support for fetching /{resource}.custom.ext to allow adding instance-specific themes without altering sourcetree diff --git a/changelog.d/tabs.change b/changelog.d/tabs.change @@ -0,0 +1 @@ +Tabs now have indentation for better visibility of which tab is currently active diff --git a/changelog.d/themes3.add b/changelog.d/themes3.add @@ -0,0 +1 @@ +UI for making v3 themes and palettes, support for bundling v3 themes diff --git a/package.json b/package.json @@ -35,6 +35,7 @@ "hash-sum": "^2.0.0", "js-cookie": "3.0.5", "localforage": "1.10.0", + "pako": "^2.1.0", "parse-link-header": "2.0.0", "phoenix": "1.7.7", "punycode.js": "2.3.0", diff --git a/src/App.js b/src/App.js @@ -67,6 +67,9 @@ export default { themeApplied () { return this.$store.state.interface.themeApplied }, + layoutModalClass () { + return '-' + this.layoutType + }, classes () { return [ { diff --git a/src/App.vue b/src/App.vue @@ -70,7 +70,7 @@ <PostStatusModal /> <EditStatusModal v-if="editingAvailable" /> <StatusHistoryModal v-if="editingAvailable" /> - <SettingsModal /> + <SettingsModal :class="layoutModalClass"/> <UpdateNotification /> <GlobalNoticeList /> </div> diff --git a/src/boot/after_store.js b/src/boot/after_store.js @@ -123,6 +123,8 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => { } copyInstanceOption('theme') + copyInstanceOption('style') + copyInstanceOption('palette') copyInstanceOption('nsfwCensorImage') copyInstanceOption('background') copyInstanceOption('hidePostStats') @@ -351,7 +353,7 @@ const afterStoreSetup = async ({ store, i18n }) => { await setConfig({ store }) document.querySelector('#status').textContent = i18n.global.t('splash.theme') try { - await store.dispatch('setTheme').catch((e) => { console.error('Error setting theme', e) }) + await store.dispatch('applyTheme').catch((e) => { console.error('Error setting theme', e) }) } catch (e) { return Promise.reject(e) } @@ -391,6 +393,13 @@ const afterStoreSetup = async ({ store, i18n }) => { app.use(store) app.use(i18n) + // Little thing to get out of invalid theme state + window.resetThemes = () => { + store.dispatch('resetThemeV3') + store.dispatch('resetThemeV3Palette') + store.dispatch('resetThemeV2') + } + app.use(vClickOutside) app.use(VBodyScrollLock) app.use(VueVirtualScroller) diff --git a/src/components/alert.style.js b/src/components/alert.style.js @@ -14,6 +14,10 @@ export default { warning: '.warning', success: '.success' }, + editor: { + border: 1, + aspect: '3 / 1' + }, defaultRules: [ { directives: { diff --git a/src/components/attachment/attachment.style.js b/src/components/attachment/attachment.style.js @@ -1,6 +1,7 @@ export default { name: 'Attachment', selector: '.Attachment', + notEditable: true, validInnerComponents: [ 'Border', 'ButtonUnstyled', diff --git a/src/components/border.style.js b/src/components/border.style.js @@ -5,7 +5,7 @@ export default { defaultRules: [ { directives: { - textColor: '$mod(--parent, 10)', + textColor: '$mod(--parent 10)', textAuto: 'no-auto' } } diff --git a/src/components/button.style.js b/src/components/button.style.js @@ -9,9 +9,9 @@ export default { // 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 toggled: '.toggled', - pressed: ':active', + focused: ':focus-visible', + pressed: ':focus:active', hover: ':hover:not(:disabled)', - focused: ':focus-within', disabled: ':disabled' }, // 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. // 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). validInnerComponents: [ 'Text', @@ -32,10 +35,11 @@ export default { { component: 'Root', directives: { - '--defaultButtonHoverGlow': 'shadow | 0 0 4 --text', - '--defaultButtonShadow': 'shadow | 0 0 2 #000000', - '--defaultButtonBevel': 'shadow | $borderSide(#FFFFFF, top, 0.2), $borderSide(#000000, bottom, 0.2)', - '--pressedButtonBevel': 'shadow | $borderSide(#FFFFFF, bottom, 0.2), $borderSide(#000000, top, 0.2)' + '--buttonDefaultHoverGlow': 'shadow | 0 0 4 --text / 0.5', + '--buttonDefaultFocusGlow': 'shadow | 0 0 4 4 --link / 0.5', + '--buttonDefaultShadow': 'shadow | 0 0 2 #000000', + '--buttonDefaultBevel': 'shadow | $borderSide(#FFFFFF top 0.2 2), $borderSide(#000000 bottom 0.2 2)', + '--buttonPressedBevel': 'shadow | $borderSide(#FFFFFF bottom 0.2 2), $borderSide(#000000 top 0.2 2)' } }, { @@ -43,47 +47,60 @@ export default { // like within it directives: { background: '--fg', - shadow: ['--defaultButtonShadow', '--defaultButtonBevel'], + shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel'], roundness: 3 } }, { state: ['hover'], directives: { - shadow: ['--defaultButtonHoverGlow', '--defaultButtonBevel'] + shadow: ['--buttonDefaultHoverGlow', '--buttonDefaultBevel'] + } + }, + { + state: ['focused'], + directives: { + shadow: ['--buttonDefaultFocusGlow', '--buttonDefaultBevel'] } }, { state: ['pressed'], directives: { - shadow: ['--defaultButtonShadow', '--pressedButtonBevel'] + shadow: ['--buttonDefaultShadow', '--buttonPressedBevel'] } }, { - state: ['hover', 'pressed'], + state: ['pressed', 'hover'], directives: { - shadow: ['--defaultButtonHoverGlow', '--pressedButtonBevel'] + shadow: ['--buttonPressedBevel', '--buttonDefaultHoverGlow'] } }, { state: ['toggled'], directives: { background: '--inheritedBackground,-14.2', - shadow: ['--defaultButtonShadow', '--pressedButtonBevel'] + shadow: ['--buttonDefaultShadow', '--buttonPressedBevel'] } }, { state: ['toggled', 'hover'], directives: { background: '--inheritedBackground,-14.2', - shadow: ['--defaultButtonHoverGlow', '--pressedButtonBevel'] + shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'] + } + }, + { + state: ['toggled', 'disabled'], + directives: { + background: '$blend(--inheritedBackground 0.25 --parent)', + shadow: ['--buttonPressedBevel'] } }, { state: ['disabled'], directives: { - background: '$blend(--inheritedBackground, 0.25, --parent)', - shadow: ['--defaultButtonBevel'] + background: '$blend(--inheritedBackground 0.25 --parent)', + shadow: ['--buttonDefaultBevel'] } }, { diff --git a/src/components/button_unstyled.style.js b/src/components/button_unstyled.style.js @@ -1,6 +1,7 @@ export default { name: 'ButtonUnstyled', selector: '.button-unstyled', + notEditable: true, states: { toggled: '.toggled', disabled: ':disabled', diff --git a/src/components/color_input/color_input.scss b/src/components/color_input/color_input.scss @@ -5,6 +5,10 @@ flex: 1 1 auto; } + .opt { + margin-right: 0.5em; + } + &-field.input { display: inline-flex; flex: 0 0 0; diff --git a/src/components/color_input/color_input.vue b/src/components/color_input/color_input.vue @@ -11,11 +11,11 @@ {{ label }} </label> <Checkbox - v-if="typeof fallback !== 'undefined' && showOptionalTickbox" + v-if="typeof fallback !== 'undefined' && showOptionalCheckbox && !hideOptionalCheckbox" :model-value="present" :disabled="disabled" class="opt" - @update:modelValue="update(typeof modelValue === 'undefined' ? fallback : undefined)" + @update:modelValue="updateValue(typeof modelValue === 'undefined' ? fallback : undefined)" /> <div class="input color-input-field" @@ -112,10 +112,16 @@ export default { default: false }, // Show "optional" tickbox, for when value might become mandatory - showOptionalTickbox: { + showOptionalCheckbox: { required: false, type: Boolean, default: true + }, + // Force "optional" tickbox to hide + hideOptionalCheckbox: { + required: false, + type: Boolean, + default: false } }, emits: ['update:modelValue'], @@ -130,7 +136,7 @@ export default { return this.modelValue === 'transparent' }, computedColor () { - return this.modelValue && this.modelValue.startsWith('--') + return this.modelValue && (this.modelValue.startsWith('--') || this.modelValue.startsWith('$')) } }, methods: { diff --git a/src/components/component_preview/component_preview.vue b/src/components/component_preview/component_preview.vue @@ -1,88 +1,190 @@ <template> -<div - class="ComponentPreview" - :class="{ '-shadow-controls': shadowControl }" -> - <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 - class="preview-window" - :class="{ '-light-grid': lightGrid }" + class="ComponentPreview" + :class="{ '-shadow-controls': shadowControl }" > - <div - class="preview-block" - :style="previewStyle" + <!-- 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 --> + <label + v-show="shadowControl" + role="heading" + class="header" + :class="{ faint: disabled }" + > + {{ $t('settings.style.shadows.offset') }} + </label> + <label + v-show="shadowControl && !hideControls" + class="x-shift-number" + > + {{ $t('settings.style.shadows.offset-x') }} + <input + :value="shadow?.x" + :disabled="disabled" + :class="{ disabled }" + class="input input-number" + type="number" + @input="e => updateProperty('x', e.target.value)" + > + </label> + <label + class="y-shift-number" + v-show="shadowControl && !hideControls" + > + {{ $t('settings.style.shadows.offset-y') }} + <input + :value="shadow?.y" + :disabled="disabled" + :class="{ disabled }" + class="input input-number" + type="number" + @input="e => updateProperty('y', e.target.value)" + > + </label> + <input + v-show="shadowControl && !hideControls" + :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)" + > + <input + v-show="shadowControl && !hideControls" + :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 + class="preview-window" + :class="{ '-light-grid': lightGrid }" + > + <div + class="preview-block" + :class="previewClass" + :style="style" + > + {{ $t('settings.style.themes3.editor.test_string') }} + </div> + <div v-if="invalid" class="invalid-container"> + <div class="alert error invalid-label"> + {{ $t('settings.style.themes3.editor.invalid') }} + </div> + </div> + </div> + <div class="assists"> + <Checkbox + v-model="lightGrid" + name="lightGrid" + class="input-light-grid" + > + {{ $t('settings.style.shadows.light_grid') }} + </Checkbox> + <div class="style-control"> + <label class="label"> + {{ $t('settings.style.shadows.zoom') }} + </label> + <input + v-model="zoom" + class="input input-number y-shift-number" + type="number" + > + </div> + <ColorInput + v-if="!noColorControl" + class="input-color-input" + v-model="colorOverride" + fallback="#606060" + :label="$t('settings.style.shadows.color_override')" + /> + </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> +<script> +import Checkbox from 'src/components/checkbox/checkbox.vue' +import ColorInput from 'src/components/color_input/color_input.vue' + +export default { + components: { + Checkbox, + ColorInput + }, + props: [ + 'shadow', + 'shadowControl', + 'previewClass', + 'previewStyle', + 'previewCss', + 'disabled', + 'invalid', + 'noColorControl' + ], + emits: ['update:shadow'], + data () { + return { + colorOverride: undefined, + lightGrid: false, + zoom: 100 + } + }, + computed: { + style () { + const result = [ + this.previewStyle, + `zoom: ${this.zoom / 100}` + ] + if (this.colorOverride) result.push(`--background: ${this.colorOverride}`) + return result + }, + hideControls () { + return typeof this.shadow === 'string' + } + }, + methods: { + updateProperty (axis, value) { + this.$emit('update:shadow', { axis, value: Number(value) }) + } + } +} +</script> <style lang="scss"> .ComponentPreview { display: grid; - grid-template-columns: 3em 1fr 3em; - grid-template-rows: 2em 1fr 2em; + grid-template-columns: 1em 1fr 1fr 1em; + grid-template-rows: 2em 1fr 1fr 1fr 1em 2em max-content; grid-template-areas: - ". header y-num " - ". preview y-slide" - "x-num x-slide . " - "options options options"; + "header header header header " + "preview preview preview y-slide" + "preview preview preview y-slide" + "preview preview preview y-slide" + "x-slide x-slide x-slide . " + "x-num x-num y-num y-num " + "assists assists assists assists"; grid-gap: 0.5em; + &:not(.-shadow-controls) { + grid-template-areas: + "header header header header " + "preview preview preview y-slide" + "preview preview preview y-slide" + "preview preview preview y-slide" + "assists assists assists assists"; + grid-template-rows: 2em 1fr 1fr 1fr max-content; + } + .header { grid-area: header; justify-self: center; @@ -90,8 +192,31 @@ line-height: 2; } + .invalid-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: grid; + align-items: center; + justify-items: center; + background-color: rgba(100 0 0 / 50%); + + .alert { + padding: 0.5em 1em; + } + } + + .assists { + grid-area: assists; + display: grid; + grid-auto-flow: rows; + grid-auto-rows: 2em; + grid-gap: 0.5em; + } + .input-light-grid { - grid-area: options; justify-self: center; } @@ -101,6 +226,19 @@ .x-shift-number { grid-area: x-num; + justify-self: right; + } + + .y-shift-number { + grid-area: y-num; + justify-self: left; + } + + .x-shift-number, + .y-shift-number { + input { + max-width: 4em; + } } .x-shift-slider { @@ -110,10 +248,6 @@ min-width: 10em; } - .y-shift-number { - grid-area: y-num; - } - .y-shift-slider { grid-area: y-slide; writing-mode: vertical-lr; @@ -139,6 +273,7 @@ --__grid-color2-disabled: rgba(255 255 255 / 20%); } + position: relative; grid-area: preview; aspect-ratio: 1; display: flex; @@ -183,30 +318,3 @@ } } </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> diff --git a/src/components/contrast_ratio/contrast_ratio.vue b/src/components/contrast_ratio/contrast_ratio.vue @@ -3,39 +3,44 @@ v-if="contrast" class="contrast-ratio" > - <span - :title="hint" + <span v-if="showRatio"> + {{ contrast.text }} + </span> + <Tooltip + :text="hint" class="rating" > <span v-if="contrast.aaa"> - <FAIcon icon="thumbs-up" /> + <FAIcon icon="thumbs-up" :size="showRatio ? 'lg' : ''" /> </span> <span v-if="!contrast.aaa && contrast.aa"> - <FAIcon icon="adjust" /> + <FAIcon icon="adjust" :size="showRatio ? 'lg' : ''" /> </span> <span v-if="!contrast.aaa && !contrast.aa"> - <FAIcon icon="exclamation-triangle" /> + <FAIcon icon="exclamation-triangle" :size="showRatio ? 'lg' : ''" /> </span> - </span> - <span + </Tooltip> + <Tooltip v-if="contrast && large" + :text="hint_18pt" class="rating" - :title="hint_18pt" > <span v-if="contrast.laaa"> - <FAIcon icon="thumbs-up" /> + <FAIcon icon="thumbs-up" :size="showRatio ? 'large' : ''" /> </span> <span v-if="!contrast.laaa && contrast.laa"> - <FAIcon icon="adjust" /> + <FAIcon icon="adjust" :size="showRatio ? 'lg' : ''" /> </span> <span v-if="!contrast.laaa && !contrast.laa"> - <FAIcon icon="exclamation-triangle" /> + <FAIcon icon="exclamation-triangle" :size="showRatio ? 'lg' : ''" /> </span> - </span> + </Tooltip> </span> </template> <script> +import Tooltip from 'src/components/tooltip/tooltip.vue' + import { library } from '@fortawesome/fontawesome-svg-core' import { faAdjust, @@ -62,8 +67,16 @@ export default { required: false, type: Object, default: () => ({}) + }, + showRatio: { + required: false, + type: Boolean, + default: false } }, + components: { + Tooltip + }, computed: { hint () { const levelVal = this.contrast.aaa ? 'aaa' : (this.contrast.aa ? 'aa' : 'bad') @@ -87,8 +100,7 @@ export default { .contrast-ratio { display: flex; justify-content: flex-end; - margin-top: -4px; - margin-bottom: 5px; + align-items: baseline; .label { margin-right: 1em; @@ -96,7 +108,6 @@ export default { .rating { display: inline-block; - text-align: center; margin-left: 0.5em; } } diff --git a/src/components/icon.style.js b/src/components/icon.style.js @@ -6,7 +6,7 @@ export default { { component: 'Icon', directives: { - textColor: '$blend(--stack, 0.5, --parent--text)', + textColor: '$blend(--stack 0.5 --parent--text)', textAuto: 'no-auto' } } diff --git a/src/components/input.style.js b/src/components/input.style.js @@ -1,12 +1,3 @@ -const hoverGlow = { - x: 0, - y: 0, - blur: 4, - spread: 0, - color: '--text', - alpha: 1 -} - export default { name: 'Input', selector: '.input', @@ -27,7 +18,9 @@ export default { { component: 'Root', 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'], directives: { - shadow: [hoverGlow, '--defaultInputBevel'] + shadow: ['--defaultInputHoverGlow', '--defaultInputBevel'] + } + }, + { + state: ['focused'], + directives: { + shadow: ['--defaultInputFocusGlow', '--defaultInputBevel'] + } + }, + { + state: ['focused', 'hover'], + directives: { + shadow: ['--defaultInputFocusGlow', '--defaultInputHoverGlow', '--defaultInputBevel'] } }, { diff --git a/src/components/menu_item.style.js b/src/components/menu_item.style.js @@ -24,21 +24,21 @@ export default { { state: ['hover'], directives: { - background: '$mod(--bg, 5)', + background: '$mod(--bg 5)', opacity: 1 } }, { state: ['active'], directives: { - background: '$mod(--bg, 10)', + background: '$mod(--bg 10)', opacity: 1 } }, { state: ['active', 'hover'], directives: { - background: '$mod(--bg, 15)', + background: '$mod(--bg 15)', opacity: 1 } }, diff --git a/src/components/modal/modals.style.js b/src/components/modal/modals.style.js @@ -1,7 +1,8 @@ export default { name: 'Modals', - selector: '.modal-view', + selector: ['.modal-view', '#modal', '.shout-panel'], lazy: true, + notEditable: true, validInnerComponents: [ 'Panel' ], diff --git a/src/components/opacity_input/opacity_input.vue b/src/components/opacity_input/opacity_input.vue @@ -8,7 +8,7 @@ class="label" :class="{ faint: !present || disabled }" > - {{ $t('settings.style.common.opacity') }} + {{ label }} </label> <Checkbox v-if="typeof fallback !== 'undefined'" @@ -39,7 +39,7 @@ export default { Checkbox }, props: [ - 'name', 'modelValue', 'fallback', 'disabled' + 'name', 'label', 'modelValue', 'fallback', 'disabled' ], emits: ['update:modelValue'], computed: { diff --git a/src/components/palette_editor/palette_editor.vue b/src/components/palette_editor/palette_editor.vue @@ -0,0 +1,192 @@ +<template> + <div + class="PaletteEditor" + :class="{ '-compact': compact, '-apply': apply }" + > + <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)" + /> + <button + class="btn button-default palette-import-button" + @click="importPalette" + > + <FAIcon icon="file-import" /> + {{ $t('settings.style.themes3.palette.import') }} + </button> + <button + class="btn button-default palette-export-button" + @click="exportPalette" + > + <FAIcon icon="file-export" /> + {{ $t('settings.style.themes3.palette.export') }} + </button> + <button + v-if="apply" + class="btn button-default palette-apply-button" + @click="applyPalette" + > + {{ $t('settings.style.themes3.palette.apply') }} + </button> + </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 paletteKeys = [ + 'bg', + 'fg', + 'text', + 'link', + 'accent', + 'cRed', + 'cBlue', + 'cGreen', + 'cOrange', + 'wallpaper' +] + +const props = defineProps(['modelValue', 'compact', 'apply']) +const emit = defineEmits(['update:modelValue', 'applyPalette']) +const getExportedObject = () => paletteKeys.reduce((acc, key) => { + const value = props.modelValue[key] + if (value == null) { + return acc + } else { + return { ...acc, [key]: props.modelValue[key] } + } +}, {}) + +const paletteExporter = newExporter({ + filename: 'pleroma_palette', + extension: 'json', + getExportedObject +}) +const paletteImporter = newImporter({ + accept: '.json', + onImport (parsed, filename) { + emit('update:modelValue', parsed) + } +}) + +const exportPalette = () => { + paletteExporter.exportData() +} + +const importPalette = () => { + paletteImporter.importData() +} + +const applyPalette = (data) => { + emit('applyPalette', getExportedObject()) +} + +const fallback = (key) => { + if (key === 'accent') { + return props.modelValue.link + } + if (key === 'link') { + return props.modelValue.accent + } + if (key.startsWith('extra')) { + return '#FF00FF' + } + if (key.startsWith('wallpaper')) { + return '#008080' + } +} + +const updatePalette = (paletteKey, value) => { + emit('update:modelValue', { + ...props.modelValue, + [paletteKey]: value + }) +} +</script> + +<style lang="scss"> +.PaletteEditor { + display: grid; + justify-content: space-around; + grid-template-columns: repeat(4, 1fr); + grid-template-rows: repeat(5, 1fr) auto; + grid-gap: 0.5em; + align-items: baseline; + + .palette-import-button { + grid-column: 1 / span 2; + } + + .palette-export-button { + grid-column: 3 / span 2; + } + + .palette-apply-button { + grid-column: 1 / span 2; + } + + .color-input.style-control { + margin: 0; + } + + &.-compact { + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(5, 1fr) auto; + + .palette-import-button { + grid-column: 1; + } + + .palette-export-button { + grid-column: 2; + } + + &.-apply { + grid-template-rows: repeat(5, 1fr) auto auto; + + .palette-apply-button { + grid-column: 1 / span 2; + } + } + + .-mobile & { + grid-template-columns: 1fr; + grid-template-rows: repeat(10, 1fr) auto; + + .palette-import-button { + grid-column: 1; + } + + .palette-export-button { + grid-column: 1; + } + + &.-apply { + .palette-apply-button { + grid-column: 1; + } + } + } + } +} +</style> diff --git a/src/components/rich_content/rich_content.style.js b/src/components/rich_content/rich_content.style.js @@ -1,6 +1,7 @@ export default { name: 'RichContent', selector: '.RichContent', + notEditable: true, validInnerComponents: [ 'Text', 'FunText', diff --git a/src/components/root.style.js b/src/components/root.style.js @@ -1,6 +1,7 @@ export default { name: 'Root', selector: ':root', + notEditable: true, validInnerComponents: [ 'Underlay', 'Modals', @@ -42,7 +43,7 @@ export default { // Selection colors '--selectionBackground': 'color | --accent', - '--selectionText': 'color | $textColor(--accent, --text, no-preserve)' + '--selectionText': 'color | $textColor(--accent --text no-preserve)' } } ] diff --git a/src/components/roundness_input/roundness_input.vue b/src/components/roundness_input/roundness_input.vue @@ -0,0 +1,51 @@ +<template> + <div + class="roundness-control style-control" + :class="{ disabled: !present || disabled }" + > + <label + :for="name" + class="label" + :class="{ faint: !present || disabled }" + > + {{ label }} + </label> + <Checkbox + v-if="typeof fallback !== 'undefined'" + :model-value="present" + :disabled="disabled" + class="opt" + @update:modelValue="$emit('update:modelValue', !present ? fallback : undefined)" + /> + <input + :id="name" + class="input input-number" + type="number" + :value="modelValue || fallback" + :disabled="!present || disabled" + :class="{ disabled: !present || disabled }" + max="999" + min="0" + step="1" + @input="$emit('update:modelValue', $event.target.value)" + > + </div> +</template> + +<script> +import Checkbox from '../checkbox/checkbox.vue' +export default { + components: { + Checkbox + }, + props: [ + 'name', 'label', 'modelValue', 'fallback', 'disabled' + ], + emits: ['update:modelValue'], + computed: { + present () { + return typeof this.modelValue !== 'undefined' + } + } +} +</script> diff --git a/src/components/scrollbar.style.js b/src/components/scrollbar.style.js @@ -1,6 +1,7 @@ export default { name: 'Scrollbar', - selector: '::-webkit-scrollbar', + selector: ['::-webkit-scrollbar-button', '::-webkit-scrollbar-thumb', '::-webkit-resizer'], + notEditable: true, // for now defaultRules: [ { directives: { diff --git a/src/components/scrollbar_element.style.js b/src/components/scrollbar_element.style.js @@ -31,6 +31,7 @@ const hoverGlow = { export default { name: 'ScrollbarElement', selector: '::-webkit-scrollbar-button', + notEditable: true, // for now states: { pressed: ':active', hover: ':hover:not(:disabled)', @@ -82,7 +83,7 @@ export default { { state: ['disabled'], directives: { - background: '$blend(--inheritedBackground, 0.25, --parent)', + background: '$blend(--inheritedBackground 0.25 --parent)', shadow: [...buttonInsetFakeBorders] } }, diff --git a/src/components/select/select.vue b/src/components/select/select.vue @@ -49,6 +49,7 @@ label.Select { option { background-color: transparent; + &:checked, &.-active { color: var(--selectionText); background-color: var(--selectionBackground); diff --git a/src/components/select/select_motion.vue b/src/components/select/select_motion.vue @@ -0,0 +1,136 @@ +<template> + <div + class="SelectMotion btn-group" + > + <button + class="btn button-default" + :disabled="disabled" + @click="add" + > + <FAIcon + fixed-width + icon="plus" + /> + </button> + <button + class="btn button-default" + :disabled="disabled || !moveUpValid" + :class="{ disabled: disabled || !moveUpValid }" + @click="moveUp" + > + <FAIcon + fixed-width + icon="chevron-up" + /> + </button> + <button + class="btn button-default" + :disabled="disabled || !moveDnValid" + :class="{ disabled: disabled || !moveDnValid }" + @click="moveDn" + > + <FAIcon + fixed-width + icon="chevron-down" + /> + </button> + <button + class="btn button-default" + :disabled="disabled || !present" + :class="{ disabled: disabled || !present }" + @click="del" + > + <FAIcon + fixed-width + icon="times" + /> + </button> + </div> +</template> + +<script setup> +import { computed, defineEmits, defineProps, nextTick } from 'vue' + +const props = defineProps({ + modelValue: { + type: Array, + required: true + }, + selectedId: { + type: Number, + required: true + }, + disabled: { + type: Boolean, + default: false + }, + getAddValue: { + type: Function, + required: true + } +}) + +const emit = defineEmits(['update:modelValue', 'update:selectedId']) + +const moveUpValid = computed(() => { + return props.selectedId > 0 +}) + +const present = computed(() => props.modelValue[props.selectedId] != null) + +const moveUp = async () => { + const newModel = [...props.modelValue] + const movable = newModel.splice(props.selectedId, 1)[0] + newModel.splice(props.selectedId - 1, 0, movable) + + emit('update:modelValue', newModel) + await nextTick() + emit('update:selectedId', props.selectedId - 1) +} + +const moveDnValid = computed(() => { + return props.selectedId < props.modelValue.length - 1 +}) + +const moveDn = async () => { + const newModel = [...props.modelValue] + const movable = newModel.splice(props.selectedId.value, 1)[0] + newModel.splice(props.selectedId + 1, 0, movable) + + emit('update:modelValue', newModel) + await nextTick() + emit('update:selectedId', props.selectedId + 1) +} + +const add = async () => { + const newModel = [...props.modelValue, props.getAddValue()] + + emit('update:modelValue', newModel) + await nextTick() + emit('update:selectedId', Math.max(newModel.length - 1, 0)) +} + +const del = async () => { + const newModel = [...props.modelValue] + newModel.splice(props.selectedId, 1) + + emit('update:modelValue', newModel) + await nextTick() + emit('update:selectedId', newModel.length === 0 ? undefined : Math.max(props.selectedId - 1, 0)) +} +</script> + +<style lang="scss"> +.SelectMotion { + flex: 0 0 auto; + display: grid; + grid-auto-columns: 1fr; + grid-auto-flow: column; + margin-top: 0.25em; + + .button-default { + margin: 0; + padding: 0; + } +} +</style> diff --git a/src/components/settings_modal/helpers/setting.js b/src/components/settings_modal/helpers/setting.js @@ -10,9 +10,13 @@ export default { ProfileSettingIndicator }, props: { + modelValue: { + type: String, + default: null + }, path: { type: [String, Array], - required: true + required: false }, disabled: { type: Boolean, @@ -68,7 +72,7 @@ export default { } }, created () { - if (this.realDraftMode && this.realSource !== 'admin') { + if (this.realDraftMode && (this.realSource !== 'admin' || this.path == null)) { this.draft = this.state } }, @@ -76,14 +80,14 @@ export default { draft: { // TODO allow passing shared draft object? get () { - if (this.realSource === 'admin') { + if (this.realSource === 'admin' || this.path == null) { return get(this.$store.state.adminSettings.draft, this.canonPath) } else { return this.localDraft } }, set (value) { - if (this.realSource === 'admin') { + if (this.realSource === 'admin' || this.path == null) { this.$store.commit('updateAdminDraft', { path: this.canonPath, value }) } else { this.localDraft = value @@ -91,6 +95,9 @@ export default { } }, state () { + if (this.path == null) { + return this.modelValue + } const value = get(this.configSource, this.canonPath) if (value === undefined) { return this.defaultState @@ -145,6 +152,9 @@ export default { return this.backendDescription?.suggestions }, shouldBeDisabled () { + if (this.path == null) { + return this.disabled + } const parentValue = this.parentPath !== undefined ? get(this.configSource, this.parentPath) : null return this.disabled || (parentValue !== null ? (this.parentInvert ? parentValue : !parentValue) : false) }, @@ -159,6 +169,9 @@ export default { } }, configSink () { + if (this.path == null) { + return (k, v) => this.$emit('update:modelValue', v) + } switch (this.realSource) { case 'profile': return (k, v) => this.$store.dispatch('setProfileOption', { name: k, value: v }) @@ -184,6 +197,7 @@ export default { return this.realSource === 'profile' }, isChanged () { + if (this.path == null) return false switch (this.realSource) { case 'profile': case 'admin': @@ -193,9 +207,11 @@ export default { } }, canonPath () { + if (this.path == null) return null return Array.isArray(this.path) ? this.path : this.path.split('.') }, isDirty () { + if (this.path == null) return false if (this.realSource === 'admin' && this.canonPath.length > 3) { return false // should not show draft buttons for "grouped" values } else { diff --git a/src/components/settings_modal/helpers/string_setting.vue b/src/components/settings_modal/helpers/string_setting.vue @@ -5,6 +5,7 @@ > <label :for="path" + class="setting-label" :class="{ 'faint': shouldBeDisabled }" > <template v-if="backendDescriptionLabel"> @@ -15,6 +16,7 @@ </template> <slot v-else /> </label> + {{ ' ' }} <input :id="path" class="input string-input" diff --git a/src/components/settings_modal/helpers/unit_setting.vue b/src/components/settings_modal/helpers/unit_setting.vue @@ -10,31 +10,33 @@ <slot /> </label> {{ ' ' }} - <input - :id="path" - class="input number-input" - type="number" - :step="step" - :disabled="disabled" - :min="min || 0" - :value="stateValue" - @change="updateValue" - > - <Select - :id="path" - :model-value="stateUnit" - :disabled="disabled" - class="unit-input unstyled" - @change="updateUnit" - > - <option - v-for="option in units" - :key="option" - :value="option" + <span class="no-break"> + <input + :id="path" + class="input number-input" + type="number" + :step="step" + :disabled="disabled" + :min="min || 0" + :value="stateValue" + @change="updateValue" + > + <Select + :id="path" + :model-value="stateUnit" + :disabled="disabled" + class="unit-input unstyled" + @change="updateUnit" > - {{ getUnitString(option) }} - </option> - </Select> + <option + v-for="option in units" + :key="option" + :value="option" + > + {{ getUnitString(option) }} + </option> + </Select> + </span> {{ ' ' }} <ModifiedIndicator :changed="isChanged" @@ -47,6 +49,10 @@ <style lang="scss"> .UnitSetting { + .no-break { + display: inline-block; + } + .number-input { max-width: 6.5em; text-align: right; diff --git a/src/components/settings_modal/settings_modal.scss b/src/components/settings_modal/settings_modal.scss @@ -10,6 +10,10 @@ list-style-type: none; padding-left: 2em; + .btn:not(.dropdown-button) { + padding: 0 2em; + } + li { margin-bottom: 0.5em; } @@ -54,10 +58,6 @@ .btn { min-height: 2em; } - - .btn:not(.dropdown-button) { - padding: 0 2em; - } } } @@ -76,6 +76,23 @@ } } + &.-mobile { + .setting-list, + .option-list { + padding-left: 0.25em; + + > li { + margin: 1em 0; + line-height: 1.5em; + vertical-align: center; + } + + &.two-column { + column-count: 1; + } + } + } + &.peek { .settings-modal-panel { /* Explanation: diff --git a/src/components/settings_modal/settings_modal_admin_content.scss b/src/components/settings_modal/settings_modal_admin_content.scss @@ -17,10 +17,13 @@ } .select-multiple { + margin-top: 0.5em; display: flex; + flex-direction: column; .option-list { margin: 0; + margin-top: 0.5em; padding-left: 0.5em; } } diff --git a/src/components/settings_modal/settings_modal_user_content.js b/src/components/settings_modal/settings_modal_user_content.js @@ -10,6 +10,7 @@ import GeneralTab from './tabs/general_tab.vue' import AppearanceTab from './tabs/appearance_tab.vue' import VersionTab from './tabs/version_tab.vue' import ThemeTab from './tabs/theme_tab/theme_tab.vue' +import StyleTab from './tabs/style_tab/style_tab.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -17,6 +18,7 @@ import { faUser, faFilter, faPaintBrush, + faPalette, faBell, faDownload, faEyeSlash, @@ -29,6 +31,7 @@ library.add( faUser, faFilter, faPaintBrush, + faPalette, faBell, faDownload, faEyeSlash, @@ -48,6 +51,7 @@ const SettingsModalContent = { ProfileTab, GeneralTab, AppearanceTab, + StyleTab, VersionTab, ThemeTab }, @@ -60,6 +64,12 @@ const SettingsModalContent = { }, bodyLock () { return this.$store.state.interface.settingsModalState === 'visible' + }, + expertLevel () { + return this.$store.state.config.expertLevel + }, + isMobileLayout () { + return this.$store.state.interface.layoutType === 'mobile' } }, methods: { diff --git a/src/components/settings_modal/settings_modal_user_content.scss b/src/components/settings_modal/settings_modal_user_content.scss @@ -1,6 +1,21 @@ .settings_tab-switcher { height: 100%; + h1 { + margin-bottom: 0.5em; + margin-top: 0.5em; + } + + h4 { + margin-bottom: 0; + margin-top: 0.25em; + } + + h5 { + margin-bottom: 0; + margin-top: 0.25em; + } + .setting-item { border-bottom: 2px solid var(--border); margin: 1em 1em 1.4em; @@ -8,7 +23,6 @@ > div, > label { - display: block; margin-bottom: 0.5em; &:last-child { @@ -17,10 +31,13 @@ } .select-multiple { + margin-top: 1em; display: flex; + flex-direction: column; .option-list { margin: 0; + margin-top: 0.5em; padding-left: 0.5em; } } diff --git a/src/components/settings_modal/settings_modal_user_content.vue b/src/components/settings_modal/settings_modal_user_content.vue @@ -21,7 +21,16 @@ <AppearanceTab /> </div> <div - :label="$t('settings.theme')" + v-if="expertLevel > 0 && !isMobileLayout" + :label="$t('settings.style.themes3.editor.title')" + icon="palette" + data-tab-name="style" + > + <StyleTab /> + </div> + <div + v-if="expertLevel > 0 && !isMobileLayout" + :label="$t('settings.theme_old')" icon="paint-brush" data-tab-name="theme" > diff --git a/src/components/settings_modal/tabs/appearance_tab.js b/src/components/settings_modal/tabs/appearance_tab.js @@ -3,20 +3,20 @@ import ChoiceSetting from '../helpers/choice_setting.vue' import IntegerSetting from '../helpers/integer_setting.vue' import FloatSetting from '../helpers/float_setting.vue' import UnitSetting, { defaultHorizontalUnits } from '../helpers/unit_setting.vue' +import PaletteEditor from 'src/components/palette_editor/palette_editor.vue' import FontControl from 'src/components/font_control/font_control.vue' import { normalizeThemeData } from 'src/modules/interface' -import { - getThemes -} from 'src/services/style_setter/style_setter.js' +import { newImporter } from 'src/services/export_import/export_import.js' import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js' import { init } from 'src/services/theme_data/theme_data_3.service.js' import { getCssRules, getScopedVersion } from 'src/services/theme_data/css_utils.js' +import { deserialize } from 'src/services/theme_data/iss_deserializer.js' import SharedComputedObject from '../helpers/shared_computed_object.js' import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue' @@ -27,6 +27,10 @@ import { import Preview from './theme_tab/theme_preview.vue' +// helper for debugging +// eslint-disable-next-line no-unused-vars +const toValue = (x) => JSON.parse(JSON.stringify(x === undefined ? 'null' : x)) + library.add( faGlobe ) @@ -34,7 +38,28 @@ library.add( const AppearanceTab = { data () { return { - availableStyles: [], + availableThemesV3: [], + availableThemesV2: [], + bundledPalettes: [], + compilationCache: {}, + fileImporter: newImporter({ + accept: '.json, .piss', + validator: this.importValidator, + onImport: this.onImport, + parser: this.importParser, + onImportFailure: this.onImportFailure + }), + palettesKeys: [ + 'bg', + 'fg', + 'link', + 'text', + 'cRed', + 'cGreen', + 'cBlue', + 'cOrange' + ], + userPalette: {}, intersectionObserver: null, thirdColumnModeOptions: ['none', 'notifications', 'postform'].map(mode => ({ key: mode, @@ -61,33 +86,69 @@ const AppearanceTab = { UnitSetting, ProfileSettingIndicator, FontControl, - Preview + Preview, + PaletteEditor }, mounted () { - getThemes() - .then((promises) => { - return Promise.all( - Object.entries(promises) - .map(([k, v]) => v.then(res => [k, res])) - ) + this.$store.dispatch('getThemeData') + + const updateIndex = (resource) => { + const capitalizedResource = resource[0].toUpperCase() + resource.slice(1) + const currentIndex = this.$store.state.instance[`${resource}sIndex`] + + let promise + if (currentIndex) { + promise = Promise.resolve(currentIndex) + } else { + promise = this.$store.dispatch(`fetch${capitalizedResource}sIndex`) + } + + return promise.then(index => { + return Object + .entries(index) + .map(([k, func]) => [k, func()]) }) - .then(themes => themes.reduce((acc, [k, v]) => { - if (v) { - return [ - ...acc, - { - name: v.name || v[0], - key: k, - data: v - } - ] + } + + updateIndex('style').then(styles => { + styles.forEach(([key, stylePromise]) => stylePromise.then(data => { + const meta = data.find(x => x.component === '@meta') + this.availableThemesV3.push({ key, data, name: meta.directives.name, version: 'v3' }) + })) + }) + + updateIndex('theme').then(themes => { + themes.forEach(([key, themePromise]) => themePromise.then(data => { + this.availableThemesV2.push({ key, data, name: data.name, version: 'v2' }) + })) + }) + + this.userPalette = this.$store.state.interface.paletteDataUsed || {} + + updateIndex('palette').then(bundledPalettes => { + bundledPalettes.forEach(([key, palettePromise]) => palettePromise.then(v => { + let palette + if (Array.isArray(v)) { + const [ + name, + bg, + fg, + text, + link, + cRed = '#FF0000', + cGreen = '#00FF00', + cBlue = '#0000FF', + cOrange = '#E3FF00' + ] = v + palette = { key, name, bg, fg, text, link, cRed, cBlue, cGreen, cOrange } } else { - return acc + palette = { key, ...v } } - }, [])) - .then((themesComplete) => { - this.availableStyles = themesComplete - }) + if (!palette.key.startsWith('style.')) { + this.bundledPalettes.push(palette) + } + })) + }) if (window.IntersectionObserver) { this.intersectionObserver = new IntersectionObserver((entries, observer) => { @@ -111,7 +172,65 @@ const AppearanceTab = { }) }) }, + watch: { + paletteDataUsed () { + this.userPalette = this.paletteDataUsed || {} + } + }, computed: { + paletteDataUsed () { + return this.$store.state.interface.paletteDataUsed + }, + availableStyles () { + return [ + ...this.availableThemesV3, + ...this.availableThemesV2 + ] + }, + availablePalettes () { + return [ + ...this.bundledPalettes, + ...this.stylePalettes + ] + }, + stylePalettes () { + const ruleset = this.$store.state.interface.styleDataUsed || [] + if (!ruleset && ruleset.length === 0) return + const meta = ruleset.find(x => x.component === '@meta') + const result = ruleset.filter(x => x.component.startsWith('@palette')) + .map(x => { + const { variant, directives } = x + const { + bg, + fg, + text, + link, + accent, + cRed, + cBlue, + cGreen, + cOrange, + wallpaper + } = directives + + const result = { + name: `${meta.directives.name || this.$t('settings.style.themes3.palette.imported')}: ${variant}`, + key: `style.${variant.toLowerCase().replace(/ /g, '_')}`, + bg, + fg, + text, + link, + accent, + cRed, + cBlue, + cGreen, + cOrange, + wallpaper + } + return Object.fromEntries(Object.entries(result).filter(([k, v]) => v)) + }) + return result + }, noIntersectionObserver () { return !window.IntersectionObserver }, @@ -144,15 +263,22 @@ const AppearanceTab = { this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val }) } }, + customThemeVersion () { + const { themeVersion } = this.$store.state.interface + return themeVersion + }, isCustomThemeUsed () { - const { theme } = this.mergedConfig - return theme === 'custom' || theme === null + const { customTheme, customThemeSource } = this.mergedConfig + return customTheme != null || customThemeSource != null + }, + isCustomStyleUsed (name) { + const { styleCustomData } = this.mergedConfig + return styleCustomData != null }, ...SharedComputedObject() }, methods: { updateFont (key, value) { - console.log(key, value) this.$store.dispatch('setOption', { name: 'theme3hacks', value: { @@ -164,25 +290,120 @@ const AppearanceTab = { } }) }, + importFile () { + this.fileImporter.importData() + }, + importParser (file, filename) { + if (filename.endsWith('.json')) { + return JSON.parse(file) + } else if (filename.endsWith('.piss')) { + return deserialize(file) + } + }, + onImport (parsed, filename) { + if (filename.endsWith('.json')) { + this.$store.dispatch('setThemeCustom', parsed.source || parsed.theme) + } else if (filename.endsWith('.piss')) { + this.$store.dispatch('setStyleCustom', parsed) + } + }, + onImportFailure (result) { + console.error('Failure importing theme:', result) + this.$store.dispatch('pushGlobalNotice', { messageKey: 'settings.invalid_theme_imported', level: 'error' }) + }, + importValidator (parsed, filename) { + if (filename.endsWith('.json')) { + const version = parsed._pleroma_theme_version + return version >= 1 || version <= 2 + } else if (filename.endsWith('.piss')) { + if (!Array.isArray(parsed)) return false + if (parsed.length < 1) return false + if (parsed.find(x => x.component === '@meta') == null) return false + return true + } + }, isThemeActive (key) { - const { theme } = this.mergedConfig - return key === theme + return key === (this.mergedConfig.theme || this.$store.state.instance.theme) + }, + isStyleActive (key) { + return key === (this.mergedConfig.style || this.$store.state.instance.style) + }, + isPaletteActive (key) { + return key === (this.mergedConfig.palette || this.$store.state.instance.palette) + }, + setStyle (name) { + this.$store.dispatch('setStyle', name) }, setTheme (name) { - this.$store.dispatch('setTheme', { themeName: name, saveData: true, recompile: true }) - }, - previewTheme (key, input) { - const style = normalizeThemeData(input) - const x = 2 - if (x === 1) return - const theme2 = convertTheme2To3(style) - const theme3 = init({ - inputRuleset: theme2, - ultimateBackgroundColor: '#000000', - liteMode: true, - debug: true, - onlyNormalState: true - }) + this.$store.dispatch('setTheme', name) + }, + setPalette (name, data) { + this.$store.dispatch('setPalette', name) + this.userPalette = data + }, + setPaletteCustom (data) { + this.$store.dispatch('setPaletteCustom', data) + this.userPalette = data + }, + resetTheming (name) { + this.$store.dispatch('setStyle', 'stock') + }, + previewTheme (key, version, input) { + let theme3 + if (this.compilationCache[key]) { + theme3 = this.compilationCache[key] + } else if (input) { + if (version === 'v2') { + const style = normalizeThemeData(input) + const theme2 = convertTheme2To3(style) + theme3 = init({ + inputRuleset: theme2, + ultimateBackgroundColor: '#000000', + liteMode: true, + debug: true, + onlyNormalState: true + }) + } else if (version === 'v3') { + const palette = input.find(x => x.component === '@palette') + let paletteRule + if (palette) { + const { directives } = palette + directives.link = directives.link || directives.accent + directives.accent = directives.accent || directives.link + paletteRule = { + component: 'Root', + directives: Object.fromEntries( + Object + .entries(directives) + .filter(([k, v]) => k && k !== 'name') + .map(([k, v]) => ['--' + k, 'color | ' + v]) + ) + } + } else { + paletteRule = null + } + + theme3 = init({ + inputRuleset: [...input, paletteRule].filter(x => x), + ultimateBackgroundColor: '#000000', + liteMode: true, + debug: true, + onlyNormalState: true + }) + } + } else { + theme3 = init({ + inputRuleset: [], + ultimateBackgroundColor: '#000000', + liteMode: true, + debug: true, + onlyNormalState: true + }) + } + + if (!this.compilationCache[key]) { + this.compilationCache[key] = theme3 + } return getScopedVersion( getCssRules(theme3.eager), diff --git a/src/components/settings_modal/tabs/appearance_tab.scss b/src/components/settings_modal/tabs/appearance_tab.scss @@ -0,0 +1,120 @@ +.appearance-tab { + .palette, + .theme-notice { + padding: 0.5em; + margin: 1em; + } + + .setting-item { + padding-bottom: 0; + + &.heading { + display: grid; + align-items: baseline; + grid-template-columns: 1fr auto auto auto; + grid-gap: 0.5em; + + h2 { + flex: 1 0 auto; + } + } + } + + .palettes { + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 0.5em; + + h4, + .unsupported-theme-v2, + .userPalette { + grid-column: 1 / span 2; + } + } + + .palette-entry { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.5em; + + .palette-label label { + text-align: center; + } + + .palette-square { + flex: 0 0 auto; + display: inline-block; + min-width: 1em; + min-height: 1em; + } + } + + .column-settings { + display: flex; + justify-content: space-evenly; + flex-wrap: wrap; + } + + .column-settings .size-label { + display: block; + margin-bottom: 0.5em; + margin-top: 0.5em; + } + + .modal-view.-mobile & { + .palette-entry { + flex-wrap: wrap; + justify-content: center; + } + + .palette-label { + line-height: 1.5em; + margin-top: 0.5em; + width: 100%; + } + + .palette-preview { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-template-rows: 1em 1em; + margin-bottom: 0.5em; + } + } + + .theme-list { + list-style: none; + display: flex; + flex-wrap: wrap; + margin: -0.5em 0; + height: 25em; + overflow-x: hidden; + overflow-y: auto; + scrollbar-gutter: stable; + border-radius: var(--roundness); + border: 1px solid var(--border); + padding: 0; + margin-bottom: 1em; + + .theme-preview { + font-size: 1rem; // fix for firefox + width: 19rem; + display: flex; + flex-direction: column; + align-items: center; + margin: 0.5em; + + &.placeholder { + opacity: 0.2; + } + + .theme-preview-container { + pointer-events: none; + zoom: 0.5; + border: none; + border-radius: var(--roundness); + text-align: left; + } + } + } +} diff --git a/src/components/settings_modal/tabs/appearance_tab.vue b/src/components/settings_modal/tabs/appearance_tab.vue @@ -1,44 +1,161 @@ <template> - <div class="appearance-tab" :label="$t('settings.general')"> + <div + class="appearance-tab" + :label="$t('settings.general')" + > <div class="setting-item"> <h2>{{ $t('settings.theme') }}</h2> <ul - class="theme-list" ref="themeList" + class="theme-list" > <button + 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', 'v3')" + /> + <!-- eslint-enable vue/no-v-text-v-html-on-component --> + <preview id="theme-preview-stock" /> + <h4 class="theme-name"> + {{ $t('settings.style.stock_theme_used') }} + <span class="alert neutral version">v3</span> + </h4> + </button> + <button v-if="isCustomThemeUsed" disabled - class="button-default theme-preview" + class="button-default theme-preview toggled" + > + <preview /> + <h4 class="theme-name"> + {{ $t('settings.style.custom_theme_used') }} + <span class="alert neutral version">v2</span> + </h4> + </button> + <button + v-if="isCustomStyleUsed" + disabled + class="button-default theme-preview toggled" > <preview /> - <h4 class="theme-name">{{ $t('settings.style.custom_theme_used') }}</h4> + <h4 class="theme-name"> + {{ $t('settings.style.custom_style_used') }} + <span class="alert neutral version">v3</span> + </h4> </button> <button v-for="style in availableStyles" - :data-theme-key="style.key" :key="style.key" + :data-theme-key="style.key" class="button-default theme-preview" - :class="{ toggled: isThemeActive(style.key) }" - @click="setTheme(style.key)" + :class="{ toggled: isStyleActive(style.key) }" + @click="style.version === 'v2' ? setTheme(style.key) : setStyle(style.key)" > <!-- eslint-disable vue/no-v-text-v-html-on-component --> - <component - :is="'style'" - v-if="style.ready || noIntersectionObserver" - v-html="previewTheme(style.key, style.data)" - /> + <div v-if="style.ready || noIntersectionObserver"> + <component + :is="'style'" + v-html="previewTheme(style.key, style.version, style.data)" + /> + </div> <!-- eslint-enable vue/no-v-text-v-html-on-component --> - <preview :class="{ placeholder: ready }" :id="'theme-preview-' + style.key"/> - <h4 class="theme-name">{{ style.name }}</h4> + <preview :id="'theme-preview-' + style.key" /> + <h4 class="theme-name"> + {{ style.name }} + <span class="alert neutral version">{{ style.version }}</span> + </h4> </button> </ul> - </div> - <div class="alert neutral theme-notice"> - {{ $t("settings.style.appearance_tab_note") }} + <div class="import-file-container"> + <button + class="btn button-default" + @click="importFile" + > + <FAIcon icon="folder-open" /> + {{ $t('settings.style.themes3.editor.load_style') }} + </button> + </div> + <div class="setting-item"> + <h2>{{ $t('settings.style.themes3.palette.label') }}</h2> + <div class="palettes"> + <template v-if="customThemeVersion === 'v3'"> + <h4>{{ $t('settings.style.themes3.palette.bundled') }}</h4> + <button + v-for="p in bundledPalettes" + :key="p.name" + class="btn button-default palette-entry" + :class="{ toggled: isPaletteActive(p.key) }" + @click="() => setPalette(p.key, p)" + > + <div class="palette-label"> + <label> + {{ p.name }} + </label> + </div> + <div class="palette-preview"> + <span + v-for="c in palettesKeys" + :key="c" + class="palette-square" + :style="{ backgroundColor: p[c], border: '1px solid ' + (p[c] ?? 'var(--text)') }" + /> + </div> + </button> + <h4 v-if="stylePalettes?.length > 0"> + {{ $t('settings.style.themes3.palette.style') }} + </h4> + <button + v-for="p in stylePalettes || []" + :key="p.name" + class="btn button-default palette-entry" + :class="{ toggled: isPaletteActive(p.key) }" + @click="() => setPalette(p.key, p)" + > + <div class="palette-label"> + <label> + {{ p.name ?? $t('settings.style.themes3.palette.user') }} + </label> + </div> + <div class="palette-preview"> + <span + v-for="c in palettesKeys" + :key="c" + class="palette-square" + :style="{ backgroundColor: p[c], border: '1px solid ' + (p[c] ?? 'var(--text)') }" + /> + </div> + </button> + <h4 v-if="expertLevel > 0"> + {{ $t('settings.style.themes3.palette.user') }} + </h4> + <PaletteEditor + v-if="expertLevel > 0" + class="userPalette" + v-model="userPalette" + :compact="true" + :apply="true" + @applyPalette="data => setPaletteCustom(data)" + /> + </template> + <template v-else-if="customThemeVersion === 'v2'"> + <div class="alert neutral theme-notice unsupported-theme-v2"> + {{ $t('settings.style.themes3.palette.v2_unsupported') }} + </div> + </template> + </div> + </div> </div> <div class="setting-item"> <h2>{{ $t('settings.scale_and_layout') }}</h2> + <div class="alert neutral theme-notice"> + {{ $t("settings.style.appearance_tab_note") }} + </div> <ul class="setting-list"> <li> <UnitSetting @@ -60,7 +177,7 @@ <code>px</code> <code>rem</code> </i18n-t> - <br/> + <br> <i18n-t scope="global" keypath="settings.text_size_tip2" @@ -256,58 +373,4 @@ <script src="./appearance_tab.js"></script> -<style lang="scss"> -.appearance-tab { - .theme-notice { - padding: 0.5em; - margin: 1em; - } - - .column-settings { - display: flex; - justify-content: space-evenly; - flex-wrap: wrap; - } - - .column-settings .size-label { - display: block; - margin-bottom: 0.5em; - margin-top: 0.5em; - } - - .theme-list { - list-style: none; - display: flex; - flex-wrap: wrap; - margin: -0.5em 0; - height: 25em; - overflow-x: hidden; - overflow-y: auto; - scrollbar-gutter: stable; - border-radius: var(--roundness); - border: 1px solid var(--border); - padding: 0; - - .theme-preview { - font-size: 1rem; // fix for firefox - width: 19rem; - display: flex; - flex-direction: column; - align-items: center; - margin: 0.5em; - - &.placeholder { - opacity: 0.2; - } - - .theme-preview-container { - pointer-events: none; - zoom: 0.5; - border: none; - border-radius: var(--roundness); - text-align: left; - } - } - } -} -</style> +<style lang="scss" src="./appearance_tab.scss"></style> diff --git a/src/components/settings_modal/tabs/style_tab/style_tab.js b/src/components/settings_modal/tabs/style_tab/style_tab.js @@ -0,0 +1,835 @@ +import { ref, reactive, computed, watch, watchEffect, provide, getCurrentInstance } from 'vue' +import { useStore } from 'vuex' +import { get, set, unset, throttle } from 'lodash' + +import Select from 'src/components/select/select.vue' +import SelectMotion from 'src/components/select/select_motion.vue' +import Checkbox from 'src/components/checkbox/checkbox.vue' +import ComponentPreview from 'src/components/component_preview/component_preview.vue' +import StringSetting from '../../helpers/string_setting.vue' +import ShadowControl from 'src/components/shadow_control/shadow_control.vue' +import ColorInput from 'src/components/color_input/color_input.vue' +import PaletteEditor from 'src/components/palette_editor/palette_editor.vue' +import OpacityInput from 'src/components/opacity_input/opacity_input.vue' +import RoundnessInput from 'src/components/roundness_input/roundness_input.vue' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' +import Tooltip from 'src/components/tooltip/tooltip.vue' +import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue' +import Preview from '../theme_tab/theme_preview.vue' + +import VirtualDirectivesTab from './virtual_directives_tab.vue' + +import { init, findColor } from 'src/services/theme_data/theme_data_3.service.js' +import { + getCssRules, + getScopedVersion +} from 'src/services/theme_data/css_utils.js' +import { serialize } from 'src/services/theme_data/iss_serializer.js' +import { deserializeShadow, deserialize } from 'src/services/theme_data/iss_deserializer.js' +import { + rgb2hex, + hex2rgb, + getContrastRatio +} from 'src/services/color_convert/color_convert.js' +import { + newImporter, + newExporter +} from 'src/services/export_import/export_import.js' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faFloppyDisk, + faFolderOpen, + faFile, + faArrowsRotate, + faCheck +} from '@fortawesome/free-solid-svg-icons' + +// helper for debugging +// eslint-disable-next-line no-unused-vars +const toValue = (x) => JSON.parse(JSON.stringify(x === undefined ? 'null' : x)) + +// helper to make states comparable +const normalizeStates = (states) => ['normal', ...(states?.filter(x => x !== 'normal') || [])].join(':') + +library.add( + faFile, + faFloppyDisk, + faFolderOpen, + faArrowsRotate, + faCheck +) + +export default { + components: { + Select, + SelectMotion, + Checkbox, + Tooltip, + StringSetting, + ComponentPreview, + TabSwitcher, + ShadowControl, + ColorInput, + PaletteEditor, + OpacityInput, + RoundnessInput, + ContrastRatio, + Preview, + VirtualDirectivesTab + }, + setup (props, context) { + const exports = {} + const store = useStore() + // All rules that are made by editor + const allEditedRules = ref(store.state.interface.styleDataUsed || {}) + const styleDataUsed = computed(() => store.state.interface.styleDataUsed) + + watch([styleDataUsed], (value) => { + onImport(store.state.interface.styleDataUsed) + }, { once: true }) + + exports.isActive = computed(() => { + const tabSwitcher = getCurrentInstance().parent.ctx + return tabSwitcher ? tabSwitcher.isActive('style') : false + }) + + // ## Meta stuff + exports.name = ref('') + exports.author = ref('') + exports.license = ref('') + exports.website = ref('') + + const metaOut = computed(() => { + return [ + '@meta {', + ` name: ${exports.name.value};`, + ` author: ${exports.author.value};`, + ` license: ${exports.license.value};`, + ` website: ${exports.website.value};`, + '}' + ].join('\n') + }) + + const metaRule = computed(() => ({ + component: '@meta', + directives: { + name: exports.name.value, + author: exports.author.value, + license: exports.license.value, + website: exports.website.value + } + })) + + // ## Palette stuff + const palettes = reactive([ + { + name: 'default', + bg: '#121a24', + fg: '#182230', + text: '#b9b9ba', + link: '#d8a070', + accent: '#d8a070', + cRed: '#FF0000', + cBlue: '#0095ff', + cGreen: '#0fa00f', + cOrange: '#ffa500' + }, + { + name: 'light', + bg: '#f2f6f9', + fg: '#d6dfed', + text: '#304055', + underlay: '#5d6086', + accent: '#f55b1b', + cBlue: '#0095ff', + cRed: '#d31014', + cGreen: '#0fa00f', + cOrange: '#ffa500', + border: '#d8e6f9' + } + ]) + exports.palettes = palettes + + // This is kinda dumb but you cannot "replace" reactive() object + // and so v-model simply fails when you try to chage (increase only?) + // length of the array. Since linter complains about mutating modelValue + // inside SelectMotion, the next best thing is to just wipe existing array + // and replace it with new one. + + const onPalettesUpdate = (e) => { + palettes.splice(0, palettes.length) + palettes.push(...e) + } + exports.onPalettesUpdate = onPalettesUpdate + + const selectedPaletteId = ref(0) + const selectedPalette = computed({ + get () { + return palettes[selectedPaletteId.value] + }, + set (newPalette) { + palettes[selectedPaletteId.value] = newPalette + } + }) + exports.selectedPaletteId = selectedPaletteId + exports.selectedPalette = selectedPalette + provide('selectedPalette', selectedPalette) + + watch([selectedPalette], () => updateOverallPreview()) + + exports.getNewPalette = () => ({ + name: 'new palette', + bg: '#121a24', + fg: '#182230', + text: '#b9b9ba', + link: '#d8a070', + accent: '#d8a070', + cRed: '#FF0000', + cBlue: '#0095ff', + cGreen: '#0fa00f', + cOrange: '#ffa500' + }) + + // Raw format + const palettesRule = computed(() => { + return palettes.map(palette => { + const { name, ...rest } = palette + return { + component: '@palette', + variant: name, + directives: Object + .entries(rest) + .filter(([k, v]) => v && k) + .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}) + } + }) + }) + + // Text format + const palettesOut = computed(() => { + return palettes.map(({ name, ...palette }) => { + const entries = Object + .entries(palette) + .filter(([k, v]) => v && k) + .map(([slot, data]) => ` ${slot}: ${data};`) + .join('\n') + + return `@palette.${name} {\n${entries}\n}` + }).join('\n\n') + }) + + // ## Components stuff + // Getting existing components + const componentsContext = require.context('src', true, /\.style.js(on)?$/) + const componentKeysAll = componentsContext.keys() + const componentsMap = new Map( + componentKeysAll + .map( + key => [key, componentsContext(key).default] + ).filter(([key, component]) => !component.virtual && !component.notEditable) + ) + exports.componentsMap = componentsMap + const componentKeys = [...componentsMap.keys()] + exports.componentKeys = componentKeys + + // Component list and selection + const selectedComponentKey = ref(componentsMap.keys().next().value) + exports.selectedComponentKey = selectedComponentKey + + const selectedComponent = computed(() => componentsMap.get(selectedComponentKey.value)) + const selectedComponentName = computed(() => selectedComponent.value.name) + + // Selection basis + exports.selectedComponentVariants = computed(() => { + return Object.keys({ normal: null, ...(selectedComponent.value.variants || {}) }) + }) + exports.selectedComponentStates = computed(() => { + const all = Object.keys({ normal: null, ...(selectedComponent.value.states || {}) }) + return all.filter(x => x !== 'normal') + }) + + // selection + const selectedVariant = ref('normal') + exports.selectedVariant = selectedVariant + const selectedState = reactive(new Set()) + exports.selectedState = selectedState + exports.updateSelectedStates = (state, v) => { + if (v) { + selectedState.add(state) + } else { + selectedState.delete(state) + } + } + + // Reset variant and state on component change + const updateSelectedComponent = () => { + selectedVariant.value = 'normal' + selectedState.clear() + } + + watch( + selectedComponentName, + updateSelectedComponent + ) + + // ### Rules stuff aka meat and potatoes + // The native structure of separate rules and the child -> parent + // relation isn't very convenient for editor, we replace the array + // and child -> parent structure with map and parent -> child structure + const rulesToEditorFriendly = (rules, root = {}) => rules.reduce((acc, rule) => { + const { parent: rParent, component: rComponent } = rule + const parent = rParent ?? rule + const hasChildren = !!rParent + const child = hasChildren ? rule : null + + const { + component: pComponent, + variant: pVariant = 'normal', + state: pState = [] // no relation to Intel CPUs whatsoever + } = parent + + const pPath = `${hasChildren ? pComponent : rComponent}.${pVariant}.${normalizeStates(pState)}` + + let output = get(acc, pPath) + if (!output) { + set(acc, pPath, {}) + output = get(acc, pPath) + } + + if (hasChildren) { + output._children = output._children ?? {} + const { + component: cComponent, + variant: cVariant = 'normal', + state: cState = [], + directives + } = child + + const cPath = `${cComponent}.${cVariant}.${normalizeStates(cState)}` + set(output._children, cPath, { directives }) + } else { + output.directives = parent.directives + } + return acc + }, root) + + const editorFriendlyFallbackStructure = computed(() => { + const root = {} + + componentKeys.forEach((componentKey) => { + const componentValue = componentsMap.get(componentKey) + const { defaultRules, name } = componentValue + rulesToEditorFriendly( + defaultRules.map((rule) => ({ ...rule, component: name })), + root + ) + }) + + return root + }) + + // Checking whether component can support some "directives" which + // are actually virtual subcomponents, i.e. Text, Link etc + exports.componentHas = (subComponent) => { + return !!selectedComponent.value.validInnerComponents?.find(x => x === subComponent) + } + + // Path for lodash's get and set + const getPath = (component, directive) => { + const pathSuffix = component ? `._children.${component}.normal.normal` : '' + const path = `${selectedComponentName.value}.${selectedVariant.value}.${normalizeStates([...selectedState])}${pathSuffix}.directives.${directive}` + return path + } + + // Templates for directives + const isElementPresent = (component, directive, defaultValue = '') => computed({ + get () { + return get(allEditedRules.value, getPath(component, directive)) != null + }, + set (value) { + if (value) { + const fallback = get( + editorFriendlyFallbackStructure.value, + getPath(component, directive) + ) + set(allEditedRules.value, getPath(component, directive), fallback ?? defaultValue) + } else { + unset(allEditedRules.value, getPath(component, directive)) + } + exports.updateOverallPreview() + } + }) + + const getEditedElement = (component, directive, postProcess = x => x) => computed({ + get () { + let usedRule + const fallback = editorFriendlyFallbackStructure.value + const real = allEditedRules.value + const path = getPath(component, directive) + + usedRule = get(real, path) // get real + if (!usedRule) { + usedRule = get(fallback, path) + } + + return postProcess(usedRule) + }, + set (value) { + if (value) { + set(allEditedRules.value, getPath(component, directive), value) + } else { + unset(allEditedRules.value, getPath(component, directive)) + } + exports.updateOverallPreview() + } + }) + + // All the editable stuff for the component + exports.editedBackgroundColor = getEditedElement(null, 'background') + exports.isBackgroundColorPresent = isElementPresent(null, 'background', '#FFFFFF') + exports.editedOpacity = getEditedElement(null, 'opacity') + exports.isOpacityPresent = isElementPresent(null, 'opacity', 1) + exports.editedRoundness = getEditedElement(null, 'roundness') + exports.isRoundnessPresent = isElementPresent(null, 'roundness', '0') + exports.editedTextColor = getEditedElement('Text', 'textColor') + exports.isTextColorPresent = isElementPresent('Text', 'textColor', '#000000') + exports.editedTextAuto = getEditedElement('Text', 'textAuto') + exports.isTextAutoPresent = isElementPresent('Text', 'textAuto', '#000000') + exports.editedLinkColor = getEditedElement('Link', 'textColor') + exports.isLinkColorPresent = isElementPresent('Link', 'textColor', '#000080') + exports.editedIconColor = getEditedElement('Icon', 'textColor') + exports.isIconColorPresent = isElementPresent('Icon', 'textColor', '#909090') + exports.editedBorderColor = getEditedElement('Border', 'textColor') + exports.isBorderColorPresent = isElementPresent('Border', 'textColor', '#909090') + + const getContrast = (bg, text) => { + try { + const bgRgb = hex2rgb(bg) + const textRgb = hex2rgb(text) + + const ratio = getContrastRatio(bgRgb, textRgb) + return { + // TODO this ideally should be part of <ContractRatio /> + ratio, + text: ratio.toPrecision(3) + ':1', + // AA level, AAA level + aa: ratio >= 4.5, + aaa: ratio >= 7, + // same but for 18pt+ texts + laa: ratio >= 3, + laaa: ratio >= 4.5 + } + } catch (e) { + console.warn('Failure computing contrast', e) + return { error: e } + } + } + + const normalizeShadows = (shadows) => { + return shadows?.map(shadow => { + if (typeof shadow === 'object') { + return shadow + } + if (typeof shadow === 'string') { + try { + return deserializeShadow(shadow) + } catch (e) { + console.warn(e) + return shadow + } + } + return null + }) + } + provide('normalizeShadows', normalizeShadows) + + // Shadow is partially edited outside the ShadowControl + // for better space utilization + const editedShadow = getEditedElement(null, 'shadow', normalizeShadows) + exports.editedShadow = editedShadow + const editedSubShadowId = ref(null) + exports.editedSubShadowId = editedSubShadowId + const editedSubShadow = computed(() => { + if (editedShadow.value == null || editedSubShadowId.value == null) return null + return editedShadow.value[editedSubShadowId.value] + }) + exports.editedSubShadow = editedSubShadow + exports.isShadowPresent = isElementPresent(null, 'shadow', []) + exports.onSubShadow = (id) => { + if (id != null) { + editedSubShadowId.value = id + } else { + editedSubShadow.value = null + } + } + exports.updateSubShadow = (axis, value) => { + if (!editedSubShadow.value || editedSubShadowId.value == null) return + const newEditedShadow = [...editedShadow.value] + + newEditedShadow[editedSubShadowId.value] = { + ...newEditedShadow[editedSubShadowId.value], + [axis]: value + } + + editedShadow.value = newEditedShadow + } + exports.isShadowTabOpen = ref(false) + exports.onTabSwitch = (tab) => { + exports.isShadowTabOpen.value = tab === 'shadow' + } + + // component preview + exports.editorHintStyle = computed(() => { + const editorHint = selectedComponent.value.editor + const styles = [] + if (editorHint && Object.keys(editorHint).length > 0) { + if (editorHint.aspect != null) { + styles.push(`aspect-ratio: ${editorHint.aspect} !important;`) + } + if (editorHint.border != null) { + styles.push(`border-width: ${editorHint.border}px !important;`) + } + } + return styles.join('; ') + }) + + const editorFriendlyToOriginal = computed(() => { + const resultRules = [] + + const convert = (component, data = {}, parent) => { + const variants = Object.entries(data || {}) + + variants.forEach(([variant, variantData]) => { + const states = Object.entries(variantData) + + states.forEach(([jointState, stateData]) => { + const state = jointState.split(/:/g) + const result = { + component, + variant, + state, + directives: stateData.directives || {} + } + + if (parent) { + result.parent = { + component: parent + } + } + + resultRules.push(result) + + // Currently we only support single depth for simplicity's sake + if (!parent) { + Object.entries(stateData._children || {}).forEach(([cName, child]) => convert(cName, child, component)) + } + }) + }) + } + + [...componentsMap.values()].forEach(({ name }) => { + convert(name, allEditedRules.value[name]) + }) + + return resultRules + }) + + const allCustomVirtualDirectives = [...componentsMap.values()] + .map(c => { + return c + .defaultRules + .filter(c => c.component === 'Root') + .map(x => Object.entries(x.directives)) + .flat() + }) + .filter(x => x) + .flat() + .map(([name, value]) => { + const [valType, valVal] = value.split('|') + return { + name: name.substring(2), + valType: valType?.trim(), + value: valVal?.trim() + } + }) + + const virtualDirectives = ref(allCustomVirtualDirectives) + exports.virtualDirectives = virtualDirectives + exports.updateVirtualDirectives = (value) => { + virtualDirectives.value = value + } + + // Raw format + const virtualDirectivesRule = computed(() => ({ + component: 'Root', + directives: Object.fromEntries( + virtualDirectives.value.map(vd => [`--${vd.name}`, `${vd.valType} | ${vd.value}`]) + ) + })) + + // Text format + const virtualDirectivesOut = computed(() => { + return [ + 'Root {', + ...virtualDirectives.value + .filter(vd => vd.name && vd.valType && vd.value) + .map(vd => ` --${vd.name}: ${vd.valType} | ${vd.value};`), + '}' + ].join('\n') + }) + + exports.computeColor = (color) => { + let computedColor + try { + computedColor = findColor(color, { dynamicVars: dynamicVars.value, staticVars: staticVars.value }) + if (computedColor) { + return rgb2hex(computedColor) + } + } catch (e) { + console.warn(e) + } + return null + } + provide('computeColor', exports.computeColor) + + exports.contrast = computed(() => { + return getContrast( + exports.computeColor(previewColors.value.background), + exports.computeColor(previewColors.value.text) + ) + }) + + // ## Export and Import + const styleExporter = newExporter({ + filename: () => exports.name.value ?? 'pleroma_theme', + mime: 'text/plain', + extension: 'piss', + getExportedObject: () => exportStyleData.value + }) + + const onImport = parsed => { + const editorComponents = parsed.filter(x => x.component.startsWith('@')) + const rootComponent = parsed.find(x => x.component === 'Root') + const rules = parsed.filter(x => !x.component.startsWith('@') && x.component !== 'Root') + const metaIn = editorComponents.find(x => x.component === '@meta').directives + const palettesIn = editorComponents.filter(x => x.component === '@palette') + + exports.name.value = metaIn.name + exports.license.value = metaIn.license + exports.author.value = metaIn.author + exports.website.value = metaIn.website + + const newVirtualDirectives = Object + .entries(rootComponent.directives) + .map(([name, value]) => { + const [valType, valVal] = value.split('|').map(x => x.trim()) + return { name: name.substring(2), valType, value: valVal } + }) + virtualDirectives.value = newVirtualDirectives + + onPalettesUpdate(palettesIn.map(x => ({ name: x.variant, ...x.directives }))) + + allEditedRules.value = rulesToEditorFriendly(rules) + + exports.updateOverallPreview() + } + + const styleImporter = newImporter({ + accept: '.piss', + parser (string) { return deserialize(string) }, + onImportFailure (result) { + console.error('Failure importing style:', result) + this.$store.dispatch('pushGlobalNotice', { messageKey: 'settings.invalid_theme_imported', level: 'error' }) + }, + onImport + }) + + // Raw format + const exportRules = computed(() => [ + metaRule.value, + ...palettesRule.value, + virtualDirectivesRule.value, + ...editorFriendlyToOriginal.value + ]) + + // Text format + const exportStyleData = computed(() => { + return [ + metaOut.value, + palettesOut.value, + virtualDirectivesOut.value, + serialize(editorFriendlyToOriginal.value) + ].join('\n\n') + }) + + exports.clearStyle = () => { + onImport(store.state.interface.styleDataUsed) + } + + exports.exportStyle = () => { + styleExporter.exportData() + } + + exports.importStyle = () => { + styleImporter.importData() + } + + exports.applyStyle = () => { + store.dispatch('setStyleCustom', exportRules.value) + } + + const overallPreviewRules = ref([]) + exports.overallPreviewRules = overallPreviewRules + + const overallPreviewCssRules = ref([]) + watchEffect(throttle(() => { + try { + overallPreviewCssRules.value = getScopedVersion( + getCssRules(overallPreviewRules.value), + '#edited-style-preview' + ).join('\n') + } catch (e) { + console.error(e) + } + }, 500)) + + exports.overallPreviewCssRules = overallPreviewCssRules + + const updateOverallPreview = throttle(() => { + try { + overallPreviewRules.value = init({ + inputRuleset: [ + ...exportRules.value, + { + component: 'Root', + directives: Object.fromEntries( + Object + .entries(selectedPalette.value) + .filter(([k, v]) => k && v && k !== 'name') + .map(([k, v]) => [`--${k}`, `color | ${v}`]) + ) + } + ], + ultimateBackgroundColor: '#000000', + debug: true + }).eager + } catch (e) { + console.error('Could not compile preview theme', e) + return null + } + }, 5000) + // + // Apart from "hover" we can't really show how component looks like in + // certain states, so we have to fake them. + const simulatePseudoSelectors = (css, prefix) => css + .replace(prefix, '.component-preview .preview-block') + .replace(':active', '.preview-active') + .replace(':hover', '.preview-hover') + .replace(':active', '.preview-active') + .replace(':focus', '.preview-focus') + .replace(':focus-within', '.preview-focus-within') + .replace(':disabled', '.preview-disabled') + + const previewRules = computed(() => { + const filtered = overallPreviewRules.value.filter(r => { + const componentMatch = r.component === selectedComponentName.value + const parentComponentMatch = r.parent?.component === selectedComponentName.value + if (!componentMatch && !parentComponentMatch) return false + const rule = parentComponentMatch ? r.parent : r + if (rule.component !== selectedComponentName.value) return false + if (rule.variant !== selectedVariant.value) return false + const ruleState = new Set(rule.state.filter(x => x !== 'normal')) + const differenceA = [...ruleState].filter(x => !selectedState.has(x)) + const differenceB = [...selectedState].filter(x => !ruleState.has(x)) + return (differenceA.length + differenceB.length) === 0 + }) + const sorted = [...filtered] + .filter(x => x.component === selectedComponentName.value) + .sort((a, b) => { + const aSelectorLength = a.selector.split(/ /g).length + const bSelectorLength = b.selector.split(/ /g).length + return aSelectorLength - bSelectorLength + }) + + const prefix = sorted[0].selector + + return filtered.filter(x => x.selector.startsWith(prefix)) + }) + + exports.previewClass = computed(() => { + const selectors = [] + if (!!selectedComponent.value.variants?.normal || selectedVariant.value !== 'normal') { + selectors.push(selectedComponent.value.variants[selectedVariant.value]) + } + if (selectedState.size > 0) { + selectedState.forEach(state => { + const original = selectedComponent.value.states[state] + selectors.push(simulatePseudoSelectors(original)) + }) + } + return selectors.map(x => x.substring(1)).join('') + }) + + exports.previewCss = computed(() => { + try { + const prefix = previewRules.value[0].selector + const scoped = getCssRules(previewRules.value).map(x => simulatePseudoSelectors(x, prefix)) + return scoped.join('\n') + } catch (e) { + console.error('Invalid ruleset', e) + return null + } + }) + + const dynamicVars = computed(() => { + return previewRules.value[0].dynamicVars + }) + + const staticVars = computed(() => { + const rootComponent = overallPreviewRules.value.find(r => { + return r.component === 'Root' + }) + const rootDirectivesEntries = Object.entries(rootComponent.directives) + const directives = {} + rootDirectivesEntries + .filter(([k, v]) => k.startsWith('--') && v.startsWith('color | ')) + .map(([k, v]) => [k.substring(2), v.substring('color | '.length)]) + .forEach(([k, v]) => { + directives[k] = findColor(v, { dynamicVars: {}, staticVars: directives }) + }) + return directives + }) + provide('staticVars', staticVars) + exports.staticVars = staticVars + + const previewColors = computed(() => { + const stacked = dynamicVars.value.stacked + const background = typeof stacked === 'string' ? stacked : rgb2hex(stacked) + return { + text: previewRules.value.find(r => r.component === 'Text')?.virtualDirectives['--text'], + link: previewRules.value.find(r => r.component === 'Link')?.virtualDirectives['--link'], + border: previewRules.value.find(r => r.component === 'Border')?.virtualDirectives['--border'], + icon: previewRules.value.find(r => r.component === 'Icon')?.virtualDirectives['--icon'], + background + } + }) + exports.previewColors = previewColors + exports.updateOverallPreview = updateOverallPreview + + updateOverallPreview() + + watch( + [ + allEditedRules.value, + palettes, + selectedPalette, + selectedState, + selectedVariant + ], + updateOverallPreview + ) + + return exports + } +} diff --git a/src/components/settings_modal/tabs/style_tab/style_tab.scss b/src/components/settings_modal/tabs/style_tab/style_tab.scss @@ -0,0 +1,264 @@ +.StyleTab { + .style-control { + display: flex; + flex-wrap: wrap; + align-items: baseline; + margin-bottom: 0.5em; + + .label { + margin-right: 0.5em; + flex: 1 1 0; + line-height: 2; + min-height: 2em; + } + + &.suboption { + margin-left: 1em; + } + + .color-input { + flex: 0 0 0; + } + + input, + select { + min-width: 3em; + margin: 0; + flex: 0; + + &[type="number"] { + min-width: 9em; + + &.-small { + min-width: 5em; + } + } + + &[type="range"] { + flex: 1; + min-width: 9em; + align-self: center; + margin: 0 0.25em; + } + + &[type="checkbox"] + i { + height: 1.1em; + align-self: center; + } + } + } + + .meta-preview { + display: grid; + grid-template: + "meta meta preview preview" + "meta meta preview preview" + "meta meta preview preview" + "meta meta preview preview"; + grid-gap: 0.5em; + grid-template-columns: min-content min-content 6fr max-content; + + ul.setting-list { + padding: 0; + margin: 0; + display: grid; + grid-template-rows: subgrid; + grid-area: meta; + + > li { + margin: 0; + } + + .meta-field { + margin: 0; + + .setting-label { + display: inline-block; + margin-bottom: 0.5em; + } + } + } + + #edited-style-preview { + grid-area: preview; + } + } + + .setting-item { + padding-bottom: 0; + + .btn { + padding: 0 0.5em; + } + + &:not(:first-child) { + margin-top: 0.5em; + } + + &:not(:last-child) { + margin-bottom: 0.5em; + } + } + + .list-editor { + display: grid; + grid-template-areas: + "label editor" + "selector editor" + "movement editor"; + grid-template-columns: 10em 1fr; + grid-template-rows: auto 1fr auto; + grid-gap: 0.5em; + + .list-edit-area { + grid-area: editor; + } + + .list-select { + grid-area: selector; + margin: 0; + + &-label { + font-weight: bold; + grid-area: label; + margin: 0; + align-self: baseline; + } + + &-movement { + grid-area: movement; + margin: 0; + } + } + } + + .palette-editor { + width: min-content; + + .list-edit-area { + display: grid; + align-self: baseline; + grid-template-rows: subgrid; + grid-template-columns: 1fr; + } + + .palette-editor-single { + grid-row: 2 / span 2; + } + } + + .variables-editor { + .variable-selector { + display: grid; + grid-template-columns: auto 1fr auto 10em; + grid-template-rows: subgrid; + align-items: baseline; + grid-gap: 0 0.5em; + } + + .list-edit-area { + display: grid; + grid-template-rows: subgrid; + } + + .shadow-control { + grid-row: 2 / span 2; + } + } + + .component-editor { + display: grid; + grid-template-columns: 6fr 3fr 4fr; + grid-template-rows: auto auto 1fr; + grid-gap: 0.5em; + grid-template-areas: + "component component variant" + "state state state" + "preview settings settings"; + + .component-selector { + grid-area: component; + align-self: center; + } + + .component-selector, + .state-selector, + .variant-selector { + display: grid; + grid-template-columns: 1fr minmax(1fr, 10em); + grid-template-rows: auto; + grid-auto-flow: column; + grid-gap: 0.5em; + align-items: baseline; + + > label:not(.Select) { + font-weight: bold; + justify-self: right; + } + } + + .state-selector { + grid-area: state; + grid-template-columns: minmax(min-content, 7em) 1fr; + } + + .variant-selector { + grid-area: variant; + } + + .state-selector-list { + display: grid; + list-style: none; + grid-auto-flow: dense; + grid-template-columns: repeat(5, minmax(min-content, 1fr)); + grid-auto-rows: 1fr; + grid-gap: 0.5em; + padding: 0; + margin: 0; + } + + .preview-container { + --border: none; + --shadow: none; + --roundness: none; + + grid-area: preview; + } + + .component-settings { + grid-area: settings; + } + + .editor-tab { + display: grid; + grid-template-columns: 1fr 2em; + grid-column-gap: 0.5em; + align-items: center; + grid-auto-rows: min-content; + grid-auto-flow: dense; + border-left: 1px solid var(--border); + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + padding: 0.5em; + } + + .shadow-tab { + grid-template-columns: 1fr; + justify-items: center; + } + } +} + +.extra-content { + .style-actions-container { + width: 100%; + display: flex; + justify-content: end; + + .style-actions { + display: grid; + grid-template-columns: repeat(4, minmax(7em, 1fr)); + grid-gap: 0.25em; + } + } +} diff --git a/src/components/settings_modal/tabs/style_tab/style_tab.vue b/src/components/settings_modal/tabs/style_tab/style_tab.vue @@ -0,0 +1,383 @@ +<script src="./style_tab.js"> +</script> + +<template> + <div class="StyleTab"> + <div class="setting-item heading"> + <h2> {{ $t('settings.style.themes3.editor.title') }} </h2> + <div class="meta-preview"> + <!-- eslint-disable vue/no-v-text-v-html-on-component --> + <component + :is="'style'" + v-html="overallPreviewCssRules" + /> + <!-- eslint-enable vue/no-v-text-v-html-on-component --> + <Preview id="edited-style-preview" /> + <teleport + v-if="isActive" + to="#unscrolled-content" + > + <div class="style-actions-container"> + <div class="style-actions"> + <button + class="btn button-default button-new" + @click="clearStyle" + > + <FAIcon icon="arrows-rotate" /> + {{ $t('settings.style.themes3.editor.reset_style') }} + </button> + <button + class="btn button-default button-load" + @click="importStyle" + > + <FAIcon icon="folder-open" /> + {{ $t('settings.style.themes3.editor.load_style') }} + </button> + <button + class="btn button-default button-save" + @click="exportStyle" + > + <FAIcon icon="floppy-disk" /> + {{ $t('settings.style.themes3.editor.save_style') }} + </button> + <button + class="btn button-default button-apply" + @click="applyStyle" + > + <FAIcon icon="check" /> + {{ $t('settings.style.themes3.editor.apply_preview') }} + </button> + </div> + </div> + </teleport> + <ul class="setting-list style-metadata"> + <li> + <StringSetting class="meta-field" v-model="name"> + {{ $t('settings.style.themes3.editor.style_name') }} + </StringSetting> + </li> + <li> + <StringSetting class="meta-field" v-model="author"> + {{ $t('settings.style.themes3.editor.style_author') }} + </StringSetting> + </li> + <li> + <StringSetting class="meta-field" v-model="license"> + {{ $t('settings.style.themes3.editor.style_license') }} + </StringSetting> + </li> + <li> + <StringSetting class="meta-field" v-model="website"> + {{ $t('settings.style.themes3.editor.style_website') }} + </StringSetting> + </li> + </ul> + </div> + </div> + <tab-switcher> + <div + key="component" + class="setting-item component-editor" + :label="$t('settings.style.themes3.editor.component_tab')" + > + <div class="component-selector"> + <label for="component-selector"> + {{ $t('settings.style.themes3.editor.component_selector') }} + {{ ' ' }} + </label> + <Select + id="component-selector" + v-model="selectedComponentKey" + > + <option + v-for="key in componentKeys" + :key="'component-' + key" + :value="key" + > + {{ componentsMap.get(key).name }} + </option> + </Select> + </div> + <div + v-if="selectedComponentVariants.length > 1" + class="variant-selector" + > + <label for="variant-selector"> + {{ $t('settings.style.themes3.editor.variant_selector') }} + </label> + <Select + v-model="selectedVariant" + > + <option + v-for="variant in selectedComponentVariants" + :key="'component-variant-' + variant" + :value="variant" + > + {{ variant }} + </option> + </Select> + </div> + <div + v-if="selectedComponentStates.length > 0" + class="state-selector" + > + <label> + {{ $t('settings.style.themes3.editor.states_selector') }} + </label> + <ul + class="state-selector-list" + > + <li + v-for="state in selectedComponentStates" + :key="'component-state-' + state" + > + <Checkbox + :value="selectedState.has(state)" + @update:modelValue="(v) => updateSelectedStates(state, v)" + > + {{ state }} + </Checkbox> + </li> + </ul> + </div> + <div class="preview-container"> + <!-- eslint-disable vue/no-v-html vue/no-v-text-v-html-on-component --> + <component + :is="'style'" + v-html="previewCss" + /> + <!-- eslint-enable vue/no-v-html vue/no-v-text-v-html-on-component --> + <ComponentPreview + class="component-preview" + :show-text="componentHas('Text')" + :shadow-control="isShadowTabOpen" + :preview-class="previewClass" + :preview-style="editorHintStyle" + :preview-css="previewCss" + :disabled="!editedSubShadow && typeof editedShadow !== 'string'" + :shadow="editedSubShadow" + :no-color-control="true" + @update:shadow="({ axis, value }) => updateSubShadow(axis, value)" + /> + </div> + <tab-switcher + ref="tabSwitcher" + class="component-settings" + :on-switch="onTabSwitch" + > + <div + key="main" + class="editor-tab" + :label="$t('settings.style.themes3.editor.main_tab')" + > + <ColorInput + v-model="editedBackgroundColor" + :fallback="computeColor(editedBackgroundColor) ?? previewColors.background" + :disabled="!isBackgroundColorPresent" + :label="$t('settings.style.themes3.editor.background')" + :hide-optional-checkbox="true" + /> + <Tooltip :text="$t('settings.style.themes3.editor.include_in_rule')"> + <Checkbox v-model="isBackgroundColorPresent" /> + </Tooltip> + <ColorInput + v-if="componentHas('Text')" + v-model="editedTextColor" + :fallback="computeColor(editedTextColor) ?? previewColors.text" + :label="$t('settings.style.themes3.editor.text_color')" + :disabled="!isTextColorPresent" + :hide-optional-checkbox="true" + /> + <Tooltip + v-if="componentHas('Text')" + :text="$t('settings.style.themes3.editor.include_in_rule')" + > + <Checkbox v-model="isTextColorPresent" /> + </Tooltip> + <div + v-if="componentHas('Text')" + class="style-control suboption" + > + <label + for="textAuto" + class="label" + :class="{ faint: !isTextAutoPresent }" + > + {{ $t('settings.style.themes3.editor.text_auto.label') }} + </label> + <Select + id="textAuto" + v-model="editedTextAuto" + :disabled="!isTextAutoPresent" + > + <option value="no-preserve"> + {{ $t('settings.style.themes3.editor.text_auto.no-preserve') }} + </option> + <option value="no-auto"> + {{ $t('settings.style.themes3.editor.text_auto.no-auto') }} + </option> + <option value="preserve"> + {{ $t('settings.style.themes3.editor.text_auto.preserve') }} + </option> + </Select> + </div> + <Tooltip + v-if="componentHas('Text')" + :text="$t('settings.style.themes3.editor.include_in_rule')" + > + <Checkbox v-model="isTextAutoPresent" /> + </Tooltip> + <div + class="style-control suboption" + v-if="componentHas('Text')" + > + <label class="label"> + {{$t('settings.style.themes3.editor.contrast') }} + </label> + <ContrastRatio + :show-ratio="true" + :contrast="contrast" + /> + </div> + <div v-if="componentHas('Text')"> + </div> + <ColorInput + v-if="componentHas('Link')" + v-model="editedLinkColor" + :fallback="computeColor(editedLinkColor) ?? previewColors.link" + :label="$t('settings.style.themes3.editor.link_color')" + :disabled="!isLinkColorPresent" + :hide-optional-checkbox="true" + /> + <Tooltip + v-if="componentHas('Link')" + :text="$t('settings.style.themes3.editor.include_in_rule')" + > + <Checkbox v-model="isLinkColorPresent" /> + </Tooltip> + <ColorInput + v-if="componentHas('Icon')" + v-model="editedIconColor" + :fallback="computeColor(editedIconColor) ?? previewColors.icon" + :label="$t('settings.style.themes3.editor.icon_color')" + :disabled="!isIconColorPresent" + :hide-optional-checkbox="true" + /> + <Tooltip + v-if="componentHas('Icon')" + :text="$t('settings.style.themes3.editor.include_in_rule')" + > + <Checkbox v-model="isIconColorPresent" /> + </Tooltip> + <ColorInput + v-if="componentHas('Border')" + v-model="editedBorderColor" + :fallback="computeColor(editedBorderColor) ?? previewColors.border" + :label="$t('settings.style.themes3.editor.border_color')" + :disabled="!isBorderColorPresent" + :hide-optional-checkbox="true" + /> + <Tooltip + v-if="componentHas('Border')" + :text="$t('settings.style.themes3.editor.include_in_rule')" + > + <Checkbox v-model="isBorderColorPresent" /> + </Tooltip> + <OpacityInput + v-model="editedOpacity" + :disabled="!isOpacityPresent" + :label="$t('settings.style.themes3.editor.opacity')" + /> + <Tooltip :text="$t('settings.style.themes3.editor.include_in_rule')"> + <Checkbox v-model="isOpacityPresent" /> + </Tooltip> + <RoundnessInput + v-model="editedRoundness" + :disabled="!isRoundnessPresent" + :label="$t('settings.style.themes3.editor.roundness')" + /> + <Tooltip :text="$t('settings.style.themes3.editor.include_in_rule')"> + <Checkbox v-model="isRoundnessPresent" /> + </Tooltip> + </div> + <div + key="shadow" + class="editor-tab shadow-tab" + :label="$t('settings.style.themes3.editor.shadows_tab')" + > + <Checkbox + v-model="isShadowPresent" + class="style-control" + > + {{ $t('settings.style.themes3.editor.include_in_rule') }} + </checkbox> + <ShadowControl + v-model="editedShadow" + :disabled="!isShadowPresent" + :no-preview="true" + :compact="true" + :static-vars="staticVars" + @subShadowSelected="onSubShadow" + /> + </div> + </tab-switcher> + </div> + <div + key="palette" + :label="$t('settings.style.themes3.editor.palette_tab')" + class="setting-item list-editor palette-editor" + > + <label + class="list-select-label" + for="palette-selector" + > + {{ $t('settings.style.themes3.palette.label') }} + {{ ' ' }} + </label> + <Select + id="palette-selector" + v-model="selectedPaletteId" + class="list-select" + size="4" + > + <option + v-for="(p, index) in palettes" + :key="p.name" + :value="index" + > + {{ p.name }} + </option> + </Select> + <SelectMotion + class="list-select-movement" + :modelValue="palettes" + @update:modelValue="onPalettesUpdate" + :selected-id="selectedPaletteId" + :get-add-value="getNewPalette" + @update:selectedId="e => selectedPaletteId = e" + /> + <div class="list-edit-area"> + <StringSetting + class="palette-name-input" + v-model="selectedPalette.name" + > + {{ $t('settings.style.themes3.palette.name_label') }} + </StringSetting> + <PaletteEditor + class="palette-editor-single" + v-model="selectedPalette" + /> + </div> + </div> + <VirtualDirectivesTab + key="variables" + :label="$t('settings.style.themes3.editor.variables_tab')" + :model-value="virtualDirectives" + @update:modelValue="updateVirtualDirectives" + :normalize-shadows="normalizeShadows" + /> + </tab-switcher> + </div> +</template> + +<style src="./style_tab.scss" lang="scss"></style> diff --git a/src/components/settings_modal/tabs/style_tab/virtual_directives_tab.js b/src/components/settings_modal/tabs/style_tab/virtual_directives_tab.js @@ -0,0 +1,132 @@ +import { ref, computed, watch, inject } from 'vue' + +import Select from 'src/components/select/select.vue' +import SelectMotion from 'src/components/select/select_motion.vue' +import ShadowControl from 'src/components/shadow_control/shadow_control.vue' +import ColorInput from 'src/components/color_input/color_input.vue' + +import { serializeShadow } from 'src/services/theme_data/iss_serializer.js' + +// helper for debugging +// eslint-disable-next-line no-unused-vars +const toValue = (x) => JSON.parse(JSON.stringify(x === undefined ? 'null' : x)) + +export default { + components: { + Select, + SelectMotion, + ShadowControl, + ColorInput + }, + props: ['modelValue'], + emits: ['update:modelValue'], + setup (props, context) { + const exports = {} + const emit = context.emit + + exports.emit = emit + exports.computeColor = inject('computeColor') + exports.staticVars = inject('staticVars') + + const selectedVirtualDirectiveId = ref(0) + exports.selectedVirtualDirectiveId = selectedVirtualDirectiveId + + const selectedVirtualDirective = computed({ + get () { + return props.modelValue[selectedVirtualDirectiveId.value] + }, + set (value) { + const newVD = [...props.modelValue] + newVD[selectedVirtualDirectiveId.value] = value + + emit('update:modelValue', newVD) + } + }) + exports.selectedVirtualDirective = selectedVirtualDirective + + exports.selectedVirtualDirectiveValType = computed({ + get () { + return props.modelValue[selectedVirtualDirectiveId.value].valType + }, + set (value) { + const newValType = value + let newValue + switch (value) { + case 'shadow': + newValue = '0 0 0 #000000 / 1' + break + case 'color': + newValue = '#000000' + break + default: + newValue = 'none' + } + const newName = props.modelValue[selectedVirtualDirectiveId.value].name + props.modelValue[selectedVirtualDirectiveId.value] = { + name: newName, + value: newValue, + valType: newValType + } + } + }) + + const draftVirtualDirectiveValid = ref(true) + const draftVirtualDirective = ref({}) + exports.draftVirtualDirective = draftVirtualDirective + const normalizeShadows = inject('normalizeShadows') + + watch( + selectedVirtualDirective, + (directive) => { + switch (directive.valType) { + case 'shadow': { + if (Array.isArray(directive.value)) { + draftVirtualDirective.value = normalizeShadows(directive.value) + } else { + const splitShadow = directive.value.split(/,/g).map(x => x.trim()) + draftVirtualDirective.value = normalizeShadows(splitShadow) + } + break + } + case 'color': + draftVirtualDirective.value = directive.value + break + default: + draftVirtualDirective.value = directive.value + break + } + }, + { immediate: true } + ) + + watch( + draftVirtualDirective, + (directive) => { + try { + switch (selectedVirtualDirective.value.valType) { + case 'shadow': { + props.modelValue[selectedVirtualDirectiveId.value].value = + directive.map(x => serializeShadow(x)).join(', ') + break + } + default: + props.modelValue[selectedVirtualDirectiveId.value].value = directive + } + draftVirtualDirectiveValid.value = true + } catch (e) { + console.error('Invalid virtual directive value', e) + draftVirtualDirectiveValid.value = false + } + }, + { immediate: true } + ) + + exports.getNewVirtualDirective = () => ({ + name: 'newDirective', + valType: 'generic', + value: 'foobar' + }) + + return exports + } +} diff --git a/src/components/settings_modal/tabs/style_tab/virtual_directives_tab.vue b/src/components/settings_modal/tabs/style_tab/virtual_directives_tab.vue @@ -0,0 +1,83 @@ +<script src="./virtual_directives_tab.js"></script> + +<template> + <div class="setting-item list-editor variables-editor"> + <label + class="list-select-label" + for="variables-selector" + > + {{ $t('settings.style.themes3.editor.variables.label') }} + {{ ' ' }} + </label> + <Select + id="variables-selector" + v-model="selectedVirtualDirectiveId" + class="list-select" + size="20" + > + <option + v-for="(p, index) in modelValue" + :key="p.name" + :value="index" + > + {{ p.name }} + </option> + </Select> + <SelectMotion + class="list-select-movement" + :model-value="modelValue" + @update:modelValue="e => emit('update:modelValue', e)" + :selected-id="selectedVirtualDirectiveId" + @update:selectedId="e => selectedVirtualDirectiveId = e" + :get-add-value="getNewVirtualDirective" + /> + <div class="list-edit-area"> + <div class="variable-selector"> + <label + class="variable-name-label" + for="variables-selector" + > + {{ $t('settings.style.themes3.editor.variables.name_label') }} + {{ ' ' }} + </label> + <input + class="input" + v-model="selectedVirtualDirective.name" + > + <label + class="variable-type-label" + for="variables-selector" + > + {{ $t('settings.style.themes3.editor.variables.type_label') }} + {{ ' ' }} + </label> + <Select + v-model="selectedVirtualDirectiveValType" + > + <option value='shadow'> + {{ $t('settings.style.themes3.editor.variables.type_shadow') }} + </option> + <option value='color'> + {{ $t('settings.style.themes3.editor.variables.type_color') }} + </option> + <option value='generic'> + {{ $t('settings.style.themes3.editor.variables.type_generic') }} + </option> + </Select> + </div> + <ShadowControl + v-if="selectedVirtualDirectiveValType === 'shadow'" + v-model="draftVirtualDirective" + :static-vars="staticVars" + :compact="true" + /> + <ColorInput + v-if="selectedVirtualDirectiveValType === 'color'" + v-model="draftVirtualDirective" + :fallback="computeColor(draftVirtualDirective)" + :label="$t('settings.style.themes3.editor.variables.virtual_color')" + :hide-optional-checkbox="true" + /> + </div> + </div> +</template> diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js @@ -5,9 +5,6 @@ import { relativeLuminance } from 'src/services/color_convert/color_convert.js' import { - getThemes -} from 'src/services/style_setter/style_setter.js' -import { newImporter, newExporter } from 'src/services/export_import/export_import.js' @@ -123,31 +120,24 @@ export default { } }, created () { - const self = this + const currentIndex = this.$store.state.instance.themesIndex - getThemes() - .then((promises) => { - return Promise.all( - Object.entries(promises) - .map(([k, v]) => v.then(res => [k, res])) - ) - }) - .then(themes => themes.reduce((acc, [k, v]) => { - if (v) { - return { - ...acc, - [k]: v - } - } else { - return acc - } - }, {})) - .then((themesComplete) => { - self.availableStyles = themesComplete - }) + let promise + if (currentIndex) { + promise = Promise.resolve(currentIndex) + } else { + promise = this.$store.dispatch('fetchThemesIndex') + } + + promise.then(themesIndex => { + Object + .values(themesIndex) + .forEach(themeFunc => { + themeFunc().then(themeData => this.availableStyles.push(themeData)) + }) + }) }, mounted () { - this.loadThemeFromLocalStorage() if (typeof this.shadowSelected === 'undefined') { this.shadowSelected = this.shadowsAvailable[0] } @@ -305,6 +295,9 @@ export default { return {} } }, + themeDataUsed () { + return this.$store.state.interface.themeDataUsed + }, shadowsAvailable () { return Object.keys(DEFAULT_SHADOWS).sort() }, @@ -412,9 +405,6 @@ export default { forceUseSource = false ) { this.dismissWarning() - if (!source && !theme) { - throw new Error('Can\'t load theme: empty') - } const version = (origin === 'localStorage' && !theme.colors) ? 'l1' : fileVersion @@ -490,22 +480,11 @@ export default { this.dismissWarning() }, loadThemeFromLocalStorage (confirmLoadSource = false, forceSnapshot = false) { - const { - customTheme: theme, - customThemeSource: source - } = this.$store.getters.mergedConfig - if (!theme && !source) { - // Anon user or never touched themes - this.loadTheme( - this.$store.state.instance.themeData, - 'defaults', - confirmLoadSource - ) - } else { + const theme = this.themeDataUsed?.source + if (theme) { this.loadTheme( { - theme, - source: forceSnapshot ? theme : source + theme }, 'localStorage', confirmLoadSource @@ -724,6 +703,9 @@ export default { } }, watch: { + themeDataUsed () { + this.loadThemeFromLocalStorage() + }, currentRadii () { try { this.previewTheme.radii = generateRadii({ radii: this.currentRadii }).theme.radii diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss @@ -45,12 +45,16 @@ flex: 0; &[type="number"] { - min-width: 5em; + min-width: 9em; + + &.-small { + min-width: 5em; + } } &[type="range"] { flex: 1; - min-width: 2em; + min-width: 9em; align-self: center; margin: 0 0.5em; } diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue @@ -187,14 +187,14 @@ name="accentColor" :fallback="previewTheme.colors?.link" :label="$t('settings.accent')" - :show-optional-tickbox="typeof linkColorLocal !== 'undefined'" + :show-optional-checkbox="typeof linkColorLocal !== 'undefined'" /> <ColorInput v-model="linkColorLocal" name="linkColor" :fallback="previewTheme.colors?.accent" :label="$t('settings.links')" - :show-optional-tickbox="typeof accentColorLocal !== 'undefined'" + :show-optional-checkbox="typeof accentColorLocal !== 'undefined'" /> <ContrastRatio :contrast="previewContrast.bgLink" /> </div> @@ -957,6 +957,8 @@ v-model="currentShadow" :separate-inset="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'" :fallback="currentShadowFallback" + :static-vars="previewTheme.colors" + :compact="true" /> </div> <div diff --git a/src/components/shadow_control/shadow_control.js b/src/components/shadow_control/shadow_control.js @@ -1,12 +1,17 @@ import ColorInput from 'src/components/color_input/color_input.vue' import OpacityInput from 'src/components/opacity_input/opacity_input.vue' import Select from 'src/components/select/select.vue' +import SelectMotion from 'src/components/select/select_motion.vue' import Checkbox from 'src/components/checkbox/checkbox.vue' import Popover from 'src/components/popover/popover.vue' import ComponentPreview from 'src/components/component_preview/component_preview.vue' -import { getCssShadow, getCssShadowFilter } from '../../services/theme_data/theme_data.service.js' +import { rgb2hex } from 'src/services/color_convert/color_convert.js' +import { serializeShadow } from 'src/services/theme_data/iss_serializer.js' +import { deserializeShadow } from 'src/services/theme_data/iss_deserializer.js' +import { getCssShadow, getCssShadowFilter } from 'src/services/theme_data/css_utils.js' +import { findShadow, findColor } from 'src/services/theme_data/theme_data_3.service.js' import { library } from '@fortawesome/fontawesome-svg-core' -import { throttle } from 'lodash' +import { throttle, flattenDeep } from 'lodash' import { faTimes, faChevronDown, @@ -21,50 +26,83 @@ library.add( faPlus ) -const toModel = (object = {}) => ({ - x: 0, - y: 0, - blur: 0, - spread: 0, - inset: false, - color: '#000000', - alpha: 1, - ...object -}) +const toModel = (input) => { + if (typeof input === 'object') { + return { + x: 0, + y: 0, + blur: 0, + spread: 0, + inset: false, + color: '#000000', + alpha: 1, + ...input + } + } else if (typeof input === 'string') { + return input + } +} export default { props: [ - 'modelValue', 'fallback', 'separateInset', 'noPreview', 'disabled' + 'modelValue', + 'fallback', + 'separateInset', + 'noPreview', + 'disabled', + 'staticVars', + 'compact' ], emits: ['update:modelValue', 'subShadowSelected'], data () { return { selectedId: 0, - // TODO there are some bugs regarding display of array (it's not getting updated when deleting for some reason) - cValue: (this.modelValue ?? this.fallback ?? []).map(toModel) + invalid: false } }, components: { ColorInput, OpacityInput, Select, + SelectMotion, Checkbox, Popover, ComponentPreview }, - beforeUpdate () { - this.cValue = (this.modelValue ?? this.fallback ?? []).map(toModel) - }, computed: { - selected () { - const selected = this.cValue[this.selectedId] - if (selected) { - return { ...selected } + cValue: { + get () { + return (this.modelValue ?? this.fallback ?? []).map(toModel) + }, + set (newVal) { + this.$emit('update:modelValue', newVal) + } + }, + selectedType: { + get () { + return typeof this.selected + }, + set (newType) { + this.selected = toModel(newType === 'object' ? {} : '') + } + }, + selected: { + get () { + const selected = this.cValue[this.selectedId] + if (selected && typeof selected === 'object') { + return { ...selected } + } else if (typeof selected === 'string') { + return selected + } + return null + }, + set (value) { + this.cValue[this.selectedId] = toModel(value) + this.$emit('update:modelValue', this.cValue) } - return null }, present () { - return this.selected != null && !this.usingFallback + return this.selected != null && this.modelValue != null }, shadowsAreNull () { return this.modelValue == null @@ -72,24 +110,43 @@ export default { currentFallback () { return this.fallback?.[this.selectedId] }, - moveUpValid () { - return this.selectedId > 0 - }, - moveDnValid () { - return this.selectedId < this.cValue.length - 1 - }, - usingFallback () { - return this.modelValue == null + getColorFallback () { + if (this.staticVars && this.selected?.color) { + try { + const computedColor = findColor(this.selected.color, { dynamicVars: {}, staticVars: this.staticVars }, true) + if (computedColor) return rgb2hex(computedColor) + return null + } catch (e) { + console.warn(e) + return null + } + } else { + return this.currentFallback?.color + } }, style () { - if (this.separateInset) { - return { - filter: getCssShadowFilter(this.cValue), - boxShadow: getCssShadow(this.cValue, true) + try { + let result + const serialized = this.cValue.map(x => serializeShadow(x)).join(',') + serialized.split(/,/).map(deserializeShadow) // validate + const expandedShadow = flattenDeep(findShadow(this.cValue, { dynamicVars: {}, staticVars: this.staticVars })) + const fixedShadows = expandedShadow.map(x => ({ ...x, color: console.log(x) || rgb2hex(x.color) })) + + if (this.separateInset) { + result = { + filter: getCssShadowFilter(fixedShadows), + boxShadow: getCssShadow(fixedShadows, true) + } + } else { + result = { + boxShadow: getCssShadow(fixedShadows) + } } - } - return { - boxShadow: getCssShadow(this.cValue) + this.invalid = false + return result + } catch (e) { + console.error('Invalid shadow', e) + this.invalid = true } } }, @@ -99,34 +156,25 @@ export default { } }, methods: { + getNewSubshadow () { + return toModel(this.selected) + }, + onSelectChange (id) { + this.selectedId = id + }, + getSubshadowLabel (shadow, index) { + if (typeof shadow === 'object') { + return shadow?.name ?? this.$t('settings.style.shadows.shadow_id', { value: index }) + } else if (typeof shadow === 'string') { + return shadow || this.$t('settings.style.shadows.empty_expression') + } + }, updateProperty: throttle(function (prop, value) { this.cValue[this.selectedId][prop] = value if (prop === 'inset' && value === false && this.separateInset) { this.cValue[this.selectedId].spread = 0 } this.$emit('update:modelValue', this.cValue) - }, 100), - add () { - this.cValue.push(toModel(this.selected)) - this.selectedId = Math.max(this.cValue.length - 1, 0) - this.$emit('update:modelValue', this.cValue) - }, - del () { - this.cValue.splice(this.selectedId, 1) - this.selectedId = this.cValue.length === 0 ? undefined : Math.max(this.selectedId - 1, 0) - this.$emit('update:modelValue', this.cValue) - }, - moveUp () { - const movable = this.cValue.splice(this.selectedId, 1)[0] - this.cValue.splice(this.selectedId - 1, 0, movable) - this.selectedId -= 1 - this.$emit('update:modelValue', this.cValue) - }, - moveDn () { - const movable = this.cValue.splice(this.selectedId, 1)[0] - this.cValue.splice(this.selectedId + 1, 0, movable) - this.selectedId += 1 - this.$emit('update:modelValue', this.cValue) - } + }, 100) } } diff --git a/src/components/shadow_control/shadow_control.scss b/src/components/shadow_control/shadow_control.scss @@ -1,11 +1,29 @@ -.settings-modal .settings-modal-panel .shadow-control { - display: flex; - flex-wrap: wrap; +.ShadowControl { + display: grid; + grid-template-columns: 10em 1fr 1fr; + grid-template-rows: 1fr; + grid-template-areas: "selector preview tweak"; + grid-gap: 0.5em; justify-content: stretch; - grid-gap: 0.25em; - margin-bottom: 1em; + + &.-compact { + grid-template-columns: 10em 1fr; + grid-template-rows: auto auto; + grid-template-areas: + "selector preview" + "tweak tweak"; + + &.-no-preview { + grid-template-columns: 1fr; + grid-template-rows: 10em 1fr; + grid-template-areas: + "selector" + "tweak"; + } + } .shadow-switcher { + grid-area: selector; order: 1; flex: 1 0 6em; min-width: 6em; @@ -16,27 +34,18 @@ .shadow-list { flex: 1 0 auto; } - - .arrange-buttons { - flex: 0 0 auto; - display: grid; - grid-auto-columns: 1fr; - grid-auto-flow: column; - margin-top: 0.25em; - - .button-default { - margin: 0; - padding: 0; - } - } } .shadow-tweak { + grid-area: tweak; order: 3; flex: 2 0 10em; min-width: 10em; margin-left: 0.125em; margin-right: 0.125em; + display: grid; + grid-template-rows: auto 1fr; + grid-gap: 0.25em; /* hack */ .input-boolean { @@ -52,6 +61,11 @@ flex: 1 0 5em; } + .shadow-expression { + width: 100%; + height: 100%; + } + .id-control { align-items: stretch; @@ -69,6 +83,10 @@ } &.-no-preview { + grid-template-columns: 10em 1fr; + grid-template-rows: 1fr; + grid-template-areas: "selector tweak"; + .shadow-tweak { order: 0; flex: 2 0 8em; @@ -91,15 +109,14 @@ } .shadow-preview { - order: 2; - flex: 3 3 15em; - min-width: 10em; + grid-area: preview; + min-width: 25em; margin-left: 0.125em; align-self: start; + justify-self: center; } } .inset-tooltip { - padding: 0.5em; max-width: 30em; } diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue @@ -1,10 +1,11 @@ <template> <div - class="label shadow-control" - :class="{ disabled: disabled || !present, '-no-preview': noPreview }" + class="ShadowControl label shadow-control" + :class="{ disabled: disabled || !present, '-no-preview': noPreview, '-compact': compact }" > <ComponentPreview v-if="!noPreview" + :invalid="invalid" class="shadow-preview" :shadow-control="true" :shadow="selected" @@ -17,8 +18,8 @@ id="shadow-list" v-model="selectedId" class="shadow-list" - size="10" - :disabled="shadowsAreNull" + size="4" + :disabled="disabled || shadowsAreNull" > <option v-for="(shadow, index) in cValue" @@ -26,227 +27,208 @@ :value="index" :class="{ '-active': index === Number(selectedId) }" > - {{ shadow?.name ?? $t('settings.style.shadows.shadow_id', { value: index }) }} + {{ getSubshadowLabel(shadow, index) }} </option> </Select> - <div - class="id-control btn-group arrange-buttons" - > - <button - class="btn button-default" - :disabled="disabled || shadowsAreNull" - @click="add" - > - <FAIcon - fixed-width - icon="plus" - /> - </button> - <button - class="btn button-default" - :disabled="disabled || !moveUpValid" - :class="{ disabled: disabled || !moveUpValid }" - @click="moveUp" - > - <FAIcon - fixed-width - icon="chevron-up" - /> - </button> - <button - class="btn button-default" - :disabled="disabled || !moveDnValid" - :class="{ disabled: disabled || !moveDnValid }" - @click="moveDn" - > - <FAIcon - fixed-width - icon="chevron-down" - /> - </button> - <button - class="btn button-default" - :disabled="disabled || !present" - :class="{ disabled: disabled || !present }" - @click="del" - > - <FAIcon - fixed-width - icon="times" - /> - </button> - </div> + <SelectMotion + v-model="cValue" + :selected-id="selectedId" + :get-add-value="getNewSubshadow" + :disabled="disabled" + @update:selectedId="onSelectChange" + /> </div> <div class="shadow-tweak"> - <div - :class="{ disabled: disabled || !present }" - class="name-control style-control" - > - <label - for="name" - class="label" - :class="{ faint: disabled || !present }" - > - {{ $t('settings.style.shadows.name') }} - </label> - <input - id="name" - :value="selected?.name" - :disabled="disabled || !present" - :class="{ disabled: disabled || !present }" - name="name" - class="input input-string" - @input="e => updateProperty('name', e.target.value)" - > - </div> - <div - :disabled="disabled || !present" - class="inset-control style-control" - > - <Checkbox - id="inset" - :value="selected?.inset" - :disabled="disabled || !present" - name="inset" - class="input-inset input-boolean" - @input="e => updateProperty('inset', e.target.checked)" - > - <template #before> - {{ $t('settings.style.shadows.inset') }} - </template> - </Checkbox> - </div> - <div + <Select + v-model="selectedType" :disabled="disabled || !present" - :class="{ disabled: disabled || !present }" - class="blur-control style-control" > - <label - for="blur" - class="label" - :class="{ faint: disabled || !present }" + <option value="object"> + {{ $t('settings.style.shadows.raw') }} + </option> + <option value="string"> + {{ $t('settings.style.shadows.expression') }} + </option> + </Select> + <template v-if="selectedType === 'string'"> + <textarea + v-model="selected" + class="input shadow-expression" + :disabled="disabled || shadowsAreNull" + :class="{disabled: disabled || shadowsAreNull}" + /> + </template> + <template v-else-if="selectedType === 'object'"> + <div + :class="{ disabled: disabled || !present }" + class="name-control style-control" > - {{ $t('settings.style.shadows.blur') }} - </label> - <input - id="blur" - :value="selected?.blur" + <label + for="name" + class="label" + :class="{ faint: disabled || !present }" + > + {{ $t('settings.style.shadows.name') }} + </label> + <input + id="name" + :value="selected?.name" + :disabled="disabled || !present" + :class="{ disabled: disabled || !present }" + name="name" + class="input input-string" + @input="e => updateProperty('name', e.target.value)" + > + </div> + <div :disabled="disabled || !present" - :class="{ disabled: disabled || !present }" - name="blur" - class="input input-range" - type="range" - max="20" - min="0" - @input="e => updateProperty('blur', e.target.value)" + class="inset-control style-control" > - <input - :value="selected?.blur" - class="input input-number -small" + <Checkbox + id="inset" + :value="selected?.inset" + :disabled="disabled || !present" + name="inset" + class="input-inset input-boolean" + @input="e => updateProperty('inset', e.target.checked)" + > + <template #before> + {{ $t('settings.style.shadows.inset') }} + </template> + </Checkbox> + </div> + <div :disabled="disabled || !present" :class="{ disabled: disabled || !present }" - type="number" - min="0" - @input="e => updateProperty('blur', e.target.value)" - > - </div> - <div - class="spread-control style-control" - :class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }" - > - <label - for="spread" - class="label" - :class="{ faint: disabled || !present || (separateInset && !selected?.inset) }" - > - {{ $t('settings.style.shadows.spread') }} - </label> - <input - id="spread" - :value="selected?.spread" - :disabled="disabled || !present || (separateInset && !selected?.inset)" - :class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }" - name="spread" - class="input input-range" - type="range" - max="20" - min="-20" - @input="e => updateProperty('spread', e.target.value)" + class="blur-control style-control" > - <input - :value="selected?.spread" - class="input input-number -small" + <label + for="blur" + class="label" + :class="{ faint: disabled || !present }" + > + {{ $t('settings.style.shadows.blur') }} + </label> + <input + id="blur" + :value="selected?.blur" + :disabled="disabled || !present" + :class="{ disabled: disabled || !present }" + name="blur" + class="input input-range" + type="range" + max="20" + min="0" + @input="e => updateProperty('blur', e.target.value)" + > + <input + :value="selected?.blur" + class="input input-number -small" + :disabled="disabled || !present" + :class="{ disabled: disabled || !present }" + type="number" + min="0" + @input="e => updateProperty('blur', e.target.value)" + > + </div> + <div + class="spread-control style-control" :class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }" - :disabled="{ disabled: disabled || !present || (separateInset && !selected?.inset) }" - type="number" - @input="e => updateProperty('spread', e.target.value)" > - </div> - <ColorInput - :model-value="selected?.color" - :disabled="disabled || !present" - :label="$t('settings.style.common.color')" - :fallback="currentFallback?.color" - :show-optional-tickbox="false" - name="shadow" - @update:modelValue="e => updateProperty('color', e)" - /> - <OpacityInput - :model-value="selected?.alpha" - :disabled="disabled || !present" - @update:modelValue="e => updateProperty('alpha', e)" - /> - <i18n-t - scope="global" - keypath="settings.style.shadows.hintV3" - :class="{ faint: disabled || !present }" - tag="p" - > - <code>--variable,mod</code> - </i18n-t> - <Popover - v-if="separateInset" - trigger="hover" - > - <template #trigger> - <div - class="inset-alert alert warning" + <label + for="spread" + class="label" + :class="{ faint: disabled || !present || (separateInset && !selected?.inset) }" > - <FAIcon icon="exclamation-triangle" /> - &nbsp; - {{ $t('settings.style.shadows.filter_hint.avatar_inset_short') }} - </div> - </template> - <template #content> - <div class="inset-tooltip"> - <i18n-t - scope="global" - keypath="settings.style.shadows.filter_hint.always_drop_shadow" - tag="p" - > - <code>filter: drop-shadow()</code> - </i18n-t> - <p>{{ $t('settings.style.shadows.filter_hint.avatar_inset') }}</p> - <i18n-t - scope="global" - keypath="settings.style.shadows.filter_hint.drop_shadow_syntax" - tag="p" - > - <code>drop-shadow</code> - <code>spread-radius</code> - <code>inset</code> - </i18n-t> - <i18n-t - scope="global" - keypath="settings.style.shadows.filter_hint.inset_classic" - tag="p" + {{ $t('settings.style.shadows.spread') }} + </label> + <input + id="spread" + :value="selected?.spread" + :class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }" + :disabled="disabled || !present || (separateInset && !selected?.inset)" + name="spread" + class="input input-range" + type="range" + max="20" + min="-20" + @input="e => updateProperty('spread', e.target.value)" + > + <input + :value="selected?.spread" + class="input input-number -small" + :class="{ disabled: disabled || !present || (separateInset && !selected?.inset) }" + :disabled="disabled || !present || (separateInset && !selected?.inset)" + type="number" + @input="e => updateProperty('spread', e.target.value)" + > + </div> + <ColorInput + :model-value="selected?.color" + :disabled="disabled || !present" + :label="$t('settings.style.common.color')" + :fallback="getColorFallback" + :show-optional-checkbox="false" + name="shadow" + @update:modelValue="e => updateProperty('color', e)" + /> + <OpacityInput + :model-value="selected?.alpha" + :disabled="disabled || !present" + @update:modelValue="e => updateProperty('alpha', e)" + /> + <i18n-t + scope="global" + keypath="settings.style.shadows.hintV3" + :class="{ faint: disabled || !present }" + tag="p" + > + <code>--variable,mod</code> + </i18n-t> + <Popover + v-if="separateInset" + trigger="hover" + > + <template #trigger> + <div + class="inset-alert alert warning" > - <code>box-shadow</code> - </i18n-t> - <p>{{ $t('settings.style.shadows.filter_hint.spread_zero') }}</p> - </div> - </template> - </Popover> + <FAIcon icon="exclamation-triangle" /> + &nbsp; + {{ $t('settings.style.shadows.filter_hint.avatar_inset_short') }} + </div> + </template> + <template #content> + <div class="inset-tooltip tooltip"> + <i18n-t + scope="global" + keypath="settings.style.shadows.filter_hint.always_drop_shadow" + tag="p" + > + <code>filter: drop-shadow()</code> + </i18n-t> + <p>{{ $t('settings.style.shadows.filter_hint.avatar_inset') }}</p> + <i18n-t + scope="global" + keypath="settings.style.shadows.filter_hint.drop_shadow_syntax" + tag="p" + > + <code>drop-shadow</code> + <code>spread-radius</code> + <code>inset</code> + </i18n-t> + <i18n-t + scope="global" + keypath="settings.style.shadows.filter_hint.inset_classic" + tag="p" + > + <code>box-shadow</code> + </i18n-t> + <p>{{ $t('settings.style.shadows.filter_hint.spread_zero') }}</p> + </div> + </template> + </Popover> + </template> </div> </div> </template> diff --git a/src/components/tab_switcher/tab.style.js b/src/components/tab_switcher/tab.style.js @@ -14,14 +14,14 @@ export default { { directives: { background: '--fg', - shadow: ['--defaultButtonShadow', '--defaultButtonBevel'], + shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel'], roundness: 3 } }, { state: ['hover'], directives: { - shadow: ['--defaultButtonHoverGlow', '--defaultButtonBevel'] + shadow: ['--buttonDefaultHoverGlow', '--buttonDefaultBevel'] } }, { @@ -33,14 +33,14 @@ export default { { state: ['hover', 'active'], directives: { - shadow: ['--defaultButtonShadow', '--defaultButtonBevel'] + shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel'] } }, { state: ['disabled'], directives: { - background: '$blend(--inheritedBackground, 0.25, --parent)', - shadow: ['--defaultButtonBevel'] + background: '$blend(--inheritedBackground 0.25 --parent)', + shadow: ['--buttonDefaultBevel'] } }, { diff --git a/src/components/tab_switcher/tab_switcher.scss b/src/components/tab_switcher/tab_switcher.scss @@ -119,7 +119,7 @@ .tab { flex: 1; box-sizing: content-box; - min-width: 10em; + max-width: 9em; min-width: 1px; border-top-right-radius: 0; border-bottom-right-radius: 0; @@ -128,12 +128,22 @@ margin-right: -200px; margin-left: 1em; + &:not(.active) { + margin-top: 0; + margin-left: 1.5em; + } + @media all and (max-width: 800px) { padding-left: 0.25em; padding-right: calc(0.25em + 200px); margin-right: calc(0.25em - 200px); margin-left: 0.25em; + &:not(.active) { + margin-top: 0; + margin-left: 0.5em; + } + .text { display: none; } @@ -181,6 +191,7 @@ &:not(.active) { z-index: 4; + margin-top: 0.25em; &:hover { z-index: 6; diff --git a/src/components/tooltip/tooltip.vue b/src/components/tooltip/tooltip.vue @@ -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> diff --git a/src/components/user_card/user_card.style.js b/src/components/user_card/user_card.style.js @@ -1,6 +1,7 @@ export default { name: 'UserCard', selector: '.user-card', + notEditable: true, validInnerComponents: [ 'Text', 'Link', @@ -25,7 +26,7 @@ export default { color: '#000000', alpha: 0.6 }], - '--profileTint': 'color | $alpha(--background, 0.5)' + '--profileTint': 'color | $alpha(--background 0.5)' } }, { diff --git a/src/i18n/en.json b/src/i18n/en.json @@ -701,6 +701,7 @@ "use_websockets": "Use websockets (Realtime updates)", "text": "Text", "theme": "Theme", + "theme_old": "Theme editor (old)", "theme_help": "Use hex color codes (#rrggbb) to customize your color theme.", "theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.", "theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.", @@ -750,11 +751,81 @@ "more_settings": "More settings", "style": { "custom_theme_used": "(Custom theme)", + "custom_style_used": "(Custom style)", + "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.", "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", "themes3": { "define": "Override", + "palette": { + "label": "Color schemes", + "name_label": "Color scheme name", + "import": "Import palette", + "export": "Export palette", + "apply": "Apply palette", + "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", + "wallpaper": "Wallpaper", + "v2_unsupported": "Older v2 themes don't support palettes. Switch to v3 theme to make use of palettes", + "bundled": "Bundled palettes", + "style": "Palettes provided by selected style", + "user": "Custom palette", + "imported": "Imported" + }, + "editor": { + "title": "Style editor", + "reset_style": "Reset", + "load_style": "Open from file", + "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", + "contrast": "Text contrast", + "roundness": "Roundness", + "opacity": "Opacity", + "border_color": "Border color", + "include_in_rule": "Add to rule", + "test_string": "TEST", + "invalid": "Invalid", + "refresh_preview": "Refresh preview", + "apply_preview": "Apply", + "text_auto": { + "label": "Auto-contrast", + "no-preserve": "Black or White", + "preserve": "Keep color", + "no-auto": "Disabled" + }, + "component_tab": "Components style", + "palette_tab": "Color schemes", + "variables_tab": "Variables (Advanced)", + "variables": { + "label": "Variables", + "name_label": "Name:", + "type_label": "Type:", + "type_shadow": "Shadow", + "type_color": "Color", + "type_generic": "Generic", + "virtual_color": "Variable color value" + } + }, "hacks": { "underlay_overrides": "Change underlay", "underlay_override_mode_none": "Theme default", @@ -877,11 +948,18 @@ "override": "Override", "shadow_id": "Shadow #{value}", "offset": "Shadow offset", + "zoom": "Zoom", + "offset-x": "x:", + "offset-y": "y:", "light_grid": "Use light checkerboard", + "color_override": "Use different color", "name": "Name", "blur": "Blur", "spread": "Spread", "inset": "Inset", + "raw": "Plain shadow", + "expression": "Expression (advanced)", + "empty_expression": "Empty expression", "hintV3": "For shadows you can also use the {0} notation to use other color slot.", "filter_hint": { "always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.", diff --git a/src/modules/config.js b/src/modules/config.js @@ -47,6 +47,10 @@ export const defaultState = { customThemeSource: undefined, // "source", stores original theme data // V3 + style: null, + styleCustomData: null, + palette: null, + paletteCustomData: null, 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 theme3hacks: { // Hacks, user overrides that are independent of theme used diff --git a/src/modules/instance.js b/src/modules/instance.js @@ -42,6 +42,9 @@ const defaultState = { registrationOpen: true, server: 'http://localhost:4040/', textlimit: 5000, + themesIndex: undefined, + stylesIndex: undefined, + palettesIndex: undefined, themeData: undefined, // used for theme editor v2 vapidPublicKey: undefined, @@ -96,6 +99,8 @@ const defaultState = { sidebarRight: false, subjectLineBehavior: 'email', theme: 'pleroma-dark', + palette: null, + style: null, emojiReactionsScale: 0.5, textSize: '14px', emojiSize: '2.2rem', diff --git a/src/modules/interface.js b/src/modules/interface.js @@ -1,10 +1,23 @@ -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 { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js' +import { deserialize } from '../services/theme_data/iss_deserializer.js' + +// helper for debugging +// eslint-disable-next-line no-unused-vars +const toValue = (x) => JSON.parse(JSON.stringify(x === undefined ? 'null' : x)) const defaultState = { localFonts: null, themeApplied: false, + themeVersion: 'v3', + styleNameUsed: null, + styleDataUsed: null, + useStylePalette: false, // hack for applying styles from appearance tab + paletteNameUsed: null, + paletteDataUsed: null, + themeNameUsed: null, + themeDataUsed: null, temporaryChangesTimeoutId: null, // used for temporary options that revert after a timeout temporaryChangesConfirm: () => {}, // used for applying temporary options temporaryChangesRevert: () => {}, // used for reverting temporary options @@ -212,142 +225,450 @@ const interfaceMod = { setLastTimeline ({ commit }, 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) + commit('setInstanceOption', { name: 'palettesIndex', value: { _error: e } }) + return Promise.resolve({}) + } + }, + setPalette ({ dispatch, commit }, value) { + dispatch('resetThemeV3Palette') + dispatch('resetThemeV2') + + commit('setOption', { name: 'palette', value }) + + dispatch('applyTheme', { recompile: true }) + }, + setPaletteCustom ({ dispatch, commit }, value) { + dispatch('resetThemeV3Palette') + dispatch('resetThemeV2') + + commit('setOption', { name: 'paletteCustomData', value }) + + dispatch('applyTheme', { recompile: true }) + }, + async fetchStylesIndex ({ commit, state }) { + try { + const value = await getResourcesIndex( + '/static/styles/index.json', + deserialize + ) + commit('setInstanceOption', { name: 'stylesIndex', value }) + return value + } catch (e) { + console.error('Could not fetch styles index', e) + commit('setInstanceOption', { name: 'stylesIndex', value: { _error: e } }) + return Promise.resolve({}) + } + }, + setStyle ({ dispatch, commit, state }, value) { + dispatch('resetThemeV3') + dispatch('resetThemeV2') + dispatch('resetThemeV3Palette') + + commit('setOption', { name: 'style', value }) + state.useStylePalette = true + + dispatch('applyTheme', { recompile: true }).then(() => { + state.useStylePalette = false + }) + }, + setStyleCustom ({ dispatch, commit, state }, value) { + dispatch('resetThemeV3') + dispatch('resetThemeV2') + dispatch('resetThemeV3Palette') + + commit('setOption', { name: 'styleCustomData', value }) + + state.useStylePalette = true + dispatch('applyTheme', { recompile: true }).then(() => { + state.useStylePalette = false + }) + }, + 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) + commit('setInstanceOption', { name: 'themesIndex', value: { _error: e } }) + return Promise.resolve({}) + } + }, + setTheme ({ dispatch, commit }, value) { + dispatch('resetThemeV3') + dispatch('resetThemeV3Palette') + dispatch('resetThemeV2') + + commit('setOption', { name: 'theme', value }) + + dispatch('applyTheme', { recompile: true }) + }, + setThemeCustom ({ dispatch, commit }, value) { + dispatch('resetThemeV3') + dispatch('resetThemeV3Palette') + dispatch('resetThemeV2') + + commit('setOption', { name: 'customTheme', value }) + commit('setOption', { name: 'customThemeSource', value }) + + dispatch('applyTheme', { recompile: true }) + }, + 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 getThemeData ({ dispatch, commit, rootState, state }) { + 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 == null) { + result.dataUsed = null + return result + } + + let fetchFunc = index[result.nameUsed] + // Fallbacks + if (!fetchFunc) { + if (resource === 'style' || resource === 'palette') { + return result + } + const newName = Object.keys(index)[0] + fetchFunc = index[newName] + console.warn(`${capitalizedResource} with id '${state.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 + } + const { - theme: instanceThemeName + style: instanceStyleName, + palette: instancePaletteName + } = rootState.instance + + let { + theme: instanceThemeV2Name, + themesIndex, + stylesIndex, + palettesIndex } = rootState.instance const { - theme: userThemeName, - customTheme: userThemeSnapshot, - customThemeSource: userThemeSource, - forceThemeRecompilation, - themeDebug, - theme3hacks + style: userStyleName, + styleCustomData: userStyleCustomData, + palette: userPaletteName, + paletteCustomData: userPaletteCustomData } = rootState.config - const actualThemeName = userThemeName || instanceThemeName + let { + theme: userThemeV2Name, + customTheme: userThemeV2Snapshot, + customThemeSource: userThemeV2Source + } = rootState.config - const forceRecompile = forceThemeRecompilation || recompile + let majorVersionUsed - 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 - }) + console.debug( + `User V3 palette: ${userPaletteName}, style: ${userStyleName} , custom: ${!!userStyleCustomData}` + ) + console.debug( + `User V2 name: ${userThemeV2Name}, source: ${!!userThemeV2Source}, snapshot: ${!!userThemeV2Snapshot}` + ) + + console.debug(`Instance V3 palette: ${instancePaletteName}, style: ${instanceStyleName}`) + console.debug('Instance V2 theme: ' + instanceThemeV2Name) + + if (userPaletteName || userPaletteCustomData || + userStyleName || userStyleCustomData || + ( + // User V2 overrides instance V3 + (instancePaletteName || + instanceStyleName) && + instanceThemeV2Name == null && + userThemeV2Name == null + ) + ) { + // Palette and/or style overrides V2 themes + instanceThemeV2Name = null + userThemeV2Name = null + userThemeV2Source = null + userThemeV2Snapshot = null + + majorVersionUsed = 'v3' + } else if ( + (userThemeV2Name || + userThemeV2Snapshot || + userThemeV2Source || + instanceThemeV2Name) + ) { + majorVersionUsed = 'v2' } else { - throw new Error('Cannot load any theme!') + // if all fails fallback to v3 + majorVersionUsed = 'v3' } + if (majorVersionUsed === 'v3') { + const result = await Promise.all([ + dispatch('fetchPalettesIndex'), + dispatch('fetchStylesIndex') + ]) + + palettesIndex = result[0] + stylesIndex = result[1] + } else { + // Promise.all just to be uniform with v3 + const result = await Promise.all([ + dispatch('fetchThemesIndex') + ]) + + themesIndex = result[0] + } + + state.themeVersion = majorVersionUsed + + console.debug('Version used', majorVersionUsed) + + if (majorVersionUsed === 'v3') { + state.themeDataUsed = null + state.themeNameUsed = null + + const style = await getData( + 'style', + stylesIndex, + userStyleCustomData, + userStyleName || instanceStyleName + ) + state.styleNameUsed = style.nameUsed + state.styleDataUsed = style.dataUsed + + let firstStylePaletteName = null + style + .dataUsed + ?.filter(x => x.component === '@palette') + .map(x => { + const cleanDirectives = Object.fromEntries( + Object + .entries(x.directives) + .filter(([k, v]) => k) + ) + + return { name: x.variant, ...cleanDirectives } + }) + .forEach(palette => { + const key = 'style.' + palette.name.toLowerCase().replace(/ /g, '_') + if (!firstStylePaletteName) firstStylePaletteName = key + palettesIndex[key] = () => Promise.resolve(palette) + }) + + const palette = await getData( + 'palette', + palettesIndex, + userPaletteCustomData, + state.useStylePalette ? firstStylePaletteName : (userPaletteName || instancePaletteName) + ) + + if (state.useStylePalette) { + commit('setOption', { name: 'palette', value: firstStylePaletteName }) + } + + state.paletteNameUsed = palette.nameUsed + state.paletteDataUsed = palette.dataUsed + + if (state.paletteDataUsed) { + state.paletteDataUsed.link = state.paletteDataUsed.link || state.paletteDataUsed.accent + state.paletteDataUsed.accent = state.paletteDataUsed.accent || state.paletteDataUsed.link + } + if (Array.isArray(state.paletteDataUsed)) { + const [ + name, + bg, + fg, + text, + link, + cRed = '#FF0000', + cGreen = '#00FF00', + cBlue = '#0000FF', + cOrange = '#E3FF00' + ] = palette.dataUsed + state.paletteDataUsed = { + name, + bg, + fg, + text, + link, + accent: link, + cRed, + cBlue, + cGreen, + cOrange + } + } + console.debug('Palette data used', palette.dataUsed) + } else { + state.styleNameUsed = null + state.styleDataUsed = null + state.paletteNameUsed = null + state.paletteDataUsed = null + + const theme = await getData( + 'theme', + themesIndex, + userThemeV2Source || userThemeV2Snapshot, + userThemeV2Name || instanceThemeV2Name + ) + state.themeNameUsed = theme.nameUsed + state.themeDataUsed = theme.dataUsed + } + }, + async applyTheme ( + { dispatch, commit, rootState, state }, + { recompile = false } = {} + ) { + const { + forceThemeRecompilation, + themeDebug, + theme3hacks + } = rootState.config // If we're not not forced to recompile try using // cache (tryLoadCache return true if load successful) - if (!forceRecompile && !themeDebug && tryLoadCache()) { - commit('setThemeApplied') - return + + const forceRecompile = forceThemeRecompilation || recompile + if (!forceRecompile && !themeDebug && await tryLoadCache()) { + return commit('setThemeApplied') } + await dispatch('getThemeData') - promise - .then(realThemeData => { - const theme2ruleset = convertTheme2To3(realThemeData) + const paletteIss = (() => { + if (!state.paletteDataUsed) return null + const result = { + component: 'Root', + directives: {} + } - if (saveData) { - commit('setOption', { name: 'theme', value: themeName || actualThemeName }) - commit('setOption', { name: 'customTheme', value: realThemeData }) - commit('setOption', { name: 'customThemeSource', value: realThemeData }) - } - 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 - } - }) + Object + .entries(state.paletteDataUsed) + .filter(([k]) => k !== 'name') + .forEach(([k, v]) => { + let issRootDirectiveName + switch (k) { + case 'background': + issRootDirectiveName = 'bg' 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) - } + case 'foreground': + issRootDirectiveName = 'fg' break - } + default: + issRootDirectiveName = k } + result.directives['--' + issRootDirectiveName] = 'color | ' + v }) + return result + })() - const ruleset = [ - ...theme2ruleset, - ...hacks - ] + const theme2ruleset = state.themeDataUsed && convertTheme2To3(normalizeThemeData(state.themeDataUsed)) + const hacks = [] - applyTheme( - ruleset, - () => commit('setThemeApplied'), - themeDebug - ) - }) + 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, + state.styleDataUsed, + paletteIss, + hacks + ].filter(x => x) - return promise + return applyTheme( + rulesetArray.flat(), + () => commit('setThemeApplied'), + themeDebug + ) } } } @@ -355,19 +676,6 @@ const interfaceMod = { export default interfaceMod export const normalizeThemeData = (input) => { - if (Array.isArray(input)) { - const themeData = { colors: {} } - themeData.colors.bg = input[1] - themeData.colors.fg = input[2] - themeData.colors.text = input[3] - themeData.colors.link = input[4] - themeData.colors.cRed = input[5] - themeData.colors.cGreen = input[6] - themeData.colors.cBlue = input[7] - themeData.colors.cOrange = input[8] - return generatePreset(themeData).theme - } - let themeData, themeSource if (input.themeFileVerison === 1) { @@ -381,7 +689,10 @@ export const normalizeThemeData = (input) => { // We got passed a full theme file themeData = input.theme themeSource = input.source - } else if (Object.prototype.hasOwnProperty.call(input, 'themeEngineVersion')) { + } else if ( + Object.prototype.hasOwnProperty.call(input, 'themeEngineVersion') || + Object.prototype.hasOwnProperty.call(input, 'colors') + ) { // We got passed a source/snapshot themeData = input themeSource = input diff --git a/src/services/export_import/export_import.js b/src/services/export_import/export_import.js @@ -2,15 +2,23 @@ import utf8 from 'utf8' export const newExporter = ({ filename = 'data', + mime = 'application/json', + extension = '.json', getExportedObject }) => ({ exportData () { - const stringified = utf8.encode(JSON.stringify(getExportedObject(), null, 2)) // Pretty-print and indent with 2 spaces + let stringified + if (mime === 'application/json') { + stringified = utf8.encode(JSON.stringify(getExportedObject(), null, 2)) // Pretty-print and indent with 2 spaces + } else { + stringified = utf8.encode(getExportedObject()) // Pretty-print and indent with 2 spaces + } // Create an invisible link with a data url and simulate a click const e = document.createElement('a') - e.setAttribute('download', `${filename}.json`) - e.setAttribute('href', 'data:application/json;base64,' + window.btoa(stringified)) + const realFilename = typeof filename === 'function' ? filename() : filename + e.setAttribute('download', `${realFilename}.${extension}`) + e.setAttribute('href', `data:${mime};base64, ${window.btoa(stringified)}`) e.style.display = 'none' document.body.appendChild(e) @@ -20,6 +28,8 @@ export const newExporter = ({ }) export const newImporter = ({ + accept = '.json', + parser = (string) => JSON.parse(string), onImport, onImportFailure, validator = () => true @@ -27,18 +37,19 @@ export const newImporter = ({ importData () { const filePicker = document.createElement('input') filePicker.setAttribute('type', 'file') - filePicker.setAttribute('accept', '.json') + filePicker.setAttribute('accept', accept) filePicker.addEventListener('change', event => { if (event.target.files[0]) { + const filename = event.target.files[0].name // eslint-disable-next-line no-undef const reader = new FileReader() reader.onload = ({ target }) => { try { - const parsed = JSON.parse(target.result) - const validationResult = validator(parsed) + const parsed = parser(target.result, filename) + const validationResult = validator(parsed, filename) if (validationResult === true) { - onImport(parsed) + onImport(parsed, filename) } else { onImportFailure({ validationResult }) } diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js @@ -1,8 +1,9 @@ -import { hex2rgb } from '../color_convert/color_convert.js' import { init, getEngineChecksum } from '../theme_data/theme_data_3.service.js' import { getCssRules } from '../theme_data/css_utils.js' import { defaultState } from '../../modules/config.js' import { chunk } from 'lodash' +import pako from 'pako' +import localforage from 'localforage' // On platforms where this is not supported, it will return undefined // Otherwise it will return an array @@ -52,29 +53,12 @@ export const generateTheme = (inputRuleset, callbacks, debug) => { const themes3 = init({ 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 }) getCssRules(themes3.eager, debug).forEach(rule => { // Hacks to support multiple selectors on same component - if (rule.match(/::-webkit-scrollbar-button/)) { - const parts = rule.split(/[{}]/g) - const newRule = [ - parts[0], - ', ', - parts[0].replace(/button/, 'thumb'), - ', ', - parts[0].replace(/scrollbar-button/, 'resizer'), - ' {', - parts[1], - '}' - ].join('') - onNewRule(newRule, false) - } else { - onNewRule(rule, false) - } + onNewRule(rule, false) }) onEagerFinished() @@ -88,22 +72,7 @@ export const generateTheme = (inputRuleset, callbacks, debug) => { const chunk = chunks[counter] Promise.all(chunk.map(x => x())).then(result => { getCssRules(result.filter(x => x), debug).forEach(rule => { - if (rule.match(/\.modal-view/)) { - const parts = rule.split(/[{}]/g) - const newRule = [ - parts[0], - ', ', - parts[0].replace(/\.modal-view/, '#modal'), - ', ', - parts[0].replace(/\.modal-view/, '.shout-panel'), - ' {', - parts[1], - '}' - ].join('') - onNewRule(newRule, true) - } else { - onNewRule(rule, true) - } + onNewRule(rule, true) }) // const t1 = performance.now() // console.debug('Chunk ' + counter + ' took ' + (t1 - t0) + 'ms') @@ -120,12 +89,15 @@ export const generateTheme = (inputRuleset, callbacks, debug) => { return { lazyProcessFunc: processChunk } } -export const tryLoadCache = () => { - const json = localStorage.getItem('pleroma-fe-theme-cache') - if (!json) return null +export const tryLoadCache = async () => { + console.info('Trying to load compiled theme data from cache') + const data = await localforage.getItem('pleromafe-theme-cache') + if (!data) return null let cache try { - cache = JSON.parse(json) + const decoded = new TextDecoder().decode(pako.inflate(data)) + cache = JSON.parse(decoded) + console.info(`Loaded theme from cache, size=${cache}`) } catch (e) { console.error('Failed to decode theme cache:', e) return false @@ -150,16 +122,28 @@ export const applyTheme = (input, onFinish = (data) => {}, debug) => { const eagerStyles = createStyleSheet(EAGER_STYLE_ID) const lazyStyles = createStyleSheet(LAZY_STYLE_ID) + const insertRule = (styles, rule) => { + if (rule.indexOf('webkit') >= 0) { + try { + styles.sheet.insertRule(rule, 'index-max') + styles.rules.push(rule) + } catch (e) { + console.warn('Can\'t insert rule due to lack of support', e) + } + } else { + styles.sheet.insertRule(rule, 'index-max') + styles.rules.push(rule) + } + } + const { lazyProcessFunc } = generateTheme( input, { onNewRule (rule, isLazy) { if (isLazy) { - lazyStyles.sheet.insertRule(rule, 'index-max') - lazyStyles.rules.push(rule) + insertRule(lazyStyles, rule) } else { - eagerStyles.sheet.insertRule(rule, 'index-max') - eagerStyles.rules.push(rule) + insertRule(eagerStyles, rule) } }, onEagerFinished () { @@ -169,16 +153,10 @@ export const applyTheme = (input, onFinish = (data) => {}, debug) => { adoptStyleSheets([eagerStyles, lazyStyles]) const cache = { engineChecksum: getEngineChecksum(), data: [eagerStyles.rules, lazyStyles.rules] } onFinish(cache) - try { - 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) - } + const compress = (js) => { + return pako.deflate(JSON.stringify(js)) } + localforage.setItem('pleromafe-theme-cache', compress(cache)) } }, debug @@ -252,64 +230,66 @@ export const applyConfig = (input, i18n) => { styleSheet.toString() styleSheet.insertRule(`:root { ${rules} }`, 'index-max') + // TODO find a way to make this not apply to theme previews if (Object.prototype.hasOwnProperty.call(config, 'forcedRoundness')) { - styleSheet.insertRule(` * { + styleSheet.insertRule(` *:not(.preview-block) { --roundness: var(--forcedRoundness) !important; }`, 'index-max') } } -export const getThemes = () => { +export const getResourcesIndex = async (url, parser = JSON.parse) => { const cache = 'no-store' - - return window.fetch('/static/styles.json', { cache }) - .then((data) => data.json()) - .then((themes) => { - return Object.entries(themes).map(([k, v]) => { - let promise = null + const customUrl = url.replace(/\.(\w+)$/, '.custom.$1') + let builtin + let custom + + const resourceTransform = (resources) => { + return Object + .entries(resources) + .map(([k, v]) => { if (typeof v === 'object') { - promise = Promise.resolve(v) + return [k, () => Promise.resolve(v)] } else if (typeof v === 'string') { - promise = window.fetch(v, { cache }) - .then((data) => data.json()) - .catch((e) => { - console.error(e) - return null - }) + return [ + k, + () => window + .fetch(v, { cache }) + .then(data => data.text()) + .then(text => parser(text)) + .catch(e => { + console.error(e) + return null + }) + ] + } else { + console.error(`Unknown resource format - ${k} is a ${typeof v}`) + return [k, null] } - return [k, promise] }) - }) - .then((promises) => { - return promises - .reduce((acc, [k, v]) => { - acc[k] = v - return acc - }, {}) - }) -} + } -export const getPreset = (val) => { - 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 } - } + try { + const builtinData = await window.fetch(url, { cache }) + const builtinResources = await builtinData.json() + builtin = resourceTransform(builtinResources) + } catch (e) { + builtin = [] + console.warn(`Builtin resources at ${url} unavailable`) + } - return { theme: data, source: theme.source } - }) + try { + const customData = await window.fetch(customUrl, { cache }) + const customResources = await customData.json() + custom = resourceTransform(customResources) + } catch (e) { + custom = [] + console.warn(`Custom resources at ${customUrl} unavailable`) + } + + const total = [...custom, ...builtin] + if (total.length === 0) { + return Promise.reject(new Error(`Resource at ${url} and ${customUrl} completely unavailable. Panicking`)) + } + return Promise.resolve(Object.fromEntries(total)) } diff --git a/src/services/theme_data/css_utils.js b/src/services/theme_data/css_utils.js @@ -2,25 +2,6 @@ import { convert } from 'chromatism' 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 getCssShadow = (input, usesDropShadow) => { @@ -84,6 +65,9 @@ export const getCssRules = (rules, debug) => rules.map(rule => { ].join(';\n ') } case 'shadow': { + if (!rule.dynamicVars.shadow) { + return '' + } return ' ' + [ '--shadow: ' + getCssShadow(rule.dynamicVars.shadow), '--shadowFilter: ' + getCssShadowFilter(rule.dynamicVars.shadow), @@ -98,7 +82,7 @@ export const getCssRules = (rules, debug) => rules.map(rule => { ` } if (v === 'transparent') { - if (rule.component === 'Root') return [] + if (rule.component === 'Root') return null return [ rule.directives.backgroundNoCssColor !== 'yes' ? ('background-color: ' + v) : '', ' --background: ' + v @@ -130,7 +114,7 @@ export const getCssRules = (rules, debug) => rules.map(rule => { } default: if (k.startsWith('--')) { - const [type, value] = v.split('|').map(x => x.trim()) // woah, Extreme! + const [type, value] = v.split('|').map(x => x.trim()) switch (type) { case 'color': { const color = rule.dynamicVars[k] @@ -143,21 +127,20 @@ export const getCssRules = (rules, debug) => rules.map(rule => { case 'generic': return k + ': ' + value default: - return '' + return null } } - return '' + return null } - }).filter(x => x).map(x => ' ' + x).join(';\n') + }).filter(x => x).map(x => ' ' + x + ';').join('\n') return [ header, - directives + ';', + directives, (rule.component === 'Text' && rule.state.indexOf('faint') < 0 && rule.directives.textNoCssColor !== 'yes') ? ' color: var(--text);' : '', - '', virtualDirectives, footer - ].join('\n') + ].filter(x => x).join('\n') }).filter(x => x) export const getScopedVersion = (rules, newScope) => { diff --git a/src/services/theme_data/iss_deserializer.js b/src/services/theme_data/iss_deserializer.js @@ -1,10 +1,11 @@ import { flattenDeep } from 'lodash' -const parseShadow = string => { - const modes = ['_full', 'inset', 'x', 'y', 'blur', 'spread', 'color', 'alpha'] +export const deserializeShadow = string => { + const modes = ['_full', 'inset', 'x', 'y', 'blur', 'spread', 'color', 'alpha', 'name'] const regexPrep = [ // inset keyword (optional) - '^(?:(inset)\\s+)?', + '^', + '(?:(inset)\\s+)?', // x '(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)', // y @@ -14,19 +15,31 @@ const parseShadow = string => { // spread (optional) '(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)?', // either hex, variable or function - '(#[0-9a-f]{6}|--[a-z\\-_]+|\\$[a-z\\-()_]+)', + '(#[0-9a-f]{6}|--[a-z0-9\\-_]+|\\$[a-z0-9\\-()_ ]+)', // opacity (optional) - '(?:\\s+\\/\\s+([0-9]+(?:\\.[0-9]+)?)\\s*)?$' + '(?:\\s+\\/\\s+([0-9]+(?:\\.[0-9]+)?)\\s*)?', + // name + '(?:\\s+#(\\w+)\\s*)?', + '$' ].join('') const regex = new RegExp(regexPrep, 'gis') // global, (stable) indices, single-string const result = regex.exec(string) if (result == null) { - return string + if (string.startsWith('$') || string.startsWith('--')) { + return string + } else { + throw new Error(`Invalid shadow definition: '${string}'`) + } } else { 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, name } = Object.fromEntries(modes.map((mode, i) => { 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') { return [mode, !!result[i]] } else { @@ -34,7 +47,7 @@ const parseShadow = string => { } }).filter(([k, v]) => v !== false).slice(1)) - return { x, y, blur, spread, color, alpha, inset } + return { x, y, blur, spread, color, alpha, inset, name } } } // this works nearly the same as HTML tree converter @@ -136,12 +149,12 @@ export const deserialize = (input) => { output.directives = Object.fromEntries(content.map(d => { const [property, value] = d.split(':') - let realValue = value.trim() + let realValue = (value || '').trim() if (property === 'shadow') { if (realValue === 'none') { realValue = [] } else { - realValue = value.split(',').map(v => parseShadow(v.trim())) + realValue = value.split(',').map(v => deserializeShadow(v.trim())) } } if (!Number.isNaN(Number(value))) { realValue = Number(value) diff --git a/src/services/theme_data/iss_serializer.js b/src/services/theme_data/iss_serializer.js @@ -1,8 +1,13 @@ import { unroll } from './iss_utils.js' +import { deserializeShadow } from './iss_deserializer.js' -const serializeShadow = s => { +export const serializeShadow = (s, throwOnInvalid) => { if (typeof s === 'object') { - return `${s.inset ? 'inset ' : ''}${s.x} ${s.y} ${s.blur} ${s.spread} ${s.color} / ${s.alpha}` + const inset = s.inset ? 'inset ' : '' + const name = s.name ? ` #${s.name} ` : '' + const result = `${inset}${s.x} ${s.y} ${s.blur} ${s.spread} ${s.color} / ${s.alpha}${name}` + deserializeShadow(result) // Verify that output is valid and parseable + return result } else { return s } diff --git a/src/services/theme_data/iss_utils.js b/src/services/theme_data/iss_utils.js @@ -56,43 +56,74 @@ export const getAllPossibleCombinations = (array) => { * * @returns {String} CSS selector (or path) */ -export const genericRuleToSelector = components => (rule, ignoreOutOfTreeSelector, isParent) => { +export const genericRuleToSelector = components => (rule, ignoreOutOfTreeSelector, liteMode, children) => { + const isParent = !!children if (!rule && !isParent) return null const component = components[rule.component] - const { states = {}, variants = {}, selector, outOfTreeSelector } = component + const { states = {}, variants = {}, outOfTreeSelector } = component + + const expand = (array = [], subArray = []) => { + if (array.length === 0) return subArray.map(x => [x]) + if (subArray.length === 0) return array.map(x => [x]) + return array.map(a => { + return subArray.map(b => [a, b]) + }).flat() + } - const applicableStates = ((rule.state || []).filter(x => x !== 'normal')).map(state => states[state]) + let componentSelectors = Array.isArray(component.selector) ? component.selector : [component.selector] + if (ignoreOutOfTreeSelector || liteMode) componentSelectors = [componentSelectors[0]] + componentSelectors = componentSelectors.map(selector => { + if (selector === ':root') { + return '' + } else if (isParent) { + return selector + } else { + if (outOfTreeSelector && !ignoreOutOfTreeSelector) return outOfTreeSelector + return selector + } + }) const applicableVariantName = (rule.variant || 'normal') - let applicableVariant = '' + let variantSelectors = null if (applicableVariantName !== 'normal') { - applicableVariant = variants[applicableVariantName] - } else { - applicableVariant = variants?.normal ?? '' - } - - let realSelector - if (selector === ':root') { - realSelector = '' - } else if (isParent) { - realSelector = selector + variantSelectors = variants[applicableVariantName] } else { - if (outOfTreeSelector && !ignoreOutOfTreeSelector) realSelector = outOfTreeSelector - else realSelector = selector + variantSelectors = variants?.normal ?? '' } - - const selectors = [realSelector, applicableVariant, ...applicableStates] - .sort((a, b) => { - if (a.startsWith(':')) return 1 - if (/^[a-z]/.exec(a)) return -1 - else return 0 - }) - .join('') + variantSelectors = Array.isArray(variantSelectors) ? variantSelectors : [variantSelectors] + if (ignoreOutOfTreeSelector || liteMode) variantSelectors = [variantSelectors[0]] + + const applicableStates = (rule.state || []).filter(x => x !== 'normal') + // const applicableStates = (rule.state || []) + const statesSelectors = applicableStates.map(state => { + const selector = states[state] || '' + let arraySelector = Array.isArray(selector) ? selector : [selector] + if (ignoreOutOfTreeSelector || liteMode) arraySelector = [arraySelector[0]] + arraySelector + .sort((a, b) => { + if (a.startsWith(':')) return 1 + if (/^[a-z]/.exec(a)) return -1 + else return 0 + }) + .join('') + return arraySelector + }) + + const statesSelectorsFlat = statesSelectors.reduce((acc, s) => { + return expand(acc, s).map(st => st.join('')) + }, []) + + const componentVariant = expand(componentSelectors, variantSelectors).map(cv => cv.join('')) + const componentVariantStates = expand(componentVariant, statesSelectorsFlat).map(cvs => cvs.join('')) + const selectors = expand(componentVariantStates, children).map(cvsc => cvsc.join(' ')) + /* + */ if (rule.parent) { - return (genericRuleToSelector(components)(rule.parent, ignoreOutOfTreeSelector, true) + ' ' + selectors).trim() + return genericRuleToSelector(components)(rule.parent, ignoreOutOfTreeSelector, liteMode, selectors) } - return selectors.trim() + + return selectors.join(', ').trim() } /** diff --git a/src/services/theme_data/theme2_to_theme3.js b/src/services/theme_data/theme2_to_theme3.js @@ -354,10 +354,6 @@ export const convertTheme2To3 = (data) => { newRules.push({ ...rule, state: ['toggled'] }) newRules.push({ ...rule, state: ['toggled', 'focus'] }) newRules.push({ ...rule, state: ['pressed', 'focus'] }) - } - if (key === 'buttonHover') { - newRules.push({ ...rule, state: ['toggled', 'hover'] }) - newRules.push({ ...rule, state: ['pressed', 'hover'] }) newRules.push({ ...rule, state: ['toggled', 'focus', 'hover'] }) newRules.push({ ...rule, state: ['pressed', 'focus', 'hover'] }) } diff --git a/src/services/theme_data/theme3_slot_functions.js b/src/services/theme_data/theme3_slot_functions.js @@ -3,7 +3,7 @@ import { alphaBlend, getTextColor, relativeLuminance } from '../color_convert/co export const process = (text, functions, { findColor, findShadow }, { dynamicVars, staticVars }) => { 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] if (args.length < func.argsNeeded) { @@ -15,6 +15,11 @@ export const process = (text, functions, { findColor, findShadow }, { dynamicVar export const colorFunctions = { alpha: { argsNeeded: 2, + documentation: 'Changes alpha value of the color only to be used for CSS variables', + args: [ + 'color: source color used', + 'amount: alpha value' + ], exec: (args, { findColor }, { dynamicVars, staticVars }) => { const [color, amountArg] = args @@ -23,8 +28,32 @@ export const colorFunctions = { return { ...colorArg, a: amount } } }, + brightness: { + argsNeeded: 2, + document: 'Changes brightness/lightness of color in HSL colorspace', + args: [ + 'color: source color used', + 'amount: lightness value' + ], + exec: (args, { findColor }, { dynamicVars, staticVars }) => { + const [color, amountArg] = args + + const colorArg = convert(findColor(color, { dynamicVars, staticVars })).hsl + colorArg.l += Number(amountArg) + return { ...convert(colorArg).rgb } + } + }, textColor: { argsNeeded: 2, + documentation: 'Get text color with adequate contrast for given background and intended text color. Same function is used internally', + args: [ + 'background: color of backdrop where text will be shown', + 'foreground: intended text color', + `[preserve]: (optional) intended color preservation: +'preserve' - try to preserve the color +'no-preserve' - if can't get adequate color - fall back to black or white +'no-auto' - don't do anything (useless as a color function)` + ], exec: (args, { findColor }, { dynamicVars, staticVars }) => { const [backgroundArg, foregroundArg, preserve = 'preserve'] = args @@ -36,6 +65,12 @@ export const colorFunctions = { }, blend: { argsNeeded: 3, + documentation: 'Alpha blending between two colors', + args: [ + 'background: bottom layer color', + 'amount: opacity of top layer', + 'foreground: upper layer color' + ], exec: (args, { findColor }, { dynamicVars, staticVars }) => { const [backgroundArg, amountArg, foregroundArg] = args @@ -48,6 +83,11 @@ export const colorFunctions = { }, mod: { argsNeeded: 2, + documentation: 'Old function that increases or decreases brightness depending if color is dark or light. Advised against using it as it might give unexpected results.', + args: [ + 'color: source color', + 'amount: how much darken/brighten the color' + ], exec: (args, { findColor }, { dynamicVars, staticVars }) => { const [colorArg, amountArg] = args @@ -65,6 +105,13 @@ export const colorFunctions = { export const shadowFunctions = { borderSide: { argsNeeded: 3, + documentation: 'Simulate a border on a side with a shadow, best works on inset border', + args: [ + 'color: border color', + 'side: string indicating on which side border should be, takes either one word or two words joined by dash (i.e. "left" or "bottom-right")', + '[alpha]: (Optional) border opacity, defaults to 1 (fully opaque)', + '[inset]: (Optional) whether border should be on the inside or outside, defaults to inside' + ], exec: (args, { findColor }) => { const [color, side, alpha = '1', widthArg = '1', inset = 'inset'] = args diff --git a/src/services/theme_data/theme_data_3.service.js b/src/services/theme_data/theme_data_3.service.js @@ -22,7 +22,7 @@ import { normalizeCombination, findRules } from './iss_utils.js' -import { parseCssShadow } from './css_utils.js' +import { deserializeShadow } from './iss_deserializer.js' // Ensuring the order of components const components = { @@ -37,18 +37,18 @@ const components = { ChatMessage: null } -const findShadow = (shadows, { dynamicVars, staticVars }) => { +export const findShadow = (shadows, { dynamicVars, staticVars }) => { return (shadows || []).map(shadow => { let targetShadow if (typeof shadow === 'string') { if (shadow.startsWith('$')) { targetShadow = process(shadow, shadowFunctions, { findColor, findShadow }, { dynamicVars, staticVars }) } else if (shadow.startsWith('--')) { - const [variable] = shadow.split(/,/g).map(str => str.trim()) // discarding modifier since it's not supported - const variableSlot = variable.substring(2) + // modifiers are completely unsupported here + const variableSlot = shadow.substring(2) return findShadow(staticVars[variableSlot], { dynamicVars, staticVars }) } else { - targetShadow = parseCssShadow(shadow) + targetShadow = deserializeShadow(shadow) } } else { targetShadow = shadow @@ -62,54 +62,63 @@ const findShadow = (shadows, { dynamicVars, staticVars }) => { }) } -const findColor = (color, { dynamicVars, staticVars }) => { - if (typeof color !== 'string' || (!color.startsWith('--') && !color.startsWith('$'))) return color - let targetColor = null - if (color.startsWith('--')) { - const [variable, modifier] = color.split(/,/g).map(str => str.trim()) - const variableSlot = variable.substring(2) - if (variableSlot === 'stack') { - const { r, g, b } = dynamicVars.stacked - targetColor = { r, g, b } - } else if (variableSlot.startsWith('parent')) { - if (variableSlot === 'parent') { - const { r, g, b } = dynamicVars.lowerLevelBackground +export const findColor = (color, { dynamicVars, staticVars }) => { + try { + if (typeof color !== 'string' || (!color.startsWith('--') && !color.startsWith('$'))) return color + let targetColor = null + if (color.startsWith('--')) { + // Modifier support is pretty much for v2 themes only + const [variable, modifier] = color.split(/,/g).map(str => str.trim()) + const variableSlot = variable.substring(2) + if (variableSlot === 'stack') { + const { r, g, b } = dynamicVars.stacked targetColor = { r, g, b } + } else if (variableSlot.startsWith('parent')) { + if (variableSlot === 'parent') { + const { r, g, b } = dynamicVars.lowerLevelBackground + targetColor = { r, g, b } + } else { + const virtualSlot = variableSlot.replace(/^parent/, '') + targetColor = convert(dynamicVars.lowerLevelVirtualDirectivesRaw[virtualSlot]).rgb + } } else { - const virtualSlot = variableSlot.replace(/^parent/, '') - targetColor = convert(dynamicVars.lowerLevelVirtualDirectivesRaw[virtualSlot]).rgb - } - } else { - switch (variableSlot) { - case 'inheritedBackground': - targetColor = convert(dynamicVars.inheritedBackground).rgb - break - case 'background': - targetColor = convert(dynamicVars.background).rgb - break - default: - targetColor = convert(staticVars[variableSlot]).rgb + switch (variableSlot) { + case 'inheritedBackground': + targetColor = convert(dynamicVars.inheritedBackground).rgb + break + case 'background': + targetColor = convert(dynamicVars.background).rgb + break + default: + targetColor = convert(staticVars[variableSlot]).rgb + } } - } - if (modifier) { - const effectiveBackground = dynamicVars.lowerLevelBackground ?? targetColor - const isLightOnDark = relativeLuminance(convert(effectiveBackground).rgb) < 0.5 - const mod = isLightOnDark ? 1 : -1 - targetColor = brightness(Number.parseFloat(modifier) * mod, targetColor).rgb + if (modifier) { + const effectiveBackground = dynamicVars.lowerLevelBackground ?? targetColor + const isLightOnDark = relativeLuminance(convert(effectiveBackground).rgb) < 0.5 + const mod = isLightOnDark ? 1 : -1 + targetColor = brightness(Number.parseFloat(modifier) * mod, targetColor).rgb + } } - } - if (color.startsWith('$')) { - try { - targetColor = process(color, colorFunctions, { findColor }, { dynamicVars, staticVars }) - } catch (e) { - console.error('Failure executing color function', e) - targetColor = '#FF00FF' + if (color.startsWith('$')) { + try { + targetColor = process(color, colorFunctions, { findColor }, { dynamicVars, staticVars }) + } catch (e) { + console.error('Failure executing color function', e) + targetColor = '#FF00FF' + } } + // Color references other color + return targetColor + } catch (e) { + throw new Error(`Couldn't find color "${color}", variables are: +Static: +${JSON.stringify(staticVars, null, 2)} +Dynamic: +${JSON.stringify(dynamicVars, null, 2)}`) } - // Color references other color - return targetColor } const getTextColorAlpha = (directives, intendedTextColor, dynamicVars, staticVars) => { @@ -164,19 +173,19 @@ export const getEngineChecksum = () => engineChecksum * @param {boolean} onlyNormalState - only use components 'normal' states, meant for generating theme * previews since states are the biggest factor for compilation time and are completely unnecessary * when previewing multiple themes at same time - * @param {string} rootComponentName - [UNTESTED] which component to start from, meant for previewing a - * part of the theme (i.e. just the button) for themes 3 editor. */ export const init = ({ inputRuleset, ultimateBackgroundColor, debug = false, liteMode = false, + editMode = false, onlyNormalState = false, - rootComponentName = 'Root' + initialStaticVars = {} }) => { + const rootComponentName = 'Root' if (!inputRuleset) throw new Error('Ruleset is null or undefined!') - const staticVars = {} + const staticVars = { ...initialStaticVars } const stacked = {} const computed = {} @@ -218,8 +227,8 @@ export const init = ({ bScore += b.component === 'Text' ? 1 : 0 // Debug - a.specifityScore = aScore - b.specifityScore = bScore + a._specificityScore = aScore + b._specificityScore = bScore if (aScore === bScore) { return ai - bi @@ -228,211 +237,227 @@ export const init = ({ }) .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 nonEditableComponents = new Set(Object.values(components).filter(c => c.notEditable).map(c => c.name)) const processCombination = (combination) => { - const selector = ruleToSelector(combination, true) - const cssSelector = ruleToSelector(combination) - - const parentSelector = selector.split(/ /g).slice(0, -1).join(' ') - const soloSelector = selector.split(/ /g).slice(-1)[0] - - const lowerLevelSelector = parentSelector - const lowerLevelBackground = computed[lowerLevelSelector]?.background - const lowerLevelVirtualDirectives = computed[lowerLevelSelector]?.virtualDirectives - const lowerLevelVirtualDirectivesRaw = computed[lowerLevelSelector]?.virtualDirectivesRaw - - const dynamicVars = computed[selector] || { - lowerLevelBackground, - lowerLevelVirtualDirectives, - lowerLevelVirtualDirectivesRaw - } + try { + const selector = ruleToSelector(combination, true) + const cssSelector = ruleToSelector(combination) - // Inheriting all of the applicable rules - const existingRules = ruleset.filter(findRules(combination)) - const computedDirectives = - existingRules - .map(r => r.directives) - .reduce((acc, directives) => ({ ...acc, ...directives }), {}) - const computedRule = { - ...combination, - directives: computedDirectives - } + const parentSelector = selector.split(/ /g).slice(0, -1).join(' ') + const soloSelector = selector.split(/ /g).slice(-1)[0] - computed[selector] = computed[selector] || {} - computed[selector].computedRule = computedRule - computed[selector].dynamicVars = dynamicVars - - if (virtualComponents.has(combination.component)) { - const virtualName = [ - '--', - combination.component.toLowerCase(), - combination.variant === 'normal' - ? '' - : combination.variant[0].toUpperCase() + combination.variant.slice(1).toLowerCase(), - ...sortBy(combination.state.filter(x => x !== 'normal')).map(state => state[0].toUpperCase() + state.slice(1).toLowerCase()) - ].join('') - - let inheritedTextColor = computedDirectives.textColor - let inheritedTextAuto = computedDirectives.textAuto - let inheritedTextOpacity = computedDirectives.textOpacity - let inheritedTextOpacityMode = computedDirectives.textOpacityMode - const lowerLevelTextSelector = [...selector.split(/ /g).slice(0, -1), soloSelector].join(' ') - const lowerLevelTextRule = computed[lowerLevelTextSelector] - - if (inheritedTextColor == null || inheritedTextOpacity == null || inheritedTextOpacityMode == null) { - inheritedTextColor = computedDirectives.textColor ?? lowerLevelTextRule.textColor - inheritedTextAuto = computedDirectives.textAuto ?? lowerLevelTextRule.textAuto - inheritedTextOpacity = computedDirectives.textOpacity ?? lowerLevelTextRule.textOpacity - inheritedTextOpacityMode = computedDirectives.textOpacityMode ?? lowerLevelTextRule.textOpacityMode + const lowerLevelSelector = parentSelector + 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 lowerLevelVirtualDirectivesRaw = computed[lowerLevelSelector]?.virtualDirectivesRaw - const newTextRule = { - ...computedRule, - directives: { - ...computedRule.directives, - textColor: inheritedTextColor, - textAuto: inheritedTextAuto ?? 'preserve', - textOpacity: inheritedTextOpacity, - textOpacityMode: inheritedTextOpacityMode - } + const dynamicVars = computed[selector] || { + lowerLevelBackground, + lowerLevelVirtualDirectives, + lowerLevelVirtualDirectivesRaw } - dynamicVars.inheritedBackground = lowerLevelBackground - dynamicVars.stacked = convert(stacked[lowerLevelSelector]).rgb - - const intendedTextColor = convert(findColor(inheritedTextColor, { dynamicVars, staticVars })).rgb - const textColor = newTextRule.directives.textAuto === 'no-auto' - ? intendedTextColor - : getTextColor( - convert(stacked[lowerLevelSelector]).rgb, - intendedTextColor, - newTextRule.directives.textAuto === 'preserve' - ) - const virtualDirectives = computed[lowerLevelSelector].virtualDirectives || {} - const virtualDirectivesRaw = computed[lowerLevelSelector].virtualDirectivesRaw || {} - - // Storing color data in lower layer to use as custom css properties - virtualDirectives[virtualName] = getTextColorAlpha(newTextRule.directives, textColor, dynamicVars) - virtualDirectivesRaw[virtualName] = textColor - - computed[lowerLevelSelector].virtualDirectives = virtualDirectives - computed[lowerLevelSelector].virtualDirectivesRaw = virtualDirectivesRaw - - return { - dynamicVars, - selector: cssSelector.split(/ /g).slice(0, -1).join(' '), + // Inheriting all of the applicable rules + const existingRules = ruleset.filter(findRules(combination)) + const computedDirectives = + existingRules + .map(r => r.directives) + .reduce((acc, directives) => ({ ...acc, ...directives }), {}) + const computedRule = { ...combination, - directives: {}, - virtualDirectives: { - [virtualName]: getTextColorAlpha(newTextRule.directives, textColor, dynamicVars) - }, - virtualDirectivesRaw: { - [virtualName]: textColor - } + directives: computedDirectives } - } else { + computed[selector] = computed[selector] || {} + computed[selector].computedRule = computedRule + computed[selector].dynamicVars = dynamicVars + + if (virtualComponents.has(combination.component)) { + const virtualName = [ + '--', + combination.component.toLowerCase(), + combination.variant === 'normal' + ? '' + : combination.variant[0].toUpperCase() + combination.variant.slice(1).toLowerCase(), + ...sortBy(combination.state.filter(x => x !== 'normal')).map(state => state[0].toUpperCase() + state.slice(1).toLowerCase()) + ].join('') + + let inheritedTextColor = computedDirectives.textColor + let inheritedTextAuto = computedDirectives.textAuto + let inheritedTextOpacity = computedDirectives.textOpacity + let inheritedTextOpacityMode = computedDirectives.textOpacityMode + const lowerLevelTextSelector = [...selector.split(/ /g).slice(0, -1), soloSelector].join(' ') + const lowerLevelTextRule = computed[lowerLevelTextSelector] + + if (inheritedTextColor == null || inheritedTextOpacity == null || inheritedTextOpacityMode == null) { + inheritedTextColor = computedDirectives.textColor ?? lowerLevelTextRule.textColor + inheritedTextAuto = computedDirectives.textAuto ?? lowerLevelTextRule.textAuto + inheritedTextOpacity = computedDirectives.textOpacity ?? lowerLevelTextRule.textOpacity + inheritedTextOpacityMode = computedDirectives.textOpacityMode ?? lowerLevelTextRule.textOpacityMode + } - // TODO: DEFAULT TEXT COLOR - const lowerLevelStackedBackground = stacked[lowerLevelSelector] || convert(ultimateBackgroundColor).rgb - - if (computedDirectives.background) { - let inheritRule = null - const variantRules = ruleset.filter( - findRules({ - component: combination.component, - variant: combination.variant, - parent: combination.parent - }) - ) - const lastVariantRule = variantRules[variantRules.length - 1] - if (lastVariantRule) { - inheritRule = lastVariantRule - } else { - const normalRules = ruleset.filter(findRules({ - component: combination.component, - parent: combination.parent - })) - const lastNormalRule = normalRules[normalRules.length - 1] - inheritRule = lastNormalRule + const newTextRule = { + ...computedRule, + directives: { + ...computedRule.directives, + textColor: inheritedTextColor, + textAuto: inheritedTextAuto ?? 'preserve', + textOpacity: inheritedTextOpacity, + textOpacityMode: inheritedTextOpacityMode + } } - const inheritSelector = ruleToSelector({ ...inheritRule, parent: combination.parent }, true) - const inheritedBackground = computed[inheritSelector].background + dynamicVars.inheritedBackground = lowerLevelBackground + dynamicVars.stacked = convert(stacked[lowerLevelSelector]).rgb + + const intendedTextColor = convert(findColor(inheritedTextColor, { dynamicVars, staticVars })).rgb + const textColor = newTextRule.directives.textAuto === 'no-auto' + ? intendedTextColor + : getTextColor( + convert(stacked[lowerLevelSelector]).rgb, + intendedTextColor, + newTextRule.directives.textAuto === 'preserve' + ) + const virtualDirectives = computed[lowerLevelSelector].virtualDirectives || {} + const virtualDirectivesRaw = computed[lowerLevelSelector].virtualDirectivesRaw || {} + + // Storing color data in lower layer to use as custom css properties + virtualDirectives[virtualName] = getTextColorAlpha(newTextRule.directives, textColor, dynamicVars) + virtualDirectivesRaw[virtualName] = textColor + + computed[lowerLevelSelector].virtualDirectives = virtualDirectives + computed[lowerLevelSelector].virtualDirectivesRaw = virtualDirectivesRaw + + return { + dynamicVars, + selector: cssSelector.split(/ /g).slice(0, -1).join(' '), + ...combination, + directives: {}, + virtualDirectives: { + [virtualName]: getTextColorAlpha(newTextRule.directives, textColor, dynamicVars) + }, + virtualDirectivesRaw: { + [virtualName]: textColor + } + } + } else { + computed[selector] = computed[selector] || {} + + // TODO: DEFAULT TEXT COLOR + const lowerLevelStackedBackground = stacked[lowerLevelSelector] || convert(ultimateBackgroundColor).rgb + + if (computedDirectives.background) { + let inheritRule = null + const variantRules = ruleset.filter( + findRules({ + component: combination.component, + variant: combination.variant, + parent: combination.parent + }) + ) + const lastVariantRule = variantRules[variantRules.length - 1] + if (lastVariantRule) { + inheritRule = lastVariantRule + } else { + const normalRules = ruleset.filter(findRules({ + component: combination.component, + parent: combination.parent + })) + const lastNormalRule = normalRules[normalRules.length - 1] + inheritRule = lastNormalRule + } - dynamicVars.inheritedBackground = inheritedBackground + const inheritSelector = ruleToSelector({ ...inheritRule, parent: combination.parent }, true) + const inheritedBackground = computed[inheritSelector].background - const rgb = convert(findColor(computedDirectives.background, { dynamicVars, staticVars })).rgb + dynamicVars.inheritedBackground = inheritedBackground - if (!stacked[selector]) { - let blend - const alpha = computedDirectives.opacity ?? 1 - if (alpha >= 1) { - blend = rgb - } else if (alpha <= 0) { - blend = lowerLevelStackedBackground - } else { - blend = alphaBlend(rgb, computedDirectives.opacity, lowerLevelStackedBackground) + const rgb = convert(findColor(computedDirectives.background, { dynamicVars, staticVars })).rgb + + if (!stacked[selector]) { + let blend + const alpha = computedDirectives.opacity ?? 1 + if (alpha >= 1) { + blend = rgb + } else if (alpha <= 0) { + blend = lowerLevelStackedBackground + } else { + blend = alphaBlend(rgb, computedDirectives.opacity, lowerLevelStackedBackground) + } + stacked[selector] = blend + computed[selector].background = { ...rgb, a: computedDirectives.opacity ?? 1 } } - stacked[selector] = blend - computed[selector].background = { ...rgb, a: computedDirectives.opacity ?? 1 } } - } - if (computedDirectives.shadow) { - dynamicVars.shadow = flattenDeep(findShadow(flattenDeep(computedDirectives.shadow), { dynamicVars, staticVars })) - } + if (computedDirectives.shadow) { + dynamicVars.shadow = flattenDeep(findShadow(flattenDeep(computedDirectives.shadow), { dynamicVars, staticVars })) + } - if (!stacked[selector]) { - computedDirectives.background = 'transparent' - computedDirectives.opacity = 0 - stacked[selector] = lowerLevelStackedBackground - computed[selector].background = { ...lowerLevelStackedBackground, a: 0 } - } + if (!stacked[selector]) { + computedDirectives.background = 'transparent' + computedDirectives.opacity = 0 + stacked[selector] = lowerLevelStackedBackground + computed[selector].background = { ...lowerLevelStackedBackground, a: 0 } + } - dynamicVars.stacked = stacked[selector] - dynamicVars.background = computed[selector].background + dynamicVars.stacked = stacked[selector] + dynamicVars.background = computed[selector].background - const dynamicSlots = Object.entries(computedDirectives).filter(([k, v]) => k.startsWith('--')) + const dynamicSlots = Object.entries(computedDirectives).filter(([k, v]) => k.startsWith('--')) - dynamicSlots.forEach(([k, v]) => { - const [type, ...value] = v.split('|').map(x => x.trim()) // woah, Extreme! - switch (type) { - case 'color': { - const color = findColor(value[0], { dynamicVars, staticVars }) - dynamicVars[k] = color - if (combination.component === 'Root') { - staticVars[k.substring(2)] = color + dynamicSlots.forEach(([k, v]) => { + const [type, value] = v.split('|').map(x => x.trim()) // woah, Extreme! + switch (type) { + case 'color': { + const color = findColor(value, { dynamicVars, staticVars }) + dynamicVars[k] = color + if (combination.component === rootComponentName) { + staticVars[k.substring(2)] = color + } + break } - break - } - case 'shadow': { - const shadow = value - dynamicVars[k] = shadow - if (combination.component === 'Root') { - staticVars[k.substring(2)] = shadow + case 'shadow': { + const shadow = value.split(/,/g).map(s => s.trim()).filter(x => x) + dynamicVars[k] = shadow + if (combination.component === rootComponentName) { + staticVars[k.substring(2)] = shadow + } + break } - break - } - case 'generic': { - dynamicVars[k] = value - if (combination.component === 'Root') { - staticVars[k.substring(2)] = value + case 'generic': { + dynamicVars[k] = value + if (combination.component === rootComponentName) { + staticVars[k.substring(2)] = value + } + break } - break } + }) + + const rule = { + dynamicVars, + selector: cssSelector, + ...combination, + directives: computedDirectives } - }) - const rule = { - dynamicVars, - selector: cssSelector, - ...combination, - directives: computedDirectives + return rule } - - return rule + } catch (e) { + const { component, variant, state } = combination + throw new Error(`Error processing combination ${component}.${variant}:${state.join(':')}: ${e}`) } } @@ -443,11 +468,15 @@ export const init = ({ variants: originalVariants = {} } = component - const validInnerComponents = ( - liteMode - ? (component.validInnerComponentsLite || component.validInnerComponents) - : component.validInnerComponents - ) || [] + let validInnerComponents + if (editMode) { + const temp = (component.validInnerComponentsLite || 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" const states = { normal: '', ...originalStates } @@ -489,7 +518,7 @@ export const init = ({ combination.component = component.name combination.lazy = component.lazy || parent?.lazy combination.parent = parent - if (combination.state.indexOf('hover') >= 0) { + if (!liteMode && combination.state.indexOf('hover') >= 0) { combination.lazy = true } @@ -538,6 +567,7 @@ export const init = ({ lazy, eager, staticVars, - engineChecksum + engineChecksum, + themeChecksum: sum([lazy, eager]) } } diff --git a/static/.gitignore b/static/.gitignore @@ -0,0 +1 @@ +*.custom.* diff --git a/static/config.json b/static/config.json @@ -24,6 +24,8 @@ "showInstanceSpecificPanel": false, "sidebarRight": false, "subjectLineBehavior": "email", - "theme": "pleroma-dark", + "theme": null, + "style": null, + "palette": null, "webPushNotifications": false } diff --git a/static/palettes/index.json b/static/palettes/index.json @@ -0,0 +1,32 @@ +{ + "pleroma-dark": [ "Pleroma Dark", "#121a24", "#182230", "#b9b9ba", "#d8a070", "#d31014", "#0fa00f", "#0095ff", "#ffa500" ], + "pleroma-light": [ "Pleroma Light", "#f2f4f6", "#dbe0e8", "#304055", "#f86f0f", "#d31014", "#0fa00f", "#0095ff", "#ffa500" ], + "classic-dark": { + "name": "Classic Dark", + "bg": "#161c20", + "fg": "#282e32", + "text": "#b9b9b9", + "link": "#baaa9c", + "cRed": "#d31014", + "cGreen": "#0fa00f", + "cBlue": "#0095ff", + "cOrange": "#ffa500" + }, + "bird": [ "Bird", "#f8fafd", "#e6ecf0", "#14171a", "#0084b8", "#e0245e", "#17bf63", "#1b95e0", "#fab81e"], + "pleroma-amoled": [ "Pleroma Dark AMOLED", "#000000", "#111111", "#b0b0b1", "#d8a070", "#aa0000", "#0fa00f", "#0095ff", "#d59500"], + "tomorrow-night": { + "name": "Tomorrow Night", + "bg": "#1d1f21", + "fg": "#373b41", + "link": "#81a2be", + "text": "#c5c8c6", + "cRed": "#cc6666", + "cBlue": "#8abeb7", + "cGreen": "#b5bd68", + "cOrange": "#de935f", + "_cYellow": "#f0c674", + "_cPurple": "#b294bb" + }, + "ir-black": [ "Ir Black", "#000000", "#242422", "#b5b3aa", "#ff6c60", "#FF6C60", "#A8FF60", "#96CBFE", "#FFFFB6" ], + "monokai": [ "Monokai", "#272822", "#383830", "#f8f8f2", "#f92672", "#F92672", "#a6e22e", "#66d9ef", "#f4bf75" ] +} diff --git a/static/styles.json b/static/styles.json @@ -1,12 +1,6 @@ { "pleroma-dark": "/static/themes/pleroma-dark.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-se": "/static/themes/redmond-xx-se.json", "redmond-xxi": "/static/themes/redmond-xxi.json", diff --git a/static/styles/Breezy DX.piss b/static/styles/Breezy DX.piss @@ -0,0 +1,80 @@ +@meta { + name: Breezy DX; + author: HJ; + license: WTFPL; + website: ebin.club; +} + +@palette.Dark { + bg: #292C32; + fg: #292C32; + text: #ffffff; + link: #1CA4F3; + accent: #1CA4F3; + cRed: #f41a51; + cBlue: #1CA4F3; + cGreen: #1af46e; + cOrange: #f4af1a; +} + +@palette.Light { + bg: #EFF0F2; + fg: #EFF0F2; + text: #1B1F22; + underlay: #5d6086; + accent: #1CA4F3; + cBlue: #1CA4F3; + cRed: #f41a51; + cGreen: #1af46e; + cOrange: #f4af1a; + border: #d8e6f9; + link: #1CA4F3; +} + +Root { + --badgeNotification: color | --cRed; + --buttonDefaultHoverGlow: shadow | inset 0 0 0 1 --accent / 1; + --buttonDefaultFocusGlow: shadow | inset 0 0 0 1 --accent / 1; + --buttonDefaultShadow: shadow | inset 0 0 0 1 --text / 0.35, 0 5 5 -5 #000000 / 0.35; + --buttonDefaultBevel: shadow | inset 0 14 14 -14 #FFFFFF / 0.1; + --buttonPressedBevel: shadow | inset 0 -20 20 -20 #000000 / 0.05; + --defaultInputBevel: shadow | inset 0 0 0 1 --text / 0.35; + --defaultInputHoverGlow: shadow | 0 0 0 1 --accent / 1; + --defaultInputFocusGlow: shadow | 0 0 0 1 --link / 1; +} + +Button:disabled { + shadow: --buttonDefaultBevel, --buttonDefaultShadow +} + +Button:hover { + shadow: --buttonDefaultHoverGlow, --buttonDefaultBevel, --buttonDefaultShadow +} + +Button:toggled { + background: $blend(--bg 0.3 --accent) +} + +Button:pressed { + background: $blend(--bg 0.8 --accent) +} + +Button:pressed:toggled { + background: $blend(--bg 0.2 --accent) +} + +Button:toggled:hover { + background: $blend(--bg 0.3 --accent) +} + +Input { + shadow: --defaultInputBevel +} + +PanelHeader { + shadow: inset 0 30 30 -30 #ffffff / 0.25 +} + +Tab:hover { + shadow: --buttonDefaultHoverGlow, --buttonDefaultBevel, --buttonDefaultShadow +} diff --git a/static/styles/Redmond DX.piss b/static/styles/Redmond DX.piss @@ -0,0 +1,169 @@ +@meta { + name: Redmond DX; + author: HJ; + license: WTFPL; + website: ebin.club; +} + +@palette.Modern { + bg: #D3CFC7; + fg: #092369; + text: #000000; + link: #0000FF; + accent: #A5C9F0; + cRed: #FF3000; + cBlue: #009EFF; + cGreen: #309E00; + cOrange: #FFCE00; +} + +@palette.Classic { + bg: #BFBFBF; + fg: #000180; + text: #000000; + link: #0000FF; + accent: #A5C9F0; + cRed: #FF0000; + cBlue: #2E2ECE; + cGreen: #007E00; + cOrange: #CE8F5F; +} + +@palette.Vapor { + bg: #F0ADCD; + fg: #bca4ee; + text: #602040; + link: #064745; + accent: #9DF7C8; + cRed: #86004a; + cBlue: #0e5663; + cGreen: #0a8b51; + cOrange: #787424; +} + +Root { + --gradientColor: color | --accent; + --inputColor: color | #FFFFFF; + --bevelLight: color | $brightness(--bg 50); + --bevelDark: color | $brightness(--bg -20); + --bevelExtraDark: color | #404040; + --buttonDefaultBevel: shadow | $borderSide(--bevelExtraDark bottom-right 1 1), $borderSide(--bevelLight top-left 1 1), $borderSide(--bevelDark bottom-right 1 2); + --buttonPressedFocusedBevel: shadow | inset 0 0 0 1 #000000 / 1 #Outer , inset 0 0 0 2 --bevelExtraDark / 1 #inner; + --buttonPressedBevel: shadow | $borderSide(--bevelDark top-left 1 1), $borderSide(--bevelLight bottom-right 1 1), $borderSide(--bevelExtraDark top-left 1 2); + --defaultInputBevel: shadow | $borderSide(--bevelDark top-left 1 1), $borderSide(--bevelLight bottom-right 1 1), $borderSide(--bevelExtraDark top-left 1 2), $borderSide(--bg bottom-right 1 2); +} + +Button:toggled { + background: --bg; + shadow: --buttonPressedBevel +} + +Button:focused { + shadow: --buttonDefaultBevel, 0 0 0 1 #000000 / 1 +} + +Button:pressed { + shadow: --buttonPressedBevel +} + +Button:hover { + shadow: --buttonDefaultBevel; + background: --bg +} + +Button { + shadow: --buttonDefaultBevel; + background: --bg; + roundness: 0 +} + +Button:pressed:hover { + shadow: --buttonPressedBevel +} + +Button:hover:pressed:focused { + shadow: --buttonPressedFocusedBevel +} + +Button:pressed:focused { + shadow: --buttonPressedFocusedBevel +} + +Button:toggled:pressed { + shadow: --buttonPressedFocusedBevel +} + +Input { + background: $mod(--bg -80); + shadow: --defaultInputBevel; + roundness: 0 +} + +Input:focused { + shadow: inset 0 0 0 1 #000000 / 1, --defaultInputBevel +} + +Input:focused:hover { + shadow: --defaultInputBevel +} + +Input:focused:hover:disabled { + shadow: --defaultInputBevel +} + +Input:hover { + shadow: --defaultInputBevel +} + +Input:disabled { + shadow: --defaultInputBevel +} + +Panel { + shadow: --buttonDefaultBevel; + roundness: 0 +} + +PanelHeader { + shadow: inset -1100 0 1000 -1000 --gradientColor / 1 #Gradient ; + background: --fg +} + +Tab:hover { + background: --bg; + shadow: --buttonDefaultBevel +} + +Tab:active { + background: --bg +} + +Tab:active:hover { + background: --bg; + shadow: --defaultButtonBevel +} + +Tab:active:hover:disabled { + background: --bg +} + +Tab:hover:disabled { + background: --bg +} + +Tab:disabled { + background: --bg +} + +Tab { + background: --bg; + shadow: --buttonDefaultBevel +} + +Tab:hover:active { + shadow: --buttonDefaultBevel +} + +TopBar Link { + textColor: #ffffff +} diff --git a/static/styles/index.json b/static/styles/index.json @@ -0,0 +1,4 @@ +{ + "RedmondDX": "/static/styles/Redmond DX.piss", + "BreezyDX": "/static/styles/Breezy DX.piss" +} diff --git a/yarn.lock b/yarn.lock @@ -7201,6 +7201,11 @@ p-try@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1" +pako@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86" + integrity sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug== + pako@~1.0.2: version "1.0.11" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"