Compare commits

...

23 commits
v0.3.0 ... main

Author SHA1 Message Date
d8cb171142
Version bump (incl. dependency)
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 1m11s
2025-09-06 17:17:10 +02:00
f1393b715d
Version bump
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 1m20s
2025-09-01 21:49:53 +02:00
7cb9664621
Hotfix for SiYuan v3.3.0
Some checks failed
Build on Push and create Release on Tag / build (push) Failing after 16s
2025-09-01 21:38:25 +02:00
751c069a68 version bump
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 1m5s
2025-08-08 18:39:27 +02:00
332d390707 Internationalize links
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 1m27s
2025-08-07 19:37:30 +02:00
16addac749 Resize demo
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 4m40s
2025-08-07 18:58:00 +02:00
c2232c3450 Add Chinese translation and improve README
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 1m2s
2025-08-07 18:30:08 +02:00
163a1513e8 Improve editor localization, localization reporting, i18n separation
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 56s
2025-08-06 17:29:42 +02:00
3874378824 Dropped migration legacy code
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 58s
2025-07-17 16:24:38 +02:00
eaf4a8e39e Improve error handling
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 1m40s
2025-07-16 15:57:14 +02:00
05984a8913 Improve labels, errors, and docs
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 4m6s
2025-07-15 12:42:18 +02:00
d34258e6bf
Add "directly open editor" shortcut and icon
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 4m48s
2025-06-24 22:26:26 +02:00
dc15e91def
Version bump
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 36s
2025-05-15 18:19:46 +02:00
ff83c23851
Improve cursor
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 51s
2025-05-15 18:16:50 +02:00
17d4e5938b
Version bump
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 36s
2025-05-09 22:57:42 +02:00
a079298433
Add CI
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 3m55s
2025-05-08 22:47:09 +02:00
5322944ad9
Add custom cursor on editor canvas 2025-05-07 21:16:50 +02:00
77e8218d1f
Config improvements and compatibility with old versions 2025-05-06 23:12:51 +02:00
764f9fe5a4
Add option to remember editor position and zoom 2025-05-06 18:19:18 +02:00
fa3eba219e
Suggest popular background colors, add transparency support
Making the UI more user-friendly by suggesting some commonly used colors in the Settings menu
2025-05-05 19:17:59 +02:00
1ad26d1e23
Add funding link 2025-05-01 23:01:55 +02:00
8d4779b8fe
Improve error handling and code structure 2025-04-23 09:52:45 +02:00
f35342a791
Start workin on i18n 2025-04-20 22:16:48 +02:00
19 changed files with 550 additions and 223 deletions

View file

@ -1,7 +1,9 @@
name: Create Release on Tag Push name: Build on Push and create Release on Tag
on: on:
push: push:
branches:
- main
tags: tags:
- "v*" - "v*"
@ -20,7 +22,7 @@ jobs:
node-version: 20 node-version: 20
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
# Install pnpm # Install pnpm
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
id: pnpm-install id: pnpm-install
@ -28,6 +30,12 @@ jobs:
version: 8 version: 8
run_install: false run_install: false
# Validate Tag Matches JSON Versions
- name: Validate Tag Matches JSON Versions
if: github.ref_type == 'tag'
run: |
node scripts/validate_tag.cjs ${{ github.ref }}
# Get pnpm store directory # Get pnpm store directory
- name: Get pnpm store directory - name: Get pnpm store directory
id: pnpm-cache id: pnpm-cache
@ -52,11 +60,22 @@ jobs:
- name: Build for production - name: Build for production
run: pnpm build run: pnpm build
- name: Release # Move file
uses: ncipollo/release-action@v1 - name: Move file
run: mkdir built; mv package.zip built/package.zip
# Upload artifacts
- name: Upload artifacts
uses: actions/upload-artifact@v3
with: with:
allowUpdates: true path: built/package.zip
artifactErrorsFailBuild: true overwrite: true
artifacts: "package.zip"
token: ${{ secrets.GITHUB_TOKEN }} # Create Forgejo Release
prerelease: false - name: Create Forgejo Release
if: github.ref_type == 'tag'
uses: actions/forgejo-release@v1
with:
direction: upload
release-dir: built
token: ${{ secrets.FORGE_TOKEN }}

36
README-zh_CN.md Normal file
View file

@ -0,0 +1,36 @@
# SiYuan js-draw 插件
本插件可在思源笔记的任意位置内嵌 js-draw 白板。
## 使用说明
![演示](asset/demo.webp)
- 在插件市场搜索 `js-draw` 并安装。
- 在文档中新建白板:
1. 在文档内输入 `/插入白板`,选择对应菜单项;
2. 白板编辑器将在新标签页打开,随意绘制后点击“保存”并关闭标签页。
- 后续编辑白板:
1. 左键(或轻触)选中白板,然后点击顶部工具栏的“编辑”图标,或使用快捷键 `Alt+Shift+D`
亦可右键白板(或移动端点击三点按钮),在菜单中选择“插件” > “编辑白板”;
2. 编辑器标签页打开后,按需修改,完成后点击“保存”并关闭标签页。
## 计划功能
查看 [Projects](https://git.massive.box/massivebox/siyuan-jsdraw-plugin/projects) 标签页了解详情!
## 贡献
欢迎任何形式的贡献!
中文翻译由 Kimi AI 完成,因我不懂中文,如有疏漏欢迎指出。
若您愿意协助,请 [提交 Issue](https://git.massive.box/massivebox/siyuan-jsdraw-plugin/issues) 或 [联系我](mailto:box@massive.box)。
## 致谢
本项目离不开以下项目与社区的帮助(排名不分先后):
- [SiYuan](https://github.com/siyuan-note/siyuan) 项目
- [js-draw](https://github.com/personalizedrefrigerator/js-draw)
- [SiYuan plugin sample with vite and svelte](https://github.com/siyuan-note/plugin-sample-vite-svelte)
- [siyuan-drawio-plugin](https://github.com/zt8989/siyuan-drawio-plugin) 与 [siyuan-plugin-whiteboard](https://github.com/zuoez02/siyuan-plugin-whiteboard) 提供的灵感与部分代码
也请关注并支持他们!
## 许可证
原始插件框架由思源笔记开发MIT 许可证。
本人所作修改版权所有 © 2025 MassiveBox同样使用 MIT 许可证。

View file

@ -4,21 +4,23 @@
This plugin allows you to embed js-draw whiteboards anywhere in your SiYuan documents. This plugin allows you to embed js-draw whiteboards anywhere in your SiYuan documents.
## Usage instructions ## Usage instructions
![Demo](asset/demo.webp)
- Install the plugin from the marketplace. You can find it by searching for `js-draw`. - Install the plugin from the marketplace. You can find it by searching for `js-draw`.
- To add a new drawing to your document: - To add a new whiteboard to your document:
1. Type `/Insert Drawing` in your document, and select the correct menu entry 1. Type `/Insert whiteboard` in your document, and select the correct menu entry
2. The whiteboard editor will open in a new tab. Draw as you like, then click the Save button and close the tab. 2. The whiteboard editor will open in a new tab. Draw as you like, then click the Save button and close the tab.
- To edit the image later: - To edit the whiteboard later:
1. Right-click on the image (or click the three dots on mobile), select "Plugin" > "Edit with js-draw" in the menu 1. Left-click or tap on the whiteboard to select it, then click on the Edit icon in the top bar or use the keyboard shortcut `Alt+Shift+D`
- Or right-click on the whiteboard (or click the three dots on mobile), select "Plugin" > "Edit whiteboard" in the menu
2. The editor tab will open, edit your file as you like, then click the Save button and close the tab. 2. The editor tab will open, edit your file as you like, then click the Save button and close the tab.
## Planned features ## Planned features
Check out the [Projects](https://git.massive.box/massivebox/siyuan-jsdraw-plugin/projects) tab! Check out the [Projects](https://git.massive.box/massivebox/siyuan-jsdraw-plugin/projects) tab!
## Contributing ## Contributing
Contributions are always welcome! Right now, I'm working on the core functionality and fixing bugs. Contributions are always welcome!
After that is done, I will need help with the internationalization, as, unfortunately, I don't speak Chinese. The Chinese translation is made by Kimi AI, and I'm unable to verify it because I don't speak Chinese. If you do and find issues, please let me know.
Please [contact me](mailto:box@massive.box) if you'd like to help! Please [open an issue](https://git.massive.box/massivebox/siyuan-jsdraw-plugin/issues) or [contact me](mailto:box@massive.box) if you'd like to help!
## Thanks to ## Thanks to
This project couldn't have been possible without (in no particular order): This project couldn't have been possible without (in no particular order):

BIN
asset/demo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

View file

@ -1,6 +1,6 @@
{ {
"name": "siyuan-jsdraw-plugin", "name": "siyuan-jsdraw-plugin",
"version": "0.3.0", "version": "0.5.2",
"type": "module", "type": "module",
"description": "Include a whiteboard for freehand drawing anywhere in your documents.", "description": "Include a whiteboard for freehand drawing anywhere in your documents.",
"repository": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin", "repository": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin",
@ -35,7 +35,8 @@
"vite-plugin-zip-pack": "^1.0.5" "vite-plugin-zip-pack": "^1.0.5"
}, },
"dependencies": { "dependencies": {
"@js-draw/material-icons": "^1.29.0", "@js-draw/material-icons": "^1.31.1",
"js-draw": "^1.29.0" "js-draw": "^1.31.1",
"ts-serializable": "^4.2.0"
} }
} }

View file

@ -2,7 +2,7 @@
"name": "siyuan-jsdraw-plugin", "name": "siyuan-jsdraw-plugin",
"author": "massivebox", "author": "massivebox",
"url": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin", "url": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin",
"version": "0.3.0", "version": "0.5.2",
"minAppVersion": "3.0.12", "minAppVersion": "3.0.12",
"backends": [ "backends": [
"windows", "windows",
@ -21,17 +21,20 @@
"desktop-window" "desktop-window"
], ],
"displayName": { "displayName": {
"en_US": "JS-Draw Whiteboard" "en_US": "JS-Draw Whiteboard",
"zh_CN": "JS-Draw 白板"
}, },
"description": { "description": {
"en_US": "Include a whiteboard for freehand drawing anywhere in your documents." "en_US": "Include a whiteboard for freehand drawing anywhere in your documents.",
"zh_CN": "在您的文档中添加一个自由绘图白板。"
}, },
"readme": { "readme": {
"en_US": "README.md" "en_US": "README.md",
"zh_CN": "README-zh_CN.md"
}, },
"funding": { "funding": {
"custom": [ "custom": [
"" "https://s.massive.box/jsdraw-plugin-donate"
] ]
}, },
"keywords": [ "keywords": [

View file

@ -1,3 +1,52 @@
{ {
"insertDrawing": "Insert Drawing" "insertWhiteboard": "Insert whiteboard",
"editWhiteboard": "Edit whiteboard",
"editShortcut": "Edit selected whiteboard",
"errors": {
"noFileID": "File ID missing - couldn't open file.",
"notAWhiteboard": "You must select a whiteboard, not a regular image. <a href='https://s.massive.box/jsdraw-plugin-instructions'>Usage instructions</a>",
"syncIDNotFound": "Couldn't find SyncID in document for drawing, make sure you're trying to edit a whiteboard that is included in at least a note.",
"createUnknown": "Unknown error while creating editor, please try again.",
"invalidBackgroundColor": "Invalid background color! Please enter an HEX color, like #000000 (black) or #FFFFFF (white). The old background color will be used.",
"multipleSyncIDs": "Multiple syncIDs found in documents. Remove the drawings that don't exist from your documents.\n Sync conflict copies can cause this error, so make sure to delete them.\nFile IDs (the part you can change in the Rename menu) must be unique across all documents.\n<a href='https://git.massive.box/massivebox/siyuan-jsdraw-plugin/wiki/Errors-and-Fixes#multiple-syncids-found'>Full explanation</a>",
"unchangedProtyle": "Make sure the image you're trying to edit still exists in your documents.",
"saveGeneric": "Error saving! The current drawing has been copied to your clipboard. You may need to create a new drawing and paste it there.",
"mustSelect": "Select a whiteboard in your document by left-clicking it, then use this icon/shortcut to open the editor directly. <a href='https://s.massive.box/jsdraw-plugin-instructions'>Usage instructions</a>"
},
"whiteboard": "Whiteboard",
"settings": {
"name": "js-draw Plugin Settings",
"suggestedColors":{
"white": "White",
"black": "Black",
"transparent": "Transparent",
"custom": "Custom",
"darkBlue": "Dark Blue",
"darkGray": "Dark Gray"
},
"grid": {
"title": "Enable grid by default",
"description": "Enable to automatically turn on the grid on new whiteboards."
},
"backgroundDropdown":{
"title": "Background color",
"description": "Default background color for new whiteboards."
},
"background": {
"title": "Custom background",
"description": "Hexadecimal code of the custom background color for new whiteboards.<br /><b>This setting is only applied if \"Background Color\" is set to \"Custom\"!</b>"
},
"dialogOnDesktop": {
"title": "Open editor as dialog on desktop",
"description": "Dialog mode provides a larger drawing area, but it's not as handy to use as tabs (default).<br />The editor will always open as a dialog on mobile."
},
"analytics": {
"title": "Analytics",
"description": "Enable to send anonymous usage data to the developer. <a href='https://s.massive.box/jsdraw-plugin-privacy'>Privacy Policy</a>"
},
"restorePosition": {
"title": "Remember editor position",
"description": "When enabled, the editor will remember the zoom factor and position, and it will restore them the next time you open the same whiteboard."
}
}
} }

52
public/i18n/zh_CN.json Normal file
View file

@ -0,0 +1,52 @@
{
"insertWhiteboard": "插入白板",
"editWhiteboard": "编辑白板",
"editShortcut": "编辑选中的白板",
"errors": {
"noFileID": "缺少文件 ID无法打开文件。",
"notAWhiteboard": "您必须选择白板,而不是普通图片。<a href='https://s.massive.box/jsdraw-plugin-instructions-zh-cn'>使用说明</a>",
"syncIDNotFound": "在文档中找不到该绘图的 SyncID请确保您尝试编辑的白板已包含在至少一个笔记中。",
"createUnknown": "创建编辑器时出现未知错误,请重试。",
"invalidBackgroundColor": "无效的背景颜色!请输入十六进制颜色,例如 #000000黑色或 #FFFFFF白色。将使用原来的背景颜色。",
"multipleSyncIDs": "在文档中发现多个 syncID。请从文档中删除不存在的绘图。\n同步冲突副本可能导致此错误因此请务必删除它们。\n文件 ID可在重命名菜单中更改的部分在所有文档中必须唯一。\n<a href='https://s.massive.box/jsdraw-plugin-multiple-syncids-zh-cn'>完整说明</a>",
"unchangedProtyle": "请确保您尝试编辑的图片仍存在于文档中。",
"saveGeneric": "保存出错!当前绘图已复制到剪贴板。您可能需要新建一个绘图并粘贴进去。",
"mustSelect": "先在文档中左键点击选中白板,然后使用此图标/快捷键直接打开编辑器。<a href='https://s.massive.box/jsdraw-plugin-instructions-zh-cn'>使用说明</a>"
},
"whiteboard": "白板",
"settings": {
"name": "js-draw 插件设置",
"suggestedColors": {
"white": "白色",
"black": "黑色",
"transparent": "透明",
"custom": "自定义",
"darkBlue": "深蓝",
"darkGray": "深灰"
},
"grid": {
"title": "默认启用网格",
"description": "开启后,新白板将自动显示网格。"
},
"backgroundDropdown": {
"title": "背景颜色",
"description": "新白板的默认背景颜色。"
},
"background": {
"title": "自定义背景",
"description": "新白板自定义背景色的十六进制代码。<br /><b>仅在“背景颜色”设为“自定义”时才生效!</b>"
},
"dialogOnDesktop": {
"title": "在桌面端以对话框打开编辑器",
"description": "对话框模式提供更大的绘图区域,但不如标签页(默认)方便。<br />移动端始终会以对话框打开编辑器。"
},
"analytics": {
"title": "分析统计",
"description": "开启后,向开发者发送匿名使用数据。<a href='https://s.massive.box/jsdraw-plugin-privacy-zh-cn'>隐私政策</a>"
},
"restorePosition": {
"title": "记住编辑器位置",
"description": "开启后,编辑器会记住缩放比例和位置,下次打开同一白板时恢复。"
}
}
}

BIN
public/webapp/cursor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 B

24
scripts/validate_tag.cjs Normal file
View file

@ -0,0 +1,24 @@
const fs = require('fs');
const path = require('path');
const [tagName] = process.argv.slice(2); // Get tag from CLI arguments
if (!tagName) {
console.error('Error: No tag name provided.');
process.exit(1);
}
const TAG_VERSION = tagName.replace('refs/tags/v', '');
try {
const packageJson = JSON.parse(fs.readFileSync(path.resolve('package.json'), 'utf8'));
const pluginJson = JSON.parse(fs.readFileSync(path.resolve('plugin.json'), 'utf8'));
if (TAG_VERSION !== packageJson.version || TAG_VERSION !== pluginJson.version) {
console.error(`Error: Tag version (${TAG_VERSION}) does not match package.json (${packageJson.version}) or plugin.json (${pluginJson.version})`);
process.exit(1);
}
console.log('Tag version matches both JSON files.');
} catch (err) {
console.error('Failed to read or parse JSON files:', err.message);
process.exit(1);
}

View file

@ -24,6 +24,7 @@ export class Analytics {
'frontend': getFrontend(), 'frontend': getFrontend(),
'backend': getBackend(), 'backend': getBackend(),
'language': navigator.language, 'language': navigator.language,
'appLanguage': window.siyuan.config.lang,
} : {}; } : {};
await fetch(Analytics.ENDPOINT, { await fetch(Analytics.ENDPOINT, {

View file

@ -2,16 +2,16 @@ import {PluginFile} from "@/file";
import {CONFIG_FILENAME, JSON_MIME, STORAGE_PATH} from "@/const"; import {CONFIG_FILENAME, JSON_MIME, STORAGE_PATH} from "@/const";
import {Plugin} from "siyuan"; import {Plugin} from "siyuan";
import {SettingUtils} from "@/libs/setting-utils"; import {SettingUtils} from "@/libs/setting-utils";
import {validateColor} from "@/helper"; import {getFirstDefined} from "@/helper";
import {ErrorReporter, InvalidBackgroundColorError} from "@/errors";
type Options = { export interface Options {
grid: boolean
background: string
dialogOnDesktop: boolean dialogOnDesktop: boolean
analytics: boolean analytics: boolean
}; editorOptions: EditorOptions
}
export type DefaultEditorOptions = { export interface EditorOptions {
restorePosition: boolean;
grid: boolean grid: boolean
background: string background: string
} }
@ -29,30 +29,23 @@ export class PluginConfig {
this.file = new PluginFile(STORAGE_PATH, CONFIG_FILENAME, JSON_MIME); this.file = new PluginFile(STORAGE_PATH, CONFIG_FILENAME, JSON_MIME);
} }
getDefaultEditorOptions(): DefaultEditorOptions {
return {
grid: this.options.grid,
background: this.options.background,
};
}
async load() { async load() {
this.firstRun = false; this.firstRun = false;
await this.file.loadFromSiYuanFS(); await this.file.loadFromSiYuanFS();
this.options = JSON.parse(this.file.getContent()); const jsonObj = JSON.parse(this.file.getContent());
if(this.options == null) { if(jsonObj == null) {
this.loadDefaultConfig(); this.firstRun = true;
} }
} // if more than one fallback, the intermediate ones are from a legacy config file version
private loadDefaultConfig() {
this.options = { this.options = {
grid: true, dialogOnDesktop: getFirstDefined(jsonObj?.dialogOnDesktop, false),
background: "#000000", analytics: getFirstDefined(jsonObj?.analytics, true),
dialogOnDesktop: false, editorOptions: {
analytics: true, restorePosition: getFirstDefined(jsonObj?.editorOptions?.restorePosition, jsonObj?.restorePosition, true),
grid: getFirstDefined(jsonObj?.editorOptions?.grid, jsonObj?.grid, true),
background: getFirstDefined(jsonObj?.editorOptions?.background, jsonObj?.background, "#00000000")
},
}; };
this.firstRun = true;
} }
async save() { async save() {
@ -61,14 +54,16 @@ export class PluginConfig {
} }
setConfig(config: Options) { setConfig(config: Options) {
if(!validateColor(config.background)) {
alert("Invalid background color! Please enter an HEX color, like #000000 (black) or #FFFFFF (white)");
config.background = this.options.background;
}
this.options = config; this.options = config;
} }
static validateColor(hex: string) {
hex = hex.replace('#', '');
return typeof hex === 'string'
&& (hex.length === 6 || hex.length === 8)
&& !isNaN(Number('0x' + hex))
}
} }
export class PluginConfigViewer { export class PluginConfigViewer {
@ -76,62 +71,100 @@ export class PluginConfigViewer {
config: PluginConfig; config: PluginConfig;
settingUtils: SettingUtils; settingUtils: SettingUtils;
plugin: Plugin; plugin: Plugin;
private readonly backgroundDropdownOptions;
constructor(config: PluginConfig, plugin: Plugin) { constructor(config: PluginConfig, plugin: Plugin) {
this.config = config; this.config = config;
this.plugin = plugin; this.plugin = plugin;
this.backgroundDropdownOptions = {
'#00000000': plugin.i18n.settings.suggestedColors.transparent,
'CUSTOM': plugin.i18n.settings.suggestedColors.custom,
'#ffffff': plugin.i18n.settings.suggestedColors.white,
'#1e2227': plugin.i18n.settings.suggestedColors.darkBlue,
'#1e1e1e': plugin.i18n.settings.suggestedColors.darkGray,
'#000000': plugin.i18n.settings.suggestedColors.black,
}
this.populateSettingMenu(); this.populateSettingMenu();
} }
async configSaveCallback(data) {
let color = data.backgroundDropdown === "CUSTOM" ? data.background : data.backgroundDropdown;
if(!PluginConfig.validateColor(color)) {
ErrorReporter.error(new InvalidBackgroundColorError());
data.background = this.config.options.editorOptions.background;
this.settingUtils.set('background', data.background);
}
this.config.setConfig({
dialogOnDesktop: data.dialogOnDesktop,
analytics: data.analytics,
editorOptions: {
grid: data.grid,
background: color,
restorePosition: data.restorePosition,
}
});
await this.config.save();
}
populateSettingMenu() { populateSettingMenu() {
this.settingUtils = new SettingUtils({ this.settingUtils = new SettingUtils({
plugin: this.plugin, plugin: this.plugin,
name: 'optionsUI',
callback: async (data) => { callback: async (data) => {
this.config.setConfig({ await this.configSaveCallback(data);
grid: data.grid,
background: data.background,
dialogOnDesktop: data.dialogOnDesktop,
analytics: data.analytics,
});
await this.config.save();
} }
}); });
this.settingUtils.addItem({ this.settingUtils.addItem({
key: "grid", key: "grid",
title: "Enable grid by default", title: this.plugin.i18n.settings.grid.title,
description: "Enable to automatically turn on the grid on new drawings.", description: this.plugin.i18n.settings.grid.description,
value: this.config.options.grid, value: this.config.options.editorOptions.grid,
type: 'checkbox' type: 'checkbox'
}); });
this.settingUtils.addItem({
key: 'backgroundDropdown',
title: this.plugin.i18n.settings.backgroundDropdown.title,
description: this.plugin.i18n.settings.backgroundDropdown.description,
type: 'select',
value: this.config.options.editorOptions.background in this.backgroundDropdownOptions ?
this.config.options.editorOptions.background : 'CUSTOM',
options: this.backgroundDropdownOptions,
});
this.settingUtils.addItem({ this.settingUtils.addItem({
key: "background", key: "background",
title: "Default background Color", title: this.plugin.i18n.settings.background.title,
description: "Default background color of the drawing area for new drawings in hexadecimal.", description: this.plugin.i18n.settings.background.description,
value: this.config.options.background, value: this.config.options.editorOptions.background,
type: 'textarea', type: 'textinput',
});
this.settingUtils.addItem({
key: "restorePosition",
title: this.plugin.i18n.settings.restorePosition.title,
description: this.plugin.i18n.settings.restorePosition.description,
value: this.config.options.editorOptions.restorePosition,
type: 'checkbox'
}); });
this.settingUtils.addItem({ this.settingUtils.addItem({
key: "dialogOnDesktop", key: "dialogOnDesktop",
title: "Open editor as dialog on desktop", title: this.plugin.i18n.settings.dialogOnDesktop.title,
description: ` description: this.plugin.i18n.settings.dialogOnDesktop.description,
Dialog mode provides a larger drawing area, but it's not as handy to use as tabs (default).<br />
The editor will always open as a dialog on mobile.
`,
value: this.config.options.dialogOnDesktop, value: this.config.options.dialogOnDesktop,
type: 'checkbox' type: 'checkbox'
}); });
this.settingUtils.addItem({ this.settingUtils.addItem({
key: "analytics", key: "analytics",
title: "Analytics", title: this.plugin.i18n.settings.analytics.title,
description: ` description: this.plugin.i18n.settings.analytics.description,
Enable to send anonymous usage data to the developer.
<a href='https://s.massive.box/jsdraw-plugin-privacy'>Privacy</a>
`,
value: this.config.options.analytics, value: this.config.options.analytics,
type: 'checkbox' type: 'checkbox'
}); });

View file

@ -1,12 +1,25 @@
import {MaterialIconProvider} from "@js-draw/material-icons"; import {MaterialIconProvider} from "@js-draw/material-icons";
import {PluginAsset, PluginFile} from "@/file"; import {PluginAsset, PluginFile} from "@/file";
import {JSON_MIME, STORAGE_PATH, SVG_MIME, TOOLBAR_FILENAME} from "@/const"; import {JSON_MIME, STORAGE_PATH, SVG_MIME, TOOLBAR_FILENAME} from "@/const";
import Editor, {BackgroundComponentBackgroundType, BaseWidget, Color4, EditorEventType} from "js-draw"; import Editor, {
BackgroundComponentBackgroundType,
BaseWidget,
Color4,
EditorEventType, getLocalizationTable,
Mat33,
Vec2,
Viewport
} from "js-draw";
import {Dialog, getFrontend, openTab, Plugin} from "siyuan"; import {Dialog, getFrontend, openTab, Plugin} from "siyuan";
import {findSyncIDInProtyle, replaceSyncID} from "@/protyle"; import {findSyncIDInProtyle, replaceSyncID} from "@/protyle";
import DrawJSPlugin from "@/index"; import DrawJSPlugin from "@/index";
import {DefaultEditorOptions} from "@/config"; import {EditorOptions} from "@/config";
import 'js-draw/styles'; import 'js-draw/styles';
import {
ErrorReporter,
GenericSaveError, InternationalizedError, NoFileIDError, SyncIDNotFoundError,
UnchangedProtyleError
} from "@/errors";
export class PluginEditor { export class PluginEditor {
@ -23,72 +36,100 @@ export class PluginEditor {
getEditor(): Editor { return this.editor; } getEditor(): Editor { return this.editor; }
getFileID(): string { return this.fileID; } getFileID(): string { return this.fileID; }
getSyncID(): string { return this.syncID; } getSyncID(): string { return this.syncID; }
setSyncID(syncID: string) { this.syncID = syncID; }
constructor(fileID: string, defaultEditorOptions: DefaultEditorOptions) { private constructor(fileID: string) {
this.fileID = fileID; this.fileID = fileID;
this.element = document.createElement("div"); this.element = document.createElement("div");
this.element.style.height = '100%'; this.element.style.height = '100%';
this.editor = new Editor(this.element, { this.editor = new Editor(this.element, {
localization: getLocalizationTable([window.siyuan.config.lang]),
iconProvider: new MaterialIconProvider(), iconProvider: new MaterialIconProvider(),
}); });
this.genToolbar().then(() => { const styleElement = document.createElement('style');
this.editor.dispatch(this.editor.setBackgroundStyle({ autoresize: true }), false); styleElement.innerHTML = `
this.editor.getRootElement().style.height = '100%'; canvas.wetInkCanvas {
}); cursor: url('/plugins/siyuan-jsdraw-plugin/webapp/cursor.png') 3 3, none;
findSyncIDInProtyle(this.fileID).then(async (syncID) => {
if(syncID == null) {
alert(
"Couldn't find SyncID in protyle for this file.\n" +
"Make sure the drawing you're trying to edit exists in a note.\n" +
"Close this editor tab now, and try to open the editor again."
);
return;
} }
`;
this.element.appendChild(styleElement);
this.syncID = syncID; this.editor.dispatch(this.editor.setBackgroundStyle({ autoresize: true }), false);
// restore drawing this.editor.getRootElement().style.height = '100%';
this.drawingFile = new PluginAsset(this.fileID, syncID, SVG_MIME);
await this.drawingFile.loadFromSiYuanFS();
if(this.drawingFile.getContent() != null) {
await this.editor.loadFromSVG(this.drawingFile.getContent());
}else{
// it's a new drawing
this.editor.dispatch(this.editor.setBackgroundStyle({
color: Color4.fromHex(defaultEditorOptions.background),
type: defaultEditorOptions.grid ? BackgroundComponentBackgroundType.Grid : BackgroundComponentBackgroundType.SolidColor,
autoresize: true
}));
}
}).catch((error) => {
alert("Error loading drawing: " + error);
});
} }
private async genToolbar() { static async create(fileID: string, defaultEditorOptions: EditorOptions): Promise<PluginEditor> {
const instance = new PluginEditor(fileID);
await instance.genToolbar();
let syncID = await findSyncIDInProtyle(fileID);
if(syncID == null) {
throw new SyncIDNotFoundError();
}
instance.setSyncID(syncID);
await instance.restoreOrInitFile(defaultEditorOptions);
return instance;
}
async restoreOrInitFile(defaultEditorOptions: EditorOptions) {
this.drawingFile = new PluginAsset(this.fileID, this.syncID, SVG_MIME);
await this.drawingFile.loadFromSiYuanFS();
const drawingContent = this.drawingFile.getContent();
if(drawingContent != null) {
await this.editor.loadFromSVG(drawingContent);
// restore position and zoom
const svgElem = new DOMParser().parseFromString(drawingContent, SVG_MIME).documentElement;
const editorViewStr = svgElem.getAttribute('editorView');
if(editorViewStr != null && defaultEditorOptions.restorePosition) {
try {
const [viewBoxOriginX, viewBoxOriginY, zoom] = editorViewStr.split(' ').map(x => parseFloat(x));
this.editor.dispatch(Viewport.transformBy(Mat33.scaling2D(zoom)));
this.editor.dispatch(Viewport.transformBy(Mat33.translation(Vec2.of(
- viewBoxOriginX,
- viewBoxOriginY
))));
}catch (e){}
}
}else{
// it's a new drawing
this.editor.dispatch(this.editor.setBackgroundStyle({
color: Color4.fromHex(defaultEditorOptions.background),
type: defaultEditorOptions.grid ? BackgroundComponentBackgroundType.Grid : BackgroundComponentBackgroundType.SolidColor,
autoresize: true
}));
}
}
async genToolbar() {
const toolbar = this.editor.addToolbar(); const toolbar = this.editor.addToolbar();
// restore toolbarFile state
this.toolbarFile = new PluginFile(STORAGE_PATH, TOOLBAR_FILENAME, JSON_MIME);
this.toolbarFile.loadFromSiYuanFS().then(() => {
if(this.toolbarFile.getContent() != null) {
toolbar.deserializeState(this.toolbarFile.getContent());
}
});
// save button // save button
const saveButton = toolbar.addSaveButton(async () => { const saveButton = toolbar.addSaveButton(async () => {
await this.saveCallback(saveButton); await this.saveCallback(saveButton);
}); });
// restore toolbarFile state
this.toolbarFile = new PluginFile(STORAGE_PATH, TOOLBAR_FILENAME, JSON_MIME);
await this.toolbarFile.loadFromSiYuanFS();
if(this.toolbarFile.getContent() != null) {
toolbar.deserializeState(this.toolbarFile.getContent());
}
// save toolbar config on tool change (toolbar state is not saved in SVGs!) // save toolbar config on tool change (toolbar state is not saved in SVGs!)
this.editor.notifier.on(EditorEventType.ToolUpdated, () => { this.editor.notifier.on(EditorEventType.ToolUpdated, () => {
this.toolbarFile.setContent(toolbar.serializeState()); this.toolbarFile.setContent(toolbar.serializeState());
@ -103,13 +144,17 @@ export class PluginEditor {
let newSyncID: string; let newSyncID: string;
const oldSyncID = this.syncID; const oldSyncID = this.syncID;
const rect = this.editor.viewport.visibleRect;
const zoom = this.editor.viewport.getScaleFactor();
svgElem.setAttribute('editorView', `${rect.x} ${rect.y} ${zoom}`)
try { try {
this.drawingFile.setContent(svgElem.outerHTML); this.drawingFile.setContent(svgElem.outerHTML);
await this.drawingFile.save(); await this.drawingFile.save();
newSyncID = this.drawingFile.getSyncID(); newSyncID = this.drawingFile.getSyncID();
if(newSyncID != oldSyncID) { // supposed to replace protyle if(newSyncID != oldSyncID) { // supposed to replace protyle
const changed = await replaceSyncID(this.fileID, oldSyncID, newSyncID); // try to change protyle const changed = await replaceSyncID(this.fileID, oldSyncID, newSyncID); // try to change protyle
if(!changed) throw new Error("Couldn't replace old images in protyle"); if(!changed) throw new UnchangedProtyleError();
await this.drawingFile.removeOld(oldSyncID); await this.drawingFile.removeOld(oldSyncID);
} }
saveButton.setDisabled(true); saveButton.setDisabled(true);
@ -117,9 +162,13 @@ export class PluginEditor {
saveButton.setDisabled(false); saveButton.setDisabled(false);
}, 500); }, 500);
} catch (error) { } catch (error) {
alert("Error saving! The current drawing has been copied to your clipboard. You may need to create a new drawing and paste it there."); if(error instanceof InternationalizedError) {
ErrorReporter.error(error);
}else{
ErrorReporter.error(new GenericSaveError());
console.error(error);
}
await navigator.clipboard.writeText(svgElem.outerHTML); await navigator.clipboard.writeText(svgElem.outerHTML);
console.error(error);
console.log("Couldn't save SVG: ", svgElem.outerHTML) console.log("Couldn't save SVG: ", svgElem.outerHTML)
return; return;
} }
@ -133,22 +182,34 @@ export class PluginEditor {
export class EditorManager { export class EditorManager {
private editor: PluginEditor private editor: PluginEditor
setEditor(editor: PluginEditor) { this.editor = editor;}
constructor(fileID: string, defaultEditorOptions: DefaultEditorOptions) { static async create(fileID: string, p: DrawJSPlugin) {
this.editor = new PluginEditor(fileID, defaultEditorOptions); let instance = new EditorManager();
try {
let editor = await PluginEditor.create(fileID, p.config.options.editorOptions);
instance.setEditor(editor);
}catch (error) {
ErrorReporter.error(error);
}
return instance;
} }
static registerTab(p: DrawJSPlugin) { static registerTab(p: DrawJSPlugin) {
p.addTab({ p.addTab({
'type': "whiteboard", 'type': "whiteboard",
init() { async init() {
const fileID = this.data.fileID; const fileID = this.data.fileID;
if (fileID == null) { if (fileID == null) {
alert("File ID missing - couldn't open file.") ErrorReporter.error(new NoFileIDError());
return; return;
} }
const editor = new PluginEditor(fileID, p.config.getDefaultEditorOptions()); try {
this.element.appendChild(editor.getElement()); const editor = await PluginEditor.create(fileID, p.config.options.editorOptions);
this.element.appendChild(editor.getElement());
}catch (error){
ErrorReporter.error(error);
}
} }
}); });
} }
@ -157,7 +218,7 @@ export class EditorManager {
openTab({ openTab({
app: p.app, app: p.app,
custom: { custom: {
title: 'Drawing', title: p.i18n.whiteboard,
icon: 'iconDraw', icon: 'iconDraw',
id: "siyuan-jsdraw-pluginwhiteboard", id: "siyuan-jsdraw-pluginwhiteboard",
data: { data: {
@ -176,7 +237,7 @@ export class EditorManager {
dialog.element.querySelector("#DrawingPanel").appendChild(this.editor.getElement()); dialog.element.querySelector("#DrawingPanel").appendChild(this.editor.getElement());
} }
async open(p: DrawJSPlugin) { open(p: DrawJSPlugin) {
if(getFrontend() != "mobile" && !p.config.options.dialogOnDesktop) { if(getFrontend() != "mobile" && !p.config.options.dialogOnDesktop) {
this.toTab(p); this.toTab(p);
} else { } else {

80
src/errors.ts Normal file
View file

@ -0,0 +1,80 @@
import {showMessage} from "siyuan";
export class InternationalizedError extends Error {
readonly key: string;
constructor(key: string) {
super(key);
this.key = key;
}
}
export class ErrorReporter {
static i18n: any;
constructor(i18n: any) {
ErrorReporter.i18n = i18n;
}
static error(err: Error, timeout?: number) {
console.error(err);
let errorTxt = err.message;
if(err instanceof InternationalizedError) {
errorTxt = ErrorReporter.i18n.errors[err.key];
}
if(!timeout) {
timeout = 0;
}
showMessage(errorTxt, timeout, 'error');
}
}
export class SyncIDNotFoundError extends InternationalizedError {
constructor() {
super('syncIDNotFound');
}
}
export class UnchangedProtyleError extends InternationalizedError {
constructor() {
super('unchangedProtyle');
}
}
export class MultipleSyncIDsError extends InternationalizedError {
constructor() {
super('multipleSyncIDs');
}
}
export class GenericSaveError extends InternationalizedError {
constructor() {
super('saveGeneric');
}
}
export class NotAWhiteboardError extends InternationalizedError {
constructor() {
super('notAWhiteboard');
}
}
export class InvalidBackgroundColorError extends InternationalizedError {
constructor() {
super('invalidBackgroundColor');
}
}
export class NoFileIDError extends InternationalizedError {
constructor() {
super('noFileID');
}
}
export class MustSelectError extends InternationalizedError {
constructor() {
super('mustSelect');
}
}

View file

@ -52,7 +52,7 @@ abstract class PluginFileBase {
protected toFile(customFilename?: string): File { protected toFile(customFilename?: string): File {
let filename = customFilename || this.fileName; let filename = customFilename || this.fileName;
const blob = new Blob([this.content], { type: this.mimeType }); const blob = new Blob([this.content], { type: this.mimeType });
return new File([blob], filename, { type: this.mimeType }); return new File([blob], filename, { type: this.mimeType, lastModified: Date.now() });
} }
} }

View file

@ -107,9 +107,10 @@ export function imgSrcToIDs(imgSrc: string | null): { fileID: string; syncID: st
} }
export function validateColor(hex: string) { export function getFirstDefined(...a) {
hex = hex.replace('#', ''); for(let i = 0; i < a.length; i++) {
return typeof hex === 'string' if(a[i] !== undefined) {
&& hex.length === 6 return a[i];
&& !isNaN(Number('0x' + hex)) }
}
} }

View file

@ -6,10 +6,10 @@ import {
findImgSrc, findImgSrc,
imgSrcToIDs, generateTimeString, generateRandomString imgSrcToIDs, generateTimeString, generateRandomString
} from "@/helper"; } from "@/helper";
import {migrate} from "@/migration";
import {EditorManager} from "@/editor"; import {EditorManager} from "@/editor";
import {PluginConfig, PluginConfigViewer} from "@/config"; import {PluginConfig, PluginConfigViewer} from "@/config";
import {Analytics} from "@/analytics"; import {Analytics} from "@/analytics";
import {ErrorReporter, MustSelectError, NotAWhiteboardError} from "@/errors";
export default class DrawJSPlugin extends Plugin { export default class DrawJSPlugin extends Plugin {
@ -18,23 +18,23 @@ export default class DrawJSPlugin extends Plugin {
async onload() { async onload() {
new ErrorReporter(this.i18n);
loadIcons(this); loadIcons(this);
EditorManager.registerTab(this); EditorManager.registerTab(this);
migrate()
await this.startConfig(); await this.startConfig();
await this.startAnalytics(); await this.startAnalytics();
this.protyleSlash = [{ this.protyleSlash = [{
id: "insert-drawing", id: "insert-whiteboard",
filter: ["Insert Drawing", "Add drawing", "whiteboard", "freehand", "graphics", "jsdraw"], filter: ["Insert Drawing", "Add drawing", "Insert whiteboard", "Add whiteboard", "whiteboard", "freehand", "graphics", "jsdraw", this.i18n.insertWhiteboard],
html: getMenuHTML("iconDraw", this.i18n.insertDrawing), html: getMenuHTML("iconDraw", this.i18n.insertWhiteboard),
callback: (protyle: Protyle) => { callback: async (protyle: Protyle) => {
void this.analytics.sendEvent('create'); void this.analytics.sendEvent('create');
const fileID = generateRandomString(); const fileID = generateRandomString();
const syncID = generateTimeString() + '-' + generateRandomString(); const syncID = generateTimeString() + '-' + generateRandomString();
protyle.insert(getMarkdownBlock(fileID, syncID), true, false); protyle.insert(getMarkdownBlock(fileID, syncID), false, false);
new EditorManager(fileID, this.config.getDefaultEditorOptions()).open(this); (await EditorManager.create(fileID, this)).open(this);
} }
}]; }];
@ -43,14 +43,31 @@ export default class DrawJSPlugin extends Plugin {
if (ids === null) return; if (ids === null) return;
e.detail.menu.addItem({ e.detail.menu.addItem({
icon: "iconDraw", icon: "iconDraw",
label: "Edit with js-draw", label: this.i18n.editWhiteboard,
click: () => { click: async () => {
void this.analytics.sendEvent('edit'); void this.analytics.sendEvent('edit');
new EditorManager(ids.fileID, this.config.getDefaultEditorOptions()).open(this); (await EditorManager.create(ids.fileID, this)).open(this);
} }
}) })
}) })
this.addCommand({
langKey: "editShortcut",
hotkey: "⌥⇧D",
callback: async () => {
this.editSelectedImg().catch(e => ErrorReporter.error(e, 5000));
},
})
this.addTopBar({
icon: "iconDraw",
title: this.i18n.editShortcut,
callback: async () => {
await this.editSelectedImg().catch(e => ErrorReporter.error(e, 5000));
},
position: "left"
})
} }
onunload() { onunload() {
@ -61,6 +78,22 @@ export default class DrawJSPlugin extends Plugin {
void this.analytics.sendEvent("uninstall"); void this.analytics.sendEvent("uninstall");
} }
private async editSelectedImg() {
let selectedImg = document.getElementsByClassName('img--select');
if(selectedImg.length == 0) {
throw new MustSelectError();
}
let ids = imgSrcToIDs(findImgSrc(selectedImg[0] as HTMLElement));
if(ids == null) {
throw new NotAWhiteboardError();
}
void this.analytics.sendEvent('edit');
(await EditorManager.create(ids.fileID, this)).open(this);
}
private async startConfig() { private async startConfig() {
this.config = new PluginConfig(); this.config = new PluginConfig();
await this.config.load(); await this.config.load();

View file

@ -1,65 +0,0 @@
import {sql} from "@/api";
import {PluginAsset, PluginFile} from "@/file";
import {ASSETS_PATH, DATA_PATH, SVG_MIME} from "@/const";
import {replaceBlockContent} from "@/protyle";
import {generateRandomString, getMarkdownBlock} from "@/helper";
import {Dialog} from "siyuan";
export async function migrate() {
let blocks = await findEmbedBlocks();
const found = blocks.length > 0;
for(const block of blocks) {
const oldFileID = extractID(block.markdown);
if(oldFileID) {
const oldFile = new PluginFile(DATA_PATH + ASSETS_PATH, oldFileID + '.svg', SVG_MIME);
await oldFile.loadFromSiYuanFS();
const newFile = new PluginAsset(generateRandomString(), oldFileID, SVG_MIME);
newFile.setContent(oldFile.getContent());
await newFile.save();
const newMarkdown = getMarkdownBlock(newFile.getFileID(), newFile.getSyncID());
if(await replaceBlockContent(block.id, block.markdown, newMarkdown)) {
await oldFile.remove();
}
}
}
if(found) {
new Dialog({
width: "90vw",
height: "90vh",
content: `
<iframe
style="width: 100%; height: 100%; background-color: white"
src="https://notes.massive.box/YRpTbbxLiD"
/>
`
})
}
}
function extractID(html: string): string | null {
// Match the pattern: id= followed by characters until &amp; or quote
const regex = /id=([^&"']+)/;
const match = html.match(regex);
return match ? match[1] : null;
}
async function findEmbedBlocks() {
const sqlQuery = `
SELECT id, markdown
FROM blocks
WHERE markdown like '%src="/plugins/siyuan-jsdraw-plugin/webapp/%'
`;
try {
return await sql(sqlQuery);
} catch (error) {
console.error('Error searching for embed blocks:', error);
return [];
}
}

View file

@ -1,5 +1,6 @@
import {getBlockByID, sql, updateBlock} from "@/api"; import {getBlockByID, sql, updateBlock} from "@/api";
import {assetPathToIDs, IDsToAssetPath} from "@/helper"; import {assetPathToIDs, IDsToAssetPath} from "@/helper";
import {MultipleSyncIDsError} from "@/errors";
export async function findSyncIDInProtyle(fileID: string, iter?: number): Promise<string> { export async function findSyncIDInProtyle(fileID: string, iter?: number): Promise<string> {
@ -15,11 +16,7 @@ export async function findSyncIDInProtyle(fileID: string, iter?: number): Promis
if(syncID == null) { if(syncID == null) {
syncID = ids.syncID; syncID = ids.syncID;
}else if(ids.syncID !== syncID) { }else if(ids.syncID !== syncID) {
throw new Error( throw new MultipleSyncIDsError();
"Multiple syncIDs found in documents. Remove the drawings that don't exist from your documents.\n" +
"Sync conflict copies can cause this error, so make sure to delete them, or at least the js-draw drawings they contain.\n" +
"File IDs must be unique. Close this editor tab now."
);
} }
} }
} }
@ -82,7 +79,7 @@ export async function replaceBlockContent(
} }
function extractImageSourcesFromMarkdown(markdown: string, mustStartWith?: string) { function extractImageSourcesFromMarkdown(markdown: string, mustStartWith?: string) {
const imageRegex = /!\[.*?\]\((.*?)\)/g; // only get images const imageRegex = /!\[.*?\]\(([^)\s]+)(?:\s+"[^"]+")?\)/g; // only get images
return Array.from(markdown.matchAll(imageRegex)) return Array.from(markdown.matchAll(imageRegex))
.map(match => match[1]) .map(match => match[1])
.filter(source => source.startsWith(mustStartWith)) // discard other images .filter(source => source.startsWith(mustStartWith)) // discard other images