浏览代码

Merge branch 'shadow-control-2.0' into 'develop'

Fixed and refined the shadow control in theme tab

See merge request pleroma/pleroma-fe!1939
HJ 1 月之前
父节点
当前提交
a1c3a7a742

+ 1 - 0
changelog.d/better-shadow-control.fix

@@ -0,0 +1 @@
+Updated shadow editor, hopefully fixed long-standing bugs, added ability to specify shadow's name.

+ 11 - 0
src/components/button.style.js

@@ -96,6 +96,17 @@ export default {
         textOpacity: 0.25,
         textOpacityMode: 'blend'
       }
+    },
+    {
+      component: 'Icon',
+      parent: {
+        component: 'Button',
+        state: ['disabled']
+      },
+      directives: {
+        textOpacity: 0.25,
+        textOpacityMode: 'blend'
+      }
     }
   ]
 }

+ 21 - 11
src/components/checkbox/checkbox.vue

@@ -3,6 +3,13 @@
     class="checkbox"
     :class="{ disabled, indeterminate, 'indeterminate-fix': indeterminateTransitionFix }"
   >
+    <span
+      v-if="!!$slots.before"
+      class="label -before"
+      :class="{ faint: disabled }"
+    >
+      <slot name="before" />
+    </span>
     <input
       type="checkbox"
       class="visible-for-screenreader-only"
@@ -14,11 +21,13 @@
     <i
       class="input -checkbox checkbox-indicator"
       :aria-hidden="true"
+      :class="{ disabled }"
       @transitionend.capture="onTransitionEnd"
     />
     <span
       v-if="!!$slots.default"
-      class="label"
+      class="label -after"
+      :class="{ faint: disabled }"
     >
       <slot />
     </span>
@@ -93,14 +102,9 @@ export default {
     box-sizing: border-box;
   }
 
-  &.disabled {
-    .checkbox-indicator::before,
-    .label {
-      opacity: 0.5;
-    }
-
-    .label {
-      color: var(--text);
+  .disabled {
+    .checkbox-indicator::before {
+      background-color: var(--background);
     }
   }
 
@@ -121,8 +125,14 @@ export default {
     }
   }
 
-  & > span {
-    margin-left: 0.5em;
+  & > .label {
+    &.-after {
+      margin-left: 0.5em;
+    }
+
+    &.-before {
+      margin-right: 0.5em;
+    }
   }
 }
 </style>

+ 18 - 6
src/components/color_input/color_input.scss

@@ -1,12 +1,15 @@
 .color-input {
   display: inline-flex;
 
+  .label {
+    flex: 1 1 auto;
+  }
+
   &-field.input {
     display: inline-flex;
     flex: 0 0 0;
     max-width: 9em;
     align-items: stretch;
-    padding: 0.2em 8px;
 
     input {
       color: var(--text);
@@ -25,6 +28,7 @@
     .nativeColor {
       cursor: pointer;
       flex: 0 0 auto;
+      padding: 0;
 
       input {
         appearance: none;
@@ -41,10 +45,10 @@
     .invalidIndicator,
     .transparentIndicator {
       flex: 0 0 2em;
-      margin: 0 0.5em;
+      margin: 0.2em 0.5em;
       min-width: 2em;
       align-self: stretch;
-      min-height: 1.5em;
+      min-height: 1.1em;
       border-radius: var(--roundness);
     }
 
@@ -81,9 +85,17 @@
         border-bottom-right-radius: var(--roundness);
       }
     }
-  }
 
-  .label {
-    flex: 1 1 auto;
+    &.disabled,
+    &:disabled {
+      .nativeColor input,
+      .computedIndicator,
+      .validIndicator,
+      .invalidIndicator,
+      .transparentIndicator {
+        /* stylelint-disable-next-line declaration-no-important */
+        opacity: 0.25 !important;
+      }
+    }
   }
 }

+ 16 - 4
src/components/color_input/color_input.vue

@@ -6,6 +6,7 @@
     <label
       :for="name"
       class="label"
+      :class="{ faint: !present || disabled }"
     >
       {{ label }}
     </label>
@@ -14,16 +15,20 @@
       :model-value="present"
       :disabled="disabled"
       class="opt"
-      @update:modelValue="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
+      @update:modelValue="update(typeof modelValue === 'undefined' ? fallback : undefined)"
     />
-    <div class="input color-input-field">
+    <div
+      class="input color-input-field"
+      :class="{ disabled: !present || disabled }"
+    >
       <input
         :id="name + '-t'"
         class="textColor unstyled"
+        :class="{ disabled: !present || disabled }"
         type="text"
         :value="modelValue || fallback"
         :disabled="!present || disabled"
-        @input="$emit('update:modelValue', $event.target.value)"
+        @input="updateValue($event.target.value)"
       >
       <div
         v-if="validColor"
@@ -51,7 +56,8 @@
           type="color"
           :value="modelValue || fallback"
           :disabled="!present || disabled"
-          @input="$emit('update:modelValue', $event.target.value)"
+          :class="{ disabled: !present || disabled }"
+          @input="updateValue($event.target.value)"
         >
       </label>
     </div>
@@ -60,6 +66,7 @@
 <script>
 import Checkbox from '../checkbox/checkbox.vue'
 import { hex2rgb } from '../../services/color_convert/color_convert.js'
+import { throttle } from 'lodash'
 
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
@@ -125,6 +132,11 @@ export default {
     computedColor () {
       return this.modelValue && this.modelValue.startsWith('--')
     }
+  },
+  methods: {
+    updateValue: throttle(function (value) {
+      this.$emit('update:modelValue', value)
+    }, 100)
   }
 }
 </script>

+ 212 - 0
src/components/component_preview/component_preview.vue

@@ -0,0 +1,212 @@
+<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 }"
+  >
+    <div
+      class="preview-block"
+      :style="previewStyle"
+    />
+  </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>
+
+<style lang="scss">
+.ComponentPreview {
+  display: grid;
+  grid-template-columns: 3em 1fr 3em;
+  grid-template-rows: 2em 1fr 2em;
+  grid-template-areas:
+    ".       header  y-num  "
+    ".       preview y-slide"
+    "x-num   x-slide .      "
+    "options options options";
+  grid-gap: 0.5em;
+
+  .header {
+    grid-area: header;
+    justify-self: center;
+    align-self: baseline;
+    line-height: 2;
+  }
+
+  .input-light-grid {
+    grid-area: options;
+    justify-self: center;
+  }
+
+  .input-number {
+    min-width: 2em;
+  }
+
+  .x-shift-number {
+    grid-area: x-num;
+  }
+
+  .x-shift-slider {
+    grid-area: x-slide;
+    height: auto;
+    align-self: start;
+    min-width: 10em;
+  }
+
+  .y-shift-number {
+    grid-area: y-num;
+  }
+
+  .y-shift-slider {
+    grid-area: y-slide;
+    writing-mode: vertical-lr;
+    justify-self: left;
+    min-height: 10em;
+  }
+
+  .x-shift-slider,
+  .y-shift-slider {
+    padding: 0;
+  }
+
+  .preview-window {
+    --__grid-color1: rgb(102 102 102);
+    --__grid-color2: rgb(153 153 153);
+    --__grid-color1-disabled: rgba(102 102 102 / 20%);
+    --__grid-color2-disabled: rgba(153 153 153 / 20%);
+
+    &.-light-grid {
+      --__grid-color1: rgb(205 205 205);
+      --__grid-color2: rgb(255 255 255);
+      --__grid-color1-disabled: rgba(205 205 205 / 20%);
+      --__grid-color2-disabled: rgba(255 255 255 / 20%);
+    }
+
+    grid-area: preview;
+    aspect-ratio: 1;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    min-width: 10em;
+    min-height: 10em;
+    background-color: var(--__grid-color2);
+    background-image:
+      linear-gradient(45deg, var(--__grid-color1) 25%, transparent 25%),
+      linear-gradient(-45deg, var(--__grid-color1) 25%, transparent 25%),
+      linear-gradient(45deg, transparent 75%, var(--__grid-color1) 75%),
+      linear-gradient(-45deg, transparent 75%, var(--__grid-color1) 75%);
+    background-size: 20px 20px;
+    background-position: 0 0, 0 10px, 10px -10px, -10px 0;
+    border-radius: var(--roundness);
+
+    &.disabled {
+      background-color: var(--__grid-color2-disabled);
+      background-image:
+        linear-gradient(45deg, var(--__grid-color1-disabled) 25%, transparent 25%),
+        linear-gradient(-45deg, var(--__grid-color1-disabled) 25%, transparent 25%),
+        linear-gradient(45deg, transparent 75%, var(--__grid-color1-disabled) 75%),
+        linear-gradient(-45deg, transparent 75%, var(--__grid-color1-disabled) 75%);
+    }
+
+    .preview-block {
+      background: var(--background, var(--bg));
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      min-width: 33%;
+      min-height: 33%;
+      max-width: 80%;
+      max-height: 80%;
+      border-width: 0;
+      border-style: solid;
+      border-color: var(--border);
+      border-radius: var(--roundness);
+      box-shadow: var(--shadow);
+    }
+  }
+}
+</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>

+ 36 - 7
src/components/input.style.js

@@ -10,17 +10,18 @@ const hoverGlow = {
 export default {
   name: 'Input',
   selector: '.input',
-  variant: {
+  states: {
+    hover: ':hover:not(.disabled)',
+    focused: ':focus-within',
+    disabled: '.disabled'
+  },
+  variants: {
     checkbox: '.-checkbox',
     radio: '.-radio'
   },
-  states: {
-    disabled: ':disabled',
-    hover: ':hover:not(:disabled)',
-    focused: ':focus-within'
-  },
   validInnerComponents: [
-    'Text'
+    'Text',
+    'Icon'
   ],
   defaultRules: [
     {
@@ -55,6 +56,34 @@ export default {
       directives: {
         shadow: [hoverGlow, '--defaultInputBevel']
       }
+    },
+    {
+      state: ['disabled'],
+      directives: {
+        background: '--parent'
+      }
+    },
+    {
+      component: 'Text',
+      parent: {
+        component: 'Input',
+        state: ['disabled']
+      },
+      directives: {
+        textOpacity: 0.25,
+        textOpacityMode: 'blend'
+      }
+    },
+    {
+      component: 'Icon',
+      parent: {
+        component: 'Input',
+        state: ['disabled']
+      },
+      directives: {
+        textOpacity: 0.25,
+        textOpacityMode: 'blend'
+      }
     }
   ]
 }

+ 2 - 0
src/components/opacity_input/opacity_input.vue

@@ -6,6 +6,7 @@
     <label
       :for="name"
       class="label"
+      :class="{ faint: !present || disabled }"
     >
       {{ $t('settings.style.common.opacity') }}
     </label>
@@ -22,6 +23,7 @@
       type="number"
       :value="modelValue || fallback"
       :disabled="!present || disabled"
+      :class="{ disabled: !present || disabled }"
       max="1"
       min="0"
       step=".05"

+ 34 - 1
src/components/select/select.vue

@@ -6,13 +6,14 @@
     <select
       :disabled="disabled"
       :value="modelValue"
-      v-bind="attrs"
+      v-bind="$attrs"
       @change="$emit('update:modelValue', $event.target.value)"
     >
       <slot />
     </select>
     {{ ' ' }}
     <FAIcon
+      v-if="!$attrs.size && !$attrs.multiple"
       class="select-down-icon"
       icon="chevron-down"
     />
@@ -39,6 +40,38 @@ label.Select {
     z-index: 1;
     height: 2em;
     line-height: 16px;
+
+    &[multiple],
+    &[size] {
+      height: 100%;
+      padding: 0.2em;
+
+      option {
+        background-color: transparent;
+
+        &.-active {
+          color: var(--selectionText);
+          background-color: var(--selectionBackground);
+        }
+      }
+    }
+  }
+
+  &.disabled,
+  &:disabled {
+    background-color: var(--background);
+    opacity: 1; /* override browser */
+    color: var(--faint);
+
+    select {
+      &[multiple],
+      &[size] {
+        option.-active {
+          color: var(--faint);
+          background: transparent;
+        }
+      }
+    }
   }
 
   .select-down-icon {

+ 12 - 1
src/components/settings_modal/tabs/theme_tab/theme_tab.js

@@ -314,7 +314,18 @@ export default {
       },
       set (val) {
         if (val) {
-          this.shadowsLocal[this.shadowSelected] = this.currentShadowFallback.map(_ => Object.assign({}, _))
+          this.shadowsLocal[this.shadowSelected] = (this.currentShadowFallback || [])
+            .map(s => ({
+              name: null,
+              x: 0,
+              y: 0,
+              blur: 0,
+              spread: 0,
+              inset: false,
+              color: '#000000',
+              alpha: 1,
+              ...s
+            }))
         } else {
           delete this.shadowsLocal[this.shadowSelected]
         }

+ 8 - 7
src/components/settings_modal/tabs/theme_tab/theme_tab.scss

@@ -25,7 +25,9 @@
     margin-bottom: 5px;
 
     .label {
+      margin-right: 1em;
       flex: 1;
+      line-height: 2;
     }
 
     .opt {
@@ -48,15 +50,14 @@
 
       &[type="range"] {
         flex: 1;
-        min-width: 3em;
-        align-self: flex-start;
+        min-width: 2em;
+        align-self: center;
+        margin: 0 0.5em;
       }
-    }
 
-    &.disabled {
-      input,
-      select {
-        opacity: 0.5;
+      &[type="checkbox"] + i {
+        height: 1.1em;
+        align-self: center;
       }
     }
   }

+ 11 - 46
src/components/settings_modal/tabs/theme_tab/theme_tab.vue

@@ -123,10 +123,13 @@
       </div>
     </div>
 
-    <!-- eslint-disable vue/no-v-text-v-html-on-component -->
-    <component :is="'style'" v-html="themeV3Preview"/>
-    <!-- eslint-enable vue/no-v-text-v-html-on-component -->
-    <preview id="theme-preview"/>
+    <!-- eslint-disable vue/no-v-html vue/no-v-text-v-html-on-component -->
+    <component
+      :is="'style'"
+      v-html="themeV3Preview"
+    />
+    <!-- eslint-enable vue/no-v-html vue/no-v-text-v-html-on-component -->
+    <preview id="theme-preview" />
 
     <div>
       <button
@@ -934,24 +937,14 @@
               </Select>
             </div>
             <div class="override">
-              <label
-                for="override"
-                class="label"
-              >
-                {{ $t('settings.style.shadows.override') }}
-              </label>
-              {{ ' ' }}
-              <input
+              <Checkbox
                 id="override"
                 v-model="currentShadowOverriden"
                 name="override"
                 class="input-override"
-                type="checkbox"
               >
-              <label
-                class="checkbox-label"
-                for="override"
-              />
+                {{ $t('settings.style.shadows.override') }}
+              </Checkbox>
             </div>
             <button
               class="btn button-default"
@@ -962,38 +955,10 @@
           </div>
           <ShadowControl
             v-model="currentShadow"
-            :ready="!!currentShadowFallback"
+            :separate-inset="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'"
             :fallback="currentShadowFallback"
           />
-          <div v-if="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'">
-            <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>
         </div>
-
         <div
           :label="$t('settings.style.fonts._tab_label')"
           class="fonts-container"

+ 73 - 63
src/components/shadow_control/shadow_control.js

@@ -1,9 +1,12 @@
-import ColorInput from '../color_input/color_input.vue'
-import OpacityInput from '../opacity_input/opacity_input.vue'
-import Select from '../select/select.vue'
-import { getCssShadow } from '../../services/theme_data/theme_data.service.js'
-import { hex2rgb } from '../../services/color_convert/color_convert.js'
+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 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 { library } from '@fortawesome/fontawesome-svg-core'
+import { throttle } from 'lodash'
 import {
   faTimes,
   faChevronDown,
@@ -30,93 +33,100 @@ const toModel = (object = {}) => ({
 })
 
 export default {
-  // 'modelValue' and 'Fallback' can be undefined, but if they are
-  // initially vue won't detect it when they become something else
-  // therefore i'm using "ready" which should be passed as true when
-  // data becomes available
   props: [
-    'modelValue', 'fallback', 'ready'
+    'modelValue', 'fallback', 'separateInset', 'noPreview', 'disabled'
   ],
-  emits: ['update:modelValue'],
+  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)
+      cValue: (this.modelValue ?? this.fallback ?? []).map(toModel)
     }
   },
   components: {
     ColorInput,
     OpacityInput,
-    Select
+    Select,
+    Checkbox,
+    Popover,
+    ComponentPreview
+  },
+  beforeUpdate () {
+    this.cValue = (this.modelValue ?? this.fallback ?? []).map(toModel)
+  },
+  computed: {
+    selected () {
+      const selected = this.cValue[this.selectedId]
+      if (selected) {
+        return { ...selected }
+      }
+      return null
+    },
+    present () {
+      return this.selected != null && !this.usingFallback
+    },
+    shadowsAreNull () {
+      return this.modelValue == null
+    },
+    currentFallback () {
+      return this.fallback?.[this.selectedId]
+    },
+    moveUpValid () {
+      return this.selectedId > 0
+    },
+    moveDnValid () {
+      return this.selectedId < this.cValue.length - 1
+    },
+    usingFallback () {
+      return this.modelValue == null
+    },
+    style () {
+      if (this.separateInset) {
+        return {
+          filter: getCssShadowFilter(this.cValue),
+          boxShadow: getCssShadow(this.cValue, true)
+        }
+      }
+      return {
+        boxShadow: getCssShadow(this.cValue)
+      }
+    }
+  },
+  watch: {
+    selected (value) {
+      this.$emit('subShadowSelected', this.selectedId)
+    }
   },
   methods: {
+    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 = this.cValue.length - 1
+      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
-    }
-  },
-  beforeUpdate () {
-    this.cValue = this.modelValue || this.fallback
-  },
-  computed: {
-    anyShadows () {
-      return this.cValue.length > 0
-    },
-    anyShadowsFallback () {
-      return this.fallback.length > 0
-    },
-    selected () {
-      if (this.ready && this.anyShadows) {
-        return this.cValue[this.selectedId]
-      } else {
-        return toModel({})
-      }
-    },
-    currentFallback () {
-      if (this.ready && this.anyShadowsFallback) {
-        return this.fallback[this.selectedId]
-      } else {
-        return toModel({})
-      }
-    },
-    moveUpValid () {
-      return this.ready && this.selectedId > 0
-    },
-    moveDnValid () {
-      return this.ready && this.selectedId < this.cValue.length - 1
-    },
-    present () {
-      return this.ready &&
-        typeof this.cValue[this.selectedId] !== 'undefined' &&
-        !this.usingFallback
-    },
-    usingFallback () {
-      return typeof this.modelValue === 'undefined'
-    },
-    rgb () {
-      return hex2rgb(this.selected.color)
-    },
-    style () {
-      return this.ready
-        ? {
-            boxShadow: getCssShadow(this.fallback)
-          }
-        : {}
+      this.$emit('update:modelValue', this.cValue)
     }
   }
 }

+ 105 - 0
src/components/shadow_control/shadow_control.scss

@@ -0,0 +1,105 @@
+.settings-modal .settings-modal-panel .shadow-control {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: stretch;
+  grid-gap: 0.25em;
+  margin-bottom: 1em;
+
+  .shadow-switcher {
+    order: 1;
+    flex: 1 0 6em;
+    min-width: 6em;
+    margin-right: 0.125em;
+    display: flex;
+    flex-direction: column;
+
+    .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 {
+    order: 3;
+    flex: 2 0 10em;
+    min-width: 10em;
+    margin-left: 0.125em;
+    margin-right: 0.125em;
+
+    /* hack */
+    .input-boolean {
+      flex: 1;
+      display: flex;
+
+      .label {
+        flex: 1;
+      }
+    }
+
+    .input-string {
+      flex: 1 0 5em;
+    }
+
+    .id-control {
+      align-items: stretch;
+
+      .shadow-switcher,
+      .btn {
+        min-width: 1px;
+        margin-right: 5px;
+      }
+
+      .btn {
+        padding: 0 0.4em;
+        margin: 0 0.1em;
+      }
+    }
+  }
+
+  &.-no-preview {
+    .shadow-tweak {
+      order: 0;
+      flex: 2 0 8em;
+      max-width: 100%;
+    }
+
+    .input-range {
+      min-width: 5em;
+    }
+  }
+
+  .inset-alert {
+    padding: 0.25em 0.5em;
+  }
+
+  &.disabled {
+    .inset-alert {
+      opacity: 0.2;
+    }
+  }
+
+  .shadow-preview {
+    order: 2;
+    flex: 3 3 15em;
+    min-width: 10em;
+    margin-left: 0.125em;
+    align-self: start;
+  }
+}
+
+.inset-tooltip {
+  padding: 0.5em;
+  max-width: 30em;
+}

+ 143 - 213
src/components/shadow_control/shadow_control.vue

@@ -1,91 +1,51 @@
 <template>
   <div
-    class="shadow-control"
-    :class="{ disabled: !present }"
+    class="label shadow-control"
+    :class="{ disabled: disabled || !present, '-no-preview': noPreview }"
   >
-    <div class="shadow-preview-container">
-      <div
-        :disabled="!present"
-        class="y-shift-control"
-      >
-        <input
-          v-model="selected.y"
-          :disabled="!present"
-          class="input input-number"
-          type="number"
-        >
-        <div class="wrap">
-          <input
-            v-model="selected.y"
-            :disabled="!present"
-            class="input input-range"
-            type="range"
-            max="20"
-            min="-20"
-          >
-        </div>
-      </div>
-      <div class="preview-window">
-        <div
-          class="preview-block"
-          :style="style"
-        />
-      </div>
-      <div
-        :disabled="!present"
-        class="x-shift-control"
+    <ComponentPreview
+      v-if="!noPreview"
+      class="shadow-preview"
+      :shadow-control="true"
+      :shadow="selected"
+      :preview-style="style"
+      :disabled="disabled || !present"
+      @update:shadow="({ axis, value }) => updateProperty(axis, value)"
+    />
+    <div class="shadow-switcher">
+      <Select
+        id="shadow-list"
+        v-model="selectedId"
+        class="shadow-list"
+        size="10"
+        :disabled="shadowsAreNull"
       >
-        <input
-          v-model="selected.x"
-          :disabled="!present"
-          class="input input-number"
-          type="number"
+        <option
+          v-for="(shadow, index) in cValue"
+          :key="index"
+          :value="index"
+          :class="{ '-active': index === Number(selectedId) }"
         >
-        <div class="wrap">
-          <input
-            v-model="selected.x"
-            :disabled="!present"
-            class="input input-range"
-            type="range"
-            max="20"
-            min="-20"
-          >
-        </div>
-      </div>
-    </div>
-
-    <div class="shadow-tweak">
+          {{ shadow?.name ?? $t('settings.style.shadows.shadow_id', { value: index }) }}
+        </option>
+      </Select>
       <div
-        :disabled="usingFallback"
-        class="id-control style-control"
+        class="id-control btn-group arrange-buttons"
       >
-        <Select
-          id="shadow-switcher"
-          v-model="selectedId"
-          class="shadow-switcher"
-          :disabled="!ready || usingFallback"
-        >
-          <option
-            v-for="(shadow, index) in cValue"
-            :key="index"
-            :value="index"
-          >
-            {{ $t('settings.style.shadows.shadow_id', { value: index }) }}
-          </option>
-        </Select>
         <button
           class="btn button-default"
-          :disabled="!ready || !present"
-          @click="del"
+          :disabled="disabled || shadowsAreNull"
+          @click="add"
         >
           <FAIcon
             fixed-width
-            icon="times"
+            icon="plus"
           />
         </button>
         <button
           class="btn button-default"
-          :disabled="!moveUpValid"
+          :disabled="disabled || !moveUpValid"
+          :class="{ disabled: disabled || !moveUpValid }"
           @click="moveUp"
         >
           <FAIcon
@@ -95,7 +55,8 @@
         </button>
         <button
           class="btn button-default"
-          :disabled="!moveDnValid"
+          :disabled="disabled || !moveDnValid"
+          :class="{ disabled: disabled || !moveDnValid }"
           @click="moveDn"
         >
           <FAIcon
@@ -105,222 +66,191 @@
         </button>
         <button
           class="btn button-default"
-          :disabled="usingFallback"
-          @click="add"
+          :disabled="disabled || !present"
+          :class="{ disabled: disabled || !present }"
+          @click="del"
         >
           <FAIcon
             fixed-width
-            icon="plus"
+            icon="times"
           />
         </button>
       </div>
+    </div>
+    <div class="shadow-tweak">
       <div
-        :disabled="!present"
-        class="inset-control style-control"
+        :class="{ disabled: disabled || !present }"
+        class="name-control style-control"
       >
         <label
-          for="inset"
+          for="name"
           class="label"
+          :class="{ faint: disabled || !present }"
         >
-          {{ $t('settings.style.shadows.inset') }}
+          {{ $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"
-          v-model="selected.inset"
-          :disabled="!present"
+          :value="selected?.inset"
+          :disabled="disabled || !present"
           name="inset"
-          class="input -checkbox input-inset visible-for-screenreader-only"
-          type="checkbox"
+          class="input-inset input-boolean"
+          @input="e => updateProperty('inset', e.target.checked)"
         >
-        <label
-          class="checkbox-label"
-          for="inset"
-          :aria-hidden="true"
-        />
+          <template #before>
+            {{ $t('settings.style.shadows.inset') }}
+          </template>
+        </Checkbox>
       </div>
       <div
-        :disabled="!present"
+        :disabled="disabled || !present"
+        :class="{ disabled: disabled || !present }"
         class="blur-control style-control"
       >
         <label
-          for="spread"
+          for="blur"
           class="label"
+          :class="{ faint: disabled || !present }"
         >
           {{ $t('settings.style.shadows.blur') }}
         </label>
         <input
           id="blur"
-          v-model="selected.blur"
-          :disabled="!present"
+          :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
-          v-model="selected.blur"
-          :disabled="!present"
-          class="input input-number"
+          :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
-        :disabled="!present"
         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"
-          v-model="selected.spread"
-          :disabled="!present"
+          :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)"
         >
         <input
-          v-model="selected.spread"
-          :disabled="!present"
-          class="input input-number"
+          :value="selected?.spread"
+          class="input input-number -small"
+          :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
-        v-model="selected.color"
-        :disabled="!present"
+        :model-value="selected?.color"
+        :disabled="disabled || !present"
         :label="$t('settings.style.common.color')"
-        :fallback="currentFallback.color"
+        :fallback="currentFallback?.color"
         :show-optional-tickbox="false"
         name="shadow"
+        @update:modelValue="e => updateProperty('color', e)"
       />
       <OpacityInput
-        v-model="selected.alpha"
-        :disabled="!present"
+        :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"
+          >
+            <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"
+            >
+              <code>box-shadow</code>
+            </i18n-t>
+            <p>{{ $t('settings.style.shadows.filter_hint.spread_zero') }}</p>
+          </div>
+        </template>
+      </Popover>
     </div>
   </div>
 </template>
 
 <script src="./shadow_control.js"></script>
 
-<style lang="scss">
-.shadow-control {
-  display: flex;
-  flex-wrap: wrap;
-  justify-content: center;
-  margin-bottom: 1em;
-
-  .shadow-preview-container,
-  .shadow-tweak {
-    margin: 5px 6px 0 0;
-  }
-
-  .shadow-preview-container {
-    flex: 0;
-    display: flex;
-    flex-wrap: wrap;
-
-    input[type="number"] {
-      width: 5em;
-      min-width: 2em;
-    }
-
-    .x-shift-control,
-    .y-shift-control {
-      display: flex;
-      flex: 0;
-
-      &[disabled="disabled"] * {
-        opacity: 0.5;
-      }
-    }
-
-    .x-shift-control {
-      align-items: flex-start;
-    }
-
-    .x-shift-control .wrap,
-    input[type="range"] {
-      margin: 0;
-      width: 15em;
-      height: 2em;
-    }
-
-    .y-shift-control {
-      flex-direction: column;
-      align-items: flex-end;
-
-      .wrap {
-        width: 2em;
-        height: 15em;
-      }
-
-      input[type="range"] {
-        transform-origin: 1em 1em;
-        transform: rotate(90deg);
-      }
-    }
-
-    .preview-window {
-      flex: 1;
-      background-color: #999;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      background-image:
-        linear-gradient(45deg, #666 25%, transparent 25%),
-        linear-gradient(-45deg, #666 25%, transparent 25%),
-        linear-gradient(45deg, transparent 75%, #666 75%),
-        linear-gradient(-45deg, transparent 75%, #666 75%);
-      background-size: 20px 20px;
-      background-position: 0 0, 0 10px, 10px -10px, -10px 0;
-      border-radius: var(--roundness);
-
-      .preview-block {
-        width: 33%;
-        height: 33%;
-        border-radius: var(--roundness);
-      }
-    }
-  }
-
-  .shadow-tweak {
-    flex: 1;
-    min-width: 280px;
-
-    .id-control {
-      align-items: stretch;
-
-      .shadow-switcher {
-        flex: 1;
-      }
-
-      .shadow-switcher,
-      .btn {
-        min-width: 1px;
-        margin-right: 5px;
-      }
-
-      .btn {
-        padding: 0 0.4em;
-        margin: 0 0.1em;
-      }
-    }
-  }
-}
-</style>
+<style src="./shadow_control.scss" lang="scss"></style>

+ 4 - 0
src/i18n/en.json

@@ -874,6 +874,9 @@
         "component": "Component",
         "override": "Override",
         "shadow_id": "Shadow #{value}",
+        "offset": "Shadow offset",
+        "light_grid": "Use light checkerboard",
+        "name": "Name",
         "blur": "Blur",
         "spread": "Spread",
         "inset": "Inset",
@@ -881,6 +884,7 @@
         "filter_hint": {
           "always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.",
           "drop_shadow_syntax": "{0} does not support {1} parameter and {2} keyword.",
+          "avatar_inset_short": "Separate inset shadow",
           "avatar_inset": "Please note that combining both inset and non-inset shadows on avatars might give unexpected results with transparent avatars.",
           "spread_zero": "Shadows with spread > 0 will appear as if it was set to zero",
           "inset_classic": "Inset shadows will be using {0}"

+ 1 - 1
src/services/theme_data/theme_data.service.js

@@ -452,7 +452,7 @@ export const getCssShadow = (input, usesDropShadow) => {
     ]).join(' ')).join(', ')
 }
 
-const getCssShadowFilter = (input) => {
+export const getCssShadowFilter = (input) => {
   if (input.length === 0) {
     return 'none'
   }

+ 27 - 9
src/services/theme_data/theme_data_3.service.js

@@ -182,7 +182,7 @@ export const init = ({
 
   const rulesetUnsorted = [
     ...Object.values(components)
-      .map(c => (c.defaultRules || []).map(r => ({ component: c.name, ...r, source: 'Built-in' })))
+      .map(c => (c.defaultRules || []).map(r => ({ source: 'Built-in', component: c.name, ...r })))
       .reduce((acc, arr) => [...acc, ...arr], []),
     ...inputRuleset
   ].map(rule => {
@@ -198,18 +198,33 @@ export const init = ({
 
   const ruleset = rulesetUnsorted
     .map((data, index) => ({ data, index }))
-    .sort(({ data: a, index: ai }, { data: b, index: bi }) => {
+    .toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
       const parentsA = unroll(a).length
       const parentsB = unroll(b).length
 
-      if (parentsA === parentsB) {
-        if (a.component === 'Text') return -1
-        if (b.component === 'Text') return 1
+      let aScore = 0
+      let bScore = 0
+
+      aScore += parentsA * 1000
+      bScore += parentsB * 1000
+
+      aScore += a.variant !== 'normal' ? 100 : 0
+      bScore += b.variant !== 'normal' ? 100 : 0
+
+      aScore += a.state.filter(x => x !== 'normal').length * 1000
+      bScore += b.state.filter(x => x !== 'normal').length * 1000
+
+      aScore += a.component === 'Text' ? 1 : 0
+      bScore += b.component === 'Text' ? 1 : 0
+
+      // Debug
+      a.specifityScore = aScore
+      b.specifityScore = bScore
+
+      if (aScore === bScore) {
         return ai - bi
       }
-      if (parentsA === 0 && parentsB !== 0) return -1
-      if (parentsB === 0 && parentsA !== 0) return 1
-      return parentsA - parentsB
+      return aScore - bScore
     })
     .map(({ data }) => data)
 
@@ -235,7 +250,10 @@ export const init = ({
 
     // 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 computedDirectives =
+          existingRules
+            .map(r => r.directives)
+            .reduce((acc, directives) => ({ ...acc, ...directives }), {})
     const computedRule = {
       ...combination,
       directives: computedDirectives