├── .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 | [](https://github.com/haydenull/logseq-plugin-excalidraw/releases)
5 | [](https://github.com/haydenull/logseq-plugin-excalidraw/blob/main/LICENSE)
6 |
7 | ## Demo
8 | 
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 | 
69 |
70 | ### Editor
71 | 
72 |
73 | ### Preview
74 | 
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: ``,
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 |
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 |
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 ?

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