718 lines
16 KiB
Markdown
718 lines
16 KiB
Markdown
# 多功能工具箱实施指南
|
|
|
|
> Feature: feat/08-multifunctional-toolbox
|
|
> Created: 2025-12-08
|
|
> Status: 实施阶段
|
|
|
|
本文档提供详细的代码实现指南和示例。
|
|
|
|
---
|
|
|
|
## 📋 实施检查清单
|
|
|
|
### Phase 1: 基础架构 ✓
|
|
|
|
- [ ] 创建类型定义文件
|
|
- [ ] 实现 withTool 插件
|
|
- [ ] 实现 ToolComponent
|
|
- [ ] 实现 ToolGenerator
|
|
- [ ] 集成到 drawnix.tsx
|
|
- [ ] 手动测试工具元素渲染
|
|
|
|
### Phase 2: UI 组件 ✓
|
|
|
|
- [ ] 实现 ToolboxDrawer 组件
|
|
- [ ] 实现 ToolList 和 ToolItem
|
|
- [ ] 实现 ToolboxService
|
|
- [ ] 配置内置工具
|
|
- [ ] 集成到 UnifiedToolbar
|
|
- [ ] 测试用户交互流程
|
|
|
|
### Phase 3: 优化完善 (可选)
|
|
|
|
- [ ] 样式优化和响应式
|
|
- [ ] postMessage 通信协议
|
|
- [ ] 自定义工具支持
|
|
- [ ] 错误处理和提示
|
|
|
|
---
|
|
|
|
## 📁 文件创建顺序
|
|
|
|
### 1. 类型定义
|
|
|
|
**文件**: `packages/drawnix/src/types/toolbox.types.ts`
|
|
|
|
```typescript
|
|
import { PlaitElement, Point } from '@plait/core';
|
|
|
|
/**
|
|
* 工具元素 - 画布上的工具实例
|
|
*/
|
|
export interface PlaitTool extends PlaitElement {
|
|
type: 'tool';
|
|
points: [Point, Point];
|
|
angle: number;
|
|
toolId: string;
|
|
url: string;
|
|
metadata?: {
|
|
name?: string;
|
|
category?: string;
|
|
permissions?: string[];
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 工具定义 - 工具箱配置
|
|
*/
|
|
export interface ToolDefinition {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
icon?: string;
|
|
category?: string;
|
|
url: string;
|
|
defaultWidth?: number;
|
|
defaultHeight?: number;
|
|
permissions?: string[];
|
|
}
|
|
|
|
/**
|
|
* 工具分类
|
|
*/
|
|
export enum ToolCategory {
|
|
AI_TOOLS = 'ai-tools',
|
|
CONTENT_TOOLS = 'content-tools',
|
|
UTILITIES = 'utilities',
|
|
CUSTOM = 'custom',
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 2. 工具箱服务
|
|
|
|
**文件**: `packages/drawnix/src/services/toolbox-service.ts`
|
|
|
|
```typescript
|
|
import { ToolDefinition } from '../types/toolbox.types';
|
|
import { BUILT_IN_TOOLS } from '../constants/built-in-tools';
|
|
|
|
/**
|
|
* 工具箱管理服务(单例)
|
|
*/
|
|
class ToolboxService {
|
|
private static instance: ToolboxService;
|
|
private customTools: ToolDefinition[] = [];
|
|
|
|
private constructor() {}
|
|
|
|
static getInstance(): ToolboxService {
|
|
if (!ToolboxService.instance) {
|
|
ToolboxService.instance = new ToolboxService();
|
|
}
|
|
return ToolboxService.instance;
|
|
}
|
|
|
|
/**
|
|
* 获取所有可用工具
|
|
*/
|
|
getAvailableTools(): ToolDefinition[] {
|
|
return [...BUILT_IN_TOOLS, ...this.customTools];
|
|
}
|
|
|
|
/**
|
|
* 根据 ID 获取工具
|
|
*/
|
|
getToolById(id: string): ToolDefinition | null {
|
|
const allTools = this.getAvailableTools();
|
|
return allTools.find(tool => tool.id === id) || null;
|
|
}
|
|
|
|
/**
|
|
* 添加自定义工具
|
|
*/
|
|
addCustomTool(tool: ToolDefinition): void {
|
|
const exists = this.customTools.some(t => t.id === tool.id);
|
|
if (!exists) {
|
|
this.customTools.push(tool);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 移除自定义工具
|
|
*/
|
|
removeCustomTool(id: string): void {
|
|
this.customTools = this.customTools.filter(t => t.id !== id);
|
|
}
|
|
|
|
/**
|
|
* 获取工具列表(按分类)
|
|
*/
|
|
getToolsByCategory(): Record<string, ToolDefinition[]> {
|
|
const tools = this.getAvailableTools();
|
|
const categorized: Record<string, ToolDefinition[]> = {};
|
|
|
|
tools.forEach(tool => {
|
|
const category = tool.category || 'utilities';
|
|
if (!categorized[category]) {
|
|
categorized[category] = [];
|
|
}
|
|
categorized[category].push(tool);
|
|
});
|
|
|
|
return categorized;
|
|
}
|
|
}
|
|
|
|
// 导出单例实例
|
|
export const toolboxService = ToolboxService.getInstance();
|
|
```
|
|
|
|
---
|
|
|
|
### 3. 内置工具配置
|
|
|
|
**文件**: `packages/drawnix/src/constants/built-in-tools.ts`
|
|
|
|
```typescript
|
|
import { ToolDefinition, ToolCategory } from '../types/toolbox.types';
|
|
|
|
/**
|
|
* 内置工具列表
|
|
*/
|
|
export const BUILT_IN_TOOLS: ToolDefinition[] = [
|
|
{
|
|
id: 'banana-prompt',
|
|
name: '香蕉提示词',
|
|
description: '查看和复制优质 AI 提示词',
|
|
icon: '🍌',
|
|
category: ToolCategory.AI_TOOLS,
|
|
url: 'https://aiprompt.cn',
|
|
defaultWidth: 800,
|
|
defaultHeight: 600,
|
|
permissions: ['allow-scripts', 'allow-same-origin'],
|
|
},
|
|
{
|
|
id: 'xiaohongshu-tool',
|
|
name: '小红薯工具',
|
|
description: '小红书文案和图片处理',
|
|
icon: '📝',
|
|
category: ToolCategory.CONTENT_TOOLS,
|
|
url: 'https://www.xiaohongshu.com',
|
|
defaultWidth: 700,
|
|
defaultHeight: 500,
|
|
permissions: ['allow-scripts', 'allow-same-origin', 'allow-forms'],
|
|
},
|
|
{
|
|
id: 'unsplash-images',
|
|
name: 'Unsplash 图片',
|
|
description: '免费高质量图片素材',
|
|
icon: '🖼️',
|
|
category: ToolCategory.CONTENT_TOOLS,
|
|
url: 'https://unsplash.com',
|
|
defaultWidth: 900,
|
|
defaultHeight: 700,
|
|
permissions: ['allow-scripts', 'allow-same-origin'],
|
|
},
|
|
];
|
|
|
|
/**
|
|
* 默认工具配置
|
|
*/
|
|
export const DEFAULT_TOOL_CONFIG = {
|
|
defaultWidth: 600,
|
|
defaultHeight: 400,
|
|
defaultPermissions: ['allow-scripts', 'allow-same-origin'],
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### 4. ToolGenerator 渲染生成器
|
|
|
|
**文件**: `packages/drawnix/src/components/tool-element/tool.generator.ts`
|
|
|
|
```typescript
|
|
import { PlaitBoard, RectangleClient } from '@plait/core';
|
|
import { Generator } from '@plait/common';
|
|
import { PlaitTool } from '../../types/toolbox.types';
|
|
|
|
/**
|
|
* 工具元素渲染生成器
|
|
*/
|
|
export class ToolGenerator extends Generator<PlaitTool> {
|
|
private board: PlaitBoard;
|
|
private iframeCache = new Map<string, HTMLIFrameElement>();
|
|
|
|
constructor(board: PlaitBoard) {
|
|
super();
|
|
this.board = board;
|
|
}
|
|
|
|
/**
|
|
* 判断是否可以绘制
|
|
*/
|
|
canDraw(element: PlaitTool): boolean {
|
|
return element && element.type === 'tool' && !!element.url;
|
|
}
|
|
|
|
/**
|
|
* 绘制工具元素
|
|
*/
|
|
draw(element: PlaitTool): SVGGElement {
|
|
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
g.setAttribute('data-element-id', element.id);
|
|
g.classList.add('plait-tool-element');
|
|
|
|
// 创建 foreignObject
|
|
const foreignObject = this.createForeignObject(element);
|
|
g.appendChild(foreignObject);
|
|
|
|
return g;
|
|
}
|
|
|
|
/**
|
|
* 更新工具元素
|
|
*/
|
|
updateImage(
|
|
nodeG: SVGGElement,
|
|
previous: PlaitTool,
|
|
current: PlaitTool
|
|
): void {
|
|
// 如果 URL 变化,重新创建
|
|
if (previous.url !== current.url) {
|
|
nodeG.innerHTML = '';
|
|
const foreignObject = this.createForeignObject(current);
|
|
nodeG.appendChild(foreignObject);
|
|
return;
|
|
}
|
|
|
|
// 更新位置和尺寸
|
|
const foreignObject = nodeG.querySelector('foreignObject');
|
|
if (foreignObject) {
|
|
const rect = this.getRectangle(current);
|
|
foreignObject.setAttribute('x', rect.x.toString());
|
|
foreignObject.setAttribute('y', rect.y.toString());
|
|
foreignObject.setAttribute('width', rect.width.toString());
|
|
foreignObject.setAttribute('height', rect.height.toString());
|
|
}
|
|
|
|
// 更新旋转
|
|
if (current.angle && current.angle !== 0) {
|
|
const rect = this.getRectangle(current);
|
|
const centerX = rect.x + rect.width / 2;
|
|
const centerY = rect.y + rect.height / 2;
|
|
nodeG.setAttribute(
|
|
'transform',
|
|
`rotate(${current.angle} ${centerX} ${centerY})`
|
|
);
|
|
} else {
|
|
nodeG.removeAttribute('transform');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 创建 foreignObject 容器
|
|
*/
|
|
private createForeignObject(element: PlaitTool): SVGForeignObjectElement {
|
|
const rect = this.getRectangle(element);
|
|
|
|
// 创建 foreignObject
|
|
const foreignObject = document.createElementNS(
|
|
'http://www.w3.org/2000/svg',
|
|
'foreignObject'
|
|
);
|
|
foreignObject.setAttribute('x', rect.x.toString());
|
|
foreignObject.setAttribute('y', rect.y.toString());
|
|
foreignObject.setAttribute('width', rect.width.toString());
|
|
foreignObject.setAttribute('height', rect.height.toString());
|
|
foreignObject.classList.add('plait-tool-foreign-object');
|
|
|
|
// 创建容器
|
|
const container = document.createElement('div');
|
|
container.className = 'plait-tool-container';
|
|
container.style.width = '100%';
|
|
container.style.height = '100%';
|
|
container.style.overflow = 'hidden';
|
|
container.style.borderRadius = '8px';
|
|
container.style.boxShadow = '0 2px 12px rgba(0, 0, 0, 0.15)';
|
|
container.style.backgroundColor = '#fff';
|
|
|
|
// 创建 iframe
|
|
const iframe = this.createIframe(element);
|
|
container.appendChild(iframe);
|
|
|
|
// 添加加载提示
|
|
const loader = document.createElement('div');
|
|
loader.className = 'plait-tool-loader';
|
|
loader.textContent = '加载中...';
|
|
loader.style.position = 'absolute';
|
|
loader.style.top = '50%';
|
|
loader.style.left = '50%';
|
|
loader.style.transform = 'translate(-50%, -50%)';
|
|
loader.style.color = '#999';
|
|
container.appendChild(loader);
|
|
|
|
// iframe 加载完成后移除 loader
|
|
iframe.onload = () => {
|
|
loader.remove();
|
|
};
|
|
|
|
foreignObject.appendChild(container);
|
|
return foreignObject;
|
|
}
|
|
|
|
/**
|
|
* 创建 iframe
|
|
*/
|
|
private createIframe(element: PlaitTool): HTMLIFrameElement {
|
|
const iframe = document.createElement('iframe');
|
|
iframe.src = element.url;
|
|
iframe.style.width = '100%';
|
|
iframe.style.height = '100%';
|
|
iframe.style.border = 'none';
|
|
iframe.style.display = 'block';
|
|
|
|
// 设置 sandbox 权限
|
|
const permissions = element.metadata?.permissions || [
|
|
'allow-scripts',
|
|
'allow-same-origin',
|
|
];
|
|
iframe.setAttribute('sandbox', permissions.join(' '));
|
|
|
|
// 设置 allow 属性
|
|
iframe.setAttribute('allow', 'clipboard-read; clipboard-write');
|
|
|
|
// 缓存 iframe
|
|
this.iframeCache.set(element.id, iframe);
|
|
|
|
return iframe;
|
|
}
|
|
|
|
/**
|
|
* 获取工具元素的矩形区域
|
|
*/
|
|
private getRectangle(element: PlaitTool): RectangleClient {
|
|
const [start, end] = element.points;
|
|
const x = Math.min(start[0], end[0]);
|
|
const y = Math.min(start[1], end[1]);
|
|
const width = Math.abs(end[0] - start[0]);
|
|
const height = Math.abs(end[1] - start[1]);
|
|
|
|
return { x, y, width, height };
|
|
}
|
|
|
|
/**
|
|
* 清理资源
|
|
*/
|
|
destroy(): void {
|
|
this.iframeCache.clear();
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 5. ToolComponent 组件
|
|
|
|
**文件**: `packages/drawnix/src/components/tool-element/tool.component.ts`
|
|
|
|
```typescript
|
|
import {
|
|
PlaitBoard,
|
|
PlaitPluginElementContext,
|
|
OnContextChanged,
|
|
} from '@plait/core';
|
|
import { CommonElementFlavour } from '@plait/common';
|
|
import { PlaitTool } from '../../types/toolbox.types';
|
|
import { ToolGenerator } from './tool.generator';
|
|
|
|
/**
|
|
* 工具元素组件
|
|
*/
|
|
export class ToolComponent
|
|
extends CommonElementFlavour<PlaitTool, PlaitBoard>
|
|
implements OnContextChanged<PlaitTool, PlaitBoard>
|
|
{
|
|
toolGenerator!: ToolGenerator;
|
|
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
/**
|
|
* 初始化生成器
|
|
*/
|
|
initializeGenerator(): void {
|
|
this.toolGenerator = new ToolGenerator(this.board);
|
|
}
|
|
|
|
/**
|
|
* 组件初始化
|
|
*/
|
|
initialize(): void {
|
|
this.initializeGenerator();
|
|
|
|
// 绘制初始状态
|
|
const g = this.toolGenerator.draw(this.element);
|
|
const elementG = this.getElementG();
|
|
elementG.appendChild(g);
|
|
}
|
|
|
|
/**
|
|
* 响应上下文变化
|
|
*/
|
|
onContextChanged(
|
|
value: PlaitPluginElementContext<PlaitTool, PlaitBoard>,
|
|
previous: PlaitPluginElementContext<PlaitTool, PlaitBoard>
|
|
): void {
|
|
// 元素属性变化时更新
|
|
if (
|
|
value.element !== previous.element ||
|
|
JSON.stringify(value.element) !== JSON.stringify(previous.element)
|
|
) {
|
|
const g = this.getElementG().querySelector('g');
|
|
if (g) {
|
|
this.toolGenerator.updateImage(
|
|
g as SVGGElement,
|
|
previous.element,
|
|
value.element
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 清理资源
|
|
*/
|
|
destroy(): void {
|
|
if (this.toolGenerator) {
|
|
this.toolGenerator.destroy();
|
|
}
|
|
super.destroy();
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 6. withTool 插件
|
|
|
|
**文件**: `packages/drawnix/src/plugins/with-tool.ts`
|
|
|
|
```typescript
|
|
import { PlaitBoard, PlaitPlugin, Point, Transforms } from '@plait/core';
|
|
import { PlaitDrawElement } from '@plait/draw';
|
|
import { ToolComponent } from '../components/tool-element/tool.component';
|
|
import { PlaitTool } from '../types/toolbox.types';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
/**
|
|
* 工具插件 - 注册 ToolComponent
|
|
*/
|
|
export const withTool: PlaitPlugin = (board: PlaitBoard) => {
|
|
const { drawElement } = board;
|
|
|
|
// 注册工具元素渲染组件
|
|
board.drawElement = (element: PlaitDrawElement) => {
|
|
if (element.type === 'tool') {
|
|
return ToolComponent;
|
|
}
|
|
return drawElement(element);
|
|
};
|
|
|
|
return board;
|
|
};
|
|
|
|
/**
|
|
* 判断是否为工具元素
|
|
*/
|
|
export function isToolElement(element: any): element is PlaitTool {
|
|
return element && element.type === 'tool';
|
|
}
|
|
|
|
/**
|
|
* 工具元素操作 API
|
|
*/
|
|
export const ToolTransforms = {
|
|
/**
|
|
* 插入工具到画布
|
|
*/
|
|
insertTool(
|
|
board: PlaitBoard,
|
|
toolId: string,
|
|
url: string,
|
|
position: Point,
|
|
size: { width: number; height: number },
|
|
metadata?: PlaitTool['metadata']
|
|
): PlaitTool {
|
|
const toolElement: PlaitTool = {
|
|
id: uuidv4(),
|
|
type: 'tool',
|
|
toolId,
|
|
url,
|
|
points: [
|
|
position,
|
|
[position[0] + size.width, position[1] + size.height],
|
|
],
|
|
angle: 0,
|
|
metadata,
|
|
};
|
|
|
|
Transforms.insertNodes(board, [toolElement], {
|
|
at: [board.children.length],
|
|
});
|
|
|
|
return toolElement;
|
|
},
|
|
|
|
/**
|
|
* 更新工具尺寸
|
|
*/
|
|
resizeTool(
|
|
board: PlaitBoard,
|
|
element: PlaitTool,
|
|
newSize: { width: number; height: number }
|
|
): void {
|
|
const [start] = element.points;
|
|
const newElement: Partial<PlaitTool> = {
|
|
points: [start, [start[0] + newSize.width, start[1] + newSize.height]],
|
|
};
|
|
|
|
const path = board.children.findIndex(el => el.id === element.id);
|
|
if (path >= 0) {
|
|
Transforms.setNodes(board, newElement, { at: [path] });
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 删除工具
|
|
*/
|
|
removeTool(board: PlaitBoard, elementId: string): void {
|
|
const path = board.children.findIndex(el => el.id === elementId);
|
|
if (path >= 0) {
|
|
Transforms.removeNodes(board, { at: [path] });
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 更新工具 URL
|
|
*/
|
|
updateToolUrl(board: PlaitBoard, elementId: string, newUrl: string): void {
|
|
const path = board.children.findIndex(el => el.id === elementId);
|
|
if (path >= 0) {
|
|
Transforms.setNodes(board, { url: newUrl }, { at: [path] });
|
|
}
|
|
},
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### 7. 样式文件
|
|
|
|
**文件**: `packages/drawnix/src/components/tool-element/tool.component.scss`
|
|
|
|
```scss
|
|
.plait-tool-element {
|
|
// 工具元素基础样式
|
|
cursor: move;
|
|
|
|
.plait-tool-foreign-object {
|
|
overflow: visible;
|
|
}
|
|
|
|
.plait-tool-container {
|
|
position: relative;
|
|
background: #fff;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
|
transition: box-shadow 0.2s ease;
|
|
|
|
&:hover {
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
|
}
|
|
}
|
|
|
|
.plait-tool-loader {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
font-size: 14px;
|
|
color: #999;
|
|
pointer-events: none;
|
|
}
|
|
|
|
// 选中状态
|
|
&.selected {
|
|
.plait-tool-container {
|
|
outline: 2px solid var(--brand-primary, #F39C12);
|
|
outline-offset: 2px;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🧪 测试代码
|
|
|
|
### 手动测试脚本
|
|
|
|
在浏览器控制台执行:
|
|
|
|
```javascript
|
|
// 获取 board 实例
|
|
const board = window.__PLAIT_BOARD__;
|
|
|
|
// 插入香蕉提示词工具
|
|
ToolTransforms.insertTool(
|
|
board,
|
|
'banana-prompt',
|
|
'https://aiprompt.cn',
|
|
[100, 100],
|
|
{ width: 800, height: 600 }
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## 🔧 调试技巧
|
|
|
|
### 1. 检查元素是否正确插入
|
|
|
|
```javascript
|
|
console.log('Board children:', board.children);
|
|
console.log('Tool elements:', board.children.filter(el => el.type === 'tool'));
|
|
```
|
|
|
|
### 2. 检查 foreignObject 渲染
|
|
|
|
```javascript
|
|
const toolElements = document.querySelectorAll('.plait-tool-element');
|
|
console.log('Rendered tool elements:', toolElements);
|
|
```
|
|
|
|
### 3. 检查 iframe 加载
|
|
|
|
```javascript
|
|
const iframes = document.querySelectorAll('.plait-tool-container iframe');
|
|
iframes.forEach((iframe, i) => {
|
|
console.log(`Iframe ${i}:`, iframe.src, iframe.contentWindow);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 📚 下一步
|
|
|
|
完成 Phase 1 后,继续实现:
|
|
|
|
1. **ToolboxDrawer UI 组件** - 用户可见的工具箱界面
|
|
2. **集成到 UnifiedToolbar** - 添加工具箱按钮
|
|
3. **测试完整流程** - 从点击到渲染的端到端测试
|
|
|
|
详见 `ARCHITECTURE.md` 中的 Phase 2 实施计划。
|