一、描述

1、项目核心功能设计

  • 画布基础功能

    • 空白画布渲染
    • 多图层管理(增删改、层级调整)
    • 元素拖拽、缩放、旋转
    • 撤销/重做(Undo/Redo)
  • 支持的元素类型

    • 矩形、圆形、文本
    • 自由路径(铅笔工具)
  • 属性编辑

    • 颜色、边框、透明度
    • 字体、字号(文本元素)
  • 辅助功能

    • 网格背景
    • 对齐线(Snap to Guide)
    • 导出为图片

2、技术栈选择

  • 核心库: Vue 3 + TypeScript + Composition API
  • 状态管理: Pinia(比 Vuex 更轻量)
  • 拖拽交互: vue-draggable-next 或自实现
  • 数学计算: paper.js(处理图形变换)
  • 渲染优化: 虚拟滚动(针对大量元素)
  • 持久化: localStorage 或 IndexedDB

二、具体实现:

1.确认已安装Node.js

2.项目初始化

npm create vite@latest figma-editor --template vue-ts
cd figma-editor
npm install pinia paperjs @vueuse/core
npm install pinia vue-draggable-next @vueuse/core

3.项目结构如图:

在这里插入图片描述

4.具体代码如下

4.1 Canvas.vue 画布组件 (核心)(src/components/Canvas.vue)

<template>
  <div class="canvas-container">
    <svg 
      ref="canvas" 
      :width="width" 
      :height="height"
      @mousedown="handleMouseDown"
      @mousemove="handleMouseMove"
      @mouseup="handleMouseUp"
      @click="handleCanvasClick"
    >
      <!-- 网格背景 -->
      <pattern 
        id="grid" 
        width="20" 
        height="20" 
        patternUnits="userSpaceOnUse"
      >
        <path d="M 20 0 L 0 0 0 20" fill="none" stroke="#eee" stroke-width="0.5"/>
      </pattern>
      <rect width="100%" height="100%" fill="url(#grid)"/>

      <!-- 渲染所有元素 -->
      <g v-for="element in elements" :key="element.id">
        <!-- 矩形 -->
        <rect 
          v-if="element.type === 'rect'"
          :x="element.x"
          :y="element.y"
          :width="element.width"
          :height="element.height"
          :fill="element.fill"
          :stroke="selectedElementId === element.id ? '#1890ff' : 'none'"
          stroke-width="2"
          @click.stop="selectElement(element.id)"
          :transform="`rotate(${element.rotation || 0}, ${element.x + element.width/2}, ${element.y + element.height/2})`"
        />
        
        <!-- 圆形 -->
        <circle 
          v-if="element.type === 'circle'"
          :cx="element.x"
          :cy="element.y"
          :r="element.radius"
          :fill="element.fill"
          :stroke="selectedElementId === element.id ? '#1890ff' : 'none'"
          stroke-width="2"
          @click.stop="selectElement(element.id)"
        />
      </g>
    </svg>
  </div>
</template>

<script lang="ts" setup>
import { useEditorStore } from '@/stores/editor'
import { storeToRefs } from 'pinia'
import { ref, onMounted } from 'vue'

const store = useEditorStore()
const { elements, selectedElementId } = storeToRefs(store)

const width = ref(800)
const height = ref(600)
const canvas = ref<SVGElement | null>(null)

// 选择元素
const selectElement = (id: string) => {
  store.selectedElementId = id
}

// 点击空白处取消选择
const handleCanvasClick = (e: MouseEvent) => {
  if (e.target === canvas.value) {
    store.selectedElementId = null
  }
}

// 拖拽逻辑
let isDragging = false
let startX = 0
let startY = 0
let draggedElement: EditorElement | null = null

const handleMouseDown = (e: MouseEvent) => {
  const target = e.target as SVGElement
  if (target.tagName !== 'rect' && target.tagName !== 'circle') return

  isDragging = true
  startX = e.clientX
  startY = e.clientY
  draggedElement = elements.value.find(el => el.id === selectedElementId.value) || null
  e.preventDefault()
}

const handleMouseMove = (e: MouseEvent) => {
  if (!isDragging || !draggedElement) return

  const dx = e.clientX - startX
  const dy = e.clientY - startY

  store.updateElement(draggedElement.id, {
    x: draggedElement.x + dx,
    y: draggedElement.y + dy
  })

  startX = e.clientX
  startY = e.clientY
}

const handleMouseUp = () => {
  isDragging = false
  draggedElement = null
}
</script>

<style scoped>
.canvas-container {
  border: 1px solid #ddd;
  overflow: auto;
  background-color: white;
}
</style>

4.2 工具栏组件 (src/components/Toolbar.vue)

<template>
  <div class="toolbar">
    <button @click="addRectangle">添加矩形</button>
    <button @click="addCircle">添加圆形</button>
    <button @click="deleteSelected" :disabled="!selectedElementId">删除</button>
    <button @click="undo" :disabled="!canUndo">撤销</button>
    <button @click="redo" :disabled="!canRedo">重做</button>
    
    <div v-if="selectedElement" class="color-picker">
      <label>填充色: 
        <input type="color" v-model="selectedElement.fill" @change="updateElementColor">
      </label>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { useEditorStore } from '@/stores/editor'
import { storeToRefs } from 'pinia'

const store = useEditorStore()
const { selectedElementId, selectedElement, canUndo, canRedo } = storeToRefs(store)

const addRectangle = () => {
  store.addElement({
    type: 'rect',
    x: 100,
    y: 100,
    width: 150,
    height: 100,
    fill: '#3aa757'
  })
}

const addCircle = () => {
  store.addElement({
    type: 'circle',
    x: 200,
    y: 200,
    radius: 50,
    fill: '#eb4034'
  })
}

const deleteSelected = () => {
  store.deleteSelected()
}

const undo = () => {
  store.undo()
}

const redo = () => {
  store.redo()
}

const updateElementColor = () => {
  if (selectedElement.value) {
    store.updateElement(selectedElement.value.id, {
      fill: selectedElement.value.fill
    })
  }
}
</script>

<style scoped>
.toolbar {
  padding: 10px;
  background: #f5f5f5;
  display: flex;
  gap: 10px;
  align-items: center;
  border-bottom: 1px solid #ddd;
}

button {
  padding: 5px 10px;
  cursor: pointer;
}

.color-picker {
  margin-left: auto;
}
</style>

4.3 属性面板组件(src/components/PropertyPanel.vue)

<template>
  <div class="property-panel" v-if="selectedElement">
    <h3>属性编辑</h3>
    
    <div class="property-group">
      <label>位置 X: 
        <input type="number" v-model.number="selectedElement.x" @change="updatePosition">
      </label>
      <label>位置 Y: 
        <input type="number" v-model.number="selectedElement.y" @change="updatePosition">
      </label>
    </div>

    <div class="property-group" v-if="selectedElement.type === 'rect'">
      <label>宽度: 
        <input type="number" v-model.number="selectedElement.width" @change="updateRectSize">
      </label>
      <label>高度: 
        <input type="number" v-model.number="selectedElement.height" @change="updateRectSize">
      </label>
    </div>

    <div class="property-group" v-if="selectedElement.type === 'circle'">
      <label>半径: 
        <input type="number" v-model.number="selectedElement.radius" @change="updateCircleRadius">
      </label>
    </div>

    <div class="property-group">
      <label>旋转角度: 
        <input type="number" v-model.number="selectedElement.rotation" @change="updateRotation">
      </label>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { useEditorStore } from '@/stores/editor'
import { storeToRefs } from 'pinia'

const store = useEditorStore()
const { selectedElement } = storeToRefs(store)

const updatePosition = () => {
  if (selectedElement.value) {
    store.updateElement(selectedElement.value.id, {
      x: selectedElement.value.x,
      y: selectedElement.value.y
    })
  }
}

const updateRectSize = () => {
  if (selectedElement.value?.type === 'rect') {
    store.updateElement(selectedElement.value.id, {
      width: selectedElement.value.width,
      height: selectedElement.value.height
    })
  }
}

const updateCircleRadius = () => {
  if (selectedElement.value?.type === 'circle') {
    store.updateElement(selectedElement.value.id, {
      radius: selectedElement.value.radius
    })
  }
}

const updateRotation = () => {
  if (selectedElement.value) {
    store.updateElement(selectedElement.value.id, {
      rotation: selectedElement.value.rotation || 0
    })
  }
}
</script>

<style scoped>
.property-panel {
  width: 250px;
  padding: 15px;
  border-left: 1px solid #ddd;
  background: #f9f9f9;
}

.property-group {
  margin-bottom: 15px;
}

label {
  display: block;
  margin-bottom: 8px;
}

input[type="number"] {
  width: 60px;
  margin-left: 5px;
}
</style>

4.4 主组件整合 (src/App.vue)

<template>
  <div class="app-container">
    <Toolbar />
    <div class="main-content">
      <Canvas />
      <PropertyPanel />
    </div>
  </div>
</template>

<script lang="ts" setup>
import Toolbar from './components/Toolbar.vue'
import Canvas from './components/Canvas.vue'
import PropertyPanel from './components/PropertyPanel.vue'
</script>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

.app-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

.main-content {
  display: flex;
  flex: 1;
  overflow: hidden;
}
</style>

4.5 入口文件(src/main.ts)

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
app.use(createPinia())

app.mount('#app')

4.6 状态管理 (Pinia Store)(src/stores/editor.ts)

import { defineStore } from 'pinia'

// 元素类型定义
type ElementType = 'rect' | 'circle' | 'text'

interface BaseElement {
  id: string
  type: ElementType
  x: number
  y: number
  fill: string
  rotation?: number
}

interface RectElement extends BaseElement {
  type: 'rect'
  width: number
  height: number
}

interface CircleElement extends BaseElement {
  type: 'circle'
  radius: number
}

type EditorElement = RectElement | CircleElement

interface EditorState {
  elements: EditorElement[]
  selectedElementId: string | null
  history: EditorElement[][]
  historyIndex: number
}

export const useEditorStore = defineStore('editor', {
  state: (): EditorState => ({
    elements: [],
    selectedElementId: null,
    history: [[]],
    historyIndex: 0
  }),

  getters: {
    selectedElement(state): EditorElement | null {
      return state.elements.find(el => el.id === state.selectedElementId) || null
    },
    canUndo(state): boolean {
      return state.historyIndex > 0
    },
    canRedo(state): boolean {
      return state.historyIndex < state.history.length - 1
    }
  },

  actions: {
    addElement(element: Omit<EditorElement, 'id'>) {
      const newElement = { 
        ...element, 
        id: Date.now().toString() 
      } as EditorElement
      this.elements.push(newElement)
      this.saveHistory()
    },

    deleteSelected() {
      if (this.selectedElementId) {
        this.elements = this.elements.filter(el => el.id !== this.selectedElementId)
        this.selectedElementId = null
        this.saveHistory()
      }
    },

    saveHistory() {
      // 移除当前指针后的历史记录
      this.history = this.history.slice(0, this.historyIndex + 1)
      // 深拷贝当前状态
      this.history.push(JSON.parse(JSON.stringify(this.elements)))
      this.historyIndex++
    },

    undo() {
      if (this.canUndo) {
        this.historyIndex--
        this.elements = JSON.parse(JSON.stringify(this.history[this.historyIndex]))
      }
    },

    redo() {
      if (this.canRedo) {
        this.historyIndex++
        this.elements = JSON.parse(JSON.stringify(this.history[this.historyIndex]))
      }
    },

    updateElement(id: string, updates: Partial<EditorElement>) {
      const index = this.elements.findIndex(el => el.id === id)
      if (index !== -1) {
        this.elements[index] = { ...this.elements[index], ...updates }
        this.saveHistory()
      }
    }
  }
})

5.可能遇到的问题

 Failed to run dependency scan. Skipping dependency pre-bundling. Error: The following dependencies are imported but could not be resolved:
                                                                                                                                                                                                                                  
  @/stores/editor (imported by E:/figmatool/figma-editor/src/components/Canvas.vue?id=0)                                                                                                                                          
                                                                                                                                                                                                                                  
Are they installed?                                                                                                                                                                                                               
    at file:///E:/figmatool/figma-editor/node_modules/vite/dist/node/chunks/config.js:10902:33                                                                                                                                    
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5)                                                                                                                                                
    at async file:///E:/figmatool/figma-editor/node_modules/vite/dist/node/chunks/config.js:34350:15   

解决方案:

这个错误是因为 Vite 无法解析 @/stores/editor 这样的路径别名。我们需要在 Vite 配置中正确设置路径别名。以下是完整的解决方案:

5.1. 修改 vite.config.ts

在项目根目录创建/修改 vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  }
})

注意:需要先安装 path 的类型定义:

npm install --save-dev @types/node

5.2. 确保 TypeScript 也识别路径别名

修改 tsconfig.json:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "node",
    "strict": true,
    "jsx": "preserve",
    "sourceMap": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "lib": ["ESNext", "DOM"],
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

如果仍然报错
尝试以下额外步骤:

方案A:使用相对路径临时解决

// 在组件中修改导入语句
import { useEditorStore } from '../stores/editor'

方案B:检查依赖完整性

rm -rf node_modules package-lock.json
npm install

方案C:显式声明路径别名
在 vite.config.ts 中更详细地声明:

alias: {
  '@': path.resolve(__dirname, './src'),
  '@stores': path.resolve(__dirname, './src/stores')
}

然后可以这样导入:

import { useEditorStore } from '@stores/editor'

6.启动项目

启动开发服务器:

npm run dev

7.效果图如下:

在这里插入图片描述

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐