普通视图
[开源] 从零到一打造在线 PPT 编辑器:React + Zustand + Zundo
![]()
![]()
技术栈
- React 框架
- TS 语言
- Umi 脚手架
- Zustand 状态管理
- Zundo 状态回滚记录
- Svg 图形
- pptxgenjs导出为PPT,还没做
- Ant Design UI框架
核心设计与实现
这个在线 PPT 编辑器的核心可以拆解为几个关键模块:数据模型、画布渲染器、状态管理器和属性面板
1. 数据模型:JSON
首先,我们需要定义数据结构。整个演示文稿、每一页幻灯片以及幻灯片上的每一个元素(文本、形状、图片等)都应该被结构化、序列化为 JSON 对象
// 单个元素(文本、形状、图片等)
export interface PPTElement {
id: string;
type: 'text' | 'image' | 'shape' | 'line';
left: number;
top: number;
width: number;
height: number;
rotate?: number;
content: string; // 文本内容、图片 URL、或形状类型
style?: {
// ... 样式属性
};
}
// 单张幻灯片
export interface Slide {
id: string;
elements: PPTElement[];
background?: {
color?: string;
image?: string;
};
}
// 整个演示文稿
export interface Presentation {
title: string;
slides: Slide[];
}
这种设计使得状态的读取、更新和持久化都变得非常直观
2. 状态管理:Zustand + Zundo
状态管理是编辑器的灵魂。我使用 Zustand 创建了一个 presentationStore 来统一管理所有的状态和操作。
Zundo 的集成异常简单,只需将你的状态创建函数包裹在 zundo 中间件里即可。
import { create } from 'zustand';
import { temporal } from 'zundo'; // 引入 zundo
export const usePresentationStore = create(
temporal( // 使用 temporal 中间件包裹
(set, get) => ({
slides: initialSlides, // 初始幻灯片数据
selectedSlideId: initialSlides[0].id,
selectedElementIds: [],
// 添加元素
addElement: (element) => {
// ...
},
// 更新元素
updateElement: (id, patch) => {
// ...
},
// ... 其他所有操作 slides 的方法
}),
{
// Zundo 配置项
limit: 50, // 最多记录 50 步历史
}
)
);
现在,presentationStore 自动拥有了撤销 (undo) 和重做 (redo) 的能力。我们只需要在组件中调用它们:
import { useTemporalStore } from '@/stores/presentationStore';
const CanvasHeader = () => {
const { undo, redo } = useTemporalStore.temporal.getState();
return (
<div>
<Tooltip title="撤销">
<Button icon={<UndoOutlined />} onClick={() => undo()} />
</Tooltip>
<Tooltip title="重做">
<Button icon={<RedoOutlined />} onClick={() => redo()} />
</Tooltip>
{/* ... */}
</div>
);
};
复杂的状态历史追溯功能被 Zundo 优雅地解决了,真香!
3. 画布与渲染器
画布是用户与 PPT 交互的核心区域。它负责根据当前 Slide 的数据,渲染出所有元素。
我创建了一个 ElementRenderer 组件,它会根据元素的 type 字段,动态地渲染出不同的子组件(如 TextElement、ImageElement 等)。
const ElementRenderer = ({ element }: { element: PPTElement }) => {
switch (element.type) {
case 'text':
return <div style={...}>{element.content}</div>;
case 'image':
return <img src={element.content} style={...} />;
case 'shape':
return <ShapeElement type={element.content} style={...} />;
default:
return null;
}
};
缩略图列表的实现:我没有为缩略图单独编写一套渲染逻辑,而是直接复用了 渲染器组件,只是通过 props 传入一个缩放比例 scale,并禁用其交互
const SlideThumbnail = ({ slide, size }) => {
const scale = size.width / 1920; // 假设画布标准宽度为 1920
return (
<div style={{ width: size.width, height: size.height }}>
<Canvas
slide={slide}
scale={scale}
interactive={false} // 禁用交互
embedded={true} // 嵌入模式
/>
</div>
);
};
4. 属性面板:响应式交互
当用户选中一个元素时,右侧的属性面板会显示其对应的可编辑属性(如颜色、字体大小、位置等)
这里的逻辑是:
- 监听
presentationStore中的selectedElementIds - 当选中元素变化时,从
slides数据中找到该元素的详细信息 - 将元素属性绑定到属性面板的输入框中
- 当用户修改输入框时,调用
store中的updateElement方法来更新状态
数据驱动视图的理念
未来路线图 (Roadmap)
这个项目还有很大的想象空间,我计划在未来加入更多的功能:
-
PPT 导出:支持将编辑好的内容导出为
.pptx文件 - 完备的快捷键:增加更多快捷键
- 更多的属性配置:支持配置多种多样的样式
- 元素对齐与分布:提供辅助线、元素吸附、水平/垂直分布等高级编辑功能
- 动画效果:为元素添加入场、退场动画
- 主题与模板:内置更多精美的设计模板
- 多人实时协作:这是最具挑战性的功能,也是在线文档的终极形态
写在最后
这个项目目前还处于早期阶段,有很多不完善之处。非常欢迎大家提出宝贵的建议、报告 Bug,甚至参与到开发中来。勿喷! Star!!!!
Canvas 如何渲染富文本、图片、SVG 及其 Path 路径?
在前端开发中,我们习惯于使用 HTML 标签(如 <img>, <div>, <svg>)来声明我们想要显示的内容,然后由浏览器负责布局和渲染
但 <canvas> 元素截然不同
Canvas 提供的是一个“即时模式”的 2D (或 3D) 绘图 API。它是一块空白的位图画布,你通过 JavaScript 发出绘图指令(如“画个圈”、“填充颜色”),它就立即执行。它不会记住你画了什么对象;一旦像素被涂上,它就只是一堆像素
1. 渲染图片
drawImage() 方法可以接受多种图像源,包括 <img> 元素、另一个 <canvas> 元素、<video> 的当前帧或 ImageBitmap。
渲染流程:
-
等待数据就绪: JavaScript 加载图片是异步的。在
img.onload事件触发后,确保图片数据(像素)已经完全加载到内存中 -
执行
drawImage:-
ctx.drawImage(image, dx, dy): 将源图像的像素数据,原封不动地“复制”到 Canvas 画布的(dx, dy)位置。 -
ctx.drawImage(image, dx, dy, dWidth, dHeight): 在复制前,先对源图像的像素进行缩放(拉伸或压缩)到dWidthxdHeight大小,然后再绘制。 -
ctx.drawImage(image, sx, sy, sWidth, sHeight, ...): 这是最复杂的形式,它先从源图像上“裁剪”出一块矩形区域,然后(可选)缩放,最后“粘贴”到画布上。
-
// 示例:加载并绘制一张图片
const img = new Image();
img.src = 'path/to/image.png';
// 必须等待图片解码完成
img.onload = () => {
// 将图片像素“印”在 (10, 10) 坐标
ctx.drawImage(img, 10, 10);
};
2. 渲染富文本
Canvas 提供了两个基本方法来绘制文本:ctx.fillText() 和 ctx.strokeText()
例如:<span>Hello <b>World</b></span> 的混合样式和自动换行
Canvas 原生 API 并不支持这些
Canvas 的 fillText 命令只是将一个纯文本字符串,根据当前的 ctx.font、ctx.fillStyle 等样式,对这些形状执行一次填充操作
如何渲染真正的“富文本”?
-
自动换行:
- 将长文本分割成单词
- 逐个单词测量其宽度 (
ctx.measureText(word).width) - 如果当前行宽度超过了设定的最大宽度,你就必须手动增加
y坐标(换行),然后从新行开始绘制
-
混合样式(例如
Hello <b>World</b>):ctx.font = '16px Arial';ctx.fillText('Hello ', x, y);- 计算 "Hello " 的宽度:
const w = ctx.measureText('Hello ').width; -
更改状态:
ctx.font = 'bold 16px Arial'; -
在后面继续绘制:
ctx.fillText('World', x + w, y);
3. 渲染 SVG
SVG(可缩放矢量图形)和 Canvas 在某种理解下可以是对立的
- SVG 是“声明式”: 用 XML 描述一个“场景”(例如,这里有个圆,那里有条线)。浏览器会记住这些对象
- Canvas 是“命令式”: 发出“画圆”的指令,它画完就忘了
因此,Canvas 不能直接渲染 SVG 字符串,你必须在两者之间进行“翻译”
方式一:作为图片“光栅化”(最常用)
这是最简单的方法:把 SVG 当作一张普通的 <img> 图片来处理
-
将 SVG 转换为图像源:
-
如果是
.svg文件:img.src = 'icon.svg'; -
如果是 SVG 字符串:
const svgString = '...';
const svgDataUri = 'data:image/svg+xml;base64,' + btoa(svgString);
img.src = svgDataUri;
-
-
等待加载:
img.onload = () => { ... } -
绘制图像:
ctx.drawImage(img, dx, dy, width, height);
在 drawImage 被调用的那一刻,SVG 的矢量特性就丢失了。它被“光栅化”成了指定 width 和 height 的像素。如果你在 Canvas 上放大它,它会像普通图片一样变模糊
方式二:作为矢量“解析转译”(复杂)
这个方法会保留矢量特性
-
解析 SVG 的 XML 结构
-
遍历 SVG 节点(如
<rect>,<circle>,<path>) -
将每个 SVG 节点的属性翻译成等效的 Canvas API 调用
-
<rect x="10" ...>->ctx.rect(10, ...) - 等等
-
4. 渲染 SVG 中的 Path 路径
SVG 的 <path> 元素使用 d 属性来定义极其复杂的形状,例如:
<path d="M10 10 L100 100 C150 150 200 150 250 100 Z">
M 代表 moveTo(移动到),L 代表 lineTo(画线到),C 代表贝塞尔曲线,Z 代表闭合路径
Canvas 的 ctx.lineTo 等 API 无法直接读取这个字符串,我们如何“翻译”它?
现代方案:Path2D 对象
现代浏览器提供了一个强大的 Path2D 对象,它就是为了解决这个问题而生的。Path2D 构造函数可以直接接收 SVG 的 d 属性字符串
-
创建
Path2D对象:JavaScript
const svgPathData = "M10 10 L100 100 C150 150 200 150 250 100 Z"; const myPath = new Path2D(svgPathData); -
渲染 Path2D 对象:
一旦你有了 myPath 这个对象,Canvas 就可以直接使用它。这个对象已经“预编译”了所有路径指令
JavaScript
ctx.strokeStyle = 'blue'; ctx.lineWidth = 3; ctx.stroke(myPath); // 描边这个路径 ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'; ctx.fill(myPath); // 填充这个路径
Path2D 的优势:
- 简洁: 无需手动解析字符串。
- 高效: 浏览器底层负责解析和优化路径。
-
可重用: 你可以创建一次
Path2D对象,然后在不同的位置、以不同的样式反复绘制它,而无需重新定义路径
传统方案:手动解析(库的实现原理)
在 Path2D 出现之前,或者当需要支持旧版浏览器时,唯一的办法就是手动解析这个 d 字符串。
你需要写一个解析器,逐个读取命令 (M, L, C...) 和坐标,然后调用等效的 Canvas API:
JavaScript
function parseAndDraw(svgPathData) {
// 这是一个极度简化的示例
const commands = svgPathData.split(' '); // 简化的分割
ctx.beginPath(); // **开始 Canvas 路径**
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
if (cmd === 'M') {
const x = parseFloat(commands[++i]);
const y = parseFloat(commands[++i]);
ctx.moveTo(x, y); // 等效 M
} else if (cmd === 'L') {
const x = parseFloat(commands[++i]);
const y = parseFloat(commands[++i]);
ctx.lineTo(x, y); // 等效 L
} else if (cmd === 'Z') {
ctx.closePath(); // 等效 Z
}
// ... 还需要处理 C, S, Q, T, A 等等...
}
ctx.stroke(); // **渲染 Canvas 路径**
}
总结
Canvas 本身只是一个低级的“像素绘图仪”。它之所以能渲染高级内容,完全依赖于将这些内容“翻译”成它能理解的指令:
-
渲染图片: 翻译成
drawImage(像素块传输)。 -
渲染富文本: 翻译成
fillText(字形绘制),但布局和换行需要 JavaScript 手动计算。 -
渲染 SVG: 翻译成
drawImage(光栅化)或者解析其所有节点。 -
渲染 SVG Path: 最佳翻译是使用
new Path2D(d)将其转换成 Canvas 原生路径对象,然后再stroke或fill。
理解了这层“翻译”关系,就掌握了 Canvas 渲染万物的核心
如何设计一个 Canvas 事件系统?
HTML5 Canvas 是一个强大的绘图工具,它允许我们在网页上绘制复杂的图形、动画和游戏。然而,Canvas 与 DOM 或 SVG 不同,它本质上是一个“哑”画布,它只是一个像素缓冲区。你画上去的矩形、圆形或文本,在 Canvas 看来都只是像素点,它本身并不“知道”那里有一个“对象”。
这就带来了一个核心挑战:Canvas 元素本身只能监听到整个画布的鼠标事件(如 click, mousemove),但它无法告诉你用户具体点击了哪个图形。
要创建丰富的交互式体验(如拖拽图形、点击按钮、悬停提示),我们必须自己构建一个事件系统。这个系统需要在 Canvas 的像素之上,抽象出一个“对象”层,并管理这些对象上的事件。
1. 从“坐标”到“对象”
我们的目标是实现类似 DOM 的事件绑定:
JavaScript
// 我们想要这个
const myRect = new Rect({ x: 10, y: 10, width: 50, height: 50 });
stage.add(myRect);
myRect.on('click', (event) => {
console.log('矩形被点击了!');
});
// 而不是这个
canvas.addEventListener('click', (e) => {
const x = e.offsetX;
const y = e.offsetY;
// 手动检查 x, y 是否在 myRect 的范围内...
// 如果有 1000 个图形怎么办?
});
要实现这一目标,我们的事件系统必须解决三个核心问题:
- 场景管理:如何跟踪画布上所有“对象”(图形)的位置和状态?
- 命中检测:当鼠标在 坐标点发生事件时,如何快速判断哪个图形被“击中”了?
- 事件分发:当一个图形被击中时,如何触发它上面绑定的回调函数,并模拟事件冒泡等行为?
2. 系统架构
一个完整的 Canvas 事件系统通常包含以下几个核心组件:
2.1 场景图(Scene Graph)或显示列表(Display List)
这是我们管理所有图形的“数据库”。最简单的是一个数组(显示列表),按照绘制顺序存储所有图形对象。
JavaScript
// 简单的显示列表
const children = [shape1, shape2, shape3];
更高级的实现是一个树状结构(场景图),允许图形分组(Group),这对于实现事件冒泡和复杂的坐标变换至关重要。
每个图形对象(Shape)至少应包含:
- 位置和尺寸属性(
x,y,width,height,radius等)。 - 一个
draw(ctx)方法,用于在 Canvas 上下文中绘制自己。 - 一个
isPointInside(x, y)方法,用于命中检测。
2.2 原生事件侦听器(Native Event Listener)
这是系统的入口。我们需要在 <canvas> 元素上绑定原生的浏览器事件。
JavaScript
class Stage {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.children = []; // 我们的显示列表
this._initListeners();
}
_initListeners() {
// 监听我们关心的所有事件
this.canvas.addEventListener('click', this._handleEvent.bind(this));
this.canvas.addEventListener('mousemove', this._handleEvent.bind(this));
this.canvas.addEventListener('mousedown', this._handleEvent.bind(this));
this.canvas.addEventListener('mouseup', this._handleEvent.bind(this));
// 还可以包括 touchstart, touchend, touchmove 等
}
_handleEvent(e) {
// 统一处理所有事件
// ...
}
}
2.3 命中检测(Hit Detection)
这是事件系统的核心算法。当 _handleEvent 被触发时,我们需要获取鼠标坐标,然后遍历我们的显示列表,找出哪个图形被“击中”了。
关键点:Z-Index(堆叠顺序)
Canvas 是“后来者居上”的。后绘制的图形会覆盖先绘制的。因此,当事件发生时,我们应该优先检测最上层(最后绘制)的图形。
这意味着我们应该反向遍历显示列表:
JavaScript
_handleEvent(e) {
const x = e.offsetX;
const y = e.offsetY;
const eventType = e.type; // 'click', 'mousemove' 等
let targetShape = null;
// 从后向前遍历(最上层的图形最先被检测)
for (let i = this.children.length - 1; i >= 0; i--) {
const shape = this.children[i];
if (shape.isPointInside(x, y)) {
targetShape = shape;
// 找到了!停止遍历
break;
}
}
if (targetShape) {
// 找到了目标,现在分发事件
this._dispatchEvent(targetShape, eventType, e);
}
}
命中检测的策略
isPointInside(x, y) 方法的实现方式各不相同:
-
几何算法(AABB)
-
矩形(Axis-Aligned Bounding Box, AABB) :最简单的。
JavaScript
isPointInside(x, y) { return x >= this.x && x <= this.x + this.width && y >= this.y && y <= this.y + this.height; } -
圆形:计算点到圆心的距离。
JavaScript
isPointInside(x, y) { const dx = x - this.x; // this.x, this.y 是圆心 const dy = y - this.y; return (dx * dx + dy * dy) <= (this.radius * this.radius); } -
多边形:通常使用 射线投射算法(Ray-casting Algorithm)。
-
-
Canvas API 路径检测(isPointInPath)
Canvas 2D 上下文提供了一个强大的方法:isPointInPath(x, y) 和 isPointInStroke(x, y)。
它允许你“重播”一个图形的绘制路径,然后询问浏览器某个点是否在该路径的内部或描边上。
JavaScript
isPointInside(x, y) { // 必须在传入的 context 上下文中重绘路径 // 注意:这里我们不能真的 "draw",只是 "build path" this.ctx.beginPath(); this.ctx.rect(this.x, this.y, this.width, this.height); // ... 或者 arc, moveTo, lineTo 等 this.ctx.closePath(); return this.ctx.isPointInPath(x, y); }- 优点:极其精确,能处理任何复杂的路径(贝塞尔曲线、不规则多边形等),甚至可以检测描边。
- 缺点:可能存在性能开销,因为它需要重新构建路径(尽管不实际渲染像素)。
性能提示:在大型场景中(数千个对象),
mousemove事件上的命中检测开销巨大。一个常见的优化是:
先进行快速的**包围盒(Bounding Box)**检测。
如果包围盒命中,再进行精确的(如
isPointInPath)检测。对于非常大的场景,使用**空间索引(如 Quadtree 四叉树)**来快速剔除不在鼠标附近的
对象,避免遍历整个列表。
2.4 事件分发器(Event Dispatcher)
当我们通过命中检测找到了 targetShape 后,需要一种机制来“触发”该图形上的自定义事件。这通常通过在 Shape 基类上实现一个简单的“发布-订阅”模式来完成。
JavaScript
// 在 Shape 基类中添加
class Shape {
constructor() {
this._listeners = {}; // 存储事件回调
}
// 订阅事件
on(eventType, callback) {
if (!this._listeners[eventType]) {
this._listeners[eventType] = [];
}
this._listeners[eventType].push(callback);
}
// 发布事件
fire(eventType, eventObject) {
const callbacks = this._listeners[eventType];
if (callbacks) {
callbacks.forEach(callback => {
callback(eventObject);
});
}
}
}
现在,我们完善 Stage 类中的 _dispatchEvent 方法:
JavaScript
// 在 Stage 类中
_dispatchEvent(targetShape, eventType, nativeEvent) {
// 我们可以创建一个自定义的事件对象,封装更多信息
const customEvent = {
target: targetShape, // 触发事件的原始图形
nativeEvent: nativeEvent, // 原始浏览器事件
x: nativeEvent.offsetX,
y: nativeEvent.offsetY,
// ... 其他需要的信息
};
// 直接在目标图形上触发事件
targetShape.fire(eventType, customEvent);
}
3. 进阶功能
一个基础的事件系统已经成型,但要实现如 DOM 般强大的交互,我们还需要更多功能。
3.1 事件冒泡(Event Bubbling)
在 DOM 中,点击一个子元素,事件会向上传播到父元素。如果我们的场景图(Scene Graph)是一个树状结构(Group 包含 Shape),我们也应该模拟这个行为。
当 targetShape 被击中时,我们不仅要 targetShape.fire(),还应该沿着它的 parent 链向上,依次触发父级的事件,直到根节点(Stage)或者事件被停止。
JavaScript
// 在 _dispatchEvent 中
_dispatchEvent(targetShape, eventType, nativeEvent) {
const customEvent = {
target: targetShape,
nativeEvent: nativeEvent,
// ...
_stopped: false, // 冒泡停止标记
stopPropagation: function() {
this._stopped = true;
}
};
let currentTarget = targetShape;
while (currentTarget && !customEvent._stopped) {
// 触发当前目标上的事件
currentTarget.fire(eventType, customEvent);
// 移动到父级
currentTarget = currentTarget.parent;
}
}
3.2 拖拽事件(Drag and Drop)
拖拽不是一个单一事件,而是一个事件序列:mousedown -> mousemove -> mouseup。
我们需要在 Stage 层面管理拖拽状态:
JavaScript
// 在 Stage 类中添加
_initListeners() {
// ... 其他事件
this.canvas.addEventListener('mousedown', this._onMouseDown.bind(this));
this.canvas.addEventListener('mousemove', this._onMouseMove.bind(this));
this.canvas.addEventListener('mouseup', this._onMouseUp.bind(this));
this._draggingTarget = null; // 当前正在拖拽的对象
this._dragStartPos = { x: 0, y: 0 }; // 拖拽起始位置
}
_onMouseDown(e) {
const target = this._findHitTarget(e.offsetX, e.offsetY);
if (target) {
// 检查图形是否可拖拽 (e.g., shape.draggable = true)
if (target.draggable) {
this._draggingTarget = target;
this._dragStartPos.x = e.offsetX - target.x;
this._dragStartPos.y = e.offsetY - target.y;
// 分发 'dragstart' 事件
this._dispatchEvent(target, 'dragstart', e);
}
// 分发 'mousedown' 事件
this._dispatchEvent(target, 'mousedown', e);
}
}
_onMouseMove(e) {
if (this._draggingTarget) {
// 如果正在拖拽
const target = this._draggingTarget;
target.x = e.offsetX - this._dragStartPos.x;
target.y = e.offsetY - this._dragStartPos.y;
// 分发 'drag' 事件
this._dispatchEvent(target, 'drag', e);
// 拖拽时需要重绘画布
this.render();
} else {
// 正常的 mousemove 命中检测
// ...
}
}
_onMouseUp(e) {
if (this._draggingTarget) {
// 分发 'dragend' 事件
this._dispatchEvent(this._draggingTarget, 'dragend', e);
this._draggingTarget = null; // 停止拖拽
}
// 正常的 'mouseup' 命中检测
const target = this._findHitTarget(e.offsetX, e.offsetY);
if (target) {
this._dispatchEvent(target, 'mouseup', e);
}
// 触发 click 事件的逻辑也可以在这里处理
}
3.3 mouseenter 和 mouseleave 事件
这两个事件比 mousemove 更复杂,因为它们需要状态。你需要跟踪上一帧鼠标悬停在哪个对象上。
JavaScript
// 在 Stage 类中添加
_lastHoveredTarget = null;
_onMouseMove(e) {
// ... 拖拽逻辑优先 ...
const currentHoveredTarget = this._findHitTarget(e.offsetX, e.offsetY);
if (this._lastHoveredTarget !== currentHoveredTarget) {
// 鼠标移出了上一个目标
if (this._lastHoveredTarget) {
this._dispatchEvent(this._lastHoveredTarget, 'mouseleave', e);
}
// 鼠标移入了新目标
if (currentHoveredTarget) {
this._dispatchEvent(currentHoveredTarget, 'mouseenter', e);
}
// 更新状态
this._lastHoveredTarget = currentHoveredTarget;
}
// 始终分发 mousemove
if (currentHoveredTarget) {
this._dispatchEvent(currentHoveredTarget, 'mousemove', e);
}
}