Initial TrueGrowth source import
This commit is contained in:
439
specs/008-multifunctional-toolbox/BUGFIX_DOUBLE_CLICK_EDIT.md
Normal file
439
specs/008-multifunctional-toolbox/BUGFIX_DOUBLE_CLICK_EDIT.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# 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` 中:
|
||||
```typescript
|
||||
// 关键:默认禁用 iframe 的鼠标事件,让画布可以接收选中、拖拽等事件
|
||||
// 当元素被双击进入编辑模式时,再启用 iframe 交互
|
||||
iframe.style.pointerEvents = 'none'; // 默认禁用
|
||||
```
|
||||
|
||||
已经实现了 `setIframeInteraction()` 方法:
|
||||
```typescript
|
||||
setIframeInteraction(elementId: string, enabled: boolean): void {
|
||||
const iframe = this.iframeCache.get(elementId);
|
||||
if (iframe) {
|
||||
iframe.style.pointerEvents = enabled ? 'auto' : 'none';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**但是**,没有任何代码调用这个方法!缺少:
|
||||
- 双击事件监听器
|
||||
- 焦点管理机制
|
||||
- 退出编辑模式的逻辑
|
||||
|
||||
#### 对比图片元素
|
||||
|
||||
图片元素使用的是 `@plait/common` 的 `ImageGenerator`,它内部有 `isFocus` 状态管理。但工具元素是自定义的,需要我们自己实现完整的焦点管理。
|
||||
|
||||
### 问题 2: 缩放手柄验证
|
||||
|
||||
当前实现已经正确:
|
||||
```typescript
|
||||
// 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`
|
||||
|
||||
**核心功能**:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 全局状态管理
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**插件钩子实现**:
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```diff
|
||||
+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` 插件
|
||||
|
||||
```diff
|
||||
+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 用于管理插件实例
|
||||
|
||||
```typescript
|
||||
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. 坐标转换
|
||||
|
||||
```typescript
|
||||
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/common` 的 `ImageGenerator` (虽然实现方式不同,但设计思路相似)
|
||||
|
||||
---
|
||||
|
||||
**Created by**: Claude Code
|
||||
**Fixed on**: 2025-12-08
|
||||
**Status**: ✅ Implementation Complete, Ready for Testing
|
||||
Reference in New Issue
Block a user