Excalidraw 开发模式编辑:在浏览器中直接修改图表并保存
- # astro
- # excalidraw
- # vite
- # react
之前我们将 Excalidraw 嵌入到了 Astro 博客中(详见Excalidraw 嵌入尝试)。但有一个痛点:每次修改图表都需要打开 Excalidraw 编辑器 → 导出 JSON → 替换文件,流程繁琐。
目标
- 开发模式(
pnpm dev):Excalidraw 可编辑,带保存按钮,修改直接写回.excalidraw.json文件 - 生产模式(
pnpm build):只读展示,与之前完全一致
架构设计
核心问题:浏览器中的 React 组件如何把数据写回到磁盘上的文件?
答案是通过 Vite 开发服务器。Vite 的 configureServer 钩子允许我们注册自定义的 HTTP 端点,这仅在 astro dev 时生效,生产构建完全不参与。
实现步骤
1. Vite 插件:保存端点
创建 src/plugins/excalidraw-save.ts,注册 POST /__excalidraw_save 端点:
import { writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import type { IncomingMessage, ServerResponse } from 'node:http'
export function excalidrawSavePlugin() {
return {
name: 'excalidraw-save',
configureServer(server) {
server.middlewares.use('/__excalidraw_save', async (req, res) => {
// 读取请求体
let body = ''
for await (const chunk of req) { body += chunk }
const { filePath, data } = JSON.parse(body)
// 安全校验:只允许写入 excalidraw 目录下的 json 文件
if (!filePath.startsWith('src/content/excalidraw/')) {
res.statusCode = 403
res.end('Path not allowed')
return
}
// 写入磁盘
const absPath = resolve(server.config.root, filePath)
writeFileSync(absPath, JSON.stringify(data, null, 2) + '\n')
res.end(JSON.stringify({ ok: true }))
})
},
}
}
然后在 astro.config.ts 中注册:
import { excalidrawSavePlugin } from './src/plugins/excalidraw-save'
export default defineConfig({
// ...
vite: {
plugins: [excalidrawSavePlugin()],
},
})
关键点:configureServer 只在开发服务器中调用,astro build 时这个钩子完全不执行,对生产构建零影响。
2. React 组件:开发模式编辑
修改 ExcalidrawRaw.tsx,新增 isDev 和 dataPath 两个属性:
// 根据 isDev 决定是否启用编辑模式
<Draw
viewModeEnabled={!isDev} // 生产环境只读,开发环境可编辑
onChange={isDev ? handleChange : undefined}
{...drawProps}
/>
脏状态检测:通过 onChange 回调追踪每次修改,与上次保存的快照对比来判断是否有未保存的变更:
const handleChange = (...args) => {
const snapshot = JSON.stringify(args[0]) // 序列化元素数组
if (isFirstChange.current) {
lastSavedSnapshot.current = snapshot // 首次加载不标记为脏
return
}
setIsDirty(snapshot !== lastSavedSnapshot.current)
}
保存逻辑:使用 Excalidraw 提供的 serializeAsJSON 序列化当前画布状态,然后 POST 到保存端点:
const handleSave = async () => {
const [elements, appState, files] = latestOnChangeArgs.current
const jsonStr = serializeAsJSON(elements, appState, files, 'local')
const data = JSON.parse(jsonStr)
await fetch('/__excalidraw_save', {
method: 'POST',
body: JSON.stringify({ filePath: dataPath, data }),
})
}
快捷键支持:监听 Cmd+S / Ctrl+S 触发保存,阻止浏览器默认的保存对话框。
3. Astro 包装器:条件性样式
修改 ExcalidrawWrapper.astro,通过 import.meta.env.DEV 判断当前环境:
---
const isDev = import.meta.env.DEV
---
<div class:list={['excalidraw-wrapper', { 'excalidraw-dev': isDev }]}>
<ExcalidrawRaw client:only="react" isDev={isDev} dataPath={dataPath} {...rest} />
</div>
开发模式启用 pointer-events: auto,允许交互;生产模式保持 pointer-events: none 并隐藏工具栏。
4. 使用方式
在 MDX 中传入 dataPath 属性即可:
import ExcalidrawWrapper from '@hokkeung/astro-excalidraw-editable'
import diagramSingleton from '../../../excalidraw/patterns/singleton.excalidraw.json'
<ExcalidrawWrapper
initialData={diagramSingleton}
dataPath="src/content/excalidraw/patterns/singleton.excalidraw.json"
/>
dataPath 是相对于项目根目录的路径,保存端点用它来定位要写入的文件。
安全考虑
保存端点做了路径校验:
- 只接受以
src/content/excalidraw/开头的路径 - 只接受以
.excalidraw.json结尾的文件 - 路径解析为绝对路径后验证仍在项目目录内
防止任意文件写入攻击。当然,这个端点本身只在开发服务器中存在,不会暴露到生产环境。
开源
以上功能已封装为 npm 包,源码开源于 GitHub,欢迎试用和反馈。