22 KiB
Excalidraw vs Aitu(开图)深度对比分析报告
一、总体架构对比
1.1 包结构与模块划分
| 维度 | Excalidraw | Aitu(开图) |
|---|---|---|
| Monorepo 工具 | Yarn Workspaces | Nx + pnpm Workspaces |
| 核心包数量 | 5 个分层包 | 3 个包 + 1 个应用 |
| 分层策略 | 按职责分层(math→element→excalidraw) | 按功能聚合(drawnix 大包 + 插件系统) |
| npm 发布 | @excalidraw/excalidraw 对外发布 |
内部使用,不发布 |
Excalidraw 包依赖链:
@excalidraw/common (基础工具/常量/类型)
↑
@excalidraw/math (2D 数学库:点/向量/线段/曲线/多边形)
↑
@excalidraw/element (元素逻辑:创建/变换/碰撞/绑定/frame)
↑
@excalidraw/excalidraw (主 React 组件:App/actions/renderer/fonts)
↑
excalidraw-app (Web 应用层:协作/Firebase/分享)
Aitu 包结构:
packages/drawnix (核心白板库:components/plugins/services/hooks/engine)
packages/react-board (React Board 封装,基于 @plait/core)
packages/react-text (文本渲染组件)
apps/web (Web 应用 + Service Worker)
可借鉴点: Excalidraw 的 @excalidraw/math 和 @excalidraw/element 拆分非常干净,math 包不依赖 React,element 包不依赖 UI,便于测试和复用。Aitu 可考虑将数学运算和元素逻辑从 drawnix 中抽离,减轻核心包的体积。
1.2 渲染架构
| 维度 | Excalidraw | Aitu |
|---|---|---|
| 渲染技术 | 双 Canvas(Static + Interactive) | SVG(@plait/core) |
| 静态层 | Canvas2D 绘制所有元素 | SVG DOM 元素 |
| 交互层 | Canvas2D 绘制选区/变换手柄/吸附线 | SVG + React 组件 |
| 性能特点 | Canvas 逐帧重绘,适合大量元素 | DOM 增量更新,灵活但节点多时性能下降 |
| 文本渲染 | Canvas 文本测量 + WYSIWYG HTML textarea | Slate.js 富文本 |
Excalidraw 双 Canvas 架构:
Renderer.ts调度类使用requestAnimationFrame+ dirty flag 控制重绘频率staticScene.ts:绘制所有元素的最终视觉效果(填充、描边、文字)interactiveScene.ts:绘制选区框、变换手柄、吸附辅助线、协作者光标
影响: 涉及渲染层的功能(吸附参考线、套索动画、激光笔)需要适配 SVG 方案。SVG 的好处是可以直接使用 <path> + <animate> 实现动画效果。
1.3 状态管理
| 维度 | Excalidraw | Aitu |
|---|---|---|
| UI 状态 | Jotai atom + jotai-scope 隔离 |
React Context(6 个 Context) |
| 服务状态 | Jotai atom + 独立 store(非 React 可访问) | RxJS Subject/Observable |
| 多实例 | createIsolation() 支持 |
单实例设计 |
| 跨组件通信 | Jotai atom 订阅 | Context + RxJS |
Excalidraw 的 Jotai 模式:
// editor-jotai.ts — 隔离的 Jotai 上下文
const { useAtom, useSetAtom, useAtomValue, useStore, Provider } = createIsolation();
export const editorJotaiStore = createStore(); // 非 React 代码也能访问
可借鉴点: Aitu 的 Context 模式在组件层级浅时工作良好,但跨组件深层传递时不够便捷。如果未来需要支持多画板实例,可参考 Jotai 隔离方案。
1.4 命令/动作系统
| 维度 | Excalidraw | Aitu |
|---|---|---|
| 模式 | ActionManager 注册表(命令模式) | withXxx 插件模式 |
| 命令数 | 70+ Action | 27 个插件 |
| 执行入口 | 键盘/UI/右键菜单/命令面板/API | 键盘快捷键/工具栏按钮 |
| 统一返回 | ActionResult { elements, appState, files } |
直接操作 board 对象 |
| 可发现性 | 命令面板可搜索所有命令 | 无命令面板 |
Excalidraw Action 示例:
const action: Action = {
name: "alignTop",
label: "Align top",
icon: AlignTopIcon,
keyTest: (event) => event.altKey && event.code === "KeyW",
predicate: (elements, appState) => getSelectedElements(elements).length > 1,
perform: (elements, appState) => ({ elements: alignTop(elements), appState }),
PanelComponent: ({ updateData }) => <Button onClick={() => updateData(null)} />,
trackEvent: { category: "element" },
};
可借鉴点: Action 系统将命令的定义(label/icon/shortcut/predicate)与执行(perform)统一封装,命令面板和工具栏都能复用。Aitu 的 with-hotkey.ts 将快捷键硬编码在一个文件中,扩展性较弱。
二、功能对比矩阵
2.1 Excalidraw 有而 Aitu 无的功能
| 功能 | 实现复杂度 | 用户价值 | 优先级 | 与 Aitu 兼容性 |
|---|---|---|---|---|
| 命令面板 | ★★☆ | ★★★ | 高 | 高(纯 UI 组件) |
| 画布元素搜索 | ★★☆ | ★★★ | 高 | 高(遍历 @plait 元素) |
| 智能吸附/对齐参考线 | ★★★ | ★★★ | 高 | 中(需适配 SVG 渲染线条) |
| 套索选择 | ★★★ | ★★☆ | 高 | 中(需适配 SVG 动画路径) |
| Frame 容器 | ★★★ | ★★☆ | 中 | 低(需 @plait 框架支持) |
| 肘形箭头 | ★★★★ | ★★☆ | 中 | 低(需 A* 寻路 + @plait 扩展) |
| 流程图快速创建 | ★★☆ | ★★☆ | 中 | 中(依赖肘形箭头) |
| 激光笔 | ★★☆ | ★☆☆ | 中 | 高(SVG path 动画) |
| 标签页同步 | ★☆☆ | ★★☆ | 中 | 高(localStorage 事件) |
| 实时多人协作 | ★★★★★ | ★★★ | 低 | 低(需后端 + CRDT/OT) |
| 图表粘贴 | ★★☆ | ★☆☆ | 低 | 中(TSV 解析 + 元素创建) |
| 字体子集化 | ★★★ | ★☆☆ | 低 | 高(WASM Worker 独立) |
| 跟随模式 | ★★☆ | ★☆☆ | 低 | 低(依赖实时协作) |
2.2 Aitu 独有优势
| 功能 | 说明 |
|---|---|
| AI 图片/视频生成 | 多模型支持、任务队列、工作流引擎 |
| AI Agent + MCP 工具 | 系统提示词、工具解析、11 个内置 MCP 工具 |
| 矢量钢笔工具 | 贝塞尔曲线精确编辑 |
| 布尔运算 | 合并、减去、相交、排除 |
| 渐变填充/文本特效 | 渐变编辑器、渐变文字、阴影 |
| 精确橡皮擦 | 基于布尔运算的路径级精确擦除 |
| GitHub Gist 云同步 | 加密分片同步(AES-256-GCM) |
| 工作区管理 | 文件夹/画板树形结构 |
| 备份恢复系统 | ZIP 格式完整备份 + 崩溃恢复 |
| 照片墙布局 | 网格/创意照片墙自动排版 |
| 视频合并 | WebCodecs 视频合并 |
| 小地图 | 智能显示/隐藏、画布导航 |
三、高优先级功能实现方案
3.1 命令面板(Command Palette)
Excalidraw 实现要点:
- 数据结构:
CommandPaletteItem { label, keywords, haystack, icon, category, predicate, shortcut, perform } - 命令来源:ActionManager 批量转换 + 手动额外命令 + 外部自定义命令 + 库项目动态命令
- 搜索算法:
fuzzy库模糊匹配,deburr去变音符号,haystack = deburr(label) + keywords - 分类排序:
DEFAULT_CATEGORIES = { app:1, export:2, editor:3, tools:4, elements:5, links:6 } - 键盘导航:Arrow Up/Down 循环,Enter 执行,字母键自动聚焦搜索框
- lastUsed 记忆:Jotai atom 持久化最近使用的命令
Aitu 融合建议:
// 1. 定义命令接口
interface CommandItem {
id: string;
label: string; // 中英文都需要
keywords?: string[]; // 额外搜索词
icon?: React.ReactNode;
category: 'tool' | 'edit' | 'view' | 'ai' | 'export' | 'settings';
shortcut?: string;
predicate?: (board: PlaitBoard) => boolean;
perform: (board: PlaitBoard) => void;
}
// 2. 命令注册表(替代分散的 with-hotkey)
class CommandRegistry {
private commands = new Map<string, CommandItem>();
register(command: CommandItem): void;
getAll(): CommandItem[];
search(query: string): CommandItem[]; // fuzzy 搜索
execute(id: string, board: PlaitBoard): void;
}
// 3. UI 组件使用 TDesign Dialog + 自定义搜索列表
// 触发方式:Ctrl+K / Cmd+K
实现复杂度:~500 行代码,1-2 天
关键依赖:fuzzy 或 fuse.js 模糊搜索库
3.2 画布元素搜索(Search Menu)
Excalidraw 实现要点:
- 搜索目标:文本元素的
originalText+ Frame 元素的name - 搜索算法:正则匹配(
new RegExp(escaped, "gi")),大小写不敏感 - 防抖:350ms debounce
- 精确定位:
measureText()计算匹配文字在元素内的像素偏移,生成matchedLines含{ offsetX, offsetY, width, height } - 自动滚动:
scrollToContent(matchElement, { animate: true, duration: 300 }) - 小字放大:当
fontSize * zoom < 14px时自动放大到可读级别 - nonce 机制:防止过期搜索结果覆盖新结果
Aitu 融合建议:
// 1. 搜索 @plait 元素中的文本内容
const searchElements = (query: string, board: PlaitBoard) => {
const regex = new RegExp(escapeRegex(query), 'gi');
const results: SearchMatch[] = [];
// 遍历 board.children,递归检查文本属性
for (const element of board.children) {
const text = getElementText(element); // 提取思维导图节点/形状/文本的文字
if (text) {
let match;
while ((match = regex.exec(text)) !== null) {
results.push({ element, index: match.index, text });
}
}
}
return results;
};
// 2. 导航到匹配元素
const scrollToMatch = (match: SearchMatch, board: PlaitBoard) => {
const rect = getElementRect(match.element); // @plait 获取元素边界
board.scrollToPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
};
// 3. UI:Ctrl+F 打开搜索栏(可复用 TDesign Input)
实现复杂度:~400 行代码,1-2 天 难点:文本精确定位需要了解 @plait 的文本渲染坐标系
3.3 智能吸附系统(Snapping)
Excalidraw 实现要点:
核心数据结构:
type PointSnap = {
type: "point";
points: [GlobalPoint, GlobalPoint]; // [被吸附点, 参考点]
offset: number; // 需要移动的距离
};
type Gap = {
startBounds: Bounds; // 起始元素边界
endBounds: Bounds; // 终止元素边界
overlap: InclusiveRange; // 垂直方向重叠范围
length: number; // 间隙长度
};
type GapSnap = {
type: "gap";
direction: "center_horizontal" | "side_left" | "side_right" | ...;
gap: Gap;
offset: number;
};
算法流程(双遍计算):
- 第一遍:计算 snapOffset(允许 SNAP_DISTANCE=8px/zoom 的容差)
- 第二遍:用修正后的位置重新精确匹配,生成辅助线
GapSnap 三种模式:
- 居中对齐:被拖元素放在间隙正中
- 右侧等距:被拖元素距右侧元素 = 间隙长度
- 左侧等距:被拖元素距左侧元素 = 间隙长度
性能优化:
SnapCache静态单例缓存参考点和间隙- 参考点仅计算可见且非选中的元素
- 排除绑定到容器的文本元素和单独箭头
Aitu 融合建议:
// 需要在 @plait 的拖拽流程中注入吸附计算
// 1. 在 board.pointerMove 中拦截拖拽偏移
// 2. 计算所有可见元素的角点+中心点
// 3. 找到最近的对齐点,修正偏移量
// 4. 通过 SVG <line> 或 <path> 渲染吸附参考线
// 渲染层适配:使用 SVG overlay 而非 Canvas
const SnapOverlay: React.FC = () => {
const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
return (
<svg className="snap-overlay" style={{ pointerEvents: 'none' }}>
{snapLines.map((line, i) => (
<line key={i} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2}
stroke="#e91e63" strokeDasharray="4 4" />
))}
</svg>
);
};
实现复杂度:~800 行代码,3-5 天 难点:需要深入理解 @plait 的拖拽事件流和坐标系统,GapSnap 的间隙检测算法较复杂
3.4 套索选择(Lasso Selection)
Excalidraw 实现要点:
架构分层:
AnimatedTrail (基类)
├── SVGPathElement 单元素渲染
├── LaserPointer 底层笔画生成
└── requestAnimationFrame 动画循环
LassoTrail extends AnimatedTrail
├── 蚂蚁线效果(stroke-dasharray + <animate>)
├── 双重选择检测
└── 元素线段缓存
选择算法(双重检测):
- AABB 粗筛:
doBoundsIntersect(lassoBounds, elementBounds)— O(1) 排除不相交元素 - 包围测试:
polygonIncludesPointNonZero()— Non-Zero Winding Rule,元素任意端点在套索多边形内即算包围 - 相交测试:
intersectElementWithLineSegment()— 套索路径线段与元素轮廓线段相交
性能优化:
- 路径简化:
simplify(path, 5/zoom)Douglas-Peucker 变体 - 仅检测可见元素
- 元素线段缓存(画布变换改变时重建)
- 增量 Set 管理(避免重复检测)
后处理:
- 文本元素 → 替换为其容器
- Frame 子元素 → 从选中集中移除
- 组选择处理
- Shift 键追加选择
Aitu 融合建议:
// 1. 在 @plait 中添加套索工具模式
// 2. SVG 路径绘制(比 Canvas 更自然)
class PlaitLassoTool {
private pathElement: SVGPathElement;
private points: Point[] = [];
onPointerDown(event: PointerEvent) {
this.points = [{ x: event.x, y: event.y }];
this.createSVGPath();
}
onPointerMove(event: PointerEvent) {
this.points.push({ x: event.x, y: event.y });
this.updateSVGPath();
this.updateSelection();
}
onPointerUp() {
this.finalizeSelection();
this.removeSVGPath();
}
private updateSelection() {
const selected = board.children.filter(element => {
const bounds = getElementBounds(element);
if (!boundsIntersect(this.lassoBounds, bounds)) return false;
return this.enclosureTest(element) || this.intersectionTest(element);
});
// 更新 board 选中状态
}
}
实现复杂度:~600 行代码,3-4 天 难点:多边形包围判定算法(Non-Zero Winding Rule)、与 @plait 选择系统集成
四、中优先级功能实现方案
4.1 Frame 容器系统
核心机制:
- 通过
element.frameId建立扁平的父子关系(非嵌套树) getFrameChildren()按需遍历全量元素获取子元素- 三层碰撞检测:线段相交 / AABB 包含 / 元素包含 Frame
- 拖拽时实时判定绑定/解绑(
updateFrameMembershipOfSelectedElements) - Frame 裁剪:通过
clip-path限制子元素的可见区域 - Group 感知:不允许 Frame 和普通元素混在同一个 Group 中
Aitu 融合难度:高。需要 @plait 框架层面支持 frameId 概念和裁剪渲染。可能需要:
- 扩展
PlaitElement类型添加frameId字段 - 在 @plait 渲染管线中注入 SVG
<clipPath>裁剪 - 拖拽事件中添加 Frame 成员关系更新逻辑
4.2 肘形箭头(Elbow Arrows)
核心算法:A 寻路*
- 非均匀网格:节点位于所有元素 AABB 边界线的交叉点
BinaryHeap<Node>优先队列,f = g + h- 方向变化惩罚:
g += bendMultiplier³(鼓励少弯折) - 障碍物避让:中点落在任何 AABB 内则跳过
FixedSegment:用户可手动调整某些线段位置
Aitu 融合难度:高。需要:
- 实现 A* 寻路算法(~400 行)
- 构建非均匀网格系统
- 与 @plait 的箭头元素系统集成
- 添加元素绑定端点的吸附逻辑
4.3 流程图快速创建
核心机制:
- 方向键映射:
ArrowUp→up, ArrowDown→down, ArrowRight→right, ArrowLeft→left - 智能定位:优先直线放置,冲突时锯齿形分布
- 前驱/后继遍历:通过肘形箭头的绑定关系查找
FlowChartNavigator:键盘在节点图中导航(同层循环 + 跨方向跳转)- Frame 感知:新节点是否在 Frame 内
依赖:肘形箭头功能。先实现肘形箭头,再实现此功能。
4.4 激光笔(Laser Pointer)
核心架构:
LaserTrails(管理器)
├── localTrail: AnimatedTrail
└── collabTrails: Map<SocketId, AnimatedTrail>
AnimatedTrail(单条轨迹)
├── 时间衰减:1s 内渐隐(performance.now() 时间戳)
├── 长度衰减:尾部 50 点渐细(easeOut 缓动)
├── SVG <path> 单元素渲染(所有轨迹合并)
└── AnimationFrameHandler 统一调度
Aitu 融合建议:激光笔是纯前端功能,不依赖协作。可在 Plait SVG 层上叠加一个透明 SVG overlay,通过 requestAnimationFrame 驱动衰减动画。实现复杂度约 200 行。
4.5 标签页同步(Tab Sync)
核心机制:
- 50ms
setInterval轮询 localStorage 中的时间戳版本 isBrowserStorageStateNewer(type):比较 localStorage vs 内存版本- 仅当标签页可见(
!document.hidden)且非协作模式时同步 - 场景更新使用
CaptureUpdateAction.NEVER避免干扰撤销历史 visibilitychange/blur时 flush 保存
Aitu 融合建议:
// 极简实现
const TAB_SYNC_KEY = 'aitu-tab-sync-version';
let localVersion = -1;
// 保存时更新版本
export const markDataSaved = () => {
const v = Date.now();
localStorage.setItem(TAB_SYNC_KEY, String(v));
localVersion = v;
};
// 轮询检测(50ms)
setInterval(() => {
if (document.hidden) return;
const remote = parseInt(localStorage.getItem(TAB_SYNC_KEY) || '-1');
if (remote > localVersion) {
localVersion = remote;
reloadBoardFromStorage(); // 从 IndexedDB 重新加载
}
}, 50);
实现复杂度:~50 行代码,半天
五、低优先级功能实现方案(核心要点)
5.1 实时多人协作
Excalidraw 方案:
- 传输层:Socket.IO(WebSocket + polling 降级)
- 持久化:Firebase Firestore + Storage
- 同步策略:增量版本追踪 + 20s 全量兜底 + 5s 超时 Firebase 降级
- 冲突解决:
versionNonce确定性解冲突(取较小 nonce 者胜出,无需中央仲裁) - 元素排序:Fractional Index(CRDT 思想,避免并发插入冲突)
- 加密:AES-128-GCM,密钥存于 URL hash(端到端加密,服务器不可见)
- 光标同步:33ms volatile 消息
Aitu 现状:GitHub Gist 异步同步,非实时。引入实时协作需要:
- WebSocket 服务端
- 状态同步协议(推荐 Yjs/CRDT 而非自研 reconcile)
- 光标/选区广播
- 冲突解决策略
实现复杂度:非常高(需要后端服务),建议作为独立里程碑规划。
5.2 图表粘贴
Excalidraw 方案:
- TSV 优先 → CSV 降级 → 转置回退
- 正则支持 5 种货币符号的智能数字解析
- 自动推断列角色(标签列 vs 数值列)
- 生成原生元素:矩形(柱形图)/ 线段+圆点(折线图)/ 文本标签 / 坐标轴
Aitu 融合:可监听粘贴事件,检测 TSV 格式数据,将其转为 @plait 元素。约 300 行代码。
5.3 字体子集化
Excalidraw 方案:
- HarfBuzz (子集化) + Woff2 (压缩) 两个 WASM 模块
WorkerPool模式:按需创建 Worker,1s TTL 自动回收- 先复制 ArrayBuffer 再 transfer(保证降级安全)
- 全局
shouldUseWorkers标志,一次失败永久降级
用途:导出 SVG/PDF 时嵌入字体子集,减小文件体积。Aitu 如果需要高质量导出可以引入。
5.4 跟随模式
依赖:实时协作。在协作基础上:
- 被跟随者通过独立 follow 房间广播视口边界(
getVisibleSceneBounds) - 跟随者通过
zoomToFitBounds同步视口 - 交叉跟随检测(A 跟随 B 且 B 跟随 A 时忽略更新,打破死循环)
六、分阶段实施路线图
Phase 1:快速提升(1-2 周)
| 功能 | 预估工时 | 备注 |
|---|---|---|
| 命令面板 | 2 天 | 纯 UI,依赖 fuzzy 库 |
| 画布搜索 | 2 天 | 遍历 @plait 元素 |
| 标签页同步 | 0.5 天 | localStorage 事件 |
| 激光笔 | 1 天 | SVG overlay + rAF |
Phase 2:核心增强(2-4 周)
| 功能 | 预估工时 | 备注 |
|---|---|---|
| 智能吸附 | 5 天 | 需深入 @plait 拖拽流程 |
| 套索选择 | 4 天 | 多边形算法 + SVG 动画 |
| Frame 容器 | 5 天 | 需 @plait 框架层支持 |
Phase 3:进阶功能(1-2 月)
| 功能 | 预估工时 | 备注 |
|---|---|---|
| 肘形箭头 | 10 天 | A* 寻路算法 + 绑定系统 |
| 流程图快速创建 | 3 天 | 依赖肘形箭头 |
| 图表粘贴 | 2 天 | TSV 解析 + 元素创建 |
| 字体子集化 | 5 天 | WASM 集成 |
Phase 4:协作能力(需独立规划)
| 功能 | 预估工时 | 备注 |
|---|---|---|
| 实时协作基础 | 20+ 天 | 需后端服务 + Yjs/CRDT |
| 跟随模式 | 2 天 | 依赖实时协作 |
七、架构借鉴建议
7.1 引入 Action 注册表模式
将当前分散在 with-hotkey.ts 中的快捷键和工具栏中的操作统一为 Action 对象,便于命令面板搜索和快捷键管理。
7.2 建立数学工具库
参考 @excalidraw/math,将散落在各处的几何计算(点距、线段相交、多边形判定等)集中到独立模块,为吸附、套索、碰撞检测等功能提供基础。
7.3 SVG Overlay 层
创建一个专用的 SVG overlay 层(pointer-events: none),用于渲染:
- 吸附参考线
- 套索路径
- 激光笔轨迹
- 搜索高亮
这样可以不侵入 @plait 的核心渲染管线。
7.4 静态缓存模式
参考 SnapCache 和元素线段缓存(elementsSegments),对计算密集的数据(可见元素列表、元素边界、参考吸附点)建立缓存机制,在高频交互(拖拽、鼠标移动)中避免重复计算。