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.md b/README.md index 73c10ff..f58614c 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,22 @@ # SiYuan js-draw Plugin -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 -1. Install the plugin - - Grab a release from the [Releases page](https://git.massive.box/massivebox/siyuan-jsdraw-plugin/releases) - - Unzip it in the folder `./data/plugins`, relatively to your SiYuan workspace. - > The plugin is not yet available in the official marketplace. I will try to publish it there soon! -2. Insert a drawing in your documents by typing `/Insert Drawing` in your document, and selecting the correct menu entry -3. The whiteboard editor will open in a new tab. Draw as you like, then click the Save button. It will also add a - drawing block to your document. -4. Click the Gear icon > Refresh to refresh the drawing block, if it's still displaying the old drawing. -5. Click the drawing block to open the editor again. +- 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 + 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 + 2. The editor tab will open, edit your file as you like, then click the Save button and close the tab. ## Planned features -- [ ] Auto-reload drawing blocks on drawing change -- [ ] Rename whiteboards -- [ ] Improve internationalization framework -- [ ] Default background color and grid options -- [ ] Respecting user theme for the editor -- And more! +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. +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! diff --git a/package.json b/package.json index 78f409d..76504eb 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { - "name": "plugin-sample-vite-svelte", - "version": "0.3.6", + "name": "siyuan-jsdraw-plugin", + "version": "0.4.0", "type": "module", - "description": "This is a sample plugin based on vite and svelte for Siyuan (https://b3log.org/siyuan)", - "repository": "", - "homepage": "", - "author": "frostime", + "description": "Include a whiteboard for freehand drawing anywhere in your documents.", + "repository": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin", + "homepage": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin", + "author": "massivebox", "license": "MIT", "scripts": { "dev": "cross-env NODE_ENV=development VITE_SOURCEMAP=inline vite build --watch", @@ -36,6 +36,7 @@ }, "dependencies": { "@js-draw/material-icons": "^1.29.0", - "js-draw": "^1.29.0" + "js-draw": "^1.29.0", + "ts-serializable": "^4.2.0" } } diff --git a/plugin.json b/plugin.json index 8413186..8acdb80 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.1.0", + "version": "0.4.0", "minAppVersion": "3.0.12", "backends": [ "windows", @@ -31,7 +31,7 @@ }, "funding": { "custom": [ - "" + "https://s.massive.box/jsdraw-plugin-donate" ] }, "keywords": [ diff --git a/public/i18n/en_US.json b/public/i18n/en_US.json new file mode 100644 index 0000000..fbeb088 --- /dev/null +++ b/public/i18n/en_US.json @@ -0,0 +1,44 @@ +{ + "insertDrawing": "Insert Drawing", + "editDrawing": "Edit with js-draw", + "errNoFileID": "File ID missing - couldn't open file.", + "errSyncIDNotFound": "Couldn't find SyncID in document for drawing, make sure you're trying to edit a drawing that is included in at least a note.", + "errCreateUnknown": "Unknown error while creating editor, please try again.", + "errInvalidBackgroundColor": "Invalid background color! Please enter an HEX color, like #000000 (black) or #FFFFFF (white). The old background color will be used.", + "drawing": "Drawing", + "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 drawings." + }, + "backgroundDropdown":{ + "title": "Background color", + "description": "Default background color for new drawings." + }, + "background": { + "title": "Custom background", + "description": "Hexadecimal code of the custom background color for new drawings.
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 drawing." + } + } +} \ No newline at end of file diff --git a/public/webapp/button.js b/public/webapp/button.js index 610799d..8be8fd4 100644 --- a/public/webapp/button.js +++ b/public/webapp/button.js @@ -1,12 +1,15 @@ -function copyEditLink(fileID) { - navigator.clipboard.writeText(getEditLink(fileID)); +function copyEditLink(path) { + navigator.clipboard.writeText(getEditLink(path)); +} +function copyImageLink(path) { + navigator.clipboard.writeText(`![Drawing](${path})`); } function refreshPage() { window.location.reload(); } -function addButton(document, fileID) { +function addButton(document, path) { // Add floating button const floatingButton = document.createElement('button'); @@ -19,8 +22,8 @@ function addButton(document, fileID) { popupMenu.id = 'popupMenu'; popupMenu.innerHTML = ` - - + + `; document.body.appendChild(popupMenu); @@ -31,6 +34,7 @@ function addButton(document, fileID) { document.body.addEventListener('mouseleave', () => { floatingButton.style.display = 'none'; + popupMenu.style.display = 'none'; }); // Toggle popup menu on button click diff --git a/public/webapp/cursor.png b/public/webapp/cursor.png new file mode 100644 index 0000000..1306cf3 Binary files /dev/null and b/public/webapp/cursor.png differ diff --git a/public/webapp/draw.js b/public/webapp/draw.js index 9d9adfc..187dab2 100644 --- a/public/webapp/draw.js +++ b/public/webapp/draw.js @@ -27,9 +27,9 @@ async function getFile(path) { } -async function getSVG(fileID) { +async function getSVG(path) { - const resp = await getFile("/data/assets/" + fileID + '.svg'); + const resp = await getFile("/data/" + path); if(resp == null) { return FALLBACK; } @@ -37,10 +37,10 @@ async function getSVG(fileID) { } -function getEditLink(fileID) { +function getEditLink(path) { const data = encodeURIComponent( JSON.stringify({ - id: fileID + path: path, }) ) return `siyuan://plugins/siyuan-jsdraw-pluginwhiteboard/?icon=iconDraw&title=Drawing&data=${data}`; diff --git a/public/webapp/error.html b/public/webapp/error.html new file mode 100644 index 0000000..2281841 --- /dev/null +++ b/public/webapp/error.html @@ -0,0 +1,21 @@ + + + + Error + + + +

It looks like an error occurred. You shouldn't be able to see this page.

+

No data has been deleted. Please excuse us for the inconvenience.

+

+ Try reloading SiYuan, and if the error persists, open an issue at + https://git.massive.box/massivebox/siyuan-jsdraw-plugin/issues + or contact the developer directly via e-mail at box@massive.box +

+ + \ No newline at end of file diff --git a/public/webapp/index.html b/public/webapp/index.html index a1fd2f6..9a02454 100644 --- a/public/webapp/index.html +++ b/public/webapp/index.html @@ -5,18 +5,22 @@ 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..13d37be --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,46 @@ +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, + } : {}; + + 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..7c4bfca --- /dev/null +++ b/src/config.ts @@ -0,0 +1,177 @@ +import {PluginFile} from "@/file"; +import {CONFIG_FILENAME, JSON_MIME, STORAGE_PATH} from "@/const"; +import {Plugin, showMessage} from "siyuan"; +import {SettingUtils} from "@/libs/setting-utils"; +import {getFirstDefined} from "@/helper"; + +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)) { + showMessage(this.plugin.i18n.errInvalidBackgroundColor, 0, 'error'); + 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 1acaa80..b7d60aa 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,7 +1,9 @@ export const SVG_MIME = "image/svg+xml"; export const JSON_MIME = "application/json"; -export const DATA_PATH = "/data/assets"; -export const STORAGE_PATH = "/data/storage/petal/siyuan-jsdraw-plugin"; -export const TOOLBAR_PATH = STORAGE_PATH + "/toolbar.json"; -export const CONFIG_PATH = STORAGE_PATH + "/conf.json"; -export const EMBED_PATH = "/plugins/siyuan-jsdraw-plugin/webapp/?id="; \ No newline at end of file +export const DATA_PATH = "/data/"; +export const ASSETS_PATH = "assets/"; +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..1331e88 --- /dev/null +++ b/src/editor.ts @@ -0,0 +1,251 @@ +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, + Mat33, + Vec2, + Viewport +} from "js-draw"; +import {Dialog, getFrontend, openTab, Plugin, showMessage} from "siyuan"; +import {findSyncIDInProtyle, replaceSyncID} from "@/protyle"; +import DrawJSPlugin from "@/index"; +import {EditorOptions} from "@/config"; +import 'js-draw/styles'; +import {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, { + iconProvider: new MaterialIconProvider(), + }); + + const styleElement = document.createElement('style'); + styleElement.innerHTML = ` + canvas.wetInkCanvas { + cursor: url('/plugins/siyuan-jsdraw-plugin/webapp/cursor.png') 6 6, auto; + } + `; + 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(fileID); + } + 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) { + showMessage("Error saving! The current drawing has been copied to your clipboard. You may need to create a new drawing and paste it there.", 0, 'error'); + if(error instanceof UnchangedProtyleError) { + showMessage("Make sure the image you're trying to edit still exists in your documents.", 0, 'error'); + } + await navigator.clipboard.writeText(svgElem.outerHTML); + console.error(error); + 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) { + EditorManager.handleCreationError(error, p); + } + return instance; + } + + static registerTab(p: DrawJSPlugin) { + p.addTab({ + 'type': "whiteboard", + async init() { + const fileID = this.data.fileID; + if (fileID == null) { + alert(p.i18n.errNoFileID); + return; + } + try { + const editor = await PluginEditor.create(fileID, p.config.options.editorOptions); + this.element.appendChild(editor.getElement()); + }catch (error){ + EditorManager.handleCreationError(error, p); + } + } + }); + } + + static handleCreationError(error: any, p: DrawJSPlugin) { + console.error(error); + let errorTxt = p.i18n.errCreateUnknown; + if(error instanceof SyncIDNotFoundError) { + errorTxt = p.i18n.errSyncIDNotFound; + } + showMessage(errorTxt, 0, 'error'); + } + + toTab(p: Plugin) { + openTab({ + app: p.app, + custom: { + title: p.i18n.drawing, + 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 bef75c7..0000000 --- a/src/editorTab.ts +++ /dev/null @@ -1,75 +0,0 @@ -import {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} from "@/file"; -import {JSON_MIME, SVG_MIME, TOOLBAR_PATH} from "@/const"; -import {idToPath} from "@/helper"; - -export function openEditorTab(p: Plugin, fileID: string) { - openTab({ - app: p.app, - custom: { - title: 'Drawing', - icon: 'iconDraw', - id: "siyuan-jsdraw-pluginwhiteboard", - data: { id: fileID } - } - }); -} - -async function saveCallback(editor: Editor, fileID: string, saveButton: BaseWidget) { - const svgElem = editor.toSVG(); - try { - saveFile(idToPath(fileID), SVG_MIME, svgElem.outerHTML); - 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) - } - -} - -export function createEditor(i: ITabModel) { - - const fileID = i.data.id; - if(fileID == null) { - alert("File ID missing - couldn't open file.") - return; - } - - const editor = new Editor(i.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(idToPath(fileID)).then(svg => { - if(svg != null) { - editor.loadFromSVG(svg); - } - }); - - // save logic - const saveButton = toolbar.addSaveButton(() => saveCallback(editor, fileID, saveButton)); - - // 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%'; - -} \ No newline at end of file diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..914bd9c --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,12 @@ + +export class SyncIDNotFoundError extends Error { + readonly fileID: string; + + constructor(fileID: string) { + super(`SyncID not found for file ${fileID}`); + this.fileID = fileID; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class UnchangedProtyleError extends Error {} \ No newline at end of file diff --git a/src/file.ts b/src/file.ts index 5ce0d20..dc2a86c 100644 --- a/src/file.ts +++ b/src/file.ts @@ -1,37 +1,108 @@ -import {getFileBlob, putFile} from "@/api"; +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 { -export function saveFile(path: string, mimeType: string, content: string) { + protected content: string | null; - const file = toFile(path.split('/').pop(), content, mimeType); + protected fileName: string; + protected folderPath: string; + protected mimeType: string; - try { - putFile(path, false, file); - } catch (error) { - console.error("Error saving file:", error); - throw error; + getContent() { return this.content; } + setContent(content: string) { this.content = content; } + setFileName(fileName: string) { this.fileName = fileName; } + + private setFolderPath(folderPath: string) { + if(folderPath.startsWith('/') && folderPath.endsWith('/')) { + this.folderPath = folderPath; + }else{ + throw new Error("folderPath must start and end with /"); + } + } + + // 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 }); } } -export async function getFile(path: string) { +export class PluginFile extends PluginFileBase { - 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; + 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; } - }catch {} - - // js-draw expects a string! - return jsonText; + } } + +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 18386c7..7041ba5 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -1,5 +1,5 @@ import { Plugin } from 'siyuan'; -import {DATA_PATH, EMBED_PATH} from "@/const"; +import {ASSETS_PATH} from "@/const"; const drawIcon: string = ` @@ -23,7 +23,7 @@ export function getMenuHTML(icon: string, text: string): string { `; } -export function generateSiyuanId() { +export function generateTimeString() { const now = new Date(); const year = now.getFullYear().toString(); @@ -33,25 +33,84 @@ export function generateSiyuanId() { const minutes = now.getMinutes().toString().padStart(2, '0'); const seconds = now.getSeconds().toString().padStart(2, '0'); - const timestamp = `${year}${month}${day}${hours}${minutes}${seconds}`; + return `${year}${month}${day}${hours}${minutes}${seconds}`; +} + +export function generateRandomString() { const characters = 'abcdefghijklmnopqrstuvwxyz'; let random = ''; for (let i = 0; i < 7; i++) { random += characters.charAt(Math.floor(Math.random() * characters.length)); } + return random; - return `${timestamp}-${random}`; } -export function idToPath(id: string) { - return DATA_PATH + '/' + id + '.svg'; +export function IDsToAssetName(fileID: string, syncID: string) { + return `${fileID}-${syncID}.svg`; +} +export function IDsToAssetPath(fileID: string, syncID: string) { + return `${ASSETS_PATH}${IDsToAssetName(fileID, syncID)}` +} +export function assetPathToIDs(assetPath: string): { fileID: string; syncID: string } | null { + + const filename = assetPath.split('/').pop() || ''; + if (!filename.endsWith('.svg')) return null; + + // Split into [basename, extension] and check format + const [basename] = filename.split('.'); + const parts = basename.split('-'); + + // Must contain exactly 2 hyphens separating 3 non-empty parts + if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) return null; + + return { + fileID: parts[0], + syncID: parts[1] + '-' + parts[2] + }; + } -// [Edit](siyuan://plugins/siyuan-jsdraw-pluginwhiteboard/?icon=iconDraw&title=Drawing&data={"id":"${id}"}) -// ![Drawing](assets/${id}.svg) -export function getPreviewHTML(id: string): string { +export function getMarkdownBlock(fileID: string, syncID: string): string { return ` - + ![Drawing](${IDsToAssetPath(fileID, syncID)}) ` +} + +// given a tag (such as a div) containing an image as a child at any level, return the src of the image +export function findImgSrc(element: HTMLElement): string | null { + // Base case: if current element is an image + if (element.tagName === 'IMG') { + return (element as HTMLImageElement).src; + } + + // Recursively check children + if (element.children) { + for (const child of Array.from(element.children)) { + const src = findImgSrc(child as HTMLElement); + if (src) return src; + } + } + + return null; +} + +export function imgSrcToIDs(imgSrc: string | null): { fileID: string; syncID: string } | null { + + if (!imgSrc) return null; + + const url = new URL(imgSrc); + imgSrc = decodeURIComponent(url.pathname); + + 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 e19ecb2..9d02d6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,44 +1,82 @@ import {Plugin, Protyle} from 'siyuan'; -import {getPreviewHTML, loadIcons, getMenuHTML, generateSiyuanId} from "@/helper"; -import {createEditor, openEditorTab} from "@/editorTab"; +import { + getMarkdownBlock, + loadIcons, + getMenuHTML, + findImgSrc, + imgSrcToIDs, generateTimeString, generateRandomString +} from "@/helper"; +import {migrate} from "@/migration"; +import {EditorManager} from "@/editor"; +import {PluginConfig, PluginConfigViewer} from "@/config"; +import {Analytics} from "@/analytics"; export default class DrawJSPlugin extends Plugin { - onload() { + + config: PluginConfig; + analytics: Analytics; + + async onload() { loadIcons(this); - //const id = Math.random().toString(36).substring(7); - this.addTab({ - 'type': "whiteboard", - init() { - createEditor(this); - } - }); + EditorManager.registerTab(this); + migrate() + + 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) => { - const uid = generateSiyuanId(); - protyle.insert(getPreviewHTML(uid), true, false); - openEditorTab(this, uid); + callback: async (protyle: Protyle) => { + void this.analytics.sendEvent('create'); + const fileID = generateRandomString(); + const syncID = generateTimeString() + '-' + generateRandomString(); + protyle.insert(getMarkdownBlock(fileID, syncID), true, 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; + e.detail.menu.addItem({ + icon: "iconDraw", + label: this.i18n.editDrawing, + click: async () => { + void this.analytics.sendEvent('edit'); + (await EditorManager.create(ids.fileID, this)).open(this); + } + }) + }) - onLayoutReady() { - // This function is automatically called when the layout is loaded. } onunload() { - // This function is automatically called when the plugin is disabled. + void this.analytics.sendEvent("unload"); } uninstall() { - // This function is automatically called when the plugin is uninstalled. + void this.analytics.sendEvent("uninstall"); + } + + 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 new file mode 100644 index 0000000..2829aad --- /dev/null +++ b/src/migration.ts @@ -0,0 +1,65 @@ +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: ` +