MiroFish:本地素材白板工作流与 Canvas 2D 实现

MiroFish:本地素材白板工作流与 Canvas 2D 实现 MiroFish 不是什么大厂产品是我自己折腾了大半年、迭代了四个版本之后稳定下来的一套本地素材白板工作流。它的核心动作只有一句话把你散落在各个文件夹、聊天记录、浏览器书签、手机截图里的零碎信息统一拖进一块可以自由缩放、自由连线的画布上再用卡片加连线的方式把它们串成一张能看见全貌的网。名字里 Miro 取的是白板这个形态Fish 取的是捕鱼这个动作关键词是打捞而不是收藏——收藏是囤积打捞才有产出。我做它的起因特别朴素有段时间我在同时推进三个方向的事情素材来源极其混乱某张关键截图在相册里某段结论在聊天记录里某个链接在书签栏吃灰。每次想写点东西光是找齐原材料就要花四十分钟真正产出的时间被压缩到只剩二十分钟效率低到让人烦躁。MiroFish 就是为了把这段找回现场的时间压到五分钟以内。这篇内容适合三类人一是和我一样素材来源极度分散、需要一个统一视图的人二是想自己撸一个画布类应用、但被坐标系和交互细节卡住的前端同学三是只想看看别人怎么组织一套个人工作流、拿来改造自己习惯的人。下面我会把设计取舍、数据结构、坐标变换、渲染实现、踩过的坑全部摊开讲代码都是可以直接抄的片段。1. MiroFish 的定位为什么我选择做一块捕鱼的白板1.1 一个真实的工作流痛点长什么样大部分知识管理方案的第一反应是建目录、打标签、写笔记这套逻辑有个致命前提你在记录的那一刻就已经知道这条信息未来属于哪个分类。现实恰恰相反我大部分素材在存进来的当下是不知道有什么用的只知道它看起来重要。让用户在这种模糊状态下被迫做分类决策结果就是要么随手丢进一个待整理文件夹从此再不打开要么干脆保存到桌面。MiroFish 的做法是把分类这件事延后而且延后得非常彻底。任何一条素材进来先变成画布上一个可以在任意位置安放的卡片位置本身就是它唯一的初始归类——离某张卡近就代表它和这件事有关系。等到积累到一定数量空间上的聚集会自然浮现出主题这时候再回头看比一开始就逼自己建文件夹要轻松得多。这个转变的关键在于从树换成图。树结构要求每个节点有唯一的父节点天然排斥多归属素材本身却往往是跨领域的一张关于渲染性能的截图既属于前端优化也属于图像处理。用图结构表达一条连线就能解决多归属问题不需要复制多份也不会出现这条笔记到底该放哪个文件夹的纠结。1.2 三条不做的红线在做 MiroFish 的过程中我给自己划了三条红线每次想加功能都要拿出来对一遍。第一条是不做云端账号体系所有数据以本地文件形式存在我可以随时把整个画布目录打包带走也可以直接打开 JSON 文件用文本编辑器改。第二条是不做自动同步冲突解决宁可让我手动导出再导入也不做那种合并后两边都乱了的自动逻辑。第三条是不做富文本编辑器卡片正文只支持纯文本加上极轻量的标记因为一旦引入排版注意力就会从信息之间的关系跑到信息本身好不好看上。第一版我违背过第二条做了个自动合并结果在一次误操作中把两个版本的画布搅在一起节点 id 冲突导致连线全断我花了整整一个晚上手工恢复。从那以后我就认了个人工具的首要品质是可预测而不是聪明。现在导出就是导出一个完整文件覆盖导入前先备份逻辑简单到不可能出错。1.3 它适合谁不适合谁MiroFish 最适合的场景是个人或两三个人的小范围使用素材量级在几百到几千条之间并且你愿意每周花二十分钟做一次巡场——把新进来的卡片粗略挪一挪位置把已经无用的删掉。这个维护成本极低但它是整套方案能跑起来的前提不巡场的画布三个月后会变成一团乱麻比文件夹还难用。它明确不适合的场景也很清楚需要多人实时协作的团队项目因为它没有冲突合并需要严格权限控制的商业资料因为它默认是本地明文存储以及希望存进去就自动整理好的人因为聚类这件事我坚持留给手工加半自动提示全自动的聚类在素材量少的时候表现极其难看。2. 核心设计拆解数据模型、坐标系与渲染路线2.1 三层数据结构鱼、线、鱼群整套系统抽象到最后只有三种实体FishNode 是一条素材FishEdge 是两条素材之间的关系Shoal 是一组素材的集合。我给它们起了捕鱼主题的名字纯粹是为了在代码里读起来顺口实际含义就是节点、边和分组换成任何领域都能直接套用。节点和边的数据结构长这样字段我做了极简处理能不加的就不加interface FishNode { id: string; type: note | image | link; x: number; y: number; w: number; h: number; title: string; body?: string; color?: string; createdAt: number; updatedAt: number; } interface FishEdge { id: string; from: string; to: string; fromSide: n | e | s | w; toSide: n | e | s | w; label?: string; } interface Shoal { id: string; name: string; color: string; nodeIds: string[]; }这里有个设计决定值得展开边的锚点我用的是四方位枚举而不是自由浮点角度。原因是自由角度在缩放和移动节点之后线条端点会飘到卡片外面视觉上非常脏四方位锚点在节点移动时会自动贴合边框而且计算量小到可以忽略。代价是连线看起来没有曲线那么飘逸但个人工具里清晰比好看重要得多。分组我用了引用式而不是包含式也就是说 Shoal 只存 nodeIds节点自己不知道属于哪个组。这样做的直接好处是一个节点可以同时属于多个组而且移动节点时只需要检查它当前落在哪个组的包围盒里不需要修改节点本身。早期我用的是父子包含结果拖出一个节点要同步更新两个对象代码里到处是双向一致性的补丁。2.2 渲染路线选型Canvas 2D 的取舍细节画布类应用绕不开的第一个问题就是渲染层选什么。我在第一版用过 DOM 加绝对定位节点数量到三百左右的时候拖动开始出现明显掉帧浏览器每次都要重排整个层叠上下文。第二版换成 SVG情况好一些但两三千条边的路径描边依然会让主线程吃力。第三版我换成了 Canvas 2D一直用到现在。Canvas 的优势是绘制指令直接下发没有 DOM 树参与批量绘制几千个矩形和曲线基本没有压力。它的代价是所有交互都要自己做命中检测文本排版也要自己处理换行。这两块工作量不小但一旦做完就非常稳定不用再担心某个浏览器对某个 CSS 属性的支持差异。下面是我实际使用的渲染主循环骨架核心思路是把世界坐标系的绘制和视口变换分开绘制时只做一次变换之后所有坐标都用世界坐标写function render(ctx: CanvasRenderingContext2D, doc: BoardDoc, vp: Viewport) { const dpr window.devicePixelRatio || 1; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.save(); ctx.translate(vp.x, vp.y); ctx.scale(vp.k, vp.k); for (const edge of doc.edges) drawEdge(ctx, edge, doc); for (const node of visibleNodes(doc.nodes, vp, ctx.canvas.width, ctx.canvas.height)) { drawNode(ctx, node, vp.k); } ctx.restore(); }这段代码里有两个容易被忽略的细节。第一是 dpr 的处理必须在 clearRect 之前完成否则清空范围会算错在高分屏上会留下右下角的残影。第二是字体缩放的补偿ctx.scale 之后所有文字也会被缩放当缩放比很小的时候文字会糊成一团我的做法是在 drawNode 里判断 vp.k 小于 0.6 时只画色块不画文字等放大之后再补上。2.3 坐标系与视图变换的数学底子画布类应用真正劝退人的地方不是渲染是坐标换算。屏幕上你看到的每个点叫屏幕坐标数据里存的位置叫世界坐标两者之间的桥梁是视口对象 viewport里面三个字段x 和 y 是平移量k 是缩放比。所有交互的第一件事都是把鼠标位置转成世界坐标只要这一步错了后面全错。换算公式非常短但必须写对方向interface Viewport { x: number; y: number; k: number } function worldToScreen(p: {x: number; y: number}, vp: Viewport) { return { x: p.x * vp.k vp.x, y: p.y * vp.k vp.y }; } function screenToWorld(p: {x: number; y: number}, vp: Viewport) { return { x: (p.x - vp.x) / vp.k, y: (p.y - vp.y) / vp.k }; }我踩过一个非常典型的坑拿到鼠标事件后直接用 event.clientX 去和节点坐标比较在缩放不为 1 的时候看起来是能拖但拖偏了。正确的做法永远是把事件坐标减掉画布在页面里的偏移再经过 screenToWorld 转换才能拿去和节点做碰撞。缩放还有一个必须处理的问题以鼠标为锚点缩放而不是以画布左上角。如果直接改 k 不改 x、y画布会往左上角跑用起来非常别扭。锚定缩放的实现只有四行但它是体验的分水岭function zoomAt(vp: Viewport, screenPoint: {x: number; y: number}, factor: number): Viewport { const k Math.min(4, Math.max(0.1, vp.k * factor)); const world screenToWorld(screenPoint, vp); return { k, x: screenPoint.x - world.x * k, y: screenPoint.y - world.y * k, }; }先求出鼠标位置对应的世界坐标再反推新的平移量保证这个点在缩放前后屏幕位置不变。这个先求世界点再反推的思路可以套用到所有需要锚定的变换上包括旋转和倾斜。2.4 本地优先的存储格式设计数据落盘我选了单文件 JSON虽然文件会比较大但好处是可以整体备份、整体回滚、用文本编辑器直接查改。为了应对后续结构变化我在文档最外层放了 version 字段任何一次读取都先过一遍迁移函数interface BoardDoc { version: number; viewport: Viewport; nodes: FishNode[]; edges: FishEdge[]; shoals: Shoal[]; } function migrate(raw: any): BoardDoc { let doc raw; if (doc.version 2) { doc.nodes doc.nodes.map((n: any) ({ ...n, color: n.color ?? #5b7cfa })); doc.version 2; } if (doc.version 3) { doc.shoals doc.shoals ?? []; doc.version 3; } return doc as BoardDoc; }迁移函数必须写成链式的每一段只负责从第 N 版升到第 N1 版而不是写成一个大 switch。这样做的好处是每次加字段只需要追加一个 if 分支历史逻辑一行都不用改半年后回头看依然能读懂。写盘时机我也做了处理拖动过程中不写松手之后延迟 800 毫秒再写用防抖避免每移动一像素就触发一次序列化。序列化一个千节点的文档大约需要十几毫秒如果每次都同步执行拖动会明显发涩。3. 动手实现把 MiroFish 从零搭起来3.1 工程骨架与目录约定我用的技术栈很朴素Vite 做开发服务器和打包TypeScript 写逻辑Canvas 2D 直接操作不引入任何图形库。整个工程只有一个依赖就是 Vite 本身其他全部手写。这样做的好处是构建产物极小、启动极快、升级不担心破坏性变更代价是很多轮子要自己造。目录结构按职责切不按文件类型切这是我试过几种方案之后觉得最不容易乱的一种src/ core/ model.ts // 数据模型与迁移 viewport.ts // 坐标换算 store.ts // 状态与订阅 render/ renderer.ts // 渲染主循环 node.ts // 节点绘制 edge.ts // 连线绘制 input/ pointer.ts // 指针事件归一化 drag.ts // 拖拽与框选 zoom.ts // 滚轮与快捷键 io/ persist.ts // 本地持久化 exchange.ts // 导入导出 main.ts这种切法的关键收益是修改边界清晰想调视觉只动 render 目录想调交互只动 input 目录想调数据结构只动 core。我见过太多项目把渲染和事件绑在同一个文件里改一个颜色要滚动八百行那种结构撑不过三个版本。3.2 节点渲染与高分屏适配节点绘制看着简单其实藏着三个细节圆角矩形、文本换行、以及缩放时的降级策略。圆角我用的是原生 roundRect现代浏览器都支持不需要手写贝塞尔。文本换行我自己实现了一个朴素版本按字符宽度累加超过节点宽度就换行中文不依赖空格因此比英文好处理。function drawNode(ctx: CanvasRenderingContext2D, n: FishNode, k: number) { ctx.fillStyle n.color ?? #ffffff; ctx.strokeStyle rgba(0,0,0,0.12); ctx.lineWidth 1 / k; ctx.beginPath(); ctx.roundRect(n.x, n.y, n.w, n.h, 8); ctx.fill(); ctx.stroke(); if (k 0.6) return; ctx.fillStyle #1f2328; ctx.font ${14}px system-ui, sans-serif; ctx.textBaseline top; wrapText(ctx, n.title, n.w - 20, 14).forEach((line, i) { ctx.fillText(line, n.x 10, n.y 10 i * 18); }); }注意 lineWidth 我写了 1 / k这是为了让描边在任何缩放下都保持视觉上的一像素宽。如果不做这个补偿缩小之后边框会消失放大之后边框会变成粗黑框非常影响观感。这个技巧在所有缩放画布里都通用。高分屏适配的关键是在 resize 的时候同时设置 canvas 的像素尺寸和 CSS 尺寸并且用 setTransform 把 dpr 一次性压进去。我见过有人每帧都调 setTransform 而不是在开始时调一次结果坐标系不断累积画面越跑越偏。function resize(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); canvas.width Math.round(rect.width * dpr); canvas.height Math.round(rect.height * dpr); canvas.style.width rect.width px; canvas.style.height rect.height px; }3.3 交互三件套拖拽、平移、缩放交互我做成了状态机同一时刻只有一种模式生效默认状态下按住空白处是框选按住节点是拖拽该节点按住空格加拖动是平移画布滚轮是缩放。这个映射我用了很久手指肌肉记忆已经形成几乎不会按错。命中检测从后往前遍历因为后绘制的节点在视觉上位于上层必须优先命中function hitTest(nodes: FishNode[], p: {x: number; y: number}): FishNode | null { for (let i nodes.length - 1; i 0; i--) { const n nodes[i]; if (p.x n.x p.x n.x n.w p.y n.y p.y n.y n.h) { return n; } } return null; }拖拽多选节点时有个细节必须处理要记录每个选中节点相对鼠标的世界坐标偏移量移动时统一按偏移量更新而不是把所有节点都设成鼠标位置。前者能保持多选之间的相对关系后者会让所有节点叠在一起。这个错误我在第一版犯过选中五个节点一拖全部重合当时还以为是渲染 bug查了半天。框选我用的是世界坐标系下的包围盒相交判断而不是屏幕坐标因为框选过程中如果用户滚动滚轮屏幕坐标会失效世界坐标永远稳定。3.4 连线系统与吸附锚点连线的操作方式是把鼠标移到节点边缘边缘上会浮出四个小圆点从一个点拖到另一个节点上松开就建立了一条边。整套交互只有一条规则——必须有明确的起点和终点不允许悬空连线所以存量的边永远指向存在的节点。锚点位置按方位算写成一个纯函数任何地方要用都调它function anchorPoint(n: FishNode, side: n | e | s | w) { switch (side) { case n: return { x: n.x n.w / 2, y: n.y }; case s: return { x: n.x n.w / 2, y: n.y n.h }; case w: return { x: n.x, y: n.y n.h / 2 }; case e: return { x: n.x n.w, y: n.y n.h / 2 }; } }曲线我用的是三次贝塞尔控制点的偏移量取两点水平距离的一半这样连线的弯曲程度会随着节点距离自适应短距离接近直线长距离会形成优美的弧形function drawEdge(ctx: CanvasRenderingContext2D, e: FishEdge, doc: BoardDoc) { const a doc.nodes.find(n n.id e.from); const b doc.nodes.find(n n.id e.to); if (!a || !b) return; const p1 anchorPoint(a, e.fromSide); const p2 anchorPoint(b, e.toSide); const dx Math.abs(p2.x - p1.x) * 0.5 20; const c1 { x: p1.x (e.fromSide e ? dx : e.fromSide w ? -dx : 0), y: p1.y }; const c2 { x: p2.x (e.toSide e ? dx : e.toSide w ? -dx : 0), y: p2.y }; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, p2.x, p2.y); ctx.strokeStyle rgba(90,110,140,0.55); ctx.lineWidth 1.5; ctx.stroke(); }这里有个隐藏问题需要注意如果找不到 from 或 to 对应的节点必须直接 return。我早期忘了这个判断删节点的时候没有级联删除边结果渲染到一半直接抛异常整块画布白屏。正确做法是删除节点时同步过滤掉所有引用它的边渲染函数里的这个判断只是第二道保险。3.5 分组鱼群与层级管理分组在我的实现里就是画一个半透明的圆角矩形垫在节点下面颜色淡到不干扰节点本身。它的行为逻辑是当节点被拖动停下时检查它的中心点落在哪个组的包围盒里如果在就把它的 id 加进该组的 nodeIds如果不在任何组里就从原来的组里移除。这个拖入即归组、拖出即离组的交互非常符合直觉不需要任何菜单操作。实现时要注意的是判断依据用节点中心而不是整个矩形否则一个横跨两组的大卡片会同时属于两边视觉上会产生歧义。组还承担了折叠功能。点击组标题可以把这个组里的所有节点缩成一个小色块双击恢复。折叠状态我用一个 collapsed 字段存在组对象上渲染时判断这个字段决定画节点还是画块。这个功能在素材很多的时候非常救命能把一整片区域收起来让当前正在处理的区域有足够的视觉空间。3.6 持久化、导入导出与文件拖入持久化这块我提供了两条路径。轻量路径是 localStorage只在文档小于两兆的时候用适合刚开始画布还很空的时候读写都是同步的非常省心。重量路径是 IndexedDB存 Blob适合文档变大之后异步写入不阻塞主线程。我用文档体积做自动切换用户完全无感。导入导出就是一个完整的 JSON 文件导出时我还会把当前视口位置一起写进去这样下次导入的时候视角能恢复到导出时的状态而不是茫然地停在一片空白上。这个细节很小但用起来差别巨大。素材拖入我做了一个不算复杂但很实用的处理监听 dragover 和 drop 事件如果拖进来的是文件并且是图片就读取成 DataURL 塞进节点。如果是文本或者链接直接作为 note 类型创建。拖入的位置就是鼠标释放点换算出来的世界坐标不需要用户再挪一次。canvas.addEventListener(drop, async (e) { e.preventDefault(); const rect canvas.getBoundingClientRect(); const world screenToWorld( { x: e.clientX - rect.left, y: e.clientY - rect.top }, currentViewport ); const file e.dataTransfer?.files?.[0]; if (file file.type.startsWith(image/)) { const dataUrl await readAsDataURL(file); store.addNode({ type: image, x: world.x, y: world.y, title: file.name, body: dataUrl }); } });注意 clientX 一定要减掉画布的 rect.left否则拖入位置会整体偏移一个侧边栏的宽度这个坑我在有侧栏布局的版本里踩过一次排查了半小时。4. 实操中踩到的坑与排查手册4.1 常见问题速查表下面这张表是我自己记录的排障清单基本上每次出问题都能在里面找到对应项我把它们按症状整理方便你直接对照。症状大概率原因处理方式新增节点不显示没有触发重绘或数据未推入 store检查渲染循环是否常驻而非事件驱动拖动位置偏移用了 clientX 而非转换后的世界坐标减画布偏移再走 screenToWorld高分屏文字发虚未按 dpr 设置 canvas 像素尺寸resize 时乘以 devicePixelRatio缩放后描边粗细异常线宽未做 1/k 补偿每帧按当前缩放折算线宽框选后全部节点重合拖拽按绝对位置而非相对偏移记录每个节点与鼠标的初始偏移缩放中心跑到左上角缩放未以鼠标为锚点用 zoomAt 先算世界点再反推平移刷新后画布空白版本迁移函数缺失或抛错加 try/catch迁移链式分段导入大文件卡死主线程同步解析超大 JSON移到 Worker或分片加载表格里的第八条我特别想展开一句超过五兆的 JSON 同步解析在主线程上是会明显卡顿的表现为页面白屏大概一两秒。如果只是偶尔导入一次还能忍但如果你和我一样喜欢来回切换几个画布那就必须挪到 Worker 里。我现在的做法是在 Worker 里 parse 完只把结构化的对象传回来。4.2 千节点之后的性能衰减与止血节点数到八百左右是一个明显的分水岭我的观察是八百以下几乎无感八百到一千五开始能感觉到拖动有一点点滞后一千五以上滚轮缩放会出现跳帧。原因不是绘制本身而是每帧都要遍历所有节点做视口裁剪和边的查找。第一个止血手段是视口裁剪只画屏幕范围内的东西。这个改动带来的提升最明显因为它把绘制量从总量变成了可见量而实际使用时屏幕里同时能看清的节点很少超过一百个function visibleNodes(nodes: FishNode[], vp: Viewport, w: number, h: number) { const tl screenToWorld({ x: 0, y: 0 }, vp); const br screenToWorld({ x: w, y: h }, vp); return nodes.filter(n n.x n.w tl.x n.x br.x n.y n.h tl.y n.y br.y ); }第二个手段是把节点和边做成 id 到对象的映射表而不是每次都用 find 去数组里线性查找。这个改动在连线数量大的时候提升非常夸张因为原来的 drawEdge 是 O(n) 查找几千条边就是几百万次比较。改成 Map 之后降到了 O(1)我从这之后再也没有遇到过连线拖慢整个画面的情况。第三个手段是把静止的节点缓存到离屏 canvas。这个优化我只在极端情况下用因为一旦引入缓存就必须处理失效逻辑——节点位置变了、内容变了、颜色变了都要重新生成缓存。我的策略是只对超过一定尺寸的图片节点做缓存普通文本节点不缓存因为文本绘制本身很快缓存反而增加复杂度。4.3 数据安全别把半年素材赌在一次误操作上这一节是我用最重的教训换来的。我在第三版的时候遇到过两次数据事故。第一次是浏览器清理站点数据把 localStorage 里的画布清空了我当时没有导出备份几个月的素材直接归零。第二次是导入时选错文件用旧版本覆盖了新版本因为当时还没有版本号字段恢复无望。从那之后我加了三道保险。第一道是自动导出每天首次打开时自动把当前文档写一份带日期的备份文件到下载目录命名格式是画布名加日期这样即使本地数据全丢翻下载目录也能找到最近几天的快照。第二道是导入前强制备份导入流程先复制一份当前文档为 .bak再执行覆盖。第三道最朴素也最有效我把主文档放在一个我自己会经常点开的文件夹里物理上看得到就不会忘记它的存在。自动导出的实现有一个小技巧不要每次打开都触发否则一天开十次就有十个备份文件。我用一个本地记录的日期字符串做判断只有当今天还不存在备份时才执行async function dailyBackup(doc: BoardDoc, name: string) { const today new Date().toISOString().slice(0, 10); const key backup:${name}; if (localStorage.getItem(key) today) return; const blob new Blob([JSON.stringify(doc)], { type: application/json }); downloadBlob(blob, ${name}-${today}.json); localStorage.setItem(key, today); }注意备份文件不要只放在同一个磁盘分区有条件的话让同步盘目录或者移动硬盘也留一份。我身边有朋友因为整块固态盘故障丢过数据本地备份在单盘故障面前是无效的。5. 让它真正长成你的工作流扩展方向5.1 检索与自动聚类画布大了之后找东西会变成新问题。我现在的做法是加了一个全局搜索框输入关键词就高亮匹配的节点同时把视口飞过去。搜索范围包括标题和正文用的是最简单的包含匹配没有引入任何索引结构因为几千条数据线性扫描完全够用加索引属于过度设计。聚类我做的是半自动提示而不是自动归组。具体来说当画布上出现三个以上距离很近但彼此没有连线的节点时我就在角落提示一句这一片要不要连起来由我决定是否建立关系。全自动聚类我试过用邻接矩阵加上简单的社区发现算法效果在素材少于五十条的时候非常糟糕因为那时候的空间分布完全是随机摆放算法会煞有介事地把毫无关系的东西归成一类反而污染了我的判断。我个人的体会是空间布局这件事人脑对看起来挨着的直觉比任何算法都准工具该做的是把这种直觉的可用性放大而不是替代它。5.2 从单机白板到小范围协作我目前还是单机使用但已经为协作留了口子。设计上有两个约束保证了这条路能走通一是每条边、每个节点都有稳定 id不会因为导入顺序不同而变化二是所有变更都以操作为单位记录而不是直接改最终状态。这意味着把操作日志发出去、在另一端重放理论上就能实现协作只差一个合并策略。不过我很清楚这条路会带来冲突问题两个人同时拖同一个节点就是经典冲突。我的打算是走分区负责而不是实时合并不同的人在画布上划分不同的区域各自维护自己的区域共享的只是只读视图。这样做牺牲了自由度但换来了零冲突对于两三人规模足够用。这个方向我还在试等稳定了再单独整理一篇。5.3 一个我用了半年的节奏习惯最后分享我实际使用 MiroFish 的节奏这套习惯我坚持了半年比任何功能带来的价值都大。每周固定一次二十分钟的巡场把新进来的卡片随手挪到大致位置删掉已经确认无用的把明显是一类的用连线串一下。整个过程不做深度整理只做粗粒度的空间安排。每次要写点什么的时候打开画布直接在目标区域新建一张卡片标题写主题然后用连线把相关素材拖到它周围连线本身就是大纲。等到连线画完了正文的顺序也就出来了剩下的只是把卡片上的要点扩写。这个流程把我从找素材的状态切换成了看结构的状态效率差别非常大。工具本身不值钱值钱的是它逼着你养成的习惯素材先进来位置先摆着分类往后放产出的时候回到同一块画布上。MiroFish 从头到尾只是给这个习惯提供了一块足够自由的地方。如果你也准备动手做类似的工具我的建议是把第一版做得极其简陋只要能拖动和保存就够了剩下的一切等你在里面放了三百条素材之后再决定加什么。