Canvas API 开发指南
Obsidian Canvas 是内置的白板功能,支持创建可视化节点图。通过 Canvas API,插件开发者可以读取、修改和扩展白板内容。
Canvas API 概述
Canvas API 在 Obsidian 1.1+ 中引入,允许插件:
- 读取白板数据(节点、边、位置)
- 程序化创建/修改/删除节点
- 添加自定义节点类型
- 监听白板事件
Canvas 数据结构
Canvas(白板)
├── Node(节点)
│ ├── Text Node(文本节点)
│ ├── File Node(文件节点)
│ ├── Link Node(链接节点)
│ └── Group Node(分组节点)
├── Edge(边/连接)
│ ├── id, fromNode, toNode
│ ├── label, color
│ └── fromSide, toSide
└── Viewport(视口)
├── x, y, zoom
└── centerX, centerY访问 Canvas
获取当前活动的 Canvas
typescript
import { App, TFile } from "obsidian";
function getActiveCanvas(app: App): Canvas | null {
const view = app.workspace.getActiveViewOfType(ItemView);
if (view && view.getViewType() === "canvas") {
return (view as any).canvas as Canvas;
}
return null;
}打开白板文件
typescript
async function openCanvas(app: App, file: TFile): Promise<Canvas | null> {
await app.workspace.openLinkText(file.path, "", false);
const leaf = app.workspace.getActiveViewOfType(ItemView);
if (leaf && leaf.getViewType() === "canvas") {
return (leaf as any).canvas;
}
return null;
}节点操作
读取节点
typescript
interface Canvas {
nodes: Map<string, CanvasNode>;
edges: Map<string, CanvasEdge>;
// ...
}
interface CanvasNode {
id: string;
x: number;
y: number;
width: number;
height: number;
color: string;
type: "text" | "file" | "link" | "group";
text?: string; // 文本节点内容
file?: TFile; // 文件节点
url?: string; // 链接节点 URL
// ...
}
// 读取所有节点
function getAllNodes(canvas: Canvas): CanvasNode[] {
return Array.from(canvas.nodes.values());
}
// 读取文本节点
function getTextNodes(canvas: Canvas): CanvasNode[] {
return getAllNodes(canvas).filter(n => n.type === "text");
}
// 获取选中节点
function getSelectedNodes(canvas: Canvas): CanvasNode[] {
return getAllNodes(canvas).filter(n => n.isEditing);
}创建节点
typescript
// 创建文本节点
function createTextNode(
canvas: Canvas,
text: string,
x: number,
y: number,
width: number = 250,
height: number = 60
): CanvasNode {
return canvas.createTextNode({
text: text,
pos: { x, y },
size: { width, height },
});
}
// 创建文件节点
function createFileNode(
canvas: Canvas,
file: TFile,
x: number,
y: number,
width: number = 400,
height: number = 300
): CanvasNode {
return canvas.createFileNode({
file: file,
pos: { x, y },
size: { width, height },
});
}
// 创建链接节点
function createLinkNode(
canvas: Canvas,
url: string,
x: number,
y: number,
width: number = 400,
height: number = 300
): CanvasNode {
return canvas.createLinkNode({
url: url,
pos: { x, y },
size: { width, height },
});
}
// 创建分组节点
function createGroupNode(
canvas: Canvas,
label: string,
x: number,
y: number,
width: number = 500,
height: number = 400
): CanvasNode {
return canvas.createGroupNode({
pos: { x, y },
size: { width, height },
label: label,
});
}修改节点
typescript
// 移动节点
function moveNode(node: CanvasNode, x: number, y: number) {
node.moveTo({ x, y });
}
// 调整大小
function resizeNode(node: CanvasNode, width: number, height: number) {
node.resize({ width, height });
}
// 设置颜色
function setNodeColor(node: CanvasNode, color: string) {
node.setColor(color);
}
// 设置文本内容
function setNodeText(node: CanvasNode, text: string) {
if (node.type === "text") {
node.setText(text);
}
}删除节点
typescript
function deleteNode(canvas: Canvas, node: CanvasNode) {
canvas.removeNode(node);
}
// 批量删除
function deleteNodes(canvas: Canvas, nodes: CanvasNode[]) {
canvas.beginTransaction();
for (let node of nodes) {
canvas.removeNode(node);
}
canvas.endTransaction();
}边操作
创建连接
typescript
interface CanvasEdge {
id: string;
fromNode: string;
toNode: string;
fromSide?: "top" | "right" | "bottom" | "left";
toSide?: "top" | "right" | "bottom" | "left";
label?: string;
color?: string;
}
// 创建边
function createEdge(
canvas: Canvas,
fromNode: CanvasNode,
toNode: CanvasNode,
label?: string
): CanvasEdge {
return canvas.createEdge({
from: fromNode.id,
to: toNode.id,
label: label,
fromSide: "right",
toSide: "left",
});
}批量创建连接
typescript
function connectNodes(
canvas: Canvas,
connections: Array<{ from: string; to: string; label?: string }>
) {
canvas.beginTransaction();
for (let conn of connections) {
const fromNode = canvas.nodes.get(conn.from);
const toNode = canvas.nodes.get(conn.to);
if (fromNode && toNode) {
canvas.createEdge({
from: fromNode.id,
to: toNode.id,
label: conn.label,
});
}
}
canvas.endTransaction();
}视口操作
typescript
// 缩放到指定节点
function zoomToNode(canvas: Canvas, node: CanvasNode) {
canvas.zoomToSelection();
// 或手动设置视口
canvas.setViewport({
x: node.x - 100,
y: node.y - 100,
zoom: 1.0,
});
}
// 缩放以适应全部内容
function zoomToFit(canvas: Canvas) {
canvas.zoomToFit();
}
// 获取当前视口
function getViewport(canvas: Canvas) {
return {
x: canvas.x,
y: canvas.y,
zoom: canvas.zoom,
};
}事件监听
typescript
// 监听节点选择变化
canvas.on("selection-change", () => {
const selected = getSelectedNodes(canvas);
console.log(`选中了 ${selected.length} 个节点`);
});
// 监听节点创建
canvas.on("node-added", (node: CanvasNode) => {
console.log(`新节点: ${node.id}`);
});
// 监听节点删除
canvas.on("node-removed", (node: CanvasNode) => {
console.log(`删除节点: ${node.id}`);
});
// 监听边创建
canvas.on("edge-added", (edge: CanvasEdge) => {
console.log(`新连接: ${edge.fromNode} → ${edge.toNode}`);
});
// 监听视口变化
canvas.on("viewport-change", (viewport: any) => {
// 防抖处理
console.log(`视口: ${viewport.x}, ${viewport.y}, zoom: ${viewport.zoom}`);
});实战案例:从笔记自动生成思维导图
typescript
import { Plugin, TFile } from "obsidian";
export default class MindMapPlugin extends Plugin {
async generateMindMapFromNote(file: TFile) {
// 读取笔记内容
const content = await this.app.vault.read(file);
// 解析标题层级
const lines = content.split("\n");
const nodes: Array<{
id: string;
text: string;
level: number;
x: number;
y: number;
}> = [];
let y = 0;
const baseX = 0;
for (let line of lines) {
const match = line.match(/^(#{1,6})\s+(.+)/);
if (match) {
const level = match[1].length;
const text = match[2];
nodes.push({
id: `node-${nodes.length}`,
text: text,
level: level,
x: baseX + (level - 1) * 300,
y: y,
});
y += 80 + level * 10;
}
}
if (nodes.length === 0) {
new Notice("笔记中没有标题,无法生成思维导图");
return;
}
// 打开白板
const canvas = await this.openOrCreateCanvas();
if (!canvas) return;
// 创建节点
canvas.beginTransaction();
const nodeMap = new Map<string, CanvasNode>();
for (let nodeData of nodes) {
const colors = ["5", "4", "3", "2", "1", "6"]; // Canvas 颜色索引
const color = colors[Math.min(nodeData.level - 1, colors.length - 1)];
const canvasNode = canvas.createTextNode({
text: nodeData.text,
pos: { x: nodeData.x, y: nodeData.y },
size: { width: 250, height: 60 },
color: color,
});
nodeMap.set(nodeData.id, canvasNode);
}
// 创建连接(父子关系)
for (let i = 1; i < nodes.length; i++) {
const current = nodes[i];
let parent = null;
// 找到上一个层级更小的节点作为父节点
for (let j = i - 1; j >= 0; j--) {
if (nodes[j].level < current.level) {
parent = nodes[j];
break;
}
}
if (parent) {
const parentNode = nodeMap.get(parent.id);
const childNode = nodeMap.get(current.id);
if (parentNode && childNode) {
canvas.createEdge({
from: parentNode.id,
to: childNode.id,
fromSide: "right",
toSide: "left",
});
}
}
}
canvas.endTransaction();
canvas.zoomToFit();
new Notice(`已生成思维导图:${nodes.length} 个节点`);
}
async openOrCreateCanvas(): Promise<Canvas | null> {
// 创建新的白板文件
const fileName = `mindmap-${Date.now()}.canvas`;
const filePath = `canvas/${fileName}`;
// 确保 canvas 目录存在
const folder = this.app.vault.getAbstractFileByPath("canvas");
if (!folder) {
await this.app.vault.createFolder("canvas");
}
// 创建空白 .canvas 文件
const file = await this.app.vault.create(filePath, `{"nodes":[],"edges":[]}`);
// 打开白板
await this.app.workspace.openLinkText(filePath, "", false);
const view = this.app.workspace.getActiveViewOfType(ItemView);
if (view && view.getViewType() === "canvas") {
return (view as any).canvas;
}
return null;
}
}保存与导出
读取 Canvas JSON
typescript
async function readCanvasData(app: App, file: TFile): Promise<any> {
const content = await app.vault.read(file);
return JSON.parse(content);
}
// 获取所有节点和边的数据
async function exportCanvas(app: App, file: TFile) {
const data = await readCanvasData(app, file);
return {
nodes: data.nodes || [],
edges: data.edges || [],
nodeCount: (data.nodes || []).length,
edgeCount: (data.edges || []).length,
};
}导出为 Markdown
typescript
async function canvasToMarkdown(canvas: Canvas): Promise<string> {
const nodes = Array.from(canvas.nodes.values());
const edges = Array.from(canvas.edges.values());
let markdown = "# 白板导出\n\n";
// 按位置排序
nodes.sort((a, b) => a.y - b.y || a.x - b.x);
for (let node of nodes) {
if (node.type === "text") {
markdown += `## ${node.text || "未命名节点"}\n\n`;
markdown += `> 位置: (${node.x}, ${node.y})\n\n`;
} else if (node.type === "file") {
markdown += `## [[${node.file?.basename || "文件"}]]\n\n`;
}
}
markdown += "\n## 连接关系\n\n";
for (let edge of edges) {
const fromNode = canvas.nodes.get(edge.fromNode);
const toNode = canvas.nodes.get(edge.toNode);
if (fromNode && toNode) {
markdown += `- ${fromNode.text || fromNode.id} → ${toNode.text || toNode.id}\n`;
}
}
return markdown;
}注意事项
Canvas API 注意事项
- 实验性 API:Canvas API 仍可能变化,请关注 Obsidian 更新日志
- 事务管理:批量操作使用
beginTransaction()/endTransaction() - 坐标系统:Canvas 坐标原点在左上角,x 向右增,y 向下增
- 文件格式:
.canvas文件是 JSON 格式,可直接读写 - 性能:大量节点时注意性能,建议分批操作