添加Excalidraw Markdown在Typecho文章中

我在 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.jsonsrc/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.jsindex.csslocalesfontsdata 等生产资源。

3. 构建

推荐 ESM 分包

npm run build:split

产物目录:dist-split

  • excalidraw-runtime.js(入口)
  • excalidraw-runtime.css
  • chunks/*
  • assets/*

可选:绝对路径分包

npm run build:split:absolute

说明:package.jsonpublic-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;  
}