示例项目
本文通过几个实际的示例项目,帮助你理解 Obsidian 插件开发的各个方面。
项目一:快速时间戳插件
一个简单的命令插件,在光标位置插入当前时间戳。
项目结构
timestamp-plugin/
├── main.ts
├── manifest.json
├── styles.css
├── tsconfig.json
├── package.json
└── README.mdmanifest.json
json
{
"id": "quick-timestamp",
"name": "Quick Timestamp",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "快速插入时间戳",
"author": "Your Name",
"authorUrl": "https://example.com",
"isDesktopOnly": false
}main.ts
typescript
import { App, Plugin, PluginSettingTab, Setting, moment } from 'obsidian';
// 定义设置接口
interface TimestampSettings {
format: string;
insertOnNewLine: boolean;
}
// 默认设置
const DEFAULT_SETTINGS: TimestampSettings = {
format: 'YYYY-MM-DD HH:mm:ss',
insertOnNewLine: false
};
export default class TimestampPlugin extends Plugin {
settings: TimestampSettings;
async onload() {
// 加载设置
await this.loadSettings();
// 注册命令:插入时间戳
this.addCommand({
id: 'insert-timestamp',
name: '插入时间戳',
editorCallback: (editor) => {
const timestamp = moment().format(this.settings.format);
if (this.settings.insertOnNewLine) {
const cursor = editor.getCursor();
editor.replaceRange(`\n${timestamp}\n`, cursor);
} else {
editor.replaceSelection(timestamp);
}
}
});
// 注册命令:插入日期
this.addCommand({
id: 'insert-date',
name: '插入日期',
editorCallback: (editor) => {
const date = moment().format('YYYY-MM-DD');
editor.replaceSelection(date);
}
});
// 添加设置面板
this.addSettingTab(new TimestampSettingTab(this.app, this));
// 添加 Ribbon 图标
this.addRibbonIcon('clock', '插入时间戳', () => {
this.app.commands.executeCommandById('quick-timestamp:insert-timestamp');
});
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
// 设置面板
class TimestampSettingTab extends PluginSettingTab {
plugin: TimestampPlugin;
constructor(app: App, plugin: TimestampPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: '时间戳设置' });
new Setting(containerEl)
.setName('时间格式')
.setDesc('使用 moment.js 格式')
.addText(text => text
.setPlaceholder('YYYY-MM-DD HH:mm:ss')
.setValue(this.plugin.settings.format)
.onChange(async (value) => {
this.plugin.settings.format = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('新行插入')
.setDesc('在光标后新行插入时间戳')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.insertOnNewLine)
.onChange(async (value) => {
this.plugin.settings.insertOnNewLine = value;
await this.plugin.saveSettings();
}));
}
}学习要点
- 基本插件结构
- 设置的加载和保存
- 添加命令
- 添加 Ribbon 图标
- 创建设置面板
项目二:统计面板插件
一个带侧边栏视图的插件,显示当前笔记的统计信息。
项目结构
stats-plugin/
├── main.ts
├── manifest.json
├── styles.css
├── tsconfig.json
└── package.jsonmain.ts
typescript
import {
App,
Plugin,
PluginSettingTab,
Setting,
ItemView,
WorkspaceLeaf,
TFile,
Notice
} from 'obsidian';
const VIEW_TYPE = 'stats-view';
interface StatsSettings {
showWordCount: boolean;
showCharCount: boolean;
showReadingTime: boolean;
}
const DEFAULT_SETTINGS: StatsSettings = {
showWordCount: true,
showCharCount: true,
showReadingTime: true
};
// 自定义视图
class StatsView extends ItemView {
plugin: StatsPlugin;
constructor(leaf: WorkspaceLeaf, plugin: StatsPlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType(): string {
return VIEW_TYPE;
}
getDisplayText(): string {
return '笔记统计';
}
getIcon(): string {
return 'bar-chart';
}
async onOpen() {
await this.render();
// 监听文件变化
this.registerEvent(
this.app.workspace.on('file-open', () => this.render())
);
this.registerEvent(
this.app.vault.on('modify', () => this.render())
);
}
async render() {
const container = this.containerEl.children[1];
container.empty();
container.addClass('stats-container');
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
container.createEl('p', { text: '没有打开的文件' });
return;
}
const content = await this.app.vault.read(activeFile);
const stats = this.calculateStats(content);
// 标题
container.createEl('h3', { text: activeFile.basename });
// 统计信息
const statsEl = container.createDiv('stats-grid');
if (this.plugin.settings.showWordCount) {
this.createStatCard(statsEl, '字数', stats.words.toString());
}
if (this.plugin.settings.showCharCount) {
this.createStatCard(statsEl, '字符', stats.chars.toString());
}
if (this.plugin.settings.showReadingTime) {
this.createStatCard(statsEl, '阅读时间', `${stats.readingTime} 分钟`);
}
// 额外信息
container.createEl('hr');
const infoEl = container.createDiv('file-info');
infoEl.createEl('p', { text: `文件大小: ${this.formatSize(activeFile.stat.size)}` });
infoEl.createEl('p', { text: `创建时间: ${this.formatDate(activeFile.stat.ctime)}` });
infoEl.createEl('p', { text: `修改时间: ${this.formatDate(activeFile.stat.mtime)}` });
}
createStatCard(container: HTMLElement, label: string, value: string) {
const card = container.createDiv('stat-card');
card.createEl('div', { cls: 'stat-value', text: value });
card.createEl('div', { cls: 'stat-label', text: label });
}
calculateStats(content: string) {
// 移除 Frontmatter
const text = content.replace(/^---\n[\s\S]*\n---\n/, '');
// 统计中文字符
const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
// 统计英文单词
const englishWords = (text.match(/[a-zA-Z]+/g) || []).length;
// 总字数(中文 + 英文单词)
const words = chineseChars + englishWords;
// 总字符数(不含空格)
const chars = text.replace(/\s/g, '').length;
// 阅读时间(假设每分钟 300 字)
const readingTime = Math.ceil(words / 300);
return { words, chars, readingTime };
}
formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleString('zh-CN');
}
async onClose() {
// 清理
}
}
export default class StatsPlugin extends Plugin {
settings: StatsSettings;
view: StatsView;
async onload() {
await this.loadSettings();
// 注册视图
this.registerView(VIEW_TYPE, (leaf) => {
this.view = new StatsView(leaf, this);
return this.view;
});
// 添加命令:打开统计面板
this.addCommand({
id: 'show-stats',
name: '显示统计面板',
callback: () => {
this.initView();
}
});
// 添加 Ribbon 图标
this.addRibbonIcon('bar-chart', '笔记统计', () => {
this.initView();
});
// 添加设置面板
this.addSettingTab(new StatsSettingTab(this.app, this));
}
async initView() {
const leaf = this.app.workspace.getRightLeaf(false);
await leaf.setViewState({ type: VIEW_TYPE, active: true });
this.app.workspace.revealLeaf(leaf);
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
onunload() {
this.app.workspace.detachLeavesOfType(VIEW_TYPE);
}
}
class StatsSettingTab extends PluginSettingTab {
plugin: StatsPlugin;
constructor(app: App, plugin: StatsPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: '统计面板设置' });
new Setting(containerEl)
.setName('显示字数')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showWordCount)
.onChange(async (value) => {
this.plugin.settings.showWordCount = value;
await this.plugin.saveSettings();
this.plugin.view?.render();
}));
new Setting(containerEl)
.setName('显示字符数')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showCharCount)
.onChange(async (value) => {
this.plugin.settings.showCharCount = value;
await this.plugin.saveSettings();
this.plugin.view?.render();
}));
new Setting(containerEl)
.setName('显示阅读时间')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showReadingTime)
.onChange(async (value) => {
this.plugin.settings.showReadingTime = value;
await this.plugin.saveSettings();
this.plugin.view?.render();
}));
}
}styles.css
css
.stats-container {
padding: 16px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 16px 0;
}
.stat-card {
background: var(--background-secondary);
border-radius: 8px;
padding: 16px;
text-align: center;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: var(--text-normal);
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.file-info {
font-size: 12px;
color: var(--text-muted);
}
.file-info p {
margin: 4px 0;
}学习要点
- 创建自定义视图(ItemView)
- 监听文件变化事件
- DOM 操作和样式
- 文件元数据访问
项目三:模板应用插件
一个自动应用模板的插件,支持根据文件夹匹配模板。
main.ts
typescript
import {
App,
Plugin,
PluginSettingTab,
Setting,
TFile,
Notice,
FuzzySuggestModal
} from 'obsidian';
interface FolderTemplate {
folder: string;
template: string;
}
interface TemplatePluginSettings {
templatesFolder: string;
folderTemplates: FolderTemplate[];
autoApply: boolean;
}
const DEFAULT_SETTINGS: TemplatePluginSettings = {
templatesFolder: 'templates',
folderTemplates: [],
autoApply: true
};
export default class TemplatePlugin extends Plugin {
settings: TemplatePluginSettings;
async onload() {
await this.loadSettings();
// 命令:应用模板
this.addCommand({
id: 'apply-template',
name: '应用模板',
editorCallback: async (editor, view) => {
const template = await this.selectTemplate();
if (template) {
await this.applyTemplate(view.file, template);
}
}
});
// 命令:新建笔记并应用模板
this.addCommand({
id: 'new-from-template',
name: '从模板新建',
callback: async () => {
const template = await this.selectTemplate();
if (template) {
await this.createFromTemplate(template);
}
}
});
// 监听文件创建,自动应用模板
this.registerEvent(
this.app.vault.on('create', async (file) => {
if (file instanceof TFile && file.extension === 'md') {
if (this.settings.autoApply) {
await this.autoApplyTemplate(file);
}
}
})
);
// 设置面板
this.addSettingTab(new TemplateSettingTab(this.app, this));
}
async selectTemplate(): Promise<TFile | null> {
const templates = await this.getTemplates();
if (templates.length === 0) {
new Notice('没有找到模板');
return null;
}
return new Promise((resolve) => {
new TemplateSuggest(this.app, templates, resolve).open();
});
}
async getTemplates(): Promise<TFile[]> {
const folder = this.app.vault.getAbstractFileByPath(this.settings.templatesFolder);
if (!folder || !('children' in folder)) {
return [];
}
return (folder.children as TFile[]).filter(
file => file.extension === 'md'
);
}
async applyTemplate(file: TFile | null, template: TFile) {
if (!file) return;
const templateContent = await this.app.vault.read(template);
const currentContent = await this.app.vault.read(file);
// 处理模板变量
const processedContent = await this.processTemplate(templateContent, file);
// 追加到当前文件
const newContent = currentContent + '\n' + processedContent;
await this.app.vault.modify(file, newContent);
new Notice(`已应用模板: ${template.basename}`);
}
async createFromTemplate(template: TFile) {
const templateContent = await this.app.vault.read(template);
// 创建新文件
const fileName = `Untitled ${Date.now()}`;
const newFile = await this.app.vault.create(
`${fileName}.md`,
await this.processTemplate(templateContent, null)
);
// 打开新文件
const leaf = this.app.workspace.getLeaf();
leaf.openFile(newFile);
}
async autoApplyTemplate(file: TFile) {
const parentPath = file.parent?.path || '';
// 查找匹配的文件夹模板
const match = this.settings.folderTemplates.find(ft =>
parentPath.startsWith(ft.folder)
);
if (match) {
const template = this.app.vault.getAbstractFileByPath(match.template);
if (template instanceof TFile) {
const templateContent = await this.app.vault.read(template);
const processedContent = await this.processTemplate(templateContent, file);
await this.app.vault.modify(file, processedContent);
}
}
}
async processTemplate(content: string, file: TFile | null): Promise<string> {
const now = new Date();
// 简单变量替换
let processed = content
.replace(/\{\{title\}\}/g, file?.basename || 'Untitled')
.replace(/\{\{date\}\}/g, now.toLocaleDateString('zh-CN'))
.replace(/\{\{time\}\}/g, now.toLocaleTimeString('zh-CN'))
.replace(/\{\{datetime\}\}/g, now.toLocaleString('zh-CN'));
return processed;
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
// 模板选择器
class TemplateSuggest extends FuzzySuggestModal<TFile> {
templates: TFile[];
onChoose: (template: TFile) => void;
constructor(app: App, templates: TFile[], onChoose: (template: TFile) => void) {
super(app);
this.templates = templates;
this.onChoose = onChoose;
}
getItems(): TFile[] {
return this.templates;
}
getItemText(template: TFile): string {
return template.basename;
}
onChooseItem(template: TFile, evt: MouseEvent | KeyboardEvent): void {
this.onChoose(template);
}
}
// 设置面板
class TemplateSettingTab extends PluginSettingTab {
plugin: TemplatePlugin;
constructor(app: App, plugin: TemplatePlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: '模板插件设置' });
new Setting(containerEl)
.setName('模板文件夹')
.setDesc('存放模板的文件夹路径')
.addText(text => text
.setPlaceholder('templates')
.setValue(this.plugin.settings.templatesFolder)
.onChange(async (value) => {
this.plugin.settings.templatesFolder = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('自动应用')
.setDesc('新建文件时自动应用文件夹模板')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.autoApply)
.onChange(async (value) => {
this.plugin.settings.autoApply = value;
await this.plugin.saveSettings();
}));
// 文件夹模板配置
containerEl.createEl('h3', { text: '文件夹模板' });
this.plugin.settings.folderTemplates.forEach((ft, index) => {
new Setting(containerEl)
.setName(`规则 ${index + 1}`)
.addText(text => text
.setPlaceholder('文件夹路径')
.setValue(ft.folder)
.onChange(async (value) => {
this.plugin.settings.folderTemplates[index].folder = value;
await this.plugin.saveSettings();
}))
.addText(text => text
.setPlaceholder('模板路径')
.setValue(ft.template)
.onChange(async (value) => {
this.plugin.settings.folderTemplates[index].template = value;
await this.plugin.saveSettings();
}))
.addButton(btn => btn
.setIcon('trash')
.onClick(async () => {
this.plugin.settings.folderTemplates.splice(index, 1);
await this.plugin.saveSettings();
this.display();
}));
});
new Setting(containerEl)
.addButton(btn => btn
.setButtonText('添加规则')
.onClick(async () => {
this.plugin.settings.folderTemplates.push({
folder: '',
template: ''
});
await this.plugin.saveSettings();
this.display();
}));
}
}学习要点
- 文件操作(读取、创建、修改)
- FuzzySuggestModal 使用
- 事件监听
- 复杂设置面板
项目四:Markdown 处理插件
一个处理 Markdown 文本的插件,提供格式化、转换等功能。
main.ts(核心部分)
typescript
import { App, Plugin, PluginSettingTab, Setting, Notice } from 'obsidian';
interface MarkdownToolsSettings {
bulletChar: string;
orderedPattern: string;
}
const DEFAULT_SETTINGS: MarkdownToolsSettings = {
bulletChar: '-',
orderedPattern: '1.'
};
export default class MarkdownToolsPlugin extends Plugin {
settings: MarkdownToolsSettings;
async onload() {
await this.loadSettings();
// 命令:格式化表格
this.addCommand({
id: 'format-table',
name: '格式化表格',
editorCallback: (editor) => {
const selection = editor.getSelection();
if (selection) {
const formatted = this.formatTable(selection);
editor.replaceSelection(formatted);
}
}
});
// 命令:转换列表
this.addCommand({
id: 'convert-list',
name: '转换列表类型',
editorCallback: (editor) => {
const selection = editor.getSelection();
if (selection) {
const converted = this.convertList(selection);
editor.replaceSelection(converted);
}
}
});
// 命令:提取链接
this.addCommand({
id: 'extract-links',
name: '提取所有链接',
editorCallback: (editor) => {
const content = editor.getValue();
const links = this.extractLinks(content);
new Notice(`找到 ${links.length} 个链接`);
console.log('提取的链接:', links);
}
});
// 命令:整理标题
this.addCommand({
id: 'sort-headings',
name: '整理标题层级',
editorCallback: (editor) => {
const content = editor.getValue();
const sorted = this.sortHeadings(content);
editor.setValue(sorted);
}
});
this.addSettingTab(new MarkdownToolsSettingTab(this.app, this));
}
formatTable(text: string): string {
const lines = text.trim().split('\n');
if (lines.length < 2) return text;
// 解析表格
const rows = lines.map(line =>
line.split('|').map(cell => cell.trim()).filter(Boolean)
);
// 计算每列最大宽度
const colWidths: number[] = [];
rows.forEach(row => {
row.forEach((cell, i) => {
colWidths[i] = Math.max(colWidths[i] || 0, cell.length);
});
});
// 格式化输出
return rows.map((row, rowIndex) => {
const cells = row.map((cell, i) =>
cell.padEnd(colWidths[i] || 0)
);
let line = '| ' + cells.join(' | ') + ' |';
// 添加分隔行
if (rowIndex === 0) {
const separator = colWidths.map(w => '-'.repeat(w + 2));
line += '\n|' + separator.join('|') + '|';
}
return line;
}).join('\n');
}
convertList(text: string): string {
const lines = text.split('\n');
return lines.map(line => {
// 无序列表转有序
if (line.match(/^\s*[-*+]\s/)) {
return line.replace(/^(\s*)[-*+](\s)/, `$11.$2`);
}
// 有序列表转无序
if (line.match(/^\s*\d+\.\s/)) {
return line.replace(/^(\s*)\d+\.(\s)/, `$1${this.settings.bulletChar}$2`);
}
return line;
}).join('\n');
}
extractLinks(text: string): string[] {
const links: string[] = [];
// 匹配 [[内部链接]]
const internalLinks = text.match(/\[\[([^\]]+)\]\]/g) || [];
links.push(...internalLinks);
// 匹配 [外部链接](url)
const externalLinks = text.match(/\[([^\]]+)\]\(([^)]+)\)/g) || [];
links.push(...externalLinks);
// 匹配裸链接
const bareLinks = text.match(/https?:\/\/[^\s]+/g) || [];
links.push(...bareLinks);
return links;
}
sortHeadings(text: string): string {
const lines = text.split('\n');
const headingRegex = /^(#{1,6})\s/;
// 收集标题
const headings: { level: number; text: string; lineIndex: number }[] = [];
lines.forEach((line, index) => {
const match = line.match(headingRegex);
if (match) {
headings.push({
level: match[1].length,
text: line,
lineIndex: index
});
}
});
// 调整层级(确保没有跳级)
let prevLevel = 0;
headings.forEach(heading => {
if (heading.level > prevLevel + 1) {
// 调整到比前一个标题大 1
const newLevel = prevLevel + 1;
lines[heading.lineIndex] = '#'.repeat(newLevel) + heading.text.replace(/^#+/, '');
heading.level = newLevel;
}
prevLevel = heading.level;
});
return lines.join('\n');
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class MarkdownToolsSettingTab extends PluginSettingTab {
plugin: MarkdownToolsPlugin;
constructor(app: App, plugin: MarkdownToolsPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Markdown 工具设置' });
new Setting(containerEl)
.setName('无序列表符号')
.setDesc('转换列表时使用的符号')
.addDropdown(dropdown => dropdown
.addOption('-', '- (连字符)')
.addOption('*', '* (星号)')
.addOption('+', '+ (加号)')
.setValue(this.plugin.settings.bulletChar)
.onChange(async (value) => {
this.plugin.settings.bulletChar = value;
await this.plugin.saveSettings();
}));
}
}学习要点
- 文本处理和正则表达式
- Editor API 使用
- 复杂的命令逻辑