Install
openclaw skills install @zhenyangze/excalidraw-handdraw-skill根据提示词生成手绘风格图表的 skill。用于:(1) 创建架构图、流程图、ER 图等 Excalidraw 手绘风格图表 (2) 通过 Docker 本地运行 canvas 服务器 (3) 生成 PNG/SVG 图片 (4) 保存图片到指定目录 (5) 将图表插入或替换到文件指定位置 (6) 支持中文手写字体。触发词:画图、创建图表、生成图表、手绘风格、架构图、流程图、Excalidraw、保存图表到文件、插入图表。
openclaw skills install @zhenyangze/excalidraw-handdraw-skill首先检查 Docker canvas 是否已运行:
docker ps | grep mcp_excalidraw-canvas
如果未运行,使用脚本启动:
./scripts/start-canvas.sh
确认服务可用:
curl -s http://localhost:3000/health
用户描述想要创建的图表类型,例如:
分析需求:
⚠️ 重要:REST API 格式说明
{"label": {"text": "文字"}} 格式(不是 text){"start": {"id": "id1"}, "end": {"id": "id2"}} 格式(不是 startElementId)x 和 y 坐标(可以为 0)API 调用方式:
POST /api/elements 逐个创建元素(不支持批量创建)Playwright 操作示例:
async () => {
const elements = [
{ id: "lb", type: "rectangle", x: 300, y: 50, width: 180, height: 60, label: { text: "负载均衡器" } },
{ id: "svc-a", type: "rectangle", x: 100, y: 200, width: 160, height: 60, label: { text: "服务 A" } },
{ id: "arrow1", type: "arrow", x: 0, y: 0, start: { id: "lb" }, end: { id: "svc-a" } }
];
for (const el of elements) {
await fetch('/api/elements', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(el)
});
}
return { success: true };
}
参考坐标系统:
max(160, labelCharCount * 9)⚠️ 布局最佳实践(避免重叠和裁剪):
创建后获取截图验证:
get_canvas_screenshot 获取图片发现问题时修复:
⚠️ 重要:必须使用 Canvas API 导出纯净图片,不要使用页面截图!
使用 Playwright 执行 JavaScript,直接从 canvas 元素导出:
async () => {
const canvas = document.querySelector('canvas');
if (!canvas) return { success: false };
// 获取画布数据并下载
const dataUrl = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.download = 'diagram.png';
link.href = dataUrl;
link.click();
return { success: true, downloaded: true };
}
Playwright 操作步骤:
browser_evaluate 执行上述 JavaScript.playwright-mcp/ 目录mv 命令移动到目标位置示例:
browser_evaluate → 执行导出 JS
→ 下载到 .playwright-mcp/diagram.png
→ mv .playwright-mcp/diagram.png ./output/diagram.png
❌ 错误方式:
browser_take_screenshot - 会包含网页工具栏和侧边栏browser_snapshot + 截图 - 同样包含 UI 元素✅ 正确方式:
browser_evaluate 执行 canvas.toDataURL() 导出图片下载后保存在 .playwright-mcp/ 目录,使用 mv 命令移动到目标位置:
mv .playwright-mcp/diagram.png ./docs/diagrams/architecture.png
将图片插入到 Markdown 文件:

| 操作 | 方法 |
|---|---|
| 启动 Canvas | docker ps | grep mcp_excalidraw-canvas 确认运行 |
| 停止 Canvas | docker stop mcp_excalidraw-canvas |
| 创建元素 | browser_evaluate 执行 fetch('/api/elements', {...}) |
| 清除画布 | 点击页面 "Clear Canvas" 按钮 |
| 同步画布 | 点击页面 "Sync to Backend" 按钮 |
| 导出纯净图片 | browser_evaluate 执行 canvas.toDataURL() + 下载 |
| 移动图片 | mv .playwright-mcp/diagram.png ./output/diagram.png |
async () => {
const elements = [];
// 布局参数
const config = {
startY: 50, // 起始 Y 坐标
levelGap: 180, // 层级垂直间距
nodeWidth: 140, // 节点宽度
nodeHeight: 60, // 节点高度
hGap: 100 // 水平间距
};
// 第一层:CEO
elements.push({
id: "ceo", type: "rectangle",
x: 300, y: config.startY,
width: 160, height: config.nodeHeight,
label: { text: "CEO\n总经理" }
});
// 第二层:高管层(水平排列)
const level2 = ["CTO\n技术总监", "CFO\n财务总监", "COO\n运营总监", "CMO\n市场总监"];
const level2Y = config.startY + config.levelGap;
level2.forEach((label, i) => {
elements.push({
id: `l2-${i}`, type: "rectangle",
x: 50 + i * (config.nodeWidth + config.hGap),
y: level2Y,
width: config.nodeWidth, height: config.nodeHeight,
label: { text: label }
});
// 连接到 CEO
elements.push({
id: `arr-ceo-${i}`, type: "arrow",
x: 0, y: 0,
start: { id: "ceo" }, end: { id: `l2-${i}` }
});
});
// 创建所有元素
for (const el of elements) {
await fetch('/api/elements', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(el)
});
}
return { success: true };
}
async () => {
const steps = ["开始", "步骤1", "步骤2", "结束"];
const config = { startX: 50, y: 100, boxWidth: 120, boxHeight: 50, gap: 80 };
const elements = [];
steps.forEach((label, i) => {
elements.push({
id: `step-${i}`, type: "rectangle",
x: config.startX + i * (config.boxWidth + config.gap),
y: config.y,
width: config.boxWidth, height: config.boxHeight,
label: { text: label }
});
// 连接箭头(从上一个到当前)
if (i > 0) {
elements.push({
id: `arr-${i}`, type: "arrow",
x: 0, y: 0,
start: { id: `step-${i-1}` }, end: { id: `step-${i}` }
});
}
});
for (const el of elements) {
await fetch('/api/elements', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(el)
});
}
return { success: true };
}
async () => {
const steps = ["输入", "处理", "输出"];
const config = { x: 300, startY: 50, boxWidth: 160, boxHeight: 60, vGap: 150 };
const elements = [];
steps.forEach((label, i) => {
elements.push({
id: `node-${i}`, type: "rectangle",
x: config.x,
y: config.startY + i * (config.boxHeight + config.vGap),
width: config.boxWidth, height: config.boxHeight,
label: { text: label }
});
if (i > 0) {
elements.push({
id: `arr-${i}`, type: "arrow",
x: 0, y: 0,
start: { id: `node-${i-1}` }, end: { id: `node-${i}` }
});
}
});
for (const el of elements) {
await fetch('/api/elements', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(el)
});
}
return { success: true };
}
在编写 JavaScript 时,可使用以下辅助计算:
// 水平居中计算
const centerX = (canvasWidth, elementWidth) => (canvasWidth - elementWidth) / 2;
// 多元素水平均匀分布
const distributeHorizontal = (count, elementWidth, totalWidth, startX) => {
const totalGap = totalWidth - count * elementWidth;
const gap = totalGap / (count + 1);
return Array.from({length: count}, (_, i) => startX + gap + i * (elementWidth + gap));
};
// 计算元素所需宽度(根据文字)
const calcWidth = (text) => Math.max(120, text.length * 12);
// 计算元素所需高度(根据行数)
const calcHeight = (text) => {
const lines = text.split('\n').length;
return lines * 30 + 20;
};
mcp_excalidraw-canvas 容器在运行canvas.toDataURL() 方式导出,不要用页面截图.playwright-mcp/ 目录start: {id}, end: {id} 格式,让 Excalidraw 自动计算路径docker ps | grep mcp_excalidraw-canvascanvas.toDataURL() 而不是 browser_take_screenshotstart: {id}, end: {id} 元素引用格式,而非手动 pointswidth = text.length * 12 估算| 问题 | 原因 | 解决方案 |
|---|---|---|
| 矩形重叠 | 间距太小 | 垂直间距改为 150px+,水平 80px+ |
| 箭头乱连 | points 坐标错误 | 改用 start: {id}, end: {id} 格式 |
| 文字不全 | width/height 不足 | 根据字数行数计算足够尺寸 |
| 图表太密 | 整体布局紧凑 | 放大间距,宁可松散不要拥挤 |
| 连线穿过元素 | 布局未考虑路径 | 调整元素位置或使用曲线箭头 |