Files

16 KiB

多功能工具箱实施指南

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

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

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

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

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

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

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

.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;
    }
  }
}

🧪 测试代码

手动测试脚本

在浏览器控制台执行:

// 获取 board 实例
const board = window.__PLAIT_BOARD__;

// 插入香蕉提示词工具
ToolTransforms.insertTool(
  board,
  'banana-prompt',
  'https://aiprompt.cn',
  [100, 100],
  { width: 800, height: 600 }
);

🔧 调试技巧

1. 检查元素是否正确插入

console.log('Board children:', board.children);
console.log('Tool elements:', board.children.filter(el => el.type === 'tool'));

2. 检查 foreignObject 渲染

const toolElements = document.querySelectorAll('.plait-tool-element');
console.log('Rendered tool elements:', toolElements);

3. 检查 iframe 加载

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 实施计划。