我想要的是一块自己用得下去的白板:打开就能画,不用想存哪、不用登录、断网也在。
数据放在浏览器的 IndexedDB 里,自动保存,没有账号体系。
怎么长出来的
它不是一次设计出来的,是四步走出来的 —— 每一步都是因为上一步不够用。
先有引擎,再有界面。 第一次提交里就已经躺着 2943 行的 CanvasEngine 和 763 行的历史测试,也就是说画布内核在进 git 之前就写完了。因为那时要验证的不是“界面好不好看”,而是“这套坐标、命中、渲染的底子立不立得住”。
然后补齐功能。 编组、锁定、对齐吸附、迷你地图、图片、连线绑定,加上 PWA 离线 —— 到这一步它已经是一块能用的白板。
接着是产品化底座。 这一步看起来最不性感,却决定了后面还能不能长:操作序列化(EngineOp + opLog)、保存可靠性、错误监控、可复现的性能基准。没有它,后面的 AI 通道就没有落脚的地方。
最后才是 AI 通道。 有了 op 这一层,AI 的动作才能和人的动作走同一条路。
和主线的结合点
我的主线是「探索面向 AI 的交互边界」,判据是三条:产出可继续编辑的结构、能主动发起、和人共用工作台。这块白板满足两条,缺的那条很清楚。
产出可继续编辑的结构 —— 满足。 AI 通过能力层改画布,走的是 applyMutation → beginChange/commit,和人手动操作同一条提交路径。于是它的每个动作都进了撤销栈和 opLog:AI 画错了,用户按 Ctrl+Z 就能撤。这是设计的起点,不是事后补的。
和人共用工作台 —— 部分满足。 通道读写的是正在运行的那块画板,不是一份副本;get_selection 让 AI 知道用户此刻选中了什么。但它只是串行读写,没有在线状态、也没有并发控制。
能主动发起 —— 没有。 通道是请求/响应式的:AI 只在被叫到时行动,画布不会反过来推事件给它。所以它现在是一个能被叫来干活的协作者,还不是一个自己看见值得做的事就会开口的协作者。
缺的这条,正好就是我在别处写下的「还没捅穿的边界」。所以这块白板更准确的读法是:它是那条边界上的一个探针,不是一个已经抵达的终点。
难点
撤销重做要能同步,就不能用快照。 最初存快照、撤销时整体覆盖。问题是等画布要和人同步时,整体覆盖会把对方刚做的修改一起抹掉。
于是改成操作回放:五种操作各自带一个逆操作,撤销走逆操作、重做走正向回放,旧的历史条目兜底用快照。难的其实不是算法,是副作用要一致 —— 选择状态、绑定的索引、opLog、背景色、对外发出的事件,一样都不能漏,漏了就会变成“撤销之后画面对了,但选中框还留在原地”。字段级合并我留给了 Yjs,还没做。
吸附要有滞回。 5px 的阈值下,鼠标停在边界附近会在“吸住”和“放开”之间反复跳。解法是命中后锁线,要拖离超过 12px 才解锁。
箭头端点得贴着形状轮廓。 矩形可以用 slab 法,椭圆要解二次方程,菱形、多边形、星形得逐边求交 —— 每种形状一套。另外让连接口可以固定不动,箭头的间隙收敛成 0,免得尖端悬在半空。
亮点
引擎是纯 TypeScript,零 Vue 依赖。 它能脱离界面单独跑测试(那 763 行历史测试就是这么来的),也意味着以后换界面不用动内核。
语义导出不靠视觉识别。 要让 AI 看懂画布,常见做法是喂截图。这里走的是另一条:连线关系本身就是图的骨架,导出时只取骨架 —— 渲染噪音丢掉、图片不转 dataURL、自由画笔如实标注“无法语义化”,不硬凑。
一键接上 AI。 桥侧的 Node 会探测本机装了哪些客户端,直接把 MCP 配置写好并备份原文件,用户不用手抄 JSON。
用基准否掉一个优化。 “要不要上空间索引”没有靠感觉回答:写了可复现的基准,测出 2000 个形状每帧遍历加裁剪是 1.46ms —— 瓶颈根本不在这。于是这个优化被明确否掉,省下一堆复杂度。
