Compare commits

...

27 commits
v0.1.1 ... 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
ea9b0be856
Update version
Some checks failed
Create Release on Tag Push / build (push) Has been cancelled
2025-04-06 15:35:37 +02:00
f2801c9f1c
Bug fix: save on no changes
Some checks failed
Create Release on Tag Push / build (push) Has been cancelled
2025-04-06 12:21:25 +02:00
0bc89f4a72
Prepare for release
Some checks failed
Create Release on Tag Push / build (push) Has been cancelled
2025-04-05 23:23:45 +02:00
d8cc4f8caf
Auto-migrate old drawing blocks on startup 2025-04-05 21:58:19 +02:00
4555ec275f
Fix sync inconsistencies across devices
Changed APIs to upload assets, reworked saving logic so that files are synced across devices when changed locally.
2025-04-05 19:30:31 +02:00
e165c69664
Open editor in dialog on mobile
SiYuan Mobile doesn't have tabs, so the editor has to be opened in a dialog.
In the future, consider including a setting to open editor in dialog on desktop as well.
2025-04-05 00:22:38 +02:00
e9a9961b61
Auto-refresh images on edit
Only for Markdown images, not drawing blocks, since those will eventually be deprecated.
2025-04-03 15:51:33 +02:00
5e51589ffa
Internal file paths are now Markdown image paths
In the last commit, file paths were implemented to be the full path for the API, however that is not necessary.
Before this change: /data/assets/filename.svg
After: assets/filename.svg
The new method is what SiYuan uses for Markdown images, like ![Drawing](assets/filename.svg)
2025-04-03 00:12:36 +02:00
a2503d5def
Move from file IDs to file paths (with retrocompatibility) 2025-04-02 20:15:48 +02:00
56cf62f1eb
Improve docs 2025-04-01 23:24:17 +02:00
8d1438de33
Offer to edit images
Any SVG image in assets/ can now be edited with js-draw. It won't reload automatically (yet)
2025-04-01 23:03:22 +02:00
24 changed files with 1235 additions and 185 deletions

View file

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

View file

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

View file

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

View file

@ -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.1", "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": [

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."
}
}
} }

View file

@ -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(`![Drawing](${path})`);
} }
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 B

View file

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

View file

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

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

View file

@ -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 {
// ![Drawing](assets/${id}.svg)
export function getPreviewHTML(id: string): string {
return ` return `
<iframe src="${EMBED_PATH + id}&antiCache=0"></iframe> ![Drawing](${IDsToAssetPath(fileID, syncID)})
` `
}
// 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];
}
}
} }

View file

@ -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
View 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 &amp; 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
View 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;
}

View file

@ -4,7 +4,7 @@
"useDefineForClassFields": true, "useDefineForClassFields": true,
"module": "ESNext", "module": "ESNext",
"lib": [ "lib": [
"ES2020", "ES2021",
"DOM", "DOM",
"DOM.Iterable" "DOM.Iterable"
], ],

File diff suppressed because one or more lines are too long