Compare commits
24 commits
Author | SHA1 | Date | |
---|---|---|---|
3874378824 | |||
eaf4a8e39e | |||
05984a8913 | |||
d34258e6bf | |||
dc15e91def | |||
ff83c23851 | |||
17d4e5938b | |||
a079298433 | |||
5322944ad9 | |||
77e8218d1f | |||
764f9fe5a4 | |||
fa3eba219e | |||
1ad26d1e23 | |||
8d4779b8fe | |||
f35342a791 | |||
3a05d36f8c | |||
e815442881 | |||
7e4da82b82 | |||
fe32505873 | |||
fc4ce8e69e | |||
6bca12c934 | |||
e23cc424f8 | |||
ea9b0be856 | |||
f2801c9f1c |
18 changed files with 905 additions and 266 deletions
|
@ -1,7 +1,9 @@
|
||||||
name: Create Release on Tag Push
|
name: Build on Push and create Release on Tag
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
|
||||||
|
@ -28,6 +30,12 @@ jobs:
|
||||||
version: 8
|
version: 8
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
# Validate Tag Matches JSON Versions
|
||||||
|
- name: Validate Tag Matches JSON Versions
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
run: |
|
||||||
|
node scripts/validate_tag.cjs ${{ github.ref }}
|
||||||
|
|
||||||
# Get pnpm store directory
|
# Get pnpm store directory
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
id: pnpm-cache
|
id: pnpm-cache
|
||||||
|
@ -52,11 +60,22 @@ jobs:
|
||||||
- name: Build for production
|
- name: Build for production
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Release
|
# Move file
|
||||||
uses: ncipollo/release-action@v1
|
- name: Move file
|
||||||
|
run: mkdir built; mv package.zip built/package.zip
|
||||||
|
|
||||||
|
# Upload artifacts
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
allowUpdates: true
|
path: built/package.zip
|
||||||
artifactErrorsFailBuild: true
|
overwrite: true
|
||||||
artifacts: "package.zip"
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
# Create Forgejo Release
|
||||||
prerelease: false
|
- name: Create Forgejo Release
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
uses: actions/forgejo-release@v1
|
||||||
|
with:
|
||||||
|
direction: upload
|
||||||
|
release-dir: built
|
||||||
|
token: ${{ secrets.FORGE_TOKEN }}
|
|
@ -5,11 +5,12 @@ This plugin allows you to embed js-draw whiteboards anywhere in your SiYuan docu
|
||||||
|
|
||||||
## Usage instructions
|
## Usage instructions
|
||||||
- Install the plugin from the marketplace. You can find it by searching for `js-draw`.
|
- Install the plugin from the marketplace. You can find it by searching for `js-draw`.
|
||||||
- To add a new drawing to your document:
|
- To add a new whiteboard to your document:
|
||||||
1. Type `/Insert Drawing` in your document, and select the correct menu entry
|
1. Type `/Insert whiteboard` in your document, and select the correct menu entry
|
||||||
2. The whiteboard editor will open in a new tab. Draw as you like, then click the Save button and close the tab.
|
2. The whiteboard editor will open in a new tab. Draw as you like, then click the Save button and close the tab.
|
||||||
- To edit the image later:
|
- To edit the whiteboard later:
|
||||||
1. Right-click on the image (or click the three dots on mobile), select "Plugin" > "Edit with js-draw" in the menu
|
1. Left-click or tap on the whiteboard to select it, then click on the Edit icon in the top bar
|
||||||
|
- Or right-click on the whiteboard (or click the three dots on mobile), select "Plugin" > "Edit whiteboard" in the menu
|
||||||
2. The editor tab will open, edit your file as you like, then click the Save button and close the tab.
|
2. The editor tab will open, edit your file as you like, then click the Save button and close the tab.
|
||||||
|
|
||||||
## Planned features
|
## Planned features
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "siyuan-jsdraw-plugin",
|
"name": "siyuan-jsdraw-plugin",
|
||||||
"version": "0.2.0",
|
"version": "0.4.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Include a whiteboard for freehand drawing anywhere in your documents.",
|
"description": "Include a whiteboard for freehand drawing anywhere in your documents.",
|
||||||
"repository": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin",
|
"repository": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin",
|
||||||
|
@ -36,6 +36,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@js-draw/material-icons": "^1.29.0",
|
"@js-draw/material-icons": "^1.29.0",
|
||||||
"js-draw": "^1.29.0"
|
"js-draw": "^1.29.0",
|
||||||
|
"ts-serializable": "^4.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "siyuan-jsdraw-plugin",
|
"name": "siyuan-jsdraw-plugin",
|
||||||
"author": "massivebox",
|
"author": "massivebox",
|
||||||
"url": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin",
|
"url": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin",
|
||||||
"version": "0.2.0",
|
"version": "0.4.1",
|
||||||
"minAppVersion": "3.0.12",
|
"minAppVersion": "3.0.12",
|
||||||
"backends": [
|
"backends": [
|
||||||
"windows",
|
"windows",
|
||||||
|
@ -31,7 +31,7 @@
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"custom": [
|
"custom": [
|
||||||
""
|
"https://s.massive.box/jsdraw-plugin-donate"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|
|
@ -1,3 +1,50 @@
|
||||||
{
|
{
|
||||||
"insertDrawing": "Insert Drawing"
|
"insertWhiteboard": "Insert whiteboard",
|
||||||
|
"editWhiteboard": "Edit whiteboard",
|
||||||
|
"editShortcut": "Edit selected whiteboard",
|
||||||
|
"errNoFileID": "File ID missing - couldn't open file.",
|
||||||
|
"errNotAWhiteboard": "You must select a whiteboard, not a regular image. <a href='https://s.massive.box/jsdraw-plugin-instructions'>Usage instructions</a>",
|
||||||
|
"errSyncIDNotFound": "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.",
|
||||||
|
"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.",
|
||||||
|
"errMultipleSyncIDs": "Multiple syncIDs found in documents. Remove the drawings that don't exist from your documents.\n Sync conflict copies can cause this error, so make sure to delete them.\nFile IDs (the part you can change in the Rename menu) must be unique across all documents.\n<a href='https://git.massive.box/massivebox/siyuan-jsdraw-plugin/wiki/Errors-and-Fixes#multiple-syncids-found'>Full explanation</a>",
|
||||||
|
"errUnchangedProtyle": "Make sure the image you're trying to edit still exists in your documents.",
|
||||||
|
"errSaveGeneric": "Error saving! The current drawing has been copied to your clipboard. You may need to create a new drawing and paste it there.",
|
||||||
|
"errMustSelect": "Select a whiteboard in your document by left-clicking it, then use this icon/shortcut to open the editor directly. <a href='https://s.massive.box/jsdraw-plugin-instructions'>Usage instructions</a>",
|
||||||
|
"whiteboard": "Whiteboard",
|
||||||
|
"settings": {
|
||||||
|
"name": "js-draw Plugin Settings",
|
||||||
|
"suggestedColors":{
|
||||||
|
"white": "White",
|
||||||
|
"black": "Black",
|
||||||
|
"transparent": "Transparent",
|
||||||
|
"custom": "Custom",
|
||||||
|
"darkBlue": "Dark Blue",
|
||||||
|
"darkGray": "Dark Gray"
|
||||||
|
},
|
||||||
|
"grid": {
|
||||||
|
"title": "Enable grid by default",
|
||||||
|
"description": "Enable to automatically turn on the grid on new whiteboards."
|
||||||
|
},
|
||||||
|
"backgroundDropdown":{
|
||||||
|
"title": "Background color",
|
||||||
|
"description": "Default background color for new whiteboards."
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"title": "Custom background",
|
||||||
|
"description": "Hexadecimal code of the custom background color for new whiteboards.<br /><b>This setting is only applied if \"Background Color\" is set to \"Custom\"!</b>"
|
||||||
|
},
|
||||||
|
"dialogOnDesktop": {
|
||||||
|
"title": "Open editor as dialog on desktop",
|
||||||
|
"description": "Dialog mode provides a larger drawing area, but it's not as handy to use as tabs (default).<br />The editor will always open as a dialog on mobile."
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"title": "Analytics",
|
||||||
|
"description": "Enable to send anonymous usage data to the developer. <a href='https://s.massive.box/jsdraw-plugin-privacy'>Privacy Policy</a>"
|
||||||
|
},
|
||||||
|
"restorePosition": {
|
||||||
|
"title": "Remember editor position",
|
||||||
|
"description": "When enabled, the editor will remember the zoom factor and position, and it will restore them the next time you open the same whiteboard."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
BIN
public/webapp/cursor.png
Normal file
BIN
public/webapp/cursor.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 606 B |
24
scripts/validate_tag.cjs
Normal file
24
scripts/validate_tag.cjs
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const [tagName] = process.argv.slice(2); // Get tag from CLI arguments
|
||||||
|
if (!tagName) {
|
||||||
|
console.error('Error: No tag name provided.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG_VERSION = tagName.replace('refs/tags/v', '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path.resolve('package.json'), 'utf8'));
|
||||||
|
const pluginJson = JSON.parse(fs.readFileSync(path.resolve('plugin.json'), 'utf8'));
|
||||||
|
|
||||||
|
if (TAG_VERSION !== packageJson.version || TAG_VERSION !== pluginJson.version) {
|
||||||
|
console.error(`Error: Tag version (${TAG_VERSION}) does not match package.json (${packageJson.version}) or plugin.json (${pluginJson.version})`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('Tag version matches both JSON files.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to read or parse JSON files:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
46
src/analytics.ts
Normal file
46
src/analytics.ts
Normal file
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
178
src/config.ts
Normal file
178
src/config.ts
Normal file
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -2,8 +2,8 @@ export const SVG_MIME = "image/svg+xml";
|
||||||
export const JSON_MIME = "application/json";
|
export const JSON_MIME = "application/json";
|
||||||
export const DATA_PATH = "/data/";
|
export const DATA_PATH = "/data/";
|
||||||
export const ASSETS_PATH = "assets/";
|
export const ASSETS_PATH = "assets/";
|
||||||
export const STORAGE_PATH = DATA_PATH + "storage/petal/siyuan-jsdraw-plugin";
|
export const STORAGE_PATH = "/data/storage/petal/siyuan-jsdraw-plugin/";
|
||||||
export const TOOLBAR_PATH = STORAGE_PATH + "/toolbar.json";
|
export const TOOLBAR_FILENAME = "toolbar.json";
|
||||||
export const CONFIG_PATH = STORAGE_PATH + "/conf.json";
|
export const CONFIG_FILENAME = "conf.json";
|
||||||
export const EMBED_PATH = "/plugins/siyuan-jsdraw-plugin/webapp/?path=";
|
export const EMBED_PATH = "/plugins/siyuan-jsdraw-plugin/webapp/?path=";
|
||||||
export const DUMMY_HOST = "https://dummy.host/";
|
export const DUMMY_HOST = "https://dummy.host/";
|
247
src/editor.ts
Normal file
247
src/editor.ts
Normal file
|
@ -0,0 +1,247 @@
|
||||||
|
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} 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, {
|
||||||
|
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<PluginEditor> {
|
||||||
|
|
||||||
|
const instance = new PluginEditor(fileID);
|
||||||
|
|
||||||
|
await instance.genToolbar();
|
||||||
|
let syncID = await findSyncIDInProtyle(fileID);
|
||||||
|
|
||||||
|
if(syncID == null) {
|
||||||
|
throw new SyncIDNotFoundError();
|
||||||
|
}
|
||||||
|
instance.setSyncID(syncID);
|
||||||
|
await instance.restoreOrInitFile(defaultEditorOptions);
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreOrInitFile(defaultEditorOptions: EditorOptions) {
|
||||||
|
|
||||||
|
this.drawingFile = new PluginAsset(this.fileID, this.syncID, SVG_MIME);
|
||||||
|
await this.drawingFile.loadFromSiYuanFS();
|
||||||
|
const drawingContent = this.drawingFile.getContent();
|
||||||
|
|
||||||
|
if(drawingContent != null) {
|
||||||
|
|
||||||
|
await this.editor.loadFromSVG(drawingContent);
|
||||||
|
|
||||||
|
// restore position and zoom
|
||||||
|
const svgElem = new DOMParser().parseFromString(drawingContent, SVG_MIME).documentElement;
|
||||||
|
const editorViewStr = svgElem.getAttribute('editorView');
|
||||||
|
if(editorViewStr != null && defaultEditorOptions.restorePosition) {
|
||||||
|
try {
|
||||||
|
const [viewBoxOriginX, viewBoxOriginY, zoom] = editorViewStr.split(' ').map(x => parseFloat(x));
|
||||||
|
this.editor.dispatch(Viewport.transformBy(Mat33.scaling2D(zoom)));
|
||||||
|
this.editor.dispatch(Viewport.transformBy(Mat33.translation(Vec2.of(
|
||||||
|
- viewBoxOriginX,
|
||||||
|
- viewBoxOriginY
|
||||||
|
))));
|
||||||
|
}catch (e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
}else{
|
||||||
|
// it's a new drawing
|
||||||
|
this.editor.dispatch(this.editor.setBackgroundStyle({
|
||||||
|
color: Color4.fromHex(defaultEditorOptions.background),
|
||||||
|
type: defaultEditorOptions.grid ? BackgroundComponentBackgroundType.Grid : BackgroundComponentBackgroundType.SolidColor,
|
||||||
|
autoresize: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async genToolbar() {
|
||||||
|
|
||||||
|
const toolbar = this.editor.addToolbar();
|
||||||
|
|
||||||
|
// 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: `<div id="DrawingPanel" style="width:100%; height: 100%;"></div>`,
|
||||||
|
});
|
||||||
|
dialog.element.querySelector("#DrawingPanel").appendChild(this.editor.getElement());
|
||||||
|
}
|
||||||
|
|
||||||
|
open(p: DrawJSPlugin) {
|
||||||
|
if(getFrontend() != "mobile" && !p.config.options.dialogOnDesktop) {
|
||||||
|
this.toTab(p);
|
||||||
|
} else {
|
||||||
|
this.toDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
116
src/editorTab.ts
116
src/editorTab.ts
|
@ -1,116 +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: `<div id="DrawingPanel" style="width:100%; height: 100%;"></div>`,
|
|
||||||
});
|
|
||||||
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<string> {
|
|
||||||
|
|
||||||
const svgElem = editor.toSVG();
|
|
||||||
let newSyncID;
|
|
||||||
|
|
||||||
try {
|
|
||||||
newSyncID = (await uploadAsset(fileID, SVG_MIME, svgElem.outerHTML)).syncID;
|
|
||||||
await replaceSyncID(fileID, oldSyncID, newSyncID);
|
|
||||||
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);
|
|
||||||
|
|
||||||
}
|
|
80
src/errors.ts
Normal file
80
src/errors.ts
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import {showMessage} from "siyuan";
|
||||||
|
|
||||||
|
export class InternationalizedError extends Error {
|
||||||
|
readonly key: string;
|
||||||
|
|
||||||
|
constructor(key: string) {
|
||||||
|
super(key);
|
||||||
|
this.key = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorReporter {
|
||||||
|
|
||||||
|
static i18n: any;
|
||||||
|
|
||||||
|
constructor(i18n: any) {
|
||||||
|
ErrorReporter.i18n = i18n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static error(err: Error, timeout?: number) {
|
||||||
|
console.error(err);
|
||||||
|
let errorTxt = err.message;
|
||||||
|
if(err instanceof InternationalizedError) {
|
||||||
|
errorTxt = ErrorReporter.i18n[err.key];
|
||||||
|
}
|
||||||
|
if(!timeout) {
|
||||||
|
timeout = 0;
|
||||||
|
}
|
||||||
|
showMessage(errorTxt, timeout, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncIDNotFoundError extends InternationalizedError {
|
||||||
|
constructor() {
|
||||||
|
super('errSyncIDNotFound');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnchangedProtyleError extends InternationalizedError {
|
||||||
|
constructor() {
|
||||||
|
super('errUnchangedProtyle');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MultipleSyncIDsError extends InternationalizedError {
|
||||||
|
constructor() {
|
||||||
|
super('errMultipleSyncIDs');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenericSaveError extends InternationalizedError {
|
||||||
|
constructor() {
|
||||||
|
super('errSaveGeneric');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotAWhiteboardError extends InternationalizedError {
|
||||||
|
constructor() {
|
||||||
|
super('errNotAWhiteboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidBackgroundColorError extends InternationalizedError {
|
||||||
|
constructor() {
|
||||||
|
super('errInvalidBackgroundColor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NoFileIDError extends InternationalizedError {
|
||||||
|
constructor() {
|
||||||
|
super('errNoFileID');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MustSelectError extends InternationalizedError {
|
||||||
|
constructor() {
|
||||||
|
super('errMustSelect');
|
||||||
|
}
|
||||||
|
}
|
130
src/file.ts
130
src/file.ts
|
@ -1,52 +1,108 @@
|
||||||
import {getFileBlob, putFile, upload} from "@/api";
|
import {getFileBlob, putFile, removeFile, upload} from "@/api";
|
||||||
import {ASSETS_PATH} from "@/const";
|
import {ASSETS_PATH, DATA_PATH} from "@/const";
|
||||||
import {assetPathToIDs} from "@/helper";
|
import {assetPathToIDs, IDsToAssetName} from "@/helper";
|
||||||
|
|
||||||
function toFile(title: string, content: string, mimeType: string){
|
abstract class PluginFileBase {
|
||||||
const blob = new Blob([content], { type: mimeType });
|
|
||||||
return new File([blob], title, { type: mimeType });
|
protected content: string | null;
|
||||||
|
|
||||||
|
protected fileName: string;
|
||||||
|
protected folderPath: string;
|
||||||
|
protected mimeType: string;
|
||||||
|
|
||||||
|
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 /");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// upload asset to the assets folder, return fileID and syncID
|
// folderPath must start and end with /
|
||||||
export async function uploadAsset(fileID: string, mimeType: string, content: string) {
|
constructor(folderPath: string, fileName: string, mimeType: string) {
|
||||||
|
this.setFolderPath(folderPath);
|
||||||
|
this.fileName = fileName;
|
||||||
|
this.mimeType = mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
const file = toFile(fileID + ".svg", content, 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]);
|
let r = await upload('/' + ASSETS_PATH, [file]);
|
||||||
if (r.errFiles) {
|
if (r.errFiles) {
|
||||||
throw new Error("Failed to upload file");
|
throw new Error("Failed to upload file");
|
||||||
}
|
}
|
||||||
return assetPathToIDs(r.succMap[file.name]);
|
const ids = assetPathToIDs(r.succMap[file.name])
|
||||||
|
|
||||||
|
this.fileID = ids.fileID;
|
||||||
|
this.syncID = ids.syncID;
|
||||||
|
super.setFileName(IDsToAssetName(this.fileID, this.syncID));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveFile(path: string, mimeType: string, content: string) {
|
async removeOld(oldSyncID: string) {
|
||||||
|
await super.remove(IDsToAssetName(this.fileID, oldSyncID));
|
||||||
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;
|
|
||||||
}
|
|
||||||
}catch {}
|
|
||||||
|
|
||||||
// js-draw expects a string!
|
|
||||||
return jsonText;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
|
@ -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) {
|
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 {
|
export function assetPathToIDs(assetPath: string): { fileID: string; syncID: string } | null {
|
||||||
|
|
||||||
|
@ -103,3 +106,11 @@ export function imgSrcToIDs(imgSrc: string | null): { fileID: string; syncID: st
|
||||||
return assetPathToIDs(imgSrc);
|
return assetPathToIDs(imgSrc);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFirstDefined(...a) {
|
||||||
|
for(let i = 0; i < a.length; i++) {
|
||||||
|
if(a[i] !== undefined) {
|
||||||
|
return a[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
100
src/index.ts
100
src/index.ts
|
@ -6,29 +6,35 @@ import {
|
||||||
findImgSrc,
|
findImgSrc,
|
||||||
imgSrcToIDs, generateTimeString, generateRandomString
|
imgSrcToIDs, generateTimeString, generateRandomString
|
||||||
} from "@/helper";
|
} from "@/helper";
|
||||||
import {editorTabInit, openEditorTab} from "@/editorTab";
|
import {EditorManager} from "@/editor";
|
||||||
import {migrate} from "@/migration";
|
import {PluginConfig, PluginConfigViewer} from "@/config";
|
||||||
|
import {Analytics} from "@/analytics";
|
||||||
|
import {ErrorReporter, MustSelectError, NotAWhiteboardError} from "@/errors";
|
||||||
|
|
||||||
export default class DrawJSPlugin extends Plugin {
|
export default class DrawJSPlugin extends Plugin {
|
||||||
|
|
||||||
onload() {
|
config: PluginConfig;
|
||||||
|
analytics: Analytics;
|
||||||
|
|
||||||
|
async onload() {
|
||||||
|
|
||||||
|
new ErrorReporter(this.i18n);
|
||||||
loadIcons(this);
|
loadIcons(this);
|
||||||
this.addTab({
|
EditorManager.registerTab(this);
|
||||||
'type': "whiteboard",
|
|
||||||
init() { editorTabInit(this) }
|
await this.startConfig();
|
||||||
});
|
await this.startAnalytics();
|
||||||
migrate()
|
|
||||||
|
|
||||||
this.protyleSlash = [{
|
this.protyleSlash = [{
|
||||||
id: "insert-drawing",
|
id: "insert-whiteboard",
|
||||||
filter: ["Insert Drawing", "Add drawing", "whiteboard", "freehand", "graphics", "jsdraw"],
|
filter: ["Insert Drawing", "Add drawing", "Insert whiteboard", "Add whiteboard", "whiteboard", "freehand", "graphics", "jsdraw"],
|
||||||
html: getMenuHTML("iconDraw", this.i18n.insertDrawing),
|
html: getMenuHTML("iconDraw", this.i18n.insertWhiteboard),
|
||||||
callback: (protyle: Protyle) => {
|
callback: async (protyle: Protyle) => {
|
||||||
|
void this.analytics.sendEvent('create');
|
||||||
const fileID = generateRandomString();
|
const fileID = generateRandomString();
|
||||||
const syncID = generateTimeString() + '-' + generateRandomString();
|
const syncID = generateTimeString() + '-' + generateRandomString();
|
||||||
protyle.insert(getMarkdownBlock(fileID, syncID), true, false);
|
protyle.insert(getMarkdownBlock(fileID, syncID), true, false);
|
||||||
openEditorTab(this, fileID, syncID);
|
(await EditorManager.create(fileID, this)).open(this);
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
|
|
||||||
|
@ -37,13 +43,73 @@ export default class DrawJSPlugin extends Plugin {
|
||||||
if (ids === null) return;
|
if (ids === null) return;
|
||||||
e.detail.menu.addItem({
|
e.detail.menu.addItem({
|
||||||
icon: "iconDraw",
|
icon: "iconDraw",
|
||||||
label: "Edit with js-draw",
|
label: this.i18n.editWhiteboard,
|
||||||
click: () => {
|
click: async () => {
|
||||||
openEditorTab(this, ids.fileID, ids.syncID);
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
|
@ -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: `
|
|
||||||
<iframe
|
|
||||||
style="width: 100%; height: 100%; background-color: white"
|
|
||||||
src="https://notes.massive.box/YRpTbbxLiD"
|
|
||||||
/>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractID(html: string): string | null {
|
|
||||||
// Match the pattern: id= followed by characters until & or quote
|
|
||||||
const regex = /id=([^&"']+)/;
|
|
||||||
const match = html.match(regex);
|
|
||||||
return match ? match[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findEmbedBlocks() {
|
|
||||||
|
|
||||||
const sqlQuery = `
|
|
||||||
SELECT id, markdown
|
|
||||||
FROM blocks
|
|
||||||
WHERE markdown like '%src="/plugins/siyuan-jsdraw-plugin/webapp/%'
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await sql(sqlQuery);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error searching for embed blocks:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,5 +1,38 @@
|
||||||
import {getBlockByID, sql, updateBlock} from "@/api";
|
import {getBlockByID, sql, updateBlock} from "@/api";
|
||||||
import {IDsToAssetPath} from "@/helper";
|
import {assetPathToIDs, IDsToAssetPath} from "@/helper";
|
||||||
|
import {MultipleSyncIDsError} from "@/errors";
|
||||||
|
|
||||||
|
export async function findSyncIDInProtyle(fileID: string, iter?: number): Promise<string> {
|
||||||
|
|
||||||
|
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 MultipleSyncIDsError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
export async function findImageBlocks(src: string) {
|
||||||
|
|
||||||
|
@ -45,25 +78,32 @@ export async function replaceBlockContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractImageSourcesFromMarkdown(markdown: string, mustStartWith?: string) {
|
||||||
|
const imageRegex = /!\[.*?\]\(([^)\s]+)(?:\s+"[^"]+")?\)/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) {
|
export async function replaceSyncID(fileID: string, oldSyncID: string, newSyncID: string) {
|
||||||
|
|
||||||
const search = encodeURI(IDsToAssetPath(fileID, oldSyncID)); // the API uses URI-encoded
|
const search = encodeURI(IDsToAssetPath(fileID, oldSyncID)); // the API uses URI-encoded
|
||||||
// find blocks containing that image
|
// find blocks containing that image
|
||||||
const blocks = await findImageBlocks(search);
|
const blocks = await findImageBlocks(search);
|
||||||
|
if(blocks.length === 0) return false;
|
||||||
|
|
||||||
for(const block of blocks) {
|
for(const block of blocks) {
|
||||||
|
|
||||||
// get all the image sources, with parameters
|
// get all the image sources, with parameters
|
||||||
const markdown = block.markdown;
|
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 newSource = IDsToAssetPath(fileID, newSyncID);
|
||||||
await replaceBlockContent(block.id, source, newSource);
|
const changed = await replaceBlockContent(block.id, source, newSource);
|
||||||
}
|
if(!changed) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue