Initial TrueGrowth source import

This commit is contained in:
2026-07-07 09:36:36 +08:00
commit 3b6781d695
2283 changed files with 691996 additions and 0 deletions

View File

@@ -0,0 +1,579 @@
# 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 包不依赖 Reactelement 包不依赖 UI便于测试和复用。Aitu 可考虑将数学运算和元素逻辑从 drawnix 中抽离,减轻核心包的体积。
### 1.2 渲染架构
| 维度 | Excalidraw | Aitu |
|------|-----------|------|
| **渲染技术** | 双 CanvasStatic + 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 Context6 个 Context |
| **服务状态** | Jotai atom + 独立 store非 React 可访问) | RxJS Subject/Observable |
| **多实例** | `createIsolation()` 支持 | 单实例设计 |
| **跨组件通信** | Jotai atom 订阅 | Context + RxJS |
**Excalidraw 的 Jotai 模式:**
```typescript
// 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 示例:**
```typescript
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 融合建议:**
```typescript
// 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 融合建议:**
```typescript
// 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. UICtrl+F 打开搜索栏(可复用 TDesign Input
```
**实现复杂度**~400 行代码1-2 天
**难点**:文本精确定位需要了解 @plait 的文本渲染坐标系
---
### 3.3 智能吸附系统Snapping
**Excalidraw 实现要点:**
**核心数据结构:**
```typescript
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;
};
```
**算法流程(双遍计算):**
1. **第一遍**:计算 snapOffset允许 SNAP_DISTANCE=8px/zoom 的容差)
2. **第二遍**:用修正后的位置重新精确匹配,生成辅助线
**GapSnap 三种模式:**
- 居中对齐:被拖元素放在间隙正中
- 右侧等距:被拖元素距右侧元素 = 间隙长度
- 左侧等距:被拖元素距左侧元素 = 间隙长度
**性能优化:**
- `SnapCache` 静态单例缓存参考点和间隙
- 参考点仅计算可见且非选中的元素
- 排除绑定到容器的文本元素和单独箭头
**Aitu 融合建议:**
```typescript
// 需要在 @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>
├── 双重选择检测
└── 元素线段缓存
```
**选择算法(双重检测):**
1. **AABB 粗筛**`doBoundsIntersect(lassoBounds, elementBounds)` — O(1) 排除不相交元素
2. **包围测试**`polygonIncludesPointNonZero()` — Non-Zero Winding Rule元素任意端点在套索多边形内即算包围
3. **相交测试**`intersectElementWithLineSegment()` — 套索路径线段与元素轮廓线段相交
**性能优化:**
- 路径简化:`simplify(path, 5/zoom)` Douglas-Peucker 变体
- 仅检测可见元素
- 元素线段缓存(画布变换改变时重建)
- 增量 Set 管理(避免重复检测)
**后处理:**
- 文本元素 → 替换为其容器
- Frame 子元素 → 从选中集中移除
- 组选择处理
- Shift 键追加选择
**Aitu 融合建议:**
```typescript
// 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` 概念和裁剪渲染。可能需要:
1. 扩展 `PlaitElement` 类型添加 `frameId` 字段
2.@plait 渲染管线中注入 SVG `<clipPath>` 裁剪
3. 拖拽事件中添加 Frame 成员关系更新逻辑
### 4.2 肘形箭头Elbow Arrows
**核心算法A* 寻路**
- 非均匀网格:节点位于所有元素 AABB 边界线的交叉点
- `BinaryHeap<Node>` 优先队列,`f = g + h`
- 方向变化惩罚:`g += bendMultiplier³`(鼓励少弯折)
- 障碍物避让:中点落在任何 AABB 内则跳过
- `FixedSegment`:用户可手动调整某些线段位置
**Aitu 融合难度****高**。需要:
1. 实现 A* 寻路算法(~400 行)
2. 构建非均匀网格系统
3.@plait 的箭头元素系统集成
4. 添加元素绑定端点的吸附逻辑
### 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 融合建议**
```typescript
// 极简实现
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.IOWebSocket + polling 降级)
- **持久化**Firebase Firestore + Storage
- **同步策略**:增量版本追踪 + 20s 全量兜底 + 5s 超时 Firebase 降级
- **冲突解决**`versionNonce` 确定性解冲突(取较小 nonce 者胜出,无需中央仲裁)
- **元素排序**Fractional IndexCRDT 思想,避免并发插入冲突)
- **加密**AES-128-GCM密钥存于 URL hash端到端加密服务器不可见
- **光标同步**33ms volatile 消息
**Aitu 现状**GitHub Gist 异步同步,非实时。引入实时协作需要:
1. WebSocket 服务端
2. 状态同步协议(推荐 Yjs/CRDT 而非自研 reconcile
3. 光标/选区广播
4. 冲突解决策略
**实现复杂度**:非常高(需要后端服务),建议作为独立里程碑规划。
### 5.2 图表粘贴
**Excalidraw 方案:**
- TSV 优先 → CSV 降级 → 转置回退
- 正则支持 5 种货币符号的智能数字解析
- 自动推断列角色(标签列 vs 数值列)
- 生成原生元素:矩形(柱形图)/ 线段+圆点(折线图)/ 文本标签 / 坐标轴
**Aitu 融合**:可监听粘贴事件,检测 TSV 格式数据,将其转为 @plait 元素。约 300 行代码。
### 5.3 字体子集化
**Excalidraw 方案:**
- HarfBuzz (子集化) + Woff2 (压缩) 两个 WASM 模块
- `WorkerPool` 模式:按需创建 Worker1s 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`),对计算密集的数据(可见元素列表、元素边界、参考吸附点)建立缓存机制,在高频交互(拖拽、鼠标移动)中避免重复计算。