├── src ├── components │ ├── tab │ │ └── editTab.vue │ ├── settings │ │ ├── items │ │ │ ├── nameDivider.vue │ │ │ ├── textarea.vue │ │ │ ├── switch.vue │ │ │ ├── button.vue │ │ │ ├── select.vue │ │ │ ├── input.vue │ │ │ ├── pathSelector.vue │ │ │ └── order.vue │ │ ├── page.vue │ │ ├── block.vue │ │ ├── item.vue │ │ ├── column.vue │ │ └── setting.vue │ └── dialog │ │ └── outdatedSetting.vue ├── index.scss ├── storage │ ├── baseStorage.ts │ └── localStorage.ts ├── utils │ ├── pluginHelper.ts │ ├── stringUtils.ts │ ├── mutex.ts │ ├── lang.ts │ ├── commonCheck.ts │ ├── settings.ts │ └── common.ts ├── constants.ts ├── manager │ ├── imageStorageHelper.ts │ ├── shortcutHandler.ts │ ├── setStyle.ts │ ├── editorHelper.ts │ ├── eventHandler.ts │ ├── settingManager.ts │ └── editorLang.ts ├── editor │ ├── baseImageEditor.ts │ ├── localEditor.ts │ ├── filerbotEditor.ts │ └── tuiEditor.ts ├── types │ ├── settings.d.ts │ └── index.d.ts ├── hello.vue ├── logger │ └── index.ts ├── i18n │ ├── zh_CN.json │ └── en_US.json ├── index.ts └── syapi │ ├── interface.d.ts │ ├── custom.ts │ └── index.ts ├── scripts ├── .gitignore ├── .release.py ├── make_dev_link.js └── reset_dev_loc.js ├── icon.png ├── preview.png ├── .gitignore ├── CHANGELOG.md ├── tsconfig.node.json ├── plugin.json ├── README_zh_CN.md ├── package.json ├── tsconfig.json ├── README.md ├── .github └── workflows │ └── release.yml ├── static └── tui-color-picker.css └── vite.config.ts /src/components/tab/editTab.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | build 3 | dist 4 | *.exe 5 | *.spec 6 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | #helloPanel { 2 | border: 1px rgb(189, 119, 119) dashed; 3 | } -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpaqueGlass/syplugin-imageEditor/main/icon.png -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpaqueGlass/syplugin-imageEditor/main/preview.png -------------------------------------------------------------------------------- /src/components/settings/items/nameDivider.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .DS_Store 4 | pnpm-lock.yaml 5 | package.zip 6 | node_modules 7 | dev 8 | dist 9 | build 10 | tmp 11 | scripts/devInfo.json -------------------------------------------------------------------------------- /src/storage/baseStorage.ts: -------------------------------------------------------------------------------- 1 | export abstract class BaseStorage { 2 | 3 | public abstract init(config: any): void; 4 | 5 | public abstract saveWithDataURL(dataPath: string, dataURL: string): Promise; 6 | } -------------------------------------------------------------------------------- /src/utils/pluginHelper.ts: -------------------------------------------------------------------------------- 1 | let pluginInstance: any = null; 2 | 3 | export function setPluginInstance(instance:any) { 4 | pluginInstance = instance; 5 | } 6 | export function getPluginInstance() { 7 | return pluginInstance; 8 | } -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export class CONSTANTS { 2 | public static readonly STYLE_ID: string = "template-plugin-style"; 3 | public static readonly PLUGIN_SHORT_NAME: string = "imageEditor"; 4 | public static readonly PLUGIN_FULL_NAME: string = "图片编辑器"; 5 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 更新日志 Change Log 2 | 3 | ### v0.2.0 (2025-09-24) 4 | 5 | - 改进:开放手机端简单编辑功能; 6 | - 改进:调整图片保存后刷新逻辑为刷新编辑器Protyle; 7 | 8 | ### v0.1.1 (2025-09-13) 9 | 10 | - 调整默认编辑器; 11 | - 处理编辑器异常退出的情况; 12 | 13 | ### v0.1.0 (2025-09-12) 14 | 15 | - 从这里开始`#`; 16 | -------------------------------------------------------------------------------- /src/components/settings/items/textarea.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/manager/imageStorageHelper.ts: -------------------------------------------------------------------------------- 1 | import LocalStorage from "@/storage/localStorage"; 2 | 3 | export async function saveImageDistributor(dataPath, dataURL) { 4 | const storage = new LocalStorage(); 5 | return await storage.saveWithDataURL(dataPath, dataURL); 6 | } -------------------------------------------------------------------------------- /src/components/settings/items/switch.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": [ 10 | "vite.config.ts" 11 | ] 12 | } -------------------------------------------------------------------------------- /scripts/.release.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | with open('CHANGELOG.md', 'r', encoding='utf-8') as f: 4 | readme_str = f.read() 5 | 6 | match_obj = re.search(r'(?<=### )[\s\S]*?(?=#)', readme_str, re.DOTALL) 7 | if match_obj: 8 | h3_title = match_obj.group(0) 9 | with open('result.txt', 'w') as f: 10 | f.write(h3_title) 11 | else: 12 | with open('result.txt', 'w') as f: 13 | f.write("") -------------------------------------------------------------------------------- /src/utils/stringUtils.ts: -------------------------------------------------------------------------------- 1 | export function htmlTransferParser(inputStr) { 2 | if (inputStr == null || inputStr == "") return ""; 3 | let transfer = ["<", ">", " ", """, "&"]; 4 | let original = ["<", ">", " ", `"`, "&"]; 5 | for (let i = 0; i < transfer.length; i++) { 6 | inputStr = inputStr.replace(new RegExp(transfer[i], "g"), original[i]); 7 | } 8 | return inputStr; 9 | } -------------------------------------------------------------------------------- /src/components/settings/page.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /src/editor/baseImageEditor.ts: -------------------------------------------------------------------------------- 1 | import { Protyle } from "siyuan"; 2 | 3 | export default abstract class BaseImageEditor { 4 | public abstract init(): Promise; 5 | public abstract showImageEditor({ source, filePath, element, protyle }: { source: string; filePath: string, element: HTMLElement, protyle: Protyle }): Promise; 6 | public abstract isAvailable(): boolean; 7 | public abstract destroy(): void; 8 | } -------------------------------------------------------------------------------- /src/components/settings/items/button.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/components/settings/block.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /src/components/settings/items/select.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/types/settings.d.ts: -------------------------------------------------------------------------------- 1 | type IConfigProperty = { 2 | key: string, 3 | type: IConfigPropertyType, // 设置项类型 4 | min?: number, // 设置项最小值 5 | max?: number, // 设置项最大值 6 | btndo?: Function, // 按钮设置项的调用函数(callback) 7 | // defaultValue?: any, //默认值 8 | options?: number, // 选项数量,选项名称由语言文件中_option_i决定 9 | }; 10 | 11 | type IConfigPropertyType = 12 | "SELECT" | 13 | "TEXT" | 14 | "NUMBER" | 15 | "BUTTON" | 16 | "TEXTAREA" | 17 | "SWITCH" | 18 | "ORDER" | 19 | "PATH" | 20 | "TIPS"; 21 | 22 | type ITabProperty = { 23 | nameKey: string, // 标签页名称对应的语言文件关键字 24 | iconKey: string, // 设置项描述对应的语言关键字 25 | properties: Array // 设置项列表 26 | }; 27 | -------------------------------------------------------------------------------- /src/manager/shortcutHandler.ts: -------------------------------------------------------------------------------- 1 | import { showMessage, Plugin } from "siyuan"; 2 | 3 | 4 | export function bindCommand(pluginInstance: Plugin) { 5 | // pluginInstance.addCommand({ 6 | // langKey: "go_up", 7 | // hotkey: "⌥⌘←", 8 | // callback: () => { 9 | // goUpShortcutHandler(); 10 | // }, 11 | // }); 12 | // 图标的制作参见帮助文档 13 | pluginInstance.addIcons(` 14 | 15 | `); 16 | } 17 | 18 | -------------------------------------------------------------------------------- /src/components/settings/item.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /src/components/settings/items/input.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/manager/setStyle.ts: -------------------------------------------------------------------------------- 1 | import { getDefaultSettings, getReadOnlyGSettings } from "@/manager/settingManager"; 2 | import { CONSTANTS } from "@/constants"; 3 | import { logPush } from "@/logger"; 4 | import { isMobile } from "@/syapi"; 5 | 6 | export function setStyle() { 7 | removeStyle(); 8 | const g_setting = getReadOnlyGSettings(); 9 | logPush("set styleg_setting", g_setting); 10 | const g_setting_default = getDefaultSettings(); 11 | const head = document.getElementsByTagName('head')[0]; 12 | const style = document.createElement('style'); 13 | style.setAttribute("id", CONSTANTS.STYLE_ID); 14 | 15 | style.innerHTML = ` 16 | `; 17 | head.appendChild(style); 18 | } 19 | 20 | function styleEscape(str) { 21 | if (!str) return ""; 22 | return str.replace(new RegExp("<[^<]*style[^>]*>", "g"), ""); 23 | } 24 | 25 | 26 | export function removeStyle() { 27 | document.getElementById(CONSTANTS.STYLE_ID)?.remove(); 28 | } -------------------------------------------------------------------------------- /src/components/dialog/outdatedSetting.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syplugin-imageEditor", 3 | "author": "OpaqueGlass", 4 | "url": "https://github.com/OpaqueGlass/syplugin-imageEditor", 5 | "version": "0.2.0", 6 | "minAppVersion": "2.9.0", 7 | "backends": [ 8 | "windows", 9 | "linux", 10 | "darwin", 11 | "android", 12 | "ios", 13 | "harmony" 14 | ], 15 | "frontends": [ 16 | "desktop", 17 | "browser-desktop", 18 | "browser-mobile", 19 | "mobile" 20 | ], 21 | "disabledInPublish": true, 22 | "displayName": { 23 | "en_US": "Image Editor (Preview Edition)", 24 | "zh_CN": "笔记图片编辑器(预览版)" 25 | }, 26 | "description": { 27 | "en_US": "Edit note image with tui-image-editor or filerobot-image-editor in siyuan-note", 28 | "zh_CN": "在思源笔记中使用开源图片编辑器或调起本地编辑器修改本地笔记图片。提供裁剪、旋转、简单标注等功能。" 29 | }, 30 | "readme": { 31 | "en_US": "README.md", 32 | "zh_CN": "README_zh_CN.md" 33 | }, 34 | "funding": { 35 | "custom": [ 36 | "https://wj.qq.com/s2/12395364/b69f/" 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/mutex.ts: -------------------------------------------------------------------------------- 1 | export default class Mutex { 2 | private isLocked: boolean = false; 3 | private queue: (() => void)[] = []; 4 | 5 | async lock(): Promise { 6 | return new Promise((resolve) => { 7 | const acquireLock = async () => { 8 | if (!this.isLocked) { 9 | this.isLocked = true; 10 | resolve(); 11 | } else { 12 | this.queue.push(() => { 13 | this.isLocked = true; 14 | resolve(); 15 | }); 16 | } 17 | }; 18 | 19 | acquireLock(); 20 | }); 21 | } 22 | 23 | tryLock(): boolean { 24 | if (!this.isLocked) { 25 | this.isLocked = true; 26 | return true; 27 | } else { 28 | return false; 29 | } 30 | } 31 | 32 | unlock(): void { 33 | this.isLocked = false; 34 | const next = this.queue.shift(); 35 | if (next) { 36 | next(); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/hello.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 7 | 33 | 34 | 62 | -------------------------------------------------------------------------------- /src/components/settings/items/pathSelector.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/components/settings/column.vue: -------------------------------------------------------------------------------- 1 | 20 | -------------------------------------------------------------------------------- /src/utils/lang.ts: -------------------------------------------------------------------------------- 1 | import { isDebugMode } from "@/logger"; 2 | 3 | let language = null; 4 | let emptyLanguageKey: Array = []; 5 | 6 | export function setLanguage(lang:any) { 7 | language = lang; 8 | } 9 | 10 | export function isZHCN() { 11 | return window.siyuan.config.appearance.lang === 'zh_CN'; 12 | } 13 | 14 | export function lang(key: string) { 15 | if (language != null && language[key] != null) { 16 | return language[key]; 17 | } 18 | if (language == null || language[key] == null) { 19 | emptyLanguageKey.push(key); 20 | if (isDebugMode()) { 21 | console.error("语言文件未定义该Key", JSON.stringify(emptyLanguageKey)); 22 | } 23 | } 24 | return key; 25 | } 26 | 27 | /** 28 | * 29 | * @param key key 30 | * @returns [设置项名称,设置项描述,设置项按钮名称(如果有)] 31 | */ 32 | export function settingLang(key: string) { 33 | let settingName: string = lang(`setting_${key}_name`); 34 | let settingDesc: string = lang(`setting_${key}_desp`); 35 | let settingBtnName: string = lang(`setting_${key}_btn`) 36 | if (settingName == "Undefined" || settingDesc == "Undefined") { 37 | throw new Error(`设置文本${key}未定义`); 38 | } 39 | return [settingName, settingDesc, settingBtnName]; 40 | } 41 | 42 | export function settingPageLang(key: string) { 43 | let pageSettingName: string = lang(`settingpage_${key}_name`); 44 | return [pageSettingName]; 45 | } -------------------------------------------------------------------------------- /src/utils/commonCheck.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 判定字符串是否有效 3 | * @param s 需要检查的字符串(或其他类型的内容) 4 | * @returns true / false 是否为有效的字符串 5 | */ 6 | export function isValidStr(s: any): boolean { 7 | if (s == undefined || s == null || s === '') { 8 | return false; 9 | } 10 | return true; 11 | } 12 | 13 | /** 14 | * 判断字符串是否为空白 15 | * @param s 字符串 16 | * @returns true 字符串为空或无效或只包含空白字符 17 | */ 18 | export function isBlankStr(s: any): boolean { 19 | if (!isValidStr(s)) return true; 20 | const clearBlankStr = s.replace(/\s+/g, ''); 21 | if (clearBlankStr === '') { 22 | return true; 23 | } 24 | return false; 25 | } 26 | 27 | let cacheIsMacOs = undefined; 28 | export function isMacOs() { 29 | let platform = window.top.siyuan.config.system.os ?? navigator.platform ?? "ERROR"; 30 | platform = platform.toUpperCase(); 31 | let isMacOSFlag = cacheIsMacOs; 32 | if (cacheIsMacOs == undefined) { 33 | for (let platformName of ["DARWIN", "MAC", "IPAD", "IPHONE", "IOS"]) { 34 | if (platform.includes(platformName)) { 35 | isMacOSFlag = true; 36 | break; 37 | } 38 | } 39 | cacheIsMacOs = isMacOSFlag; 40 | } 41 | if (isMacOSFlag == undefined) { 42 | isMacOSFlag = false; 43 | } 44 | return isMacOSFlag; 45 | } 46 | 47 | export function isEventCtrlKey(event) { 48 | if (isMacOs()) { 49 | return event.metaKey; 50 | } 51 | return event.ctrlKey; 52 | } -------------------------------------------------------------------------------- /README_zh_CN.md: -------------------------------------------------------------------------------- 1 | # 图片编辑器 2 | 3 | [English](./README.md) 4 | 5 | > 在[思源笔记](https://github.com/siyuan-note/siyuan/)中使用开源图片编辑器(filerobot/tui)修改本地笔记图片。 6 | 7 | > [!WARNING] 8 | > 9 | > 使用前请注意,插件引入了开源的图片编辑器,且未做样式隔离,可能和主题、插件、CSS/JS存在冲突。如果出现问题,请切换到“本地编辑器”模式或卸载插件。 10 | 11 | > 当前版本:v0.2.0 12 | > 改进:开放手机端简单编辑功能;改进:调整图片保存后刷新逻辑为刷新编辑器Protyle;(如果遇到修改后图片并未刷新的问题,请与开发者联系) 13 | 14 | ## 快速开始 15 | 16 | - 从集市下载 或 1、解压Release中的`package.zip`,2、将文件夹移动到`工作空间/data/plugins/`,3、并将文件夹重命名为`syplugin-imageEditor`; 17 | - 开启插件; 18 | - 在图片上右键-插件-使用图片编辑器打开; 19 | - 桌面端对图片按下`Alt+鼠标左键`,可以快速打开编辑器; 20 | 21 | > ⭐ 如果这对你有帮助,请考虑点亮Star! 22 | 23 | ## 可能常见的问题 24 | 25 | - Q: 图床图片支持吗? 26 | - 不支持; 27 | - Q:手机端支持吗? 28 | - 手机端仅能够使用`filerobot-image-editor`,图片加载可能较慢,如果放弃等待请点击编辑器区域外; 29 | - Q: 部分编辑器没有退出按钮,如何退出? 30 | - 请点击编辑器区域外灰色区域; 31 | - Q: 有时提示“很抱歉,图片编辑器崩溃退出,未保存的编辑已经丢失,请重试或选择其他图片编辑器” 32 | - 请避免使用画笔模式快速操作,或换用其他图片编辑器; 33 | 34 | ## 参考与感谢 35 | 36 | > 部分依赖项在`package.json`中列出。 37 | 38 | | 开发者/项目 | 项目描述 | 引用方式 | 39 | |---------------------------------------------------------------------|----------------|--------------| 40 | | [tui.image-editor](https://github.com/nhn/tui.image-editor/) | 图片编辑器 | 作为图片编辑器主体,以依赖项的方式在本项目中使用 | 41 | | [filerobot-image-editor](https://github.com/scaleflex/filerobot-image-editor) | 图片编辑器 | 作为图片编辑器主体,以依赖项的方式在本项目中使用 | 42 | | [lucide](https://lucide.dev/) | 提供了丰富的图标 [ISC License](https://lucide.dev/license) | 插件使用了部分图标 | 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syplugin-imageeditor", 3 | "version": "1.0.0-SNAPSHOT", 4 | "type": "module", 5 | "description": "", 6 | "repository": "https://github.com/OpaqueGlass/syplugin-hierarchyNavigate", 7 | "homepage": "", 8 | "author": "OpaqueGlass", 9 | "license": "AGPL-3.0-only", 10 | "scripts": { 11 | "make-link": "node --no-warnings ./scripts/make_dev_link.js", 12 | "change-dir": "node --no-warnings ./scripts/reset_dev_loc.js", 13 | "dev": "vite build --watch", 14 | "build": "vite build" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^20.6.2", 18 | "@types/sortablejs": "^1.15.8", 19 | "fast-glob": "^3.3.1", 20 | "glob": "^11.0.3", 21 | "minimist": "^1.2.8", 22 | "rollup-plugin-livereload": "^2.0.5", 23 | "sass": "^1.67.0", 24 | "siyuan": "^1.0.6", 25 | "ts-node": "^10.9.1", 26 | "typescript": "^5.2.2", 27 | "vite": "^4.4.9", 28 | "vite-plugin-banner": "^0.7.1", 29 | "vite-plugin-static-copy": "^0.15.0", 30 | "vite-plugin-zip-pack": "^1.0.6" 31 | }, 32 | "dependencies": { 33 | "@vitejs/plugin-vue": "^4.5.0", 34 | "@vue/tsconfig": "^0.4.0", 35 | "natsort": "^2.0.3", 36 | "tui-image-editor": "^3.15.3", 37 | "vue": "^3.4.15", 38 | "vue-tsc": "^1.8.22" 39 | }, 40 | "pnpm": { 41 | "ignoredBuiltDependencies": [ 42 | "@parcel/watcher", 43 | "esbuild" 44 | ], 45 | "onlyBuiltDependencies": [ 46 | "@parcel/watcher", 47 | "canvas", 48 | "esbuild" 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": [ 7 | "ES2021", //ES2020.String不支持.replaceAll 8 | "DOM", 9 | "DOM.Iterable" 10 | ], 11 | "skipLibCheck": true, 12 | /* Bundler mode */ 13 | "moduleResolution": "Node", 14 | // "allowImportingTsExtensions": true, 15 | "allowSyntheticDefaultImports": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "preserve", 20 | /* Linting */ 21 | "strict": false, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | /* svelte 过去的设置*/ 26 | "allowJs": true, 27 | "checkJs": true, 28 | "types": [ 29 | "node", 30 | "vite/client", 31 | "vue", 32 | "sortablejs" 33 | ], 34 | // "baseUrl": "./src", 35 | "paths": { 36 | "@/*": ["./src/*"], 37 | "@/libs/*": ["./src/libs/*"], 38 | }, 39 | "typeRoots": ["./src/types"] 40 | }, 41 | "include": [ 42 | "tools/**/*.ts", 43 | "src/**/*.ts", 44 | "src/**/*.d.ts", 45 | "src/**/*.tsx", 46 | "src/**/*.vue" 47 | ], 48 | "references": [ 49 | { 50 | "path": "./tsconfig.node.json" 51 | } 52 | ], 53 | "root": "." 54 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Image Editor 2 | 3 | [中文](README_zh_CN.md) 4 | 5 | > Edit note image with tui-image-editor or filerobot-image-editor in [siyuan-note](https://github.com/siyuan-note/siyuan/). 6 | 7 | > [!WARNING] 8 | > 9 | > Please note before use that the plugin introduces an (some) open source image editor, and there is no style isolation, which may conflict with themes, plugins, CSS/JS. 10 | 11 | ## Quick Start 12 | 13 | - Download from the marketplace **or**: 14 | 1. Unzip `package.zip` from the Release, 15 | 2. Move the folder to `workspace/data/plugins/`, 16 | 3. Rename the folder to `syplugin-imageEditor`; 17 | - Enable the plugin; 18 | - Right-click on an image → Plugins → Edit with Image Editor; 19 | 20 | > ⭐ If this project is helpful to you, please consider giving it a Star! 21 | 22 | ## Frequently Asked Questions 23 | 24 | - Q: Are image hosting services supported? 25 | - Not supported; 26 | - Q: Is mobile supported? 27 | - Not supported; 28 | 29 | ## References & Acknowledgments 30 | 31 | > Some dependencies are listed in `package.json`. 32 | 33 | | Developer/Project | Description | Usage | 34 | |----------------------------------------------------------------------|------------------|-----------------------------------------| 35 | | [tui.image-editor](https://github.com/nhn/tui.image-editor/) | Image editor | Used as the main image editor, included as a dependency in this project | 36 | | [filerobot-image-editor](https://github.com/scaleflex/filerobot-image-editor) | Image editor | Used as the main image editor, included as a dependency in this project | 37 | | [lucide](https://lucide.dev/) | Provides a wide variety of icons [ISC License](https://lucide.dev/license) | Certain icons are used in plugins | -------------------------------------------------------------------------------- /src/logger/index.ts: -------------------------------------------------------------------------------- 1 | import { CONSTANTS } from "@/constants"; 2 | 3 | // debug push 4 | let g_DEBUG = 2; 5 | const g_NAME = CONSTANTS.PLUGIN_SHORT_NAME; 6 | const g_FULLNAME = CONSTANTS.PLUGIN_FULL_NAME; 7 | 8 | /* 9 | LEVEL 0 忽略所有 10 | LEVEL 1 仅Error 11 | LEVEL 2 Err + Warn 12 | LEVEL 3 Err + Warn + Info 13 | LEVEL 4 Err + Warn + Info + Log 14 | LEVEL 5 Err + Warn + Info + Log + Debug 15 | 请注意,基于代码片段加入window下的debug设置,可能在刚载入挂件时无效 16 | */ 17 | export function commonPushCheck() { 18 | if (window.top["OpaqueGlassDebugV2"] !== undefined && window.top["OpaqueGlassDebugV2"]["*"]) { 19 | return window.top["OpaqueGlassDebugV2"]["*"]; 20 | } 21 | if (window.top["OpaqueGlassDebugV2"] == undefined || window.top["OpaqueGlassDebugV2"][g_NAME] == undefined) { 22 | return g_DEBUG; 23 | } 24 | return window.top["OpaqueGlassDebugV2"][g_NAME]; 25 | } 26 | 27 | export function isDebugMode() { 28 | return commonPushCheck() > g_DEBUG; 29 | } 30 | 31 | export function debugPush(str: string, ...args: any[]) { 32 | if (commonPushCheck() >= 5) { 33 | console.debug(`${g_FULLNAME}[D] ${new Date().toLocaleTimeString()} ${str}`, ...args); 34 | } 35 | } 36 | 37 | export function infoPush(str: string, ...args: any[]) { 38 | if (commonPushCheck() >= 3) { 39 | console.info(`${g_FULLNAME}[I] ${new Date().toLocaleTimeString()} ${str}`, ...args); 40 | } 41 | } 42 | 43 | export function logPush(str: string, ...args: any[]) { 44 | if (commonPushCheck() >= 4) { 45 | console.log(`${g_FULLNAME}[L] ${new Date().toLocaleTimeString()} ${str}`, ...args); 46 | } 47 | } 48 | 49 | export function errorPush(str: string, ... args: any[]) { 50 | if (commonPushCheck() >= 1) { 51 | console.error(`${g_FULLNAME}[E] ${new Date().toLocaleTimeString()} ${str}`, ...args); 52 | } 53 | } 54 | 55 | export function warnPush(str: string, ... args: any[]) { 56 | if (commonPushCheck() >= 2) { 57 | console.warn(`${g_FULLNAME}[W] ${new Date().toLocaleTimeString()} ${str}`, ...args); 58 | } 59 | } -------------------------------------------------------------------------------- /src/manager/editorHelper.ts: -------------------------------------------------------------------------------- 1 | import { FilerbotEditor } from "@/editor/filerbotEditor" 2 | import { LocalCmdEditor } from "@/editor/localEditor"; 3 | import TuiEditor from "@/editor/tuiEditor"; 4 | import { logPush } from "@/logger"; 5 | import { isMobile } from "@/syapi"; 6 | import { showPluginMessage } from "@/utils/common"; 7 | import { Protyle } from "siyuan"; 8 | 9 | export const IMAGE_EDITOR_KEY = { 10 | TUI: 'tui', 11 | FILERBOT: 'filerbot', 12 | LOCAL: "local" 13 | } 14 | const EDITOR = { 15 | [IMAGE_EDITOR_KEY.FILERBOT]: new FilerbotEditor(), 16 | [IMAGE_EDITOR_KEY.TUI]: new TuiEditor(), 17 | [IMAGE_EDITOR_KEY.LOCAL]: new LocalCmdEditor(), 18 | } 19 | const DEFAULT_EDITOR = isMobile() ? IMAGE_EDITOR_KEY.FILERBOT : IMAGE_EDITOR_KEY.TUI; 20 | 21 | let currentEditor; 22 | 23 | export function showImageEditor({ source, filePath, element, protyle }: { source: string; filePath: string, element: HTMLElement, protyle: Protyle }) { 24 | currentEditor.showImageEditor({ source, filePath, element, protyle }); 25 | } 26 | 27 | export function initImageEditor() { 28 | currentEditor.init(); 29 | } 30 | 31 | export function changeEditor(editorKey: string) { 32 | if (currentEditor) { 33 | currentEditor.destroy(); 34 | logPush("destroy") 35 | } 36 | logPush("调整编辑器到", editorKey); 37 | currentEditor = EDITOR[editorKey] || EDITOR[DEFAULT_EDITOR]; 38 | if (!currentEditor.isAvailable()) { 39 | logPush("编辑器不可用", editorKey); 40 | currentEditor = EDITOR[DEFAULT_EDITOR]; 41 | } 42 | currentEditor.init(); 43 | } 44 | 45 | export function destroyEditor() { 46 | if (currentEditor) { 47 | currentEditor.destroy(); 48 | } 49 | } 50 | 51 | export function refreshImg(imgElement, protyle) { 52 | logPush("protyle", protyle); 53 | if (protyle) { 54 | protyle.reload(); 55 | } else { 56 | let src = imgElement.getAttribute('src') || ''; 57 | let base = src.split('?')[0]; 58 | imgElement.setAttribute('src', base + '?t=' + Date.now()); 59 | } 60 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release on Tag Push 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | # Checkout 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | 19 | # Install Node.js 20 | - name: Install Node.js 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 18 24 | registry-url: "https://registry.npmjs.org" 25 | 26 | # Install pnpm 27 | - name: Install pnpm 28 | uses: pnpm/action-setup@v4 29 | id: pnpm-install 30 | with: 31 | version: 8 32 | run_install: false 33 | 34 | # Get pnpm store directory 35 | - name: Get pnpm store directory 36 | id: pnpm-cache 37 | shell: bash 38 | run: | 39 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 40 | 41 | # Setup pnpm cache 42 | - name: Setup pnpm cache 43 | uses: actions/cache@v3 44 | with: 45 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 46 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 47 | restore-keys: | 48 | ${{ runner.os }}-pnpm-store- 49 | 50 | # Install dependencies 51 | - name: Install dependencies 52 | run: pnpm install 53 | 54 | # Build for production, 这一步会生成一个 package.zip 55 | - name: Build for production 56 | run: pnpm build 57 | 58 | - name: Set up Python 59 | uses: actions/setup-python@v2 60 | with: 61 | python-version: '3.8' 62 | 63 | - name: Get CHANGELOGS 64 | run: python ./scripts/.release.py 65 | 66 | - name: Release 67 | uses: softprops/action-gh-release@v1 68 | with: 69 | body_path: ./result.txt 70 | files: package.zip 71 | prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') || contains(github.ref, 'dev') }} 72 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /src/storage/localStorage.ts: -------------------------------------------------------------------------------- 1 | import { showPluginMessage } from "@/utils/common"; 2 | import { BaseStorage } from "./baseStorage"; 3 | import { lang } from "@/utils/lang"; 4 | 5 | export default class LocalStorage extends BaseStorage { 6 | public init(config): void { 7 | 8 | } 9 | public async saveWithDataURL(dataPath: string, dataURL: string): Promise { 10 | return this.uploadFile(dataPath, dataURL); 11 | } 12 | private async uploadFile (path: string, base64: string): Promise { 13 | const match = base64.match(/^data:([^;]+);base64,(.*)$/); 14 | if (!match) throw new Error('base64格式错误'); 15 | const mime = match[1]; 16 | const b64data = match[2]; 17 | const byteString = atob(b64data); 18 | const arrayBuffer = new Uint8Array(byteString.length); 19 | for (let i = 0; i < byteString.length; i++) { 20 | arrayBuffer[i] = byteString.charCodeAt(i); 21 | } 22 | let ext = ''; 23 | if (mime.startsWith('image/')) { 24 | ext = '.' + mime.split('/')[1]; 25 | } else if (mime === 'application/pdf') { 26 | ext = '.pdf'; 27 | } 28 | let fileName = path.split('/').pop() || 'file'; 29 | if (!fileName.includes('.') && ext) fileName += ext; 30 | const file = new File([arrayBuffer], fileName, { type: mime }); 31 | const url = '/api/file/putFile'; 32 | const data = new FormData(); 33 | data.append('path', path); 34 | data.append('isDir', 'false'); 35 | // data.append('modTime', Date.now().toString()); 36 | data.append('file', file); 37 | const result = await fetch(url, { 38 | body: data, 39 | method: 'POST', 40 | headers: { 41 | Authorization: 'Token ' + (localStorage.getItem('token') || ''), 42 | }, 43 | }).then((response) => response.json()); 44 | if (result.code !== 0) { 45 | showPluginMessage(lang("save_failed") + result.msg, 5000, "error"); 46 | return false; 47 | } else { 48 | return true; 49 | } 50 | }; 51 | } -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2023 frostime. All rights reserved. 3 | */ 4 | 5 | /** 6 | * Frequently used data structures in SiYuan 7 | */ 8 | type DocumentId = string; 9 | type BlockId = string; 10 | type NotebookId = string; 11 | type PreviousID = BlockId; 12 | type ParentID = BlockId | DocumentId; 13 | 14 | type Notebook = { 15 | id: NotebookId; 16 | name: string; 17 | icon: string; 18 | sort: number; 19 | closed: boolean; 20 | } 21 | 22 | type NotebookConf = { 23 | name: string; 24 | closed: boolean; 25 | refCreateSavePath: string; 26 | createDocNameTemplate: string; 27 | dailyNoteSavePath: string; 28 | dailyNoteTemplatePath: string; 29 | } 30 | 31 | type BlockType = "d" | "s" | "h" | "t" | "i" | "p" | "f" | "audio" | "video" | "other"; 32 | 33 | type BlockSubType = "d1" | "d2" | "s1" | "s2" | "s3" | "t1" | "t2" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "table" | "task" | "toggle" | "latex" | "quote" | "html" | "code" | "footnote" | "cite" | "collection" | "bookmark" | "attachment" | "comment" | "mindmap" | "spreadsheet" | "calendar" | "image" | "audio" | "video" | "other"; 34 | 35 | type Block = { 36 | id: BlockId; 37 | parent_id?: BlockId; 38 | root_id: DocumentId; 39 | hash: string; 40 | box: string; 41 | path: string; 42 | hpath: string; 43 | name: string; 44 | alias: string; 45 | memo: string; 46 | tag: string; 47 | content: string; 48 | fcontent?: string; 49 | markdown: string; 50 | length: number; 51 | type: BlockType; 52 | subtype: BlockSubType; 53 | ial?: { [key: string]: string }; 54 | sort: number; 55 | created: string; 56 | updated: string; 57 | } 58 | 59 | type doOperation = { 60 | action: string; 61 | data: string; 62 | id: BlockId; 63 | parentID: BlockId | DocumentId; 64 | previousID: BlockId; 65 | retData: null; 66 | } 67 | 68 | /** 69 | * By OpaqueGlass. Copy from https://github.com/siyuan-note/siyuan/blob/master/app/src/types/index.d.ts 70 | */ 71 | interface IFile { 72 | icon: string; 73 | name1: string; 74 | alias: string; 75 | memo: string; 76 | bookmark: string; 77 | path: string; 78 | name: string; 79 | hMtime: string; 80 | hCtime: string; 81 | hSize: string; 82 | dueFlashcardCount?: string; 83 | newFlashcardCount?: string; 84 | flashcardCount?: string; 85 | id: string; 86 | count: number; 87 | subFileCount: number; 88 | } 89 | -------------------------------------------------------------------------------- /src/i18n/zh_CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "setting_imageEditor_name": "图片编辑器", 3 | "setting_imageEditor_desp": "选择用于图片编辑的工具。在工具下拉选项上鼠标悬停了解详细信息或已知问题", 4 | "setting_imageEditor_option_filerbot": "Filerobot 编辑器", 5 | "setting_imageEditor_option_filerbot_desp": "画笔连续操作时,编辑器可能崩溃退出", 6 | "setting_imageEditor_option_tui": "TUI 编辑器", 7 | "setting_imageEditor_option_tui_desp": "由于插件未进行样式隔离,切换到TUI编辑器可能导致与其他插件、主题等出现冲突", 8 | "setting_imageEditor_option_local": "本地编辑器", 9 | "setting_imageEditor_option_local_desp": "在浏览器/docker环境下无法使用,部分情况下需要保存后手动刷新。", 10 | "setting_saveType_name": "保存方式", 11 | "setting_saveType_desp": "选择图片保存的方式。", 12 | "setting_saveType_option_local": "本地保存", 13 | "setting_saveType_option_local_desp": "将图片保存到本地文件系统。", 14 | "setting_column_none_name": "无栏目", 15 | "setting_localEditorPath_name": "本地图片编辑器程序路径", 16 | "setting_localEditorPath_desp": "选择或填写本地图片编辑器路径", 17 | "editor_unexpected_exit_tip": "很抱歉,图片编辑器崩溃退出,未保存的编辑已经丢失,请重试或选择其他图片编辑器", 18 | 19 | "setting_mainTips_name": "提示", 20 | "setting_mainTips_desp": "1)设置项以siyuan-systemId为区分,不同系统设置项独立设定;2)本地编辑器可能会锁定图片,导致思源获取锁失败而直接退出。", 21 | 22 | "setting_localEditorArgs_name": "本地编辑器参数", 23 | "setting_localEditorArgs_desp": "一行一个参数,其中应当包含%%PATH%%,它将被替换为图片实际路径。", 24 | 25 | "local_editor_frontend_error": "当前前端环境不支持本地编辑器调用,仅支持桌面版客户端", 26 | "local_editor_backend_error": "当前后端环境不支持本地编辑器调用", 27 | 28 | "setting_about_name": "关于", 29 | "setting_about_desp": "作者:OpaqueGlass
问题反馈&赞助支持:github填写问卷
插件基于AGPLv3协议授权使用;
使用前,请阅读“README.md”说明文档或集市下载页说明文档。", 30 | 31 | "addTopBarIcon": "使用插件添加一个顶栏按钮", 32 | "cancel": "取消", 33 | "save": "保存", 34 | "byeMenu": "再见,菜单!", 35 | "helloPlugin": "你好,插件!", 36 | "byePlugin": "再见,插件!", 37 | "showDialog": "弹出一个对话框", 38 | "removedData": "数据已删除", 39 | "setting_panel_title": "图片编辑器插件设置", 40 | 41 | "editor_filerbot_text_default": "双击开始编辑,Ctrl+Enter生效", 42 | "open_with_editor": "使用图片编辑器打开", 43 | 44 | "dialog_leave_without_save": "存在未保存的更改", 45 | "dialog_leave_without_save_tip": "喵🐱?", 46 | "dialog_leave_without_save_tip_dog": "汪🐶!", 47 | "dialog_leave_without_save_cancel": "直接退出", 48 | "dialog_leave_without_save_confirm": "保存并退出", 49 | "dialog_leave_without_save_return": "返回编辑页面", 50 | 51 | "editor_save": "保存", 52 | "save_failed": "保存失败", 53 | "save_success": "保存成功", 54 | 55 | "settingpage_general_name": "通用", 56 | "settingpage_settingave_name": "设置项保存", 57 | "settingpage_about_name": "关于", 58 | 59 | "dialog_external_editor_wait_tip": "已尝试打开外部编辑器,如果编辑完成,请保存并关闭外部编辑器,然后点击刷新。若编辑器启动失败,请检查下面的提示信息(退出码、命令和参数)", 60 | "dialog_external_editor_cancel": "不刷新", 61 | "dialog_external_editor_refresh": "刷新", 62 | "dialog_external_editor_title": "等待编辑完成……", 63 | "dialog_external_editor_exitcode": "退出码:", 64 | "dialog_external_editor_cmd": "命令行:", 65 | "dialog_external_editor_args": "参数列表:", 66 | "msg_call_external_editor_failed": "本地编辑器调用失败,错误信息:", 67 | "msg_called_external_editor": "已调用本地编辑器,编辑完成后请手动保存、然后关闭编辑器。", 68 | "msg_not_select_path": "未选择路径或没有选择有效路径", 69 | "msg_select_editor_not_usable": "所选图片编辑器在当前环境下不可用,临时调整为默认图片编辑器", 70 | "only_available_in_client": "仅在客户端中可用", 71 | "msg_not_set_exec_path": "未设置本地编辑器路径,请在设置中完善" 72 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Plugin, 3 | showMessage, 4 | getFrontend, 5 | } from "siyuan"; 6 | import * as siyuan from "siyuan"; 7 | import "@/index.scss"; 8 | 9 | import { createApp } from "vue"; 10 | import settingVue from "./components/settings/setting.vue"; 11 | import { setLanguage } from "./utils/lang"; 12 | import { debugPush, errorPush, logPush } from "./logger"; 13 | import { initSettingProperty } from './manager/settingManager'; 14 | import { setPluginInstance } from "./utils/pluginHelper"; 15 | import { loadSettings } from "./manager/settingManager"; 16 | import EventHandler from "./manager/eventHandler"; 17 | import { removeStyle, setStyle } from "./manager/setStyle"; 18 | import { bindCommand } from "./manager/shortcutHandler"; 19 | import { generateUUID } from "./utils/common"; 20 | import { destroyEditor, initImageEditor } from "./manager/editorHelper"; 21 | 22 | const STORAGE_NAME = "menu-config"; 23 | 24 | export default class OGSamplePlugin extends Plugin { 25 | private myEventHandler: EventHandler; 26 | private _imageEditorVueApp: any = null; 27 | private imageEditorTab: any = null; 28 | 29 | async onload() { 30 | this.data[STORAGE_NAME] = {readonlyText: "Readonly"}; 31 | logPush("测试", this.i18n); 32 | setLanguage(this.i18n); 33 | setPluginInstance(this); 34 | initSettingProperty(); 35 | bindCommand(this); 36 | // 载入设置项,此项必须在setPluginInstance之后被调用 37 | this.myEventHandler = new EventHandler(); 38 | // 示例:将得到的svg复制过来,将元素类型修改为symbol,然后设置一个id应该就行,移除width="24" height="24" 39 | this.addIcons(` 40 | `); 41 | 42 | 43 | } 44 | 45 | onLayoutReady(): void { 46 | loadSettings().then(()=>{ 47 | this.myEventHandler.bindHandler(); 48 | setStyle(); 49 | }).catch((e)=>{ 50 | showMessage("插件载入设置项失败。Load plugin settings faild. " + this.name); 51 | errorPush(e); 52 | }); 53 | } 54 | 55 | onunload(): void { 56 | // 善后 57 | this.myEventHandler.unbindHandler(); 58 | // 移除所有已经插入的导航区 59 | removeStyle(); 60 | destroyEditor(); 61 | // 清理绑定的宽度监听 62 | if (window["og_hn_observe"]) { 63 | for (const key in window["og_hn_observe"]) { 64 | debugPush("插件卸载清理observer", key); 65 | window["og_hn_observe"][key]?.disconnect(); 66 | } 67 | delete window["og_hn_observe"]; 68 | } 69 | } 70 | 71 | openSetting() { 72 | // 生成Dialog内容 73 | const uid = generateUUID(); 74 | // 创建dialog 75 | const app = createApp(settingVue); 76 | const settingDialog = new siyuan.Dialog({ 77 | "title": this.i18n["setting_panel_title"], 78 | "content": ` 79 |
80 | `, 81 | "width": isMobile() ? "92vw":"1040px", 82 | "height": isMobile() ? "50vh":"80vh", 83 | "destroyCallback": ()=>{app.unmount(); }, 84 | }); 85 | app.mount(`#og_plugintemplate_${uid}`); 86 | } 87 | } 88 | 89 | function isMobile() { 90 | return window.top.document.getElementById("sidebar") ? true : false; 91 | }; 92 | -------------------------------------------------------------------------------- /src/manager/eventHandler.ts: -------------------------------------------------------------------------------- 1 | import { getPluginInstance } from "@/utils/pluginHelper"; 2 | import Mutex from "@/utils/mutex"; 3 | import { getReadOnlyGSettings } from "@/manager/settingManager"; 4 | import { IEventBusMap, openTab } from "siyuan"; 5 | import { logPush } from "@/logger"; 6 | import { showImageEditor } from "./editorHelper"; 7 | import { lang } from "@/utils/lang"; 8 | export default class EventHandler { 9 | private handlerBindList: Recordvoid> = { 10 | "loaded-protyle-static": this.loadedProtyleRetryEntry.bind(this), // mutex需要访问EventHandler的属性 11 | "open-menu-image": this.openMenuImage.bind(this), 12 | "click-editorcontent": this.clickEditorContent.bind(this), 13 | }; 14 | // 关联的设置项,如果设置项对应为true,则才执行绑定 15 | private relateGsettingKeyStr: Record = { 16 | "loaded-protyle-static": null, // mutex需要访问EventHandler的属性 17 | "open-menu-image": null, 18 | "ws-main": "immediatelyUpdate", 19 | }; 20 | 21 | private loadAndSwitchMutex: Mutex; 22 | private simpleMutex: number = 0; 23 | private docIdMutex: Record = {}; 24 | constructor() { 25 | this.loadAndSwitchMutex = new Mutex(); 26 | } 27 | 28 | bindHandler() { 29 | const plugin = getPluginInstance(); 30 | const g_setting = getReadOnlyGSettings(); 31 | // const g_setting = getReadOnlyGSettings(); 32 | for (let key in this.handlerBindList) { 33 | if (this.relateGsettingKeyStr[key] == null || g_setting[this.relateGsettingKeyStr[key]]) { 34 | plugin.eventBus.on(key, this.handlerBindList[key]); 35 | } 36 | } 37 | 38 | } 39 | 40 | unbindHandler() { 41 | const plugin = getPluginInstance(); 42 | for (let key in this.handlerBindList) { 43 | plugin.eventBus.off(key, this.handlerBindList[key]); 44 | } 45 | } 46 | 47 | async loadedProtyleRetryEntry(event: CustomEvent) { 48 | // do sth 49 | } 50 | 51 | async openMenuImage(event: CustomEvent) { 52 | const {menu, protyle, element} = event.detail; 53 | logPush("menue", menu, protyle, element); 54 | const src = element.querySelector("img").getAttribute("src"); 55 | if (src.startsWith("asset")) { 56 | menu.addItem({ 57 | label: lang("open_with_editor"), 58 | icon: "ogiconFileImage", 59 | click: () => { 60 | // 在这里处理点击事件 61 | showImageEditor({ 62 | source: element.querySelector("img").getAttribute("src"), 63 | filePath: "data/" + element.querySelector("img").getAttribute("data-src"), 64 | element: element.querySelector("img"), 65 | protyle: protyle.getInstance(), 66 | }); 67 | } 68 | }); 69 | } 70 | } 71 | 72 | async clickEditorContent(customEvent: CustomEvent) { 73 | const { protyle, event } = customEvent.detail; 74 | logPush("clickEditorContent", protyle, event); 75 | if (event.target && (event.target as HTMLElement).tagName === "IMG" && event.altKey) { 76 | const element = event.target as HTMLElement; 77 | if (!element.getAttribute("data-src")) { 78 | return; 79 | } 80 | if (!element.getAttribute("src") || !element.getAttribute("src").startsWith("asset")) { 81 | return; 82 | } 83 | showImageEditor({ 84 | source: element.getAttribute("src"), 85 | filePath: "data/" + element.getAttribute("data-src"), 86 | element: element, 87 | protyle: protyle.getInstance(), 88 | }); 89 | } 90 | } 91 | 92 | } -------------------------------------------------------------------------------- /src/utils/settings.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 设置、设置标签页定义 3 | * 请注意,设置项的初始化应该在语言文件加载后进行 4 | */ 5 | import { isValidStr } from "./commonCheck"; 6 | import { lang } from "./lang"; 7 | 8 | interface IConfigProperty { 9 | key: string, 10 | type: IConfigPropertyType, // 设置项类型 11 | min?: number, // 设置项最小值 12 | max?: number, // 设置项最大值 13 | btndo?: () => void, // 按钮设置项的调用函数(callback) 14 | options?: Array, // 选项key数组,元素顺序决定排序顺序,请勿使用非法字符串 15 | optionSameAsSettingKey?: string, // 如果选项的描述文本和其他某个设置项相同,在此指定;请注意,仍需要指定options 16 | } 17 | 18 | export class ConfigProperty { 19 | key: string; 20 | type: IConfigPropertyType; 21 | min?: number; 22 | max?: number; 23 | btndo?: () => void; 24 | options?: Array; 25 | 26 | configName: string; 27 | description: string; 28 | tips: string; 29 | 30 | optionNames: Array; 31 | optionDesps: Array; 32 | 33 | constructor({key, type, min, max, btndo, options, optionSameAsSettingKey}: IConfigProperty){ 34 | this.key = key; 35 | this.type = type; 36 | this.min = min; 37 | this.max = max; 38 | this.btndo = btndo; 39 | this.options = options ?? new Array(); 40 | 41 | this.configName = lang(`setting_${key}_name`); 42 | this.description = lang(`setting_${key}_desp`); 43 | if (this.configName.startsWith("🧪")) { 44 | this.description = lang("setting_experimental") + this.description; 45 | } else if (this.configName.startsWith("✈")) { 46 | this.description = lang("setting_testing") + this.description; 47 | } else if (this.configName.startsWith("❌")) { 48 | this.description = lang("setting_deprecated") + this.description; 49 | } 50 | // this.tips = lang(`setting_${key}_tips`); 51 | 52 | this.optionNames = new Array(); 53 | this.optionDesps = new Array(); 54 | for(let optionKey of this.options){ 55 | this.optionNames.push(lang(`setting_${optionSameAsSettingKey ?? key}_option_${optionKey}`)); 56 | this.optionDesps.push(lang(`setting_${optionSameAsSettingKey ?? key}_option_${optionKey}_desp`)); 57 | } 58 | } 59 | 60 | } 61 | 62 | interface ITabProperty { 63 | key: string, 64 | props: Array|Record>, 65 | iconKey?: string 66 | } 67 | 68 | export class TabProperty { 69 | key: string; 70 | iconKey: string; 71 | props: {[name:string]:Array}; 72 | isColumn: boolean = false; 73 | columnNames: Array = new Array(); 74 | columnKeys: Array = new Array(); 75 | 76 | constructor({key, props, iconKey}: ITabProperty){ 77 | this.key = key; 78 | if (isValidStr(iconKey)) { 79 | this.iconKey = iconKey; 80 | } else { 81 | this.iconKey = "setting"; 82 | } 83 | if (!Array.isArray(props)) { 84 | this.isColumn = true; 85 | Object.keys(props).forEach((columnKey) => { 86 | this.columnNames.push(lang(`setting_column_${columnKey}_name`)); 87 | this.columnKeys.push(columnKey); 88 | }); 89 | this.props = props; 90 | } else { 91 | this.props = {"none": props}; 92 | this.columnNames.push("none"); 93 | this.columnKeys.push("none"); 94 | } 95 | } 96 | 97 | } 98 | 99 | 100 | /** 101 | * 设置标签页 102 | * @param tabDefinitions 设置标签页定义 103 | * @returns 104 | */ 105 | // export function loadDefinitionFromTabProperty(tabDefinitions: Array):Array { 106 | // let result: Array = []; 107 | // tabDefinitions.forEach((tabDefinition) => { 108 | // tabDefinition.props.forEach((property) => { 109 | // result.push(property); 110 | // }); 111 | // }); 112 | 113 | // return result; 114 | // } 115 | 116 | /** 117 | * 获得ConfigMap对象 118 | * @param tabDefinitions 119 | * @returns 120 | */ 121 | export function loadAllConfigPropertyFromTabProperty(tabDefinitions: Array):Record { 122 | let result: Record = {}; 123 | tabDefinitions.forEach((tabDefinition) => { 124 | Object.values(tabDefinition.props).forEach((properties) => { 125 | properties.forEach((property) => { 126 | result[property.key] = property; 127 | }); 128 | }); 129 | }); 130 | return result; 131 | } -------------------------------------------------------------------------------- /static/tui-color-picker.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * TOAST UI Color Picker 3 | * @version 2.2.6 4 | * @author NHN FE Development Team 5 | * @license MIT 6 | */ 7 | .tui-colorpicker-clearfix { 8 | zoom: 1; 9 | } 10 | .tui-colorpicker-clearfix:after { 11 | content: ''; 12 | display: block; 13 | clear: both; 14 | } 15 | .tui-colorpicker-vml { 16 | behavior: url("#default#VML"); 17 | display: block; 18 | } 19 | .tui-colorpicker-container { 20 | width: 152px; 21 | } 22 | .tui-colorpicker-palette-container { 23 | width: 152px; 24 | } 25 | .tui-colorpicker-palette-container ul { 26 | width: 152px; 27 | margin: 0px; 28 | padding: 0px; 29 | } 30 | .tui-colorpicker-palette-container li { 31 | float: left; 32 | margin: 0; 33 | padding: 0 3px 3px 0; 34 | list-style: none; 35 | } 36 | .tui-colorpicker-palette-button { 37 | display: block; 38 | border: none; 39 | overflow: hidden; 40 | outline: none; 41 | margin: 0px; 42 | padding: 0px; 43 | width: 16px; 44 | height: 16px; 45 | border: 1px solid #ccc; 46 | cursor: pointer; 47 | } 48 | .tui-colorpicker-palette-button.tui-colorpicker-selected { 49 | border: 2px solid #000; 50 | } 51 | .tui-colorpicker-palette-button.tui-colorpicker-color-transparent { 52 | barckground-repeat: repeat; 53 | background-repeat: no-repeat; 54 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA0AAAAOCAYAAAD0f5bSAAABfGlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGAqSSwoyGFhYGDIzSspCnJ3UoiIjFJgv8PAzcDDIMRgxSCemFxc4BgQ4MOAE3y7xsAIoi/rgsxK8/x506a1fP4WNq+ZclYlOrj1gQF3SmpxMgMDIweQnZxSnJwLZOcA2TrJBUUlQPYMIFu3vKQAxD4BZIsUAR0IZN8BsdMh7A8gdhKYzcQCVhMS5AxkSwDZAkkQtgaInQ5hW4DYyRmJKUC2B8guiBvAgNPDRcHcwFLXkYC7SQa5OaUwO0ChxZOaFxoMcgcQyzB4MLgwKDCYMxgwWDLoMjiWpFaUgBQ65xdUFmWmZ5QoOAJDNlXBOT+3oLQktUhHwTMvWU9HwcjA0ACkDhRnEKM/B4FNZxQ7jxDLX8jAYKnMwMDcgxBLmsbAsH0PA4PEKYSYyjwGBn5rBoZt5woSixLhDmf8xkKIX5xmbARh8zgxMLDe+///sxoDA/skBoa/E////73o//+/i4H2A+PsQA4AJHdp4IxrEg8AAAGbaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJYTVAgQ29yZSA1LjQuMCI+CiAgIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOmV4aWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vZXhpZi8xLjAvIj4KICAgICAgICAgPGV4aWY6UGl4ZWxYRGltZW5zaW9uPjEzPC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjE0PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CghrN1AAAABzSURBVCgVldKxEYAgDAXQD5VOpLuwgi4jlrTMqF00oOd5Aia/CcV/F4oYOgNlrLjvVyCEVJchBjEC25538PeaWTzRMBLxvIL7UZwFwL06qoA6aoAy+gFfJABvJAQPUoCMlICRRd8BzgHzJL4ok9aJ67l4AK9AxVKhHryUAAAAAElFTkSuQmCC"); 55 | } 56 | .tui-colorpicker-palette-hex { 57 | font-family: monospace; 58 | display: inline-block; 59 | *display: inline; 60 | zoom: 1; 61 | width: 60px; 62 | vertical-align: middle; 63 | } 64 | .tui-colorpicker-palette-preview { 65 | display: inline-block; 66 | *display: inline; 67 | zoom: 1; 68 | width: 12px; 69 | height: 12px; 70 | border: 1px solid #ccc; 71 | border: 1px solid #ccc; 72 | vertical-align: middle; 73 | overflow: hidden; 74 | } 75 | .tui-colorpicker-palette-toggle-slider { 76 | display: inline-block; 77 | *display: inline; 78 | zoom: 1; 79 | vertical-align: middle; 80 | float: right; 81 | } 82 | .tui-colorpicker-slider-container { 83 | margin: 5px 0 0 0; 84 | height: 122px; 85 | zoom: 1; 86 | } 87 | .tui-colorpicker-slider-container:after { 88 | content: ''; 89 | display: block; 90 | clear: both; 91 | } 92 | .tui-colorpicker-slider-left { 93 | float: left; 94 | width: 120px; 95 | height: 120px; 96 | } 97 | .tui-colorpicker-slider-right { 98 | float: right; 99 | width: 32px; 100 | height: 120px; 101 | } 102 | .tui-colorpicker-svg { 103 | display: block; 104 | } 105 | .tui-colorpicker-slider-handle { 106 | position: absolute; 107 | overflow: visible; 108 | top: 0; 109 | left: 0; 110 | width: 1px; 111 | height: 1px; 112 | z-index: 2; 113 | opacity: 0.9; 114 | } 115 | .tui-colorpicker-svg-slider { 116 | width: 120px; 117 | height: 120px; 118 | border: 1px solid #ccc; 119 | overflow: hidden; 120 | } 121 | .tui-colorpicker-vml-slider { 122 | position: relative; 123 | width: 120px; 124 | height: 120px; 125 | border: 1px solid #ccc; 126 | overflow: hidden; 127 | } 128 | .tui-colorpicker-vml-slider-bg { 129 | position: absolute; 130 | margin: -1px 0 0 -1px; 131 | top: 0; 132 | left: 0; 133 | width: 122px; 134 | height: 122px; 135 | } 136 | .tui-colorpicker-svg-huebar { 137 | float: right; 138 | width: 18px; 139 | height: 120px; 140 | border: 1px solid #ccc; 141 | overflow: visible; 142 | } 143 | .tui-colorpicker-vml-huebar { 144 | width: 32px; 145 | position: relative; 146 | } 147 | .tui-colorpicker-vml-huebar-bg { 148 | position: absolute; 149 | top: 0; 150 | right: 0; 151 | width: 18px; 152 | height: 121px; 153 | } 154 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path" 2 | import { defineConfig, loadEnv } from "vite" 3 | import minimist from "minimist" 4 | import { viteStaticCopy } from "vite-plugin-static-copy" 5 | import livereload from "rollup-plugin-livereload" 6 | import zipPack from "vite-plugin-zip-pack"; 7 | import fg from 'fast-glob'; 8 | import vue from '@vitejs/plugin-vue'; 9 | import fs from "fs"; 10 | 11 | const args = minimist(process.argv.slice(2)) 12 | const isWatch = args.watch || args.w || false; 13 | // 使用change-dir命令更改dev的目录 14 | const devDistDirInfo = "./scripts/devInfo.json"; 15 | const loadDirJsonContent = fs.existsSync(devDistDirInfo) 16 | ? JSON.parse(fs.readFileSync(devDistDirInfo, "utf-8")) 17 | : {}; 18 | const devDistDir = loadDirJsonContent["devDir"] ?? "./dev"; 19 | const distDir = isWatch ? devDistDir : "./dist" 20 | 21 | console.log("isWatch=>", isWatch) 22 | console.log("distDir=>", distDir) 23 | 24 | export default defineConfig({ 25 | resolve: { 26 | alias: { 27 | "@": resolve(__dirname, "src"), 28 | } 29 | }, 30 | 31 | plugins: [ 32 | vue(), 33 | viteStaticCopy({ 34 | targets: [ 35 | { 36 | src: "./README*.md", 37 | dest: "./", 38 | }, 39 | { 40 | src: "./icon.png", 41 | dest: "./", 42 | }, 43 | { 44 | src: "./preview.png", 45 | dest: "./", 46 | }, 47 | { 48 | src: "./plugin.json", 49 | dest: "./", 50 | }, 51 | { 52 | src: "./src/i18n/**", 53 | dest: "./i18n/", 54 | }, 55 | { 56 | src: "./LICENSE", 57 | dest: "./" 58 | }, 59 | { 60 | src: "./CHANGELOG.md", 61 | dest: "./" 62 | }, 63 | { 64 | src: "./static/**", 65 | dest: "./static/" 66 | } 67 | ], 68 | }), 69 | ], 70 | 71 | // https://github.com/vitejs/vite/issues/1930 72 | // https://vitejs.dev/guide/env-and-mode.html#env-files 73 | // https://github.com/vitejs/vite/discussions/3058#discussioncomment-2115319 74 | // 在这里自定义变量 75 | define: { 76 | "process.env": process.env, 77 | "process.env.DEV_MODE": `"${isWatch}"`, 78 | __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, 79 | }, 80 | 81 | build: { 82 | // 输出路径 83 | outDir: distDir, 84 | emptyOutDir: false, 85 | 86 | // 构建后是否生成 source map 文件 87 | sourcemap: false, 88 | 89 | // 设置为 false 可以禁用最小化混淆 90 | // 或是用来指定是应用哪种混淆器 91 | // boolean | 'terser' | 'esbuild' 92 | // 不压缩,用于调试 93 | minify: !isWatch, 94 | 95 | lib: { 96 | // Could also be a dictionary or array of multiple entry points 97 | entry: resolve(__dirname, "src/index.ts"), 98 | // the proper extensions will be added 99 | fileName: "index", 100 | formats: ["cjs"], 101 | }, 102 | rollupOptions: { 103 | plugins: [ 104 | ...( 105 | isWatch ? [ 106 | livereload(devDistDir), 107 | { 108 | //监听静态资源文件 109 | name: 'watch-external', 110 | async buildStart() { 111 | const files = await fg([ 112 | 'src/i18n/*.json', 113 | './README*.md', 114 | './widget.json' 115 | ]); 116 | for (let file of files) { 117 | this.addWatchFile(file); 118 | } 119 | } 120 | } 121 | ] : [ 122 | zipPack({ 123 | inDir: './dist', 124 | outDir: './', 125 | outFileName: 'package.zip' 126 | }) 127 | ] 128 | ) 129 | ], 130 | 131 | // make sure to externalize deps that shouldn't be bundled 132 | // into your library 133 | external: ["siyuan", "process"], 134 | 135 | output: { 136 | entryFileNames: "[name].js", 137 | assetFileNames: (assetInfo) => { 138 | if (assetInfo.name === "style.css") { 139 | return "index.css" 140 | } 141 | return assetInfo.name 142 | }, 143 | }, 144 | }, 145 | } 146 | }) 147 | -------------------------------------------------------------------------------- /src/i18n/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | "setting_imageEditor_name": "Image Editor", 3 | "setting_imageEditor_desp": "Select the tools for picture editing. Hover over the tool select options to learn more details or known issues.", 4 | "setting_imageEditor_option_filerbot": "Filerobot Editor", 5 | "setting_imageEditor_option_filerbot_desp": "The editor may crash and exit during continuous Annotate-Pen operations.", 6 | "setting_imageEditor_option_tui": "TUI Editor", 7 | "setting_imageEditor_option_tui_desp": "Switching to the TUI editor may cause conflicts with other plugins, themes, etc., as the plugin does not perform style isolation.", 8 | "setting_imageEditor_option_local": "Local Editor", 9 | "setting_imageEditor_option_local_desp": "Cannot be used in browser/docker environments. In some cases, manual refresh is required after saving.", 10 | "setting_saveType_name": "Save Method", 11 | "setting_saveType_desp": "Choose how to save images.", 12 | "setting_saveType_option_local": "Save Locally", 13 | "setting_saveType_option_local_desp": "Save images to the local file system.", 14 | "setting_column_none_name": "No Column", 15 | "addTopBarIcon": "Add a top bar button using the plugin", 16 | "cancel": "Cancel", 17 | "save": "Save", 18 | "byeMenu": "Goodbye, menu!", 19 | "helloPlugin": "Hello, plugin!", 20 | "byePlugin": "Goodbye, plugin!", 21 | "showDialog": "Show a dialog", 22 | "removedData": "Data has been deleted", 23 | "setting_panel_title": "Image Editor Plugin Settings", 24 | 25 | "editor_filerbot_text_default": "Double-click to edit, Ctrl+Enter to apply", 26 | "open_with_editor": "Open with Image Editor", 27 | 28 | "dialog_leave_without_save": "There are unsaved changes", 29 | "dialog_leave_without_save_tip": "Meow🐱?", 30 | "dialog_leave_without_save_tip_dog": "Woof🐶!", 31 | "dialog_leave_without_save_cancel": "Leave without saving", 32 | "dialog_leave_without_save_confirm": "Save & Exit", 33 | "dialog_leave_without_save_return": "Cancel & Return Edit Page", 34 | 35 | "editor_save": "Save", 36 | 37 | "save_failed": "Save Failed: ", 38 | "save_success": "Saved Successfully", 39 | 40 | "editor_unexpected_exit_tip": "Sorry, the image editor crashed out, please try again or choose other image editor", 41 | 42 | 43 | "settingpage_general_name": "General", 44 | "settingpage_settingave_name": "Save Settings", 45 | "settingpage_about_name": "About", 46 | 47 | "local_editor_frontend_error": "The current frontend environment does not support invoking the local editor; only the desktop client is supported.", 48 | "local_editor_backend_error": "The current backend environment does not support invoking the local editor.", 49 | 50 | "setting_about_name": "About", 51 | "setting_about_desp": "Translator: GPT 4o-mini & OpaqueGlass;
Developer: OpaqueGlass
Feedback & sponsor: github;
Released under AGPLv3 license;
Read 'README' file before using it.", 52 | 53 | "dialog_external_editor_wait_tip": "After editing, please save manually in the external editor. Once you save and close the external editor, click Refresh. If the editor fails to start, please check the following information (exit code, command, and arguments).", 54 | "dialog_external_editor_cancel": "Do not Refresh", 55 | "dialog_external_editor_refresh": "Refresh", 56 | "dialog_external_editor_title": "Waiting for editing to finish...", 57 | "dialog_external_editor_exitcode": "Exit Code: ", 58 | "dialog_external_editor_cmd": "CMD: ", 59 | "dialog_external_editor_args": "args: ", 60 | "msg_call_external_editor_failed": "Local editor call failed, error message: ", 61 | "msg_not_set_exec_path": "Please set the local editor path in plugin settings. ", 62 | "msg_called_external_editor": "The local editor has been invoked. Please save manually and close the editor when editing is complete. ", 63 | "msg_not_select_path": "Not Select A Valid Path", 64 | "msg_select_editor_not_usable": "The selected image editor is not available in the current environment. Temporarily switched to the default image editor.", 65 | 66 | "only_available_in_client": "Only Available in Client", 67 | 68 | "setting_mainTips_name": "Tips", 69 | "setting_mainTips_desp": "1) Settings are distinguished by siyuan-systemId, and different systems have independent settings. 2) The local editor may lock the image, causing SiYuan to fail to acquire the lock and exit directly.", 70 | 71 | "setting_localEditorPath_name": "Local Image Editor Program Path", 72 | "setting_localEditorPath_desp": "Select or enter the path of the local image editor.", 73 | "setting_localEditorArgs_name": "Local Editor Arguments", 74 | "setting_localEditorArgs_desp": "One argument per line, which should include %%PATH%%, and it will be replaced with the actual image path.", 75 | "setting_localEditorWaitingDialog_name": "Enable Waiting Dialog", 76 | "setting_localEditorWaitingDialog_desp": "Show a waiting dialog when the local editor is called.", 77 | "settingpage_none_name": "No Column" 78 | } -------------------------------------------------------------------------------- /src/editor/localEditor.ts: -------------------------------------------------------------------------------- 1 | import { errorPush, logPush } from "@/logger"; 2 | import { getReadOnlyGSettings } from "@/manager/settingManager"; 3 | import { escapeHTML, showPluginMessage } from "@/utils/common"; 4 | import { isValidStr } from "@/utils/commonCheck"; 5 | import { Dialog, getBackend, getFrontend, Protyle } from "siyuan"; 6 | import BaseImageEditor from "./baseImageEditor"; 7 | import { lang } from "@/utils/lang"; 8 | import { refreshImg } from "@/manager/editorHelper"; 9 | 10 | export class LocalCmdEditor extends BaseImageEditor { 11 | public async init() { 12 | 13 | } 14 | 15 | public async showImageEditor({ source, filePath, element, protyle }: { source: string; filePath: string, element: HTMLElement, protyle: Protyle }) { 16 | const { spawn } = window.require("child_process"); 17 | const path = window.require("path"); 18 | // 本地路径需要去除参数 19 | let src = element.getAttribute('src') || ''; 20 | let base = src.split('?')[0]; 21 | logPush("打开本地编辑器", path.join(window.siyuan.config.system.dataDir, base)); 22 | 23 | const g_setting = getReadOnlyGSettings(); 24 | if (!isValidStr(g_setting.localEditorPath)) { 25 | showPluginMessage(lang("msg_not_set_exec_path")); 26 | return; 27 | } 28 | const args = g_setting.localEditorArgs ? g_setting.localEditorArgs.split('\n') : []; 29 | const imagePath = path.join(window.siyuan.config.system.dataDir, base); 30 | // 如果为空,则补充 path, 如果不为空,替换其中的%%PATH%%为path 31 | const finalArgs = args.length > 0 ? args.map((v: string) => { 32 | if (v.indexOf('%%PATH%%') >= 0) { 33 | return v.replace(/%%PATH%%/g, imagePath); 34 | } 35 | return v; 36 | }) : [imagePath]; 37 | logPush("编辑器调用参数", g_setting.localEditorPath, finalArgs); 38 | const child = spawn(g_setting.localEditorPath, finalArgs); 39 | showPluginMessage(lang("msg_called_external_editor")); 40 | child.on("error", (err) => { 41 | showPluginMessage(lang("msg_call_external_editor_failed") + err.message, 8000, "error"); 42 | errorPush("本地编辑器调用失败", err); 43 | }); 44 | child.stdout.on("data", (data) => { 45 | logPush(`stdout: ${data}`); 46 | }); 47 | 48 | child.stderr.on("data", (data) => { 49 | logPush(`stderr: ${data}`); 50 | }); 51 | const callDate = new Date(); 52 | child.on("close", (code) => { 53 | logPush(`子进程退出,code = ${code}`); 54 | const endDate = new Date(); 55 | if (endDate.getTime() - callDate.getTime() < 3000) { 56 | const dialogContent = ` 57 |
58 |
59 |
${escapeHTML(lang("dialog_external_editor_wait_tip"))}
60 |
61 |
${escapeHTML(lang("dialog_external_editor_exitcode"))}${code}
62 |
63 |
${escapeHTML(lang("dialog_external_editor_cmd"))}${g_setting.localEditorPath} ${escapeHTML(lang("dialog_external_editor_args"))}${JSON.stringify(finalArgs)}
64 |
65 |
66 | 67 |
68 | 69 |
70 |
71 | `; 72 | const dialog = new Dialog({ 73 | title: '⏳' + lang("dialog_external_editor_title"), 74 | content: dialogContent, 75 | width: '480px', 76 | height: '320px', 77 | disableClose: true, 78 | }); 79 | 80 | // Bind button events 81 | setTimeout(() => { 82 | const refreshBtn = document.getElementById('refreshDialogBtn'); 83 | const cancelBtn = document.getElementById('cancelDialogBtn'); 84 | if (refreshBtn) { 85 | refreshBtn.onclick = () => { 86 | dialog.destroy(); 87 | refreshImg(element, protyle); 88 | }; 89 | } 90 | if (cancelBtn) { 91 | cancelBtn.onclick = () => { 92 | dialog.destroy(); 93 | this.closeEditor(); 94 | }; 95 | } 96 | }, 0); 97 | } 98 | refreshImg(element, protyle); 99 | }); 100 | 101 | } 102 | public isAvailable() { 103 | const frontEnd = getFrontend(); 104 | if (["browser-desktop", "browser-mobile", "mobile"].includes(frontEnd)) { 105 | showPluginMessage(lang("local_editor_frontend_error")); 106 | return false; 107 | } 108 | const backEnd = getBackend(); 109 | if (["docker", "android", "ios", "harmony"].includes(backEnd)) { 110 | showPluginMessage(lang("local_editor_backend_error")); 111 | return false; 112 | } 113 | return true; 114 | } 115 | private closeEditor() { 116 | 117 | } 118 | public destroy() { 119 | 120 | } 121 | } -------------------------------------------------------------------------------- /src/syapi/interface.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | echarts: { 3 | init(element: HTMLElement, theme?: string, options?: { 4 | width: number 5 | }): { 6 | setOption(option: any): void; 7 | getZr(): any; 8 | on(name: string, event: (e: any) => void): any; 9 | containPixel(name: string, position: number[]): any; 10 | resize(): void; 11 | }; 12 | dispose(element: Element): void; 13 | getInstanceById(id: string): { 14 | resize: () => void 15 | }; 16 | } 17 | ABCJS: { 18 | renderAbc(element: Element, text: string, options: { 19 | responsive: string 20 | }): void; 21 | } 22 | hljs: { 23 | listLanguages(): string[]; 24 | highlight(text: string, options: { 25 | language?: string, 26 | ignoreIllegals: boolean 27 | }): { 28 | value: string 29 | }; 30 | getLanguage(text: string): { 31 | name: string 32 | }; 33 | }; 34 | katex: { 35 | renderToString(math: string, option?: { 36 | displayMode?: boolean; 37 | output?: string; 38 | macros?: IObject; 39 | trust?: boolean; 40 | strict?: (errorCode: string) => "ignore" | "warn"; 41 | }): string; 42 | } 43 | mermaid: { 44 | initialize(options: any): void, 45 | init(options: any, element: Element): void 46 | }; 47 | plantumlEncoder: { 48 | encode(options: string): string, 49 | }; 50 | pdfjsLib: any 51 | 52 | dataLayer: any[] 53 | 54 | siyuan: ISiyuan 55 | webkit: any 56 | html2canvas: (element: Element, opitons: { 57 | useCORS: boolean, 58 | scale?: number 59 | }) => Promise; 60 | JSAndroid: { 61 | returnDesktop(): void 62 | openExternal(url: string): void 63 | changeStatusBarColor(color: string, mode: number): void 64 | writeClipboard(text: string): void 65 | writeImageClipboard(uri: string): void 66 | readClipboard(): string 67 | getBlockURL(): string 68 | } 69 | 70 | Protyle: any 71 | 72 | goBack(): void 73 | 74 | reconnectWebSocket(): void 75 | 76 | showKeyboardToolbar(height: number): void 77 | 78 | hideKeyboardToolbar(): void 79 | 80 | openFileByURL(URL: string): boolean 81 | 82 | destroyTheme(): Promise 83 | } 84 | 85 | interface IFile { 86 | icon: string; 87 | name1: string; 88 | alias: string; 89 | memo: string; 90 | bookmark: string; 91 | path: string; 92 | name: string; 93 | hMtime: string; 94 | hCtime: string; 95 | hSize: string; 96 | dueFlashcardCount?: string; 97 | newFlashcardCount?: string; 98 | flashcardCount?: string; 99 | id: string; 100 | count: number; 101 | subFileCount: number; 102 | } 103 | 104 | interface ISiyuan { 105 | zIndex: number 106 | storage?: { 107 | [key: string]: any 108 | }, 109 | transactions?: { 110 | protyle: IProtyle, 111 | doOperations: IOperation[], 112 | undoOperations: IOperation[] 113 | }[] 114 | reqIds: { 115 | [key: string]: number 116 | }, 117 | editorIsFullscreen?: boolean, 118 | hideBreadcrumb?: boolean, 119 | notebooks?: INotebook[], 120 | emojis?: IEmoji[], 121 | backStack?: IBackStack[], 122 | mobile?: { 123 | editor?: any 124 | popEditor?: any 125 | files?: any 126 | }, 127 | user?: { 128 | userId: string 129 | userName: string 130 | userAvatarURL: string 131 | userHomeBImgURL: string 132 | userIntro: string 133 | userNickname: string 134 | userSiYuanOneTimePayStatus: number // 0 未付费;1 已付费 135 | userSiYuanProExpireTime: number // -1 终身会员;0 普通用户;> 0 过期时间 136 | userSiYuanSubscriptionPlan: number // 0 年付订阅/终生;1 教育优惠;2 订阅试用 137 | userSiYuanSubscriptionType: number // 0 年付;1 终生;2 月付 138 | userSiYuanSubscriptionStatus: number // -1:未订阅,0:订阅可用,1:订阅封禁,2:订阅过期 139 | userToken: string 140 | userTitles: { 141 | name: string, 142 | icon: string, 143 | desc: string 144 | }[] 145 | }, 146 | dragElement?: HTMLElement, 147 | layout?: { 148 | layout?: any, 149 | centerLayout?: any, 150 | leftDock?: any, 151 | rightDock?: any, 152 | bottomDock?: any, 153 | } 154 | config?: any; 155 | ws: any, 156 | ctrlIsPressed?: boolean, 157 | altIsPressed?: boolean, 158 | shiftIsPressed?: boolean, 159 | coordinates?: { 160 | pageX: number, 161 | pageY: number, 162 | clientX: number, 163 | clientY: number, 164 | screenX: number, 165 | screenY: number, 166 | }, 167 | menus?: any, 168 | languages?: { 169 | [key: string]: any; 170 | } 171 | bookmarkLabel?: string[] 172 | blockPanels: any, 173 | dialogs: any, 174 | viewer?: any 175 | } 176 | 177 | interface SqlResult { 178 | alias: string; 179 | box: string; 180 | content: string; 181 | created: string; 182 | fcontent: string; 183 | hash: string; 184 | hpath: string; 185 | ial: string; 186 | id: string; 187 | length: number; 188 | markdown: string; 189 | memo: string; 190 | name: string; 191 | parent_id: string; 192 | path: string; 193 | root_id: string; 194 | sort: number; 195 | subtype: SqlBlockSubType; 196 | tag: string; 197 | type: SqlBlockType; 198 | updated: string; 199 | } 200 | 201 | type SqlBlockType = "d" | "p" | "h" | "l" | "i" | "b" | "html" | "widget" | "tb" | "c" | "s" | "t" | "iframe" | "av" | "m" | "query_embed" | "video" | "audio"; 202 | 203 | type SqlBlockSubType = "o" | "u" | "t" | "" |"h1" | "h2" | "h3" | "h4" | "h5" | "h6" 204 | -------------------------------------------------------------------------------- /scripts/make_dev_link.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import http from 'node:http'; 3 | import readline from 'node:readline'; 4 | 5 | 6 | //************************************ Write you dir here ************************************ 7 | 8 | //Please write the "workspace/data/plugins" directory here 9 | //请在这里填写你的 "workspace/data/plugins" 目录 10 | let targetDir = ''; 11 | //Like this 12 | // let targetDir = `H:\\SiYuanDevSpace\\data\\plugins`; 13 | //******************************************************************************************** 14 | 15 | const log = (info) => console.log(`\x1B[36m%s\x1B[0m`, info); 16 | const error = (info) => console.log(`\x1B[31m%s\x1B[0m`, info); 17 | const SIYUAN_API_TOKEN = process.env.SIYUAN_API_TOKEN; 18 | 19 | log("read token " + SIYUAN_API_TOKEN) 20 | let POST_HEADER = { 21 | "Content-Type": "application/json", 22 | } 23 | if (SIYUAN_API_TOKEN) { 24 | POST_HEADER["Authorization"] = `Token ${SIYUAN_API_TOKEN}` 25 | } 26 | 27 | async function myfetch(url, options) { 28 | //使用 http 模块,从而兼容那些不支持 fetch 的 nodejs 版本 29 | return new Promise((resolve, reject) => { 30 | let req = http.request(url, options, (res) => { 31 | let data = ''; 32 | res.on('data', (chunk) => { 33 | data += chunk; 34 | }); 35 | res.on('end', () => { 36 | resolve({ 37 | ok: true, 38 | status: res.statusCode, 39 | json: () => JSON.parse(data) 40 | }); 41 | }); 42 | }); 43 | req.on('error', (e) => { 44 | reject(e); 45 | }); 46 | req.end(); 47 | }); 48 | } 49 | 50 | async function getSiYuanDir() { 51 | let url = 'http://127.0.0.1:6806/api/system/getWorkspaces'; 52 | let conf = {}; 53 | try { 54 | let response = await myfetch(url, { 55 | method: 'POST', 56 | headers: POST_HEADER 57 | }); 58 | if (response.ok) { 59 | conf = await response.json(); 60 | } else { 61 | error(`HTTP-Error: ${response.status}`); 62 | return null; 63 | } 64 | } catch (e) { 65 | error("Error:", e); 66 | error("Please make sure SiYuan is running!!!"); 67 | return null; 68 | } 69 | return conf.data; 70 | } 71 | 72 | async function chooseTarget(workspaces) { 73 | let count = workspaces.length; 74 | log(`Got ${count} SiYuan ${count > 1 ? 'workspaces' : 'workspace'}`) 75 | for (let i = 0; i < workspaces.length; i++) { 76 | log(`[${i}] ${workspaces[i].path}`); 77 | } 78 | 79 | if (count == 1) { 80 | return `${workspaces[0].path}/data/plugins`; 81 | } else { 82 | const rl = readline.createInterface({ 83 | input: process.stdin, 84 | output: process.stdout 85 | }); 86 | let index = await new Promise((resolve, reject) => { 87 | rl.question(`Please select a workspace[0-${count-1}]: `, (answer) => { 88 | resolve(answer); 89 | }); 90 | }); 91 | rl.close(); 92 | return `${workspaces[index].path}/data/plugins`; 93 | } 94 | } 95 | 96 | if (targetDir === '') { 97 | log('"targetDir" is empty, try to get SiYuan directory automatically....') 98 | let res = await getSiYuanDir(); 99 | 100 | if (res === null) { 101 | log('Failed! You can set the plugin directory in scripts/make_dev_link.js and try again'); 102 | process.exit(1); 103 | } 104 | 105 | targetDir = await chooseTarget(res); 106 | log(`Got target directory: ${targetDir}`); 107 | } 108 | 109 | //Check 110 | if (!fs.existsSync(targetDir)) { 111 | error(`Failed! plugin directory not exists: "${targetDir}"`); 112 | error(`Please set the plugin directory in scripts/make_dev_link.js`); 113 | process.exit(1); 114 | } 115 | 116 | 117 | //check if plugin.json exists 118 | if (!fs.existsSync('./plugin.json')) { 119 | //change dir to parent 120 | process.chdir('../'); 121 | if (!fs.existsSync('./plugin.json')) { 122 | error('Failed! plugin.json not found'); 123 | process.exit(1); 124 | } 125 | } 126 | 127 | //load plugin.json 128 | const plugin = JSON.parse(fs.readFileSync('./plugin.json', 'utf8')); 129 | const name = plugin?.name; 130 | if (!name || name === '') { 131 | error('Failed! Please set plugin name in plugin.json'); 132 | process.exit(1); 133 | } 134 | 135 | //dev directory 136 | const devDir = `${process.cwd()}/dev`; 137 | //mkdir if not exists 138 | if (!fs.existsSync(devDir)) { 139 | fs.mkdirSync(devDir); 140 | } 141 | 142 | function cmpPath(path1, path2) { 143 | path1 = path1.replace(/\\/g, '/'); 144 | path2 = path2.replace(/\\/g, '/'); 145 | // sepertor at tail 146 | if (path1[path1.length - 1] !== '/') { 147 | path1 += '/'; 148 | } 149 | if (path2[path2.length - 1] !== '/') { 150 | path2 += '/'; 151 | } 152 | return path1 === path2; 153 | } 154 | 155 | const targetPath = `${targetDir}/${name}`; 156 | //如果已经存在,就退出 157 | if (fs.existsSync(targetPath)) { 158 | let isSymbol = fs.lstatSync(targetPath).isSymbolicLink(); 159 | 160 | if (isSymbol) { 161 | let srcPath = fs.readlinkSync(targetPath); 162 | 163 | if (cmpPath(srcPath, devDir)) { 164 | log(`Good! ${targetPath} is already linked to ${devDir}`); 165 | } else { 166 | error(`Error! Already exists symbolic link ${targetPath}\nBut it links to ${srcPath}`); 167 | } 168 | } else { 169 | error(`Failed! ${targetPath} already exists and is not a symbolic link`); 170 | } 171 | 172 | } else { 173 | //创建软链接 174 | fs.symlinkSync(devDir, targetPath, 'junction'); 175 | let devInfo = { 176 | "devDir": null 177 | } 178 | fs.writeFileSync(`${process.cwd()}\\scripts\\devInfo.json`, JSON.stringify(devInfo), 'utf-8'); 179 | log(`Done! Created symlink ${targetPath}`); 180 | } 181 | 182 | -------------------------------------------------------------------------------- /scripts/reset_dev_loc.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import http from 'node:http'; 3 | import readline from 'node:readline'; 4 | 5 | 6 | //************************************ Write you dir here ************************************ 7 | 8 | //Please write the "workspace/data/plugins" directory here 9 | //请在这里填写你的 "workspace/data/plugins" 目录 10 | let targetDir = ''; 11 | //Like this 12 | // let targetDir = `H:\\SiYuanDevSpace\\data\\plugins`; 13 | //******************************************************************************************** 14 | 15 | const log = (info) => console.log(`\x1B[36m%s\x1B[0m`, info); 16 | const error = (info) => console.log(`\x1B[31m%s\x1B[0m`, info); 17 | 18 | 19 | const SIYUAN_API_TOKEN = process.env.SIYUAN_API_TOKEN; 20 | 21 | log("read token " + SIYUAN_API_TOKEN) 22 | let POST_HEADER = { 23 | "Content-Type": "application/json", 24 | } 25 | if (SIYUAN_API_TOKEN) { 26 | POST_HEADER["Authorization"] = `Token ${SIYUAN_API_TOKEN}` 27 | } 28 | 29 | async function myfetch(url, options) { 30 | //使用 http 模块,从而兼容那些不支持 fetch 的 nodejs 版本 31 | return new Promise((resolve, reject) => { 32 | let req = http.request(url, options, (res) => { 33 | let data = ''; 34 | res.on('data', (chunk) => { 35 | data += chunk; 36 | }); 37 | res.on('end', () => { 38 | resolve({ 39 | ok: true, 40 | status: res.statusCode, 41 | json: () => JSON.parse(data) 42 | }); 43 | }); 44 | }); 45 | req.on('error', (e) => { 46 | reject(e); 47 | }); 48 | req.end(); 49 | }); 50 | } 51 | 52 | async function getSiYuanDir() { 53 | let url = 'http://127.0.0.1:6806/api/system/getWorkspaces'; 54 | let conf = {}; 55 | try { 56 | let response = await myfetch(url, { 57 | method: 'POST', 58 | headers: POST_HEADER 59 | }); 60 | if (response.ok) { 61 | conf = await response.json(); 62 | } else { 63 | error(`HTTP-Error: ${response.status}`); 64 | return null; 65 | } 66 | } catch (e) { 67 | error("Error:", e); 68 | error("Please make sure SiYuan is running!!!"); 69 | return null; 70 | } 71 | if (conf && conf.code != 0) { 72 | error("ERROR: " + conf.msg); 73 | } 74 | return conf.data; 75 | } 76 | 77 | async function chooseTarget(workspaces) { 78 | let count = workspaces.length; 79 | log(`Got ${count} SiYuan ${count > 1 ? 'workspaces' : 'workspace'}`) 80 | for (let i = 0; i < workspaces.length; i++) { 81 | log(`[${i}] ${workspaces[i].path}`); 82 | } 83 | 84 | if (count == 1) { 85 | return `${workspaces[0].path}/data/plugins`; 86 | } else { 87 | const rl = readline.createInterface({ 88 | input: process.stdin, 89 | output: process.stdout 90 | }); 91 | let index = await new Promise((resolve, reject) => { 92 | rl.question(`Please select a workspace[0-${count-1}]: `, (answer) => { 93 | resolve(answer); 94 | }); 95 | }); 96 | rl.close(); 97 | return `${workspaces[index].path}/data/plugins`; 98 | } 99 | } 100 | 101 | if (targetDir === '') { 102 | log('"targetDir" is empty, try to get SiYuan directory automatically....') 103 | let res = await getSiYuanDir(); 104 | 105 | if (res === null) { 106 | log('Failed! You can set the plugin directory in scripts/make_dev_link.js and try again'); 107 | process.exit(1); 108 | } 109 | 110 | targetDir = await chooseTarget(res); 111 | log(`Got target directory: ${targetDir}`); 112 | } 113 | 114 | //Check 115 | if (!fs.existsSync(targetDir)) { 116 | error(`Failed! plugin directory not exists: "${targetDir}"`); 117 | error(`Please set the plugin directory in scripts/make_dev_link.js`); 118 | process.exit(1); 119 | } 120 | 121 | 122 | //check if plugin.json exists 123 | if (!fs.existsSync('./plugin.json')) { 124 | //change dir to parent 125 | process.chdir('../'); 126 | if (!fs.existsSync('./plugin.json')) { 127 | error('Failed! plugin.json not found'); 128 | process.exit(1); 129 | } 130 | } 131 | 132 | //load plugin.json 133 | const plugin = JSON.parse(fs.readFileSync('./plugin.json', 'utf8')); 134 | const name = plugin?.name; 135 | if (!name || name === '') { 136 | error('Failed! Please set plugin name in plugin.json'); 137 | process.exit(1); 138 | } 139 | 140 | //dev directory 141 | const devDir = `${process.cwd()}/dev`; 142 | //mkdir if not exists 143 | if (!fs.existsSync(devDir)) { 144 | fs.mkdirSync(devDir); 145 | } 146 | 147 | function cmpPath(path1, path2) { 148 | path1 = path1.replace(/\\/g, '/'); 149 | path2 = path2.replace(/\\/g, '/'); 150 | // sepertor at tail 151 | if (path1[path1.length - 1] !== '/') { 152 | path1 += '/'; 153 | } 154 | if (path2[path2.length - 1] !== '/') { 155 | path2 += '/'; 156 | } 157 | return path1 === path2; 158 | } 159 | 160 | const targetPath = `${targetDir}/${name}`; 161 | //如果已经存在,就退出 162 | if (fs.existsSync(targetPath)) { 163 | let isSymbol = fs.lstatSync(targetPath).isSymbolicLink(); 164 | 165 | if (isSymbol) { 166 | let srcPath = fs.readlinkSync(targetPath); 167 | 168 | if (cmpPath(srcPath, devDir)) { 169 | log(`Good! ${targetPath} is already linked to ${devDir}`); 170 | } else { 171 | error(`Error! Already exists symbolic link ${targetPath}\nBut it links to ${srcPath}`); 172 | } 173 | } else { 174 | error(`Failed! ${targetPath} already exists and is not a symbolic link`); 175 | } 176 | 177 | } else { 178 | //创建软链接 179 | // fs.symlinkSync(devDir, targetPath, 'junction'); 180 | let devInfo = { 181 | "devDir": targetPath 182 | } 183 | fs.writeFileSync(`${process.cwd()}\\scripts\\devInfo.json`, JSON.stringify(devInfo), 'utf-8'); 184 | log(`Done! Changed dev save path ${targetPath}`); 185 | } 186 | 187 | -------------------------------------------------------------------------------- /src/components/settings/setting.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 108 | 109 | -------------------------------------------------------------------------------- /src/components/settings/items/order.vue: -------------------------------------------------------------------------------- 1 | 33 | 151 | -------------------------------------------------------------------------------- /src/manager/settingManager.ts: -------------------------------------------------------------------------------- 1 | import { TabProperty, ConfigProperty, loadAllConfigPropertyFromTabProperty } from "../utils/settings"; 2 | import { createApp, ref, watch } from "vue"; 3 | import { getPluginInstance } from "@/utils/pluginHelper"; 4 | import { debugPush, logPush, warnPush } from "@/logger"; 5 | import { isMobile } from "@/syapi"; 6 | import { isValidStr } from "@/utils/commonCheck"; 7 | import * as siyuan from "siyuan"; 8 | import outdatedSettingVue from "@/components/dialog/outdatedSetting.vue"; 9 | import { generateUUID } from "@/utils/common"; 10 | import { lang } from "@/utils/lang"; 11 | import { setStyle } from "./setStyle"; 12 | import { CONSTANTS } from "@/constants"; 13 | import { changeEditor, IMAGE_EDITOR_KEY } from "./editorHelper"; 14 | 15 | // const pluginInstance = getPluginInstance(); 16 | 17 | let setting: any = ref({}); 18 | interface IPluginSettings { 19 | 20 | }; 21 | let defaultSetting: IPluginSettings = { 22 | "imageEditor": IMAGE_EDITOR_KEY.TUI, 23 | "saveType": "local", 24 | "localEditorPath": "", 25 | "localEditorArgs": "", 26 | "localEditorWaitingDialog": false, 27 | } 28 | 29 | 30 | let tabProperties: Array = [ 31 | 32 | ]; 33 | let updateTimeout: any = null; 34 | 35 | /** 36 | * 设置项初始化 37 | * 应该在语言文件载入完成后调用执行 38 | */ 39 | export function initSettingProperty() { 40 | tabProperties.push( 41 | new TabProperty({ 42 | key: "none", iconKey: "iconSettings", props: [ 43 | new ConfigProperty({ key: "mainTips", type: "TIPS"}), 44 | new ConfigProperty({ key: "imageEditor", type: "SELECT", options: Object.values( IMAGE_EDITOR_KEY) }), 45 | // new ConfigProperty({ key: "saveType", type: "SELECT", options: ["local"] }), 46 | new ConfigProperty({ key: "localEditorPath", type: "PATH" }), 47 | new ConfigProperty({ key: "localEditorPath", type: "TEXT" }), 48 | new ConfigProperty({ key: "localEditorArgs", type: "TEXTAREA" }), 49 | new ConfigProperty({ key: "about", type: "TIPS" }), 50 | ] 51 | }), 52 | ); 53 | } 54 | 55 | export function getTabProperties() { 56 | return tabProperties; 57 | } 58 | 59 | // 发生变动之后,由界面调用这里 60 | export function saveSettings(newSettings: any) { 61 | // 如果有必要,需要判断当前设备,然后选择保存位置 62 | debugPush("界面调起保存设置项", newSettings); 63 | getPluginInstance().saveData(getSettingFileName(), JSON.stringify(newSettings, null, 4)); 64 | }; 65 | 66 | function getSettingFileName() { 67 | const SYSTEM_ID = window.siyuan.config.system.id ?? "main"; 68 | return `settings_${SYSTEM_ID}.json`; 69 | } 70 | 71 | 72 | /** 73 | * 仅用于初始化时载入设置项 74 | * 请不要重复使用 75 | * 这里也进行设置项转移操作 76 | * @returns 77 | */ 78 | export async function loadSettings() { 79 | let loadResult = null; 80 | // 这里从文件载入 81 | loadResult = await getPluginInstance().loadData(getSettingFileName()); 82 | debugPush("文件载入设置", loadResult); 83 | if (loadResult == undefined || loadResult == "") { 84 | let oldSettings = await transferOldSetting(); 85 | debugPush("oldSettings", oldSettings); 86 | if (oldSettings != null) { 87 | debugPush("使用转换后的旧设置", oldSettings); 88 | loadResult = oldSettings; 89 | } else { 90 | loadResult = defaultSetting; 91 | } 92 | } 93 | const currentVersion = 20241219; 94 | if (!loadResult["@version"] || loadResult["@version"] < currentVersion) { 95 | // 旧版本 96 | loadResult["@version"] = currentVersion; 97 | if (siyuan.getAllEditor == null) { 98 | loadResult["immediatelyUpdate"] = false; 99 | } 100 | // 检查数组中指定设置和defaultSetting是否一致 101 | showOutdatedSettingWarnDialog(checkOutdatedSettings(loadResult), defaultSetting); 102 | } 103 | // showOutdatedSettingWarnDialog(checkOutdatedSettings(loadResult), defaultSetting); 104 | // 检查选项类设置项,如果发现不在列表中的,重置为默认 105 | try { 106 | loadResult = checkSettingType(loadResult); 107 | } catch(err) { 108 | logPush("设置项类型检查时发生错误", err); 109 | } 110 | 111 | // 如果有必要,判断设置项是否对当前设备生效 112 | // TODO: 对于Order,switch需要进行检查,防止版本问题导致选项不存在,不存在的用默认值 113 | // TODO: switch旧版需要迁移,另外引出迁移逻辑 114 | setting.value = Object.assign(Object.assign({}, defaultSetting), loadResult); 115 | logPush("载入设置项", setting.value); 116 | // return loadResult; 117 | changeNotify(setting.value); 118 | watch(setting, (newVal) => { 119 | // 延迟更新 120 | if (updateTimeout) { 121 | clearTimeout(updateTimeout); 122 | } 123 | logPush("检查到变化"); 124 | updateTimeout = setTimeout(() => { 125 | // updateSingleSetting(key, newVal); 126 | saveSettings(newVal); 127 | // logPush("保存设置项", newVal); 128 | setStyle(); 129 | changeDebug(newVal); 130 | changeNotify(newVal); 131 | updateTimeout = null; 132 | }, 1000); 133 | }, {deep: true, immediate: false}); 134 | } 135 | 136 | function changeNotify(settings) { 137 | changeEditor(settings.imageEditor); 138 | } 139 | 140 | /** 141 | * 检查当前载入设置项中是否存在过时的 142 | * @param loadSetting 当前载入的实际设置项 143 | * @returns 过时的设置项key 144 | */ 145 | function checkOutdatedSettings(loadSetting) { 146 | // 这里配置哪些设置项key是过时的 147 | const CHECK_SETTING_KEYS = [ 148 | ]; 149 | let result = []; 150 | for (let key of CHECK_SETTING_KEYS) { 151 | if (loadSetting[key] != defaultSetting[key]) { 152 | result.push(key); 153 | } 154 | } 155 | return result; 156 | } 157 | 158 | function showOutdatedSettingWarnDialog(outdatedSettingKeys, defaultSettings) { 159 | if (outdatedSettingKeys.length == 0) { 160 | return; 161 | } 162 | const app = createApp(outdatedSettingVue, {"outdatedKeys": outdatedSettingKeys, "defaultSettings": defaultSettings}); 163 | const uid = generateUUID(); 164 | const settingDialog = new siyuan.Dialog({ 165 | "title": lang("dialog_panel_plugin_name") + lang("dialog_panel_outdate"), 166 | "content": ` 167 |
168 | `, 169 | "width": isMobile() ? "42vw":"520px", 170 | "height": isMobile() ? "auto":"auto", 171 | "destroyCallback": ()=>{app.unmount();}, 172 | }); 173 | app.mount(`#og_plugintemplate_${uid}`); 174 | return; 175 | } 176 | 177 | function changeDebug(newVal) { 178 | if (newVal["debugMode"] === true) { 179 | debugPush("调试模式已开启"); 180 | window.top["OpaqueGlassDebug"] = true; 181 | if (!window.top["OpaqueGlassDebugV2"]) { 182 | window.top["OpaqueGlassDebugV2"] = {}; 183 | } 184 | window.top["OpaqueGlassDebugV2"][CONSTANTS.PLUGIN_SHORT_NAME] = 5; 185 | } else if (newVal["debugMode"] === true) { 186 | debugPush("调试模式已关闭"); 187 | if (window.top["OpaqueGlassDebugV2"] && window.top["OpaqueGlassDebugV2"][CONSTANTS.PLUGIN_SHORT_NAME]) { 188 | delete window.top["OpaqueGlassDebugV2"][CONSTANTS.PLUGIN_SHORT_NAME]; 189 | } 190 | } 191 | } 192 | function checkSettingType(input:any) { 193 | const propertyMap = loadAllConfigPropertyFromTabProperty(tabProperties) 194 | for (const prop of Object.values(propertyMap)) { 195 | if (prop.type == "SELECT") { 196 | if (!prop.options.includes(input[prop.key])) { 197 | input[prop.key] = defaultSetting[prop.key]; 198 | } 199 | // input[prop.key] = String(input[prop.key]); 200 | } else if (prop.type == "ORDER") { 201 | // 这里是删除无效的设置项,实际上有可以优化的点 202 | // const newOrder = []; 203 | // for (const item of input[prop.key]) { 204 | // if (Object.values(PRINTER_NAME).includes(item)) { 205 | // newOrder.push(item); 206 | // } 207 | // } 208 | // input[prop.key] = newOrder; 209 | } else if (prop.type == "SWITCH") { 210 | if (input[prop.key] == undefined) { 211 | input[prop.key] = defaultSetting[prop.key]; 212 | } 213 | } else if (prop.type == "NUMBER") { 214 | if (isValidStr(input[prop.key])) { 215 | input[prop.key] = parseFloat(input[prop.key]); 216 | } 217 | } 218 | } 219 | return input; 220 | } 221 | 222 | /** 223 | * 迁移、转换、覆盖设置项 224 | * @returns 修改后的新设置项 225 | */ 226 | async function transferOldSetting() { 227 | const oldSettings = await getPluginInstance().loadData("settings.json"); 228 | // 判断并迁移设置项 229 | let newSetting = Object.assign({}, oldSettings); 230 | if (oldSettings == null || oldSettings == "") { 231 | return null; 232 | } 233 | 234 | /* 这里是转换代码 */ 235 | 236 | // 移除过时的设置项 237 | for (let key of Object.keys(newSetting)) { 238 | if (!(key in defaultSetting)) { 239 | delete newSetting[key]; 240 | } 241 | } 242 | newSetting = Object.assign(Object.assign({}, defaultSetting), newSetting); 243 | 244 | return newSetting; 245 | } 246 | 247 | export function getGSettings() { 248 | // logPush("getConfig", setting.value, setting); 249 | // 改成 setting._rawValue不行 250 | return setting; 251 | } 252 | 253 | export function getReadOnlyGSettings() { 254 | return setting._rawValue; 255 | } 256 | 257 | export function getDefaultSettings() { 258 | return defaultSetting; 259 | } 260 | 261 | export function updateSingleSetting(key: string, value: any) { 262 | // 对照检查setting的类型 263 | // 直接绑定@change的话,value部分可能传回event 264 | // 如果700毫秒内没用重复调用,则执行保存 265 | 266 | } 267 | 268 | -------------------------------------------------------------------------------- /src/syapi/custom.ts: -------------------------------------------------------------------------------- 1 | import * as siyuanAPIs from "siyuan"; 2 | import { debugPush, logPush } from "@/logger"; 3 | import { queryAPI, listDocsByPathT, getTreeStat, getCurrentDocIdF, listDocTree, getDocInfo, getRiffDecks } from "."; 4 | import { isValidStr } from "@/utils/commonCheck"; 5 | 6 | 7 | /** 8 | * 统计子文档字符数 9 | * @param {*} childDocs 10 | * @returns 11 | */ 12 | export async function getChildDocumentsWordCount(docId:string) { 13 | const sqlResult = await queryAPI(` 14 | SELECT SUM(length) AS count 15 | FROM blocks 16 | WHERE 17 | path like "%/${docId}/%" 18 | AND 19 | type in ("p", "h", "c", "t") 20 | `); 21 | if (sqlResult[0].count) { 22 | return sqlResult[0].count; 23 | } 24 | return 0; 25 | // let totalWords = 0; 26 | // let docCount = 0; 27 | // for (let childDoc of childDocs) { 28 | // let tempWordsResult = await getTreeStat(childDoc.id); 29 | // totalWords += tempWordsResult.wordCount; 30 | // childDoc["wordCount"] = tempWordsResult.wordCount; 31 | // docCount++; 32 | // if (docCount > 128) { 33 | // totalWords = `${totalWords}+`; 34 | // break; 35 | // } 36 | // } 37 | // return [childDocs, totalWords]; 38 | } 39 | 40 | export async function getChildDocuments(sqlResult:SqlResult, maxListCount: number): Promise { 41 | let childDocs = await listDocsByPathT({path: sqlResult.path, notebook: sqlResult.box, maxListCount: maxListCount}); 42 | return childDocs; 43 | } 44 | 45 | export async function getChildDocumentIds(sqlResult:SqlResult, maxListCount: number): Promise { 46 | let childDocs = await listDocsByPathT({path: sqlResult.path, notebook: sqlResult.box, maxListCount: maxListCount}); 47 | return childDocs.map(item=>item.id); 48 | } 49 | 50 | export async function isChildDocExist(id: string) { 51 | const sqlResponse = await queryAPI(` 52 | SELECT * FROM blocks WHERE path like '%${id}/%' LIMIT 3 53 | `); 54 | if (sqlResponse && sqlResponse.length > 0) { 55 | return true; 56 | } 57 | return false; 58 | } 59 | 60 | export async function isDocHasAv(docId: string) { 61 | let sqlResult = await queryAPI(` 62 | SELECT count(*) as avcount FROM blocks WHERE root_id = '${docId}' 63 | AND type = 'av' 64 | `); 65 | if (sqlResult.length > 0 && sqlResult[0].avcount > 0) { 66 | return true; 67 | } else { 68 | 69 | return false; 70 | } 71 | } 72 | 73 | export async function isDocEmpty(docId: string, blockCountThreshold = 0) { 74 | // 检查父文档是否为空 75 | let treeStat = await getTreeStat(docId); 76 | if (blockCountThreshold == 0 && treeStat.wordCount != 0 && treeStat.imageCount != 0) { 77 | debugPush("treeStat判定文档非空,不插入挂件"); 78 | return false; 79 | } 80 | if (blockCountThreshold != 0) { 81 | let blockCountSqlResult = await queryAPI(`SELECT count(*) as bcount FROM blocks WHERE root_id like '${docId}' AND type in ('p', 'c', 'iframe', 'html', 'video', 'audio', 'widget', 'query_embed', 't')`); 82 | if (blockCountSqlResult.length > 0) { 83 | if (blockCountSqlResult[0].bcount > blockCountThreshold) { 84 | return false; 85 | } else { 86 | return true; 87 | } 88 | } 89 | } 90 | 91 | let sqlResult = await queryAPI(`SELECT markdown FROM blocks WHERE 92 | root_id like '${docId}' 93 | AND type != 'd' 94 | AND (type != 'p' 95 | OR (type = 'p' AND length != 0) 96 | ) 97 | LIMIT 5`); 98 | if (sqlResult.length <= 0) { 99 | return true; 100 | } else { 101 | debugPush("sql判定文档非空,不插入挂件"); 102 | return false; 103 | } 104 | } 105 | 106 | export function getActiveDocProtyle() { 107 | const allProtyle = {}; 108 | window.siyuan.layout.centerLayout?.children?.forEach((wndItem) => { 109 | wndItem?.children?.forEach((tabItem) => { 110 | if (tabItem?.model) { 111 | allProtyle[tabItem?.id](tabItem.model?.editor?.protyle); 112 | } 113 | }); 114 | }); 115 | } 116 | 117 | export function getActiveEditorIds() { 118 | let result = []; 119 | let id = window.document.querySelector(`.layout__wnd--active [data-type="tab-header"].item--focus`)?.getAttribute("data-id"); 120 | if (id) return [id]; 121 | window.document.querySelectorAll(`[data-type="tab-header"].item--focus`).forEach(item=>{ 122 | let uid = item.getAttribute("data-id"); 123 | if (uid) result.push(uid); 124 | }); 125 | return result; 126 | } 127 | 128 | 129 | 130 | /** 131 | * 获取当前更新时间字符串 132 | * @returns 133 | */ 134 | export function getUpdateString(){ 135 | let nowDate = new Date(); 136 | let hours = nowDate.getHours(); 137 | let minutes = nowDate.getMinutes(); 138 | let seconds = nowDate.getSeconds(); 139 | hours = formatTime(hours); 140 | minutes = formatTime(minutes); 141 | seconds = formatTime(seconds); 142 | let timeStr = nowDate.toJSON().replace(new RegExp("-", "g"),"").substring(0, 8) + hours + minutes + seconds; 143 | return timeStr; 144 | function formatTime(num) { 145 | return num < 10 ? '0' + num : num; 146 | } 147 | } 148 | 149 | /** 150 | * 生成一个随机的块id 151 | * @returns 152 | */ 153 | export function generateBlockId(){ 154 | // @ts-ignore 155 | if (window?.Lute?.NewNodeID) { 156 | // @ts-ignore 157 | return window.Lute.NewNodeID(); 158 | } 159 | let timeStr = getUpdateString(); 160 | let alphabet = new Array(); 161 | for (let i = 48; i <= 57; i++) alphabet.push(String.fromCharCode(i)); 162 | for (let i = 97; i <= 122; i++) alphabet.push(String.fromCharCode(i)); 163 | let randomStr = ""; 164 | for (let i = 0; i < 7; i++){ 165 | randomStr += alphabet[Math.floor(Math.random() * alphabet.length)]; 166 | } 167 | let result = timeStr + "-" + randomStr; 168 | return result; 169 | } 170 | 171 | /** 172 | * 转换块属性对象为{: }格式IAL字符串 173 | * @param {*} attrData 其属性值应当为String类型 174 | * @returns 175 | */ 176 | export function transfromAttrToIAL(attrData) { 177 | let result = "{:"; 178 | for (let key in attrData) { 179 | result += ` ${key}=\"${attrData[key]}\"`; 180 | } 181 | result += "}"; 182 | if (result == "{:}") return null; 183 | return result; 184 | } 185 | 186 | 187 | export function removeCurrentTabF(docId?:string) { 188 | // 获取tabId 189 | if (!isValidStr(docId)) { 190 | docId = getCurrentDocIdF(true); 191 | } 192 | if (!isValidStr(docId)) { 193 | debugPush("错误的id或多个匹配id"); 194 | return; 195 | } 196 | // v3.1.11或以上 197 | if (siyuanAPIs?.getAllEditor) { 198 | const editor = siyuanAPIs.getAllEditor(); 199 | let protyle = null; 200 | for (let i = 0; i < editor.length; i++) { 201 | if (editor[i].protyle.block.rootID === docId) { 202 | protyle = editor[i].protyle; 203 | break; 204 | } 205 | } 206 | if (protyle) { 207 | if (protyle.model.headElement) { 208 | if (protyle.model.headElement.classList.contains("item--pin")) { 209 | debugPush("Pin页面,不关闭存在页签"); 210 | return; 211 | } 212 | } 213 | //id: string, closeAll = false, animate = true, isSaveLayout = true 214 | debugPush("关闭存在页签", protyle?.model?.parent?.parent, protyle.model?.parent?.id); 215 | protyle?.model?.parent?.parent?.removeTab(protyle.model?.parent?.id, false, false); 216 | } else { 217 | debugPush("没有找到对应的protyle,不关闭存在的页签"); 218 | return; 219 | } 220 | } else { // v3.1.10或以下 221 | return; 222 | } 223 | 224 | } 225 | 226 | export function isValidIdFormat(id: string): boolean { 227 | const idRegex = /^\d{14}-[a-zA-Z0-9]{7}$/; 228 | return idRegex.test(id); 229 | } 230 | 231 | export function checkIdValid(id: string): void { 232 | if (!isValidIdFormat(id)) { 233 | throw new Error("The `id` format is incorrect, please check if it is a valid `id`."); 234 | } 235 | } 236 | 237 | 238 | export async function isADocId(id:string): Promise { 239 | const queryResponse = await queryAPI(`SELECT type FROM blocks WHERE id = '${id}'`); 240 | if (queryResponse == null || queryResponse.length == 0) { 241 | return false; 242 | } 243 | if (queryResponse[0].type == "d") { 244 | return true; 245 | } 246 | return false; 247 | } 248 | 249 | export async function getDocDBitem(id:string) { 250 | const queryResponse = await queryAPI(`SELECT * FROM blocks WHERE id = '${id}' and type = 'd'`); 251 | if (queryResponse == null || queryResponse.length == 0) { 252 | return null; 253 | } 254 | return queryResponse[0]; 255 | } 256 | /** 257 | * 通过id获取数据库中的id 258 | * @param id 块id或文档id 259 | * @returns DB item 260 | */ 261 | export async function getBlockDBItem(id:string) { 262 | const queryResponse = await queryAPI(`SELECT * FROM blocks WHERE id = '${id}'`); 263 | if (queryResponse == null || queryResponse.length == 0) { 264 | return null; 265 | } 266 | return queryResponse[0]; 267 | } 268 | 269 | export interface IAssetsDBItem { 270 | /** 引用 ID,资源自身的唯一标识 */ 271 | id: string; 272 | /** 所属块的 ID,表示该资源挂载在哪个块上 */ 273 | block_id: string; 274 | /** 所属文档的 ID */ 275 | root_id: string; 276 | /** 所属笔记本(Box)的 ID */ 277 | box: string; 278 | /** 所属文档的路径,比如 `/20200812220555-lj3enxa/20200915214115-42b8zma.sy` */ 279 | docpath: string; 280 | /** 资源文件的相对路径,比如 `assets/siyuan-128-20210604092205-djd749a.png` */ 281 | path: string; 282 | /** 资源文件名,比如 `siyuan-128-20210604092205-djd749a.png` */ 283 | name: string; 284 | /** 资源的标题,比如 `源于思考,饮水思源`,可以为空 */ 285 | title: string; 286 | /** 资源文件的 SHA256 哈希,用于校验或去重 */ 287 | hash: string; 288 | } 289 | 290 | 291 | /** 292 | * 获取附件信息 293 | * @param id 块id 294 | * @returns 数组列表 295 | */ 296 | export async function getBlockAssets(id:string): Promise { 297 | const queryResponse = await queryAPI(`SELECT * FROM assets WHERE block_id = '${id}'`); 298 | if (queryResponse == null || queryResponse.length == 0) { 299 | return []; 300 | } 301 | return queryResponse; 302 | } 303 | 304 | /** 305 | * 递归地获取所有下层级文档的id 306 | * @param id 文档id 307 | * @returns 所有下层级文档的id 308 | */ 309 | export async function getSubDocIds(id:string) { 310 | // 添加idx? 311 | const docInfo = await getDocDBitem(id); 312 | const treeList = await listDocTree(docInfo["box"], docInfo["path"].replace(".sy", "")); 313 | const subIdsSet = new Set(); 314 | function addToSet(obj) { 315 | if (obj instanceof Array) { 316 | obj.forEach(item=>addToSet(item)); 317 | return; 318 | } 319 | if (obj == null) { 320 | return; 321 | } 322 | if (isValidStr(obj["id"])) { 323 | subIdsSet.add(obj["id"]); 324 | } 325 | if (obj["children"] != undefined ) { 326 | for (let item of obj["children"]) { 327 | addToSet(item); 328 | } 329 | } 330 | } 331 | addToSet(treeList); 332 | logPush("subIdsSet", subIdsSet, treeList); 333 | return Array.from(subIdsSet); 334 | } 335 | 336 | export const QUICK_DECK_ID = "20230218211946-2kw8jgx"; 337 | 338 | export async function isValidDeck(deckId) { 339 | if (deckId === QUICK_DECK_ID) return true; 340 | const deckResponse = await getRiffDecks(); 341 | return !!deckResponse.find(item => item.id == deckId); 342 | } -------------------------------------------------------------------------------- /src/editor/filerbotEditor.ts: -------------------------------------------------------------------------------- 1 | import { errorPush, logPush } from "@/logger"; 2 | import { fibLangZhCN, filerbotDarkTheme } from "@/manager/editorLang"; 3 | import { saveImageDistributor } from "@/manager/imageStorageHelper"; 4 | import { isDarkMode, isMobile } from "@/syapi"; 5 | import { showPluginMessage } from "@/utils/common"; 6 | import { isZHCN, lang } from "@/utils/lang"; 7 | import { Dialog, Protyle } from "siyuan"; 8 | import BaseImageEditor from "./baseImageEditor"; 9 | import { refreshImg } from "@/manager/editorHelper"; 10 | 11 | export class FilerbotEditor extends BaseImageEditor { 12 | private filerobotImageEditor: any = null; 13 | private editorContainer: HTMLDivElement | null = null; 14 | private mask: HTMLDivElement | null = null; 15 | private unsavedModify: boolean = false; 16 | private observer: MutationObserver | null = null; 17 | private isClosingNormally: boolean = false; 18 | 19 | private getToken() { 20 | return localStorage.getItem('token') || ''; 21 | } 22 | 23 | public async init() { 24 | const script = document.createElement('script'); 25 | script.src = '/plugins/syplugin-imageEditor/static/filerobot-image-editor.4.9.1.min.js'; 26 | script.async = true; 27 | document.head.appendChild(script); 28 | const ourFloatView = document.createElement('div'); 29 | ourFloatView.id = 'og-image-editor-float-view'; 30 | if (isMobile()) { 31 | ourFloatView.className = "viewer-container"; 32 | } 33 | ourFloatView.style.zIndex = "10"; 34 | ourFloatView.style.display = "none"; 35 | ourFloatView.style.position = "fixed"; 36 | ourFloatView.style.width = isMobile() ? "100vw" : "80vw"; 37 | ourFloatView.style.height = isMobile() ? "90vh" : "80vh"; 38 | ourFloatView.style.top = "50%"; 39 | ourFloatView.style.left = "50%"; 40 | ourFloatView.style.transform = "translate(-50%, -50%)"; 41 | document.body.appendChild(ourFloatView); 42 | this.setStyle(); 43 | } 44 | 45 | private setStyle() { 46 | const oldstyle = document.getElementById('filerbot-image-editor-style-fix'); 47 | oldstyle?.remove(); 48 | const style = document.createElement('style'); 49 | style.id = 'filerbot-image-editor-style-fix'; 50 | style.innerHTML = 51 | isDarkMode() ? `.irhhtN { 52 | background-color: #1e1e1e !important; 53 | } 54 | .irhhtN:hover{ 55 | background-color: unset !important; 56 | }` : "" + 57 | ` 58 | /* 59 | #og-image-editor-float-view { 60 | display: none; 61 | } 62 | #og-image-editor-float-view:has(> *) { 63 | display: block; 64 | }*/ 65 | `; 66 | const head = document.getElementsByTagName('head')[0]; 67 | head.appendChild(style); 68 | 69 | } 70 | 71 | public async showImageEditor({ source, filePath, element, protyle }: { source: string; filePath: string, element: HTMLElement, protyle: Protyle }) { 72 | this.setStyle(); 73 | this.isClosingNormally = false; // 重置关闭状态 74 | this.editorContainer = document.getElementById('og-image-editor-float-view') as HTMLDivElement; 75 | if (!this.editorContainer) { 76 | this.destroy(); 77 | this.init(); 78 | } 79 | // 创建遮罩层 80 | this.mask = document.getElementById('og-image-editor-mask') as HTMLDivElement; 81 | if (!this.mask) { 82 | this.mask = document.createElement('div'); 83 | this.mask.id = 'og-image-editor-mask'; 84 | this.mask.style.position = 'fixed'; 85 | this.mask.style.top = '0'; 86 | this.mask.style.left = '0'; 87 | this.mask.style.width = '100vw'; 88 | this.mask.style.height = '100vh'; 89 | this.mask.style.background = 'rgba(0,0,0,0.3)'; 90 | this.mask.style.zIndex = '9'; 91 | this.mask.style.display = 'none'; 92 | document.body.appendChild(this.mask); 93 | } 94 | this.mask.style.display = 'block'; 95 | this.editorContainer.style.display = 'block'; 96 | if (this.observer) { 97 | this.observer.disconnect(); 98 | this.observer = null; 99 | } 100 | // 监视编辑器容器的子元素变化 101 | this.observer = new MutationObserver((mutationsList) => { 102 | for (const mutation of mutationsList) { 103 | if (mutation.type === 'childList' && this.editorContainer && this.editorContainer.childElementCount === 0) { 104 | this.handleEditorClose(); 105 | break; 106 | } 107 | } 108 | }); 109 | this.observer.observe(this.editorContainer, { childList: true }); 110 | 111 | // 点击遮罩关闭编辑器 112 | this.mask.addEventListener("mouseup", (event) => { 113 | if (!this.unsavedModify) { 114 | this.closeEditor(); 115 | return; 116 | } 117 | event.stopPropagation(); 118 | event.preventDefault(); 119 | event.stopImmediatePropagation(); 120 | logPush("unsavedModify", this.unsavedModify); 121 | // 创建 Dialog,使用 b3-dialog 样式 122 | const dialogContent = ` 123 |
124 |
125 |
${lang("dialog_leave_without_save_tip")}
126 |
127 |
128 |
129 | 130 |
131 | 132 |
133 |
134 | `; 135 | const dialog = new Dialog({ 136 | title: '⚠️' + lang("dialog_leave_without_save"), 137 | content: dialogContent, 138 | width: '320px', 139 | height: '180px', 140 | disableClose: true, 141 | }); 142 | // 绑定按钮事件 143 | setTimeout(() => { 144 | const saveBtn = document.getElementById('confirmDialogConfirmBtn'); 145 | const cancelBtn = document.getElementById('cancelDialogConfirmBtn'); 146 | if (saveBtn) { 147 | saveBtn.onclick = () => { 148 | dialog.destroy(); 149 | } 150 | } 151 | if (cancelBtn) { 152 | cancelBtn.onclick = () => { 153 | dialog.destroy(); 154 | this.closeEditor(); 155 | }; 156 | } 157 | }, 0); 158 | }, true); 159 | 160 | // 首次加载或已销毁时,初始化编辑器 161 | if (!this.filerobotImageEditor) { 162 | const FilerobotImageEditor = window.FilerobotImageEditor; 163 | const TABS = window.FilerobotImageEditor.TABS; 164 | const TOOLS = window.FilerobotImageEditor.TOOLS; 165 | const config = { 166 | source, 167 | theme: isDarkMode() ? { 168 | "palette": filerbotDarkTheme 169 | } : undefined, 170 | annotationsCommon: { 171 | fill: '#000000', 172 | stroke: '#ff0000', 173 | strokeWidth: 1, 174 | shadowOffsetX: 0, 175 | shadowOffsetY: 0, 176 | shadowBlur: 0, 177 | shadowColor: '#000000', 178 | shadowOpacity: 1, 179 | opacity: 1, 180 | }, 181 | Text: { 182 | fill: '#000000', 183 | stroke: '#ff0000', 184 | strokeWidth: 0, 185 | text: lang("editor_filerbot_text_default") 186 | }, 187 | Rotate: { angle: 90, componentType: 'slider' }, 188 | translations: isZHCN() ? fibLangZhCN : undefined, 189 | Crop: { 190 | presetsItems: [ 191 | { titleKey: 'classicTv', descriptionKey: '4:3', ratio: 4 / 3 }, 192 | { titleKey: 'cinemascope', descriptionKey: '21:9', ratio: 21 / 9 }, 193 | ] 194 | }, 195 | Rect: { 196 | fill: '#00000000', 197 | stroke: '#ff0000', 198 | strokeWidth: 1, 199 | }, 200 | tabsIds: [TABS.ADJUST, TABS.ANNOTATE, TABS.RESIZE, TABS.FILTERS, TABS.FINETUNE], 201 | defaultTabId: TABS.ANNOTATE, 202 | defaultToolId: TOOLS.RECT, 203 | onBeforeSave: (editedImageObject: any) => { 204 | return false; 205 | }, 206 | onModify: (c) => { 207 | logPush("modify", c) 208 | this.unsavedModify = true; 209 | } 210 | }; 211 | this.filerobotImageEditor = new FilerobotImageEditor(this.editorContainer, config); 212 | } else { 213 | this.filerobotImageEditor.config.source = source; 214 | } 215 | this.unsavedModify = false; 216 | this.filerobotImageEditor.render({ 217 | onClose: (closingReason: any) => { 218 | logPush('Closing reason', closingReason); 219 | this.closeEditor(); 220 | }, 221 | onSave: async (editedImageObject: any, designState: any) => { 222 | logPush('保存图片', editedImageObject, designState); 223 | try { 224 | await saveImageDistributor(filePath, editedImageObject.imageBase64); 225 | } catch (e) { 226 | errorPush('图片上传失败', e); 227 | } 228 | refreshImg(element, protyle); 229 | this.unsavedModify = false; 230 | return 0; 231 | }, 232 | }); 233 | } 234 | 235 | private handleEditorClose() { 236 | if (!this.isClosingNormally) { 237 | showPluginMessage(lang("editor_unexpected_exit_tip"), 7000, "error"); 238 | logPush("Filerobot editor closed unexpectedly."); 239 | } 240 | 241 | if (this.mask) { 242 | this.mask.style.display = 'none'; 243 | } 244 | 245 | if (this.editorContainer) { 246 | this.editorContainer.style.display = 'none'; 247 | } 248 | 249 | if (this.observer) { 250 | this.observer.disconnect(); 251 | this.observer = null; 252 | } 253 | } 254 | 255 | private closeEditor() { 256 | this.isClosingNormally = true; 257 | if (this.filerobotImageEditor) { 258 | this.filerobotImageEditor.terminate(); 259 | } 260 | // The MutationObserver will handle the rest 261 | } 262 | 263 | public isAvailable() { 264 | return true; 265 | } 266 | public destroy() { 267 | this.closeEditor(); 268 | // 移除插入的 script 269 | const script = document.querySelector('script[src*="filerobot-image-editor"]'); 270 | if (script && script.parentNode) { 271 | script.parentNode.removeChild(script); 272 | } 273 | // 移除遮罩层 274 | const mask = document.getElementById('og-image-editor-mask'); 275 | if (mask && mask.parentNode) { 276 | mask.parentNode.removeChild(mask); 277 | } 278 | // 移除浮层容器 279 | document.querySelectorAll("#og-image-editor-float-view").forEach((child) => { 280 | child.remove(); 281 | }); 282 | this.filerobotImageEditor = null; 283 | } 284 | } -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- 1 | import { getBackend, IProtyle, openMobileFileById, openTab, showMessage } from "siyuan"; 2 | import { isEventCtrlKey, isValidStr } from "./commonCheck"; 3 | import { debugPush, logPush, warnPush } from "@/logger"; 4 | import { getPluginInstance } from "./pluginHelper"; 5 | import { getCurrentDocIdF, isMobile } from "@/syapi"; 6 | import { removeCurrentTabF } from "@/syapi/custom"; 7 | 8 | export function getToken(): string { 9 | return ""; 10 | } 11 | 12 | export function escapeHTML(str: string): string { 13 | return str.replace(/&/g, "&") 14 | .replace(//g, ">") 16 | .replace(/"/g, """) 17 | .replace(/'/g, "'"); 18 | } 19 | 20 | /** 21 | * 封装的 showMessage API,自动在消息后添加插件名称 22 | * @param message 要显示的消息内容 23 | * @param timeout 显示时长(毫秒),默认 6000 24 | * @param type 消息类型,默认 "info" 25 | */ 26 | export function showPluginMessage(message: string, timeout?: number, type?: "info" | "error"): void { 27 | const prefixedMessage = `${message} —— ${getPluginInstance().name}`; 28 | showMessage(prefixedMessage, timeout, type); 29 | } 30 | 31 | /** 32 | * 在protyle所在的分屏中打开 33 | * @param event 34 | * @param protyleElem 35 | * @deprecated 36 | */ 37 | export function openRefLinkInProtyleWnd(protyleElem: IProtyle, openInFocus: boolean, event: MouseEvent) { 38 | logPush("debug", event, protyleElem); 39 | openRefLink(event, null, null, protyleElem, openInFocus); 40 | } 41 | 42 | /** 43 | * 休息一下,等待 44 | * @param time 单位毫秒 45 | * @returns 46 | */ 47 | export function sleep(time:number){ 48 | return new Promise((resolve) => setTimeout(resolve, time)); 49 | } 50 | 51 | export function getFocusedBlockId() { 52 | const focusedBlock = getFocusedBlock(); 53 | if (focusedBlock == null) { 54 | return null; 55 | } 56 | return focusedBlock.dataset.nodeId; 57 | } 58 | 59 | 60 | export function getFocusedBlock() { 61 | if (document.activeElement.classList.contains('protyle-wysiwyg')) { 62 | /* 光标在编辑区内 */ 63 | let block = window.getSelection()?.focusNode?.parentElement; // 当前光标 64 | while (block != null && block?.dataset?.nodeId == null) block = block.parentElement; 65 | return block; 66 | } 67 | else return null; 68 | } 69 | 70 | /** 71 | * 在点击时打开思源块/文档 72 | * 为引入本项目,和原代码相比有更改 73 | * @refer https://github.com/leolee9086/cc-template/blob/6909dac169e720d3354d77685d6cc705b1ae95be/baselib/src/commonFunctionsForSiyuan.js#L118-L141 74 | * @license 木兰宽松许可证 75 | * @param {MouseEvent} event 当给出event时,将寻找event.currentTarget的data-node-id作为打开的文档id 76 | * @param {string} docId,此项仅在event对应的发起Elem上找不到data node id的情况下使用 77 | * @param {any} keyParam event的Key,主要是ctrlKey shiftKey等,此项仅在event无效时使用 78 | * @param {IProtyle} protyleElem 如果不为空打开文档点击事件将在该Elem上发起 79 | * @param {boolean} openInFocus 在当前聚焦的窗口中打开,给定此项为true,则优于protyle选项生效 80 | * @deprecated 请使用openRefLinkByAPI 81 | */ 82 | export function openRefLink(event: MouseEvent, paramId = "", keyParam = undefined, protyleElem = undefined, openInFocus = false){ 83 | let syMainWndDocument= window.parent.document 84 | let id; 85 | if (event && (event.currentTarget as HTMLElement)?.getAttribute("data-node-id")) { 86 | id = (event.currentTarget as HTMLElement)?.getAttribute("data-node-id"); 87 | } else if ((event?.currentTarget as HTMLElement)?.getAttribute("data-id")) { 88 | id = (event.currentTarget as HTMLElement)?.getAttribute("data-id"); 89 | } else { 90 | id = paramId; 91 | } 92 | // 处理笔记本等无法跳转的情况 93 | if (!isValidStr(id)) { 94 | debugPush("错误的id", id) 95 | return; 96 | } 97 | event?.preventDefault(); 98 | event?.stopPropagation(); 99 | debugPush("openRefLinkEvent", event); 100 | let simulateLink = syMainWndDocument.createElement("span") 101 | simulateLink.setAttribute("data-type","a") 102 | simulateLink.setAttribute("data-href", "siyuan://blocks/" + id) 103 | simulateLink.style.display = "none";//不显示虚拟链接,防止视觉干扰 104 | let tempTarget = null; 105 | // 如果提供了目标protyle,在其中插入 106 | if (protyleElem && !openInFocus) { 107 | tempTarget = protyleElem.querySelector(".protyle-wysiwyg div[data-node-id] div[contenteditable]") ?? protyleElem; 108 | debugPush("openRefLink使用提供窗口", tempTarget); 109 | } 110 | debugPush("openInFocus?", openInFocus); 111 | if (openInFocus) { 112 | // 先确定Tab 113 | const dataId = syMainWndDocument.querySelector(".layout__wnd--active .layout-tab-bar .item--focus")?.getAttribute("data-id"); 114 | debugPush("openRefLink尝试使用聚焦窗口", dataId); 115 | // 再确定Protyle 116 | if (isValidStr(dataId)) { 117 | tempTarget = window.document.querySelector(`.fn__flex-1.protyle[data-id='${dataId}'] 118 | .protyle-wysiwyg div[data-node-id] div[contenteditable]`); 119 | debugPush("openRefLink使用聚焦窗口", tempTarget); 120 | } 121 | } 122 | if (!isValidStr(tempTarget)) { 123 | tempTarget = syMainWndDocument.querySelector(".protyle-wysiwyg div[data-node-id] div[contenteditable]"); 124 | debugPush("openRefLink未能找到指定窗口,更改为原状态"); 125 | } 126 | tempTarget.appendChild(simulateLink); 127 | let clickEvent = new MouseEvent("click", { 128 | ctrlKey: event?.ctrlKey ?? keyParam?.ctrlKey, 129 | shiftKey: event?.shiftKey ?? keyParam?.shiftKey, 130 | altKey: event?.altKey ?? keyParam?.altKey, 131 | metaKey: event?.metaKey ?? keyParam?.metaKey, 132 | bubbles: true 133 | }); 134 | // 存在选区时,ref相关点击是不执行的,这里暂存、清除,并稍后恢复 135 | const tempSaveRanges = []; 136 | const selection = window.getSelection(); 137 | for (let i = 0; i < selection.rangeCount; i++) { 138 | tempSaveRanges.push(selection.getRangeAt(i)); 139 | } 140 | window.getSelection()?.removeAllRanges(); 141 | 142 | simulateLink.dispatchEvent(clickEvent); 143 | simulateLink.remove(); 144 | 145 | // // 恢复选区,不确定恢复选区是否会导致其他问题 146 | // if (selection.isCollapsed) { 147 | // tempSaveRanges.forEach(range => selection.addRange(range)); // 恢复选区 148 | // } 149 | } 150 | 151 | let lastClickTime_openRefLinkByAPI = 0; 152 | /** 153 | * 基于API的打开思源块/文档 154 | * @param mouseEvent 鼠标点击事件,如果存在,优先使用 155 | * @param paramDocId 如果没有指定 event,使用此参数作为文档id 156 | * @param keyParam 如果没有event,使用此次数指定ctrlKey后台打开、shiftKey下方打开、altKey右侧打开 157 | * @param openInFocus 是否以聚焦块的方式打开(此参数有变动) 158 | * @param removeCurrentTab 是否移除当前Tab 159 | * @param autoRemoveJudgeMiliseconds 自动判断是否移除当前Tab的时间间隔(0则 不自动判断) 160 | * @returns 161 | */ 162 | export function openRefLinkByAPI({mouseEvent, paramDocId = "", keyParam = {}, openInFocus = undefined, removeCurrentTab = undefined, autoRemoveJudgeMiliseconds = 0}: {mouseEvent?: MouseEvent, paramDocId?: string, keyParam?: any, openInFocus?: boolean, removeCurrentTab?: boolean, autoRemoveJudgeMiliseconds?: number}) { 163 | let docId: string; 164 | if (mouseEvent && (mouseEvent.currentTarget as HTMLElement)?.getAttribute("data-node-id")) { 165 | docId = (mouseEvent.currentTarget as HTMLElement)?.getAttribute("data-node-id"); 166 | } else if ((mouseEvent?.currentTarget as HTMLElement)?.getAttribute("data-id")) { 167 | docId = (mouseEvent.currentTarget as HTMLElement)?.getAttribute("data-id"); 168 | } else { 169 | docId = paramDocId; 170 | } 171 | // 处理笔记本等无法跳转的情况 172 | if (!isValidStr(docId)) { 173 | debugPush("错误的id", docId) 174 | return; 175 | } 176 | // 需要冒泡,否则不能在所在页签打开 177 | // event?.preventDefault(); 178 | // event?.stopPropagation(); 179 | if (isMobile()) { 180 | openMobileFileById(getPluginInstance().app, docId); 181 | return; 182 | } 183 | debugPush("openRefLinkEventAPIF", mouseEvent); 184 | if (mouseEvent) { 185 | keyParam = {}; 186 | keyParam["ctrlKey"] = mouseEvent.ctrlKey; 187 | keyParam["shiftKey"] = mouseEvent.shiftKey; 188 | keyParam["altKey"] = mouseEvent.altKey; 189 | keyParam["metaKey"] = mouseEvent.metaKey; 190 | } 191 | let positionKey = undefined; 192 | if (keyParam["altKey"]) { 193 | positionKey = "right"; 194 | } else if (keyParam["shiftKey"]) { 195 | positionKey = "bottom"; 196 | } 197 | if (autoRemoveJudgeMiliseconds > 0) { 198 | if (Date.now() - lastClickTime_openRefLinkByAPI < autoRemoveJudgeMiliseconds) { 199 | removeCurrentTab = true; 200 | } 201 | lastClickTime_openRefLinkByAPI = Date.now(); 202 | } 203 | // 手动关闭 204 | const needToCloseDocId = getCurrentDocIdF(true); 205 | 206 | const finalParam = { 207 | app: getPluginInstance().app, 208 | doc: { 209 | id: docId, 210 | zoomIn: openInFocus 211 | }, 212 | position: positionKey, 213 | keepCursor: isEventCtrlKey(keyParam) ? true : undefined, 214 | removeCurrentTab: removeCurrentTab, // 目前这个选项的行为是:true,则当前页签打开;false,则根据思源设置:新页签打开 215 | }; 216 | debugPush("打开文档执行参数", finalParam); 217 | openTab(finalParam); 218 | // 后台打开页签不可移除 219 | if (removeCurrentTab && !isEventCtrlKey(keyParam)) { 220 | debugPush("插件自行移除页签"); 221 | removeCurrentTabF(needToCloseDocId); 222 | removeCurrentTab = false; 223 | } 224 | } 225 | 226 | 227 | 228 | export function parseDateString(dateString: string): Date | null { 229 | if (dateString.length !== 14) { 230 | warnPush("Invalid date string length. Expected format: 'YYYYMMDDHHmmss'"); 231 | return null; 232 | } 233 | 234 | const year = parseInt(dateString.slice(0, 4), 10); 235 | const month = parseInt(dateString.slice(4, 6), 10) - 1; // 月份从 0 开始 236 | const day = parseInt(dateString.slice(6, 8), 10); 237 | const hours = parseInt(dateString.slice(8, 10), 10); 238 | const minutes = parseInt(dateString.slice(10, 12), 10); 239 | const seconds = parseInt(dateString.slice(12, 14), 10); 240 | 241 | const date = new Date(year, month, day, hours, minutes, seconds); 242 | 243 | if (isNaN(date.getTime())) { 244 | warnPush("Invalid date components."); 245 | return null; 246 | } 247 | 248 | return date; 249 | } 250 | 251 | export function generateUUID() { 252 | let uuid = ''; 253 | let i = 0; 254 | let random = 0; 255 | 256 | for (i = 0; i < 36; i++) { 257 | if (i === 8 || i === 13 || i === 18 || i === 23) { 258 | uuid += '-'; 259 | } else if (i === 14) { 260 | uuid += '4'; 261 | } else { 262 | random = Math.random() * 16 | 0; 263 | if (i === 19) { 264 | random = (random & 0x3) | 0x8; 265 | } 266 | uuid += (random).toString(16); 267 | } 268 | } 269 | 270 | return uuid; 271 | } 272 | 273 | export function isPluginExist(pluginName: string) { 274 | const plugins = window.siyuan.ws.app.plugins; 275 | return plugins?.some((plugin) => plugin.name === pluginName); 276 | } 277 | 278 | export function isAnyPluginExist(pluginNames: string[]) { 279 | return pluginNames.some(isPluginExist); 280 | } 281 | 282 | export function replaceShortcutString(shortcut:string) { 283 | const backend = getBackend(); 284 | 285 | if (backend !== "darwin") { 286 | return shortcut 287 | .replace(/⌥/g, 'Alt ') // 替换 Option 键 288 | .replace(/⌘/g, 'Ctrl ') // 替换 Command 键 289 | .replace(/⇧/g, 'Shift ') // 替换 Shift 键 290 | .replace(/⇪/g, 'CapsLock ') // 替换 Caps Lock 键 291 | .replace(/⌃/g, 'Ctrl '); // 替换 Control 键 292 | } 293 | 294 | return shortcut; 295 | } 296 | 297 | export async function blobToBase64Object(blob) { 298 | return new Promise((resolve, reject) => { 299 | const reader = new FileReader(); 300 | 301 | reader.onloadend = () => { 302 | const dataUrl = reader.result; // 形如 data:image/png;base64,xxxxxxx 303 | const [meta, base64Data] = dataUrl.split(','); 304 | 305 | const mimeMatch = meta.match(/data:(.*);base64/); 306 | const mimeType = mimeMatch ? mimeMatch[1] : 'application/octet-stream'; 307 | 308 | resolve({ 309 | type: mimeType.split("/")[0], 310 | data: base64Data, 311 | mimeType: mimeType 312 | }); 313 | }; 314 | 315 | reader.onerror = reject; 316 | reader.readAsDataURL(blob); 317 | }); 318 | } 319 | 320 | /** 321 | * 解析HTML字符串,并提取其中所有 NodeParagraph的 data-node-id 322 | * 323 | * @param htmlString HTML字符串 324 | * @returns 数组 325 | */ 326 | export function extractNodeParagraphIds(htmlString: string): string[] { 327 | const parser = new DOMParser(); 328 | const doc = parser.parseFromString(htmlString, 'text/html'); 329 | const paragraphElements = doc.querySelectorAll('[data-type="NodeParagraph"]'); 330 | const ids = Array.from(paragraphElements) 331 | .map(element => element.getAttribute('data-node-id')) 332 | .filter((id): id is string => id !== null); 333 | return ids; 334 | } -------------------------------------------------------------------------------- /src/editor/tuiEditor.ts: -------------------------------------------------------------------------------- 1 | import { logPush } from "@/logger"; 2 | import { refreshImg } from "@/manager/editorHelper"; 3 | import { tuiDark, tuiLang, tuiLight } from "@/manager/editorLang"; 4 | import { saveImageDistributor } from "@/manager/imageStorageHelper"; 5 | import { isDarkMode, isMobile } from "@/syapi"; 6 | import { showPluginMessage } from "@/utils/common"; 7 | import { isZHCN, lang } from "@/utils/lang"; 8 | import { Dialog, Protyle } from "siyuan"; 9 | import ImageEditor from "tui-image-editor"; 10 | import "tui-image-editor/dist/tui-image-editor.css"; 11 | 12 | export default class TuiEditor { 13 | private imageEditor: any = null; 14 | private editorContainer: HTMLDivElement | null = null; 15 | private mask: HTMLDivElement | null = null; 16 | private unsavedModify: boolean = false; 17 | public async init() { 18 | // const script = document.createElement('script'); 19 | // script.src = '/plugins/syplugin-imageEditor/static/tui-image-editor.css'; 20 | // script.async = true; 21 | // document.head.appendChild(script); 22 | const stylePicker = document.createElement("link"); 23 | stylePicker.href = "/plugins/syplugin-imageEditor/static/tui-color-picker.css"; 24 | stylePicker.rel = "stylesheet"; 25 | document.head.appendChild(stylePicker); 26 | const style = document.createElement('style'); 27 | style.id = 'tui-image-editor-style-fix'; 28 | style.innerHTML = ` 29 | svg[display="none"] { 30 | display: none !important; 31 | } 32 | .tui-image-editor-header-buttons { 33 | display: none !important; 34 | } 35 | `; 36 | const head = document.getElementsByTagName('head')[0]; 37 | head.appendChild(style); 38 | 39 | const ourFloatView = document.createElement('div'); 40 | ourFloatView.id = 'og-image-editor-float-view'; 41 | ourFloatView.style.zIndex = "10"; 42 | ourFloatView.style.display = "none"; 43 | ourFloatView.style.position = "fixed"; 44 | ourFloatView.style.width = "80vw"; 45 | ourFloatView.style.height = "90vh"; 46 | ourFloatView.style.top = "50%"; 47 | ourFloatView.style.left = "50%"; 48 | ourFloatView.style.transform = "translate(-50%, -50%)"; 49 | document.body.appendChild(ourFloatView); 50 | } 51 | 52 | private closeEditor() { 53 | this.editorContainer!.style.display = 'none'; 54 | this.mask!.style.display = 'none'; 55 | if (this.imageEditor) { 56 | this.imageEditor.destroy(); 57 | this.imageEditor = null; 58 | } 59 | let editorDiv = document.getElementById('tui-image-editor-container') as HTMLDivElement; 60 | if (editorDiv) editorDiv.innerHTML = ''; 61 | } 62 | 63 | public async showImageEditor({ source, filePath, element, protyle }: { source: string; filePath: string, element: HTMLElement, protyle: Protyle }) { 64 | // 获取/创建浮层容器 65 | // 获取/创建浮层容器 66 | this.editorContainer = document.getElementById('og-image-editor-float-view') as HTMLDivElement; 67 | if (!this.editorContainer) { 68 | this.destroy(); 69 | this.init(); 70 | } 71 | // 创建遮罩层 72 | this.mask = document.getElementById('og-image-editor-mask') as HTMLDivElement; 73 | // 创建遮罩层 74 | this.mask = document.getElementById('og-image-editor-mask') as HTMLDivElement; 75 | if (!this.mask) { 76 | this.mask = document.createElement('div'); 77 | this.mask.id = 'og-image-editor-mask'; 78 | this.mask.style.position = 'fixed'; 79 | this.mask.style.top = '0'; 80 | this.mask.style.left = '0'; 81 | this.mask.style.width = '100vw'; 82 | this.mask.style.height = '100vh'; 83 | this.mask.style.background = 'rgba(0,0,0,0.3)'; 84 | this.mask.style.zIndex = '9'; 85 | this.mask.style.display = 'none'; 86 | document.body.appendChild(this.mask); 87 | } 88 | this.mask.style.display = 'block'; 89 | this.editorContainer.style.display = 'block'; 90 | 91 | // 创建编辑器挂载点 92 | let editorDiv = document.getElementById('tui-image-editor-container') as HTMLDivElement; 93 | if (!editorDiv) { 94 | editorDiv = document.createElement('div'); 95 | editorDiv.id = 'tui-image-editor-container'; 96 | editorDiv.style.width = '100%'; 97 | editorDiv.style.height = '100%'; 98 | this.editorContainer.appendChild(editorDiv); 99 | this.editorContainer.appendChild(editorDiv); 100 | } else { 101 | // 清空内容 102 | // 清空内容 103 | editorDiv.innerHTML = ''; 104 | } 105 | 106 | // 点击遮罩关闭编辑器 107 | // 点击遮罩关闭编辑器 108 | this.mask.onclick = () => { 109 | if (!this.unsavedModify) { 110 | this.closeEditor(); 111 | return; 112 | } 113 | logPush("unsavedModify", this.unsavedModify); 114 | // 创建 Dialog,使用 b3-dialog 样式 115 | const dialogContent = ` 116 |
117 |
118 |
${lang("dialog_leave_without_save_tip_dog")}
119 |
120 |
121 | 122 |
123 | 124 |
125 | 126 |
127 |
128 | `; 129 | const dialog = new Dialog({ 130 | title: '⚠️' + lang("dialog_leave_without_save"), 131 | content: dialogContent, 132 | width: '480px', 133 | height: '180px', 134 | disableClose: true, 135 | }); 136 | // 绑定按钮事件 137 | setTimeout(() => { 138 | const saveBtn = document.getElementById('confirmDialogConfirmBtn'); 139 | const cancelBtn = document.getElementById('cancelDialogConfirmBtn'); 140 | const returnBtn = document.getElementById('returnDialogConfirmBtn'); 141 | if (saveBtn) { 142 | saveBtn.onclick = async () => { 143 | try { 144 | const base64 = this.imageEditor.toDataURL(); 145 | await saveImageDistributor(filePath, base64); 146 | refreshImg(element, protyle); 147 | } catch (e) { 148 | showPluginMessage(lang("save_failed") + e); 149 | } 150 | this.closeEditor(); 151 | dialog.destroy(); 152 | }; 153 | } 154 | if (cancelBtn) { 155 | cancelBtn.onclick = () => { 156 | dialog.destroy(); 157 | this.closeEditor(); 158 | }; 159 | } 160 | if (returnBtn) { 161 | returnBtn.onclick = () => { 162 | dialog.destroy(); 163 | }; 164 | } 165 | }, 0); 166 | 167 | } 168 | const options = { 169 | includeUI: { 170 | loadImage: { 171 | path: source, 172 | name: 'image', 173 | }, 174 | theme: {}, 175 | // theme: isDarkMode() ? tuiDark : tuiLight, 176 | menu: ['crop', 'flip', 'rotate', 'draw', 'shape', 'icon', 'text', 'mask', 'filter'], 177 | initMenu: 'shape', 178 | uiSize: { 179 | width: '100%', 180 | height: '100%', 181 | }, 182 | menuBarPosition: 'bottom', 183 | locale: isZHCN() ? tuiLang: undefined 184 | }, 185 | cssMaxWidth: isMobile()? document.documentElement.clientWidth : 700, 186 | cssMaxHeight: isMobile()? document.documentElement.clientHeight : 500, 187 | selectionStyle: { 188 | cornerSize: 20, 189 | rotatingPointOffset: 70, 190 | }, 191 | usageStatistics: false, 192 | }; 193 | 194 | // @ts-ignore 195 | this.imageEditor = new ImageEditor('#tui-image-editor-container', options); 196 | this.unsavedModify = false; 197 | this.imageEditor.on('objectAdded', (props) => { 198 | logPush('objectAdded', props); 199 | this.unsavedModify = true; 200 | }); 201 | this.imageEditor.on('objectMoved', (props) => { 202 | logPush('objectMoved', props); 203 | this.unsavedModify = true; 204 | }); 205 | this.imageEditor.on('objectScaled', (props) => { 206 | logPush('objectScaled', props); 207 | this.unsavedModify = true; 208 | }); 209 | this.imageEditor.on('objectRotated', (props) => { 210 | logPush('objectRotated', props); 211 | this.unsavedModify = true; 212 | }); 213 | this.imageEditor.on('textEditing', () => { 214 | logPush('textEditing'); 215 | this.unsavedModify = true; 216 | }); 217 | const that = this; 218 | setTimeout(()=>{ 219 | that.imageEditor?.on('undoStackChanged', (length) => { 220 | logPush('undoStackChanged', length); 221 | if (length > 0) { 222 | that.unsavedModify = true; 223 | } 224 | }); 225 | }, 1000); 226 | 227 | // 添加保存按钮 228 | let saveBtn = document.getElementById('tui-image-editor-save-btn') as HTMLButtonElement; 229 | if (!saveBtn) { 230 | saveBtn = document.createElement('button'); 231 | saveBtn.id = 'tui-image-editor-save-btn'; 232 | saveBtn.innerText = lang("editor_save"); 233 | saveBtn.style.position = 'absolute'; 234 | saveBtn.style.right = '24px'; 235 | saveBtn.style.top = '24px'; 236 | saveBtn.style.zIndex = '20'; 237 | saveBtn.style.padding = '8px 24px'; 238 | saveBtn.style.fontSize = '16px'; 239 | saveBtn.style.background = '#409eff'; 240 | saveBtn.style.color = '#fff'; 241 | saveBtn.style.border = 'none'; 242 | saveBtn.style.borderRadius = '4px'; 243 | saveBtn.style.cursor = 'pointer'; 244 | this.editorContainer.appendChild(saveBtn); 245 | } 246 | saveBtn.onclick = async () => { 247 | try { 248 | const base64 = this.imageEditor.toDataURL(); 249 | await saveImageDistributor(filePath, base64); 250 | // 刷新图片 251 | refreshImg(element, protyle); 252 | this.unsavedModify = false; 253 | showPluginMessage(lang("save_success"), 3000); 254 | } catch (e) { 255 | showPluginMessage(lang("save_failed") + e); 256 | } 257 | }; 258 | // 添加关闭按钮 259 | // let closeBtn = document.getElementById('tui-image-editor-close-btn') as HTMLButtonElement; 260 | // if (!closeBtn) { 261 | // closeBtn = document.createElement('button'); 262 | // closeBtn.id = 'tui-image-editor-close-btn'; 263 | // closeBtn.innerText = lang("editor_close"); 264 | // closeBtn.style.position = 'absolute'; 265 | // closeBtn.style.right = '24px'; 266 | // closeBtn.style.top = '64px'; 267 | // closeBtn.style.zIndex = '20'; 268 | // closeBtn.style.padding = '8px 24px'; 269 | // closeBtn.style.fontSize = '16px'; 270 | // closeBtn.style.background = '#f56c6c'; 271 | // closeBtn.style.color = '#fff'; 272 | // closeBtn.style.border = 'none'; 273 | // closeBtn.style.borderRadius = '4px'; 274 | // closeBtn.style.cursor = 'pointer'; 275 | // this.editorContainer.appendChild(closeBtn); 276 | // } 277 | // closeBtn.onclick = () => { 278 | // this.closeEditor(); 279 | // }; 280 | } 281 | 282 | public isAvailable() { 283 | return true && !isMobile(); 284 | } 285 | 286 | public destroy() { 287 | // 移除遮罩层 288 | const mask = document.getElementById('og-image-editor-mask'); 289 | if (mask && mask.parentNode) { 290 | mask.parentNode.removeChild(mask); 291 | } 292 | // 移除浮层容器 293 | document.querySelectorAll("#og-image-editor-float-view").forEach((child) => { 294 | child.remove(); 295 | }); 296 | this.imageEditor?.destroy(); 297 | this.imageEditor = null; 298 | } 299 | } -------------------------------------------------------------------------------- /src/manager/editorLang.ts: -------------------------------------------------------------------------------- 1 | export const fibLangZhCN = { 2 | name: '名称', 3 | save: '保存', 4 | saveAs: '另存为', 5 | back: '返回', 6 | loading: '加载中...', 7 | resetOperations: '重置/删除所有操作', 8 | changesLoseWarningHint: '如果按下“重置”按钮,您的更改将会丢失。是否继续?', 9 | discardChangesWarningHint: '如果关闭弹窗,您最近的更改将不会被保存。', 10 | cancel: '取消', 11 | apply: '应用', 12 | warning: '警告', 13 | confirm: '确认', 14 | discardChanges: '放弃更改', 15 | undoTitle: '撤销上一步操作', 16 | redoTitle: '重做上一步操作', 17 | showImageTitle: '显示原始图像', 18 | zoomInTitle: '放大', 19 | zoomOutTitle: '缩小', 20 | toggleZoomMenuTitle: '切换缩放菜单', 21 | adjustTab: '调整', 22 | finetuneTab: '精细调节', 23 | filtersTab: '滤镜', 24 | watermarkTab: '水印', 25 | annotateTabLabel: '注释', 26 | resize: '调整大小', 27 | resizeTab: '调整大小', 28 | imageName: '图像名称', 29 | invalidImageError: '提供的图像无效。', 30 | uploadImageError: '上传图像时出错。', 31 | areNotImages: '不是图像', 32 | isNotImage: '不是图像', 33 | toBeUploaded: '待上传', 34 | cropTool: '裁剪', 35 | original: '原始', 36 | custom: '自定义', 37 | square: '正方形', 38 | landscape: '横向', 39 | portrait: '纵向', 40 | ellipse: '椭圆', 41 | classicTv: '经典电视', 42 | cinemascope: '宽银幕', 43 | arrowTool: '箭头', 44 | blurTool: '模糊', 45 | brightnessTool: '亮度', 46 | contrastTool: '对比度', 47 | ellipseTool: '椭圆', 48 | unFlipX: '取消水平翻转', 49 | flipX: '水平翻转', 50 | unFlipY: '取消垂直翻转', 51 | flipY: '垂直翻转', 52 | hsvTool: 'HSV', 53 | hue: '色调', 54 | brightness: '亮度', 55 | saturation: '饱和度', 56 | value: '明度', 57 | imageTool: '图像', 58 | importing: '正在导入...', 59 | addImage: '+ 添加图像', 60 | uploadImage: '上传图像', 61 | fromGallery: '从图库', 62 | lineTool: '直线', 63 | penTool: '画笔', 64 | polygonTool: '多边形', 65 | sides: '边数', 66 | rectangleTool: '矩形', 67 | cornerRadius: '圆角半径', 68 | resizeWidthTitle: '宽度(像素)', 69 | resizeHeightTitle: '高度(像素)', 70 | toggleRatioLockTitle: '切换宽高比锁定', 71 | resetSize: '恢复为原始图像尺寸', 72 | rotateTool: '旋转', 73 | textTool: '文本', 74 | textSpacings: '文字间距', 75 | textAlignment: '文字对齐', 76 | fontFamily: '字体', 77 | size: '大小', 78 | letterSpacing: '字间距', 79 | lineHeight: '行高', 80 | warmthTool: '色温', 81 | addWatermark: '+ 添加水印', 82 | addTextWatermark: '+ 添加文字水印', 83 | addWatermarkTitle: '选择水印类型', 84 | uploadWatermark: '上传水印', 85 | addWatermarkAsText: '作为文本添加', 86 | padding: '内边距', 87 | paddings: '内边距', 88 | shadow: '阴影', 89 | horizontal: '水平', 90 | vertical: '垂直', 91 | blur: '模糊', 92 | opacity: '不透明度', 93 | transparency: '透明度', 94 | position: '位置', 95 | stroke: '描边', 96 | saveAsModalTitle: '另存为', 97 | extension: '扩展名', 98 | format: '格式', 99 | nameIsRequired: '名称是必填项。', 100 | quality: '质量', 101 | imageDimensionsHoverTitle: '保存的图像尺寸(宽 x 高)', 102 | cropSizeLowerThanResizedWarning: '注意,所选裁剪区域小于已应用的调整大小,可能导致质量下降', 103 | actualSize: '实际大小 (100%)', 104 | fitSize: '适应尺寸', 105 | addImageTitle: '选择要添加的图像...', 106 | mutualizedFailedToLoadImg: '加载图像失败。', 107 | tabsMenu: '菜单', 108 | download: '下载', 109 | width: '宽度', 110 | height: '高度', 111 | plus: '+', 112 | cropItemNoEffect: '此裁剪项目无预览可用', 113 | }; 114 | 115 | export const tuiLang = { 116 | "3:2": "3:2", 117 | "4:3": "4:3", 118 | "5:4": "5:4", 119 | "7:5": "7:5", 120 | "16:9": "16:9", 121 | Apply: "应用", 122 | Arrow: "箭头", 123 | "Arrow-2": "箭头-2", 124 | "Arrow-3": "箭头-3", 125 | Blend: "混合", 126 | Blur: "模糊", 127 | Bold: "粗体", 128 | Brightness: "亮度", 129 | Bubble: "气泡", 130 | Cancel: "取消", 131 | Center: "居中", 132 | Circle: "圆形", 133 | Color: "颜色", 134 | "Color Filter": "颜色滤镜", 135 | Crop: "裁剪", 136 | Custom: "自定义", 137 | "Custom icon": "自定义图标", 138 | Delete: "删除", 139 | "Delete-all": "全部删除", 140 | Distance: "距离", 141 | Download: "下载", 142 | Draw: "绘制", 143 | Emboss: "浮雕", 144 | Fill: "填充", 145 | Filter: "滤镜", 146 | Flip: "翻转", 147 | "Flip X": "水平翻转", 148 | "Flip Y": "垂直翻转", 149 | Free: "自由", 150 | Grayscale: "灰度", 151 | Heart: "心形", 152 | Icon: "图标", 153 | Invert: "反色", 154 | Italic: "斜体", 155 | Left: "左对齐", 156 | Load: "加载", 157 | "Load Mask Image": "加载遮罩图像", 158 | Location: "位置", 159 | Mask: "遮罩", 160 | Multiply: "正片叠底", 161 | Noise: "噪点", 162 | Pixelate: "像素化", 163 | Polygon: "多边形", 164 | Range: "范围", 165 | Rectangle: "矩形", 166 | Redo: "重做", 167 | "Remove White": "移除白色", 168 | Reset: "重置", 169 | Right: "右对齐", 170 | Rotate: "旋转", 171 | Sepia: "褐色调", 172 | Sepia2: "褐色调2", 173 | Shape: "形状", 174 | Sharpen: "锐化", 175 | Square: "正方形", 176 | "Star-1": "星形-1", 177 | "Star-2": "星形-2", 178 | Straight: "直线", 179 | Stroke: "描边", 180 | Text: "文本", 181 | "Text size": "文本大小", 182 | Threshold: "阈值", 183 | Tint: "色调", 184 | Triangle: "三角形", 185 | Underline: "下划线", 186 | Undo: "撤销", 187 | Value: "值" 188 | } 189 | 190 | export const tuiLight = { 191 | 'common.bi.image': 'https://uicdn.toast.com/toastui/img/tui-image-editor-bi.png', 192 | 'common.bisize.width': '251px', 193 | 'common.bisize.height': '21px', 194 | 'common.backgroundImage': './img/bg.png', 195 | 'common.backgroundColor': '#fff', 196 | 'common.border': '1px solid #c1c1c1', 197 | 198 | 199 | // header 200 | 'header.backgroundImage': 'none', 201 | 'header.backgroundColor': 'transparent', 202 | 'header.border': '0px', 203 | 204 | // load button 205 | 'loadButton.backgroundColor': '#fff', 206 | 'loadButton.border': '1px solid #ddd', 207 | 'loadButton.color': '#222', 208 | 'loadButton.fontFamily': "'Noto Sans', sans-serif", 209 | 'loadButton.fontSize': '12px', 210 | 211 | // download button 212 | 'downloadButton.backgroundColor': '#fdba3b', 213 | 'downloadButton.border': '1px solid #fdba3b', 214 | 'downloadButton.color': '#fff', 215 | 'downloadButton.fontFamily': "'Noto Sans', sans-serif", 216 | 'downloadButton.fontSize': '12px', 217 | 218 | // main icons 219 | 'menu.normalIcon.color': '#8a8a8a', 220 | 'menu.activeIcon.color': '#555555', 221 | 'menu.disabledIcon.color': '#434343', 222 | 'menu.hoverIcon.color': '#e9e9e9', 223 | 'menu.iconSize.width': '24px', 224 | 'menu.iconSize.height': '24px', 225 | 226 | // submenu icons 227 | 'submenu.normalIcon.color': '#8a8a8a', 228 | 'submenu.activeIcon.color': '#555555', 229 | 'submenu.iconSize.width': '32px', 230 | 'submenu.iconSize.height': '32px', 231 | 232 | // submenu primary color 233 | 'submenu.backgroundColor': 'transparent', 234 | 'submenu.partition.color': '#e5e5e5', 235 | 236 | // submenu labels 237 | 'submenu.normalLabel.color': '#858585', 238 | 'submenu.normalLabel.fontWeight': 'normal', 239 | 'submenu.activeLabel.color': '#000', 240 | 'submenu.activeLabel.fontWeight': 'normal', 241 | 242 | // checkbox style 243 | 'checkbox.border': '1px solid #ccc', 244 | 'checkbox.backgroundColor': '#fff', 245 | 246 | // rango style 247 | 'range.pointer.color': '#333', 248 | 'range.bar.color': '#ccc', 249 | 'range.subbar.color': '#606060', 250 | 251 | 'range.disabledPointer.color': '#d3d3d3', 252 | 'range.disabledBar.color': 'rgba(85,85,85,0.06)', 253 | 'range.disabledSubbar.color': 'rgba(51,51,51,0.2)', 254 | 255 | 'range.value.color': '#000', 256 | 'range.value.fontWeight': 'normal', 257 | 'range.value.fontSize': '11px', 258 | 'range.value.border': '0', 259 | 'range.value.backgroundColor': '#f5f5f5', 260 | 'range.title.color': '#000', 261 | 'range.title.fontWeight': 'lighter', 262 | 263 | // colorpicker style 264 | 'colorpicker.button.border': '0px', 265 | 'colorpicker.title.color': '#000', 266 | } 267 | export const tuiDark = { 268 | 'common.bi.image': 'https://uicdn.toast.com/toastui/img/tui-image-editor-bi.png', 269 | 'common.bisize.width': '251px', 270 | 'common.bisize.height': '21px', 271 | 'common.backgroundImage': 'none', 272 | 'common.backgroundColor': '#1e1e1e', 273 | 'common.border': '0px', 274 | 275 | // header 276 | 'header.backgroundImage': 'none', 277 | 'header.backgroundColor': 'transparent', 278 | 'header.border': '0px', 279 | 280 | // load button 281 | 'loadButton.backgroundColor': '#fff', 282 | 'loadButton.border': '1px solid #ddd', 283 | 'loadButton.color': '#222', 284 | 'loadButton.fontFamily': "'Noto Sans', sans-serif", 285 | 'loadButton.fontSize': '12px', 286 | 287 | // download button 288 | 'downloadButton.backgroundColor': '#fdba3b', 289 | 'downloadButton.border': '1px solid #fdba3b', 290 | 'downloadButton.color': '#fff', 291 | 'downloadButton.fontFamily': "'Noto Sans', sans-serif", 292 | 'downloadButton.fontSize': '12px', 293 | 294 | // main icons 295 | 'menu.normalIcon.color': '#8a8a8a', 296 | 'menu.activeIcon.color': '#555555', 297 | 'menu.disabledIcon.color': '#434343', 298 | 'menu.hoverIcon.color': '#e9e9e9', 299 | 'menu.iconSize.width': '24px', 300 | 'menu.iconSize.height': '24px', 301 | 302 | // submenu icons 303 | 'submenu.normalIcon.color': '#8a8a8a', 304 | 'submenu.activeIcon.color': '#e9e9e9', 305 | 'submenu.iconSize.width': '32px', 306 | 'submenu.iconSize.height': '32px', 307 | 308 | // submenu primary color 309 | 'submenu.backgroundColor': '#1e1e1e', 310 | 'submenu.partition.color': '#3c3c3c', 311 | 312 | // submenu labels 313 | 'submenu.normalLabel.color': '#8a8a8a', 314 | 'submenu.normalLabel.fontWeight': 'lighter', 315 | 'submenu.activeLabel.color': '#fff', 316 | 'submenu.activeLabel.fontWeight': 'lighter', 317 | 318 | // checkbox style 319 | 'checkbox.border': '0px', 320 | 'checkbox.backgroundColor': '#fff', 321 | 322 | // range style 323 | 'range.pointer.color': '#fff', 324 | 'range.bar.color': '#666', 325 | 'range.subbar.color': '#d1d1d1', 326 | 327 | 'range.disabledPointer.color': '#414141', 328 | 'range.disabledBar.color': '#282828', 329 | 'range.disabledSubbar.color': '#414141', 330 | 331 | 'range.value.color': '#fff', 332 | 'range.value.fontWeight': 'lighter', 333 | 'range.value.fontSize': '11px', 334 | 'range.value.border': '1px solid #353535', 335 | 'range.value.backgroundColor': '#151515', 336 | 'range.title.color': '#fff', 337 | 'range.title.fontWeight': 'lighter', 338 | 339 | // colorpicker style 340 | 'colorpicker.button.border': '1px solid #1e1e1e', 341 | 'colorpicker.title.color': '#fff', 342 | 343 | } 344 | 345 | export const filerbotDarkTheme = { 346 | 'txt-primary': '#FFFFFF', 347 | 'txt-secondary': '#CCCCCC', 348 | 'txt-secondary-invert': '#333333', 349 | 'txt-placeholder': '#888888', 350 | 'txt-warning': '#FFCC00', 351 | 'txt-error': '#FF4C4C', 352 | 'txt-info': '#66CCFF', 353 | 354 | 'accent-primary': '#00BFFF', 355 | 'accent-primary-hover': '#33CCFF', 356 | 'accent-primary-active': '#0099CC', 357 | 'accent-primary-disabled': '#555555', 358 | 'accent-secondary-disabled': '#444444', 359 | 'accent-stateless': '#00BFFF', 360 | 'accent-stateless_0_4_opacity': 'rgba(0, 191, 255, 0.4)', 361 | 'accent_0_5_5_opacity': 'rgba(0, 191, 255, 0.55)', 362 | 'accent_0_5_opacity': 'rgba(0, 191, 255, 0.5)', 363 | 'accent_0_7_opacity': 'rgba(0, 191, 255, 0.7)', 364 | 'accent_1_2_opacity': 'rgba(0, 191, 255, 1)', 365 | 'accent_1_8_opacity': 'rgba(0, 191, 255, 1)', 366 | 'accent_2_8_opacity': 'rgba(0, 191, 255, 1)', 367 | 'accent_4_0_opacity': 'rgba(0, 191, 255, 1)', 368 | 369 | 'bg-grey': '#2A2A2A', 370 | 'bg-stateless': '#1E1E1E', 371 | 'bg-active': '#333333', 372 | 'bg-base-light': '#2C2C2C', 373 | 'bg-base-medium': '#1A1A1A', 374 | 'bg-primary': '#121212', 375 | 'bg-primary-light': '#1F1F1F', 376 | 'bg-primary-hover': '#2A2A2A', 377 | 'bg-primary-active': '#3A3A3A', 378 | 'bg-primary-stateless': '#121212', 379 | 'bg-primary-0-5-opacity': 'rgba(18, 18, 18, 0.5)', 380 | 'bg-secondary': '#1A1A1A', 381 | 'bg-hover': '#2E2E2E', 382 | 'bg-green': '#1F3D1F', 383 | 'bg-green-medium': '#2F5F2F', 384 | 'bg-blue': '#1F2F3D', 385 | 'bg-red': '#3D1F1F', 386 | 'bg-red-light': '#5F2F2F', 387 | 'background-red-medium': '#7F3F3F', 388 | 'bg-orange': '#4F2F1F', 389 | 'bg-tooltip': '#333333', 390 | 391 | 'icon-primary': '#FFFFFF', 392 | 'icons-primary-opacity-0-6': 'rgba(255, 255, 255, 0.6)', 393 | 'icons-secondary': '#AAAAAA', 394 | 'icons-placeholder': '#777777', 395 | 'icons-invert': '#000000', 396 | 'icons-muted': '#555555', 397 | 'icons-primary-hover': '#DDDDDD', 398 | 'icons-secondary-hover': '#BBBBBB', 399 | 400 | 'btn-primary-text': '#FFFFFF', 401 | 'btn-primary-text-0-6': 'rgba(255, 255, 255, 0.6)', 402 | 'btn-primary-text-0-4': 'rgba(255, 255, 255, 0.4)', 403 | 'btn-disabled-text': '#666666', 404 | 'btn-secondary-text': '#CCCCCC', 405 | 406 | 'link-primary': '#00BFFF', 407 | 'link-stateless': '#00BFFF', 408 | 'link-hover': '#33CCFF', 409 | 'link-active': '#0099CC', 410 | 'link-muted': '#888888', 411 | 'link-pressed': '#006699', 412 | 413 | 'borders-primary': '#444444', 414 | 'borders-primary-hover': '#666666', 415 | 'borders-secondary': '#333333', 416 | 'borders-strong': '#888888', 417 | 'borders-invert': '#FFFFFF', 418 | 'border-hover-bottom': '#00BFFF', 419 | 'border-active-bottom': '#0099CC', 420 | 'border-primary-stateless': '#444444', 421 | 'borders-disabled': '#555555', 422 | 'borders-button': '#666666', 423 | 'borders-item': '#444444', 424 | 'borders-base-light': '#2C2C2C', 425 | 'borders-base-medium': '#1A1A1A', 426 | 'borders-green': '#2F5F2F', 427 | 'borders-green-medium': '#3F7F3F', 428 | 'borders-red': '#7F3F3F', 429 | 430 | // 其他状态色、阴影、渐变等也可以继续补充 431 | 'active-secondary': '#2E2E2E', 432 | 'active-secondary-hover': '#3A3A3A', 433 | 434 | 'tag': '#444444', 435 | 'states-error-disabled-text': '#993333', 436 | 437 | 'error': '#FF4C4C', 438 | 'error-0-28-opacity': 'rgba(255, 76, 76, 0.28)', 439 | 'error-0-12-opacity': 'rgba(255, 76, 76, 0.12)', 440 | 'error-hover': '#FF6666', 441 | 'error-active': '#CC3333', 442 | 443 | 'success': '#4CAF50', 444 | 'success-hover': '#66BB6A', 445 | 'success-Active': '#388E3C', 446 | 447 | 'warning': '#FFCC00', 448 | 'warning-hover': '#FFDD33', 449 | 'warning-active': '#FFB300', 450 | 451 | 'info': '#66CCFF', 452 | 'modified': '#FF99CC', 453 | 454 | 'red': '#FF4C4C', 455 | 'orange': '#FF9900', 456 | 'salad': '#A8D5BA', 457 | 'green': '#4CAF50', 458 | 'blue': '#2196F3', 459 | 'indigo': '#3F51B5', 460 | 'violet': '#9C27B0', 461 | 'pink': '#E91E63', 462 | 463 | 'gradient-right': 'linear-gradient(to right, #1E1E1E, #2A2A2A)', 464 | 'gradient-right-active': 'linear-gradient(to right, #2A2A2A, #3A3A3A)', 465 | 'gradient-right-hover': 'linear-gradient(to right, #2A2A2A, #4A4A4A)', 466 | 467 | 'extra-0-3-overlay': 'rgba(0, 0, 0, 0.3)', 468 | 'extra-0-5-overlay': 'rgba(0, 0, 0, 0.5)', 469 | 'extra-0-7-overlay': 'rgba(0, 0, 0, 0.7)', 470 | 'extra-0-9-overlay': 'rgba(0, 0, 0, 0.9)', 471 | 472 | 'red-0-1-overlay': 'rgba(255, 76, 76, 0.1)', 473 | 'orange-0-1-overlay': 'rgba(255, 153, 0, 0.1)', 474 | 'accent-0-8-overlay': 'rgba(0, 191, 255, 0.8)', 475 | 'green-0-2-Overlay': 'rgba(76, 175, 80, 0.2)', 476 | 'white-0-7-8-overlay': 'rgba(255, 255, 255, 0.78)', 477 | 478 | 'link': '#00BFFF', 479 | 'camera': '#FFFFFF', 480 | 'google-drive': '#FFFFFF', 481 | 'dropbox': '#FFFFFF', 482 | 'one-drive': '#FFFFFF', 483 | 'device': '#FFFFFF', 484 | 'instagram': '#FFFFFF', 485 | 'free-images': '#FFFFFF', 486 | 'free-icons': '#FFFFFF', 487 | 'canvas': '#FFFFFF', 488 | 'box': '#FFFFFF', 489 | 'screen-cast': '#FFFFFF', 490 | 'unsplash': '#FFFFFF', 491 | 492 | 'light-shadow': '0px 1px 3px rgba(0, 0, 0, 0.2)', 493 | 'medium-shadow': '0px 3px 6px rgba(0, 0, 0, 0.3)', 494 | 'large-shadow': '0px 10px 20px rgba(0, 0, 0, 0.4)', 495 | 'x-large-shadow': '0px 15px 30px rgba(0, 0, 0, 0.5)', 496 | }; 497 | -------------------------------------------------------------------------------- /src/syapi/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * API.js 3 | * 用于发送思源api请求。 4 | */ 5 | import { getToken } from "@/utils/common"; 6 | import { isValidStr } from "@/utils/commonCheck"; 7 | import { warnPush, errorPush, debugPush, logPush } from "@/logger" 8 | /**向思源api发送请求 9 | * @param data 传递的信息(body) 10 | * @param url 请求的地址 11 | */ 12 | export async function postRequest(data: any, url:string){ 13 | let result; 14 | await fetch(url, { 15 | body: JSON.stringify(data), 16 | method: 'POST', 17 | headers: { 18 | // "Authorization": "Token "+ getToken(), 19 | "Content-Type": "application/json" 20 | } 21 | }).then((response) => { 22 | result = response.json(); 23 | }); 24 | return result; 25 | } 26 | 27 | export async function getResponseData(promiseResponse){ 28 | const response = await promiseResponse; 29 | if (response.code != 0 || response.data == null){ 30 | return null; 31 | }else{ 32 | return response.data; 33 | } 34 | } 35 | 36 | /** 37 | * 检查请求是否成功,返回0、-1 38 | * @param {*} response 39 | * @returns 成功为0,失败为-1 40 | */ 41 | export async function checkResponse(response){ 42 | if (response.code == 0){ 43 | return 0; 44 | }else{ 45 | return -1; 46 | } 47 | } 48 | 49 | /**SQL(api) 50 | * @param sqlstmt SQL语句 51 | */ 52 | export async function queryAPI(sqlstmt:string){ 53 | let url = "/api/query/sql"; 54 | let response = await postRequest({stmt: sqlstmt},url); 55 | if (response.code == 0 && response.data != null){ 56 | return response.data; 57 | } 58 | if (response.msg != "") { 59 | throw new Error(`SQL ERROR: ${response.msg}`); 60 | } 61 | 62 | return []; 63 | } 64 | 65 | /**重建索引 66 | * @param docpath 需要重建索引的文档路径 67 | */ 68 | export async function reindexDoc(docpath){ 69 | let url = "/api/filetree/reindexTree"; 70 | await postRequest({path: docpath},url); 71 | return 0; 72 | } 73 | 74 | /**列出子文件(api) 75 | * @param notebookId 笔记本id 76 | * @param path 需要列出子文件的路径 77 | * @param maxListCount 子文档最大显示数量 78 | * @param sort 排序方式(类型号) 79 | */ 80 | export async function listDocsByPathT({notebook, path, maxListCount = undefined, sort = undefined, ignore = true, showHidden = null}){ 81 | let url = "/api/filetree/listDocsByPath"; 82 | let body = { 83 | "notebook": notebook, 84 | "path": path 85 | } 86 | if (maxListCount != undefined && maxListCount >= 0) { 87 | body["maxListCount"] = maxListCount; 88 | } 89 | if (sort != undefined && sort != DOC_SORT_TYPES.FOLLOW_DOC_TREE && sort != DOC_SORT_TYPES.UNASSIGNED) { 90 | body["sort"] = sort; 91 | } 92 | if (ignore != undefined) { 93 | body["ignoreMaxListHint"] = ignore; 94 | } 95 | if (showHidden != null) { 96 | body["showHidden"] = showHidden; 97 | } 98 | let response = await postRequest(body, url); 99 | if (response.code != 0 || response.data == null){ 100 | warnPush("listDocsByPath请求错误", response.msg); 101 | return new Array(); 102 | } 103 | return response.data.files; 104 | } 105 | 106 | /** 107 | * 添加属性(API) 108 | * @param attrs 属性对象 109 | * @param 挂件id 110 | * */ 111 | export async function addblockAttrAPI(attrs, blockid){ 112 | let url = "/api/attr/setBlockAttrs"; 113 | let attr = { 114 | id: blockid, 115 | attrs: attrs 116 | } 117 | let result = await postRequest(attr, url); 118 | return checkResponse(result); 119 | } 120 | 121 | /**获取挂件块参数(API) 122 | * @param blockid 123 | * @return response 请访问result.data获取对应的属性 124 | */ 125 | export async function getblockAttr(blockid){ 126 | let url = "/api/attr/getBlockAttrs"; 127 | let response = await postRequest({id: blockid}, url); 128 | if (response.code != 0){ 129 | throw Error("获取挂件块参数失败"); 130 | } 131 | return response.data; 132 | } 133 | 134 | /** 135 | * 更新块(返回值有删减) 136 | * @param {String} text 更新写入的文本 137 | * @param {String} blockid 更新的块id 138 | * @param {String} textType 文本类型,markdown、dom可选 139 | * @returns 对象,为response.data[0].doOperations[0]的值,返回码为-1时也返回null 140 | */ 141 | export async function updateBlockAPI(text, blockid, textType = "markdown"){ 142 | let url = "/api/block/updateBlock"; 143 | let data = {dataType: textType, data: text, id: blockid}; 144 | let response = await postRequest(data, url); 145 | try{ 146 | if (response.code == 0 && response.data != null && isValidStr(response.data[0].doOperations[0].id)){ 147 | return response.data[0].doOperations[0]; 148 | } 149 | if (response.code == -1){ 150 | warnPush("更新块失败", response.msg); 151 | return null; 152 | } 153 | }catch(err){ 154 | errorPush(err); 155 | warnPush(response.msg); 156 | } 157 | return null; 158 | } 159 | 160 | /** 161 | * 插入块(返回值有删减) 162 | * @param {string} text 文本 163 | * @param {string} blockid 指定的块 164 | * @param {string} textType 插入的文本类型,"markdown" or "dom" 165 | * @param {string} addType 插入到哪里?默认插入为指定块之后,NEXT 为插入到指定块之前, PARENT 为插入为指定块的子块 166 | * @return 对象,为response.data[0].doOperations[0]的值,返回码为-1时也返回null 167 | */ 168 | export async function insertBlockAPI(text, blockid, addType = "previousID", textType = "markdown"){ 169 | let url = "/api/block/insertBlock"; 170 | let data = {dataType: textType, data: text}; 171 | switch (addType) { 172 | case "parentID": 173 | case "PARENT": 174 | case "parentId": { 175 | data["parentID"] = blockid; 176 | break; 177 | } 178 | case "nextID": 179 | case "NEXT": 180 | case "nextId": { 181 | data["nextID"] = blockid; 182 | break; 183 | } 184 | case "previousID": 185 | case "PREVIOUS": 186 | case "previousId": 187 | default: { 188 | data["previousID"] = blockid; 189 | break; 190 | } 191 | } 192 | let response = await postRequest(data, url); 193 | try{ 194 | if (response.code == 0 && response.data != null && isValidStr(response.data[0].doOperations[0].id)){ 195 | return response.data[0].doOperations[0]; 196 | } 197 | if (response.code == -1){ 198 | warnPush("插入块失败", response.msg); 199 | return null; 200 | } 201 | }catch(err){ 202 | errorPush(err); 203 | warnPush(response.msg); 204 | } 205 | return null; 206 | 207 | } 208 | 209 | 210 | /** 211 | * 插入块 API 212 | * @param {Object} params 213 | * @param {"markdown"|"dom"} params.dataType - 数据类型 214 | * @param {string} params.data - 待插入的数据 215 | * @param {string} params.nextID - 后一个块的ID 216 | * @param {string} params.previousID - 前一个块的ID 217 | * @param {string} params.parentID - 父块ID 218 | * @returns {Promise} 插入结果 219 | */ 220 | export async function insertBlockOriginAPI({ dataType, data, nextID, previousID, parentID }) { 221 | // 参数校验 222 | if (!isValidStr(dataType) || !["markdown", "dom"].includes(dataType)) { 223 | throw new Error("Invalid dataType"); 224 | } 225 | if (!isValidStr(data)) { 226 | throw new Error("Data cannot be empty"); 227 | } 228 | // 定位插入点,优先级 nextID > previousID > parentID 229 | let anchorType = ""; 230 | let anchorID = ""; 231 | if (isValidStr(nextID)) { 232 | anchorType = "nextID"; 233 | anchorID = nextID; 234 | } else if (isValidStr(previousID)) { 235 | anchorType = "previousID"; 236 | anchorID = previousID; 237 | } else if (isValidStr(parentID)) { 238 | anchorType = "parentID"; 239 | anchorID = parentID; 240 | } else { 241 | throw new Error("At least one anchor ID(nextID, previousId or parentId) must be provided"); 242 | } 243 | 244 | const payload = { 245 | dataType, 246 | data, 247 | nextID, 248 | previousID, 249 | parentID 250 | }; 251 | let response = await postRequest(payload, "/api/block/insertBlock"); 252 | if (response.data == null || response.data.length == 0 || response.data[0].doOperations == null || response.data[0].doOperations.length === 0 || response.data[0].doOperations[0] == null || !isValidStr(response.data[0].doOperations[0].id)) { 253 | throw new Error("Insert block failed: No operations returned"); 254 | } 255 | return response.data; 256 | } 257 | 258 | /** 259 | * 获取文档大纲 260 | * @param {string} docid 要获取的文档id 261 | * @returns {*} 响应的data部分,为outline对象数组 262 | */ 263 | export async function getDocOutlineAPI(docid){ 264 | let url = "/api/outline/getDocOutline"; 265 | let data = {"id": docid}; 266 | let response = await postRequest(data, url); 267 | if (response.code == 0){ 268 | return response.data; 269 | }else{ 270 | return null; 271 | } 272 | } 273 | 274 | /** 275 | * 插入为后置子块 276 | * @param {*} text 子块文本 277 | * @param {*} parentId 父块id 278 | * @param {*} textType 默认为"markdown" 279 | * @returns 280 | */ 281 | export async function prependBlockAPI(text, parentId, textType = "markdown"){ 282 | let url = "/api/block/prependBlock"; 283 | let data = {"dataType": textType, "data": text, "parentID": parentId}; 284 | let response = await postRequest(data, url); 285 | try{ 286 | if (response.code == 0 && response.data != null && isValidStr(response.data[0].doOperations[0].id)){ 287 | return response.data[0].doOperations[0]; 288 | } 289 | if (response.code == -1){ 290 | warnPush("插入块失败", response.msg); 291 | return null; 292 | } 293 | }catch(err){ 294 | errorPush(err); 295 | warnPush(response.msg); 296 | } 297 | return null; 298 | 299 | } 300 | 301 | /** 302 | * 插入为前置子块 303 | * @param {*} text 子块文本 304 | * @param {*} parentId 父块id 305 | * @param {*} textType 默认为markdown 306 | * @returns 307 | */ 308 | export async function appendBlockAPI(text, parentId, textType = "markdown"){ 309 | let url = "/api/block/appendBlock"; 310 | let data = {"dataType": textType, "data": text, "parentID": parentId}; 311 | let response = await postRequest(data, url); 312 | try{ 313 | if (response.code == 0 && response.data != null && isValidStr(response.data[0].doOperations[0].id)){ 314 | return response.data[0].doOperations[0]; 315 | } 316 | if (response.code == -1){ 317 | warnPush("插入块失败", response.msg); 318 | return null; 319 | } 320 | }catch(err){ 321 | errorPush(err); 322 | warnPush(response.msg); 323 | } 324 | return null; 325 | 326 | } 327 | 328 | /** 329 | * 推送普通消息 330 | * @param {string} msgText 推送的内容 331 | * @param {number} timeout 显示时间,单位毫秒 332 | * @return 0正常推送 -1 推送失败 333 | */ 334 | export async function pushMsgAPI(msgText, timeout){ 335 | let url = "/api/notification/pushMsg"; 336 | let response = await postRequest({msg: msgText, timeout: timeout}, url); 337 | if (response.code != 0 || response.data == null || !isValidStr(response.data.id)){ 338 | return -1; 339 | } 340 | return 0; 341 | } 342 | 343 | /** 344 | * 获取当前文档id(伪api) 345 | * 优先使用jquery查询 346 | * @param {boolean} mustSure 是否必须确认,若为true,找到多个打开中的文档时返回null 347 | */ 348 | export function getCurrentDocIdF(mustSure: boolean = false) { 349 | let thisDocId:string = null; 350 | // 桌面端 351 | thisDocId = window.top.document.querySelector(".layout__wnd--active .protyle.fn__flex-1:not(.fn__none) .protyle-background")?.getAttribute("data-node-id"); 352 | debugPush("尝试获取当前具有焦点的id", thisDocId); 353 | let temp:string = null; 354 | // 移动端 355 | if (!thisDocId && isMobile()) { 356 | // UNSTABLE: 面包屑样式变动将导致此方案错误! 357 | try { 358 | temp = window.top.document.querySelector(".protyle-breadcrumb .protyle-breadcrumb__item .popover__block[data-id]")?.getAttribute("data-id"); 359 | let iconArray = window.top.document.querySelectorAll(".protyle-breadcrumb .protyle-breadcrumb__item .popover__block[data-id]"); 360 | for (let i = 0; i < iconArray.length; i++) { 361 | let iconOne = iconArray[i]; 362 | if (iconOne.children.length > 0 363 | && iconOne.children[0].getAttribute("xlink:href") == "#iconFile"){ 364 | temp = iconOne.getAttribute("data-id"); 365 | break; 366 | } 367 | } 368 | thisDocId = temp; 369 | }catch(e){ 370 | console.error(e); 371 | temp = null; 372 | } 373 | } 374 | // 无聚焦窗口 375 | if (!thisDocId) { 376 | thisDocId = window.top.document.querySelector(".protyle.fn__flex-1:not(.fn__none) .protyle-background")?.getAttribute("data-node-id"); 377 | debugPush("获取具有焦点id失败,获取首个打开中的文档", thisDocId); 378 | if (mustSure && window.top.document.querySelectorAll(".protyle.fn__flex-1:not(.fn__none) .protyle-background").length > 1) { 379 | debugPush("要求必须唯一确认,但是找到多个打开中的文档"); 380 | return null; 381 | } 382 | } 383 | return thisDocId; 384 | } 385 | 386 | export function getAllShowingDocId(): string[] { 387 | if (isMobile()) { 388 | return [getCurrentDocIdF()]; 389 | } else { 390 | const elemList = window.document.querySelectorAll("[data-type=wnd] .protyle.fn__flex-1:not(.fn__none) .protyle-background"); 391 | const result = [].map.call(elemList, function(elem: Element) { 392 | return elem.getAttribute("data-node-id"); 393 | }); 394 | return result 395 | } 396 | } 397 | 398 | /** 399 | * 获取当前挂件id 400 | * @returns 401 | */ 402 | export function getCurrentWidgetId(){ 403 | try{ 404 | if (!window.frameElement.parentElement.parentElement.dataset.nodeId) { 405 | return window.frameElement.parentElement.parentElement.dataset.id; 406 | }else{ 407 | return window.frameElement.parentElement.parentElement.dataset.nodeId; 408 | } 409 | }catch(err){ 410 | warnPush("getCurrentWidgetId window...nodeId方法失效"); 411 | return null; 412 | } 413 | } 414 | 415 | /** 416 | * 检查运行的操作系统 417 | * @return true 可以运行,当前os在允许列表中 418 | */ 419 | // export function checkOs(){ 420 | // try{ 421 | // if (setting.includeOs.indexOf(window.top.siyuan.config.system.os.toLowerCase()) != -1){ 422 | // return true; 423 | // } 424 | // }catch(err){ 425 | // errorPush(err); 426 | // warnPush("检查操作系统失败"); 427 | // } 428 | 429 | // return false; 430 | // } 431 | /** 432 | * 删除块 433 | * @param {*} blockid 434 | * @returns 435 | */ 436 | export async function removeBlockAPI(blockid){ 437 | let url = "/api/block/deleteBlock"; 438 | let response = await postRequest({id: blockid}, url); 439 | if (response.code == 0){ 440 | return true; 441 | } 442 | warnPush("删除块失败", response); 443 | return false; 444 | } 445 | 446 | /** 447 | * 获取块kramdown源码 448 | * @param {*} blockid 449 | * @returns kramdown文本 450 | */ 451 | export async function getKramdown(blockid, throwError = false){ 452 | let url = "/api/block/getBlockKramdown"; 453 | let response = await postRequest({id: blockid}, url); 454 | if (response.code == 0 && response.data != null && "kramdown" in response.data){ 455 | return response.data.kramdown; 456 | } 457 | if (throwError) { 458 | throw new Error(`get kramdown failed: ${response.msg}`); 459 | } 460 | return null; 461 | } 462 | 463 | /** 464 | * 获取笔记本列表 465 | * @returns 466 | "id": "20210817205410-2kvfpfn", 467 | "name": "测试笔记本", 468 | "icon": "1f41b", 469 | "sort": 0, 470 | "closed": false 471 | 472 | */ 473 | export async function getNodebookList() { 474 | let url = "/api/notebook/lsNotebooks"; 475 | let response = await postRequest({}, url); 476 | if (response.code == 0 && response.data != null && "notebooks" in response.data){ 477 | return response.data.notebooks; 478 | } 479 | return null; 480 | } 481 | 482 | /** 483 | * 基于本地window.siyuan获得笔记本信息 484 | * @param {*} notebookId 为空获得所有笔记本信息 485 | * @returns 486 | */ 487 | export function getNotebookInfoLocallyF(notebookId = undefined) { 488 | try { 489 | if (!notebookId) return window.top.siyuan.notebooks; 490 | for (let notebookInfo of window.top.siyuan.notebooks) { 491 | if (notebookInfo.id == notebookId) { 492 | return notebookInfo; 493 | } 494 | } 495 | return undefined; 496 | }catch(err) { 497 | errorPush(err); 498 | return undefined; 499 | } 500 | } 501 | 502 | /** 503 | * 获取笔记本排序规则 504 | * (为“跟随文档树“的,转为文档树排序 505 | * @param {*} notebookId 笔记本id,不传则为文档树排序 506 | * @returns 507 | */ 508 | export function getNotebookSortModeF(notebookId = undefined) { 509 | try { 510 | let fileTreeSort = window.top.siyuan.config.fileTree.sort; 511 | if (!notebookId) return fileTreeSort; 512 | let notebookSortMode = window.document.querySelector(`.file-tree.sy__file ul[data-url='${notebookId}']`)?.getAttribute("data-sortmode") ?? getNotebookInfoLocallyF(notebookId).sortMode; 513 | if (typeof notebookSortMode === "string") { 514 | notebookSortMode = parseInt(notebookSortMode, 10); 515 | } 516 | if (notebookSortMode == DOC_SORT_TYPES.UNASSIGNED || notebookSortMode == DOC_SORT_TYPES.FOLLOW_DOC_TREE) { 517 | return fileTreeSort; 518 | } 519 | return notebookSortMode; 520 | }catch(err) { 521 | errorPush(err); 522 | return undefined; 523 | } 524 | } 525 | 526 | /** 527 | * 批量添加闪卡 528 | * @param {*} ids 529 | * @param {*} deckId 目标牌组Id 530 | * @param {*} oldCardsNum 原有牌组卡牌数(可选) 531 | * @returns (若未传入原卡牌数)添加后牌组内卡牌数, (若传入)返回实际添加的卡牌数; 返回null表示请求失败 532 | */ 533 | export async function addRiffCards(ids, deckId, oldCardsNum = -1) { 534 | let url = "/api/riff/addRiffCards"; 535 | let postBody = { 536 | deckID: deckId, 537 | blockIDs: ids 538 | }; 539 | let response = await postRequest(postBody, url); 540 | if (response.code == 0 && response.data != null && "size" in response.data) { 541 | if (oldCardsNum < 0) { 542 | return response.data.size; 543 | }else{ 544 | return response.data.size - oldCardsNum; 545 | } 546 | } 547 | warnPush("添加闪卡出错", response); 548 | return null; 549 | } 550 | 551 | export async function getNotebookConf(notebookId: string) { 552 | const url = "/api/notebook/getNotebookConf"; 553 | const response = await postRequest({ notebook: notebookId }, url); 554 | if (response.code === 0 && response.data) { 555 | return response.data; 556 | } 557 | return null; 558 | } 559 | 560 | /** 561 | * 批量移除闪卡 562 | * @param {*} ids 563 | * @param {*} deckId 目标牌组Id 564 | * @param {*} oldCardsNum 原有牌组卡牌数(可选) 565 | * @returns (若未传入原卡牌数)移除后牌组内卡牌数, (若传入)返回实际移除的卡牌数; 返回null表示请求失败 566 | */ 567 | export async function removeRiffCards(ids, deckId, oldCardsNum = -1) { 568 | let url = "/api/riff/removeRiffCards"; 569 | let postBody = { 570 | deckID: deckId, 571 | blockIDs: ids 572 | }; 573 | let response = await postRequest(postBody, url); 574 | if (response.code == 0 && response.data != null && "size" in response.data) { 575 | if (oldCardsNum < 0) { 576 | return response.data.size; 577 | }else{ 578 | return oldCardsNum - response.data.size; 579 | } 580 | } 581 | if (response.code == 0) { 582 | return ids.length; 583 | } 584 | warnPush("移除闪卡出错", response); 585 | return null; 586 | } 587 | 588 | /** 589 | * 获取全部牌组信息 590 | * @returns 返回数组 591 | * [{"created":"2023-01-05 20:29:48", 592 | * "id":"20230105202948-xn12hz6", 593 | * "name":"Default Deck", 594 | * "size":1, 595 | * "updated":"2023-01-19 21:48:21"}] 596 | */ 597 | export async function getRiffDecks() { 598 | let url = "/api/riff/getRiffDecks"; 599 | let response = await postRequest({}, url); 600 | if (response.code == 0 && response.data != null) { 601 | return response.data; 602 | } 603 | return new Array(); 604 | } 605 | 606 | /** 607 | * 获取文件内容或链接信息 608 | * @param {*} blockid 获取的文件id 609 | * @param {*} size 获取的块数 610 | * @param {*} mode 获取模式,0为获取html;1为 611 | */ 612 | export async function getDoc(blockid, size = 5, mode = 0) { 613 | let url = "/api/filetree/getDoc"; 614 | let response = await postRequest({id: blockid, mode: mode, size: size}, url); 615 | if (response.code == 0 && response.data != null) { 616 | return response.data; 617 | } 618 | return undefined; 619 | } 620 | 621 | /** 622 | * 获取文档导出预览 623 | * @param {*} docid 624 | * @returns 625 | */ 626 | export async function getDocPreview(docid) { 627 | let url = "/api/export/preview"; 628 | let response = await postRequest({id: docid}, url); 629 | if (response.code == 0 && response.data != null) { 630 | return response.data.html; 631 | } 632 | return ""; 633 | } 634 | /** 635 | * 删除文档 636 | * @param {*} notebookid 笔记本id 637 | * @param {*} path 文档所在路径 638 | * @returns 639 | */ 640 | export async function removeDocAPI(notebookid, path) { 641 | let url = "/api/filetree/removeDoc"; 642 | let response = await postRequest({"notebook": notebookid, "path": path}, url); 643 | if (response.code == 0) { 644 | return response.code; 645 | } 646 | warnPush("删除文档时发生错误", response.msg); 647 | return response.code; 648 | } 649 | /** 650 | * 重命名文档 651 | * @param {*} notebookid 笔记本id 652 | * @param {*} path 文档所在路径 653 | * @param {*} title 新文档名 654 | * @returns 655 | */ 656 | export async function renameDocAPI(notebookid, path, title) { 657 | let url = "/api/filetree/renameDoc"; 658 | let response = await postRequest({"notebook": notebookid, "path": path, "title": title}, url); 659 | if (response.code == 0) { 660 | return response.code; 661 | } 662 | warnPush("重命名文档时发生错误", response.msg); 663 | return response.code; 664 | } 665 | 666 | export function isDarkMode() { 667 | if (window.top.siyuan) { 668 | return window.top.siyuan.config.appearance.mode == 1 ? true : false; 669 | } else { 670 | let isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; 671 | return isDarkMode; 672 | } 673 | } 674 | 675 | /** 676 | * 通过markdown创建文件 677 | * @param {*} notebookid 笔记本id 678 | * @param {*} hpath 示例 /父文档1/父文档2/你要新建的文档名 679 | * @param {*} md 680 | * @returns 681 | */ 682 | export async function createDocWithMdAPI(notebookid, hpath, md) { 683 | let url = "/api/filetree/createDocWithMd"; 684 | let response = await postRequest({"notebook": notebookid, "path": hpath, "markdown": md}, url); 685 | if (response.code == 0 && response.data != null) { 686 | return response.data.id; 687 | } 688 | return null; 689 | } 690 | 691 | /** 692 | * 693 | * @param {*} notebookid 694 | * @param {*} path 待创建的新文档path,即,最后应当为一个随机的id.sy 695 | * @param {*} title 【可选】文档标题 696 | * @param {*} contentMd 【可选】markdown格式的内容 697 | * @returns 698 | */ 699 | export async function createDocWithPath(notebookid, path, title = "Untitled", contentMd = "", listDocTree=false) { 700 | let url = "/api/filetree/createDoc"; 701 | let response = await postRequest({"notebook": notebookid, "path": path, "md": contentMd, "title": title, "listDocTree": listDocTree}, url); 702 | if (response.code == 0) { 703 | return true; 704 | } 705 | logPush("responseERROR", response); 706 | throw Error(response.msg); 707 | return false; 708 | } 709 | 710 | /** 711 | * 将对象保存为JSON文件 712 | * @param {*} path 713 | * @param {*} object 714 | * @param {boolean} format 715 | * @returns 716 | */ 717 | export async function putJSONFile(path, object, format = false) { 718 | const url = "/api/file/putFile"; 719 | const pathSplited = path.split("/"); 720 | let fileContent = ""; 721 | if (format) { 722 | fileContent = JSON.stringify(object, null, 4); 723 | } else { 724 | fileContent = JSON.stringify(object); 725 | } 726 | // File的文件名实际上无关,但这里考虑到兼容,将上传文件按照路径进行了重命名 727 | const file = new File([fileContent], pathSplited[pathSplited.length - 1], {type: "text/plain"}); 728 | const data = new FormData(); 729 | data.append("path", path); 730 | data.append("isDir", "false"); 731 | data.append("modTime", new Date().valueOf().toString()); 732 | data.append("file", file); 733 | return fetch(url, { 734 | body: data, 735 | method: 'POST', 736 | headers: { 737 | "Authorization": "Token "+ getToken() 738 | } 739 | }).then((response) => { 740 | return response.json(); 741 | }); 742 | } 743 | 744 | /** 745 | * 从JSON文件中读取对象 746 | * @param {*} path 747 | * @returns 748 | */ 749 | export async function getJSONFile(path) { 750 | const url = "/api/file/getFile"; 751 | let response = await postRequest({"path": path}, url); 752 | if (response.code == 404) { 753 | return null; 754 | } 755 | return response; 756 | } 757 | 758 | export async function getFileAPI(path) { 759 | const url = "/api/file/getFile"; 760 | let data = {"path": path}; 761 | let result; 762 | let response = await fetch(url, { 763 | body: JSON.stringify(data), 764 | method: 'POST', 765 | headers: { 766 | "Authorization": "Token "+ getToken(), 767 | "Content-Type": "application/json" 768 | } 769 | }); 770 | result = await response.text(); 771 | try { 772 | let jsonresult = JSON.parse(result); 773 | if (jsonresult.code == 404) { 774 | return null; 775 | } 776 | return result; 777 | } catch(err) { 778 | 779 | } 780 | return result; 781 | } 782 | 783 | /** 784 | * 获取工作空间中的文件,部分情况下返回blob 785 | * @param path 文件路径 786 | * @returns json内容或blob 787 | */ 788 | export async function getFileAPIv2(path:string) { 789 | const url = "/api/file/getFile"; 790 | const data = { path }; 791 | 792 | const response = await fetch(url, { 793 | method: 'POST', 794 | headers: { 795 | "Authorization": "Token " + getToken(), 796 | "Content-Type": "application/json" 797 | }, 798 | body: JSON.stringify(data) 799 | }); 800 | 801 | const contentType = response.headers.get("Content-Type") || ""; 802 | 803 | if (contentType.includes("application/json")) { 804 | const json = await response.json(); 805 | if (json.code === 404) { 806 | return null; 807 | } 808 | return json; 809 | } else { 810 | // 如果是文件,返回二进制 blob 811 | const blob = await response.blob(); 812 | return blob; 813 | } 814 | } 815 | 816 | /** 817 | * 列出工作空间下的文件 818 | * @param {*} path 例如"/data/20210808180117-6v0mkxr/20200923234011-ieuun1p.sy" 819 | * @returns isDir, isSymlink, name三个属性 820 | */ 821 | export async function listFileAPI(path) { 822 | const url = "/api/file/readDir"; 823 | let response = await postRequest({"path": path}, url); 824 | if (response.code == 0) { 825 | return response.data; 826 | } 827 | return []; 828 | } 829 | 830 | export async function removeFileAPI(path) { 831 | const url = "/api/file/removeFile"; 832 | let response = await postRequest({"path": path}, url); 833 | if (response.code == 0) { 834 | return true; 835 | } else { 836 | return false; 837 | } 838 | } 839 | 840 | export async function getDocInfo(id) { 841 | let data = { 842 | "id": id 843 | }; 844 | let url = `/api/block/getDocInfo`; 845 | return getResponseData(postRequest(data, url)); 846 | } 847 | 848 | /** 849 | * 反向链接面板用的API(标注有T,该API不是正式API) 850 | * @param id 851 | * @param sort 反链结果排序方式 字母0/1、自然4/5创建9/10,修改2/3 852 | * @param msort 853 | * @param k 854 | * @param mk 看起来是提及部分的关键词 855 | * @returns 856 | */ 857 | export async function getBackLink2T(id, sort = "3", msort= "3", k = "", mk = "") { 858 | let data = { 859 | "id": id, 860 | "sort": sort, 861 | "msort": msort, 862 | "k": k, 863 | "mk": mk 864 | }; 865 | let url = `/api/ref/getBacklink2`; 866 | return getResponseData(postRequest(data, url)); 867 | } 868 | 869 | export async function getTreeStat(id:string) { 870 | let data = { 871 | "id": id 872 | }; 873 | let url = `/api/block/getTreeStat`; 874 | return getResponseData(postRequest(data, url)); 875 | } 876 | 877 | let isMobileRecentResult = null; 878 | export function isMobile() { 879 | if (isMobileRecentResult != null) { 880 | return isMobileRecentResult; 881 | } 882 | if (window.top.document.getElementById("sidebar")) { 883 | isMobileRecentResult = true; 884 | return true; 885 | } else { 886 | isMobileRecentResult = false; 887 | return false; 888 | } 889 | }; 890 | 891 | export function getBlockBreadcrumb(blockId: string, excludeTypes: string[] = []) { 892 | let data = { 893 | "id": blockId, 894 | "excludeTypes": excludeTypes 895 | }; 896 | let url = `/api/block/getBlockBreadcrumb`; 897 | return getResponseData(postRequest(data, url)); 898 | } 899 | 900 | export async function getHPathById(docId:string): Promise { 901 | let data = { 902 | "id": docId 903 | } 904 | const url = "/api/filetree/getHPathByID"; 905 | return getResponseData(postRequest(data, url)) as Promise; 906 | } 907 | 908 | /** 909 | * 批量设置属性 910 | * @param {*} blockAttrs 数组,每一个元素为对象,包含 id 和 attrs两个属性值,attrs为对象,其属性和属性值即为 attr-key: attr-value 911 | * @ref https://github.com/siyuan-note/siyuan/issues/10337 912 | */ 913 | export async function batchSetBlockAtrs(blockAttrs: string) { 914 | let url = "/api/attr/batchSetBlockAttrs"; 915 | let postBody = { 916 | blockAttrs: blockAttrs, 917 | }; 918 | let response = await postRequest(postBody, url); 919 | if (response.code == 0 && response.data != null) { 920 | return response.data; 921 | } 922 | return null; 923 | } 924 | 925 | /** 926 | * 创建daily note 927 | * @param notebook 笔记本id 928 | * @param app appid 929 | * @returns dailynote id 930 | */ 931 | export async function createDailyNote(notebook:string, app: string) { 932 | const url = "/api/filetree/createDailyNote"; 933 | let postBody = { 934 | app: app, 935 | notebook: notebook 936 | }; 937 | let response = await postRequest(postBody, url); 938 | if (response.code == 0) { 939 | return response.data.id; 940 | } else { 941 | throw new Error("Create Dailynote Failed: " + response.msg); 942 | } 943 | } 944 | 945 | export async function fullTextSearchBlock({query, method = 0, paths = [], groupBy = 1, orderBy = 0, page = 1, types = DEFAULT_FILTER}:FullTextSearchQuery) { 946 | const url = "/api/search/fullTextSearchBlock"; 947 | if (groupBy == 0 && orderBy == 5){ 948 | orderBy = 0; 949 | warnPush("orderBy取值不合法,已被重置"); 950 | } 951 | let postBody = { 952 | query, 953 | method, 954 | page, 955 | paths, 956 | groupBy, 957 | orderBy, 958 | types, 959 | pageSize: 10, 960 | } 961 | postBody["reqId"] = Date.now(); 962 | let response = await postRequest(postBody, url); 963 | if (response.code == 0) { 964 | return response.data; 965 | } else { 966 | throw new Error("fullTextSearchBlock Failed: " + response.msg); 967 | } 968 | } 969 | 970 | export async function exportMdContent({id, refMode, embedMode, yfm}: ExportMdContentBody) { 971 | const url = "/api/export/exportMdContent"; 972 | let postBody = { 973 | id, 974 | refMode, 975 | embedMode, 976 | yfm, 977 | } 978 | let response = await postRequest(postBody, url); 979 | if (response.code == 0) { 980 | return response.data; 981 | } else { 982 | throw new Error("exportMdContent Failed: " + response.msg); 983 | } 984 | } 985 | /** 986 | * 获得子块 987 | * @param id 块id或文档id 988 | * @returns 子块信息,数组,有id, type, subType, content, markdown字段 989 | */ 990 | export async function getChildBlocks(id:string) { 991 | const url = "/api/block/getChildBlocks"; 992 | let postBody = { 993 | id 994 | } 995 | let response = await postRequest(postBody, url); 996 | if (response.code == 0) { 997 | return response.data; 998 | } else { 999 | throw new Error("getChildBlocks Failed: " + response.msg); 1000 | } 1001 | } 1002 | 1003 | export async function listDocTree(notebook:string, path:string) { 1004 | const url = "/api/filetree/listDocTree"; 1005 | let postBody = { 1006 | notebook, 1007 | path 1008 | } 1009 | let response = await postRequest(postBody, url); 1010 | if (response.code == 0) { 1011 | return response.data.tree; 1012 | } else { 1013 | throw new Error("listDocTree Failed: " + response.msg); 1014 | } 1015 | } 1016 | 1017 | export const DOC_SORT_TYPES = { 1018 | FILE_NAME_ASC: 0, 1019 | FILE_NAME_DESC: 1, 1020 | NAME_NAT_ASC: 4, 1021 | NAME_NAT_DESC: 5, 1022 | CREATED_TIME_ASC: 9, 1023 | CREATED_TIME_DESC: 10, 1024 | MODIFIED_TIME_ASC: 2, 1025 | MODIFIED_TIME_DESC: 3, 1026 | REF_COUNT_ASC: 7, 1027 | REF_COUNT_DESC: 8, 1028 | DOC_SIZE_ASC: 11, 1029 | DOC_SIZE_DESC: 12, 1030 | SUB_DOC_COUNT_ASC: 13, 1031 | SUB_DOC_COUNT_DESC: 14, 1032 | CUSTOM_SORT: 6, 1033 | FOLLOW_DOC_TREE: 255, // 插件内部定义的”跟随文档树“ 1034 | FOLLOW_DOC_TREE_ORI: 15, // 官方对于”跟随文档树“的定义 1035 | UNASSIGNED: 256, 1036 | }; 1037 | 1038 | 1039 | export const DEFAULT_FILTER: BlockTypeFilter = { 1040 | audioBlock: false, 1041 | blockquote: false, 1042 | codeBlock: true, 1043 | databaseBlock: false, 1044 | document: true, 1045 | embedBlock: false, 1046 | heading: true, 1047 | htmlBlock: true, 1048 | iframeBlock: false, 1049 | list: false, 1050 | listItem: false, 1051 | mathBlock: true, 1052 | paragraph: true, 1053 | superBlock: false, 1054 | table: true, 1055 | videoBlock: false, 1056 | widgetBlock: false 1057 | }; 1058 | --------------------------------------------------------------------------------