下面按功能列出怎么用和怎么实现的(附对应文件)。
- 打开多个
.md在同一窗口,Ctrl+Tab/Ctrl+Shift+Tab循环切换 - 在资源管理器双击
.md→ 不新开程序,而是在已有窗口加一个标签 - 新建:标签条末尾的 +、顶栏右侧独立的 + 按钮、
Ctrl+N,都调同一个newTab(新建未命名草稿,首次Ctrl+S选位置保存)
实现:
- 主进程
requestSingleInstanceLock()+second-instance事件,把第二次启动的 argv 转发给已有窗口(src/main/index.js) - 渲染层
openPaths()用tabsRef同步快照去重,避免 setState 竞态导致重复标签(App.jsx)
Ctrl+Shift+O打开文件夹,左侧树状浏览;右键可新建 / 重命名 / 复制一份 / 删除 / 导出为 PDF / 在资源管理器中显示- 拖拽移动:把文件/文件夹拖到另一个文件夹(或根目录)即可移动,落点文件夹高亮提示
- 顶部 展开全部 / 折叠全部 按钮一键切换(图标随状态翻转,展开会递归展开所有子目录)
- 活动栏有常驻的折叠 / 展开侧边栏按钮(收起后图标翻转成"展开"样式)
- 在资源管理器右键文件夹 "用 HorseMD 打开" → 作为工作区打开(启动参数支持文件夹路径)
- 外部增删文件会自动刷新树
实现:Sidebar.jsx + 主进程 fs:readDir / fs:duplicate / watch:start(chokidar 监听文件夹,去抖后推 watch:changed);文件夹启动参数见 main/index.js 的 extractArgs()(区分文件 vs 目录,目录走 open-folder)
- 新建 / 重命名输入框带**行内确认(✓)/ 取消(✗)**按钮;失焦即提交(点别处不会丢掉已输入的名字)。
- 重命名时输入框默认选中文件名(不含扩展名),和新建一致(
onFocus里setSelectionRange(0, dotIndex))。- 展开的空目录会显示"空文件夹"提示,而不是一片空白。
- 拖拽移动通过 HTML5 DnD(
draggable+dataTransfer)+ 主进程的重命名/移动 IPC 完成(dropProps()/moveItem())。
WYSIWYG 由 Milkdown Crepe 提供。在它之上自研了改标题层级的多种入口(共用一条 setBlock → convertBlock 路径):
| 入口 | 用法 |
|---|---|
| 键盘 | Ctrl+1…Ctrl+6 设标题、Ctrl+0 转正文 |
| 选中工具条 | 选中文字 → Crepe 工具条里注入的 H 按钮,悬浮展开 H1/H2/H3/¶ |
| 右键菜单 | 编辑区右键 → "转换为" 列出全部类型 |
| 状态栏切换器 | 右下角常驻显示当前块类型,点开可切换 |
| Crepe 原生 | 行首 / 斜杠菜单、行首 # 、左侧块手柄 |
另外有一个跟随光标的浮动块级标记:光标落在标题/正文上时,行旁会浮出当前块类型(H1…H6 / 正文);编辑器失焦或光标滚出视口时自动隐藏。选中工具条的按钮(加粗/斜体/删除线/行内代码/链接)也都带了 tooltip。
实现(Editor.jsx):
convertBlock(view, type, attrs)→view.dispatch(state.tr.setNodeMarkup(pos, targetType, attrs)),作用于光标所在的 textblock- 工具条按钮:用
MutationObserver监听.milkdown-toolbar出现,注入自定义.hm-heading-item,CSS:hover展开子菜单(并覆盖 Crepe 工具条的overflow:hidden以免裁掉子菜单) - 浮动块级标记:监听光标位置/滚动,用
view.coordsAtPos定位到当前行旁;只对标题和段落显示 - 块类型定义集中在
blocks.js,标签文案走 i18n
外部程序(如 agent、其它编辑器)改了正在打开的文件 → 编辑器自动重载,无需手动关开。
实现:
- 主进程对每个打开的文件单独 chokidar 监听(
watch:file),change时推file:changed {path, mtimeMs}(src/main/index.js) - 渲染层
App.jsx为打开的文件挂/卸监听,收到变更后:- 若该标签有未保存修改 → 不覆盖(保护你的编辑)
- 否则从磁盘重载,并 bump
reloadNonce让 Editor 重挂载 - 忽略自己保存产生的回声(比对 mtime)
按住 Ctrl(Win)/Cmd(Mac) 点链接 → 系统浏览器打开。
实现:Editor.jsx 在 view.dom 捕获阶段拦截 click,命中 http(s):/mailto: 链接走 shell.openExternal。
复制内容时,剪贴板 HTML 版本注入内联样式,粘到微信公众号/邮件/Notion 等不读外部 CSS 的地方也能保留格式(加粗、标题大小、行内代码、代码块灰底、引用、表格边框等)。
实现:Editor.jsx 拦截 copy 事件,对选区 HTML 逐元素套用固定浅色配色的内联样式(COPY_STYLES),写入 text/html;CodeMirror 代码块内的复制交还给它自己处理。
 这类相对路径图片,按当前文件所在文件夹解析成 file:// 绝对路径并正常显示。
实现:Editor.jsx 用 MutationObserver 把相对路径 <img> 的 src 改写成 file://。只改 DOM 显示,不动文档模型 —— 保存时磁盘里仍是相对路径,不污染文件。
- 单击图片 → Crepe 原生交互:选中、并可加图片说明(caption)。点说明按钮后会自动聚焦说明输入框,直接打字即可(组件本身不聚焦,需我们补
focus())。 - 双击图片 → 灯箱里放大查看(点背景 / ✕ / Esc 关闭)。
- 图片说明、上传按钮等文案跟随中英文切换。
实现(Editor.jsx):
- 放大用自己的双击判定(同一
img的src在 350ms 内点两次),不用原生dblclick—— 图片是 Vue 组件,单击选中会重渲染,原生dblclick常不触发;详见 implementation-notes.md。判定排除说明输入框 / 说明按钮 / 缩放手柄,避免抢它们的点击。 - 灯箱
.hm-image-lightbox是纯显示覆盖层,不改文档。 - 文案本地化:用
imageBlockConfig/inlineImageConfig,创建时按当前语言设置,切换语言时更新配置并直接改已渲染.caption-input的 placeholder。
- 大纲(
Ctrl+Shift+L):从内容解析标题,点击跳转,随编辑实时更新(Outline.jsx) - 命令面板(
Ctrl+P):模糊搜索文件与命令(CommandPalette.jsx) - 查找(
Ctrl+F):文档内查找,实时显示当前/总数计数,next/prev 即时跳转
查找实现(App.jsx 的 find-in-document helpers):用 CSS Custom Highlight API(CSS.highlights + Highlight)给匹配区间上色 —— 不改 DOM、不插标记节点,所以不会污染文档、也不会触发编辑器重排。只在编辑器正文(富文本 view.dom 或源码 <textarea>)里搜,绝不匹配查找框自己输入的文字;上下一个全在前端完成,无 IPC 往返。当前命中用单独的 highlight 名(hm-find-current)区分高亮。不支持该 API 的环境会优雅降级。
6 套配色:暖光、暖夜、莫兰迪·灰绿 / 豆沙 / 雾蓝 / 暮。右下角状态栏带色块的主题选择器;Ctrl+Shift+T 循环切换。
实现:
themes.js注册表,每套主题 = 一个base(light/dark,驱动 Crepe 明暗规则)+ 可选theme-*类(覆盖调色板变量)applyTheme(id)设置body.className = base [+ ' theme-*']- 调色板变量在
styles/app.css(body.light/body.dark/body.theme-morandi*)
整个界面可在英文/中文间实时切换,默认跟随系统语言。状态栏有 🌐 切换。
实现:
i18n.jsx:STRINGS{en,zh}翻译表 +I18nProvider上下文 +useI18n()的t(key, vars)- 各组件用
t('...')取文案;App.jsx自身用translate(lang, key) - 编辑器占位符通过 Crepe
featureConfigs[Placeholder].text本地化
全新安装首次打开 → 自动弹出本地化的《欢迎使用 HorseMD》文档(介绍软件、功能、快捷键)。只出现一次。
实现:App.jsx 检测 localStorage 无 horsemd.onboarded.v1 且无恢复标签时,把 onboarding.js 的内容作为一个标签打开。
无打开文件时显示欢迎页:Logo + 标题(带版本号,如 HorseMD v0.1.6)+ 标语 + 三个操作按钮 + 最近文件列表 + 快捷键提示。活动栏最上方有一个 App 图标按钮(主页),随时点它回到欢迎页。
实现(App.jsx 的 Welcome 组件 + 活动栏):
- 最近文件:每次打开文件时
remember()记录{path, name, dir, openedAt},去重、上限 8、持久化在会话 - 相对时间
relTime():刚刚 / N 分钟前 / N 小时前 / 昨天 / 日期(本地化) - 点击条目打开文件;没有"清空"按钮(产品决策)
- 版本号:构建时由 Vite
define注入__APP_VERSION__(取自package.json,见electron.vite.config.mjs),和app.getVersion()一致 - 主页按钮:
home状态控制显示欢迎页,但保留已打开标签的编辑器挂载(只隐藏),所以回到文档不会重建编辑器、不卡;点任意标签 / 新建 / 打开 / 大纲跳转 / 查找都会退出主页
新建/空文档第一行是空的一级标题(Typora 式标题),下面带一个空的正文段落。想写标题就写;想跳过标题直接写正文,点一下下面那行(或按 ↓)即可。
实现(Editor.jsx,在设基线前完成、所以新标签不会被标"已修改"):若文档是单个空段落,则把首块转成 H1,并在其后插入一个空段落,光标默认留在标题。
历史:早期只把首行转成 H1、没有正文块,导致"不写标题就没法写正文"(整篇只有一个标题、也没正文块可点),只能"写完标题→回车→才到正文"。补上正文段落后即可跳过标题直接写。
顶栏空白处(含标签条背景)和活动栏空白都能拖动窗口;标签、按钮、输入框可点。
实现:styles/app.css 用 -webkit-app-region —— .topbar / .tabs / .tabs-scroll / .activity-bar 设 drag,.tab / .tab-new / .drag-no / input / .activity-item 设 no-drag。
.md/.markdown/.mdx 用 Crepe 富文本;.txt(及其它带路径的非 Markdown 文件)用 textarea 纯文本编辑。这样:大文件秒开不卡、原始换行保留、不会把 */# 误当 Markdown 语法。新建的未命名文档(无路径)仍是富文本。
实现(App.jsx):isPlainTextDoc(tab)(路径存在且不匹配 MD_DOC_RE)决定每个标签的编辑器;富文本标签首次激活才挂载、之后常驻(懒加载,见 implementation-notes.md 的性能一节),纯文本标签只在激活时渲染(避免后台重型 txt 拖慢应用)。
有些 Markdown 文件几乎没有空行(笔记/转写直接粘进来,几千行连续不空行)。Markdown 会把它们压成几个超大段落、段内有上千个换行节点,ProseMirror 近乎平方级渲染 → 主线程能卡死十几秒(实测一个 81KB 文件冻结 10.2 秒)。
为此 HorseMD 自动识别"重文档",默认用纯文本极速模式打开(瞬间、零卡顿),顶部给一个 「渲染为富文本」 按钮,需要时再按需加载(这时才会有几秒解析 + 骨架屏)。
实现(App.jsx):isHeavyDoc(content) —— 单段落连续非空行 > 150 行,或总长 > 400KB,即判定为重文档(结构而非单纯大小:结构良好的 120KB 文档照常富文本)。heavy 标记在文件载入时算一次存到标签上;richForced(Set)记录用户对某标签的"仍要富文本"选择。重文档默认走 usesTextarea 分支(和 .txt 同款 textarea)。
文档里直接写的 HTML 表格(<table><tr><td>…</td></tr></table>)会渲染成真正的表格,而不是显示成转义后的源码(Typora 也是这个行为)。其它块级 HTML(<div>、<details> 等)同样渲染。
实现(Editor.jsx):Milkdown 默认的 html 节点把内容当转义文本显示。我们给它加了一个 ProseMirror node view(renderHtmlNodeView)渲染真实 HTML:
- 只改显示,不动文档模型 —— 节点仍通过
attrs.value原样进出,保存时磁盘里还是原始 HTML,不破坏文件 - 只对识别到的块级标签(
<table>等,见RENDER_HTML_RE)渲染;零散的内联片段(落单的<b>)退回默认文本显示,避免不闭合标签把版面搞乱 - 渲染前
sanitizeHtml()去掉<script>/<style>和on*事件属性、javascript:链接(在<template>里解析,表格片段能正确解析) - 节点是 atom(不可编辑内部),
ignoreMutation让 ProseMirror 不去 reconcile 渲染出的 HTML - 注册入口:
crepe.editor.config里ctx.update(nodeViewCtx, (v) => [...v, ['html', …]])(在crepe.create()之前)—— 必须走nodeViewCtx($view的同款通道),不能用editorViewOptionsCtx.nodeViews,否则会覆盖图片/代码块/表格等组件的节点视图。详见 implementation-notes.md 的"致命 bug 12" - 样式
.hm-html-block(styles/app.css),表格边框/表头用主题变量,跟随明暗与莫兰迪配色
文件 → 导出为 PDF…(Ctrl/Cmd+Shift+E,或命令面板)把当前文档导成排版干净的 PDF —— 不带编辑器自身的控件(代码块工具条、表格手柄、块手柄、加号按钮等)。
实现:
Editor.jsx的getDocHTML()克隆view.dom,剥掉所有控件类、把 CodeMirror 代码块压平成<pre><code>、清掉 class/style/data-/aria- 属性,返回纯净 HTML- 主进程
export:pdf把这段 HTML + 一套专用打印样式(PDF_CSS)写进临时 HTML,丢进一个隐藏BrowserWindow(webSecurity:false以便加载本地图片),webContents.printToPDF出文件后shell.openPath打开 - 多标签各自挂载(激活过的)下,导出的是当前激活文档:用
editorApis(按tab.id建的注册表)取对应编辑器的getDocHTML,不会串成同一篇
Windows/Linux 下不再用系统原生的标题栏覆盖层,改由渲染层自己画 最小化 / 最大化(还原) / 关闭 三个按钮,带自定义 hover 态(关闭悬浮变红)。macOS 仍用原生红绿灯。
实现:
- 主进程关掉
titleBarOverlay,提供window:minimize/toggleMaximize/close/isMaximizedIPC;并监听maximize/unmaximize推window:maximized给渲染层,让"最大化/还原"图标跟真实窗口状态同步(双击拖拽最大化、系统快捷键都能跟上) - 渲染层
WindowControls(仅platform === 'win32'渲染,App.jsx),图标在icons.jsx(win-min/win-max/win-restore)
关闭未保存提醒:关标签(closeTab)和关窗口/退出都会在有未保存修改时弹本地化确认框。
- 关标签:
App.jsx的closeTab直接window.confirm(confirm.closeUnsaved)。 - 关窗口(macOS 红灯 / Windows 关闭按钮 / Cmd·Ctrl+Q):主进程拦截窗口
close,用allowClose标志,未确认时preventDefault并发app-close-request;渲染层查脏标签,干净或用户确认(confirm.quitUnsaved)后调confirmAppClose()→ 主进程置allowClose=true再关。干净时无弹窗、不卡。详见 implementation-notes.md。
打开大文档(渲染要花点时间)时,先显示一组波动的灰色占位条(骨架屏),加载完自动消失;小文件秒开、不显示。
实现(Editor.jsx + styles/app.css):按内容大小触发(initialContent.length > 8000),而非时间延迟(同一大文档冷/热启动耗时差很多,时间方案不可靠)。骨架 .editor-skeleton 用主题变量 --border-soft、opacity 呼吸式波动,位置和正文对齐。
关键时序:
loaded必须在crepe.create()一完成(内容已进 DOM)就用flushSync同步置 true,而不是普通setState。否则 React 会把它和紧随其后的重活(getMarkdown()整篇序列化 +onChange触发大纲/字数重算)批处理到一起,导致正文已渲染、骨架屏却还压在上面几百毫秒(切换源码↔富文本时尤其明显)。序列化/onChange这步也推迟到下一帧再做,让骨架屏先消失。详见 implementation-notes.md。
启动时查一次 GitHub 最新正式 release(草稿/预发布被该接口排除),若有新版本则弹一个可关闭的"有新版本"提示;关掉后记住,不再骚扰。不在应用内下载/安装。提示里自动展示该 release 的更新说明(GitHub release notes),内容长则在卡片内滚动(细滚动条)。
实现:主进程 update:check 用 net.fetch 打 releases/latest,比对 app.getVersion(),并把 data.body(发布说明)截断后作为 notes 一并返回;渲染层启动时调一次,记 localStorage["horsemd.update.dismissed"](App.jsx)。UpdateToast.jsx 把 notes(Markdown)用纯 React 元素轻量渲染成标题/要点/粗体/行内代码(不注入 HTML,无 XSS),版本号显示为"旧版删除线 → 新版药丸"。
两个文档左右并排、都可编辑。开启方式:
- 标签或文件树右键 → 「在右侧分屏打开」(文件树的还能直接把未打开的文件开到右栏);
- 顶栏的分屏按钮(田字格图标)切换;
- 右栏右上角一个淡色 ✕(悬停显示「关闭分屏」)关闭。
- 中间的 1px 发丝分隔条可拖动调整左右比例(20%–80%,悬停变主题色)。
- 点哪一栏,再点标签栏就切换那一栏的文件(聚焦栏由其标签的强调色下划线标示,另一栏标签为淡色下划线)。
实现(App.jsx + styles/app.css):
splitId= 右栏显示的标签 id;split为派生标志(右栏标签存在、且 ≠ 活动标签、且不在主页);focusedPane('left'/'right')决定标签点击切换哪一栏。- 两栏是
.editor-area(flex 行)里的同级兄弟,靠每个标签的display/order控制显隐 —— 不重新挂载、不重复实例,所以切换/开关分屏不会重建编辑器、不重新解析。 splitRatio+.hm-split-divider(startSplitDrag按鼠标 x 算比例,给左栏设flex-basis)。editorHostRef始终指向左/活动栏(查找、大纲、滚动比例都作用于它);focusedTabRef记录最后获得焦点的栏,让保存/导出作用于你正在编辑的那一栏。右栏不显示全局源码模式。- 聚焦提示走标签下划线(
.tab.active强调色 vs.tab.active.split-peer淡色),编辑区不画任何额外色条,保持简约。
标签页和侧边栏文件树的右键菜单提供一致的文件操作:在右侧分屏打开 · 复制文件路径 · 复制文件名 · 打开所在文件夹 · 重命名 · 创建副本 · 导出为 PDF(md)· 删除。标签页额外有 关闭 / 关闭其他;文件树额外有 新建文件 / 新建文件夹。未保存(无路径)的标签里,依赖路径的项自动置灰。
实现(Tabs.jsx / Sidebar.jsx / App.jsx):
- 标签的文件操作(重命名 / 复制 / 删除 / 导出)在
App.jsx(renameTabFile/duplicateTabFile/deleteTabFile/exportPathToPdf),复用与文件树相同的 IPC(fs:rename/fs:duplicate/fs:delete),完成后 bumprefreshNonce刷新树。 - 重命名走自研的内联弹窗
RenameModal,不能用window.prompt—— Electron 渲染层不支持prompt()(直接抛 "prompt() is not supported",导致重命名静默失效)。window.confirm/window.alert仍可用。 - 复制路径/文件名用
navigator.clipboard+ 一个hm:toast提示;"打开所在文件夹"走shell:showInFolder。
代码块右上角的 「复制」 按钮点击后会闪一个绿色 ✓ 并弹出 「已复制」 轻提示(toast),不再点了没反应。
实现:Editor.jsx 委托监听 .copy-button 点击 → 给按钮加瞬时 .hm-copied 类(CSS 变绿加 ✓)+ 派发 hm:toast 事件;App.jsx 监听该事件显示底部居中 toast(copyText 文案也本地化)。
新建但没保存的临时文档(未命名标签),关掉 HorseMD 再打开还在(标签带"已修改"红点)。
实现(App.jsx):会话持久化里新增 untitled: [{title, content}],只存有内容且脏的无路径标签(未动过的欢迎页/空白页不会反复回来);启动时重建这些标签(savedContent:'' 让它们保持"未保存")。有路径的文件仍从磁盘重开。
持久化防抖 400ms(并在关闭/刷新时兜底刷一次),避免大文档每敲一个字就整篇序列化写盘导致打字卡顿。
右上角图片按钮配置一条上传命令(如 picgo upload)。之后粘贴 / 拖入 / 上传图片会:把图片写到临时文件 → 运行 <命令> "<临时文件>" → 取它打印到 stdout 的图片 URL 插入文档。命令留空 = 保持默认(图片为本地引用,不拦截粘贴/拖入,避免插入刷新即失效的 blob:)。
实现:ImageHostButton.jsx(顶栏 popover,position:fixed 避开顶栏 overflow:hidden;配置后图标带强调色小点)+ Editor.jsx 的 onUpload / 粘贴 / 拖放钩子(代码块内不拦截)+ 主进程 image:upload IPC(临时文件 + exec + 解析最后一个 http(s) URL)。命令存 localStorage["horsemd.settings.v1"]。
状态栏页宽按钮 → 小弹窗:分段预设(窄 / 中 / 宽 / 全宽,选中胶囊滑动)+ 「微调」滑块(像素级)。
实现:StatusBar.jsx 的 PageWidthControl;CSS 变量 --editor-max-width 驱动 .editor-host / .source-editor / 骨架屏宽度,「全宽」用 body.hm-full-width 类(源码模式靠 calc 居中,变量无法单独表达"无上限")。值存 settings.js。
```mermaid代码块下方实时渲染图表(可编辑源码不变)。- 行内
$…$、块级$$…$$($$单独成行)经 KaTeX 渲染;过长的显示公式在列内横向滚动,不溢出。
实现:Mermaid 走 ProseMirror widget 装饰(editor-mermaid.js,不替换 Crepe 的 CodeMirror node view;mermaid 动态 import() 懒加载;装饰 key 含渲染状态,异步渲染完成后替换占位)。公式启用 CrepeFeature.Latex(默认关),KaTeX/latex 样式随主题 CSS 已打包;.katex-display { overflow-x:auto }。
把 .css(或整个下载来的主题文件夹)丢进主题文件夹,状态栏主题菜单的「自定义」区即可选用;另有「打开主题文件夹」「获取更多主题」(theme.typora.io)。Typora 主题可直接迁移。
实现(main themes IPC + customThemes.js + StatusBar.jsx):themes:list 递归扫描子目录(Typora 主题常是文件夹);themes:read 把相对 url(...) 改写成绝对 file://(字体/图能加载);CSS 注入到一个 <style>,编辑器内容带 Typora 的 #write / markdown-body 钩子;激活时(body.hm-has-custom-theme)正文区背景/宽度与文字 color:inherit 让位给主题,应用外壳保持自身风格;applyTheme 保留 hm-* body 类(切主题不丢全宽/自定义标记)。选择存于会话(customTheme)。
表格单元格内按 Enter / Shift+Enter 换行,保存为 <br>(GFM 表格仍是单行,不损坏),重开能正确解析回换行。
实现(editor-tablebreak.js,接入 Editor.jsx):keymap 在单元格内插入 hardbreak(渲染为 <br>);自定义 remark stringify break 处理器仅在 tableCell 上下文输出 <br>(其它走默认,段落换行不变);remark 解析插件把内联 <br> 转回 break(顺带修了"单元格 <br> 被丢")。
Markdown 表格渲染更紧凑:去掉单元格内段落的上下 margin、收紧内边距与行高(单行行高约从 84px 降到 45px),并对超列宽内容/行内代码自动换行(word-break),不再与相邻列重叠。
| 操作 | 快捷键 |
|---|---|
| 新建 / 打开文件 / 打开文件夹 | Ctrl+N / Ctrl+O / Ctrl+Shift+O |
| 保存 / 另存为 | Ctrl+S / Ctrl+Shift+S |
| 导出为 PDF | Ctrl+Shift+E |
| 关闭标签 / 循环标签 | Ctrl+W / Ctrl+Tab |
| 命令面板 / 查找 | Ctrl+P / Ctrl+F |
| 侧边栏 / 大纲 | Ctrl+B / Ctrl+Shift+L |
| 源码模式 / 主题 | Ctrl+/ / Ctrl+Shift+T |
| 标题层级 / 正文 | Ctrl+1…6 / Ctrl+0 |
注:
Ctrl+B现在固定用于切换侧边栏(不再触发加粗);加粗请用选中工具条的 B 按钮或**文字**语法。