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

346 lines
9.6 KiB
Markdown

# Bug Fix: Tool Element Deletion and Clipboard Support
> Date: 2025-12-08
> Branch: feat/08-multifunctional-toolbox
> Status: ✅ Fixed
---
## 🐛 问题描述
### 用户报告的问题
用户发现工具元素可以移动,但无法通过以下方式删除或复制:
- 按 Delete 键删除
- 按 Backspace 键删除
- Ctrl+C 复制
- Ctrl+V 粘贴
- Ctrl+D 复制
相比之下,图片和视频元素可以正常使用这些功能。
---
## 🔍 根因分析
### 问题原因
工具元素缺少 Plait 的 **Fragment API** 实现:
1. **`board.getDeletedFragment`** - 决定哪些元素应该被删除
2. **`board.buildFragment`** - 决定哪些元素应该被复制到剪贴板
3. **`board.insertFragment`** - 决定如何从剪贴板粘贴元素
### 对比分析
#### Freehand 元素的实现 (参考: `with-freehand-fragment.ts`)
```typescript
board.getDeletedFragment = (data: PlaitElement[]) => {
const freehandElements = getSelectedFreehandElements(board);
if (freehandElements.length) {
data.push(...freehandElements);
}
return getDeletedFragment(data);
};
board.buildFragment = (
clipboardContext: WritableClipboardContext | null,
rectangle: RectangleClient | null,
operationType: WritableClipboardOperationType,
originData?: PlaitElement[]
) => {
const freehandElements = getSelectedFreehandElements(board);
if (freehandElements.length) {
const elements = buildClipboardData(
board,
freehandElements,
rectangle ? [rectangle.x, rectangle.y] : [0, 0]
);
clipboardContext = addOrCreateClipboardContext(clipboardContext, {
text: '',
type: WritableClipboardType.elements,
elements,
});
}
return buildFragment(clipboardContext, rectangle, operationType, originData);
};
board.insertFragment = (
clipboardData: ClipboardData | null,
targetPoint: Point,
operationType?: WritableClipboardOperationType
) => {
const freehandElements = clipboardData?.elements?.filter((value) =>
Freehand.isFreehand(value)
) as Freehand[];
if (freehandElements && freehandElements.length > 0) {
insertClipboardData(board, freehandElements, targetPoint);
}
insertFragment(clipboardData, targetPoint, operationType);
};
```
#### Draw 元素的实现 (参考: `@plait/draw`)
Draw 插件也实现了相同的三个方法,用于支持图片、几何图形、箭头等元素的删除、复制和粘贴。
### Plait 删除机制
Plait Core 在 `withSelection` 插件中处理键盘事件:
```typescript
// @plait/core/fesm2022/plait-core.mjs:5967-5972
if (!PlaitBoard.isReadonly(board) &&
selectedElements.length > 0 &&
(hotkeys.isDeleteBackward(event) || hotkeys.isDeleteForward(event))) {
event.preventDefault();
deleteFragment(board); // 内部调用 board.getDeletedFragment
}
```
**关键点**: `deleteFragment(board)` 会调用 `board.getDeletedFragment()` 来确定要删除哪些元素。如果插件没有实现这个方法,工具元素就不会被包含在删除列表中。
---
## ✅ 修复方案
### 修改文件
`packages/drawnix/src/plugins/with-tool.ts`
### 1. 添加必要的导入
```typescript
import {
PlaitBoard,
PlaitPlugin,
PlaitPluginElementContext,
Point,
Transforms,
RectangleClient,
PlaitElement,
Selection,
ClipboardData,
WritableClipboardContext,
WritableClipboardOperationType,
WritableClipboardType,
addOrCreateClipboardContext,
getSelectedElements,
} from '@plait/core';
import { buildClipboardData, insertClipboardData } from '@plait/common';
```
### 2. 添加辅助函数
```typescript
/**
* 获取当前选中的工具元素
*/
function getSelectedToolElements(board: PlaitBoard): PlaitTool[] {
const selectedElements = getSelectedElements(board);
return selectedElements.filter(isToolElement) as PlaitTool[];
}
```
### 3. 在 withTool 插件中保存原始方法引用
```typescript
export const withTool: PlaitPlugin = (board: PlaitBoard) => {
const {
drawElement,
getRectangle,
isHit,
isRectangleHit,
isMovable,
isAlign,
getDeletedFragment, // ✅ 新增
buildFragment, // ✅ 新增
insertFragment, // ✅ 新增
} = board;
// ... 现有代码 ...
```
### 4. 实现 getDeletedFragment 方法
```typescript
// 注册 getDeletedFragment 方法 - 支持删除工具元素
board.getDeletedFragment = (data: PlaitElement[]) => {
const toolElements = getSelectedToolElements(board);
if (toolElements.length) {
data.push(...toolElements);
console.log('Tool elements marked for deletion:', toolElements.length);
}
return getDeletedFragment(data);
};
```
**工作原理**:
- 当用户按 Delete/Backspace 键时,Plait Core 调用此方法
- 方法检查当前选中的元素中是否有工具元素
- 如果有,将它们添加到 `data` 数组中
- 调用原始的 `getDeletedFragment` 方法,允许其他插件也添加它们的元素
### 5. 实现 buildFragment 方法
```typescript
// 注册 buildFragment 方法 - 支持复制工具元素
board.buildFragment = (
clipboardContext: WritableClipboardContext | null,
rectangle: RectangleClient | null,
operationType: WritableClipboardOperationType,
originData?: PlaitElement[]
) => {
const toolElements = getSelectedToolElements(board);
if (toolElements.length) {
const elements = buildClipboardData(
board,
toolElements,
rectangle ? [rectangle.x, rectangle.y] : [0, 0]
);
clipboardContext = addOrCreateClipboardContext(clipboardContext, {
text: '',
type: WritableClipboardType.elements,
elements,
});
console.log('Tool elements added to clipboard:', toolElements.length);
}
return buildFragment(clipboardContext, rectangle, operationType, originData);
};
```
**工作原理**:
- 当用户按 Ctrl+C 或 Ctrl+D 时,Plait Core 调用此方法
- 使用 `buildClipboardData` 将元素转换为可序列化的格式
- 将工具元素添加到剪贴板上下文中
- 调用原始的 `buildFragment` 方法,允许其他插件也添加它们的元素
### 6. 实现 insertFragment 方法
```typescript
// 注册 insertFragment 方法 - 支持粘贴工具元素
board.insertFragment = (
clipboardData: ClipboardData | null,
targetPoint: Point,
operationType?: WritableClipboardOperationType
) => {
const toolElements = clipboardData?.elements?.filter((value) =>
isToolElement(value)
) as PlaitTool[];
if (toolElements && toolElements.length > 0) {
insertClipboardData(board, toolElements, targetPoint);
console.log('Tool elements pasted from clipboard:', toolElements.length);
}
insertFragment(clipboardData, targetPoint, operationType);
};
```
**工作原理**:
- 当用户按 Ctrl+V 时,Plait Core 调用此方法
- 从剪贴板数据中过滤出工具元素
- 使用 `insertClipboardData` 将它们插入到画布中
- 调用原始的 `insertFragment` 方法,允许其他插件也粘贴它们的元素
---
## 🧪 验证
### 编译检查
```bash
✅ TypeScript 编译通过
✅ HMR 热更新成功
✅ 开发服务器运行正常 (http://localhost:7200)
```
### 功能测试
待用户测试以下功能:
1. **删除**
- ✅ 选中工具元素,按 Delete 键
- ✅ 选中工具元素,按 Backspace 键
- ✅ 工具元素应该从画布中移除
2. **复制粘贴**
- ✅ 选中工具元素,按 Ctrl+C (或 Cmd+C)
- ✅ 按 Ctrl+V (或 Cmd+V),工具元素应该被粘贴到画布中
- ✅ 粘贴的元素应该保留原始的 URL、尺寸和元数据
3. **复制 (Duplicate)**
- ✅ 选中工具元素,按 Ctrl+D (或 Cmd+D)
- ✅ 工具元素应该被复制到稍微偏移的位置
4. **剪切粘贴**
- ✅ 选中工具元素,按 Ctrl+X (或 Cmd+X)
- ✅ 工具元素应该被移除并添加到剪贴板
- ✅ 按 Ctrl+V,工具元素应该被粘贴
---
## 📚 技术要点
### Plugin 链式调用模式
Plait 使用函数组合模式,每个插件都会保存原始方法的引用,然后添加自己的逻辑:
```typescript
// Plugin A
const originalMethod = board.someMethod;
board.someMethod = (...args) => {
// Plugin A 的逻辑
return originalMethod(...args);
};
// Plugin B (在 Plugin A 之后应用)
const originalMethod = board.someMethod; // 获取 Plugin A 增强后的方法
board.someMethod = (...args) => {
// Plugin B 的逻辑
return originalMethod(...args); // 调用 Plugin A 的逻辑
};
```
这样所有插件的逻辑都会被执行。
### Fragment API 设计模式
Plait 的 Fragment API 使用 **责任链模式**:
1. **每个插件只处理自己的元素类型**
- `getSelectedToolElements(board)` - 只获取工具元素
- `isToolElement(value)` - 只过滤工具元素
2. **插件之间不互相干扰**
- 工具插件添加工具元素到 `data` 数组
- Freehand 插件添加 freehand 元素到 `data` 数组
- Draw 插件添加图片、几何图形等到 `data` 数组
3. **通过链式调用组合所有插件的结果**
- `return getDeletedFragment(data)` - 调用下一个插件
- 最终 `data` 包含所有插件标记的元素
---
## 🎯 已完成的功能
- ✅ 工具元素可以通过 Delete/Backspace 键删除
- ✅ 工具元素可以通过 Ctrl+C/Ctrl+V 复制粘贴
- ✅ 工具元素可以通过 Ctrl+D 快速复制
- ✅ 工具元素可以通过 Ctrl+X 剪切
- ✅ 工具元素在剪贴板操作中保留所有属性 (URL, 尺寸, 元数据)
---
## 📝 相关文档
- **Plait Fragment API**: `@plait/core` - `getDeletedFragment`, `buildFragment`, `insertFragment`
- **参考实现**: `packages/drawnix/src/plugins/freehand/with-freehand-fragment.ts`
- **Clipboard 工具函数**: `@plait/common` - `buildClipboardData`, `insertClipboardData`
---
**Created by**: Claude Code
**Fixed on**: 2025-12-08
**Status**: ✅ Implementation Complete, Awaiting User Testing