A privacy-first, self-hosted, fully open source personal knowledge management software, written in typescript and golang. (PERSONAL FORK)
1import {App} from "../index";
2import {EventBus} from "./EventBus";
3import {fetchPost} from "../util/fetch";
4import {isMobile, isWindow} from "../util/functions";
5/// #if !MOBILE
6import {Custom} from "../layout/dock/Custom";
7import {getAllModels} from "../layout/getAll";
8import {Tab} from "../layout/Tab";
9import {resizeTopBar, setPanelFocus} from "../layout/util";
10import {getDockByType} from "../layout/tabUtil";
11///#else
12import {MobileCustom} from "../mobile/dock/MobileCustom";
13/// #endif
14import {hasClosestByAttribute} from "../protyle/util/hasClosest";
15import {BlockPanel} from "../block/Panel";
16import {Setting} from "./Setting";
17import {clearOBG} from "../layout/dock/util";
18import {Constants} from "../constants";
19
20export class Plugin {
21 private app: App;
22 public i18n: IObject;
23 public eventBus: EventBus;
24 public data: any = {};
25 public displayName: string;
26 public readonly name: string;
27 public protyleSlash: {
28 filter: string[],
29 html: string,
30 id: string,
31 callback: (protyle: import("../protyle").Protyle, nodeElement: HTMLElement) => void
32 }[] = [];
33 // TODO
34 public customBlockRenders: {
35 [key: string]: {
36 icon: string,
37 action: "edit" | "more"[],
38 genCursor: boolean,
39 render: (options: { app: App, element: Element }) => void
40 }
41 } = {};
42 public topBarIcons: Element[] = [];
43 public setting: Setting;
44 public statusBarIcons: Element[] = [];
45 public commands: ICommand[] = [];
46 public models: {
47 /// #if !MOBILE
48 [key: string]: (options: { tab: Tab, data: any }) => Custom
49 /// #endif
50 } = {};
51 public docks: {
52 [key: string]: {
53 config: IPluginDockTab,
54 /// #if !MOBILE
55 model: (options: { tab: Tab }) => Custom
56 /// #else
57 mobileModel: (element: Element) => MobileCustom
58 /// #endif
59 }
60 } = {};
61 private protyleOptionsValue: IProtyleOptions;
62
63 constructor(options: {
64 app: App,
65 name: string,
66 displayName: string,
67 i18n: IObject
68 }) {
69 this.app = options.app;
70 this.i18n = options.i18n;
71 this.displayName = options.displayName;
72 this.eventBus = new EventBus(options.name);
73
74 // https://github.com/siyuan-note/siyuan/issues/9943
75 Object.defineProperty(this, "name", {
76 value: options.name,
77 writable: false,
78 });
79
80 this.updateProtyleToolbar([]).forEach(toolbarItem => {
81 if (typeof toolbarItem === "string" || Constants.INLINE_TYPE.concat("|").includes(toolbarItem.name) || !toolbarItem.hotkey) {
82 return;
83 }
84 if (!window.siyuan.config.keymap.plugin) {
85 window.siyuan.config.keymap.plugin = {};
86 }
87 if (!window.siyuan.config.keymap.plugin[options.name]) {
88 window.siyuan.config.keymap.plugin[options.name] = {
89 [toolbarItem.name]: {
90 default: toolbarItem.hotkey,
91 custom: toolbarItem.hotkey,
92 }
93 };
94 }
95 if (!window.siyuan.config.keymap.plugin[options.name][toolbarItem.name]) {
96 window.siyuan.config.keymap.plugin[options.name][toolbarItem.name] = {
97 default: toolbarItem.hotkey,
98 custom: toolbarItem.hotkey,
99 };
100 }
101 });
102 }
103
104 public onload() {
105 // 加载
106 }
107
108 public onunload() {
109 // 禁用/关闭
110 }
111
112 public uninstall() {
113 // 卸载
114 }
115
116 public async updateCards(options: ICardData) {
117 return options;
118 }
119
120 public onLayoutReady() {
121 // 布局加载完成
122 }
123
124 public addCommand(command: ICommand) {
125 if (!window.siyuan.config.keymap.plugin) {
126 window.siyuan.config.keymap.plugin = {};
127 }
128 if (!window.siyuan.config.keymap.plugin[this.name]) {
129 command.customHotkey = command.hotkey;
130 window.siyuan.config.keymap.plugin[this.name] = {
131 [command.langKey]: {
132 default: command.hotkey,
133 custom: command.hotkey,
134 }
135 };
136 } else if (!window.siyuan.config.keymap.plugin[this.name][command.langKey]) {
137 command.customHotkey = command.hotkey;
138 window.siyuan.config.keymap.plugin[this.name][command.langKey] = {
139 default: command.hotkey,
140 custom: command.hotkey,
141 };
142 } else if (window.siyuan.config.keymap.plugin[this.name][command.langKey]) {
143 if (typeof window.siyuan.config.keymap.plugin[this.name][command.langKey].custom === "string") {
144 command.customHotkey = window.siyuan.config.keymap.plugin[this.name][command.langKey].custom;
145 } else {
146 command.customHotkey = command.hotkey;
147 }
148 window.siyuan.config.keymap.plugin[this.name][command.langKey]["default"] = command.hotkey;
149 }
150 if (typeof command.customHotkey !== "string") {
151 console.error(`${this.name} - commands data is error and has been removed.`);
152 } else {
153 this.commands.push(command);
154 }
155 }
156
157 public addIcons(svg: string) {
158 const svgElement = document.querySelector(`svg[data-name="${this.name}"] defs`);
159 if (svgElement) {
160 svgElement.insertAdjacentHTML("afterbegin", svg);
161 } else {
162 const lastSvgElement = document.querySelector("body > svg:last-of-type");
163 if (lastSvgElement) {
164 lastSvgElement.insertAdjacentHTML("afterend", `<svg data-name="${this.name}" style="position: absolute; width: 0; height: 0; overflow: hidden;" xmlns="http://www.w3.org/2000/svg">
165<defs>${svg}</defs></svg>`);
166 } else {
167 document.body.insertAdjacentHTML("afterbegin", `<svg data-name="${this.name}" style="position: absolute; width: 0; height: 0; overflow: hidden;" xmlns="http://www.w3.org/2000/svg">
168<defs>${svg}</defs></svg>`);
169 }
170 }
171 }
172
173 public addTopBar(options: {
174 icon: string,
175 title: string,
176 position?: "south" | "left",
177 callback: (evt: MouseEvent) => void
178 }) {
179 if (!options.icon.startsWith("icon") && !options.icon.startsWith("<svg")) {
180 console.error(`plugin ${this.name} addTopBar error: icon must be svg id or svg tag`);
181 return;
182 }
183 const iconElement = document.createElement("div");
184 iconElement.setAttribute("data-menu", "true");
185 iconElement.addEventListener("click", options.callback);
186 iconElement.id = `plugin_${this.name}_${this.topBarIcons.length}`;
187 if (isMobile()) {
188 iconElement.className = "b3-menu__item";
189 iconElement.innerHTML = (options.icon.startsWith("icon") ? `<svg class="b3-menu__icon"><use xlink:href="#${options.icon}"></use></svg>` : options.icon) +
190 `<span class="b3-menu__label">${options.title}</span>`;
191 } else if (!isWindow()) {
192 iconElement.className = "toolbar__item ariaLabel";
193 iconElement.setAttribute("aria-label", options.title);
194 iconElement.innerHTML = options.icon.startsWith("icon") ? `<svg><use xlink:href="#${options.icon}"></use></svg>` : options.icon;
195 iconElement.addEventListener("click", options.callback);
196 iconElement.setAttribute("data-location", options.position || "right");
197 resizeTopBar();
198 }
199 if (isMobile() && window.siyuan.storage) {
200 if (!window.siyuan.storage[Constants.LOCAL_PLUGINTOPUNPIN].includes(iconElement.id)) {
201 document.querySelector("#menuAbout")?.after(iconElement);
202 }
203 } else if (!isWindow() && window.siyuan.storage) {
204 if (window.siyuan.storage[Constants.LOCAL_PLUGINTOPUNPIN].includes(iconElement.id)) {
205 iconElement.classList.add("fn__none");
206 }
207 document.querySelector("#" + (iconElement.getAttribute("data-location") === "right" ? "barPlugins" : "drag"))?.before(iconElement);
208 }
209 this.topBarIcons.push(iconElement);
210 return iconElement;
211 }
212
213 public addStatusBar(options: {
214 element: HTMLElement,
215 position?: "right" | "left",
216 }) {
217 /// #if !MOBILE
218 options.element.setAttribute("data-location", options.position || "right");
219 this.statusBarIcons.push(options.element);
220 const statusElement = document.getElementById("status");
221 if (statusElement) {
222 if (options.element.getAttribute("data-location") === "right") {
223 statusElement.insertAdjacentElement("beforeend", options.element);
224 } else {
225 statusElement.insertAdjacentElement("afterbegin", options.element);
226 }
227 }
228 return options.element;
229 /// #endif
230 }
231
232 public openSetting() {
233 if (!this.setting) {
234 return;
235 }
236 this.setting.open(this.displayName || this.name);
237 }
238
239 public loadData(storageName: string) {
240 if (typeof this.data[storageName] === "undefined") {
241 this.data[storageName] = "";
242 }
243 return new Promise((resolve) => {
244 fetchPost("/api/file/getFile", {path: `/data/storage/petal/${this.name}/${storageName}`}, (response) => {
245 if (response.code !== 404) {
246 this.data[storageName] = response;
247 }
248 resolve(this.data[storageName]);
249 });
250 });
251 }
252
253 public saveData(storageName: string, data: any) {
254 if (window.siyuan.config.readonly || window.siyuan.isPublish) {
255 return;
256 }
257
258 return new Promise((resolve) => {
259 const pathString = `/data/storage/petal/${this.name}/${storageName}`;
260 let file: File;
261 if (typeof data === "object") {
262 file = new File([new Blob([JSON.stringify(data)], {
263 type: "application/json"
264 })], pathString.split("/").pop());
265 } else {
266 file = new File([new Blob([data])], pathString.split("/").pop());
267 }
268 const formData = new FormData();
269 formData.append("path", pathString);
270 formData.append("file", file);
271 formData.append("isDir", "false");
272 fetchPost("/api/file/putFile", formData, (response) => {
273 this.data[storageName] = data;
274 resolve(response);
275 });
276 });
277 }
278
279 public removeData(storageName: string) {
280 if (window.siyuan.config.readonly || window.siyuan.isPublish) {
281 return;
282 }
283
284 return new Promise((resolve) => {
285 if (!this.data) {
286 this.data = {};
287 }
288 fetchPost("/api/file/removeFile", {path: `/data/storage/petal/${this.name}/${storageName}`}, (response) => {
289 delete this.data[storageName];
290 resolve(response);
291 });
292 });
293 }
294
295 public getOpenedTab() {
296 const tabs: { [key: string]: Custom[] } = {};
297 const modelKeys = Object.keys(this.models);
298 modelKeys.forEach(item => {
299 tabs[item.replace(this.name, "")] = [];
300 });
301 /// #if !MOBILE
302 getAllModels().custom.find(item => {
303 if (modelKeys.includes(item.type)) {
304 tabs[item.type.replace(this.name, "")].push(item);
305 }
306 });
307 /// #endif
308 return tabs;
309 }
310
311 public addTab(options: {
312 type: string,
313 destroy?: () => void,
314 beforeDestroy?: () => void,
315 resize?: () => void,
316 update?: () => void,
317 init: () => void
318 }) {
319 /// #if !MOBILE
320 const type2 = this.name + options.type;
321 this.models[type2] = (arg: { data: any, tab: Tab }) => {
322 const customObj = new Custom({
323 app: this.app,
324 tab: arg.tab,
325 type: type2,
326 data: arg.data,
327 init: options.init,
328 beforeDestroy: options.beforeDestroy,
329 destroy: options.destroy,
330 resize: options.resize,
331 update: options.update,
332 });
333 customObj.element.addEventListener("click", () => {
334 clearOBG();
335 setPanelFocus(customObj.element.parentElement.parentElement);
336 });
337 return customObj;
338 };
339 return this.models[type2];
340 /// #endif
341 }
342
343 public addDock(options: {
344 config: IPluginDockTab,
345 data: any,
346 type: string,
347 destroy?: () => void,
348 resize?: () => void,
349 update?: () => void,
350 init: () => void
351 }) {
352 const type2 = this.name + options.type;
353 if (typeof options.config.index === "undefined") {
354 options.config.index = 1000;
355 }
356 this.docks[type2] = {
357 config: options.config,
358 /// #if MOBILE
359 mobileModel: (element) => {
360 const customObj = new MobileCustom({
361 element,
362 type: type2,
363 data: options.data,
364 init: options.init,
365 update: options.update,
366 destroy: options.destroy,
367 });
368 return customObj;
369 },
370 /// #else
371 model: (arg: { tab: Tab }) => {
372 const customObj = new Custom({
373 app: this.app,
374 tab: arg.tab,
375 type: type2,
376 data: options.data,
377 init: options.init,
378 destroy: options.destroy,
379 resize: options.resize,
380 update: options.update,
381 });
382 customObj.element.addEventListener("click", (event: MouseEvent) => {
383 setPanelFocus(customObj.element);
384 if (hasClosestByAttribute(event.target as HTMLElement, "data-type", "min")) {
385 getDockByType(type2).toggleModel(type2);
386 }
387 });
388 customObj.element.classList.add("sy__" + type2);
389 return customObj;
390 }
391 /// #endif
392 };
393 if (!window.siyuan.config.keymap.plugin) {
394 window.siyuan.config.keymap.plugin = {};
395 }
396 if (options.config.hotkey) {
397 if (!window.siyuan.config.keymap.plugin[this.name]) {
398 window.siyuan.config.keymap.plugin[this.name] = {
399 [type2]: {
400 default: options.config.hotkey,
401 custom: options.config.hotkey,
402 }
403 };
404 } else if (!window.siyuan.config.keymap.plugin[this.name][type2]) {
405 window.siyuan.config.keymap.plugin[this.name][type2] = {
406 default: options.config.hotkey,
407 custom: options.config.hotkey,
408 };
409 } else if (window.siyuan.config.keymap.plugin[this.name][type2]) {
410 if (typeof window.siyuan.config.keymap.plugin[this.name][type2].custom !== "string") {
411 window.siyuan.config.keymap.plugin[this.name][type2].custom = options.config.hotkey;
412 }
413 window.siyuan.config.keymap.plugin[this.name][type2]["default"] = options.config.hotkey;
414 }
415 }
416 return this.docks[type2];
417 }
418
419 public addFloatLayer = (options: {
420 refDefs: IRefDefs[],
421 x?: number,
422 y?: number,
423 targetElement?: HTMLElement,
424 originalRefBlockIDs?: IObject,
425 isBacklink: boolean,
426 }) => {
427 window.siyuan.blockPanels.push(new BlockPanel({
428 app: this.app,
429 originalRefBlockIDs: options.originalRefBlockIDs,
430 targetElement: options.targetElement,
431 isBacklink: options.isBacklink,
432 x: options.x,
433 y: options.y,
434 refDefs: options.refDefs,
435 }));
436 };
437
438 public updateProtyleToolbar(toolbar: Array<string | IMenuItem>) {
439 return toolbar;
440 }
441
442 set protyleOptions(options: IProtyleOptions) {
443 this.protyleOptionsValue = options;
444 }
445
446 get protyleOptions() {
447 return this.protyleOptionsValue;
448 }
449}