Compare commits
28 commits
Author | SHA1 | Date | |
---|---|---|---|
17d4e5938b | |||
a079298433 | |||
5322944ad9 | |||
77e8218d1f | |||
764f9fe5a4 | |||
fa3eba219e | |||
1ad26d1e23 | |||
8d4779b8fe | |||
f35342a791 | |||
3a05d36f8c | |||
e815442881 | |||
7e4da82b82 | |||
fe32505873 | |||
fc4ce8e69e | |||
6bca12c934 | |||
e23cc424f8 | |||
ea9b0be856 | |||
f2801c9f1c | |||
0bc89f4a72 | |||
d8cc4f8caf | |||
4555ec275f | |||
e165c69664 | |||
e9a9961b61 | |||
5e51589ffa | |||
a2503d5def | |||
56cf62f1eb | |||
8d1438de33 | |||
5c261b35f2 |
24 changed files with 1237 additions and 184 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*"
|
||||||
|
|
||||||
|
@ -20,7 +22,7 @@ jobs:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
# Install pnpm
|
# Install pnpm
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
id: pnpm-install
|
id: pnpm-install
|
||||||
|
@ -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 }}
|
27
README.md
27
README.md
|
@ -1,29 +1,22 @@
|
||||||
|
|
||||||
# SiYuan js-draw Plugin
|
# SiYuan js-draw Plugin
|
||||||
|
|
||||||
This plugin allows you to embed js-draw whiteboards anywhere in your SiYuan documents.
|
This plugin allows you to embed js-draw whiteboards anywhere in your SiYuan documents.
|
||||||
|
|
||||||
## Usage instructions
|
## Usage instructions
|
||||||
1. Install the plugin
|
- Install the plugin from the marketplace. You can find it by searching for `js-draw`.
|
||||||
- Grab a release from the [Releases page](https://git.massive.box/massivebox/siyuan-jsdraw-plugin/releases)
|
- To add a new drawing to your document:
|
||||||
- Unzip it in the folder `./data/plugins`, relatively to your SiYuan workspace.
|
1. Type `/Insert Drawing` in your document, and select the correct menu entry
|
||||||
> The plugin is not yet available in the official marketplace. I will try to publish it there soon!
|
2. The whiteboard editor will open in a new tab. Draw as you like, then click the Save button and close the tab.
|
||||||
2. Insert a drawing in your documents by typing `/Insert Drawing` in your document, and selecting the correct menu entry
|
- To edit the image later:
|
||||||
3. The whiteboard editor will open in a new tab. Draw as you like, then click the Save button. It will also add a
|
1. Right-click on the image (or click the three dots on mobile), select "Plugin" > "Edit with js-draw" in the menu
|
||||||
drawing block to your document.
|
2. The editor tab will open, edit your file as you like, then click the Save button and close the tab.
|
||||||
4. Click the Gear icon > Refresh to refresh the drawing block, if it's still displaying the old drawing.
|
|
||||||
5. Click the drawing block to open the editor again.
|
|
||||||
|
|
||||||
## Planned features
|
## Planned features
|
||||||
- [ ] Auto-reload drawing blocks on drawing change
|
Check out the [Projects](https://git.massive.box/massivebox/siyuan-jsdraw-plugin/projects) tab!
|
||||||
- [ ] Rename whiteboards
|
|
||||||
- [ ] Improve internationalization framework
|
|
||||||
- [ ] Default background color and grid options
|
|
||||||
- [ ] Respecting user theme for the editor
|
|
||||||
- And more!
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
Contributions are always welcome! Right now, I'm working on the core functionality and fixing bugs.
|
Contributions are always welcome! Right now, I'm working on the core functionality and fixing bugs.
|
||||||
After that is done, I will need help with the internationalization, as, unfortunately, I don't speak Chinese.
|
After that is done, I will need help with the internationalization, as, unfortunately, I don't speak Chinese.
|
||||||
Please [contact me](mailto:box@massive.box) if you'd like to help!
|
Please [contact me](mailto:box@massive.box) if you'd like to help!
|
||||||
|
|
||||||
|
|
15
package.json
15
package.json
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "plugin-sample-vite-svelte",
|
"name": "siyuan-jsdraw-plugin",
|
||||||
"version": "0.3.6",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "This is a sample plugin based on vite and svelte for Siyuan (https://b3log.org/siyuan)",
|
"description": "Include a whiteboard for freehand drawing anywhere in your documents.",
|
||||||
"repository": "",
|
"repository": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin",
|
||||||
"homepage": "",
|
"homepage": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin",
|
||||||
"author": "frostime",
|
"author": "massivebox",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cross-env NODE_ENV=development VITE_SOURCEMAP=inline vite build --watch",
|
"dev": "cross-env NODE_ENV=development VITE_SOURCEMAP=inline vite build --watch",
|
||||||
|
@ -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.1.0",
|
"version": "0.4.0",
|
||||||
"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": [
|
||||||
|
|
44
public/i18n/en_US.json
Normal file
44
public/i18n/en_US.json
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
{
|
||||||
|
"insertDrawing": "Insert Drawing",
|
||||||
|
"editDrawing": "Edit with js-draw",
|
||||||
|
"errNoFileID": "File ID missing - couldn't open file.",
|
||||||
|
"errSyncIDNotFound": "Couldn't find SyncID in document for drawing, make sure you're trying to edit a drawing that is included in at least a note.",
|
||||||
|
"errCreateUnknown": "Unknown error while creating editor, please try again.",
|
||||||
|
"errInvalidBackgroundColor": "Invalid background color! Please enter an HEX color, like #000000 (black) or #FFFFFF (white). The old background color will be used.",
|
||||||
|
"drawing": "Drawing",
|
||||||
|
"settings": {
|
||||||
|
"name": "js-draw Plugin Settings",
|
||||||
|
"suggestedColors":{
|
||||||
|
"white": "White",
|
||||||
|
"black": "Black",
|
||||||
|
"transparent": "Transparent",
|
||||||
|
"custom": "Custom",
|
||||||
|
"darkBlue": "Dark Blue",
|
||||||
|
"darkGray": "Dark Gray"
|
||||||
|
},
|
||||||
|
"grid": {
|
||||||
|
"title": "Enable grid by default",
|
||||||
|
"description": "Enable to automatically turn on the grid on new drawings."
|
||||||
|
},
|
||||||
|
"backgroundDropdown":{
|
||||||
|
"title": "Background color",
|
||||||
|
"description": "Default background color for new drawings."
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"title": "Custom background",
|
||||||
|
"description": "Hexadecimal code of the custom background color for new drawings.<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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,15 @@
|
||||||
function copyEditLink(fileID) {
|
function copyEditLink(path) {
|
||||||
navigator.clipboard.writeText(getEditLink(fileID));
|
navigator.clipboard.writeText(getEditLink(path));
|
||||||
|
}
|
||||||
|
function copyImageLink(path) {
|
||||||
|
navigator.clipboard.writeText(``);
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshPage() {
|
function refreshPage() {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addButton(document, fileID) {
|
function addButton(document, path) {
|
||||||
|
|
||||||
// Add floating button
|
// Add floating button
|
||||||
const floatingButton = document.createElement('button');
|
const floatingButton = document.createElement('button');
|
||||||
|
@ -19,8 +22,8 @@ function addButton(document, fileID) {
|
||||||
popupMenu.id = 'popupMenu';
|
popupMenu.id = 'popupMenu';
|
||||||
popupMenu.innerHTML = `
|
popupMenu.innerHTML = `
|
||||||
<button onclick="refreshPage()">Refresh</button>
|
<button onclick="refreshPage()">Refresh</button>
|
||||||
<button onclick="copyEditLink('${fileID}')">Copy Direct Edit Link</button>
|
<button onclick="copyEditLink('${path}')">Copy Direct Edit Link</button>
|
||||||
|
<button onclick="copyImageLink('${path}')">Copy Image Link</button>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(popupMenu);
|
document.body.appendChild(popupMenu);
|
||||||
|
|
||||||
|
@ -31,6 +34,7 @@ function addButton(document, fileID) {
|
||||||
|
|
||||||
document.body.addEventListener('mouseleave', () => {
|
document.body.addEventListener('mouseleave', () => {
|
||||||
floatingButton.style.display = 'none';
|
floatingButton.style.display = 'none';
|
||||||
|
popupMenu.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle popup menu on button click
|
// Toggle popup menu on button click
|
||||||
|
|
BIN
public/webapp/cursor.png
Normal file
BIN
public/webapp/cursor.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 719 B |
|
@ -27,9 +27,9 @@ async function getFile(path) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSVG(fileID) {
|
async function getSVG(path) {
|
||||||
|
|
||||||
const resp = await getFile("/data/assets/" + fileID + '.svg');
|
const resp = await getFile("/data/" + path);
|
||||||
if(resp == null) {
|
if(resp == null) {
|
||||||
return FALLBACK;
|
return FALLBACK;
|
||||||
}
|
}
|
||||||
|
@ -37,10 +37,10 @@ async function getSVG(fileID) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEditLink(fileID) {
|
function getEditLink(path) {
|
||||||
const data = encodeURIComponent(
|
const data = encodeURIComponent(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
id: fileID
|
path: path,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
return `siyuan://plugins/siyuan-jsdraw-pluginwhiteboard/?icon=iconDraw&title=Drawing&data=${data}`;
|
return `siyuan://plugins/siyuan-jsdraw-pluginwhiteboard/?icon=iconDraw&title=Drawing&data=${data}`;
|
||||||
|
|
21
public/webapp/error.html
Normal file
21
public/webapp/error.html
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Error</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: white;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>It looks like an error occurred. You shouldn't be able to see this page.</p>
|
||||||
|
<p>No data has been deleted. Please excuse us for the inconvenience.</p>
|
||||||
|
<p>
|
||||||
|
Try reloading SiYuan, and if the error persists, open an issue at
|
||||||
|
<code>https://git.massive.box/massivebox/siyuan-jsdraw-plugin/issues</code>
|
||||||
|
or contact the developer directly via e-mail at <code>box@massive.box</code>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -5,18 +5,22 @@
|
||||||
<script src="button.js"></script>
|
<script src="button.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const fileID = urlParams.get('id');
|
let path = urlParams.get('path');
|
||||||
|
if(path === null) {
|
||||||
|
const fileID = urlParams.get('id'); // legacy support
|
||||||
|
path = "assets/" + fileID + ".svg";
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const editLink = document.createElement('a');
|
const editLink = document.createElement('a');
|
||||||
editLink.href = getEditLink(fileID);
|
editLink.href = "./error.html";
|
||||||
document.body.appendChild(editLink);
|
document.body.appendChild(editLink);
|
||||||
|
|
||||||
const htmlContainer = document.createElement('div');
|
const htmlContainer = document.createElement('div');
|
||||||
htmlContainer.innerHTML = await getSVG(fileID);
|
htmlContainer.innerHTML = await getSVG(path);
|
||||||
editLink.appendChild(htmlContainer);
|
editLink.appendChild(htmlContainer);
|
||||||
|
|
||||||
addButton(document, fileID);
|
addButton(document, path);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="index.css">
|
<link rel="stylesheet" href="index.css">
|
||||||
|
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
177
src/config.ts
Normal file
177
src/config.ts
Normal 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
12
src/const.ts
12
src/const.ts
|
@ -1,7 +1,9 @@
|
||||||
export const SVG_MIME = "image/svg+xml";
|
export const SVG_MIME = "image/svg+xml";
|
||||||
export const JSON_MIME = "application/json";
|
export const JSON_MIME = "application/json";
|
||||||
export const DATA_PATH = "/data/assets";
|
export const DATA_PATH = "/data/";
|
||||||
export const STORAGE_PATH = "/data/storage/petal/siyuan-jsdraw-plugin";
|
export const ASSETS_PATH = "assets/";
|
||||||
export const TOOLBAR_PATH = STORAGE_PATH + "/toolbar.json";
|
export const STORAGE_PATH = "/data/storage/petal/siyuan-jsdraw-plugin/";
|
||||||
export const CONFIG_PATH = STORAGE_PATH + "/conf.json";
|
export const TOOLBAR_FILENAME = "toolbar.json";
|
||||||
export const EMBED_PATH = "/plugins/siyuan-jsdraw-plugin/webapp/?id=";
|
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
251
src/editor.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,75 +0,0 @@
|
||||||
import {ITabModel, openTab, Plugin} from "siyuan"
|
|
||||||
import Editor, {BaseWidget, EditorEventType} from "js-draw";
|
|
||||||
import { MaterialIconProvider } from '@js-draw/material-icons';
|
|
||||||
import 'js-draw/styles';
|
|
||||||
import {getFile, saveFile} from "@/file";
|
|
||||||
import {JSON_MIME, SVG_MIME, TOOLBAR_PATH} from "@/const";
|
|
||||||
import {idToPath} from "@/helper";
|
|
||||||
|
|
||||||
export function openEditorTab(p: Plugin, fileID: string) {
|
|
||||||
openTab({
|
|
||||||
app: p.app,
|
|
||||||
custom: {
|
|
||||||
title: 'Drawing',
|
|
||||||
icon: 'iconDraw',
|
|
||||||
id: "siyuan-jsdraw-pluginwhiteboard",
|
|
||||||
data: { id: fileID }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCallback(editor: Editor, fileID: string, saveButton: BaseWidget) {
|
|
||||||
const svgElem = editor.toSVG();
|
|
||||||
try {
|
|
||||||
saveFile(idToPath(fileID), SVG_MIME, svgElem.outerHTML);
|
|
||||||
saveButton.setDisabled(true);
|
|
||||||
setTimeout(() => { // @todo improve save button feedback
|
|
||||||
saveButton.setDisabled(false);
|
|
||||||
}, 500);
|
|
||||||
} catch (error) {
|
|
||||||
alert("Error saving drawing! Enter developer mode to find the error, and a copy of the current status.");
|
|
||||||
console.error(error);
|
|
||||||
console.log("Couldn't save SVG: ", svgElem.outerHTML)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEditor(i: ITabModel) {
|
|
||||||
|
|
||||||
const fileID = i.data.id;
|
|
||||||
if(fileID == null) {
|
|
||||||
alert("File ID missing - couldn't open file.")
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const editor = new Editor(i.element, {
|
|
||||||
iconProvider: new MaterialIconProvider(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const toolbar = editor.addToolbar();
|
|
||||||
|
|
||||||
// restore toolbar state
|
|
||||||
getFile(TOOLBAR_PATH).then(toolbarState => {
|
|
||||||
if(toolbarState!= null) {
|
|
||||||
toolbar.deserializeState(toolbarState)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// restore drawing
|
|
||||||
getFile(idToPath(fileID)).then(svg => {
|
|
||||||
if(svg != null) {
|
|
||||||
editor.loadFromSVG(svg);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// save logic
|
|
||||||
const saveButton = toolbar.addSaveButton(() => saveCallback(editor, fileID, saveButton));
|
|
||||||
|
|
||||||
// save toolbar config on tool change (toolbar state is not saved in SVGs!)
|
|
||||||
editor.notifier.on(EditorEventType.ToolUpdated, () => {
|
|
||||||
saveFile(TOOLBAR_PATH, JSON_MIME, toolbar.serializeState());
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.dispatch(editor.setBackgroundStyle({ autoresize: true }), false);
|
|
||||||
editor.getRootElement().style.height = '100%';
|
|
||||||
|
|
||||||
}
|
|
12
src/errors.ts
Normal file
12
src/errors.ts
Normal 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 {}
|
121
src/file.ts
121
src/file.ts
|
@ -1,37 +1,108 @@
|
||||||
import {getFileBlob, putFile} from "@/api";
|
import {getFileBlob, putFile, removeFile, upload} from "@/api";
|
||||||
|
import {ASSETS_PATH, DATA_PATH} from "@/const";
|
||||||
|
import {assetPathToIDs, IDsToAssetName} from "@/helper";
|
||||||
|
|
||||||
function toFile(title: string, content: string, mimeType: string){
|
abstract class PluginFileBase {
|
||||||
const blob = new Blob([content], { type: mimeType });
|
|
||||||
return new File([blob], title, { type: mimeType });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveFile(path: string, mimeType: string, content: string) {
|
protected content: string | null;
|
||||||
|
|
||||||
const file = toFile(path.split('/').pop(), content, mimeType);
|
protected fileName: string;
|
||||||
|
protected folderPath: string;
|
||||||
|
protected mimeType: string;
|
||||||
|
|
||||||
try {
|
getContent() { return this.content; }
|
||||||
putFile(path, false, file);
|
setContent(content: string) { this.content = content; }
|
||||||
} catch (error) {
|
setFileName(fileName: string) { this.fileName = fileName; }
|
||||||
console.error("Error saving file:", error);
|
|
||||||
throw error;
|
private setFolderPath(folderPath: string) {
|
||||||
|
if(folderPath.startsWith('/') && folderPath.endsWith('/')) {
|
||||||
|
this.folderPath = folderPath;
|
||||||
|
}else{
|
||||||
|
throw new Error("folderPath must start and end with /");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// folderPath must start and end with /
|
||||||
|
constructor(folderPath: string, fileName: string, mimeType: string) {
|
||||||
|
this.setFolderPath(folderPath);
|
||||||
|
this.fileName = fileName;
|
||||||
|
this.mimeType = mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadFromSiYuanFS() {
|
||||||
|
const blob = await getFileBlob(this.folderPath + this.fileName);
|
||||||
|
const text = await blob.text();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = JSON.parse(text);
|
||||||
|
if(res.code == 404) {
|
||||||
|
this.content = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}catch {}
|
||||||
|
|
||||||
|
this.content = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(customFilename?: string) {
|
||||||
|
let filename = customFilename || this.fileName;
|
||||||
|
await removeFile(this.folderPath + filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toFile(customFilename?: string): File {
|
||||||
|
let filename = customFilename || this.fileName;
|
||||||
|
const blob = new Blob([this.content], { type: this.mimeType });
|
||||||
|
return new File([blob], filename, { type: this.mimeType });
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFile(path: string) {
|
export class PluginFile extends PluginFileBase {
|
||||||
|
|
||||||
const blob = await getFileBlob(path);
|
async save() {
|
||||||
const jsonText = await blob.text();
|
const file = this.toFile();
|
||||||
|
try {
|
||||||
// if we got a 404 api response, we will return null
|
await putFile(this.folderPath + this.fileName, false, file);
|
||||||
try {
|
} catch (error) {
|
||||||
const res = JSON.parse(jsonText);
|
console.error("Error saving file:", error);
|
||||||
if(res.code == 404) {
|
throw error;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}catch {}
|
}
|
||||||
|
|
||||||
// js-draw expects a string!
|
|
||||||
return jsonText;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PluginAsset extends PluginFileBase {
|
||||||
|
|
||||||
|
private fileID: string
|
||||||
|
private syncID: string
|
||||||
|
|
||||||
|
getFileID() { return this.fileID; }
|
||||||
|
getSyncID() { return this.syncID; }
|
||||||
|
|
||||||
|
constructor(fileID: string, syncID: string, mimeType: string) {
|
||||||
|
super(DATA_PATH + ASSETS_PATH, IDsToAssetName(fileID, syncID), mimeType);
|
||||||
|
this.fileID = fileID;
|
||||||
|
this.syncID = syncID;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
|
||||||
|
const file = this.toFile(this.fileID + '.svg');
|
||||||
|
|
||||||
|
let r = await upload('/' + ASSETS_PATH, [file]);
|
||||||
|
if (r.errFiles) {
|
||||||
|
throw new Error("Failed to upload file");
|
||||||
|
}
|
||||||
|
const ids = assetPathToIDs(r.succMap[file.name])
|
||||||
|
|
||||||
|
this.fileID = ids.fileID;
|
||||||
|
this.syncID = ids.syncID;
|
||||||
|
super.setFileName(IDsToAssetName(this.fileID, this.syncID));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeOld(oldSyncID: string) {
|
||||||
|
await super.remove(IDsToAssetName(this.fileID, oldSyncID));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import { Plugin } from 'siyuan';
|
import { Plugin } from 'siyuan';
|
||||||
import {DATA_PATH, EMBED_PATH} from "@/const";
|
import {ASSETS_PATH} from "@/const";
|
||||||
|
|
||||||
const drawIcon: string = `
|
const drawIcon: string = `
|
||||||
<symbol id="iconDraw" viewBox="0 0 28 28">
|
<symbol id="iconDraw" viewBox="0 0 28 28">
|
||||||
|
@ -23,7 +23,7 @@ export function getMenuHTML(icon: string, text: string): string {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateSiyuanId() {
|
export function generateTimeString() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const year = now.getFullYear().toString();
|
const year = now.getFullYear().toString();
|
||||||
|
@ -33,25 +33,84 @@ export function generateSiyuanId() {
|
||||||
const minutes = now.getMinutes().toString().padStart(2, '0');
|
const minutes = now.getMinutes().toString().padStart(2, '0');
|
||||||
const seconds = now.getSeconds().toString().padStart(2, '0');
|
const seconds = now.getSeconds().toString().padStart(2, '0');
|
||||||
|
|
||||||
const timestamp = `${year}${month}${day}${hours}${minutes}${seconds}`;
|
return `${year}${month}${day}${hours}${minutes}${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRandomString() {
|
||||||
|
|
||||||
const characters = 'abcdefghijklmnopqrstuvwxyz';
|
const characters = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
let random = '';
|
let random = '';
|
||||||
for (let i = 0; i < 7; i++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
random += characters.charAt(Math.floor(Math.random() * characters.length));
|
random += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||||
}
|
}
|
||||||
|
return random;
|
||||||
|
|
||||||
return `${timestamp}-${random}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function idToPath(id: string) {
|
export function IDsToAssetName(fileID: string, syncID: string) {
|
||||||
return DATA_PATH + '/' + id + '.svg';
|
return `${fileID}-${syncID}.svg`;
|
||||||
|
}
|
||||||
|
export function IDsToAssetPath(fileID: string, syncID: string) {
|
||||||
|
return `${ASSETS_PATH}${IDsToAssetName(fileID, syncID)}`
|
||||||
|
}
|
||||||
|
export function assetPathToIDs(assetPath: string): { fileID: string; syncID: string } | null {
|
||||||
|
|
||||||
|
const filename = assetPath.split('/').pop() || '';
|
||||||
|
if (!filename.endsWith('.svg')) return null;
|
||||||
|
|
||||||
|
// Split into [basename, extension] and check format
|
||||||
|
const [basename] = filename.split('.');
|
||||||
|
const parts = basename.split('-');
|
||||||
|
|
||||||
|
// Must contain exactly 2 hyphens separating 3 non-empty parts
|
||||||
|
if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileID: parts[0],
|
||||||
|
syncID: parts[1] + '-' + parts[2]
|
||||||
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Edit](siyuan://plugins/siyuan-jsdraw-pluginwhiteboard/?icon=iconDraw&title=Drawing&data={"id":"${id}"})
|
export function getMarkdownBlock(fileID: string, syncID: string): string {
|
||||||
// 
|
|
||||||
export function getPreviewHTML(id: string): string {
|
|
||||||
return `
|
return `
|
||||||
<iframe src="${EMBED_PATH + id}&antiCache=0"></iframe>
|
})
|
||||||
`
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// given a tag (such as a div) containing an image as a child at any level, return the src of the image
|
||||||
|
export function findImgSrc(element: HTMLElement): string | null {
|
||||||
|
// Base case: if current element is an image
|
||||||
|
if (element.tagName === 'IMG') {
|
||||||
|
return (element as HTMLImageElement).src;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively check children
|
||||||
|
if (element.children) {
|
||||||
|
for (const child of Array.from(element.children)) {
|
||||||
|
const src = findImgSrc(child as HTMLElement);
|
||||||
|
if (src) return src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imgSrcToIDs(imgSrc: string | null): { fileID: string; syncID: string } | null {
|
||||||
|
|
||||||
|
if (!imgSrc) return null;
|
||||||
|
|
||||||
|
const url = new URL(imgSrc);
|
||||||
|
imgSrc = decodeURIComponent(url.pathname);
|
||||||
|
|
||||||
|
return assetPathToIDs(imgSrc);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFirstDefined(...a) {
|
||||||
|
for(let i = 0; i < a.length; i++) {
|
||||||
|
if(a[i] !== undefined) {
|
||||||
|
return a[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
78
src/index.ts
78
src/index.ts
|
@ -1,44 +1,82 @@
|
||||||
import {Plugin, Protyle} from 'siyuan';
|
import {Plugin, Protyle} from 'siyuan';
|
||||||
import {getPreviewHTML, loadIcons, getMenuHTML, generateSiyuanId} from "@/helper";
|
import {
|
||||||
import {createEditor, openEditorTab} from "@/editorTab";
|
getMarkdownBlock,
|
||||||
|
loadIcons,
|
||||||
|
getMenuHTML,
|
||||||
|
findImgSrc,
|
||||||
|
imgSrcToIDs, generateTimeString, generateRandomString
|
||||||
|
} from "@/helper";
|
||||||
|
import {migrate} from "@/migration";
|
||||||
|
import {EditorManager} from "@/editor";
|
||||||
|
import {PluginConfig, PluginConfigViewer} from "@/config";
|
||||||
|
import {Analytics} from "@/analytics";
|
||||||
|
|
||||||
export default class DrawJSPlugin extends Plugin {
|
export default class DrawJSPlugin extends Plugin {
|
||||||
onload() {
|
|
||||||
|
config: PluginConfig;
|
||||||
|
analytics: Analytics;
|
||||||
|
|
||||||
|
async onload() {
|
||||||
|
|
||||||
loadIcons(this);
|
loadIcons(this);
|
||||||
//const id = Math.random().toString(36).substring(7);
|
EditorManager.registerTab(this);
|
||||||
this.addTab({
|
migrate()
|
||||||
'type': "whiteboard",
|
|
||||||
init() {
|
await this.startConfig();
|
||||||
createEditor(this);
|
await this.startAnalytics();
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.protyleSlash = [{
|
this.protyleSlash = [{
|
||||||
id: "insert-drawing",
|
id: "insert-drawing",
|
||||||
filter: ["Insert Drawing", "Add drawing", "whiteboard", "freehand", "graphics", "jsdraw"],
|
filter: ["Insert Drawing", "Add drawing", "whiteboard", "freehand", "graphics", "jsdraw"],
|
||||||
html: getMenuHTML("iconDraw", this.i18n.insertDrawing),
|
html: getMenuHTML("iconDraw", this.i18n.insertDrawing),
|
||||||
callback: (protyle: Protyle) => {
|
callback: async (protyle: Protyle) => {
|
||||||
const uid = generateSiyuanId();
|
void this.analytics.sendEvent('create');
|
||||||
protyle.insert(getPreviewHTML(uid), true, false);
|
const fileID = generateRandomString();
|
||||||
openEditorTab(this, uid);
|
const syncID = generateTimeString() + '-' + generateRandomString();
|
||||||
|
protyle.insert(getMarkdownBlock(fileID, syncID), true, false);
|
||||||
|
(await EditorManager.create(fileID, this)).open(this);
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
|
|
||||||
}
|
this.eventBus.on("open-menu-image", (e: any) => {
|
||||||
|
const ids = imgSrcToIDs(findImgSrc(e.detail.element));
|
||||||
|
if (ids === null) return;
|
||||||
|
e.detail.menu.addItem({
|
||||||
|
icon: "iconDraw",
|
||||||
|
label: this.i18n.editDrawing,
|
||||||
|
click: async () => {
|
||||||
|
void this.analytics.sendEvent('edit');
|
||||||
|
(await EditorManager.create(ids.fileID, this)).open(this);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
onLayoutReady() {
|
|
||||||
// This function is automatically called when the layout is loaded.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onunload() {
|
onunload() {
|
||||||
// This function is automatically called when the plugin is disabled.
|
void this.analytics.sendEvent("unload");
|
||||||
}
|
}
|
||||||
|
|
||||||
uninstall() {
|
uninstall() {
|
||||||
// This function is automatically called when the plugin is uninstalled.
|
void this.analytics.sendEvent("uninstall");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startConfig() {
|
||||||
|
this.config = new PluginConfig();
|
||||||
|
await this.config.load();
|
||||||
|
let configViewer = new PluginConfigViewer(this.config, this);
|
||||||
|
await configViewer.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startAnalytics() {
|
||||||
|
this.analytics = new Analytics(this.config.options.analytics);
|
||||||
|
if(this.config.getFirstRun()) {
|
||||||
|
await this.config.save();
|
||||||
|
void this.analytics.sendEvent('install');
|
||||||
|
}else{
|
||||||
|
void this.analytics.sendEvent('load');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
65
src/migration.ts
Normal file
65
src/migration.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import {sql} from "@/api";
|
||||||
|
import {PluginAsset, PluginFile} from "@/file";
|
||||||
|
import {ASSETS_PATH, DATA_PATH, SVG_MIME} from "@/const";
|
||||||
|
import {replaceBlockContent} from "@/protyle";
|
||||||
|
import {generateRandomString, getMarkdownBlock} from "@/helper";
|
||||||
|
import {Dialog} from "siyuan";
|
||||||
|
|
||||||
|
export async function migrate() {
|
||||||
|
|
||||||
|
let blocks = await findEmbedBlocks();
|
||||||
|
const found = blocks.length > 0;
|
||||||
|
|
||||||
|
for(const block of blocks) {
|
||||||
|
const oldFileID = extractID(block.markdown);
|
||||||
|
if(oldFileID) {
|
||||||
|
const oldFile = new PluginFile(DATA_PATH + ASSETS_PATH, oldFileID + '.svg', SVG_MIME);
|
||||||
|
await oldFile.loadFromSiYuanFS();
|
||||||
|
const newFile = new PluginAsset(generateRandomString(), oldFileID, SVG_MIME);
|
||||||
|
newFile.setContent(oldFile.getContent());
|
||||||
|
await newFile.save();
|
||||||
|
const newMarkdown = getMarkdownBlock(newFile.getFileID(), newFile.getSyncID());
|
||||||
|
if(await replaceBlockContent(block.id, block.markdown, newMarkdown)) {
|
||||||
|
await oldFile.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(found) {
|
||||||
|
new Dialog({
|
||||||
|
width: "90vw",
|
||||||
|
height: "90vh",
|
||||||
|
content: `
|
||||||
|
<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 [];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
112
src/protyle.ts
Normal file
112
src/protyle.ts
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import {getBlockByID, sql, updateBlock} from "@/api";
|
||||||
|
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) {
|
||||||
|
|
||||||
|
const sqlQuery = `
|
||||||
|
SELECT id, markdown
|
||||||
|
FROM blocks
|
||||||
|
WHERE markdown like '%](${src}%' // "](" is to check it's an image src
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await sql(sqlQuery);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching for image blocks:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
export async function replaceBlockContent(
|
||||||
|
blockId: string,
|
||||||
|
searchStr: string,
|
||||||
|
replaceStr: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const block = await getBlockByID(blockId);
|
||||||
|
if (!block) {
|
||||||
|
throw new Error('Block not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalContent = block.markdown;
|
||||||
|
const newContent = originalContent.replaceAll(searchStr, replaceStr);
|
||||||
|
|
||||||
|
if (newContent === originalContent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateBlock('markdown', newContent, blockId);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to replace block content:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// find blocks containing that image
|
||||||
|
const blocks = await findImageBlocks(search);
|
||||||
|
if(blocks.length === 0) return false;
|
||||||
|
|
||||||
|
for(const block of blocks) {
|
||||||
|
|
||||||
|
// get all the image sources, with parameters
|
||||||
|
const markdown = block.markdown;
|
||||||
|
|
||||||
|
for(const source of extractImageSourcesFromMarkdown(markdown, search)) {
|
||||||
|
const newSource = IDsToAssetPath(fileID, newSyncID);
|
||||||
|
const changed = await replaceBlockContent(block.id, source, newSource);
|
||||||
|
if(!changed) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
|
||||||
|
}
|
|
@ -4,7 +4,7 @@
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": [
|
"lib": [
|
||||||
"ES2020",
|
"ES2021",
|
||||||
"DOM",
|
"DOM",
|
||||||
"DOM.Iterable"
|
"DOM.Iterable"
|
||||||
],
|
],
|
||||||
|
|
185
vite.config.ts.timestamp-1743541342564-d66840ad6dd8b.mjs
Normal file
185
vite.config.ts.timestamp-1743541342564-d66840ad6dd8b.mjs
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue