` 会还原成 `[文字](URL)`(反向对照表的既定写法)。另外,已生成的块只有在外层标签**仍留在「支持的标签」里**时才有放大图标、才能被 `Unpopup` 认出来:把 `p` 从列表里删掉后,之前生成的 `` 块会同时失去图标与反向还原能力,加回来即恢复。
- **右键菜单入口以桌面端为准**:两个命令本身在移动端可用(命令面板 / 移动工具栏),但部分移动端路径下核心不触发 `editor-menu`,右键菜单不一定出现。
- **列表与表格转换的已知限制**:① 分隔线(`- - -` / `* * *` / `---`)与「标记后没有空白」的写法(`-test`、`*斜体*`)按原文保留、不按列表转换;② 首行缩进 4 空格以上的选区仍被既有守卫整体拒绝,所以「一段本来就缩进 4 空格的子列表」也照样会被拒;③ 关闭设置里的「渲染 HTML 与 Markdown」后,列表退化成纯文本(`innerText` 不含项目符号与复选框),复选框在 HTML 块里**点不动**(带 `disabled`)。表格侧还有:④ **列表项内部的表格**(`- a` 下面缩进的表格)不转换 —— 既有列表处理本就不认「项里的块级内容」,保持一致;⑤ 表格的**单元格里不能有换行**(`
`、嵌套列表 / 表格),HTML 侧遇到这类结构整段保留、不还原;⑥ 合并单元格(`colspan` / `rowspan`)在 Markdown 表格里表达不了,反向时整段保留;⑦ 关闭「渲染 HTML 与 Markdown」时,表格的纯文本形态是「单元格文字被换行 / 制表符平铺」(走 `innerText`,既有行为)。
- **标题转换的已知限制**:① **Setext 标题**(`S` + `===` / `---`)不转也不还原 —— 往返必然变形,且 `===` / `---` 与分隔线、表格分隔行共享字符;`S` + `===` 在弹窗里落在一个段落里(`===` 是字面文本),所以不转的代价只是形态没升级、内容不会被吞。② **列表项里的标题**(`- # T`)不转换,`#` 保持字面 —— 既有列表实现只认「项内容 = 一行行内文本」,与「表格放在列表项里」同一条边界。③ `> #### T` 这类**引用行里的标题**留给原文(弹窗里 `>` 会被序列化成 `>`,本来就不会变成引用)。④ 标题的**尾部闭合串不保留、分隔空白与缩进会被归一**(见上面的归一化清单),所以「本来就是标题的文本」过一次转换后不是逐字节相同。⑤ 选区中间夹着**未闭合的代码围栏**(```)时后文会被吃进代码块 —— 既有观察,与标题无关,不在本次范围。⑥ 表格「必须起一个块」这条前置条件**只放宽了「上一行是 ATX 标题」**一种;分隔行之后、列表行 / 引用行之后仍然不判表格(与既有「宁可漏判」一致)。
- **手写的畸形 HTML 不会被修复**:`Unpopup Selected Text` 找不到外层标签的闭合标签时直接拒绝,不猜、不改。
- **块内已嵌入的 `![[笔记]]` 嵌入块**:这类内容在编辑器里已被核心处理成交互式嵌入节点,回灌渲染后只保留外观、失去交互(不会重新加载被嵌入的内容)。属于已知限制。
- **Canvas 的边界(真机实测,V119 起)**:① 弹窗里的画布是**自研只读快照**,**不追求与核心画布像素级一致** —— 字体层级、核心的 `--zoom-multiplier` 那一套、组的背景图、边的曲率细节都不做(唯**节点内文字的垂直对齐**与核心同口径:内容不足节点高时居中、内容多时从上排);② **没有任何写回路径**:拖拽节点 / 双击文字都不会改动 `.canvas` 文件,也没有选中、编辑、缩放画布本体、加卡片、右键菜单这些能力(弹窗的定位是只读浏览);③ 画布**解析不到文件**(被删 / 改名 / 找不到)时既不挂图标也不产候选(不会有点不开的死图标),JSON 坏掉或一个节点都画不出来时回退成**文件名纯文本**、不是空白屏;④ 快照**只缩不放**(小画布按原尺寸居中),节点文字**固定 16px** 不跟随弹窗字号(节点盒宽高来自画布 JSON,字号一变文字就溢出被裁);⑤ 节点里的 `[[链接]]` 点击会**导航当前窗口**(与核心画布一致),弹窗本身不关;⑥ 组背景图、画布内嵌画布不做;⑦ **Callout / 引用块内**嵌的画布、以及**嵌入笔记**里的画布仍由外层那一条覆盖(弹窗里出现的是核心的缩略图),与图片那两处同一条边界;⑧ 笔记卡片的「缩小至标题 / 块」**只读不回显**:弹窗里不提供「缩小至…」入口(那要么写进画布文件、要么往笔记里写块 id,与快照零写回冲突),想改就去画布里右键改,弹窗跟着变;非法 subpath(不以 `#` 开头,只可能来自手改的 `.canvas`)**忽略、退回整篇**,不会渲染成一串未解析文字。
- **Canvas 与普通图片在候选层就是两类**:`![[x.canvas]]` 归 `canvas`、`![[x.excalidraw]]` 仍归 `image`(它走「同名 PNG/SVG 图片回退」),所以「放大 Canvas」与「放大图片」是两个独立开关 —— 关掉前者不影响图片与 Excalidraw。
- **图片既是「区块内容」也可以是「区块本身」**:块内只有一张图片时(例如手写 HTML 块里只放了一张图),本插件弹的仍是文字弹窗、内容就是那张图,而不是图片 lightbox(lightbox 由 Obsidian 原生的「放大」图标提供);而图片**作为独立区块**时(见「用法 → 图片」)本插件给它在原生「放大」图标左侧加了一个自己的图标,点它也走本插件的弹窗。
- **弹窗里的图片不受「字号 / 缩放」影响**:图片不吃 `font-size`,所以底部控制条的缩放对图片是无效的(对文字与 mermaid 图生效)。图片按 `max-width: 100%` 收缩,不会撑破内容区;需要更大就看原生查看器。
- 富文本渲染是**异步**的:内容极大的块打开时会先短暂显示空白容器再填充;数学块首次打开还要先动态加载 MathJax,可能多等一下(弹窗会等到排版完成,不会显示错误内容)。
- 弹窗里的**缩放是按比例作用于字号**的(50%–400%),这样内容会自然重排,不会被裁切。「恢复默认」会把字号还原成设置里的值、缩放还原成 100%。
- 弹窗尺寸固定为**应用窗口的 100%×100%**,目前不提供半屏 / 自定义尺寸的选项。
- **方向键只在点击时的那个窗格内切换**:分屏并排打开两个都含可放大区块的笔记时,在哪个窗格点开图标就只在那个窗格内切换,不会跨到另一个窗格;笔记里只有一个可放大区块时,方向键不产生任何变化(标题栏也不带序号)。
- **方向键只占用 `←` / `→`**:`↑` / `↓` 仍是滚动正文,内容长的块不会被抢走键盘滚动。也不提供控制条上的「上一个 / 下一个」按钮。
- 弹窗打开期间若在别处编辑同一个笔记,弹窗里仍是**打开那一刻**的候选与正文(打开时取一次快照);关闭后重新打开即为最新内容。某一条为空时不会切到空白屏。
## 开发
```bash
npm install
npm run dev # 监听编译
npm run build # 类型检查 + 生产构建
npm run lint # ESLint(含 obsidianmd 规则)
```
产物为插件根目录的 `main.js`,与 `manifest.json`、`styles.css` 一起由 Obsidian 加载。
### 代码结构
```
src/
├── main.ts # 生命周期:加载设置、挂设置页、注册扫描器
├── settings.ts # 设置接口 + 默认值 + 声明式设置页(读 / 写 / 副作用都是纯函数)
├── tags.ts # 标签注册表(手写 HTML 块的标签闸门)
├── blocks.ts # 按笔记文本扫出八类可放大区间(候选集的事实来源)
├── commands.ts # 入口层:两个转换命令 + `Show Popup In The Note` 命令 + 编辑器右键菜单
├── scanner/
│ ├── index.ts # 门面:注册扫描 + 同步各处注入点 + 移除按钮
│ ├── shared.ts # 扫描锚点选择器、分类与开关判据、共享类型
│ ├── inject.ts # 四处 DOM 注入点(.cm-embed-block / .code-block-flair / .image-embed / 表格)
│ ├── session.ts# 候选集组装 + 定位起点 + 打开弹窗(点图标 / 命令共用)
│ └── quote.ts # 引用块的图标:CM6 装饰器(唯一不走 DOM 注入的一处)
├── extract.ts # 提取纯文本(回退)与富文本渲染输入(去注入节点 / 去缩进)
├── canvas.ts # Canvas 只读快照:画布 JSON → 模型(纯函数)+ 模型 → DOM
├── modal.ts # 放大弹窗(候选切换 + 富文本渲染 + 回退 + 字号 / 缩放控制条)
└── lang/ # 多语言(en / zh-cn / zh-tw)
```
### 实现要点
**设置页是声明式的,取值 / 存值 / 副作用各只有一处。**
- 设置页由 `TextPopupSettingTab.getSettingDefinitions()` 返回 3 组 17 条定义,核心(1.13+)据此渲染,并把 `name` / `desc` 编进**设置搜索**索引 —— 这正是 `manifest.json` 的 `minAppVersion` 必须 ≥ `1.13.0` 的原因(`display()` 已标 `@deprecated`,不再保留第二套命令式设置页)。
- 控件的读 / 写统一走 `settings.ts` 导出的纯函数 `readSettingValue` / `writeSettingValue`;「改完这个键要刷新什么」只有 `settingSideEffects` 一张表:`refreshActions` → `refreshTextPopupActions`、`quoteActions` → `notifyQuoteActionsChanged`、`rebuildDefinitions` → `this.update()`。**改字号 / 改颜色不触发任何扫描**(见上文引用块那条自激教训),由单测钉住。`writeSettingValue` 会返回「值有没有真的变」,值没变就不保存、不刷新。
- 颜色两行用**虚拟键**(`popupBackgroundFollowTheme` / `popupTextFollowTheme`)表达「跟随主题」,`data.json` 里依旧只有 `popupBackgroundColor` / `popupTextColor` 两个字段、空串仍表示跟随主题。不能直接把空串喂给色块控件:`` 表示不了空值,浏览器会把它显示成 `#000000`。
- 「支持的标签」改完要顺手把两个包裹标签拉回合法值(**先多行、后单行**,回落链依赖这个顺序),并重建定义让两个下拉的选项跟着变。重建时核心会**跳过焦点所在的那一行**,所以在输入框里打字不会丢焦点 —— 这也是原来那段手写下拉同步可以整段删掉的原因。
**候选集是同一套,注入点有五处。**
- **widget 类区块**(块级原始 HTML、Callout、数学块,以及被 `mermaid` / `base` 等 post-processor 接管的代码块):容器都带 `.cm-embed-block`,都在容器内建 `.embed-actions` 放控制图标(可与 `obsidian.asar` 的 `app.js` 原文对照:`createEl("div", "cm-html-embed cm-embed-block")`、`createDiv("cm-preview-code-block cm-embed-block …")`、`createDiv("cm-embed-block cm-callout")`、`toggleClass("math-block" / "cm-embed-block")`)。放大图标插进 `.embed-actions` 首位,因此自动获得「悬停显示、右上角定位、RTL 镜像」等核心行为,不需要自己写定位与显隐样式。
- **普通围栏代码块**(语言不是 `mermaid` / `query` / 有 post-processor 的):`P3.canRenderLang(lang)` 为假,核心**不建 widget**,只把代码留成源码行(`HyperMD-codeblock`),并在开围栏行末尾挂一个 widget `E3` —— 它的 `toDOM` 就是 `createSpan({ cls: "code-block-flair" })`,即右上角的语言名 / 点击复制 chip。它没有 `.embed-actions`,所以放大图标作为 chip 的**子节点**嵌进去(`styles.css` 的 `.text-popup-flair-action` 负责摆成行内)。
为什么嵌进 chip 而不是当兄弟节点插到 `.cm-line` 上:CM6 的 DOMObserver 会**忽略 widget 内部的** DOM 变更(`readMutation` 里命中 widget tile 直接返回 null),而往 `.cm-line` 里塞一个它不认识的节点会被算成一次 DOM 变更、把整行标脏重渲染 —— 那会反复把按钮冲掉。
- **图片嵌入**:容器**不是** `.cm-embed-block`(这正是最初的 `.cm-embed-block` 选择器完全扫不到图片的原因),而是核心给图片 widget 自己建的 `div.image-embed`。它内部的 `.embed-actions` 与 widget 类区块是同一套(`addActions` → `addAction` → `this.actionsEl ||= e.createDiv("embed-actions")`),所以放大图标照旧插首位、插在原生「放大」(`lucide-zoom-in`)左侧,胶囊皮肤 / 悬停显隐 / RTL 镜像全部白拿,**不需要新增任何 CSS**。注入前有三条守卫:没有 `.embed-actions` 的图片(Callout 内、行内 HTML 里的 `span.image-embed`)天然跳过,嵌入笔记(`.markdown-embed`)里的图片也跳过,**引用行**(`.cm-line.HyperMD-quote`)里的图片同样跳过(V114 起由外层引用块覆盖)—— 与扫描器「这几类不产候选」的判断保持一致。
- **表格**(`injectTableAction`):唯一一处**容器也要自己建**的注入点。表格 widget 的容器 `.cm-table-widget` 带 `.cm-embed-block`(所以按 `.cm-embed-block` 分类的遍历本来就扫得到它),但核心的 `addEditButton` 只有 5 个调用点(嵌入笔记 / 两种代码块 / 数学块 / HTML 块),**表格不在其中** —— 它没有现成的 `.embed-actions`。所以本插件在 `.table-wrapper` 里自己建一个 `text-popup-table-actions embed-actions` 再插按钮:`embed-actions` 用来白拿核心的定位与皮肤,`text-popup-table-actions` 用来**认领**这个容器(摘的时候要连容器一起摘,且绝不能误删核心建的 `.embed-actions`)。锚点选 `.table-wrapper`(表格本体)而不是 widget 容器:后者常被拉满行宽(实测同一张表 732px vs 347px),锚在它上面按钮会飘到编辑区右缘。给表格按钮额外补了一条 **`mousedown` 守卫**(`preventDefault` + `stopPropagation`):表格式块内部有真实可编辑的单元格,实测只拦 `click` 时点击按钮会被顺手挪动光标。核心只在 `.cm-embed-block:hover` / `.image-embed:hover` 里显形,而表格被核心那条规则用 `:not(.cm-table-widget, …)` **显式排除**,所以 `styles.css` 里补了一条 `.cm-table-widget:hover .embed-actions { opacity: 1 }`。
- **引用块**(`src/scanner/quote.ts`):**唯一一处不是 DOM 注入的注入点**。引用块在实时预览里没有容器,它只是一串 `.cm-line.HyperMD-quote`(实测普通引用行上一条 `.cm-embed-block` 都没有),而往 `.cm-line` 里插的节点会被 CM6 的 DOMObserver 当成文档变更、整行重渲染时冲掉(实测 2 秒内消失)—— 与上一条「代码块 chip 必须嵌在 widget 内部」同因。所以这里走核心自己在普通行上用的那条路(对照核心的 `.code-block-flair`:它就是 `.cm-line.HyperMD-codeblock-begin` 里的一个绝对定位元素):由一个 CM6 装饰器(`ViewPlugin` + `Decoration.widget`)承载按钮,DOM 由 CM6 托管,不会被冲掉,定位也白拿。DOM 形状仍是核心那套 `div.embed-actions > div.embed-action`,因此皮肤全部复用;核心只在 `.cm-embed-block:hover` / `.image-embed:hover` 里显形,引用块两种都不是,所以 `styles.css` 里补了一条显隐规则(悬停首行 + 由委托监听写的 `data-text-popup-hover`)。设置变更靠 `StateEffect` 重建装饰集(`notifyQuoteActionsChanged`),**且只能由设置页触发** —— 塞进 `refreshTextPopupActions` 的观察者路径会让 dispatch → 改 DOM → 观察者 → dispatch 自激。
扫描是**一次 `.cm-embed-block` 遍历 + 类名分类**(`classifyBlock`):块级原始 HTML / 代码块 widget / Callout / 数学块 / **表格**共用这一次遍历,各自按类别分流(表格走 `injectTableAction`,其余走 `injectAction`);不带这几类类名的核心区块仍直接跳过。另有 `.code-block-flair` 与 `.cm-content .image-embed` 两次遍历,前者只吃「放大代码块」这一个开关,后者只吃「放大图片」。引用块不在这三次遍历里 —— 它的图标随装饰器自动重算,`removeAllActions` 也要跳过它(widget 的 DOM 归 CM6 管,删了不会自己长回来),但要顺手清掉表格那个**本插件认领的**空容器。
这些都依赖 Obsidian 的内部类名(非公开 API),升级后若图标不出现,集中修改 `src/scanner/inject.ts` 的 `injectAction` / `injectFlairAction` / `injectImageAction` / `injectTableAction` 与 `src/scanner/shared.ts` 的选择器即可;引用块见 `src/scanner/quote.ts`。
富文本渲染走**公开 API** `MarkdownRenderer.render(app, markdown, el, sourcePath, component)`:
- 传入的 markdown 分两路:手写 HTML 块传「去掉外层容器标签后的 `innerHTML`」(若直接传 `outerHTML`,字符串以 `
` 开头会被按 CommonMark 的 HTML block 规则整段吞掉,块内 Markdown 不会解析);代码块 / Callout / 数学块 / 引用块 / **表格**直接传**区间原文**(含围栏 / `> ` 前缀 / `$$` / `|` 网格),由 `MarkdownRenderer` 渲染成对应的原生形态 —— 引用块的原文正好渲染成一个带引用条的 `
`,表格原文以 `|` 开头、照样被认成表格(不像 `` 开头那样被 HTML block 规则吞掉),这是需求「模仿 Obsidian 显示」最省力的实现。
- 必须传一个真实的 `Component`(弹窗持有的 `component`),在 `onOpen` 里 `load()`、`onClose` 里 `unload()`;否则核心会打印 `is not passing Component` 告警,且嵌入内容的事件监听会泄漏。
- `sourcePath` 必须传**笔记完整路径**(`TFile.path`),`[[wikilink]]` 与 `![[图片]]` 才能正确解析。
- 内容容器加 `markdown-rendered` 类即可复用核心的标题 / 列表 / 引用 / 表格 / 代码块样式,不需要自己写 Markdown CSS。
- 输入前会按最小缩进整体左移(`dedent`):Markdown 中 4 个及以上前导空格会被当成代码块,不去缩进会把块内文字误渲染成代码块。
- 渲染为空或抛错时自动回退为纯文本,弹窗不会空白。
方向键切换的**候选集来自笔记文本**,不是 DOM:
- 弹窗的数据入口从「一条内容」改为 `TextPopupSource`(`size` / `sourceName` / `sourcePath` / `read(index)` / `dispose()`),由 `scanner/session.ts` 实现,弹窗只按索引惰性读取,不认识 DOM。
- 候选集由 `blocks.ts` 的 `scanTextBlocks(editor.getValue())` 扫出:单趟扫描、命中即吃掉整段区间,因此区间天然不重叠、**外层优先**(Callout 里嵌的代码块不会另算一条,否则会出现「能翻到、但永远没有图标」的幽灵条目)。HTML 块的标签表与排除表都从 `obsidian.asar` 抄来,规则与核心对齐。**为什么不用 DOM**:Live Preview 只渲染视口附近的块,滚出视口的块连按钮都没有;以 DOM 为准会让「能翻到几条」随滚动位置、光标位置、分屏状态变化(这就是「有时 2 个、有时 4 个、有时 5 个」的根因)。数量必须是**笔记的属性**,不能是屏幕的属性。
- 八类的**纯文本回退**分别由 `extractFencedBody` / `extractCalloutBody` / `extractMathBody` / `extractImageBody` / `extractQuoteBody` / `extractTableBody` / `extractCanvasBody` 得到(代码正文 / 去掉 `> ` 的文字 / TeX 源码 / alt 或文件名 / 去掉 `> ` 前缀的引用文字 / 去掉首尾空行的表格原文 / 画布文件名),只有手写 HTML 块才需要离屏渲染;空块不进候选,所以按方向键不会切到一屏空白。表格是唯一**不去壳**的一类(`|` 网格本身就是内容,剥它就得重排对齐列宽),只有「表头每格都空且没有数据行」的纯空表才返回空串、让候选被丢掉。图片 / Canvas 区的 `raw` 存的是**命中的那段图片语法**而不是整行:引用行 / 列表行的 `> ` `- ` 前缀喂给 `MarkdownRenderer` 会多渲染出一层引用块 / 列表项;引用块的 `raw` 则相反,**保留**每行的 `> ` 前缀,正好渲染成 `
`。
- 图片的**行级排除**共四条,都判在 `blocks.ts` 的 `matchImageBlock` 里(缩进 / 表格 / 行内 HTML / 行内代码),理由是同一个:**实测这四种写法核心都不建 `.image-embed`**,放进候选就是「能翻到、但永远没有图标」的幽灵条目。其中缩进那条自 V118 起按**相对容器**判(`isIndentedCode`:相对所在列表项的内容列满 4 列才算代码块),围栏 / `$$` / HTML 块三条守卫同轮一起改(`$$` 那类几乎不吃缩进,只排除「已被在跑的缩进代码块吞掉」的行),表格那条保持不变。行内代码的区间由 `inlineCodeRanges` 从**笔记文本**算出(四条实测规则,见「已知限制」),命中的图片用 `firstHitOutsideCode` **跳过**而不是「整行放弃」—— 同一行里「代码里的图 + 后面的真图」时,后面那张真图必须仍是候选。
- 候选的正文用**离屏渲染**取得:手写 HTML 的每个区间交给公开 API `sanitizeHTMLToDom`(核心 widget 自己用的就是这个函数),渲染进一个 `position: fixed; left: -10000px` 的离屏宿主,再跑原来的 `findSupportedElement` / `extractText` / `extractRichSource`。隐藏方式不能用 `display: none`:`extractText` 依赖 `innerText`,元素不参与布局时 `innerText` 会退化成 `textContent`,`
` 的换行会丢。另外六类是纯字符串处理,不需要离屏宿主。
- 打开时**算一次、取一次快照**:`size` 与 `read(index)` 都读同一份候选,弹窗打开期间不再重采,所以标题里的「总数 / 序号」与正文永远同源(旧实现每次切换都重采 DOM,点击后核心把光标移进块并滚动,会让数字在打开后继续变,点最后一个块还会因为越界而弹空白)。离屏宿主在 `onClose` 里通过 `source.dispose()` 移除。
- 「点开的是第几个」用 `Editor.cm`(核心内部入口,`obsidian.d.ts` 未暴露)上的 CM6 `posAtDOM` 把块容器映射回行号,再落到区间上;失败时按内容相等兜底,再不行回第 1 条。代码块 chip / 图片 / 引用块这三处的按钮不在 `.cm-embed-block` 里,退回所在行(`.cm-line`)精确匹配区间起始行,对不上就**不开弹窗**(宁可不开,也不翻出别的块)。表格的按钮在 `.cm-table-widget` 里(它带 `.cm-embed-block`),走的是「块容器 → 行号 → 落进区间」那条主路径。**这是本插件唯一新增的内部入口依赖**,与已有的 `.cm-html-embed` 类名依赖同级;只在 `locateStartIndex` 一处使用,包在 `try/catch` 里。
- 引用块的**图标侧与候选侧同源**:两侧都用同一个 `scanTextBlocks` 与 `isKindEnabled`,图标侧额外过滤「正文为空」的引用块(`readTextBody` 返回空串),与候选侧 `createCandidate` 的判据一致 —— 「有图标必有一条候选」是本仓库的硬约束,不满足就会出现「有图标、点开没反应」的幽灵。
- 按钮带核心约定的 `interactive-child` 类,并在 click 里 `preventDefault` + `stopPropagation`:核心在 widget 容器上挂了「点击即进入块内编辑」的延迟处理(先判 `defaultPrevented`,10ms 后才执行),拦住它就不会出现「光标跳进块、编辑器滚动、刚点的图标消失」。
- 键位注册在 `Modal` 自带的 `this.scope` 上(`open()` 时核心已把它压进键盘栈,`close()` 时自动弹出,不会漏监听)。handler **返回 `false`** 才会让核心 `preventDefault` + `stopPropagation`,方向键不会冒泡给编辑器;`modifiers` 传 `null` 表示不限修饰键,与内置图片 lightbox 行为对齐。
- `MarkdownRenderer.render` 是 **append 语义且异步**:连按方向键时两次渲染会交错写进同一个容器导致内容串台。因此每次切换都渲染到**独立的新容器**,并配合自增 `renderToken` 丢弃过期渲染;子组件一律用 `Component.addChild` / `removeChild` 管理(它们会随父组件自动 load / unload),不手写 `load()` / `unload()`。
- Alt+Click 的缩放过渡**不用 CSS `transition`**,而是 `requestAnimationFrame` 逐帧写 scale 变量与滚动量。屏幕坐标 = 元素原点 − 滚动量 + scale × 元素本地坐标,`transition` 只管得住 `transform` 那一项,滚动还得靠另一条时间线去追 —— 两条时间线一定会错开,中途就能看到锚点脱轨。「每帧先写 scale 变量、再写滚动量」的顺序也不能反:滚动赋值会被夹在由 scale 决定的滚动上限上,反过来会被夹在上一帧的上限上。整个过渡(`VIEW_ZOOM_DURATION` / `easeOutCubic` / `zoomTweenFrame`)是可打断的:切换条目、恢复默认、开始拖拽、关窗都会先 `cancelViewZoom()`。