资讯动态

Madeira:一款记录删除痕迹的浏览器本地笔记应用设计与实现

发布时间:2026/10/1 1:09:17 来源:尧图企业网站定制
如果你也是那种会在文档里把整段删掉、然后眼不见心不烦的人那 Madeira 这个项目的出发点也许能引起你的注意。它不是大厂出品的时间管理工具而是我花了一个月写出来的本地笔记应用一个把编辑过的字符全部保留在屏幕上的浏览器记事本。没有文件夹、没有标签、没有云端同步核心规则就一句话——你在文档里输入的每一个字符都被记录删除操作不会被抹除而是变成一条可见的删除痕迹留在原处算是一款剥离掉所有干扰的记事本。这篇文章适合两类人一是想找一个反主流笔记产品的轻量写作环境二是好奇浏览器端编辑器数据模型、中文输入法兼容、新标签页扩展这些事如何落地的人。我会把从取舍到踩坑的完整过程写出来不是设计文档而是实际动手做完之后那种“早知道这样会好很多”的复盘。1. 为什么我把笔记软件做成了“不允许假装没写过”1.1 大多数笔记软件都在悄悄纵容无效删除先说说做 Madeira 之前我自己的状态。我日常会写技术复盘和项目记录过去用的都是普通在线文档。写到一半觉得思路不对随手选中文档内容一个退格键删掉几千字。当时的感觉是“痛快”尤其是删完之后屏幕重新变得干净仿佛那段跑偏的思路从来不存在。但次数一多问题就暴露了我发现自己越来越依赖“删除”这个动作来回避真正的判断。删的时候并没有在思考“这段为什么不好”只是在消除视觉上的杂乱。过一两天想起某个被删掉的想法想找回又根本无从下手。在线文档虽然有历史记录但我不会每次删东西都刻意去翻而且多版本历史通常要等同步完成才能查看打开路径太长人早就失去了追索的耐心。另一个让我不舒服的点是编辑器默认的“干净”本身就是一种暗示。它暗示你应该直接写出成品没写好的部分就应该彻底消失。可现实里写作更像是在一堆半成品里挑挑拣拣那些“写坏了”的句子恰恰是信息的入口里面藏着最初的想法碎片。把这些东西一键抹掉等于把自己的思考现场也一并抹掉了。我试过一些替代方案有人推荐用 Git 管理文档每次提交就是一次快照但写作不是一个稳定的 commit 过程我写一段话四十五秒不可能中途停下来 commit 一次。也有人推荐把删除的内容转移到文末“回收站”但那样文档结构会碎掉读起来很别扭。绕了一圈之后我意识到真正要改的是产品的交互规则不是给用户再加一个“备份”按钮。1.2 产品规则字符可以删但删除的痕迹必须留下所以 Madeira 的规则被我收敛成一句话文档里只发生过字符的插入和删除插入的内容正常显示删除的内容不消失而是以带删除线的弱化样式留在原位。你可以随时删掉一句话但这句话会以一个“被删掉的状态”继续躺在屏幕上变成文档的一部分。这个规则的直接效果是你没法再假装一段废稿没写过。每次退格都会在纸上留下一道划痕划痕多了你就不得不正面回答一个问题——为什么我在同一个句子上反反复复这个回答往往是写作时真正需要解决的问题。我管这个叫“可见的编辑历史”。它与一般版本历史不一样版本历史是静态快照要你主动切出去对比Madeira 的编辑历史是即时动态的就在你眼睛正在看的位置。你对文本做的每个操作都会立刻反映在视觉上不需要额外动作也就没有“我待会儿再看”的心理缓冲。至于产品边界我在动手前就划好了线不做账号体系、不做文件夹分类、不做多级目录也不做富文本格式。所谓“剥离掉所有干扰”不只是界面上的极简连功能本身也要克制到只剩编辑。最开始的版本连导出按钮都没有因为我觉得一旦有了导出人又会回到“把内容整理干净再带走”的惯性里。后来越用越觉得不便才补了一个最小化的“复制为纯文本”按钮这个后面会细说。1.3 技术选型纯浏览器页面而不是 Desktop App决定技术方案时我一度考虑过 Electron毕竟很多本地笔记工具都用它打包。但实际操作后发现一个只做单文档编辑的应用用 Electron 太重了启动要几百毫秒内存占用动不动几百 M和“打开就写”的产品属性冲突。Madeira 最后用的是纯 HTML、CSS 和 TypeScript跑在浏览器里落地形态是一个可以覆盖浏览器新标签页的轻量页面。选择这个形态有两个原因。一是启动成本接近零浏览器本身就是运行环境新建一个标签页就能立刻进入写作状态。二是新标签页在用户心智里是一个“刚打开、还没进入任何一个网站”的空白区把编辑器放在这里天然会减少我顺手切去刷信息流的冲动。本地存储用的是 localStorage不引入 IndexedDB。它容量小但胜在同步读写、API 简单、不需要处理复杂的事务。对一个单文档笔记工具来说几百 K 的操作日志完全够用。我保留了一个后期把存储换到 IndexedDB 的接口但在实际使用中一直没用到说明第一版选型没有浪费。2. 数据模型用操作流而不是最终文本来记账2.1 如果只存字符串你的“历史”就丢了这是整个项目里最应该想清楚的部分内存里到底拿什么作为文档的“真相”。初学做编辑器的人容易直接用字符串输入就拼接、删除就 slice操作起来很直接。但字符串只有最终状态一旦删掉一段话那段话就没有任何痕迹了。想实现“删除可见”光有最终字符串远远不够我们必须记录每个字符是怎么进来的、又是怎么没的。所以 Madeira 把数据模型定义成一条只增不减的操作流operation log。每一次用户输入、每一次删除都以一个独立事件追加到操作流末尾。文档的最终文本不是保存在某个变量里而是通过回放整条操作流计算出来的。听起来有点绕但它是整个“可见历史”的地基。拿一个最简单的例子来说操作流 1. insert: 在第0位插入 今天写项目 2. insert: 在第5位插入 有点难 3. delete: 从第2位删除3个字符 写项目 4. insert: 在第2位插入 做复盘最终显示出来的文本是“今天做复盘有点难”但操作流里仍然保存着“写项目”这三个字以及它被删除的秘密。渲染层可以从这条操作流里拿到两个东西一个是“当前文档长什么样”另一个是“哪些字符曾经被删除过、当时删在哪里”。2.2 最小操作类型与一次输入的完整处理周期我把操作类型收敛成四种insert、delete、undo、redo。其中 undo 和 redo 并不是普通操作流的成员属于“导航操作”它们不追加历史记录只负责把当前编辑位置和显示状态回退到某个操作之前。这个区分极其重要不然你 undo 一次历史里就会多一条“撤销了上次删除”的记录画面语义会产生循环。再看一次输入的完整周期。用户在输入框按下键盘浏览器触发 beforeinput 或 input 事件我读取当前光标位置和新增字符生成一条 insert 操作。如果用户按退格我读取被移除的字符和位置生成一条 delete 操作。每次生成后把操作追加到内存里的操作流同时触发渲染函数让删除线立刻出现在画面上。这里有一个值得强调的小设计操作流里的 position 是回溯全局的不是相对某个版本。也就是说第 10 条操作里的位置被第 11 条操作插入之后哪怕光标意义上的物理位置已经变了日志里保存的仍然是最初记录时的绝对位置。这样回放时从头到尾应用每一条操作才能得到一个唯一确定的文档状态。删除操作还要额外满足一个约束它必须记录被删除的具体字符不能只记录“删除了 3 个字符”这种长度信息。因为在回放时如果经过前面的操作当前位置的字符已经被替换过长度匹配但内容对不上文档就会失真。所以 delete 操作会先根据当前快照取出真实文本再把文本一起存进日志。2.3 为什么不用 MongoDB 式的文档列表而用事件流如果不需要历史回放最自然的数据结构肯定是一个数组或者字符串改一下渲染一下简单粗暴。但一旦要实现“删除可见”和“撤销重做”事件流的优势就出来了。首先是撤销的准确性。字符串模型下做撤销要么拍快照要么记录前后差异但快照粒度很难把握操作流模型下撤销很简单找到最近一条 insert 或 delete做一个反向操作即可。原因是事件流保留了用户意图的粒度而不是丢失为字符状态。其次是未来可扩展性。操作流天然支持“把今天所有删除的字符拿出来统计”——只需过滤 type 为 delete 的操作汇总 text 字段的长度即可。如果想要按时间切换查看任意时刻的文档回放操作流到目标时间戳就行。这些都建立在“发生了什么都被记下来”的前提上而不是“现在变成什么样”。当时我也担心过性能如果写一个两万字的文档操作流可能有上万条每次输入都要全量回放一遍会不会卡实测下来两万字文档全量回放只需要十几毫秒浏览器完全扛得住。真正需要警惕的是渲染层不要每次都重新建 DOM这个放到下一节说。3. 渲染层让每次删除都变成看得见的“横线”3.1 三种编辑器方案的取舍编辑器渲染层有一个经典选择题textarea、contenteditable、自定义渲染。我把它们的核心差异列一下方案输入法兼容光标控制动态样式实现复杂度textarea依赖系统控件事件比较规整只能通过 selectionStart 读取几乎没法给局部字符加样式低contenteditable各家浏览器处理差异大但事件齐全有 Range 对象可精确控制可以在节点里插入任意 DOM中高自定义渲染需要自己接 IME 状态工作量最大完全自己维护完全自由最高选择 textarea 看似简单但它只能显示纯文本没法在一段文字中间给几个字加删除线这直接否了核心需求。contenteditable 则允许我在文本流里插入 span 节点给被删除的字符包一个含有删除线样式的标签。所以 Madeira 的主渲染层用的是 contenteditable再用 TreeWalker 处理光标和节点的映射。3.2 把输入事件吃进来再把删除写成删除线开启 contenteditable 之后浏览器自带光标和选择我只需要监听 input 事件判断当前是插入还是删除。具体到 DOM 结构我会把每个字符或者每段连续的“存活字符”包成这样的层级contenteditable span今天/spanspan classdel写项目/spanspan做复盘/spanspan有点难/spanspan/span /contenteditable当用户删除“写项目”这三个字时我不是直接从 DOM 里把这段文字拿掉而是把它替换成带classdel的 span设置成text-decoration: line-through并降低不透明度。与此同时操作流里追加一条 delete 操作。这样在视觉层面删除变成了“文本仍然存在但明确地被标记为废弃”而不是“文本干脆消失”。这里有个决定要提前做被删除的 span 得设置成contenteditablefalse否则光标移入之后用户又能往里输入操作流和视觉状态就脱节了。处理方式是在每次渲染后清理所有span classdel的编辑属性并确保光标只落在非删除节点上。3.3 渲染的脏检查与局部更新策略刚开始实现时我偷懒每次输入都重新渲染整个文档。几千字以内体验没问题但到一万字以上每次按键屏幕明显闪烁光标还会跳到行首非常难受。于是改成增量渲染策略。思路是维护一个渲染游标每次只在操作流末尾有新增时把新增的那一段文字追加到 DOM 末尾。删除操作则需要回溯到被删除字符的原始位置修改对应节点。删除位置在中间时大多数情况下也只需要把一整段跨度较小的文本替换成“存活字符 删除标记”的组合不需要全量重建。真正的全量重建只发生在两种场景一是从 localStorage 恢复数据后的首次加载二是用户手动点击历史回放按钮。日常输入时的性能问题基本上靠“局部 diff 200ms 节流”就能解决。你要是也做编辑器建议一开始就把增量渲染设计进去不要先全量再优化后者改起来牵扯的边界情况特别多。3.4 文本多起来之后的分片加载写了半个月的笔记之后我的最长文档到了两万字。此时即使增量渲染没问题页面内一次性加载所有 DOM 节点也开始让我感到卡顿。Madeira 做了一件轻量的事把文档按段落切成多个 container初始渲染时只加载靠近光标位置的段落其余段落用 IntersectionObserver 按需懒加载。因为笔记日常是线性书写阅读时从上往下滚动懒加载体验基本无感。不过懒加载会带来一个注意力上的小骗局屏幕上只渲染了你眼睛看到的这一段没看到的地方暂时没有删除线。这不会影响操作流的完整性因为操作流始终在内存里只是渲染层给了自己一点喘息空间。我是接受这个折中的数据完整性和视觉完整性是两件事渲染层可以在视觉上偷工减料但数据层必须绝对真实。4. 长文测试中翻过车的三个技术坑4.1 中文输入法的 composition 幽灵字符第一个坑是中文输入法。用户用拼音输入“你好”时记事本里会出现“nihao”的拼音候选过程。如果我在 compositionstart 到 compositionend 期间直接监听 input 事件会把中间产生的拼音字母当成真实内容写入操作流然后等候选词被确认时又写入一遍“你好”。结局是操作流里多了一整串幽灵字符看起来像“nihaonihao你好”。解决办法是维护一个“正在输入法组合中”的状态位。在compositionstart时设为 true这个阶段所有 input 事件全部忽略不做日志记录也不做渲染在compositionend时一次性读取最终文本算清楚它和上一状态的差异再生成一条完整的 insert 操作。这个不只对中文输入法必要日文、韩文、以及带 preedit 的移动端输入法都会踩到类似问题。还有另一个细节有的浏览器在输入法组合结束后会额外触发一次多余的重绘如果处理不当视觉上会闪一下。我的做法是在 compositionend 的下一帧统一做渲染利用requestAnimationFrame把多次输入事件的渲染合并成一次。场景现象处理方案拼音输入过程出现拼音字母写入日志compositionstart 起忽略输入事件输入法确认候选词最终文本与拼音重复compositionend 时统一计算差异输入法结束后重绘视觉闪烁合并到下一帧统一渲染4.2 CtrlZ 不再是浏览器的 CtrlZcontenteditable 自带原生撤销但它撤销的是浏览器内部的 DOM 变化不理解我的操作流。按下 CtrlZ 时浏览器可能把某个删除线的 span 活生生变回普通文本但日志里根本不知道这条记录。这会导致日志和画面脱离感。最干净的处理方式是最开始就拦截 CtrlZ 和 CtrlY或 CtrlShiftZ调用preventDefault()关掉原生撤销然后用操作流实现自己的撤销栈。撤销的具体逻辑是从操作流末尾向前找最近一条用户发起的 insert 或 delete执行它的逆操作。插入的逆操作是删除删除的逆操作是重新插入。逆操作的显示策略要额外注意——被重新插入的文本不是普通文本而是显示为“曾被删除后又被恢复”的特殊样式颜色比正常文本淡一些但不带中间删除线。这样即使执行了撤销操作流里仍然留着最初的删除记录。它记录的永远是真实发生过的事件而不是用户最终看到的结果。这个决定是我认为 Madeira 能成立的关键之一如果撤销等于抹除那么历史上发生过的事就永远消散了用户迟早会开始钻空子用撤销在心理上抹掉自己的草稿。实现撤销栈时我还处理了一个老坑连续输入应该合并成一条操作而不是每个字符一条否则按一次 CtrlZ 只回退一个字母体验很差。合并的规则是如果两次 input 事件时间间隔小于 500ms、且光标位置连续就合并为同一批操作反之就开一条新操作。4.3 多标签页同时编辑localStorage 的并发假象我一开始默认用户只会开着 Madeira 的一个标签页。后来自己也犯了这个误操作左边写着方案右边重新打开新标签页写同一份笔记。两块数据同时往 localStorage 写互相覆盖最后打开文档时整个数据乱了。localStorage 没有多标签页之间的实时通知机制需要主动做同步。我用BroadcastChannel在多个标签页之间广播写操作让其它标签页即时拉取最新操作流。写入端做了一个很简单的策略最后写入者获胜也就是每个标签页在 commit 前先读一次最新版本把自己的操作追加在最后再把结果写回去。这个策略在单人高频编辑时够用但会出现一种情况标签页 A 在离线几分钟后切回来它本地缓存了一大堆操作一口气追加到最新版本后面可能会让顺序错乱。我只能说如果需要更强的实时协作能力就应该换用后端服务加 CRDT 或者 OT本地 localStorage 并发本质上是“伪并发”能做到不崩已经不错了。作为本地工具我更担心的是数据丢所以每次 commit 都会先写操作流副本再更新索引保证断电时最多丢最后一条操作而不是全盘皆输。4.4 新标签页免打扰的实际边界Madeira 之所以能成为“新标签页里的记事本”是因为浏览器扩展支持chrome_url_overrides.newtab把新建标签页指向本地笔记页面。这带来一个额外的产品收益我每次想“开个新标签页随便看看”时迎面先撞上自己的文档。这种先发制人的注意力提醒比任何待办清单都有效。不过“免打扰”没有想象中那么彻底。一开始我还想做全局通知静默后来发现浏览器对扩展访问其它网站的推送通知非常严格不能也不应该拦截不属于自己的通知。我退了一步只在 Madeira 页面内部做免打扰页面默认不弹任何通知不闪烁标题栏不产生声音同时提供一个手动按钮允许我把当前文档临时设为“可被打扰”比如等一个外部反馈时。这件事我的结论是不要试图抢系统权限去管理其它应用的通知那会让用户既反感又觉得复杂。免打扰的重点是把入口做在文档之前让人第一眼看到的是自己在写什么而不是让所有提醒一夜消失。5. 成为新标签页之后它改变了使用习惯里哪些环节5.1 新标签页提示可被忽视但每次都会出现把编辑器放进新标签页的副作用是它成为我打开浏览器时第一个接触到的界面。想打开 Gmail、想刷信息流都得先看到这一页文档。这里没有任何强迫机制单纯是位置优势。实际体验下来我每天至少有五六次会因为这个“先看见”而顺手写几句话要是在老版本里那些零碎念头可能就随标签页一起关闭了。后来我给新标签页加了一个很小的倒计时显示从上次打开标签页到当前时间写了多少字、删了多少字。本身没有提醒功能就是一个数字。但人就是很吃这套——看到自己某天删掉的字符多于留下的字符次数多了会不自觉地调整思路节奏。5.2 导出功能必须存在的“逃生通道”我很早就意识到如果一个笔记工具永远不让你把内容带走它和监牢没有区别。用了两个星期之后我自己第一个受不了想整理一篇稿子发出去却没有干净文本可复制。于是补了一个“复制为纯文本”按钮它会遍历操作流生成最终文档忽略所有删除线标记只输出真正存活的字符。这个按钮本身又是一个产品判断它让用户可以随时逃离但又不主动提醒逃离。默认界面没有任何导出图标用户需要按住 CtrlShiftC 调出。这个设计是为了避免“为了导出而整理”的冲动。我想通过这个按钮表达Madeira 不反对你离开但它希望你离开时是带着完整内容走的而不是带着删掉的草稿一起走。5.3 删除率统计一个让人脸红的数据做统计是后面加的小功能但我越看越觉得值得写。代码非常简单从操作流里拉一遍就可以了const deletedCount log .filter(op op.type delete) .reduce((sum, op) sum op.text.length, 0);这段代码的输出是我一个月里删掉的字符总数。我第一次看到这个数字时比自己预期的多很多。它让我重新审视一个很基础的问题写作时删除是效率的表现还是逃避的表现不做统计时我往往分不清这两者做了统计后我能看到哪天删得多、哪篇稿子删得少并从中找到节奏规律。我后来在这个基础上做了一点延展把删除字符按时间段汇总成曲线放在文档最下方。它不会弹出不会抢注意力但每次滚动到文末都能看到那条陡峭的鼓起。这种“知道自己删了多少”的感知本身就是我最初做 Madeira 想要的自我觉察。6. 如果你也要做一个“剥离干扰”的工具这是我最想说的6.1 产品上的经验干扰不只是界面而是删除这条默认路径很多人在谈“极简工具”时默认把干扰理解为界面上多余的按钮和配色。但我在做 Madeira 过程中最深的体会是干扰可以藏在交互默认行为里。旧的笔记产品默认“删除即消失”这个默认行为本身就在鼓励我假装某段思考没发生过是一种隐性的干扰。所以当你设计一个类似工具时可以先问一个问题这个工具默认允许我做什么如果默认允许的操作里包含“无痕抹掉自己做过的事”那么用户就始终有一个逃避反思的路径。把删除变成可见的操作相当于把心理逃避的通道焊死了一个口子。这未必适用于所有工具但至少对写作类工具值得一试。6.2 技术上的经验数据层一定要先于界面层设计我在整个项目中得的另一个教训是很多编辑器项目先从 DOM 和 CSS 开始写对着漂亮的文字输入框调样式结果数据模型想不清楚后面追加历史记录和撤销功能就举步维艰。Madeira 的顺序反过来先定好操作流所有界面只是操作流的投影。这个顺序让我在渲染层犯过的那些错都不会扩散到数据层因为数据永远是对的。如果你也想做编辑器我强烈建议先把“一次输入会产生什么数据”想清楚再碰 DOM。光标、样式、性能都可以后面补数据模型错了后面全盘返工。最后再分享一个小技巧本地笔记工具的导出格式最好不要绑死成自己的私有 JSON。我在测试时保存过一份名为madeira-ops.json的备份后来去读它时发现操作流格式虽然结构清晰但对不熟悉这套机制的人来说和乱码没有太多区别。于是我给导出功能同时加了一个“导出为 Markdown”的选项只把最终文档按段落拼成文本。如果你不想让数据被工具本身绑架从一开始就保持“操作流 导出为通用格式”两条腿走路会省去很多事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑