十一篇文章,十一次进阶。

我们从最基础的 SVGElement 基因开始,一步步走过了矩形、圆形、椭圆、路径的绘制;掌握了分组与复用的组织艺术;理解了矩阵变换的数学本质;学会了列表数据的操作技巧;最后还解锁了 SMIL 动画的动效魔法。

但学以致用,才是学习的终点。

今天,我们将把前 11 篇文章的所有知识点串联起来,从零构建一个简易版的 Figma 画板——一个功能完整的 SVG 图形编辑器。

这个项目将涵盖:

  • 图形创建:矩形、圆形、椭圆、路径,一键添加到画布。

  • 选择与高亮:点击选中图形,边框高亮反馈。

  • 拖拽移动:选中后拖拽改变位置。

  • 变换控制:旋转手柄和缩放手柄,可视化操控。

  • 属性面板:实时编辑选中图形的颜色、尺寸、位置。

  • 图层管理:上移、下移、删除图层。

  • 导出功能:导出为 SVG 文件和 PNG 图片。

这不是一个“玩具项目”——它包含了真实图形编辑器的核心架构。完成之后,你将拥有一个可以继续扩展的、工业级的 SVG 编辑框架。

一、项目架构设计

在写代码之前,我们先想清楚架构。

1.1 核心设计原则

数据驱动

我们不直接操作 DOM 来管理“选中状态”或“图层顺序”,而是维护一个图形数据数组,每次数据变化后重新渲染画布。

graphics: [
  { id: 'rect_1', type: 'rect', x: 50, y: 50, width: 100, height: 80, fill: '#4A90D9' },
  { id: 'circle_1', type: 'circle', cx: 250, cy: 150, r: 50, fill: '#E67E22' },
  ...
]

单一职责

每个功能模块独立封装——renderer 负责渲染,selector 负责选择,transformer 负责变换,propertyPanel 负责属性编辑。

事件驱动

用户操作 → 触发事件 → 更新数据 → 重新渲染 → 用户看到新状态。这是一个闭环。

1.2 技术栈

本项目零依赖,全部使用原生 JavaScript + DOM API + SVG API。你只需要一个浏览器。

1.3 目录结构
/editor
  ├── index.html          # 主页面
  ├── style.css           # 样式
  └── script.js           # 所有逻辑(为便于阅读,集中在一个文件)

二、HTML 结构与 CSS 样式

2.1 HTML 结构
<!-- 顶部工具栏 -->
<header class="toolbar">
  <div class="tool-group">
      <button data-tool="select" class="active">🖱 选择</button>
      <button data-tool="rect">▭ 矩形</button>
      <button data-tool="circle">◯ 圆形</button>
      <button data-tool="ellipse">⬮ 椭圆</button>
      <button data-tool="path">╲ 路径</button>
      <button data-tool="freedraw">✎ 自由绘制</button>
  </div>
  <div class="tool-group">
      <button id="deleteBtn">🗑 删除</button>
      <button id="moveUpBtn">⬆ 上移</button>
      <button id="moveDownBtn">⬇ 下移</button>
  </div>
  <div class="tool-group">
      <button id="exportSvgBtn">📄 导出 SVG</button>
      <button id="exportPngBtn">🖼 导出 PNG</button>
  </div>
</header>

<!-- 主区域 -->
<main class="main">
  <!-- 画布 -->
  <div class="canvas-wrapper">
      <svg id="canvas" viewBox="0 0 800 500" width="800" height="500">
          <!-- 网格背景 -->
          <pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
              <path d="M 20 0 L 0 0 0 20" fill="none" stroke="#e8ecf1" stroke-width="0.5" />
          </pattern>
          <rect width="800" height="500" fill="url(#grid)" />

          <!-- 图形容器 -->
          <g id="shapes"></g>

          <!-- 选择框(高亮) -->
          <rect id="selectionBox" fill="none" stroke="#4A90D9" stroke-width="1.5"
                stroke-dasharray="4,4" display="none" />

          <!-- 变换手柄 -->
          <g id="handles" display="none">
              <!-- 旋转手柄 -->
              <circle id="rotateHandle" cx="0" cy="-40" r="8" fill="white" stroke="#4A90D9" stroke-width="2"
                      style="cursor: grab" />
              <line id="rotateLine" x1="0" y1="0" x2="0" y2="-40" stroke="#4A90D9" stroke-width="1.5" />
              <!-- 缩放手柄(右下角) -->
              <circle id="scaleHandle" cx="0" cy="0" r="8" fill="white" stroke="#4A90D9" stroke-width="2"
                      style="cursor: nwse-resize" />
          </g>
      </svg>
  </div>

  <!-- 属性面板 -->
  <aside class="panel">
      <h3>属性面板</h3>
      <div id="propertyPanel">
          <p style="color: #999; font-size: 14px;">点击画布上的图形以编辑</p>
      </div>
  </aside>
</main>
2.2 CSS 样式
* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: #f0f2f5;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
}

.app {
  background: white;
  border-radius: 12px;
  box-shadow: 0 4px 24px rgba(0,0,0,0.1);
  overflow: hidden;
  width: 1100px;
  max-width: 98vw;
}

/* ... 剩下部分省略 */

三、JavaScript 核心逻辑

3.1 数据层
// ===== 数据层 =====
const state = {
    graphics: [],           // 所有图形数据
    selectedId: null,       // 当前选中的图形 ID
    tool: 'select',         // 当前工具: 'select' | 'rect' | 'circle' | 'ellipse' | 'path' | 'freedraw'
    nextId: 1,
};

// 图形数据工厂
function createGraphic(type, props) {
    const base = { id: `g_${state.nextId++}`, type };
    let data;
    switch (type) {
        case 'rect':
            data = { ...base, x: 100, y: 100, width: 120, height: 80, fill: '#4A90D9', stroke: '#2C3E50', strokeWidth: 2 };
            break;
        case 'circle':
            data = { ...base, cx: 200, cy: 150, r: 50, fill: '#E67E22', stroke: '#2C3E50', strokeWidth: 2 };
            break;
        case 'ellipse':
            data = { ...base, cx: 200, cy: 150, rx: 70, ry: 45, fill: '#27AE60', stroke: '#2C3E50', strokeWidth: 2 };
            break;
        case 'path':
            data = { ...base, d: 'M 50 50 L 150 50 L 100 130 Z', fill: '#9B59B6', stroke: '#2C3E50', strokeWidth: 2 };
            break;
        default:
            data = base;
    }
    // 合并传入的属性
    return { ...data, ...props };
}
3.2 渲染层

渲染层的核心职责是:根据 state.graphics 数据,在 SVG 中创建或更新对应的 DOM 元素。

// ===== 渲染层 =====
const canvas = document.getElementById('canvas');
const shapesContainer = document.getElementById('shapes');
const selectionBox = document.getElementById('selectionBox');
const handlesGroup = document.getElementById('handles');
const rotateHandle = document.getElementById('rotateHandle');
const scaleHandle = document.getElementById('scaleHandle');
const rotateLine = document.getElementById('rotateLine');
const propertyPanel = document.getElementById('propertyPanel');

const SVG_NS = 'http://www.w3.org/2000/svg';

// 存储图形 DOM 元素的映射 { id: SVGElement }
const elementMap = new Map();

function render() {
    // 1. 清除所有现有图形
    shapesContainer.innerHTML = '';
    elementMap.clear();

    // 2. 遍历数据,创建 DOM 元素
    state.graphics.forEach(data => {
        const el = createElement(data);
        shapesContainer.appendChild(el);
        elementMap.set(data.id, el);
    });

    // 3. 更新选择状态
    updateSelection();
}

// 剩下部分省略
3.3 选择逻辑
// ===== 选择逻辑 =====
function selectGraphic(id) {
    state.selectedId = id;
    updateSelection();
}

function deselectAll() {
    state.selectedId = null;
    updateSelection();
}
3.4 图形创建(绘制工具)
// ---- 绘制图形 ----
function startDrawing(point) {
    isDrawing = true;
    drawStartX = point.x;
    drawStartY = point.y;

    const data = createGraphic(state.tool, {});
    // 初始尺寸为 0
    if (data.type === 'rect') { data.x = drawStartX; data.y = drawStartY; data.width = 1; data.height = 1; }
    if (data.type === 'circle') { data.cx = drawStartX; data.cy = drawStartY; data.r = 1; }
    if (data.type === 'ellipse') { data.cx = drawStartX; data.cy = drawStartY; data.rx = 1; data.ry = 1; }
    if (data.type === 'path') { data.d = `M ${drawStartX} ${drawStartY} L ${drawStartX} ${drawStartY} Z`; }

    state.graphics.push(data);
    tempData = data;
    render();
    selectGraphic(data.id);
}

// 剩下部分省略
3.5 拖拽移动

选中图形后,拖拽它来改变位置。

// ---- 绘制图形 ----
function startDrawing(point) {
    isDrawing = true;
    drawStartX = point.x;
    drawStartY = point.y;

    const data = createGraphic(state.tool, {});
    // 初始尺寸为 0
    if (data.type === 'rect') { data.x = drawStartX; data.y = drawStartY; data.width = 1; data.height = 1; }
    if (data.type === 'circle') { data.cx = drawStartX; data.cy = drawStartY; data.r = 1; }
    if (data.type === 'ellipse') { data.cx = drawStartX; data.cy = drawStartY; data.rx = 1; data.ry = 1; }
    if (data.type === 'path') { data.d = `M ${drawStartX} ${drawStartY} L ${drawStartX} ${drawStartY} Z`; }

    state.graphics.push(data);
    tempData = data;
    render();
    selectGraphic(data.id);
}

// 剩下部分省略
3.6 变换控制(旋转与缩放)
// 部分代码
// 变换中(旋转/缩放)
if (isTransforming && transformData) {
    const point = getSVGPoint(e);

    if (transformType === 'rotate') {
        const currentAngle = Math.atan2(point.y - transformCenter.y, point.x - transformCenter.x);
        const deltaAngle = (currentAngle - transformStartAngle) * 180 / Math.PI;
        // 从初始矩阵开始叠加旋转,避免累积误差
        const rotateMatrix = new DOMMatrix()
            .translate(transformCenter.x, transformCenter.y)
            .rotate(deltaAngle)
            .translate(-transformCenter.x, -transformCenter.y);
        const combined = transformStartMatrix.multiply(rotateMatrix);
        applyMatrixToGraphic(transformData, combined);
    } else if (transformType === 'scale') {
        const currentDist = Math.hypot(point.x - transformCenter.x, point.y - transformCenter.y);
        const scale = Math.max(0.1, currentDist / transformStartDist);
        // 从初始矩阵开始叠加缩放,避免累积误差
        const scaleMatrix = new DOMMatrix()
            .translate(transformCenter.x, transformCenter.y)
            .scale(scale)
            .translate(-transformCenter.x, -transformCenter.y);
        const combined = transformStartMatrix.multiply(scaleMatrix);
        applyMatrixToGraphic(transformData, combined);
    }

    updateGraphicDOM(transformData);
    updateSelection();
    return;
}
3.7 属性面板

属性面板实时显示和编辑选中图形的属性。

// ===== 属性面板 =====
function getPropertyFields(data) {
    const common = [
        { key: 'fill', label: '填充色', type: 'color' },
        { key: 'stroke', label: '描边色', type: 'color' },
        { key: 'strokeWidth', label: '描边宽', type: 'number' },
    ];

    switch (data.type) {
        case 'rect':
            return [
                { key: 'x', label: 'X 坐标', type: 'number' },
                { key: 'y', label: 'Y 坐标', type: 'number' },
                { key: 'width', label: '宽度', type: 'number' },
                { key: 'height', label: '高度', type: 'number' },
                ...common,
            ];
        case 'circle':
            return [
                { key: 'cx', label: '圆心 X', type: 'number' },
                { key: 'cy', label: '圆心 Y', type: 'number' },
                { key: 'r', label: '半径', type: 'number' },
                ...common,
            ];
        case 'ellipse':
            return [
                { key: 'cx', label: '圆心 X', type: 'number' },
                { key: 'cy', label: '圆心 Y', type: 'number' },
                { key: 'rx', label: 'X 半径', type: 'number' },
                { key: 'ry', label: 'Y 半径', type: 'number' },
                ...common,
            ];
        case 'path':
            return [
                { key: 'd', label: '路径数据', type: 'text' },
                ...common,
            ];
        default:
            return common;
    }
}

// 部分代码省略
3.8 图层管理
// ===== 图层管理 =====
function moveUp() {
    if (!state.selectedId) return;
    const index = state.graphics.findIndex(g => g.id === state.selectedId);
    if (index < 0 || index >= state.graphics.length - 1) return;
    [state.graphics[index], state.graphics[index + 1]] = [state.graphics[index + 1], state.graphics[index]];
    render();
}

function moveDown() {
    if (!state.selectedId) return;
    const index = state.graphics.findIndex(g => g.id === state.selectedId);
    if (index <= 0) return;
    [state.graphics[index], state.graphics[index - 1]] = [state.graphics[index - 1], state.graphics[index]];
    render();
}

function deleteSelected() {
    if (!state.selectedId) return;
    state.graphics = state.graphics.filter(g => g.id !== state.selectedId);
    state.selectedId = null;
    render();
}
3.9 导出功能
  • 导出 SVG:序列化当前画布内容为 SVG 字符串,并下载为 .svg 文件。

  • 导出 PNG:将 SVG 渲染到 Canvas,再导出为 PNG 图片。

// ===== 导出功能 =====
function exportSVG() {
    // 克隆 SVG 并移除交互元素
    const clone = canvas.cloneNode(true);
    const selBox = clone.querySelector('#selectionBox');
    const handles = clone.querySelector('#handles');
    if (selBox) selBox.remove();
    if (handles) handles.remove();

    const svgString = new XMLSerializer().serializeToString(clone);
    const blob = new Blob([svgString], { type: 'image/svg+xml' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'drawing.svg';
    a.click();
    URL.revokeObjectURL(url);
}

// 部分代码省略
3.10 工具切换与初始化
function setTool(tool) {
    // 切换工具时,如果正在绘制 path,先取消
    if (isDrawingPath && tool !== 'path') {
        cancelPathDrawing();
    }
    // 切换工具时,如果正在自由绘制,先取消
    if (isFreeDrawing && tool !== 'freedraw') {
        cancelFreeDrawing();
    }

    state.tool = tool;
    document.querySelectorAll('[data-tool]').forEach(btn => {
        btn.classList.toggle('active', btn.dataset.tool === tool);
    });
    // 切换到绘制工具时取消选择
    if (tool !== 'select') {
        deselectAll();
    }
    // 更新光标
    canvas.style.cursor = tool === 'select' ? 'default' : 'crosshair';
}

// 部分代码省略。。。
3.11 path 多点折线绘制
// ---- path 多点折线绘制 ----
// 交互:点击添加顶点 → 移动鼠标显示预览线 → 双击/回车结束 → ESC 取消

// path 双击检测状态
let lastPathClickTime = 0;
let lastPathClickPoint = null;
const DBLCLICK_INTERVAL = 300;  // 双击最大间隔(ms)
const DBLCLICK_DISTANCE = 6;    // 双击最大距离(px)

// 部分代码省略
3.12 自由绘制
// ---- 自由绘制 ----
// 交互:按住鼠标拖动 → 实时记录轨迹点 → 松开鼠标完成

// 开始自由绘制
function startFreeDrawing(point) {
    isFreeDrawing = true;
    freeDrawPoints = [{ x: point.x, y: point.y }];

    // 创建 path 数据,初始只有一个点
    freeDrawData = createGraphic('path', {});
    freeDrawData.fill = 'none';  // 自由绘制默认不填充
    freeDrawData.d = `M ${point.x} ${point.y}`;
    state.graphics.push(freeDrawData);
    render();
    selectGraphic(freeDrawData.id);
}

// 部分代码省略。。。

四、完整代码整合

由于篇幅限制,部分代码已在上述各节中分段展示。需要完整代码进行进阶的小伙伴,请评论区留言。下图是作者手搓图图例:

五、功能演示与使用说明

5.1 基本操作

操作

说明

选择图形

点击“选择”工具后,点击任意图形

拖拽移动

选中图形后,按住并拖动

绘制图形

切换到“矩形/圆形/椭圆/路径”工具,在画布上点击并拖拽

旋转图形

选中图形后,拖拽上方的圆形手柄

缩放图形

选中图形后,拖拽右下角的方形手柄

多点折线绘制

点击添加顶点 → 移动鼠标显示预览线 → 双击/回车结束 → ESC 取消

自由绘制

按住鼠标拖动 → 实时记录轨迹点 → 松开鼠标完成

修改属性

选中图形后,在右侧属性面板中修改

删除图形

选中后点击“删除”按钮

调整图层

选中后点击“上移”或“下移”

导出 SVG

点击“导出 SVG”,下载 .svg 文件

导出 PNG

点击“导出 PNG”,下载 .png 图片

5.2 技术亮点回顾

功能

使用的 API/技术

对应系列文章

图形创建

createElementNS、setAttribute

第 3、4、5 篇

边界计算

SVGGraphicsElement.getBBox()

第 2 篇

坐标转换

DOMMatrix、getScreenCTM()

第 8 篇

变换控制

DOMMatrix 矩阵运算

第 8 篇

属性编辑

setAttribute、baseVal.value

第 3 篇

导出 SVG

XMLSerializer

—

导出 PNG

Canvas drawImage、toDataURL

—

六、扩展方向

这个画板虽然“简易”,但已经具备了图形编辑器的核心骨架。你可以在此基础上继续扩展:

(1)更丰富的图形类型

  • 多边形(<polygon>):使用 SVGPointList 操作顶点【第 8 篇】。

  • 文本(<text>):使用 SVGTextElement。

  • 图片(<image>):使用 SVGImageElement。

(2)更强大的选择能力

  • 框选(拖拽矩形批量选择):使用 getIntersectionList()【第 2 篇】。

  • 多选 + 批量变换。

  • 图层树显示。

(3)更精细的变换控制

  • 8 个方向的手柄(四角 + 四边中点)。

  • 变换时显示实时数值。

  • 对齐线与智能吸附。

(4)撤销/重做

  • 使用命令模式(Command Pattern),每次操作保存一个命令对象

  • 维护历史栈,支持撤销和重做。

(5)更完善的导出

  • 导出为 PDF。

  • 导出为不同分辨率。

  • 导出选区。

七、本章总结

模块

核心 API

关键认知

数据层

—

数据驱动渲染,DOM 只是数据的“投影”

渲染层

 getBBox()、setAttribute()

getBBox() 返回的是不考虑 transform 的边界框

选择逻辑

事件冒泡、closest()

点击检测从顶层向下遍历

图形创建

createElementNS

SVG 元素必须使用 http://www.w3.org/2000/svg 命名空间

拖拽移动

getScreenCTM()、DOMMatrix

屏幕坐标 → SVG 坐标的转换是关键

变换控制

DOMMatrix 矩阵运算

旋转/缩放 = 平移→变换→平移回来

属性面板

双向数据绑定

数据变化 → 渲染 → 面板更新 → 用户修改 → 数据变化

导出 SVG

XMLSerializer

直接序列化 DOM 为 XML 字符串

导出 PNG

Canvas  drawImage()

SVG → Image → Canvas → PNG

三个核心认知:

  1. 数据是唯一的真相来源——所有 UI 状态(选中、位置、样式)都存储在 state 中,DOM 只是数据的可视化表现。这让你可以轻松实现撤销/重做、保存/加载等功能。

  2. 矩阵是变换的通用语言——无论是旋转、缩放还是平移,都可以用 DOMMatrix 统一表达。理解矩阵运算,你就理解了所有变换的本质。

  3. getBBox() 是布局计算的基石——它让你能够精确计算任意图形的位置和大小,是实现选择框、手柄定位、碰撞检测等功能的基础。

写在最后

十二篇文章,从零到一。

我们从一个最简单的 <circle> 开始,一路走到今天这个功能完整的图形编辑器。这中间,我们学会了:

  • SVG 元素的继承体系(SVGElement → SVGGraphicsElement → SVGGeometryElement)。

  • 各种图形接口(SVGRectElement、SVGCircleElement、SVGPathElement)。

  • 分组与复用(<g>、<defs>、<use>、<symbol>)。

  • SVG数据:SVGLength 与 SVGNumber。

  • 矩阵变换(DOMMatrix、getCTM()、getScreenCTM())。

  • 列表操作(SVGStringList、SVGNumberList、SVGPointList)。

  • SMIL 动画(<animate>、<animateTransform>、<animateMotion>)

这些知识,不仅仅是“SVG 的 API”——它们是 Web 图形编程的底层通用能力。无论你以后用 Canvas、WebGL 还是 Three.js,这些关于坐标系统、变换矩阵、事件处理的底层认知,都会让你走得更远。

现在,轮到你动手了。

打开编辑器,画一个属于你自己的图形吧。然后,尝试给它加一个你一直想要的功能——也许是一个自由画笔,也许是一个对齐网格,也许是一个导出动画的功能。

互动话题:你最想为这个画板添加什么功能?或者你用它画了什么有趣的东西?欢迎在评论区分享你的创意和作品!