Skip to content

05 · 主题、扩展点与设置 ​

(文件名仍是 05-themes-and-plugins.md —— 内容以「二、扩展点」开头那段说明为准。)

一、主题 ​

1. 为什么主题必须是纯 CSS ​

Typora 生态最有价值的资产就是社区主题。要复制这个效果,门槛必须低到 「会写 CSS 就能做主题」,且主题不能因为应用升级而炸。

因此:

  • 主题 = 一个 CSS 文件(可带资源目录),不是 JS。
  • 编辑器暴露一份稳定的类名与 CSS 变量契约,进 semver 管理。
  • 契约变更走废弃期:旧类名保留至少两个 minor 版本,控制台给出废弃警告。

2. 变量契约 ​

css
: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),开的是这一层:

ts
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,但没有 给它们做界面 —— 现在只能靠自定义主题改。做界面要先决定「它算主题的一部分 还是独立设置」,而那个问题在用户主题目录落地之前没法回答。

MIT 许可发布。与 Typora 无关联,是一个独立实现。