├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── doc ├── advance.png └── basic.png ├── esbuild.config.mjs ├── manifest.json ├── package.json ├── src ├── main.ts ├── pdfblock │ ├── cache.ts │ ├── processor.ts │ └── renderer.ts ├── pdfcmd │ ├── generator.ts │ ├── open.ts │ └── utils.ts ├── pdfview │ └── canvas.ts └── settings.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | PLUGIN_NAME: obsidian-slide-note 10 | 11 | permissions: 12 | contents: 'write' 13 | id-token: 'write' 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Use Node.js 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: "14.x" 25 | 26 | - name: Build 27 | id: build 28 | run: | 29 | npm install 30 | npm run build 31 | mkdir ${{ env.PLUGIN_NAME }} 32 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 33 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 34 | ls 35 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 36 | 37 | - name: Create Release 38 | id: create_release 39 | uses: actions/create-release@v1 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | VERSION: ${{ github.ref }} 43 | with: 44 | tag_name: ${{ github.ref }} 45 | release_name: ${{ github.ref }} 46 | draft: false 47 | prerelease: false 48 | 49 | - name: Upload zip file 50 | id: upload-zip 51 | uses: actions/upload-release-asset@v1 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | with: 55 | upload_url: ${{ steps.create_release.outputs.upload_url }} 56 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 57 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 58 | asset_content_type: application/zip 59 | 60 | - name: Upload main.js 61 | id: upload-main 62 | uses: actions/upload-release-asset@v1 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | with: 66 | upload_url: ${{ steps.create_release.outputs.upload_url }} 67 | asset_path: ./main.js 68 | asset_name: main.js 69 | asset_content_type: text/javascript 70 | 71 | - name: Upload manifest.json 72 | id: upload-manifest 73 | uses: actions/upload-release-asset@v1 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | with: 77 | upload_url: ${{ steps.create_release.outputs.upload_url }} 78 | asset_path: ./manifest.json 79 | asset_name: manifest.json 80 | asset_content_type: application/json 81 | 82 | - name: Upload styles.css 83 | id: upload-css 84 | uses: actions/upload-release-asset@v1 85 | env: 86 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 87 | with: 88 | upload_url: ${{ steps.create_release.outputs.upload_url }} 89 | asset_path: ./styles.css 90 | asset_name: styles.css 91 | asset_content_type: text/css 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | package-lock.json 24 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jinyan Xu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Slide Note 2 | 3 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/Phantom1003) 4 | 5 | This repository maintains an Obsidian plugin that can help you take notes for your classes more easily. 6 | 7 | With this plugin you can write plaintext notes, and: 8 | 9 | - keep binding with the slides 10 | - render the slide and your graphic annotations together 11 | - make your notes decouple with the heavy tools 12 | 13 | This plugin is inspired by the [better-pdf](https://github.com/MSzturc/obsidian-better-pdf-plugin), but beyond rendering PDF pages. 14 | Slide Note provides several new features, including: 15 | 16 | - graphic annotation support 17 | - per-file frontmatter configuration 18 | - performance optimization for the huge number of pages 19 | - automatic rerender when the pdf file has been modified 20 | 21 | ### Notice 22 | > Slide Note is still under development, and some of the usages may have incompatible modifications. 23 | > 24 | > In addition, Slide Note will only be compatible with the latest non-internal version. 25 | 26 | ## 1 Usage 27 | 28 | ### 1.1 Basic Fields 29 | 30 | You can involve Slide Note by writing a code block with the `slide-note` type. 31 | 32 | `````markdown 33 | ```slide-note 34 | file: example.pdf 35 | page: 2, 4-5, 8 36 | scale: 0.2 37 | dpi: 2 38 | text: true 39 | rotat: 90 40 | rect: W(0.069), H(0.113), W(0.861), H(0.337) 41 | ``` 42 | ````` 43 | 44 | ![basic usage](doc/basic.png) 45 | 46 | #### 1.1.1 `file` Field 47 | 48 | The `file` field is the relative path of your file, use the `/` symbol as the path separator. 49 | This is a mandatory field when there is no default value. 50 | 51 | For example, if you have a file named `example.pdf` in the `slide` directory, you can use either of the following methods to specify this file: 52 | 53 | ```markdown 54 | file: slide/example.pdf 55 | file: example.pdf 56 | file: [[example.pdf]] 57 | ``` 58 | 59 | This field also supports the absolute path in desktop mode. 60 | However, if you also want to view your notes on your phone, you should not use the absolute path. 61 | Besides, we also don't suggest you use the relative path, please use the obsidian built-in link name, using the relative path may produce unexpected behaviors. 62 | 63 | #### 1.1.2 `page` Field 64 | 65 | You can use `page` field to specify the pages you want to render. 66 | By default, all pages in the PDF will be rendered. 67 | This field supports continuous page rendering, and you can use `-` to specify a page range. 68 | Also, you can enter multiple groups of pages, using `,` to separate them. 69 | 70 | ```markdown 71 | page: 2, 4-5, 8 72 | ``` 73 | 74 | #### 1.1.3 `scale` Field 75 | 76 | You may want to control the size of the rendering block. 77 | Use the `scale` field for scaling, the default value is 1.0. 78 | 79 | #### 1.1.4 `dpi` Field 80 | 81 | Sometimes you may feel that the rendered page is a bit blurry, you can use the dpi field to adjust the resolution. 82 | The default DPI level is 1. 83 | 84 | #### 1.1.5 `text` Field 85 | 86 | Since the PDF pages are rendered as HTML canvas elements, You cannot select the text on the page. 87 | Enable the `text` field to allow you to select them. 88 | The default value is false. 89 | 90 | #### 1.1.6 `rotat` Field 91 | 92 | You can also rotate your page with the `rotat` field. 93 | The value of this field must be a multiple of 90 degrees, the default value is 0 94 | 95 | Notice this field is not compatible with the `text` field. 96 | 97 | #### 1.1.6 `rect` Field 98 | 99 | The `rect` field can help you render only a part of the page. 100 | This field receives four parameters which are the x and y coordinates of the upper left corner of the render window, and the width and height of the render window. 101 | For simplicity, each parameter is presented as a percentage. 102 | For example, W(0.5) represents 50% of the width. 103 | The default render window is the entire page. 104 | 105 | ```markdown 106 | rect: W(0.069), H(0.113), W(0.861), H(0.337) 107 | ``` 108 | 109 | Notice this field is not compatible with the `text` field. 110 | 111 | ### 1.2 File Front Matter 112 | 113 | You can overwrite the above default value by writing a front matter in the front of your note file. 114 | ```markdown 115 | --- 116 | default_file: example.pdf 117 | default_text: true 118 | default_scale: 0.8 119 | default_dpi: 2 120 | default_rotat: 90 121 | --- 122 | ``` 123 | 124 | ### 1.3 Advanced Annotations 125 | 126 | #### 1.3.1 Graphic Annotations 127 | 128 | Besides these basic uses, you can also append more statements in the block to annotate the PDF. 129 | A string starting with @ is a graphic annotation. 130 | Slide Note provides a drawboard view to help you to generate the above code. 131 | Double-click the slide page will launch the drawboard on the right side. 132 | You can add path, line, rectangle, and text on the slide. 133 | Once you finish your annotations, click the save button to generate the code that is used to render your annotations. 134 | 135 | Notice this feature needs to be turned on manually in the settings. 136 | 137 | #### 1.3.2 Inline Notes 138 | 139 | And all the other statements will be treated as your notes, this makes sure that all your notes bind with the page in one block. 140 | Therefore, when you link them in other places, you can get them all. 141 | 142 | In the end, your notes should look like the following: 143 | 144 | ![advance usage](doc/advance.png) 145 | 146 | 147 | ### 1.4 `Better PDF` Compatibility 148 | This plugin is compatible with a subset of the features [better-pdf](https://github.com/MSzturc/obsidian-better-pdf-plugin) offers. 149 | 150 | If you wish to display your old `better-pdf` notes, you can do so by enabling the "Support Better PDF Code Blocks" setting in the plugin settings. 151 | 152 | More information on the better-pdf syntax can be found [here](https://github.com/MSzturc/obsidian-better-pdf-plugin#syntax). 153 | 154 | It is not recommended that you continue to use the `better-pdf` syntax, as it is not guaranteed to be compatible with future versions of Slide Note. 155 | Try to migrate to the new syntax as soon as possible. 156 | 157 | While using the `better-pdf` syntax, some slide note features won't be available. 158 | 159 | | Better PDF Field Name | Supported by Slide Note | 160 | | ------------------ | ------------------------------------------------------------------------------------------------- | 161 | | url | ⚠️Partial, name.pdf subfolder/name.pdf and "[[filename.pdf]]" are supported, urls aren't supported | 162 | | link | ❌ | 163 | | page | ✅ | 164 | | range | ✅ | 165 | | scale | ✅ | 166 | | fit | ❌ | 167 | | rotation | ✅ | 168 | | rect | ✅ | 169 | 170 | ### 1.5 Slide Note Block Generator 171 | 172 | You will find an item called `Slide Note Block Generation` on your left sidebar. 173 | You can use this generator to insert a bunch of blocks into your current active file. 174 | To generate blocks, you first need to specify a file to open. 175 | You can specify the PDF file to open by: 176 | 177 | 1. selecting a Slide Note code block. 178 | 2. setting a `default_file` property in the front matter. 179 | 180 | And then click the icon in the left sidebar, input the pages you want to insert. 181 | Notice when you select a block to specify the file to open, the generated blocks will replace your selection. 182 | 183 | ### 1.6 Slide Note Quick Open 184 | 185 | You may still want to use your PDF viewer to edit your slides. 186 | You also need to specify a PDF file to open. 187 | You can use one of the following methods: 188 | 189 | 1. select a Slide Note code block. 190 | 2. move the cursor to the line containing the `file` field in the SlideNote block. 191 | 3. set a `default_file` property in the front matter. 192 | 193 | After confirming the file to open, you can right-click the selection/line/editor and select `Slide Note: open with local application`. 194 | -------------------------------------------------------------------------------- /doc/advance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phantom1003/obsidian-slide-note/9b9f6b38a477f9eb6e12fe67a7a1d874d95f0f8c/doc/advance.png -------------------------------------------------------------------------------- /doc/basic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phantom1003/obsidian-slide-note/9b9f6b38a477f9eb6e12fe67a7a1d874d95f0f8c/doc/basic.png -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["src/main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | }); 42 | 43 | if (prod) { 44 | await context.rebuild(); 45 | process.exit(0); 46 | } else { 47 | await context.watch(); 48 | } 49 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "slide-note", 3 | "name": "Slide Note", 4 | "version": "0.0.17", 5 | "minAppVersion": "0.0.17", 6 | "description": "Conveniently take notes on PDF course slides :P", 7 | "author": "Jinyan Xu", 8 | "authorUrl": "https://github.com/Phantom1003", 9 | "fundingUrl": "https://www.buymeacoffee.com/Phantom1003", 10 | "isDesktopOnly": false 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slide-note", 3 | "version": "0.0.17", 4 | "description": "Conveniently take notes on PDF course slides :P", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/fabric": "^5.3.0", 16 | "@types/node": "^16.11.6", 17 | "@typescript-eslint/eslint-plugin": "5.29.0", 18 | "@typescript-eslint/parser": "5.29.0", 19 | "builtin-modules": "3.3.0", 20 | "esbuild": "^0.25.0", 21 | "obsidian": "latest", 22 | "tslib": "2.4.0", 23 | "typescript": "4.7.4" 24 | }, 25 | "dependencies": { 26 | "canvas": "^2.11.2", 27 | "fabric": "^5.3.0", 28 | "lru-cache": "^10.2.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import {Editor, MarkdownView, Menu, Platform, Plugin} from 'obsidian'; 2 | 3 | import { FileCache } from "./pdfblock/cache"; 4 | import { PDFBlockProcessor, ParameterSyntaxType } from "./pdfblock/processor"; 5 | import { PDFCANVAS_VIEW, PDFCanvasView } from "./pdfview/canvas"; 6 | import { SlideNoteCMDModal } from "./pdfcmd/generator"; 7 | import { SlideNoteSettings, SlideNoteSettingsTab } from './settings'; 8 | import { openPDFwithLocal } from "./pdfcmd/open"; 9 | import {getFileName} from "./pdfcmd/utils"; 10 | 11 | export default class SlideNotePlugin extends Plugin { 12 | settings: SlideNoteSettings; 13 | 14 | async onload() { 15 | console.log("SlideNote loading ..."); 16 | 17 | await this.loadSettings(); 18 | this.addSettingTab(new SlideNoteSettingsTab(this.app, this)); 19 | 20 | this.registerPDFProcessor(); 21 | this.registerPDFCanvas(); 22 | this.registerPDFMenu(); 23 | 24 | if (this.settings.support_better_pdf) 25 | this.registerBetterPdfProcessor(); 26 | 27 | this.addRibbonIcon('star-list', 'Slide Note Block Generator', (evt: MouseEvent) => { 28 | new SlideNoteCMDModal(this.app).open(); 29 | }); 30 | this.addCommand({ 31 | id: 'generate-slide-note-block', 32 | name: 'Generate Slide Note Code Block', 33 | callback: () => { 34 | new SlideNoteCMDModal(this.app).open(); 35 | // this.app.commands.executeCommandById 36 | } 37 | }); 38 | } 39 | 40 | registerPDFProcessor() { 41 | const cache = new FileCache(3); 42 | const processor = new PDFBlockProcessor(this, cache, ParameterSyntaxType.SlideNote); 43 | const handler = this.registerMarkdownCodeBlockProcessor( 44 | "slide-note", 45 | async (src, el, ctx) => 46 | processor.codeProcessCallBack(src, el, ctx) 47 | ); 48 | handler.sortOrder = -100; 49 | } 50 | 51 | registerBetterPdfProcessor() { 52 | const cache = new FileCache(3); 53 | const processor = new PDFBlockProcessor(this, cache, ParameterSyntaxType.BetterPDF); 54 | const handler = this.registerMarkdownCodeBlockProcessor( 55 | "pdf", 56 | async (src, el, ctx) => 57 | processor.codeProcessCallBack(src, el, ctx) 58 | ); 59 | handler.sortOrder = -100; 60 | } 61 | 62 | registerPDFCanvas() { 63 | // @ts-ignore 64 | this.registerEvent(this.app.workspace.on("slidenote:dblclick", (event, canvas) => { 65 | this.activeCanvas(canvas.toDataURL()); 66 | })); 67 | 68 | this.registerView( 69 | PDFCANVAS_VIEW, 70 | (leaf) => new PDFCanvasView(leaf) 71 | ); 72 | } 73 | 74 | registerPDFMenu() { 75 | // @ts-ignore 76 | this.registerEvent(this.app.workspace.on("slidenote:rclick", (event, block: HTMLElement) => { 77 | const menu = new Menu(); 78 | menu.addItem((item) => { 79 | item.setTitle("Edit") 80 | .setIcon("pencil") 81 | .onClick((_) => { 82 | // @ts-ignore 83 | block.nextSibling?.click(); 84 | }) 85 | }) 86 | if (Platform.isDesktop) { 87 | menu.addItem((item) => { 88 | item.setTitle("Open PDF with local APP") 89 | .setIcon("book-open") 90 | .onClick((_) => { 91 | // @ts-ignore 92 | openPDFwithLocal(this.app.workspace.getActiveViewOfType(MarkdownView)); 93 | }); 94 | }); 95 | } 96 | menu.showAtMouseEvent(event); 97 | })); 98 | 99 | this.registerEvent(this.app.workspace.on('editor-menu', 100 | (menu: Menu, _: Editor, view: MarkdownView) => { 101 | if (Platform.isDesktop && getFileName(view) != undefined) { 102 | menu.addItem((item) => { 103 | item.setTitle("Slide Note: Open PDF with local APP") 104 | .setIcon("book-open") 105 | .onClick((_) => { 106 | openPDFwithLocal(view); 107 | }); 108 | }); 109 | } 110 | })); 111 | } 112 | 113 | onunload() { 114 | console.log("SlideNote unloading ..."); 115 | this.app.workspace.detachLeavesOfType(PDFCANVAS_VIEW); 116 | } 117 | 118 | async loadSettings() { 119 | this.settings = Object.assign({}, new SlideNoteSettings(), await this.loadData()); 120 | } 121 | 122 | async saveSettings() { 123 | await this.saveData(this.settings); 124 | if(this.settings.support_better_pdf) 125 | this.registerBetterPdfProcessor(); 126 | } 127 | 128 | async activeCanvas(src: string) { 129 | this.app.workspace.detachLeavesOfType(PDFCANVAS_VIEW); 130 | 131 | await this.app.workspace.getRightLeaf(false)?.setViewState({ 132 | type: PDFCANVAS_VIEW, 133 | active: true, 134 | }); 135 | const canvas = this.app.workspace.getLeavesOfType(PDFCANVAS_VIEW)[0]; 136 | 137 | app.workspace.trigger("slidenote:newcanvas", src); 138 | this.app.workspace.revealLeaf(canvas); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/pdfblock/cache.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from "obsidian"; 2 | import { LRUCache } from "lru-cache"; 3 | import { isAbsolute } from "path"; 4 | import { readFileSync } from "fs"; 5 | 6 | export class FileCache { 7 | map: LRUCache 8 | pending: Map> 9 | 10 | constructor(max: number) { 11 | this.map = new LRUCache({ max: max }); 12 | this.pending = new Map(); 13 | } 14 | 15 | async get(path: string): Promise { 16 | if (this.map.has(path)) { 17 | // @ts-ignore 18 | return this.map.get(path).slice(0); 19 | } 20 | else if (this.pending.has(path)) { 21 | const buffer = await this.pending.get(path) 22 | // @ts-ignore 23 | return buffer.slice(0); 24 | } 25 | else { 26 | const buffer = Platform.isDesktop && isAbsolute(path) ? this.readLocalFile(path) : app.vault.adapter.readBinary(path); 27 | this.pending.set(path, buffer); 28 | this.map.set(path, await buffer); 29 | this.pending.delete(path); 30 | // @ts-ignore 31 | return this.map.get(path).slice(0); 32 | } 33 | } 34 | invalid(path: string): void { 35 | this.map.delete(path); 36 | } 37 | 38 | async readLocalFile(path: string): Promise { 39 | const buffer = readFileSync(path); 40 | const arrayBuffer = new ArrayBuffer(buffer.length); 41 | const typedArray = new Uint8Array(arrayBuffer); 42 | for (let i = 0; i < buffer.length; ++i) { 43 | typedArray[i] = buffer[i]; 44 | } 45 | return arrayBuffer; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/pdfblock/processor.ts: -------------------------------------------------------------------------------- 1 | import { FrontMatterCache, MarkdownPostProcessorContext } from "obsidian"; 2 | import SlideNotePlugin from '../main'; 3 | import { PDFBlockRenderer } from "./renderer"; 4 | import { FileCache } from "./cache"; 5 | 6 | export interface PDFBlockParameters { 7 | file: string; 8 | page: Array; 9 | text: boolean; 10 | scale: number; 11 | dpi: number; 12 | rotat: number; 13 | rect: Array; 14 | annot: string; 15 | note: string; 16 | } 17 | 18 | export enum ParameterSyntaxType { 19 | SlideNote = 'SlideNote', 20 | BetterPDF = 'BetterPDF' 21 | } 22 | 23 | export class PDFBlockProcessor { 24 | 25 | constructor(private plugin: SlideNotePlugin, 26 | private cache: FileCache, 27 | private parameterSyntax:ParameterSyntaxType 28 | ) { 29 | } 30 | 31 | async codeProcessCallBack(src: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) { 32 | const frontmatter = app.metadataCache.getCache(ctx.sourcePath)?.frontmatter as FrontMatterCache 33 | try { 34 | let params: PDFBlockParameters; 35 | if (this.parameterSyntax == ParameterSyntaxType.SlideNote) 36 | params = await this.parseParameters(src, frontmatter); 37 | else if (this.parameterSyntax == ParameterSyntaxType.BetterPDF) 38 | params = await this.parseBetterPdfParameters(src, frontmatter); 39 | else 40 | throw new Error("Invalid Parameter Syntax Type"); 41 | const render = new PDFBlockRenderer(el, params, ctx.sourcePath, this.plugin.settings, this.cache); 42 | render.load(); 43 | ctx.addChild(render); 44 | } catch (e) { 45 | const p = el.createEl("p", {text: "[SlideNote] Invalid Parameters: " + e.message}); 46 | p.style.color = "red"; 47 | } 48 | } 49 | 50 | async parseParameters(src: string, frontmatter: FrontMatterCache): Promise { 51 | const lines = src.split("\n"); 52 | const keywords = ["file", "page", "text", "scale", "rotat", "rect", "dpi"]; 53 | const paramsRaw: { [k: string]: string } = {}; 54 | const annot: Array = []; 55 | const note: Array = []; 56 | 57 | for (let i = 0; i < lines.length; i++) { 58 | const words = lines[i].trim().split(/:(.*)/s) 59 | if (keywords.indexOf(words[0]) > -1) { 60 | if (words[1].length != 0) 61 | paramsRaw[words[0]] = words[1].trim(); 62 | } 63 | else { 64 | if (lines[i].trim().startsWith("@")) 65 | annot.push(lines[i].trim().slice(1)); 66 | else 67 | note.push(lines[i]); 68 | } 69 | } 70 | 71 | const params: PDFBlockParameters = { 72 | file: "", 73 | page: [], 74 | text: this.plugin.settings.default_text, 75 | scale: 1, 76 | dpi: this.plugin.settings.default_dpi, 77 | rotat: 0, 78 | rect: [-1, -1, -1, -1], 79 | annot: annot.join("\n"), 80 | note: note.join("\n") 81 | }; 82 | 83 | // handle file 84 | if (paramsRaw["file"] == undefined) 85 | paramsRaw["file"] = typeof frontmatter["default_file"] == "string" ? 86 | frontmatter["default_file"] : 87 | frontmatter["default_file"][0][0]; 88 | const file_raw = paramsRaw["file"].contains("[[") ? 89 | paramsRaw["file"].replace("[[", "").replace("]]", "") : 90 | paramsRaw["file"]; 91 | params.file = app.metadataCache.getFirstLinkpathDest(file_raw, "")?.path ?? file_raw; 92 | if (params.file == undefined) 93 | throw new Error(paramsRaw["file"] + ": No such file or directory"); 94 | 95 | // handle pages 96 | if (paramsRaw["page"] == undefined) 97 | paramsRaw["page"] = "0"; 98 | const pages = paramsRaw["page"].split(","); 99 | for (let i = 0; i < pages.length; i++) { 100 | if (pages[i].contains("-")) { 101 | const range = pages[i].split("-"); 102 | if (range.length != 2) 103 | throw new Error(pages[i] + ": Invalid page range"); 104 | params.page = params.page.concat(Array.from({ length: parseInt(range[1]) - parseInt(range[0]) + 1 }, (_, i) => parseInt(range[0]) + i)); 105 | } 106 | else { 107 | params.page.push(parseInt(pages[i])); 108 | } 109 | } 110 | 111 | // handle text layer 112 | if (paramsRaw["text"] == undefined) { 113 | if (frontmatter && "default_text" in frontmatter) 114 | params.text = frontmatter["default_text"]; 115 | } 116 | else { 117 | params.text = paramsRaw["text"].toLowerCase() === 'true'; 118 | } 119 | 120 | // handle scale 121 | if (paramsRaw["scale"] == undefined) { 122 | if (frontmatter && "default_scale" in frontmatter) 123 | params.scale = frontmatter["default_scale"]; 124 | } 125 | else { 126 | params.scale = parseFloat(paramsRaw["scale"]); 127 | } 128 | 129 | // handle dpi 130 | if (paramsRaw["dpi"] == undefined) { 131 | if (frontmatter && "default_dpi" in frontmatter) 132 | params.dpi = frontmatter["default_dpi"]; 133 | } 134 | else { 135 | params.dpi = parseInt(paramsRaw["dpi"]); 136 | } 137 | 138 | // handle rotation 139 | if (paramsRaw["rotat"] == undefined) { 140 | if (frontmatter && "default_rotat" in frontmatter) { 141 | params.rotat = frontmatter["default_rotat"]; 142 | } 143 | } 144 | else { 145 | params.rotat = parseInt(paramsRaw["rotat"]); 146 | } 147 | 148 | // handle rect 149 | if (paramsRaw["rect"] != undefined) { 150 | const rect = paramsRaw["rect"].split(","); 151 | const new_rect = []; 152 | new_rect.push(parseFloat(rect[0].trim().slice(2,-1))); 153 | new_rect.push(parseFloat(rect[1].trim().slice(2,-1))); 154 | new_rect.push(parseFloat(rect[2].trim().slice(2,-1))); 155 | new_rect.push(parseFloat(rect[3].trim().slice(2,-1))); 156 | params.rect = new_rect; 157 | } 158 | 159 | // console.log(params) 160 | return params; 161 | } 162 | 163 | async parseBetterPdfParameters(src: string, frontmatter: FrontMatterCache): Promise { 164 | // [[filename.pdf]] isn't valid json, add quotes to fix it 165 | let left_brackets = /("url":.*\]\])(?!")/g 166 | let right_brackets = /("url":[^",]*)(\[\[)/g 167 | 168 | src = src.replace(left_brackets, "$1\"") 169 | src = src.replace(right_brackets, "$1\"$2") 170 | 171 | const config = JSON.parse(src); 172 | const params: PDFBlockParameters = { 173 | file: "", 174 | page: [], 175 | text: this.plugin.settings.default_text, 176 | scale: 1, 177 | dpi: this.plugin.settings.default_dpi, 178 | rotat: 0, 179 | rect: [-1, -1, -1, -1], 180 | annot: "", 181 | note: "" 182 | }; 183 | 184 | // handle pages 185 | if(config["url"] != undefined) { 186 | params.file = config["url"]; 187 | let url_regex = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/ 188 | // if it is url, error as urls are not supported 189 | if (params.file.match(url_regex)) 190 | throw new Error(params.file + ": Urls are not supported"); 191 | 192 | params.file = params.file.replace("[[", "").replace("]]", ""); 193 | params.file = app.metadataCache.getFirstLinkpathDest(params.file, "")?.path as string; 194 | if (params.file == undefined) 195 | throw new Error(params.file + ": No such file or directory"); 196 | } 197 | 198 | // handle pages 199 | if(config["page"]){ 200 | if (typeof config["page"] === "number") 201 | params.page = [config["page"]]; 202 | else if (Array.isArray(config["page"])) 203 | { 204 | for (let i = 0; i < config["page"].length; i++) { 205 | if (typeof config["page"][i] === "number") 206 | params.page.push(config["page"][i]); 207 | else if (Array.isArray(config["page"][i])) 208 | { 209 | if (config["page"][i].length != 2) 210 | throw new Error(config["page"][i] + ": Invalid page range"); 211 | let start = config["page"][i][0]; 212 | let end = config["page"][i][1]; 213 | params.page = params.page.concat(Array.from({ length: end - start + 1 }, (_, i) => start + i)); 214 | } 215 | } 216 | } 217 | } 218 | 219 | // handle range 220 | if(config["range"]){ 221 | if (config["range"].length != 2) 222 | throw new Error(config["range"] + ": Invalid page range"); 223 | let start = config["range"][0]; 224 | let end = config["range"][1]; 225 | params.page = Array.from({ length: end - start + 1 }, (_, i) => start + i); 226 | } 227 | 228 | // handle scale 229 | if(config["scale"] != undefined) 230 | params.scale = config["scale"]; 231 | else if (frontmatter && "default_scale" in frontmatter) 232 | params.scale = frontmatter["default_scale"]; 233 | 234 | 235 | // handle rotation 236 | if(config["rotation"] != undefined) 237 | params.rotat = config["rotation"]; 238 | else if (frontmatter && "default_rotat" in frontmatter) 239 | params.rotat = frontmatter["default_rotat"]; 240 | 241 | // handle rect 242 | if(config["rect"] != undefined && config["rect"].length == 4) 243 | params.rect = [...config["rect"]]; 244 | 245 | return params; 246 | } 247 | } 248 | 249 | 250 | 251 | 252 | -------------------------------------------------------------------------------- /src/pdfblock/renderer.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownPreviewView, MarkdownRenderChild, loadPdfJs } from "obsidian"; 2 | import { PDFBlockParameters } from "./processor"; 3 | import { SlideNoteSettings } from "../settings"; 4 | import { FileCache } from "./cache"; 5 | 6 | export class PDFBlockRenderer extends MarkdownRenderChild { 7 | el: HTMLElement 8 | params: PDFBlockParameters 9 | sourcePath: string 10 | settings: SlideNoteSettings 11 | cache: FileCache 12 | public constructor( 13 | el: HTMLElement, 14 | params: PDFBlockParameters, 15 | sourcePath: string, 16 | settings: SlideNoteSettings, 17 | cache: FileCache 18 | ) { 19 | super(el); 20 | this.el = el; 21 | this.params = params; 22 | this.sourcePath = sourcePath; 23 | this.settings = settings; 24 | this.cache = cache; 25 | } 26 | 27 | onload() { 28 | this.init(); 29 | this.registerEvent( 30 | app.vault.on("modify", (file) => { 31 | if (file.path == this.params.file ) { 32 | this.cache.invalid(file.path); 33 | this.render(); 34 | } 35 | }) 36 | ) 37 | } 38 | 39 | async init() { 40 | const hook = this.el.createEl("div"); 41 | hook.addClass("slide-note-loading-hook"); 42 | const loader = hook.createEl("div"); 43 | loader.addClass("loader"); 44 | 45 | const pos = hook.getBoundingClientRect().bottom; 46 | 47 | if (pos != 0 && pos <= window.innerHeight) { 48 | this.render(); 49 | } 50 | else { 51 | const delay = window.setInterval( 52 | () => { 53 | clearInterval(delay); 54 | this.render(); 55 | }, 56 | (this.params.page[0] % 15 + 1) * 5000 57 | ) 58 | 59 | const renderCallBack = function () { 60 | if (hook.getBoundingClientRect().bottom != 0) { 61 | clearInterval(delay); 62 | this.render(); 63 | } 64 | } 65 | document.addEventListener("wheel", renderCallBack.bind(this)); 66 | document.addEventListener("touchmove", renderCallBack.bind(this)); 67 | } 68 | } 69 | 70 | async render() { 71 | this.el.innerHTML = ""; 72 | if (this.params !== null) { 73 | try { 74 | const buffer = await this.cache.get(this.params.file); 75 | const pdfjs = await loadPdfJs(); 76 | const pdfdocument = await pdfjs.getDocument(buffer).promise; 77 | 78 | if (this.params.page.includes(0)) { 79 | this.params.page = Array.from( 80 | {length: pdfdocument.numPages}, 81 | (_, i) => i + 1 82 | ); 83 | } 84 | 85 | for (const pageNumber of this.params.page) { 86 | 87 | const page = await pdfdocument.getPage(pageNumber); 88 | const host = this.el.createEl("div"); 89 | host.addClass("slide-note-pdfblock"); 90 | host.style.position = "relative"; 91 | 92 | const canvas = host.createEl("canvas"); 93 | canvas.addClass("slide-note-canvas-layer"); 94 | canvas.style.width = `${Math.floor(this.params.scale * 100)}%`; 95 | canvas.style.direction = "ltr"; 96 | const context = canvas.getContext("2d"); 97 | const zoom = this.params.dpi; 98 | const offsetX = this.params.rect[0] == -1 ? 0 : - this.params.rect[0] * page.view[2] * zoom; 99 | const offsetY = this.params.rect[1] == -1 ? 0 : - this.params.rect[1] * page.view[3] * zoom; 100 | const pageview = page.getViewport({ 101 | scale: zoom, 102 | rotation: this.params.rotat, 103 | offsetX: offsetX, 104 | offsetY: offsetY, 105 | }); 106 | 107 | const effectWidth = this.params.rect[0] == -1 ? 108 | pageview.width : Math.floor(this.params.rect[2] * page.view[2] * zoom); 109 | 110 | const effectHeight = this.params.rect[1] == -1 ? 111 | pageview.height : Math.floor(this.params.rect[3] * page.view[3] * zoom); 112 | canvas.width = effectWidth; 113 | canvas.height = effectHeight; 114 | 115 | const renderContext = { 116 | canvasContext: context, 117 | viewport: pageview, 118 | }; 119 | 120 | await page.render(renderContext).promise 121 | .then( 122 | () => { 123 | if (this.params.annot != "" && this.settings.allow_annotations) { 124 | try { 125 | const annots = new Function( 126 | "ctx", "zoom", "w", "h", 127 | ` 128 | function H(n) { 129 | if (n > 0 && n < 1) return n * zoom * h; 130 | else return n * zoom; 131 | } 132 | function W(n) { 133 | if (n > 0 && n < 1) return n * zoom * w; 134 | else return n * zoom; 135 | } 136 | ctx.font=\`\${25 * zoom}px Arial\` 137 | ${this.params.annot} 138 | ` 139 | ); 140 | annots(context, zoom, effectWidth / zoom, effectHeight / zoom); 141 | } catch (error) { 142 | throw new Error(`Annotation Failed: ${error}`); 143 | } 144 | 145 | } 146 | } 147 | ); 148 | 149 | const has_text = this.params.text && (this.params.rect[0] == -1) && (this.params.rotat == 0); 150 | const event_hover = has_text ? host.createEl("div") : canvas; 151 | event_hover.addEventListener("dblclick", (event) => { 152 | app.workspace.trigger("slidenote:dblclick", event, canvas); 153 | }); 154 | event_hover.addEventListener("mouseup", (event: MouseEvent) => { 155 | if (event.button == 0) { // left 156 | } else if (event.button == 1) { // wheel 157 | } else if (event.button == 2){ // right 158 | app.workspace.trigger("slidenote:rclick", event, this.el); 159 | }}); 160 | 161 | if (has_text) { 162 | await page.getTextContent() 163 | .then((textContent: any) => { 164 | function resize2Canvas() { 165 | text.style.setProperty('--scale-factor', (canvas.clientWidth/effectWidth*zoom).toString()); 166 | } 167 | 168 | const text = event_hover; 169 | text.addClass("slide-note-text-layer"); 170 | text.style.setProperty('--scale-factor', zoom.toString()); 171 | new pdfjs.TextLayer({ 172 | textContentSource: textContent, 173 | container: text, 174 | viewport: pageview 175 | }).render(); 176 | 177 | new ResizeObserver(resize2Canvas).observe(canvas) 178 | }); 179 | } 180 | } 181 | MarkdownPreviewView.renderMarkdown(this.params.note, this.el, this.sourcePath, this); 182 | } catch (error) { 183 | const p = this.el.createEl("p", {text: "[SlideNote] Render Error: " + error}); 184 | p.style.color = "red"; 185 | } 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/pdfcmd/generator.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Notice, MarkdownView, normalizePath } from 'obsidian'; 2 | import { getFileName } from "./utils"; 3 | 4 | export class SlideNoteCMDModal extends Modal { 5 | constructor(app: App) { 6 | super(app); 7 | } 8 | 9 | onOpen() { 10 | const container = this.contentEl.createEl("div"); 11 | container.createEl('h2', {text: "SlideNote Block Generator"}); 12 | container.createEl('p', {text: `Current Active File: ${this.app.workspace.getActiveFile()?.path}`}); 13 | 14 | const view = this.app.workspace.getActiveViewOfType(MarkdownView); 15 | if (view == null) { 16 | container.createEl('p', { 17 | text: "Please open a file first", 18 | attr: {style: "color: red;"} 19 | }); 20 | return; 21 | } 22 | 23 | const fileName = getFileName(view); 24 | if (fileName == undefined) { 25 | container.createEl('p', { 26 | text: "Please select a Slide Note block, or specify a `default_file` in the frontmatter", 27 | attr: {style: "color: red;"} 28 | }); 29 | return; 30 | } 31 | container.style.maxHeight = '1000px'; 32 | container.createEl('p', {text: `Target File: ${fileName}`}); 33 | const iframe = container.createEl('iframe'); 34 | iframe.src = this.app.vault.adapter.getResourcePath(normalizePath(fileName)); 35 | iframe.style.width = '100%'; 36 | iframe.style.height = '500px'; 37 | 38 | const generate = container.createEl('div'); 39 | generate.style.textAlign = "center"; 40 | const input = generate.createEl("input", {attr: {style: "margin-right: 8px;"}}) 41 | input.placeholder = "enter page range" 42 | generate.createEl('button', {text: "Generate"}).onclick = (e) => { 43 | let pages: any[] = [] 44 | const ranges = input.value.trim().split(","); 45 | console.log(ranges, input.value) 46 | ranges.forEach((r, i) => { 47 | if (r.contains("-")) { 48 | const range = r.split("-"); 49 | if (range.length != 2) 50 | throw new Notice(r + ": Invalid page range"); 51 | pages = pages.concat(Array.from({ length: parseInt(range[1]) - parseInt(range[0]) + 1 }, (_, i) => parseInt(range[0]) + i)); 52 | } 53 | else if (!isNaN(parseInt(r))) { 54 | pages.push(parseInt(r)); 55 | } 56 | }); 57 | pages.forEach((p, i) => { 58 | if (view) { 59 | const template = [ 60 | "", 61 | "```slide-note", 62 | `file: ${fileName}`, 63 | `page: ${p}`, 64 | "```", 65 | `^page${p}`, 66 | "", 67 | "", 68 | "---", 69 | "\n" 70 | ]; 71 | view.editor.replaceSelection(template.join('\n')); 72 | } 73 | }); 74 | this.close(); 75 | }; 76 | } 77 | 78 | onClose() { 79 | const {contentEl} = this; 80 | contentEl.empty(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/pdfcmd/open.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownView, Notice, Platform} from "obsidian"; 2 | import { exec } from "child_process"; 3 | import { getFileName } from "./utils"; 4 | 5 | export function openPDFwithLocal(view: MarkdownView) { 6 | try { 7 | const fileName = getFileName(view, true); 8 | 9 | if (fileName) { 10 | const openCommand = Platform.isWin ? 'start ""' : Platform.isLinux ? "xdg-open" : "open"; 11 | const cmd = `${openCommand} "${fileName}"` 12 | exec(cmd, (error, stdout, stderr) => { 13 | if (error) { 14 | throw new Error(`${error}, ${stdout}, ${stderr}`); 15 | } 16 | new Notice(`[SlideNote] Open ${fileName}`); 17 | }) 18 | 19 | } 20 | else { 21 | throw new Error("Unable to find a file name to open."); 22 | } 23 | } catch (e) { 24 | new Notice("[SlideNote] Failed: " + e.message); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/pdfcmd/utils.ts: -------------------------------------------------------------------------------- 1 | import {FrontMatterCache, MarkdownView, normalizePath, Platform} from "obsidian"; 2 | import { isAbsolute } from "path"; 3 | 4 | export function getFileName(view: MarkdownView, absolute = false): string | undefined { 5 | const selected: string = view.editor.somethingSelected() ? 6 | view.editor.getSelection() : view.editor.getLine(view.editor.getCursor("anchor").line); 7 | const filePath = this.app.workspace.getActiveFile()?.path; 8 | if (!filePath) 9 | return undefined; 10 | const frontmatter = app.metadataCache.getCache(filePath)?.frontmatter ?? {} as FrontMatterCache; 11 | const lines = selected.split("\n"); 12 | let fileName = frontmatter["default_file"]; 13 | for (let i = 0; i < lines.length; i++) { 14 | const words = lines[i].trim().split(/:(.*)/s); 15 | if (words[0] == "file") { 16 | if (words[1].length != 0) 17 | fileName = words[1].trim(); 18 | break; 19 | } 20 | } 21 | 22 | if (fileName) { 23 | if (absolute) { 24 | fileName = Platform.isDesktop && isAbsolute(fileName) ? fileName : 25 | normalizePath( 26 | // @ts-ignore 27 | app.vault.adapter.getBasePath() + "/" + 28 | app.metadataCache.getFirstLinkpathDest( 29 | fileName.replace("[[", "").replace("]]", ""), 30 | "")?.path 31 | ); 32 | } 33 | else { 34 | fileName = app.metadataCache.getFirstLinkpathDest( 35 | fileName.replace("[[", "").replace("]]", ""), 36 | "")?.path 37 | } 38 | } 39 | 40 | return fileName; 41 | } 42 | -------------------------------------------------------------------------------- /src/pdfview/canvas.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, Notice, WorkspaceLeaf } from "obsidian"; 2 | 3 | import { fabric } from "fabric"; 4 | 5 | export const PDFCANVAS_VIEW = "slidenote-pdfcanvas"; 6 | 7 | export class PDFCanvasView extends ItemView { 8 | constructor(leaf: WorkspaceLeaf) { 9 | super(leaf); 10 | } 11 | 12 | getViewType() { 13 | return PDFCANVAS_VIEW; 14 | } 15 | 16 | getDisplayText() { 17 | return "PDF Canvas"; 18 | } 19 | 20 | async onOpen() { 21 | const container = this.containerEl.children[1]; 22 | container.empty(); 23 | const content = container.createEl("div"); 24 | content.style.position = "relative"; 25 | content.createEl("h1").setText("SlideNote PDF DrawBoard"); 26 | 27 | const preview = content.createEl("div"); 28 | preview.createEl("h4").setText("Preview:"); 29 | const image = preview.createEl("img"); 30 | image.alt = "Click slide to start ..."; 31 | image.style.position = "absolute"; 32 | image.style.width = "100%"; 33 | 34 | function resize2Image() { 35 | drawboard.setHeight(image.innerHeight); 36 | drawboard.setWidth(image.innerWidth); 37 | drawboard.renderAll(); 38 | } 39 | 40 | const canvas = preview.createEl("canvas"); 41 | canvas.style.position = "absolute"; 42 | const drawboard = new fabric.Canvas(canvas, {isDrawingMode: false}); 43 | resize2Image(); 44 | 45 | this.createToolbox(container, drawboard); 46 | 47 | drawboard.freeDrawingBrush.color = "rgba(250,230,50,0.5)"; 48 | drawboard.freeDrawingBrush.width = 10; 49 | drawboard.on("selection:created", (event) => { 50 | const element = drawboard.getActiveObject(); 51 | if (element && element.type == "path") { 52 | element.setControlsVisibility({ 53 | bl: false, br: false, 54 | mb: false, ml: false, mr: false, mt: false, 55 | tl: false, tr: false, 56 | mtr: false 57 | }); 58 | } 59 | }); 60 | 61 | this.registerEvent(app.workspace.on("slidenote:newcanvas", (src) => { 62 | image.src = src; 63 | })); 64 | 65 | new ResizeObserver(resize2Image).observe(container); 66 | } 67 | 68 | async onClose() { 69 | // Nothing to clean up. 70 | } 71 | 72 | createToolbox(container: Element, drawboard: fabric.Canvas) { 73 | const option = container.createEl("div"); 74 | option.createEl("h4").setText("Toolbox:"); 75 | option.createEl("button", {text: "Select", attr: {style: "margin-right: 4px;"}}).addEventListener("click", () => { 76 | drawboard.isDrawingMode = false; 77 | }); 78 | option.createEl("button", {text: "Pen", attr: {style: "margin-right: 4px;"}}).addEventListener("click", () => { 79 | drawboard.isDrawingMode = true; 80 | }); 81 | option.createEl("button", {text: "Delete", attr: {style: "margin-right: 4px;"}}).addEventListener("click", () => { 82 | if(drawboard.getActiveObject()){ 83 | drawboard.getActiveObjects().forEach((element) => { 84 | drawboard.remove(element); 85 | }); 86 | drawboard.discardActiveObject(); 87 | } 88 | }); 89 | option.createEl("button", {text: "Text", attr: {style: "margin-right: 4px;"}}).addEventListener("click", () => { 90 | const textbox = new fabric.Textbox("add your text here",{ 91 | width : 400, 92 | fontSize: 30, 93 | fontFamily: "Arial" 94 | }); 95 | textbox.setControlsVisibility({ 96 | mt: false, mb: false, 97 | mtr: false 98 | }); 99 | drawboard.add(textbox); 100 | drawboard.setActiveObject(textbox); 101 | }); 102 | option.createEl("button", {text: "Line", attr: {style: "margin-right: 4px;"}}).addEventListener("click", () => { 103 | const line = new fabric.Line([50, 50, 150, 50], { 104 | stroke: "rgba(250,230,50,0.3)", 105 | strokeWidth: 10, 106 | }); 107 | line.setControlsVisibility({ 108 | bl: false, br: false, 109 | tl: false, tr: false, 110 | mtr: false 111 | }); 112 | drawboard.add(line); 113 | drawboard.setActiveObject(line); 114 | }); 115 | option.createEl("button", {text: "Rect", attr: {style: "margin-right: 4px;"}}).addEventListener("click", () => { 116 | const rectangle = new fabric.Rect({ 117 | width: 100, 118 | height: 100, 119 | stroke: "red", 120 | strokeWidth: 3, 121 | fill: "", 122 | strokeUniform: true, 123 | }); 124 | rectangle.setControlsVisibility({ mtr: false }); 125 | drawboard.add(rectangle); 126 | drawboard.setActiveObject(rectangle); 127 | }); 128 | const save = container.createEl("div"); 129 | save.createEl("h4", {text: "Export:"}) 130 | save.createEl("button", {text: "Save", attr: {style: "margin-right: 4px; margin-bottom: 8px;"}}).addEventListener("click", () => { 131 | const fractionDigit = 3; 132 | const canvasWidth = drawboard.width; 133 | const canvasHeight = drawboard.height; 134 | const elements = drawboard.toDatalessJSON().objects; 135 | const buffer: string[] = []; 136 | for (const element of elements) { 137 | switch (element.type) { 138 | case "rect": { 139 | const strokeWidth = (element.strokeWidth / canvasWidth).toFixed(fractionDigit); 140 | const left = (element.left / canvasWidth).toFixed(fractionDigit); 141 | const top= (element.top / canvasHeight).toFixed(fractionDigit); 142 | const width = (element.width * element.scaleX / canvasWidth).toFixed(fractionDigit); 143 | const height = (element.height * element.scaleY / canvasHeight).toFixed(fractionDigit); 144 | buffer.push("// rect"); 145 | buffer.push(`ctx.strokeStyle = "${element.stroke}";`); 146 | buffer.push(`ctx.lineWidth = W(${strokeWidth});`); 147 | buffer.push(`ctx.strokeRect(W(${left}), H(${top}), W(${width}), H(${height}));`); 148 | break; 149 | } 150 | case "textbox": { 151 | const left = (element.left / canvasWidth).toFixed(fractionDigit); 152 | const top= ((element.top + element.height * 100/116 ) / canvasHeight).toFixed(fractionDigit); 153 | const fontSize = (element.fontSize * element.scaleY / canvasHeight).toFixed(fractionDigit); 154 | const fontFamily = element.fontFamily; 155 | buffer.push("// textbox"); 156 | buffer.push(`ctx.fillStyle = "${element.fill}";`); 157 | buffer.push(`ctx.font=\`\${H(${fontSize})}px ${fontFamily}\`;`); 158 | buffer.push(`ctx.fillText("${element.text}", W(${left}), H(${top}));`); 159 | break; 160 | } 161 | case "line": { 162 | const left = (element.left / canvasWidth).toFixed(fractionDigit); 163 | const top= (element.top / canvasHeight).toFixed(fractionDigit); 164 | const width = (element.width * element.scaleX / canvasWidth).toFixed(fractionDigit); 165 | const height = (element.strokeWidth * element.scaleY / canvasHeight).toFixed(fractionDigit); 166 | buffer.push("// line"); 167 | buffer.push(`ctx.fillStyle = "${element.stroke}";`); 168 | buffer.push(`ctx.fillRect(W(${left}), H(${top}), W(${width}), H(${height}));`); 169 | break; 170 | } 171 | case "path": { 172 | const strokeWidth = (element.strokeWidth * element.scaleY / canvasWidth).toFixed(fractionDigit); 173 | buffer.push("// path"); 174 | buffer.push(`ctx.strokeStyle = "${element.stroke}";`); 175 | buffer.push(`ctx.lineWidth = W(${strokeWidth});`); 176 | buffer.push("ctx.beginPath();"); 177 | const path: string[] = []; 178 | for (const point of element.path) { 179 | const x = (point[1]/canvasWidth).toFixed(fractionDigit); 180 | const y = (point[2]/canvasHeight).toFixed(fractionDigit); 181 | path.push(`ctx.lineTo(W(${x}), H(${y}));`); 182 | } 183 | buffer.push(path.join(" ")); 184 | buffer.push("ctx.stroke();"); 185 | break; 186 | } 187 | default: 188 | new Notice("SlideNote: Unknown Canvas Type!"); 189 | } 190 | } 191 | output.setText(buffer.map((s) => ("@ " + s)).join("\n")); 192 | output.style.height = output.scrollHeight.toString() + "px"; 193 | }); 194 | save.createEl("p").setText(" Click the save button and copy generated annotations to your note.") 195 | const output = save.createEl("textarea", {attr: {style: "width: 100%"}}) 196 | output.setText("Click Save button first ..."); 197 | output.style.minHeight = "100px"; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting } from 'obsidian'; 2 | import SlideNotePlugin from './main'; 3 | 4 | export class SlideNoteSettings { 5 | allow_annotations: boolean = false; 6 | default_text: boolean = false; 7 | default_dpi: number = 1; 8 | support_better_pdf: boolean = false; 9 | } 10 | 11 | export class SlideNoteSettingsTab extends PluginSettingTab { 12 | plugin: SlideNotePlugin; 13 | 14 | constructor(app: App, plugin: SlideNotePlugin) { 15 | super(app, plugin); 16 | this.plugin = plugin; 17 | } 18 | 19 | display(): void { 20 | let { containerEl} = this; 21 | 22 | containerEl.empty(); 23 | 24 | containerEl.createEl('h1', { text: 'Slide Note Settings' }); 25 | 26 | new Setting(containerEl) 27 | .setName("Extract text by default") 28 | .setDesc("When turned on, you can select the text in the page. Can be overridden using the 'text' parameter and 'default_text' frontmatter.") 29 | .addToggle(toggle => toggle.setValue(this.plugin.settings.default_text) 30 | .onChange((value) => { 31 | this.plugin.settings.default_text = value; 32 | this.plugin.saveSettings(); 33 | })); 34 | 35 | new Setting(containerEl) 36 | .setName("Allow execute additional annotations") 37 | .setDesc("[WARNING] This feature may introduce security problem, only use on your personal annotations! When turned on, you can add text or draw on the slides.") 38 | .addToggle(toggle => toggle.setValue(this.plugin.settings.allow_annotations) 39 | .onChange((value) => { 40 | this.plugin.settings.allow_annotations = value; 41 | this.plugin.saveSettings(); 42 | })); 43 | 44 | new Setting(containerEl) 45 | .setName("Default DPI level") 46 | .setDesc("Increase the value to improve the resolution of the slide. Can be overridden using the 'dpi' parameter and 'default_dpi' frontmatter.") 47 | .addText(text => text.setValue(this.plugin.settings.default_dpi.toString()) 48 | .onChange((value) => { 49 | this.plugin.settings.default_dpi = parseInt(value); 50 | this.plugin.saveSettings(); 51 | })); 52 | 53 | new Setting(containerEl) 54 | .setName("Support Better PDF Code Blocks") 55 | .setDesc("Whether better-pdf code blocks are supported. If you previously used better-pdf, turn it on to use your legacy code blocks. Please note that you should start using slide-note code blocks as soon as possible.") 56 | .addToggle(toggle => toggle.setValue(this.plugin.settings.support_better_pdf) 57 | .onChange((value) => { 58 | this.plugin.settings.support_better_pdf = value; 59 | this.plugin.saveSettings(); 60 | } 61 | )); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .slide-note-loading-hook { 2 | display: flex; 3 | width: 100%; 4 | height: 300px; 5 | color: black; 6 | align-items: center; 7 | justify-content:center; 8 | border-radius: 8px; 9 | background-image: linear-gradient(to bottom right, #5761B2, #1FC5A8); 10 | } 11 | 12 | .loader { 13 | display: inline-block; 14 | width: 80px; 15 | height: 80px; 16 | } 17 | 18 | .loader:after { 19 | content: " "; 20 | display: block; 21 | width: 64px; 22 | height: 64px; 23 | margin: 8px; 24 | border-radius: 50%; 25 | border: 6px solid dodgerblue; 26 | border-color: #305293 transparent #CCEEFFFF transparent; 27 | animation: loader 1.2s linear infinite; 28 | } 29 | 30 | @keyframes loader { 31 | 0% { 32 | transform: rotate(0deg); 33 | } 34 | 100% { 35 | transform: rotate(360deg); 36 | } 37 | } 38 | 39 | .slide-note-canvas-layer { 40 | cursor: default; 41 | } 42 | 43 | .slide-note-text-layer { 44 | position: absolute; 45 | left: 0; 46 | top: 0; 47 | right: 0; 48 | bottom: 0; 49 | overflow: hidden; 50 | opacity: 0.8; 51 | line-height: 1.0; 52 | } 53 | 54 | .slide-note-text-layer > span { 55 | cursor: text; 56 | color: transparent; 57 | position: absolute; 58 | overflow: hidden; 59 | white-space: nowrap; 60 | transform-origin: 0% 0%; 61 | } 62 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.1": "0.0.1" 3 | } 4 | --------------------------------------------------------------------------------