Compare commits

...

19 commits
v0.2.2 ... main

Author SHA1 Message Date
d34258e6bf
Add "directly open editor" shortcut and icon
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 4m48s
2025-06-24 22:26:26 +02:00
dc15e91def
Version bump
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 36s
2025-05-15 18:19:46 +02:00
ff83c23851
Improve cursor
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 51s
2025-05-15 18:16:50 +02:00
17d4e5938b
Version bump
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 36s
2025-05-09 22:57:42 +02:00
a079298433
Add CI
All checks were successful
Build on Push and create Release on Tag / build (push) Successful in 3m55s
2025-05-08 22:47:09 +02:00
5322944ad9
Add custom cursor on editor canvas 2025-05-07 21:16:50 +02:00
77e8218d1f
Config improvements and compatibility with old versions 2025-05-06 23:12:51 +02:00
764f9fe5a4
Add option to remember editor position and zoom 2025-05-06 18:19:18 +02:00
fa3eba219e
Suggest popular background colors, add transparency support
Making the UI more user-friendly by suggesting some commonly used colors in the Settings menu
2025-05-05 19:17:59 +02:00
1ad26d1e23
Add funding link 2025-05-01 23:01:55 +02:00
8d4779b8fe
Improve error handling and code structure 2025-04-23 09:52:45 +02:00
f35342a791
Start workin on i18n 2025-04-20 22:16:48 +02:00
3a05d36f8c
Fixes + Version bump
Some checks failed
Create Release on Tag Push / build (push) Has been cancelled
2025-04-17 22:27:38 +02:00
e815442881
Editor default options 2025-04-17 16:08:26 +02:00
7e4da82b82
Get initial Sync ID from protyle
Related to issue #9
2025-04-17 15:16:07 +02:00
fe32505873
Implement analytics 2025-04-16 23:56:24 +02:00
fc4ce8e69e
Add config menu framework
Options don't do anything as of now, but they are saved and loaded
2025-04-15 19:42:43 +02:00
6bca12c934
File refactoring 2025-04-11 18:42:45 +02:00
e23cc424f8
Code quality improvements 2025-04-09 22:53:40 +02:00
17 changed files with 836 additions and 212 deletions

View file

@ -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*"
@ -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 }}

View file

@ -1,6 +1,6 @@
{
"name": "siyuan-jsdraw-plugin",
"version": "0.2.2",
"version": "0.4.1",
"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"
}
}

View file

@ -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.1",
"minAppVersion": "3.0.12",
"backends": [
"windows",
@ -31,7 +31,7 @@
},
"funding": {
"custom": [
""
"https://s.massive.box/jsdraw-plugin-donate"
]
},
"keywords": [

View file

@ -1,3 +1,46 @@
{
"insertDrawing": "Insert Drawing"
"insertDrawing": "Insert Drawing",
"editDrawing": "Edit with js-draw",
"editShortcut": "Open editor directly",
"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.",
"msgMustSelect": "Select a whiteboard in your document by left-clicking it, then use this icon/shortcut to open the editor directly.",
"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.<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 drawing."
}
}
}

BIN
public/webapp/cursor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 B

24
scripts/validate_tag.cjs Normal file
View file

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

46
src/analytics.ts Normal file
View 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,
},
})
})
}
}

177
src/config.ts Normal file
View file

@ -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();
}
}

View file

@ -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/";

251
src/editor.ts Normal file
View file

@ -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') 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(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: `<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();
}
}
}

View file

@ -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: `<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;
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);
}

12
src/errors.ts Normal file
View file

@ -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 {}

View file

@ -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 {
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
export async function uploadAsset(fileID: string, mimeType: string, content: string) {
// folderPath must start and end with /
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 });
}
}
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");
}
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) {
const file = toFile(path.split('/').pop(), content, mimeType);
try {
putFile(path, false, file);
} catch (error) {
console.error("Error saving file:", error);
throw error;
async removeOld(oldSyncID: string) {
await super.remove(IDsToAssetName(this.fileID, oldSyncID));
}
}
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;
}

View file

@ -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 {
@ -103,3 +106,11 @@ 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];
}
}
}

View file

@ -1,4 +1,4 @@
import {Plugin, Protyle} from 'siyuan';
import {Plugin, Protyle, showMessage} from 'siyuan';
import {
getMarkdownBlock,
loadIcons,
@ -6,29 +6,35 @@ 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);
}
}];
@ -37,13 +43,74 @@ export default class DrawJSPlugin extends Plugin {
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);
}
})
})
}
this.addCommand({
langKey: "editShortcut",
hotkey: "⌥⇧D",
callback: async () => {
await this.editSelectedImg();
},
})
this.addTopBar({
icon: "iconDraw",
title: this.i18n.insertDrawing,
callback: async () => {
await this.editSelectedImg();
},
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) {
showMessage(this.i18n.msgMustSelect, 5000, 'info');
return;
}
let ids = imgSrcToIDs(findImgSrc(selectedImg[0] as HTMLElement));
if(ids == null) {
return;
}
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');
}
}
}

View file

@ -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();
}
}
}

View file

@ -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<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 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