在构建专业级可视化编辑器时,颜色拾取器远不止"选个颜色"那么简单。本文将以一个基于 TDesign Vue Next + Babylon.js 的 3D 编辑器项目为例,深度剖析两个高频痛点:如何实现颜色修改的撤销(Undo)功能,以及如何自定义弹出面板的样式

一、撤销功能的实现:巧用 visible-change 事件

1.1 核心挑战:如何捕获"变化前"的值

在编辑器场景中,撤销功能是标配。但 TDesign 的 <t-color-picker> 组件的 change 事件只会返回新值,无法直接获取修改前的颜色。我们需要一个机制来"记住"用户打开拾色器那一刻的初始值。

关键洞察:用户打开拾色器面板 → 选择颜色 → 关闭面板,这个过程的边界正是 visible-change 事件的触发时机。

1.2 解决方案:事件分工与状态管理

BgSettings.vue 中,我们采用"预览+提交"模式:

<t-color-picker
  v-model="clearColorRef"
  @change="previewClearColor"        <!-- 实时预览 -->
  @visible-change="cmdHandleClearColor" <!-- 控制撤销命令 -->
/>

核心逻辑拆解

// 变化前的值存储
let clearColorChangeStart = '#000'

// 1. 拾色器打开时:保存当前值作为"后悔点"
const cmdHandleClearColor = (visible: boolean) => { 
  if(visible){  // 面板出现
    clearColorChangeStart = clearColorRef.value; // 记录起始值
  } else {      // 面板关闭
    const finalColor = clearColorRef.value;
    // 2. 先恢复初始值(撤销操作的核心)
    bgController.setClearColor(clearColorChangeStart);
    // 3. 再提交命令(支持后续撤销)
    bgController.cmdSetClearColor(finalColor);
  }
}

// 实时预览(不改变撤销栈)
const previewClearColor = (hexColor: string) => { 
  bgController.setClearColor(hexColor); // 直接修改,不记录命令
}

流程图

用户点击颜色框
  ↓ visible-change(visible=true)
保存 clearColorChangeStart = 当前值
  ↓ 用户挑选颜色...
  ↓ change事件触发
实时预览新颜色(不记录命令)
  ↓ 用户确认/点击外部关闭
  ↓ visible-change(visible=false)
恢复为 clearColorChangeStart(撤销本次预览)
提交 cmdSetClearColor(最终值) 到命令栈

1.3 命名约定:changeStart 的语义化

观察代码可发现,clearColorChangeStart 这个变量名并非偶然。这是一种行业内约定俗成的命名模式:

  • changeStart:变化开始的值

  • changeEnd / finalValue:变化结束的值

  • oldValue / newValue:新旧值对比

这种命名让协作者能立刻理解:"哦,这是用来支持撤销的初始状态!"

1.4 为什么 focus/blur 方案会失败?

我曾尝试通过透传 Input 的 focusblur 事件来替代 visible-change

<!-- 失败的尝试 -->
<t-color-picker
  @focus="handleFocus"  <!-- 期望打开时触发 -->
  @blur="handleBlur"    <!-- 期望关闭时触发 -->
/>

失败原因

  1. 事件未透传:TDesign 的 t-color-picker 并未将底层 Input 的 focus/blur 事件暴露到组件根节点

  2. Popup 机制干扰:点击拾色器面板本身不会导致 Input 失焦,因为面板是 Portal 到 body 的,不在同一 DOM 树

  3. 时机不对:即使能触发,blur 可能在颜色预览阶段就执行,无法区分"临时选择"和"最终确认"

结论到目前为止visible-change 是官方为这种场景设计的唯一可靠生命周期钩子。

1.5 命令模式:让撤销可堆叠

bgController.cmdSetClearColor() 并非直接修改,而是包装成命令对象:

// BgControllerEditor.ts
public cmdSetClearColor(hexColor: string): void {
  const cmd = new CmdProperty<string>(
    this._setClearColor.bind(this),  // 执行函数
    hexColor,                        // 新值
    this.scene.clearColor.toHexString() // 旧值(用于撤销)
  );
  cmdEmitter.emit("execute", cmd)    // 交给命令管理器
}

这实现了:

  • 执行:应用新颜色

  • 撤销:自动用旧值回滚

  • 重做:再次执行

二、样式穿透难题:Popup 组件的 Teleport 困局

2.1 问题现象::deep() 突然失效

当你想在 BgSettings.vue 中修改拾色器弹出面板的暗色主题时,可能会写成:

<style scoped>
:deep(.t-color-picker__panel) {
  background-color: #222; /* ❌ 无效! */
}
</style>

结果:样式完全不生效。但官方文档明明说 :deep() 能穿透 scoped...

2.2 根本原因:Teleport 的"降维打击"

查阅 TDesign Popup 文档 会发现关键说明:

Popup 使用 Vue3 的 Teleport 实现,默认挂载到 body,因此 :deep() 无法作用于 Teleport 包裹的元素。

原理剖析

组件树结构(开发者视角):
BgSettings.vue
  └─ <t-color-picker>
      └─ <t-popup> (逻辑上属于子组件)

实际 DOM 结构(浏览器视角):
<body>
  ├─ <div id="app">...</div>           ← 你的组件在这里
  └─ <div class="t-popup">...</div>   ← 拾色器面板在这里(Teleport 插入)

scoped 样式通过 data-v-xxx 属性匹配元素,但 Teleport 将面板移动到了组件外部,丢失了 scoped 属性,导致 :deep() 无法跨越这道"次元壁"。

2.3 解决方案:全局样式精准打击

正确姿势:在根组件 EditorApp.vue 中使用不带 scoped<style> 标签:

<!-- EditorApp.vue -->
<style>
/* 颜色拾取器弹出面板的输入框样式 */
.t-color-picker__format--item .input-group .input-group__item .t-input__wrap .t-input {
  background-color: #222 !important;
}

/* 格式选择器下拉框 */
.t-color-picker__format-mode-select .t-select-input .t-input__wrap .t-input {
  background-color: #222 !important;
}
</style>

最佳实践建议

  1. 集中管理:在 EditorApp.vue 或独立的 global-popup-styles.css 中统一覆盖

  2. Specificity 控制:使用合理的选择器权重,避免 !important 滥用

  3. 限定作用域:通过父级 class 限定,如 .dark-theme .t-color-picker__panel

/* 更安全的写法 */
.dark-theme .t-color-picker__panel {
  background-color: var(--td-bg-color-container);
}

2.4 举一反三:Dialog 等组件同样适用

观察 EditorApp.vue 中的全局样式,你会发现还覆盖了对话框:

/* 可拖拽的非模态对话框样式 */
.drag-modeles-dialog .t-dialog__wrap .t-dialog__position .t-dialog--default {
  display: flex;
  flex-direction: column;
  padding: 2px;
}

受影响组件清单

  • t-popup 及衍生:t-color-pickert-selectt-date-picker

  • t-dialog / t-drawer

  • 任何使用 attach="body" 或自定义挂载点的组件

组件库设计启示:TDesign 通过 popupProps 暴露底层配置,但样式始终是全局维度的问题。

三、完整代码参考

3.1 撤销逻辑核心(BgSettings.vue)

<template>
  <div class="form-item">
    <label>背景色</label>
    <t-color-picker
      v-model="clearColorRef"
      format="HEX"
      :color-modes="['monochrome']"
      :input-props="{ readonly: true }"
      @change="previewClearColor"
      @visible-change="cmdHandleClearColor"
    />
  </div>
</template>

<script setup>
const clearColorRef = ref('#000')
let clearColorChangeStart = '#000'

const previewClearColor = (hexColor: string) => {
  bgController.setClearColor(hexColor) // 直接预览
}

const cmdHandleClearColor = (visible: boolean) => {
  if (visible) {
    clearColorChangeStart = clearColorRef.value
  } else {
     //由于后面册小预览的时候也会引发颜色修改事件,
    //这会导致clearColorRef.value发生变化,需要先保存下来
    const colorFinal = clearColorRef.value
    bgController.setClearColor(clearColorChangeStart) // 撤销预览
    bgController.cmdSetClearColor(colorFinal) // 提交命令
  }
}
</script>

3.2 全局样式覆盖(EditorApp.vue)

<style>
/* Popup 样式必须在全局作用域 */
.t-color-picker__trigger .t-input__wrap {
  background-color: transparent !important;
  border: none;
}

/* 所有 Teleport 组件的通用处理 */
.t-popup .t-dialog .t-drawer {
  /* 你的主题变量 */
}
</style>

四、总结与最佳实践

场景 错误做法 正确做法 关键技术点
撤销功能 依赖 change 事件记录旧值 visible-change 保存 changeStart 事件生命周期 + 命令模式
样式穿透 :deep() 在 scoped 中 根组件全局 <style> Teleport DOM 分离原理
事件选择 使用 focus/blur 使用 visible-change 官方事件透传完整性
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐