diff --git a/.github/workflows/release.yml b/.forgejo/workflows/build.yml similarity index 60% rename from .github/workflows/release.yml rename to .forgejo/workflows/build.yml index 49834e5..4bfcc57 100644 --- a/.github/workflows/release.yml +++ b/.forgejo/workflows/build.yml @@ -1,7 +1,9 @@ -name: Create Release on Tag Push +name: Build on Push and create Release on Tag on: push: + branches: + - main tags: - "v*" @@ -20,7 +22,7 @@ jobs: node-version: 20 registry-url: "https://registry.npmjs.org" - # Install pnpm + # Install pnpm - name: Install pnpm uses: pnpm/action-setup@v4 id: pnpm-install @@ -28,6 +30,12 @@ jobs: version: 8 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 - name: Get pnpm store directory id: pnpm-cache @@ -52,11 +60,22 @@ jobs: - name: Build for production run: pnpm build - - name: Release - uses: ncipollo/release-action@v1 + # Move file + - name: Move file + run: mkdir built; mv package.zip built/package.zip + + # Upload artifacts + - name: Upload artifacts + uses: actions/upload-artifact@v3 with: - allowUpdates: true - artifactErrorsFailBuild: true - artifacts: "package.zip" - token: ${{ secrets.GITHUB_TOKEN }} - prerelease: false + path: built/package.zip + overwrite: true + + # Create Forgejo Release + - name: Create Forgejo Release + if: github.ref_type == 'tag' + uses: actions/forgejo-release@v1 + with: + direction: upload + release-dir: built + token: ${{ secrets.FORGE_TOKEN }} diff --git a/README-zh_CN.md b/README-zh_CN.md new file mode 100644 index 0000000..f027586 --- /dev/null +++ b/README-zh_CN.md @@ -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 许可证。 \ No newline at end of file diff --git a/README.md b/README.md index f58614c..77a52af 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,23 @@ This plugin allows you to embed js-draw whiteboards anywhere in your SiYuan documents. ## Usage instructions +![Demo](asset/demo.webp) - Install the plugin from the marketplace. You can find it by searching for `js-draw`. -- To add a new drawing to your document: - 1. Type `/Insert Drawing` in your document, and select the correct menu entry +- To add a new whiteboard to your document: + 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. -- To edit the image later: - 1. Right-click on the image (or click the three dots on mobile), select "Plugin" > "Edit with js-draw" in the menu +- To edit the whiteboard later: + 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. ## Planned features Check out the [Projects](https://git.massive.box/massivebox/siyuan-jsdraw-plugin/projects) tab! ## Contributing -Contributions are always welcome! Right now, I'm working on the core functionality and fixing bugs. -After that is done, I will need help with the internationalization, as, unfortunately, I don't speak Chinese. -Please [contact me](mailto:box@massive.box) if you'd like to help! +Contributions are always welcome! +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 [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 This project couldn't have been possible without (in no particular order): diff --git a/asset/demo.webp b/asset/demo.webp new file mode 100644 index 0000000..829c974 Binary files /dev/null and b/asset/demo.webp differ diff --git a/package.json b/package.json index 3dd8f4f..8c62cd5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "siyuan-jsdraw-plugin", - "version": "0.2.2", + "version": "0.5.2", "type": "module", "description": "Include a whiteboard for freehand drawing anywhere in your documents.", "repository": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin", @@ -35,7 +35,8 @@ "vite-plugin-zip-pack": "^1.0.5" }, "dependencies": { - "@js-draw/material-icons": "^1.29.0", - "js-draw": "^1.29.0" + "@js-draw/material-icons": "^1.31.1", + "js-draw": "^1.31.1", + "ts-serializable": "^4.2.0" } } diff --git a/plugin.json b/plugin.json index 8c3ef88..65997ac 100644 --- a/plugin.json +++ b/plugin.json @@ -2,7 +2,7 @@ "name": "siyuan-jsdraw-plugin", "author": "massivebox", "url": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin", - "version": "0.2.2", + "version": "0.5.2", "minAppVersion": "3.0.12", "backends": [ "windows", @@ -21,17 +21,20 @@ "desktop-window" ], "displayName": { - "en_US": "JS-Draw Whiteboard" + "en_US": "JS-Draw Whiteboard", + "zh_CN": "JS-Draw 白板" }, "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": { - "en_US": "README.md" + "en_US": "README.md", + "zh_CN": "README-zh_CN.md" }, "funding": { "custom": [ - "" + "https://s.massive.box/jsdraw-plugin-donate" ] }, "keywords": [ diff --git a/public/i18n/en_US.json b/public/i18n/en_US.json index b6d2382..f08dd9d 100644 --- a/public/i18n/en_US.json +++ b/public/i18n/en_US.json @@ -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. Usage instructions", + "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.\nFull explanation", + "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. Usage instructions" + }, + "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.
This setting is only applied if \"Background Color\" is set to \"Custom\"!" + }, + "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).
The editor will always open as a dialog on mobile." + }, + "analytics": { + "title": "Analytics", + "description": "Enable to send anonymous usage data to the developer. Privacy Policy" + }, + "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." + } + } } \ No newline at end of file diff --git a/public/i18n/zh_CN.json b/public/i18n/zh_CN.json new file mode 100644 index 0000000..fb00d23 --- /dev/null +++ b/public/i18n/zh_CN.json @@ -0,0 +1,52 @@ +{ + "insertWhiteboard": "插入白板", + "editWhiteboard": "编辑白板", + "editShortcut": "编辑选中的白板", + "errors": { + "noFileID": "缺少文件 ID,无法打开文件。", + "notAWhiteboard": "您必须选择白板,而不是普通图片。使用说明", + "syncIDNotFound": "在文档中找不到该绘图的 SyncID,请确保您尝试编辑的白板已包含在至少一个笔记中。", + "createUnknown": "创建编辑器时出现未知错误,请重试。", + "invalidBackgroundColor": "无效的背景颜色!请输入十六进制颜色,例如 #000000(黑色)或 #FFFFFF(白色)。将使用原来的背景颜色。", + "multipleSyncIDs": "在文档中发现多个 syncID。请从文档中删除不存在的绘图。\n同步冲突副本可能导致此错误,因此请务必删除它们。\n文件 ID(可在重命名菜单中更改的部分)在所有文档中必须唯一。\n完整说明", + "unchangedProtyle": "请确保您尝试编辑的图片仍存在于文档中。", + "saveGeneric": "保存出错!当前绘图已复制到剪贴板。您可能需要新建一个绘图并粘贴进去。", + "mustSelect": "先在文档中左键点击选中白板,然后使用此图标/快捷键直接打开编辑器。使用说明" + }, + "whiteboard": "白板", + "settings": { + "name": "js-draw 插件设置", + "suggestedColors": { + "white": "白色", + "black": "黑色", + "transparent": "透明", + "custom": "自定义", + "darkBlue": "深蓝", + "darkGray": "深灰" + }, + "grid": { + "title": "默认启用网格", + "description": "开启后,新白板将自动显示网格。" + }, + "backgroundDropdown": { + "title": "背景颜色", + "description": "新白板的默认背景颜色。" + }, + "background": { + "title": "自定义背景", + "description": "新白板自定义背景色的十六进制代码。
仅在“背景颜色”设为“自定义”时才生效!" + }, + "dialogOnDesktop": { + "title": "在桌面端以对话框打开编辑器", + "description": "对话框模式提供更大的绘图区域,但不如标签页(默认)方便。
移动端始终会以对话框打开编辑器。" + }, + "analytics": { + "title": "分析统计", + "description": "开启后,向开发者发送匿名使用数据。隐私政策" + }, + "restorePosition": { + "title": "记住编辑器位置", + "description": "开启后,编辑器会记住缩放比例和位置,下次打开同一白板时恢复。" + } + } +} \ No newline at end of file diff --git a/public/webapp/cursor.png b/public/webapp/cursor.png new file mode 100644 index 0000000..939ddde Binary files /dev/null and b/public/webapp/cursor.png differ diff --git a/scripts/validate_tag.cjs b/scripts/validate_tag.cjs new file mode 100644 index 0000000..c842ffc --- /dev/null +++ b/scripts/validate_tag.cjs @@ -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); +} diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 0000000..127908c --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,47 @@ +import {getBackend, getFrontend} from "siyuan"; +import {JSON_MIME} from "@/const"; +import packageJson from '../package.json' assert { type: 'json' }; + +export class Analytics { + + private readonly enabled: boolean; + + private static readonly ENDPOINT = 'https://stats.massive.box/api/send_noua'; + private static readonly WEBSITE_ID = '0a1ebbc1-d702-4f64-86ed-f62dcde9b522'; + + constructor(enabled: boolean) { + this.enabled = enabled; + } + + async sendEvent(name: string) { + + if(!this.enabled) return; + + const sendData = (name == 'load' || name == 'install') ? + { + 'appVersion': window.navigator.userAgent.split(' ')[0], + 'pluginVersion': packageJson.version, + 'frontend': getFrontend(), + 'backend': getBackend(), + 'language': navigator.language, + 'appLanguage': window.siyuan.config.lang, + } : {}; + + await fetch(Analytics.ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': JSON_MIME, + }, + body: JSON.stringify({ + type: 'event', + payload: { + website: Analytics.WEBSITE_ID, + name: name, + data: sendData, + }, + }) + }) + + } + +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..116a1af --- /dev/null +++ b/src/config.ts @@ -0,0 +1,178 @@ +import {PluginFile} from "@/file"; +import {CONFIG_FILENAME, JSON_MIME, STORAGE_PATH} from "@/const"; +import {Plugin} from "siyuan"; +import {SettingUtils} from "@/libs/setting-utils"; +import {getFirstDefined} from "@/helper"; +import {ErrorReporter, InvalidBackgroundColorError} from "@/errors"; + +export interface Options { + dialogOnDesktop: boolean + analytics: boolean + editorOptions: EditorOptions +} +export interface EditorOptions { + restorePosition: boolean; + grid: boolean + background: string +} + +export class PluginConfig { + + private file: PluginFile; + + options: Options; + private firstRun: boolean; + + getFirstRun() { return this.firstRun } + + constructor() { + this.file = new PluginFile(STORAGE_PATH, CONFIG_FILENAME, JSON_MIME); + } + + async load() { + this.firstRun = false; + await this.file.loadFromSiYuanFS(); + const jsonObj = JSON.parse(this.file.getContent()); + if(jsonObj == null) { + this.firstRun = true; + } + // if more than one fallback, the intermediate ones are from a legacy config file version + this.options = { + dialogOnDesktop: getFirstDefined(jsonObj?.dialogOnDesktop, false), + analytics: getFirstDefined(jsonObj?.analytics, true), + editorOptions: { + restorePosition: getFirstDefined(jsonObj?.editorOptions?.restorePosition, jsonObj?.restorePosition, true), + grid: getFirstDefined(jsonObj?.editorOptions?.grid, jsonObj?.grid, true), + background: getFirstDefined(jsonObj?.editorOptions?.background, jsonObj?.background, "#00000000") + }, + }; + } + + async save() { + this.file.setContent(JSON.stringify(this.options)); + await this.file.save(); + } + + setConfig(config: Options) { + 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 { + + config: PluginConfig; + settingUtils: SettingUtils; + plugin: Plugin; + private readonly backgroundDropdownOptions; + + constructor(config: PluginConfig, plugin: Plugin) { + this.config = config; + 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(); + } + + 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() { + + this.settingUtils = new SettingUtils({ + plugin: this.plugin, + name: 'optionsUI', + callback: async (data) => { + await this.configSaveCallback(data); + } + }); + + this.settingUtils.addItem({ + key: "grid", + title: this.plugin.i18n.settings.grid.title, + description: this.plugin.i18n.settings.grid.description, + value: this.config.options.editorOptions.grid, + 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({ + key: "background", + title: this.plugin.i18n.settings.background.title, + description: this.plugin.i18n.settings.background.description, + value: this.config.options.editorOptions.background, + 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({ + key: "dialogOnDesktop", + title: this.plugin.i18n.settings.dialogOnDesktop.title, + description: this.plugin.i18n.settings.dialogOnDesktop.description, + value: this.config.options.dialogOnDesktop, + type: 'checkbox' + }); + + this.settingUtils.addItem({ + key: "analytics", + title: this.plugin.i18n.settings.analytics.title, + description: this.plugin.i18n.settings.analytics.description, + value: this.config.options.analytics, + type: 'checkbox' + }); + + } + + load() { + return this.settingUtils.load(); + } + +} \ No newline at end of file diff --git a/src/const.ts b/src/const.ts index 2f8e88a..b7d60aa 100644 --- a/src/const.ts +++ b/src/const.ts @@ -2,8 +2,8 @@ export const SVG_MIME = "image/svg+xml"; export const JSON_MIME = "application/json"; export const DATA_PATH = "/data/"; export const ASSETS_PATH = "assets/"; -export const STORAGE_PATH = DATA_PATH + "storage/petal/siyuan-jsdraw-plugin"; -export const TOOLBAR_PATH = STORAGE_PATH + "/toolbar.json"; -export const CONFIG_PATH = STORAGE_PATH + "/conf.json"; +export const STORAGE_PATH = "/data/storage/petal/siyuan-jsdraw-plugin/"; +export const TOOLBAR_FILENAME = "toolbar.json"; +export const CONFIG_FILENAME = "conf.json"; export const EMBED_PATH = "/plugins/siyuan-jsdraw-plugin/webapp/?path="; export const DUMMY_HOST = "https://dummy.host/"; \ No newline at end of file diff --git a/src/editor.ts b/src/editor.ts new file mode 100644 index 0000000..aeb032b --- /dev/null +++ b/src/editor.ts @@ -0,0 +1,248 @@ +import {MaterialIconProvider} from "@js-draw/material-icons"; +import {PluginAsset, PluginFile} from "@/file"; +import {JSON_MIME, STORAGE_PATH, SVG_MIME, TOOLBAR_FILENAME} from "@/const"; +import Editor, { + BackgroundComponentBackgroundType, + BaseWidget, + Color4, + EditorEventType, getLocalizationTable, + Mat33, + Vec2, + Viewport +} from "js-draw"; +import {Dialog, getFrontend, openTab, Plugin} from "siyuan"; +import {findSyncIDInProtyle, replaceSyncID} from "@/protyle"; +import DrawJSPlugin from "@/index"; +import {EditorOptions} from "@/config"; +import 'js-draw/styles'; +import { + ErrorReporter, + GenericSaveError, InternationalizedError, NoFileIDError, SyncIDNotFoundError, + UnchangedProtyleError +} from "@/errors"; + +export class PluginEditor { + + private readonly element: HTMLElement; + private readonly editor: Editor; + + private drawingFile: PluginAsset; + private toolbarFile: PluginFile; + + private readonly fileID: string; + private syncID: string; + + getElement(): HTMLElement { return this.element; } + getEditor(): Editor { return this.editor; } + getFileID(): string { return this.fileID; } + getSyncID(): string { return this.syncID; } + setSyncID(syncID: string) { this.syncID = syncID; } + + private constructor(fileID: string) { + + this.fileID = fileID; + + this.element = document.createElement("div"); + this.element.style.height = '100%'; + this.editor = new Editor(this.element, { + localization: getLocalizationTable([window.siyuan.config.lang]), + iconProvider: new MaterialIconProvider(), + }); + + const styleElement = document.createElement('style'); + styleElement.innerHTML = ` + canvas.wetInkCanvas { + cursor: url('/plugins/siyuan-jsdraw-plugin/webapp/cursor.png') 3 3, none; + } + `; + this.element.appendChild(styleElement); + + this.editor.dispatch(this.editor.setBackgroundStyle({ autoresize: true }), false); + this.editor.getRootElement().style.height = '100%'; + + } + + static async create(fileID: string, defaultEditorOptions: EditorOptions): Promise { + + 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(); + + // save button + const saveButton = toolbar.addSaveButton(async () => { + 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!) + this.editor.notifier.on(EditorEventType.ToolUpdated, () => { + this.toolbarFile.setContent(toolbar.serializeState()); + this.toolbarFile.save(); + }); + + } + + private async saveCallback(saveButton: BaseWidget) { + + const svgElem = this.editor.toSVG(); + let newSyncID: string; + 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 { + this.drawingFile.setContent(svgElem.outerHTML); + await this.drawingFile.save(); + newSyncID = this.drawingFile.getSyncID(); + if(newSyncID != oldSyncID) { // supposed to replace protyle + const changed = await replaceSyncID(this.fileID, oldSyncID, newSyncID); // try to change protyle + if(!changed) throw new UnchangedProtyleError(); + await this.drawingFile.removeOld(oldSyncID); + } + saveButton.setDisabled(true); + setTimeout(() => { // @todo improve save button feedback + saveButton.setDisabled(false); + }, 500); + } catch (error) { + if(error instanceof InternationalizedError) { + ErrorReporter.error(error); + }else{ + ErrorReporter.error(new GenericSaveError()); + console.error(error); + } + await navigator.clipboard.writeText(svgElem.outerHTML); + console.log("Couldn't save SVG: ", svgElem.outerHTML) + return; + } + + this.syncID = newSyncID; + + } + +} + +export class EditorManager { + + private editor: PluginEditor + setEditor(editor: PluginEditor) { this.editor = editor;} + + static async create(fileID: string, p: DrawJSPlugin) { + 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) { + p.addTab({ + 'type': "whiteboard", + async init() { + const fileID = this.data.fileID; + if (fileID == null) { + ErrorReporter.error(new NoFileIDError()); + return; + } + try { + const editor = await PluginEditor.create(fileID, p.config.options.editorOptions); + this.element.appendChild(editor.getElement()); + }catch (error){ + ErrorReporter.error(error); + } + } + }); + } + + toTab(p: Plugin) { + openTab({ + app: p.app, + custom: { + title: p.i18n.whiteboard, + icon: 'iconDraw', + id: "siyuan-jsdraw-pluginwhiteboard", + data: { + fileID: this.editor.getFileID(), + } + } + }); + } + + toDialog() { + const dialog = new Dialog({ + width: "100vw", + height: getFrontend() == "mobile" ? "100vh" : "90vh", + content: `
`, + }); + dialog.element.querySelector("#DrawingPanel").appendChild(this.editor.getElement()); + } + + open(p: DrawJSPlugin) { + if(getFrontend() != "mobile" && !p.config.options.dialogOnDesktop) { + this.toTab(p); + } else { + this.toDialog(); + } + } + +} \ No newline at end of file diff --git a/src/editorTab.ts b/src/editorTab.ts deleted file mode 100644 index 001320d..0000000 --- a/src/editorTab.ts +++ /dev/null @@ -1,126 +0,0 @@ -import {Dialog, getFrontend, ITabModel, openTab, Plugin} from "siyuan" -import Editor, {BaseWidget, EditorEventType} from "js-draw"; -import { MaterialIconProvider } from '@js-draw/material-icons'; -import 'js-draw/styles'; -import {getFile, saveFile, uploadAsset} from "@/file"; -import {DATA_PATH, JSON_MIME, SVG_MIME, TOOLBAR_PATH} from "@/const"; -import {replaceSyncID} from "@/protyle"; -import {IDsToAssetPath} from "@/helper"; -import {removeFile} from "@/api"; - -export function openEditorTab(p: Plugin, fileID: string, initialSyncID: string) { - if(getFrontend() == "mobile") { - const dialog = new Dialog({ - width: "100vw", - height: "100vh", - content: `
`, - }); - createEditor(dialog.element.querySelector("#DrawingPanel"), fileID, initialSyncID); - return; - } - for(const tab of p.getOpenedTab()["whiteboard"]) { - if(tab.data.fileID == fileID) { - alert("File is already open in another editor tab!"); - return; - } - } - openTab({ - app: p.app, - custom: { - title: 'Drawing', - icon: 'iconDraw', - id: "siyuan-jsdraw-pluginwhiteboard", - data: { - fileID: fileID, - initialSyncID: initialSyncID - } - } - }); -} - -async function saveCallback(editor: Editor, fileID: string, oldSyncID: string, saveButton: BaseWidget): Promise { - - const svgElem = editor.toSVG(); - let newSyncID; - - try { - newSyncID = (await uploadAsset(fileID, SVG_MIME, svgElem.outerHTML)).syncID; - if(newSyncID != oldSyncID) { - const changed = await replaceSyncID(fileID, oldSyncID, newSyncID); - if(!changed) { - alert( - "Error replacing old sync ID with new one! You may need to manually replace the file path." + - "\nTry saving the drawing again. This is a bug, please open an issue as soon as you can." + - "\nIf your document doesn't show the drawing, you can recover it from the SiYuan workspace directory." - ); - return oldSyncID; - } - await removeFile(DATA_PATH + IDsToAssetPath(fileID, oldSyncID)); - } - saveButton.setDisabled(true); - setTimeout(() => { // @todo improve save button feedback - saveButton.setDisabled(false); - }, 500); - } catch (error) { - alert("Error saving drawing! Enter developer mode to find the error, and a copy of the current status."); - console.error(error); - console.log("Couldn't save SVG: ", svgElem.outerHTML) - return oldSyncID; - } - - return newSyncID - -} - -export function createEditor(element: HTMLElement, fileID: string, initialSyncID: string) { - - const editor = new Editor(element, { - iconProvider: new MaterialIconProvider(), - }); - - const toolbar = editor.addToolbar(); - - // restore toolbar state - getFile(TOOLBAR_PATH).then(toolbarState => { - if(toolbarState!= null) { - toolbar.deserializeState(toolbarState) - } - }); - // restore drawing - getFile(DATA_PATH +IDsToAssetPath(fileID, initialSyncID)).then(svg => { - if(svg != null) { - editor.loadFromSVG(svg); - } - }); - - let syncID = initialSyncID; - // save logic - const saveButton = toolbar.addSaveButton(() => { - saveCallback(editor, fileID, syncID, saveButton).then( - newSyncID => { - syncID = newSyncID - } - ) - }); - - // save toolbar config on tool change (toolbar state is not saved in SVGs!) - editor.notifier.on(EditorEventType.ToolUpdated, () => { - saveFile(TOOLBAR_PATH, JSON_MIME, toolbar.serializeState()); - }); - - editor.dispatch(editor.setBackgroundStyle({ autoresize: true }), false); - editor.getRootElement().style.height = '100%'; - -} - -export function editorTabInit(tab: ITabModel) { - - const fileID = tab.data.fileID; - const initialSyncID = tab.data.initialSyncID; - if (fileID == null || initialSyncID == null) { - alert("File or Sync ID and path missing - couldn't open file.") - return; - } - createEditor(tab.element, fileID, initialSyncID); - -} \ No newline at end of file diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..e135e53 --- /dev/null +++ b/src/errors.ts @@ -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'); + } +} \ No newline at end of file diff --git a/src/file.ts b/src/file.ts index 79e0e48..a3a1a10 100644 --- a/src/file.ts +++ b/src/file.ts @@ -1,52 +1,108 @@ -import {getFileBlob, putFile, upload} from "@/api"; -import {ASSETS_PATH} from "@/const"; -import {assetPathToIDs} from "@/helper"; +import {getFileBlob, putFile, removeFile, upload} from "@/api"; +import {ASSETS_PATH, DATA_PATH} from "@/const"; +import {assetPathToIDs, IDsToAssetName} from "@/helper"; -function toFile(title: string, content: string, mimeType: string){ - const blob = new Blob([content], { type: mimeType }); - return new File([blob], title, { type: mimeType }); -} +abstract class PluginFileBase { -// upload asset to the assets folder, return fileID and syncID -export async function uploadAsset(fileID: string, mimeType: string, content: string) { + protected content: string | null; - const file = toFile(fileID + ".svg", content, mimeType); + protected fileName: string; + protected folderPath: string; + protected mimeType: string; - let r = await upload('/' + ASSETS_PATH, [file]); - if(r.errFiles) { - throw new Error("Failed to upload file"); - } - return assetPathToIDs(r.succMap[file.name]); + getContent() { return this.content; } + setContent(content: string) { this.content = content; } + setFileName(fileName: string) { this.fileName = fileName; } -} - -export function saveFile(path: string, mimeType: string, content: string) { - - const file = toFile(path.split('/').pop(), content, mimeType); - - try { - putFile(path, false, file); - } catch (error) { - console.error("Error saving file:", error); - throw error; - } - -} - -export async function getFile(path: string) { - - const blob = await getFileBlob(path); - const jsonText = await blob.text(); - - // if we got a 404 api response, we will return null - try { - const res = JSON.parse(jsonText); - if(res.code == 404) { - return null; + private setFolderPath(folderPath: string) { + if(folderPath.startsWith('/') && folderPath.endsWith('/')) { + this.folderPath = folderPath; + }else{ + throw new Error("folderPath must start and end with /"); } - }catch {} + } - // js-draw expects a string! - return jsonText; + // folderPath must start and end with / + constructor(folderPath: string, fileName: string, mimeType: string) { + this.setFolderPath(folderPath); + this.fileName = fileName; + this.mimeType = mimeType; + } + + async loadFromSiYuanFS() { + const blob = await getFileBlob(this.folderPath + this.fileName); + const text = await blob.text(); + + try { + const res = JSON.parse(text); + if(res.code == 404) { + this.content = null; + return; + } + }catch {} + + this.content = text; + } + + async remove(customFilename?: string) { + let filename = customFilename || this.fileName; + await removeFile(this.folderPath + filename); + } + + protected toFile(customFilename?: string): File { + let filename = customFilename || this.fileName; + const blob = new Blob([this.content], { type: this.mimeType }); + return new File([blob], filename, { type: this.mimeType, lastModified: Date.now() }); + } } + +export class PluginFile extends PluginFileBase { + + async save() { + const file = this.toFile(); + try { + await putFile(this.folderPath + this.fileName, false, file); + } catch (error) { + console.error("Error saving file:", error); + throw error; + } + } + +} + +export class PluginAsset extends PluginFileBase { + + private fileID: string + private syncID: string + + getFileID() { return this.fileID; } + getSyncID() { return this.syncID; } + + constructor(fileID: string, syncID: string, mimeType: string) { + super(DATA_PATH + ASSETS_PATH, IDsToAssetName(fileID, syncID), mimeType); + this.fileID = fileID; + this.syncID = syncID; + } + + async save() { + + const file = this.toFile(this.fileID + '.svg'); + + let r = await upload('/' + ASSETS_PATH, [file]); + if (r.errFiles) { + throw new Error("Failed to upload file"); + } + const ids = assetPathToIDs(r.succMap[file.name]) + + this.fileID = ids.fileID; + this.syncID = ids.syncID; + super.setFileName(IDsToAssetName(this.fileID, this.syncID)); + + } + + async removeOld(oldSyncID: string) { + await super.remove(IDsToAssetName(this.fileID, oldSyncID)); + } + +} \ No newline at end of file diff --git a/src/helper.ts b/src/helper.ts index ea4524d..7041ba5 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -47,8 +47,11 @@ export function generateRandomString() { } +export function IDsToAssetName(fileID: string, syncID: string) { + return `${fileID}-${syncID}.svg`; +} export function IDsToAssetPath(fileID: string, syncID: string) { - return `${ASSETS_PATH}${fileID}-${syncID}.svg` + return `${ASSETS_PATH}${IDsToAssetName(fileID, syncID)}` } export function assetPathToIDs(assetPath: string): { fileID: string; syncID: string } | null { @@ -102,4 +105,12 @@ export function imgSrcToIDs(imgSrc: string | null): { fileID: string; syncID: st return assetPathToIDs(imgSrc); +} + +export function getFirstDefined(...a) { + for(let i = 0; i < a.length; i++) { + if(a[i] !== undefined) { + return a[i]; + } + } } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index adf4b94..886b87b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,44 +6,110 @@ import { findImgSrc, imgSrcToIDs, generateTimeString, generateRandomString } from "@/helper"; -import {editorTabInit, openEditorTab} from "@/editorTab"; -import {migrate} from "@/migration"; +import {EditorManager} from "@/editor"; +import {PluginConfig, PluginConfigViewer} from "@/config"; +import {Analytics} from "@/analytics"; +import {ErrorReporter, MustSelectError, NotAWhiteboardError} from "@/errors"; export default class DrawJSPlugin extends Plugin { - onload() { + config: PluginConfig; + analytics: Analytics; + async onload() { + + new ErrorReporter(this.i18n); loadIcons(this); - this.addTab({ - 'type': "whiteboard", - init() { editorTabInit(this) } - }); - migrate() + EditorManager.registerTab(this); + + await this.startConfig(); + await this.startAnalytics(); this.protyleSlash = [{ - id: "insert-drawing", - filter: ["Insert Drawing", "Add drawing", "whiteboard", "freehand", "graphics", "jsdraw"], - html: getMenuHTML("iconDraw", this.i18n.insertDrawing), - callback: (protyle: Protyle) => { + id: "insert-whiteboard", + filter: ["Insert Drawing", "Add drawing", "Insert whiteboard", "Add whiteboard", "whiteboard", "freehand", "graphics", "jsdraw", this.i18n.insertWhiteboard], + html: getMenuHTML("iconDraw", this.i18n.insertWhiteboard), + callback: async (protyle: Protyle) => { + void this.analytics.sendEvent('create'); const fileID = generateRandomString(); const syncID = generateTimeString() + '-' + generateRandomString(); - protyle.insert(getMarkdownBlock(fileID, syncID), true, false); - openEditorTab(this, fileID, syncID); + protyle.insert(getMarkdownBlock(fileID, syncID), false, false); + (await EditorManager.create(fileID, this)).open(this); } }]; this.eventBus.on("open-menu-image", (e: any) => { const ids = imgSrcToIDs(findImgSrc(e.detail.element)); - if(ids === null) return; + if (ids === null) return; e.detail.menu.addItem({ icon: "iconDraw", - label: "Edit with js-draw", - click: () => { - openEditorTab(this, ids.fileID, ids.syncID); + label: this.i18n.editWhiteboard, + click: async () => { + void this.analytics.sendEvent('edit'); + (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() { + void this.analytics.sendEvent("unload"); + } + + 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() { + this.config = new PluginConfig(); + await this.config.load(); + let configViewer = new PluginConfigViewer(this.config, this); + await configViewer.load(); + } + + private async startAnalytics() { + this.analytics = new Analytics(this.config.options.analytics); + if(this.config.getFirstRun()) { + await this.config.save(); + void this.analytics.sendEvent('install'); + }else{ + void this.analytics.sendEvent('load'); + } + } + + } \ No newline at end of file diff --git a/src/migration.ts b/src/migration.ts deleted file mode 100644 index 6ec3eac..0000000 --- a/src/migration.ts +++ /dev/null @@ -1,61 +0,0 @@ -import {sql} from "@/api"; -import {getFile, uploadAsset} 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 newFileID = generateRandomString() + "-" + oldFileID; - const file = await getFile(DATA_PATH + ASSETS_PATH + oldFileID + ".svg"); - const r = await uploadAsset(newFileID, SVG_MIME, file); - const newMarkdown = getMarkdownBlock(r.fileID, r.syncID); - await replaceBlockContent(block.id, block.markdown, newMarkdown); - } - } - - if(found) { - new Dialog({ - width: "90vw", - height: "90vh", - content: ` -