├── scripts ├── .gitignore ├── .release.py ├── make_dev_link.js └── reset_dev_loc.js ├── src ├── index.scss ├── constants.ts ├── components │ ├── settings │ │ ├── items │ │ │ ├── nameDivider.vue │ │ │ ├── textarea.vue │ │ │ ├── switch.vue │ │ │ ├── button.vue │ │ │ ├── select.vue │ │ │ ├── input.vue │ │ │ └── order.vue │ │ ├── page.vue │ │ ├── block.vue │ │ ├── item.vue │ │ ├── column.vue │ │ └── setting.vue │ └── dialog │ │ └── outdatedSetting.vue ├── utils │ ├── pluginHelper.ts │ ├── mathjax.ts │ ├── stringUtils.ts │ ├── imageProcessorWorker.js │ ├── mutex.ts │ ├── lang.ts │ ├── commonCheck.ts │ ├── settings.ts │ ├── onlyThisUtils.ts │ └── common.ts ├── types │ ├── settings.d.ts │ └── index.d.ts ├── manager │ ├── shortcutHandler.ts │ ├── setStyle.ts │ ├── eventHandler.ts │ └── settingManager.ts ├── i18n │ ├── en_US.json │ └── zh_CN.json ├── worker │ ├── canvasProcessor.ts │ ├── baseProcessor.ts │ ├── mermaidProcessor.ts │ ├── ABCProcessor.ts │ ├── mathProcessor.ts │ └── plantUMLProcessor.ts ├── logger │ └── index.ts ├── hello.vue ├── index.ts └── syapi │ ├── interface.d.ts │ ├── custom.ts │ └── index.ts ├── icon.png ├── preview.png ├── asset └── action.png ├── .gitignore ├── tsconfig.node.json ├── CHANGELOG.md ├── plugin.json ├── README_zh_CN.md ├── package.json ├── tsconfig.json ├── README.md ├── .github ├── workflows │ └── release.yml └── ISSUE_TEMPLATE │ ├── bug_report_zh_cn.yml │ └── bug_report.yml ├── vite.config.ts └── LICENSE /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-copyAsImage/main/icon.png -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpaqueGlass/syplugin-copyAsImage/main/preview.png -------------------------------------------------------------------------------- /asset/action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpaqueGlass/syplugin-copyAsImage/main/asset/action.png -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export class CONSTANTS { 2 | public static readonly STYLE_ID: string = "template-plugin-style"; 3 | } -------------------------------------------------------------------------------- /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/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/components/settings/items/textarea.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /src/utils/mathjax.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | window.MathJax = { 3 | tex: { 4 | inlineMath: [ 5 | ["$", "$"], 6 | ["\\(", "\\)"], 7 | ], // 行内公式选择符 8 | displayMath: [ 9 | ["$$", "$$"], 10 | ["\\[", "\\]"], 11 | ], // 段内公式选择符 12 | }, 13 | startup: { 14 | ready() { 15 | window.MathJax.startup.defaultReady(); 16 | }, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 更新日志 2 | 3 | ### v1.1.0 (2025年8月27日) 4 | 5 | - 新增:对任意文本块导出图片(块菜单); 6 | - 修复:本身过大的公式/流程图导出后被拉伸缩放的问题; 7 | 8 | ### v1.0.0 (2025年6月13日) 9 | 10 | > 综合实际情况,插件进入正式版。 11 | 12 | - 修复:含换行符`
`的mermaid图无法正确导出的问题; 13 | 14 | ### v0.2.0 (2025年3月21日) 15 | 16 | - 新增:下载svg图片; 17 | 18 | ### v0.1.1 (2025年3月16日) 19 | 20 | - 改进:支持更多情况、重整代码结构; 21 | 22 | ### v0.1.0 (2025年03月09日) 23 | 24 | - 从这里开始; 25 | - 支持公式块,代码块(需要渲染的); -------------------------------------------------------------------------------- /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 | "TIPS"; 20 | 21 | type ITabProperty = { 22 | nameKey: string, // 标签页名称对应的语言文件关键字 23 | iconKey: string, // 设置项描述对应的语言关键字 24 | properties: Array // 设置项列表 25 | }; 26 | -------------------------------------------------------------------------------- /src/manager/shortcutHandler.ts: -------------------------------------------------------------------------------- 1 | import { 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/utils/imageProcessorWorker.js: -------------------------------------------------------------------------------- 1 | self.onmessage = (event) => { 2 | const { imgData, width, height } = event.data; 3 | let lOffset = width, 4 | rOffset = 0, 5 | tOffset = height, 6 | bOffset = 0; 7 | 8 | for (let j = 0; j < height; j++) { 9 | let rowStart = j * width * 4; 10 | for (let i = 0; i < width; i++) { 11 | let pos = rowStart + i * 4; 12 | if (imgData[pos + 3] > 0) { 13 | bOffset = Math.max(j, bOffset); 14 | rOffset = Math.max(i, rOffset); 15 | tOffset = Math.min(j, tOffset); 16 | lOffset = Math.min(i, lOffset); 17 | } 18 | } 19 | } 20 | 21 | self.postMessage({ lOffset, rOffset, tOffset, bOffset }); 22 | }; -------------------------------------------------------------------------------- /src/components/settings/item.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syplugin-copyAsImage", 3 | "author": "OpaqueGlass", 4 | "url": "https://github.com/OpaqueGlass/syplugin-copyAsImage", 5 | "version": "1.1.0", 6 | "minAppVersion": "2.9.0", 7 | "backends": ["all"], 8 | "frontends": ["browser-desktop", "desktop-window", "desktop"], 9 | "displayName": { 10 | "en_US": "Copy as Image", 11 | "zh_CN": "复制为图片" 12 | }, 13 | "description": { 14 | "en_US": "Copy Formula, mermaid etc. as png. Or download some blocks as SVG", 15 | "zh_CN": "将公式、Mermaid图形等复制为png图片、下载为SVG。比自带的导出图片清晰一些(大概)" 16 | }, 17 | "readme": { 18 | "en_US": "README.md", 19 | "zh_CN": "README_zh_CN.md" 20 | }, 21 | "i18n": [ 22 | "zh_CN", 23 | "en_US" 24 | ], 25 | "funding": { 26 | "custom": [ 27 | "https://wj.qq.com/s2/12395364/b69f/" 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/i18n/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | "addTopBarIcon": "Add a top bar icon by plugin", 3 | "cancel": "Cancel", 4 | "save": "Save", 5 | "byeMenu": "Bye, Menu!", 6 | "helloPlugin": "Hello, Plugin!", 7 | "byePlugin": "Bye, Plugin!", 8 | "showDialog": "Show dialog", 9 | "removedData": "Data deleted", 10 | "confirmRemove": "Confirm to delete the data in ${name}?", 11 | "name": "CopyAsImg", 12 | "copy_as_png": "Copy as PNG. Shift+Click to download PNG", 13 | "copy_as_png_only": "Copy as PNG", 14 | "copy_as_mathml": "Copy as MathML", 15 | "download_svg": "Download SVG", 16 | "error:svg": "An error occured when converting svg", 17 | "error:download_uml": "An error occured while loading PlantUML", 18 | "info:render_node": "Some rendering nodes (e.g., Mermaid, formulas) can be copied as images by clicking in the formula editing area for clearer export.", 19 | 20 | "success:copy": "Copied to clipboard!", 21 | "error:clipboard": "Due to browser security policies, the plugin cannot write content to the clipboard. If you are using a web server, try using a secure connection with https." 22 | } -------------------------------------------------------------------------------- /src/i18n/zh_CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "addTopBarIcon": "使用插件添加一个顶栏按钮", 3 | "cancel": "取消", 4 | "save": "保存", 5 | "byeMenu": "再见,菜单!", 6 | "helloPlugin": "你好,插件!", 7 | "byePlugin": "再见,插件!", 8 | "showDialog": "弹出一个对话框", 9 | "removedData": "数据已删除", 10 | "confirmRemove": "确认删除 ${name} 中的数据?", 11 | "copy_as_png": "复制为PNG(Shift+点击:下载PNG)", 12 | "copy_as_png_only": "复制为PNG", 13 | "copy_as_mathml": "复制为MathML", 14 | "download_svg": "下载svg", 15 | 16 | "success:copy": "已复制到剪贴板!", 17 | "error:clipboard": "由于浏览器安全策略,插件不能将内容写入剪贴板。如果正在使用网络伺服,请尝试使用https安全连接。", 18 | "error:svg": "转换svg时出现错误", 19 | "error:download_uml": "获取UML时出现错误", 20 | 21 | "info:render_node": "部分渲染节点(例如:Mermaid,公式)可在公式编辑区点击复制为图片,导出更清晰", 22 | 23 | "name": "复制为图片", 24 | "setting_panel_title": "“复制为图片”插件设置", 25 | "setting_enableIt_name": "启用插件", 26 | "setting_fontSize_desp": "单位px", 27 | "setting_fontSize_name": "字号", 28 | "setting_popupWindow_name": "悬浮窗模式", 29 | "setting_popupWindow_desp": "", 30 | 31 | "settingpage_general_name": "通用", 32 | "settingpage_settingave_name": "设置项保存", 33 | "settingpage_about_name": "关于" 34 | } -------------------------------------------------------------------------------- /src/utils/lang.ts: -------------------------------------------------------------------------------- 1 | let language = null; 2 | let emptyLanguageKey: Array = []; 3 | 4 | export function setLanguage(lang:any) { 5 | language = lang; 6 | } 7 | 8 | export function lang(key: string) { 9 | if (language != null && language[key] != null) { 10 | return language[key]; 11 | } 12 | if (language == null) { 13 | emptyLanguageKey.push(key); 14 | console.error("语言文件未定义该Key", JSON.stringify(emptyLanguageKey)); 15 | } 16 | return key; 17 | } 18 | 19 | /** 20 | * 21 | * @param key key 22 | * @returns [设置项名称,设置项描述,设置项按钮名称(如果有)] 23 | */ 24 | export function settingLang(key: string) { 25 | let settingName: string = lang(`setting_${key}_name`); 26 | let settingDesc: string = lang(`setting_${key}_desp`); 27 | let settingBtnName: string = lang(`setting_${key}_btn`) 28 | if (settingName == "Undefined" || settingDesc == "Undefined") { 29 | throw new Error(`设置文本${key}未定义`); 30 | } 31 | return [settingName, settingDesc, settingBtnName]; 32 | } 33 | 34 | export function settingPageLang(key: string) { 35 | let pageSettingName: string = lang(`settingpage_${key}_name`); 36 | return [pageSettingName]; 37 | } -------------------------------------------------------------------------------- /README_zh_CN.md: -------------------------------------------------------------------------------- 1 | # 复制为图片 2 | 3 | [English](./README.md) 4 | 5 | > 将公式、Mermaid、UML等部分类型的块复制为PNG图片的[思源笔记](https://github.com/siyuan-note/siyuan/)插件。 6 | 7 | > 当前版本 v1.1.0 新增:对任意文本块导出图片(块菜单);修复:本身过大的公式/流程图导出后被拉伸缩放的问题; 8 | 9 | ## ✨快速开始 10 | 11 | - 从集市下载 或 1、解压Release中的`package.zip`,2、将文件夹移动到`工作空间/data/plugins/`,3、并将文件夹重命名为`syplugin-copyAsImage`; 12 | - 开启插件; 13 | - 点击公式、Mermaid等块的编辑按钮,在**编辑区域右上角**可以看到插件添加的功能按钮; 14 | - 复制为PNG:在部分块编辑区,点击“复制为PNG”按钮即可; 15 | - 按下`Shift`的同时点击此按钮,将下载PNG图片;(在不支持复制的情况下,也是如此) 16 | - 复制为MathML:在公式编辑区,点击“复制为MathML”即可;(可直接粘贴到Word中,但不支持PPT) 17 | 18 | > ⭐ 如果这对你有帮助,请考虑点亮Star! 19 | 20 | ## ❓可能常见的问题 21 | 22 | - 无法复制? 23 | - 如果使用docker或网络伺服,请确保使用https安全连接,否则,点击复制按钮将下载图片。 24 | - 没有显示复制按钮? 25 | - 插件在移动端不生效;如果在电脑端仍有问题,请提交issue。 26 | - 公式和思源有些不同? 27 | - 插件的实现路径是:$\KaTeX$→MathML→SVG,这个过程中可能带来差异;如果你有更好的方案,欢迎提交PR。 28 | 29 | ## 🙏参考&感谢 30 | 31 | > 部分依赖项在`package.json`中列出。 32 | 33 | | 开发者/项目 | 项目描述 | 引用方式 | 34 | |---------------------------------------------------------------------|----------------|--------------| 35 | | [QianJianTech/LaTeXLive](https://github.com/QianJianTech/LaTeXLive) | $\LaTeX$公式编辑器;Apache-2.0 license | SVG公式另存为PNG | -------------------------------------------------------------------------------- /src/components/settings/column.vue: -------------------------------------------------------------------------------- 1 | 20 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /src/worker/canvasProcessor.ts: -------------------------------------------------------------------------------- 1 | import { IEventBusMap } from "siyuan"; 2 | import { BaseCopyProcessor } from "./baseProcessor"; 3 | import { checkClipboard, copyImageToClipboard, downloadImageFromCanvas, getCanvasFromSVG } from "@/utils/onlyThisUtils"; 4 | 5 | export class CanvasProcessor extends BaseCopyProcessor { 6 | public test(eventDetail: IEventBusMap["open-noneditableblock"]): boolean { 7 | const renderElement = eventDetail.renderElement; 8 | return ["mindmap", "echarts"].includes(renderElement.getAttribute("data-subtype")); 9 | } 10 | 11 | public doAddButton(eventDetail: IEventBusMap["open-noneditableblock"]) { 12 | const btnElem = this.createButton("og-copy-png", "copy_as_png", ""); 13 | btnElem.addEventListener("click", (event)=>{ 14 | this.copyItem(eventDetail, event.shiftKey || !checkClipboard(true)); 15 | }); 16 | this.addButtonAfter(eventDetail.toolbar.subElement.querySelector("[data-type='export']"), 17 | [btnElem]); 18 | } 19 | 20 | public async copyItem(eventDetail: IEventBusMap["open-noneditableblock"], download: boolean): Promise { 21 | const renderElement = eventDetail.renderElement; 22 | const canvasElement = renderElement.querySelector(".protyle-icons + div canvas"); 23 | if (download) { 24 | downloadImageFromCanvas(canvasElement); 25 | } else { 26 | copyImageToClipboard(canvasElement); 27 | } 28 | return true; 29 | } 30 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syplugin-mytemplate", 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/mathjax": "^0.0.40", 18 | "@types/node": "^20.6.2", 19 | "@types/sortablejs": "^1.15.8", 20 | "fast-glob": "^3.3.1", 21 | "glob": "^7.2.3", 22 | "minimist": "^1.2.8", 23 | "rollup-plugin-livereload": "^2.0.5", 24 | "sass": "^1.67.0", 25 | "siyuan": "^1.1.0", 26 | "ts-node": "^10.9.1", 27 | "typescript": "^5.2.2", 28 | "vite": "^4.4.9", 29 | "vite-plugin-banner": "^0.7.1", 30 | "vite-plugin-static-copy": "^0.15.0", 31 | "vite-plugin-zip-pack": "^1.0.6" 32 | }, 33 | "dependencies": { 34 | "@vitejs/plugin-vue": "^4.5.0", 35 | "@vue/tsconfig": "^0.4.0", 36 | "html-to-image": "^1.11.13", 37 | "mathjax": "^3.2.2", 38 | "natsort": "^2.0.3", 39 | "sortablejs": "^1.15.2", 40 | "vue": "^3.4.15", 41 | "vue-tsc": "^1.8.22" 42 | }, 43 | "pnpm": { 44 | "onlyBuiltDependencies": [ 45 | "@parcel/watcher", 46 | "esbuild" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /src/logger/index.ts: -------------------------------------------------------------------------------- 1 | 2 | // debug push 3 | let g_DEBUG = 2; 4 | const g_NAME = "cai"; 5 | const g_FULLNAME = "复制为图片"; 6 | 7 | /* 8 | LEVEL 0 忽略所有 9 | LEVEL 1 仅Error 10 | LEVEL 2 Err + Warn 11 | LEVEL 3 Err + Warn + Info 12 | LEVEL 4 Err + Warn + Info + Log 13 | LEVEL 5 Err + Warn + Info + Log + Debug 14 | 请注意,基于代码片段加入window下的debug设置,可能在刚载入挂件时无效 15 | */ 16 | export function commonPushCheck() { 17 | if (window.top["OpaqueGlassDebugV2"] == undefined || window.top["OpaqueGlassDebugV2"][g_NAME] == undefined) { 18 | return g_DEBUG; 19 | } 20 | console.log("indow.top[", window.top["OpaqueGlassDebugV2"][g_NAME]) 21 | return window.top["OpaqueGlassDebugV2"][g_NAME]; 22 | } 23 | 24 | export function isDebugMode() { 25 | return commonPushCheck() > g_DEBUG; 26 | } 27 | 28 | export function debugPush(str: string, ...args: any[]) { 29 | if (commonPushCheck() >= 5) { 30 | console.debug(`${g_FULLNAME}[D] ${new Date().toLocaleTimeString()} ${str}`, ...args); 31 | } 32 | } 33 | 34 | export function infoPush(str: string, ...args: any[]) { 35 | if (commonPushCheck() >= 3) { 36 | console.info(`${g_FULLNAME}[I] ${new Date().toLocaleTimeString()} ${str}`, ...args); 37 | } 38 | } 39 | 40 | export function logPush(str: string, ...args: any[]) { 41 | if (commonPushCheck() >= 4) { 42 | console.log(`${g_FULLNAME}[L] ${new Date().toLocaleTimeString()} ${str}`, ...args); 43 | } 44 | } 45 | 46 | export function errorPush(str: string, ... args: any[]) { 47 | if (commonPushCheck() >= 1) { 48 | console.error(`${g_FULLNAME}[E] ${new Date().toLocaleTimeString()} ${str}`, ...args); 49 | } 50 | } 51 | 52 | export function warnPush(str: string, ... args: any[]) { 53 | if (commonPushCheck() >= 2) { 54 | console.warn(`${g_FULLNAME}[W] ${new Date().toLocaleTimeString()} ${str}`, ...args); 55 | } 56 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CopyAsIMG 2 | 3 | [中文](./README_zh_CN.md) 4 | 5 | > A [siyuan-note](https://github.com/siyuan-note/siyuan/) plugin that copy formulas, Mermaid, UML, and other types of blocks as PNG images (or download SVG File). 6 | 7 | ## ✨Quick Start 8 | 9 | - Download from the marketplace or 1. unzip `package.zip` from Releases, 2. move the folder to `workspace/data/plugins/`, 3. rename the folder to `syplugin-copyAsImage`; 10 | - Enable the plugin; 11 | - Click the edit button of blocks such as formulas and Mermaid, and you can see the function buttons added by the plugin in the **top-right corner of the edit area**; 12 | - Copy as PNG: In some block editing areas, click the "Copy as PNG" button; 13 | - While pressing `Shift`, click this button to download the PNG image; (also in cases where copying is not supported) 14 | - Copy as MathML: In the formula editing area, click "Copy as MathML"; (can be directly pasted into Word, but not supported in PPT) 15 | 16 | > ⭐ If this project helps you, please give it a star! 17 | 18 | ## ❓FAQs 19 | 20 | - Cannot copy? 21 | - If using docker or web server, ensure a secure HTTPS connection is used; otherwise, clicking the copy button will download the image. 22 | - No copy button displayed? 23 | - The plugin does not work on mobile devices; if there are still issues on desktop, please submit an issue. 24 | - Formulas look different from what's expected in SiYuan? 25 | - The plugin's implementation path is: $\KaTeX$→MathML→SVG, which may lead to differences during this process; if you have a better solution, PRs are welcome. 26 | 27 | ## 🙏Acknowledgments & Thanks 28 | 29 | > Some dependencies are listed in `package.json`. 30 | 31 | | Developer/Project | Description | Usage | 32 | |---------------------------------------------------------------------|----------------|--------------| 33 | | [QianJianTech/LaTeXLive](https://github.com/QianJianTech/LaTeXLive) | $\LaTeX$ formula editor; Apache-2.0 license | Save SVG formula as PNG | -------------------------------------------------------------------------------- /src/worker/baseProcessor.ts: -------------------------------------------------------------------------------- 1 | import { logPush } from "@/logger"; 2 | import { isValidStr } from "@/utils/commonCheck"; 3 | import { lang } from "@/utils/lang"; 4 | import { IEventBusMap } from "siyuan"; 5 | 6 | export abstract class BaseCopyProcessor { 7 | /** 8 | * 验证当前块是否可以被当前处理器处理 9 | * @param eventDetail IEventBusMap["open-noneditableblock"] 10 | * @returns boolean 表示是否可以当前处理函数处理 11 | */ 12 | public abstract test(eventDetail: IEventBusMap["open-noneditableblock"]): boolean; 13 | 14 | public abstract doAddButton(eventDetail: IEventBusMap["open-noneditableblock"]); 15 | /** 16 | * 获取一个封装的图标 17 | */ 18 | public createButton(dataType:string, langKey:string, svgIconId:string): HTMLElement { 19 | if (!isValidStr(svgIconId)) { 20 | svgIconId = "ogiconCopyImage"; 21 | } 22 | const copyPngBtn = document.createElement("button"); 23 | copyPngBtn.classList.add(..."block__icon block__icon--show b3-tooltips b3-tooltips__nw".split(" ")); 24 | copyPngBtn.setAttribute("data-type", dataType); 25 | copyPngBtn.setAttribute("aria-label", lang(langKey)); 26 | copyPngBtn.innerHTML = ``; 27 | return copyPngBtn; 28 | } 29 | 30 | public addButtonAfter(existElem: HTMLElement, buttonList: HTMLElement[]) { 31 | for (let i = buttonList.length-1; i >=0; i--) { 32 | logPush("add", buttonList[i]); 33 | existElem.insertAdjacentElement("afterend", buttonList[i]); 34 | existElem.insertAdjacentHTML("afterend", ``); 35 | } 36 | } 37 | 38 | public insertFlag(eventDetail: IEventBusMap["open-noneditableblock"]) { 39 | eventDetail.toolbar.subElement.children[0].setAttribute("data-og-added-flag", "true"); 40 | } 41 | public isInserted(eventDetail: IEventBusMap["open-noneditableblock"]) { 42 | return eventDetail.toolbar.subElement.children[0].getAttribute("data-og-added-flag") == null ? false:true; 43 | } 44 | } -------------------------------------------------------------------------------- /src/worker/mermaidProcessor.ts: -------------------------------------------------------------------------------- 1 | import { IEventBusMap } from "siyuan"; 2 | import { BaseCopyProcessor } from "./baseProcessor"; 3 | import { logPush } from "@/logger"; 4 | import { checkClipboard, copyImageToClipboard, downloadImageFromCanvas, downloadSVG, getCanvasFromSVG } from "@/utils/onlyThisUtils"; 5 | 6 | export class MermaidCopyProcessor extends BaseCopyProcessor { 7 | public async doAddButton(eventDetail: IEventBusMap["open-noneditableblock"]): Promise { 8 | const renderElement = eventDetail.renderElement; 9 | const copyPngBtn = this.createButton("og-copy-png", "copy_as_png", "ogiconCopyImage"); 10 | copyPngBtn.onclick = (event)=>{ 11 | logPush("Clicked", renderElement.querySelector(`.protyle-icons + div svg`)); 12 | getCanvasFromSVG(renderElement.querySelector(`.protyle-icons + div svg`), (canvas)=>{ 13 | if (event.shiftKey || !checkClipboard(true)) { 14 | downloadImageFromCanvas(canvas); 15 | } else { 16 | copyImageToClipboard(canvas); 17 | } 18 | }); 19 | // downloadPNG(event.detail.renderElement.querySelector(`.protyle-icons + div svg`)) 20 | }; 21 | const copySVGBtn = this.createButton("og-copy-svg", "download_svg", "ogiconImageDown"); 22 | copySVGBtn.onclick = (event) => { 23 | downloadSVG(renderElement.querySelector(`.protyle-icons + div svg`)); 24 | } 25 | this.addButtonAfter(eventDetail.toolbar.subElement.querySelector("[data-type='export']"), 26 | [copyPngBtn, copySVGBtn]); 27 | return true; 28 | } 29 | /** 30 | * 验证当前块是否可以被当前处理器处理 31 | * @param eventDetail IEventBusMap["open-noneditableblock"] 32 | * @returns boolean 表示是否可以当前处理函数处理 33 | */ 34 | public test(eventDetail: IEventBusMap["open-noneditableblock"]): boolean { 35 | const renderElement = eventDetail.renderElement; 36 | return ["mermaid", "flowchart", "graphviz"].includes(renderElement.getAttribute("data-subtype")); 37 | } 38 | } -------------------------------------------------------------------------------- /.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 }} -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report_zh_cn.yml: -------------------------------------------------------------------------------- 1 | name: 问题反馈 2 | description: 提交非预期行为、错误或缺陷报告 3 | assignees: [] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | 感谢提交问题反馈!请尽可能详细地填写以下内容,以帮助我们理解和解决问题。 9 | 10 | - type: textarea 11 | id: problem-description 12 | attributes: 13 | label: 问题现象 14 | description: 尽可能详细地描述问题的表现。 15 | placeholder: 在此描述问题... 16 | validations: 17 | required: true 18 | 19 | - type: markdown 20 | attributes: 21 | value: | 22 | 如果问题不能通过某些步骤稳定重现,请在错误发生时关注`Ctrl+Shift+I`开发者工具中的`Console`/`控制台`中的有关提示信息,将遇到问题时的错误信息截图上传。 23 | 24 | - type: textarea 25 | id: reproduce-steps 26 | attributes: 27 | label: 复现操作 28 | description: 描述重现问题所需要的步骤或设置项。如果不能稳定重现,请说明问题的发生频率,并上传错误提示信息。 29 | placeholder: | 30 | 1. 打开插件的xxx功能; 31 | 2. 打开xxx文档; 32 | 3. 问题出现; 33 | validations: 34 | required: true 35 | 36 | - type: textarea 37 | id: screenshots-or-recordings 38 | attributes: 39 | label: 截图或录屏说明 40 | description: 请上传截图或录屏来演示问题。(可不填) 41 | placeholder: 在此提供截图或录屏... 42 | 43 | - type: textarea 44 | id: expected-behavior 45 | attributes: 46 | label: 预期行为 47 | description: 描述你认为插件应当表现出怎样的行为或结果(可不填) 48 | placeholder: 在此描述预期行为... 49 | 50 | - type: textarea 51 | attributes: 52 | label: 设备和系统信息 53 | description: | 54 | 示例: 55 | - **操作系统**: Windows11 24H2 56 | - **Siyuan**:v3.1.25 57 | - **插件版本**:v0.2.0 58 | value: | 59 | - 操作系统: 60 | - Siyuan: 61 | - 插件版本: 62 | render: markdown 63 | validations: 64 | required: true 65 | 66 | - type: checkboxes 67 | id: check_list 68 | attributes: 69 | label: 检查单 70 | description: 在提交前,请确认这些事项 71 | options: 72 | - label: 我已经查询了issue列表,我认为没有人反馈过类似问题 73 | required: true 74 | - label: 我已经将插件升级到最新版本 75 | required: true 76 | 77 | - type: textarea 78 | id: additional-info 79 | attributes: 80 | label: 其他补充信息 81 | description: 如有其他相关信息,请在此提供。例如插件设置、思源设置等。 82 | placeholder: 在此提供补充信息... 83 | -------------------------------------------------------------------------------- /src/worker/ABCProcessor.ts: -------------------------------------------------------------------------------- 1 | import { IEventBusMap } from "siyuan"; 2 | import { BaseCopyProcessor } from "./baseProcessor"; 3 | import { errorPush, logPush } from "@/logger"; 4 | import { checkClipboard, copyImageToClipboard, downloadImageFromCanvas, downloadSVG, getCanvasFromSVG } from "@/utils/onlyThisUtils"; 5 | 6 | export class ABCProcessor extends BaseCopyProcessor { 7 | public test(eventDetail: IEventBusMap["open-noneditableblock"]): boolean { 8 | const renderElement = eventDetail.renderElement; 9 | return ["abc"].includes(renderElement.getAttribute("data-subtype")); 10 | } 11 | 12 | public doAddButton(eventDetail: IEventBusMap["open-noneditableblock"]) { 13 | const btnElem = this.createButton("og-copy-png", "copy_as_png", ""); 14 | btnElem.addEventListener("click", (event)=>{ 15 | this.copyItem(eventDetail, event.shiftKey || !checkClipboard(true)); 16 | }); 17 | const copySVGBtn = this.createButton("og-copy-svg", "download_svg", "ogiconImageDown"); 18 | copySVGBtn.onclick = ()=>{ 19 | const node = this.getSVG(eventDetail); 20 | if (node) { 21 | downloadSVG(node); 22 | } 23 | }; 24 | this.addButtonAfter(eventDetail.toolbar.subElement.querySelector("[data-type='export']"), 25 | [btnElem, copySVGBtn]); 26 | } 27 | 28 | private getSVG(eventDetail: IEventBusMap["open-noneditableblock"]) { 29 | const renderElement = eventDetail.renderElement; 30 | const svgElement = renderElement.querySelector(".protyle-icons + div svg"); 31 | svgElement.setAttribute("xmlns", "http://www.w3.org/2000/svg"); 32 | logPush("svg", svgElement); 33 | return svgElement; 34 | } 35 | 36 | public async copyItem(eventDetail: IEventBusMap["open-noneditableblock"], download:boolean): Promise { 37 | const svgElement = this.getSVG(eventDetail); 38 | logPush("svg", svgElement); 39 | getCanvasFromSVG(svgElement, (canvas)=>{ 40 | if (download) { 41 | downloadImageFromCanvas(canvas); 42 | } else { 43 | copyImageToClipboard(canvas); 44 | } 45 | }); 46 | return true; 47 | } 48 | } -------------------------------------------------------------------------------- /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/hello.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 45 | 46 | 72 | 73 | 101 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Submit a report for unexpected behavior, errors, or defects 3 | assignees: [] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for submitting a bug report! Please provide as much detail as possible to help us understand and resolve the issue. 9 | 10 | - type: textarea 11 | id: problem-description 12 | attributes: 13 | label: Issue Description 14 | description: Describe the issue in as much detail as possible. 15 | placeholder: Describe the issue here... 16 | validations: 17 | required: true 18 | 19 | - type: markdown 20 | attributes: 21 | value: | 22 | If the issue cannot be consistently reproduced through specific steps, please check the `Console` in `Ctrl+Shift+I` Developer Tools for relevant messages and upload a screenshot of the error when the error occurs. 23 | 24 | - type: textarea 25 | id: reproduce-steps 26 | attributes: 27 | label: Steps to Reproduce 28 | description: Describe the steps or settings required to reproduce the issue. If it cannot be consistently reproduced, specify the frequency of occurrence and upload error messages if available. 29 | placeholder: | 30 | 1. Open the xxx feature in the plugin; 31 | 2. Open the xxx document; 32 | 3. The issue occurs; 33 | validations: 34 | required: true 35 | 36 | - type: textarea 37 | id: screenshots-or-recordings 38 | attributes: 39 | label: Screenshots or Recordings 40 | description: Please upload screenshots or recordings to demonstrate the issue. (Optional) 41 | placeholder: Provide screenshots or recordings here... 42 | 43 | - type: textarea 44 | id: expected-behavior 45 | attributes: 46 | label: Expected Behavior 47 | description: Describe what you expect the plugin to do or the correct outcome. (Optional) 48 | placeholder: Describe the expected behavior here... 49 | 50 | - type: textarea 51 | attributes: 52 | label: Device and System Information 53 | description: | 54 | Example: 55 | - **Operating System**: Windows 11 24H2 56 | - **Siyuan**: v3.1.25 57 | - **Plugin Version**: v0.2.0 58 | value: | 59 | - Operating System: 60 | - Siyuan: 61 | - Plugin Version: 62 | render: markdown 63 | validations: 64 | required: true 65 | 66 | - type: checkboxes 67 | id: check_list 68 | attributes: 69 | label: Checklist 70 | description: Before submitting, please confirm the following 71 | options: 72 | - label: I have searched the issue list and found no similar reports 73 | required: true 74 | - label: I have updated the plugin to the latest version 75 | required: true 76 | 77 | - type: textarea 78 | id: additional-info 79 | attributes: 80 | label: Additional Information 81 | description: Provide any other relevant details here, such as plugin settings or Siyuan settings. 82 | placeholder: Provide additional information here... -------------------------------------------------------------------------------- /src/worker/mathProcessor.ts: -------------------------------------------------------------------------------- 1 | import { IEventBusMap } from "siyuan"; 2 | import { BaseCopyProcessor } from "./baseProcessor"; 3 | import { logPush } from "@/logger"; 4 | import { htmlTransferParser } from "@/utils/stringUtils"; 5 | import { checkClipboard, convertToMathML, copyImageToClipboard, copyPlainTextToClipboard, copySVG, downloadImageFromCanvas, downloadSVG, getCanvasFromSVG, mathmlToSvg } from "@/utils/onlyThisUtils"; 6 | 7 | export class MathCopyProcessor extends BaseCopyProcessor { 8 | public async doAddButton(eventDetail: IEventBusMap["open-noneditableblock"]): Promise { 9 | const renderElement = eventDetail.renderElement; 10 | const copyPngBtn = this.createButton("og-copy-png", "copy_as_png", "ogiconCopyImage"); 11 | copyPngBtn.onclick = async (event) => { 12 | logPush("公式content", htmlTransferParser(renderElement.dataset["content"])); 13 | const mathml = convertToMathML(htmlTransferParser(renderElement.dataset["content"])); 14 | logPush("mathml", mathml); 15 | const svgE = await mathmlToSvg(mathml); 16 | logPush("svgE", svgE); 17 | getCanvasFromSVG(svgE, (canvas) => { 18 | if (event.shiftKey || !checkClipboard(true)) { 19 | downloadImageFromCanvas(canvas) 20 | } else { 21 | copyImageToClipboard(canvas); 22 | } 23 | }); 24 | }; 25 | const copyMathMLBtn = this.createButton("og-copy-mathml", "copy_as_mathml", "ogiconSquareFunction"); 26 | copyMathMLBtn.onclick = async () => { 27 | logPush("公式content", htmlTransferParser(renderElement.dataset["content"])); 28 | const mathml = convertToMathML(htmlTransferParser(renderElement.dataset["content"])); 29 | logPush("mathml", mathml); 30 | copyPlainTextToClipboard(mathml); 31 | }; 32 | const copySVGBtn = this.createButton("og-copy-svg", "download_svg", "ogiconImageDown"); 33 | copySVGBtn.onclick = async (event) => { 34 | logPush("公式content", htmlTransferParser(renderElement.dataset["content"])); 35 | const mathml = convertToMathML(htmlTransferParser(renderElement.dataset["content"])); 36 | logPush("mathml", mathml); 37 | const svgE = await mathmlToSvg(mathml); 38 | downloadSVG(svgE); 39 | // if (event.shiftKey || !checkClipboard()) { 40 | // downloadSVG(svgE); 41 | // } else { 42 | // copySVG(svgE); 43 | // } 44 | } 45 | this.addButtonAfter(eventDetail.toolbar.subElement.querySelector("[data-type='export']"), 46 | [copyPngBtn, copyMathMLBtn, copySVGBtn]); 47 | return true; 48 | } 49 | /** 50 | * 验证当前块是否可以被当前处理器处理 51 | * @param eventDetail IEventBusMap["open-noneditableblock"] 52 | * @returns boolean 表示是否可以当前处理函数处理 53 | */ 54 | public test(eventDetail: IEventBusMap["open-noneditableblock"]): boolean { 55 | const renderElement = eventDetail.renderElement; 56 | return ["inline-math", "NodeMathBlock"].includes(renderElement.getAttribute("data-type")); 57 | } 58 | } -------------------------------------------------------------------------------- /src/worker/plantUMLProcessor.ts: -------------------------------------------------------------------------------- 1 | import { IEventBusMap, showMessage } from "siyuan"; 2 | import { BaseCopyProcessor } from "./baseProcessor"; 3 | import { errorPush, logPush } from "@/logger"; 4 | import { checkClipboard, copyImageToClipboard, downloadImageFromCanvas, downloadSVG, getCanvasFromSVG } from "@/utils/onlyThisUtils"; 5 | import { showPluginMessage } from "@/utils/common"; 6 | 7 | export class PlantUMLProcessor extends BaseCopyProcessor { 8 | public test(eventDetail: IEventBusMap["open-noneditableblock"]): boolean { 9 | const renderElement = eventDetail.renderElement; 10 | return ["plantuml"].includes(renderElement.getAttribute("data-subtype")); 11 | } 12 | 13 | public doAddButton(eventDetail: IEventBusMap["open-noneditableblock"]) { 14 | const btnElem = this.createButton("og-copy-png", "copy_as_png", ""); 15 | btnElem.addEventListener("click", (event)=>{ 16 | this.copyItem(eventDetail, event.shiftKey || !checkClipboard(true)); 17 | }); 18 | const copySVGBtn = this.createButton("og-copy-svg", "download_svg", "ogiconImageDown"); 19 | copySVGBtn.onclick = ()=>{ 20 | this.getSVG(eventDetail).then((result)=>{ 21 | if (result) { 22 | downloadSVG(result); 23 | } else { 24 | showPluginMessage("error:svg"); 25 | } 26 | }).catch((err)=>{ 27 | showPluginMessage("error:download_uml"); 28 | errorPush(err); 29 | }); 30 | }; 31 | this.addButtonAfter(eventDetail.toolbar.subElement.querySelector("[data-type='export']"), 32 | [btnElem, copySVGBtn]); 33 | } 34 | 35 | private async getSVG(eventDetail: IEventBusMap["open-noneditableblock"]): Promise { 36 | const renderElement = eventDetail.renderElement; 37 | const objectE = renderElement.querySelector(".protyle-icons + div object"); 38 | const dataUrl = objectE.getAttribute("data"); 39 | let documentNode: Element = null; 40 | try { 41 | const response = await fetch(dataUrl); 42 | const html = await response.text(); 43 | let parser = new DOMParser(); 44 | let doc = parser.parseFromString(html, 'text/html'); 45 | documentNode = doc.documentElement; 46 | documentNode = documentNode.querySelector("svg"); 47 | } catch (error) { 48 | errorPush('无法访问URL内容:', error); 49 | return null; 50 | } 51 | documentNode.removeAttribute("preserveAspectRatio"); 52 | logPush("svg", documentNode); 53 | return documentNode as HTMLElement; 54 | } 55 | 56 | public async copyItem(eventDetail: IEventBusMap["open-noneditableblock"], download: boolean): Promise { 57 | const documentNode = await this.getSVG(eventDetail); 58 | if (documentNode == null) { 59 | return false; 60 | } 61 | getCanvasFromSVG(documentNode, (canvas)=>{ 62 | if (download) { 63 | downloadImageFromCanvas(canvas); 64 | } else { 65 | copyImageToClipboard(canvas); 66 | } 67 | }); 68 | documentNode.remove(); 69 | return true; 70 | } 71 | } -------------------------------------------------------------------------------- /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 | 21 | const STORAGE_NAME = "menu-config"; 22 | 23 | /** 24 | * 25 | * @license AGPL-3.0 26 | */ 27 | export default class OGCopyPngPlugin extends Plugin { 28 | private myEventHandler: EventHandler; 29 | 30 | async onload() { 31 | this.data[STORAGE_NAME] = {readonlyText: "Readonly"}; 32 | logPush("测试", this.i18n); 33 | setLanguage(this.i18n); 34 | setPluginInstance(this); 35 | initSettingProperty(); 36 | bindCommand(this); 37 | // 载入设置项,此项必须在setPluginInstance之后被调用 38 | this.myEventHandler = new EventHandler(); 39 | 40 | this.addIcons(` 41 | 42 | `); 43 | this.addIcons(``); 44 | } 45 | 46 | onLayoutReady(): void { 47 | loadSettings().then(()=>{ 48 | this.myEventHandler.bindHandler(); 49 | setStyle(); 50 | }).catch((e)=>{ 51 | showMessage("复制为图片载入设置项失败。Load plugin settings faild. syplugin-copyAsImg"); 52 | errorPush(e); 53 | }); 54 | } 55 | 56 | onunload(): void { 57 | // 善后 58 | this.myEventHandler.unbindHandler(); 59 | // 移除所有已经插入的导航区 60 | removeStyle(); 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 | 72 | // openSetting() { 73 | // // 生成Dialog内容 74 | // const uid = generateUUID(); 75 | // // 创建dialog 76 | // const app = createApp(settingVue); 77 | // const settingDialog = new siyuan.Dialog({ 78 | // "title": this.i18n["setting_panel_title"], 79 | // "content": ` 80 | //
81 | // `, 82 | // "width": isMobile() ? "92vw":"1040px", 83 | // "height": isMobile() ? "50vw":"80vh", 84 | // "destroyCallback": ()=>{app.unmount(); }, 85 | // }); 86 | // app.mount(`#og_plugintemplate_${uid}`); 87 | // } 88 | } 89 | 90 | function isMobile() { 91 | return window.top.document.getElementById("sidebar") ? true : false; 92 | }; 93 | -------------------------------------------------------------------------------- /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(lang(`setting_column_none_name`)); 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 | } -------------------------------------------------------------------------------- /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 | base: "/plugins/syplugin-copyAsImage", 26 | resolve: { 27 | alias: { 28 | "@": resolve(__dirname, "src"), 29 | } 30 | }, 31 | 32 | plugins: [ 33 | vue(), 34 | viteStaticCopy({ 35 | targets: [ 36 | { 37 | src: "./README*.md", 38 | dest: "./", 39 | }, 40 | { 41 | src: "./icon.png", 42 | dest: "./", 43 | }, 44 | { 45 | src: "./preview.png", 46 | dest: "./", 47 | }, 48 | { 49 | src: "./plugin.json", 50 | dest: "./", 51 | }, 52 | { 53 | src: "./src/i18n/**", 54 | dest: "./i18n/", 55 | }, 56 | { 57 | src: "./LICENSE", 58 | dest: "./" 59 | }, 60 | { 61 | src: "./CHANGELOG.md", 62 | dest: "./" 63 | } 64 | ], 65 | }), 66 | ], 67 | 68 | // https://github.com/vitejs/vite/issues/1930 69 | // https://vitejs.dev/guide/env-and-mode.html#env-files 70 | // https://github.com/vitejs/vite/discussions/3058#discussioncomment-2115319 71 | // 在这里自定义变量 72 | define: { 73 | "process.env": process.env, 74 | "process.env.DEV_MODE": `"${isWatch}"`, 75 | __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, 76 | }, 77 | 78 | build: { 79 | // 输出路径 80 | outDir: distDir, 81 | emptyOutDir: false, 82 | 83 | // 构建后是否生成 source map 文件 84 | sourcemap: false, 85 | 86 | // 设置为 false 可以禁用最小化混淆 87 | // 或是用来指定是应用哪种混淆器 88 | // boolean | 'terser' | 'esbuild' 89 | // 不压缩,用于调试 90 | minify: !isWatch, 91 | 92 | lib: { 93 | // Could also be a dictionary or array of multiple entry points 94 | entry: resolve(__dirname, "src/index.ts"), 95 | // the proper extensions will be added 96 | fileName: "index", 97 | formats: ["cjs"], 98 | }, 99 | rollupOptions: { 100 | plugins: [ 101 | ...( 102 | isWatch ? [ 103 | livereload(devDistDir), 104 | { 105 | //监听静态资源文件 106 | name: 'watch-external', 107 | async buildStart() { 108 | const files = await fg([ 109 | 'src/i18n/*.json', 110 | './README*.md', 111 | './widget.json' 112 | ]); 113 | for (let file of files) { 114 | this.addWatchFile(file); 115 | } 116 | } 117 | } 118 | ] : [ 119 | zipPack({ 120 | inDir: './dist', 121 | outDir: './', 122 | outFileName: 'package.zip' 123 | }) 124 | ] 125 | ) 126 | ], 127 | 128 | // make sure to externalize deps that shouldn't be bundled 129 | // into your library 130 | external: ["siyuan", "process"], 131 | 132 | output: { 133 | entryFileNames: "[name].js", 134 | assetFileNames: (assetInfo) => { 135 | if (assetInfo.name === "style.css") { 136 | return "index.css" 137 | } 138 | return assetInfo.name 139 | }, 140 | }, 141 | }, 142 | } 143 | }) 144 | -------------------------------------------------------------------------------- /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, Menu, showMessage } from "siyuan"; 5 | import { logPush } from "@/logger"; 6 | import { PlantUMLProcessor } from "@/worker/plantUMLProcessor"; 7 | import { ABCProcessor } from "@/worker/ABCProcessor"; 8 | import { CanvasProcessor } from "@/worker/canvasProcessor"; 9 | import { MermaidCopyProcessor } from "@/worker/mermaidProcessor"; 10 | import { MathCopyProcessor } from "@/worker/mathProcessor"; 11 | import { toPng, toSvg } from "html-to-image"; 12 | import { copyImageBase64URLToClipboard, copyImageToClipboard, downloadImageBase64URL, getCanvasFromSVG, handleSVGStringToSVGElement } from "@/utils/onlyThisUtils"; 13 | import { lang } from "@/utils/lang"; 14 | import { showPluginMessage } from "@/utils/common"; 15 | export default class EventHandler { 16 | private handlerBindList: Recordvoid> = { 17 | "open-noneditableblock": this.bindActionEntry.bind(this), // mutex需要访问EventHandler的属性 18 | "click-blockicon": this.copyAsImg.bind(this), 19 | }; 20 | // 关联的设置项,如果设置项对应为true,则才执行绑定 21 | private relateGsettingKeyStr: Record = { 22 | "loaded-protyle-static": null, // mutex需要访问EventHandler的属性 23 | "switch-protyle": null, 24 | "ws-main": "immediatelyUpdate", 25 | }; 26 | 27 | private loadAndSwitchMutex: Mutex; 28 | private simpleMutex: number = 0; 29 | private docIdMutex: Record = {}; 30 | constructor() { 31 | this.loadAndSwitchMutex = new Mutex(); 32 | } 33 | 34 | bindHandler() { 35 | const plugin = getPluginInstance(); 36 | const g_setting = getReadOnlyGSettings(); 37 | // const g_setting = getReadOnlyGSettings(); 38 | for (let key in this.handlerBindList) { 39 | if (this.relateGsettingKeyStr[key] == null || g_setting[this.relateGsettingKeyStr[key]]) { 40 | plugin.eventBus.on(key, this.handlerBindList[key]); 41 | } 42 | } 43 | 44 | } 45 | 46 | unbindHandler() { 47 | const plugin = getPluginInstance(); 48 | for (let key in this.handlerBindList) { 49 | plugin.eventBus.off(key, this.handlerBindList[key]); 50 | } 51 | } 52 | 53 | async bindActionEntry(event: CustomEvent) { 54 | // do sth 55 | logPush("点击了不可编辑块", event.detail); 56 | const processorList = [new PlantUMLProcessor(), new ABCProcessor(), new CanvasProcessor(), new MermaidCopyProcessor(), new MathCopyProcessor]; 57 | 58 | for (const processor of processorList) { 59 | if (processor.test(event.detail)) { 60 | if (!processor.isInserted(event.detail)) { 61 | processor.doAddButton(event.detail); 62 | processor.insertFlag(event.detail); 63 | } 64 | } 65 | } 66 | } 67 | 68 | async copyAsImg(event: CustomEvent) { 69 | const htmlElements = event.detail.blockElements; 70 | logPush("click-block-icon", htmlElements, event.detail.menu); 71 | event.detail.menu.addItem({ 72 | "label": lang("copy_as_png_only"), 73 | "click": (element, event)=>{ 74 | logPush("clicked", element); 75 | const targetElement = htmlElements[0]; 76 | if (targetElement.classList.contains("render-node")) { 77 | showPluginMessage(lang("info:render_node")); 78 | } 79 | targetElement.classList.remove("protyle-wysiwyg--select"); 80 | // toPng(htmlElements[0]).then((result)=>{ 81 | // logPush("clicked", result); 82 | 83 | // copyImageBase64URLToClipboard(result); 84 | // }); 85 | // 86 | logPush("svgs", htmlElements); 87 | toSvg(htmlElements[0]).then((result)=>{ 88 | const svg = handleSVGStringToSVGElement(result); 89 | const foreignObject = svg.querySelector('foreignObject'); 90 | const scale = 1.1; 91 | foreignObject.childNodes[0].setAttribute('transform', `scale(${scale})`); 92 | foreignObject.childNodes[0].setAttribute("style", "max-width: 1080px") 93 | svg.setAttribute("style", "max-width: 1080px") 94 | // 需要更新 SVG 的宽高以匹配缩放后的显示区域 95 | // svg.setAttribute('width', parseInt( svg.getAttribute("width")) * scale); 96 | // svg.setAttribute('height', parseInt(svg.getAttribute("height")) * scale); 97 | logPush("clicked", svg); 98 | getCanvasFromSVG(svg, (canvas)=>{ 99 | copyImageToClipboard(canvas); 100 | }, false); 101 | }) 102 | } 103 | }); 104 | } 105 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/components/settings/setting.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 104 | 105 | -------------------------------------------------------------------------------- /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/syapi/custom.ts: -------------------------------------------------------------------------------- 1 | import * as siyuanAPIs from "siyuan"; 2 | import { debugPush } from "@/logger"; 3 | import { queryAPI, listDocsByPathT, getTreeStat, getCurrentDocIdF } from "."; 4 | import { isValidStr } from "@/utils/commonCheck"; 5 | /** 6 | * 统计子文档字符数 7 | * @param {*} childDocs 8 | * @returns 9 | */ 10 | export async function getChildDocumentsWordCount(docId:string) { 11 | const sqlResult = await queryAPI(` 12 | SELECT SUM(length) AS count 13 | FROM blocks 14 | WHERE 15 | path like "%/${docId}/%" 16 | AND 17 | type in ("p", "h", "c", "t") 18 | `); 19 | if (sqlResult[0].count) { 20 | return sqlResult[0].count; 21 | } 22 | return 0; 23 | // let totalWords = 0; 24 | // let docCount = 0; 25 | // for (let childDoc of childDocs) { 26 | // let tempWordsResult = await getTreeStat(childDoc.id); 27 | // totalWords += tempWordsResult.wordCount; 28 | // childDoc["wordCount"] = tempWordsResult.wordCount; 29 | // docCount++; 30 | // if (docCount > 128) { 31 | // totalWords = `${totalWords}+`; 32 | // break; 33 | // } 34 | // } 35 | // return [childDocs, totalWords]; 36 | } 37 | 38 | export async function getChildDocuments(sqlResult:SqlResult, maxListCount: number): Promise { 39 | let childDocs = await listDocsByPathT({path: sqlResult.path, notebook: sqlResult.box, maxListCount: maxListCount}); 40 | return childDocs; 41 | } 42 | 43 | export async function isChildDocExist(id: string) { 44 | const sqlResponse = await queryAPI(` 45 | SELECT * FROM blocks WHERE path like '%${id}/%' LIMIT 3 46 | `); 47 | if (sqlResponse && sqlResponse.length > 0) { 48 | return true; 49 | } 50 | return false; 51 | } 52 | 53 | export async function isDocHasAv(docId: string) { 54 | let sqlResult = await queryAPI(` 55 | SELECT count(*) as avcount FROM blocks WHERE root_id = '${docId}' 56 | AND type = 'av' 57 | `); 58 | if (sqlResult.length > 0 && sqlResult[0].avcount > 0) { 59 | return true; 60 | } else { 61 | 62 | return false; 63 | } 64 | } 65 | 66 | export async function isDocEmpty(docId: string, blockCountThreshold = 0) { 67 | // 检查父文档是否为空 68 | let treeStat = await getTreeStat(docId); 69 | if (blockCountThreshold == 0 && treeStat.wordCount != 0 && treeStat.imageCount != 0) { 70 | debugPush("treeStat判定文档非空,不插入挂件"); 71 | return false; 72 | } 73 | if (blockCountThreshold != 0) { 74 | 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')`); 75 | if (blockCountSqlResult.length > 0) { 76 | if (blockCountSqlResult[0].bcount > blockCountThreshold) { 77 | return false; 78 | } else { 79 | return true; 80 | } 81 | } 82 | } 83 | 84 | let sqlResult = await queryAPI(`SELECT markdown FROM blocks WHERE 85 | root_id like '${docId}' 86 | AND type != 'd' 87 | AND (type != 'p' 88 | OR (type = 'p' AND length != 0) 89 | ) 90 | LIMIT 5`); 91 | if (sqlResult.length <= 0) { 92 | return true; 93 | } else { 94 | debugPush("sql判定文档非空,不插入挂件"); 95 | return false; 96 | } 97 | } 98 | 99 | export function getActiveDocProtyle() { 100 | const allProtyle = {}; 101 | window.siyuan.layout.centerLayout?.children?.forEach((wndItem) => { 102 | wndItem?.children?.forEach((tabItem) => { 103 | if (tabItem?.model) { 104 | allProtyle[tabItem?.id](tabItem.model?.editor?.protyle); 105 | } 106 | }); 107 | }); 108 | } 109 | 110 | export function getActiveEditorIds() { 111 | let result = []; 112 | let id = window.document.querySelector(`.layout__wnd--active [data-type="tab-header"].item--focus`)?.getAttribute("data-id"); 113 | if (id) return [id]; 114 | window.document.querySelectorAll(`[data-type="tab-header"].item--focus`).forEach(item=>{ 115 | let uid = item.getAttribute("data-id"); 116 | if (uid) result.push(uid); 117 | }); 118 | return result; 119 | } 120 | 121 | 122 | 123 | /** 124 | * 获取当前更新时间字符串 125 | * @returns 126 | */ 127 | export function getUpdateString(){ 128 | let nowDate = new Date(); 129 | let hours = nowDate.getHours(); 130 | let minutes = nowDate.getMinutes(); 131 | let seconds = nowDate.getSeconds(); 132 | hours = formatTime(hours); 133 | minutes = formatTime(minutes); 134 | seconds = formatTime(seconds); 135 | let timeStr = nowDate.toJSON().replace(new RegExp("-", "g"),"").substring(0, 8) + hours + minutes + seconds; 136 | return timeStr; 137 | function formatTime(num) { 138 | return num < 10 ? '0' + num : num; 139 | } 140 | } 141 | 142 | /** 143 | * 生成一个随机的块id 144 | * @returns 145 | */ 146 | export function generateBlockId(){ 147 | // @ts-ignore 148 | if (window?.Lute?.NewNodeID) { 149 | // @ts-ignore 150 | return window.Lute.NewNodeID(); 151 | } 152 | let timeStr = getUpdateString(); 153 | let alphabet = new Array(); 154 | for (let i = 48; i <= 57; i++) alphabet.push(String.fromCharCode(i)); 155 | for (let i = 97; i <= 122; i++) alphabet.push(String.fromCharCode(i)); 156 | let randomStr = ""; 157 | for (let i = 0; i < 7; i++){ 158 | randomStr += alphabet[Math.floor(Math.random() * alphabet.length)]; 159 | } 160 | let result = timeStr + "-" + randomStr; 161 | return result; 162 | } 163 | 164 | /** 165 | * 转换块属性对象为{: }格式IAL字符串 166 | * @param {*} attrData 其属性值应当为String类型 167 | * @returns 168 | */ 169 | export function transfromAttrToIAL(attrData) { 170 | let result = "{:"; 171 | for (let key in attrData) { 172 | result += ` ${key}=\"${attrData[key]}\"`; 173 | } 174 | result += "}"; 175 | if (result == "{:}") return null; 176 | return result; 177 | } 178 | 179 | 180 | export function removeCurrentTabF(docId?:string) { 181 | // 获取tabId 182 | if (!isValidStr(docId)) { 183 | docId = getCurrentDocIdF(true); 184 | } 185 | if (!isValidStr(docId)) { 186 | debugPush("错误的id或多个匹配id"); 187 | return; 188 | } 189 | // v3.1.11或以上 190 | if (siyuanAPIs?.getAllEditor) { 191 | const editor = siyuanAPIs.getAllEditor(); 192 | let protyle = null; 193 | for (let i = 0; i < editor.length; i++) { 194 | if (editor[i].protyle.block.rootID === docId) { 195 | protyle = editor[i].protyle; 196 | break; 197 | } 198 | } 199 | if (protyle) { 200 | if (protyle.model.headElement) { 201 | if (protyle.model.headElement.classList.contains("item--pin")) { 202 | debugPush("Pin页面,不关闭存在页签"); 203 | return; 204 | } 205 | } 206 | //id: string, closeAll = false, animate = true, isSaveLayout = true 207 | debugPush("关闭存在页签", protyle?.model?.parent?.parent, protyle.model?.parent?.id); 208 | protyle?.model?.parent?.parent?.removeTab(protyle.model?.parent?.id, false, false); 209 | } else { 210 | debugPush("没有找到对应的protyle,不关闭存在的页签"); 211 | return; 212 | } 213 | } else { // v3.1.10或以下 214 | return; 215 | } 216 | 217 | } -------------------------------------------------------------------------------- /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 | 13 | // const pluginInstance = getPluginInstance(); 14 | 15 | let setting: any = ref({}); 16 | interface IPluginSettings { 17 | 18 | }; 19 | let defaultSetting: IPluginSettings = { 20 | fontSize: 16, 21 | popupWindow: "" 22 | } 23 | 24 | 25 | let tabProperties: Array = [ 26 | 27 | ]; 28 | let updateTimeout: any = null; 29 | 30 | /** 31 | * 设置项初始化 32 | * 应该在语言文件载入完成后调用执行 33 | */ 34 | export function initSettingProperty() { 35 | tabProperties.push( 36 | new TabProperty({ 37 | key: "general", iconKey: "iconSettings", props: [ 38 | new ConfigProperty({ key: "fontSize", type: "NUMBER" }), 39 | new ConfigProperty({ key: "popupWindow", type: "SWITCH" }), 40 | new ConfigProperty({ key: "icon", type: "SELECT", options: ["all", "none", "part"] }), 41 | ] 42 | }), 43 | new TabProperty({ 44 | key: "appearance", iconKey: "iconTheme", props: [ 45 | new ConfigProperty({ key: "sameWidthColumn", type: "NUMBER" }), 46 | new ConfigProperty({ key: "parentBoxCSS", type: "TEXTAREA" }), 47 | ] 48 | }) 49 | ); 50 | } 51 | 52 | export function getTabProperties() { 53 | return tabProperties; 54 | } 55 | 56 | // 发生变动之后,由界面调用这里 57 | export function saveSettings(newSettings: any) { 58 | // 如果有必要,需要判断当前设备,然后选择保存位置 59 | debugPush("界面调起保存设置项", newSettings); 60 | getPluginInstance().saveData("settings_main.json", JSON.stringify(newSettings, null, 4)); 61 | } 62 | 63 | 64 | /** 65 | * 仅用于初始化时载入设置项 66 | * 请不要重复使用 67 | * @returns 68 | */ 69 | export async function loadSettings() { 70 | let loadResult = null; 71 | // 这里从文件载入 72 | loadResult = await getPluginInstance().loadData("settings_main.json"); 73 | debugPush("文件载入设置", loadResult); 74 | if (loadResult == undefined || loadResult == "") { 75 | let oldSettings = await transferOldSetting(); 76 | debugPush("oldSettings", oldSettings); 77 | if (oldSettings != null) { 78 | debugPush("使用转换后的旧设置", oldSettings); 79 | loadResult = oldSettings; 80 | } else { 81 | loadResult = defaultSetting; 82 | } 83 | } 84 | const currentVersion = 20241219; 85 | if (!loadResult["@version"] || loadResult["@version"] < currentVersion) { 86 | // 旧版本 87 | loadResult["@version"] = currentVersion; 88 | if (siyuan.getAllEditor == null) { 89 | loadResult["immediatelyUpdate"] = false; 90 | } 91 | // 检查数组中指定设置和defaultSetting是否一致 92 | showOutdatedSettingWarnDialog(checkOutdatedSettings(loadResult), defaultSetting); 93 | } 94 | // showOutdatedSettingWarnDialog(checkOutdatedSettings(loadResult), defaultSetting); 95 | // 检查选项类设置项,如果发现不在列表中的,重置为默认 96 | try { 97 | loadResult = checkSettingType(loadResult); 98 | } catch(err) { 99 | logPush("设置项类型检查时发生错误", err); 100 | } 101 | 102 | // 如果有必要,判断设置项是否对当前设备生效 103 | // TODO: 对于Order,switch需要进行检查,防止版本问题导致选项不存在,不存在的用默认值 104 | // TODO: switch旧版需要迁移,另外引出迁移逻辑 105 | setting.value = Object.assign(defaultSetting, loadResult); 106 | logPush("载入设置项", setting.value); 107 | // return loadResult; 108 | watch(setting, (newVal) => { 109 | // 延迟更新 110 | if (updateTimeout) { 111 | clearTimeout(updateTimeout); 112 | } 113 | logPush("检查到变化"); 114 | updateTimeout = setTimeout(() => { 115 | // updateSingleSetting(key, newVal); 116 | saveSettings(newVal); 117 | // logPush("保存设置项", newVal); 118 | setStyle(); 119 | changeDebug(newVal); 120 | updateTimeout = null; 121 | }, 1000); 122 | }, {deep: true, immediate: false}); 123 | } 124 | 125 | function checkOutdatedSettings(loadSetting) { 126 | const CHECK_SETTING_KEYS = [ 127 | ]; 128 | let result = []; 129 | for (let key of CHECK_SETTING_KEYS) { 130 | if (loadSetting[key] != defaultSetting[key]) { 131 | result.push(key); 132 | } 133 | } 134 | return result; 135 | } 136 | 137 | function showOutdatedSettingWarnDialog(outdatedSettingKeys, defaultSettings) { 138 | if (outdatedSettingKeys.length == 0) { 139 | return; 140 | } 141 | const app = createApp(outdatedSettingVue, {"outdatedKeys": outdatedSettingKeys, "defaultSettings": defaultSettings}); 142 | const uid = generateUUID(); 143 | const settingDialog = new siyuan.Dialog({ 144 | "title": lang("dialog_panel_plugin_name") + lang("dialog_panel_outdate"), 145 | "content": ` 146 |
147 | `, 148 | "width": isMobile() ? "42vw":"520px", 149 | "height": isMobile() ? "auto":"auto", 150 | "destroyCallback": ()=>{app.unmount();}, 151 | }); 152 | app.mount(`#og_plugintemplate_${uid}`); 153 | return; 154 | } 155 | 156 | function changeDebug(newVal) { 157 | if (newVal.debugMode) { 158 | debugPush("调试模式已开启"); 159 | window.top["OpaqueGlassDebug"] = true; 160 | if (window.top["OpaqueGlassDebugV2"]["hn"]) { 161 | window.top["OpaqueGlassDebugV2"]["hn"] = 5; 162 | } else { 163 | window.top["OpaqueGlassDebugV2"] = { 164 | "hn": 5 165 | } 166 | } 167 | 168 | } else { 169 | debugPush("调试模式已关闭"); 170 | if (window.top["OpaqueGlassDebugV2"]["hn"]) { 171 | delete window.top["OpaqueGlassDebugV2"]["hn"]; 172 | } 173 | } 174 | } 175 | 176 | function checkSettingType(input:any) { 177 | const propertyMap = loadAllConfigPropertyFromTabProperty(tabProperties) 178 | for (const prop of Object.values(propertyMap)) { 179 | if (prop.type == "SELECT") { 180 | if (!prop.options.includes(input[prop.key])) { 181 | input[prop.key] = defaultSetting[prop.key]; 182 | } 183 | // input[prop.key] = String(input[prop.key]); 184 | } else if (prop.type == "ORDER") { 185 | const newOrder = []; 186 | for (const item of input[prop.key]) { 187 | if (Object.values(PRINTER_NAME).includes(item)) { 188 | newOrder.push(item); 189 | } 190 | } 191 | input[prop.key] = newOrder; 192 | } else if (prop.type == "SWITCH") { 193 | if (input[prop.key] == undefined) { 194 | input[prop.key] = defaultSetting[prop.key]; 195 | } 196 | } else if (prop.type == "NUMBER") { 197 | if (isValidStr(input[prop.key])) { 198 | input[prop.key] = parseFloat(input[prop.key]); 199 | } 200 | } 201 | } 202 | return input; 203 | } 204 | 205 | /** 206 | * 迁移、转换、覆盖设置项 207 | * @returns 修改后的新设置项 208 | */ 209 | async function transferOldSetting() { 210 | const oldSettings = await getPluginInstance().loadData("settings.json"); 211 | // 判断并迁移设置项 212 | let newSetting = Object.assign({}, oldSettings); 213 | if (oldSettings == null || oldSettings == "") { 214 | return null; 215 | } 216 | 217 | /* 这里是转换代码 */ 218 | 219 | // 移除过时的设置项 220 | for (let key of Object.keys(newSetting)) { 221 | if (!(key in defaultSetting)) { 222 | delete newSetting[key]; 223 | } 224 | } 225 | newSetting = Object.assign(defaultSetting, newSetting); 226 | 227 | return newSetting; 228 | } 229 | 230 | export function getGSettings() { 231 | // logPush("getConfig", setting.value, setting); 232 | // 改成 setting._rawValue不行 233 | return setting; 234 | } 235 | 236 | export function getReadOnlyGSettings() { 237 | return setting._rawValue; 238 | } 239 | 240 | export function getDefaultSettings() { 241 | return defaultSetting; 242 | } 243 | 244 | export function updateSingleSetting(key: string, value: any) { 245 | // 对照检查setting的类型 246 | // 直接绑定@change的话,value部分可能传回event 247 | // 如果700毫秒内没用重复调用,则执行保存 248 | 249 | } 250 | 251 | -------------------------------------------------------------------------------- /src/components/settings/items/order.vue: -------------------------------------------------------------------------------- 1 | 33 | 151 | -------------------------------------------------------------------------------- /src/utils/onlyThisUtils.ts: -------------------------------------------------------------------------------- 1 | import { debugPush, logPush, warnPush } from "@/logger"; 2 | import "@/utils/mathjax"; 3 | import "mathjax/es5/tex-mml-svg"; 4 | import { lang } from "./lang"; 5 | import { showPluginMessage } from "./common"; 6 | 7 | export function downloadSVG(svgElement) { 8 | let hiddenLink = document.createElement("a"); 9 | let svgHTMLCode = serializeSVG(svgElement) //filterSVGouterHTML(svgElement.outerHTML); 10 | let blob = new Blob([svgHTMLCode], { 11 | type: "image/svg+xml", 12 | }); 13 | hiddenLink.href = URL.createObjectURL(blob); 14 | hiddenLink.download = "export_SVG_" + new Date().toLocaleString() + ".svg"; 15 | hiddenLink.click(); 16 | } 17 | 18 | export function filterSVGouterHTML(str) { 19 | return str.replaceAll("
", "
"); 20 | } 21 | 22 | export function serializeSVG(svgElement) { 23 | let serializer = new XMLSerializer(); 24 | let svgXml = serializer.serializeToString(svgElement); 25 | return svgXml; 26 | } 27 | 28 | export async function copySVG(svgElement) { 29 | throw Error("Not implemented"); 30 | copyPlainTextToClipboard(svgElement.outerHTML); 31 | let svgHTMLCode = svgElement.outerHTML; 32 | const base64code = "data:image/svg+xml;base64," + utf8ToBase64(svgHTMLCode); 33 | logPush("writing to clipboard", base64code); 34 | const data = await fetch(base64code); 35 | const item = new ClipboardItem({ "image/svg+xml": data.blob()}); 36 | navigator.clipboard.write([item]); 37 | showPluginMessage(lang("success:copy")); 38 | } 39 | 40 | function utf8ToBase64(str) { 41 | return window.btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) { 42 | return String.fromCharCode(parseInt(p1, 16)); 43 | })); 44 | } 45 | 46 | export function handleSVGStringToSVGElement(dataUrl) { 47 | // 1. 提取编码后的 SVG 字符串(去掉前缀) 48 | const encodedSVG = dataUrl.split(',')[1]; 49 | 50 | // 2. 解码成原始 SVG XML 51 | const svgText = decodeURIComponent(encodedSVG); 52 | 53 | // 3. 使用 DOMParser 解析为 SVGElement 54 | const parser = new DOMParser(); 55 | const svgDoc = parser.parseFromString(svgText, "image/svg+xml"); 56 | const svgElement = svgDoc.documentElement; // 这就是 SVGElement! 57 | return svgElement; 58 | } 59 | 60 | export async function copyImageBase64URLToClipboard(dataUrl) { 61 | checkClipboard(); 62 | const res = await fetch(dataUrl); 63 | const blob = await res.blob(); 64 | 65 | const item = new ClipboardItem({ [blob.type]: blob }); 66 | await navigator.clipboard.write([item]); 67 | showPluginMessage(lang("success:copy")); 68 | } 69 | 70 | export async function downloadImageBase64URL(dataUrl:string) { 71 | const a = document.createElement("a"); 72 | a.href = dataUrl; 73 | a.download = "downloaded_image_" + new Date().toLocaleString()+ ".png"; 74 | document.body.appendChild(a); 75 | a.click(); 76 | document.body.removeChild(a); 77 | } 78 | 79 | /** 80 | * SVG to canvas 81 | * @param svgElement 82 | * @param callback 83 | * @param remvoeBG 84 | * 85 | * Generate/Modify From: (Under Apache-2.0 license) 86 | * https://github.com/QianJianTech/LaTeXLive/blob/3703d8fa4e1df598b3384c7ef60af3c3d00385ea/js/latex/action.js#L172-L241 87 | */ 88 | export function getCanvasFromSVG(svgElement, callback, removeBG=false) { 89 | // Clone the SVG element 90 | let svgClone = svgElement.cloneNode(true); 91 | 92 | // 获取 SVG 的原始尺寸 93 | let viewBox = svgElement.viewBox.baseVal; 94 | let origWidth = viewBox && viewBox.width ? viewBox.width : svgElement.clientWidth || 800; 95 | let origHeight = viewBox && viewBox.height ? viewBox.height : svgElement.clientHeight || 600; 96 | let aspectRatio = origWidth / origHeight; 97 | 98 | // 设置目标渲染尺寸(保持比例) 99 | let targetWidth = 1920; 100 | let targetHeight = Math.round(targetWidth / aspectRatio); 101 | 102 | svgClone.setAttribute("width", targetWidth + "px"); 103 | svgClone.setAttribute("height", targetHeight + "px"); 104 | 105 | // Convert the SVG to XML 106 | let svgXml = serializeSVG(svgClone); 107 | let image = new Image(); 108 | image.src = "data:image/svg+xml;base64," + utf8ToBase64(svgXml); 109 | 110 | image.onerror = function (e) { 111 | console.error("SVG image failed to load", e); 112 | }; 113 | 114 | image.onload = function () { 115 | // === 第一步:把 SVG 渲染到一个大画布上(保持比例) 116 | let scale = 2; // 提高分辨率因子(2 = 2倍分辨率,可调节) 117 | let canvas = document.createElement("canvas"); 118 | canvas.width = targetWidth * scale; 119 | canvas.height = targetHeight * scale; 120 | 121 | let context = canvas.getContext("2d"); 122 | context.drawImage(image, 0, 0, canvas.width, canvas.height); 123 | if (!removeBG) { 124 | callback(canvas, context); 125 | return; 126 | } 127 | // === 第二步:检测非透明区域(提交到工作线程) 128 | let imgData = context.getImageData(0, 0, canvas.width, canvas.height).data; 129 | const worker = new Worker(new URL('./imageProcessorWorker.js', import.meta.url)); 130 | worker.onmessage = (event) => { 131 | const { lOffset, rOffset, tOffset, bOffset } = event.data; 132 | // === 第三步:裁剪并输出(保持比例) 133 | let cropWidth = rOffset - lOffset; 134 | let cropHeight = bOffset - tOffset; 135 | 136 | let canvas2 = document.createElement("canvas"); 137 | canvas2.width = cropWidth; 138 | canvas2.height = cropHeight; 139 | let context2 = canvas2.getContext("2d"); 140 | 141 | // 裁剪部分原样绘制(不再强行拉伸) 142 | context2.drawImage( 143 | canvas, 144 | lOffset, 145 | tOffset, 146 | cropWidth, 147 | cropHeight, 148 | 0, 149 | 0, 150 | cropWidth, 151 | cropHeight 152 | ); 153 | 154 | callback(canvas2, context2); 155 | }; 156 | worker.postMessage({ imgData, width: canvas.width, height: canvas.height }); 157 | }; 158 | } 159 | 160 | export function downloadImageFromCanvas(canvas) { 161 | // Create a hidden link to download the resulting image 162 | let hiddenLink = document.createElement("a"); 163 | hiddenLink.href = canvas.toDataURL("image/png"); 164 | hiddenLink.download = "downloaded_image_" + new Date().toLocaleString()+ ".png"; 165 | hiddenLink.click(); 166 | } 167 | 168 | export function copyImageToClipboard(canvas) { 169 | checkClipboard(); 170 | canvas.toBlob(function (blob) { 171 | logPush("writing to clipboard"); 172 | const item = new ClipboardItem({ "image/png": blob }); 173 | navigator.clipboard.write([item]); 174 | showPluginMessage(lang("success:copy")); 175 | }); 176 | } 177 | 178 | export function copyPlainTextToClipboard(text) { 179 | checkClipboard(); 180 | const item = new ClipboardItem({ "text/plain": new Blob([text], { type: 'text/plain' }) }); 181 | navigator.clipboard.write([item]); 182 | showPluginMessage(lang("success:copy")); 183 | } 184 | 185 | export function checkClipboard(sendMessage = true) { 186 | if (!navigator.clipboard) { 187 | if (sendMessage) { 188 | showPluginMessage(lang("error:clipboard")); 189 | } 190 | return false; 191 | } 192 | return true; 193 | } 194 | 195 | export function convertToMathML(katexString) { 196 | try { 197 | // 使用 KaTeX 将 LaTeX 字符串转换为 MathML 198 | const katexOutput = window.katex.renderToString(katexString, { 199 | output: 'mathml', 200 | }); 201 | 202 | // 移除最外层的 标签 203 | const div = document.createElement('div'); 204 | div.innerHTML = katexOutput; 205 | const mathML = div.querySelector('math').outerHTML; 206 | 207 | return mathML; 208 | } catch (error) { 209 | console.error('Error converting to MathML:', error); 210 | return ''; 211 | } 212 | } 213 | 214 | 215 | export async function mathmlToSvg(mathmlString) { 216 | return new Promise((resolve, reject) => { 217 | // Ensure MathJax is loaded and configured 218 | if (typeof MathJax === 'undefined') { 219 | reject(new Error('MathJax is not loaded')); 220 | return; 221 | } 222 | 223 | const div = document.createElement('div'); 224 | div.setAttribute("id", "temp"); 225 | div.style.opacity = "0.0"; 226 | div.style.display = "overflow"; 227 | div.style.zIndex = "-999"; 228 | document.body.appendChild(div); 229 | div.innerHTML = mathmlString; 230 | MathJax.typesetPromise([div]).then(() => { 231 | const svg = div.querySelector('svg'); 232 | if (svg) { 233 | div.remove(); 234 | resolve(svg); 235 | } else { 236 | div.remove(); 237 | reject(new Error('SVG element not found')); 238 | } 239 | }).catch((err) => { 240 | reject(new Error('Error rendering MathML: ' + err.message)); 241 | }); 242 | }); 243 | } -------------------------------------------------------------------------------- /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 | import { lang } from "./lang"; 8 | 9 | export function getToken(): string { 10 | return ""; 11 | } 12 | 13 | export function showPluginMessage(message: string, timeout?: number, type?: "info" | "error"): void { 14 | const pluginName = lang("name"); 15 | const prefixedMessage = `${message} —— ${pluginName}`; 16 | showMessage(prefixedMessage, timeout, type); 17 | } 18 | 19 | /** 20 | * 在protyle所在的分屏中打开 21 | * @param event 22 | * @param protyleElem 23 | * @deprecated 24 | */ 25 | export function openRefLinkInProtyleWnd(protyleElem: IProtyle, openInFocus: boolean, event: MouseEvent) { 26 | logPush("debug", event, protyleElem); 27 | openRefLink(event, null, null, protyleElem, openInFocus); 28 | } 29 | 30 | /** 31 | * 休息一下,等待 32 | * @param time 单位毫秒 33 | * @returns 34 | */ 35 | export function sleep(time:number){ 36 | return new Promise((resolve) => setTimeout(resolve, time)); 37 | } 38 | 39 | export function getFocusedBlockId() { 40 | const focusedBlock = getFocusedBlock(); 41 | if (focusedBlock == null) { 42 | return null; 43 | } 44 | return focusedBlock.dataset.nodeId; 45 | } 46 | 47 | 48 | export function getFocusedBlock() { 49 | if (document.activeElement.classList.contains('protyle-wysiwyg')) { 50 | /* 光标在编辑区内 */ 51 | let block = window.getSelection()?.focusNode?.parentElement; // 当前光标 52 | while (block != null && block?.dataset?.nodeId == null) block = block.parentElement; 53 | return block; 54 | } 55 | else return null; 56 | } 57 | 58 | /** 59 | * 在点击时打开思源块/文档 60 | * 为引入本项目,和原代码相比有更改 61 | * @refer https://github.com/leolee9086/cc-template/blob/6909dac169e720d3354d77685d6cc705b1ae95be/baselib/src/commonFunctionsForSiyuan.js#L118-L141 62 | * @license 木兰宽松许可证 63 | * @param {MouseEvent} event 当给出event时,将寻找event.currentTarget的data-node-id作为打开的文档id 64 | * @param {string} docId,此项仅在event对应的发起Elem上找不到data node id的情况下使用 65 | * @param {any} keyParam event的Key,主要是ctrlKey shiftKey等,此项仅在event无效时使用 66 | * @param {IProtyle} protyleElem 如果不为空打开文档点击事件将在该Elem上发起 67 | * @param {boolean} openInFocus 在当前聚焦的窗口中打开,给定此项为true,则优于protyle选项生效 68 | * @deprecated 请使用openRefLinkByAPI 69 | */ 70 | export function openRefLink(event: MouseEvent, paramId = "", keyParam = undefined, protyleElem = undefined, openInFocus = false){ 71 | let syMainWndDocument= window.parent.document 72 | let id; 73 | if (event && (event.currentTarget as HTMLElement)?.getAttribute("data-node-id")) { 74 | id = (event.currentTarget as HTMLElement)?.getAttribute("data-node-id"); 75 | } else if ((event?.currentTarget as HTMLElement)?.getAttribute("data-id")) { 76 | id = (event.currentTarget as HTMLElement)?.getAttribute("data-id"); 77 | } else { 78 | id = paramId; 79 | } 80 | // 处理笔记本等无法跳转的情况 81 | if (!isValidStr(id)) { 82 | debugPush("错误的id", id) 83 | return; 84 | } 85 | event?.preventDefault(); 86 | event?.stopPropagation(); 87 | debugPush("openRefLinkEvent", event); 88 | let simulateLink = syMainWndDocument.createElement("span") 89 | simulateLink.setAttribute("data-type","a") 90 | simulateLink.setAttribute("data-href", "siyuan://blocks/" + id) 91 | simulateLink.style.display = "none";//不显示虚拟链接,防止视觉干扰 92 | let tempTarget = null; 93 | // 如果提供了目标protyle,在其中插入 94 | if (protyleElem && !openInFocus) { 95 | tempTarget = protyleElem.querySelector(".protyle-wysiwyg div[data-node-id] div[contenteditable]") ?? protyleElem; 96 | debugPush("openRefLink使用提供窗口", tempTarget); 97 | } 98 | debugPush("openInFocus?", openInFocus); 99 | if (openInFocus) { 100 | // 先确定Tab 101 | const dataId = syMainWndDocument.querySelector(".layout__wnd--active .layout-tab-bar .item--focus")?.getAttribute("data-id"); 102 | debugPush("openRefLink尝试使用聚焦窗口", dataId); 103 | // 再确定Protyle 104 | if (isValidStr(dataId)) { 105 | tempTarget = window.document.querySelector(`.fn__flex-1.protyle[data-id='${dataId}'] 106 | .protyle-wysiwyg div[data-node-id] div[contenteditable]`); 107 | debugPush("openRefLink使用聚焦窗口", tempTarget); 108 | } 109 | } 110 | if (!isValidStr(tempTarget)) { 111 | tempTarget = syMainWndDocument.querySelector(".protyle-wysiwyg div[data-node-id] div[contenteditable]"); 112 | debugPush("openRefLink未能找到指定窗口,更改为原状态"); 113 | } 114 | tempTarget.appendChild(simulateLink); 115 | let clickEvent = new MouseEvent("click", { 116 | ctrlKey: event?.ctrlKey ?? keyParam?.ctrlKey, 117 | shiftKey: event?.shiftKey ?? keyParam?.shiftKey, 118 | altKey: event?.altKey ?? keyParam?.altKey, 119 | metaKey: event?.metaKey ?? keyParam?.metaKey, 120 | bubbles: true 121 | }); 122 | // 存在选区时,ref相关点击是不执行的,这里暂存、清除,并稍后恢复 123 | const tempSaveRanges = []; 124 | const selection = window.getSelection(); 125 | for (let i = 0; i < selection.rangeCount; i++) { 126 | tempSaveRanges.push(selection.getRangeAt(i)); 127 | } 128 | window.getSelection()?.removeAllRanges(); 129 | 130 | simulateLink.dispatchEvent(clickEvent); 131 | simulateLink.remove(); 132 | 133 | // // 恢复选区,不确定恢复选区是否会导致其他问题 134 | // if (selection.isCollapsed) { 135 | // tempSaveRanges.forEach(range => selection.addRange(range)); // 恢复选区 136 | // } 137 | } 138 | 139 | let lastClickTime_openRefLinkByAPI = 0; 140 | /** 141 | * 基于API的打开思源块/文档 142 | * @param mouseEvent 鼠标点击事件,如果存在,优先使用 143 | * @param paramDocId 如果没有指定 event,使用此参数作为文档id 144 | * @param keyParam 如果没有event,使用此次数指定ctrlKey后台打开、shiftKey下方打开、altKey右侧打开 145 | * @param openInFocus 是否以聚焦块的方式打开(此参数有变动) 146 | * @param removeCurrentTab 是否移除当前Tab 147 | * @param autoRemoveJudgeMiliseconds 自动判断是否移除当前Tab的时间间隔(0则 不自动判断) 148 | * @returns 149 | */ 150 | export function openRefLinkByAPI({mouseEvent, paramDocId = "", keyParam = {}, openInFocus = undefined, removeCurrentTab = undefined, autoRemoveJudgeMiliseconds = 0}: {mouseEvent?: MouseEvent, paramDocId?: string, keyParam?: any, openInFocus?: boolean, removeCurrentTab?: boolean, autoRemoveJudgeMiliseconds?: number}) { 151 | let docId: string; 152 | if (mouseEvent && (mouseEvent.currentTarget as HTMLElement)?.getAttribute("data-node-id")) { 153 | docId = (mouseEvent.currentTarget as HTMLElement)?.getAttribute("data-node-id"); 154 | } else if ((mouseEvent?.currentTarget as HTMLElement)?.getAttribute("data-id")) { 155 | docId = (mouseEvent.currentTarget as HTMLElement)?.getAttribute("data-id"); 156 | } else { 157 | docId = paramDocId; 158 | } 159 | // 处理笔记本等无法跳转的情况 160 | if (!isValidStr(docId)) { 161 | debugPush("错误的id", docId) 162 | return; 163 | } 164 | // 需要冒泡,否则不能在所在页签打开 165 | // event?.preventDefault(); 166 | // event?.stopPropagation(); 167 | if (isMobile()) { 168 | openMobileFileById(getPluginInstance().app, docId); 169 | return; 170 | } 171 | debugPush("openRefLinkEventAPIF", mouseEvent); 172 | if (mouseEvent) { 173 | keyParam = {}; 174 | keyParam["ctrlKey"] = mouseEvent.ctrlKey; 175 | keyParam["shiftKey"] = mouseEvent.shiftKey; 176 | keyParam["altKey"] = mouseEvent.altKey; 177 | keyParam["metaKey"] = mouseEvent.metaKey; 178 | } 179 | let positionKey = undefined; 180 | if (keyParam["altKey"]) { 181 | positionKey = "right"; 182 | } else if (keyParam["shiftKey"]) { 183 | positionKey = "bottom"; 184 | } 185 | if (autoRemoveJudgeMiliseconds > 0) { 186 | if (Date.now() - lastClickTime_openRefLinkByAPI < autoRemoveJudgeMiliseconds) { 187 | removeCurrentTab = true; 188 | } 189 | lastClickTime_openRefLinkByAPI = Date.now(); 190 | } 191 | // 手动关闭 192 | const needToCloseDocId = getCurrentDocIdF(true); 193 | 194 | const finalParam = { 195 | app: getPluginInstance().app, 196 | doc: { 197 | id: docId, 198 | zoomIn: openInFocus 199 | }, 200 | position: positionKey, 201 | keepCursor: isEventCtrlKey(keyParam) ? true : undefined, 202 | removeCurrentTab: removeCurrentTab, // 目前这个选项的行为是:true,则当前页签打开;false,则根据思源设置:新页签打开 203 | }; 204 | debugPush("打开文档执行参数", finalParam); 205 | openTab(finalParam); 206 | // 后台打开页签不可移除 207 | if (removeCurrentTab && !isEventCtrlKey(keyParam)) { 208 | debugPush("插件自行移除页签"); 209 | removeCurrentTabF(needToCloseDocId); 210 | removeCurrentTab = false; 211 | } 212 | } 213 | 214 | 215 | 216 | export function parseDateString(dateString: string): Date | null { 217 | if (dateString.length !== 14) { 218 | warnPush("Invalid date string length. Expected format: 'YYYYMMDDHHmmss'"); 219 | return null; 220 | } 221 | 222 | const year = parseInt(dateString.slice(0, 4), 10); 223 | const month = parseInt(dateString.slice(4, 6), 10) - 1; // 月份从 0 开始 224 | const day = parseInt(dateString.slice(6, 8), 10); 225 | const hours = parseInt(dateString.slice(8, 10), 10); 226 | const minutes = parseInt(dateString.slice(10, 12), 10); 227 | const seconds = parseInt(dateString.slice(12, 14), 10); 228 | 229 | const date = new Date(year, month, day, hours, minutes, seconds); 230 | 231 | if (isNaN(date.getTime())) { 232 | warnPush("Invalid date components."); 233 | return null; 234 | } 235 | 236 | return date; 237 | } 238 | 239 | export function generateUUID() { 240 | let uuid = ''; 241 | let i = 0; 242 | let random = 0; 243 | 244 | for (i = 0; i < 36; i++) { 245 | if (i === 8 || i === 13 || i === 18 || i === 23) { 246 | uuid += '-'; 247 | } else if (i === 14) { 248 | uuid += '4'; 249 | } else { 250 | random = Math.random() * 16 | 0; 251 | if (i === 19) { 252 | random = (random & 0x3) | 0x8; 253 | } 254 | uuid += (random).toString(16); 255 | } 256 | } 257 | 258 | return uuid; 259 | } 260 | 261 | export function isPluginExist(pluginName: string) { 262 | const plugins = window.siyuan.ws.app.plugins; 263 | return plugins?.some((plugin) => plugin.name === pluginName); 264 | } 265 | 266 | export function isAnyPluginExist(pluginNames: string[]) { 267 | return pluginNames.some(isPluginExist); 268 | } 269 | 270 | export function replaceShortcutString(shortcut:string) { 271 | const backend = getBackend(); 272 | 273 | if (backend !== "darwin") { 274 | return shortcut 275 | .replace(/⌥/g, 'Alt ') // 替换 Option 键 276 | .replace(/⌘/g, 'Ctrl ') // 替换 Command 键 277 | .replace(/⇧/g, 'Shift ') // 替换 Shift 键 278 | .replace(/⇪/g, 'CapsLock ') // 替换 Caps Lock 键 279 | .replace(/⌃/g, 'Ctrl '); // 替换 Control 键 280 | } 281 | 282 | return shortcut; 283 | } 284 | -------------------------------------------------------------------------------- /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 { logPush, warnPush, errorPush, debugPush } 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 | * @param {string} docid 要获取的文档id 212 | * @returns {*} 响应的data部分,为outline对象数组 213 | */ 214 | export async function getDocOutlineAPI(docid){ 215 | let url = "/api/outline/getDocOutline"; 216 | let data = {"id": docid}; 217 | let response = await postRequest(data, url); 218 | if (response.code == 0){ 219 | return response.data; 220 | }else{ 221 | return null; 222 | } 223 | } 224 | 225 | /** 226 | * 插入为后置子块 227 | * @param {*} text 子块文本 228 | * @param {*} parentId 父块id 229 | * @param {*} textType 默认为"markdown" 230 | * @returns 231 | */ 232 | export async function prependBlockAPI(text, parentId, textType = "markdown"){ 233 | let url = "/api/block/prependBlock"; 234 | let data = {"dataType": textType, "data": text, "parentID": parentId}; 235 | let response = await postRequest(data, url); 236 | try{ 237 | if (response.code == 0 && response.data != null && isValidStr(response.data[0].doOperations[0].id)){ 238 | return response.data[0].doOperations[0]; 239 | } 240 | if (response.code == -1){ 241 | warnPush("插入块失败", response.msg); 242 | return null; 243 | } 244 | }catch(err){ 245 | errorPush(err); 246 | warnPush(response.msg); 247 | } 248 | return null; 249 | 250 | } 251 | /** 252 | * 插入为前置子块 253 | * @param {*} text 子块文本 254 | * @param {*} parentId 父块id 255 | * @param {*} textType 默认为markdown 256 | * @returns 257 | */ 258 | export async function appendBlockAPI(text, parentId, textType = "markdown"){ 259 | let url = "/api/block/appendBlock"; 260 | let data = {"dataType": textType, "data": text, "parentID": parentId}; 261 | let response = await postRequest(data, url); 262 | try{ 263 | if (response.code == 0 && response.data != null && isValidStr(response.data[0].doOperations[0].id)){ 264 | return response.data[0].doOperations[0]; 265 | } 266 | if (response.code == -1){ 267 | warnPush("插入块失败", response.msg); 268 | return null; 269 | } 270 | }catch(err){ 271 | errorPush(err); 272 | warnPush(response.msg); 273 | } 274 | return null; 275 | 276 | } 277 | 278 | /** 279 | * 推送普通消息 280 | * @param {string} msgText 推送的内容 281 | * @param {number} timeout 显示时间,单位毫秒 282 | * @return 0正常推送 -1 推送失败 283 | */ 284 | export async function pushMsgAPI(msgText, timeout){ 285 | let url = "/api/notification/pushMsg"; 286 | let response = await postRequest({msg: msgText, timeout: timeout}, url); 287 | if (response.code != 0 || response.data == null || !isValidStr(response.data.id)){ 288 | return -1; 289 | } 290 | return 0; 291 | } 292 | 293 | /** 294 | * 获取当前文档id(伪api) 295 | * 优先使用jquery查询 296 | * @param {boolean} mustSure 是否必须确认,若为true,找到多个打开中的文档时返回null 297 | */ 298 | export function getCurrentDocIdF(mustSure: boolean = false) { 299 | let thisDocId:string = null; 300 | // 桌面端 301 | thisDocId = window.top.document.querySelector(".layout__wnd--active .protyle.fn__flex-1:not(.fn__none) .protyle-background")?.getAttribute("data-node-id"); 302 | debugPush("尝试获取当前具有焦点的id", thisDocId); 303 | let temp:string = null; 304 | // 移动端 305 | if (!thisDocId && isMobile()) { 306 | // UNSTABLE: 面包屑样式变动将导致此方案错误! 307 | try { 308 | temp = window.top.document.querySelector(".protyle-breadcrumb .protyle-breadcrumb__item .popover__block[data-id]")?.getAttribute("data-id"); 309 | let iconArray = window.top.document.querySelectorAll(".protyle-breadcrumb .protyle-breadcrumb__item .popover__block[data-id]"); 310 | for (let i = 0; i < iconArray.length; i++) { 311 | let iconOne = iconArray[i]; 312 | if (iconOne.children.length > 0 313 | && iconOne.children[0].getAttribute("xlink:href") == "#iconFile"){ 314 | temp = iconOne.getAttribute("data-id"); 315 | break; 316 | } 317 | } 318 | thisDocId = temp; 319 | }catch(e){ 320 | console.error(e); 321 | temp = null; 322 | } 323 | } 324 | // 无聚焦窗口 325 | if (!thisDocId) { 326 | thisDocId = window.top.document.querySelector(".protyle.fn__flex-1:not(.fn__none) .protyle-background")?.getAttribute("data-node-id"); 327 | debugPush("获取具有焦点id失败,获取首个打开中的文档", thisDocId); 328 | if (mustSure && window.top.document.querySelectorAll(".protyle.fn__flex-1:not(.fn__none) .protyle-background").length > 1) { 329 | debugPush("要求必须唯一确认,但是找到多个打开中的文档"); 330 | return null; 331 | } 332 | } 333 | return thisDocId; 334 | } 335 | 336 | export function getAllShowingDocId(): string[] { 337 | if (isMobile()) { 338 | return [getCurrentDocIdF()]; 339 | } else { 340 | const elemList = window.document.querySelectorAll("[data-type=wnd] .protyle.fn__flex-1:not(.fn__none) .protyle-background"); 341 | const result = [].map.call(elemList, function(elem: Element) { 342 | return elem.getAttribute("data-node-id"); 343 | }); 344 | return result 345 | } 346 | } 347 | 348 | /** 349 | * 获取当前挂件id 350 | * @returns 351 | */ 352 | export function getCurrentWidgetId(){ 353 | try{ 354 | if (!window.frameElement.parentElement.parentElement.dataset.nodeId) { 355 | return window.frameElement.parentElement.parentElement.dataset.id; 356 | }else{ 357 | return window.frameElement.parentElement.parentElement.dataset.nodeId; 358 | } 359 | }catch(err){ 360 | warnPush("getCurrentWidgetId window...nodeId方法失效"); 361 | return null; 362 | } 363 | } 364 | 365 | /** 366 | * 检查运行的操作系统 367 | * @return true 可以运行,当前os在允许列表中 368 | */ 369 | // export function checkOs(){ 370 | // try{ 371 | // if (setting.includeOs.indexOf(window.top.siyuan.config.system.os.toLowerCase()) != -1){ 372 | // return true; 373 | // } 374 | // }catch(err){ 375 | // errorPush(err); 376 | // warnPush("检查操作系统失败"); 377 | // } 378 | 379 | // return false; 380 | // } 381 | /** 382 | * 删除块 383 | * @param {*} blockid 384 | * @returns 385 | */ 386 | export async function removeBlockAPI(blockid){ 387 | let url = "/api/block/deleteBlock"; 388 | let response = await postRequest({id: blockid}, url); 389 | if (response.code == 0){ 390 | return true; 391 | } 392 | warnPush("删除块失败", response); 393 | return false; 394 | } 395 | 396 | /** 397 | * 获取块kramdown源码 398 | * @param {*} blockid 399 | * @returns kramdown文本 400 | */ 401 | export async function getKramdown(blockid){ 402 | let url = "/api/block/getBlockKramdown"; 403 | let response = await postRequest({id: blockid}, url); 404 | if (response.code == 0 && response.data != null && "kramdown" in response.data){ 405 | return response.data.kramdown; 406 | } 407 | return null; 408 | } 409 | 410 | /** 411 | * 获取笔记本列表 412 | * @returns 413 | "id": "20210817205410-2kvfpfn", 414 | "name": "测试笔记本", 415 | "icon": "1f41b", 416 | "sort": 0, 417 | "closed": false 418 | 419 | */ 420 | export async function getNodebookList() { 421 | let url = "/api/notebook/lsNotebooks"; 422 | let response = await postRequest({}, url); 423 | if (response.code == 0 && response.data != null && "notebooks" in response.data){ 424 | return response.data.notebooks; 425 | } 426 | return null; 427 | } 428 | 429 | /** 430 | * 基于本地window.siyuan获得笔记本信息 431 | * @param {*} notebookId 为空获得所有笔记本信息 432 | * @returns 433 | */ 434 | export function getNotebookInfoLocallyF(notebookId = undefined) { 435 | try { 436 | if (!notebookId) return window.top.siyuan.notebooks; 437 | for (let notebookInfo of window.top.siyuan.notebooks) { 438 | if (notebookInfo.id == notebookId) { 439 | return notebookInfo; 440 | } 441 | } 442 | return undefined; 443 | }catch(err) { 444 | errorPush(err); 445 | return undefined; 446 | } 447 | } 448 | 449 | /** 450 | * 获取笔记本排序规则 451 | * (为“跟随文档树“的,转为文档树排序 452 | * @param {*} notebookId 笔记本id,不传则为文档树排序 453 | * @returns 454 | */ 455 | export function getNotebookSortModeF(notebookId = undefined) { 456 | try { 457 | let fileTreeSort = window.top.siyuan.config.fileTree.sort; 458 | if (!notebookId) return fileTreeSort; 459 | let notebookSortMode = window.document.querySelector(`.file-tree.sy__file ul[data-url='${notebookId}']`)?.getAttribute("data-sortmode") ?? getNotebookInfoLocallyF(notebookId).sortMode; 460 | if (typeof notebookSortMode === "string") { 461 | notebookSortMode = parseInt(notebookSortMode, 10); 462 | } 463 | if (notebookSortMode == DOC_SORT_TYPES.UNASSIGNED || notebookSortMode == DOC_SORT_TYPES.FOLLOW_DOC_TREE) { 464 | return fileTreeSort; 465 | } 466 | return notebookSortMode; 467 | }catch(err) { 468 | errorPush(err); 469 | return undefined; 470 | } 471 | } 472 | 473 | /** 474 | * 批量添加闪卡 475 | * @param {*} ids 476 | * @param {*} deckId 目标牌组Id 477 | * @param {*} oldCardsNum 原有牌组卡牌数(可选) 478 | * @returns (若未传入原卡牌数)添加后牌组内卡牌数, (若传入)返回实际添加的卡牌数; 返回null表示请求失败 479 | */ 480 | export async function addRiffCards(ids, deckId, oldCardsNum = -1) { 481 | let url = "/api/riff/addRiffCards"; 482 | let postBody = { 483 | deckID: deckId, 484 | blockIDs: ids 485 | }; 486 | let response = await postRequest(postBody, url); 487 | if (response.code == 0 && response.data != null && "size" in response.data) { 488 | if (oldCardsNum < 0) { 489 | return response.data.size; 490 | }else{ 491 | return response.data.size - oldCardsNum; 492 | } 493 | } 494 | warnPush("添加闪卡出错", response); 495 | return null; 496 | } 497 | 498 | /** 499 | * 批量移除闪卡 500 | * @param {*} ids 501 | * @param {*} deckId 目标牌组Id 502 | * @param {*} oldCardsNum 原有牌组卡牌数(可选) 503 | * @returns (若未传入原卡牌数)移除后牌组内卡牌数, (若传入)返回实际移除的卡牌数; 返回null表示请求失败 504 | */ 505 | export async function removeRiffCards(ids, deckId, oldCardsNum = -1) { 506 | let url = "/api/riff/removeRiffCards"; 507 | let postBody = { 508 | deckID: deckId, 509 | blockIDs: ids 510 | }; 511 | let response = await postRequest(postBody, url); 512 | if (response.code == 0 && response.data != null && "size" in response.data) { 513 | if (oldCardsNum < 0) { 514 | return response.data.size; 515 | }else{ 516 | return oldCardsNum - response.data.size; 517 | } 518 | } 519 | warnPush("移除闪卡出错", response); 520 | return null; 521 | } 522 | 523 | /** 524 | * 获取全部牌组信息 525 | * @returns 返回数组 526 | * [{"created":"2023-01-05 20:29:48", 527 | * "id":"20230105202948-xn12hz6", 528 | * "name":"Default Deck", 529 | * "size":1, 530 | * "updated":"2023-01-19 21:48:21"}] 531 | */ 532 | export async function getRiffDecks() { 533 | let url = "/api/riff/getRiffDecks"; 534 | let response = await postRequest({}, url); 535 | if (response.code == 0 && response.data != null) { 536 | return response.data; 537 | } 538 | return new Array(); 539 | } 540 | 541 | /** 542 | * 获取文件内容或链接信息 543 | * @param {*} blockid 获取的文件id 544 | * @param {*} size 获取的块数 545 | * @param {*} mode 获取模式,0为获取html;1为 546 | */ 547 | export async function getDoc(blockid, size = 5, mode = 0) { 548 | let url = "/api/filetree/getDoc"; 549 | let response = await postRequest({id: blockid, mode: mode, size: size}, url); 550 | if (response.code == 0 && response.data != null) { 551 | return response.data; 552 | } 553 | return undefined; 554 | } 555 | 556 | /** 557 | * 获取文档导出预览 558 | * @param {*} docid 559 | * @returns 560 | */ 561 | export async function getDocPreview(docid) { 562 | let url = "/api/export/preview"; 563 | let response = await postRequest({id: docid}, url); 564 | if (response.code == 0 && response.data != null) { 565 | return response.data.html; 566 | } 567 | return ""; 568 | } 569 | /** 570 | * 删除文档 571 | * @param {*} notebookid 笔记本id 572 | * @param {*} path 文档所在路径 573 | * @returns 574 | */ 575 | export async function removeDocAPI(notebookid, path) { 576 | let url = "/api/filetree/removeDoc"; 577 | let response = await postRequest({"notebook": notebookid, "path": path}, url); 578 | if (response.code == 0) { 579 | return response.code; 580 | } 581 | warnPush("删除文档时发生错误", response.msg); 582 | return response.code; 583 | } 584 | /** 585 | * 重命名文档 586 | * @param {*} notebookid 笔记本id 587 | * @param {*} path 文档所在路径 588 | * @param {*} title 新文档名 589 | * @returns 590 | */ 591 | export async function renameDocAPI(notebookid, path, title) { 592 | let url = "/api/filetree/renameDoc"; 593 | let response = await postRequest({"notebook": notebookid, "path": path, "title": title}, url); 594 | if (response.code == 0) { 595 | return response.code; 596 | } 597 | warnPush("重命名文档时发生错误", response.msg); 598 | return response.code; 599 | } 600 | 601 | export function isDarkMode() { 602 | if (window.top.siyuan) { 603 | return window.top.siyuan.config.appearance.mode == 1 ? true : false; 604 | } else { 605 | let isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; 606 | return isDarkMode; 607 | } 608 | } 609 | 610 | /** 611 | * 通过markdown创建文件 612 | * @param {*} notebookid 笔记本id 613 | * @param {*} hpath 示例 /父文档1/父文档2/你要新建的文档名 614 | * @param {*} md 615 | * @returns 616 | */ 617 | export async function createDocWithMdAPI(notebookid, hpath, md) { 618 | let url = "/api/filetree/createDocWithMd"; 619 | let response = await postRequest({"notebook": notebookid, "path": hpath, "markdown": md}, url); 620 | if (response.code == 0 && response.data != null) { 621 | return response.data.id; 622 | } 623 | return null; 624 | } 625 | 626 | /** 627 | * 628 | * @param {*} notebookid 629 | * @param {*} path 待创建的新文档path,即,最后应当为一个随机的id.sy 630 | * @param {*} title 【可选】文档标题 631 | * @param {*} contentMd 【可选】markdown格式的内容 632 | * @returns 633 | */ 634 | export async function createDocWithPath(notebookid, path, title = "Untitled", contentMd = "") { 635 | let url = "/api/filetree/createDoc"; 636 | let response = await postRequest({"notebook": notebookid, "path": path, "md": contentMd, "title": title}, url); 637 | if (response.code == 0) { 638 | return true; 639 | } 640 | return false; 641 | } 642 | 643 | /** 644 | * 将对象保存为JSON文件 645 | * @param {*} path 646 | * @param {*} object 647 | * @param {boolean} format 648 | * @returns 649 | */ 650 | export async function putJSONFile(path, object, format = false) { 651 | const url = "/api/file/putFile"; 652 | const pathSplited = path.split("/"); 653 | let fileContent = ""; 654 | if (format) { 655 | fileContent = JSON.stringify(object, null, 4); 656 | } else { 657 | fileContent = JSON.stringify(object); 658 | } 659 | // File的文件名实际上无关,但这里考虑到兼容,将上传文件按照路径进行了重命名 660 | const file = new File([fileContent], pathSplited[pathSplited.length - 1], {type: "text/plain"}); 661 | const data = new FormData(); 662 | data.append("path", path); 663 | data.append("isDir", "false"); 664 | data.append("modTime", new Date().valueOf().toString()); 665 | data.append("file", file); 666 | return fetch(url, { 667 | body: data, 668 | method: 'POST', 669 | headers: { 670 | "Authorization": "Token "+ getToken() 671 | } 672 | }).then((response) => { 673 | return response.json(); 674 | }); 675 | } 676 | 677 | /** 678 | * 从JSON文件中读取对象 679 | * @param {*} path 680 | * @returns 681 | */ 682 | export async function getJSONFile(path) { 683 | const url = "/api/file/getFile"; 684 | let response = await postRequest({"path": path}, url); 685 | if (response.code == 404) { 686 | return null; 687 | } 688 | return response; 689 | } 690 | 691 | export async function getFileAPI(path) { 692 | const url = "/api/file/getFile"; 693 | let data = {"path": path}; 694 | let result; 695 | let response = await fetch(url, { 696 | body: JSON.stringify(data), 697 | method: 'POST', 698 | headers: { 699 | "Authorization": "Token "+ getToken(), 700 | "Content-Type": "application/json" 701 | } 702 | }); 703 | result = await response.text(); 704 | try { 705 | let jsonresult = JSON.parse(result); 706 | if (jsonresult.code == 404) { 707 | return null; 708 | } 709 | return result; 710 | } catch(err) { 711 | 712 | } 713 | return result; 714 | } 715 | 716 | /** 717 | * 列出工作空间下的文件 718 | * @param {*} path 例如"/data/20210808180117-6v0mkxr/20200923234011-ieuun1p.sy" 719 | * @returns isDir, isSymlink, name三个属性 720 | */ 721 | export async function listFileAPI(path) { 722 | const url = "/api/file/readDir"; 723 | let response = await postRequest({"path": path}, url); 724 | if (response.code == 0) { 725 | return response.data; 726 | } 727 | return []; 728 | } 729 | 730 | export async function removeFileAPI(path) { 731 | const url = "/api/file/removeFile"; 732 | let response = await postRequest({"path": path}, url); 733 | if (response.code == 0) { 734 | return true; 735 | } else { 736 | return false; 737 | } 738 | } 739 | 740 | export async function getDocInfo(id) { 741 | let data = { 742 | "id": id 743 | }; 744 | let url = `/api/block/getDocInfo`; 745 | return getResponseData(postRequest(data, url)); 746 | } 747 | 748 | /** 749 | * 反向链接面板用的API(标注有T,该API不是正式API) 750 | * @param id 751 | * @param sort 反链结果排序方式 字母0/1、自然4/5创建9/10,修改2/3 752 | * @param msort 753 | * @param k 754 | * @param mk 看起来是提及部分的关键词 755 | * @returns 756 | */ 757 | export async function getBackLink2T(id, sort = "3", msort= "3", k = "", mk = "") { 758 | let data = { 759 | "id": id, 760 | "sort": sort, 761 | "msort": msort, 762 | "k": k, 763 | "mk": mk 764 | }; 765 | let url = `/api/ref/getBacklink2`; 766 | return getResponseData(postRequest(data, url)); 767 | } 768 | 769 | export async function getTreeStat(id:string) { 770 | let data = { 771 | "id": id 772 | }; 773 | let url = `/api/block/getTreeStat`; 774 | return getResponseData(postRequest(data, url)); 775 | } 776 | 777 | let isMobileRecentResult = null; 778 | export function isMobile() { 779 | if (isMobileRecentResult != null) { 780 | return isMobileRecentResult; 781 | } 782 | if (window.top.document.getElementById("sidebar")) { 783 | isMobileRecentResult = true; 784 | return true; 785 | } else { 786 | isMobileRecentResult = false; 787 | return false; 788 | } 789 | }; 790 | 791 | export function getBlockBreadcrumb(blockId: string, excludeTypes: string[] = []) { 792 | let data = { 793 | "id": blockId, 794 | "excludeTypes": excludeTypes 795 | }; 796 | let url = `/api/block/getBlockBreadcrumb`; 797 | return getResponseData(postRequest(data, url)); 798 | } 799 | 800 | export async function getHPathById(docId:string): Promise { 801 | let data = { 802 | "id": docId 803 | } 804 | const url = "/api/filetree/getHPathByID"; 805 | return getResponseData(postRequest(data, url)) as Promise; 806 | } 807 | 808 | /** 809 | * 批量设置属性 810 | * @param {*} blockAttrs 数组,每一个元素为对象,包含 id 和 attrs两个属性值,attrs为对象,其属性和属性值即为 attr-key: attr-value 811 | * @ref https://github.com/siyuan-note/siyuan/issues/10337 812 | */ 813 | export async function batchSetBlockAtrs(blockAttrs: string) { 814 | let url = "/api/attr/batchSetBlockAttrs"; 815 | let postBody = { 816 | blockAttrs: blockAttrs, 817 | }; 818 | let response = await postRequest(postBody, url); 819 | if (response.code == 0 && response.data != null) { 820 | return response.data; 821 | } 822 | return null; 823 | } 824 | 825 | export const DOC_SORT_TYPES = { 826 | FILE_NAME_ASC: 0, 827 | FILE_NAME_DESC: 1, 828 | NAME_NAT_ASC: 4, 829 | NAME_NAT_DESC: 5, 830 | CREATED_TIME_ASC: 9, 831 | CREATED_TIME_DESC: 10, 832 | MODIFIED_TIME_ASC: 2, 833 | MODIFIED_TIME_DESC: 3, 834 | REF_COUNT_ASC: 7, 835 | REF_COUNT_DESC: 8, 836 | DOC_SIZE_ASC: 11, 837 | DOC_SIZE_DESC: 12, 838 | SUB_DOC_COUNT_ASC: 13, 839 | SUB_DOC_COUNT_DESC: 14, 840 | CUSTOM_SORT: 6, 841 | FOLLOW_DOC_TREE: 255, // 插件内部定义的”跟随文档树“ 842 | FOLLOW_DOC_TREE_ORI: 15, // 官方对于”跟随文档树“的定义 843 | UNASSIGNED: 256, 844 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | --------------------------------------------------------------------------------