MineImages — Obsidian 统一预览插件开发纪实
一、开发背景
1.1 痛点
Obsidian 作为一款优秀的本地知识库工具,在图片和 Mermaid 图表预览方面存在明显的交互短板:
- 图片预览:原生仅支持点击后在新标签页打开,无法在上下文中缩放、旋转或对比查看多张图片
- Mermaid 图表:渲染后尺寸不可控,大图溢出容器,小图留白过多;编辑模式下无法直接预览
- 功能碎片化:社区中虽有一些独立插件,但各自解决单一问题,交互风格不统一且存在功能重叠
1.2 设计目标
开发一款统一、流畅、功能完整的预览插件,满足以下设计原则:
flowchart LR
A["统一"] --> D["图片 + Mermaid 同一浮层"]
B["流畅"] --> E["≤500ms 打开"]
B --> F["60fps 缩放平移"]
C["完整"] --> G["缩放/旋转/复制/切换"]
C --> H["标注/全屏/缩略图导航"]
D --> I("一站式预览体验")
E --> I
G --> I
1.3 技术选型
秉持 "最大化利用 Obsidian 官方 API,避免重复造轮子" 的原则:
| 模块 | 选用方案 | 原因 |
|---|---|---|
| 预览容器 | Modal 类 | Obsidian 官方推荐,自带遮罩和关闭逻辑 |
| 图表捕获(阅读模式) | MarkdownPostProcessor | 原生渲染管道,无需额外渲染 |
| 图表捕获(实时预览) | DOM 事件委托 | 兼容 .cm-embed-block 中的动态渲染 |
| 图片捕获 | registerDomEvent | 轻量级,无侵入 |
| 变换(缩放/旋转/平移) | CSS transform | GPU 加速,60fps |
| 右键菜单 | Menu 类 | 原生 Obsidian 风格 |
| 设置界面 | PluginSettingTab | 标准配置模式 |
| 主题适配 | CSS 变量 | 自动跟随深浅主题 |
| 国际化 | i18n.ts + as const 推导 | 中英双语 55 个 key,设置页实时切换 |
二、功能介绍
2.1 核心预览能力
点击笔记中的任意图片或 Mermaid 图表,立即在统一的模态浮层中打开:
sequenceDiagram
actor User
participant Page as "笔记页面"
participant Modal as "mineImages 浮层"
participant Toolbar as "工具栏"
User->>Page: "点击图片/Mermaid"
Page->>Modal: "收集当前文件所有预览项"
Modal->>Modal: "渲染内容(img / SVG)"
Modal->>Toolbar: "显示工具栏"
Toolbar-->>User: "缩放 / 旋转 / 复制 / 切换"
User->>Toolbar: "点击缩放"
Toolbar->>Modal: "CSS transform 缩放"
Modal-->>User: "实时响应"
User->>Toolbar: "点击标注"
Toolbar->>Modal: "启用 Canvas 标注层"
Modal-->>User: "可绘制画笔/箭头/矩形"
2.2 交互功能矩阵
| 功能 | 触发方式 | 说明 |
|---|---|---|
| 缩放 | Ctrl + 滚轮 / 工具栏 ± 按钮 | 步进可配置,范围 10%–1000% |
| 拖动平移 | 鼠标拖拽 | 缩放后平移查看细节 |
| 旋转 | 工具栏按钮 / R 键 | 90° 步进 |
| 重置 | 双击内容 / 工具栏按钮 / 0 键 | 恢复初始缩放和旋转 |
| 复制 | 工具栏按钮 / 右键菜单 | 图片/Mermaid 均复制为 PNG |
| 切换 | ← → 箭头键 / 工具栏 / 右键菜单 | 遍历当前文件所有预览项 |
| 关闭 | ESC / 点击遮罩 / 工具栏关闭按钮 | |
| 全屏 | 工具栏按钮 | 浏览器 Fullscreen API |
| 标注 | 工具栏笔/箭头/矩形/清除 | Canvas 叠加层,不修改原图 |
| 缩略图导航 | 左侧面板 | 固定大小的缩略图列表,点击跳转 |
2.3 页面内交互
对于 Mermaid 图表,在笔记页面中提供额外的交互能力:
flowchart TD
subgraph "页面交互"
A["鼠标悬停 Mermaid"] --> B["光标变为 Pointer"]
B --> C["右下角显示红色拖拽手柄"]
C --> D["拖拽调整图表大小"]
end
subgraph "预览交互"
A --> E["点击打开预览浮层"]
E --> F["全功能工具栏"]
end
D --> G["保持等比缩放"]
D --> H["退出 auto-fit 模式"]
三、技术实现
3.1 整体架构
flowchart TB
subgraph "Capture Layer"
IC["image-capture.ts"] --> |"DOM 事件"| DOM[("笔记 DOM")]
MC["mermaid-capture.ts"] --> |"MarkdownPostProcessor"| DOM
MCL["Live Preview"] --> |"事件委托"| DOM
end
subgraph "Bridge"
PU["preview-utils.ts"] --> |"收集预览项"| DOM
PU --> |"打开 Modal"| PM["PreviewModal"]
end
subgraph "Preview Layer"
PM --> TM["TransformManager<br/>缩放/旋转/平移状态"]
PM --> NC["NavigationController<br/>项目索引管理"]
PM --> AC["AnnotationCanvas<br/>Canvas 标注叠加层"]
PM --> TC["TouchController<br/>触摸手势"]
PM --> CC["clipboard.ts<br/>复制为 PNG"]
end
subgraph "UI"
PM --> TB["工具栏<br/>按钮分组"]
PM --> TH["缩略图栏<br/>固定大小导航"]
PM --> CT["内容区<br/>img / SVG 渲染"]
end
3.2 模态框生命周期
sequenceDiagram
participant User
participant PU as "preview-utils"
participant PM as "PreviewModal"
participant DOM as "DOM"
User->>DOM: "点击图片/Mermaid"
DOM->>PU: "openUnifiedModal()"
PU->>PU: "collectAllInSourceOrder()"
PU->>PM: "new PreviewModal(items, index)"
Note over PM: "onOpen()"
PM->>PM: "构建全屏覆盖层"
PM->>PM: "构建内容区 + 工具栏 + 缩略图栏"
PM->>PM: "renderCurrent() 渲染当前项"
PM->>PM: "绑定键盘/鼠标事件"
PM-->>User: "浮层就绪"
Note over User,PM: "用户交互..."
User->>PM: "ESC / 点击遮罩 / 关闭按钮"
Note over PM: "onClose()"
PM->>PM: "保存偏好(if enabled)"
PM->>PM: "清理事件监听"
PM->>PM: "销毁 Canvas"
PM->>PM: "清空内容"
3.3 关键实现细节
3.3.1 变换管理(TransformManager)
// 核心状态
class TransformManager {
scale: number; // 当前缩放 (0.1 ~ 10.0)
translateX: number; // 水平偏移
translateY: number; // 垂直偏移
rotation: number; // 旋转角度 (0 / 90 / 180 / 270)
// 应用到 DOM 元素
applyTo(el: HTMLElement) {
el.style.transform = `
translate(${this.translateX}px, ${this.translateY}px)
scale(${this.scale})
rotate(${this.rotation}deg)
`;
}
}
3.3.2 Mermaid 自适应与拖拽缩放
在页面中通过 MutationObserver 捕获所有 .mermaid 元素,自动添加 width: 100% 适配容器宽度。由于 Mermaid 是异步渲染,bindMermaidElement 内置了 5 次 × 500ms 的重试等待 SVG 元素就绪。右下角的拖拽手柄允许用户手动调整大小:
flowchart LR
subgraph "自动适配"
A["Mermaid 渲染"] --> B["MutationObserver 检测"]
B --> C["添加 mine-images-auto-fit"]
C --> D["width: 100% !important"]
end
subgraph "手动调整"
E["鼠标悬停"] --> F["显示拖拽手柄"]
F --> G["拖拽"]
G --> H["移除 auto-fit"]
H --> I["固定宽高, 等比缩放"]
end
3.3.3 缩略图导航
左侧缩略图栏为固定 100×80px 的导航面板,自动收集当前文件所有图片和 Mermaid 图表,点击即可跳转:
// 缩略图构建流程
private buildThumbnailBar(): void {
this.navigation.items.forEach((item, index) => {
const thumb = createDiv("mine-images-thumbnail");
if (item.type === "image") {
this.loadThumbnailImage(item.source, thumb);
} else {
this.renderMermaidThumbnail(item, thumb);
}
thumb.addEventListener("click", () => this.navigateTo(index));
});
}
3.3.4 标注系统
基于 HTML5 Canvas 实现轻量级标注,支持画笔、箭头、矩形三种模式:
sequenceDiagram
participant User
participant TB as "工具栏"
participant AC as "AnnotationCanvas"
User->>TB: "点击「画笔」/「箭头」/「矩形」"
TB->>AC: "setMode(mode)"
AC->>AC: "Canvas pointerEvents = auto"
AC->>AC: "cursor = crosshair"
User->>AC: "mousedown (起点)"
AC->>AC: "记录 startX, startY"
AC->>AC: "保存当前 canvas 快照"
User->>AC: "mousemove (绘制中)"
AC->>AC: "还原快照 → 绘制形状"
AC-->>User: "实时预览"
User->>AC: "mouseup (完成)"
AC->>AC: "最终绘制提交"
User->>TB: "点击「清除」"
TB->>AC: "clear()"
AC->>AC: "ctx.clearRect()"
3.4 图片复制流程
flowchart TD
Start["用户点击复制"] --> Check{"图片类型?"}
Check -->|"同源图片"| Fetch["fetch → Blob"]
Fetch --> Clipboard["navigator.clipboard.write"]
Clipboard --> Done["Toast 提示成功"]
Check -->|"跨域图片"| Canvas["绘制到 Canvas"]
Canvas --> Tainted{"tainted?"}
Tainted -->|"否"| Clipboard
Tainted -->|"是"| Fallback["复制 URL 到剪贴板"]
Fallback --> Warn["Toast 提示CORS限制"]
Check -->|"Mermaid SVG"| Serialize["序列化 SVG"]
Serialize --> Image["new Image 加载"]
Image --> Draw["绘制到 Canvas"]
Draw --> PNG["转为 PNG Blob"]
PNG --> Clipboard
四、未来规划
4.1 路线图
4.2 短期目标
- 触摸手势优化:完善双指缩放和旋转体验
- 标注工具增强:支持颜色选择、笔触粗细、撤销/重做
- 性能调优:大缩略图列表按需渲染(虚拟滚动),Mermaid 渲染缓存
4.3 长期愿景
- 移动端支持:适配 Obsidian Mobile,提供触屏友好的交互
- 更多图表类型:支持 Excalidraw、PlantUML、Graphviz 等
- 插件生态:开放 API,允许第三方开发者扩展预览类型
插件 ID:
mine-images许可证:MIT