Skip to content

示例项目

本文通过几个实际的示例项目,帮助你理解 Obsidian 插件开发的各个方面。

项目一:快速时间戳插件

一个简单的命令插件,在光标位置插入当前时间戳。

项目结构

timestamp-plugin/
├── main.ts
├── manifest.json
├── styles.css
├── tsconfig.json
├── package.json
└── README.md

manifest.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.json

main.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 使用
  • 复杂的命令逻辑

相关内容

最后更新:2026年2月22日编辑此页反馈问题