├── .env.example ├── .eslintrc.cjs ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.yml └── workflows │ └── release.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── CHANGELOG.md ├── LICENSE ├── README.md ├── cz.config.js ├── demo.gif ├── index.html ├── logo.png ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.cjs ├── release.config.js ├── screenshots ├── dashboard.png ├── editor.png └── preview.png ├── src ├── App.css ├── App.tsx ├── app │ ├── Dashboard.tsx │ ├── Editor.tsx │ └── Preview.tsx ├── bootstrap │ ├── command.ts │ ├── excalidrawLibraryItems.ts │ ├── model.ts │ └── renderBlockImage.ts ├── components │ ├── DrawingCard.tsx │ ├── EditDrawingInfoModal.tsx │ ├── Editor.tsx │ ├── Preview.tsx │ ├── PreviewImage.tsx │ ├── SVGComponent.tsx │ ├── SlidesOverview.tsx │ ├── SlidesPreview.tsx │ ├── TagSelector.tsx │ └── ui │ │ ├── alert-dialog.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── separator.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ └── use-toast.ts ├── favicon.svg ├── hook │ └── useSlides.ts ├── index.css ├── lib │ ├── constants.ts │ ├── logseqProxy.ts │ ├── rewriteFont.ts │ └── utils.ts ├── locales │ ├── en.ts │ ├── index.ts │ └── zh-CN.ts ├── main.tsx ├── model │ ├── slides.ts │ └── tags.ts ├── type.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | VITE_LOGSEQ_API_SERVER=http://127.0.0.1:12315 2 | VITE_LOGSEQ_API_TOKEN=your-token -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [require.resolve('@haydenull/fabric/eslint/react')], 3 | rules: { 4 | 'react/react-in-jsx-scope': 'off', 5 | } 6 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug encountered while using Excalidraw plugin 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thank you very much for opening a bug report with Excalidraw plugin. 8 | - type: checkboxes 9 | id: confirm-search 10 | attributes: 11 | label: Search first 12 | description: Please search [exist issues](https://github.com/haydenull/logseq-plugin-excalidraw/issues) before reporting. 13 | options: 14 | - label: I searched and no similar issues were found 15 | required: true 16 | 17 | - type: textarea 18 | id: problem 19 | attributes: 20 | label: What Happened? 21 | description: | 22 | Please provide as much info as possible. Not doing so may result in your bug not being addressed in a timely manner. 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: reproduce 27 | attributes: 28 | label: Reproduce the Bug 29 | description: | 30 | Please tell us the steps to reproduce the bug. 31 | placeholder: | 32 | 1. Go to '...' 33 | 2. Click on '....' 34 | 3. Scroll down to '....' 35 | 4. See error 36 | validations: 37 | required: true 38 | - type: textarea 39 | id: screenshots 40 | attributes: 41 | label: Screenshots 42 | description: | 43 | If applicable, add screenshots or screen recordings to help explain your problem. 44 | validations: 45 | required: false 46 | - type: textarea 47 | id: platform 48 | attributes: 49 | label: Environment Information 50 | description: | 51 | Would you mind to tell us the information about your operating environment? 52 | placeholder: | 53 | OS version, Logseq App version, Excalidraw plugin version 54 | example: macOS 13.2.1, Desktop App v0.9.3, Excalidraw plugin v1.2.0 55 | validations: 56 | required: false 57 | - type: textarea 58 | id: additional 59 | attributes: 60 | label: Additional Context 61 | description: | 62 | If applicable, add additional context to help explain your problem. 63 | validations: 64 | required: false 65 | 66 | - type: checkboxes 67 | id: ask-pr 68 | attributes: 69 | label: Are you willing to submit a PR? If you know how to fix the bug. 70 | description: | 71 | If you are not familiar with programming, you can skip this step. 72 | If you are a developer and know how to fix the bug, you can submit a PR to fix it. 73 | Your contributions are greatly appreciated and play a vital role in helping to improve the project! 74 | options: 75 | - label: I'm willing to submit a PR (Thank you!) 76 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | release: 11 | name: Release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18 22 | - name: Install pnpm 23 | uses: pnpm/action-setup@v2 24 | with: 25 | version: 8 26 | run_install: false 27 | - name: Install dependencies 28 | run: pnpm install 29 | - name: Build 30 | run: pnpm build 31 | - name: Release 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | run: npx semantic-release 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx haydenull-fabric verify-commit 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | npm run typecheck 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.5.1](https://github.com/haydenull/logseq-plugin-excalidraw/compare/v1.5.0...v1.5.1) (2023-11-30) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * :bug: sort by using - to differentiate indexes ([75fa7c4](https://github.com/haydenull/logseq-plugin-excalidraw/commit/75fa7c4f29342cc2470316947eb6b78b448be99b)) 7 | 8 | # [1.5.0](https://github.com/haydenull/logseq-plugin-excalidraw/compare/v1.4.1...v1.5.0) (2023-11-28) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * :bug: add padding top in Editor ([b688bde](https://github.com/haydenull/logseq-plugin-excalidraw/commit/b688bdeca72769f82166d6dd76087b91b3f6e279)) 14 | 15 | 16 | ### Features 17 | 18 | * :sparkles: complete slides flow ([55f585f](https://github.com/haydenull/logseq-plugin-excalidraw/commit/55f585f8abbacb79c27bafe686eafe5b1587d36d)) 19 | * :sparkles: show slides preview ([7f548e2](https://github.com/haydenull/logseq-plugin-excalidraw/commit/7f548e2251859b36402e8747e2dcb547ca478dbd)) 20 | * :sparkles: supports slides ([0c794f1](https://github.com/haydenull/logseq-plugin-excalidraw/commit/0c794f1beb1a6c88c2f9d7198f681606f4912f63)) 21 | 22 | ## [1.4.1](https://github.com/haydenull/logseq-plugin-excalidraw/compare/v1.4.0...v1.4.1) (2023-09-11) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * :bug: copy failed ([643b3ee](https://github.com/haydenull/logseq-plugin-excalidraw/commit/643b3ee28aaf95e483e4e9d000c0fefd2c9f64bb)) 28 | 29 | # [1.4.0](https://github.com/haydenull/logseq-plugin-excalidraw/compare/v1.3.0...v1.4.0) (2023-08-21) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * :bug: remove duplicate tags ([b7e6bfb](https://github.com/haydenull/logseq-plugin-excalidraw/commit/b7e6bfb7924115f7103806c35bd27c17d5e834c3)) 35 | * :bug: remove unnecessary scrollbar from editor ([66f63a1](https://github.com/haydenull/logseq-plugin-excalidraw/commit/66f63a13f2496ea0fa884cffb70e59d3856f4c80)) 36 | 37 | 38 | ### Features 39 | 40 | * :sparkles: preview image uses alias title ([ed589c1](https://github.com/haydenull/logseq-plugin-excalidraw/commit/ed589c192bddc3e295454f06133c9ff45443d4d0)) 41 | * :sparkles: save pan zoom view-mode status ([4f51843](https://github.com/haydenull/logseq-plugin-excalidraw/commit/4f518437eae00ec2556ec0ec6fb436c83bc3efc9)) 42 | 43 | # [1.3.0](https://github.com/haydenull/logseq-plugin-excalidraw/compare/v1.2.0...v1.3.0) (2023-05-26) 44 | 45 | 46 | ### Bug Fixes 47 | 48 | * :bug: adapt preview image to dark mode ([cea8ef3](https://github.com/haydenull/logseq-plugin-excalidraw/commit/cea8ef33ff373e15cd12bf624fd952bde0694eeb)) 49 | 50 | 51 | ### Features 52 | 53 | * :sparkles: add filter ([0615297](https://github.com/haydenull/logseq-plugin-excalidraw/commit/061529785e0e61183265c9ec8300ab8e704d958d)) 54 | * support filter ([38bce57](https://github.com/haydenull/logseq-plugin-excalidraw/commit/38bce57913f63994802e203c63136cc48af9049d)) 55 | 56 | # [1.2.0](https://github.com/haydenull/logseq-plugin-excalidraw/compare/v1.1.0...v1.2.0) (2023-04-30) 57 | 58 | 59 | ### Features 60 | 61 | * add custom font family settings ([3f54940](https://github.com/haydenull/logseq-plugin-excalidraw/commit/3f54940a2f50fd07fd39033de5a3fb08849c084b)) 62 | * set customize font family ([4eba96d](https://github.com/haydenull/logseq-plugin-excalidraw/commit/4eba96da20313f46efb4181470777d7527710cc9)) 63 | * support customize font family ([aa85a46](https://github.com/haydenull/logseq-plugin-excalidraw/commit/aa85a46849cd97a358dafd9daff23ab49c1cd4cf)) 64 | 65 | # [1.1.0](https://github.com/haydenull/logseq-plugin-excalidraw/compare/v1.0.0...v1.1.0) (2023-04-27) 66 | 67 | 68 | ### Features 69 | 70 | * :sparkles: automatically refresh the preview image after exiting editing ([9deeb4f](https://github.com/haydenull/logseq-plugin-excalidraw/commit/9deeb4fe7cb17e778ff80fb7a9c5fdc7e3e4c88a)) 71 | 72 | # 1.0.0 (2023-04-26) 73 | 74 | 75 | ### Bug Fixes 76 | 77 | * :bug: support paste image ([c76301b](https://github.com/haydenull/logseq-plugin-excalidraw/commit/c76301bc772a1a51d0ce9b34c6e7407af107ee3f)) 78 | * editor crash because appState error ([e71228a](https://github.com/haydenull/logseq-plugin-excalidraw/commit/e71228a0fb7cca180ec197a018593fe8d1746a7b)) 79 | 80 | 81 | ### Features 82 | 83 | * :sparkles: add menu ([4304593](https://github.com/haydenull/logseq-plugin-excalidraw/commit/430459339b1580b8330e3dea97c5609bfc3dd183)) 84 | * :sparkles: add save toast ([ff708f1](https://github.com/haydenull/logseq-plugin-excalidraw/commit/ff708f10d7168b057d012bf9702bf6b1f82181ea)) 85 | * :sparkles: create draw from slash command ([fd87e5e](https://github.com/haydenull/logseq-plugin-excalidraw/commit/fd87e5e9b10082dbe45f843d4d96d3361e64c270)) 86 | * :sparkles: mvp ([86598b5](https://github.com/haydenull/logseq-plugin-excalidraw/commit/86598b508df8324ec09afe1e658e295e4a284b0f)) 87 | * :sparkles: support add library ([2384cec](https://github.com/haydenull/logseq-plugin-excalidraw/commit/2384cecaf93fbce4a0cee55049bc7ca2cb344e6d)) 88 | * support edit ([8915347](https://github.com/haydenull/logseq-plugin-excalidraw/commit/8915347581c83502fbf650246eb2af1cbcdb7f29)) 89 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Hayden Chen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # logseq-plugin-excalidraw 2 | > Logseq-plugin-excalidraw is a powerful and versatile plugin designed to seamlessly integrate Excalidraw into Logseq. With this plugin, you can effortlessly create and edit Excalidraw drawings within your Logseq. 3 | 4 | [![latest release version](https://img.shields.io/github/v/release/haydenull/logseq-plugin-excalidraw)](https://github.com/haydenull/logseq-plugin-excalidraw/releases) 5 | [![License](https://img.shields.io/github/license/haydenull/logseq-plugin-excalidraw?color=blue)](https://github.com/haydenull/logseq-plugin-excalidraw/blob/main/LICENSE) 6 | 7 | ## Demo 8 | ![demo](./demo.gif) 9 | 10 | [slides Demo](https://lusun.com/v/Y8FLjgVpM2A) 11 | 12 | ## Features 13 | 14 | - Create and edit Excalidraw drawings directly in Logseq 15 | - Import Excalidraw libraries for a more comprehensive drawing experience 16 | - Easy-to-use interface for a smooth and efficient workflow 17 | - Customizable font family 18 | - Supports slides mode 19 | 20 | ## Installation 21 | 22 | 1. Open Logseq and navigate to the plugins page. 23 | 2. Search for "Excalidraw". 24 | 3. Click "Install" to add the plugin to your Logseq. 25 | 26 | ## Usage 27 | 28 | ### Create a new drawing 29 | Use `/` command, select "🎨 Excalidraw: Create New Drawing". 30 | 31 | ### Set font family 32 | Fill in the font file address in the plugin settings. 33 | 34 | - Online File: `https://example.com/your/path/font.woff2` 35 | - Local File: 36 | - Mac: `file:///Users/foo/your/path/font.woff2` 37 | - Windows: `file:///C:/Users/foo/your/path/font.woff2` 38 | 39 | ## Notes 40 | 41 | - Large files may cause freezing when saving, so it is recommended to save your work frequently and avoid working with excessively large files. 42 | - Deleting an Excalidraw drawing within Logseq will also delete the original file. 43 | 44 | ## Why I made Excalidraw plugin 45 | The reason for developing this plugin is that the excalidraw build-in with logseq cannot meet my needs, such as preview and full-screen operation. 46 | 47 | I hope this plugin can help users easily showcase their ideas, so it needs to have the following features: 48 | 49 | - full-screen operation 50 | - support excalidraw library 51 | - customize drawing name (in development) 52 | - drawing management dashboard (in development) 53 | 54 | Another point is that the plugin will remain synchronized with the official version of Excalidraw. 55 | 56 | ## Roadmap 57 | 58 | - [x] image preview 59 | - [x] full-screen operation 60 | - [x] support excalidraw library 61 | - [x] customize font family 62 | - [x] customize drawing name 63 | - [x] drawing management dashboard 64 | 65 | ## Screenshots 66 | 67 | ### Dashboard 68 | ![dashboard](./screenshots/dashboard.png) 69 | 70 | ### Editor 71 | ![editor](./screenshots/editor.png) 72 | 73 | ### Preview 74 | ![preview](./screenshots/preview.png) 75 | 76 | ## Contributing 77 | 78 | We welcome contributions to improve and expand the functionality of logseq-plugin-excalidraw. If you have any suggestions or would like to contribute, please feel free to open an issue or submit a pull request on our GitHub repository. 79 | 80 | ## License 81 | 82 | This project is licensed under the MIT License. -------------------------------------------------------------------------------- /cz.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('czg').CommitizenGitOptions} */ 2 | module.exports = { 3 | ...require('@haydenull/fabric/cz'), 4 | scopes: [ 5 | /** your scopes */ 6 | ], 7 | } 8 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haydenull/logseq-plugin-excalidraw/59b640c470d5310b670560882592fcc46ae832ae/demo.gif -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haydenull/logseq-plugin-excalidraw/59b640c470d5310b670560882592fcc46ae832ae/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "excalidraw", 3 | "version": "1.5.1", 4 | "main": "dist/index.html", 5 | "logseq": { 6 | "id": "logseq-plugin-excalidraw", 7 | "icon": "logo.png" 8 | }, 9 | "scripts": { 10 | "dev": "vite", 11 | "build": "tsc && vite build", 12 | "preview": "vite preview", 13 | "prepare": "husky install", 14 | "typecheck": "tsc --noEmit" 15 | }, 16 | "lint-staged": { 17 | "**/*.{js,jsx,ts,tsx}": [ 18 | "npx prettier --write", 19 | "npx eslint --fix" 20 | ] 21 | }, 22 | "dependencies": { 23 | "@logseq/libs": "^0.0.15", 24 | "@radix-ui/react-alert-dialog": "^1.0.3", 25 | "@radix-ui/react-context-menu": "^2.1.3", 26 | "@radix-ui/react-dialog": "^1.0.3", 27 | "@radix-ui/react-dropdown-menu": "^2.0.4", 28 | "@radix-ui/react-label": "^2.0.1", 29 | "@radix-ui/react-popover": "^1.0.5", 30 | "@radix-ui/react-separator": "^1.0.2", 31 | "@radix-ui/react-slot": "^1.0.1", 32 | "@radix-ui/react-toast": "^1.1.3", 33 | "antd": "^5.0.2", 34 | "class-variance-authority": "^0.5.2", 35 | "clsx": "^1.2.1", 36 | "cmdk": "^0.2.0", 37 | "dayjs": "^1.11.7", 38 | "jotai": "^2.1.0", 39 | "lodash-es": "^4.17.21", 40 | "lucide-react": "^0.180.0", 41 | "react": "^18.2.0", 42 | "react-dom": "^18.2.0", 43 | "react-icons": "^4.12.0", 44 | "tailwind-merge": "^1.12.0", 45 | "tailwindcss-animate": "^1.0.5" 46 | }, 47 | "devDependencies": { 48 | "@excalidraw/excalidraw": "0.17.0", 49 | "@haydenull/fabric": "^0.2.4", 50 | "@semantic-release/changelog": "^6.0.1", 51 | "@semantic-release/exec": "^6.0.3", 52 | "@semantic-release/git": "^10.0.1", 53 | "@semantic-release/npm": "^8.0.3", 54 | "@types/lodash": "^4.14.194", 55 | "@types/node": "^18.11.9", 56 | "@types/react": "^18.0.25", 57 | "@types/react-dom": "^18.0.9", 58 | "@vitejs/plugin-react": "^4.0.4", 59 | "autoprefixer": "^10.4.2", 60 | "husky": "^8.0.0", 61 | "lint-staged": "^14.0.0", 62 | "postcss": "^8.4.5", 63 | "semantic-release": "^21.0.1", 64 | "tailwindcss": "^3.2.4", 65 | "typescript": "^4.9.3", 66 | "vite": "^4.4.9", 67 | "vite-plugin-importer": "^0.2.5" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require("@haydenull/fabric/prettier"), 3 | } -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | const pluginName = require('./package.json').name 2 | 3 | module.exports = { 4 | branches: 'main', 5 | plugins: [ 6 | '@semantic-release/commit-analyzer', 7 | '@semantic-release/release-notes-generator', 8 | '@semantic-release/changelog', 9 | [ 10 | '@semantic-release/npm', 11 | { npmPublish: false } 12 | ], 13 | [ 14 | '@semantic-release/git', 15 | { 16 | assets: ['CHANGELOG.md', 'package.json'], 17 | message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}' 18 | } 19 | ], 20 | [ 21 | '@semantic-release/exec', 22 | { 23 | prepareCmd: 'zip -qq -r ' + pluginName + '-${nextRelease.version}.zip package.json logo.png dist/' 24 | } 25 | ], 26 | [ 27 | '@semantic-release/github', 28 | { 29 | assets: [`${pluginName}-*.zip`] 30 | } 31 | ] 32 | ] 33 | } -------------------------------------------------------------------------------- /screenshots/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haydenull/logseq-plugin-excalidraw/59b640c470d5310b670560882592fcc46ae832ae/screenshots/dashboard.png -------------------------------------------------------------------------------- /screenshots/editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haydenull/logseq-plugin-excalidraw/59b640c470d5310b670560882592fcc46ae832ae/screenshots/editor.png -------------------------------------------------------------------------------- /screenshots/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haydenull/logseq-plugin-excalidraw/59b640c470d5310b670560882592fcc46ae832ae/screenshots/preview.png -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haydenull/logseq-plugin-excalidraw/59b640c470d5310b670560882592fcc46ae832ae/src/App.css -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./App.css"; 2 | 3 | const App: React.FC<{ pageName: string }> = ({ pageName }) => { 4 | return ( 5 |
6 |
logseq.hideMainUI()} 9 | >
10 |
11 | ); 12 | }; 13 | 14 | export default App; 15 | -------------------------------------------------------------------------------- /src/app/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { exportToSvg } from '@excalidraw/excalidraw' 2 | import { useAtom } from 'jotai' 3 | import { LogOut, X } from 'lucide-react' 4 | import { useEffect, useState } from 'react' 5 | 6 | import DrawingCard, { PREVIEW_WINDOW, type IPageWithDrawing } from '@/components/DrawingCard' 7 | import CreateDrawingModal, { EditTypeEnum } from '@/components/EditDrawingInfoModal' 8 | import Editor, { EditorTypeEnum, type Theme } from '@/components/Editor' 9 | import TagSelector from '@/components/TagSelector' 10 | import { Button } from '@/components/ui/button' 11 | import { Input } from '@/components/ui/input' 12 | import { Toaster } from '@/components/ui/toaster' 13 | import { getExcalidrawInfoFromPage, getExcalidrawPages, getTags, setTheme } from '@/lib/utils' 14 | import { tagsAtom } from '@/model/tags' 15 | 16 | /** 17 | * Get all drawing pages and generate svg for each page 18 | */ 19 | const getAllPages = async (): Promise => { 20 | const pages = await getExcalidrawPages() 21 | if (!pages) return [] 22 | 23 | const theme = await logseq.App.getStateFromStore('ui/theme') 24 | const promises = pages.map(async (page) => { 25 | const { excalidrawData, rawBlocks } = await getExcalidrawInfoFromPage(page.name) 26 | const svg = await exportToSvg({ 27 | elements: excalidrawData?.elements ?? [], 28 | // appState: , 29 | appState: { 30 | ...(excalidrawData?.appState ?? {}), 31 | exportWithDarkMode: theme === 'dark', 32 | }, 33 | exportPadding: 20, 34 | files: excalidrawData?.files ?? null, 35 | }) 36 | const width = Number(svg.getAttribute('width')) || 100 37 | const height = Number(svg.getAttribute('height')) || 80 38 | // display svg in full screen based on aspect radio 39 | const aspectRadio = width / height 40 | const windowAspectRadio = PREVIEW_WINDOW.width / PREVIEW_WINDOW.height 41 | if (aspectRadio > windowAspectRadio) { 42 | svg.style.width = PREVIEW_WINDOW.width + 'px' 43 | svg.style.height = 'auto' 44 | } else { 45 | svg.style.width = 'auto' 46 | svg.style.height = PREVIEW_WINDOW.height + 'px' 47 | } 48 | 49 | const firstBlock = rawBlocks?.[0] 50 | const drawAlias = firstBlock?.properties?.excalidrawPluginAlias 51 | const drawTag = firstBlock?.properties?.excalidrawPluginTag 52 | return { 53 | ...page, 54 | drawSvg: svg, 55 | drawAlias, 56 | drawTag, 57 | drawRawBlocks: rawBlocks, 58 | } 59 | }) 60 | return Promise.all(promises) 61 | } 62 | 63 | const DashboardApp = () => { 64 | const [allPages, setAllPages] = useState([]) 65 | const [openCreateDrawingModal, setOpenCreateDrawingModal] = useState(false) 66 | const [editorInfo, setEditorInfo] = useState<{ 67 | show: boolean 68 | pageName?: string 69 | }>({ 70 | show: false, 71 | }) 72 | const [, setTags] = useAtom(tagsAtom) 73 | const [filterTag, setFilterTag] = useState() 74 | const [filterInput, setFilterInput] = useState('') 75 | 76 | const pagesAfterFilter = allPages.filter((page) => { 77 | const _filterInput = filterInput?.trim() 78 | const _filterTag = filterTag?.trim() 79 | 80 | // show all drawings if no filter 81 | const hasFilterTag = _filterTag ? page.drawTag?.toLowerCase().includes(_filterTag) : true 82 | const hasFilterInput = _filterInput ? page.drawAlias?.toLowerCase().includes(_filterInput) : true 83 | return hasFilterTag && hasFilterInput 84 | }) 85 | 86 | const onClickReset = () => { 87 | setFilterInput('') 88 | setFilterTag('') 89 | } 90 | const onClickDrawing = (page: IPageWithDrawing) => { 91 | setEditorInfo({ 92 | show: true, 93 | pageName: page.originalName, 94 | }) 95 | } 96 | const onDeleteDrawing = (page: IPageWithDrawing) => { 97 | setAllPages(allPages.filter((p) => p.originalName !== page.originalName)) 98 | } 99 | 100 | const refresh = () => { 101 | getAllPages().then(setAllPages) 102 | } 103 | 104 | useEffect(() => { 105 | getAllPages().then(setAllPages) 106 | }, []) 107 | useEffect(() => { 108 | getTags().then(setTags) 109 | }, []) 110 | // initialize theme 111 | useEffect(() => { 112 | logseq.App.getStateFromStore('ui/theme').then(setTheme) 113 | }, []) 114 | 115 | return ( 116 | <> 117 |
118 |
119 |
120 | setFilterInput(e.target.value)} 123 | placeholder="Filter drawings..." 124 | /> 125 | 126 | {Boolean(filterTag) || Boolean(filterInput) ? ( 127 | 130 | ) : null} 131 | 134 | 137 |
138 |
139 |
145 | {pagesAfterFilter.map((page) => ( 146 | 153 | ))} 154 |
155 | {editorInfo.show && editorInfo.pageName && ( 156 |
157 | setEditorInfo({ show: false })} 162 | /> 163 |
164 | )} 165 |
166 | 167 | 173 | 174 | ) 175 | } 176 | 177 | export default DashboardApp 178 | -------------------------------------------------------------------------------- /src/app/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { Toaster } from "@/components/ui/toaster"; 3 | import Editor, { Theme } from "@/components/Editor"; 4 | import { getExcalidrawInfoFromPage, getTags, setTheme } from "@/lib/utils"; 5 | import { insertSVG } from "@/bootstrap/renderBlockImage"; 6 | import { useEffect } from "react"; 7 | import { tagsAtom } from "@/model/tags"; 8 | 9 | const EditorApp: React.FC<{ pageName: string; renderSlotId?: string }> = ({ 10 | pageName, 11 | renderSlotId, 12 | }) => { 13 | const [, setTags] = useAtom(tagsAtom); 14 | const onClose = async () => { 15 | // refresh render block image 16 | if (pageName && renderSlotId) { 17 | const { excalidrawData } = await getExcalidrawInfoFromPage(pageName); 18 | insertSVG(renderSlotId, undefined, excalidrawData); 19 | } 20 | logseq.hideMainUI(); 21 | }; 22 | useEffect(() => { 23 | getTags().then(setTags); 24 | }, []); 25 | // initialize theme 26 | useEffect(() => { 27 | logseq.App.getStateFromStore("ui/theme").then(setTheme); 28 | }, []); 29 | return ( 30 | <> 31 |
32 |
logseq.hideMainUI()} 35 | >
36 | 37 |
38 | 39 | 40 | ); 41 | }; 42 | 43 | export default EditorApp; 44 | -------------------------------------------------------------------------------- /src/app/Preview.tsx: -------------------------------------------------------------------------------- 1 | import Preview from "@/components/Preview"; 2 | import { useEffect } from "react"; 3 | 4 | export type Mode = "edit" | "preview"; 5 | 6 | const PreviewApp: React.FC<{ pageName: string }> = ({ pageName }) => { 7 | useEffect(() => { 8 | const close = (e) => { 9 | if (e.key === "Escape") logseq.hideMainUI(); 10 | }; 11 | // listen esc 12 | window.addEventListener("keyup", close); 13 | return () => { 14 | window.removeEventListener("keyup", close); 15 | }; 16 | }, []); 17 | 18 | return ( 19 |
20 |
logseq.hideMainUI()} 23 | >
24 | 25 |
26 | ); 27 | }; 28 | 29 | export default PreviewApp; 30 | -------------------------------------------------------------------------------- /src/bootstrap/command.ts: -------------------------------------------------------------------------------- 1 | import { createDrawing } from '@/lib/utils' 2 | import getI18N from '@/locales' 3 | 4 | const bootCommand = () => { 5 | const { createDrawing: i18nCreateDrawing } = getI18N() 6 | // slash command: create excalidraw 7 | logseq.Editor.registerSlashCommand(i18nCreateDrawing.tag, async ({ uuid }) => { 8 | const drawingPage = await createDrawing() 9 | if (!drawingPage) return 10 | logseq.Editor.updateBlock(uuid, `{{renderer excalidraw, ${drawingPage.fileName}}}`) 11 | }) 12 | } 13 | 14 | export default bootCommand 15 | -------------------------------------------------------------------------------- /src/bootstrap/excalidrawLibraryItems.ts: -------------------------------------------------------------------------------- 1 | import type { LibraryItems } from '@excalidraw/excalidraw/types/types' 2 | 3 | import { DEFAULT_EXCALIDRAW_LIBRARY_ITEMS, EXCALIDRAW_FILE_PROMPT } from '@/lib/constants' 4 | import { genBlockData, getExcalidrawData } from '@/lib/utils' 5 | 6 | const PAGE_NAME = 'excalidraw-library-items-storage' 7 | /** 8 | * initialize the page sorted in the excalidraw library items 9 | */ 10 | const bootExcalidrawLibraryItems = async () => { 11 | const page = await logseq.Editor.getPage(PAGE_NAME) 12 | if (page) return 13 | 14 | await logseq.Editor.createPage( 15 | PAGE_NAME, 16 | { 'excalidraw-plugin-library': 'true' }, 17 | { format: 'markdown', redirect: false }, 18 | ) 19 | await logseq.Editor.appendBlockInPage(PAGE_NAME, EXCALIDRAW_FILE_PROMPT) 20 | await logseq.Editor.appendBlockInPage(PAGE_NAME, genBlockData(DEFAULT_EXCALIDRAW_LIBRARY_ITEMS)) 21 | } 22 | 23 | export const getExcalidrawLibraryItems = async () => { 24 | const pageBlocks = await logseq.Editor.getPageBlocksTree(PAGE_NAME) 25 | const codeBlock = pageBlocks?.[2] 26 | const libraryItems = getExcalidrawData(codeBlock?.content) as LibraryItems 27 | return libraryItems 28 | } 29 | 30 | export const updateExcalidrawLibraryItems = async (items: LibraryItems) => { 31 | const pageBlocks = await logseq.Editor.getPageBlocksTree(PAGE_NAME) 32 | const codeBlock = pageBlocks?.[2] 33 | return logseq.Editor.updateBlock(codeBlock.uuid, genBlockData(items)) 34 | } 35 | 36 | export default bootExcalidrawLibraryItems 37 | -------------------------------------------------------------------------------- /src/bootstrap/model.ts: -------------------------------------------------------------------------------- 1 | import { getExcalidrawInfoFromPage } from "@/lib/utils"; 2 | import { RenderAppProps } from "@/main"; 3 | import { insertSVG } from "./renderBlockImage"; 4 | import getI18N from "@/locales"; 5 | 6 | const bootModels = (renderApp: (props: RenderAppProps) => void) => { 7 | const { common: i18nCommon } = getI18N(); 8 | logseq.provideModel({ 9 | edit(e) { 10 | const pageName = e.dataset.pageName; 11 | const containerId = e.dataset.containerId; 12 | if (!pageName) return logseq.UI.showMsg(i18nCommon.pageNotFound); 13 | renderApp({ mode: "edit", pageName, renderSlotId: containerId }); 14 | logseq.showMainUI(); 15 | }, 16 | fullscreen(e) { 17 | const pageName = e.dataset.pageName; 18 | if (!pageName) return logseq.UI.showMsg(i18nCommon.pageNotFound); 19 | renderApp({ mode: "preview", pageName }); 20 | logseq.showMainUI(); 21 | }, 22 | async refresh(e) { 23 | const pageName = e.dataset.pageName; 24 | const containerId = e.dataset.containerId; 25 | if (!pageName) return logseq.UI.showMsg(i18nCommon.pageNotFound); 26 | const { excalidrawData } = await getExcalidrawInfoFromPage(pageName); 27 | insertSVG(containerId, undefined, excalidrawData); 28 | }, 29 | async delete(e) { 30 | const pageName = e.dataset.pageName; 31 | if (!pageName) return logseq.UI.showMsg(i18nCommon.pageNotFound); 32 | await logseq.Editor.deletePage(pageName); 33 | logseq.UI.showMsg("Delete excalidraw file success", "success"); 34 | const uuid = e.dataset.blockId; 35 | logseq.Editor.removeBlock(uuid); 36 | }, 37 | async navPage(e) { 38 | const pageName = e.dataset.pageName; 39 | if (!pageName) return logseq.UI.showMsg(i18nCommon.pageNotFound); 40 | logseq.App.pushState("page", { name: pageName }); 41 | }, 42 | showDashboard() { 43 | // @ts-ignore do not need pageName 44 | renderApp({ mode: "dashboard" }); 45 | logseq.showMainUI(); 46 | }, 47 | }); 48 | }; 49 | 50 | export default bootModels; 51 | -------------------------------------------------------------------------------- /src/bootstrap/renderBlockImage.ts: -------------------------------------------------------------------------------- 1 | import { exportToSvg } from '@excalidraw/excalidraw' 2 | 3 | import type { Theme } from '@/components/Editor' 4 | import { NEW_FILE_EXCALIDRAW_DATA } from '@/lib/constants' 5 | import { getExcalidrawInfoFromPage } from '@/lib/utils' 6 | import getI18N from '@/locales' 7 | import type { ExcalidrawData } from '@/type' 8 | 9 | // const DEMO_FILE_ORIGINAL_NAME = "excalidraw-2023-04-24-16-39-01"; 10 | 11 | export const insertSVG = async (containerId: string, svg?: SVGSVGElement, excalidrawData?: ExcalidrawData) => { 12 | const theme = await logseq.App.getStateFromStore('ui/theme') 13 | const _svg = 14 | svg ?? 15 | (await exportToSvg( 16 | excalidrawData ?? { 17 | elements: [], 18 | appState: { exportWithDarkMode: theme === 'dark' }, 19 | files: null, 20 | }, 21 | )) 22 | setTimeout(() => { 23 | // remove svg if it exists 24 | const prevSvg = parent.document.getElementById(containerId)?.querySelector?.('.excalidraw-svg') 25 | if (prevSvg) prevSvg.remove() 26 | 27 | // insert preview img 28 | _svg.style.maxWidth = '100%' 29 | _svg.style.minWidth = '100px' 30 | _svg.style.height = 'auto' 31 | _svg.classList.add('excalidraw-svg') 32 | parent.document.getElementById(containerId)?.prepend?.(_svg) 33 | }, 0) 34 | } 35 | 36 | const bootRenderBlockImage = () => { 37 | const { preview: i18nPreview } = getI18N() 38 | // render: {{renderer excalidraw, excalidraw-2021-08-31-16-00-00}} 39 | logseq.App.onMacroRendererSlotted(async ({ slot, payload: { arguments: args, uuid } }) => { 40 | const slotType = args?.[0] 41 | if (slotType === 'excalidraw') { 42 | const pageName = args?.[1] 43 | console.log('[faiz:] === render pageName', pageName) 44 | 45 | const rendered = parent.document.getElementById(slot)?.childElementCount 46 | if (rendered) return 47 | 48 | const page = await logseq.Editor.getPage(pageName) 49 | if (page === null) { 50 | return logseq.provideUI({ 51 | key: `excalidraw-${slot}`, 52 | slot, 53 | reset: true, 54 | template: `🚨 Excalidraw: Page Not Found (${pageName})`, 55 | }) 56 | } 57 | if (!page?.properties?.excalidrawPlugin) { 58 | return logseq.provideUI({ 59 | key: `excalidraw-${slot}`, 60 | slot, 61 | reset: true, 62 | template: `🚨 Excalidraw: This page is not an excalidraw file (${pageName})`, 63 | }) 64 | } 65 | 66 | // get excalidraw data 67 | const { excalidrawData } = await getExcalidrawInfoFromPage(pageName) 68 | 69 | const { elements, appState, files } = excalidrawData 70 | const id = `excalidraw-${pageName}-${slot}` 71 | 72 | const isNewFile = elements?.length === 0 && appState === undefined 73 | const theme = await logseq.App.getStateFromStore('ui/theme') 74 | 75 | const svg = await exportToSvg( 76 | isNewFile 77 | ? { 78 | ...NEW_FILE_EXCALIDRAW_DATA, 79 | appState: { exportWithDarkMode: theme === 'dark' }, 80 | } 81 | : { 82 | elements, 83 | appState: { 84 | ...(appState ?? {}), 85 | exportWithDarkMode: theme === 'dark', 86 | }, 87 | files, 88 | }, 89 | ) 90 | 91 | const showTitle = page?.propertiesTextValues?.excalidrawPluginAlias ?? page?.originalName 92 | logseq.provideUI({ 93 | key: `excalidraw-${slot}`, 94 | slot, 95 | reset: true, 96 | template: `
97 |
98 | ${showTitle} 99 | 113 |
114 |
`, 115 | }) 116 | 117 | insertSVG(id, svg) 118 | } else if (slotType === 'excalidraw-menu') { 119 | logseq.provideUI({ 120 | key: `excalidraw-${slot}`, 121 | slot, 122 | reset: true, 123 | template: `WIP`, 124 | }) 125 | } 126 | }) 127 | 128 | logseq.provideStyle(` 129 | .excalidraw-container { 130 | position: relative; 131 | line-height: 0; 132 | } 133 | .excalidraw-container:hover .excalidraw-toolbar-container { 134 | opacity: 1; 135 | } 136 | .excalidraw-toolbar-container { 137 | display: flex; 138 | justify-content: space-between; 139 | opacity: 0; 140 | transition: opacity 0.3s ease-in-out; 141 | position: absolute; 142 | top: 0; 143 | left: 0; 144 | width: 100%; 145 | height: 100%; 146 | padding: 10px 6px; 147 | background-image: linear-gradient(var(--ls-primary-background-color),transparent); 148 | } 149 | .excalidraw-title { 150 | line-height: 16px; 151 | white-space: nowrap; 152 | overflow: hidden; 153 | text-overflow: ellipsis; 154 | } 155 | .excalidraw-toolbar { 156 | display: flex; 157 | justify-content: flex-end; 158 | gap: 10px; 159 | } 160 | .excalidraw-toolbar a { 161 | width: 18px; 162 | height: 18px; 163 | line-height: 0; 164 | } 165 | `) 166 | } 167 | 168 | export default bootRenderBlockImage 169 | -------------------------------------------------------------------------------- /src/components/DrawingCard.tsx: -------------------------------------------------------------------------------- 1 | import type { BlockEntity, PageEntity } from '@logseq/libs/dist/LSPlugin' 2 | import { Trash2, Edit3, Tag, Image } from 'lucide-react' 3 | import React, { useState } from 'react' 4 | 5 | import SVGComponent from '@/components/SVGComponent' 6 | import { 7 | ContextMenu, 8 | ContextMenuContent, 9 | ContextMenuItem, 10 | ContextMenuSeparator, 11 | ContextMenuTrigger, 12 | } from '@/components/ui/context-menu' 13 | import { copyToClipboard } from '@/lib/utils' 14 | 15 | import EditDrawingInfoModal, { EditTypeEnum } from './EditDrawingInfoModal' 16 | import { 17 | AlertDialog, 18 | AlertDialogAction, 19 | AlertDialogCancel, 20 | AlertDialogContent, 21 | AlertDialogDescription, 22 | AlertDialogFooter, 23 | AlertDialogHeader, 24 | AlertDialogTitle, 25 | AlertDialogTrigger, 26 | } from './ui/alert-dialog' 27 | import { useToast } from './ui/use-toast' 28 | 29 | export const PREVIEW_WINDOW = { 30 | width: 280, 31 | height: 180, 32 | } 33 | const TITLE_HEIGHT = 50 34 | export type IPageWithDrawing = PageEntity & { 35 | drawSvg: SVGSVGElement 36 | drawAlias?: string 37 | drawTag?: string 38 | drawRawBlocks: BlockEntity[] 39 | } 40 | 41 | const DrawingCard: React.FC<{ 42 | page: IPageWithDrawing 43 | onClickDrawing: (page: IPageWithDrawing) => void 44 | onDelete: (page: IPageWithDrawing) => void 45 | onChange?: () => void 46 | }> = ({ page, onClickDrawing, onDelete, onChange }) => { 47 | const [editModalData, setEditModalData] = useState<{ 48 | type?: EditTypeEnum 49 | open: boolean 50 | }>() 51 | const { toast } = useToast() 52 | 53 | const openEditDialog = (type: EditTypeEnum) => { 54 | setEditModalData({ 55 | type, 56 | open: true, 57 | }) 58 | } 59 | const onClickCopyRendererText = () => { 60 | // The reason for delay in execution is that the copying action can only be performed after shadcn's layer disappears. 61 | setTimeout(() => { 62 | copyToClipboard(`{{renderer excalidraw, ${page.originalName}}}`) 63 | toast({ 64 | title: 'Copied', 65 | description: 'Renderer text copied to clipboard successfully', 66 | }) 67 | }, 1000) 68 | } 69 | const deleteDrawing = async () => { 70 | await logseq.Editor.deletePage(page.originalName) 71 | toast({ 72 | title: 'Deleted', 73 | description: 'Page deleted successfully', 74 | }) 75 | onDelete(page) 76 | } 77 | return ( 78 | <> 79 |
84 |
88 | 89 | 90 |
onClickDrawing(page)}> 91 | {page.drawSvg && } 92 |
93 |
94 | 95 | openEditDialog(EditTypeEnum.Name)}> 96 | Rename 97 | 98 | openEditDialog(EditTypeEnum.Tag)}> 99 | Set Tag 100 | 101 | 102 | Copy Renderer Text 103 | 104 | 105 | 106 | 107 | 108 |
109 | Delete 110 |
111 |
112 | 113 | 114 | Are you sure absolutely sure? 115 | 116 | This action cannot be undone. This will permanently delete your drawing. 117 | 118 | 119 | 120 | Cancel 121 | Continue 122 | 123 | 124 |
125 |
126 |
127 |
128 | {page.drawTag && ( 129 |
130 | {page.drawTag} 131 |
132 | )} 133 |
134 |
onClickDrawing(page)} 138 | > 139 | {page.drawAlias || page.name} 140 |
141 | setEditModalData({ open: _open })} 146 | onOk={onChange} 147 | /> 148 |
149 | 150 | ) 151 | } 152 | 153 | export default DrawingCard 154 | -------------------------------------------------------------------------------- /src/components/EditDrawingInfoModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | Dialog, 4 | DialogContent, 5 | DialogFooter, 6 | DialogHeader, 7 | DialogTitle, 8 | DialogTrigger, 9 | } from "@/components/ui/dialog"; 10 | import { Button } from "./ui/button"; 11 | import { Input } from "./ui/input"; 12 | import { Label } from "./ui/label"; 13 | import { IPageWithDrawing } from "./DrawingCard"; 14 | import TagSelector from "./TagSelector"; 15 | import { useToast } from "./ui/use-toast"; 16 | import { createDrawing } from "@/lib/utils"; 17 | 18 | export enum EditTypeEnum { 19 | Name, 20 | Tag, 21 | Create, 22 | } 23 | 24 | const TITLE = { 25 | [EditTypeEnum.Name]: "Edit drawing alias name", 26 | [EditTypeEnum.Tag]: "Edit drawing tag", 27 | [EditTypeEnum.Create]: "Create new drawing", 28 | }; 29 | 30 | const EditDrawingInfoModal: React.FC<{ 31 | type: EditTypeEnum; 32 | drawingData?: IPageWithDrawing; 33 | open: boolean; 34 | onOpenChange: (open: boolean) => void; 35 | onOk?: () => void; 36 | }> = ({ type, drawingData, open, onOpenChange, onOk }) => { 37 | const [name, setName] = useState(drawingData?.drawAlias || ""); 38 | const [tag, setTag] = useState(drawingData?.drawTag || ""); 39 | const [isOpen, setIsOpen] = useState(false); 40 | 41 | const { toast } = useToast(); 42 | 43 | const onClickSave = async () => { 44 | const _name = name?.trim() ?? ""; 45 | const _tag = tag?.trim() ?? ""; 46 | if (type === EditTypeEnum.Create) { 47 | if (!_name) { 48 | return toast({ title: "Name is required", variant: "destructive" }); 49 | } 50 | let params: Record = {}; 51 | if (_name) params.alias = _name; 52 | if (_tag) params.tag = _tag; 53 | await createDrawing(params); 54 | toast({ title: "Created" }); 55 | onOpenChange(false); 56 | onOk?.(); 57 | return; 58 | } 59 | 60 | if (!drawingData) return; 61 | const { uuid } = drawingData.drawRawBlocks[0]; 62 | if (type === EditTypeEnum.Name) { 63 | if (_name?.length === 0) { 64 | return toast({ title: "Name is required", variant: "destructive" }); 65 | } 66 | await logseq.Editor.upsertBlockProperty( 67 | uuid, 68 | "excalidraw-plugin-alias", 69 | _name 70 | ); 71 | } else if (type === EditTypeEnum.Tag) { 72 | if (_tag?.length === 0) { 73 | return toast({ title: "Tag is required", variant: "destructive" }); 74 | } 75 | await logseq.Editor.upsertBlockProperty( 76 | uuid, 77 | "excalidraw-plugin-tag", 78 | _tag 79 | ); 80 | } 81 | toast({ title: "Saved" }); 82 | onOpenChange(false); 83 | onOk?.(); 84 | }; 85 | 86 | return ( 87 | 88 | 89 | 90 | {TITLE[type]} 91 | 92 |
93 | {type === EditTypeEnum.Name || type === EditTypeEnum.Create ? ( 94 |
95 | 98 | setName(e.target.value)} 103 | /> 104 |
105 | ) : null} 106 | {type === EditTypeEnum.Tag || type === EditTypeEnum.Create ? ( 107 |
108 | 111 | 117 |
118 | ) : null} 119 |
120 | 121 | 124 | 125 |
126 |
127 | ); 128 | }; 129 | 130 | export default EditDrawingInfoModal; 131 | -------------------------------------------------------------------------------- /src/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { Excalidraw, Button, MainMenu, WelcomeScreen, getSceneVersion, Footer } from '@excalidraw/excalidraw' 2 | import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types' 3 | import type { AppState, BinaryFiles, ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types' 4 | import type { LibraryItems } from '@excalidraw/excalidraw/types/types' 5 | import type { BlockIdentity } from '@logseq/libs/dist/LSPlugin' 6 | import { debounce } from 'lodash-es' 7 | import React, { useEffect, useRef, useState } from 'react' 8 | import { BiSlideshow } from 'react-icons/bi' 9 | import { BsLayoutSidebarInset } from 'react-icons/bs' 10 | import { FiArrowLeft, FiArrowRight } from 'react-icons/fi' 11 | import { IoAppsOutline } from 'react-icons/io5' 12 | import { TbLogout, TbBrandGithub, TbArrowsMinimize } from 'react-icons/tb' 13 | 14 | import { getExcalidrawLibraryItems, updateExcalidrawLibraryItems } from '@/bootstrap/excalidrawLibraryItems' 15 | import { Input } from '@/components/ui/input' 16 | import { useToast } from '@/components/ui/use-toast' 17 | import useSlides from '@/hook/useSlides' 18 | import { cn, genBlockData, getExcalidrawInfoFromPage, getLangCode, getMinimalAppState } from '@/lib/utils' 19 | import getI18N from '@/locales' 20 | import type { ExcalidrawData, PluginSettings } from '@/type' 21 | 22 | import SlidesOverview from './SlidesOverview' 23 | import SlidesPreview from './SlidesPreview' 24 | import TagSelector from './TagSelector' 25 | 26 | export type Theme = 'light' | 'dark' 27 | export enum EditorTypeEnum { 28 | App = 'app', 29 | Page = 'page', 30 | } 31 | const WAIT = 1000 32 | const updatePageProperty = debounce((block: BlockIdentity, key: string, value: string) => { 33 | logseq.Editor.upsertBlockProperty(block, key, value) 34 | }, WAIT) 35 | 36 | const Editor: React.FC< 37 | React.PropsWithChildren<{ 38 | pageName: string 39 | onClose?: () => void 40 | type?: EditorTypeEnum 41 | }> 42 | > = ({ pageName, onClose, type = EditorTypeEnum.App }) => { 43 | const [excalidrawData, setExcalidrawData] = useState() 44 | const [libraryItems, setLibraryItems] = useState() 45 | const [theme, setTheme] = useState() 46 | const blockUUIDRef = useRef() 47 | const pagePropertyBlockUUIDRef = useRef() 48 | const currentExcalidrawDataRef = useRef() 49 | // const [currentExcalidrawData, setCurrentExcalidrawData] = useState() 50 | const sceneVersionRef = useRef() 51 | const [excalidrawAPI, setExcalidrawAPI] = useState(null) 52 | const { frames, isFirst, isLast, activeFrameIndex, updateFrames, prev, next } = useSlides(excalidrawAPI) 53 | const [slidesModeEnabled, setSlidesModeEnabled] = useState(false) 54 | const [showSlidesPreview, setShowSlidesPreview] = useState(true) 55 | const [showSlidesOverview, setShowSlidesOverview] = useState(false) 56 | 57 | const [aliasName, setAliasName] = useState() 58 | const [tag, setTag] = useState() 59 | 60 | const { toast } = useToast() 61 | const { editor: i18nEditor } = getI18N() 62 | 63 | // save excalidraw data to currentExcalidrawDataRef 64 | const onExcalidrawChange = debounce( 65 | (elements: readonly ExcalidrawElement[], appState: AppState, files: BinaryFiles) => { 66 | // const blockData = genBlockData({ 67 | // ...excalidrawData, 68 | // elements: excalidrawElements, 69 | // appState: getMinimalAppState(appState), 70 | // files, 71 | // }); 72 | // if (blockUUIDRef.current) 73 | // logseq.Editor.updateBlock(blockUUIDRef.current, blockData); 74 | currentExcalidrawDataRef.current = { 75 | elements, 76 | appState, 77 | files, 78 | } 79 | const sceneVersion = getSceneVersion(elements) 80 | // fix https://github.com/excalidraw/excalidraw/issues/3014 81 | if (sceneVersionRef.current !== sceneVersion) { 82 | sceneVersionRef.current = sceneVersion 83 | // setCurrentExcalidrawData({ 84 | // elements, 85 | // appState, 86 | // files, 87 | // }) 88 | updateFrames({ elements, files, theme }) 89 | } 90 | }, 91 | WAIT, 92 | ) 93 | // save library items to page 94 | const onLibraryChange = (items: LibraryItems) => { 95 | updateExcalidrawLibraryItems(items) 96 | } 97 | // save excalidraw data to page 98 | const onClickClose = (type?: EditorTypeEnum) => { 99 | const { id, dismiss } = toast({ 100 | variant: 'destructive', 101 | title: i18nEditor.saveToast.title, 102 | description: i18nEditor.saveToast.description, 103 | duration: 0, 104 | }) 105 | setTimeout(async () => { 106 | if (currentExcalidrawDataRef.current && blockUUIDRef.current) { 107 | console.log('[faiz:] === start save') 108 | const { elements, appState, files } = currentExcalidrawDataRef.current 109 | const blockData = genBlockData({ 110 | ...excalidrawData, 111 | elements, 112 | appState: getMinimalAppState(appState!), 113 | files, 114 | }) 115 | await logseq.Editor.updateBlock(blockUUIDRef.current, blockData) 116 | console.log('[faiz:] === end save') 117 | dismiss() 118 | onClose?.() 119 | } 120 | }, WAIT + 100) 121 | } 122 | 123 | const onAliasNameChange = (aliasName: string) => { 124 | setAliasName(aliasName) 125 | if (pagePropertyBlockUUIDRef.current) { 126 | updatePageProperty(pagePropertyBlockUUIDRef.current, 'excalidraw-plugin-alias', aliasName) 127 | } 128 | } 129 | const onTagChange = (tag: string) => { 130 | setTag(tag) 131 | if (pagePropertyBlockUUIDRef.current) { 132 | updatePageProperty(pagePropertyBlockUUIDRef.current, 'excalidraw-plugin-tag', tag) 133 | } 134 | } 135 | 136 | // initialize excalidraw data 137 | useEffect(() => { 138 | getExcalidrawInfoFromPage(pageName).then((data) => { 139 | setExcalidrawData(data?.excalidrawData) 140 | blockUUIDRef.current = data?.block?.uuid 141 | 142 | const firstBlock = data?.rawBlocks?.[0] 143 | pagePropertyBlockUUIDRef.current = firstBlock?.uuid 144 | setAliasName(firstBlock.properties?.excalidrawPluginAlias || '') 145 | setTag(firstBlock.properties?.excalidrawPluginTag?.toLowerCase?.() || '') 146 | }) 147 | }, [pageName]) 148 | // initialize library items 149 | useEffect(() => { 150 | getExcalidrawLibraryItems().then((items) => { 151 | setLibraryItems(items || []) 152 | }) 153 | }, []) 154 | // initialize theme 155 | useEffect(() => { 156 | logseq.App.getStateFromStore('ui/theme').then(setTheme) 157 | }, []) 158 | 159 | return ( 160 |
161 | {excalidrawData && libraryItems && ( 162 | setExcalidrawAPI(api)} 164 | langCode={getLangCode((logseq.settings as unknown as PluginSettings)?.langCode)} 165 | initialData={{ 166 | elements: excalidrawData.elements || [], 167 | appState: excalidrawData.appState 168 | ? Object.assign({ theme }, getMinimalAppState(excalidrawData.appState)) 169 | : { theme }, 170 | files: excalidrawData?.files || undefined, 171 | libraryItems, 172 | }} 173 | onChange={onExcalidrawChange} 174 | onLibraryChange={onLibraryChange} 175 | renderTopRightUI={() => ( 176 |
177 | onAliasNameChange(e.target.value)} /> 178 | 179 | 186 | 197 |
198 | )} 199 | > 200 | 201 | } 203 | onSelect={() => logseq.App.openExternalLink('https://github.com/haydenull/logseq-plugin-excalidraw')} 204 | > 205 | Github 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | Logseq Excalidraw Plugin 218 | 219 | 220 |
221 | {slidesModeEnabled ? ( 222 |
223 | 230 | 237 | 245 | 253 | {activeFrameIndex >= 0 ? ( 254 |
255 | {activeFrameIndex + 1} 256 | / {frames.length} 257 |
258 | ) : null} 259 |
260 | ) : null} 261 |
262 |
263 | )} 264 | {slidesModeEnabled && showSlidesPreview ? : null} 265 | setShowSlidesOverview(false)} 270 | api={excalidrawAPI} 271 | /> 272 |
273 | ) 274 | } 275 | 276 | export default Editor 277 | -------------------------------------------------------------------------------- /src/components/Preview.tsx: -------------------------------------------------------------------------------- 1 | import { exportToSvg } from '@excalidraw/excalidraw' 2 | import React, { useEffect, useRef } from 'react' 3 | import { AiFillCloseCircle } from 'react-icons/ai' 4 | 5 | import { getExcalidrawInfoFromPage } from '@/lib/utils' 6 | 7 | import type { Theme } from './Editor' 8 | 9 | const Preview: React.FC> = ({ pageName }) => { 10 | const containerRef = useRef(null) 11 | 12 | useEffect(() => { 13 | if (pageName) { 14 | getExcalidrawInfoFromPage(pageName).then(async ({ excalidrawData }) => { 15 | const theme = await logseq.App.getStateFromStore('ui/theme') 16 | const svg = await exportToSvg({ 17 | elements: excalidrawData?.elements ?? [], 18 | appState: { 19 | ...(excalidrawData?.appState ?? {}), 20 | exportWithDarkMode: theme === 'dark', 21 | }, 22 | files: excalidrawData?.files ?? null, 23 | }) 24 | const width = Number(svg.getAttribute('width')) || 100 25 | const height = Number(svg.getAttribute('height')) || 80 26 | if (containerRef?.current) { 27 | // display svg in full screen based on aspect radio 28 | const aspectRadio = width / height 29 | const windowAspectRadio = window.innerWidth / window.innerHeight 30 | if (aspectRadio > windowAspectRadio) { 31 | svg.style.width = '100vw' 32 | svg.style.height = 'auto' 33 | } else { 34 | svg.style.width = 'auto' 35 | svg.style.height = '100vh' 36 | } 37 | 38 | containerRef.current.appendChild(svg) 39 | } 40 | }) 41 | } 42 | }, [pageName]) 43 | 44 | return ( 45 | <> 46 |
47 |
48 | logseq.hideMainUI()} /> 49 |
50 | 51 | ) 52 | } 53 | 54 | export default Preview 55 | -------------------------------------------------------------------------------- /src/components/PreviewImage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | 3 | const PreviewImage = ({ blobPromise }: { blobPromise: Promise }) => { 4 | const [imageSrc, setImageSrc] = useState() 5 | 6 | useEffect(() => { 7 | const loadImage = async () => { 8 | try { 9 | const blob = await blobPromise 10 | const imageUrl = URL.createObjectURL(blob) 11 | setImageSrc(imageUrl) 12 | } catch (error) { 13 | console.error('Failed to load image:', error) 14 | } 15 | } 16 | 17 | loadImage() 18 | }, [blobPromise]) 19 | 20 | useEffect(() => { 21 | return () => { 22 | // Clean up the URL object when component unmounts 23 | if (imageSrc) { 24 | URL.revokeObjectURL(imageSrc) 25 | } 26 | } 27 | }, []) 28 | 29 | return
{imageSrc ? Image :
Loading image...
}
30 | } 31 | 32 | export default PreviewImage 33 | -------------------------------------------------------------------------------- /src/components/SVGComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | 3 | interface SVGComponentProps { 4 | svgElement: SVGSVGElement; 5 | } 6 | 7 | const SVGComponent: FC = ({ svgElement }) => { 8 | return
; 9 | }; 10 | 11 | export default SVGComponent; 12 | -------------------------------------------------------------------------------- /src/components/SlidesOverview.tsx: -------------------------------------------------------------------------------- 1 | import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types' 2 | import { CgClose } from 'react-icons/cg' 3 | 4 | import useSlides from '@/hook/useSlides' 5 | import { cn } from '@/lib/utils' 6 | import getI18N from '@/locales' 7 | 8 | import type { Theme } from './Editor' 9 | import PreviewImage from './PreviewImage' 10 | 11 | const SlidesOverview = ({ 12 | api, 13 | theme, 14 | open, 15 | onClose, 16 | className, 17 | }: { 18 | open?: boolean 19 | onClose: () => void 20 | api: ExcalidrawImperativeAPI | null 21 | theme?: Theme 22 | className?: string 23 | }) => { 24 | const { editor: i18nEditor } = getI18N() 25 | const { frames, activeFrameId, scrollToFrame } = useSlides(api) 26 | 27 | const onClickFrame = (frame) => { 28 | scrollToFrame(frame) 29 | onClose() 30 | } 31 | return open ? ( 32 |
33 | {frames.length > 0 ? ( 34 |
41 | {frames?.map(({ frameElement: frame, blobPromise }, index) => ( 42 |
43 |
onClickFrame(frame)} 49 | > 50 |
51 | 52 |
53 |
59 | {frame.name || frame.id} 60 |
61 |
62 |
63 | {index + 1} 64 |
65 |
66 | ))} 67 |
68 | ) : ( 69 |
{i18nEditor.frameNotFound}
70 | )} 71 |
72 | 73 |
74 |
75 | ) : null 76 | } 77 | 78 | export default SlidesOverview 79 | -------------------------------------------------------------------------------- /src/components/SlidesPreview.tsx: -------------------------------------------------------------------------------- 1 | import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types' 2 | 3 | import useSlides from '@/hook/useSlides' 4 | import { cn } from '@/lib/utils' 5 | import getI18N from '@/locales' 6 | 7 | import { type Theme } from './Editor' 8 | import PreviewImage from './PreviewImage' 9 | 10 | const SlidesPreview = ({ api, theme }: { api: ExcalidrawImperativeAPI | null; theme?: Theme }) => { 11 | const { editor: i18nEditor } = getI18N() 12 | const { frames, activeFrameId, scrollToFrame } = useSlides(api) 13 | 14 | const onClickFrame = (frame) => { 15 | scrollToFrame(frame) 16 | } 17 | return ( 18 |
24 | {frames.length > 0 ? ( 25 | frames?.map(({ frameElement: frame, blobPromise }) => ( 26 |
onClickFrame(frame)} 33 | > 34 | 35 |
41 | {frame.name || frame.id} 42 |
43 |
44 | )) 45 | ) : ( 46 |
{i18nEditor.frameNotFound}
47 | )} 48 |
49 | ) 50 | } 51 | 52 | export default SlidesPreview 53 | -------------------------------------------------------------------------------- /src/components/TagSelector.tsx: -------------------------------------------------------------------------------- 1 | import { Check, ChevronsUpDown, PlusCircle } from "lucide-react"; 2 | import { cn } from "@/lib/utils"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Command, 6 | CommandEmpty, 7 | CommandGroup, 8 | CommandInput, 9 | CommandItem, 10 | } from "@/components/ui/command"; 11 | import { 12 | Popover, 13 | PopoverContent, 14 | PopoverTrigger, 15 | } from "@/components/ui/popover"; 16 | import { useState } from "react"; 17 | import { tagsAtom } from "@/model/tags"; 18 | import { useAtom } from "jotai"; 19 | import { Input } from "./ui/input"; 20 | import { useToast } from "./ui/use-toast"; 21 | import { Separator } from "./ui/separator"; 22 | import { Badge } from "./ui/badge"; 23 | 24 | const TagSelector: React.FC<{ 25 | value?: string; 26 | showAdd?: boolean; 27 | onChange: (value: string) => void; 28 | asFilter?: boolean; 29 | className?: string; 30 | }> = ({ value, onChange, showAdd = false, asFilter = false, className }) => { 31 | const [open, setOpen] = useState(false); 32 | const [tags = [], setTags] = useAtom(tagsAtom); 33 | const tagOptions = tags?.map((tag) => ({ 34 | value: tag?.toLowerCase(), 35 | label: tag, 36 | })); 37 | 38 | const [newTag, setNewTag] = useState(""); 39 | 40 | const { toast } = useToast(); 41 | 42 | const onClickAddTag = () => { 43 | if (!newTag) { 44 | return toast({ 45 | variant: "destructive", 46 | title: "Tag cannot be empty", 47 | }); 48 | } 49 | if (tags?.map((tag) => tag.toLowerCase()).includes(newTag?.toLowerCase())) { 50 | return toast({ 51 | variant: "destructive", 52 | title: "Tag already exists", 53 | }); 54 | } 55 | setTags([...tags, newTag]); 56 | setNewTag(""); 57 | onChange(newTag); 58 | setOpen(false); 59 | }; 60 | 61 | return ( 62 | 63 | 64 | {asFilter ? ( 65 | 80 | ) : ( 81 | 94 | )} 95 | 96 | 97 | 98 | 99 | No tag found. 100 | 101 | {tagOptions.map((tag) => ( 102 | { 105 | onChange(currentValue === value ? "" : currentValue); 106 | setOpen(false); 107 | }} 108 | > 109 | 115 | {tag.label} 116 | 117 | ))} 118 | 119 | 120 | {showAdd && ( 121 |
122 | setNewTag(e.target.value)} 127 | /> 128 | 131 |
132 | )} 133 |
134 |
135 | ); 136 | }; 137 | 138 | export default TagSelector; 139 | -------------------------------------------------------------------------------- /src/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = ({ 14 | className, 15 | children, 16 | ...props 17 | }: AlertDialogPrimitive.AlertDialogPortalProps) => ( 18 | 19 |
20 | {children} 21 |
22 |
23 | ) 24 | AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName 25 | 26 | const AlertDialogOverlay = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, children, ...props }, ref) => ( 30 | 38 | )) 39 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 40 | 41 | const AlertDialogContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, ...props }, ref) => ( 45 | 46 | 47 | 55 | 56 | )) 57 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 58 | 59 | const AlertDialogHeader = ({ 60 | className, 61 | ...props 62 | }: React.HTMLAttributes) => ( 63 |
70 | ) 71 | AlertDialogHeader.displayName = "AlertDialogHeader" 72 | 73 | const AlertDialogFooter = ({ 74 | className, 75 | ...props 76 | }: React.HTMLAttributes) => ( 77 |
84 | ) 85 | AlertDialogFooter.displayName = "AlertDialogFooter" 86 | 87 | const AlertDialogTitle = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => ( 91 | 96 | )) 97 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 98 | 99 | const AlertDialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | AlertDialogDescription.displayName = 110 | AlertDialogPrimitive.Description.displayName 111 | 112 | const AlertDialogAction = React.forwardRef< 113 | React.ElementRef, 114 | React.ComponentPropsWithoutRef 115 | >(({ className, ...props }, ref) => ( 116 | 121 | )) 122 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 123 | 124 | const AlertDialogCancel = React.forwardRef< 125 | React.ElementRef, 126 | React.ComponentPropsWithoutRef 127 | >(({ className, ...props }, ref) => ( 128 | 137 | )) 138 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 139 | 140 | export { 141 | AlertDialog, 142 | AlertDialogTrigger, 143 | AlertDialogContent, 144 | AlertDialogHeader, 145 | AlertDialogFooter, 146 | AlertDialogTitle, 147 | AlertDialogDescription, 148 | AlertDialogAction, 149 | AlertDialogCancel, 150 | } 151 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { VariantProps, cva } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center border rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "bg-primary hover:bg-primary/80 border-transparent text-primary-foreground", 13 | secondary: 14 | "bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground", 15 | destructive: 16 | "bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { VariantProps, cva } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "underline-offset-4 hover:underline text-primary", 21 | }, 22 | size: { 23 | default: "h-10 py-2 px-4", 24 | sm: "h-9 px-3 rounded-md", 25 | lg: "h-11 px-8 rounded-md", 26 | }, 27 | }, 28 | defaultVariants: { 29 | variant: "default", 30 | size: "default", 31 | }, 32 | } 33 | ) 34 | 35 | export interface ButtonProps 36 | extends React.ButtonHTMLAttributes, 37 | VariantProps { 38 | asChild?: boolean 39 | } 40 | 41 | const Button = React.forwardRef( 42 | ({ className, variant, size, asChild = false, ...props }, ref) => { 43 | const Comp = asChild ? Slot : "button" 44 | return ( 45 | 50 | ) 51 | } 52 | ) 53 | Button.displayName = "Button" 54 | 55 | export { Button, buttonVariants } 56 | -------------------------------------------------------------------------------- /src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { DialogProps } from "@radix-ui/react-dialog" 5 | import { Command as CommandPrimitive } from "cmdk" 6 | import { Search } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | import { Dialog, DialogContent } from "@/components/ui/dialog" 10 | 11 | const Command = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 23 | )) 24 | Command.displayName = CommandPrimitive.displayName 25 | 26 | interface CommandDialogProps extends DialogProps {} 27 | 28 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => { 29 | return ( 30 | 31 | 32 | 33 | {children} 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | const CommandInput = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 |
45 | 46 | 54 |
55 | )) 56 | 57 | CommandInput.displayName = CommandPrimitive.Input.displayName 58 | 59 | const CommandList = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, ...props }, ref) => ( 63 | 68 | )) 69 | 70 | CommandList.displayName = CommandPrimitive.List.displayName 71 | 72 | const CommandEmpty = React.forwardRef< 73 | React.ElementRef, 74 | React.ComponentPropsWithoutRef 75 | >((props, ref) => ( 76 | 81 | )) 82 | 83 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 84 | 85 | const CommandGroup = React.forwardRef< 86 | React.ElementRef, 87 | React.ComponentPropsWithoutRef 88 | >(({ className, ...props }, ref) => ( 89 | 97 | )) 98 | 99 | CommandGroup.displayName = CommandPrimitive.Group.displayName 100 | 101 | const CommandSeparator = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 112 | 113 | const CommandItem = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 125 | )) 126 | 127 | CommandItem.displayName = CommandPrimitive.Item.displayName 128 | 129 | const CommandShortcut = ({ 130 | className, 131 | ...props 132 | }: React.HTMLAttributes) => { 133 | return ( 134 | 141 | ) 142 | } 143 | CommandShortcut.displayName = "CommandShortcut" 144 | 145 | export { 146 | Command, 147 | CommandDialog, 148 | CommandInput, 149 | CommandList, 150 | CommandEmpty, 151 | CommandGroup, 152 | CommandItem, 153 | CommandShortcut, 154 | CommandSeparator, 155 | } 156 | -------------------------------------------------------------------------------- /src/components/ui/context-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const ContextMenu = ContextMenuPrimitive.Root 10 | 11 | const ContextMenuTrigger = ContextMenuPrimitive.Trigger 12 | 13 | const ContextMenuGroup = ContextMenuPrimitive.Group 14 | 15 | const ContextMenuPortal = ContextMenuPrimitive.Portal 16 | 17 | const ContextMenuSub = ContextMenuPrimitive.Sub 18 | 19 | const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup 20 | 21 | const ContextMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName 41 | 42 | const ContextMenuSubContent = React.forwardRef< 43 | React.ElementRef, 44 | React.ComponentPropsWithoutRef 45 | >(({ className, ...props }, ref) => ( 46 | 54 | )) 55 | ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName 56 | 57 | const ContextMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 62 | 70 | 71 | )) 72 | ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName 73 | 74 | const ContextMenuItem = React.forwardRef< 75 | React.ElementRef, 76 | React.ComponentPropsWithoutRef & { 77 | inset?: boolean 78 | } 79 | >(({ className, inset, ...props }, ref) => ( 80 | 89 | )) 90 | ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName 91 | 92 | const ContextMenuCheckboxItem = React.forwardRef< 93 | React.ElementRef, 94 | React.ComponentPropsWithoutRef 95 | >(({ className, children, checked, ...props }, ref) => ( 96 | 105 | 106 | 107 | 108 | 109 | 110 | {children} 111 | 112 | )) 113 | ContextMenuCheckboxItem.displayName = 114 | ContextMenuPrimitive.CheckboxItem.displayName 115 | 116 | const ContextMenuRadioItem = React.forwardRef< 117 | React.ElementRef, 118 | React.ComponentPropsWithoutRef 119 | >(({ className, children, ...props }, ref) => ( 120 | 128 | 129 | 130 | 131 | 132 | 133 | {children} 134 | 135 | )) 136 | ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName 137 | 138 | const ContextMenuLabel = React.forwardRef< 139 | React.ElementRef, 140 | React.ComponentPropsWithoutRef & { 141 | inset?: boolean 142 | } 143 | >(({ className, inset, ...props }, ref) => ( 144 | 153 | )) 154 | ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName 155 | 156 | const ContextMenuSeparator = React.forwardRef< 157 | React.ElementRef, 158 | React.ComponentPropsWithoutRef 159 | >(({ className, ...props }, ref) => ( 160 | 165 | )) 166 | ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName 167 | 168 | const ContextMenuShortcut = ({ 169 | className, 170 | ...props 171 | }: React.HTMLAttributes) => { 172 | return ( 173 | 180 | ) 181 | } 182 | ContextMenuShortcut.displayName = "ContextMenuShortcut" 183 | 184 | export { 185 | ContextMenu, 186 | ContextMenuTrigger, 187 | ContextMenuContent, 188 | ContextMenuItem, 189 | ContextMenuCheckboxItem, 190 | ContextMenuRadioItem, 191 | ContextMenuLabel, 192 | ContextMenuSeparator, 193 | ContextMenuShortcut, 194 | ContextMenuGroup, 195 | ContextMenuPortal, 196 | ContextMenuSub, 197 | ContextMenuSubContent, 198 | ContextMenuSubTrigger, 199 | ContextMenuRadioGroup, 200 | } 201 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = ({ 14 | className, 15 | children, 16 | ...props 17 | }: DialogPrimitive.DialogPortalProps) => ( 18 | 19 |
20 | {children} 21 |
22 |
23 | ) 24 | DialogPortal.displayName = DialogPrimitive.Portal.displayName 25 | 26 | const DialogOverlay = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, ...props }, ref) => ( 30 | 38 | )) 39 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 40 | 41 | const DialogContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, children, ...props }, ref) => ( 45 | 46 | 47 | 55 | {children} 56 | 57 | 58 | Close 59 | 60 | 61 | 62 | )) 63 | DialogContent.displayName = DialogPrimitive.Content.displayName 64 | 65 | const DialogHeader = ({ 66 | className, 67 | ...props 68 | }: React.HTMLAttributes) => ( 69 |
76 | ) 77 | DialogHeader.displayName = "DialogHeader" 78 | 79 | const DialogFooter = ({ 80 | className, 81 | ...props 82 | }: React.HTMLAttributes) => ( 83 |
90 | ) 91 | DialogFooter.displayName = "DialogFooter" 92 | 93 | const DialogTitle = React.forwardRef< 94 | React.ElementRef, 95 | React.ComponentPropsWithoutRef 96 | >(({ className, ...props }, ref) => ( 97 | 105 | )) 106 | DialogTitle.displayName = DialogPrimitive.Title.displayName 107 | 108 | const DialogDescription = React.forwardRef< 109 | React.ElementRef, 110 | React.ComponentPropsWithoutRef 111 | >(({ className, ...props }, ref) => ( 112 | 117 | )) 118 | DialogDescription.displayName = DialogPrimitive.Description.displayName 119 | 120 | export { 121 | Dialog, 122 | DialogTrigger, 123 | DialogContent, 124 | DialogHeader, 125 | DialogFooter, 126 | DialogTitle, 127 | DialogDescription, 128 | } 129 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 73 | 74 | )) 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef & { 80 | inset?: boolean 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | 92 | )) 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | 108 | 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | )) 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )) 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef & { 144 | inset?: boolean 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | 156 | )) 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef, 161 | React.ComponentPropsWithoutRef 162 | >(({ className, ...props }, ref) => ( 163 | 168 | )) 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes) => { 175 | return ( 176 | 180 | ) 181 | } 182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 183 | 184 | export { 185 | DropdownMenu, 186 | DropdownMenuTrigger, 187 | DropdownMenuContent, 188 | DropdownMenuItem, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuRadioItem, 191 | DropdownMenuLabel, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuGroup, 195 | DropdownMenuPortal, 196 | DropdownMenuSub, 197 | DropdownMenuSubContent, 198 | DropdownMenuSubTrigger, 199 | DropdownMenuRadioGroup, 200 | } 201 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { VariantProps, cva } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent } 32 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /src/components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ToastPrimitives from "@radix-ui/react-toast" 3 | import { VariantProps, cva } from "class-variance-authority" 4 | import { X } from "lucide-react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const ToastProvider = ToastPrimitives.Provider 9 | 10 | const ToastViewport = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName 24 | 25 | const toastVariants = cva( 26 | "data-[swipe=move]:transition-none group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full data-[state=closed]:slide-out-to-right-full", 27 | { 28 | variants: { 29 | variant: { 30 | default: "bg-background border", 31 | destructive: 32 | "group destructive border-destructive bg-destructive text-destructive-foreground", 33 | }, 34 | }, 35 | defaultVariants: { 36 | variant: "default", 37 | }, 38 | } 39 | ) 40 | 41 | const Toast = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef & 44 | VariantProps 45 | >(({ className, variant, ...props }, ref) => { 46 | return ( 47 | 52 | ) 53 | }) 54 | Toast.displayName = ToastPrimitives.Root.displayName 55 | 56 | const ToastAction = React.forwardRef< 57 | React.ElementRef, 58 | React.ComponentPropsWithoutRef 59 | >(({ className, ...props }, ref) => ( 60 | 68 | )) 69 | ToastAction.displayName = ToastPrimitives.Action.displayName 70 | 71 | const ToastClose = React.forwardRef< 72 | React.ElementRef, 73 | React.ComponentPropsWithoutRef 74 | >(({ className, ...props }, ref) => ( 75 | 84 | 85 | 86 | )) 87 | ToastClose.displayName = ToastPrimitives.Close.displayName 88 | 89 | const ToastTitle = React.forwardRef< 90 | React.ElementRef, 91 | React.ComponentPropsWithoutRef 92 | >(({ className, ...props }, ref) => ( 93 | 98 | )) 99 | ToastTitle.displayName = ToastPrimitives.Title.displayName 100 | 101 | const ToastDescription = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | ToastDescription.displayName = ToastPrimitives.Description.displayName 112 | 113 | type ToastProps = React.ComponentPropsWithoutRef 114 | 115 | type ToastActionElement = React.ReactElement 116 | 117 | export { 118 | type ToastProps, 119 | type ToastActionElement, 120 | ToastProvider, 121 | ToastViewport, 122 | Toast, 123 | ToastTitle, 124 | ToastDescription, 125 | ToastClose, 126 | ToastAction, 127 | } 128 | -------------------------------------------------------------------------------- /src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useToast } from "@/components/ui/use-toast"; 4 | 5 | import { 6 | Toast, 7 | ToastClose, 8 | ToastDescription, 9 | ToastProvider, 10 | ToastTitle, 11 | ToastViewport, 12 | } from "@/components/ui/toast"; 13 | 14 | export function Toaster() { 15 | const { toasts } = useToast(); 16 | 17 | return ( 18 | 19 | {toasts.map(function ({ id, title, description, action, ...props }) { 20 | return ( 21 | 22 |
23 | {title && {title}} 24 | {description && ( 25 | {description} 26 | )} 27 |
28 | {action} 29 | 30 |
31 | ); 32 | })} 33 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/ui/use-toast.ts: -------------------------------------------------------------------------------- 1 | // Inspired by react-hot-toast library 2 | import * as React from "react" 3 | 4 | import { ToastActionElement, type ToastProps } from "@/components/ui/toast" 5 | 6 | const TOAST_LIMIT = 1 7 | const TOAST_REMOVE_DELAY = 1000000 8 | 9 | type ToasterToast = ToastProps & { 10 | id: string 11 | title?: React.ReactNode 12 | description?: React.ReactNode 13 | action?: ToastActionElement 14 | } 15 | 16 | const actionTypes = { 17 | ADD_TOAST: "ADD_TOAST", 18 | UPDATE_TOAST: "UPDATE_TOAST", 19 | DISMISS_TOAST: "DISMISS_TOAST", 20 | REMOVE_TOAST: "REMOVE_TOAST", 21 | } as const 22 | 23 | let count = 0 24 | 25 | function genId() { 26 | count = (count + 1) % Number.MAX_VALUE 27 | return count.toString() 28 | } 29 | 30 | type ActionType = typeof actionTypes 31 | 32 | type Action = 33 | | { 34 | type: ActionType["ADD_TOAST"] 35 | toast: ToasterToast 36 | } 37 | | { 38 | type: ActionType["UPDATE_TOAST"] 39 | toast: Partial 40 | } 41 | | { 42 | type: ActionType["DISMISS_TOAST"] 43 | toastId?: ToasterToast["id"] 44 | } 45 | | { 46 | type: ActionType["REMOVE_TOAST"] 47 | toastId?: ToasterToast["id"] 48 | } 49 | 50 | interface State { 51 | toasts: ToasterToast[] 52 | } 53 | 54 | const toastTimeouts = new Map>() 55 | 56 | const addToRemoveQueue = (toastId: string) => { 57 | if (toastTimeouts.has(toastId)) { 58 | return 59 | } 60 | 61 | const timeout = setTimeout(() => { 62 | toastTimeouts.delete(toastId) 63 | dispatch({ 64 | type: "REMOVE_TOAST", 65 | toastId: toastId, 66 | }) 67 | }, TOAST_REMOVE_DELAY) 68 | 69 | toastTimeouts.set(toastId, timeout) 70 | } 71 | 72 | export const reducer = (state: State, action: Action): State => { 73 | switch (action.type) { 74 | case "ADD_TOAST": 75 | return { 76 | ...state, 77 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 78 | } 79 | 80 | case "UPDATE_TOAST": 81 | return { 82 | ...state, 83 | toasts: state.toasts.map((t) => 84 | t.id === action.toast.id ? { ...t, ...action.toast } : t 85 | ), 86 | } 87 | 88 | case "DISMISS_TOAST": { 89 | const { toastId } = action 90 | 91 | // ! Side effects ! - This could be extracted into a dismissToast() action, 92 | // but I'll keep it here for simplicity 93 | if (toastId) { 94 | addToRemoveQueue(toastId) 95 | } else { 96 | state.toasts.forEach((toast) => { 97 | addToRemoveQueue(toast.id) 98 | }) 99 | } 100 | 101 | return { 102 | ...state, 103 | toasts: state.toasts.map((t) => 104 | t.id === toastId || toastId === undefined 105 | ? { 106 | ...t, 107 | open: false, 108 | } 109 | : t 110 | ), 111 | } 112 | } 113 | case "REMOVE_TOAST": 114 | if (action.toastId === undefined) { 115 | return { 116 | ...state, 117 | toasts: [], 118 | } 119 | } 120 | return { 121 | ...state, 122 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 123 | } 124 | } 125 | } 126 | 127 | const listeners: Array<(state: State) => void> = [] 128 | 129 | let memoryState: State = { toasts: [] } 130 | 131 | function dispatch(action: Action) { 132 | memoryState = reducer(memoryState, action) 133 | listeners.forEach((listener) => { 134 | listener(memoryState) 135 | }) 136 | } 137 | 138 | interface Toast extends Omit {} 139 | 140 | function toast({ ...props }: Toast) { 141 | const id = genId() 142 | 143 | const update = (props: ToasterToast) => 144 | dispatch({ 145 | type: "UPDATE_TOAST", 146 | toast: { ...props, id }, 147 | }) 148 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) 149 | 150 | dispatch({ 151 | type: "ADD_TOAST", 152 | toast: { 153 | ...props, 154 | id, 155 | open: true, 156 | onOpenChange: (open) => { 157 | if (!open) dismiss() 158 | }, 159 | }, 160 | }) 161 | 162 | return { 163 | id: id, 164 | dismiss, 165 | update, 166 | } 167 | } 168 | 169 | function useToast() { 170 | const [state, setState] = React.useState(memoryState) 171 | 172 | React.useEffect(() => { 173 | listeners.push(setState) 174 | return () => { 175 | const index = listeners.indexOf(setState) 176 | if (index > -1) { 177 | listeners.splice(index, 1) 178 | } 179 | } 180 | }, [state]) 181 | 182 | return { 183 | ...state, 184 | toast, 185 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 186 | } 187 | } 188 | 189 | export { useToast, toast } 190 | -------------------------------------------------------------------------------- /src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/hook/useSlides.ts: -------------------------------------------------------------------------------- 1 | import { exportToBlob } from '@excalidraw/excalidraw' 2 | import type { ExcalidrawElement, ExcalidrawFrameElement, Theme } from '@excalidraw/excalidraw/types/element/types' 3 | import type { BinaryFiles, ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types' 4 | import { useAtom } from 'jotai' 5 | import { groupBy } from 'lodash-es' 6 | 7 | import { activeFrameIdAtom, framesAtom } from '@/model/slides' 8 | 9 | const useSlides = (api: ExcalidrawImperativeAPI | null) => { 10 | const [frames, setFrames] = useAtom(framesAtom) 11 | const [activeFrameId, setActiveFrameId] = useAtom(activeFrameIdAtom) 12 | const activeFrameIndex = frames?.findIndex(({ frameElement: frame }) => frame.id === activeFrameId) 13 | const isFirst = activeFrameIndex === 0 14 | const isLast = activeFrameIndex === frames?.length - 1 15 | 16 | const updateFrames = (params: GetFramesParams) => { 17 | setFrames(getFrames(params)) 18 | } 19 | const scrollToFrame = (frame: ExcalidrawFrameElement) => { 20 | api?.scrollToContent(frame, { 21 | animate: true, 22 | duration: 300, 23 | fitToViewport: true, 24 | }) 25 | setActiveFrameId(frame.id) 26 | } 27 | const next = () => { 28 | if (isLast) return 29 | const index = activeFrameIndex < 0 ? 0 : activeFrameIndex + 1 30 | scrollToFrame(frames[index].frameElement) 31 | } 32 | const prev = () => { 33 | if (isFirst) return 34 | const index = activeFrameIndex < 0 ? 0 : activeFrameIndex - 1 35 | scrollToFrame(frames[index].frameElement) 36 | } 37 | 38 | return { 39 | frames, 40 | activeFrameId, 41 | activeFrameIndex, 42 | isFirst, 43 | isLast, 44 | 45 | updateFrames, 46 | scrollToFrame, 47 | prev, 48 | next, 49 | } 50 | } 51 | 52 | type GetFramesParams = { 53 | elements?: readonly ExcalidrawElement[] 54 | files: BinaryFiles | null 55 | theme?: Theme 56 | } 57 | function getFrames({ elements, files, theme }: GetFramesParams) { 58 | const frames: ExcalidrawFrameElement[] = ( 59 | elements?.filter((element) => element.type === 'frame' && !element.isDeleted) as ExcalidrawFrameElement[] 60 | )?.sort((a, b) => { 61 | const getIndex = (name: string | null) => { 62 | const defaultIndex = 9999 63 | if (!name) return defaultIndex 64 | const index = Number(name.split('-')[0]) 65 | return isNaN(index) ? defaultIndex : index 66 | } 67 | return getIndex(a.name) - getIndex(b.name) 68 | }) 69 | const frameChildrenElementsMap: Record = groupBy(elements, (element) => element.frameId) 70 | return frames?.map((frame) => ({ 71 | frameElement: frame, 72 | blobPromise: exportToBlob({ 73 | elements: [frame, ...(frameChildrenElementsMap[frame.id] || [])], 74 | files, 75 | appState: { 76 | exportWithDarkMode: theme === 'dark', 77 | }, 78 | }), 79 | })) 80 | } 81 | 82 | export default useSlides 83 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, body { 6 | @apply dark:bg-slate-900; 7 | } 8 | #root { 9 | @apply w-screen h-screen; 10 | } 11 | 12 | @layer base { 13 | :root { 14 | --background: 0 0% 100%; 15 | --foreground: 222.2 47.4% 11.2%; 16 | 17 | --muted: 210 40% 96.1%; 18 | --muted-foreground: 215.4 16.3% 46.9%; 19 | 20 | --popover: 0 0% 100%; 21 | --popover-foreground: 222.2 47.4% 11.2%; 22 | 23 | --card: 0 0% 100%; 24 | --card-foreground: 222.2 47.4% 11.2%; 25 | 26 | --border: 214.3 31.8% 91.4%; 27 | --input: 214.3 31.8% 91.4%; 28 | 29 | --primary: 222.2 47.4% 11.2%; 30 | --primary-foreground: 210 40% 98%; 31 | 32 | --secondary: 210 40% 96.1%; 33 | --secondary-foreground: 222.2 47.4% 11.2%; 34 | 35 | --accent: 210 40% 96.1%; 36 | --accent-foreground: 222.2 47.4% 11.2%; 37 | 38 | --destructive: 0 100% 50%; 39 | --destructive-foreground: 210 40% 98%; 40 | 41 | --ring: 215 20.2% 65.1%; 42 | 43 | --radius: 0.5rem; 44 | 45 | --scroll-bar-color: #e3e3e3; 46 | } 47 | 48 | .dark { 49 | --background: 224 71% 4%; 50 | --foreground: 213 31% 91%; 51 | 52 | --muted: 223 47% 11%; 53 | --muted-foreground: 215.4 16.3% 56.9%; 54 | 55 | --popover: 224 71% 4%; 56 | --popover-foreground: 215 20.2% 65.1%; 57 | 58 | --card: 0 0% 100%; 59 | --card-foreground: 222.2 47.4% 11.2%; 60 | 61 | --border: 216 34% 17%; 62 | --input: 216 34% 17%; 63 | 64 | --primary: 210 40% 98%; 65 | --primary-foreground: 222.2 47.4% 1.2%; 66 | 67 | --secondary: 222.2 47.4% 11.2%; 68 | --secondary-foreground: 210 40% 98%; 69 | 70 | --accent: 216 34% 17%; 71 | --accent-foreground: 210 40% 98%; 72 | 73 | --destructive: 0 63% 31%; 74 | --destructive-foreground: 210 40% 98%; 75 | 76 | --ring: 216 34% 17%; 77 | 78 | --radius: 0.5rem; 79 | 80 | --scroll-bar-color: #2c3c57; 81 | } 82 | } 83 | 84 | @layer base { 85 | * { 86 | @apply border-border; 87 | } 88 | body { 89 | @apply bg-background text-foreground; 90 | font-feature-settings: "rlig" 1, "calt" 1; 91 | } 92 | } 93 | 94 | .custom-scroll::-webkit-scrollbar { 95 | background-color: transparent; 96 | } 97 | .custom-scroll::-webkit-scrollbar-corner { 98 | background-color: transparent; 99 | } 100 | .custom-scroll::-webkit-scrollbar-thumb { 101 | background-color: var(--scroll-bar-color); 102 | border: 5px solid transparent; 103 | border-radius: 9px; 104 | background-clip: content-box; 105 | } -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import type { AppState } from '@excalidraw/excalidraw/types/types' 2 | 3 | import { type ExcalidrawData } from '@/type' 4 | 5 | export const FONT_ID = { 6 | 'Hand-drawn': 'Virgil', 7 | Normal: 'Cascadia', 8 | Code: 'Assistant', 9 | } as const 10 | 11 | export const DEFAULT_EXCALIDRAW_DATA: ExcalidrawData = { 12 | elements: [], 13 | files: null, 14 | } 15 | export const DEFAULT_EXCALIDRAW_LIBRARY_ITEMS = [] 16 | 17 | /** 18 | * Prompt: do not manually edit this file 19 | */ 20 | export const EXCALIDRAW_FILE_PROMPT = `#+BEGIN_IMPORTANT 21 | This file is used to store excalidraw information, Please do not manually edit this file. 22 | #+END_IMPORTANT` 23 | 24 | /** 25 | * The Excalidraw data when creating a new file (before user draw anything) 26 | */ 27 | export const NEW_FILE_EXCALIDRAW_DATA: ExcalidrawData = { 28 | elements: [ 29 | { 30 | type: 'text', 31 | version: 70, 32 | versionNonce: 1337451650, 33 | isDeleted: false, 34 | id: '3ANbtSkpsVqoYMSNbrH93', 35 | fillStyle: 'hachure', 36 | strokeWidth: 1, 37 | strokeStyle: 'solid', 38 | roughness: 1, 39 | opacity: 100, 40 | angle: 0, 41 | x: 677.9921875, 42 | y: 362.51171875, 43 | strokeColor: '#000000', 44 | backgroundColor: 'transparent', 45 | width: 112.1279296875, 46 | height: 20, 47 | seed: 314248926, 48 | groupIds: [], 49 | roundness: null, 50 | boundElements: [], 51 | updated: 1682417141458, 52 | link: null, 53 | locked: false, 54 | fontSize: 16, 55 | fontFamily: 1, 56 | text: 'Start Drawing', 57 | textAlign: 'left', 58 | verticalAlign: 'top', 59 | containerId: null, 60 | originalText: 'Start Drawing', 61 | // @ts-expect-error copy from excalidraw demo 62 | lineHeight: 1.25, 63 | baseline: 14, 64 | }, 65 | ], 66 | files: null, 67 | } 68 | 69 | /** 70 | * The appState properties that need to be stored 71 | */ 72 | export const APP_STATE_PROPERTIES: Array = [ 73 | 'gridSize', 74 | 'viewBackgroundColor', 75 | 'zoom', 76 | 'offsetTop', 77 | 'offsetLeft', 78 | 'scrollX', 79 | 'scrollY', 80 | 'viewModeEnabled', 81 | 'zenModeEnabled', 82 | ] 83 | -------------------------------------------------------------------------------- /src/lib/logseqProxy.ts: -------------------------------------------------------------------------------- 1 | const fetchLogseqApi = async (method: string, args?: any[]) => { 2 | const res = await fetch(`${import.meta.env.VITE_LOGSEQ_API_SERVER}/api`, { 3 | method: "POST", 4 | headers: { 5 | "Content-Type": "application/json", 6 | Authorization: `Bearer ${import.meta.env.VITE_LOGSEQ_API_TOKEN}`, 7 | }, 8 | body: JSON.stringify({ 9 | method, 10 | args, 11 | }), 12 | }); 13 | if (res.headers.get("Content-Type")?.includes("application/json")) { 14 | return await res.json(); 15 | } 16 | return res.text(); 17 | }; 18 | 19 | const LOGSEQ_METHODS_OBJECT = [ 20 | "App", 21 | "Editor", 22 | "DB", 23 | "Git", 24 | "UI", 25 | "Assets", 26 | "FileStorage", 27 | ] as const; 28 | const proxyLogseqMethodsObject = ( 29 | key: (typeof LOGSEQ_METHODS_OBJECT)[number] 30 | ) => { 31 | const proxy = new Proxy( 32 | {}, 33 | { 34 | get(target, propKey) { 35 | return async (...args: any[]) => { 36 | const method = `logseq.${key}.${propKey.toString()}`; 37 | console.warn("=== Proxy call to logseq: ", method); 38 | const data = await fetchLogseqApi(method, args); 39 | 40 | if (data?.error) { 41 | console.error(`=== Proxy ${method} error: `, data.error); 42 | } 43 | 44 | return data; 45 | }; 46 | }, 47 | } 48 | ); 49 | // @ts-ignore 50 | window.logseq[key] = proxy; 51 | }; 52 | export const proxyLogseq = () => { 53 | // @ts-ignore 54 | // window.logseqBack = window.logseq; 55 | // @ts-ignore 56 | window.logseq = {}; 57 | LOGSEQ_METHODS_OBJECT.forEach(proxyLogseqMethodsObject); 58 | window.logseq.hideMainUI = () => alert("Proxy call to logseq.hideMainUI()"); 59 | logseq.settings = { 60 | langCode: "en: English", 61 | "Hand-drawn": "https://pocket.haydenhayden.com/font/chinese.woff2", 62 | Normal: "", 63 | Code: "", 64 | disabled: false, 65 | }; 66 | }; 67 | -------------------------------------------------------------------------------- /src/lib/rewriteFont.ts: -------------------------------------------------------------------------------- 1 | import { pick } from 'lodash-es' 2 | 3 | import { FONT_ID } from '@/lib/constants' 4 | import type { PluginSettings, PluginSettingsKeys } from '@/type' 5 | 6 | const rewriteFont = ( 7 | fontFamily: string, 8 | fontUrl: string, 9 | // fontType = "font/woff2" 10 | ) => { 11 | const link = document.createElement('link') 12 | link.rel = 'preload' 13 | link.href = fontUrl 14 | link.as = 'font' 15 | // link.type = fontType; 16 | // link.crossOrigin = 'anonymous' 17 | document.head.appendChild(link.cloneNode(true)) 18 | parent.document.head.appendChild(link.cloneNode(true)) 19 | 20 | const style = document.createElement('style') 21 | style.innerHTML = ` 22 | @font-face { 23 | font-family: "${fontFamily}"; 24 | src: url("${fontUrl}"); 25 | font-display: swap; 26 | } 27 | ` 28 | document.head.appendChild(style.cloneNode(true)) 29 | parent.document.head.appendChild(style.cloneNode(true)) 30 | } 31 | 32 | const rewriteAllFont = async () => { 33 | const settings = logseq.settings as unknown as PluginSettings 34 | const fontSettings = pick(settings, Object.keys(FONT_ID)) 35 | for (const [name, url] of Object.entries(fontSettings)) { 36 | const _url = (url as string)?.trim?.() 37 | if (_url) rewriteFont(FONT_ID[name as PluginSettingsKeys], _url) 38 | } 39 | } 40 | 41 | export default rewriteAllFont 42 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type Language } from '@excalidraw/excalidraw/types/i18n' 2 | import type { AppState, LibraryItems } from '@excalidraw/excalidraw/types/types' 3 | import type { BlockEntity, PageEntity, PageIdentity } from '@logseq/libs/dist/LSPlugin.user' 4 | import { type ClassValue, clsx } from 'clsx' 5 | import dayjs from 'dayjs' 6 | import { pick } from 'lodash-es' 7 | import { twMerge } from 'tailwind-merge' 8 | 9 | import { type Theme } from '@/components/Editor' 10 | import getI18N, { DEFAULT_LANGUAGE, LANGUAGES } from '@/locales' 11 | import type { ExcalidrawData, PluginSettingsKeys, SettingItemSchema } from '@/type' 12 | 13 | import { APP_STATE_PROPERTIES, DEFAULT_EXCALIDRAW_DATA, EXCALIDRAW_FILE_PROMPT } from './constants' 14 | 15 | export function cn(...inputs: ClassValue[]) { 16 | return twMerge(clsx(inputs)) 17 | } 18 | 19 | /** 20 | * get excalidraw data 21 | * ```json\n{xxx}\n``` --> {xxx} 22 | */ 23 | export const getExcalidrawData = (text?: string): ExcalidrawData | LibraryItems | null => { 24 | const match = text?.match(/```json\n(.*)\n```/s) 25 | return match ? JSON.parse(match[1]) : null 26 | } 27 | /** 28 | * gen block data 29 | * {xxx} --> ```json\n{xxx}\n``` 30 | */ 31 | export const genBlockData = (excalidrawData: Record | LibraryItems) => { 32 | return `\`\`\`json\n${JSON.stringify(excalidrawData)}\n\`\`\`` 33 | } 34 | 35 | export const getExcalidrawInfoFromPage = async ( 36 | srcPage: PageIdentity, 37 | ): Promise<{ 38 | excalidrawData: ExcalidrawData 39 | /** block that stores the excalidraw data */ 40 | block: BlockEntity 41 | /** page blocks */ 42 | rawBlocks: BlockEntity[] 43 | }> => { 44 | const pageBlocks = await logseq.Editor.getPageBlocksTree(srcPage) 45 | const codeBlock = pageBlocks?.[3] 46 | const excalidrawData = getExcalidrawData(codeBlock?.content) as ExcalidrawData 47 | return { 48 | excalidrawData: excalidrawData || DEFAULT_EXCALIDRAW_DATA, 49 | block: codeBlock, 50 | rawBlocks: pageBlocks, 51 | } 52 | } 53 | 54 | /** 55 | * listen esc keyup event 56 | */ 57 | export const listenEsc = (callback: () => void) => { 58 | document.addEventListener('keyup', (e) => { 59 | if (e.key === 'Escape') { 60 | callback() 61 | } 62 | }) 63 | } 64 | 65 | export const createSVGElement = (svgString: string) => { 66 | const parser = new DOMParser() 67 | const svgDoc = parser.parseFromString(svgString, 'image/svg+xml') 68 | return svgDoc.documentElement 69 | } 70 | 71 | /** 72 | * Extract some properties from appState 73 | */ 74 | export const getMinimalAppState = (appState: AppState) => { 75 | return pick(appState, APP_STATE_PROPERTIES) 76 | } 77 | 78 | /** 79 | * get lang code from language setting 80 | * en: English -> en 81 | */ 82 | export const getLangCode = (langSetting: string) => langSetting?.split(':')?.[0] || DEFAULT_LANGUAGE.code 83 | 84 | /** 85 | * join excalidraw lang to logseq setting 86 | * This method must defined together with SETTINGS_SCHEMA, otherwise plugin will initialize error 87 | */ 88 | export const joinLangCode = (lang: Language) => `${lang.code}: ${lang.label}` 89 | 90 | /** 91 | * Logseq settings 92 | */ 93 | export const getSettingsSchema = (): SettingItemSchema[] => { 94 | const { settings: i18nSettings } = getI18N() 95 | return [ 96 | { 97 | key: 'langCode', 98 | title: i18nSettings.langCode.title, 99 | type: 'enum', 100 | default: joinLangCode(DEFAULT_LANGUAGE), 101 | description: i18nSettings.langCode.description, 102 | enumPicker: 'select', 103 | enumChoices: LANGUAGES?.map(joinLangCode), 104 | }, 105 | { 106 | key: 'Hand-drawn', 107 | title: i18nSettings.HandDrawn.title, 108 | type: 'string', 109 | default: '', 110 | description: i18nSettings.HandDrawn.description, 111 | }, 112 | { 113 | key: 'Normal', 114 | title: i18nSettings.Normal.title, 115 | type: 'string', 116 | default: '', 117 | description: i18nSettings.Normal.description, 118 | }, 119 | { 120 | key: 'Code', 121 | title: i18nSettings.Code.title, 122 | type: 'string', 123 | default: '', 124 | description: i18nSettings.Code.description, 125 | }, 126 | ] 127 | } 128 | 129 | /** 130 | * get excalidraw pages 131 | */ 132 | export const getExcalidrawPages = async (): Promise => { 133 | return logseq.DB.q(`(page-property :excalidraw-plugin "true")`) 134 | } 135 | 136 | /** 137 | * create or update logseq page property 138 | */ 139 | export const updateLogseqPageProperty = async (pageName: string, properties: Record) => { 140 | const upsertBlockPropertyPromises = Object.keys(properties).map((key) => 141 | logseq.Editor.upsertBlockProperty(pageName, key, properties?.[key]), 142 | ) 143 | return Promise.allSettled(upsertBlockPropertyPromises) 144 | } 145 | 146 | export const getTags = async (): Promise => { 147 | return getExcalidrawPages().then(async (pages) => { 148 | console.log('[faiz:] === getTags pages', pages) 149 | if (!pages) return [] 150 | const promises = pages.map(async (page: PageEntity) => { 151 | const blocks = await logseq.Editor.getPageBlocksTree(page.originalName) 152 | return blocks?.[0]?.properties?.excalidrawPluginTag 153 | }) 154 | const tags = await Promise.all(promises) 155 | return [...new Set(tags?.filter(Boolean))] 156 | }) 157 | } 158 | 159 | export const setTheme = (theme: Theme = 'light') => { 160 | if (theme === 'light') { 161 | document.documentElement.classList.remove('dark') 162 | } else { 163 | document.documentElement.classList.add('dark') 164 | } 165 | } 166 | 167 | export const createDrawing = async (params?: Partial<{ alias: string; tag: string }>) => { 168 | const { createDrawing: i18nCreateDrawing } = getI18N() 169 | const fileName = 'excalidraw-' + dayjs().format('YYYY-MM-DD-HH-mm-ss') 170 | try { 171 | const pageProperty = { 172 | 'excalidraw-plugin': 'true', 173 | } 174 | const { tag, alias } = params || {} 175 | if (alias) pageProperty['excalidraw-plugin-alias'] = alias 176 | if (tag) pageProperty['excalidraw-plugin-tag'] = tag 177 | const page = await logseq.Editor.createPage(fileName, pageProperty, { 178 | format: 'markdown', 179 | redirect: false, 180 | }) 181 | await logseq.Editor.appendBlockInPage(page!.originalName, EXCALIDRAW_FILE_PROMPT) 182 | await logseq.Editor.appendBlockInPage(page!.originalName, `{{renderer excalidraw-menu, ${fileName}}}`) 183 | await logseq.Editor.appendBlockInPage(page!.originalName, genBlockData(DEFAULT_EXCALIDRAW_DATA)) 184 | return { ...page, fileName } 185 | } catch (error) { 186 | logseq.UI.showMsg(i18nCreateDrawing.errorMsg, 'error') 187 | console.error('[faiz:] === create excalidraw error', error) 188 | } 189 | } 190 | 191 | export const copyToClipboard = (text: string) => { 192 | const textArea = document.createElement('textarea') 193 | textArea.value = text 194 | document.body.appendChild(textArea) 195 | textArea.select() 196 | document.execCommand('copy') 197 | document.body.removeChild(textArea) 198 | } 199 | -------------------------------------------------------------------------------- /src/locales/en.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | settings: { 3 | langCode: { 4 | title: 'Language', 5 | description: 'Plugin Language, restart logseq to take effect', 6 | }, 7 | HandDrawn: { 8 | title: 'Hand-drawn Font Family', 9 | description: 'Custom Hand-drawn font family, restart logseq to take effect', 10 | }, 11 | Normal: { 12 | title: 'Normal Font Family', 13 | description: 'Custom Normal font family, restart logseq to take effect', 14 | }, 15 | Code: { 16 | title: 'Code Font Family', 17 | description: 'Custom Code font family, restart logseq to take effect', 18 | }, 19 | }, 20 | preview: { 21 | deleteButton: 'Delete preview and drawing file', 22 | refreshButton: 'Refresh preview', 23 | editButton: 'Edit drawing', 24 | fullScreenButton: 'Full screen', 25 | }, 26 | editor: { 27 | slidesMode: 'Slides Mode', 28 | exitButton: 'Save and exit', 29 | slidesPreview: 'Slides Preview', 30 | slidesOverview: 'Slides Overview', 31 | slidesPrev: 'Prev', 32 | slidesNext: 'Next', 33 | frameNotFound: 'Frame not found', 34 | saveToast: { 35 | title: 'Saving...', 36 | description: 'When contains a lot of elements or images, it may take a while.', 37 | }, 38 | }, 39 | createDrawing: { 40 | tag: '🎨 Excalidraw: Create New Drawing', 41 | errorMsg: 'Create excalidraw error', 42 | }, 43 | common: { 44 | pageNotFound: 'The page corresponding to the drawing is not found', 45 | }, 46 | } 47 | -------------------------------------------------------------------------------- /src/locales/index.ts: -------------------------------------------------------------------------------- 1 | import type { Language } from '@excalidraw/excalidraw/types/i18n' 2 | 3 | import { getLangCode } from '@/lib/utils' 4 | import type { PluginSettings } from '@/type' 5 | 6 | import en from './en' 7 | import zhCN from './zh-CN' 8 | 9 | export const DEFAULT_LANGUAGE: Language = { code: 'en', label: 'English' } 10 | 11 | /** 12 | * languages 13 | * The value here must be one of the excalidraw languages 14 | * https://github.com/excalidraw/excalidraw/blob/master/src/i18n.ts#L14 15 | */ 16 | export const LANGUAGES = [DEFAULT_LANGUAGE].concat([{ code: 'zh-CN', label: '简体中文' }]) 17 | 18 | const i18nData = { 19 | en, 20 | 'zh-CN': zhCN, 21 | } 22 | 23 | export type I18N = typeof en 24 | 25 | const getI18N = () => { 26 | const i18n: I18N = i18nData[getLangCode((logseq.settings as unknown as PluginSettings).langCode)] 27 | return i18n 28 | } 29 | 30 | export default getI18N 31 | -------------------------------------------------------------------------------- /src/locales/zh-CN.ts: -------------------------------------------------------------------------------- 1 | import type { I18N } from '.' 2 | 3 | const zhCN: I18N = { 4 | settings: { 5 | langCode: { 6 | title: '语言', 7 | description: '选择插件语言,重启 logseq 生效', 8 | }, 9 | HandDrawn: { 10 | title: '手写字体', 11 | description: '自定义手写字体,重启 logseq 生效', 12 | }, 13 | Normal: { 14 | title: '普通字体', 15 | description: '自定义普通字体,重启 logseq 生效', 16 | }, 17 | Code: { 18 | title: '代码字体', 19 | description: '自定义代码字体,重启 logseq 生效', 20 | }, 21 | }, 22 | preview: { 23 | deleteButton: '删除预览和及画板文件', 24 | refreshButton: '刷新预览', 25 | editButton: '编辑画板', 26 | fullScreenButton: '全屏', 27 | }, 28 | editor: { 29 | slidesMode: '幻灯片模式', 30 | exitButton: '保存并退出', 31 | slidesPreview: '幻灯片预览', 32 | slidesOverview: '幻灯片概览', 33 | slidesPrev: '上一张', 34 | slidesNext: '下一张', 35 | frameNotFound: '未找到画框', 36 | saveToast: { 37 | title: '保存中...', 38 | description: '当包含大量元素或图片时, 可能需要更长的时间。', 39 | }, 40 | }, 41 | createDrawing: { 42 | tag: '🎨 Excalidraw: 创建新画板', 43 | errorMsg: '创建画板失败', 44 | }, 45 | common: { 46 | pageNotFound: '未找到画板对应的文件', 47 | }, 48 | } 49 | 50 | export default zhCN 51 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import '@logseq/libs' 2 | import React from 'react' 3 | import { type Root, createRoot } from 'react-dom/client' 4 | 5 | import EditorApp from '@/app/Editor' 6 | import PreviewApp from '@/app/Preview' 7 | import bootCommand from '@/bootstrap/command' 8 | import bootExcalidrawLibraryItems from '@/bootstrap/excalidrawLibraryItems' 9 | import bootModels from '@/bootstrap/model' 10 | import bootRenderBlockImage from '@/bootstrap/renderBlockImage' 11 | import { proxyLogseq } from '@/lib/logseqProxy' 12 | import rewriteAllFont from '@/lib/rewriteFont' 13 | import { getSettingsSchema } from '@/lib/utils' 14 | 15 | import DashboardApp from './app/Dashboard' 16 | import './index.css' 17 | 18 | const isDevelopment = import.meta.env.DEV 19 | let reactAppRoot: Root | null = null 20 | 21 | console.log('=== logseq-plugin-excalidraw loaded ===') 22 | 23 | if (isDevelopment) { 24 | // run in browser 25 | proxyLogseq() 26 | 27 | renderApp({ mode: 'dashboard' }) 28 | 29 | // bootModels(renderApp); 30 | // toolbar item 31 | // logseq.App.registerUIItem("toolbar", { 32 | // key: "logseq-plugin-excalidraw", 33 | // template: 34 | // '', 35 | // }); 36 | } else { 37 | // run in logseq 38 | logseq.ready(() => { 39 | logseq.on('ui:visible:changed', (e) => { 40 | if (!e.visible) { 41 | // ReactDOM.unmountComponentAtNode( 42 | // document.getElementById("root") as Element 43 | // ); 44 | reactAppRoot?.unmount?.() 45 | } 46 | }) 47 | 48 | // fix: https://github.com/haydenull/logseq-plugin-excalidraw/issues/6 49 | logseq.setMainUIInlineStyle({ zIndex: 9999 }) 50 | 51 | bootModels(renderApp) 52 | 53 | // toolbar item 54 | logseq.App.registerUIItem('toolbar', { 55 | key: 'logseq-plugin-excalidraw', 56 | template: ` 57 | 58 | 59 | 60 | 61 | `, 62 | }) 63 | 64 | // render excalidraw block svg 65 | bootRenderBlockImage() 66 | 67 | // initialize excalidraw library items 68 | bootExcalidrawLibraryItems() 69 | 70 | bootCommand() 71 | 72 | const settingsSchema = getSettingsSchema() 73 | logseq.useSettingsSchema(settingsSchema) 74 | 75 | rewriteAllFont() 76 | }) 77 | } 78 | 79 | export type Mode = 'edit' | 'preview' | 'dashboard' 80 | export type RenderAppProps = 81 | | { mode: 'dashboard' } 82 | | { 83 | mode: 'preview' 84 | pageName: string 85 | } 86 | | { 87 | mode: 'edit' 88 | pageName: string 89 | renderSlotId: string 90 | } 91 | function renderApp(props: RenderAppProps) { 92 | let App: React.ReactNode = null 93 | switch (props.mode) { 94 | case 'dashboard': 95 | App = 96 | break 97 | case 'preview': 98 | App = 99 | break 100 | case 'edit': 101 | App = 102 | } 103 | 104 | const container = document.getElementById('root') 105 | reactAppRoot = createRoot(container!) 106 | reactAppRoot.render({App}) 107 | } 108 | -------------------------------------------------------------------------------- /src/model/slides.ts: -------------------------------------------------------------------------------- 1 | import type { ExcalidrawFrameElement } from '@excalidraw/excalidraw/types/element/types' 2 | import { atom } from 'jotai' 3 | 4 | export const framesAtom = atom< 5 | { 6 | frameElement: ExcalidrawFrameElement 7 | blobPromise: Promise 8 | }[] 9 | >([]) 10 | 11 | export const activeFrameIdAtom = atom(null) 12 | -------------------------------------------------------------------------------- /src/model/tags.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | 3 | export const tagsAtom = atom([]); 4 | -------------------------------------------------------------------------------- /src/type.ts: -------------------------------------------------------------------------------- 1 | import type { ExcalidrawElement } from "@excalidraw/excalidraw/types/element/types"; 2 | import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types/types"; 3 | import type { SettingSchemaDesc } from "@logseq/libs/dist/LSPlugin"; 4 | import { FONT_ID } from "@/lib/constants"; 5 | 6 | export type ExcalidrawData = { 7 | elements: readonly ExcalidrawElement[]; 8 | appState?: AppState; 9 | files: null | BinaryFiles; 10 | }; 11 | 12 | export type SettingItemSchema = SettingSchemaDesc & { 13 | key: TKey; 14 | }; 15 | 16 | export type PluginSettingsKeys = keyof typeof FONT_ID | "langCode"; 17 | export type PluginSettings = Record; 18 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: [ 5 | './src/*.{ts,tsx}', 6 | './src/pages/**/*.{ts,tsx}', 7 | './src/components/**/*.{ts,tsx}', 8 | './src/app/**/*.{ts,tsx}', 9 | ], 10 | theme: { 11 | container: { 12 | center: true, 13 | padding: "2rem", 14 | screens: { 15 | "2xl": "1400px", 16 | }, 17 | }, 18 | extend: { 19 | colors: { 20 | border: "hsl(var(--border))", 21 | input: "hsl(var(--input))", 22 | ring: "hsl(var(--ring))", 23 | background: "hsl(var(--background))", 24 | foreground: "hsl(var(--foreground))", 25 | primary: { 26 | DEFAULT: "hsl(var(--primary))", 27 | foreground: "hsl(var(--primary-foreground))", 28 | }, 29 | secondary: { 30 | DEFAULT: "hsl(var(--secondary))", 31 | foreground: "hsl(var(--secondary-foreground))", 32 | }, 33 | destructive: { 34 | DEFAULT: "hsl(var(--destructive))", 35 | foreground: "hsl(var(--destructive-foreground))", 36 | }, 37 | muted: { 38 | DEFAULT: "hsl(var(--muted))", 39 | foreground: "hsl(var(--muted-foreground))", 40 | }, 41 | accent: { 42 | DEFAULT: "hsl(var(--accent))", 43 | foreground: "hsl(var(--accent-foreground))", 44 | }, 45 | popover: { 46 | DEFAULT: "hsl(var(--popover))", 47 | foreground: "hsl(var(--popover-foreground))", 48 | }, 49 | card: { 50 | DEFAULT: "hsl(var(--card))", 51 | foreground: "hsl(var(--card-foreground))", 52 | }, 53 | }, 54 | borderRadius: { 55 | lg: "var(--radius)", 56 | md: "calc(var(--radius) - 2px)", 57 | sm: "calc(var(--radius) - 4px)", 58 | }, 59 | keyframes: { 60 | "accordion-down": { 61 | from: { height: 0 }, 62 | to: { height: "var(--radix-accordion-content-height)" }, 63 | }, 64 | "accordion-up": { 65 | from: { height: "var(--radix-accordion-content-height)" }, 66 | to: { height: 0 }, 67 | }, 68 | }, 69 | animation: { 70 | "accordion-down": "accordion-down 0.2s ease-out", 71 | "accordion-up": "accordion-up 0.2s ease-out", 72 | }, 73 | }, 74 | }, 75 | plugins: [require("tailwindcss-animate")], 76 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@haydenull/fabric/tsconfig/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "baseUrl": "./", 6 | "paths": { 7 | "@/*": ["./src/*"], 8 | } 9 | }, 10 | "include": ["./src", "./vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { resolve } from 'path' 3 | import { defineConfig } from 'vite' 4 | import usePluginImport from 'vite-plugin-importer' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | react(), 10 | // eslint-disable-next-line react-hooks/rules-of-hooks 11 | usePluginImport({ 12 | libraryName: 'antd', 13 | libraryDirectory: 'es', 14 | style: 'css', 15 | }), 16 | ], 17 | base: './', 18 | resolve: { 19 | alias: { 20 | '@': resolve(__dirname, 'src'), 21 | }, 22 | }, 23 | build: { 24 | target: 'esnext', 25 | // minify: "esbuild", 26 | }, 27 | define: { 28 | // fix: https://github.com/excalidraw/excalidraw/issues/7331 29 | // https://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#referenceerror-process-is-not-defined 30 | 'process.env.IS_PREACT': process.env.IS_PREACT, 31 | }, 32 | }) 33 | --------------------------------------------------------------------------------