我在 Typecho 里支持下面Excalidraw Markdown 语法,并在前台自动渲染为 Excalidraw 预览图:
{"type":"excalidraw/clipboard","elements":[{"id":"6GjbnNpb0v7uZHOxT6s7-","type":"rectangle","x":661.5999755859375,"y":333.6000061035156,"width":584.800048828125,"height":419.9999694824219,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a0","roundness":{"type":3},"seed":1761550740,"version":78,"versionNonce":1747656724,"isDeleted":false,"boundElements":null,"updated":1772295344156,"link":null,"locked":false},{"id":"WvhWp8UfkYdSsNK8OTPDJ","type":"rectangle","x":618.4000244140625,"y":205.60000610351562,"width":400,"height":370.3999938964844,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a2","roundness":{"type":3},"seed":1405776428,"version":30,"versionNonce":285044652,"isDeleted":false,"boundElements":null,"updated":1772295348046,"link":null,"locked":false}],"files":{}}- 前台只做预览,不开放编辑工具栏
- 本地化资源
- 文章内支持
excalidraw代码块
Ⅰ. 构建本地化资源
把 Excalidraw 运行时资源放到当前主题目录,例如:
/usr/themes/你的主题/assets/vendor/excalidraw/excalidraw-runtime.mod.js/usr/themes/你的主题/assets/vendor/excalidraw/excalidraw-runtime.css/usr/themes/你的主题/assets/vendor/excalidraw/chunks/*/usr/themes/你的主题/assets/vendor/excalidraw/assets/*
注意:推荐 ESM 分包版本,入口小、按需加载 chunks1
1. 创建文件夹
在.tmp-excalidraw-build文件夹添加文件package.json、src/runtime.js
package.json如下:
{
"name": "excalidraw-build",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "esbuild src/runtime.js --bundle --format=iife --target=es2020 --conditions=production --define:process.env.NODE_ENV=\\\"production\\\" --minify --global-name=ExcalidrawMarkdownRuntime --outfile=dist/excalidraw-runtime.js --loader:.woff2=file --loader:.ttf=file --loader:.png=file --loader:.svg=file --loader:.css=css --asset-names=assets/[name]-[hash] --public-path=./",
"build:split": "esbuild src/runtime.js --bundle --format=esm --splitting --target=es2020 --conditions=production --define:process.env.NODE_ENV=\\\"production\\\" --minify --outdir=dist-split --entry-names=excalidraw-runtime --chunk-names=chunks/[name]-[hash] --loader:.woff2=file --loader:.ttf=file --loader:.png=file --loader:.svg=file --loader:.css=css --asset-names=assets/[name]-[hash] --public-path=./",
"build:split:absolute": "esbuild src/runtime.js --bundle --format=esm --splitting --target=es2020 --conditions=production --define:process.env.NODE_ENV=\\\"production\\\" --minify --outdir=dist-split-abs --entry-names=excalidraw-runtime --chunk-names=chunks/[name]-[hash] --loader:.woff2=file --loader:.ttf=file --loader:.png=file --loader:.svg=file --loader:.css=css --asset-names=assets/[name]-[hash] --public-path=/usr/themes/YOUR_THEME/assets/vendor/excalidraw/"
},
"dependencies": {
"@excalidraw/excalidraw": "0.18.0",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"esbuild": "0.25.10"
}
}Excalidraw 渲染适配层 把 Excalidraw 包装成你主题里可直接调用的统一入口 src/runtime.js如下:
import React from "react";
import { createRoot } from "react-dom/client";
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
function safeTheme(theme) {
return theme === "dark" ? "dark" : "light";
}
function normalizeScene(scene) {
const raw = scene && typeof scene === "object" ? scene : {};
const appState = raw.appState && typeof raw.appState === "object" ? raw.appState : {};
return {
scrollToContent: true,
elements: Array.isArray(raw.elements) ? raw.elements : [],
files: raw.files && typeof raw.files === "object" ? raw.files : {},
appState: {
...appState,
viewModeEnabled: true,
zenModeEnabled: false,
viewBackgroundColor: "transparent"
}
};
}
function mount(stage, scene, options) {
if (!stage) {
throw new Error("excalidraw mount stage missing");
}
const opts = options && typeof options === "object" ? options : {};
const normalizedScene = normalizeScene(scene);
let apiRef = null;
const root = createRoot(stage);
root.render(
React.createElement(Excalidraw, {
initialData: normalizedScene,
excalidrawAPI: (api) => {
apiRef = api;
},
theme: safeTheme(opts.theme),
detectScroll: false,
viewModeEnabled: true,
zenModeEnabled: false
})
);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
try {
if (apiRef && typeof apiRef.scrollToContent === "function") {
apiRef.scrollToContent(undefined, {
fitToContent: true,
animate: false
});
}
} catch (e) {}
});
});
return {
unmount() {
try {
root.unmount();
} catch (e) {}
}
};
}
window.ExcalidrawMarkdownRuntime = {
mount
};2. 初始化
cd .tmp-excalidraw-build
npm install安装完成后,官方 Excalidraw prod 资源在:
node_modules/@excalidraw/excalidraw/dist/prod
这里包含 index.js、index.css、locales、fonts、data 等生产资源。
3. 构建
推荐 ESM 分包
npm run build:split产物目录:dist-split
excalidraw-runtime.js(入口)excalidraw-runtime.csschunks/*assets/*
可选:绝对路径分包
npm run build:split:absolute说明:package.json 中 public-path 默认是占位值:
/usr/themes/YOUR_THEME/assets/vendor/excalidraw/
使用前请改成你的主题路径。
4. 本地化到主题目录
.tmp-excalidraw-build/node_modules/@excalidraw/excalidraw/dist/prod
目标文件夹 /assets/vendor/excalidraw/prod
.tmp-excalidraw-build/dist-split/chunks
目标文件夹 /assets/vendor/excalidraw/chunks
.tmp-excalidraw-build/dist-split/assets
目标文件夹 /assets/vendor/excalidraw/assets
.tmp-excalidraw-build/dist-split/excalidraw-runtime.js
目标文件 /assets/vendor/excalidraw/excalidraw-runtime.js
.tmp-excalidraw-build/dist-split/excalidraw-runtime.css
目标文件 /assets/vendor/excalidraw/excalidraw-runtime.css
Ⅱ. 前端渲染
把下面脚本放进主题 footer.php中:
<script>
(function () {
var CONTENT_SELECTORS = [
'.entry-content',
'.post-content',
'.article-content',
'.markdown-body',
'.hj-article-content',
'.typecho-post-content'
];
var ROOTS = Array.prototype.slice.call(
document.querySelectorAll(CONTENT_SELECTORS.join(','))
);
if (!ROOTS.length) return;
function loadCssOnce(href) {
if (!href || document.querySelector('link[data-tc-excalidraw-css]')) return;
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.setAttribute('data-tc-excalidraw-css', '1');
document.head.appendChild(link);
}
function loadModuleOnce(src, done) {
if (!src) return done && done();
var key = src.replace(/[^a-z0-9]/gi, '_');
var selector = 'script[data-tc-excalidraw-js="' + key + '"]';
var existing = document.querySelector(selector);
if (existing) {
if (existing.getAttribute('data-loaded') === '1') return done && done();
existing.addEventListener('load', function () { done && done(); }, { once: true });
existing.addEventListener('error', function () { done && done(); }, { once: true });
return;
}
var script = document.createElement('script');
script.type = 'module';
script.src = src;
script.async = false;
script.setAttribute('data-tc-excalidraw-js', key);
script.onload = function () {
script.setAttribute('data-loaded', '1');
done && done();
};
script.onerror = function () { done && done(); };
document.head.appendChild(script);
}
function normalizeScene(scene) {
if (!scene || typeof scene !== 'object') return null;
var appState = scene.appState && typeof scene.appState === 'object' ? scene.appState : {};
return {
scrollToContent: true,
elements: Array.isArray(scene.elements) ? scene.elements : [],
files: scene.files && typeof scene.files === 'object' ? scene.files : {},
appState: Object.assign({}, appState, {
viewModeEnabled: true,
zenModeEnabled: false,
viewBackgroundColor: 'transparent'
})
};
}
function buildFigure() {
var figure = document.createElement('figure');
figure.className = 'tc-excalidraw-block';
var stage = document.createElement('div');
stage.className = 'tc-excalidraw-stage';
var editor = document.createElement('div');
editor.className = 'tc-excalidraw-editor';
// 兜底尺寸(防止某些主题样式缓存导致 canvas 0x0)
figure.style.display = 'block';
figure.style.width = '100%';
figure.style.maxWidth = '100%';
figure.style.margin = '1rem 0';
figure.style.border = '1px solid #000';
figure.style.borderRadius = '4px';
figure.style.overflow = 'hidden';
figure.style.background = 'transparent';
stage.style.position = 'relative';
stage.style.width = '100%';
stage.style.minHeight = '520px';
stage.style.maxHeight = '760px';
stage.style.overflow = 'hidden';
editor.style.position = 'absolute';
editor.style.inset = '0';
editor.style.width = '100%';
editor.style.height = '100%';
stage.appendChild(editor);
figure.appendChild(stage);
return { figure: figure, editor: editor };
}
function findTargets() {
var all = [];
ROOTS.forEach(function (root) {
var list = root.querySelectorAll('pre > code.language-excalidraw, pre > code.lang-excalidraw, pre > code[class*="excalidraw"]');
for (var i = 0; i < list.length; i++) all.push(list[i]);
});
return all;
}
function renderAll() {
var targets = findTargets();
if (!targets.length) return;
targets.forEach(function (codeEl) {
var pre = codeEl.closest('pre');
if (!pre || !pre.parentNode || pre.getAttribute('data-tc-excalidraw') === '1') return;
var text = (codeEl.textContent || '').trim();
if (!text) return;
var scene;
try {
scene = JSON.parse(text);
} catch (_) {
return;
}
var normalized = normalizeScene(scene);
if (!normalized) return;
var mounted = buildFigure();
pre.setAttribute('data-tc-excalidraw', '1');
pre.parentNode.replaceChild(mounted.figure, pre);
try {
var runtime = window.HansJackExcalidraw;
if (!runtime || typeof runtime.mount !== 'function') return;
runtime.mount(mounted.editor, normalized, { theme: 'light' });
} catch (_) {}
});
}
// 主题路径按你的实际主题名修改
var BASE = '/usr/themes/你的主题/assets/vendor/excalidraw/';
window.EXCALIDRAW_ASSET_PATH = BASE + 'prod/';
loadCssOnce(BASE + 'hj-excalidraw-runtime.css');
loadModuleOnce(BASE + 'hj-excalidraw-runtime.mod.js', renderAll);
})();
</script>Ⅲ. 样式
.tc-excalidraw-block {
display: block;
margin: 1rem 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
border: 1px solid #000;
border-radius: 4px;
background: transparent;
overflow: hidden;
}
.tc-excalidraw-stage {
position: relative;
width: 100%;
min-height: 520px;
max-height: min(82vh, 760px);
overflow: hidden;
background: transparent;
}
.tc-excalidraw-editor {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
/* 预览模式下隐藏编辑工具区(防缓存差异) */
.tc-excalidraw-editor .excalidraw .shapes-section,
.tc-excalidraw-editor .excalidraw .App-toolbar-container {
display: none !important;
}