跳到主要内容

Canvas 编辑器 — 使用 JavaScript 和 HTML5 Canvas 构建设计编辑器

设计编辑器让人们在页面上排布文字、图片和图形并导出结果 — 这是 Canva、Figma 以及每一个 社交媒体图形工具背后的模式。Konva 很适合作为它的基础:它在 Canvas 之上提供对象模型、 命中检测、事件、拖放和缩放手柄。

本页构建一个可用的编辑器,包含选择、移动、缩放控件、撤销、重做和 PNG 导出。最后会诚实 说明它与产品之间还差什么。

看一个成品编辑器

Polotno 是一个基于 Konva 构建的商业设计编辑器 SDK。它最清楚地展示了这个模式能扩展到多远, 因此在阅读任何代码之前,值得先花一分钟看看:

你刚刚用到的一切 — 工具栏、侧边面板、模板、字体和导出 — 都建立在下面介绍的同一套 Konva 基础之上。

自己动手构建一个

操作说明: 在画布上或对象列表中点击一个对象。拖动它,或使用缩放手柄。添加对象、 撤销,然后导出结果。

import React from 'react';
import { Stage, Layer, Rect, Circle, Star, Text, Transformer } from 'react-konva';

const WIDTH = 760;
const HEIGHT = 420;

const initialShapes = [
{ id: 'card', type: 'rect', x: 60, y: 60, width: 300, height: 300, fill: '#1e3a8a', cornerRadius: 16 },
{ id: 'accent', type: 'circle', x: 470, y: 150, radius: 74, fill: '#f59e0b' },
{ id: 'badge', type: 'star', x: 620, y: 300, numPoints: 5, innerRadius: 26, outerRadius: 58, fill: '#ec4899' },
{ id: 'headline', type: 'text', x: 92, y: 120, text: 'Spring\nSale', fontSize: 58, fontStyle: 'bold', lineHeight: 1.1, fill: '#ffffff' },
{ id: 'caption', type: 'text', x: 92, y: 268, text: 'up to 40% off', fontSize: 22, fill: '#bfdbfe' },
];

function useResponsiveWidth(maxWidth) {
const containerRef = React.useRef(null);
const [width, setWidth] = React.useState(1);

React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const update = () =>
setWidth(Math.max(1, Math.min(maxWidth, container.clientWidth)));
update();
const observer = new ResizeObserver(update);
observer.observe(container);
return () => observer.disconnect();
}, [maxWidth]);

return { containerRef, width, scale: width / maxWidth };
}

// One renderer per shape type. The document stores plain data, never Konva nodes.
function EditableShape({ shape, selected, onSelect, onCommit }) {
const shapeRef = React.useRef(null);
const transformerRef = React.useRef(null);

React.useEffect(() => {
if (selected && shapeRef.current && transformerRef.current) {
transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer().batchDraw();
}
}, [selected]);

// The Transformer resizes by changing node scale. Fold that scale back into
// the shape's own size properties so the saved document stays readable.
const handleTransformEnd = () => {
const node = shapeRef.current;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
const average = (scaleX + scaleY) / 2;
node.scaleX(1);
node.scaleY(1);

const base = { ...shape, x: node.x(), y: node.y(), rotation: node.rotation() };

if (shape.type === 'rect') {
onCommit({
...base,
width: Math.max(20, node.width() * scaleX),
height: Math.max(20, node.height() * scaleY),
});
} else if (shape.type === 'circle') {
onCommit({ ...base, radius: Math.max(10, shape.radius * average) });
} else if (shape.type === 'star') {
onCommit({
...base,
innerRadius: Math.max(6, shape.innerRadius * average),
outerRadius: Math.max(12, shape.outerRadius * average),
});
} else {
onCommit({ ...base, fontSize: Math.max(8, shape.fontSize * average) });
}
};

const common = {
ref: shapeRef,
draggable: true,
onClick: onSelect,
onTap: onSelect,
onDragEnd: (event) =>
onCommit({ ...shape, x: event.target.x(), y: event.target.y() }),
onTransformEnd: handleTransformEnd,
};

const { id, type, ...props } = shape;

return (
<>
{type === 'rect' && <Rect {...props} {...common} />}
{type === 'circle' && <Circle {...props} {...common} />}
{type === 'star' && <Star {...props} {...common} />}
{type === 'text' && <Text {...props} {...common} />}
{selected && (
<Transformer
ref={transformerRef}
rotateAnchorOffset={26}
anchorStroke="#2563eb"
borderStroke="#2563eb"
anchorSize={9}
flipEnabled={false}
boundBoxFunc={(oldBox, newBox) =>
newBox.width < 20 || newBox.height < 20 ? oldBox : newBox
}
/>
)}
</>
);
}

export default function App() {
const stageRef = React.useRef(null);
const nextId = React.useRef(1);
const { containerRef, width: displayWidth, scale } = useResponsiveWidth(WIDTH);
const [selectedId, setSelectedId] = React.useState('headline');
const [history, setHistory] = React.useState({
past: [],
present: initialShapes,
future: [],
});

// One history entry per finished action — never one per pointer move.
const commit = (nextShapes) =>
setHistory((current) => ({
past: [...current.past, current.present],
present: nextShapes,
future: [],
}));

const updateShape = (next) =>
commit(history.present.map((s) => (s.id === next.id ? next : s)));

const addShape = (type) => {
const id = `${type}-${nextId.current++}`;
const offset = history.present.length * 14;
const presets = {
rect: { width: 150, height: 100, fill: '#10b981', cornerRadius: 10 },
circle: { radius: 52, fill: '#8b5cf6' },
text: { text: 'Double-click to retype', fontSize: 24, fill: '#0f172a' },
};
commit([
...history.present,
{ id, type, x: 160 + offset, y: 150 + offset, ...presets[type] },
]);
setSelectedId(id);
};

const undo = () =>
setHistory((c) =>
c.past.length === 0
? c
: {
past: c.past.slice(0, -1),
present: c.past[c.past.length - 1],
future: [c.present, ...c.future],
}
);

const redo = () =>
setHistory((c) =>
c.future.length === 0
? c
: {
past: [...c.past, c.present],
present: c.future[0],
future: c.future.slice(1),
}
);

// Hide the selection handles so they never appear in the exported file.
const exportPng = () => {
const stage = stageRef.current;
const transformers = stage.find('Transformer');
transformers.forEach((t) => t.hide());
let dataUrl;
try {
dataUrl = stage.toDataURL({ pixelRatio: 2 / scale });
} finally {
transformers.forEach((t) => t.show());
stage.batchDraw();
}
const link = document.createElement('a');
link.download = 'design.png';
link.href = dataUrl;
link.click();
};

const button = {
padding: '6px 12px',
border: '1px solid #cbd5e1',
borderRadius: 6,
background: '#fff',
cursor: 'pointer',
};

return (
<div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
<button style={button} onClick={() => addShape('rect')}>Add rectangle</button>
<button style={button} onClick={() => addShape('circle')}>Add circle</button>
<button style={button} onClick={() => addShape('text')}>Add text</button>
<button style={button} onClick={undo} disabled={history.past.length === 0}>Undo</button>
<button style={button} onClick={redo} disabled={history.future.length === 0}>Redo</button>
<button style={button} onClick={exportPng}>Export PNG</button>
</div>

<div ref={containerRef} style={{ width: '100%', maxWidth: WIDTH }}>
<Stage
ref={stageRef}
width={displayWidth}
height={HEIGHT * scale}
scaleX={scale}
scaleY={scale}
style={{ background: '#f1f5f9', borderRadius: 8 }}
onMouseDown={(e) => {
if (e.target === e.target.getStage()) setSelectedId(null);
}}
onTouchStart={(e) => {
if (e.target === e.target.getStage()) setSelectedId(null);
}}
>
<Layer>
{history.present.map((shape) => (
<EditableShape
key={shape.id}
shape={shape}
selected={shape.id === selectedId}
onSelect={() => setSelectedId(shape.id)}
onCommit={updateShape}
/>
))}
</Layer>
</Stage>
</div>

{/* Canvas pixels mean nothing to a screen reader. Mirror the document in HTML. */}
<p style={{ marginTop: 12, marginBottom: 6 }}>Objects:</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{history.present.map((shape) => (
<button
key={shape.id}
style={{
...button,
borderColor: shape.id === selectedId ? '#2563eb' : '#cbd5e1',
}}
aria-pressed={shape.id === selectedId}
onClick={() => setSelectedId(shape.id)}
>
{shape.id}
</button>
))}
</div>
</div>
);
}

让文档数据与 Konva 节点分离

history.present 数组就是文档。每个对象都有稳定的 ID 和普通的可序列化属性。Konva 节点只是一个交互表面 — 示例在拖动或变换结束时读取它的值,而不是在过程中。

Transformer 通过改变节点的 scale 来缩放。如果你保存这个 scale,文档里就会填满 不断累积的乘数。示例在提交前把 scale 折算回 width、height、radius 或 fontSize,因此 保存的文件保持可读,并且独立于 Konva 内部实现。

正是这种分离让之后的历史记录、协作和服务端渲染成为可能。如果早期做错了,这些都会变得 非常昂贵。

定义历史记录的边界

每个完成的用户操作保存一条记录,而不是每个指针事件一条。把图形拖过画布是步撤销, 不是两百步。

示例在拖动或变换结束时提交。对于文字,在用户确认编辑时提交;对于颜色控件,在控件关闭时 提交。相关示例:画布撤销与重做

处理坐标与导出

这个编辑器没有相机变换,因此文档坐标与舞台坐标一致。一旦添加平移或缩放,你就必须用舞台 变换的逆矩阵转换指针位置 — 参见 相对于指针缩放无限画布

预览会缩放到容器大小,因此导出时用该缩放值去除 pixelRatio,无论屏幕大小都能得到稳定的 1520 × 840 图像。它还会先隐藏 Transformer,因此选择手柄绝不会进入文件。

来自其他源的图片需要正确的 CORS 响应头,否则浏览器会污染画布并阻止导出。参见 高质量导出

添加无障碍界面

Canvas 是单个元素。辅助技术看不到其中的对象、选择状态或结构。用户能用指针做的每件事, 都需要一个 HTML 等价物。

示例把文档镜像成一组按钮。生产级编辑器还需要键盘移动、合理的焦点顺序,以及选择变化时的 播报。把文字编辑保留在真正的 inputtextarea 中 — 原生输入框提供 canvas 无法提供的 选择、光标移动和输入法支持。参见可编辑文字

为生产规模做规划

当文档包含很多对象时,使用归一化的实体映射,对组件做记忆化,并只重新渲染发生变化的部分。 绝不要把图片数据放进历史记录数组 — 存储资源引用,并在文档模型之外管理该资源。

示例省略了持久化、复制粘贴、编组、对齐和并发编辑。在构建界面之前,先把每一项加入 文档模型。

自建还是集成

上面的示例是一个真正的编辑器,同时也如实衡量了还有多远的路要走。Konva 提供渲染、命中 测试、事件和 Transformer。产品级设计编辑器还需要:

  • 文字引擎,支持重排、逐字符样式和 Web 字体度量
  • 字体加载与回退,并且在导出时仍然一致
  • 模板、多页面和素材管理
  • 能对操作分组并在刷新后保留的历史记录
  • 导出流水线,包括超出浏览器画布尺寸上限的尺寸和适合印刷的输出

这份清单通常需要数月工作,而且其中大部分并不是 canvas 工作。

当编辑器就是你的产品、当你的文档模型不同寻常、或者当你需要完全控制输出时,就自己构建。 当编辑器只是支撑你真正销售的东西时,就集成一个 SDK。Polotno 覆盖了上面的清单;它是付费 软件,并且对文档模型有自己的主张,因此在决定采用前请阅读它的文档。

如果你读到这里仍然想自己构建,上面的示例就是正确的起点。继续阅读 变换器对象吸附自由绘制