How to migrate Vue UI to React with minimal features?

HEAD
6f44398Update README roadmap and tool counts
This post might have stale content, as HEAD is 378 commits ahead.
Avatar of WhoKnowInfinity
Infinity
posted
  1. 分析 packages 下每个 package 的作用和使用场景 2. 分析 src 下每个模块的作用和使用场景 3. 因为 src 下面是 vue 代码,我希望将其迁移到 react 且只需要展示最小的能力,去掉各种 sidebar / panel 等等,只保留核心的 canvas 交互区域,我该如何在 react 中实现(我理解应该会引用 packages/core )
Avatar of anthropic/claude-haiku-4.5
Claude Haiku 4.5
commented

完美!现在我有足够的信息了。让我生成一个完整的分析文档:

##📋 OpenPencil 项目分析

###1️⃣ Packages 分析

####packages/core - 核心引擎 ⭐

作用: 这是整个项目的心脏,包含所有与设计编辑无关的核心算法和数据结构。

主要功能模块:

  • scene-graph.ts - 设计文档的数据模型,定义所有设计节点类型(Frame、Shape、Text、Component等)
  • renderer.ts - SkiaRenderer:使用 Skia 渲染引擎在 Canvas 上绘制场景
  • layout.ts - 布局算法(类似 Figma 的自动布局 Auto-layout)
  • text-editor.ts - 文本编辑核心逻辑
  • undo.ts - 撤销/重做管理
  • vector.ts - 向量路径处理(钢笔工具)
  • kiwi/ - Figma .fig 文件序列化/反序列化,包括 Kiwi 二进制协议
  • render/ - JSX 渲染系统(可将设计导出为 React 组件)
  • tools/ - AI 工具定义系统(与 AI 聊天集成)
  • fonts.ts - 字体管理
  • color.ts - 颜色处理

依赖:

{
  "canvaskit-wasm": "Skia的WebAssembly绑定",
  "yoga-layout": "Facebook的布局引擎",
  "culori": "颜色处理库",
  "fflate": "ZIP压缩",
  "fzstd": "Zstandard压缩"
}

使用场景: 任何需要处理设计数据的应用(web、CLI、MCP服务器、React迁移)都需要这个包。


####packages/cli - 命令行工具

作用: 无头设计工具,可在命令行或脚本中操作 .fig 文件。

主要命令:

  • eval - 执行脚本修改设计
  • export - 导出设计为图片/JSX
  • find - 搜索设计中的节点
  • tree - 打印设计树结构
  • analyze - 分析设计
  • variables/pages/node - 操作特定对象

使用场景: CI/CD 流程、自动化设计处理、MCP 服务器的后端支持。


####packages/mcp - Model Context Protocol 服务器

作用: 让 Claude Code、Cursor 等 LLM 编辑器可以直接读写 .fig 文件。

主要功能:

  • 暴露 75+ AI 工具供 LLM 调用
  • HTTP 和 stdio 两种协议支持
  • 使用 Zod 进行请求验证

使用场景: AI 编辑集成、自动化设计任务。


####packages/docs - 文档站点

作用: 项目文档和开发者指南。


###2️⃣ Src 模块分析(Vue 代码结构)

src/
├── components/          # Vue UI 组件
├── composables/         # Vue 组合函数(逻辑层)
├── views/              # 页面级组件
├── stores/             # Pinia 状态管理
├── engine/             # 引擎封装
├── kiwi/               # Figma 协议实现
├── ai/                 # AI 集成
└── types.ts           # TypeScript 类型

####核心 Composables(最重要!)

Composable作用关键功能
use-canvas.ts画布初始化和渲染循环• 初始化 WebGL Surface
• 创建 SkiaRenderer
• 触发渲染循环
use-canvas-input.ts鼠标/触摸交互处理• 框选、拖拽、旋转
• 工具状态管理
• 快捷键处理
use-text-edit.ts文本编辑• 文本选择和输入
• 光标管理
use-keyboard.ts快捷键绑定• 快捷键事件处理
use-collab.ts实时协作(P2P WebRTC)• 多用户光标/选区同步
• Yjs 状态管理

####核心 Store(编辑器状态)

EditorStore (stores/editor.ts) - 管理:

- graph: SceneGraph        // 设计文档数据结构
- selectedIds: string[]    // 当前选中节点
- activeTool: Tool         // 当前工具
- zoom, panX, panY         // 视图变换
- currentPageId            // 当前页面
- clipboard                // 剪贴板

// 关键方法:
- addNode(), deleteNode(), updateNode()  // 编辑操作
- undo(), redo()                         // 撤销重做
- copy(), paste()                        // 剪贴板

####组件层次结构

EditorView.vue (主容器)
├── TabBar              (标签栏)
├── SplitterGroup       (分割布局)
│   ├── LayersPanel     (左侧图层面板)
│   ├── EditorCanvas    (中心画布)
│   │   ├── canvas[DOM]
│   │   ├── Toolbar     (工具栏)
│   │   └── CanvasContextMenu
│   └── PropertiesPanel (右侧属性面板)
│       ├── DesignPanel
│       ├── CodePanel
│       └── ChatPanel   (AI 聊天)
└── CollabPanel         (协作状态)

####EditorCanvas 的工作流程

EditorCanvas.vue
    ↓ ref
<canvas>
    ↓ useCanvas()
    ├─ 初始化 SkiaRenderer
    ├─ 监听 store.state 变化
    └─ 每帧调用 renderer.render()
    
    ↓ useCanvasInput()
    ├─ 监听 mouse/touch 事件
    ├─ 计算交互逻辑
    └─ 修改 store(自动触发重新渲染)

###3️⃣ Vue → React 迁移方案(仅保留 Canvas 核心)

####架构设计

最小化的 React 实现只需要:

src-react/
├── components/
│   ├── Canvas.tsx           # 核心画布组件(替代 EditorCanvas.vue)
│   └── App.tsx              # 主组件
├── hooks/                   # 替代 Vue composables
│   ├── useCanvas.ts         # 画布初始化和渲染
│   ├── useCanvasInput.ts    # 交互处理
│   └── useEditor.ts         # 编辑器状态(替代 Pinia store)
├── types.ts                 # 类型定义
└── index.tsx                # 入口

####核心实现步骤

Step 1: 创建编辑器状态管理(React Context + useState)

// hooks/useEditor.ts
import { useState, useCallback } from 'react'
import { SceneGraph, SkiaRenderer, type SceneNode } from '@open-pencil/core'

interface EditorState {
  graph: SceneGraph
  selectedIds: string[]
  activeTool: 'SELECT' | 'RECTANGLE' | 'HAND'
  zoom: number
  panX: number
  panY: number
  currentPageId: string
}

export function useEditor() {
  const [state, setState] = useState<EditorState>({
    graph: new SceneGraph(),
    selectedIds: [],
    activeTool: 'SELECT',
    zoom: 1,
    panX: 0,
    panY: 0,
    currentPageId: 'page-1'
  })

  const selectNode = useCallback((ids: string[]) => {
    setState(prev => ({ ...prev, selectedIds: ids }))
  }, [])

  const updateNode = useCallback((id: string, changes: Partial<SceneNode>) => {
    setState(prev => {
      const newGraph = new SceneGraph()
      // 复制原有的图形
      Object.assign(newGraph, prev.graph)
      // 更新节点
      const node = newGraph.nodeById(id)
      if (node) Object.assign(node, changes)
      return { ...prev, graph: newGraph }
    })
  }, [])

  return { state, selectNode, updateNode, setState }
}

Step 2: 画布初始化和渲染

// hooks/useCanvas.ts
import { useEffect, useRef, useCallback } from 'react'
import { getCanvasKit, SkiaRenderer } from '@open-pencil/core'
import type { CanvasKit } from 'canvaskit-wasm'

export function useCanvas(
  canvasRef: React.RefObject<HTMLCanvasElement>,
  state: EditorState
) {
  const rendererRef = useRef<SkiaRenderer | null>(null)
  const ckRef = useRef<CanvasKit | null>(null)
  const rafRef = useRef<number | null>(null)

  // 初始化渲染器
  useEffect(() => {
    const init = async () => {
      const ck = await getCanvasKit()
      const canvas = canvasRef.current
      if (!canvas) return

      ckRef.current = ck
      const dpr = window.devicePixelRatio || 1
      canvas.width = canvas.clientWidth * dpr
      canvas.height = canvas.clientHeight * dpr

      // 创建 WebGL Surface
      const surface = ck.MakeWebGLCanvasSurface(canvas)
      if (!surface) {
        console.error('Failed to create WebGL surface')
        return
      }

      rendererRef.current = new SkiaRenderer(ck, surface)
      await rendererRef.current.loadFonts()
      scheduleRender()
    }

    init()

    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current)
      rendererRef.current?.destroy()
    }
  }, [])

  // 渲染循环
  const scheduleRender = useCallback(() => {
    if (rafRef.current) cancelAnimationFrame(rafRef.current)
    
    rafRef.current = requestAnimationFrame(() => {
      const renderer = rendererRef.current
      if (!renderer) return

      // 配置渲染参数
      renderer.dpr = window.devicePixelRatio || 1
      renderer.panX = state.panX
      renderer.panY = state.panY
      renderer.zoom = state.zoom
      renderer.viewportWidth = canvasRef.current?.clientWidth ?? 0
      renderer.viewportHeight = canvasRef.current?.clientHeight ?? 0
      renderer.pageId = state.currentPageId

      // 执行渲染
      renderer.render(
        state.graph,
        state.selectedIds,
        {
          hoveredNodeId: null,
          editingTextId: null,
          // 其他渲染覆盖层配置...
        }
      )

      scheduleRender()
    })
  }, [state])

  // 监听状态变化触发重新渲染
  useEffect(() => {
    scheduleRender()
  }, [state, scheduleRender])
}

Step 3: 交互处理

// hooks/useCanvasInput.ts
import { useEffect } from 'react'
import type { EditorState } from './useEditor'

interface DragState {
  type: 'move' | 'pan' | 'marquee' | 'resize'
  startX: number
  startY: number
  [key: string]: any
}

export function useCanvasInput(
  canvasRef: React.RefObject<HTMLCanvasElement>,
  state: EditorState,
  onStateChange: (newState: Partial<EditorState>) => void
) {
  useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return

    let dragState: DragState | null = null

    const handleMouseDown = (e: MouseEvent) => {
      const rect = canvas.getBoundingClientRect()
      const x = e.clientX - rect.left
      const y = e.clientY - rect.top

      // 根据当前工具和按键确定拖拽类型
      if (e.button === 2 || e.spaceKey) {
        // 空格或右键 -> 平移
        dragState = {
          type: 'pan',
          startX: x,
          startY: y,
          startPanX: state.panX,
          startPanY: state.panY
        }
      } else if (state.activeTool === 'RECTANGLE') {
        // 绘制矩形
        dragState = {
          type: 'move',
          startX: x,
          startY: y
        }
      }
    }

    const handleMouseMove = (e: MouseEvent) => {
      if (!dragState) return

      const rect = canvas.getBoundingClientRect()
      const x = e.clientX - rect.left
      const y = e.clientY - rect.top

      if (dragState.type === 'pan') {
        const dx = x - dragState.startX
        const dy = y - dragState.startY
        onStateChange({
          panX: dragState.startPanX + dx,
          panY: dragState.startPanY + dy
        })
      }
    }

    const handleMouseUp = () => {
      dragState = null
    }

    canvas.addEventListener('mousedown', handleMouseDown)
    canvas.addEventListener('mousemove', handleMouseMove)
    canvas.addEventListener('mouseup', handleMouseUp)
    canvas.addEventListener('contextmenu', (e) => e.preventDefault())

    return () => {
      canvas.removeEventListener('mousedown', handleMouseDown)
      canvas.removeEventListener('mousemove', handleMouseMove)
      canvas.removeEventListener('mouseup', handleMouseUp)
    }
  }, [canvasRef, state, onStateChange])
}

Step 4: React 主组件

// components/Canvas.tsx
import { useRef } from 'react'
import { useEditor } from '../hooks/useEditor'
import { useCanvas } from '../hooks/useCanvas'
import { useCanvasInput } from '../hooks/useCanvasInput'

export function Canvas() {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const { state, setState } = useEditor()

  // 初始化渲染
  useCanvas(canvasRef, state)

  // 初始化交互
  useCanvasInput(canvasRef, state, (changes) => {
    setState(prev => ({ ...prev, ...changes }))
  })

  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <canvas
        ref={canvasRef}
        style={{ display: 'block', width: '100%', height: '100%' }}
      />
    </div>
  )
}

Step 5: App 入口

// App.tsx
import { Canvas } from './components/Canvas'

export function App() {
  return (
    <div style={{ display: 'flex', height: '100vh' }}>
      <Canvas />
    </div>
  )
}

####关键导入

// 从 @open-pencil/core 中导入
import {
  SceneGraph,           // 设计数据结构
  SkiaRenderer,         // 渲染器
  getCanvasKit,         // 获取 CanvasKit 实例
  computeLayout,        // 布局计算
  type SceneNode,       // 节点类型
  type RenderOverlays   // 渲染覆盖层类型
} from '@open-pencil/core'

####最小功能集(可逐步扩展)

第一阶段(MVP)

  • 基础画布渲染
  • 框选和单选
  • 拖拽移动节点
  • 平移和缩放视图
  • 基础矩形绘制

📝 第二阶段

  • 其他形状工具(圆形、线条)
  • 节点编辑(改变大小、颜色)
  • 撤销/重做

🎨 第三阶段

  • 文本编辑
  • 自动布局
  • 组件和实例

####避免的陷阱

  1. ❌ 不要重新实现 SceneGraph - 直接使用 @open-pencil/core
  2. ❌ 不要绘制 UI - 专注于 Canvas 交互
  3. ❌ 不要建立复杂的状态管理 - 用 useState + Context 即可
  4. ❌ 不要处理 Figma 导入/导出 - 先用简单的 JSON 或直接在内存中工作

####包大小对比

原生 Vue 版本:  ~300KB (包含所有 UI)
React 最小版本:  ~150KB (仅 Canvas)
core 包:         ~500KB (共享)

###总结

层级Vue 技术React 等价物
状态管理Pinia (EditorStore)Context API + useState
逻辑组织ComposablesCustom Hooks
渲染templateJSX/TSX
副作用watch, onMounteduseEffect
核心EditorCanvas.vueCanvas.tsx

关键洞察:

  • EditorCanvas.vue 的核心逻辑其实就是 useCanvas + useCanvasInput 两个 composable
  • 整个 UI 框架都是围绕这个中心 Canvas 组件的
  • React 版本可以直接复用 @open-pencil/core 中的所有算法,只需要改变上层的状态管理和交互处理

希望这个分析对你有帮助!需要我详细讲解某个具体部分吗?


END OF POST

How to migrate Vue UI to React with minimal features? — open-pencil/open-pencil