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/package.json b/package.json
index 3dd8f4f..76504eb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "siyuan-jsdraw-plugin",
- "version": "0.2.2",
+ "version": "0.4.0",
"type": "module",
"description": "Include a whiteboard for freehand drawing anywhere in your documents.",
"repository": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin",
@@ -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 8c3ef88..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.2.2",
+ "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
index b6d2382..fbeb088 100644
--- a/public/i18n/en_US.json
+++ b/public/i18n/en_US.json
@@ -1,3 +1,44 @@
{
- "insertDrawing": "Insert Drawing"
+ "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/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/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 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..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 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..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 79e0e48..dc2a86c 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 });
+ }
}
+
+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..9d02d6d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,44 +6,77 @@ 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";
export default class DrawJSPlugin extends Plugin {
- onload() {
+ config: PluginConfig;
+ analytics: Analytics;
+
+ async onload() {
loadIcons(this);
- this.addTab({
- 'type': "whiteboard",
- init() { editorTabInit(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) => {
+ 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);
+ (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.editDrawing,
+ click: async () => {
+ void this.analytics.sendEvent('edit');
+ (await EditorManager.create(ids.fileID, this)).open(this);
}
})
})
}
+ onunload() {
+ void this.analytics.sendEvent("unload");
+ }
+
+ uninstall() {
+ 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
index 6ec3eac..2829aad 100644
--- a/src/migration.ts
+++ b/src/migration.ts
@@ -1,5 +1,5 @@
import {sql} from "@/api";
-import {getFile, uploadAsset} from "@/file";
+import {PluginAsset, PluginFile} from "@/file";
import {ASSETS_PATH, DATA_PATH, SVG_MIME} from "@/const";
import {replaceBlockContent} from "@/protyle";
import {generateRandomString, getMarkdownBlock} from "@/helper";
@@ -13,11 +13,15 @@ export async function migrate() {
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);
+ 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();
+ }
}
}
diff --git a/src/protyle.ts b/src/protyle.ts
index b3b8905..5c3e842 100644
--- a/src/protyle.ts
+++ b/src/protyle.ts
@@ -1,5 +1,41 @@
import {getBlockByID, sql, updateBlock} from "@/api";
-import {IDsToAssetPath} from "@/helper";
+import {assetPathToIDs, IDsToAssetPath} from "@/helper";
+
+export async function findSyncIDInProtyle(fileID: string, iter?: number): Promise {
+
+ const search = `assets/${fileID}-`;
+ const blocks = await findImageBlocks(search);
+
+ let syncID = null;
+
+ for(const block of blocks) {
+ const sources = extractImageSourcesFromMarkdown(block.markdown, search);
+ for(const source of sources) {
+ const ids = assetPathToIDs(source);
+ if(syncID == null) {
+ syncID = ids.syncID;
+ }else if(ids.syncID !== syncID) {
+ throw new Error(
+ "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."
+ );
+ }
+ }
+ }
+
+ if(!iter) iter = 0;
+ if(syncID == null) {
+ // when the block has just been created, we need to wait a bit before it can be found
+ if(iter < 4) { // cap max time at 2s, it should be ok by then
+ await new Promise(resolve => setTimeout(resolve, 500));
+ return await findSyncIDInProtyle(fileID, iter + 1);
+ }
+ }
+
+ return syncID;
+
+}
export async function findImageBlocks(src: string) {
@@ -45,6 +81,13 @@ export async function replaceBlockContent(
}
}
+function extractImageSourcesFromMarkdown(markdown: string, mustStartWith?: string) {
+ const imageRegex = /!\[.*?\]\((.*?)\)/g; // only get images
+ return Array.from(markdown.matchAll(imageRegex))
+ .map(match => match[1])
+ .filter(source => source.startsWith(mustStartWith)) // discard other images
+}
+
export async function replaceSyncID(fileID: string, oldSyncID: string, newSyncID: string) {
const search = encodeURI(IDsToAssetPath(fileID, oldSyncID)); // the API uses URI-encoded
@@ -56,12 +99,8 @@ export async function replaceSyncID(fileID: string, oldSyncID: string, newSyncID
// get all the image sources, with parameters
const markdown = block.markdown;
- const imageRegex = /!\[.*?\]\((.*?)\)/g; // only get images
- const sources = Array.from(markdown.matchAll(imageRegex))
- .map(match => match[1])
- .filter(source => source.startsWith(search)) // discard other images
- for(const source of sources) {
+ for(const source of extractImageSourcesFromMarkdown(markdown, search)) {
const newSource = IDsToAssetPath(fileID, newSyncID);
const changed = await replaceBlockContent(block.id, source, newSource);
if(!changed) return false