Skip to content

ADR-140: DragSliderController 统一滑块输入

  • 状态: 完成(已实施);2026-07-28 修订——键盘调值仅保留 ←→(↑↓ 让给菜单列表遍历,见下方行为变更记录)。
  • 日期: 2026-07-19
  • 相关: ADR-093(菜单声明式 Schema)、ADR-096(通用 Helper 收敛)、ADR-153(无障碍/键盘导航——滑块 ↑↓ 让位的上游决策)

背景与问题

滑块「游标拖拽 + 键盘 + mousedown 拖拽」逻辑在 3~4 个文件中重复实现,语义已漂移:

文件函数键盘步进拖拽逻辑shift/ctrl 支持
core/ui-advanced-rows.ts:78-167addColorSliderRowdelta=0.01/0.1忽略 step,硬编码)setValueFromClientX不支持 shift/ctrl
core/ui-advanced-rows.ts:266-432addVector3SliderRow同上同上同上
core/ui-advanced-rows.ts:551-657addModeSliderquarter/1(四分位,硬编码)复制 addSliderRow支持 shift(切四分位)

问题:

  1. 键盘步进不统一(有的忽略 step,有的硬编码 0.01/0.1/四分位)
  2. shift/ctrl 倍数支持不一致(addSliderRow 支持,其他不支持)
  3. 拖拽逻辑复制,行为不一致(didDrag/dragRect/moveDisp/endDisp 各自实现;方法名有 setIndexFromClientXsetValueFromClientX 两种语义)
  4. 无法一处审核竞态/一致性

决策

抽取 DragSliderController 类,参数化 min/max/step/snap/axis/onChange/onDragEnd,四个 builder 退化为此控制器的特例配置。

typescript
interface DragSliderOptions {
    value: number;
    min: number;
    max: number;
    step: number;
    /** 吸附步数;若设 snap=0.05,则值域对齐到 0.05 的整数倍(Math.round(v/snap)*snap) */
    snap?: number;
    axis?: 'x' | 'y';
    onChange?: (value: number) => void;
    onDragEnd?: (value: number) => void;
}

class DragSliderController {
    constructor(opts: DragSliderOptions) {}
    /** 绑定 DOM 并注册事件,返回 Disposable */
    bind(el: HTMLElement): Disposable { ... }
    /** 动态更新当前值(builder 重建或外部重置时调用) */
    setValue(v: number): void { ... }
    // 内部统一处理:mousedown→mousemove→mouseup / 键盘方向键 / 游标点击
}

方案设计

1. ui-slider-controller.ts(新建)

typescript
export class DragSliderController {
    private dragging = false;
    private startX = 0;
    private startValue = 0;

    constructor(private opts: DragSliderOptions) {}

    /** 动态更新当前值(builder 重建或外部重置时调用) */
    setValue(v: number): void {
        this.opts.value = v;
    }

    bind(el: HTMLElement): Disposable {
        const onMouseDown = (e: MouseEvent) => { ... };
        const onKeyDown = (e: KeyboardEvent) => { ... };
        el.addEventListener('mousedown', onMouseDown);
        el.addEventListener('keydown', onKeyDown);
        return {
            dispose: () => {
                el.removeEventListener('mousedown', onMouseDown);
                el.removeEventListener('keydown', onKeyDown);
            }
        };
    }

    /** 统一拖拽计算:基于 el.getBoundingClientRect() 将 clientX 映射为 value */
    private setValueFromClientX(clientX: number, el: HTMLElement): void { ... }
    private handleKeyDown(e: KeyboardEvent): void { ... }
}

Disposable 来源@babylonjs/coreIDisposable 接口,等价于 { dispose(): void }

2. 迁移策略

  • addSliderRownew DragSliderController({ value, min, max, step, onChange })
  • addColorSliderRow → 同上(step 从 opts 读取,不再硬编码 0.01/0.1)
  • addVector3SliderRow → 同上(每轴一个 controller 实例)
  • addModeSlider → 同上(step/snap 从 opts 读取,不再硬编码 quarter/1)

3. 统一行为

  • 键盘步进:step(默认)/ step * 10(shift)/ step * 100(ctrl)
  • 拖拽:统一基于 el.getBoundingClientRect()setValueFromClientX 计算(不再区分 index/value 两条路径)
  • 步进:统一从 opts.step 派生,不再硬编码

影响面

  • 代码: core/ui-rows.tscore/ui-advanced-rows.ts
  • 行为: 滑块行为统一(拖拽/键盘/步进)
  • 测试: 4 个 builder 单测覆盖

风险与缓解

风险等级缓解
行为漂移导致用户感知变化🟡 中对比测试 + 目检
迁移遗漏导致旧逻辑残留🟢 低删除旧代码,统一走 controller

分阶段实施

  • 阶段 0(本 ADR): 立项 ✅
  • 阶段 1: 新建 core/ui-slider-controller.ts,完成基础功能 + 基础单元测试 ✅
  • 阶段 2: 迁移 addSliderRow + addColorSliderRow 到 DragSliderController ✅(builders 保留为稳定公共 API,未标记 @deprecated;详见「决策修正」)
  • 阶段 3: 迁移 addVector3SliderRow + addModeSlider 到 DragSliderController ✅
  • 阶段 4: 全量回归测试(npm run test 全绿 + tsc --noEmit 通过)+ 行为变更目检 ✅

验收标准

  • 4 个 builder 行为一致(拖拽/键盘/步进)
  • 单测覆盖 4 个 builder
  • npm run test 全绿

行为变更记录(实施于 2026-07-19)

4 个 builder 现均为 DragSliderController 的薄封装,拖拽 / 键盘 / 游标点击逻辑完全统一。 以下为与原实现的可见行为差异,属风险表「🟡 中」项,需人工目检确认。

Builder变更影响
addSliderRow移除独有的 row 四分位 click 步进(点击 label / 空白区不再微调);现由控制器驱动,获得 bar 拖拽能力点击行非 bar 区域不再生效(其他 builder 本无此行为,故属归一)
addModeSlider键盘 shift 由「四分位跳 floor(total/4)」改为「step*10 = 跳 10 个索引」;纯点击(mousedown→mouseup 无移动)由「循环微移」改为「绝对跳转到点击位置」;内部以 value=currentIndex, min=0, max=total-1, step=1 映射多选项模式下 shift / 点击语义更规整,但与旧版不同,需目检
addColorSliderRow / addVector3SliderRow行为基本一致(step 派生、拖拽 / 键盘 / 点击统一);控制器新增 ctrl 步进(×100)作为增强无破坏,仅新增能力
全部 builder初始化不再触发用户 onChange(与原实现一致,避免误触发)

行为变更记录二(实施于 2026-07-28,键盘 ↑↓ 让位)

配合 ADR-153 菜单方向键导航纳入滑块/开关行:菜单靠 ↑↓ 在各行间遍历,若滑块也用 ↑↓ 调值则与菜单抢键。只能二选一,因此滑块放弃 ↑↓。

Builder变更影响
全部滑块(DragSliderController.handleKeyDown键盘调值仅保留 ArrowLeft/ArrowRight(+ Home/End);移除 ArrowUp/ArrowDown 调值分支滑块聚焦时 ↑↓ 不再调值,而是冒泡到菜单做列表遍历;符合 WAI-ARIA slider 允许仅水平键的规范。shift/ctrl 倍率、Home/End 不变

决策修正(关于「标记 @deprecated」)

原阶段 2 / 3 写「旧函数标记 @deprecated」。实施中修正为:4 个 builder 是稳定公共 UI API(被约 20 个菜单文件调用),应保留并作为 DragSliderController 的薄封装,而非废弃。真正消除的是各 builder 内部重复的拖拽 / 键盘 / 吸附逻辑——已通过迁移到统一控制器完成。因此不对 builder 标记 @deprecated,只在其 doc 注释中标注「由 DragSliderController 驱动」。