@holo-max-lab/excalidraw-ai
v0.18.0-ai.9
Published
Excalidraw as a React component
Maintainers
Readme
@holo-max-lab/excalidraw-ai
Excalidraw(上游 master,@excalidraw/excalidraw 0.18.0 基线)的单包化 fork,叠加教学实施平台内部补丁,并内置「白板适配层 + 持久化契约」。
产物为 ESM + 类型声明 + 样式 + 全部手绘字体;React 17 / 18 / 19 均可作 peer。
版本语义
0.18.0-ai.<N>:0.18.0 = 上游基线版本,ai.<N> = 内部补丁轮次。
prerelease 的 caret 语义反直觉,消费方请钉死精确版本("@holo-max-lab/excalidraw-ai": "0.18.0-ai.9"),不要用 ^。
补丁清单与 rebase 流程见仓库根 PATCHES.md。
安装
npm i @holo-max-lab/excalidraw-ai --save-exact运行时依赖(roughjs / jotai / codemirror / radix-ui / perfect-freehand / pica / mermaid-to-excalidraw 等 23 个)随包自动安装;
react / react-dom 为 peerDependencies,由宿主提供(务必保证全应用单实例)。
快速开始(裸 Excalidraw)
import { Excalidraw } from "@holo-max-lab/excalidraw-ai";
import "@holo-max-lab/excalidraw-ai/index.css";
export const Board = () => <Excalidraw langCode="zh-CN" />;字体(必做,否则中文手写体 404 降级为系统字体)
按需字体(Xiaolai / Excalifont 等)以相对路径 fonts/… 引用;未设置基址时上游会回落到 esm.sh CDN(本包未在该 CDN 存在,必 404)。
设置基址并把字体拷到该路径下:
// 模块顶层,先于任何 Excalidraw 渲染
window.EXCALIDRAW_ASSET_PATH = "/excalidraw-assets/";Vite 宿主示例(幂等拷贝):
// vite.config.ts
import fs from "fs";
import path from "path";
{
name: "copy-excalidraw-fonts",
buildStart() {
const src = path.resolve(__dirname, "node_modules/@holo-max-lab/excalidraw-ai/dist/prod/fonts");
const dest = path.resolve(__dirname, "public/excalidraw-assets/fonts");
if (fs.existsSync(dest)) return;
fs.mkdirSync(dest, { recursive: true });
fs.cpSync(src, dest, { recursive: true });
},
}webpack 宿主用 copy-webpack-plugin 做等价事;public/excalidraw-assets/ 应进 .gitignore。
白板适配层(推荐入口)
Whiteboard 组件 = Excalidraw + 去品牌主菜单 + 持久化接线;BoardStorage 是持久化契约接口(包内不含任何实现与兜底)。
你只需实现一个 BoardStorage,或直接用下面的 createHttpBoardStorage 工厂。
import { Whiteboard, type BoardStorage, BoardVersionConflictError } from "@holo-max-lab/excalidraw-ai";
const storage: BoardStorage = {
async load(bizType, bizId) { /* GET 你的后端 → BoardDocumentVO | null */ },
async save(bizType, bizId, payload) { /* PUT;版本冲突请抛 BoardVersionConflictError */ },
async freeze(bizType, bizId) { /* 业务提交/冻结时调用 */ },
async unfreeze(bizType, bizId) { /* 业务退回/返修时调用 */ },
async listHistory(bizType, bizId) { /* 可选 */ },
async uploadImage(blob, fileName) { /* 上传对象存储,返回可同源加载的 url */ },
};
<Whiteboard
storage={storage}
bizType="teaching_whiteboard"
bizId={id}
onPersistState={setState} // idle/loading/saving/saved/conflict/error
onNotify={(kind, text) => toast[kind](text)} // 包内不依赖任何 UI 库
/>关键 Props 与 ref
| prop | 说明 |
|---|---|
| storage / bizType / bizId | 三者齐备时开启持久化(initialData 改从 storage.load 读,onChange 防抖写回) |
| viewMode | 只读模式(查看 / 组员 / 冻结态):透传 Excalidraw viewModeEnabled 且关闭持久化 |
| onPersistState | 持久化状态回调,供宿主展示「已保存 / 保存中 / 版本冲突…」 |
| onNotify | error / warning / info 通知,宿主决定用 toast 还是别的 |
| mainMenu | 自定义主菜单;不传则用去品牌默认菜单 |
| formFactor | 强制 "phone" / "tablet" / "desktop"。Excalidraw 默认按容器尺寸判定版式(宽 ≤599,或 宽<1000 且高<500 → phone);白板嵌在弹窗等小容器里会切成移动端版式(工具栏置底、主菜单弹层形态变化),传 "desktop" 可强制桌面版式 |
| initialData / onChange / theme | 透传 Excalidraw 同名语义(未开持久化时有效) |
ref 暴露 WhiteboardHandle:
const ref = useRef<WhiteboardHandle>(null);
ref.current?.getApi(); // 原生 ExcalidrawImperativeAPI 逃生舱口(updateScene / addFiles / zoomToFit …)
ref.current?.saveNow(); // 立即落库(跳过 800ms 防抖),用于「提交前先保存」createHttpBoardStorage:HTTP 后端一行装配(推荐)
包内已内置一个通用 HTTP 实现工厂,把「409 → BoardVersionConflictError、上传、代理改写」收敛一处,宿主只注入 transport 与地址。
import {
createHttpBoardStorage,
type BoardHttpTransport,
} from "@holo-max-lab/excalidraw-ai";
// 1) 把你自己的 request 封装适配成 BoardHttpTransport(axios 示例)
const transport: BoardHttpTransport = {
async json(method, url, body) {
try {
const res = await axios.request({ method, url, data: body });
return { code: res.data.code, msg: res.data.msg, data: res.data.data };
} catch (e) {
return { code: e?.response?.status === 409 ? 409 : 1, msg: e?.message, data: undefined };
}
},
async upload(url, blob, fileName) {
const fd = new FormData();
fd.append("file", blob, fileName || `board-${Date.now()}.png`);
const res = await axios.post(url, fd); // 注意自己设置 multipart 头
return { code: res.data.code, msg: res.data.msg, data: res.data.data };
},
};
// 2) 一行产出 BoardStorage
export const boardStorage = createHttpBoardStorage({
transport,
baseUrl: "/teaching/board/documents", // 文档接口前缀
uploadUrl: "/admin/sys-file/upload", // 图片上传接口
rewriteUrl: (url) => toOssProxyUrl(url), // OSS 无 CORS 时改写为同源代理地址(如 /ossimg)
});createHttpBoardStorage 会自动组装以下后端契约(与文档接口 {bizType}/{bizId} 对齐):
| 方法 | HTTP |
|---|---|
| load | GET {baseUrl}/{bizType}/{bizId} → BoardDocumentVO \| null |
| save | PUT {baseUrl}/{bizType}/{bizId}(body {docJson, version})→ {version};响应 code===409 抛 BoardVersionConflictError |
| freeze | POST {baseUrl}/{bizType}/{bizId}/freeze |
| unfreeze | POST {baseUrl}/{bizType}/{bizId}/unfreeze |
| listHistory | GET {baseUrl}/{bizType}/{bizId}/history → BoardHistoryVO[] |
| uploadImage | POST {uploadUrl}(multipart)→ {url},再经 rewriteUrl 改写 |
transport 的
json/upload返回宿主统一响应体{code,msg,data}(code===0成功,code===409版本冲突)。
renderThumbnailBlob:提交/冻结时派生缩略图
场景 JSON 是唯一真源,不存储整图预览;仅在「提交 / 冻结」那一刻按需派生一张缩略图(列表降级展示用)。
import { renderThumbnailBlob } from "@holo-max-lab/excalidraw-ai";
// scene = { elements, files, appState }(files 的 dataURL 必须是可同源加载的代理地址)
const blob = await renderThumbnailBlob(scene); // PNG Blob | null
const url = await storage.uploadImage(blob!); // 再上传回对象存储注意:files 的 dataURL 必须可同源加载(OSS 无 CORS 时用代理地址),否则 canvas 被污染、exportToBlob 抛 SecurityError。
存储契约要点
- 画布上的一切 = 一份 JSON 真源:
{ elements, files, appState(白名单) };读取后必须过restoreElements归一化 - 图片像素不进 JSON:保存前把内存 base64 经
uploadImage换 url,files只留{id,mimeType,size,url};url 必须可同源加载(OSS 无 CORS 时走反代),否则 canvas 被污染、导出失败 - 保存策略已内置:onChange 防抖 800ms;客户端内容哈希短路(瞬态 appState 不产生请求);409 自动 rebase(拉最新版
updateScene + addFiles收敛) - 不派生预览图:消费方直接渲染场景;如需光栅用
exportToBlob/renderThumbnailBlob自行导出 - 共享文档(多人写同一 bizType+bizId)请让后端做乐观锁并在冲突时返回可识别信号,宿主实现里抛
BoardVersionConflictError
已包含的内部补丁(详见 PATCHES.md)
- 单包化构建(内部 workspace 包内联)
- 包名/版本/依赖收敛
- 工具栏提示统一为自定义 Tooltip(移除原生
title白框) - 字体 esm.sh 回退条件化(设了
EXCALIDRAW_ASSET_PATH就不再回落到 CDN) adapter/适配层与持久化契约(Whiteboard/BoardStorage/createHttpBoardStorage/renderThumbnailBlob)
去品牌:默认主菜单不含上游 Socials 品牌块;如需完全自定义传 mainMenu prop。
已知约束
- React Native 原生渲染不可用(DOM/canvas 库);Taro/uni 等请走 H5 目标或 WebView 承载
- webpack 宿主需确认 woff2 / css 的 loader 配置(Vite 开箱即用)
- 以
file:/junction 方式本地引入时:需preserveSymlinks(tsconfig 与 bundler 双处),否则解析到 fork 仓库自己的 react/@types 造成双实例;改 fork 后需清 bundler 预打包缓存 - 包体约 32MB(含 dev+prod 双份产物与 468 个字体子集),请路由级懒加载,勿进首屏
本地开发(fork 仓库)
corepack yarn install # fork 用 Yarn 1 workspaces,corepack 取 yarn 1.22.22
corepack yarn build:excalidraw # 只重建主包
corepack yarn build:packages # 全链重建发布 runbook(维护者)
- scope 必须是已存在的 org:向不存在的 scope 发新包,npm 返回误导性的
404(探针包同因) - 用 Classic Automation token +
npm publish --userconfig <含 token 的 rc>;网页登录会话 token 发布需 TOTP,recovery code 不能当 OTP(报 EOTP) - 连续失败发布/无效 OTP 会触发 npm 账号风控冻结,失败后停手排查,勿重试轰炸
