├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── README.md ├── README_CN.md ├── esbuild.config.mjs ├── manifest.json ├── media └── screenshot.png ├── package.json ├── pnpm-lock.yaml ├── src ├── annotation │ ├── components │ │ └── toolbar │ │ │ ├── index.scss │ │ │ ├── index.tsx │ │ │ ├── signature.tsx │ │ │ └── stamp.tsx │ ├── const │ │ ├── definitions.tsx │ │ ├── icon.tsx │ │ └── pdfjs.d.ts │ ├── index.tsx │ ├── painter │ │ ├── const.ts │ │ ├── editor │ │ │ ├── editor.ts │ │ │ ├── editor_ellipse.ts │ │ │ ├── editor_free_hand.ts │ │ │ ├── editor_free_highlight.ts │ │ │ ├── editor_free_text.ts │ │ │ ├── editor_highlight.ts │ │ │ ├── editor_rectangle.ts │ │ │ ├── editor_signature.ts │ │ │ ├── editor_stamp.ts │ │ │ └── selector.ts │ │ ├── index.scss │ │ ├── index.ts │ │ ├── store.ts │ │ └── webSelection.ts │ ├── scss │ │ └── app.scss │ └── utils │ │ ├── json.ts │ │ └── utils.ts ├── main.ts └── types │ ├── obsidian.d.ts │ └── store.d.ts ├── tsconfig.json ├── version-bump.mjs ├── versions.json └── vite.config.cjs /.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 | npm node_modules 2 | build -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { 5 | "node": true 6 | }, 7 | "plugins": [ 8 | "@typescript-eslint" 9 | ], 10 | "extends": [ 11 | "eslint:recommended", 12 | "plugin:@typescript-eslint/eslint-recommended", 13 | "plugin:@typescript-eslint/recommended" 14 | ], 15 | "parserOptions": { 16 | "sourceType": "module" 17 | }, 18 | "rules": { 19 | "no-unused-vars": "off", 20 | "@typescript-eslint/no-unused-vars": [ 21 | "error", 22 | { 23 | "args": "none" 24 | } 25 | ], 26 | "@typescript-eslint/ban-ts-comment": "off", 27 | "no-prototype-builtins": "off", 28 | "@typescript-eslint/no-empty-function": "off", 29 | "@typescript-eslint/no-explicit-any": "off" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: [ "bug" ] 5 | body: 6 | - type: textarea 7 | id: bug-description 8 | attributes: 9 | label: Bug Description 10 | description: A clear and concise description of the bug. 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: screenshot 15 | attributes: 16 | label: Relevant Screenshot 17 | description: If applicable, add screenshots or a screen recording to help explain your problem. 18 | - type: textarea 19 | id: reproduction-steps 20 | attributes: 21 | label: To Reproduce 22 | description: Steps to reproduce the problem 23 | placeholder: | 24 | For example: 25 | 1. Go to '...' 26 | 2. Click on '...' 27 | 3. Scroll down to '...' 28 | - type: input 29 | id: obsi-version 30 | attributes: 31 | label: Obsidian Version 32 | description: You can find the version in the *About* Tab of the settings. 33 | placeholder: 0.13.19 34 | validations: 35 | required: true 36 | - type: checkboxes 37 | id: checklist 38 | attributes: 39 | label: Checklist 40 | options: 41 | - label: I updated to the latest version of the plugin. 42 | required: true 43 | 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea 3 | title: "Feature Request: " 4 | labels: [ "feature request" ] 5 | body: 6 | - type: textarea 7 | id: feature-requested 8 | attributes: 9 | label: Feature Requested 10 | description: A clear and concise description of the feature. 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: screenshot 15 | attributes: 16 | label: Relevant Screenshot 17 | description: If applicable, add screenshots or a screen recording to help explain the request. 18 | - type: checkboxes 19 | id: checklist 20 | attributes: 21 | label: Checklist 22 | options: 23 | - label: The feature would be useful to more users than just me. 24 | required: true 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | release: 5 | types: [ created ] 6 | 7 | env: 8 | PLUGIN_NAME: obsidian-pdf-annotator 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 16 19 | - name: Build 20 | id: build 21 | run: | 22 | npm install 23 | npm run build 24 | mkdir ${{ env.PLUGIN_NAME }} 25 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 26 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 27 | ls 28 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 29 | - name: Upload zip file 30 | id: upload-zip 31 | uses: actions/upload-release-asset@v1 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | with: 35 | upload_url: ${{ github.event.release.upload_url }} 36 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 37 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 38 | asset_content_type: application/zip 39 | 40 | - name: Upload main.js 41 | id: upload-main 42 | uses: actions/upload-release-asset@v1 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | with: 46 | upload_url: ${{ github.event.release.upload_url }} 47 | asset_path: ./main.js 48 | asset_name: main.js 49 | asset_content_type: text/javascript 50 | 51 | - name: Upload manifest.json 52 | id: upload-manifest 53 | uses: actions/upload-release-asset@v1 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | with: 57 | upload_url: ${{ github.event.release.upload_url }} 58 | asset_path: ./manifest.json 59 | asset_name: manifest.json 60 | asset_content_type: application/json 61 | 62 | - name: Upload styles.css 63 | id: upload-css 64 | uses: actions/upload-release-asset@v1 65 | env: 66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | with: 68 | upload_url: ${{ github.event.release.upload_url }} 69 | asset_path: ./styles.css 70 | asset_name: styles.css 71 | asset_content_type: text/css 72 | -------------------------------------------------------------------------------- /.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 | 24 | 25 | *.zip 26 | *.tar.gz 27 | *.rar 28 | 29 | styles.css 30 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian PDF Annotator 2 | 3 | [中文文档](./README_CN.md) 4 | 5 | Simple but powerful PDF annotator for Obsidian. 6 | 7 | ![screenshot](./media/screenshot.png) 8 | 9 | **Stay tuned when Obsidian merge the pdf.js 4.x version, I will support save the annotations into pdf file.** 10 | 11 | ## Features 12 | 13 | - Annotate PDFs in Obsidian; 14 | - Highlight text; 15 | - Add comments; 16 | - Add stamps; 17 | - Add shapes; 18 | - Circle; 19 | - Rectangle; 20 | - Add signatures; 21 | - Currently, it is not possible to save the highlight annotations to the PDF file itself (So I don't support save it into pdf yet), but the annotations are saved in the Obsidian vault. 22 | - Because Obsidian is still using 3.9.0 version of pdf.js, which does not support save highlight annotations to the PDF file itself. 23 | - And the highlight annotations is supported in 4.x stage of Pdf.js. 24 | - **All the annotations is follow the pdf.js annotations extension format, so it is possible to save it into pdf file in the future.** 25 | 26 | ## Installation 27 | 28 | ### BRAT 29 | 30 | [BRAT](https://github.com/TfTHacker/obsidian42-brat) (Beta Reviewer's Auto-update Tool) is a plugin that allows users to 31 | install Obsidian plugins directly from GitHub with automatic updates. 32 | 33 | via Commands: 34 | 35 | 1. Ensure BRAT is installed 36 | 2. Enter the command `BRAT: Plugins: Add a beta plugin for testing` 37 | 3. Enter `Quorafind/Obsidian-PDF-Annotator` 38 | 4. Click on Add Plugin 39 | 40 | via Settings: 41 | 42 | 1. Ensure BRAT is installed 43 | 2. Go to *Settings > BRAT > Beta Plugin List* 44 | 3. Click on Add Beta plugin 45 | 4. Enter `Quorafind/Obsidian-PDF-Annotator` 46 | 5. Click on Add Plugin 47 | 48 | ### Manual 49 | 50 | Option 1: 51 | 52 | 1. Go to [Releases](https://github.com/Quorafind/Obsidian-PDF-Annotator/releases) 53 | 2. Download the latest `Obsidian-PDF-Annotator-${version}.zip` 54 | 3. Extract its contents 55 | 4. Move the contents into /your-vault/.obsidian/plugins/obsidian-PDF-Annotator/ 56 | 5. Go to *Settings > Community plugins* 57 | 6. Enable PDF Annotator 58 | 59 | Option 2: 60 | 61 | 1. Go to [Releases](https://github.com/Quorafind/Obsidian-PDF-Annotator/releases) 62 | 2. Download the latest `main.js`, `styles.css` and `manifest.json` 63 | 3. Move the files into /your-vault/.obsidian/plugins/obsidian-PDF-Annotator/ 64 | 5. Go to *Settings > Community plugins* 65 | 6. Enable PDF Annotator 66 | 67 | 68 | ## Credits 69 | 70 | - Most features from [pdf.js annotations extension](https://github.com/Laomai-codefee/pdfjs-annotation-extension) 71 | - [pdf.js](https://mozilla.github.io/pdf.js/) 72 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | # Obsidian PDF 注释器 2 | 3 | [English](./README.md) 4 | 5 | 简单但功能强大的 Obsidian PDF 注释工具。 6 | 7 | ![screenshot](./media/screenshot.png) 8 | 9 | **当 Obsidian 合并 pdf.js 4.x 版本时,我将支持将注释保存到 PDF 文件中,或者保存成 Obsidian 所支持的注释。** 10 | 11 | ## 功能 12 | 13 | - 在 Obsidian 中对 PDF 进行注释; 14 | - 高亮文本; 15 | - 添加评论; 16 | - 添加印章; 17 | - 添加图形; 18 | - 圆形; 19 | - 矩形; 20 | - 添加签名; 21 | - 目前,无法将高亮注释保存到 PDF 文件本身(所以我还不支持将其保存到 PDF),但注释会保存在 Obsidian 的库中。 22 | - 因为 Obsidian 目前使用的是 pdf.js 3.9.0 版本,该版本不支持将高亮注释保存到 PDF 文件本身。 23 | - 高亮注释功能在 pdf.js 4.x 阶段得到了支持。 24 | - **所有的注释都遵循 pdf.js 注释扩展格式,因此将来有可能将其保存到 PDF 文件中。** 25 | 26 | ## 安装 27 | 28 | ### BRAT 29 | 30 | [BRAT](https://github.com/TfTHacker/obsidian42-brat) 是一个插件,允许用户直接从 GitHub 安装 Obsidian 插件并自动更新。 31 | 32 | 通过命令: 33 | 34 | 1. 确保已安装 BRAT 35 | 2. 输入命令 `BRAT: Plugins: Add a beta plugin for testing` 36 | 3. 输入 `Quorafind/Obsidian-PDF-Annotator` 37 | 4. 点击添加插件 38 | 39 | 通过设置: 40 | 41 | 1. 确保已安装 BRAT 42 | 2. 转到*设置 > BRAT > Beta 插件列表* 43 | 3. 点击添加 Beta 插件 44 | 4. 输入 `Quorafind/Obsidian-PDF-Annotator` 45 | 5. 点击添加插件 46 | 47 | ### 手动 48 | 49 | 选项 1: 50 | 51 | 1. 转到 [Releases](https://github.com/Quorafind/Obsidian-PDF-Annotator/releases) 52 | 2. 下载最新的 `Obsidian-PDF-Annotator-${version}.zip` 53 | 3. 解压内容 54 | 4. 将内容移动到 /your-vault/.obsidian/plugins/obsidian-PDF-Annotator/ 55 | 5. 转到*设置 > 社区插件* 56 | 6. 启用 PDF Annotator 57 | 58 | 选项 2: 59 | 60 | 1. 转到 [Releases](https://github.com/Quorafind/Obsidian-PDF-Annotator/releases) 61 | 2. 下载最新的 `main.js`、`styles.css` 和 `manifest.json` 62 | 3. 将文件移动到 /your-vault/.obsidian/plugins/obsidian-PDF-Annotator/ 63 | 5. 转到*设置 > 社区插件* 64 | 6. 启用 PDF Annotator 65 | 66 | ## 致谢 67 | 68 | - 大多数功能来自 [pdf.js annotations extension](https://github.com/Laomai-codefee/pdfjs-annotation-extension)。我只是做了很少的 Obsidian 集成。 69 | - [pdf.js](https://mozilla.github.io/pdf.js/) 70 | -------------------------------------------------------------------------------- /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 | esbuild.build({ 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 | watch: !prod, 37 | target: 'es2018', 38 | logLevel: "info", 39 | sourcemap: prod ? false : 'inline', 40 | treeShaking: true, 41 | outfile: 'main.js', 42 | }).catch(() => process.exit(1)); 43 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "pdf-annotator", 3 | "name": "Pdf Annotator", 4 | "version": "1.0.1", 5 | "minAppVersion": "1.4.0", 6 | "description": "Simple PDF annotator.", 7 | "author": "Boninall", 8 | "authorUrl": "https://github.com/Quorafind", 9 | "fundingUrl": { 10 | "Buy Me a Coffee": "https://www.buymeacoffee.com/boninall", 11 | "爱发电": "https://afdian.net/a/boninall", 12 | "支付宝": "https://cdn.jsdelivr.net/gh/Quorafind/.github@main/IMAGE/%E6%94%AF%E4%BB%98%E5%AE%9D%E4%BB%98%E6%AC%BE%E7%A0%81.jpg" 13 | }, 14 | "isDesktopOnly": false 15 | } -------------------------------------------------------------------------------- /media/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quorafind/Obsidian-PDF-Annotator/65f66281caed50efdd17c13b2a40a5713d39b85c/media/screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pdf-annotator", 3 | "version": "1.0.1", 4 | "description": "Simple PDF Annotator", 5 | "main": "main.js", 6 | "scripts": { 7 | "lint": "eslint . --ext .ts", 8 | "dev": "npm run lint && vite build --watch --mode development", 9 | "build": "vite build", 10 | "version": "node version-bump.mjs && git add manifest.json versions.json" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@ant-design/icons": "^5.3.7", 17 | "@babel/core": "^7.17.9", 18 | "@babel/preset-env": "^7.16.0", 19 | "@babel/preset-react": "^7.22.3", 20 | "@babel/preset-typescript": "^7.21.5", 21 | "@rollup/plugin-commonjs": "^25.0.7", 22 | "@rollup/plugin-node-resolve": "^15.2.1", 23 | "@rollup/plugin-replace": "^5.0.2", 24 | "@rollup/plugin-terser": "^0.4.3", 25 | "@types/node": "^16.11.6", 26 | "@types/react": "^18.2.9", 27 | "@types/react-dom": "^18.2.4", 28 | "@typescript-eslint/eslint-plugin": "^7.15.0", 29 | "@typescript-eslint/parser": "5.29.0", 30 | "@vitejs/plugin-react": "^4.3.1", 31 | "autoprefixer": "^10.4.0", 32 | "babel-loader": "^9.1.0", 33 | "builtin-modules": "3.3.0", 34 | "clean-webpack-plugin": "^4.0.0", 35 | "copy-webpack-plugin": "^11.0.0", 36 | "css-loader": "^6.5.1", 37 | "css-minimizer-webpack-plugin": "^4.0.0", 38 | "esbuild": "0.14.47", 39 | "eslint": "^8.13.0", 40 | "eslint-config-airbnb": "^19.0.0", 41 | "eslint-plugin-import": "^2.26.0", 42 | "eslint-plugin-jsx-a11y": "^6.5.1", 43 | "eslint-plugin-prettier": "^5.1.3", 44 | "eslint-plugin-react": "^7.27.0", 45 | "eslint-plugin-react-hooks": "^4.3.0", 46 | "eslint-plugin-simple-import-sort": "^12.1.0", 47 | "eslint-plugin-unused-imports": "^3.2.0", 48 | "file-loader": "^6.2.0", 49 | "image-minimizer-webpack-plugin": "^3.2.3", 50 | "imagemin": "^8.0.1", 51 | "monkey-around": "^3.0.0", 52 | "postcss": "^8.3.11", 53 | "postcss-loader": "^7.0.0", 54 | "prettier": "^3.3.2", 55 | "raw-loader": "^4.0.2", 56 | "sass": "^1.50.1", 57 | "sass-lint": "^1.13.1", 58 | "sass-loader": "^13.0.0", 59 | "style-loader": "^3.3.3", 60 | "terser-webpack-plugin": "^5.2.5", 61 | "tslib": "2.4.0", 62 | "typescript": "4.7.4", 63 | "typescript-eslint": "^7.15.0", 64 | "url-loader": "^4.1.1", 65 | "vite": "5.2.6" 66 | }, 67 | "dependencies": { 68 | "antd": "^5.15.3", 69 | "esbuild": "0.14.47", 70 | "konva": "^9.3.6", 71 | "obsidian": "latest", 72 | "react": "^18.2.0", 73 | "react-dom": "^18.2.0", 74 | "tslib": "2.4.0", 75 | "typescript": "4.7.4", 76 | "web-highlighter": "^0.7.4" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/annotation/components/toolbar/index.scss: -------------------------------------------------------------------------------- 1 | .CustomToolbar { 2 | width: 100%; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | 7 | --toolbar-border-color: var(--background-modifier-border); 8 | --button-hover-color: var(--background-modifier-hover); 9 | --main-color: var(--color-accent); 10 | 11 | .buttons { 12 | display: flex; 13 | padding: 0; 14 | margin: 0; 15 | list-style: none; 16 | gap: 3px; 17 | user-select: none; 18 | 19 | li { 20 | align-items: center; 21 | height: 100%; 22 | text-align: center; 23 | border: 1px solid transparent; 24 | border-radius: 3px; 25 | color: var(--main-color); 26 | transition: background-color 0.3s; 27 | 28 | .ant-upload-wrapper { 29 | line-height: normal; 30 | } 31 | 32 | .icon { 33 | font-size: 18px; 34 | padding: 5px 10px 3px 10px; 35 | border-bottom: 1px solid transparent; 36 | opacity: 0.8; 37 | } 38 | 39 | .name { 40 | font-size: 14px; 41 | padding: 1px 10px 2px 10px; 42 | } 43 | 44 | &:hover { 45 | background-color: var(--button-hover-color); 46 | } 47 | } 48 | 49 | li.selected { 50 | border: 1px solid var(--toolbar-border-color); 51 | background-color: var(--toolbar-border-color); 52 | } 53 | 54 | li.disabled { 55 | opacity: 0.5; 56 | } 57 | 58 | li.disabled:hover { 59 | background-color: transparent; 60 | } 61 | } 62 | 63 | .splitToolbarButtonSeparator { 64 | height: 51px; 65 | margin: 0 10px; 66 | } 67 | } 68 | 69 | .SignatureTool { 70 | margin: 0 auto; 71 | 72 | &-Container { 73 | background-color: #eee; 74 | border: 1px solid var(--background-modifier-border); 75 | position: relative; 76 | margin: 0 auto; 77 | 78 | .konvajs-content { 79 | z-index: 99; 80 | cursor: crosshair; 81 | } 82 | 83 | &::after { 84 | content: '签名处...'; 85 | font-size: 20px; 86 | z-index: 0; 87 | color: #ccc; 88 | position: absolute; 89 | top: 50%; 90 | left: 0; 91 | right: 0; 92 | transform: translateY(-50%); 93 | text-align: center; 94 | } 95 | } 96 | 97 | &-Toolbar { 98 | border: 1px solid var(--background-modifier-border); 99 | border-top: 0; 100 | display: flex; 101 | justify-content: space-between; 102 | margin: 0 auto; 103 | 104 | .colorPalette { 105 | display: flex; 106 | margin: 8px; 107 | 108 | .cell { 109 | cursor: pointer; 110 | width: 22px; 111 | height: 22px; 112 | margin-right: 10px; 113 | border-radius: 100px; 114 | display: flex; 115 | align-items: center; 116 | justify-content: center; 117 | border: 1px solid #fff; 118 | 119 | span { 120 | width: 12px; 121 | height: 12px; 122 | display: inline-block; 123 | border-radius: 100px; 124 | } 125 | } 126 | 127 | .active { 128 | border: 1px solid #bbb; 129 | } 130 | } 131 | 132 | .clear { 133 | padding: 8px; 134 | cursor: pointer; 135 | } 136 | 137 | .clear:hover { 138 | text-decoration: underline; 139 | } 140 | } 141 | } 142 | 143 | .SignaturePop { 144 | .ant-popover-inner { 145 | padding: 5px; 146 | background: var(--background-secondary) 147 | } 148 | 149 | ul, 150 | li { 151 | margin: 0; 152 | list-style: none; 153 | padding: 0; 154 | 155 | img:hover { 156 | background-color: #ccc; 157 | } 158 | } 159 | 160 | li { 161 | display: flex; 162 | margin: 5px; 163 | justify-content: center; 164 | align-items: center; 165 | 166 | span { 167 | margin-left: 5px; 168 | cursor: pointer; 169 | } 170 | } 171 | 172 | &-Toolbar { 173 | padding: 5px; 174 | 175 | .Create-Signature-Button { 176 | background: var(--background-primary); 177 | color: var(--text-normal); 178 | border-color: var(--background-modifier-border); 179 | 180 | &:hover { 181 | background: var(--background-secondary) !important; 182 | color: var(--text-normal) !important; 183 | border-color: var(--background-modifier-hover) !important; 184 | } 185 | } 186 | } 187 | } 188 | 189 | .ant-modal { 190 | color: var(--text-normal) !important; 191 | 192 | &-title { 193 | color: var(--text-normal) !important; 194 | background-color: var(--background-primary) !important; 195 | } 196 | 197 | &-content { 198 | background-color: var(--modal-background) !important; 199 | border-radius: var(--modal-radius) !important; 200 | border: var(--modal-border-width) solid var(--modal-border-color) !important; 201 | } 202 | 203 | &-close { 204 | color: var(--text-normal) !important; 205 | } 206 | 207 | &-footer { 208 | 209 | button { 210 | color: var(--text-normal) !important; 211 | background-color: var(--background-primary) !important; 212 | border-color: var(--background-primary) !important; 213 | 214 | &:hover { 215 | color: var(--text-normal) !important; 216 | background-color: var(--background-secondary) !important; 217 | border-color: var(--background-secondary) !important; 218 | } 219 | } 220 | } 221 | } 222 | 223 | .ant-popover { 224 | --antd-arrow-background-color: var(--background-secondary) !important; 225 | border-color: var(--background-modifier-border) !important; 226 | color: var(--text-normal) !important; 227 | 228 | &-inner { 229 | background-color: var(--background-secondary) !important; 230 | border-color: var(--background-modifier-border) !important; 231 | color: var(--text-normal) !important; 232 | } 233 | } 234 | 235 | .ant-input { 236 | &-outlined { 237 | background-color: var(--background-secondary) !important; 238 | border-color: var(--background-modifier-border) !important; 239 | color: var(--text-normal) !important; 240 | } 241 | } 242 | 243 | .ant-select { 244 | background-color: var(--background-secondary) !important; 245 | border-color: var(--background-modifier-border) !important; 246 | color: var(--text-normal) !important; 247 | 248 | &-selector { 249 | background-color: var(--background-secondary) !important; 250 | border-color: var(--background-modifier-border) !important; 251 | color: var(--text-normal) !important; 252 | } 253 | 254 | &-dropdown { 255 | background-color: var(--background-secondary) !important; 256 | border-color: var(--background-modifier-border) !important; 257 | color: var(--text-normal) !important; 258 | } 259 | 260 | &-item { 261 | background-color: var(--background-secondary) !important; 262 | border-color: var(--background-modifier-border) !important; 263 | color: var(--text-normal) !important; 264 | 265 | } 266 | } 267 | 268 | .ant-color-picker-presets-label { 269 | color: var(--text-normal) !important; 270 | 271 | } 272 | 273 | .ant-dropdown { 274 | border-color: var(--background-modifier-border) !important; 275 | color: var(--text-normal) !important; 276 | 277 | &-menu { 278 | background-color: var(--background-secondary) !important; 279 | border-color: var(--background-modifier-border) !important; 280 | color: var(--text-normal) !important; 281 | 282 | &-item { 283 | color: var(--text-normal) !important; 284 | 285 | &:hover { 286 | background-color: var(--background-modifier-hover) !important; 287 | } 288 | } 289 | } 290 | 291 | 292 | 293 | } 294 | 295 | .StampTool { 296 | position: relative; 297 | 298 | input { 299 | position: absolute; 300 | width: 100%; 301 | height: 100%; 302 | top: 0; 303 | left: 0; 304 | z-index: 1; 305 | opacity: 0; 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /src/annotation/components/toolbar/index.tsx: -------------------------------------------------------------------------------- 1 | import "./index.scss"; 2 | import React, { 3 | forwardRef, 4 | useEffect, 5 | useImperativeHandle, 6 | useState, 7 | } from "react"; 8 | import { 9 | annotationDefinitions, 10 | AnnotationType, 11 | DefaultColors, 12 | DefaultFontSize, 13 | DefaultSettings, 14 | IAnnotationType, 15 | } from "../../const/definitions"; 16 | import { ColorPicker, Dropdown } from "antd"; 17 | import { SignatureTool } from "./signature"; 18 | import { StampTool } from "./stamp"; 19 | import { FontSizeIcon, PaletteIcon } from "../../const/icon"; 20 | 21 | interface CustomToolbarProps { 22 | onChange: ( 23 | annotation: IAnnotationType | null, 24 | dataTransfer: string | null, 25 | ) => void; 26 | } 27 | 28 | export interface CustomToolbarRef { 29 | activeAnnotation(annotation: IAnnotationType): void; 30 | } 31 | 32 | /** 33 | * @description CustomToolbar 34 | */ 35 | const CustomToolbar = forwardRef( 36 | function CustomToolbar(props, ref) { 37 | const [currentAnnotation, setCurrentAnnotation] = 38 | useState(null); 39 | const [annotations, setAnnotations] = useState( 40 | annotationDefinitions, 41 | ); 42 | const [dataTransfer, setDataTransfer] = useState(null); 43 | 44 | useImperativeHandle(ref, () => ({ 45 | activeAnnotation, 46 | })); 47 | 48 | const activeAnnotation = (annotation: IAnnotationType) => { 49 | handleAnnotationClick(annotation); 50 | }; 51 | 52 | const selectedType = currentAnnotation?.type; 53 | 54 | const handleAnnotationClick = (annotation: IAnnotationType | null) => { 55 | setCurrentAnnotation(annotation); 56 | if (annotation?.type !== AnnotationType.SIGNATURE) { 57 | setDataTransfer(null); // 非签名类型时清空 dataTransfer 58 | } 59 | }; 60 | 61 | const buttons = annotations.map((annotation, index) => { 62 | const isSelected = annotation.type === selectedType; 63 | 64 | const commonProps = { 65 | className: isSelected ? "selected" : "", 66 | }; 67 | 68 | const handleAdd = (signatureDataUrl) => { 69 | setDataTransfer(signatureDataUrl); 70 | setCurrentAnnotation(annotation); 71 | }; 72 | 73 | switch (annotation.type) { 74 | case AnnotationType.STAMP: 75 | return ( 76 |
  • 77 | 81 |
  • 82 | ); 83 | 84 | case AnnotationType.SIGNATURE: 85 | return ( 86 |
  • 87 | 91 |
  • 92 | ); 93 | 94 | default: 95 | return ( 96 |
  • 100 | handleAnnotationClick( 101 | isSelected ? null : annotation, 102 | ) 103 | } 104 | > 105 |
    109 | {annotation.icon} 110 |
    111 | {/*
    {annotation.name}
    */} 112 |
  • 113 | ); 114 | } 115 | }); 116 | 117 | const isColorDisabled = !currentAnnotation?.style?.color; 118 | const isFontSizeDisabled = !currentAnnotation?.style?.fontSize; 119 | 120 | useEffect(() => { 121 | // 调用 onChange 并传递当前的 annotation 和 dataTransfer 122 | props.onChange(currentAnnotation, dataTransfer); 123 | }, [currentAnnotation, dataTransfer]); 124 | 125 | const handleColorChange = (color: string) => { 126 | if (!currentAnnotation) return; 127 | const updatedAnnotation = { 128 | ...currentAnnotation, 129 | style: { ...currentAnnotation.style, color }, 130 | }; 131 | const updatedAnnotations = annotations.map((annotation) => 132 | annotation.type === currentAnnotation.type 133 | ? updatedAnnotation 134 | : annotation, 135 | ); 136 | setAnnotations(updatedAnnotations); 137 | setCurrentAnnotation(updatedAnnotation); 138 | }; 139 | 140 | // 处理字体大小变化 141 | const handleFontSizeChange = (size: number) => { 142 | if (!currentAnnotation) return; 143 | const updatedAnnotation = { 144 | ...currentAnnotation, 145 | style: { ...currentAnnotation.style, fontSize: size }, 146 | }; 147 | const updatedAnnotations = annotations.map((annotation) => 148 | annotation.type === currentAnnotation.type 149 | ? updatedAnnotation 150 | : annotation, 151 | ); 152 | setAnnotations(updatedAnnotations); 153 | setCurrentAnnotation(updatedAnnotation); 154 | }; 155 | 156 | // 构建字体大小的菜单项 157 | const fontSizeMenuItems = DefaultFontSize.map((size) => ({ 158 | key: size.toString(), 159 | label: size, 160 | onClick: () => handleFontSizeChange(size), 161 | })); 162 | 163 | return ( 164 |
    165 |
      {buttons}
    166 |
    167 |
      168 | 177 | handleColorChange(color.toHexString()) 178 | } 179 | presets={[{ label: "标准色", colors: DefaultColors }]} 180 | > 181 |
    • 182 |
      183 | 188 |
      189 |
    • 190 |
      191 | 195 |
    • 196 |
      197 | 198 |
      199 |
    • 200 |
      201 |
    202 |
    203 | ); 204 | }, 205 | ); 206 | 207 | export { CustomToolbar }; 208 | -------------------------------------------------------------------------------- /src/annotation/components/toolbar/signature.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Modal, Popover } from "antd"; // 导入 antd 组件 2 | import { 3 | DefaultSignatureSetting, 4 | IAnnotationType, 5 | } from "../../const/definitions"; // 导入自定义类型和默认设置 6 | import "./index.scss"; // 导入样式 7 | import React, { useEffect, useRef, useState, useCallback } from "react"; // 导入 React 和相关 Hooks 8 | import Konva from "konva"; // 导入 Konva 库 9 | 10 | interface SignatureToolProps { 11 | annotation: IAnnotationType; // 签名工具的注释类型 12 | onAdd: (signatureDataUrl: string) => void; // 回调函数,当签名被添加时调用 13 | } 14 | 15 | const SignatureTool: React.FC = (props) => { 16 | const [isPopoverOpen, setIsPopoverOpen] = useState(false); // 控制 Popover 的显示状态 17 | const [isModalOpen, setIsModalOpen] = useState(false); // 控制 Modal 的显示状态 18 | const [currentColor, setCurrentColor] = useState( 19 | DefaultSignatureSetting.COLORS[0], 20 | ); // 当前选择的颜色 21 | const containerRef = useRef(null); // 引用签名容器的 DOM 节点 22 | const konvaStageRef = useRef(null); // 引用 Konva.Stage 实例 23 | const colorRef = useRef(currentColor); // 用于追踪 currentColor 的最新值 24 | 25 | const [isOKButtonDisabled, setIsOKButtonDisabled] = useState(true); // 初始状态下禁用 OK 按钮 26 | 27 | const [signatures, setSignatures] = useState([]); // 存储所有签名的数组 28 | 29 | // 更新 colorRef 当 currentColor 改变时 30 | useEffect(() => { 31 | colorRef.current = currentColor; 32 | }, [currentColor]); 33 | 34 | // 处理 Popover 打开的状态变化 35 | const handleOpenChange = (newOpen: boolean) => { 36 | setIsPopoverOpen(newOpen); 37 | }; 38 | 39 | // 处理签名的变化,将新的签名添加到签名列表中 40 | const handleSignaturesChange = (signature: string) => { 41 | setSignatures([...signatures, signature]); 42 | }; 43 | 44 | // 打开 Modal 窗口 45 | const openModal = () => { 46 | setIsModalOpen(true); 47 | }; 48 | 49 | // 处理签名的添加 50 | const handleAdd = (signatureDataUrl: string) => { 51 | props.onAdd(signatureDataUrl); 52 | handleOpenChange(false); 53 | }; 54 | 55 | // 点击确定按钮后的操作 56 | const handleOk = () => { 57 | // 获取当前绘制的签名数据 URL 58 | const signatureDataUrl = konvaStageRef.current?.toDataURL(); 59 | setIsModalOpen(false); 60 | if (signatureDataUrl) { 61 | handleSignaturesChange(signatureDataUrl); 62 | props.onAdd(signatureDataUrl); 63 | } 64 | }; 65 | 66 | // 点击取消按钮后的操作 67 | const handleCancel = () => { 68 | setIsModalOpen(false); 69 | }; 70 | 71 | // 处理 Modal 打开后的操作 72 | const afterOpen = useCallback((open: boolean) => { 73 | if (!open && konvaStageRef.current) { 74 | // 如果 Modal 关闭且 Konva 实例存在,则销毁 Konva.Stage 实例 75 | konvaStageRef.current.destroy(); 76 | konvaStageRef.current = null; 77 | return; 78 | } 79 | if (containerRef.current) { 80 | // 创建新的 Konva.Stage 实例并附加到容器中 81 | const stage = new Konva.Stage({ 82 | container: containerRef.current, // 容器节点 83 | width: DefaultSignatureSetting.WIDTH, // 宽度 84 | height: DefaultSignatureSetting.HEIGHT, // 高度 85 | }); 86 | const layer = new Konva.Layer(); // 创建新的图层 87 | stage.add(layer); // 将图层添加到舞台 88 | konvaStageRef.current = stage; // 将实例引用保存到 konvaStageRef 中 89 | 90 | let isPainting = false; // 标记当前是否正在绘制 91 | let lastLine: Konva.Line | null = null; // 保存最后绘制的线条 92 | 93 | // 开始绘制的事件处理函数 94 | const startDrawing = () => { 95 | isPainting = true; 96 | const pos = stage.getPointerPosition(); // 获取当前指针位置 97 | if (!pos) return; 98 | 99 | lastLine = new Konva.Line({ 100 | stroke: colorRef.current, // 使用最新的颜色 101 | strokeWidth: props.annotation.style.strokeWidth || 3, // 线条宽度 102 | globalCompositeOperation: "source-over", 103 | lineCap: "round", 104 | lineJoin: "round", 105 | points: [pos.x, pos.y], // 初始点 106 | }); 107 | layer.add(lastLine); // 将线条添加到图层 108 | }; 109 | 110 | // 停止绘制的事件处理函数 111 | const stopDrawing = () => { 112 | isPainting = false; 113 | lastLine = null; 114 | }; 115 | 116 | // 绘制过程中的事件处理函数 117 | const draw = ( 118 | e: Konva.KonvaEventObject, 119 | ) => { 120 | if (!isPainting) return; 121 | e.evt.preventDefault(); 122 | 123 | const pos = stage.getPointerPosition(); // 获取当前指针位置 124 | if (!pos || !lastLine) return; 125 | 126 | // 添加新的点到当前线条 127 | const newPoints = lastLine.points().concat([pos.x, pos.y]); 128 | lastLine.points(newPoints); 129 | setIsOKButtonDisabled(false); // 使 OK 按钮可用 130 | }; 131 | 132 | // 添加 Konva.Stage 的事件监听 133 | stage.on("mousedown touchstart", startDrawing); 134 | stage.on("mouseup touchend", stopDrawing); 135 | stage.on("mousemove touchmove", draw); 136 | 137 | // 在组件卸载或状态变化时清理 Konva.Stage 的事件和实例 138 | return () => { 139 | stage.off("mousedown touchstart", startDrawing); 140 | stage.off("mouseup touchend", stopDrawing); 141 | stage.off("mousemove touchmove", draw); 142 | stage.destroy(); 143 | konvaStageRef.current = null; 144 | }; 145 | } 146 | }, []); 147 | 148 | // 更新当前颜色的状态 149 | const changeColor = (color: string) => { 150 | setCurrentColor(color); 151 | // 获取所有的线条,并更新它们的颜色 152 | const allLine = 153 | konvaStageRef.current 154 | ?.getLayers()[0] 155 | .getChildren( 156 | (node: Konva.Node) => node.getClassName() === "Line", 157 | ) || []; 158 | allLine.forEach((line: Konva.Line) => { 159 | line.stroke(color); 160 | }); 161 | }; 162 | 163 | return ( 164 | <> 165 | 169 |
      170 | {signatures.map((signature, index) => { 171 | return ( 172 |
    • 173 | { 175 | handleAdd(signature); // 选择一个签名进行添加 176 | }} 177 | src={signature} 178 | height={40} 179 | /> 180 | {/* 181 | // 签名删除按钮,未启用 182 | */} 183 |
    • 184 | ); 185 | })} 186 |
    187 |
    188 | 195 |
    196 | 197 | } 198 | trigger="click" 199 | open={isPopoverOpen} 200 | onOpenChange={handleOpenChange} 201 | zIndex={9999} 202 | placement="bottom" 203 | arrow={false} 204 | > 205 | <> 206 |
    210 | {props.annotation.icon} 211 |
    212 | {/*
    {props.annotation.name}
    */} 213 | 214 |
    215 | 228 |
    229 |
    233 |
    240 |
    241 |
    245 |
    246 | {DefaultSignatureSetting.COLORS.map((color) => ( 247 |
    changeColor(color)} 249 | className={`cell ${color === currentColor ? "active" : ""}`} 250 | key={color} 251 | > 252 | 255 |
    256 | ))} 257 |
    258 |
    { 261 | if (konvaStageRef.current) { 262 | // 清空绘制内容 263 | konvaStageRef.current.clear(); 264 | konvaStageRef.current 265 | .getLayers() 266 | .forEach((layer) => 267 | layer.destroyChildren(), 268 | ); 269 | setIsOKButtonDisabled(true); // 禁用 OK 按钮 270 | } 271 | }} 272 | > 273 | 清空 274 |
    275 |
    276 |
    277 |
    278 | 279 | ); 280 | }; 281 | 282 | export { SignatureTool }; 283 | -------------------------------------------------------------------------------- /src/annotation/components/toolbar/stamp.tsx: -------------------------------------------------------------------------------- 1 | import { IAnnotationType } from "../../const/definitions"; // 导入自定义注释类型 2 | import { formatFileSize } from "../../utils/utils"; // 导入文件大小格式化工具 3 | import "./index.scss"; // 导入组件的样式 4 | import React from "react"; // 导入 React 5 | 6 | // 定义组件的 props 类型 7 | interface StampToolProps { 8 | annotation: IAnnotationType; // 注释类型 9 | onAdd: (signatureDataUrl: string) => void; // 当签名(印章)被添加时的回调函数 10 | } 11 | 12 | const StampTool: React.FC = (props) => { 13 | const maxSize: number = 1024 * 1024; // 最大文件大小为 1MB 14 | 15 | // 文件输入变化的事件处理函数 16 | const onInputFileChange = (event: React.ChangeEvent) => { 17 | const target = event.target as HTMLInputElement; // 获取事件目标(即文件输入) 18 | const files = target.files; // 获取文件列表 19 | if (files?.length) { 20 | const _file = files[0]; // 获取第一个文件 21 | if (_file.size > maxSize) { 22 | // 如果文件大小超过最大限制,显示提示并返回 23 | alert(`文件大小超出 ${formatFileSize(maxSize)} 限制`); 24 | return; 25 | } 26 | const reader = new FileReader(); // 创建文件读取器 27 | 28 | // 文件读取完成后的处理函数 29 | reader.onload = (e) => { 30 | if (typeof e.target?.result === "string") { 31 | target.value = ""; 32 | // 如果结果是字符串,调用 onAdd 回调函数 33 | props.onAdd(e.target.result); 34 | } 35 | }; 36 | reader.readAsDataURL(_file); // 以数据 URL 的形式读取文件 37 | } 38 | }; 39 | 40 | return ( 41 |
    42 | 48 |
    49 | {props.annotation.icon} 50 |
    51 |
    52 | ); 53 | }; 54 | 55 | export { StampTool }; // 导出 StampTool 组件 56 | -------------------------------------------------------------------------------- /src/annotation/const/definitions.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | EllipseIcon, 4 | FreehandIcon, 5 | FreeHighlightIcon, 6 | FreetextIcon, 7 | HighlightIcon, 8 | RectangleIcon, 9 | SelectIcon, 10 | SignatureIcon, 11 | StampIcon, 12 | StrikeoutIcon, 13 | UnderlineIcon, 14 | } from "./icon"; 15 | 16 | /** 17 | * 描述批注路径数据的接口 18 | */ 19 | interface IPathData { 20 | /** 21 | * 贝塞尔曲线的控制点数组 22 | * 通常是 4 个或 8 个数值表示控制点坐标 23 | */ 24 | bezier?: number[]; 25 | 26 | /** 27 | * 路径上的关键点数组 28 | * 表示绘制路径上的一些关键坐标点 29 | */ 30 | points?: number[]; 31 | } 32 | 33 | /** 34 | * PDF.js 批注存储接口定义 35 | * 描述了存储在 PDF.js 中的批注的各种属性 36 | */ 37 | export interface IPdfjsAnnotationStorage { 38 | /** 39 | * 批注的类型 40 | * 对应 PDF.js 内部定义的批注类型枚举 41 | */ 42 | annotationType: PdfjsAnnotationEditorType; 43 | 44 | /** 45 | * 批注颜色,使用 [R, G, B] 数组表示 46 | * 表示批注的颜色,范围从 0 到 255 47 | */ 48 | color?: number[]; 49 | 50 | /** 51 | * 批注的线条粗细 52 | * 表示批注线条的宽度 53 | */ 54 | thickness?: number; 55 | 56 | /** 57 | * 批注的不透明度 (0.0 - 1.0) 58 | * 表示批注的透明度,0.0 为完全透明,1.0 为完全不透明 59 | */ 60 | opacity?: number; 61 | 62 | /** 63 | * 描述批注的路径数据数组 64 | * 用于存储批注的路径信息,例如手绘的路径 65 | */ 66 | paths?: IPathData[]; 67 | 68 | /** 69 | * 批注所在的页面索引(从0开始) 70 | * 表示批注位于 PDF 的第几页 71 | */ 72 | pageIndex: number; 73 | 74 | /** 75 | * 批注的边界矩形,以 [x, y, width, height] 表示 76 | * 可选,因为并非所有批注类型都需要这个属性 77 | * 用于描述批注在页面上的位置和尺寸 78 | */ 79 | rect?: [number, number, number, number]; 80 | 81 | /** 82 | * 路径的四边形点数组 83 | * 用于表示四边形批注的各个顶点 84 | */ 85 | quadPoints?: number[]; 86 | 87 | /** 88 | * 批注的轮廓点数组 89 | * 用于描述批注的轮廓形状 90 | */ 91 | outlines?: number[][]; 92 | 93 | /** 94 | * 批注的旋转角度(度数) 95 | * 可选,因为并非所有批注类型都需要旋转属性 96 | */ 97 | rotation?: number; 98 | 99 | /** 100 | * 图片标识 101 | * 用于存储签名或图章的标识符 102 | */ 103 | bitmapId?: string; 104 | 105 | /** 106 | * 图片数据 107 | * 用于存储签名或图章的图像数据 108 | */ 109 | bitmap?: ImageBitmap; 110 | 111 | /** 112 | * 图片名称 113 | * 用于存储图章的名称 114 | */ 115 | bitmapName?: string; 116 | 117 | /** 118 | * 图片 URL 119 | * 用于存储图章的 URL 地址 120 | */ 121 | bitmapUrl?: string; 122 | 123 | /** 124 | * 是否为 SVG 图像 125 | * 用于指示图像是否为 SVG 格式 126 | */ 127 | isSvg?: boolean; 128 | 129 | /** 130 | * 结构树父节点 ID 131 | * 用于标识批注在结构树中的父节点 132 | */ 133 | structTreeParentId?: string; 134 | } 135 | 136 | // PDF.js 自带的批注编辑器类型枚举 137 | // 用于定义 PDF.js 支持的批注类型 138 | export enum PdfjsAnnotationEditorType { 139 | DISABLE = -1, // 禁用批注编辑器 140 | NONE = 0, // 没有批注类型 141 | FREETEXT = 3, // 自由文本批注 142 | HIGHLIGHT = 9, // 高亮批注 143 | STAMP = 13, // 盖章批注 144 | INK = 15, // 墨迹(自由绘制)批注 145 | } 146 | 147 | // 自定义的批注类型枚举 148 | // 用于定义在应用中使用的批注类型 149 | export enum AnnotationType { 150 | NONE = -1, // 没有批注类型 151 | SELECT = 0, // 选择批注 152 | HIGHLIGHT = 1, // 高亮批注 153 | STRIKEOUT = 2, // 删除线批注 154 | UNDERLINE = 3, // 下划线批注 155 | FREETEXT = 4, // 自由文本批注 156 | RECTANGLE = 5, // 矩形批注 157 | ELLIPSE = 6, // 圆形批注 158 | FREEHAND = 7, // 自由绘制批注 159 | FREE_HIGHLIGHT = 8, // 自由高亮批注 160 | SIGNATURE = 9, // 签名批注 161 | STAMP = 10, // 盖章批注 162 | } 163 | 164 | // 配置默认颜色 165 | // 提供一组默认的颜色选项 166 | export const DefaultColors = [ 167 | "#FF0000", 168 | "#FFBE00", 169 | "#CC0000", 170 | , 171 | "#FFFF00", 172 | "#83D33C", 173 | "#00B445", 174 | "#00B2F4", 175 | "#0071C4", 176 | "#001F63", 177 | "#7828A4", 178 | ]; 179 | 180 | // 配置默认字体大小 181 | // 提供一组默认的字体大小选项 182 | export const DefaultFontSize = [14, 16, 18, 20, 22, 24]; 183 | 184 | // 配置默认的签名设置 185 | export const DefaultSignatureSetting = { 186 | COLORS: ["#000000", "#FF0000"], // 签名的默认颜色选项 187 | WIDTH: 128 * 3, // 签名框的宽度 188 | HEIGHT: 128, // 签名框的高度 189 | }; 190 | 191 | // 配置默认的选择设置 192 | // 提供默认的选择工具的颜色和线条宽度 193 | export const DefaultChooseSetting = { 194 | COLOR: "#0071C4", // 选择工具的颜色 195 | STROKEWIDTH: 2, // 选择工具的线条宽度 196 | }; 197 | 198 | // 默认配置对象 199 | // 提供一组默认的批注设置 200 | export const DefaultSettings = { 201 | COLOR: DefaultColors[0], // 默认颜色 202 | FONT_SIZE: DefaultFontSize[0], // 默认字体大小 203 | HIGHLIGHT_COLOR: DefaultColors[1], // 默认高亮颜色 204 | STRIKEOUT_COLOR: DefaultColors[0], // 默认删除线颜色 205 | UNDERLINE_COLOR: DefaultColors[6], // 默认下划线颜色 206 | STROKE_WIDTH: 2, // 默认线条宽度 207 | OPACITY: 1, // 默认不透明度 208 | MAX_CURSOR_SIZE: 96, // 鼠标指针图片最大宽度/高度 209 | }; 210 | 211 | // 定义批注类型的接口 212 | // 用于描述应用中支持的批注类型 213 | export interface IAnnotationType { 214 | name: string; // 批注的名称 215 | type: AnnotationType; // 自定义的批注类型 216 | pdfjsType: PdfjsAnnotationEditorType; // 对应的 Pdfjs 批注类型 217 | isOnce: boolean; // 是否只绘制一次 218 | readonly: boolean; // 绘制的图形是否可以调整修改 219 | icon?: React.JSX.Element; // 可选的图标,用于表示批注类型 220 | style?: IAnnotationStyle; // 可选的样式配置对象 221 | } 222 | 223 | // 批注的样式配置接口 224 | // 用于描述批注的外观样式 225 | export interface IAnnotationStyle { 226 | color?: string; // 线条、文本、填充的颜色 227 | fontSize?: number; // 字体大小 228 | opacity?: number; // 透明度 229 | strokeWidth?: number; // 边框宽度 230 | } 231 | 232 | // 批注的内容接口 233 | // 用于描述批注的文本或图像内容 234 | export interface IAnnotationContent { 235 | text?: string; // 批注的文本内容 236 | image?: string; // 批注的图像内容 237 | } 238 | 239 | // 批注存储接口 240 | // 用于描述存储在应用中的批注信息 241 | export interface IAnnotationStore { 242 | id: string; // 批注的唯一标识符 243 | pageNumber: number; // 批注所在的页面编号 244 | konvaString: string; // 批注的 Konva 相关数据(Konva 是一个 HTML5 2D 绘图库) 245 | content?: IAnnotationContent; // 可选的批注内容 246 | type: AnnotationType; // 批注的类型 247 | readonly: boolean; // 批注是否只读 248 | pdfjsAnnotationStorage: IPdfjsAnnotationStorage; // 对应的 PDF.js 批注存储数据 249 | time: number; // 批注的创建时间(时间戳) 250 | } 251 | 252 | // 批注类型定义数组 253 | // 用于描述所有支持的批注类型及其属性 254 | export const annotationDefinitions: IAnnotationType[] = [ 255 | { 256 | name: "选择", // 批注名称 257 | type: AnnotationType.SELECT, // 批注类型 258 | pdfjsType: PdfjsAnnotationEditorType.NONE, // 对应的 PDF.js 批注类型 259 | isOnce: false, // 是否只绘制一次 260 | readonly: true, // 是否只读 261 | icon: , // 图标 262 | }, 263 | { 264 | name: "高亮", 265 | type: AnnotationType.HIGHLIGHT, 266 | pdfjsType: PdfjsAnnotationEditorType.HIGHLIGHT, 267 | isOnce: false, 268 | readonly: true, 269 | icon: , 270 | style: { 271 | color: DefaultSettings.HIGHLIGHT_COLOR, // 默认高亮颜色 272 | opacity: 0.2, // 默认透明度 273 | }, 274 | }, 275 | { 276 | name: "删除线", 277 | type: AnnotationType.STRIKEOUT, 278 | pdfjsType: PdfjsAnnotationEditorType.HIGHLIGHT, 279 | isOnce: false, 280 | readonly: true, 281 | icon: , 282 | style: { 283 | color: DefaultSettings.STRIKEOUT_COLOR, // 默认删除线颜色 284 | opacity: DefaultSettings.OPACITY, // 默认透明度 285 | }, 286 | }, 287 | { 288 | name: "下划线", 289 | type: AnnotationType.UNDERLINE, 290 | pdfjsType: PdfjsAnnotationEditorType.HIGHLIGHT, 291 | isOnce: false, 292 | readonly: true, 293 | icon: , 294 | style: { 295 | color: DefaultSettings.UNDERLINE_COLOR, // 默认下划线颜色 296 | opacity: DefaultSettings.OPACITY, // 默认透明度 297 | }, 298 | }, 299 | { 300 | name: "文字", 301 | type: AnnotationType.FREETEXT, 302 | pdfjsType: PdfjsAnnotationEditorType.STAMP, 303 | isOnce: true, 304 | readonly: false, 305 | icon: , 306 | style: { 307 | color: DefaultSettings.COLOR, // 默认文字颜色 308 | fontSize: DefaultSettings.FONT_SIZE, // 默认字体大小 309 | opacity: DefaultSettings.OPACITY, // 默认透明度 310 | }, 311 | }, 312 | { 313 | name: "矩形", 314 | type: AnnotationType.RECTANGLE, 315 | pdfjsType: PdfjsAnnotationEditorType.INK, 316 | isOnce: false, 317 | readonly: false, 318 | icon: , 319 | style: { 320 | color: DefaultSettings.COLOR, // 默认矩形颜色 321 | strokeWidth: DefaultSettings.STROKE_WIDTH, // 默认线条宽度 322 | opacity: DefaultSettings.OPACITY, // 默认透明度 323 | }, 324 | }, 325 | { 326 | name: "圆形", 327 | type: AnnotationType.ELLIPSE, 328 | pdfjsType: PdfjsAnnotationEditorType.INK, 329 | isOnce: false, 330 | readonly: false, 331 | icon: , 332 | style: { 333 | color: DefaultSettings.COLOR, // 默认圆形颜色 334 | strokeWidth: DefaultSettings.STROKE_WIDTH, // 默认线条宽度 335 | opacity: DefaultSettings.OPACITY, // 默认透明度 336 | }, 337 | }, 338 | { 339 | name: "自由绘制", 340 | type: AnnotationType.FREEHAND, 341 | pdfjsType: PdfjsAnnotationEditorType.INK, 342 | isOnce: false, 343 | readonly: false, 344 | icon: , 345 | style: { 346 | color: DefaultSettings.COLOR, // 默认自由绘制颜色 347 | strokeWidth: DefaultSettings.STROKE_WIDTH, // 默认线条宽度 348 | }, 349 | }, 350 | { 351 | name: "自由高亮", 352 | type: AnnotationType.FREE_HIGHLIGHT, 353 | pdfjsType: PdfjsAnnotationEditorType.INK, 354 | isOnce: false, 355 | readonly: false, 356 | icon: , 357 | style: { 358 | color: DefaultSettings.COLOR, // 默认自由高亮颜色 359 | strokeWidth: 10, // 默认线条宽度 360 | opacity: 0.5, // 默认透明度 361 | }, 362 | }, 363 | { 364 | name: "签名", 365 | type: AnnotationType.SIGNATURE, 366 | pdfjsType: PdfjsAnnotationEditorType.STAMP, 367 | isOnce: true, 368 | readonly: false, 369 | icon: , 370 | style: { 371 | strokeWidth: 3, // 默认线条宽度 372 | opacity: 1, // 默认不透明度 373 | }, 374 | }, 375 | { 376 | name: "盖章", 377 | type: AnnotationType.STAMP, 378 | pdfjsType: PdfjsAnnotationEditorType.STAMP, 379 | isOnce: true, 380 | readonly: false, 381 | icon: , 382 | }, 383 | ]; 384 | -------------------------------------------------------------------------------- /src/annotation/const/icon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Icon from '@ant-design/icons' 3 | import type { GetProps } from 'antd' 4 | 5 | type CustomIconComponentProps = GetProps 6 | 7 | const SelectSvg = () => ( 8 | 9 | 13 | 14 | ) 15 | 16 | const HighlightSvg = () => ( 17 | 18 | 22 | 23 | ) 24 | 25 | const StrikeoutSvg = () => ( 26 | 27 | 31 | 32 | ) 33 | 34 | const UnderlineSvg = () => ( 35 | 36 | 40 | 41 | ) 42 | 43 | const FreetextSvg = () => ( 44 | 45 | 49 | 50 | ) 51 | 52 | const RectangleSvg = () => ( 53 | 54 | 58 | 59 | ) 60 | 61 | const EllipseSvg = () => ( 62 | 63 | 64 | 65 | ) 66 | 67 | const FreehandSvg = () => ( 68 | 69 | 73 | 74 | ) 75 | 76 | const FreeHighlightSvg = () => ( 77 | 78 | 82 | 83 | ) 84 | 85 | const SignatureSvg = () => ( 86 | 87 | 91 | 92 | ) 93 | 94 | const StampSvg = () => ( 95 | 96 | 100 | 101 | ) 102 | 103 | const PaletteSvg = () => ( 104 | 105 | 109 | 110 | ) 111 | 112 | const FontSizeSvg = () => ( 113 | 114 | 118 | 119 | ) 120 | 121 | const SelectIcon = (props: Partial) => 122 | 123 | const HighlightIcon = (props: Partial) => 124 | 125 | const StrikeoutIcon = (props: Partial) => 126 | 127 | const UnderlineIcon = (props: Partial) => 128 | 129 | const FreetextIcon = (props: Partial) => 130 | 131 | const RectangleIcon = (props: Partial) => 132 | 133 | const EllipseIcon = (props: Partial) => 134 | 135 | const FreehandIcon = (props: Partial) => 136 | 137 | const FreeHighlightIcon = (props: Partial) => 138 | 139 | const SignatureIcon = (props: Partial) => 140 | 141 | const StampIcon = (props: Partial) => 142 | 143 | const PaletteIcon = (props: Partial) => 144 | 145 | const FontSizeIcon = (props: Partial) => 146 | 147 | export { 148 | SelectIcon, 149 | HighlightIcon, 150 | StrikeoutIcon, 151 | UnderlineIcon, 152 | FreetextIcon, 153 | RectangleIcon, 154 | EllipseIcon, 155 | FreehandIcon, 156 | FreeHighlightIcon, 157 | SignatureIcon, 158 | StampIcon, 159 | PaletteIcon, 160 | FontSizeIcon 161 | } 162 | -------------------------------------------------------------------------------- /src/annotation/const/pdfjs.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'pdfjs' { 2 | /** 3 | 在 PDF.js 中,PDFViewerApplication 是一个重要的对象,它代表了整个 PDF 查看器的应用程序。 4 | 它是 PDF.js 中核心的控制器,负责管理 PDF 文档的加载、渲染、交互等方面的逻辑。 5 | PDFViewerApplication 对象通常包含了许多方法和属性,用于控制 PDF 文档的各种功能。 6 | 一般来说,你可以通过 PDFViewerApplication 对象来执行诸如加载 PDF 文档、跳转页面、放大缩小、搜索文本等操作。 7 | 通常情况下,你可以通过全局变量 PDFViewerApplication 来访问 PDFViewerApplication 对象,然后调用其相应的方法和属性来执行所需的操作。 8 | */ 9 | export interface PDFViewerApplication { 10 | appConfig: AppConfig 11 | page: number 12 | eventBus: EventBus 13 | pdfViewer: PDFPageView 14 | [key: string]: any // 其他未知属性的类型定义 15 | } 16 | /** 17 | 在 PDF.js 中,Sidebar 是一个对象,用于管理 PDF 查看器的侧边栏。 18 | 侧边栏通常包含了一些用于导航和查看 PDF 文档的辅助功能,例如缩略图、大纲、附件等。 19 | Sidebar 对象包含了一些属性和方法,用于控制侧边栏的显示和行为。 20 | 通过 Sidebar 对象,你可以管理侧边栏的打开/关闭状态,以及其中各个功能组件的显示和交互。 21 | 以下是一些可能包含在 Sidebar 对象中的属性和方法: 22 | outerContainer: 指定侧边栏的外部容器元素。 23 | sidebarContainer: 指定侧边栏内容的容器元素。 24 | toggleButton: 指定用于切换侧边栏显示/隐藏状态的按钮。 25 | resizer: 指定用于调整侧边栏宽度的调整器。 26 | thumbnailButton: 指定用于显示缩略图的按钮。 27 | outlineButton: 指定用于显示大纲的按钮。 28 | attachmentsButton: 指定用于显示附件的按钮。 29 | layersButton: 指定用于显示图层的按钮。 30 | thumbnailView: 控制缩略图视图的显示和交互。 31 | outlineView: 控制大纲视图的显示和交互。 32 | attachmentsView: 控制附件视图的显示和交互。 33 | layersView: 控制图层视图的显示和交互。 34 | currentOutlineItemButton: 指定当前大纲项的按钮。 35 | 通过使用 Sidebar 对象,你可以对 PDF 查看器的侧边栏进行定制和控制,以实现更好的用户体验和交互效果。 36 | */ 37 | export interface Sidebar { 38 | outerContainer: HTMLDivElement 39 | sidebarContainer: HTMLDivElement 40 | [key: string]: any // 其他未知属性的类型定义 41 | } 42 | /** 43 | 在 PDF.js 中,工具栏(Toolbar)是用于控制 PDF 查看器各种功能的界面元素集合。它通常位于 PDF 查看器的顶部或底部,并包含了一系列按钮、下拉菜单和输入框等组件,用于执行各种操作,如跳转页面、放大缩小、搜索文本等。 44 | PDF.js 中的工具栏(Toolbar)包含了一些重要的组件和功能,例如: 45 | 跳转到特定页码的输入框和按钮。 46 | 缩放选项,包括放大、缩小以及自定义缩放比例。 47 | 查找文本的输入框和按钮。 48 | 打印和下载 PDF 文档的按钮。 49 | PDF 文档注释工具、标记工具等。 50 | 以下是可能包含在 PDF.js 工具栏中的一些组件和功能: 51 | container: 工具栏的容器元素。 52 | numPages: 显示 PDF 文档的总页数。 53 | pageNumber: 显示当前页码,并提供跳转到特定页码的输入框。 54 | scaleSelect: 放大缩小选项,包括预定义的缩放比例和自定义缩放比例输入框。 55 | viewFind: 查找文本的输入框和按钮。 56 | print: 打印 PDF 文档的按钮。 57 | download: 下载 PDF 文档的按钮。 58 | 通过使用工具栏(Toolbar),用户可以方便地控制 PDF 查看器的各种功能,从而实现更加舒适和高效的 PDF 文档浏览体验。 59 | */ 60 | export interface Toolbar { 61 | container: HTMLDivElement 62 | [key: string]: any // 其他未知属性的类型定义 63 | } 64 | /** 65 | 在 PDF.js 中,AppConfig 是一个对象,用于配置 PDF 查看器应用程序的各种设置和选项。 66 | 它通常包含了一些用于定制 PDF 查看器外观和行为的属性。 67 | AppConfig 对象通常在 PDF 查看器应用程序初始化时被使用,用于指定各种配置选项,以便根据实际需求来定制 PDF 查看器的行为。 68 | 以下是一些可能包含在 AppConfig 对象中的配置选项: 69 | viewerContainer: 指定 PDF 查看器的容器元素。 70 | toolbar: 配置 PDF 查看器的工具栏,包括按钮、菜单项等。 71 | secondaryToolbar: 配置 PDF 查看器的次要工具栏,通常包含一些不常用的功能按钮。 72 | sidebar: 配置 PDF 查看器的侧边栏,包括缩略图、大纲等。 73 | findBar: 配置 PDF 查看器的查找栏,用于搜索文本。 74 | passwordOverlay: 配置 PDF 查看器的密码输入框,用于打开受密码保护的 PDF 文档。 75 | documentProperties: 配置 PDF 查看器的文档属性对话框,用于显示文档的元数据信息。 76 | debuggerScriptPath: 配置 PDF 查看器的调试器脚本路径。 77 | 通过配置 AppConfig 对象,你可以自定义 PDF 查看器的外观和行为,以满足特定的需求。这些配置选项通常可以在初始化 PDF 查看器应用程序时进行指定。 78 | */ 79 | export interface AppConfig { 80 | sidebar: Sidebar 81 | toolbar: Toolbar 82 | viewerContainer: HTMLDivElement 83 | [key: string]: any // 其他未知属性的类型定义 84 | } 85 | /** 86 | * PDFPageView 是表示 PDF 页面视图的对象。 87 | * 它用于管理和渲染 PDF 文档的单个页面。 88 | * PDFPageView 对象包含了页面的各种信息,例如页面的尺寸、内容、注释等,并提供了方法来管理和操作页面,如渲染页面内容、添加注释等。 89 | */ 90 | export interface PDFPageView { 91 | id: number 92 | div: HTMLDivElement 93 | viewport: PageViewport 94 | [key: string]: any // 其他未知属性的类型定义 95 | } 96 | /** 97 | 在 PDF.js 中,PageViewport 是一个表示 PDF 页面视口的对象。它描述了 PDF 页面在浏览器窗口中的位置、尺寸和缩放比例等信息。 98 | PageViewport 对象通常由 PDF.js 提供的渲染引擎计算得出,用于在将 PDF 页面渲染到屏幕上时确定页面的适当位置和大小。 99 | 以下是 PageViewport 可能包含的一些属性: 100 | viewBox: 表示 PDF 页面的边界框,通常是一个包含四个数字的数组,表示页面的左上角和右下角的坐标。 101 | scale: 表示页面的缩放比例。 102 | rotation: 表示页面的旋转角度。 103 | offsetX 和 offsetY: 表示页面在 PDF 文档中的偏移量。 104 | width 和 height: 表示页面在浏览器窗口中的宽度和高度。 105 | 通过 PageViewport 对象,你可以了解到 PDF 页面在渲染时的各种属性,以便进行正确的显示和操作。 106 | */ 107 | export interface PageViewport { 108 | viewBox: [number, number, number, number] 109 | scale: number 110 | rotation: number 111 | offsetX: number 112 | offsetY: number 113 | transform: [number, number, number, number, number, number] 114 | width: number 115 | height: number 116 | rawDims: { 117 | pageWidth: number 118 | pageHeight: number 119 | pageX: number 120 | pageY: number 121 | } 122 | } 123 | 124 | /** 125 | 在 PDF.js 中,eventBus 是一个用于处理 PDF 文档渲染过程中的事件的重要组件。 126 | 它充当了事件总线的角色,负责在不同部件之间传递和处理事件 127 | 通过 eventBus,你可以监听和响应各种 PDF 渲染相关的事件,例如文档加载完成、页面渲染完成、页面缩放、滚动等等。 128 | 这些事件可以帮助你更好地控制 PDF 文档的交互和行为。 129 | 以下是一些常见的 PDF.js 中的事件: 130 | pagesinit:当 PDF 文档的所有页面初始化完成时触发。 131 | pagerendered:当页面渲染完成时触发。 132 | textlayerrendered:当文本图层渲染完成时触发。 133 | scalechange:当页面缩放改变时触发。 134 | updateviewarea:当视图区域更新时触发,通常用于监听滚动事件。 135 | 你可以通过监听这些事件,并在事件触发时执行相应的操作,以实现你所需的 PDF 文档交互效果。 136 | */ 137 | export interface EventBus { 138 | [key: string]: any // 其他未知属性的类型定义 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/annotation/index.tsx: -------------------------------------------------------------------------------- 1 | import "./scss/app.scss"; 2 | import { createRoot } from "react-dom/client"; 3 | import { CustomToolbarRef, CustomToolbar } from "./components/toolbar"; 4 | import { EventBus, PDFPageView, PDFViewerApplication } from "pdfjs"; 5 | import { createRef } from "react"; 6 | import { Painter } from "./painter"; 7 | import { annotationDefinitions } from "./const/definitions"; 8 | import React from "react"; 9 | import ReactDOM from "react-dom/client"; 10 | import PdfAnnotatorPlugin from "@/main"; 11 | import { ItemView, normalizePath, TFile } from "obsidian"; 12 | 13 | export default class PdfjsAnnotationExtension { 14 | PDFJS_PDFViewerApplication: PDFViewerApplication; // PDF.js 的 PDFViewerApplication 对象 15 | PDFJS_EventBus: EventBus; // PDF.js 的 EventBus 对象 16 | $PDFJS_sidebarContainer: HTMLDivElement; // PDF.js 侧边栏容器 17 | $PDFJS_toolbar_container: HTMLDivElement; // PDF.js 工具栏容器 18 | $PDFJS_viewerContainer: HTMLDivElement; // PDF.js 页面视图容器 19 | customToolbarRef: React.RefObject; // 自定义工具栏的引用 20 | painter: Painter; // 画笔实例 21 | 22 | toolbarRoot: ReactDOM.Root; 23 | toolbarContainer: HTMLDivElement; 24 | viewer: any; 25 | view: ItemView; 26 | plugin: PdfAnnotatorPlugin; 27 | file: TFile; 28 | 29 | constructor( 30 | pdfjsViewer: PDFViewerApplication, 31 | viewer: any, 32 | view: ItemView, 33 | plugin: PdfAnnotatorPlugin, 34 | ) { 35 | // 初始化来自 Obsidian 的对象和属性 36 | this.viewer = viewer; 37 | this.view = view; 38 | this.plugin = plugin; 39 | this.file = view.file; 40 | // 初始化 PDF.js 对象和相关属性 41 | this.PDFJS_PDFViewerApplication = pdfjsViewer; 42 | this.PDFJS_EventBus = this.PDFJS_PDFViewerApplication.eventBus; 43 | this.$PDFJS_sidebarContainer = 44 | this.PDFJS_PDFViewerApplication.pdfSidebar.sidebarContainer; 45 | this.$PDFJS_toolbar_container = 46 | this.PDFJS_PDFViewerApplication.toolbar.toolbarRightEl; 47 | this.$PDFJS_viewerContainer = 48 | this.PDFJS_PDFViewerApplication.pdfViewer.container; 49 | // 使用 createRef 方法创建 React 引用 50 | this.customToolbarRef = createRef(); 51 | // 创建画笔实例 52 | this.painter = new Painter({ 53 | view: this.view, 54 | plugin: this.plugin, 55 | PDFViewerApplication: this.PDFJS_PDFViewerApplication, 56 | PDFJS_EventBus: this.PDFJS_EventBus, 57 | setDefaultMode: () => { 58 | this.customToolbarRef.current.activeAnnotation( 59 | annotationDefinitions[0], 60 | ); 61 | }, 62 | }); 63 | // 初始化操作 64 | this.init(); 65 | } 66 | 67 | /** 68 | * @description 初始化 PdfjsAnnotationExtension 类 69 | */ 70 | private init(): void { 71 | this.addCustomStyle(); 72 | this.bindPdfjsEvents(); 73 | this.renderToolbar(); 74 | this.aroundPdfSave(); 75 | this.registerScopeEvents(); 76 | } 77 | 78 | public unload() { 79 | // this.painter.unmount(); 80 | this.view.containerEl.toggleClass("PdfjsAnnotationExtension", false); 81 | this.toolbarRoot && this.toolbarRoot.unmount(); 82 | // this.unbindPdfjsEvents(); 83 | this.toolbarContainer.detach(); 84 | } 85 | 86 | /** 87 | * @description 添加自定义样式 88 | */ 89 | private addCustomStyle(): void { 90 | // document.body.classList.add("PdfjsAnnotationExtension"); 91 | this.view.containerEl.toggleClass("PdfjsAnnotationExtension", true); 92 | } 93 | 94 | /** 95 | * @description 渲染自定义工具栏 96 | */ 97 | private renderToolbar(): void { 98 | this.toolbarContainer = this.$PDFJS_toolbar_container.createEl("div", { 99 | cls: "PdfjsAnnotationExtension-toolbar", 100 | }); 101 | // this.$PDFJS_toolbar_container.insertAdjacentElement( 102 | // "afterend", 103 | // toolbar, 104 | // ); 105 | this.toolbarRoot = createRoot(this.toolbarContainer); 106 | this.toolbarRoot.render( 107 | { 110 | this.painter.activate(currentAnnotation, dataTransfer); 111 | }} 112 | />, 113 | ); 114 | } 115 | 116 | /** 117 | * @description 隐藏 PDF.js 编辑模式按钮 118 | */ 119 | private hidePdfjsEditorModeButtons(): void { 120 | const editorModeButtons = document.querySelector( 121 | "#editorModeButtons", 122 | ) as HTMLDivElement; 123 | const editorModeSeparator = document.querySelector( 124 | "#editorModeSeparator", 125 | ) as HTMLDivElement; 126 | editorModeButtons.style.display = "none"; 127 | editorModeSeparator.style.display = "none"; 128 | } 129 | 130 | /** 131 | * @description 绑定 PDF.js 相关事件 132 | */ 133 | private bindPdfjsEvents(): void { 134 | // this.hidePdfjsEditorModeButtons(); 135 | // 监听页面渲染完成事件 136 | this.PDFJS_EventBus._on( 137 | "pagerendered", 138 | ({ 139 | source, 140 | cssTransform, 141 | pageNumber, 142 | }: { 143 | source: PDFPageView; 144 | cssTransform: boolean; 145 | pageNumber: number; 146 | }) => { 147 | this.painter.initCanvas({ 148 | pageView: source, 149 | cssTransform, 150 | pageNumber, 151 | }); 152 | // this.painter.resetPdfjsAnnotationStorage(); 153 | }, 154 | ); 155 | // 监听文档加载完成事件 156 | this.PDFJS_EventBus._on("textlayerrendered", () => { 157 | this.painter.initWebSelection(this.$PDFJS_viewerContainer); 158 | }); 159 | // 重置 Pdfjs AnnotationStorage 解决有嵌入图片打印、下载会ImageBitmap报错的问题 160 | this.PDFJS_EventBus._on("beforeprint", () => { 161 | this.painter.resetPdfjsAnnotationStorage(); 162 | }); 163 | this.PDFJS_EventBus._on("download", () => { 164 | this.painter.resetPdfjsAnnotationStorage(); 165 | }); 166 | } 167 | 168 | private async initCanvasEvent({ 169 | source, 170 | cssTransform, 171 | pageNumber, 172 | }: { 173 | source: PDFPageView; 174 | cssTransform: boolean; 175 | pageNumber: number; 176 | }) { 177 | this.painter.initCanvas({ 178 | pageView: source, 179 | cssTransform, 180 | pageNumber, 181 | }); 182 | } 183 | 184 | /** 185 | * @description 保存 PDF 时的操作 186 | */ 187 | private aroundPdfSave() { 188 | // this.PDFJS_PDFViewerApplication.save = async () => { 189 | // if (!this.view.file) return; 190 | // 191 | // let e = arguments[0] !== undefined ? arguments[0] : {}; 192 | // if (this.PDFJS_PDFViewerApplication._saveInProgress) { 193 | // return; 194 | // } 195 | // this.PDFJS_PDFViewerApplication._saveInProgress = true; 196 | // await this.PDFJS_PDFViewerApplication.pdfScriptingManager.dispatchWillSave(); 197 | // const t = this.PDFJS_PDFViewerApplication._downloadUrl; 198 | // const i = this.PDFJS_PDFViewerApplication._docFilename; 199 | // try { 200 | // this.PDFJS_PDFViewerApplication._ensureDownloadComplete(); 201 | // const n = 202 | // (await this.PDFJS_PDFViewerApplication.pdfDocument.saveDocument()) as Uint8Array; 203 | // 204 | // const r = new Blob([n], { type: "application/pdf" }); 205 | // // const arrayBuffer = await r.arrayBuffer(); 206 | // 207 | // 208 | // const file = await this.plugin.app.vault.adapter.exists( 209 | // normalizePath(this.file.path), 210 | // ); 211 | // 212 | // await this.plugin.app.vault.adapter.writeBinary( 213 | // normalizePath(this.file.path), 214 | // n.buffer, 215 | // ); 216 | // // await this.PDFJS_PDFViewerApplication.downloadManager.download( 217 | // // r, 218 | // // t, 219 | // // i, 220 | // // e, 221 | // // ); 222 | // } catch (t) { 223 | // console.error(`Error when saving the document: ${t.message}`); 224 | // await this.PDFJS_PDFViewerApplication.download(e); 225 | // } finally { 226 | // await this.PDFJS_PDFViewerApplication.pdfScriptingManager.dispatchDidSave(); 227 | // this.PDFJS_PDFViewerApplication._saveInProgress = false; 228 | // } 229 | // if (this.PDFJS_PDFViewerApplication._hasAnnotationEditors) { 230 | // this.PDFJS_PDFViewerApplication.externalServices.reportTelemetry( 231 | // { 232 | // type: "editing", 233 | // data: { 234 | // type: "save", 235 | // }, 236 | // }, 237 | // ); 238 | // } 239 | // }; 240 | 241 | this.PDFJS_PDFViewerApplication.save = async (downloadOptions: any) => { 242 | if (!this.view.file) return; 243 | 244 | let options = downloadOptions || {}; 245 | if (this.PDFJS_PDFViewerApplication._saveInProgress) { 246 | return; 247 | } 248 | this.PDFJS_PDFViewerApplication._saveInProgress = true; 249 | await this.PDFJS_PDFViewerApplication.pdfScriptingManager.dispatchWillSave(); 250 | // 不需要的代码,暂时保留 251 | // const downloadUrl = this.PDFJS_PDFViewerApplication._downloadUrl; 252 | // const filename = this.PDFJS_PDFViewerApplication._docFilename; 253 | try { 254 | if (options.saveToFile) { 255 | /** 256 | * Currently, because highlight annotations are not supported by Obsidian default PDFjs lib yet. 257 | * So don't save the annotations to the PDF file. 258 | */ 259 | 260 | const annotations = this.painter.getPdfjsAllAnnotations(); 261 | 262 | this.PDFJS_PDFViewerApplication._ensureDownloadComplete(); 263 | const savedDocument = 264 | (await this.PDFJS_PDFViewerApplication.pdfDocument.saveDocument()) as Uint8Array; 265 | this.painter.resetPdfjsAnnotationStorage(); 266 | 267 | await this.plugin.app.vault.adapter.writeBinary( 268 | normalizePath(this.file.path), 269 | savedDocument.buffer, 270 | ); 271 | } else { 272 | const annotations = this.painter.getPdfjsAllAnnotations(); 273 | this.PDFJS_PDFViewerApplication._ensureDownloadComplete(); 274 | 275 | this.plugin.controller.updateAnnotations( 276 | this.view.file.path, 277 | annotations, 278 | ); 279 | } 280 | 281 | // await this.PDFJS_PDFViewerApplication.downloadManager.download( 282 | // pdfBlob, 283 | // downloadUrl, 284 | // filename, 285 | // options, 286 | // ); 287 | } catch (error) { 288 | console.error( 289 | `Error when saving the document: ${error.message}`, 290 | ); 291 | // await this.PDFJS_PDFViewerApplication.download(options); 292 | } finally { 293 | // await this.PDFJS_PDFViewerApplication.pdfScriptingManager.dispatchDidSave(); 294 | this.PDFJS_PDFViewerApplication._saveInProgress = false; 295 | } 296 | if (this.PDFJS_PDFViewerApplication._hasAnnotationEditors) { 297 | this.PDFJS_PDFViewerApplication.externalServices.reportTelemetry( 298 | { 299 | type: "editing", 300 | data: { 301 | type: "save", 302 | }, 303 | }, 304 | ); 305 | } 306 | }; 307 | } 308 | 309 | /** 310 | * @private 311 | * @description 注册 View 层级的事件 312 | */ 313 | private registerScopeEvents() { 314 | // this.view.scope.register(["Mod", "Shift"], "s", () => { 315 | // this.PDFJS_PDFViewerApplication.save({ 316 | // saveToFile: true, 317 | // }); 318 | // }); 319 | this.view.scope.register(["Mod"], "s", () => { 320 | this.PDFJS_PDFViewerApplication.save(); 321 | }); 322 | } 323 | } 324 | 325 | // new PdfjsAnnotationExtension(); 326 | -------------------------------------------------------------------------------- /src/annotation/painter/const.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 常量:PdfjsAnnotationExtension 的样式前缀 3 | */ 4 | const STYLE_PREFIX = "PdfjsAnnotationExtension"; 5 | 6 | /** 7 | * 常量:Painter 组件的包裹器样式前缀 8 | */ 9 | export const PAINTER_WRAPPER_PREFIX = `${STYLE_PREFIX}_painter_wrapper`; 10 | 11 | /** 12 | * 常量:表示当前是否处于绘画模式的样式名称 13 | */ 14 | export const PAINTER_IS_PAINTING_STYLE = `${STYLE_PREFIX}_is_painting`; 15 | 16 | /** 17 | * 常量:绘画类型的样式前缀 18 | */ 19 | export const PAINTER_PAINTING_TYPE = `${STYLE_PREFIX}_painting_type`; 20 | 21 | /** 22 | * 常量:形状组的样式名称 23 | */ 24 | export const SHAPE_GROUP_NAME = `${STYLE_PREFIX}_shape_group`; 25 | 26 | /** 27 | * 常量:选择器悬停时的样式名称 28 | */ 29 | export const SELECTOR_HOVER_STYLE = `${STYLE_PREFIX}_selector_hover`; 30 | 31 | /** 32 | * 常量:定义用于设置鼠标指针样式的 CSS 变量 33 | */ 34 | export const CURSOR_CSS_PROPERTY = `--${STYLE_PREFIX}-image-cursor`; 35 | 36 | /** 37 | * 常量:定义用于设置鼠标指针样式的样式名称 38 | */ 39 | export const CURSOR_STYLE_CLASS_NAME = `${STYLE_PREFIX}_cursor_style`; 40 | 41 | /** 42 | * 常量:自由文本输入框的类名 43 | */ 44 | export const FREE_TEXT_TEXT_CLASS_NAME = `${STYLE_PREFIX}_free_text_input`; 45 | -------------------------------------------------------------------------------- /src/annotation/painter/editor/editor.ts: -------------------------------------------------------------------------------- 1 | import Konva from "konva"; 2 | import { 3 | AnnotationType, 4 | IAnnotationContent, 5 | IAnnotationStore, 6 | IAnnotationType, 7 | IPdfjsAnnotationStorage, 8 | } from "../../const/definitions"; 9 | import { KonvaEventObject } from "konva/lib/Node"; 10 | import { generateUUID } from "../../utils/utils"; 11 | import { SHAPE_GROUP_NAME } from "../const"; 12 | 13 | /** 14 | * IEditorOptions 接口定义了编辑器的初始化选项。 15 | */ 16 | export interface IEditorOptions { 17 | konvaStage: Konva.Stage; // Konva Stage对象 18 | pageNumber: number; // 页面编号 19 | annotation: IAnnotationType | null; // 当前注解对象,可以为 null 20 | /** 21 | * 添加形状组的回调函数。 22 | * @param shapeGroup 添加的形状组对象 23 | * @param pdfjsAnnotationStorage PDF.js 注解存储对象 24 | * @param annotationContent 注解内容,可选 25 | */ 26 | onAdd: ( 27 | shapeGroup: IShapeGroup, 28 | pdfjsAnnotationStorage: IPdfjsAnnotationStorage, 29 | annotationContent?: IAnnotationContent, 30 | ) => void; 31 | } 32 | 33 | /** 34 | * IShapeGroup 接口定义了形状组的基本结构。 35 | */ 36 | export interface IShapeGroup { 37 | id: string; // 形状组的唯一标识符 38 | konvaGroup: Konva.Group; // Konva.Group 对象 39 | isDone: boolean; // 标识形状组是否已完成 40 | pageNumber: number; // 所属页面的页码 41 | annotation: IAnnotationType; // 关联的注解对象 42 | } 43 | 44 | /** 45 | * Editor 是一个抽象类,定义了编辑器的基本行为和属性。 46 | */ 47 | export abstract class Editor { 48 | public readonly id: string; // 编辑器实例的唯一标识符 49 | public readonly onAdd: ( 50 | shapeGroup: IShapeGroup, 51 | pdfjsAnnotationStorage: IPdfjsAnnotationStorage, 52 | annotationContent?: IAnnotationContent, 53 | ) => void; // 添加形状组的回调函数 54 | protected konvaStage: Konva.Stage; // Konva Stage对象 55 | protected readonly pageNumber: number; // 页面编号 56 | protected currentAnnotation: IAnnotationType | null; // 当前注解对象,可以为 null 57 | protected isPainting: boolean; // 是否正在绘制标志位 58 | public shapeGroupStore: Map = new Map(); // 存储形状组的 Map 59 | public currentShapeGroup: IShapeGroup | null; // 当前操作的形状组,可以为 null 60 | 61 | static MinSize = 8; // 最小尺寸常量 62 | 63 | /** 64 | * Editor 类的构造函数。 65 | * @param options 初始化编辑器的选项 66 | */ 67 | constructor({ 68 | konvaStage, 69 | pageNumber, 70 | annotation, 71 | onAdd, 72 | editorType, 73 | }: IEditorOptions & { editorType: AnnotationType }) { 74 | this.id = `${pageNumber}_${editorType}`; // 构造唯一标识符 75 | this.konvaStage = konvaStage; // 初始化 Konva Stage对象 76 | this.pageNumber = pageNumber; // 初始化页面编号 77 | this.currentAnnotation = annotation; // 初始化当前注解对象 78 | this.isPainting = false; // 初始化绘制状态为 false 79 | this.currentShapeGroup = null; // 初始化当前形状组为 null 80 | this.onAdd = onAdd; // 初始化添加形状组的回调函数 81 | this.disableEditMode(); // 禁用编辑模式 82 | this.enableEditMode(); // 启用编辑模式 83 | } 84 | 85 | /** 86 | * 发送添加事件的私有方法,调用 onAdd 回调函数。 87 | * @param pdfjsAnnotationStorage PDF.js 注解存储对象 88 | * @param annotationContent 注解内容,可选 89 | */ 90 | private dispatchAddEvent( 91 | pdfjsAnnotationStorage: IPdfjsAnnotationStorage, 92 | annotationContent?: IAnnotationContent, 93 | ) { 94 | this.onAdd( 95 | this.currentShapeGroup, 96 | pdfjsAnnotationStorage, 97 | annotationContent, 98 | ); // 调用 onAdd 回调函数 99 | } 100 | 101 | /** 102 | * 启用编辑模式,监听 Konva Stage的鼠标事件。 103 | */ 104 | protected enableEditMode() { 105 | this.konvaStage.on("mousedown", (e) => { 106 | if (e.evt.button === 0) { 107 | this.mouseDownHandler(e); // 处理鼠标按下事件 108 | } 109 | }); 110 | this.konvaStage.on("mousemove", (e) => { 111 | this.mouseMoveHandler(e); // 处理鼠标移动事件 112 | }); 113 | this.konvaStage.on("mouseup", (e) => { 114 | if (e.evt.button === 0) { 115 | this.mouseUpHandler(e); // 处理鼠标松开事件 116 | } 117 | }); 118 | this.konvaStage.on("mouseenter", (e) => { 119 | if (e.evt.button === 0) { 120 | this.mouseEnterHandler(e); // 处理鼠标进入事件 121 | } 122 | }); 123 | this.konvaStage.on("mouseout", (e) => { 124 | this.mouseOutHandler(e); // 处理鼠标离开事件 125 | }); 126 | } 127 | 128 | /** 129 | * 禁用编辑模式,取消 Konva Stage的鼠标事件监听。 130 | */ 131 | protected disableEditMode() { 132 | this.isPainting = false; // 设置绘制状态为 false 133 | this.konvaStage.off("click"); 134 | this.konvaStage.off("mousedown"); 135 | this.konvaStage.off("mousemove"); 136 | this.konvaStage.off("mouseup"); 137 | this.konvaStage.off("mouseenter"); 138 | this.konvaStage.off("mouseout"); 139 | } 140 | 141 | /** 142 | * 获取背景图层,如果传入了 konvaStage 则使用传入的Stage,否则使用类中的Stage。 143 | * @param konvaStage 可选参数,传入的 Konva Stage对象 144 | * @returns 返回第一个图层作为背景图层 145 | * @protected 146 | */ 147 | protected getBgLayer(konvaStage?: Konva.Stage): Konva.Layer { 148 | return konvaStage 149 | ? konvaStage.getLayers()[0] 150 | : this.konvaStage.getLayers()[0]; 151 | } 152 | 153 | /** 154 | * 删除指定 ID 的形状组,并在Stage上销毁对应的 Konva.Group 对象。 155 | * @param id 要删除的形状组的 ID 156 | * @protected 157 | */ 158 | protected delShapeGroup(id: string) { 159 | this.shapeGroupStore.delete(id); // 从 shapeGroupStore 中删除指定 ID 的形状组 160 | const group = this.konvaStage.findOne( 161 | (node) => node.getType() === "Group" && node.id() === id, 162 | ); // 查找对应 ID 的 Konva.Group 对象 163 | if (group) { 164 | group.destroy(); // 销毁 Konva.Group 对象 165 | group.remove(); // 从 Konva Stage 中移除 Konva.Group 对象 166 | this.konvaStage.batchDraw(); // 重新绘制 Konva Stage 167 | } 168 | } 169 | 170 | /** 171 | * 设置指定 ID 的形状组为已完成状态,并触发添加事件。 172 | * @param id 要设置为已完成的形状组的 ID 173 | * @param pdfjsAnnotationStorage PDF.js 注解存储对象 174 | * @param annotationContent 注解内容,可选 175 | * @protected 176 | */ 177 | protected setShapeGroupDone( 178 | id: string, 179 | pdfjsAnnotationStorage: IPdfjsAnnotationStorage, 180 | annotationContent?: IAnnotationContent, 181 | ) { 182 | const shapeGroup = this.shapeGroupStore.get(id); // 获取指定 ID 的形状组对象 183 | if (shapeGroup) { 184 | shapeGroup.isDone = true; // 设置形状组为已完成状态 185 | this.dispatchAddEvent(pdfjsAnnotationStorage, annotationContent); // 触发添加事件 186 | } 187 | } 188 | 189 | /** 190 | * 获取当前形状组中指定类型的子节点。 191 | * @param className Konva 形状的类名,例如 'Rect' 192 | * @returns 返回符合指定类名的节点数组 193 | * @protected 194 | */ 195 | protected getNodesByClassName( 196 | className: string, 197 | ): T[] { 198 | const children = this.currentShapeGroup.konvaGroup.getChildren( 199 | (node: Konva.Node) => node.getClassName() === className, 200 | ); // 获取当前形状组中指定类名的子节点 201 | return children as unknown as T[]; // 返回子节点数组 202 | } 203 | 204 | /** 205 | * 获取指定 Konva.Group 中指定类型的子节点。 206 | * @param group 指定的 Konva.Group 对象 207 | * @param className Konva 形状的类名,例如 'Rect' 208 | * @returns 返回符合指定类名的节点数组 209 | * @protected 210 | */ 211 | protected getGroupNodesByClassName( 212 | group: Konva.Group, 213 | className: string, 214 | ): T[] { 215 | const children = group.getChildren( 216 | (node: Konva.Node) => node.getClassName() === className, 217 | ); // 获取指定 Konva.Group 中指定类名的子节点 218 | return children as unknown as T[]; // 返回子节点数组 219 | } 220 | 221 | /** 222 | * 更新 shapeGroupStore 中指定 ID 的 Konva.Group 对象。 223 | * @param id 需要更新的形状组的 ID 224 | * @param newKonvaGroup 用于更新的新 Konva.Group 对象 225 | * @returns 返回更新后的形状组对象,如果未找到对应 ID 的形状组则返回 null 226 | * @protected 227 | */ 228 | protected updateKonvaGroup( 229 | id: string, 230 | newKonvaGroup: Konva.Group, 231 | ): IShapeGroup | null { 232 | if (this.shapeGroupStore.has(id)) { 233 | // 检查 shapeGroupStore 中是否存在指定 ID 的形状组 234 | const shapeGroup = this.shapeGroupStore.get(id); // 获取当前形状组 235 | if (shapeGroup) { 236 | shapeGroup.konvaGroup = newKonvaGroup; // 更新 Konva.Group 对象 237 | this.shapeGroupStore.set(id, shapeGroup); // 更新存储中的形状组对象 238 | return shapeGroup; // 返回更新后的形状组对象 239 | } 240 | } else { 241 | console.warn(`ShapeGroup with id ${id} not found.`); // 输出警告信息,指定 ID 的形状组未找到 242 | } 243 | return null; // 如果未找到形状组,则返回 null 244 | } 245 | 246 | /** 247 | * 创建一个新的形状组,并添加到 shapeGroupStore 中。 248 | * @returns 返回新创建的形状组对象 249 | * @protected 250 | */ 251 | protected createShapeGroup(): IShapeGroup { 252 | const id = generateUUID(); // 生成新的唯一标识符 253 | const group = new Konva.Group({ 254 | // 创建新的 Konva.Group 对象 255 | draggable: false, 256 | name: SHAPE_GROUP_NAME, 257 | id, 258 | }); 259 | const shapeGroup: IShapeGroup = { 260 | // 创建形状组对象 261 | id, 262 | konvaGroup: group, 263 | pageNumber: this.pageNumber, 264 | annotation: this.currentAnnotation, 265 | isDone: false, 266 | }; 267 | this.shapeGroupStore.set(id, shapeGroup); // 将形状组对象添加到 shapeGroupStore 中 268 | return shapeGroup; // 返回新创建的形状组对象 269 | } 270 | 271 | /** 272 | * 刷新 PDF.js 注解存储,更新指定形状组的内容。 273 | * @param groupId 形状组的 ID 274 | * @param groupString 形状组的字符串表示 275 | * @param rawAnnotationStore 原始注解存储对象 276 | * @returns 返回更新后的 PDF.js 注解存储对象的 Promise 277 | */ 278 | public abstract refreshPdfjsAnnotationStorage( 279 | groupId: string, 280 | groupString: string, 281 | rawAnnotationStore: IAnnotationStore, 282 | ): Promise; 283 | 284 | /** 285 | * 处理鼠标按下事件的抽象方法,子类需实现具体逻辑。 286 | * @param e Konva 事件对象 287 | * @protected 288 | */ 289 | protected abstract mouseDownHandler(e: KonvaEventObject): void; 290 | 291 | /** 292 | * 处理鼠标移动事件的抽象方法,子类需实现具体逻辑。 293 | * @param e Konva 事件对象 294 | * @protected 295 | */ 296 | protected abstract mouseMoveHandler(e: KonvaEventObject): void; 297 | 298 | /** 299 | * 处理鼠标松开事件的抽象方法,子类需实现具体逻辑。 300 | * @param e Konva 事件对象 301 | * @protected 302 | */ 303 | protected abstract mouseUpHandler(e: KonvaEventObject): void; 304 | 305 | /** 306 | * 处理鼠标离开事件的抽象方法,子类需实现具体逻辑。 307 | * @param e Konva 事件对象 308 | * @protected 309 | */ 310 | protected abstract mouseOutHandler(e: KonvaEventObject): void; 311 | 312 | /** 313 | * 处理鼠标进入事件的抽象方法,子类需实现具体逻辑。 314 | * @param e Konva 事件对象 315 | * @protected 316 | */ 317 | protected abstract mouseEnterHandler(e: KonvaEventObject): void; 318 | 319 | /** 320 | * 激活编辑器,重新设置 Konva Stage和当前注解对象,并启用编辑模式。 321 | * @param konvaStage 新的 Konva Stage对象 322 | * @param annotation 新的注解对象 323 | */ 324 | public activate(konvaStage: Konva.Stage, annotation: IAnnotationType) { 325 | this.konvaStage = konvaStage; // 更新 Konva Stage对象 326 | this.currentAnnotation = annotation; // 更新当前注解对象 327 | this.isPainting = false; // 重置绘制状态 328 | this.disableEditMode(); // 禁用编辑模式 329 | this.enableEditMode(); // 启用编辑模式 330 | } 331 | 332 | /** 333 | * 将序列化的 Konva.Group 添加到图层。 334 | * @param konvaStage Konva Stage对象 335 | * @param konvaString 序列化的 Konva.Group 字符串表示 336 | */ 337 | public addSerializedGroupToLayer( 338 | konvaStage: Konva.Stage, 339 | konvaString: string, 340 | ) { 341 | const ghostGroup = Konva.Node.create(konvaString); // 根据序列化字符串创建 Konva.Group 对象 342 | this.getBgLayer(konvaStage).add(ghostGroup); // 将 Konva.Group 对象添加到背景图层 343 | } 344 | 345 | /** 346 | * 删除指定 ID 的形状组。 347 | * @param id 要删除的形状组的 ID 348 | */ 349 | public deleteGroup(id: string, konvaStage: Konva.Stage) { 350 | this.konvaStage = konvaStage; 351 | this.delShapeGroup(id); // 调用 delShapeGroup 方法删除指定 ID 的形状组 352 | } 353 | 354 | /** 355 | * 静态属性,存储所有的 Timer 实例。 356 | */ 357 | static Timer: { [pageNumber: number]: number } = {}; 358 | 359 | /** 360 | * 静态方法,清除指定页面的定时器。 361 | * @param pageNumber 页面编号 362 | */ 363 | static TimerClear(pageNumber: number) { 364 | const timer = Editor.Timer[pageNumber]; // 获取指定页面的定时器 365 | if (timer) { 366 | window.clearTimeout(timer); // 清除定时器 367 | } 368 | } 369 | 370 | /** 371 | * 静态方法,启动指定页面的定时器。 372 | * @param pageNumber 页面编号 373 | * @param callback 定时器回调函数,接受页面编号作为参数 374 | */ 375 | static TimerStart( 376 | pageNumber: number, 377 | callback: (pageNumber: number) => void, 378 | ) { 379 | Editor.Timer[pageNumber] = window.setTimeout(() => { 380 | // 设置定时器 381 | if (typeof callback === "function") { 382 | callback(pageNumber); // 执行定时器回调函数 383 | } 384 | }, 1000); 385 | } 386 | 387 | /** 388 | * 卸载编辑器,清除所有的形状组。 389 | */ 390 | public unload() { 391 | this.shapeGroupStore.forEach((shapeGroup) => { 392 | shapeGroup.konvaGroup.destroy(); 393 | }); 394 | this.shapeGroupStore.clear(); 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /src/annotation/painter/editor/editor_ellipse.ts: -------------------------------------------------------------------------------- 1 | import Konva from 'konva' 2 | import { KonvaEventObject } from 'konva/lib/Node' 3 | 4 | import { IEditorOptions, Editor } from './editor' 5 | import { AnnotationType, IAnnotationStore, IPdfjsAnnotationStorage, PdfjsAnnotationEditorType } from '../../const/definitions' 6 | import { getRGB } from '../../utils/utils' 7 | 8 | /** 9 | * 椭圆编辑器类,继承自基础编辑器类 Editor,用于在画布上绘制椭圆。 10 | */ 11 | export class EditorEllipse extends Editor { 12 | private ellipse: Konva.Ellipse | null // 当前正在绘制的椭圆对象 13 | private vertex: { x: number; y: number } // 用于存储椭圆的起点(顶点)坐标 14 | 15 | /** 16 | * 构造函数,初始化椭圆编辑器。 17 | * @param EditorOptions 编辑器选项接口 18 | */ 19 | constructor(EditorOptions: IEditorOptions) { 20 | super({ ...EditorOptions, editorType: AnnotationType.ELLIPSE }) 21 | this.ellipse = null 22 | this.vertex = { x: 0, y: 0 } 23 | } 24 | 25 | /** 26 | * 处理鼠标或触摸指针按下事件,开始绘制椭圆。 27 | * @param e Konva 事件对象 28 | */ 29 | protected mouseDownHandler(e: KonvaEventObject) { 30 | if (e.currentTarget !== this.konvaStage) { 31 | return 32 | } 33 | this.ellipse = null // 重置当前椭圆对象 34 | this.isPainting = true // 设置绘制状态为真 35 | this.currentShapeGroup = this.createShapeGroup() // 创建新的形状组 36 | this.getBgLayer().add(this.currentShapeGroup.konvaGroup) // 将形状组添加到背景层 37 | 38 | // 获取当前指针位置并存储为椭圆的起点(顶点)坐标 39 | const pos = this.konvaStage.getRelativePointerPosition() 40 | this.vertex = { x: pos.x, y: pos.y } 41 | 42 | // 初始化椭圆对象,初始半径为0 43 | this.ellipse = new Konva.Ellipse({ 44 | radiusX: 0, 45 | radiusY: 0, 46 | x: pos.x, 47 | y: pos.y, 48 | visible: false, // 初始状态为不可见 49 | stroke: this.currentAnnotation.style.color, // 设置椭圆边框颜色 50 | strokeWidth: this.currentAnnotation.style.strokeWidth, // 设置椭圆边框宽度 51 | opacity: this.currentAnnotation.style.opacity // 设置椭圆透明度 52 | }) 53 | 54 | // 将椭圆对象添加到当前形状组中 55 | this.currentShapeGroup.konvaGroup.add(this.ellipse) 56 | window.addEventListener('mouseup', this.globalPointerUpHandler) // 添加全局鼠标释放事件监听器 57 | } 58 | 59 | /** 60 | * 处理鼠标或触摸指针移动事件,绘制椭圆。 61 | * @param e Konva 事件对象 62 | */ 63 | protected mouseMoveHandler(e: KonvaEventObject) { 64 | if (!this.isPainting) { 65 | return 66 | } 67 | e.evt.preventDefault() // 阻止默认事件,如滚动页面 68 | 69 | this.ellipse.show() // 显示当前绘制的椭圆 70 | 71 | // 获取当前指针位置并计算椭圆的半径 72 | const pos = this.konvaStage.getRelativePointerPosition() 73 | const radiusX = Math.abs(pos.x - this.vertex.x) / 2 74 | const radiusY = Math.abs(pos.y - this.vertex.y) / 2 75 | 76 | // 计算椭圆的中心点和半径,并更新椭圆属性 77 | const areaAttr = { 78 | x: (pos.x - this.vertex.x) / 2 + this.vertex.x, 79 | y: (pos.y - this.vertex.y) / 2 + this.vertex.y, 80 | radiusX, 81 | radiusY 82 | } 83 | 84 | this.ellipse.setAttrs(areaAttr) 85 | } 86 | 87 | /** 88 | * 处理鼠标或触摸指针释放事件,完成椭圆的绘制。 89 | */ 90 | protected mouseUpHandler() { 91 | if (!this.isPainting) { 92 | return 93 | } 94 | this.isPainting = false // 结束绘制状态 95 | 96 | // 如果图形是隐藏状态,将图形从画布和 MAP 上移除 97 | const group = this.ellipse.getParent() 98 | if (!this.ellipse.isVisible() && group.getType() === 'Group') { 99 | this.delShapeGroup(group.id()) 100 | return 101 | } 102 | 103 | if (this.isTooSmall()) { 104 | // 如果椭圆太小,则销毁椭圆对象并移除形状组 105 | this.ellipse.destroy() 106 | this.delShapeGroup(group.id()) 107 | this.ellipse = null 108 | return 109 | } 110 | 111 | // 提取椭圆的属性并保存形状组的状态 112 | const { x, y, radiusX, radiusY } = this.ellipse.attrs 113 | this.setShapeGroupDone( 114 | group.id(), 115 | this.calculateEllipseForStorage({ 116 | x, 117 | y, 118 | radiusX, 119 | radiusY, 120 | annotationType: this.currentAnnotation.pdfjsType, 121 | color: getRGB(this.currentAnnotation.style.color), 122 | thickness: this.currentAnnotation.style.strokeWidth || 2, 123 | opacity: this.currentAnnotation.style.opacity, 124 | pageIndex: this.pageNumber - 1 125 | }) 126 | ) 127 | this.ellipse = null // 重置当前椭圆对象为 null 128 | } 129 | 130 | /** 131 | * 全局鼠标释放事件处理器,仅处理左键释放事件。 132 | * @param e MouseEvent 对象 133 | */ 134 | private globalPointerUpHandler = (e: MouseEvent) => { 135 | if (e.button !== 0) return // 只处理左键释放事件 136 | this.mouseUpHandler() // 调用指针释放处理方法 137 | window.removeEventListener('mouseup', this.globalPointerUpHandler) // 移除全局鼠标释放事件监听器 138 | } 139 | 140 | /** 141 | * 刷新 Pdfjs 注释存储,用于更新或修正注释组。 142 | * @param groupId 注释组 ID 143 | * @param groupString 注释组的序列化字符串 144 | * @param rawAnnotationStore 原始注释存储数据 145 | * @returns 更新后的 Pdfjs 注释存储对象 146 | */ 147 | public async refreshPdfjsAnnotationStorage(groupId: string, groupString: string, rawAnnotationStore: IAnnotationStore) { 148 | const ghostGroup = Konva.Node.create(groupString) // 通过序列化字符串创建临时组 149 | const ellipse = this.getGroupNodesByClassName(ghostGroup, 'Ellipse')[0] as Konva.Ellipse // 获取椭圆对象 150 | const { x, y, radiusX, radiusY } = this.fixShapeCoordinateForGroup(ellipse, ghostGroup) // 修正椭圆坐标 151 | 152 | return this.calculateEllipseForStorage({ 153 | x, 154 | y, 155 | radiusX, 156 | radiusY, 157 | annotationType: rawAnnotationStore.pdfjsAnnotationStorage.annotationType, 158 | color: rawAnnotationStore.pdfjsAnnotationStorage.color, 159 | thickness: rawAnnotationStore.pdfjsAnnotationStorage.thickness, 160 | opacity: rawAnnotationStore.pdfjsAnnotationStorage.opacity, 161 | pageIndex: rawAnnotationStore.pdfjsAnnotationStorage.pageIndex 162 | }) 163 | } 164 | 165 | /** 166 | * 修正椭圆在组内的坐标。 167 | * @param shape Konva.Ellipse 对象 168 | * @param group Konva.Group 对象 169 | * @returns 修正后的坐标和半径 170 | */ 171 | private fixShapeCoordinateForGroup(shape: Konva.Ellipse, group: Konva.Group) { 172 | const groupTransform = group.getTransform() // 获取组的全局变换矩阵 173 | const localX = shape.attrs.x // 获取椭圆的局部 x 坐标 174 | const localY = shape.attrs.y // 获取椭圆的局部 y 坐标 175 | 176 | // 将椭圆的局部坐标转换为全局坐标 177 | const globalPos = groupTransform.point({ x: localX, y: localY }) 178 | 179 | // 计算椭圆的全局半径 180 | const globalRadiusX = shape.attrs.radiusX * (group.attrs.scaleX || 1) 181 | const globalRadiusY = shape.attrs.radiusY * (group.attrs.scaleY || 1) 182 | 183 | return { 184 | x: globalPos.x, 185 | y: globalPos.y, 186 | radiusX: globalRadiusX, 187 | radiusY: globalRadiusY 188 | } 189 | } 190 | 191 | /** 192 | * 将椭圆数据转换为 PDF.js 所需的注释存储数据格式。 193 | * @param param0 参数对象 194 | * @returns 符合 PDF.js 注释存储数据格式的对象 195 | */ 196 | private calculateEllipseForStorage({ 197 | x, 198 | y, 199 | radiusX, 200 | radiusY, 201 | annotationType, 202 | color, 203 | thickness, 204 | opacity, 205 | pageIndex 206 | }: { 207 | x: number 208 | y: number 209 | radiusX: number 210 | radiusY: number 211 | annotationType: PdfjsAnnotationEditorType 212 | color: any 213 | thickness: number 214 | opacity: number 215 | pageIndex: number 216 | }): IPdfjsAnnotationStorage { 217 | const canvasHeight = this.konvaStage.size().height / this.konvaStage.scale().y 218 | const halfInterval: number = 0.5 // 角度间隔 219 | const points: number[] = [] 220 | 221 | // 计算椭圆周上的点,并将其转换为适合 PDF.js 存储的格式 222 | for (let angle = 0; angle <= 360; angle += halfInterval) { 223 | const radians = (angle * Math.PI) / 180 224 | const pointX = x + radiusX * Math.cos(radians) 225 | const pointY = y + radiusY * Math.sin(radians) 226 | points.push(pointX, canvasHeight - pointY) // 将点添加到数组中,并调整 y 坐标 227 | } 228 | 229 | // 计算椭圆的边界矩形的左上角和右下角顶点坐标 230 | const rect: [number, number, number, number] = [x - radiusX * 2, canvasHeight - (y + radiusY * 2), x + radiusX * 2, canvasHeight - (y - radiusY * 2)] 231 | 232 | return { 233 | annotationType, 234 | color, 235 | thickness, 236 | opacity, 237 | paths: [{ bezier: points, points: points }], // 存储椭圆路径 238 | pageIndex, 239 | rect: rect, 240 | rotation: 0 241 | } 242 | } 243 | 244 | /** 245 | * 判断椭圆是否太小。 246 | * @returns 如果椭圆的宽度或高度小于最小尺寸,返回 true,否则返回 false。 247 | */ 248 | private isTooSmall(): boolean { 249 | const { width, height } = this.ellipse.size() 250 | return Math.max(width, height) < Editor.MinSize 251 | } 252 | 253 | protected mouseOutHandler() {} 254 | protected mouseEnterHandler() {} 255 | } 256 | -------------------------------------------------------------------------------- /src/annotation/painter/editor/editor_free_hand.ts: -------------------------------------------------------------------------------- 1 | import Konva from 'konva' 2 | import { KonvaEventObject } from 'konva/lib/Node' 3 | 4 | import { IEditorOptions, Editor } from './editor' 5 | import { AnnotationType, IAnnotationStore, IPdfjsAnnotationStorage, PdfjsAnnotationEditorType } from '../../const/definitions' 6 | import { getRGB } from '../../utils/utils' 7 | 8 | /** 9 | * 自由手绘编辑器类,继承自基础编辑器类 Editor,用于在画布上绘制自由曲线。 10 | */ 11 | export class EditorFreeHand extends Editor { 12 | private line: Konva.Line | null // 当前正在绘制的自由曲线 13 | 14 | /** 15 | * 构造函数,初始化自由手绘编辑器。 16 | * @param EditorOptions 编辑器选项接口 17 | */ 18 | constructor(EditorOptions: IEditorOptions) { 19 | super({ ...EditorOptions, editorType: AnnotationType.FREEHAND }) 20 | this.line = null // 初始化当前曲线为null 21 | } 22 | 23 | /** 24 | * 处理鼠标或触摸指针按下事件,开始绘制自由曲线。 25 | * @param e Konva 事件对象 26 | */ 27 | protected mouseDownHandler(e: KonvaEventObject) { 28 | if (e.currentTarget !== this.konvaStage) { 29 | return 30 | } 31 | 32 | Editor.TimerClear(this.pageNumber) // 清除当前页的计时器 33 | this.line = null // 重置当前曲线对象 34 | this.isPainting = true // 设置绘制状态为真 35 | 36 | if (!this.currentShapeGroup) { 37 | // 如果当前形状组不存在,则创建新的形状组并添加到背景层 38 | this.currentShapeGroup = this.createShapeGroup() 39 | this.getBgLayer().add(this.currentShapeGroup.konvaGroup) 40 | } 41 | 42 | // 获取当前指针位置,并初始化线条对象 43 | const pos = this.konvaStage.getRelativePointerPosition() 44 | this.line = new Konva.Line({ 45 | stroke: this.currentAnnotation.style.color, // 设置线条颜色 46 | strokeWidth: this.currentAnnotation.style.strokeWidth, // 设置线条宽度 47 | opacity: this.currentAnnotation.style.opacity, // 设置线条透明度 48 | lineCap: 'round', // 设置线条端点为圆形 49 | lineJoin: 'round', // 设置线条连接处为圆形 50 | hitStrokeWidth: 10, // 设置点击检测的宽度 51 | visible: false, // 初始化为不可见 52 | globalCompositeOperation: 'source-over', 53 | points: [pos.x, pos.y, pos.x, pos.y] // 初始化起始点 54 | }) 55 | 56 | this.currentShapeGroup.konvaGroup.add(this.line) // 将曲线添加到当前形状组中 57 | window.addEventListener('mouseup', this.globalPointerUpHandler) // 添加全局鼠标释放事件监听器 58 | } 59 | 60 | /** 61 | * 处理鼠标或触摸指针移动事件,绘制自由曲线。 62 | * @param e Konva 事件对象 63 | */ 64 | protected mouseMoveHandler(e: KonvaEventObject) { 65 | if (!this.isPainting) { 66 | return 67 | } 68 | 69 | e.evt.preventDefault() // 阻止默认事件,如滚动页面 70 | this.line.show() // 显示当前绘制的曲线 71 | 72 | // 获取当前指针位置并更新线条点集 73 | const pos = this.konvaStage.getRelativePointerPosition() 74 | const newPoints = this.line.points().concat([pos.x, pos.y]) 75 | this.line.points(newPoints) 76 | } 77 | 78 | /** 79 | * 处理鼠标或触摸指针释放事件,完成自由曲线的绘制。 80 | */ 81 | protected mouseUpHandler() { 82 | if (!this.isPainting) { 83 | return 84 | } 85 | 86 | this.isPainting = false // 结束绘制状态 87 | const group = this.line.getParent() // 获取曲线所在的父组 88 | 89 | if (this.isTooSmall()) { 90 | // 如果曲线太小,则销毁曲线对象并延时保存形状组状态 91 | this.line.destroy() 92 | Editor.TimerStart(this.pageNumber, _pageNumber => { 93 | this.setShapeGroupDone( 94 | group.id(), 95 | this.calculateLinesForStorage({ 96 | group: this.currentShapeGroup.konvaGroup, 97 | annotationType: this.currentAnnotation.pdfjsType, 98 | color: getRGB(this.currentAnnotation.style.color), 99 | thickness: this.currentAnnotation.style.strokeWidth || 2, 100 | opacity: this.currentAnnotation.style.opacity, 101 | pageIndex: this.pageNumber - 1 102 | }) 103 | ) 104 | this.currentShapeGroup = null 105 | }) 106 | return 107 | } 108 | 109 | // 否则,延时保存形状组状态 110 | Editor.TimerStart(this.pageNumber, _pageNumber => { 111 | this.setShapeGroupDone( 112 | group.id(), 113 | this.calculateLinesForStorage({ 114 | group: this.currentShapeGroup.konvaGroup, 115 | annotationType: this.currentAnnotation.pdfjsType, 116 | color: getRGB(this.currentAnnotation.style.color), 117 | thickness: this.currentAnnotation.style.strokeWidth || 2, 118 | opacity: this.currentAnnotation.style.opacity, 119 | pageIndex: this.pageNumber - 1 120 | }) 121 | ) 122 | this.currentShapeGroup = null 123 | }) 124 | 125 | this.line = null // 重置当前曲线对象为null 126 | } 127 | 128 | /** 129 | * 全局鼠标释放事件处理器,仅处理左键释放事件。 130 | * @param e MouseEvent 对象 131 | */ 132 | private globalPointerUpHandler = (e: MouseEvent) => { 133 | if (e.button !== 0) return // 只处理左键释放事件 134 | this.mouseUpHandler() // 调用指针释放处理方法 135 | window.removeEventListener('mouseup', this.globalPointerUpHandler) // 移除全局鼠标释放事件监听器 136 | } 137 | 138 | /** 139 | * 刷新 Pdfjs 注释存储,用于更新或修正注释组。 140 | * @param groupId 注释组 ID 141 | * @param groupString 注释组的序列化字符串 142 | * @param rawAnnotationStore 原始注释存储数据 143 | * @returns 更新后的 Pdfjs 注释存储对象 144 | */ 145 | public async refreshPdfjsAnnotationStorage(groupId: string, groupString: string, rawAnnotationStore: IAnnotationStore) { 146 | const ghostGroup = Konva.Node.create(groupString) // 通过序列化字符串创建临时组 147 | return this.calculateLinesForStorage({ 148 | group: ghostGroup, 149 | annotationType: rawAnnotationStore.pdfjsAnnotationStorage.annotationType, 150 | color: rawAnnotationStore.pdfjsAnnotationStorage.color, 151 | thickness: rawAnnotationStore.pdfjsAnnotationStorage.thickness, 152 | opacity: rawAnnotationStore.pdfjsAnnotationStorage.opacity, 153 | pageIndex: rawAnnotationStore.pdfjsAnnotationStorage.pageIndex 154 | }) 155 | } 156 | 157 | /** 158 | * 修正线条在组内的坐标。 159 | * @param line Konva.Line 对象 160 | * @param group Konva.Group 对象 161 | * @returns 修正后的点集 162 | */ 163 | private fixLineCoordinateForGroup(line: Konva.Line, group: Konva.Group) { 164 | const groupTransform = group.getTransform() // 获取组的全局变换矩阵 165 | const points = line.points() // 获取线条的局部点集 166 | const transformedPoints: number[] = [] 167 | 168 | // 遍历点集并应用组的变换 169 | for (let i = 0; i < points.length; i += 2) { 170 | const localX = points[i] 171 | const localY = points[i + 1] 172 | 173 | // 应用组的变换,将局部坐标转换为全局坐标 174 | const globalPos = groupTransform.point({ x: localX, y: localY }) 175 | 176 | transformedPoints.push(globalPos.x, globalPos.y) 177 | } 178 | 179 | return transformedPoints 180 | } 181 | 182 | /** 183 | * 将当前绘制的曲线数据转换为 PDF.js 所需的注释存储数据格式。 184 | * @param param0 参数对象 185 | * @returns 符合 PDF.js 注释存储数据格式的对象 186 | */ 187 | private calculateLinesForStorage({ 188 | group, 189 | annotationType, 190 | color, 191 | thickness, 192 | opacity, 193 | pageIndex 194 | }: { 195 | group: Konva.Group 196 | annotationType: PdfjsAnnotationEditorType 197 | color: any 198 | thickness: number 199 | opacity: number 200 | pageIndex: number 201 | }): IPdfjsAnnotationStorage { 202 | const canvasHeight = this.konvaStage.size().height / this.konvaStage.scale().y // 获取画布高度 203 | let minX = Infinity, 204 | minY = Infinity, 205 | maxX = -Infinity, 206 | maxY = -Infinity 207 | const path: Array<{ bezier: number[]; points: number[] }> = [] 208 | const lines = this.getGroupNodesByClassName(group, 'Line') as Konva.Line[] // 获取所有 Line 对象 209 | 210 | lines.forEach(line => { 211 | const originalPoints = this.fixLineCoordinateForGroup(line, group) // 获取曲线的点集 212 | const transformedPoints = originalPoints.map((coord, index) => { 213 | if (index % 2 !== 0) { 214 | // 转换 y 坐标到 PDF.js 坐标系 215 | const transformedY = canvasHeight - coord 216 | // 更新边界框计算 217 | minY = Math.min(minY, transformedY) 218 | maxY = Math.max(maxY, transformedY) 219 | return transformedY 220 | } else { 221 | // x 坐标保持不变,更新边界框计算 222 | minX = Math.min(minX, coord) 223 | maxX = Math.max(maxX, coord) 224 | return coord 225 | } 226 | }) 227 | 228 | // 将转换后的点集添加到路径数组中 229 | path.push({ 230 | bezier: transformedPoints, 231 | points: transformedPoints 232 | }) 233 | }) 234 | 235 | // 构建边界框数组 236 | const rect: [number, number, number, number] = [minX, minY, maxX, maxY] 237 | 238 | // 返回符合 PDF.js 注释存储数据格式的对象 239 | return { 240 | annotationType, 241 | color, 242 | thickness, 243 | opacity, 244 | paths: path, 245 | pageIndex, 246 | rect: rect, 247 | rotation: 0 248 | } 249 | } 250 | 251 | /** 252 | * 判断当前绘制的曲线是否太小。 253 | * @returns 如果曲线点集长度小于 5 返回 true,否则返回 false 254 | */ 255 | private isTooSmall(): boolean { 256 | return this.line.points().length < Editor.MinSize 257 | } 258 | 259 | protected mouseOutHandler() {} 260 | protected mouseEnterHandler() {} 261 | } 262 | -------------------------------------------------------------------------------- /src/annotation/painter/editor/editor_free_highlight.ts: -------------------------------------------------------------------------------- 1 | import Konva from 'konva' 2 | import { KonvaEventObject } from 'konva/lib/Node' 3 | import { IEditorOptions, Editor } from './editor' 4 | import { AnnotationType, IAnnotationStore, IPdfjsAnnotationStorage, PdfjsAnnotationEditorType } from '../../const/definitions' 5 | import { getRGB } from '../../utils/utils' 6 | 7 | export class EditorFreeHighlight extends Editor { 8 | private line: Konva.Line | null // 当前正在绘制的自由曲线 9 | 10 | constructor(EditorOptions: IEditorOptions) { 11 | super({ ...EditorOptions, editorType: AnnotationType.FREE_HIGHLIGHT }) 12 | this.line = null // 初始化当前曲线为 null 13 | } 14 | 15 | protected mouseOutHandler() {} 16 | protected mouseEnterHandler() {} 17 | 18 | /** 19 | * 处理鼠标或触摸指针按下事件,开始绘制自由曲线。 20 | * @param e Konva 事件对象 21 | */ 22 | protected mouseDownHandler(e: KonvaEventObject): void { 23 | if (e.currentTarget !== this.konvaStage) { 24 | return 25 | } 26 | 27 | this.line = null // 重置当前曲线对象 28 | this.isPainting = true 29 | 30 | this.currentShapeGroup = this.createShapeGroup() 31 | this.getBgLayer().add(this.currentShapeGroup.konvaGroup) 32 | 33 | const pos = this.konvaStage.getRelativePointerPosition() 34 | 35 | this.line = new Konva.Line({ 36 | stroke: this.currentAnnotation.style.color, 37 | strokeWidth: this.currentAnnotation.style.strokeWidth, 38 | opacity: this.currentAnnotation.style.opacity, 39 | hitStrokeWidth: this.currentAnnotation.style.strokeWidth, 40 | lineCap: 'round', 41 | lineJoin: 'round', 42 | visible: false, 43 | globalCompositeOperation: 'source-over', 44 | points: [pos.x, pos.y] // 初始化起始点 45 | }) 46 | 47 | this.currentShapeGroup.konvaGroup.add(this.line) // 将曲线添加到当前形状组中 48 | window.addEventListener('mouseup', this.globalPointerUpHandler) // 添加全局鼠标释放事件监听器 49 | } 50 | 51 | /** 52 | * 处理鼠标或触摸指针移动事件,绘制自由曲线。 53 | * @param e Konva 事件对象 54 | */ 55 | protected mouseMoveHandler(e: KonvaEventObject): void { 56 | if (!this.isPainting) { 57 | return 58 | } 59 | e.evt.preventDefault() // 阻止默认事件,如滚动页面 60 | this.line.show() // 显示当前绘制的曲线 61 | const pos = this.konvaStage.getRelativePointerPosition() 62 | const newPoints = this.line.points().concat([pos.x, pos.y]) 63 | this.line.points(newPoints) // 更新曲线的点集 64 | } 65 | 66 | /** 67 | * 处理鼠标或触摸指针释放事件,完成自由曲线的绘制。 68 | */ 69 | protected mouseUpHandler(): void { 70 | if (!this.isPainting) { 71 | return 72 | } 73 | 74 | this.isPainting = false // 结束绘制状态 75 | 76 | // 获取曲线的父节点组 77 | const group = this.line?.getParent() 78 | if (group && !this.line.isVisible() && group.getType() === 'Group') { 79 | this.delShapeGroup(group.id()) 80 | return 81 | } 82 | 83 | if (this.isTooSmall()) { 84 | this.line?.destroy() 85 | if (group) { 86 | this.delShapeGroup(group.id()) 87 | } 88 | this.line = null 89 | return 90 | } 91 | 92 | if (this.line) { 93 | const originalPoints = this.line.points() 94 | const correctedPoints = this.correctLineIfStraight(originalPoints) 95 | this.line.points(correctedPoints) // 更新线条点集为修正后的点集 96 | this.setShapeGroupDone( 97 | group.id(), 98 | this.calculateLinesForStorage({ 99 | line: this.line, 100 | group: this.currentShapeGroup.konvaGroup, 101 | annotationType: this.currentAnnotation.pdfjsType, 102 | color: getRGB(this.currentAnnotation.style.color), 103 | thickness: this.currentAnnotation.style.strokeWidth || 2, 104 | opacity: this.currentAnnotation.style.opacity, 105 | pageIndex: this.pageNumber - 1 106 | }) 107 | ) 108 | this.line = null 109 | } 110 | } 111 | 112 | /** 113 | * 全局鼠标释放事件处理器,仅处理左键释放事件。 114 | * @param e MouseEvent 对象 115 | */ 116 | private globalPointerUpHandler = (e: MouseEvent): void => { 117 | if (e.button !== 0) return // 只处理左键释放事件 118 | this.mouseUpHandler() // 调用指针释放处理方法 119 | window.removeEventListener('mouseup', this.globalPointerUpHandler) // 移除全局鼠标释放事件监听器 120 | } 121 | 122 | /** 123 | * 修正接近水平或垂直的线条,使其成为完全水平或垂直的直线。 124 | * @param points 曲线的点集 125 | * @returns 修正后的点集 126 | */ 127 | private correctLineIfStraight(points: number[]): number[] { 128 | // 阈值,用于判断线段接近水平或垂直 129 | const THRESHOLD_ANGLE_DEGREES = 2 130 | 131 | // 获取起始和结束点的坐标 132 | const startX = points[0] 133 | const startY = points[1] 134 | const endX = points[points.length - 2] 135 | const endY = points[points.length - 1] 136 | 137 | const deltaX = endX - startX 138 | const deltaY = endY - startY 139 | 140 | // 计算线段的角度 141 | const angleRad = Math.atan2(deltaY, deltaX) 142 | const angleDeg = Math.abs(angleRad * (180 / Math.PI)) 143 | 144 | // 判断线段是否接近水平或垂直 145 | const isCloseToHorizontal = angleDeg <= THRESHOLD_ANGLE_DEGREES || angleDeg >= 180 - THRESHOLD_ANGLE_DEGREES 146 | const isCloseToVertical = Math.abs(angleDeg - 90) <= THRESHOLD_ANGLE_DEGREES 147 | 148 | if (isCloseToHorizontal) { 149 | // 修正为水平线 150 | return points.map((value, index) => (index % 2 === 0 ? value : startY)) 151 | } else if (isCloseToVertical) { 152 | // 修正为垂直线 153 | return points.map((value, index) => (index % 2 === 0 ? startX : value)) 154 | } 155 | 156 | // 返回原始点集 157 | return points 158 | } 159 | 160 | private fixLineCoordinateForGroup(line: Konva.Line, group: Konva.Group) { 161 | // 获取组的全局变换矩阵 162 | const groupTransform = group.getTransform() 163 | 164 | // 获取线条的局部点集 165 | const points = line.points() 166 | const transformedPoints: number[] = [] 167 | 168 | // 遍历点集并应用组的变换 169 | for (let i = 0; i < points.length; i += 2) { 170 | const localX = points[i] 171 | const localY = points[i + 1] 172 | 173 | // 应用组的变换,将局部坐标转换为全局坐标 174 | const globalPos = groupTransform.point({ x: localX, y: localY }) 175 | 176 | transformedPoints.push(globalPos.x, globalPos.y) 177 | } 178 | 179 | return transformedPoints 180 | } 181 | 182 | /** 183 | * 将当前绘制的曲线数据转换为 PDF.js 所需的注释存储数据格式。 184 | * @returns 符合 PDF.js 注释存储数据格式的对象 185 | */ 186 | private calculateLinesForStorage({ 187 | group, 188 | line, 189 | annotationType, 190 | color, 191 | thickness, 192 | opacity, 193 | pageIndex 194 | }: { 195 | group: Konva.Group 196 | line: Konva.Line 197 | annotationType: PdfjsAnnotationEditorType 198 | color: any 199 | thickness: number 200 | opacity: number 201 | pageIndex: number 202 | }): IPdfjsAnnotationStorage { 203 | const canvasHeight = this.konvaStage.size().height / this.konvaStage.scale().y 204 | 205 | // 初始化边界框变量 206 | let minX = Infinity, 207 | minY = Infinity, 208 | maxX = -Infinity, 209 | maxY = -Infinity 210 | 211 | const path: Array<{ bezier: number[]; points: number[] }> = [] 212 | 213 | // 获取原始点集 214 | const originalPoints = this.fixLineCoordinateForGroup(line, group) || [] 215 | 216 | // 获取当前线条的宽度 217 | const strokeWidth = thickness 218 | const halfStrokeWidth = strokeWidth / 2 219 | 220 | // 转换点集并计算边界框 221 | const transformedPoints = originalPoints.map((coord, index) => { 222 | if (index % 2 !== 0) { 223 | // Y 坐标 224 | const transformedY = canvasHeight - coord 225 | // 考虑到笔触宽度的边界框计算 226 | minY = Math.min(minY, transformedY - halfStrokeWidth) 227 | maxY = Math.max(maxY, transformedY + halfStrokeWidth) 228 | return transformedY 229 | } else { 230 | // X 坐标 231 | // 考虑到笔触宽度的边界框计算 232 | minX = Math.min(minX, coord - halfStrokeWidth) 233 | maxX = Math.max(maxX, coord + halfStrokeWidth) 234 | return coord 235 | } 236 | }) 237 | 238 | // 添加转换后的点集到路径 239 | path.push({ 240 | bezier: this.generateBezierPoints(transformedPoints), 241 | points: transformedPoints 242 | }) 243 | 244 | // 构建边界框数组 245 | const rect: [number, number, number, number] = [minX, minY, maxX, maxY] 246 | 247 | // 返回符合 PDF.js 注释存储数据格式的对象 248 | return { 249 | annotationType, 250 | color, 251 | thickness: strokeWidth, 252 | opacity, 253 | paths: path, 254 | pageIndex, 255 | rect: rect, 256 | rotation: 0 257 | } 258 | } 259 | 260 | /** 261 | * 生成贝塞尔曲线点集(当前为占位符,仅返回原始点集)。 262 | * @param path 原始点集 263 | * @returns 生成的贝塞尔曲线点集 264 | */ 265 | private generateBezierPoints(path: number[]): number[] { 266 | // 当前实现仅返回原始点集 267 | return path 268 | } 269 | 270 | /** 271 | * 判断当前绘制的曲线是否太小。 272 | * @returns 如果曲线点集长度小于 5 返回 true,否则返回 false 273 | */ 274 | private isTooSmall(): boolean { 275 | return (this.line?.points().length || 0) < 5 276 | } 277 | 278 | /** 279 | * 刷新 Pdfjs 注释存储,用于更新或修正注释组。 280 | * @param groupId 注释组 ID 281 | * @param groupString 注释组的序列化字符串 282 | * @param rawAnnotationStore 原始注释存储数据 283 | * @returns 更新后的 Pdfjs 注释存储对象 284 | */ 285 | public async refreshPdfjsAnnotationStorage(groupId: string, groupString: string, rawAnnotationStore: IAnnotationStore) { 286 | const ghostGroup = Konva.Node.create(groupString) 287 | const line = this.getGroupNodesByClassName(ghostGroup, 'Line')[0] as Konva.Line 288 | return this.calculateLinesForStorage({ 289 | group: ghostGroup, 290 | line, 291 | annotationType: rawAnnotationStore.pdfjsAnnotationStorage.annotationType, 292 | color: rawAnnotationStore.pdfjsAnnotationStorage.color, 293 | thickness: rawAnnotationStore.pdfjsAnnotationStorage.thickness, 294 | opacity: rawAnnotationStore.pdfjsAnnotationStorage.opacity, 295 | pageIndex: rawAnnotationStore.pdfjsAnnotationStorage.pageIndex 296 | }) 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/annotation/painter/editor/editor_free_text.ts: -------------------------------------------------------------------------------- 1 | import Konva from "konva"; 2 | import { KonvaEventObject } from "konva/lib/Node"; 3 | 4 | import { IEditorOptions, Editor } from "./editor"; 5 | import { 6 | AnnotationType, 7 | IAnnotationStore, 8 | IPdfjsAnnotationStorage, 9 | PdfjsAnnotationEditorType, 10 | } from "../../const/definitions"; 11 | import { FREE_TEXT_TEXT_CLASS_NAME } from "../const"; 12 | import { base64ToImageBitmap } from "../../utils/utils"; 13 | 14 | /** 15 | * EditorFreeText 是继承自 Editor 的自由文本编辑器类。 16 | */ 17 | export class EditorFreeText extends Editor { 18 | /** 19 | * 创建一个 EditorFreeText 实例。 20 | * @param EditorOptions 初始化编辑器的选项 21 | */ 22 | constructor(EditorOptions: IEditorOptions) { 23 | super({ ...EditorOptions, editorType: AnnotationType.FREETEXT }); 24 | } 25 | 26 | protected mouseOutHandler() {} 27 | 28 | protected mouseEnterHandler() {} 29 | 30 | protected mouseDownHandler() {} 31 | 32 | protected mouseMoveHandler() {} 33 | 34 | /** 35 | * 处理鼠标抬起事件,创建输入区域。 36 | * @param e Konva 事件对象 37 | */ 38 | protected mouseUpHandler(e: KonvaEventObject) { 39 | if (e.currentTarget !== this.konvaStage) { 40 | return; 41 | } 42 | this.isPainting = true; 43 | this.currentShapeGroup = this.createShapeGroup(); 44 | this.getBgLayer().add(this.currentShapeGroup.konvaGroup); 45 | this.createInputArea(e); 46 | } 47 | 48 | /** 49 | * 创建输入区域(textarea)并设置样式。 50 | * @param e Konva 事件对象 51 | */ 52 | private createInputArea(e: KonvaEventObject) { 53 | const pos = this.konvaStage.getRelativePointerPosition(); 54 | const { y: scaleY } = this.konvaStage.scale(); 55 | 56 | // 创建和配置 textarea 元素 57 | const inputArea = document.createElement("textarea"); 58 | this.setupInputAreaStyles( 59 | inputArea, 60 | { 61 | x: e.evt.offsetX, 62 | y: e.evt.offsetY, 63 | }, 64 | scaleY, 65 | ); 66 | this.konvaStage.container().append(inputArea); 67 | 68 | // 设置初始焦点和选中状态 69 | setTimeout(() => { 70 | inputArea.focus(); 71 | inputArea.select(); 72 | }, 100); 73 | 74 | // 注册事件监听器 75 | this.addInputAreaEventListeners(inputArea, scaleY, pos); 76 | } 77 | 78 | /** 79 | * 设置 textarea 的样式和初始配置。 80 | * @param inputArea textarea 元素 81 | * @param evt 鼠标事件坐标 82 | * @param scaleY Y 轴缩放比例 83 | */ 84 | private setupInputAreaStyles( 85 | inputArea: HTMLTextAreaElement, 86 | evt: { x: number; y: number }, 87 | scaleY: number, 88 | ) { 89 | inputArea.placeholder = "开始输入..."; 90 | inputArea.rows = 1; 91 | inputArea.className = FREE_TEXT_TEXT_CLASS_NAME; 92 | 93 | // 使用 CSS 类管理样式而不是内联样式 94 | inputArea.style.width = "200px"; 95 | inputArea.style.left = `${evt.x}px`; 96 | inputArea.style.top = `${evt.y}px`; 97 | inputArea.style.height = `${this.currentAnnotation.style.fontSize * scaleY + 6}px`; 98 | inputArea.style.fontSize = `${this.currentAnnotation.style.fontSize * scaleY}px`; 99 | inputArea.style.color = this.currentAnnotation.style.color; 100 | } 101 | 102 | /** 103 | * 注册 textarea 的事件监听器。 104 | * @param inputArea textarea 元素 105 | * @param scaleY Y 轴缩放比例 106 | * @param pos 相对位置坐标 107 | */ 108 | private addInputAreaEventListeners( 109 | inputArea: HTMLTextAreaElement, 110 | scaleY: number, 111 | pos: { x: number; y: number }, 112 | ) { 113 | // 动态调整 textarea 的高度以适应输入内容 114 | inputArea.addEventListener("input", (e) => 115 | this.adjustTextareaHeight(e), 116 | ); 117 | 118 | // 失去焦点时处理 119 | inputArea.addEventListener("blur", async (e) => { 120 | const target = e.target as HTMLTextAreaElement; 121 | if (target.getAttribute("del") === "true") { 122 | this.removeInputArea(target); 123 | } else { 124 | this.inputDoneHandler(target, scaleY, pos); 125 | } 126 | }); 127 | 128 | // 处理键盘事件 129 | inputArea.addEventListener("keydown", (e) => 130 | this.handleInputAreaKeydown(e, scaleY), 131 | ); 132 | } 133 | 134 | /** 135 | * 动态调整 textarea 的高度。 136 | * @param event 输入事件对象 137 | */ 138 | private adjustTextareaHeight(event: Event) { 139 | const element = event.target as HTMLTextAreaElement; 140 | element.style.height = "auto"; // 重置高度以重新计算 141 | const scrollHeight = element.scrollHeight; 142 | element.style.height = `${scrollHeight}px`; // 设置为内容的实际高度 143 | } 144 | 145 | /** 146 | * 处理 textarea 的键盘事件。 147 | * @param e 键盘事件对象 148 | * @param scaleY Y 轴缩放比例 149 | */ 150 | private handleInputAreaKeydown(e: KeyboardEvent, scaleY: number) { 151 | const target = e.target as HTMLTextAreaElement; 152 | const scrollHeight = target.scrollHeight; 153 | 154 | if (e.key === "Enter") { 155 | if (e.shiftKey) { 156 | // 允许在按住 Shift 键的情况下换行 157 | target.style.height = `${scrollHeight + this.currentAnnotation.style.fontSize * scaleY}px`; 158 | } else { 159 | e.preventDefault(); 160 | e.stopPropagation(); 161 | target.blur(); // 完成输入并失去焦点 162 | } 163 | } 164 | 165 | if (e.key === "Escape") { 166 | target.setAttribute("del", "true"); // 标记为删除状态 167 | e.preventDefault(); 168 | e.stopPropagation(); 169 | target.blur(); // 取消输入并失去焦点 170 | } 171 | } 172 | 173 | /** 174 | * 处理输入完成后的操作。 175 | * @param inputArea textarea 元素 176 | * @param scaleY Y 轴缩放比例 177 | * @param pos 相对位置坐标 178 | */ 179 | private async inputDoneHandler( 180 | inputArea: HTMLTextAreaElement, 181 | scaleY: number, 182 | pos: { x: number; y: number }, 183 | ) { 184 | const value = inputArea.value.trim(); 185 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 186 | const textWidth = inputArea.offsetWidth; 187 | inputArea.remove(); 188 | 189 | if (value === "") { 190 | this.delShapeGroup(this.currentShapeGroup.id); 191 | this.currentShapeGroup = null; 192 | return; 193 | } 194 | const text = new Konva.Text({ 195 | x: pos.x, 196 | y: pos.y, 197 | text: value, 198 | fontSize: this.currentAnnotation.style.fontSize / scaleY, 199 | fill: this.currentAnnotation.style.color, 200 | }); 201 | 202 | this.currentShapeGroup.konvaGroup.add(text); 203 | 204 | // 将 Text 节点转换为 Image 205 | const imageUrl = await new Promise((resolve) => { 206 | text.toDataURL({ 207 | callback: (url) => resolve(url), 208 | }); 209 | }); 210 | 211 | // 使用生成的 imageUrl 创建 Konva.Image 212 | Konva.Image.fromURL(imageUrl, async (image) => { 213 | // 删除之前的文本节点 214 | this.getGroupNodesByClassName( 215 | this.currentShapeGroup.konvaGroup, 216 | "Text", 217 | )[0]?.destroy(); 218 | this.currentShapeGroup.konvaGroup.add(image); 219 | const { width: width_rec, height: height_rec } = 220 | image.getClientRect(); 221 | image.setAttrs({ 222 | x: pos.x, 223 | y: pos.y, 224 | width: width_rec, 225 | height: height_rec, 226 | base64: imageUrl, 227 | }); 228 | 229 | // 修正图像的坐标和尺寸 230 | const { x, y, width, height } = this.fixImageCoordinateForGroup( 231 | image, 232 | this.currentShapeGroup.konvaGroup, 233 | ); 234 | const id = this.currentShapeGroup.konvaGroup.id(); 235 | 236 | // 计算并保存图像的存储信息 237 | const storage = await this.calculateImageForStorage({ 238 | x, 239 | y, 240 | width, 241 | height, 242 | annotationType: this.currentAnnotation.pdfjsType, 243 | pageIndex: this.pageNumber - 1, 244 | imageUrl, 245 | id, 246 | }); 247 | 248 | // 标记当前形状组为完成状态 249 | this.setShapeGroupDone(id, storage, { 250 | image: imageUrl, 251 | }); 252 | }); 253 | } 254 | 255 | /** 256 | * 移除输入区域(textarea)并删除对应的形状组。 257 | * @param inputArea textarea 元素 258 | */ 259 | private removeInputArea(inputArea: HTMLTextAreaElement) { 260 | inputArea.remove(); 261 | inputArea = null; 262 | this.delShapeGroup(this.currentShapeGroup.id); 263 | } 264 | 265 | /** 266 | * 刷新 PDF.js 注解存储,返回计算后的存储信息。 267 | * @param groupId 形状组的 ID 268 | * @param groupString 序列化的组字符串 269 | * @param rawAnnotationStore 原始注解存储对象 270 | * @returns 返回注解的存储信息 IPdfjsAnnotationStorage 的 Promise 271 | */ 272 | public async refreshPdfjsAnnotationStorage( 273 | groupId: string, 274 | groupString: string, 275 | rawAnnotationStore: IAnnotationStore, 276 | ): Promise { 277 | const ghostGroup = Konva.Node.create(groupString); 278 | const image = this.getGroupNodesByClassName( 279 | ghostGroup, 280 | "Image", 281 | )[0] as Konva.Image; 282 | 283 | const { x, y, width, height } = this.fixImageCoordinateForGroup( 284 | image, 285 | ghostGroup, 286 | ); 287 | 288 | // 计算并返回注解的存储信息 289 | const annotationStorage = await this.calculateImageForStorage({ 290 | x, 291 | y, 292 | width, 293 | height, 294 | annotationType: 295 | rawAnnotationStore.pdfjsAnnotationStorage.annotationType, 296 | pageIndex: rawAnnotationStore.pdfjsAnnotationStorage.pageIndex, 297 | imageUrl: image.getAttr("base64"), 298 | id: groupId, 299 | }); 300 | 301 | return annotationStorage; 302 | } 303 | 304 | /** 305 | * 修正图像在组中的坐标和尺寸,返回全局坐标和尺寸。 306 | * @param image Konva.Image 对象 307 | * @param group Konva.Group 对象 308 | * @returns 返回修正后的坐标和尺寸 { x, y, width, height } 309 | */ 310 | private fixImageCoordinateForGroup(image: Konva.Image, group: Konva.Group) { 311 | const imageLocalRect = image.getClientRect({ relativeTo: group }); 312 | 313 | // 获取组的全局变换 314 | const groupTransform = group.getTransform(); 315 | 316 | // 使用组的变换将局部坐标转换为全局坐标 317 | const imageGlobalPos = groupTransform.point({ 318 | x: imageLocalRect.x, 319 | y: imageLocalRect.y, 320 | }); 321 | 322 | // 计算形状的全局宽度和高度 323 | const globalWidth = imageLocalRect.width * (group.attrs.scaleX || 1); 324 | const globalHeight = imageLocalRect.height * (group.attrs.scaleY || 1); 325 | 326 | return { 327 | x: imageGlobalPos.x, 328 | y: imageGlobalPos.y, 329 | width: globalWidth, 330 | height: globalHeight, 331 | }; 332 | } 333 | 334 | /** 335 | * 计算图像的存储信息,并返回 IPdfjsAnnotationStorage 对象。 336 | * @param param0 包含图像信息的参数对象 337 | * @returns 返回 Promise 对象 338 | */ 339 | private async calculateImageForStorage({ 340 | x, 341 | y, 342 | width, 343 | height, 344 | annotationType, 345 | pageIndex, 346 | imageUrl, 347 | id, 348 | }: { 349 | x: number; 350 | y: number; 351 | width: number; 352 | height: number; 353 | annotationType: PdfjsAnnotationEditorType; 354 | pageIndex: number; 355 | imageUrl: string; 356 | id: string; 357 | }): Promise { 358 | const canvasHeight = 359 | this.konvaStage.size().height / this.konvaStage.scale().y; 360 | 361 | // 计算矩形的右下角顶点坐标 362 | const rectBottomRightX: number = x + width; 363 | const rectBottomRightY: number = y + height; 364 | const rect: [number, number, number, number] = [ 365 | x, 366 | canvasHeight - y, 367 | rectBottomRightX, 368 | canvasHeight - rectBottomRightY, 369 | ]; 370 | 371 | // 构造并返回注解的存储信息对象 372 | const annotationStorage: IPdfjsAnnotationStorage = { 373 | annotationType, 374 | isSvg: false, 375 | bitmap: await base64ToImageBitmap(imageUrl), 376 | bitmapId: `image_${id}`, 377 | pageIndex, 378 | rect: rect, 379 | rotation: 0, 380 | }; 381 | 382 | return annotationStorage; 383 | } 384 | 385 | /** 386 | * 将序列化的组字符串添加到 Konva 舞台的背景层中。 387 | * @param konvaStage Konva 舞台对象 388 | * @param konvaString 序列化的 Konva 字符串 389 | */ 390 | public addSerializedGroupToLayer( 391 | konvaStage: Konva.Stage, 392 | konvaString: string, 393 | ) { 394 | const ghostGroup = Konva.Node.create(konvaString); 395 | const oldImage = this.getGroupNodesByClassName( 396 | ghostGroup, 397 | "Image", 398 | )[0] as Konva.Image; 399 | const imageUrl = oldImage.getAttr("base64"); 400 | 401 | // 使用 imageUrl 创建新的 Konva.Image 402 | Konva.Image.fromURL(imageUrl, async (image) => { 403 | image.setAttrs(oldImage.getAttrs()); 404 | oldImage.destroy(); 405 | ghostGroup.add(image); 406 | }); 407 | 408 | // 将组添加到背景层 409 | this.getBgLayer(konvaStage).add(ghostGroup); 410 | } 411 | } 412 | -------------------------------------------------------------------------------- /src/annotation/painter/editor/editor_highlight.ts: -------------------------------------------------------------------------------- 1 | import Konva from "konva"; 2 | import { IEditorOptions, Editor } from "./editor"; 3 | import { 4 | AnnotationType, 5 | IPdfjsAnnotationStorage, 6 | } from "../../const/definitions"; 7 | import { getRGB } from "../../utils/utils"; 8 | 9 | /** 10 | * EditorHighLight 是继承自 Editor 的高亮编辑器类。 11 | */ 12 | export class EditorHighLight extends Editor { 13 | /** 14 | * 创建一个 EditorHighLight 实例。 15 | * @param EditorOptions 初始化编辑器的选项 16 | * @param editorType 注释类型 17 | */ 18 | constructor(EditorOptions: IEditorOptions, editorType: AnnotationType) { 19 | super({ ...EditorOptions, editorType }); 20 | } 21 | 22 | /** 23 | * 将网页上选中文字区域转换为图形并绘制在 Canvas 上。 24 | * @param elements HTMLSpanElement 数组,表示要绘制的元素 25 | * @param fixElement 用于修正计算的元素 26 | */ 27 | public convertTextSelection( 28 | elements: HTMLSpanElement[], 29 | fixElement: HTMLDivElement, 30 | ) { 31 | this.currentShapeGroup = this.createShapeGroup(); 32 | this.getBgLayer().add(this.currentShapeGroup.konvaGroup); 33 | 34 | // 获取基准元素的边界矩形,用于计算相对坐标 35 | const fixBounding = fixElement.getBoundingClientRect(); 36 | 37 | elements.forEach((spanEl) => { 38 | const bounding = spanEl.getBoundingClientRect(); 39 | const { x, y, width, height } = this.calculateRelativePosition( 40 | bounding, 41 | fixBounding, 42 | ); 43 | const shape = this.createShape(x, y, width, height); 44 | this.currentShapeGroup.konvaGroup.add(shape); 45 | }); 46 | 47 | this.setShapeGroupDone( 48 | this.currentShapeGroup.id, 49 | this.calculateHighlightForStorage(), 50 | { 51 | text: this.getElementOuterText(elements), 52 | }, 53 | ); 54 | } 55 | 56 | /** 57 | * 获取所有 elements 内部文字。 58 | * @param elements HTMLSpanElement 数组 59 | * @returns 所有元素内部文字的字符串 60 | */ 61 | private getElementOuterText(elements: HTMLSpanElement[]): string { 62 | return elements.map((el) => el.outerText).join(""); 63 | } 64 | 65 | /** 66 | * 计算元素的相对位置和尺寸,适配 Canvas 坐标系。 67 | * @param elementBounding 元素的边界矩形 68 | * @param fixBounding 基准元素的边界矩形 69 | * @returns 相对位置和尺寸的对象 { x, y, width, height } 70 | */ 71 | private calculateRelativePosition( 72 | elementBounding: DOMRect, 73 | fixBounding: DOMRect, 74 | ) { 75 | const scale = this.konvaStage.scale(); 76 | const x = (elementBounding.x - fixBounding.x) / scale.x; 77 | const y = (elementBounding.y - fixBounding.y) / scale.y; 78 | const width = elementBounding.width / scale.x; 79 | const height = elementBounding.height / scale.y; 80 | return { x, y, width, height }; 81 | } 82 | 83 | /** 84 | * 根据当前的注释类型创建对应的形状。 85 | * @param x 形状的 X 坐标 86 | * @param y 形状的 Y 坐标 87 | * @param width 形状的宽度 88 | * @param height 形状的高度 89 | * @returns Konva.Shape 具体类型的形状 90 | */ 91 | private createShape( 92 | x: number, 93 | y: number, 94 | width: number, 95 | height: number, 96 | ): Konva.Shape { 97 | switch (this.currentAnnotation.type) { 98 | case AnnotationType.HIGHLIGHT: 99 | return this.createHighlightShape(x, y, width, height); 100 | case AnnotationType.UNDERLINE: 101 | return this.createUnderlineShape(x, y, width, height); 102 | case AnnotationType.STRIKEOUT: 103 | return this.createStrikeoutShape(x, y, width, height); 104 | default: 105 | throw new Error( 106 | `Unsupported annotation type: ${this.currentAnnotation.type}`, 107 | ); 108 | } 109 | } 110 | 111 | /** 112 | * 创建高亮形状。 113 | * @param x 形状的 X 坐标 114 | * @param y 形状的 Y 坐标 115 | * @param width 形状的宽度 116 | * @param height 形状的高度 117 | * @returns Konva.Rect 高亮形状对象 118 | */ 119 | private createHighlightShape( 120 | x: number, 121 | y: number, 122 | width: number, 123 | height: number, 124 | ): Konva.Rect { 125 | return new Konva.Rect({ 126 | x, 127 | y, 128 | width, 129 | height, 130 | opacity: this.currentAnnotation.style.opacity || 0.5, 131 | fill: this.currentAnnotation.style.color, 132 | }); 133 | } 134 | 135 | /** 136 | * 创建下划线形状。 137 | * @param x 形状的 X 坐标 138 | * @param y 形状的 Y 坐标 139 | * @param width 形状的宽度 140 | * @param height 形状的高度 141 | * @returns Konva.Rect 下划线形状对象 142 | */ 143 | private createUnderlineShape( 144 | x: number, 145 | y: number, 146 | width: number, 147 | height: number, 148 | ): Konva.Rect { 149 | return new Konva.Rect({ 150 | x, 151 | y: height + y - 2, 152 | width, 153 | stroke: this.currentAnnotation.style.color, 154 | opacity: this.currentAnnotation.style.opacity, 155 | strokeWidth: 1, 156 | hitStrokeWidth: 10, 157 | height: 1, 158 | }); 159 | } 160 | 161 | /** 162 | * 创建删除线形状。 163 | * @param x 形状的 X 坐标 164 | * @param y 形状的 Y 坐标 165 | * @param width 形状的宽度 166 | * @param height 形状的高度 167 | * @returns Konva.Rect 删除线形状对象 168 | */ 169 | private createStrikeoutShape( 170 | x: number, 171 | y: number, 172 | width: number, 173 | height: number, 174 | ): Konva.Rect { 175 | return new Konva.Rect({ 176 | x, 177 | y: y + height / 2, 178 | width, 179 | stroke: this.currentAnnotation.style.color, 180 | opacity: this.currentAnnotation.style.opacity, 181 | strokeWidth: 1, 182 | hitStrokeWidth: 10, 183 | height: 1, 184 | }); 185 | } 186 | 187 | /** 188 | * 刷新 PDF.js 注解存储,目前未实现具体逻辑。 189 | * @param groupId 形状组的 ID 190 | * @param groupString 序列化的组字符串 191 | * @returns 返回空 Promise 192 | */ 193 | public async refreshPdfjsAnnotationStorage( 194 | groupId: string, 195 | groupString: string, 196 | ): Promise { 197 | return null; 198 | } 199 | 200 | /** 201 | * 计算并存储当前高亮注释信息。 202 | * @returns 返回高亮注释的存储信息 IPdfjsAnnotationStorage 203 | */ 204 | private calculateHighlightForStorage(): IPdfjsAnnotationStorage { 205 | const allHighlights: Konva.Rect[] = 206 | this.getNodesByClassName("Rect"); 207 | const quadPoints: number[] = []; 208 | const outlines: number[][] = []; 209 | 210 | let minX = Infinity; 211 | let minY = Infinity; 212 | let maxX = -Infinity; 213 | let maxY = -Infinity; 214 | 215 | const canvasHeight = 216 | this.konvaStage.size().height / this.konvaStage.scale().y; 217 | 218 | allHighlights.forEach((shape) => { 219 | const { x, y, width, height } = shape.attrs; 220 | 221 | // 计算矩形的四个顶点坐标,并转换到 PDF.js 坐标系 222 | const topLeft = [x, canvasHeight - y]; 223 | const bottomLeft = [x, canvasHeight - (y + height)]; 224 | const bottomRight = [x + width, canvasHeight - (y + height)]; 225 | const topRight = [x + width, canvasHeight - y]; 226 | 227 | // 对于 outlines,顺序是:左上,左下,右下,右上 228 | const rectOutlines = [ 229 | ...topLeft, 230 | ...bottomLeft, 231 | ...bottomRight, 232 | ...topRight, 233 | ]; 234 | outlines.push(rectOutlines); 235 | 236 | // 对于 quadPoints,顺序是:左上,右上,左下,右下 237 | const rectQuadPoints = [ 238 | ...topLeft, 239 | ...topRight, 240 | ...bottomLeft, 241 | ...bottomRight, 242 | ]; 243 | quadPoints.push(...rectQuadPoints); 244 | 245 | // 更新边界 246 | minX = Math.min(minX, x); 247 | minY = Math.min(minY, y); 248 | maxX = Math.max(maxX, x + width); 249 | maxY = Math.max(maxY, y + height); 250 | }); 251 | 252 | // 构建包含所有形状的边界矩形 (rect) 253 | const rect: [number, number, number, number] = [ 254 | minX, 255 | canvasHeight - maxY, 256 | maxX, 257 | canvasHeight - minY, 258 | ]; 259 | 260 | // 构建综合存储对象 261 | return { 262 | annotationType: this.currentAnnotation.pdfjsType, 263 | color: getRGB(this.currentAnnotation.style.color), 264 | opacity: this.currentAnnotation.style.opacity, 265 | quadPoints: quadPoints, 266 | outlines: outlines, 267 | rect: rect, 268 | pageIndex: this.pageNumber - 1, 269 | rotation: 0, 270 | }; 271 | } 272 | 273 | /** 274 | * 处理鼠标按下事件,目前未实现具体逻辑。 275 | */ 276 | protected mouseDownHandler() {} 277 | 278 | /** 279 | * 处理鼠标移动事件,目前未实现具体逻辑。 280 | */ 281 | protected mouseMoveHandler() {} 282 | 283 | /** 284 | * 处理鼠标抬起事件,目前未实现具体逻辑。 285 | */ 286 | protected mouseUpHandler() {} 287 | 288 | /** 289 | * 处理鼠标移出事件,目前未实现具体逻辑。 290 | */ 291 | protected mouseOutHandler() {} 292 | 293 | /** 294 | * 处理鼠标移入事件,目前未实现具体逻辑。 295 | */ 296 | protected mouseEnterHandler() {} 297 | } 298 | -------------------------------------------------------------------------------- /src/annotation/painter/editor/editor_rectangle.ts: -------------------------------------------------------------------------------- 1 | import Konva from 'konva' 2 | import { KonvaEventObject } from 'konva/lib/Node' 3 | 4 | import { IEditorOptions, Editor } from './editor' 5 | import { AnnotationType, IAnnotationStore, IPdfjsAnnotationStorage, PdfjsAnnotationEditorType } from '../../const/definitions' 6 | import { getRGB } from '../../utils/utils' 7 | 8 | /** 9 | * EditorRectangle 是继承自 Editor 的矩形编辑器类。 10 | */ 11 | export class EditorRectangle extends Editor { 12 | private rect: Konva.Rect // 当前正在绘制的矩形对象 13 | private vertex: { x: number; y: number } // 矩形的起始顶点坐标 14 | 15 | /** 16 | * 创建一个 EditorRectangle 实例。 17 | * @param EditorOptions 初始化编辑器的选项 18 | */ 19 | constructor(EditorOptions: IEditorOptions) { 20 | super({ ...EditorOptions, editorType: AnnotationType.RECTANGLE }) // 调用父类的构造函数 21 | this.rect = null 22 | this.vertex = { x: 0, y: 0 } 23 | } 24 | 25 | /** 26 | * 处理鼠标按下事件的方法,创建新的矩形对象并添加到舞台。 27 | * @param e Konva 事件对象 28 | */ 29 | protected mouseDownHandler(e: KonvaEventObject) { 30 | if (e.currentTarget !== this.konvaStage) { 31 | return 32 | } 33 | this.rect = null 34 | this.isPainting = true 35 | this.currentShapeGroup = this.createShapeGroup() // 创建新的形状组 36 | this.getBgLayer().add(this.currentShapeGroup.konvaGroup) // 将形状组添加到背景图层 37 | const pos = this.konvaStage.getRelativePointerPosition() 38 | this.vertex = { x: pos.x, y: pos.y } // 记录鼠标按下时的坐标作为矩形的起始点 39 | this.rect = new Konva.Rect({ 40 | x: pos.x, 41 | y: pos.y, 42 | width: 0, 43 | height: 0, 44 | visible: false, 45 | stroke: this.currentAnnotation.style.color, 46 | strokeWidth: this.currentAnnotation.style.strokeWidth || 2, 47 | opacity: this.currentAnnotation.style.opacity 48 | }) 49 | this.currentShapeGroup.konvaGroup.add(this.rect) // 将矩形添加到形状组 50 | window.addEventListener('mouseup', this.globalPointerUpHandler) // 添加全局鼠标抬起事件监听器 51 | } 52 | 53 | /** 54 | * 处理鼠标移动事件的方法,实时更新绘制的矩形对象的大小和位置。 55 | * @param e Konva 事件对象 56 | */ 57 | protected mouseMoveHandler(e: KonvaEventObject) { 58 | if (!this.isPainting) { 59 | return 60 | } 61 | e.evt.preventDefault() 62 | this.rect.show() // 显示矩形 63 | const pos = this.konvaStage.getRelativePointerPosition() 64 | const areaAttr = { 65 | x: Math.min(this.vertex.x, pos.x), 66 | y: Math.min(this.vertex.y, pos.y), 67 | width: Math.abs(pos.x - this.vertex.x), 68 | height: Math.abs(pos.y - this.vertex.y) 69 | } 70 | this.rect.setAttrs(areaAttr) // 更新矩形的属性(位置和大小) 71 | } 72 | 73 | /** 74 | * 处理鼠标抬起事件的方法,完成矩形的绘制并更新到 PDF.js 注解存储。 75 | */ 76 | protected mouseUpHandler() { 77 | if (!this.isPainting) { 78 | return 79 | } 80 | this.isPainting = false 81 | const group = this.rect.getParent() // 获取矩形所在的组 82 | if (!this.rect.isVisible() && group.getType() === 'Group') { 83 | this.delShapeGroup(group.id()) // 如果矩形不可见且在组中,则删除该组 84 | return 85 | } 86 | if (this.isTooSmall()) { 87 | this.rect.destroy() // 如果矩形太小,则销毁矩形对象 88 | this.delShapeGroup(group.id()) // 删除矩形所在的组 89 | this.rect = null 90 | return 91 | } 92 | const { x, y, width, height } = this.fixShapeCoordinateForGroup(this.rect, this.currentShapeGroup.konvaGroup) // 调整矩形在组中的坐标 93 | this.setShapeGroupDone( 94 | group.id(), 95 | this.calculateRectForStorage({ 96 | x, 97 | y, 98 | width, 99 | height, 100 | annotationType: this.currentAnnotation.pdfjsType, 101 | color: getRGB(this.currentAnnotation.style.color), 102 | thickness: this.currentAnnotation.style.strokeWidth || 2, 103 | opacity: this.currentAnnotation.style.opacity, 104 | pageIndex: this.pageNumber - 1 105 | }) 106 | ) // 更新 PDF.js 注解存储 107 | this.rect = null 108 | } 109 | 110 | /** 111 | * 全局鼠标抬起事件处理器,仅处理左键释放事件。 112 | * @param e MouseEvent 对象 113 | */ 114 | private globalPointerUpHandler = (e: MouseEvent) => { 115 | if (e.button !== 0) return // 只处理左键释放事件 116 | this.mouseUpHandler() // 调用鼠标抬起处理方法 117 | window.removeEventListener('mouseup', this.globalPointerUpHandler) // 移除全局鼠标抬起事件监听器 118 | } 119 | 120 | /** 121 | * 刷新 PDF.js 注解存储,从序列化的组字符串中恢复矩形的信息。 122 | * @param groupId 形状组的 ID 123 | * @param groupString 序列化的组字符串 124 | * @param rawAnnotationStore 原始注解存储对象 125 | * @returns 返回更新后的 PDF.js 注解存储对象的 Promise 126 | */ 127 | public async refreshPdfjsAnnotationStorage(groupId: string, groupString: string, rawAnnotationStore: IAnnotationStore): Promise { 128 | const ghostGroup = Konva.Node.create(groupString) // 根据序列化的组字符串创建 Konva 节点 129 | const rect = this.getGroupNodesByClassName(ghostGroup, 'Rect')[0] as Konva.Rect // 获取组中的矩形对象 130 | const { x, y, width, height } = this.fixShapeCoordinateForGroup(rect, ghostGroup) // 调整矩形在组中的坐标 131 | return this.calculateRectForStorage({ 132 | x, 133 | y, 134 | width, 135 | height, 136 | annotationType: rawAnnotationStore.pdfjsAnnotationStorage.annotationType, 137 | color: rawAnnotationStore.pdfjsAnnotationStorage.color, 138 | thickness: rawAnnotationStore.pdfjsAnnotationStorage.thickness, 139 | opacity: rawAnnotationStore.pdfjsAnnotationStorage.opacity, 140 | pageIndex: rawAnnotationStore.pdfjsAnnotationStorage.pageIndex 141 | }) // 计算并返回更新后的 PDF.js 注解存储对象 142 | } 143 | 144 | /** 145 | * 调整矩形在组中的坐标和大小。 146 | * @param shape Konva.Rect 对象(矩形) 147 | * @param group Konva.Group 对象(组) 148 | * @returns 返回调整后的坐标信息 149 | */ 150 | private fixShapeCoordinateForGroup(shape: Konva.Rect, group: Konva.Group) { 151 | const shapeLocalRect = shape.getClientRect({ relativeTo: group }) // 获取矩形在组中的局部坐标 152 | 153 | const groupTransform = group.getTransform() // 获取组的全局变换 154 | 155 | const shapeGlobalPos = groupTransform.point({ 156 | x: shapeLocalRect.x, 157 | y: shapeLocalRect.y 158 | }) // 使用组的变换将局部坐标转换为全局坐标 159 | 160 | const globalWidth = shapeLocalRect.width * (group.attrs.scaleX || 1) // 计算形状的全局宽度 161 | const globalHeight = shapeLocalRect.height * (group.attrs.scaleY || 1) // 计算形状的全局高度 162 | 163 | return { 164 | x: shapeGlobalPos.x, 165 | y: shapeGlobalPos.y, 166 | width: globalWidth, 167 | height: globalHeight 168 | } 169 | } 170 | 171 | /** 172 | * 将矩形数据转换为 PDF.js 注解存储所需的数据格式。 173 | * @param param0 包含矩形和相关信息的参数 174 | * @returns 返回处理后的 PDF.js 注解存储对象 175 | */ 176 | private calculateRectForStorage({ 177 | x, 178 | y, 179 | width, 180 | height, 181 | annotationType, 182 | color, 183 | thickness, 184 | opacity, 185 | pageIndex 186 | }: { 187 | x: number 188 | y: number 189 | width: number 190 | height: number 191 | annotationType: PdfjsAnnotationEditorType 192 | color: any 193 | thickness: number 194 | opacity: number 195 | pageIndex: number 196 | }): IPdfjsAnnotationStorage { 197 | const canvasHeight = this.konvaStage.size().height / this.konvaStage.scale().y // 获取舞台的缩放后的高度 198 | const halfInterval: number = 0.5 // 半间隔大小 199 | const points: number[] = [] // 用于存储顶点坐标的数组 200 | 201 | const rectBottomRightX: number = x + width // 计算矩形的右下角顶点 X 坐标 202 | const rectBottomRightY: number = y + height // 计算矩形的右下角顶点 Y 坐标 203 | const rect: [number, number, number, number] = [x, canvasHeight - y, rectBottomRightX, canvasHeight - rectBottomRightY] // 组装矩形坐标信息 204 | 205 | // 添加矩形边框上的顶点坐标 206 | // 左边缘上的点 207 | for (let i = y; i < rectBottomRightY; i += halfInterval) { 208 | points.push(x, canvasHeight - i) 209 | } 210 | // 底边缘上的点 211 | for (let i = x + halfInterval; i < rectBottomRightX; i += halfInterval) { 212 | points.push(i, canvasHeight - rectBottomRightY) 213 | } 214 | // 右边缘上的点 215 | for (let i = rectBottomRightY - halfInterval; i >= y; i -= halfInterval) { 216 | points.push(rectBottomRightX, canvasHeight - i) 217 | } 218 | // 顶边缘上的点 219 | for (let i = rectBottomRightX - halfInterval; i >= x + halfInterval; i -= halfInterval) { 220 | points.push(i, canvasHeight - y) 221 | } 222 | 223 | return { 224 | annotationType, 225 | color, 226 | thickness, 227 | opacity, 228 | paths: [{ bezier: points, points: points }], // 存储路径信息 229 | pageIndex, 230 | rect: rect, // 存储矩形的坐标信息 231 | rotation: 0 // 默认旋转角度为 0 232 | } 233 | } 234 | 235 | /** 236 | * 判断矩形是否太小(小于最小允许大小)。 237 | * @returns 如果矩形太小返回 true,否则返回 false 238 | */ 239 | private isTooSmall(): boolean { 240 | const { width, height } = this.rect.size() // 获取矩形的宽度和高度 241 | return Math.max(width, height) < Editor.MinSize // 判断宽度和高度的最大值是否小于最小允许大小 242 | } 243 | 244 | /** 245 | * 空方法,处理鼠标移出事件。 246 | */ 247 | protected mouseOutHandler() {} 248 | 249 | /** 250 | * 空方法,处理鼠标移入事件。 251 | */ 252 | protected mouseEnterHandler() {} 253 | } 254 | -------------------------------------------------------------------------------- /src/annotation/painter/editor/editor_signature.ts: -------------------------------------------------------------------------------- 1 | import Konva from 'konva' 2 | import { KonvaEventObject } from 'konva/lib/Node' 3 | 4 | import { IEditorOptions, Editor } from './editor' 5 | import { AnnotationType, DefaultSettings, IAnnotationStore, IAnnotationType, IPdfjsAnnotationStorage, PdfjsAnnotationEditorType } from '../../const/definitions' 6 | import { base64ToImageBitmap, resizeImage, setCssCustomProperty } from '../../utils/utils' 7 | import { CURSOR_CSS_PROPERTY } from '../const' 8 | 9 | /** 10 | * EditorSignature 是继承自 Editor 的签名编辑器类。 11 | */ 12 | export class EditorSignature extends Editor { 13 | private signatureUrl: string // 签名图片的 URL 14 | private signatureImage: Konva.Image // Konva.Image 对象用于显示签名图片 15 | 16 | /** 17 | * 创建一个 EditorSignature 实例。 18 | * @param EditorOptions 初始化编辑器的选项 19 | * @param defaultSignatureUrl 默认的签名图片 URL 20 | */ 21 | constructor(EditorOptions: IEditorOptions, defaultSignatureUrl: string | null) { 22 | super({ ...EditorOptions, editorType: AnnotationType.SIGNATURE }) // 调用父类的构造函数 23 | this.signatureUrl = defaultSignatureUrl // 设置签名图片 URL 24 | if (defaultSignatureUrl) { 25 | this.createCursorImg() // 如果有默认签名图片 URL,则创建光标图像 26 | } 27 | } 28 | 29 | /** 30 | * 创建光标图像,并设置 CSS 自定义属性。 31 | */ 32 | private createCursorImg() { 33 | const cursorGroup = new Konva.Group({ 34 | draggable: false 35 | }) 36 | 37 | // 从 URL 加载签名图片并处理 38 | Konva.Image.fromURL(this.signatureUrl, image => { 39 | const { width, height } = image.getClientRect() 40 | const { newWidth, newHeight } = resizeImage(width, height, DefaultSettings.MAX_CURSOR_SIZE) 41 | const crosshair = { x: newWidth / 2, y: newHeight / 2 } 42 | 43 | // 创建边框和交叉线 44 | const border = new Konva.Rect({ 45 | x: 0, 46 | y: 0, 47 | width: newWidth, 48 | height: newHeight, 49 | stroke: 'red', 50 | strokeWidth: 2 51 | }) 52 | const horizontalLine = new Konva.Line({ 53 | points: [0, crosshair.y, newWidth, crosshair.y], 54 | stroke: 'red', 55 | strokeWidth: 1, 56 | dash: [5, 5] 57 | }) 58 | const verticalLine = new Konva.Line({ 59 | points: [crosshair.x, 0, crosshair.x, newHeight], 60 | stroke: 'red', 61 | strokeWidth: 1, 62 | dash: [5, 5] 63 | }) 64 | const point = new Konva.Circle({ 65 | x: crosshair.x, 66 | y: crosshair.y, 67 | radius: 5, 68 | stroke: 'red' 69 | }) 70 | 71 | // 设置签名图片属性并添加到组中 72 | image.setAttrs({ 73 | x: 0, 74 | y: 0, 75 | width: newWidth, 76 | height: newHeight, 77 | visible: true 78 | }) 79 | cursorGroup.add(image, horizontalLine, verticalLine, point, border) 80 | 81 | // 将组转换为数据 URL,并设置 CSS 自定义属性 82 | const cursorImg = cursorGroup.toDataURL() 83 | cursorGroup.destroy() 84 | setCssCustomProperty(CURSOR_CSS_PROPERTY, `url(${cursorImg}) ${crosshair.x} ${crosshair.y}, default`) 85 | }) 86 | } 87 | 88 | /** 89 | * 处理鼠标按下事件的方法,创建新的形状组并添加签名图片。 90 | * @param e Konva 事件对象 91 | */ 92 | protected mouseDownHandler(e: KonvaEventObject) { 93 | if (e.currentTarget !== this.konvaStage) { 94 | return // 如果事件不是在舞台上发生的,则直接返回 95 | } 96 | this.signatureImage = null 97 | this.currentShapeGroup = this.createShapeGroup() // 创建新的形状组 98 | this.getBgLayer().add(this.currentShapeGroup.konvaGroup) // 将形状组添加到背景图层 99 | const pos = this.konvaStage.getRelativePointerPosition() 100 | 101 | // 从 URL 加载签名图片并处理 102 | Konva.Image.fromURL(this.signatureUrl, async image => { 103 | const { width: width_rec, height: height_rec } = image.getClientRect() 104 | const { newWidth, newHeight } = resizeImage(width_rec, height_rec, 120) 105 | const crosshair = { x: newWidth / 2, y: newHeight / 2 } 106 | 107 | this.signatureImage = image 108 | this.signatureImage.setAttrs({ 109 | x: pos.x - crosshair.x, 110 | y: pos.y - crosshair.y, 111 | width: newWidth, 112 | height: newHeight, 113 | base64: this.signatureUrl 114 | }) 115 | this.currentShapeGroup.konvaGroup.add(this.signatureImage) 116 | 117 | // 调整图片坐标并更新 PDF.js 注解存储 118 | const { x, y, width, height } = this.fixImageCoordinateForGroup(this.signatureImage, this.currentShapeGroup.konvaGroup) 119 | const id = this.currentShapeGroup.konvaGroup.id() 120 | this.setShapeGroupDone( 121 | id, 122 | await this.calculateImageForStorage({ 123 | x, 124 | y, 125 | width, 126 | height, 127 | annotationType: this.currentAnnotation.pdfjsType, 128 | pageIndex: this.pageNumber - 1, 129 | signatureUrl: this.signatureUrl, 130 | id 131 | }), 132 | { 133 | image: this.signatureUrl 134 | } 135 | ) 136 | this.signatureImage = null 137 | }) 138 | } 139 | 140 | /** 141 | * 刷新 PDF.js 注解存储,从序列化的组字符串中恢复签名图片的信息。 142 | * @param groupId 形状组的 ID 143 | * @param groupString 序列化的组字符串 144 | * @param rawAnnotationStore 原始注解存储对象 145 | * @returns 返回更新后的 PDF.js 注解存储对象的 Promise 146 | */ 147 | public async refreshPdfjsAnnotationStorage(groupId: string, groupString: string, rawAnnotationStore: IAnnotationStore): Promise { 148 | const ghostGroup = Konva.Node.create(groupString) 149 | const image = this.getGroupNodesByClassName(ghostGroup, 'Image')[0] as Konva.Image 150 | const { x, y, width, height } = this.fixImageCoordinateForGroup(image, ghostGroup) 151 | 152 | // 计算并返回更新后的 PDF.js 注解存储对象 153 | const annotationStorage = await this.calculateImageForStorage({ 154 | x, 155 | y, 156 | width, 157 | height, 158 | annotationType: rawAnnotationStore.pdfjsAnnotationStorage.annotationType, 159 | pageIndex: rawAnnotationStore.pdfjsAnnotationStorage.pageIndex, 160 | signatureUrl: image.getAttr('base64'), 161 | id: groupId 162 | }) 163 | return annotationStorage 164 | } 165 | 166 | /** 167 | * 调整签名图片在组内的坐标。 168 | * @param image Konva.Image 对象 169 | * @param group Konva.Group 对象 170 | * @returns 返回调整后的坐标信息 171 | */ 172 | private fixImageCoordinateForGroup(image: Konva.Image, group: Konva.Group) { 173 | const imageLocalRect = image.getClientRect({ relativeTo: group }) 174 | 175 | // 获取组的全局变换 176 | const groupTransform = group.getTransform() 177 | 178 | // 使用组的变换将局部坐标转换为全局坐标 179 | const imageGlobalPos = groupTransform.point({ 180 | x: imageLocalRect.x, 181 | y: imageLocalRect.y 182 | }) 183 | 184 | // 计算形状的全局宽度和高度 185 | const globalWidth = imageLocalRect.width * (group.attrs.scaleX || 1) 186 | const globalHeight = imageLocalRect.height * (group.attrs.scaleY || 1) 187 | 188 | return { 189 | x: imageGlobalPos.x, 190 | y: imageGlobalPos.y, 191 | width: globalWidth, 192 | height: globalHeight 193 | } 194 | } 195 | 196 | /** 197 | * 将签名图片数据转换为 PDF.js 注解存储所需的数据格式。 198 | * @param param0 包含签名图片和相关信息的参数 199 | * @returns 返回处理后的 PDF.js 注解存储对象的 Promise 200 | */ 201 | private async calculateImageForStorage({ 202 | x, 203 | y, 204 | width, 205 | height, 206 | annotationType, 207 | pageIndex, 208 | signatureUrl, 209 | id 210 | }: { 211 | x: number 212 | y: number 213 | width: number 214 | height: number 215 | annotationType: PdfjsAnnotationEditorType 216 | pageIndex: number 217 | signatureUrl: string 218 | id: string 219 | }): Promise { 220 | const canvasHeight = this.konvaStage.size().height / this.konvaStage.scale().y 221 | const rectBottomRightX: number = x + width 222 | const rectBottomRightY: number = y + height 223 | const rect: [number, number, number, number] = [x, canvasHeight - y, rectBottomRightX, canvasHeight - rectBottomRightY] 224 | 225 | // 构造 PDF.js 注解存储对象 226 | const annotationStorage: IPdfjsAnnotationStorage = { 227 | annotationType, 228 | isSvg: false, 229 | bitmap: await base64ToImageBitmap(signatureUrl), 230 | bitmapId: `image_${id}`, 231 | pageIndex, 232 | rect, 233 | rotation: 0 234 | } 235 | return annotationStorage 236 | } 237 | /** 238 | * 激活编辑器并设置签名图片。 239 | * @param konvaStage Konva 舞台对象 240 | * @param annotation 新的注解对象 241 | * @param signatureUrl 签名图片的 URL 242 | */ 243 | public activateWithSignature(konvaStage: Konva.Stage, annotation: IAnnotationType, signatureUrl: string | null) { 244 | super.activate(konvaStage, annotation) // 调用父类的激活方法 245 | this.signatureUrl = signatureUrl // 设置签名图片 URL 246 | if (signatureUrl) { 247 | this.createCursorImg() // 如果有签名图片 URL,则创建光标图像 248 | } 249 | } 250 | 251 | /** 252 | * 将序列化的 Konva.Group 添加到图层,并恢复其中的签名图片。 253 | * @param konvaStage Konva 舞台对象 254 | * @param konvaString 序列化的 Konva.Group 字符串表示 255 | */ 256 | public addSerializedGroupToLayer(konvaStage: Konva.Stage, konvaString: string) { 257 | const ghostGroup = Konva.Node.create(konvaString) 258 | const oldImage = this.getGroupNodesByClassName(ghostGroup, 'Image')[0] as Konva.Image 259 | const imageUrl = oldImage.getAttr('base64') 260 | 261 | // 从 URL 加载签名图片并替换旧图片 262 | Konva.Image.fromURL(imageUrl, async image => { 263 | image.setAttrs(oldImage.getAttrs()) 264 | oldImage.destroy() 265 | ghostGroup.add(image) 266 | }) 267 | 268 | // 将恢复后的组添加到背景图层 269 | this.getBgLayer(konvaStage).add(ghostGroup) 270 | } 271 | 272 | // 下面是未实现的抽象方法的空实现 273 | protected mouseMoveHandler() {} 274 | protected mouseUpHandler() {} 275 | protected mouseOutHandler() {} 276 | protected mouseEnterHandler() {} 277 | } 278 | -------------------------------------------------------------------------------- /src/annotation/painter/editor/editor_stamp.ts: -------------------------------------------------------------------------------- 1 | import Konva from "konva"; 2 | import { KonvaEventObject } from "konva/lib/Node"; 3 | 4 | import { IEditorOptions, Editor } from "./editor"; 5 | import { 6 | AnnotationType, 7 | DefaultSettings, 8 | IAnnotationStore, 9 | IAnnotationType, 10 | IPdfjsAnnotationStorage, 11 | PdfjsAnnotationEditorType, 12 | } from "../../const/definitions"; 13 | import { 14 | base64ToImageBitmap, 15 | resizeImage, 16 | setCssCustomProperty, 17 | } from "../../utils/utils"; 18 | import { CURSOR_CSS_PROPERTY } from "../const"; 19 | 20 | /** 21 | * EditorStamp 是继承自 Editor 的签章编辑器类。 22 | */ 23 | export class EditorStamp extends Editor { 24 | private stampUrl: string; // 签章图片的 URL 25 | private stampImage: Konva.Image; // Konva.Image 对象用于显示签章图片 26 | 27 | /** 28 | * 创建一个 EditorStamp 实例。 29 | * @param EditorOptions 初始化编辑器的选项 30 | * @param defaultStampUrl 默认的签章图片 URL 31 | */ 32 | constructor(EditorOptions: IEditorOptions, defaultStampUrl: string | null) { 33 | super({ ...EditorOptions, editorType: AnnotationType.STAMP }); // 调用父类的构造函数 34 | this.stampUrl = defaultStampUrl; // 设置签章图片 URL 35 | if (defaultStampUrl) { 36 | this.createCursorImg(); // 如果有默认签章图片 URL,则创建光标图像 37 | } 38 | } 39 | 40 | /** 41 | * 创建光标图像,并设置 CSS 自定义属性。 42 | */ 43 | private createCursorImg() { 44 | const cursorGroup = new Konva.Group({ 45 | draggable: false, 46 | }); 47 | 48 | // 从 URL 加载签章图片并处理 49 | Konva.Image.fromURL(this.stampUrl, (image) => { 50 | const { width, height } = image.getClientRect(); 51 | const { newWidth, newHeight } = resizeImage( 52 | width, 53 | height, 54 | DefaultSettings.MAX_CURSOR_SIZE, 55 | ); 56 | const crosshair = { x: newWidth / 2, y: newHeight / 2 }; 57 | 58 | // 创建边框和交叉线 59 | const border = new Konva.Rect({ 60 | x: 0, 61 | y: 0, 62 | width: newWidth, 63 | height: newHeight, 64 | stroke: "red", 65 | strokeWidth: 2, 66 | }); 67 | const horizontalLine = new Konva.Line({ 68 | points: [0, crosshair.y, newWidth, crosshair.y], 69 | stroke: "red", 70 | strokeWidth: 1, 71 | dash: [5, 5], 72 | }); 73 | const verticalLine = new Konva.Line({ 74 | points: [crosshair.x, 0, crosshair.x, newHeight], 75 | stroke: "red", 76 | strokeWidth: 1, 77 | dash: [5, 5], 78 | }); 79 | const point = new Konva.Circle({ 80 | x: crosshair.x, 81 | y: crosshair.y, 82 | radius: 5, 83 | stroke: "red", 84 | }); 85 | 86 | // 设置签章图片属性并添加到组中 87 | image.setAttrs({ 88 | x: 0, 89 | y: 0, 90 | width: newWidth, 91 | height: newHeight, 92 | visible: true, 93 | }); 94 | cursorGroup.add(image, horizontalLine, verticalLine, point, border); 95 | 96 | // 将组转换为数据 URL,并设置 CSS 自定义属性 97 | const cursorImg = cursorGroup.toDataURL(); 98 | cursorGroup.destroy(); 99 | setCssCustomProperty( 100 | CURSOR_CSS_PROPERTY, 101 | `url(${cursorImg}) ${crosshair.x} ${crosshair.y}, default`, 102 | ); 103 | }); 104 | } 105 | 106 | /** 107 | * 处理鼠标按下事件的方法,创建新的形状组并添加签章图片。 108 | * @param e Konva 事件对象 109 | */ 110 | protected mouseDownHandler(e: KonvaEventObject) { 111 | if (e.currentTarget !== this.konvaStage) { 112 | return; // 如果事件不是在舞台上发生的,则直接返回 113 | } 114 | this.stampImage = null; 115 | this.currentShapeGroup = this.createShapeGroup(); // 创建新的形状组 116 | this.getBgLayer().add(this.currentShapeGroup.konvaGroup); // 将形状组添加到背景图层 117 | const pos = this.konvaStage.getRelativePointerPosition(); 118 | 119 | // 从 URL 加载签章图片并处理 120 | Konva.Image.fromURL(this.stampUrl, async (image) => { 121 | const { width: width_rec, height: height_rec } = 122 | image.getClientRect(); 123 | const { newWidth, newHeight } = resizeImage( 124 | width_rec, 125 | height_rec, 126 | 120, 127 | ); 128 | const crosshair = { x: newWidth / 2, y: newHeight / 2 }; 129 | 130 | this.stampImage = image; 131 | this.stampImage.setAttrs({ 132 | x: pos.x - crosshair.x, 133 | y: pos.y - crosshair.y, 134 | width: newWidth, 135 | height: newHeight, 136 | base64: this.stampUrl, 137 | }); 138 | this.currentShapeGroup.konvaGroup.add(this.stampImage); 139 | 140 | // 调整图片坐标并更新 PDF.js 注解存储 141 | const { x, y, width, height } = this.fixImageCoordinateForGroup( 142 | this.stampImage, 143 | this.currentShapeGroup.konvaGroup, 144 | ); 145 | const id = this.currentShapeGroup.konvaGroup.id(); 146 | this.setShapeGroupDone( 147 | id, 148 | await this.calculateImageForStorage({ 149 | x, 150 | y, 151 | width, 152 | height, 153 | annotationType: this.currentAnnotation.pdfjsType, 154 | pageIndex: this.pageNumber - 1, 155 | stampUrl: this.stampUrl, 156 | id, 157 | }), 158 | { 159 | image: this.stampUrl, 160 | }, 161 | ); 162 | this.stampImage = null; 163 | }); 164 | } 165 | 166 | /** 167 | * 刷新 PDF.js 注解存储,从序列化的组字符串中恢复签章图片的信息。 168 | * @param groupId 形状组的 ID 169 | * @param groupString 序列化的组字符串 170 | * @param rawAnnotationStore 原始注解存储对象 171 | * @returns 返回更新后的 PDF.js 注解存储对象的 Promise 172 | */ 173 | public async refreshPdfjsAnnotationStorage( 174 | groupId: string, 175 | groupString: string, 176 | rawAnnotationStore: IAnnotationStore, 177 | ): Promise { 178 | const ghostGroup = Konva.Node.create(groupString); 179 | const image = this.getGroupNodesByClassName( 180 | ghostGroup, 181 | "Image", 182 | )[0] as Konva.Image; 183 | const { x, y, width, height } = this.fixImageCoordinateForGroup( 184 | image, 185 | ghostGroup, 186 | ); 187 | 188 | // 计算并返回更新后的 PDF.js 注解存储对象 189 | const annotationStorage = await this.calculateImageForStorage({ 190 | x, 191 | y, 192 | width, 193 | height, 194 | annotationType: 195 | rawAnnotationStore.pdfjsAnnotationStorage.annotationType, 196 | pageIndex: rawAnnotationStore.pdfjsAnnotationStorage.pageIndex, 197 | stampUrl: image.getAttr("base64"), 198 | id: groupId, 199 | }); 200 | return annotationStorage; 201 | } 202 | 203 | /** 204 | * 调整签章图片在组内的坐标。 205 | * @param image Konva.Image 对象 206 | * @param group Konva.Group 对象 207 | * @returns 返回调整后的坐标信息 208 | */ 209 | private fixImageCoordinateForGroup(image: Konva.Image, group: Konva.Group) { 210 | const imageLocalRect = image.getClientRect({ relativeTo: group }); 211 | 212 | // 获取组的全局变换 213 | const groupTransform = group.getTransform(); 214 | 215 | // 使用组的变换将局部坐标转换为全局坐标 216 | const imageGlobalPos = groupTransform.point({ 217 | x: imageLocalRect.x, 218 | y: imageLocalRect.y, 219 | }); 220 | 221 | // 计算形状的全局宽度和高度 222 | const globalWidth = imageLocalRect.width * (group.attrs.scaleX || 1); 223 | const globalHeight = imageLocalRect.height * (group.attrs.scaleY || 1); 224 | 225 | return { 226 | x: imageGlobalPos.x, 227 | y: imageGlobalPos.y, 228 | width: globalWidth, 229 | height: globalHeight, 230 | }; 231 | } 232 | 233 | /** 234 | * 将签章图片数据转换为 PDF.js 注解存储所需的数据格式。 235 | * @param param0 包含签章图片和相关信息的参数 236 | * @returns 返回处理后的 PDF.js 注解存储对象的 Promise 237 | */ 238 | private async calculateImageForStorage({ 239 | x, 240 | y, 241 | width, 242 | height, 243 | annotationType, 244 | pageIndex, 245 | stampUrl, 246 | id, 247 | }: { 248 | x: number; 249 | y: number; 250 | width: number; 251 | height: number; 252 | annotationType: PdfjsAnnotationEditorType; 253 | pageIndex: number; 254 | stampUrl: string; 255 | id: string; 256 | }): Promise { 257 | const canvasHeight = 258 | this.konvaStage.size().height / this.konvaStage.scale().y; 259 | const rectBottomRightX: number = x + width; 260 | const rectBottomRightY: number = y + height; 261 | const rect: [number, number, number, number] = [ 262 | x, 263 | canvasHeight - y, 264 | rectBottomRightX, 265 | canvasHeight - rectBottomRightY, 266 | ]; 267 | 268 | // 构造 PDF.js 注解存储对象 269 | const annotationStorage: IPdfjsAnnotationStorage = { 270 | annotationType, 271 | isSvg: false, 272 | bitmap: await base64ToImageBitmap(stampUrl), 273 | bitmapId: `image_${id}`, 274 | pageIndex, 275 | rect, 276 | rotation: 0, 277 | }; 278 | 279 | return annotationStorage; 280 | } 281 | 282 | /** 283 | * 激活编辑器并设置签章图片。 284 | * @param konvaStage Konva 舞台对象 285 | * @param annotation 新的注解对象 286 | * @param stampUrl 签章图片的 URL 287 | */ 288 | public activateWithStamp( 289 | konvaStage: Konva.Stage, 290 | annotation: IAnnotationType, 291 | stampUrl: string | null, 292 | ) { 293 | super.activate(konvaStage, annotation); // 调用父类的激活方法 294 | this.stampUrl = stampUrl; // 设置签章图片 URL 295 | if (stampUrl) { 296 | this.createCursorImg(); // 如果有签章图片 URL,则创建光标图像 297 | } 298 | } 299 | 300 | /** 301 | * 将序列化的 Konva.Group 添加到图层,并恢复其中的签章图片。 302 | * @param konvaStage Konva 舞台对象 303 | * @param konvaString 序列化的 Konva.Group 字符串表示 304 | */ 305 | public addSerializedGroupToLayer( 306 | konvaStage: Konva.Stage, 307 | konvaString: string, 308 | ) { 309 | const ghostGroup = Konva.Node.create(konvaString); 310 | const oldImage = this.getGroupNodesByClassName( 311 | ghostGroup, 312 | "Image", 313 | )[0] as Konva.Image; 314 | const imageUrl = oldImage.getAttr("base64"); 315 | 316 | // 从 URL 加载签章图片并替换旧图片 317 | Konva.Image.fromURL(imageUrl, async (image) => { 318 | image.setAttrs(oldImage.getAttrs()); 319 | oldImage.destroy(); 320 | ghostGroup.add(image); 321 | }); 322 | 323 | // 将恢复后的组添加到背景图层 324 | this.getBgLayer(konvaStage).add(ghostGroup); 325 | } 326 | 327 | // 以下是未实现的抽象方法的空实现 328 | protected mouseMoveHandler() {} 329 | protected mouseUpHandler() {} 330 | protected mouseOutHandler() {} 331 | protected mouseEnterHandler() {} 332 | } 333 | -------------------------------------------------------------------------------- /src/annotation/painter/editor/selector.ts: -------------------------------------------------------------------------------- 1 | import Konva from "konva"; 2 | import { 3 | DefaultChooseSetting, 4 | IAnnotationStore, 5 | } from "../../const/definitions"; 6 | import { KonvaCanvas } from "../index"; 7 | import { SELECTOR_HOVER_STYLE, SHAPE_GROUP_NAME } from "../const"; 8 | import { ItemView } from "obsidian"; 9 | 10 | /** 11 | * 定义选择器的选项接口 12 | */ 13 | export interface ISelectorOptions { 14 | view: ItemView; 15 | konvaCanvasStore: Map; // 存储各个页面的 Konva 画布实例 16 | getAnnotationStore: (id: string) => IAnnotationStore; // 获取注解存储的方法 17 | onChange: ( 18 | id: string, 19 | konvaGroupString: string, 20 | rawAnnotationStore: IAnnotationStore, 21 | ) => void; // 注解变化时的回调 22 | onDelete: (id: string) => void; // 删除注解时的回调 23 | } 24 | 25 | /** 26 | * 定义选择器类 27 | */ 28 | export class Selector { 29 | public readonly onChange: ( 30 | id: string, 31 | konvaGroupString: string, 32 | rawAnnotationStore: IAnnotationStore, 33 | ) => void; 34 | public readonly onDelete: (id: string) => void; 35 | private transformerStore: Map = new Map(); // 存储变换器实例 36 | private getAnnotationStore: (id: string) => IAnnotationStore; // 获取注解存储的方法 37 | private konvaCanvasStore: Map; // 存储各个页面的 Konva 画布实例 38 | 39 | private _currentTransformerId: string | null = null; // 当前激活的变换器ID 40 | 41 | private selectedId: string | null = null; 42 | 43 | private view: ItemView; 44 | 45 | // 构造函数,初始化选择器类 46 | constructor({ 47 | view, 48 | konvaCanvasStore, 49 | getAnnotationStore, 50 | onChange, 51 | onDelete, 52 | }: ISelectorOptions) { 53 | this.view = view; 54 | this.konvaCanvasStore = konvaCanvasStore; 55 | this.getAnnotationStore = getAnnotationStore; 56 | this.onChange = onChange; 57 | this.onDelete = onDelete; 58 | } 59 | 60 | // 获取当前激活的变换器ID 61 | get currentTransformerId(): string | null { 62 | return this._currentTransformerId; 63 | } 64 | 65 | // 设置当前激活的变换器ID,并处理变换器状态的更新 66 | set currentTransformerId(id: string | null) { 67 | if (this._currentTransformerId !== id) { 68 | this.selectedId = id; 69 | this.deactivateTransformer(this._currentTransformerId); 70 | this._currentTransformerId = id; 71 | } 72 | } 73 | 74 | /** 75 | * 禁用给定 Konva Stage上的默认事件。 76 | * @param konvaStage - 要禁用事件的 Konva Stage。 77 | */ 78 | private disableStageEvents(konvaStage: Konva.Stage): void { 79 | konvaStage.off("click mousedown mousemove mouseup mouseenter mouseout"); 80 | } 81 | 82 | /** 83 | * 绑定 Konva Stage上的全局点击事件。 84 | * @param konvaStage - 要绑定事件的 Konva Stage。 85 | */ 86 | private bindStageEvents(konvaStage: Konva.Stage): void { 87 | konvaStage.on("click", (e) => { 88 | if (e.target !== konvaStage) return; 89 | this.clearTransformers(); 90 | }); 91 | } 92 | 93 | /** 94 | * 获取 Konva Stage的背景图层。 95 | * @param konvaStage - 要获取背景图层的 Konva Stage。 96 | * @returns Konva Stage的背景图层。 97 | */ 98 | private getBackgroundLayer(konvaStage: Konva.Stage): Konva.Layer { 99 | return konvaStage.getLayers()[0]; 100 | } 101 | 102 | /** 103 | * 获取给定 Konva Stage上的所有形状组。 104 | * @param konvaStage - 要获取形状组的 Konva Stage。 105 | * @returns 形状组的数组。 106 | */ 107 | private getPageShapeGroups(konvaStage: Konva.Stage): Konva.Group[] { 108 | return this.getBackgroundLayer(konvaStage).getChildren( 109 | (node) => node.name() === SHAPE_GROUP_NAME, 110 | ) as Konva.Group[]; 111 | } 112 | 113 | // 获取指定 id 的形状组 114 | private getGroupById( 115 | konvaStage: Konva.Stage, 116 | groupId: string, 117 | ): Konva.Group | null { 118 | const pageGroups = this.getPageShapeGroups(konvaStage); 119 | return pageGroups.find((group) => group.id() === groupId) || null; 120 | } 121 | 122 | private getFirstShapeInGroup(group: Konva.Group): Konva.Shape | null { 123 | return ( 124 | (group 125 | .getChildren() 126 | .find((node) => node instanceof Konva.Shape) as Konva.Shape) || 127 | null 128 | ); 129 | } 130 | 131 | /** 132 | * 启用给定组中的所有形状的交互功能。 133 | * @param groups - 要启用的形状组。 134 | * @param konvaStage - 形状组所在的 Konva Stage。 135 | */ 136 | private enableShapeGroups( 137 | groups: Konva.Group[], 138 | konvaStage: Konva.Stage, 139 | ): void { 140 | groups.forEach((group) => { 141 | group.getChildren().forEach((shape) => { 142 | if (shape instanceof Konva.Shape) { 143 | this.removeShapeEvents(shape); 144 | this.bindShapeEvents(shape, konvaStage); 145 | } 146 | }); 147 | }); 148 | } 149 | 150 | /** 151 | * 禁用给定组中的所有形状的交互功能。 152 | * @param groups - 要禁用的形状组。 153 | */ 154 | public disableShapeGroups(groups: Konva.Group[]): void { 155 | groups.forEach((group) => { 156 | group.getChildren().forEach((shape) => { 157 | if (shape instanceof Konva.Shape) { 158 | this.removeShapeEvents(shape); 159 | } 160 | }); 161 | }); 162 | } 163 | 164 | /** 165 | * 为给定形状绑定点击、鼠标悬停和鼠标离开事件。 166 | * @param shape - 要绑定事件的形状。 167 | * @param konvaStage - 形状所在的 Konva Stage。 168 | */ 169 | private bindShapeEvents(shape: Konva.Shape, konvaStage: Konva.Stage): void { 170 | shape.on("click", (e) => { 171 | if (e.evt.button === 0) { 172 | this.handleShapeClick(shape, konvaStage); 173 | } 174 | }); 175 | shape.on("mouseover", (e) => { 176 | if (e.evt.button === 0) { 177 | this.handleShapeMouseover(); 178 | } 179 | }); 180 | shape.on("mouseout", (e) => { 181 | if (e.evt.button === 0) { 182 | this.handleShapeMouseout(); 183 | } 184 | }); 185 | } 186 | 187 | /** 188 | * 移除给定形状上的所有绑定事件。 189 | * @param shape - 要移除事件的形状。 190 | */ 191 | private removeShapeEvents(shape: Konva.Shape): void { 192 | shape.off("click mouseover mouseout"); 193 | } 194 | 195 | /** 196 | * 处理形状的点击事件。 197 | * @param shape - 被点击的形状。 198 | * @param konvaStage - 形状所在的 Konva Stage。 199 | */ 200 | private handleShapeClick( 201 | shape: Konva.Shape, 202 | konvaStage: Konva.Stage, 203 | ): void { 204 | const group = shape.findAncestor(`.${SHAPE_GROUP_NAME}`) as Konva.Group; 205 | if (!group) return; 206 | this.clearTransformers(); // 清除之前的变换器 207 | this.createTransformer(group, konvaStage); 208 | this.bindGlobalEvents(); // 绑定全局事件 209 | } 210 | 211 | /** 212 | * 创建变形区域 213 | * @param group 214 | * @param konvaStage 215 | */ 216 | private createTransformer(group: Konva.Group, konvaStage: Konva.Stage) { 217 | const groupId = group.id(); 218 | this.currentTransformerId = groupId; 219 | const rawAnnotationStore = this.getAnnotationStore(groupId); 220 | if (!rawAnnotationStore) return; 221 | 222 | group.off("dragend"); 223 | const transformer = new Konva.Transformer({ 224 | resizeEnabled: !rawAnnotationStore.readonly, 225 | rotateEnabled: false, 226 | borderStrokeWidth: DefaultChooseSetting.STROKEWIDTH, 227 | borderStroke: DefaultChooseSetting.COLOR, 228 | anchorFill: "#fff", 229 | anchorStroke: DefaultChooseSetting.COLOR, 230 | anchorCornerRadius: 5, 231 | anchorStrokeWidth: 2, 232 | anchorSize: 8, 233 | padding: 1, 234 | boundBoxFunc: (oldBox, newBox) => { 235 | newBox.width = Math.max(30, newBox.width); 236 | return newBox; 237 | }, 238 | }); 239 | group.draggable(!rawAnnotationStore.readonly); 240 | transformer.off("transformend"); 241 | transformer.on("transformend", () => { 242 | this.onChange(group.id(), group.toJSON(), { 243 | ...rawAnnotationStore, 244 | }); 245 | }); 246 | 247 | transformer.on("dragend", () => { 248 | this.onChange(group.id(), group.toJSON(), { 249 | ...rawAnnotationStore, 250 | }); 251 | }); 252 | 253 | transformer.nodes([group]); 254 | this.getBackgroundLayer(konvaStage).add(transformer); 255 | this.transformerStore.set(groupId, transformer); 256 | } 257 | 258 | /** 259 | * 根据悬停状态切换光标样式。 260 | * @param add - 是否添加悬停样式。 261 | */ 262 | private toggleCursorStyle(add: boolean): void { 263 | // document.body.classList.toggle(SELECTOR_HOVER_STYLE, add); 264 | this.view.containerEl.toggleClass(SELECTOR_HOVER_STYLE, add); 265 | } 266 | 267 | /** 268 | * 处理形状的鼠标悬停事件。 269 | */ 270 | private handleShapeMouseover(): void { 271 | this.toggleCursorStyle(true); 272 | } 273 | 274 | /** 275 | * 处理形状的鼠标离开事件。 276 | */ 277 | private handleShapeMouseout(): void { 278 | this.toggleCursorStyle(false); 279 | } 280 | 281 | /** 282 | * 清除所有变换器。 283 | */ 284 | private clearTransformers(): void { 285 | this.toggleCursorStyle(false); 286 | this.removeGlobalEvents(); 287 | this.transformerStore.forEach((transformer) => { 288 | if (transformer) { 289 | transformer.nodes().forEach((group) => { 290 | if (group instanceof Konva.Group) { 291 | group.draggable(false); 292 | } 293 | }); 294 | transformer.nodes([]); 295 | } 296 | }); 297 | this.transformerStore.clear(); 298 | this.currentTransformerId = null; 299 | } 300 | 301 | /** 302 | * 激活指定变换器。 303 | * @param transformerId - 要激活的变换器ID。 304 | */ 305 | private activateTransformer(transformerId: string | null): void { 306 | if (transformerId) { 307 | const transformer = this.transformerStore.get(transformerId); 308 | if (transformer) { 309 | transformer.nodes().forEach((group) => { 310 | if (group instanceof Konva.Group) { 311 | group.draggable(true); 312 | } 313 | }); 314 | } 315 | } 316 | } 317 | 318 | /** 319 | * 停用指定变换器。 320 | * @param transformerId - 要停用的变换器ID。 321 | */ 322 | private deactivateTransformer(transformerId: string | null): void { 323 | if (transformerId) { 324 | const transformer = this.transformerStore.get(transformerId); 325 | if (transformer) { 326 | transformer.nodes().forEach((group) => { 327 | if (group instanceof Konva.Group) { 328 | group.draggable(false); 329 | } 330 | }); 331 | } 332 | } 333 | } 334 | 335 | /** 336 | * 绑定全局事件。 337 | */ 338 | private bindGlobalEvents(): void { 339 | window.addEventListener("keyup", this.globalKeyUpHandler); 340 | } 341 | 342 | /** 343 | * 移除全局事件。 344 | */ 345 | private removeGlobalEvents(): void { 346 | window.removeEventListener("keyup", this.globalKeyUpHandler); 347 | } 348 | 349 | /** 350 | * 全局键盘抬起事件处理器。 351 | * @param e - 键盘事件。 352 | */ 353 | private globalKeyUpHandler = (e: KeyboardEvent): void => { 354 | if (e.code === "Backspace" || e.code === "Delete") { 355 | this.onDelete(this.currentTransformerId); 356 | this.deactivateTransformer(this.currentTransformerId); 357 | this.clearTransformers(); 358 | } 359 | }; 360 | 361 | private selectedShape(id: string, konvaStage: Konva.Stage) { 362 | const group = this.getGroupById(konvaStage, id); 363 | if (!group) { 364 | return; 365 | } 366 | const shape = this.getFirstShapeInGroup(group); 367 | if (!shape) { 368 | return; 369 | } 370 | this.handleShapeClick(shape, konvaStage); 371 | } 372 | 373 | /** 374 | * 清除选择器的所有状态和事件。 375 | */ 376 | public clear(): void { 377 | this.clearTransformers(); 378 | this.konvaCanvasStore.forEach((konvaCanvas) => { 379 | const { konvaStage } = konvaCanvas; 380 | const pageGroups = this.getPageShapeGroups(konvaStage); 381 | this.disableStageEvents(konvaStage); 382 | this.disableShapeGroups(pageGroups); 383 | }); 384 | } 385 | 386 | /** 387 | * 在指定页面上激活选择器。 388 | * @param pageNumber - 要激活选择器的页面号。 389 | */ 390 | public activate(pageNumber: number): void { 391 | const konvaCanvas = this.konvaCanvasStore.get(pageNumber); 392 | if (!konvaCanvas) return; 393 | const { konvaStage } = konvaCanvas; 394 | const pageGroups = this.getPageShapeGroups(konvaStage); 395 | this.disableStageEvents(konvaStage); 396 | this.bindStageEvents(konvaStage); 397 | this.enableShapeGroups(pageGroups, konvaStage); 398 | if (this.selectedId) { 399 | this.selectedShape(this.selectedId, konvaStage); 400 | } 401 | } 402 | 403 | /** 404 | * 选择指定的形状组。 405 | * @param id - 要选择的形状组的 ID。 406 | */ 407 | public select(id: string): void { 408 | this.selectedId = id; 409 | } 410 | } 411 | -------------------------------------------------------------------------------- /src/annotation/painter/index.scss: -------------------------------------------------------------------------------- 1 | .PdfjsAnnotationExtension_painter_wrapper { 2 | position: absolute; 3 | text-align: initial; 4 | top: 0; 5 | inset: 0; 6 | overflow: hidden; 7 | line-height: 1; 8 | text-size-adjust: none; 9 | forced-color-adjust: none; 10 | transform-origin: 0 0; 11 | z-index: 1; 12 | } 13 | 14 | .PdfjsAnnotationExtension_selector_hover { 15 | .PdfjsAnnotationExtension_painter_wrapper { 16 | cursor: pointer !important; 17 | } 18 | } 19 | 20 | .PdfjsAnnotationExtension_is_painting { 21 | .PdfjsAnnotationExtension_painter_wrapper { 22 | z-index: 999; 23 | } 24 | } 25 | 26 | .PdfjsAnnotationExtension_painting_type_1, 27 | .PdfjsAnnotationExtension_painting_type_2, 28 | .PdfjsAnnotationExtension_painting_type_3 { 29 | .textLayer { 30 | &:not(.free) span { 31 | cursor: var(--editorHighlight-editing-cursor); 32 | } 33 | } 34 | } 35 | 36 | .PdfjsAnnotationExtension_painting_type_4, 37 | .PdfjsAnnotationExtension_painting_type_5, 38 | .PdfjsAnnotationExtension_painting_type_6 { 39 | .PdfjsAnnotationExtension_painter_wrapper { 40 | cursor: crosshair; 41 | } 42 | } 43 | .PdfjsAnnotationExtension_painting_type_7 { 44 | .PdfjsAnnotationExtension_painter_wrapper { 45 | cursor: var(--editorInk-editing-cursor); 46 | } 47 | } 48 | 49 | .PdfjsAnnotationExtension_painting_type_8 { 50 | .PdfjsAnnotationExtension_painter_wrapper { 51 | cursor: var(--editorFreeHighlight-editing-cursor); 52 | } 53 | } 54 | 55 | .PdfjsAnnotationExtension_painting_type_9, 56 | .PdfjsAnnotationExtension_painting_type_10 { 57 | .PdfjsAnnotationExtension_painter_wrapper { 58 | cursor: var(--PdfjsAnnotationExtension-image-cursor); 59 | } 60 | } 61 | 62 | .PdfjsAnnotationExtension_free_text_input { 63 | width: auto; 64 | position: absolute; 65 | z-index: 999; 66 | padding: 0px; 67 | overflow: hidden; 68 | background: #fff; 69 | outline: none; 70 | overflow-wrap: break-word; 71 | white-space: pre-wrap; 72 | user-select: text; 73 | word-break: normal; 74 | font-weight: normal; 75 | font-style: normal; 76 | } 77 | 78 | .PdfjsAnnotationExtension_is_painting .pdf-container .textLayer { 79 | user-select: none !important; 80 | } 81 | -------------------------------------------------------------------------------- /src/annotation/painter/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IAnnotationContent, 3 | IAnnotationStore, 4 | IPdfjsAnnotationStorage, 5 | } from "../const/definitions"; 6 | import { IShapeGroup } from "./editor/editor"; 7 | import { PDFViewerApplication } from "pdfjs"; 8 | import { base64ToImageBitmap } from "@/annotation/utils/utils"; 9 | 10 | const PDFJS_INTERNAL_EDITOR_PREFIX = "pdfjs_internal_editor_"; 11 | 12 | export class Store { 13 | private annotationStore: Map = new Map(); 14 | private pdfViewerApplication: PDFViewerApplication; 15 | 16 | constructor({ 17 | PDFViewerApplication, 18 | initAnnotationStore, 19 | }: { 20 | PDFViewerApplication: PDFViewerApplication; 21 | initAnnotationStore: IAnnotationStore[]; 22 | }) { 23 | this.pdfViewerApplication = PDFViewerApplication; 24 | this.annotationStore = new Map( 25 | initAnnotationStore.map((annotation) => [ 26 | annotation.id, 27 | annotation, 28 | ]), 29 | ); 30 | } 31 | 32 | get annotation() { 33 | return (id: string) => { 34 | return this.annotationStore.get(id); 35 | }; 36 | } 37 | 38 | public getAnnotationStore() { 39 | return this.annotationStore; 40 | } 41 | 42 | public getAllAnnotations() { 43 | return Array.from(this.annotationStore.values()); 44 | } 45 | 46 | public restoreAnnotationStore(annotations: IAnnotationStore[]) { 47 | annotations.forEach((annotation) => { 48 | this.annotationStore.set(annotation.id, annotation); 49 | }); 50 | } 51 | 52 | public save( 53 | shapeGroup: IShapeGroup, 54 | pdfjsAnnotationStorage: IPdfjsAnnotationStorage, 55 | annotationContent?: IAnnotationContent, 56 | ) { 57 | const id = shapeGroup.id; 58 | this.pdfViewerApplication.pdfDocument.annotationStorage.setValue( 59 | `${PDFJS_INTERNAL_EDITOR_PREFIX}${id}`, 60 | pdfjsAnnotationStorage, 61 | ); 62 | this.annotationStore.set(id, { 63 | id, 64 | pageNumber: shapeGroup.pageNumber, 65 | konvaString: shapeGroup.konvaGroup.toJSON(), 66 | type: shapeGroup.annotation.type, 67 | readonly: shapeGroup.annotation.readonly, 68 | pdfjsAnnotationStorage, 69 | content: annotationContent, 70 | time: new Date().getTime(), 71 | }); 72 | } 73 | 74 | public update(id: string, updates: Partial) { 75 | if (this.annotationStore.has(id)) { 76 | const existingAnnotation = this.annotationStore.get(id); 77 | if (existingAnnotation) { 78 | const updatedAnnotation = { 79 | ...existingAnnotation, 80 | ...updates, 81 | time: new Date().getTime(), 82 | }; 83 | this.annotationStore.set(id, updatedAnnotation); 84 | this.pdfViewerApplication.pdfDocument.annotationStorage.setValue( 85 | `${PDFJS_INTERNAL_EDITOR_PREFIX}${id}`, 86 | updates.pdfjsAnnotationStorage, 87 | ); 88 | } 89 | } else { 90 | console.warn(`Annotation with id ${id} not found.`); 91 | } 92 | } 93 | 94 | public getByPage(pageNumber: number): IAnnotationStore[] { 95 | const annotations: IAnnotationStore[] = []; 96 | this.annotationStore.forEach((annotation) => { 97 | if (annotation.pageNumber === pageNumber) { 98 | annotations.push(annotation); 99 | } 100 | }); 101 | return annotations; 102 | } 103 | 104 | /** 105 | * 删除指定 ID 的注释。 106 | * @param id - 要删除的注释的 ID。 107 | */ 108 | public delete(id: string): void { 109 | if (this.annotationStore.has(id)) { 110 | this.annotationStore.delete(id); 111 | this.pdfViewerApplication.pdfDocument.annotationStorage.remove( 112 | `${PDFJS_INTERNAL_EDITOR_PREFIX}${id}`, 113 | ); 114 | } else { 115 | console.warn(`Annotation with id ${id} not found.`); 116 | } 117 | } 118 | 119 | /** 120 | * 重置 pdfjs annotationStorage中的ImageBitmap 121 | */ 122 | public async resetAnnotationStorage(): Promise { 123 | const annotationStorage = 124 | this.pdfViewerApplication.pdfDocument.annotationStorage; 125 | for (const key in annotationStorage._storage) { 126 | if (key.startsWith(PDFJS_INTERNAL_EDITOR_PREFIX)) { 127 | annotationStorage.remove(key); 128 | } 129 | } 130 | this.annotationStore.forEach(async (annotation, id) => { 131 | console.warn("resetAnnotationStorage", annotation, id); 132 | if (annotation.content && annotation.content.image) { 133 | // 如果存在 content.image,将其 base64 转换为 ImageBitmap 134 | annotation.pdfjsAnnotationStorage.bitmap = 135 | await base64ToImageBitmap(annotation.content.image); 136 | annotationStorage.setValue( 137 | `${PDFJS_INTERNAL_EDITOR_PREFIX}${annotation.id}`, 138 | annotation.pdfjsAnnotationStorage, 139 | ); 140 | } 141 | }); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/annotation/painter/webSelection.ts: -------------------------------------------------------------------------------- 1 | import Highlighter from "web-highlighter"; 2 | 3 | /** 4 | * WebSelection 类用于处理网页选区的实用工具类。 5 | */ 6 | export class WebSelection { 7 | isEditing: boolean; // 指示是否启用编辑模式 8 | onSelect: (pageNumber: number, elements: Array) => void; // 当选区被选中时调用的回调函数 9 | highlighterObj: null | Highlighter; 10 | 11 | /** 12 | * 构造一个新的 WebSelection 实例。 13 | * @param onSelect 当选区被选中时调用的回调函数 14 | */ 15 | constructor({ onSelect }) { 16 | this.isEditing = false; 17 | this.onSelect = onSelect; 18 | this.highlighterObj = null; 19 | } 20 | 21 | /** 22 | * 在指定的根元素和页码上创建一个高亮器。 23 | * @param root 要应用高亮器的根元素 24 | */ 25 | public create(root: HTMLDivElement) { 26 | if (this.highlighterObj) return; 27 | this.highlighterObj = new Highlighter({ 28 | $root: root, 29 | wrapTag: "mark", 30 | }); 31 | this.highlighterObj.on("selection:create", (data) => { 32 | const allSourcesId = data.sources.map((item) => item.id); 33 | const allSourcesSpan = []; 34 | allSourcesId.forEach((value) => { 35 | allSourcesSpan.push(...this.highlighterObj.getDoms(value)); 36 | }); 37 | 38 | const pageSelection = Object.groupBy(allSourcesSpan, (span) => { 39 | return span.closest(".page").getAttribute("data-page-number"); 40 | }); 41 | 42 | for (const pageNumber in pageSelection) { 43 | this.onSelect(parseInt(pageNumber), pageSelection[pageNumber]); 44 | } 45 | 46 | this.highlighterObj.removeAll(); 47 | }); 48 | } 49 | 50 | /** 51 | * 启用编辑模式。 52 | */ 53 | enable() { 54 | this.isEditing = true; 55 | this.highlighterObj?.run(); 56 | } 57 | 58 | /** 59 | * 禁用编辑模式。 60 | */ 61 | disable() { 62 | this.isEditing = false; 63 | this.highlighterObj?.stop(); 64 | } 65 | 66 | unload() { 67 | this.highlighterObj?.dispose(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/annotation/scss/app.scss: -------------------------------------------------------------------------------- 1 | .PdfjsAnnotationExtension { 2 | .textLayer { 3 | z-index: 9 !important; 4 | } 5 | #viewerContainer { 6 | top: 107px !important; 7 | } 8 | #sidebarContainer { 9 | top: 107px !important; 10 | } 11 | #toolbarContainer { 12 | height: 107px; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/annotation/utils/json.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile } from "obsidian"; 2 | import { IAnnotationStore } from "@/annotation/const/definitions"; 3 | import { IPdfJSAnnotationStore } from "@/types/store"; 4 | 5 | export const annotationsSavePath = (app: App) => { 6 | return `${app.vault.configDir}/pdf-annotations.json`; 7 | }; 8 | 9 | export const checkJsonFileExists = async (app: App, path: string) => { 10 | try { 11 | await app.vault.adapter.read(path); 12 | return true; 13 | } catch (error) { 14 | console.error(error); 15 | return false; 16 | } 17 | }; 18 | 19 | export const loadAnnotationsJson = async ( 20 | app: App, 21 | ): Promise => { 22 | const exists = await checkJsonFileExists(app, annotationsSavePath(app)); 23 | if (!exists) { 24 | await initAnnotationsJson(app); 25 | } 26 | 27 | const result = JSON.parse( 28 | await app.vault.adapter.read(annotationsSavePath(app)), 29 | ) as IPdfJSAnnotationStore; 30 | 31 | return ( 32 | result || { 33 | annotations: [], 34 | } 35 | ); 36 | }; 37 | 38 | export const updateAnnotationsJson = async ( 39 | app: App, 40 | file: TFile, 41 | data: IAnnotationStore[], 42 | ) => { 43 | const exists = await checkJsonFileExists(app, annotationsSavePath(app)); 44 | if (!exists) { 45 | await initAnnotationsJson(app); 46 | } 47 | 48 | const result = JSON.parse( 49 | await app.vault.adapter.read(annotationsSavePath(app)), 50 | ) as IPdfJSAnnotationStore; 51 | 52 | const index = result.annotations.findIndex( 53 | (query: any) => query.file === file.path, 54 | ); 55 | if (index === -1) { 56 | result.annotations.push({ 57 | file: file.path, 58 | data, 59 | }); 60 | } else { 61 | result.annotations[index].data = data; 62 | } 63 | 64 | await app.vault.adapter.write( 65 | annotationsSavePath(app), 66 | JSON.stringify(result, null, 2), 67 | ); 68 | }; 69 | 70 | export const saveAnnotationsJson = async ( 71 | app: App, 72 | file: TFile, 73 | data: IAnnotationStore[], 74 | ) => { 75 | await app.vault.adapter.write( 76 | annotationsSavePath(app), 77 | JSON.stringify( 78 | { 79 | annotations: [ 80 | { 81 | file: file.path, 82 | data, 83 | }, 84 | ], 85 | }, 86 | null, 87 | 2, 88 | ), 89 | ); 90 | }; 91 | 92 | export const initAnnotationsJson = async (app: App) => { 93 | await app.vault.adapter.write( 94 | annotationsSavePath(app), 95 | JSON.stringify( 96 | { 97 | annotations: [], 98 | }, 99 | null, 100 | 2, 101 | ), 102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /src/annotation/utils/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 根据颜色字符串获取 RGB 数组。 3 | * 支持以下格式:'#RRGGBB'、'rgb(R, G, B)'、'rgba(R, G, B, A)'。 4 | * @param color - 要解析的颜色字符串 5 | * @returns 表示颜色的 RGB 数组,如 [R, G, B]。 6 | * 如果颜色格式无效,则返回 [0, 0, 0]。 7 | */ 8 | function getRGB(color: string): number[] { 9 | if (color.startsWith('#')) { 10 | const colorRGB = parseInt(color.slice(1), 16); 11 | return [(colorRGB & 0xff0000) >> 16, (colorRGB & 0x00ff00) >> 8, colorRGB & 0x0000ff]; 12 | } 13 | 14 | if (color.startsWith('rgb(')) { 15 | return color 16 | .slice(4, -1) // 去掉 "rgb(" 和 ")" 17 | .split(',') 18 | .map(x => parseInt(x.trim())); 19 | } 20 | 21 | if (color.startsWith('rgba(')) { 22 | return color 23 | .slice(5, -1) // 去掉 "rgba(" 和 ")" 24 | .split(',') 25 | .map((x, index) => (index < 3 ? parseInt(x.trim()) : 1)); // 只保留 RGB 部分,忽略透明度 26 | } 27 | 28 | console.error(`Not a valid color format: "${color}"`); 29 | return [0, 0, 0]; 30 | } 31 | 32 | /** 33 | * 检查元素是否存在于 DOM 中。 34 | * @param element - 要检查的元素 35 | * @returns 如果元素存在于 DOM 中,则返回 true;否则返回 false。 36 | */ 37 | function isElementInDOM(element: HTMLElement): boolean { 38 | return document.body.contains(element); 39 | } 40 | 41 | /** 42 | * 生成一个符合 RFC 4122 标准的 UUID v4。 43 | * UUID v4 格式为:xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx 44 | * 其中的 x 是随机生成的十六进制数,y 是 8, 9, A, 或 B。 45 | * @returns 生成的 UUID v4 字符串 46 | */ 47 | function generateUUID(): string { 48 | const bytes = getRandomBytes(16); 49 | bytes[6] = (bytes[6] & 0x0f) | 0x40; // 设置版本号 4 50 | bytes[8] = (bytes[8] & 0x3f) | 0x80; // 设置变体 10xx 51 | return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')) 52 | .join('') 53 | .match(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/)! 54 | .slice(1) 55 | .join('-'); 56 | } 57 | 58 | /** 59 | * 获取指定长度的随机字节数组。 60 | * 使用 window.crypto 或 node.js 的 crypto 获取安全的随机值。 61 | * @param length - 随机字节的长度 62 | * @returns 随机字节数组 63 | */ 64 | function getRandomBytes(length: number): Uint8Array { 65 | const bytes = new Uint8Array(length); 66 | if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { 67 | crypto.getRandomValues(bytes); 68 | } else { 69 | for (let i = 0; i < length; i++) { 70 | bytes[i] = Math.floor(Math.random() * 256); 71 | } 72 | } 73 | return bytes; 74 | } 75 | 76 | /** 77 | * 设置文档元素的 CSS 自定义属性。 78 | * @param propertyName - 属性名称 79 | * @param value - 属性值 80 | */ 81 | function setCssCustomProperty(propertyName: string, value: string): void { 82 | document.documentElement.style.setProperty(propertyName, value); 83 | } 84 | 85 | /** 86 | * 移除文档元素的 CSS 自定义属性。 87 | * @param propertyName - 要移除的属性名称 88 | */ 89 | function removeCssCustomProperty(propertyName: string): void { 90 | document.documentElement.style.removeProperty(propertyName); 91 | } 92 | 93 | /** 94 | * 将 Base64 格式的图像数据转换为 ImageBitmap 对象。 95 | * @param base64 - Base64 编码的图像数据 96 | * @returns ImageBitmap 对象,表示转换后的图像 97 | */ 98 | async function base64ToImageBitmap(base64: string): Promise { 99 | // 将 Base64 数据去掉前缀部分 "data:image/png;base64," (如果有) 100 | const base64Data = base64.split(',')[1]; 101 | 102 | // 解码 Base64 数据为二进制字符串 103 | const binaryString = atob(base64Data); 104 | 105 | // 创建一个 Uint8Array 来存储解码后的二进制数据 106 | const length = binaryString.length; 107 | const bytes = new Uint8Array(length); 108 | for (let i = 0; i < length; i++) { 109 | bytes[i] = binaryString.charCodeAt(i); 110 | } 111 | 112 | // 创建 Blob 对象,类型为 image/png 113 | const blob = new Blob([bytes], { type: 'image/png' }); 114 | 115 | // 将 Blob 转换为 ImageBitmap 116 | const imageBitmap = await createImageBitmap(blob); 117 | 118 | return imageBitmap; 119 | } 120 | 121 | /** 122 | * 格式化文件大小,将字节数转换为友好的字符串格式。 123 | * @param sizeInBytes - 文件大小,单位字节 124 | * @returns 友好格式的文件大小字符串,如 "2.56 MB" 125 | */ 126 | function formatFileSize(sizeInBytes: number): string { 127 | if (sizeInBytes < 1024) return `${sizeInBytes} B`; 128 | const units = ['KB', 'MB', 'GB', 'TB']; 129 | let unitIndex = -1; 130 | let size = sizeInBytes; 131 | do { 132 | size /= 1024; 133 | unitIndex++; 134 | } while (size >= 1024 && unitIndex < units.length - 1); 135 | 136 | return `${size.toFixed(2)} ${units[unitIndex]}`; 137 | } 138 | 139 | /** 140 | * 等比缩放图像的宽度和高度,使其在给定的最大宽度或高度内。 141 | * @param width - 原始图像的宽度 142 | * @param height - 原始图像的高度 143 | * @param max - 最大宽度或高度,缩放后的尺寸任意一边都不超过该值 144 | * @returns 包含等比缩放后的宽度和高度的对象 { newWidth, newHeight } 145 | */ 146 | function resizeImage(width: number, height: number, max: number): { newWidth: number; newHeight: number } { 147 | // 检查是否需要缩放 148 | if (width <= max && height <= max) { 149 | // 如果宽度和高度都在 max 范围内,不需要缩放,直接返回原尺寸 150 | return { newWidth: width, newHeight: height }; 151 | } 152 | 153 | // 计算宽度和高度的缩放比例 154 | const widthScale = max / width; 155 | const heightScale = max / height; 156 | 157 | // 选择较小的比例来保持图像的宽高比 158 | const scaleFactor = Math.min(widthScale, heightScale); 159 | 160 | // 计算缩放后的宽度和高度 161 | const newWidth = width * scaleFactor; 162 | const newHeight = height * scaleFactor; 163 | 164 | return { newWidth, newHeight }; 165 | } 166 | 167 | export { 168 | getRGB, 169 | isElementInDOM, 170 | generateUUID, 171 | getRandomBytes, 172 | setCssCustomProperty, 173 | removeCssCustomProperty, 174 | base64ToImageBitmap, 175 | formatFileSize, 176 | resizeImage 177 | }; 178 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { App, Component, ItemView, Plugin, WorkspaceLeaf } from "obsidian"; 2 | import PdfjsAnnotationExtension from "./annotation"; 3 | import { around } from "monkey-around"; 4 | import { IAnnotationStore } from "@/annotation/const/definitions"; 5 | import { 6 | annotationsSavePath, 7 | loadAnnotationsJson, 8 | } from "@/annotation/utils/json"; 9 | import { IPdfJSAnnotationStore } from "@/types/store"; 10 | 11 | export default class PdfAnnotatorPlugin extends Plugin { 12 | pdfAnnotators: PdfjsAnnotationExtension[] = []; 13 | public controller: IAnnotationStoreController = 14 | new IAnnotationStoreController(this.app); 15 | 16 | async onload() { 17 | await this.controller.onload(); 18 | const leaves = this.app.workspace.getLeavesOfType("pdf"); 19 | if (leaves.length > 0) { 20 | for (const leaf of leaves) { 21 | this.createToolbar(leaf, this); 22 | } 23 | } 24 | 25 | this.patchView(this); 26 | this.patchPDFViewer(); 27 | } 28 | 29 | onunload() { 30 | for (const annotator of this.pdfAnnotators) { 31 | annotator.unload(); 32 | annotator.viewer.pdfAnnotator = null; 33 | } 34 | this.pdfAnnotators = []; 35 | } 36 | 37 | createToolbar(leaf: WorkspaceLeaf, plugin: PdfAnnotatorPlugin) { 38 | if (!leaf.view.viewer || leaf.view.viewer.pdfAnnotator) return; 39 | const annotator = new PdfjsAnnotationExtension( 40 | leaf.view.viewer.child.pdfViewer, 41 | leaf.view.viewer, 42 | leaf.view as ItemView, 43 | this, 44 | ); 45 | this.pdfAnnotators.push(annotator); 46 | } 47 | 48 | patchView(plugin: PdfAnnotatorPlugin) { 49 | const uninstaller = around(WorkspaceLeaf.prototype, { 50 | setViewState: (next: any) => 51 | function (viewState, eState) { 52 | const result = next.call(this, viewState, eState); 53 | if (viewState.type === "pdf") { 54 | setTimeout(() => { 55 | plugin.createToolbar(this, plugin); 56 | }, 200); 57 | // uninstaller(); 58 | } 59 | return result; 60 | }, 61 | }); 62 | 63 | this.register(uninstaller); 64 | } 65 | 66 | patchPDFViewer() { 67 | const uninstaller = around(ItemView.prototype, { 68 | unload: (next: any) => 69 | function () { 70 | if (this.file && this.file.extension === "pdf") { 71 | const annotator = this.viewer.pdfAnnotator; 72 | if (annotator) { 73 | annotator.unload(); 74 | } 75 | this.viewer.pdfAnnotator = null; 76 | } 77 | return next.call(this); 78 | }, 79 | }); 80 | 81 | this.register(uninstaller); 82 | } 83 | } 84 | 85 | class IAnnotationStoreController extends Component { 86 | store: IPdfJSAnnotationStore; 87 | annotations: { 88 | file: string; 89 | data: IAnnotationStore[]; 90 | }[] = []; 91 | app: App; 92 | 93 | constructor(app: App) { 94 | super(); 95 | this.app = app; 96 | } 97 | 98 | async onload() { 99 | super.onload(); 100 | this.store = await loadAnnotationsJson(this.app); 101 | this.annotations = this.store.annotations || []; 102 | } 103 | 104 | getAnnotation(file: string) { 105 | return this.annotations.find((query) => query.file === file); 106 | } 107 | 108 | getAllAnnotations() { 109 | return this.annotations; 110 | } 111 | 112 | updateAnnotations(file: string, data: IAnnotationStore[]) { 113 | const index = this.annotations.findIndex( 114 | (query: any) => query.file === file, 115 | ); 116 | if (index === -1) { 117 | this.annotations.push({ 118 | file, 119 | data, 120 | }); 121 | } else { 122 | this.annotations[index].data = data; 123 | } 124 | 125 | this.saveAnnotations(); 126 | } 127 | 128 | removeAnnotations(file: string) { 129 | const index = this.annotations.findIndex( 130 | (query: any) => query.file === file, 131 | ); 132 | if (index !== -1) { 133 | this.annotations.splice(index, 1); 134 | } 135 | } 136 | 137 | deleteAnnotation(file: string, annotationId: string) { 138 | const index = this.annotations.findIndex( 139 | (query: any) => query.file === file, 140 | ); 141 | if (index !== -1) { 142 | const annotationIndex = this.annotations[index].data.findIndex( 143 | (annotation) => annotation.id === annotationId, 144 | ); 145 | if (annotationIndex !== -1) { 146 | this.annotations[index].data.splice(annotationIndex, 1); 147 | } 148 | } 149 | this.saveAnnotations(); 150 | } 151 | 152 | saveAnnotations() { 153 | this.store.annotations = this.annotations; 154 | this.app.vault.adapter.write( 155 | annotationsSavePath(this.app), 156 | JSON.stringify(this.store, null, 2), 157 | ); 158 | } 159 | 160 | clearAnnotations() { 161 | this.store.annotations = []; 162 | this.app.vault.adapter.write( 163 | annotationsSavePath(this.app), 164 | JSON.stringify(this.store, null, 2), 165 | ); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/types/obsidian.d.ts: -------------------------------------------------------------------------------- 1 | import "obsidian"; 2 | 3 | declare module "obsidian" { 4 | interface View { 5 | /** 6 | * The viewer of the view. 7 | */ 8 | viewer: any; 9 | file: any; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/types/store.d.ts: -------------------------------------------------------------------------------- 1 | import { IAnnotationStore } from "@/annotation/const/definitions"; 2 | 3 | interface IPdfJSAnnotationStore { 4 | annotations: { 5 | file: string; 6 | data: IAnnotationStore[]; 7 | }[]; 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./src/*"] 6 | }, 7 | "allowSyntheticDefaultImports": true, 8 | "inlineSourceMap": true, 9 | "inlineSources": true, 10 | "jsx": "react", 11 | "module": "ESNext", 12 | "target": "esnext", 13 | "allowJs": true, 14 | "noImplicitAny": false, 15 | "moduleResolution": "node", 16 | "importHelpers": true, 17 | "lib": ["dom", "esnext"] 18 | }, 19 | "include": ["**/*.ts", "**/*.tsx"] 20 | } 21 | -------------------------------------------------------------------------------- /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": "1.4.0", 3 | "1.0.0": "1.4.0", 4 | "1.0.1": "1.4.0" 5 | } -------------------------------------------------------------------------------- /vite.config.cjs: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import {defineConfig} from 'vite'; 3 | import react from '@vitejs/plugin-react'; 4 | import terser from '@rollup/plugin-terser'; 5 | import replace from '@rollup/plugin-replace'; 6 | import resolve from '@rollup/plugin-node-resolve'; 7 | 8 | export default defineConfig(({mode}) => { 9 | return { 10 | plugins: [react()], 11 | build: { 12 | sourcemap: mode === 'development' ? 'inline' : false, 13 | minify: mode === 'development' ? false : true, 14 | // Use Vite lib mode https://vitejs.dev/guide/build.html#library-mode 15 | lib: { 16 | entry: path.resolve(__dirname, './src/main.ts'), 17 | formats: ['cjs'], 18 | }, 19 | rollupOptions: { 20 | plugins: [ 21 | mode === 'development' 22 | ? '' 23 | : terser({ 24 | compress: { 25 | defaults: false, 26 | drop_console: ['log', 'info'], 27 | }, 28 | mangle: { 29 | eval: true, 30 | module: true, 31 | toplevel: true, 32 | safari10: true, 33 | properties: false, 34 | }, 35 | output: { 36 | comments: false, 37 | ecma: '2020', 38 | }, 39 | }), 40 | resolve({ 41 | browser: false, 42 | }), 43 | replace({ 44 | preventAssignment: true, 45 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 46 | }), 47 | ], 48 | treeshake: mode === 'production', 49 | output: { 50 | // Overwrite default Vite output fileName 51 | entryFileNames: 'main.js', 52 | assetFileNames: 'styles.css', 53 | dir: '.', 54 | }, 55 | external: [ 56 | 'obsidian', 57 | 'electron', 58 | 'pdfjs', 59 | '@codemirror/autocomplete', 60 | '@codemirror/collab', 61 | '@codemirror/commands', 62 | '@codemirror/language', 63 | '@codemirror/lint', 64 | '@codemirror/search', 65 | '@codemirror/state', 66 | '@codemirror/view', 67 | '@lezer/common', 68 | '@lezer/highlight', 69 | '@lezer/lr', 70 | ], 71 | }, 72 | emptyOutDir: false, 73 | outDir: '.', 74 | }, 75 | resolve: { 76 | alias: { 77 | '@': path.resolve(__dirname, './src'), 78 | }, 79 | }, 80 | }; 81 | }); 82 | --------------------------------------------------------------------------------