思源笔记社区正在搭建中,现邀您共建
SiYuan Community is under construction. Join us to co-build.
原贴地址:https://ld246.com/article/1723732790981
作者:F 酱
npm install siyuan❓ 为什么是 svelte,而不是更加常见的例如 react 框架? React 的流行主要源自其先发地位和优秀的生态环境;但是在插件开发的场景下,前端库的生态如何、组件库是否够多带来的影响并不大 svelte 足够轻量级、性能足够高;而 React 这类基于 vdom 的框架,往往打包结果偏大,并不适合插件开发这种小型项目 svelte 的开发和上手成本最低

import { Plugin } from 'siyuan';
class MyPlugin extends Plugin {
onload() {
//插件的入口函数,一个 minimum 的插件至少要包含 onload 的实现, 最常用
//onload 可以被声明为一个 async 函数
}
onLayoutReady() {
//布局加载完成的时候,会自动调用这个函数
}
onunload() {
//当插件被禁用的时候,会自动调用这个函数
}
uninstall() {
//当插件被卸载的时候,会自动调用这个函数
}
}eventBus 对象。class Plugin {
eventBus: EventBus;
}plugin.eventBus.on('some event', callback func),为插件注册一个总线事件的回调函数,让插件在思源的特定时刻执行一些特别的功能,例如:import { Plugin } from 'siyuan';
class MyPlugin extends Plugin {
cbBound: this.cb.bind(this);
cb({ detail} ) {
console.log('刚刚打开了一个新的文档!');
}
onload() {
this.eventBus.on('loaded-protyle-dynamic', this.cbBound);
}
onunload() {
this.eventBus.off('loaded-protyle-dynamic', this.cbBound);
}
}fetch、python 的 requests 等)require('siyuan') 获取 API 对象protyle 类名的元素。这里的 protyle 就代表了完整的文档。
注意:尽量不要手动改 DOM!如果想要更改文档内容,请使用后端 API。
data-node-id 对应了块的 IDdata-type 对应了块的 typedata-subtype 对应了块的 subtypeplugin.addTopbar 来为插件添加一个顶栏的按钮。
/**
* Must be executed before the synchronous function.
* @param {string} [options.position=right]
* @param {string} options.icon - Support svg id or svg tag.
*/
addTopBar(options: {
icon: string,
title: string,
callback: (event: MouseEvent) => void
position?: "right" | "left"
}): HTMLElement;icon 参数iconRight 的参数body>svg>defs 下,你可以查看到所有思源内置的 symbolplugin.addIcons 来传入自定义的 svg symbol,例如

new Menu 创建一个菜单对象menu.addItem 添加菜单项目menu.open 显示菜单import { Menu } from 'siyuan';
private addMenu() {
const menu = new Menu("myPluginMenu", () => {
console.log("Menu will close");
});
menu.addItem({
icon: "iconInfo",
label: "About",
click: () => {
// 菜单项的回调
}
});
menu.open({ x: 0, y: 0 }); // 显示菜单
}IMenuItemOptionexport interface IMenuItemOption {
iconClass?: string;
label?: string;
click?: (element: HTMLElement, event: MouseEvent) => boolean | void | Promise<boolean | void>;
type?: "separator" | "submenu" | "readonly";
accelerator?: string;
action?: string;
id?: string;
submenu?: IMenuItemOption[];
disabled?: boolean;
icon?: string;
iconHTML?: string;
current?: boolean;
bind?: (element: HTMLElement) => void;
index?: number;
element?: HTMLElement;
}
export default class BqCalloutPlugin extends Plugin {
private blockIconEventBindThis = this.blockIconEvent.bind(this);
async onload() {
this.eventBus.on("click-blockicon", this.blockIconEventBindThis);
}
async onunload() {
this.eventBus.off("click-blockicon", this.blockIconEventBindThis);
}
private blockIconEvent({ detail }: any) {
// 强行请查看 click-blockicon eventBus 的类型定义
let menu: Menu = detail.menu;
let submenus = [];
submenus.push({
element: callout.createCalloutButton("", {id: this.i18n.mode.big, icon: '🇹'}),
click: () => {
setBlockAttrs(ele.getAttribute("data-node-id"), {
'custom-callout-mode': 'big',
});
}
});
submenus.push({
element: callout.createCalloutButton("", {id: this.i18n.mode.small, icon: '🇵'}),
click: () => {
setBlockAttrs(ele.getAttribute("data-node-id"), {
'custom-callout-mode': 'small',
});
}
});
menu.addItem({
icon: "iconInfo",
label: this.i18n.name,
type: "submenu",
submenu: submenus
});
}
}this.eventBus.on('click-editortitleicon', this.blockIconEventBindThis);
Dialog 对象,比如这样:const dialog = new Dialog({
title: "Hello",
content: "This is a dialog",
width: "500px",
// 其他配置...
});Dialog 是一个类,只要创建就会自动打开,不需要调用什么 open 方法。但是他有一个 destroy方法可以手动关闭对话框。content,这是一个字符串,代表了对话框当中的内部内容。不过你也可以传入 HTML 字符串进去。比如下面这个案例(参考 plugin-sample-vite-svelte/src/libs/dialog.ts)export const inputDialog = (args: {
title: string, placeholder?: string, defaultText?: string,
confirm?: (text: string) => void, cancel?: () => void,
width?: string, height?: string
}) => {
const dialog = new Dialog({
title: args.title,
content: `<div class="b3-dialog__content">
<div class="ft__breakword"><textarea class="b3-text-field fn__block" style="height: 100%;" placeholder=${args?.placeholder ?? ''}>${args?.defaultText ?? ''}</textarea></div>
</div>
<div class="b3-dialog__action">
<button class="b3-button b3-button--cancel">${window.siyuan.languages.cancel}</button><div class="fn__space"></div>
<button class="b3-button b3-button--text" id="confirmDialogConfirmBtn">${window.siyuan.languages.confirm}</button>
</div>`,
width: args.width ?? "520px",
height: args.height
});
const target: HTMLTextAreaElement = dialog.element.querySelector(".b3-dialog__content>div.ft__breakword>textarea");
const btnsElement = dialog.element.querySelectorAll(".b3-button");
btnsElement[0].addEventListener("click", () => {
if (args?.cancel) {
args.cancel();
}
dialog.destroy();
});
btnsElement[1].addEventListener("click", () => {
if (args?.confirm) {
args.confirm(target.value);
}
dialog.destroy();
});
};element 元素,这个属性就代表了 Dialog 对象本身的 HTMLElement。比如我们可以把 Dialog 封装一下,让他接受一个传入的 Element://参考 https://github.com/siyuan-note/plugin-sample-vite-svelte/blob/main/src/libs/dialog.ts
export const simpleDialog = (args: {
title: string, ele: HTMLElement | DocumentFragment,
width?: string, height?: string,
callback?: () => void;
}) => {
const dialog = new Dialog({
title: args.title,
content: `<div class="dialog-content" style="display: flex; height: 100%;"/>`,
width: args.width,
height: args.height,
destroyCallback: args.callback
});
dialog.element.querySelector(".dialog-content").appendChild(args.ele);
return dialog;
}import { openTab } from 'siyuan';
openTab({
app: plugin.app, //plugin 是你插件的 this 对象
doc: {
id: "文档或者块ID"
}
});openMobileFileById(plugin.app, blockId)siyuan:// 链接。比如你可以创建这么做:
openTab 来打开一个文档。而如果你想要打开一个自定义的 tab,可以参考下面这个使用案例(参考 sy-test-template/index.ts)。addTab 创建一个 Tab 对象type 参数:传入 Tab 的唯一标识符init 函数中初始化内部 domopenTab 打开 tab;对于 plugin 创建的自定义 tab 而言,id 为 <Plugin 名称> + <type名称>import {
Plugin,
openTab
} from "siyuan";
import "@/index.scss";
import { createElement } from "./func";
export default class PluginTestTemplate extends Plugin {
openTab() {
const id = Math.random().toString(36).substring(7);
this.addTab({
'type': id,
init() {
this.element.style.display = 'flex';
this.element.appendChild(createElement());
}
});
openTab({
app: this.app,
custom: {
title: 'TestTemplate',
icon: 'iconMarkdown',
id: this.name + id,
}
});
}
}{
"backends": [
"windows",
"linux",
"darwin",
"docker",
"ios",
"android"
],
"frontends": [
"desktop",
"mobile",
"browser-desktop",
"browser-mobile",
"desktop-window"
],
}function getFrontend(): "desktop" | "desktop-window" | "mobile" | "browser-desktop" | "browser-mobile";
function getBackend(): "windows" | "linux" | "darwin" | "docker" | "android" | "ios";getFrontend 判断是否为移动端环境;因为移动端环境的很多 DOM 结构和桌面端不同,需要插件做单独适配。(例如需要用 openMobileFileById 来打开一个文档)。以下是一个参考案例(参考sy-bookmark-plus)//utils.ts
import { getFrontend } from 'siyuan';
export const isMobile = () => {
return getFrontend().endsWith('mobile');
}
//components/item.tsx
import { isMobile } from "@/utils";
const openBlock = () => {
if (isMobile()) {
openMobileFileById(plugin.app, item().id);
} else {
openTab({
app: plugin.app,
doc: {
id: item().id,
zoomIn: item().type === 'd' ? false : true,
},
});
}
};SettingExample 是一个 Svelte 组件,我们在一个 Dialog 当中展示这个组件。import SettingExample from "@/setting-example.svelte";
let dialog = new Dialog({
title: "SettingPanel",
content: `<div id="SettingPanel" style="height: 100%;"></div>`,
width: "800px",
destroyCallback: (options) => {
console.log("destroyCallback", options);
//You'd better destroy the component when the dialog is closed
panel.$destroy();
}
});
let panel = new SettingExample({
target: dialog.element.querySelector("#SettingPanel"),
});//参考: siyuan-plugin-picture-library
import Tab from './components/tab.vue';
this.addTab({
type: TAB_TYPE,
init() {
const tab = createApp(Tab);
tab.use(ElementPlus);
tab.provide('plugin', plugin);
tab.provide('folder', this.data);
tab.mount(this.element);
}
})onUnMount (onDestroy、onCleanup,各个前端框架的叫法不一样)钩子。clearInterval),建议在 Dialog 的 destroyCallback 中手动调用销毁方法以触发组件的回收声明周期。plugin.saveData 和 plugin.loadData 来写入/读取配置文件。const File = 'config.json';
const DefaultConfig = {
refresh: true,
title: 'hello'
}
export default class PluginSample extends Plugin {
async onload() {
//读取
let data = await this.loadData(File);
data = data ?? DefaultConfig;
// 保存
this.saveData(File, data);
}
}data/storage/petal/<name>/ 下。

plugin.settingSettingUtilsplugin.setting 对象是思源提供的一个特殊的工具,可以帮助开发者创建一个 Setting 面板。createActionElement 方法。import { Setting } from 'siyuan';
this.setting = new Setting({
confirmCallback: () => {
this.saveData(STORAGE_NAME, {readonlyText: textareaElement.value});
}
});
this.setting.addItem({
title: "Readonly text",
direction: "row",
description: "Open plugin url in browser",
createActionElement: () => {
textareaElement.className = "b3-text-field fn__block";
textareaElement.placeholder = "Readonly text in the menu";
textareaElement.value = this.data[STORAGE_NAME].readonlyText;
return textareaElement;
},
});plugin.setting 用起来还是有点麻烦的,需要自己编写 createActionElement,同时还要独自处理 loadData 和 saveData。所以更推荐使用插件模板提供的 SettingUtils 工具(plugin-sample-vite-svelte/libs/setting-utils.ts)。import { SettingUtils } from "./libs/setting-utils";
export default class PluginSample extends Plugin {
customTab: () => IModel;
private isMobile: boolean;
private blockIconEventBindThis = this.blockIconEvent.bind(this);
private settingUtils: SettingUtils;
async onload() {
this.settingUtils = new SettingUtils({
plugin: this, name: STORAGE_NAME
});
/*
通过 type 自动指定 action 元素类型; value 填写默认值
*/
this.settingUtils.addItem({
key: "Input",
value: "",
type: "textinput",
title: "Readonly text",
description: "Input description",
action: {
// Called when focus is lost and content changes
callback: () => {
// Return data and save it in real time
console.log(value);
}
}
});
this.settingUtils.addItem({
key: "Select",
value: 1,
type: "select",
title: "Select",
description: "Select description",
options: {
1: "Option 1",
2: "Option 2"
},
action: {
callback: () => {
// Read data in real time
console.log(value);
}
}
});
await this.settingUtils.load(); //导入配置并合并
}
}openSetting 方法。plugin.openSetting 方法会被自动调用。
import SettingExample from "@/setting-example.svelte";
openSetting(): void {
let dialog = new Dialog({
title: "SettingPanel",
content: `<div id="SettingPanel" style="height: 100%;"></div>`,
width: "800px",
destroyCallback: (options) => {
console.log("destroyCallback", options);
//You'd better destroy the component when the dialog is closed
panel.$destroy();
}
});
let panel = new SettingExample({
target: dialog.element.querySelector("#SettingPanel"),
});
}

addDock API:init api 里面直接被 this 获取this.addDock({
type: '::dock',
config: {
position: 'RightBottom',
size: {
width: 200,
height: 200,
},
icon: 'iconBookmark',
title: 'Bookmark+'
},
data: {
plugin: this,
initBookmark: initBookmark,
},
init() {
this.data.initBookmark(this.element, this.data.plugin);
}
});plugin.addCommand 来注册一个快捷键操作。this.addCommand({
langKey: "showDialog",
hotkey: "⇧⌘O",
callback: () => {
this.showDialog();
},
fileTreeCallback: (file: any) => {
console.log(file, "fileTreeCallback");
},
editorCallback: (protyle: any) => {
console.log(protyle, "editorCallback");
},
dockCallback: (element: HTMLElement) => {
console.log(element, "dockCallback");
},
});hotkey 一个是 callback 方法。 hotkey 必须按照特定的顺序设置才会生效。export interface ICommandOption {
langKey: string // 用于区分不同快捷键的 key
langText?: string // 快捷键功能描述文本
/**
* 目前需使用 MacOS 符号标识,顺序按照 ⌥⇧⌘,入 ⌥⇧⌘A
* "Ctrl": "⌘",
* "Shift": "⇧",
* "Alt": "⌥",
* "Tab": "⇥",
* "Backspace": "⌫",
* "Delete": "⌦",
* "Enter": "↩",
*/
hotkey: string,
customHotkey?: string,
callback?: () => void // 其余回调存在时将不会触
globalCallback?: () => void // 焦点不在应用内时执行的回调
fileTreeCallback?: (file: any) => void // 焦点在文档树上时执行的回调
editorCallback?: (protyle: any) => void // 焦点在编辑器上时执行的回调
dockCallback?: (element: HTMLElement) => void // 焦点在 dock 上时执行的回调
}
custom 字段置空;等到恢复的时候,在从 default 中填写回来。const bookmarkKeymap = window.siyuan.config.keymap.general.bookmark;
//禁用默认书签快捷键
bookmarkKeymap.custom = '';
//恢复快捷键
bookmarkKeymap.custom = bookmarkKeymap.default;
/ 命令
/ 命令,又称 slash 命令,就是思源中通过 / 触发,并快速在编辑器中插入某些元素的命令。/ 命令,可以通过设置 plugin.protyleSlash 属性来配置。protyleSlash: {
filter: string[],
html: string,
id: string,
callback(protyle: Protyle): void,
}[];protyle.insert 在编辑器中插入元素。let Templates = {
datetime: {
filter: ['xz', 'now'],
name: 'Now',
template: 'yyyy-MM-dd HH:mm:ss'
},
date: {
filter: ['rq', 'date', 'jt', 'today'],
name: 'Date',
template: 'yyyy-MM-dd'
},
time: {
filter: ['sj', 'time'],
name: 'Time',
template: 'HH:mm:ss'
}
};
this.protyleSlash = Object.values(Templates).map((template) => {
return {
filter: template.filter,
html: `<span>${template.name} ${formatDateTime(template.template)}</span>`,
id: template.name,
callback: (protyle: Protyle) => {
let strnow = formatDateTime(template.template);
console.log(template.name, strnow);
protyle.insert(strnow, false);
},
//@ts-ignore
update() {
this.html = `<span>${template.name} ${formatDateTime(template.template)}</span>`;
}
}
});
/xxx了。具体方法是插入一个 Lute.Carte 字符,来清空前面的输入。这里给一个参考案例:quick-attr 插件protyle.insert(Lute.Carte);index.css 文件里面就可以了。但是有时候可能需要使用 JS 插入一些自定义的 style,这时你就会遇到一个问题:插入的自定义样式在导出 PDF 的时候无法生效。style#snippetCSS-BqCallout 当中,这样导出的 PDF 中,这些动态的样式同样会生效。window.siyuan 变量;在内部中存储了大量思源内部的设置。
plugin.i18n 对象来访问其中的内容。window.siyuan.config.lang 指向了当前思源呈现的语言。比如你可以这么干:const I18N = {
zh_CN: {
warn: '⚠️ 注意Asset目录已更改!',
menuLabel: '同本地 Markdown 文件同步',
},
en_US: {
warn: '⚠️ Warning: Asset directory has changed!',
menuLabel: 'Sync With Local Markdown File',
}
};
let i18n: typeof I18N.zh_CN = window.siyuan.config.lang in I18N ? I18N[window.siyuan.config.lang] : I18N.en_US;
export default i18n;let lute = window.Lute.New();
lute.Md2HTML('## Hello')
// 输出: '<h2>Hello</h2>\n'const nodeFs = window.require('fs') as typeof import('fs');
const nodePath = window.require('path') as typeof import('path');
const electron = window.require('electron');