← 全部作品

Work — 2026-08

Pinbod 平铺 · 手写无限画布

从零手写的一块无限画布:拖出形状、连上箭头、画手绘线,导出 PNG / SVG / JSON。

进行中可试玩Vue 3TypeScriptCanvas 2Drough.jsDexieMCP

编辑会存在你自己的浏览器里,刷新还在,也随时能清掉。

我想要的是一块自己用得下去的白板:打开就能画,不用想存哪、不用登录、断网也在。

数据放在浏览器的 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 —— 瓶颈根本不在这。于是这个优化被明确否掉,省下一堆复杂度。