← 返回文章列表

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,新增 isDevdataPath 两个属性:

// 根据 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,欢迎试用和反馈。