Astro 嵌入 Excalidraw 组件
很早就接触到 Excalidraw 这款白板工具, 可以绘制精美的手绘风格图表,并支持将画布直接导出为 PNG 或 SVG 图像,便于在网页中展示。
但直接使用导出的图像有一个问题:没法跟随页面主题的切换而改变颜色(除非对 SVG 进行复杂的颜色转换)。 而在全局深色模式下突然滑出白底图片会很刺眼,极其影响浏览体验。
好在官方有提供组件版本,可以直接在页面中嵌入 Excalidraw 画布,支持拖拽、缩放、主题切换等操作。 曾经在 Blog 技术栈还是 Vue 的时候尝试过,但没能成功,具体原因已经记不清了。前不久迁移到 Astro + React 后,又想起来这件事,打算再尝试一下。踩了一些坑,最终还是嵌入成功。
Demo
包含下文内容,作为参考。
截至本文编写时,Excalidraw 的最新版本为 0.18.0,后文操作都基于该版本。
添加组件
由于 Excalidraw 提供的组件仅有 React 版本,需要先为 Astro 添加 React 支持。可以前往官方文档查看详细信息。
添加好 React 支持后,安装 @excalidraw/excalidraw
包:
pnpm add @excalidraw/excalidraw
也许是实现差异,在 .astro
组件中可以直接引入 Excalidraw,而在 .mdx
中则会报错:
Cannot find module ‘/repo/node_modules/roughjs/bin/rough’ imported from /repo/node_modules/@excalidraw/excalidraw/dist/prod/index.js
Did you mean to import “roughjs/bin/rough.js”?
最好的解决办法是用 .astro
包一层。创建一个 ExcalidrawWrapper.astro
:
---
import { Excalidraw } from '@excalidraw/excalidraw'
import type { ComponentProps } from 'react'
// 引入 Excalidraw 的样式,否则会报错。
import '@excalidraw/excalidraw/index.css'
type Props = ComponentProps<typeof Excalidraw>
const props = Astro.props
---
<div class="excalidraw-wrapper">
<!-- 添加 client:only="react",否则会报错。 -->
<Excalidraw client:only="react" {...props} />
</div>
<style>
/* Excalidraw 组件会填充整个父组件的空间 */
.excalidraw-wrapper {
width: 400px;
height: 400px;
}
</style>
注意点:
- 由于 Excalidraw 组件无法 SSR,需要在
<Excalidraw />
上加入client:only="react"
否则报错:Cannot find module … Did you mean to import “roughjs/bin/rough.js”? - 在 TS 部分
import '@excalidraw/excalidraw/index.css'
否则报错:CanvasRenderingContext2D.setTransform: Canvas exceeds max size.
然后就可以在其他地方引入 ExcalidrawWrapper.astro
组件了,例如:
---
import ExcalidrawWrapper from '../components/ExcalidrawWrapper.astro'
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Astro embed Excalidraw</title>
</head>
<body>
<ExcalidrawWrapper />
</body>
</html>
导入画布
Excalidraw 网页端可以选择将画布保存为 .excalidraw
文件(菜单 - Save to… Save to disk)。这个 .excalidraw
文件的内容实际上就是一个 JSON 对象。
我们可以读取这个 .excalidraw
文件并传入 Excalidraw 组件的 initialData
prop,即可读取自定义的画布。
但 Astro(基于 Vite)无法 import .excalidraw
格式的文件,我们可以将其扩展名改为 .json
(或 .excalidraw.json
)再 import:
---
import ExcalidrawWrapper from '../components/ExcalidrawWrapper.astro'
import test from './_test.excalidraw.json'
---
<ExcalidrawWrapper initialData={test} />
如果你使用 VS Code,推荐这个扩展,可以直接在编辑器内绘制 .excalidraw.json
文件。
定制化
只读模式
Excalidraw 组件默认带编辑功能,但由于目标是在文章中嵌入,编辑功能就显得多余了。可以设置 viewModeEnabled
prop 来禁用编辑功能:
<ExcalidrawWrapper viewModeEnabled initialData={test} />
隐藏菜单栏和右键菜单
可以添加自定义样式:
<style is:global>
.excalidraw .App-toolbar-content,
.excalidraw .context-menu {
display: none !important;
}
</style>
自动移动并缩放至内容
Excalidraw 画布中的元素是根据绝对坐标来定位的,导入的画布很有可能默认没有显示在组件之中(移动或者缩放才能看到)。
首先需要将 ExcalidrawWrapper.astro
中的部分内容拆分到一个单独的 React 组件,例如 ExcalidrawRaw.tsx
,然后提供 excalidrawAPI
函数轮询是否初始化完成,最后调用 scrollToContent
方法。代码如下:
ExcalidrawRaw.tsx
:
import { Excalidraw as Draw } from '@excalidraw/excalidraw'
import type { ComponentProps } from 'react'
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
export type ExcalidrawProps = ComponentProps<typeof Draw>
export function ExcalidrawRaw(props: ExcalidrawProps) {
const excalidrawAPI = (api: ExcalidrawImperativeAPI) => {
const interval = setInterval(() => {
if (api.getSceneElements().length > 0) {
api.scrollToContent(undefined, {
fitToContent: true,
})
clearInterval(interval)
}
}, 10)
}
return <Draw viewModeEnabled excalidrawAPI={excalidrawAPI} {...props} />
}
ExcalidrawWrapper.astro
:
---
import { ExcalidrawRaw, type ExcalidrawProps } from './ExcalidrawRaw'
import '@excalidraw/excalidraw/index.css'
type Props = ExcalidrawProps
const props = Astro.props
---
<div class="excalidraw-wrapper">
<ExcalidrawRaw client:only="react" {...props} />
</div>
<style>
.excalidraw-wrapper {
width: 400px;
height: 400px;
}
</style>
跟随主题变化
作为示例,我使用 Astro 官方推荐的 nanostores 来管理主题状态:
pnpm add nanostores @nanostores/react
新建 stores.ts
:
import { atom } from 'nanostores'
export const $currentTheme = atom<'light' | 'dark'>('light')
修改 ExcalidrawRaw.tsx
:
import { Excalidraw as Draw } from '@excalidraw/excalidraw'
import type { ComponentProps } from 'react'
import { useStore } from '@nanostores/react'
import { $currentTheme } from '../stores'
export type ExcalidrawProps = ComponentProps<typeof Draw>
export function ExcalidrawRaw(props: ExcalidrawProps) {
const currentTheme = useStore($currentTheme)
return <Draw theme={currentTheme} {...props} />
}
实现主题切换:
---
import ExcalidrawWrapper from '../components/ExcalidrawWrapper.astro'
import test from './_test.excalidraw.json'
---
<script>
import { $currentTheme } from '../stores'
document.getElementById('theme-btn').onclick = () => {
const toDark = $currentTheme.get() === 'light'
$currentTheme.set(toDark ? 'dark' : 'light')
document.documentElement.classList[toDark ? 'add' : 'remove']('dark')
}
</script>
<html lang="en">
<head>
<title>Astro embed Excalidraw</title>
</head>
<body>
<button id="theme-btn">Toggle theme</button>
<ExcalidrawWrapper initialData={test} />
</body>
</html>
<style is:global>
html.dark {
background-color: #000;
}
</style>