Skip to content

Commit 71a2a3f

Browse files
committed
feat: Implement keyboard navigation for Excalidraw slides, display zoom level, and streamline data parsing to compressed JSON.
1 parent 9ba4abf commit 71a2a3f

2 files changed

Lines changed: 80 additions & 90 deletions

File tree

src/components/mdx/Excalidraw.tsx

Lines changed: 56 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export function Excalidraw({
6767
const [loading, setLoading] = useState(true);
6868
const [isClient, setIsClient] = useState(false);
6969
const [viewMode, setViewMode] = useState<'overview' | 'slide'>('overview');
70+
const [zoom, setZoom] = useState(100);
7071

7172
// The raw viewBox of the entire exported SVG
7273
const [rawViewBox, setRawViewBox] = useState<number[] | null>(null);
@@ -92,7 +93,6 @@ export function Excalidraw({
9293
if (!res.ok) throw new Error(`Failed to load: ${res.status}`);
9394

9495
const textContent = await res.text();
95-
let json;
9696
// Match lines like "hash: $$formula$$" for LaTeX extraction
9797
const latexMap: Record<string, string> = {};
9898
const latexLines = textContent.match(/^[a-f0-9]{40}: \$\$.*?\$\$/gm);
@@ -107,32 +107,30 @@ export function Excalidraw({
107107
});
108108
}
109109

110-
// Try parsing as standard JSON first
111-
try {
112-
json = JSON.parse(textContent);
113-
} catch (e) {
114-
// If not JSON, try parsing as Obsidian-Excalidraw Markdown
115-
// Look for ```compressed-json ... ``` block
116-
const match = textContent.match(/```compressed-json\s*([\s\S]*?)```/);
117-
if (match) {
118-
// Remove all whitespace (newlines, spaces) as LZString expects a continuous string
119-
const compressed = match[1].replace(/\s/g, '');
120-
const decompressed = LZString.decompressFromBase64(compressed);
121-
if (decompressed) {
122-
json = JSON.parse(decompressed);
123-
124-
// Populate json.files with LaTeX renders if we found any
125-
if (Object.keys(latexMap).length > 0) {
126-
json.files = json.files || {};
127-
for (const [id, formula] of Object.entries(latexMap)) {
128-
// Find corresponding image element to get its intended size
129-
const el = json.elements?.find((e: any) => e.fileId === id && !e.isDeleted);
130-
if (!el) continue;
131-
132-
try {
133-
const html = katex.renderToString(formula, { displayMode: true, throwOnError: false });
134-
// Create a self-contained SVG with inlined KaTeX CSS
135-
const svgString = `
110+
let json;
111+
// Only support Obsidian-Excalidraw Markdown
112+
const match = textContent.match(/```compressed-json\s*([\s\S]*?)```/);
113+
if (!match) return;
114+
115+
// Remove all whitespace (newlines, spaces) as LZString expects a continuous string
116+
const compressed = match[1].replace(/\s/g, '');
117+
const decompressed = LZString.decompressFromBase64(compressed);
118+
if (!decompressed) return;
119+
120+
json = JSON.parse(decompressed);
121+
122+
// Populate json.files with LaTeX renders if we found any
123+
if (Object.keys(latexMap).length > 0) {
124+
json.files = json.files || {};
125+
for (const [id, formula] of Object.entries(latexMap)) {
126+
// Find corresponding image element to get its intended size
127+
const el = json.elements?.find((e: any) => e.fileId === id && !e.isDeleted);
128+
if (!el) continue;
129+
130+
try {
131+
const html = katex.renderToString(formula, { displayMode: true, throwOnError: false });
132+
// Create a self-contained SVG with inlined KaTeX CSS
133+
const svgString = `
136134
<svg xmlns="http://www.w3.org/2000/svg" width="${el.width}" height="${el.height}">
137135
<foreignObject width="100%" height="100%">
138136
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; overflow: hidden;">
@@ -145,23 +143,16 @@ export function Excalidraw({
145143
</div>
146144
</foreignObject>
147145
</svg>`.trim();
148-
const dataURL = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgString)))}`;
149-
json.files[id] = {
150-
mimeType: "image/svg+xml",
151-
id,
152-
dataURL,
153-
created: Date.now()
154-
};
155-
} catch (err) {
156-
console.error("KaTeX rendering failed for ID:", id, err);
157-
}
158-
}
159-
}
160-
} else {
161-
throw new Error("Failed to decompress Excalidraw data");
146+
const dataURL = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgString)))}`;
147+
json.files[id] = {
148+
mimeType: "image/svg+xml",
149+
id,
150+
dataURL,
151+
created: Date.now()
152+
};
153+
} catch (err) {
154+
console.error("KaTeX rendering failed for ID:", id, err);
162155
}
163-
} else {
164-
throw new Error("Invalid Excalidraw file format");
165156
}
166157
}
167158

@@ -191,8 +182,12 @@ export function Excalidraw({
191182
if (svgRef.current) {
192183
svgRef.current.setAttribute('viewBox', vb.join(' '));
193184
currentViewBoxRef.current = vb;
185+
if (rawViewBox) {
186+
const z = Math.round((rawViewBox[2] / vb[2]) * 100);
187+
setZoom(z);
188+
}
194189
}
195-
}, []);
190+
}, [rawViewBox]);
196191

197192
// 2. Render SVG
198193
useEffect(() => {
@@ -383,6 +378,22 @@ export function Excalidraw({
383378
}
384379
};
385380

381+
useEffect(() => {
382+
const handleKeyDown = (e: KeyboardEvent) => {
383+
if (viewMode !== 'slide' || frames.length === 0) return;
384+
if (e.key === 'ArrowRight') {
385+
e.preventDefault();
386+
goToSlide((currentSlide + 1) % frames.length);
387+
}
388+
if (e.key === 'ArrowLeft') {
389+
e.preventDefault();
390+
goToSlide((currentSlide - 1 + frames.length) % frames.length);
391+
}
392+
};
393+
window.addEventListener('keydown', handleKeyDown);
394+
return () => window.removeEventListener('keydown', handleKeyDown);
395+
}, [viewMode, currentSlide, frames.length]);
396+
386397
// Drag Handlers
387398
const handleMouseDown = (e: React.MouseEvent) => {
388399
e.preventDefault();
@@ -520,6 +531,7 @@ export function Excalidraw({
520531
</div>
521532

522533
<div className="flex items-center gap-1 w-24 justify-end">
534+
<span className="text-[10px] font-black text-gray-400 mr-1">{zoom}%</span>
523535
<button
524536
onClick={() => {
525537
syncView();
Lines changed: 24 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: '建站: Excalidraw 组件集成'
33
publishDate: 2025-12-27
4-
description: '在 Astro MDX 中无缝集成 Excalidraw 绘图,支持标准格式与 Obsidian 压缩格式'
4+
description: '在 Astro MDX 中无缝集成 Excalidraw 绘图,专为 Obsidian Excalidraw 插件设计'
55
tags:
66
- excalidraw
77
- obsidian
@@ -12,83 +12,61 @@ slug: wasd
1212
import { Excalidraw } from '@/components/mdx/Excalidraw'
1313
import IconLink from '@/components/IconLink.astro'
1414

15-
本文介绍了如何在博客中集成自定义的 `Excalidraw` 组件,实现在 MDX 文档中直接渲染交互式的白板绘图。该组件不仅支持标准的 Excalidraw 文件,还完美兼容 Obsidian 插件导出的格式
15+
本文介绍了如何在博客中集成自定义的 `Excalidraw` 组件,实现在 MDX 文档中直接渲染交互式的白板绘图。本组件**专注于支持 Obsidian Excalidraw 插件生成的 Markdown (.md) 文件**,实现了从双向链笔记到博客展示的无缝衔接
1616

1717
## 简介
1818

19-
`Excalidraw` 组件是一个基于 React 的轻量级渲染器,专为在 MDX 环境中展示白板绘图而设计。为了获得最佳的编辑体验和资源占用,本组件**专注于支持 Obsidian Excalidraw 插件生成的 Markdown (.md) 文件**。通过直接解析压缩的 JSON 场景数据,实现在现代 web 环境中的完美渲染
19+
`Excalidraw` 组件是一个基于 React 的轻量级渲染器,专为在 MDX 环境中展示白板绘图而设计。为了获得最佳的编辑体验和资源占用,本组件抛弃了传统的 `.excalidraw` JSON 格式,直接解析 Obsidian 场景数据中的 `compressed-json`
2020

2121
## 处理流程
2222

23-
1. 在 obsidian 里编辑 excalidraw 源文件
24-
2. 复制该文件到 `public/excalidraw/` 文件夹下
25-
3. 在 mdx 或者任意页面使用 `Excalidraw` 组件引用
23+
1. **编辑场景**: 在 Obsidian 中使用 Excalidraw 插件编辑绘图。
24+
2. **文件分发**: 复制该 `.md` 文件到项目的 `public/excalidraw/` 文件夹下
25+
3. **组件引用**: 在 MDX 中通过 `<Excalidraw snapshotUrl="/excalidraw/your-file.md" />` 进行引用。
2626

2727
## 核心功能
2828

29-
* **Obsidian 原生支持**: 完美解析 Obsidian Excalidraw 插件生成的 `.md` 文件,无需导出。
30-
* **幻灯片展示 (Frames)**: 自动识别绘图中的 "画框" (Frame),并按标题或位置排序,支持平滑的演示导航。
31-
* **Magic Animations (魔法动画)**: 通过特定的“颜色+线型”组合触发持续动画:
32-
* **蓝色 (#0000ff) + 虚线**: 自动触发“线条流动”效果(如模拟电流/水流)。
33-
* **红色 (#ff0000) + 虚线**: 自动触发“呼吸灯”效果(用于关键警告)。
34-
* **极致性能**: 直接操作 SVG 的 `viewBox` 进行缩放 (Zoom) 与平移 (Pan),避免 Canvas 重绘。
35-
* **无感知集成**: 组件在客户端实时导出 SVG,完美融入站点背景与样式,文字可搜索。
36-
* **告别 .excalidraw**: 废弃体积庞大且不便协作的原始 JSON 格式,全面转向 Markdown 工作流。
29+
* **Obsidian 原生支持**: 完美解析 Obsidian Excalidraw 插件生成的 `.md` 文件,包含文本、几何图形及公式映射。
30+
* **LaTeX 公式增强**: 自动识别并使用 KaTeX 渲染嵌入的 LaTeX 公式,确保数学排版精准美观。
31+
* **Magic Flow 信号流动画**:
32+
* **蓝色虚线**: 自动触发“能量球轨迹流动”效果,常用于展示系统架构中的数据流向。
33+
* **幻灯片演示 (Frames)**: 自动识别绘图中的 "Frame" (画框),并支持通过快捷键或导航栏进行 PPT 式的演示切换。
34+
* **沉浸式交互**:
35+
* **平移/缩放**: 支持平滑的拖拽和平移,控制台实时显示当前的 **缩放比例**
36+
* **键盘驱动**: 支持使用键盘 **左/右方向键** 快速切换幻灯片页。
37+
* **极致性能**: 直接操作 SVG DOM 节点,无 Canvas 负担,支持 SEO 抓取。
3738

3839
## 示例演示
3940

4041
### 1. 多画框演示 (Obsidian .md)
4142

42-
这是最推荐的使用方式:在 Obsidian 中通过 Frame 组织内容,组件会自动生成导航控件。
43+
在 Obsidian 中通过 Frame 组织内容,组件会自动生成导航控件。你可以尝试使用 **键盘左右键** 切换
4344

4445
<Excalidraw
4546
snapshotUrl="/excalidraw/test-2.md"
4647
client:only="react"
4748
>
4849
<div slot="exc-title" class="flex items-center gap-2">
4950
<IconLink href="#" class="text-sm font-semibold text-gray-700">
50-
多画框演示 (test-2.md)
51+
excalidraw 动画效果
5152
</IconLink>
52-
<span class="text-xs text-green-500 font-bold">New</span>
53+
<span class="text-xs text-blue-500 font-bold">Zoom Enabled</span>
5354
</div>
5455
</Excalidraw>
5556

56-
```tsx
57-
<Excalidraw
58-
snapshotUrl="/excalidraw/test-2.md"
59-
client:only="react"
60-
/>
61-
```
62-
63-
### 2. 基础方案图示例
64-
65-
<Excalidraw
66-
snapshotUrl="/excalidraw/ob-e-m.md"
67-
client:only="react"
68-
>
69-
<span slot="exc-title">系统架构草稿 (.md)</span>
70-
</Excalidraw>
71-
72-
```tsx
73-
<Excalidraw
74-
snapshotUrl="/excalidraw/ob-e-m.md"
75-
client:only="react"
76-
/>
77-
```
78-
7957
## 组件参数 (Props)
8058

8159
| 属性名 | 类型 | 默认值 | 说明 |
8260
| :--- | :--- | :--- | :--- |
83-
| `snapshotUrl` | `string` | **必填** | `.md` 文件或 JSON 数据路径 (放至 public 目录) |
61+
| `snapshotUrl` | `string` | **必填** | 仅支持 Obsidian 生成的 `.md` 文件路径 |
8462
| `height` | `number \| string` | `500` | 容器高度 |
8563
| `exc-title` | `ReactNode` | `undefined` | 标题栏内容 (推荐使用 `slot="exc-title"`) |
8664
| `fontFamily` | `string` | `system-ui...` | 文本渲染优先使用的字体栈 |
8765

8866
## 技术实现原理
8967

90-
1. **数据流**: 组件通过异步 `fetch` 获取文本,利用正则高效提取 `compressed-json` 代码块
91-
2. **透明渲染**: 利用官方 `@excalidraw/excalidraw` 库的导出接口,在浏览器端生成精准的 SVG DOM
92-
3. **坐标对齐**: 自动计算所有可见元素的边界框(Bounding Box),并应用偏移修正,确保 SVG `viewBox` 与实际绘图坐标系完美对齐
93-
4. **运镜系统**: 采用摄像机插值算法,在切换 Frame 时通过 `requestAnimationFrame` 实现平滑的缩放和位移
94-
68+
1. **格式锁定**: 组件只接受包含 `compressed-json` 代码块的文件。解析器会提取该块并利用 `LZString` 还原场景数据
69+
2. **LaTeX 集成**: 组件会扫描 Markdown 头部元数据及 `Embedded Files` 区域,利用 KaTeX 生成独立的 SVG 图片对象并注入 Excalidraw 场景缓存
70+
3. **SVG 动力学引擎**: 针对 Magic Flow,通过在路径末尾动态插入 `<animateMotion>` 物理对象(红色小球),实现不丢帧的匀速流动感
71+
4. **视口感知**: 通过 `getBoundingClientRect` 与 SVG `viewBox` 的比例关系,精确计算并反馈当前的缩放百分比
72+
5. **全局监听**: 组件激活时会拦截方向键事件,优先响应幻灯片导航,提升演示体验。

0 commit comments

Comments
 (0)