改变低代码平台的单调背景!实现一个支持复合图层的调色盘

1,985 阅读11分钟

分享一个最近自研产品里添加的一个调色盘模块,使用的前端框架是最新的vue3+vite4,如果对相关源码有兴趣的可以私信确认,让我们先看看效果(忽略其他配置属性):

整体示例.gif

以上效果是已经集成在自研低代码平台内的,业余开发用时大概在三周左右,UI上可能有点微妙的大家都懂得的即视感。但整体目前产品还未发布,计划7月左右发布,现在就算先打个广告了,希望感兴趣的大佬能一起讨论和交流。 😁

第一步、分析基础功能

说回正文,现在让我们首先分析一下功能:

需要实现一个让组件能修改背景的背景模块(调色盘),支持单色、渐变色及图像等背景,其次支持类似PS的混合模式。

目前低代码平台上只能简单的编辑单色背景,如果需要做更加好看的背景效果,就需要更复杂的组件,这时可以选择参考目前市面上一些流行的设计软件。

在参考了目前的一些设计软件之后,得到了目前的界面,然后,让我们直接开始抄…咳…借鉴吧!

由于目前调色盘模块是集成到低代码平台内的,且低代码平台主要是由DOM节点及组件构成,未来还会继续扩展,所以不太合适参考其他软件直接使用canvas画布来开发,而是考虑使用原生css及部分svg效果实现。但是要使用css及svg实现接近完全的背景编辑效果…ummmmm坑确实有不少,但是先让我们一步一步来

第二步、分析核心功能

首先,我们需要给一个组件增加背景,常规背景包含颜色/渐变/图像等,省略掉在低代码平台中的配置过程,背景最终拆分为以下几种类型:

1. 纯色背景

指只有一种颜色的背景,虽然单调,但是平时在生产中可能反而用的是最多的,以下是相关功能:

功能1:允许直接在画板上选择颜色;允许更改色环或者直接更改透明度。

功能2:允许在变更样式后缓存样式。

功能3:可以在文本框直接通过粘贴或者编辑实现颜色值及细节的微调。

完成结果的预览图如下:

2. 渐变背景

包含线性渐变/径向渐变/旋转渐变这三种(目前CSS仅支持这三种渐变),用的好的话对界面观感会有较大的提升,当然用不好了在别人看来可能就是所谓的花里胡哨了 🤣 ,以下是相关功能:

功能1:可以在主画板上通过拖拽动态改变渐变的开始点及结束点。

功能2:可以通过修改渐变色上的游标动态改变渐变效果。

功能3:新增游标;通过拖拽或者删除键删除游标。

功能4:在径向渐变及旋转渐变上,需要通过圆环的侧边点修改椭圆度。

功能5:渐变游标整体的翻转、及渐变线的旋转操作。

完成结果的预览图如下:

3. 图像

在背景上算是重中之重了,UI要求高一些的界面上会频繁的采用背景图像,相对来说功能上也是最难实现的,以下是相关功能:

功能1:选择图像。

功能2:修改填充模式,例如适应、拉伸、平铺等等。

功能3:旋转、翻转等等。

功能4:图像特效,例如亮度、对比度、色调、反相等等。

功能5:缩放、裁剪、变形等。(暂未实现,后续有可能实现)

功能6:路径裁切、蒙版等。(暂未实现,后续有可能实现)

完成结果的预览图如下:

4. 背景项叠加

多个背景项可以像PS的图层那样进行叠加,除了样式效果叠加以外,也需要加入类似于PS的混合模式,让UI可以在界面中发挥出更高的水平。(更加的花里胡哨)

第三步、完成后的整体架构整理如下:

第四步、整理难点

经过一两天的整理,有如下几个难点:

1. 怎样通过非canvas方案实现屏幕上任意一个点到另外一个点的线性渐变效果。

目前只有线性渐变是无法设置开始点和结束点,只能设置渐变角度。而要突破这个限制,只能在组件里新增一个div,然后动态计算div背景层的尺寸及位置,根据两点的中点去找到和对应组件内切的正方形,实际计算效果(移除 overflow样式后)可以参考以下示例:

示例1.gif

2. 如何实现图像的特殊效果。

特殊效果分为两种,第一种是缩放/旋转/翻转;另外一种则是亮度/对比度/色调/反向等等效果。熟悉CSS的读者应该会很快的反应过来,这两种都是CSS样式可以实现,即通过 transformbackgroundfilter实现。

但实际上真的要去做的话就很发现问题了,比如当时困扰我的有几个问题:

1、通过 background背景图来实现的情况下,怎样在平铺的情况下旋转图片?怎样在图片DOM节点旋转后依旧能铺满父级元素?

2、怎样实现背景图未来更多的调整方式?例如变形、水波纹等更多滤镜。

从这两点看来,虽然短期内可以通过css样式去实现,至少其中部分功能长期来说还是需要选用一个好用的前端图形处理库实现。

目前因为css还是更容易控制,部分功能还是采用css实现,另外一部分使用 AlloyImage 库实现,如果大家有更好用的前端图形库也欢迎推荐。

3. 图层混合模式

最开始以为会比较困难的类似于PS图层的混合模式,实际上CSS一直有提供,也就是 mix-blend-mode样式,大部分常用混合模式都有提供。为啥写在这儿呢,也算是给我自己提个醒吧,最开始是从前端图形处理库上去尝试解决,直到后来才发现都是现成的。可以参考以下链接:developer.mozilla.org/zh-CN/docs/…

第五步、编写TS接口

要开发之前需要确认我们需要实现的背景接口及字段定义等,背景接口分为如上述需求的几个类型。

    // 背景类型
    export type AppBackgroundType = 'color' | 'linear-gradient' | 'radial-gradient' | 'conic-gradient' | 'image';

    /** 应用背景(单层) */
    export interface AppBasicBackground {
      type: AppBackgroundType;
      /** 图层混合类型 */
      blendType: 'normal';
      /** 是否显示 */
      show: boolean;
      /** 透明度:0~1 */
      opacity: number;
    }

    /** 背景 */
    export type AppBackground = AppColorBackground | 
      AppLinearGradientBackground | 
      AppRadialGradientBackground | 
      AppConicGradientBackground |
      AppImageBackground;

    /** 颜色型背景 */
    export interface AppColorBackground extends AppBasicBackground {
      /** 类型:颜色 */
      type: 'color';
      /** 颜色 */
      color: AppColor;
    }

    /** 线性渐变型背景 */
    export interface AppLinearGradientBackground extends AppBasicBackground {
      /** 类型:线性渐变 */
      type: 'linear-gradient';
      /** 是否循环渐变 */
      repeating: boolean;
      /** 渐变列表 */
      gradientList: GradientItem[];
      /** 第1个坐标点 - 横坐标 */
      x1: number;
      /** 第1个坐标点 - 纵坐标 */
      y1: number;
      /** 第2个坐标点 - 横坐标 */
      x2: number;
      /** 第2个坐标点 - 纵坐标 */
      y2: number;
    }

    /** 径向渐变型背景 */
    export interface AppRadialGradientBackground extends AppBasicBackground {
      /** 类型:径向渐变 */
      type: 'radial-gradient';
      /** 是否循环渐变 */
      repeating: boolean;
      /** 基础圆半径 */
      radius: number;
      /** 椭圆长边和基础圆半径的比值 */
      ovalityRatio: number;
      /** 渐变列表 */
      gradientList: GradientItem[];
      /** 第1个坐标点 - 横坐标 */
      x1: number;
      /** 第1个坐标点 - 纵坐标 */
      y1: number;
      /** 第2个坐标点 - 横坐标 */
      x2: number;
      /** 第2个坐标点 - 纵坐标 */
      y2: number;
    }

    /** 圆锥渐变型背景 */
    export interface AppConicGradientBackground extends AppBasicBackground {
      /** 类型:圆锥渐变 */
      type: 'conic-gradient';
      /** 基础圆半径 */
      radius: number;
      /** 椭圆长边和基础圆半径的比值 */
      ovalityRatio: number;
      /** 渐变列表 */
      gradientList: GradientItem[];
      /** 第1个坐标点 - 横坐标 */
      x1: number;
      /** 第1个坐标点 - 纵坐标 */
      y1: number;
      /** 第2个坐标点 - 横坐标 */
      x2: number;
      /** 第2个坐标点 - 纵坐标 */
      y2: number;
    }

    /** 图片型背景 */
    export interface AppImageBackground extends AppBasicBackground {
      /** 类型:圆锥渐变 */
      type: 'image';
      /** 图片路径 */
      imageUrl: string;
      /** 填充方式 auto:自动;stretch:拉伸;cover:充满;contain:适应;repeat:平铺;repeat-x:横向平铺;repeat-y:纵向平铺  */
      fillMode: 'auto' | 'stretch' | 'contain' | 'cover' | 'repeat' | 'repeat-x' | 'repeat-y';
      /** 图片横向位置 */
      x: 'left' | 'center' | 'right' | number;
      /** 图片纵向位置 */
      y: 'top' | 'center' | 'bottom' | number;
      /** 旋转角度:0~360 */
      rotate: number;
      /** 横向翻转 */
      xFlipOver: boolean;
      /** 纵向翻转 */
      yFlipOver: boolean;

      /** 亮度:0~2 */
      brightness: number;
      /** 对比度:0~2 */
      contrast: number;
      /** 模糊:0~100 */
      blur: number;
      /** 灰度:0~1 */
      grayscale: number;
      /** 色调:0~360° */
      hueRotate: number;
      /** 反相:0~1 */
      invert: number;
      /** 饱和度:0~2 */
      saturate: number;
      /** 深褐色:0~1 */
      sepia: number;
    }

其次是背景、颜色相关杂项

    /** 应用颜色 */
    export interface AppColor {
      /** 红色值:0~255 */
      r: number;
      /** 绿色值:0~255 */
      g: number;
      /** 蓝色值:0~255 */
      b: number;
      /** 透明度:0~1 */
      a: number;
      /** 临时Hue值 */
      tempHue?: number;
    }

    /** 渐变列表项 */
    export interface GradientItem {
      /** 百分比定位(0~1) */
      progress: number;
      /** 颜色 */
      color: AppColor;
    }

    /**
     * 混合模式
     * - normal: 正常
     * - multiply: 正片叠底
     * - screen: 滤色
     * - overlay: 叠加
     * - darken: 变暗
     * - lighten: 变亮
     * - color-dodge: 颜色减淡
     * - color-burn: 颜色加深
     * - hard-light: 强光
     * - soft-light: 柔光
     * - difference: 差值
     * - exclusion: 排除
     * - hue: 色相
     * - saturation: 饱和度
     * - color: 颜色
     * - luminosity: 亮度
     * - initial: 初始
     * - inherit: 继承
     */
    export type BlendMode = 'normal' | 'darken' | 'multiply' | 'color-burn' | 'lighten' | 'screen' | 'color-dodge' | 'overlay' | 'soft-light' |
      'hard-light' | 'difference' | 'exclusion' | 'hue' | 'saturation' | 'color' | 'luminosity' | 'initial' | 'inherit';


    /** 渐变相关尺寸信息 */
    export interface GradientRectInfo {
      /** 最小X */
      minX: number;
      /** 最小Y */
      minY: number;
      /** 最大X */
      maxX: number;
      /** 最大Y */
      maxY: number;
      /** 中点X坐标 */
      centerX: number;
      /** 中点Y坐标 */
      centerY: number;
      /** 背景正方形半径 */
      radius: number;
      /** 渐变线角度 */
      rotate: number;
      /** 线段延长线与正方形相交的坐标A */
      pointA: { x: number, y: number };
      /** 线段延长线与正方形相交的坐标B */
      pointB: { x: number, y: number };
      /** 线段和整条延长线的比值 */
      ratio: number;
    }

第六步、确认需开发组件

现在基础需求已经整理完了,接下来就是确认待开发的组件了。

依旧还是参考某产品 🤣,确认下来之后我们粗略划分为以下3个大组件。

承载背景编辑功能的弹出框

承载动态修改渐变效果的画布组件

在属性编辑栏上的编辑组件

其中最为复杂的则是弹出框内的编辑器部分,每个背景类型都有对应的编辑界面,相对来说需要注意的细节会非常多。

第七步、完成代码开发

1. 背景调整组件

用于调整当前背景效果的功能区域,可以简单的调整颜色/基础渐变/图像效果等。

    <template>
      <HakuDialog
        class="background-dialog"
        v-model:visible="backgroundEditorState.isShow"
        :drag="true"
        :title="backgroundEditorState.currentBackgroundTypeText"
        :body-style="{ width: '240px' }"
        :style="[backgroundEditorState.dialogCss, { width: '240px', left: 'initial' }]"
        @close="backgroundEditorState.isShow = false;"
      >
        <template #header-tools>
          
          <div
            class="haku-dialog-header-tool"
            title="混合模式"
            tabindex="-1"
            @click="state.showBlendModeSelect = !state.showBlendModeSelect"
            @blur="state.showBlendModeSelect = false"
          >
            <i class="iconfont icon-config3"></i>
            <SimpleSelect
              ref="colorTypeSelect"
              v-model:visible="state.showBlendModeSelect"
              :options="backgroundEditorState.blendModeList"
              v-model:value="backgroundEditorState.currentBackground.blendType"
            ></SimpleSelect>
          </div>
        </template>
        <!-- 背景类型选择 -->
        <div class="background-dialog-type-panel">
          <ul
            class="background-type-tabs"
            :style="{
              '--background-type-count': backgroundEditorState.backgroundTypeList.length,
              '--background-type-index': currentBackgroundTypeIndex
            }"
          >
            <li
              class="background-type-tab"
              :class="{ active: backgroundEditorState.currentBackground.type === item.name }"
              :title="item.title"
              v-for="item in backgroundEditorState.backgroundTypeList"
              @click="changeBackgroundType(item.name)"
              :style="{
                backgroundImage: `url(${item.url})`
              }"
            ></li>
          </ul>
        </div>
        <!-- 选择器内容区域 -->
        <div class="background-dialog-content">
          <!-- 渐变 -->
          <div class="gradient-config" v-if="backgroundEditorState.currentBackground.type !== 'image' && backgroundEditorState.currentBackground.type !== 'color'">
            <GradientSlider
              v-model:gradient-background="backgroundEditorState.currentBackground"
              v-model:current-cursor-index="backgroundEditorState.currentGradientItemIndex"
              @change="change"
            />
          </div>
          <!-- 纯色 -->
          <TypeColorPicker
            v-if="backgroundEditorState.currentBackground.type !== 'image'"
            v-model:value="currentColor"
            @change="change"
          />
          <TypeImagePicker
            v-else
            v-model:value="backgroundEditorState.currentBackground"
            @change="change"
          />
        </div>
      </HakuDialog>
    </template>

    <script lang="ts" setup>
    import HakuDialog from '@/components/common/HakuDialog.vue';
    import { reactive } from 'vue';
    import TypeColorPicker from './type-color/TypeColorPicker.vue';
    import TypeImagePicker from './type-image/TypeImagePicker.vue';
    import SimpleSelect from './common/SimpleSelect.vue';
    import type { AppBackground, AppBackgroundType, AppColor } from '../index.d';
    import GradientSlider from './common/GradientSlider.vue';
    import { state as backgroundEditorState, service as backgroundEditorService } from '../';
    import { computed } from 'vue';
    import bus from '@/tools/bus';

    const emit = defineEmits<{
      (event: 'change', value: AppBackground): void;
    }>();

    const state = reactive({
      /** 混合模式下拉框是否显示 */
      showBlendModeSelect: false,
    });

    const currentBackgroundTypeIndex = computed(() => {
      return backgroundEditorState.backgroundTypeList.findIndex(i => i.name === backgroundEditorState.currentBackground.type);
    })

    const changeBackgroundType = (name: AppBackgroundType) => {
      backgroundEditorService.setBackgroundType(name);
      change();
    };

    const change = () => {
      if (backgroundEditorState.currentBackground.type === 'color') {
        backgroundEditorState.currentBackground.opacity = backgroundEditorState.currentBackground.color.a;
      }
      bus.$emit('background_editor_change');
      emit('change', backgroundEditorState.currentBackground);
    }

    const currentColor = computed<AppColor>({
      get() {
        if (backgroundEditorState.currentBackground.type === 'color') {
          return backgroundEditorState.currentBackground.color;
        } else if (backgroundEditorState.currentBackground.type === 'image') {
          return { r: 0, g: 0, b: 0, a: 0 };
        } else {
          if (backgroundEditorState.currentGradientItemIndex >= 0 && backgroundEditorState.currentGradientItemIndex < backgroundEditorState.currentBackground.gradientList.length - 1) {
            return backgroundEditorState.currentBackground.gradientList[backgroundEditorState.currentGradientItemIndex].color;
          } else {
            return { r: 0, g: 0, b: 0, a: 0 };
          }
        }
      },
      set(color: AppColor) {
        if (backgroundEditorState.currentBackground.type === 'color') {
          backgroundEditorState.currentBackground.color = color;
        } else if (backgroundEditorState.currentBackground.type === 'image') {
        } else {
          if (backgroundEditorState.currentGradientItemIndex >= 0) {
            backgroundEditorState.currentBackground.gradientList[backgroundEditorState.currentGradientItemIndex].color = color;
          }
        }
      }
    });
    </script>

    <style lang="less" scoped>

    .background-dialog-content {
      > .gradient-config {
        margin: 8px 0px;
      }
    }

    .background-dialog-type-panel {
      margin-bottom: 10px;
      
      > .background-type-tabs {
        position: relative;
        display: flex;
        flex-direction: row;
        justify-content: space-between;
        align-items: center;
        width: 100%;
        margin-bottom: 0px;
        background-color: #F5F5F5;
        border-radius: 6px;

        &:before {
          content: '';
          position: absolute;
          display: block;
          left: calc(var(--background-type-index, 0) * 100% / var(--background-type-count, 1) + 1%);
          top: 8%;
          width: calc(100% / var(--background-type-count, 1) - 2%);
          height: 84%;
          background-color: white;
          border: 1px solid #CCC;
          box-shadow: 0px 0px 3px 0px rgba(0,0,0,0.1);
          border-radius: 4px;
          transition: 0.15s left;
        }

        > .background-type-tab {
          cursor: pointer;
          display: flex;
          flex-direction: row;
          justify-content: center;
          align-items: center;
          width: 100%;
          height: 30px;
          text-align: center;
          z-index: 1;
          transition: 0.15s color;
          background-position: center center;
          background-size: 16px 16px;
          background-repeat: no-repeat;

          &:first-child {
            border-top-left-radius: 6px;
            border-bottom-left-radius: 6px;
          }

          &:last-child {
            border-top-right-radius: 6px;
            border-bottom-right-radius: 6px;
          }

          &.active {
            cursor: default;
            color: white;
          }
        }
      }
    }
    </style>

2. 颜色选择器滑块

调整颜色的滑块,根据参数可以用于调整色环或透明度等参数。

    <!-- 颜色选择器滑块 -->
    <template>
      <div class="color-picker-slider">
        <div ref="slider" class="color-picker-slider-content" :style="sliderStyle" @mousedown="startDrag">
          <div :style="{ left: cursorLeft + 'px' }" class="color-picker-slider-bar"></div>
        </div>
      </div>
    </template>

    <script lang="ts" setup>
    import { toDecimal } from '@/tools/common';
    import { useDragHook } from '@/tools/drag';
    import { computed, onMounted, onUnmounted, PropType, reactive, ref, defineModel } from 'vue';

    /** 绑定value */
    const modelValue = defineModel<number>('value', { required: true, default: 0 });

    const props = defineProps({
      sliderStyle: {
        type: Object as PropType<Record<string, any>>,
      },
      max: {
        type: Number,
        default: 100,
      },
    });

    /** 控件画布 */
    const slider = ref<HTMLElement>();

    const startDrag = (e) => {
      dragHook.startDrag(e);
      dragHook.drag(e);
    };

    /** 拖拽钩子 */
    const dragHook = useDragHook({
      drag(e) {
        const rect = slider.value!.getBoundingClientRect();
        const _cursorLeft = Math.min(Math.max(0, e.pageX - rect.left - 5), (rect.width - 10));
        const _value = toDecimal((_cursorLeft / (slider.value!.offsetWidth - 10)) * props.max, 3);
        modelValue.value = _value;
      }
    });

    /** 游标离左侧距离 */
    const cursorLeft = computed(() => {
      if (slider.value) return (slider.value.offsetWidth - 10) * modelValue.value / props.max - 1;
      else return 0;
    });

    onMounted(() => {
      dragHook.init();
    });

    onUnmounted(() => {
      dragHook.destory();
    });
    </script>

3. 渐变滑块

渐变滑块和颜色选择器滑块区别是包含多个渐变游标,可以移动/新增/删除这些游标

    <template>
      <div class="color-picker-slider">
        <div class="color-picker-slider-body">
          <div
            ref="slider"
            class="color-picker-slider-content"
            :style="sliderStyle"
            @mousedown.stop="addCursor"
          >
            <div class="color-picker-slider-bg" :style="{ backgroundImage: getBackgroundImage }"></div>
            <div
              :style="{
                '--leave': dragHook.config.currentCursorLeaveWidth,
                left: `${item.progress * 100}%`
              }"
              :class="{
                active: index === props.currentCursorIndex
              }"
              tabindex="-1"
              class="color-picker-slider-bar"
              v-for="(item, index) in props.gradientBackground.gradientList"
              @mousedown.stop="setCursor($event, index)"
              @keydown.stop="onKeyDown($event, index)"
            >
            </div>
          </div>
          <!-- 工具栏 -->
          <div class="color-picker-slider-tools">
            <div class="color-picker-slider-tool" tooltip="翻转渐变" @click="flipGradient">
              <i class="iconfont icon-change"></i>
            </div>
            <div class="color-picker-slider-tool" tooltip="旋转90°" @click="rotate90">
              <i class="iconfont icon-refresh"></i>
            </div>
          </div>
        </div>
      </div>
    </template>

    <script lang="ts" setup>
    import { computed, onMounted, onUnmounted, PropType, reactive, ref, type StyleValue } from 'vue';
    import type { AppLinearGradientBackground, AppConicGradientBackground, AppRadialGradientBackground, GradientItem } from '../../index.d';
    import { state as backgroundEditorState, service as backgroundEditorService } from '../../';
    import { toDecimal } from '@/tools/common';
    import { toast } from '@/common/message';
    import { getLinearGradientItem } from '@/lib/color/Color';
    import { useDragHook } from '@/tools/drag';

    const props = defineProps({
      value: {
        type: Number,
        default: 0,
      },
      sliderStyle: {
        type: Object as PropType<StyleValue>,
      },
      /** 渐变背景 */
      gradientBackground: {
        type: Object as PropType<AppLinearGradientBackground | AppRadialGradientBackground | AppConicGradientBackground>,
        required: true
      },
      /** 当前游标索引 */
      currentCursorIndex: {
        type: Number,
        required: true
      },
    });

    /** 控件画布 */
    const slider = ref<HTMLElement>();

    const emit = defineEmits<{
      (event: 'change'): void;
      (event: 'update:value', value: number): void;
      (event: 'update:gradientBackground', value: GradientItem[]): void;
      (event: 'update:currentCursorIndex', value: number): void;
    }>();

    /** 旋转90° */
    const rotate90 = () => {
      backgroundEditorService.rotate90Gradient(props.gradientBackground);
      emit('change');
    };

    /** 翻转背景 */
    const flipGradient = () => {
      const _gradientList = [] as GradientItem[];
      for (let i = props.gradientBackground.gradientList.length - 1; i >= 0; i--) {
        const item = props.gradientBackground.gradientList[i];
        _gradientList.push({
          ...item,
          progress: 1 - item.progress
        })
      }
      props.gradientBackground.gradientList = _gradientList;
      emit('change');
    }

    /** 获取从左到右线性渐变背景 */
    const getBackgroundImage = computed(() => {
      let _str = '';
      const _rgbList = props.gradientBackground.gradientList
        .map<[number, string]>(i => [i.progress, `rgba(${i.color.r}, ${i.color.g}, ${i.color.b}, ${i.color.a}) ${i.progress * 100}%` ])
        .sort((a, b) => a[0] - b[0]);
      for (let i = 0; i < _rgbList.length; i++) {
        if (i > 0) _str += ', ';
        _str += _rgbList[i][1];
      }
      return `linear-gradient(90deg, ${_str})`;
    });

    /** 键盘事件 */
    const onKeyDown = (e: KeyboardEvent, index: number) => {
      // 按下退格键
      if (e.code === 'Backspace') {
        removeCursor(index);
      }
    };

    /** 移除游标 */
    const removeCursor = (index: number) => {
      if (props.gradientBackground.gradientList.length <= 2) {
        toast('最少保留2个渐变节点');
        return;
      }
      emit('update:currentCursorIndex', index > 0 ? index - 1 : index + 1);
      props.gradientBackground.gradientList.splice(index, 1);
      emit('change');
    }

    /** 添加游标 */
    const addCursor = (e: MouseEvent) => {
      let _cursorList = props.gradientBackground.gradientList;

      const rect = slider.value!.getBoundingClientRect();
      const _cursorLeft = Math.min(Math.max(0, e.pageX - rect.left - 5), rect.width);
      const _progress = toDecimal(_cursorLeft / slider.value!.offsetWidth, 3);

      let prev: GradientItem | undefined;
      let next: GradientItem | undefined;
      for (let i = 0; i < props.gradientBackground.gradientList.length - 1; i++) {
        prev = props.gradientBackground.gradientList[i];
        next = props.gradientBackground.gradientList[i + 1];
        if (prev.progress <= _progress && next.progress >= _progress) {
          break;
        }
      }
      let color = { r: 255, g: 255, b: 255, a: 0 };
      if (prev && next) {
        color = getLinearGradientItem(prev.color, next.color, (_progress - prev.progress) / (next.progress - prev.progress) * 100);
      }
      
      _cursorList.push({ color, progress: _progress });
      _cursorList = _cursorList.sort((a, b) => a.progress - b.progress);
      const _index = _cursorList.findIndex(i => i.progress === _progress);
      props.gradientBackground.gradientList = _cursorList;
      emit('change');
      emit('update:currentCursorIndex', _index);
    }

    const setCursor = (e: MouseEvent, index: number) => {
      dragHook.isStart.value = true;
      dragHook.config.initDragY = e.pageY;
      emit('change');
      emit('update:currentCursorIndex', index);
    };

    /** 拖拽钩子 */
    const dragHook = useDragHook({
      config: {
        /** 初始拖拽Y坐标(可以用于拖离渐变条) */
        initDragY: 0,
        /** 光标离轴长度 */
        currentCursorLeaveWidth: 0,
      },
      drag(e) {
        if (props.currentCursorIndex >= 0) {
          if (Math.abs(dragHook.config.initDragY - e.pageY) > 50) {
            removeCursor(props.currentCursorIndex);
            dragHook.isStart.value = false;
          } else {
            const rect = slider.value!.getBoundingClientRect();
            const _cursorLeft = Math.min(Math.max(0, e.pageX - rect.left - 5), rect.width);
            props.gradientBackground.gradientList[props.currentCursorIndex].progress = toDecimal(_cursorLeft / slider.value!.offsetWidth, 3);
            emit('change');
          }
        }
      }
    });

    onMounted(() => {
      dragHook.init();
    });

    onUnmounted(() => {
      dragHook.destory();
    });
    </script>

4. 调色盘相关画布面板

用于在主体画布上直接操作渐变游标,用了控制显示效果

    <template>
      <div
        class="gradient-editor-panel"
        :class="{ visible: backgroundEditorState.isShow }"
        ref="gradientEditorPanel"
        @mousedown.stop
      >

        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="100%"
          height="100%"
          class="bg-gradient-editor-svg"
        >
          <!-- 圆环 -->
          <ellipse
            :cx="currentComponent.x + backgroundEditorState.currentBackground.x1"
            :cy="currentComponent.y + backgroundEditorState.currentBackground.y1"
            :rx="backgroundEditorState.currentBackground.radius"
            :ry="backgroundEditorState.currentBackground.ovalityRatio * backgroundEditorState.currentBackground.radius"
            stroke="#FFFFFF"
            stroke-width="3"
            fill="rgba(0,0,0,0)"
            :style="{
            transform: `rotate(${backgroundEditorService.getRotate(backgroundEditorState.currentBackground)}deg)`,
            transformOrigin: `${currentComponent.x + backgroundEditorState.currentBackground.x1}px ${currentComponent.y + backgroundEditorState.currentBackground.y1}px`
            }"
          />
          <!-- AB点连线 -->
          <line
            :x1="currentComponent.x + backgroundEditorState.currentBackground.x1"
            :y1="currentComponent.y + backgroundEditorState.currentBackground.y1"
            :x2="currentComponent.x + backgroundEditorState.currentBackground.x2"
            :y2="currentComponent.y + backgroundEditorState.currentBackground.y2"
            stroke-width="2"
            stroke="#FFFFFF"
          />
          <!-- 椭圆侧边点 -->
          <circle
            fill="#FFFFFF"
            :cx="currentComponent.x - (backgroundEditorState.currentBackground.radius * backgroundEditorState.currentBackground.ovalityRatio - currentComponent.width / 2)"
            :cy="currentComponent.y + backgroundEditorState.currentBackground.y1"
            v-if="backgroundEditorState.currentBackground.type === 'radial-gradient'"
            r="4"
              @mousedown="$event => dragSlidePointHook.startDrag($event)"
              :style="{
              transform: `rotate(${backgroundEditorService.getRotate(backgroundEditorState.currentBackground) - 90}deg)`,
              transformOrigin: `${currentComponent.x + backgroundEditorState.currentBackground.x1}px ${currentComponent.y + backgroundEditorState.currentBackground.y1}px`
              }"
            />
            <!-- A点 -->
            <circle
              fill="#FFFFFF"
              :cx="currentComponent.x + backgroundEditorState.currentBackground.x1"
              :cy="currentComponent.y + backgroundEditorState.currentBackground.y1"
              r="4"
              @mousedown="$event => dragPointHook.startDrag($event, { isStartNode: true })"
            />
            <!-- B点 -->
            <circle
              fill="#FFFFFF"
              :cx="currentComponent.x + backgroundEditorState.currentBackground.x2"
              :cy="currentComponent.y + backgroundEditorState.currentBackground.y2"
              r="4"
              @mousedown="$event => dragPointHook.startDrag($event, { isStartNode: false })"
            />
          </svg>

          <template>
            <div
              class="bg-panel-gradien-item"
              tabindex="-1"
              :class="{ 
                active: backgroundEditorState.currentGradientItemIndex === index,
                disabled: dragPointHook.isStart.value || dragSlidePointHook.isStart.value
              }"
              :style="{
                '--item-color': `rgba(${item.color.r}, ${item.color.g}, ${item.color.b}, ${item.color.a})`,
                left: `${backgroundEditorState.gradientListItemLocs?.[index]?.x}px`,
                top: `${backgroundEditorState.gradientListItemLocs?.[index]?.y}px`,
                transform: `rotate(${backgroundEditorState.gradientListItemLocs?.[index]?.rotate}deg)`,
              }"
              @keydown.stop="$event => onKeyDown($event, index)"
              v-for="(item, index) in backgroundEditorState.currentBackground.gradientList"
            >
            <svg
              class="bg-panel-gradien-item-icon"
              viewBox="0 0 1024 1024"
              version="1.1"
              xmlns="http://www.w3.org/2000/svg"
              width="20"
              height="20"
              @mousedown="$event => setCursor($event, index)"
            >
              <path
                d="M513.024 1024h-1.024c-17.92 0-34.816-7.168-47.104-20.48-9.728-10.24-97.28-102.912-184.832-219.648C162.304 625.664 102.4 499.2 102.4 409.088 102.4 183.296 286.208 0 512 0s409.6 183.296 409.6 409.088c0 54.784-20.992 121.856-62.976 199.68-39.936 74.752-100.352 161.792-179.712 258.048l-0.512 0.512-117.76 134.144c-11.776 14.336-29.184 22.528-47.616 22.528z m-1.024-423.936c105.984 0 191.488-86.016 191.488-191.488S617.984 217.6 512 217.6 320 303.104 320 409.088s86.016 190.976 192 190.976z"
                :fill="backgroundEditorState.currentGradientItemIndex === index ? '#3662EC' : '#FFFFFF'"
              ></path>
            </svg>
          </div>
        </template>
      </div>
    </template>

    <script lang="ts" setup>
      import { StyleValue, onMounted, onUnmounted, reactive, computed, ref, watch } from 'vue';
      import { 
        state as backgroundEditorState, 
        service as backgroundEditorService,
        type GradientRectInfo
      } from '@/modules/background-editor-module'
      import { toDecimal, distance, getPerpendicularPoint } from '@/tools/common';
      import { useDragHook } from '@/tools/drag';
      import bus from '@/tools/bus';

      const state = reactive({
        /** 内部层样式 */
        innerLayerStyle: { } as StyleValue,
        /** 外部层样式 */
        parentLayerStyle: { } as StyleValue,
        /** 椭圆侧边点 */
        slidePoint: { x: 0, y: 0 } as { x: number; y: number; },
        /** 拖拽渐变色的游标栏 */
        dragSlider: {
          width: 0,
          height: 0,
        },
        /** 组件节点 */
        componentEl: {} as HTMLElement
      });

      const gradientEditorPanel = ref<HTMLElement>();

      bus.$on('background_editor_change', () => {
      	refreshStyle();
      });

      const currentComponent = computed(() => {
        const component = editorState.currentSelectedComponents.find(i => !i.isGroup) as Component | undefined;
        if (component) {
        	return component.attrs as unknown as { x: number, y: number, width: number, height: number };
        } else {
        	return { x: 0, y: 0, width: 0, height: 0 };
        }
      });

      /** 键盘事件 */
      const onKeyDown = (e: KeyboardEvent, index: number) => {
        // 按下退格键
        if (e.code === 'Backspace') {
        	removeCursor(index);
        }
      };

      /** 移除游标 */
      const removeCursor = (index: number) => {
        if (backgroundEditorState.currentBackground.gradientList.length <= 2) {
          toast('最少保留2个渐变节点');
          return;
        }
        backgroundEditorState.currentGradientItemIndex = index > 0 ? index - 1 : index + 1;
        backgroundEditorState.currentBackground.gradientList.splice(index, 1);
        refreshStyle();
      }

      /** 添加游标 */
      const addCursor = (e: MouseEvent) => {
        let _cursorList = backgroundEditorState.currentBackground.gradientList;
      
        const rect = gradientEditorPanel.value!.getBoundingClientRect();
        const _cursorLeft = Math.min(Math.max(0, e.pageX - rect.left - 5), rect.width);
      
        const _progress = toDecimal(_cursorLeft / state.dragSlider.width, 3);
        _cursorList.push({ color: { r: 255, g: 255, b: 255, a: 0 }, progress: _progress });
        _cursorList = _cursorList.sort((a, b) => a.progress - b.progress);
        const _index = _cursorList.findIndex(i => i.progress === _progress);
        backgroundEditorState.currentBackground.gradientList = _cursorList;
        backgroundEditorState.currentGradientItemIndex = _index;
        refreshStyle();
      }

      const setCursor = (e: MouseEvent, index: number) => {
        backgroundEditorState.currentGradientItemIndex = index;
        dragCursorHook.startDrag(e, { initDragY: e.pageY });
        refreshStyle();
      };

      /** 拖拽游标钩子 */
      const dragCursorHook = useDragHook({
        config: {
          /** 初始化拖拽Y坐标 */
          initDragY: 0
        },
        drag(e) {
          const rect = gradientEditorPanel.value!.getBoundingClientRect();
        
          const a = [currentComponent.value.x + backgroundEditorState.currentBackground.x1, currentComponent.value.y + backgroundEditorState.currentBackground.y1] as [number, number];
          const b = [currentComponent.value.x + backgroundEditorState.currentBackground.x2, currentComponent.value.y + backgroundEditorState.currentBackground.y2] as [number, number];
          const c = [e.pageX - rect.left, e.pageY - rect.top] as [number, number];
        
          const _point1 = getPerpendicularPoint(a, b, c);
          if (_point1.ratio >= 0 && _point1.ratio <= 1) {
            backgroundEditorState.currentBackground.gradientList[backgroundEditorState.currentGradientItemIndex].progress = _point1.ratio;
          }
          refreshStyle();
        }
      });

      /** 拖拽椭圆侧边点钩子 */
      const dragSlidePointHook = useDragHook({
        drag(e) {
          if (backgroundEditorState.currentBackground.type === 'radial-gradient') {
            const { x1, y1 } = backgroundEditorState.currentBackground;
            const _point1 = getPerpendicularPoint([ x1, y1 ], [ state.slidePoint.x, state.slidePoint.y ], [ e.offsetX - currentComponent.value.x, e.offsetY - currentComponent.value.y ]);
            const _distance = distance({ x: _point1.x, y: _point1.y }, { x: x1, y: y1 });
            backgroundEditorState.currentBackground.ovalityRatio = _distance / backgroundEditorState.currentBackground.radius;
            refreshStyle();
          }
        }
      });

      /** 拖拽点钩子 */
      const dragPointHook = useDragHook({
        config: {
        	isStartNode: false
        },
        drag(e) {
          if (dragPointHook.config.isStartNode) {
            backgroundEditorState.currentBackground.x1 = e.offsetX - currentComponent.value.x;
            backgroundEditorState.currentBackground.y1 = e.offsetY - currentComponent.value.y;
          } else {
            backgroundEditorState.currentBackground.x2 = e.offsetX - currentComponent.value.x;
            backgroundEditorState.currentBackground.y2 = e.offsetY - currentComponent.value.y;
          }
          refreshStyle();
        }
      });

      /** 刷新样式 */
      const refreshStyle = () => {
        if (state.componentEl) {
          let _gradientBgRect = undefined as GradientRectInfo | undefined;
          const { x1, y1, x2, y2 } = backgroundEditorState.currentBackground;
          const points = [] as { x: number, y: number, rotate: number }[];
          
          for (let i = 0; i < backgroundEditorState.currentBackground.gradientList.length; i++) {
            const item = backgroundEditorState.currentBackground.gradientList[i];
            const _point = backgroundEditorService.getPointLocByLine(x1, y1, x2, y2, item.progress);
        
            points.push({
              x: currentComponent.value.x + _point.x,
              y: currentComponent.value.y + _point.y,
              rotate: _gradientBgRect.rotate,
            });
          }
          backgroundEditorState.gradientListItemLocs = points;
        
          state.dragSlider.width = _gradientBgRect.maxX - _gradientBgRect.minX;
          state.dragSlider.height = _gradientBgRect.maxY - _gradientBgRect.minY;
        
          const slidePoint = backgroundEditorService.getSlidePoint(x1, y1, x2, y2);
        
          state.slidePoint.x = slidePoint.x;
          state.slidePoint.y = slidePoint.y;
        } else if (backgroundEditorState.currentBackground.type === 'conic-gradient') {
          _gradientBgRect = backgroundEditorService.getConicGradientRectInfo(
            backgroundEditorState.currentBackground,
            state.componentEl.offsetWidth, 
            state.componentEl.offsetHeight
          );
          const { x1, y1, x2, y2 } = backgroundEditorState.currentBackground;
          const points = [] as { x: number, y: number, rotate: number }[];
          for (let i = 0; i < backgroundEditorState.currentBackground.gradientList.length; i++) {
            const item = backgroundEditorState.currentBackground.gradientList[i];
            const _point = backgroundEditorService.getPointLocByLine(x1, y1, x2, y2, item.progress);
          
            points.push({
              x: currentComponent.value.x + _point.x,
              y: currentComponent.value.y + _point.y,
              rotate: _gradientBgRect.rotate,
            });
          }
          backgroundEditorState.gradientListItemLocs = points;
        
          state.dragSlider.width = _gradientBgRect.maxX - _gradientBgRect.minX;
          state.dragSlider.height = _gradientBgRect.maxY - _gradientBgRect.minY;
        }
      
        const backgroundStyle = backgroundEditorService.getBackgroundStyle(
          backgroundEditorState.currentBackground, 
          state.componentEl.offsetWidth, 
          state.componentEl.offsetHeight,
          _gradientBgRect
        );
        if (backgroundStyle) {
          backgroundEditorState.currentBackground.parentStyle = backgroundStyle.parentStyle as Record<string, string>;
          backgroundEditorState.currentBackground.innerStyle = backgroundStyle.innerStyle as Record<string, string>;
        }
      }
    }

    watch(() => backgroundEditorState.isShow && backgroundEditorState.currentBackground.show, () => {
      const _components = editorState.currentSelectedComponents.find(i => !i.isGroup);
      if (_components) {
        const _dom = editorState.canvasPanelEl.querySelector<HTMLElement>(`[component-id="${_components.id}"]`);
        if (_dom) {
        state.componentEl = _dom;
        }
      }
    });

    onMounted(() => {
      dragCursorHook.init();
      dragPointHook.init();
      dragSlidePointHook.init();

      refreshStyle();
    });

    onUnmounted(() => {
      dragCursorHook.destory();
      dragPointHook.destory();
      dragSlidePointHook.destory();
    });
    </script>

在梳理好其他内容后,开发总结来说就是持续不断的搬砖,碰到问题就科学上网搜索最省力的搬砖姿势,然后继续搬砖 🤣

第八步、其他的一些细节

在代码编写中也会学到一些新知识点,碰到一些细节,这里也记录一下:

1、css图层混合模式:使用 mix-blend-mode可以完美的实现类似于PS图层混合模式的效果。

2、由于这个功能模块里涉及到太多拖拽功能,就自己简单实现了一个vue拖拽钩子 useDragHook,如果大家有兴趣的话我后面就再写一篇文章介绍一下。

3、一些图像转换函数,类似于hex / rpg / hsl 等互相转换,其中可能需要大量转换函数,当然推荐使用一些可靠的前端库。

4、计划最初其实有一个屏幕取色功能,但是到最后都没实现,如果大佬们知道怎样在前端做到网页屏幕取色也欢迎指点。

5、图像方面确实没做的太好,因为工期问题也不允许我无限制的开发下去,但是产品稳定一点之后会把相关功能给进一步完善,毕竟造轮子的本质就是折腾嘛。

第九步、一些复盘

在项目越做越庞大之后近乎就是自然而然的出现一些神奇的性能/结构问题,就比如说图片转base64然后硬置入dom中可能就需要一些比较极限的性能优化了。

在编码、扩大项目规模的同时还是要好好学习才是,如果有一个合适的产品,就会有机会将自己所学的所有东西在产品中应用起来。至少对目前的前端环境来说,要做的就是不断重构,不断学习 🙂

第十步、最后,一些吐槽

(等等为啥还有这一步??)

css变量习惯下来还没多久,vant组件库大版本有从3升到了4;还没学完发现vue又是升到了3.3;然后眼看着ant-design-vue马上也是下一个大版本了,求求了卷不到了 🤣🤣🤣