05 · 主题、扩展点与设置
(文件名仍是 05-themes-and-plugins.md —— 内容以「二、扩展点」开头那段说明为准。)
一、主题
1. 为什么主题必须是纯 CSS
Typora 生态最有价值的资产就是社区主题。要复制这个效果,门槛必须低到 「会写 CSS 就能做主题」,且主题不能因为应用升级而炸。
因此:
- 主题 = 一个 CSS 文件(可带资源目录),不是 JS。
- 编辑器暴露一份稳定的类名与 CSS 变量契约,进 semver 管理。
- 契约变更走废弃期:旧类名保留至少两个 minor 版本,控制台给出废弃警告。
2. 变量契约
:root {
/* 排版 */
--mosu-font-body: -apple-system, "Segoe UI", "PingFang SC", sans-serif;
--mosu-font-mono: "JetBrains Mono", Menlo, Consolas, monospace;
--mosu-font-size: 16px;
--mosu-line-height: 1.7;
--mosu-content-width: 40em;
/* 颜色(语义化,不是"blue-500"这种) */
--mosu-bg: #fff;
--mosu-fg: #24292f;
--mosu-fg-muted: #6e7781;
--mosu-accent: #0969da;
--mosu-border: #d0d7de;
--mosu-code-bg: #f6f8fa;
--mosu-selection: #b4d5fe;
/* 编辑器专属 */
--mosu-marker-fg: #b0b6bd; /* 显形的 Markdown 标记 */
--mosu-cursor: var(--mosu-fg);
--mosu-focus-dim: 0.35; /* 专注模式下非当前块的不透明度 */
/* 注意:0.35 下变暗的正文只有 2.1:1,达不到 WCAG AA。
内置样式在 `prefers-contrast: more` 下把它覆盖成 1,主题作者若要重定义
这个变量,请一并保留那条 media query。取舍的完整理由见 07 §3.1 */
}深色模式:主题在 @media (prefers-color-scheme: dark) 与 [data-theme="dark"] 两处都提供覆盖(后者用于用户手动切换)。
3. 类名契约
内容区根节点 .mosu-content,所有块级元素带 .mosu-<type>:
.mosu-content
├── .mosu-heading.mosu-h1 … .mosu-h6
├── .mosu-paragraph
├── .mosu-list.mosu-list--ordered / --bullet / --task
├── .mosu-blockquote
├── .mosu-code-block[data-lang="ts"]
├── .mosu-table
├── .mosu-math.mosu-math--block / --inline
├── .mosu-image
└── .mosu-hr状态类:.mosu-active(光标所在块)、.mosu-source-visible(标记显形中)、 .mosu-widget-editing。
打印样式是契约的一部分:主题必须在 @media print 下可用,导出 PDF 直接复用(见 06)。
4. 主题包格式
my-theme/
├── theme.json { name, version, author, license, variants: ["light","dark"], apiVersion }
├── theme.css
└── assets/ # 字体、背景图(相对路径引用)用户主题放 <userData>/themes/,编辑器热加载(改 CSS 立即生效,方便调试)。 自带主题:Light / Dark / Sepia / High Contrast / GitHub 风格。
主题 CSS 加载时同样受 CSP 约束:不允许 @import 远程地址,url() 限定在主题包内。
二、扩展点
这一节在 2026-08 被整个换掉了。 原来它叫「插件」,写的是 manifest、
PluginContext、权限声明与插件市场。v1 不做开放插件系统了,理由与重估条件在 ADR-0006。原来那份设计没有删, 它在 ADR-0004 里,插件回来的那天还要用。文件名仍然是
05-themes-and-plugins.md。这是刻意的 —— 改名会让文档站上 已经存在的 URL 404,而 GitHub Pages 上没有便宜的重定向。名不副实的是文件名, 内容以这一节为准。
1. 现在有哪些扩展面
判据只有一条:不需要执行第三方代码。
| 扩展面 | 形态 | 现状 |
|---|---|---|
| 主题 | 一个 CSS 文件 + 变量与类名契约 | ✅ 内置六套;用户主题目录与热加载仍未做(见三 §6) |
| 命令与快捷键 | 命令注册表 + 用户可改的绑定 | ✅ 见三 §5 |
| 导出 | HTML(自包含单文件)、PDF | ✅ 见 06 |
| 导入 | 粘贴 HTML → Markdown | ✅ 见 03 §8 |
| 文档模板 / front matter 模板 | 一份用户目录里的 .md | ⬜ 没做 |
| 命令行入口(打开、导出、自动化) | 一个可执行入口 | ⬜ 没做 |
后两行别读成「计划中」—— 它们只是属于这一类,今天一行都没有。写在这里是 因为它们该走哪条路已经清楚了(都不需要执行第三方代码),而不是因为排上了期。
2. 将来要开放的是 Tool API,不是 Plugin API
真到了要开放的那天(ADR-0006 D6),开的是这一层:
registerCommand() // 已经有内部版本,见三 §5 的命令注册表
registerExporter() // 06 的导出管线已经是这个形状,只是没暴露
registerRenderer() // 块级 widget(数学、Mermaid)走的就是它
registerAITool() // 见 10 §2特点是权限可控、生命周期简单、不暴露内部实现 —— 开出去的是「注册一个能力」, 不是「这是 EditorView,你随便」。
最值得记的一条是它跟 AI 的关系:registerAITool() 与 10 §2 的 Tool Registry 是同一样东西的两端。 AI 是这套 Tool API 的第一个消费者,而且是内部消费者。
这恰好接上了 05 原来那条被撤回的原则 —— 「内置的数学 / 图表 / 脚注自身就用插件 API 实现,保证 API 不是二等公民」。那条原则本身没有错,错的是它挂在插件上。 现在它挂在 Tool Registry 上,而且比原来结实:插件 API 是先设计再等人用, Tool Registry 是先被自己用熟再决定要不要开放。等到有人来问「怎么给 Mosu 写 扩展」的时候,这套 API 已经被真实需求磨过一轮了。
3. 语法扩展的三件套契约还在
03 §2 那份「Lezer + mdast + 序列化,缺一不可」的契约不随插件系统一起推后。 它约束的是「一个语法怎么加进来」,而内置语法(脚注、front matter、数学) 今天就是照它加的。
区别只在于现在没有第三方来注册。契约留着的价值是:它逼着每个新语法在动手前 先回答「序列化怎么写回去」,而那正是格式保真(G2)会坏在的地方。
4. 不做插件之后,用户会明确缺什么
不粉饰,列清楚:
- Wiki 链接
[[x]]、标签#tag—— 原来标着「由插件提供」,现在是明确不做 (知识库是 00 §3 的非目标); - 图床上传 —— 需要「渲染进程能往任意域名发请求」,而 CSP 是
connect-src 'self'(01 §6)。原来这条指望插件带network权限来解决, 现在没有提供方了。粘贴图片仍然走本地附件目录(04 §5); - 导出到 Anki / Notion / 任意第三方这类长尾格式;
- 社区自制的语法扩展。
主题不在这个清单里 —— 主题从来就是纯 CSS,不受影响。
三、设置
1. 现在是手写的,不是 schema 驱动的
M4.5 给「设置界面」的判词是:「用 schema 自动生成表单实质上是另起一个项目: 条件显隐、校验、分组、搜索、重置、迁移。」 这话没错,但它描述的是一个有几十 上百项设置的清单 —— 而那个清单当时不存在(整个应用只有一条 appearance.theme)。
这里原来接着一句:「让清单存在的是插件(manifest 里那句 "settings": { JSON Schema }),所以通用化的正确时机是 M5。」那个前提没有了 —— 插件系统推后了 (ADR-0006)。
结论没变,但要换个理由,而且新理由更弱:M5 的 AI 会带来一批新设置 (provider、模型、每个功能的开关、上下文范围),十几项的量级,不是几百项。 十几项仍然不值得造一个表单引擎。 把这一节改准的意义在于:下一个人不会照着 一个不存在的插件 manifest 去动手做通用化。
现在的做法:一个 Preferences 类型、一张默认值表、一个逐字段的校验器。 字段就这么几个,而手写的校验器能说清楚为什么这个范围是这个范围,schema 说不清。
AI 的凭据是这里唯一的例外:它不进 settings.json,走 safeStorage 且只在 main, 理由见 10 §5。
2. 校验不是可选项
settings.json 就在用户数据目录里,明摆着让人直接改 —— 这是「不锁定用户数据」的 一部分,不是疏漏。所以读进来的每个值都可能是任何东西:字符串、null、数组, 或者手滑打成 "A4 "。
三条规则:
- 一个坏值只作废它自己,其余字段照常生效。整份设置因为一个手滑而回到出厂状态, 比那个手滑本身糟糕得多;
- 范围类的值夹住而不是拒绝。页边距填 0 或者填很宽都是正当需求, 只有超出纸张物理限制的才算错;
- 读不出来不报错、不提示。设置项是锦上添花,为它挡在启动路径上不值当。
main 侧再兜一次底:PDF 的页边距在 renderPdf 里又校验了一遍。 渲染进程已经验过,但 main 不信任传进来的任何东西(01 §5),这条不因为「自己人传的」 而放松。
3. 面板是当前窗口里的浮层,不是新窗口
开窗口意味着再来一份渲染进程入口、一套自己的主题初始化、一条跨窗口同步设置的通路 —— 而设置项现在只有六条外加一张快捷键表,那些管道比它们要装的东西还重。
交互沿用命令面板那三条:Esc 与点遮罩都能关、关掉之后焦点回编辑器、浮层对读屏软件 是一个 role="dialog"。
没有「确定 / 取消」,改一下就存一下。 设置项之间没有依赖,也没有「一批改动要 一起生效」的语义。给一个确定按钮只会多出一种状态(改了但没提交), 以及一个必须回答的问题:关掉浮层算确定还是算取消。
4. 当前有哪些设置
| 设置 | 作用范围 | 存在哪 |
|---|---|---|
| 主题 | 立即,全窗口 | appearance.theme |
| 界面语言 | 立即,两个进程(面板 + 原生菜单) | ui.language |
| 新标签页默认进源码模式 | 只影响新建的标签 | editor.sourceModeByDefault |
| 专注模式 | 立即,所有已开标签 | editor.focusMode |
| 打字机模式 | 立即,所有已开标签 | editor.typewriterMode |
| 渲染行内 HTML | 立即,所有已开标签 | editor.renderInlineHtml |
| 快捷键 | 立即(重建原生菜单) | keys.* |
| 导出 PDF 的纸张 / 方向 / 页边距 | 下一次导出 | export.pdf.* |
三种作用范围,每一种都是**按「用户按下去时期待发生什么」**定的,不是按「哪种 实现更一致」:
- 只影响新建的标签(源码模式):改一个设置就把所有已打开标签的视图切一遍 是很吓人的行为 —— 用户正在读的那篇文章会突然变成源码。
- 立刻作用到所有标签(行内 HTML):用户勾掉它就是想现在看见自己文件里到底 写了什么。
- 立刻,而且是全局状态(专注 / 打字机):源码模式回答「这一篇怎么看」, 而这两个回答「我现在处在什么工作状态」—— 它不该因为切了个标签就消失。
界面语言那条是唯一不走 PreferenceStore 的:那份偏好是纯渲染进程的东西, 而语言两个进程都要看(原生菜单在 main 侧)。它直接落在 settings.json 的 ui.language 上,main 在 settingsSet 里看见这个键就重建菜单 —— 一次写入, 两边都对。详见 07 §4。
5. 快捷键编辑
第一步不是做界面,是把三份绑定合成一份。 在此之前同一个绑定写在三个地方: main/menu.ts 的 accelerator(CmdOrCtrl+Shift+K)、渲染进程给命令面板显示的 那份(Mod+Shift+K)、以及 CodeMirror 的 keymap(Mod-Shift-k)。格式互不相同, 改一处漏两处是必然的 —— 而「让用户能改」意味着三份都要跟着变。
现在唯一的真相是 shared/keys.ts,它同时被 main 与渲染进程 import, 并负责翻译成那三种目标格式。几条不那么显然的决定:
Mod与Ctrl是两个不同的东西。Mod在 macOS 上是 ⌘、别处是 Ctrl;Ctrl到哪儿都是 Control。切标签用Ctrl+Tab,macOS 上也不该变成 ⌘Tab —— 那是系统的。- 修饰键顺序被规范化。 冲突检测靠字符串相等,
Shift+Mod+K与Mod+Shift+K不归一就会漏掉真冲突。 - 没有修饰键的组合一律拒绝(功能键除外):那会把一个字符键抢走, 用户再也打不出这个字。
- 录制而不是让用户敲字符串。 用户知道自己想按什么,未必知道该怎么拼它。 录制期间吞掉所有按键 —— 否则你想绑 ⌘S,结果文档存了一次。
- 冲突只警告,不阻止。 两条命令共用一个键在别的应用里也常见(上下文不同时 各自生效),我们判断不了用户的上下文,所以只把事实摆出来。
- 改完立刻重建整个原生菜单。 Electron 没有「改一个 accelerator」的接口, 而重建是几毫秒的事。这一步不能省:真正拦住按键的是菜单 (菜单加速键优先于网页),界面显示新的、菜单还挂着旧的,是这个功能最可能的坏法。
- 解绑与恢复默认是两件事。 设置里存空串是「这个命令从此没有快捷键」, 把键删掉才是「回到出厂」。两件事用户都要做得到。
明确不可配置的:Tab / Enter / Backspace。它们不是「某个功能的快捷键」, 而是编辑行为本身 —— 表格里的 Tab 是「下一个单元格」,列表里的 Enter 是「续写 标记」。让用户把 Enter 改掉,等于让他把换行改掉。另外「新建窗口」(⌘N)也没进来, 它是 main 自己执行的窗口动作,不走命令表。
6. 还没进来的
- 用户主题目录与热加载(M4 顺延项)。它要新增一条「读 userData 下的任意 CSS 并注入」的通路,涉及 CSP 与路径白名单,跟内置主题不是一个量级。
- 附件目录名。现在写死
assets。改它要动附件落盘那条路径的校验 (目录名不能含分隔符、不能是..),跟设置面板本身无关,单独做更稳妥。 - 字体与字号。变量契约里有
--mosu-font-body/--mosu-font-size,但没有 给它们做界面 —— 现在只能靠自定义主题改。做界面要先决定「它算主题的一部分 还是独立设置」,而那个问题在用户主题目录落地之前没法回答。