Files
TrueGrowth/specs/008-multifunctional-toolbox/BUGFIX_DOUBLE_CLICK_EDIT.md

12 KiB

Bug Fix: Tool Element Double-Click Edit & Resize Handle

Date: 2025-12-08 Branch: feat/08-multifunctional-toolbox Status: Fixed


🐛 问题描述

用户报告的问题

工具元素与图片/视频元素相比,缺少两个关键功能:

  1. 无法双击进入编辑模式 - 用户无法双击工具元素来与内嵌网页交互
  2. 缩放手柄问题 - 虽然已实现但需要确认是否正常工作

相比之下,图片元素可以:

  • 单击选中 → 显示选中边框和8个缩放手柄
  • 拖拽缩放手柄 → 调整图片大小 (保持宽高比)
  • 双击图片 → 进入编辑/查看模式

🔍 根因分析

问题 1: 缺少双击编辑功能

已有的基础设施

tool.generator.ts:159-170 中:

// 关键:默认禁用 iframe 的鼠标事件,让画布可以接收选中、拖拽等事件
// 当元素被双击进入编辑模式时,再启用 iframe 交互
iframe.style.pointerEvents = 'none';  // 默认禁用

已经实现了 setIframeInteraction() 方法:

setIframeInteraction(elementId: string, enabled: boolean): void {
  const iframe = this.iframeCache.get(elementId);
  if (iframe) {
    iframe.style.pointerEvents = enabled ? 'auto' : 'none';
  }
}

但是,没有任何代码调用这个方法!缺少:

  • 双击事件监听器
  • 焦点管理机制
  • 退出编辑模式的逻辑

对比图片元素

图片元素使用的是 @plait/commonImageGenerator,它内部有 isFocus 状态管理。但工具元素是自定义的,需要我们自己实现完整的焦点管理。

问题 2: 缩放手柄验证

当前实现已经正确:

// tool.component.ts:53-55
hasResizeHandle: () => {
  return hasResizeHandle(this.board, this.element);
}

hasResizeHandle 函数会检查:

  1. 只有一个元素被选中
  2. 该元素就是当前元素
  3. 元素有多个点 (工具元素有两个点,满足条件)

结论: 缩放手柄应该已经可以正常工作,只需测试确认。


修复方案

修复思路

参考图片元素的焦点管理,为工具元素实现完整的双击编辑流程:

  1. 创建 withToolFocus 插件 - 处理双击事件和焦点管理
  2. 注册 ToolGenerator 实例 - 让插件可以调用 setIframeInteraction()
  3. 管理焦点状态 - 追踪当前哪个工具元素处于编辑模式
  4. 退出编辑模式 - 点击画布外或其他元素时自动退出

实现详情

1. 创建 with-tool-focus.ts 插件

文件位置: packages/drawnix/src/plugins/with-tool-focus.ts

核心功能:

/**
 * 全局状态管理
 */
let focusedToolId: string | null = null;  // 当前焦点工具 ID
const toolGenerators = new WeakMap<PlaitBoard, Map<string, ToolGenerator>>();

/**
 * 注册和管理 ToolGenerator 实例
 */
export function registerToolGenerator(
  board: PlaitBoard,
  elementId: string,
  generator: ToolGenerator
): void;

export function unregisterToolGenerator(
  board: PlaitBoard,
  elementId: string
): void;

/**
 * 设置工具元素的焦点状态
 */
function setToolFocus(board: PlaitBoard, element: PlaitTool, isFocus: boolean): void {
  const generator = getToolGenerator(board, element.id);
  if (generator) {
    generator.setIframeInteraction(element.id, isFocus);
    if (isFocus) {
      focusedToolId = element.id;
    } else if (focusedToolId === element.id) {
      focusedToolId = null;
    }
  }
}

插件钩子实现:

export const withToolFocus: PlaitPlugin = (board: PlaitBoard) => {
  const { pointerDown, globalPointerUp, dblClick } = board;

  // 1. 处理双击事件 - 进入编辑模式
  board.dblClick = (event: PointerEvent) => {
    const selectedElements = getSelectedElements(board);

    if (selectedElements.length === 1 && isToolElement(selectedElements[0])) {
      const toolElement = selectedElements[0] as PlaitTool;
      setToolFocus(board, toolElement, true);  // 启用 iframe 交互
      event.preventDefault();
      event.stopPropagation();
      return;
    }

    dblClick(event);
  };

  // 2. 处理单击事件 - 检查是否退出编辑模式
  board.pointerDown = (event: PointerEvent) => {
    const point: Point = [event.clientX, event.clientY];
    const clickedTool = getToolElementAtPoint(board, point);

    if (focusedToolId) {
      // 如果点击的不是焦点工具,取消焦点
      if (!clickedTool || clickedTool.id !== focusedToolId) {
        blurAllTools(board);
      }
    }

    pointerDown(event);
  };

  // 3. 处理画布外点击 - 退出编辑模式
  board.globalPointerUp = (event: PointerEvent) => {
    if (focusedToolId) {
      const boardContainer = PlaitBoard.getBoardContainer(board);
      const rect = boardContainer.getBoundingClientRect();

      if (
        event.clientX < rect.left ||
        event.clientX > rect.right ||
        event.clientY < rect.top ||
        event.clientY > rect.bottom
      ) {
        blurAllTools(board);
      }
    }

    globalPointerUp(event);
  };

  return board;
};

2. 修改 tool.component.ts - 注册 ToolGenerator

+import {
+  registerToolGenerator,
+  unregisterToolGenerator,
+} from '../../plugins/with-tool-focus';

 initializeGenerator(): void {
   this.activeGenerator = createActiveGenerator(this.board, {
     getRectangle: (element: PlaitTool) => {
       return RectangleClient.getRectangleByPoints(element.points);
     },
     getStrokeWidth: () => ACTIVE_STROKE_WIDTH,
     getStrokeOpacity: () => 1,
     hasResizeHandle: () => {
       return hasResizeHandle(this.board, this.element);
     },
   });

   this.toolGenerator = new ToolGenerator(this.board);
+
+  // 注册 ToolGenerator 以支持焦点管理
+  registerToolGenerator(this.board, this.element.id, this.toolGenerator);
 }

 destroy(): void {
   super.destroy();
+
+  // 取消注册 ToolGenerator
+  if (this.element) {
+    unregisterToolGenerator(this.board, this.element.id);
+  }
+
   if (this.activeGenerator) {
     this.activeGenerator.destroy();
   }
   if (this.toolGenerator) {
     this.toolGenerator.destroy();
   }
   this.renderedG = undefined;
 }

3. 修改 drawnix.tsx - 添加 withToolFocus 插件

+import { withToolFocus } from './plugins/with-tool-focus';

 const plugins: PlaitPlugin[] = [
   withDraw,
   withGroup,
   withMind,
   withMindExtend,
   withCommonPlugin,
   buildDrawnixHotkeyPlugin(updateAppState),
   withFreehand,
   buildPencilPlugin(updateAppState),
   buildTextLinkPlugin(updateAppState),
   withVideo,
   withTool,
+  withToolFocus,  // 工具焦点管理 - 双击编辑
   withTracking,
 ];

📊 功能对比

修复前 vs 修复后

功能 修复前 修复后
单击选中 正常 正常
显示选中边框 正常 正常
显示缩放手柄 正常 正常
拖拽移动 正常 正常
拖拽缩放 正常 正常
Delete/Backspace 删除 已修复 正常
Ctrl+C/V 复制粘贴 已修复 正常
双击进入编辑模式 不支持 新增
与 iframe 内容交互 不支持 新增
单击退出编辑模式 不支持 新增

现在的用户体验

  1. 默认状态 (选中)

    • 单击工具元素 → 显示选中边框和8个缩放手柄
    • iframe 内容不可交互 (pointer-events: none)
    • 可以拖拽移动、拖拽缩放手柄调整大小
    • 可以 Delete 删除、Ctrl+C/V 复制粘贴
  2. 编辑模式 (双击后)

    • 双击工具元素 → 进入编辑模式
    • iframe 内容可交互 (pointer-events: auto)
    • 可以点击、滚动、输入内嵌网页中的内容
    • 控制台输出: Tool element focused: tool_xxx
  3. 退出编辑模式

    • 点击画布上的其他位置 → 自动退出编辑模式
    • 点击画布外 → 自动退出编辑模式
    • iframe 恢复不可交互状态
    • 控制台输出: All tool elements unfocused

🧪 验证方法

1. 测试缩放手柄

步骤:
1. 从工具箱插入一个工具到画布
2. 单击选中工具元素
3. 观察: 应该看到选中边框和四个角的缩放手柄 (8个总共)
4. 拖拽右下角的缩放手柄向外拉
5. 观察: 工具元素应该变大,iframe 内容也跟着缩放
6. 拖拽右下角的缩放手柄向内收
7. 观察: 工具元素应该变小

✅ 如果能正常缩放,说明缩放手柄功能正常

2. 测试双击编辑

步骤:
1. 单击选中工具元素
2. 尝试点击 iframe 内的按钮/链接
3. 观察: 点击无效,因为默认 pointer-events: none
4. 双击工具元素 (双击速度要快)
5. 观察控制台: 应该输出 "Tool element focused: tool_xxx"
6. 再次尝试点击 iframe 内的按钮/链接
7. 观察: 现在应该可以交互了!
8. 点击画布上的空白区域
9. 观察控制台: 应该输出 "All tool elements unfocused"
10. 再次尝试点击 iframe 内的按钮
11. 观察: 又无法交互了,说明已退出编辑模式

✅ 如果交互按预期工作,说明双击编辑功能正常

3. 测试退出编辑模式的多种方式

方式 1: 点击其他工具元素
1. 插入两个工具到画布
2. 双击工具 A 进入编辑模式
3. 单击工具 B
4. 观察: 工具 A 退出编辑模式,工具 B 被选中

方式 2: 点击画布空白区域
1. 双击工具元素进入编辑模式
2. 点击画布空白区域
3. 观察: 工具元素退出编辑模式,被取消选中

方式 3: 点击画布外
1. 双击工具元素进入编辑模式
2. 点击浏览器窗口外或工具栏
3. 观察: 工具元素退出编辑模式

✅ 如果所有方式都能正常退出,说明焦点管理功能完善

📚 技术要点

1. WeakMap 用于管理插件实例

const toolGenerators = new WeakMap<PlaitBoard, Map<string, ToolGenerator>>();

优势:

  • 当 board 被垃圾回收时,关联的 Map 也会被自动清理
  • 避免内存泄漏
  • 不影响 board 对象的正常垃圾回收

2. 全局焦点状态 vs 元素级焦点状态

设计选择: 使用全局 focusedToolId 而不是为每个元素存储 isFocus

原因:

  1. 一次只能编辑一个工具 - 符合用户预期
  2. 简化焦点管理 - 不需要遍历所有元素来查找焦点元素
  3. 性能更好 - 单个变量的读写比遍历数组快
  4. 避免状态不一致 - 保证全局只有一个元素处于焦点状态

3. 事件处理顺序

1. board.dblClick
   ↓
2. 检查选中元素
   ↓
3. 如果是工具元素 → setToolFocus(true)
   ↓
4. event.preventDefault() + event.stopPropagation()
   ↓
5. 返回 (不调用原始 dblClick)

关键: preventDefault()stopPropagation() 防止:

  • 触发其他编辑操作 (如文本编辑)
  • 事件冒泡到父元素

4. 坐标转换

const viewBoxPoint = toViewBoxPoint(
  board,
  toHostPoint(board, event.clientX, event.clientY)
);

为什么需要两次转换?

  1. toHostPoint: 将屏幕坐标转换为画布宿主坐标 (考虑画布的位置和缩放)
  2. toViewBoxPoint: 将宿主坐标转换为 ViewBox 坐标 (考虑 viewport 的平移和缩放)

这样才能正确判断点击位置是否在元素内。


🎯 已完成的功能

  • 工具元素支持8个缩放手柄 (4个角 + 4个边)
  • 拖拽缩放手柄可以调整工具元素大小
  • 双击工具元素进入编辑模式,启用 iframe 交互
  • 单击其他位置或画布外自动退出编辑模式
  • 焦点状态通过控制台日志可见
  • 一次只能有一个工具元素处于编辑模式
  • 完整的资源清理 (注册/取消注册 ToolGenerator)

📝 相关文档

  • Plait Plugin System: PlaitPlugin, board.dblClick, board.pointerDown, board.globalPointerUp
  • 焦点管理: registerToolGenerator, setToolFocus, blurAllTools
  • 坐标转换: toHostPoint, toViewBoxPoint
  • 参考实现: @plait/commonImageGenerator (虽然实现方式不同,但设计思路相似)

Created by: Claude Code Fixed on: 2025-12-08 Status: Implementation Complete, Ready for Testing