一个编辑器该有什么:WriteFlow功能设计手记

前面的文章聊了技术选型、ProseMirror踩坑、Electron打包。这篇换个角度,聊聊产品本身:WriteFlow作为一个Markdown编辑器,到底做了哪些功能,每个功能的设计取舍是什么。
WriteFlow定位是Typora的平替。这意味着功能范围已经很明确了:打开md文件、编辑、保存、所见即所得。
但”定位明确”不代表”功能简单”。一个编辑器从能用到好用之间,隔着很多看不见的设计决策。
比如,要不要支持多标签页?
Typora本身是单标签的,一次只编辑一个文件。但很多人习惯了VS Code的多标签页体验。WriteFlow选择了多标签,代价是状态管理复杂度直接翻倍。
多标签页:状态管理的暗涌
单标签编辑器的状态很简单:一个EditorView,一个文件路径,一个dirty标记。
多标签就不一样了。每个标签有自己的EditorState、文件路径、滚动位置、dirty状态。切换标签的时候,要先保存当前标签的状态,再恢复目标标签的状态。
// 切换标签的核心逻辑const switchTab = useCallback((id: string) => { if (id === activeTabId) return // 保存当前标签的状态 saveCurrentState() const targetTab = tabs.find((t) => t.id === id) if (!targetTab || !editorViewRef.current) return // 恢复目标标签的状态 editorViewRef.current.updateState(targetTab.editorState) const container = editorViewRef.current.dom.parentElement if (container) { container.scrollTop = targetTab.scrollPosition } setActiveTabId(id)}, [activeTabId, tabs, saveCurrentState])
注意这里保存了滚动位置。很多人做编辑器会忽略这一点:用户在A标签滚动到中间位置,切到B标签再切回来,如果滚动位置丢了,体验会很差。
另一个容易忽略的:dirty检查。关闭标签的时候,如果内容有未保存的修改,需要提醒用户。但这个检查要在渲染层做还是在状态层做?WriteFlow选择了在渲染层用confirm对话框,简单粗暴但有效。
自动备份:3秒防抖
这是WriteFlow我最喜欢的功能之一。
写东西的时候,最怕的不是写错,而是写了一大段之后软件崩溃,什么都没了。Typora有自动保存,但逻辑不太透明。WriteFlow做了一个更显性的自动备份机制:
// 内容变更后3秒自动备份useEffect(() => { if (!isDirty || !editorState) return if (timerRef.current) { clearTimeout(timerRef.current) } timerRef.current = setTimeout(() => { doBackup() }, 3000) return () => { if (timerRef.current) { clearTimeout(timerRef.current) } }}, [editorState, isDirty, doBackup])
设计要点:
防抖3秒。 用户快速打字的时候,不需要每次按键都备份。等用户停顿3秒,再触发备份。这样既保证了数据安全,又不会频繁写磁盘。
备份和保存是两回事。 备份写到app userData目录下,对用户透明。用户手动保存(Ctrl+S)的时候,才写到原始文件路径。保存成功后清除备份。
启动时静默恢复。 如果上次没保存就关了软件,下次启动时自动恢复备份内容,不需要用户手动操作。
备份key的设计。 每个文件路径生成一个唯一的备份key:文件名 + 路径hash。这样即使文件名相同但路径不同,也不会冲突。
大纲导航:不只是”列表”
侧边栏的大纲导航看起来简单,实际上有几个设计选择:
遍历方式。 用ProseMirror的 doc.descendants 遍历整个文档树,找到所有heading节点。这是最简单也最可靠的方式。
export function extractOutline(doc: Node): OutlineItem[] { const items: OutlineItem[] = [] doc.descendants((node, pos) => { if (node.type.name === 'heading') { items.push({ level: node.attrs.level as number, text: node.textContent, pos }) } }) return items}
点击跳转。 点击大纲项时,不是简单地滚动到对应位置,而是用ProseMirror的 TextSelection.near 设置光标位置,这样跳转后光标就在那个标题下,用户可以直接继续编辑。
层级缩进。 H1、H2、H3用不同的缩进展示,用CSS处理,不需要额外逻辑。
字数统计:中英文分开算
字数统计看起来是个小功能,但中英文混合的内容怎么算,有讲究:
export function countWords(doc: Node): { words: number; chars: number } { let text = '' doc.descendants((node) => { if (node.isText) { text += node.text + ' ' } }) const chars = text.replace(/\s/g, '').length // 中文每个字算一个词 const cjkMatch = text.match(/[一-鿿㐀-䶿豈-]/g) const cjkCount = cjkMatch ? cjkMatch.length : 0 // 英文按空格分词 const withoutCjk = text.replace(/[一-鿿㐀-䶿豈-]/g, ' ') const englishWords = withoutCjk .split(/\s+/) .filter((w) => w.length > 0).length return { words: cjkCount + englishWords, chars }}
中文字符单独匹配,每个算一个”词”。英文用空格分词。两者加起来就是总词数。字符数则去掉所有空白符。
这个算法不完美,比如标点符号算不算字符、数字怎么处理,但没有完美的通用方案。对于写作场景,这个精度足够了。
拖拽打开文件
这个功能代码不多,但体验提升很大:
const onDrop = async (e: DragEvent) => { e.preventDefault() const files = e.dataTransfer?.files if (!files) return for (let i = 0; i < files.length; i++) { const file = files[i] if (!file.name.match(/\.(md|markdown|txt)$/i)) continue const content = await file.text() openFileTab(file.path || file.name, content) }}
用户拖一个md文件到编辑器窗口,直接在新标签页打开。支持一次拖多个文件。只接受md/markdown/txt后缀,其他文件忽略。
用AI写这些功能是什么体验
坦白说,上面这些功能,大部分是AI帮我写的。
多标签页的状态管理、自动备份的防抖逻辑、大纲的遍历和跳转、字数统计的中英文处理、拖拽打开文件——这些代码的第一版都来自AI。
但”AI写的”和”AI帮我写的”是两回事。
我的做法是:先想清楚要做什么,用自然语言描述需求,然后让AI生成代码。生成之后,审查每一行,理解它在干什么。如果有不合理的地方,手动改掉。
比如自动备份的防抖时间,AI第一版写的是5秒。我觉得太长了,改成3秒。
字数统计的正则表达式,AI第一版用的 /[\u4e00-\u9fff]/g 只覆盖了基本汉字。我扩展到 /[一-鿿㐀-䶿豈-]/g,覆盖更多CJK字符。
标签页切换时保存滚动位置,AI第一版没有这个逻辑,是我自己加的。
AI能帮你写代码,但产品设计的判断还是得你自己来。
还没做的
写这篇文章的时候,WriteFlow还有不少功能没做:没有搜索替换、没有主题自定义、没有Markdown导出为其他格式、没有协同编辑、没有版本历史。
每个功能都能做,但每个功能都有机会成本。一个14年程序员转型做独立开发,最大的体会就是:不是所有功能都值得做,选择不做什么,比选择做什么更重要。
GitHub:
https://github.com/jaqen6688/WriteFlow
这个系列到这里就先告一段落了。后面WriteFlow如果有重大更新,我会单独写文章分享。
我在「全栈生涯」等你,关注就能找到。
夜雨聆风