Compare commits

...

16 commits
v0.2.2 ... main

Author SHA1 Message Date
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 799 additions and 211 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.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"
}
}

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.0",
"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,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.<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: 719 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') 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<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

@ -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,40 @@ 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);
}
})
})
}
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');
}
}
}

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