├── .changeset └── config.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml └── workflows │ └── release.yaml ├── .gitignore ├── .husky └── commit-msg ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── SECURITY.md ├── apps └── web │ ├── .env.example │ ├── .gitignore │ ├── .prettierignore │ ├── app │ ├── api │ │ ├── generate │ │ │ └── route.ts │ │ └── upload │ │ │ └── route.ts │ ├── favicon.ico │ ├── layout.tsx │ ├── opengraph-image.png │ ├── page.tsx │ └── providers.tsx │ ├── biome.json │ ├── components.json │ ├── components │ └── tailwind │ │ ├── advanced-editor.tsx │ │ ├── extensions.ts │ │ ├── generative │ │ ├── ai-completion-command.tsx │ │ ├── ai-selector-commands.tsx │ │ ├── ai-selector.tsx │ │ └── generative-menu-switch.tsx │ │ ├── image-upload.ts │ │ ├── selectors │ │ ├── color-selector.tsx │ │ ├── link-selector.tsx │ │ ├── math-selector.tsx │ │ ├── node-selector.tsx │ │ └── text-buttons.tsx │ │ ├── slash-command.tsx │ │ └── ui │ │ ├── button.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── icons │ │ ├── crazy-spinner.tsx │ │ ├── font-default.tsx │ │ ├── font-mono.tsx │ │ ├── font-serif.tsx │ │ ├── index.tsx │ │ ├── loading-circle.tsx │ │ └── magic.tsx │ │ ├── menu.tsx │ │ ├── popover.tsx │ │ ├── scroll-area.tsx │ │ └── separator.tsx │ ├── hooks │ └── use-local-storage.ts │ ├── lib │ ├── content.ts │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── styles │ ├── CalSans-SemiBold.otf │ ├── fonts.ts │ ├── globals.css │ └── prosemirror.css │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── vercel.json ├── biome.json ├── package.json ├── packages ├── headless │ ├── CHANGELOG.md │ ├── biome.json │ ├── package.json │ ├── src │ │ ├── components │ │ │ ├── editor-bubble-item.tsx │ │ │ ├── editor-bubble.tsx │ │ │ ├── editor-command-item.tsx │ │ │ ├── editor-command.tsx │ │ │ ├── editor.tsx │ │ │ └── index.ts │ │ ├── extensions │ │ │ ├── ai-highlight.ts │ │ │ ├── custom-keymap.ts │ │ │ ├── image-resizer.tsx │ │ │ ├── index.ts │ │ │ ├── mathematics.ts │ │ │ ├── slash-command.tsx │ │ │ ├── twitter.tsx │ │ │ └── updated-image.ts │ │ ├── index.ts │ │ ├── plugins │ │ │ ├── index.ts │ │ │ └── upload-images.tsx │ │ └── utils │ │ │ ├── atoms.ts │ │ │ ├── index.ts │ │ │ └── store.ts │ ├── tsconfig.json │ └── tsup.config.ts └── tsconfig │ ├── base.json │ ├── next.json │ ├── package.json │ └── react.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── prettier.config.js └── turbo.json /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { 6 | "repo": "steven-tey/novel" 7 | } 8 | ], 9 | "commit": false, 10 | "fixed": [], 11 | "linked": [], 12 | "access": "public", 13 | "baseBranch": "main", 14 | "updateInternalDependencies": "patch", 15 | "ignore": [ 16 | "novel-next-app" 17 | ] 18 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: andrewdoro 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug Report 2 | description: Create a bug report to help us improve 3 | title: "bug: " 4 | labels: ["🐞❔ unconfirmed bug"] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Provide environment information 9 | description: | 10 | Run this command in your project root and paste the results in a code block: 11 | ```bash 12 | npx envinfo --system --binaries 13 | ``` 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Describe the bug 19 | description: A clear and concise description of the bug, as well as what you expected to happen when encountering it. 20 | validations: 21 | required: true 22 | - type: input 23 | attributes: 24 | label: Link to reproduction 25 | description: Please provide a link to a reproduction of the bug. Issues without a reproduction repo may be ignored. 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: To reproduce 31 | description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc. 32 | validations: 33 | required: true 34 | - type: textarea 35 | attributes: 36 | label: Additional information 37 | description: Add any other information related to the bug here, screenshots if applicable. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | # This template is heavily inspired by the Next.js's template: 2 | # See here: https://github.com/vercel/next.js/tree/canary/.github/ISSUE_TEMPLATE 3 | 4 | name: 🛠 Feature Request 5 | description: Create a feature request for the core packages 6 | title: "feat: " 7 | labels: ["✨ enhancement"] 8 | body: 9 | - type: markdown 10 | attributes: 11 | value: | 12 | Thank you for taking the time to file a feature request. Please fill out this form as completely as possible. 13 | - type: textarea 14 | attributes: 15 | label: Describe the feature you'd like to request 16 | description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed. 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: Describe the solution you'd like to see 22 | description: Please describe the solution you would like to see. Adding example usage is a good way to provide context. 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Additional information 28 | description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here. 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will release the packages with Changesets 2 | 3 | name: 🚀 Release 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | concurrency: ${{ github.workflow }}-${{ github.ref }} 12 | 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 15 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 16 | 17 | jobs: 18 | release: 19 | name: 🚀 Release 20 | strategy: 21 | matrix: 22 | os: [ubuntu-latest] 23 | node-version: [lts/*] 24 | pnpm-version: [latest] 25 | runs-on: ${{ matrix.os }} 26 | steps: 27 | - name: ⬇️ Checkout 28 | id: checkout 29 | uses: actions/checkout@v2.3.3 30 | with: 31 | token: ${{ env.GITHUB_TOKEN }} 32 | fetch-depth: 0 33 | 34 | - name: 🟢 Setup node 35 | id: setup-node 36 | uses: actions/setup-node@v2 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | 40 | - name: 🥡 Setup pnpm 41 | id: setup-pnpm 42 | uses: pnpm/action-setup@v2.1.0 43 | with: 44 | version: ${{ matrix.pnpm-version }} 45 | run_install: false 46 | 47 | - name: 🎈 Get pnpm store directory 48 | id: get-pnpm-cache-dir 49 | run: | 50 | echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" 51 | 52 | - name: 🔆 Cache pnpm modules 53 | uses: actions/cache@v3 54 | id: pnpm-cache 55 | with: 56 | path: ${{ steps.get-pnpm-cache-dir.outputs.pnpm_cache_dir }} 57 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 58 | restore-keys: | 59 | ${{ runner.os }}-pnpm-store- 60 | 61 | - name: 🧩 Install Dependencies 62 | id: install-dependencies 63 | run: pnpm install 64 | 65 | - name: 🏗️ Build 66 | id: build-the-mono-repo 67 | run: pnpm build 68 | 69 | - name: 📣 Create Release Pull Request or Publish to npm 70 | id: changesets 71 | uses: changesets/action@v1 72 | with: 73 | title: "chore(release): version packages 🦋" 74 | publish: pnpm publish:packages 75 | version: pnpm version:packages 76 | commit: "chore(release): version packages 🦋 [skip ci]" 77 | env: 78 | GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} 79 | NPM_TOKEN: ${{ env.NPM_TOKEN }} 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | node_modules 7 | packages/*/node_modules 8 | apps/*/node_modules 9 | .next 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | .pnpm-debug.log* 24 | 25 | # other lockfiles that's not pnpm-lock.yaml 26 | package-lock.json 27 | yarn.lock 28 | 29 | # local env files 30 | .env 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | 40 | 41 | # intellij 42 | .idea 43 | 44 | dist/** 45 | /dist 46 | packages/*/dist 47 | 48 | .turbo 49 | /test-results/ 50 | /playwright-report/ 51 | /playwright/.cache/ 52 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | pnpm commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "yoavbls.pretty-ts-errors", 4 | "bradlc.vscode-tailwindcss", 5 | "biomejs.biome" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports.biome": "explicit", 4 | "source.fixAll.biome": "explicit", 5 | // "quickfix.biome": "explicit" 6 | }, 7 | "editor.defaultFormatter": "biomejs.biome", 8 | "editor.formatOnSave": true, 9 | "tailwindCSS.experimental.classRegex": [ 10 | ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], 11 | ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] 12 | ], 13 | "typescript.enablePromptUseWorkspaceTsdk": true, 14 | "typescript.tsdk": "node_modules/typescript/lib", 15 | 16 | "typescript.preferences.autoImportFileExcludePatterns": [ 17 | "next/router.d.ts", 18 | "next/dist/client/router.d.ts" 19 | ], 20 | "[typescriptreact]": { 21 | "editor.defaultFormatter": "biomejs.biome" 22 | }, 23 | "[typescript]": { 24 | "editor.defaultFormatter": "biomejs.biome" 25 | }, 26 | "[json]": { 27 | "editor.defaultFormatter": "vscode.json-language-features" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Novel is a Notion-style WYSIWYG editor with AI-powered autocompletions. 3 |

Novel

4 |
5 | 6 |

7 | An open-source Notion-style WYSIWYG editor with AI-powered autocompletions. 8 |

9 | 10 |

11 | Hacker News 12 | 13 | License 14 | 15 | Novel.sh's GitHub repo 16 |

17 | 18 |

19 | Introduction · 20 | Deploy Your Own · 21 | Setting Up Locally · 22 | Tech Stack · 23 | Contributing · 24 | License 25 |

26 |
27 | 28 | ## Docs (WIP) 29 | 30 | https://novel.sh/docs/introduction 31 | 32 | ## Introduction 33 | 34 | [Novel](https://novel.sh/) is a Notion-style WYSIWYG editor with AI-powered autocompletions. 35 | 36 | https://github.com/steven-tey/novel/assets/28986134/2099877f-4f2b-4b1c-8782-5d803d63be5c 37 | 38 |
39 | 40 | ## Deploy Your Own 41 | 42 | You can deploy your own version of Novel to Vercel with one click: 43 | 44 | [![Deploy with Vercel](https://vercel.com/button)](https://stey.me/novel-deploy) 45 | 46 | ## Setting Up Locally 47 | 48 | To set up Novel locally, you'll need to clone the repository and set up the following environment variables: 49 | 50 | - `OPENAI_API_KEY` – your OpenAI API key (you can get one [here](https://platform.openai.com/account/api-keys)) 51 | - `BLOB_READ_WRITE_TOKEN` – your Vercel Blob read/write token (currently [still in beta](https://vercel.com/docs/storage/vercel-blob/quickstart#quickstart), but feel free to [sign up on this form](https://vercel.fyi/blob-beta) for access) 52 | 53 | If you've deployed this to Vercel, you can also use [`vc env pull`](https://vercel.com/docs/cli/env#exporting-development-environment-variables) to pull the environment variables from your Vercel project. 54 | 55 | To run the app locally, you can run the following commands: 56 | 57 | ``` 58 | pnpm i 59 | pnpm dev 60 | ``` 61 | 62 | ## Cross-framework support 63 | 64 | While Novel is built for React, we also have a few community-maintained packages for non-React frameworks: 65 | 66 | - Svelte: https://novel.sh/svelte 67 | - Vue: https://novel.sh/vue 68 | 69 | ## VSCode Extension 70 | 71 | Thanks to @bennykok, Novel also has a VSCode Extension: https://novel.sh/vscode 72 | 73 | https://github.com/steven-tey/novel/assets/28986134/58ebf7e3-cdb3-43df-878b-119e304f7373 74 | 75 | ## Tech Stack 76 | 77 | Novel is built on the following stack: 78 | 79 | - [Next.js](https://nextjs.org/) – framework 80 | - [Tiptap](https://tiptap.dev/) – text editor 81 | - [OpenAI](https://openai.com/) - AI completions 82 | - [Vercel AI SDK](https://sdk.vercel.ai/docs) – AI library 83 | - [Vercel](https://vercel.com) – deployments 84 | - [TailwindCSS](https://tailwindcss.com/) – styles 85 | - [Cal Sans](https://github.com/calcom/font) – font 86 | 87 | ## Contributing 88 | 89 | Here's how you can contribute: 90 | 91 | - [Open an issue](https://github.com/steven-tey/novel/issues) if you believe you've encountered a bug. 92 | - Make a [pull request](https://github.com/steven-tey/novel/pull) to add new features/make quality-of-life improvements/fix bugs. 93 | 94 | 95 | 96 | 97 | 98 | ## Repo Activity 99 | 100 | ![Novel.sh repo activity – generated by Axiom](https://repobeats.axiom.co/api/embed/2ebdaa143b0ad6e7c2ee23151da7b37f67da0b36.svg) 101 | 102 | ## License 103 | 104 | Licensed under the [Apache-2.0 license](https://github.com/steven-tey/novel/blob/main/LICENSE). 105 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We release patches for security vulnerabilities. 6 | 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 0.2.x | :white_check_mark: | 11 | | 0.1.x | :x: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | Please report (suspected) security vulnerabilities to elfandreis@gmail.com. You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity. 16 | -------------------------------------------------------------------------------- /apps/web/.env.example: -------------------------------------------------------------------------------- 1 | # This file will be committed to version control, so make sure not to have any 2 | # secrets in it. If you are cloning this repo, create a copy of this file named 3 | # ".env" and populate it with your secrets. 4 | 5 | # Get your OpenAI API key here: https://platform.openai.com/account/api-keys 6 | OPENAI_API_KEY= 7 | # OPTIONAL: OpenAI Base URL (default to https://api.openai.com/v1) 8 | OPENAI_BASE_URL= 9 | 10 | # OPTIONAL: Vercel Blob (for uploading images) 11 | # Get your Vercel Blob credentials here: https://vercel.com/docs/storage/vercel-blob/quickstart#quickstart 12 | BLOB_READ_WRITE_TOKEN= 13 | 14 | # OPTIONAL: Vercel KV (for ratelimiting) 15 | # Get your Vercel KV credentials here: https://vercel.com/docs/storage/vercel-kv/quickstart#quickstart 16 | KV_REST_API_URL= 17 | KV_REST_API_TOKEN= 18 | -------------------------------------------------------------------------------- /apps/web/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | -------------------------------------------------------------------------------- /apps/web/.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | yarn.lock 3 | node_modules 4 | .next -------------------------------------------------------------------------------- /apps/web/app/api/generate/route.ts: -------------------------------------------------------------------------------- 1 | import { openai } from "@ai-sdk/openai"; 2 | import { Ratelimit } from "@upstash/ratelimit"; 3 | import { kv } from "@vercel/kv"; 4 | import { streamText } from "ai"; 5 | import { match } from "ts-pattern"; 6 | 7 | // IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime 8 | export const runtime = "edge"; 9 | 10 | export async function POST(req: Request): Promise { 11 | // Check if the OPENAI_API_KEY is set, if not return 400 12 | if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") { 13 | return new Response("Missing OPENAI_API_KEY - make sure to add it to your .env file.", { 14 | status: 400, 15 | }); 16 | } 17 | if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { 18 | const ip = req.headers.get("x-forwarded-for"); 19 | const ratelimit = new Ratelimit({ 20 | redis: kv, 21 | limiter: Ratelimit.slidingWindow(50, "1 d"), 22 | }); 23 | 24 | const { success, limit, reset, remaining } = await ratelimit.limit(`novel_ratelimit_${ip}`); 25 | 26 | if (!success) { 27 | return new Response("You have reached your request limit for the day.", { 28 | status: 429, 29 | headers: { 30 | "X-RateLimit-Limit": limit.toString(), 31 | "X-RateLimit-Remaining": remaining.toString(), 32 | "X-RateLimit-Reset": reset.toString(), 33 | }, 34 | }); 35 | } 36 | } 37 | 38 | const { prompt, option, command } = await req.json(); 39 | const messages = match(option) 40 | .with("continue", () => [ 41 | { 42 | role: "system", 43 | content: 44 | "You are an AI writing assistant that continues existing text based on context from prior text. " + 45 | "Give more weight/priority to the later characters than the beginning ones. " + 46 | "Limit your response to no more than 200 characters, but make sure to construct complete sentences." + 47 | "Use Markdown formatting when appropriate.", 48 | }, 49 | { 50 | role: "user", 51 | content: prompt, 52 | }, 53 | ]) 54 | .with("improve", () => [ 55 | { 56 | role: "system", 57 | content: 58 | "You are an AI writing assistant that improves existing text. " + 59 | "Limit your response to no more than 200 characters, but make sure to construct complete sentences." + 60 | "Use Markdown formatting when appropriate.", 61 | }, 62 | { 63 | role: "user", 64 | content: `The existing text is: ${prompt}`, 65 | }, 66 | ]) 67 | .with("shorter", () => [ 68 | { 69 | role: "system", 70 | content: 71 | "You are an AI writing assistant that shortens existing text. " + "Use Markdown formatting when appropriate.", 72 | }, 73 | { 74 | role: "user", 75 | content: `The existing text is: ${prompt}`, 76 | }, 77 | ]) 78 | .with("longer", () => [ 79 | { 80 | role: "system", 81 | content: 82 | "You are an AI writing assistant that lengthens existing text. " + 83 | "Use Markdown formatting when appropriate.", 84 | }, 85 | { 86 | role: "user", 87 | content: `The existing text is: ${prompt}`, 88 | }, 89 | ]) 90 | .with("fix", () => [ 91 | { 92 | role: "system", 93 | content: 94 | "You are an AI writing assistant that fixes grammar and spelling errors in existing text. " + 95 | "Limit your response to no more than 200 characters, but make sure to construct complete sentences." + 96 | "Use Markdown formatting when appropriate.", 97 | }, 98 | { 99 | role: "user", 100 | content: `The existing text is: ${prompt}`, 101 | }, 102 | ]) 103 | .with("zap", () => [ 104 | { 105 | role: "system", 106 | content: 107 | "You area an AI writing assistant that generates text based on a prompt. " + 108 | "You take an input from the user and a command for manipulating the text" + 109 | "Use Markdown formatting when appropriate.", 110 | }, 111 | { 112 | role: "user", 113 | content: `For this text: ${prompt}. You have to respect the command: ${command}`, 114 | }, 115 | ]) 116 | .run(); 117 | 118 | const result = await streamText({ 119 | prompt: messages[messages.length - 1].content, 120 | maxTokens: 4096, 121 | temperature: 0.7, 122 | topP: 1, 123 | frequencyPenalty: 0, 124 | presencePenalty: 0, 125 | model: openai("gpt-4o-mini"), 126 | }); 127 | 128 | return result.toDataStreamResponse(); 129 | } 130 | -------------------------------------------------------------------------------- /apps/web/app/api/upload/route.ts: -------------------------------------------------------------------------------- 1 | import { put } from "@vercel/blob"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export const runtime = "edge"; 5 | 6 | export async function POST(req: Request) { 7 | if (!process.env.BLOB_READ_WRITE_TOKEN) { 8 | return new Response("Missing BLOB_READ_WRITE_TOKEN. Don't forget to add that to your .env file.", { 9 | status: 401, 10 | }); 11 | } 12 | 13 | const file = req.body || ""; 14 | const filename = req.headers.get("x-vercel-filename") || "file.txt"; 15 | const contentType = req.headers.get("content-type") || "text/plain"; 16 | const fileType = `.${contentType.split("/")[1]}`; 17 | 18 | // construct final filename based on content-type if not provided 19 | const finalName = filename.includes(fileType) ? filename : `${filename}${fileType}`; 20 | const blob = await put(finalName, file, { 21 | contentType, 22 | access: "public", 23 | }); 24 | 25 | return NextResponse.json(blob); 26 | } 27 | -------------------------------------------------------------------------------- /apps/web/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steven-tey/novel/fa95098e66476c466faebb8211baa5869c101a9c/apps/web/app/favicon.ico -------------------------------------------------------------------------------- /apps/web/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | import "@/styles/prosemirror.css"; 3 | import 'katex/dist/katex.min.css'; 4 | 5 | import type { Metadata, Viewport } from "next"; 6 | import type { ReactNode } from "react"; 7 | import Providers from "./providers"; 8 | 9 | const title = "Novel - Notion-style WYSIWYG editor with AI-powered autocompletions"; 10 | const description = 11 | "Novel is a Notion-style WYSIWYG editor with AI-powered autocompletions. Built with Tiptap, OpenAI, and Vercel AI SDK."; 12 | 13 | export const metadata: Metadata = { 14 | title, 15 | description, 16 | openGraph: { 17 | title, 18 | description, 19 | }, 20 | twitter: { 21 | title, 22 | description, 23 | card: "summary_large_image", 24 | creator: "@steventey", 25 | }, 26 | metadataBase: new URL("https://novel.sh"), 27 | }; 28 | 29 | export const viewport: Viewport = { 30 | themeColor: "#ffffff", 31 | }; 32 | 33 | export default function RootLayout({ children }: { children: ReactNode }) { 34 | return ( 35 | 36 | 37 | {children} 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /apps/web/app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steven-tey/novel/fa95098e66476c466faebb8211baa5869c101a9c/apps/web/app/opengraph-image.png -------------------------------------------------------------------------------- /apps/web/app/page.tsx: -------------------------------------------------------------------------------- 1 | import TailwindAdvancedEditor from "@/components/tailwind/advanced-editor"; 2 | import { Button } from "@/components/tailwind/ui/button"; 3 | import { Dialog, DialogContent, DialogTrigger } from "@/components/tailwind/ui/dialog"; 4 | import Menu from "@/components/tailwind/ui/menu"; 5 | import { ScrollArea } from "@/components/tailwind/ui/scroll-area"; 6 | import { BookOpen, GithubIcon } from "lucide-react"; 7 | import Link from "next/link"; 8 | 9 | export default function Page() { 10 | return ( 11 |
12 |
13 | 18 | 19 | 20 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 | 37 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /apps/web/app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { type Dispatch, type ReactNode, type SetStateAction, createContext } from "react"; 4 | import { ThemeProvider, useTheme } from "next-themes"; 5 | import { Toaster } from "sonner"; 6 | import { Analytics } from "@vercel/analytics/react"; 7 | import useLocalStorage from "@/hooks/use-local-storage"; 8 | 9 | export const AppContext = createContext<{ 10 | font: string; 11 | setFont: Dispatch>; 12 | }>({ 13 | font: "Default", 14 | setFont: () => {}, 15 | }); 16 | 17 | const ToasterProvider = () => { 18 | const { theme } = useTheme() as { 19 | theme: "light" | "dark" | "system"; 20 | }; 21 | return ; 22 | }; 23 | 24 | export default function Providers({ children }: { children: ReactNode }) { 25 | const [font, setFont] = useLocalStorage("novel__font", "Default"); 26 | 27 | return ( 28 | 29 | 35 | 36 | {children} 37 | 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /apps/web/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../biome.json"] 3 | } 4 | -------------------------------------------------------------------------------- /apps/web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "styles/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components/tailwind", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/advanced-editor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { defaultEditorContent } from "@/lib/content"; 3 | import { 4 | EditorCommand, 5 | EditorCommandEmpty, 6 | EditorCommandItem, 7 | EditorCommandList, 8 | EditorContent, 9 | type EditorInstance, 10 | EditorRoot, 11 | ImageResizer, 12 | type JSONContent, 13 | handleCommandNavigation, 14 | handleImageDrop, 15 | handleImagePaste, 16 | } from "novel"; 17 | import { useEffect, useState } from "react"; 18 | import { useDebouncedCallback } from "use-debounce"; 19 | import { defaultExtensions } from "./extensions"; 20 | import { ColorSelector } from "./selectors/color-selector"; 21 | import { LinkSelector } from "./selectors/link-selector"; 22 | import { MathSelector } from "./selectors/math-selector"; 23 | import { NodeSelector } from "./selectors/node-selector"; 24 | import { Separator } from "./ui/separator"; 25 | 26 | import GenerativeMenuSwitch from "./generative/generative-menu-switch"; 27 | import { uploadFn } from "./image-upload"; 28 | import { TextButtons } from "./selectors/text-buttons"; 29 | import { slashCommand, suggestionItems } from "./slash-command"; 30 | 31 | const hljs = require("highlight.js"); 32 | 33 | const extensions = [...defaultExtensions, slashCommand]; 34 | 35 | const TailwindAdvancedEditor = () => { 36 | const [initialContent, setInitialContent] = useState(null); 37 | const [saveStatus, setSaveStatus] = useState("Saved"); 38 | const [charsCount, setCharsCount] = useState(); 39 | 40 | const [openNode, setOpenNode] = useState(false); 41 | const [openColor, setOpenColor] = useState(false); 42 | const [openLink, setOpenLink] = useState(false); 43 | const [openAI, setOpenAI] = useState(false); 44 | 45 | //Apply Codeblock Highlighting on the HTML from editor.getHTML() 46 | const highlightCodeblocks = (content: string) => { 47 | const doc = new DOMParser().parseFromString(content, "text/html"); 48 | doc.querySelectorAll("pre code").forEach((el) => { 49 | // @ts-ignore 50 | // https://highlightjs.readthedocs.io/en/latest/api.html?highlight=highlightElement#highlightelement 51 | hljs.highlightElement(el); 52 | }); 53 | return new XMLSerializer().serializeToString(doc); 54 | }; 55 | 56 | const debouncedUpdates = useDebouncedCallback(async (editor: EditorInstance) => { 57 | const json = editor.getJSON(); 58 | setCharsCount(editor.storage.characterCount.words()); 59 | window.localStorage.setItem("html-content", highlightCodeblocks(editor.getHTML())); 60 | window.localStorage.setItem("novel-content", JSON.stringify(json)); 61 | window.localStorage.setItem("markdown", editor.storage.markdown.getMarkdown()); 62 | setSaveStatus("Saved"); 63 | }, 500); 64 | 65 | useEffect(() => { 66 | const content = window.localStorage.getItem("novel-content"); 67 | if (content) setInitialContent(JSON.parse(content)); 68 | else setInitialContent(defaultEditorContent); 69 | }, []); 70 | 71 | if (!initialContent) return null; 72 | 73 | return ( 74 |
75 |
76 |
{saveStatus}
77 |
78 | {charsCount} Words 79 |
80 |
81 | 82 | handleCommandNavigation(event), 89 | }, 90 | handlePaste: (view, event) => handleImagePaste(view, event, uploadFn), 91 | handleDrop: (view, event, _slice, moved) => handleImageDrop(view, event, moved, uploadFn), 92 | attributes: { 93 | class: 94 | "prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full", 95 | }, 96 | }} 97 | onUpdate={({ editor }) => { 98 | debouncedUpdates(editor); 99 | setSaveStatus("Unsaved"); 100 | }} 101 | slotAfter={} 102 | > 103 | 104 | No results 105 | 106 | {suggestionItems.map((item) => ( 107 | item.command(val)} 110 | className="flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm hover:bg-accent aria-selected:bg-accent" 111 | key={item.title} 112 | > 113 |
114 | {item.icon} 115 |
116 |
117 |

{item.title}

118 |

{item.description}

119 |
120 |
121 | ))} 122 |
123 |
124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 |
139 |
140 |
141 | ); 142 | }; 143 | 144 | export default TailwindAdvancedEditor; 145 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/extensions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AIHighlight, 3 | CharacterCount, 4 | CodeBlockLowlight, 5 | Color, 6 | CustomKeymap, 7 | GlobalDragHandle, 8 | HighlightExtension, 9 | HorizontalRule, 10 | MarkdownExtension, 11 | Mathematics, 12 | Placeholder, 13 | StarterKit, 14 | TaskItem, 15 | TaskList, 16 | TextStyle, 17 | TiptapImage, 18 | TiptapLink, 19 | TiptapUnderline, 20 | Twitter, 21 | UpdatedImage, 22 | UploadImagesPlugin, 23 | Youtube, 24 | } from "novel"; 25 | 26 | import { cx } from "class-variance-authority"; 27 | import { common, createLowlight } from "lowlight"; 28 | 29 | //TODO I am using cx here to get tailwind autocomplete working, idk if someone else can write a regex to just capture the class key in objects 30 | const aiHighlight = AIHighlight; 31 | //You can overwrite the placeholder with your own configuration 32 | const placeholder = Placeholder; 33 | const tiptapLink = TiptapLink.configure({ 34 | HTMLAttributes: { 35 | class: cx( 36 | "text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer", 37 | ), 38 | }, 39 | }); 40 | 41 | const tiptapImage = TiptapImage.extend({ 42 | addProseMirrorPlugins() { 43 | return [ 44 | UploadImagesPlugin({ 45 | imageClass: cx("opacity-40 rounded-lg border border-stone-200"), 46 | }), 47 | ]; 48 | }, 49 | }).configure({ 50 | allowBase64: true, 51 | HTMLAttributes: { 52 | class: cx("rounded-lg border border-muted"), 53 | }, 54 | }); 55 | 56 | const updatedImage = UpdatedImage.configure({ 57 | HTMLAttributes: { 58 | class: cx("rounded-lg border border-muted"), 59 | }, 60 | }); 61 | 62 | const taskList = TaskList.configure({ 63 | HTMLAttributes: { 64 | class: cx("not-prose pl-2 "), 65 | }, 66 | }); 67 | const taskItem = TaskItem.configure({ 68 | HTMLAttributes: { 69 | class: cx("flex gap-2 items-start my-4"), 70 | }, 71 | nested: true, 72 | }); 73 | 74 | const horizontalRule = HorizontalRule.configure({ 75 | HTMLAttributes: { 76 | class: cx("mt-4 mb-6 border-t border-muted-foreground"), 77 | }, 78 | }); 79 | 80 | const starterKit = StarterKit.configure({ 81 | bulletList: { 82 | HTMLAttributes: { 83 | class: cx("list-disc list-outside leading-3 -mt-2"), 84 | }, 85 | }, 86 | orderedList: { 87 | HTMLAttributes: { 88 | class: cx("list-decimal list-outside leading-3 -mt-2"), 89 | }, 90 | }, 91 | listItem: { 92 | HTMLAttributes: { 93 | class: cx("leading-normal -mb-2"), 94 | }, 95 | }, 96 | blockquote: { 97 | HTMLAttributes: { 98 | class: cx("border-l-4 border-primary"), 99 | }, 100 | }, 101 | codeBlock: { 102 | HTMLAttributes: { 103 | class: cx("rounded-md bg-muted text-muted-foreground border p-5 font-mono font-medium"), 104 | }, 105 | }, 106 | code: { 107 | HTMLAttributes: { 108 | class: cx("rounded-md bg-muted px-1.5 py-1 font-mono font-medium"), 109 | spellcheck: "false", 110 | }, 111 | }, 112 | horizontalRule: false, 113 | dropcursor: { 114 | color: "#DBEAFE", 115 | width: 4, 116 | }, 117 | gapcursor: false, 118 | }); 119 | 120 | const codeBlockLowlight = CodeBlockLowlight.configure({ 121 | // configure lowlight: common / all / use highlightJS in case there is a need to specify certain language grammars only 122 | // common: covers 37 language grammars which should be good enough in most cases 123 | lowlight: createLowlight(common), 124 | }); 125 | 126 | const youtube = Youtube.configure({ 127 | HTMLAttributes: { 128 | class: cx("rounded-lg border border-muted"), 129 | }, 130 | inline: false, 131 | }); 132 | 133 | const twitter = Twitter.configure({ 134 | HTMLAttributes: { 135 | class: cx("not-prose"), 136 | }, 137 | inline: false, 138 | }); 139 | 140 | const mathematics = Mathematics.configure({ 141 | HTMLAttributes: { 142 | class: cx("text-foreground rounded p-1 hover:bg-accent cursor-pointer"), 143 | }, 144 | katexOptions: { 145 | throwOnError: false, 146 | }, 147 | }); 148 | 149 | const characterCount = CharacterCount.configure(); 150 | 151 | const markdownExtension = MarkdownExtension.configure({ 152 | html: true, 153 | tightLists: true, 154 | tightListClass: "tight", 155 | bulletListMarker: "-", 156 | linkify: false, 157 | breaks: false, 158 | transformPastedText: false, 159 | transformCopiedText: false, 160 | }); 161 | 162 | export const defaultExtensions = [ 163 | starterKit, 164 | placeholder, 165 | tiptapLink, 166 | tiptapImage, 167 | updatedImage, 168 | taskList, 169 | taskItem, 170 | horizontalRule, 171 | aiHighlight, 172 | codeBlockLowlight, 173 | youtube, 174 | twitter, 175 | mathematics, 176 | characterCount, 177 | TiptapUnderline, 178 | markdownExtension, 179 | HighlightExtension, 180 | TextStyle, 181 | Color, 182 | CustomKeymap, 183 | GlobalDragHandle, 184 | ]; 185 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/generative/ai-completion-command.tsx: -------------------------------------------------------------------------------- 1 | import { CommandGroup, CommandItem, CommandSeparator } from "../ui/command"; 2 | import { useEditor } from "novel"; 3 | import { Check, TextQuote, TrashIcon } from "lucide-react"; 4 | 5 | const AICompletionCommands = ({ 6 | completion, 7 | onDiscard, 8 | }: { 9 | completion: string; 10 | onDiscard: () => void; 11 | }) => { 12 | const { editor } = useEditor(); 13 | return ( 14 | <> 15 | 16 | { 20 | const selection = editor.view.state.selection; 21 | 22 | editor 23 | .chain() 24 | .focus() 25 | .insertContentAt( 26 | { 27 | from: selection.from, 28 | to: selection.to, 29 | }, 30 | completion, 31 | ) 32 | .run(); 33 | }} 34 | > 35 | 36 | Replace selection 37 | 38 | { 42 | const selection = editor.view.state.selection; 43 | editor 44 | .chain() 45 | .focus() 46 | .insertContentAt(selection.to + 1, completion) 47 | .run(); 48 | }} 49 | > 50 | 51 | Insert below 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | Discard 60 | 61 | 62 | 63 | ); 64 | }; 65 | 66 | export default AICompletionCommands; 67 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/generative/ai-selector-commands.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowDownWideNarrow, CheckCheck, RefreshCcwDot, StepForward, WrapText } from "lucide-react"; 2 | import { getPrevText, useEditor } from "novel"; 3 | import { CommandGroup, CommandItem, CommandSeparator } from "../ui/command"; 4 | 5 | const options = [ 6 | { 7 | value: "improve", 8 | label: "Improve writing", 9 | icon: RefreshCcwDot, 10 | }, 11 | { 12 | value: "fix", 13 | label: "Fix grammar", 14 | icon: CheckCheck, 15 | }, 16 | { 17 | value: "shorter", 18 | label: "Make shorter", 19 | icon: ArrowDownWideNarrow, 20 | }, 21 | { 22 | value: "longer", 23 | label: "Make longer", 24 | icon: WrapText, 25 | }, 26 | ]; 27 | 28 | interface AISelectorCommandsProps { 29 | onSelect: (value: string, option: string) => void; 30 | } 31 | 32 | const AISelectorCommands = ({ onSelect }: AISelectorCommandsProps) => { 33 | const { editor } = useEditor(); 34 | 35 | return ( 36 | <> 37 | 38 | {options.map((option) => ( 39 | { 41 | const slice = editor.state.selection.content(); 42 | const text = editor.storage.markdown.serializer.serialize(slice.content); 43 | onSelect(text, value); 44 | }} 45 | className="flex gap-2 px-4" 46 | key={option.value} 47 | value={option.value} 48 | > 49 | 50 | {option.label} 51 | 52 | ))} 53 | 54 | 55 | 56 | { 58 | const pos = editor.state.selection.from; 59 | const text = getPrevText(editor, pos); 60 | onSelect(text, "continue"); 61 | }} 62 | value="continue" 63 | className="gap-2 px-4" 64 | > 65 | 66 | Continue writing 67 | 68 | 69 | 70 | ); 71 | }; 72 | 73 | export default AISelectorCommands; 74 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/generative/ai-selector.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Command, CommandInput } from "@/components/tailwind/ui/command"; 4 | 5 | import { useCompletion } from "ai/react"; 6 | import { ArrowUp } from "lucide-react"; 7 | import { useEditor } from "novel"; 8 | import { addAIHighlight } from "novel"; 9 | import { useState } from "react"; 10 | import Markdown from "react-markdown"; 11 | import { toast } from "sonner"; 12 | import { Button } from "../ui/button"; 13 | import CrazySpinner from "../ui/icons/crazy-spinner"; 14 | import Magic from "../ui/icons/magic"; 15 | import { ScrollArea } from "../ui/scroll-area"; 16 | import AICompletionCommands from "./ai-completion-command"; 17 | import AISelectorCommands from "./ai-selector-commands"; 18 | //TODO: I think it makes more sense to create a custom Tiptap extension for this functionality https://tiptap.dev/docs/editor/ai/introduction 19 | 20 | interface AISelectorProps { 21 | open: boolean; 22 | onOpenChange: (open: boolean) => void; 23 | } 24 | 25 | export function AISelector({ onOpenChange }: AISelectorProps) { 26 | const { editor } = useEditor(); 27 | const [inputValue, setInputValue] = useState(""); 28 | 29 | const { completion, complete, isLoading } = useCompletion({ 30 | // id: "novel", 31 | api: "/api/generate", 32 | onResponse: (response) => { 33 | if (response.status === 429) { 34 | toast.error("You have reached your request limit for the day."); 35 | return; 36 | } 37 | }, 38 | onError: (e) => { 39 | toast.error(e.message); 40 | }, 41 | }); 42 | 43 | const hasCompletion = completion.length > 0; 44 | 45 | return ( 46 | 47 | {hasCompletion && ( 48 |
49 | 50 |
51 | {completion} 52 |
53 |
54 |
55 | )} 56 | 57 | {isLoading && ( 58 |
59 | 60 | AI is thinking 61 |
62 | 63 |
64 |
65 | )} 66 | {!isLoading && ( 67 | <> 68 |
69 | addAIHighlight(editor)} 75 | /> 76 | 95 |
96 | {hasCompletion ? ( 97 | { 99 | editor.chain().unsetHighlight().focus().run(); 100 | onOpenChange(false); 101 | }} 102 | completion={completion} 103 | /> 104 | ) : ( 105 | complete(value, { body: { option } })} /> 106 | )} 107 | 108 | )} 109 |
110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/generative/generative-menu-switch.tsx: -------------------------------------------------------------------------------- 1 | import { EditorBubble, removeAIHighlight, useEditor } from "novel"; 2 | import { Fragment, type ReactNode, useEffect } from "react"; 3 | import { Button } from "../ui/button"; 4 | import Magic from "../ui/icons/magic"; 5 | import { AISelector } from "./ai-selector"; 6 | 7 | interface GenerativeMenuSwitchProps { 8 | children: ReactNode; 9 | open: boolean; 10 | onOpenChange: (open: boolean) => void; 11 | } 12 | const GenerativeMenuSwitch = ({ children, open, onOpenChange }: GenerativeMenuSwitchProps) => { 13 | const { editor } = useEditor(); 14 | 15 | useEffect(() => { 16 | if (!open) removeAIHighlight(editor); 17 | }, [open]); 18 | return ( 19 | { 23 | onOpenChange(false); 24 | editor.chain().unsetHighlight().run(); 25 | }, 26 | }} 27 | className="flex w-fit max-w-[90vw] overflow-hidden rounded-md border border-muted bg-background shadow-xl" 28 | > 29 | {open && } 30 | {!open && ( 31 | 32 | 41 | {children} 42 | 43 | )} 44 | 45 | ); 46 | }; 47 | 48 | export default GenerativeMenuSwitch; 49 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/image-upload.ts: -------------------------------------------------------------------------------- 1 | import { createImageUpload } from "novel"; 2 | import { toast } from "sonner"; 3 | 4 | const onUpload = (file: File) => { 5 | const promise = fetch("/api/upload", { 6 | method: "POST", 7 | headers: { 8 | "content-type": file?.type || "application/octet-stream", 9 | "x-vercel-filename": file?.name || "image.png", 10 | }, 11 | body: file, 12 | }); 13 | 14 | return new Promise((resolve, reject) => { 15 | toast.promise( 16 | promise.then(async (res) => { 17 | // Successfully uploaded image 18 | if (res.status === 200) { 19 | const { url } = (await res.json()) as { url: string }; 20 | // preload the image 21 | const image = new Image(); 22 | image.src = url; 23 | image.onload = () => { 24 | resolve(url); 25 | }; 26 | // No blob store configured 27 | } else if (res.status === 401) { 28 | resolve(file); 29 | throw new Error("`BLOB_READ_WRITE_TOKEN` environment variable not found, reading image locally instead."); 30 | // Unknown error 31 | } else { 32 | throw new Error("Error uploading image. Please try again."); 33 | } 34 | }), 35 | { 36 | loading: "Uploading image...", 37 | success: "Image uploaded successfully.", 38 | error: (e) => { 39 | reject(e); 40 | return e.message; 41 | }, 42 | }, 43 | ); 44 | }); 45 | }; 46 | 47 | export const uploadFn = createImageUpload({ 48 | onUpload, 49 | validateFn: (file) => { 50 | if (!file.type.includes("image/")) { 51 | toast.error("File type not supported."); 52 | return false; 53 | } 54 | if (file.size / 1024 / 1024 > 20) { 55 | toast.error("File size too big (max 20MB)."); 56 | return false; 57 | } 58 | return true; 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/selectors/color-selector.tsx: -------------------------------------------------------------------------------- 1 | import { Check, ChevronDown } from "lucide-react"; 2 | import { EditorBubbleItem, useEditor } from "novel"; 3 | 4 | import { Button } from "@/components/tailwind/ui/button"; 5 | import { Popover, PopoverContent, PopoverTrigger } from "@/components/tailwind/ui/popover"; 6 | export interface BubbleColorMenuItem { 7 | name: string; 8 | color: string; 9 | } 10 | 11 | const TEXT_COLORS: BubbleColorMenuItem[] = [ 12 | { 13 | name: "Default", 14 | color: "var(--novel-black)", 15 | }, 16 | { 17 | name: "Purple", 18 | color: "#9333EA", 19 | }, 20 | { 21 | name: "Red", 22 | color: "#E00000", 23 | }, 24 | { 25 | name: "Yellow", 26 | color: "#EAB308", 27 | }, 28 | { 29 | name: "Blue", 30 | color: "#2563EB", 31 | }, 32 | { 33 | name: "Green", 34 | color: "#008A00", 35 | }, 36 | { 37 | name: "Orange", 38 | color: "#FFA500", 39 | }, 40 | { 41 | name: "Pink", 42 | color: "#BA4081", 43 | }, 44 | { 45 | name: "Gray", 46 | color: "#A8A29E", 47 | }, 48 | ]; 49 | 50 | const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [ 51 | { 52 | name: "Default", 53 | color: "var(--novel-highlight-default)", 54 | }, 55 | { 56 | name: "Purple", 57 | color: "var(--novel-highlight-purple)", 58 | }, 59 | { 60 | name: "Red", 61 | color: "var(--novel-highlight-red)", 62 | }, 63 | { 64 | name: "Yellow", 65 | color: "var(--novel-highlight-yellow)", 66 | }, 67 | { 68 | name: "Blue", 69 | color: "var(--novel-highlight-blue)", 70 | }, 71 | { 72 | name: "Green", 73 | color: "var(--novel-highlight-green)", 74 | }, 75 | { 76 | name: "Orange", 77 | color: "var(--novel-highlight-orange)", 78 | }, 79 | { 80 | name: "Pink", 81 | color: "var(--novel-highlight-pink)", 82 | }, 83 | { 84 | name: "Gray", 85 | color: "var(--novel-highlight-gray)", 86 | }, 87 | ]; 88 | 89 | interface ColorSelectorProps { 90 | open: boolean; 91 | onOpenChange: (open: boolean) => void; 92 | } 93 | 94 | export const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => { 95 | const { editor } = useEditor(); 96 | 97 | if (!editor) return null; 98 | const activeColorItem = TEXT_COLORS.find(({ color }) => editor.isActive("textStyle", { color })); 99 | 100 | const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) => editor.isActive("highlight", { color })); 101 | 102 | return ( 103 | 104 | 105 | 117 | 118 | 119 | 124 |
125 |
Color
126 | {TEXT_COLORS.map(({ name, color }) => ( 127 | { 130 | editor.commands.unsetColor(); 131 | name !== "Default" && 132 | editor 133 | .chain() 134 | .focus() 135 | .setColor(color || "") 136 | .run(); 137 | onOpenChange(false); 138 | }} 139 | className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent" 140 | > 141 |
142 |
143 | A 144 |
145 | {name} 146 |
147 |
148 | ))} 149 |
150 |
151 |
Background
152 | {HIGHLIGHT_COLORS.map(({ name, color }) => ( 153 | { 156 | editor.commands.unsetHighlight(); 157 | name !== "Default" && editor.chain().focus().setHighlight({ color }).run(); 158 | onOpenChange(false); 159 | }} 160 | className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent" 161 | > 162 |
163 |
164 | A 165 |
166 | {name} 167 |
168 | {editor.isActive("highlight", { color }) && } 169 |
170 | ))} 171 |
172 |
173 |
174 | ); 175 | }; 176 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/selectors/link-selector.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/tailwind/ui/button"; 2 | import { PopoverContent } from "@/components/tailwind/ui/popover"; 3 | import { cn } from "@/lib/utils"; 4 | import { Popover, PopoverTrigger } from "@radix-ui/react-popover"; 5 | import { Check, Trash } from "lucide-react"; 6 | import { useEditor } from "novel"; 7 | import { useEffect, useRef } from "react"; 8 | 9 | export function isValidUrl(url: string) { 10 | try { 11 | new URL(url); 12 | return true; 13 | } catch (_e) { 14 | return false; 15 | } 16 | } 17 | export function getUrlFromString(str: string) { 18 | if (isValidUrl(str)) return str; 19 | try { 20 | if (str.includes(".") && !str.includes(" ")) { 21 | return new URL(`https://${str}`).toString(); 22 | } 23 | } catch (_e) { 24 | return null; 25 | } 26 | } 27 | interface LinkSelectorProps { 28 | open: boolean; 29 | onOpenChange: (open: boolean) => void; 30 | } 31 | 32 | export const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => { 33 | const inputRef = useRef(null); 34 | const { editor } = useEditor(); 35 | 36 | // Autofocus on input by default 37 | useEffect(() => { 38 | inputRef.current?.focus(); 39 | }); 40 | if (!editor) return null; 41 | 42 | return ( 43 | 44 | 45 | 55 | 56 | 57 |
{ 59 | const target = e.currentTarget as HTMLFormElement; 60 | e.preventDefault(); 61 | const input = target[0] as HTMLInputElement; 62 | const url = getUrlFromString(input.value); 63 | if (url) { 64 | editor.chain().focus().setLink({ href: url }).run(); 65 | onOpenChange(false); 66 | } 67 | }} 68 | className="flex p-1 " 69 | > 70 | 77 | {editor.getAttributes("link").href ? ( 78 | 91 | ) : ( 92 | 95 | )} 96 |
97 |
98 |
99 | ); 100 | }; 101 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/selectors/math-selector.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/tailwind/ui/button"; 2 | import { cn } from "@/lib/utils"; 3 | import { SigmaIcon } from "lucide-react"; 4 | import { useEditor } from "novel"; 5 | 6 | export const MathSelector = () => { 7 | const { editor } = useEditor(); 8 | 9 | if (!editor) return null; 10 | 11 | return ( 12 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/selectors/node-selector.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Check, 3 | CheckSquare, 4 | ChevronDown, 5 | Code, 6 | Heading1, 7 | Heading2, 8 | Heading3, 9 | ListOrdered, 10 | type LucideIcon, 11 | TextIcon, 12 | TextQuote, 13 | } from "lucide-react"; 14 | import { EditorBubbleItem, useEditor } from "novel"; 15 | 16 | import { Button } from "@/components/tailwind/ui/button"; 17 | import { PopoverContent, PopoverTrigger } from "@/components/tailwind/ui/popover"; 18 | import { Popover } from "@radix-ui/react-popover"; 19 | 20 | export type SelectorItem = { 21 | name: string; 22 | icon: LucideIcon; 23 | command: (editor: ReturnType["editor"]) => void; 24 | isActive: (editor: ReturnType["editor"]) => boolean; 25 | }; 26 | 27 | const items: SelectorItem[] = [ 28 | { 29 | name: "Text", 30 | icon: TextIcon, 31 | command: (editor) => editor.chain().focus().clearNodes().run(), 32 | // I feel like there has to be a more efficient way to do this – feel free to PR if you know how! 33 | isActive: (editor) => 34 | editor.isActive("paragraph") && !editor.isActive("bulletList") && !editor.isActive("orderedList"), 35 | }, 36 | { 37 | name: "Heading 1", 38 | icon: Heading1, 39 | command: (editor) => editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run(), 40 | isActive: (editor) => editor.isActive("heading", { level: 1 }), 41 | }, 42 | { 43 | name: "Heading 2", 44 | icon: Heading2, 45 | command: (editor) => editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run(), 46 | isActive: (editor) => editor.isActive("heading", { level: 2 }), 47 | }, 48 | { 49 | name: "Heading 3", 50 | icon: Heading3, 51 | command: (editor) => editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run(), 52 | isActive: (editor) => editor.isActive("heading", { level: 3 }), 53 | }, 54 | { 55 | name: "To-do List", 56 | icon: CheckSquare, 57 | command: (editor) => editor.chain().focus().clearNodes().toggleTaskList().run(), 58 | isActive: (editor) => editor.isActive("taskItem"), 59 | }, 60 | { 61 | name: "Bullet List", 62 | icon: ListOrdered, 63 | command: (editor) => editor.chain().focus().clearNodes().toggleBulletList().run(), 64 | isActive: (editor) => editor.isActive("bulletList"), 65 | }, 66 | { 67 | name: "Numbered List", 68 | icon: ListOrdered, 69 | command: (editor) => editor.chain().focus().clearNodes().toggleOrderedList().run(), 70 | isActive: (editor) => editor.isActive("orderedList"), 71 | }, 72 | { 73 | name: "Quote", 74 | icon: TextQuote, 75 | command: (editor) => editor.chain().focus().clearNodes().toggleBlockquote().run(), 76 | isActive: (editor) => editor.isActive("blockquote"), 77 | }, 78 | { 79 | name: "Code", 80 | icon: Code, 81 | command: (editor) => editor.chain().focus().clearNodes().toggleCodeBlock().run(), 82 | isActive: (editor) => editor.isActive("codeBlock"), 83 | }, 84 | ]; 85 | interface NodeSelectorProps { 86 | open: boolean; 87 | onOpenChange: (open: boolean) => void; 88 | } 89 | 90 | export const NodeSelector = ({ open, onOpenChange }: NodeSelectorProps) => { 91 | const { editor } = useEditor(); 92 | if (!editor) return null; 93 | const activeItem = items.filter((item) => item.isActive(editor)).pop() ?? { 94 | name: "Multiple", 95 | }; 96 | 97 | return ( 98 | 99 | 100 | 104 | 105 | 106 | {items.map((item) => ( 107 | { 110 | item.command(editor); 111 | onOpenChange(false); 112 | }} 113 | className="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm hover:bg-accent" 114 | > 115 |
116 |
117 | 118 |
119 | {item.name} 120 |
121 | {activeItem.name === item.name && } 122 |
123 | ))} 124 |
125 |
126 | ); 127 | }; 128 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/selectors/text-buttons.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/tailwind/ui/button"; 2 | import { cn } from "@/lib/utils"; 3 | import { BoldIcon, CodeIcon, ItalicIcon, StrikethroughIcon, UnderlineIcon } from "lucide-react"; 4 | import { EditorBubbleItem, useEditor } from "novel"; 5 | import type { SelectorItem } from "./node-selector"; 6 | 7 | export const TextButtons = () => { 8 | const { editor } = useEditor(); 9 | if (!editor) return null; 10 | const items: SelectorItem[] = [ 11 | { 12 | name: "bold", 13 | isActive: (editor) => editor.isActive("bold"), 14 | command: (editor) => editor.chain().focus().toggleBold().run(), 15 | icon: BoldIcon, 16 | }, 17 | { 18 | name: "italic", 19 | isActive: (editor) => editor.isActive("italic"), 20 | command: (editor) => editor.chain().focus().toggleItalic().run(), 21 | icon: ItalicIcon, 22 | }, 23 | { 24 | name: "underline", 25 | isActive: (editor) => editor.isActive("underline"), 26 | command: (editor) => editor.chain().focus().toggleUnderline().run(), 27 | icon: UnderlineIcon, 28 | }, 29 | { 30 | name: "strike", 31 | isActive: (editor) => editor.isActive("strike"), 32 | command: (editor) => editor.chain().focus().toggleStrike().run(), 33 | icon: StrikethroughIcon, 34 | }, 35 | { 36 | name: "code", 37 | isActive: (editor) => editor.isActive("code"), 38 | command: (editor) => editor.chain().focus().toggleCode().run(), 39 | icon: CodeIcon, 40 | }, 41 | ]; 42 | return ( 43 |
44 | {items.map((item) => ( 45 | { 48 | item.command(editor); 49 | }} 50 | > 51 | 58 | 59 | ))} 60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/slash-command.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CheckSquare, 3 | Code, 4 | Heading1, 5 | Heading2, 6 | Heading3, 7 | ImageIcon, 8 | List, 9 | ListOrdered, 10 | MessageSquarePlus, 11 | Text, 12 | TextQuote, 13 | Twitter, 14 | Youtube, 15 | } from "lucide-react"; 16 | import { Command, createSuggestionItems, renderItems } from "novel"; 17 | import { uploadFn } from "./image-upload"; 18 | 19 | export const suggestionItems = createSuggestionItems([ 20 | { 21 | title: "Send Feedback", 22 | description: "Let us know how we can improve.", 23 | icon: , 24 | command: ({ editor, range }) => { 25 | editor.chain().focus().deleteRange(range).run(); 26 | window.open("/feedback", "_blank"); 27 | }, 28 | }, 29 | { 30 | title: "Text", 31 | description: "Just start typing with plain text.", 32 | searchTerms: ["p", "paragraph"], 33 | icon: , 34 | command: ({ editor, range }) => { 35 | editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run(); 36 | }, 37 | }, 38 | { 39 | title: "To-do List", 40 | description: "Track tasks with a to-do list.", 41 | searchTerms: ["todo", "task", "list", "check", "checkbox"], 42 | icon: , 43 | command: ({ editor, range }) => { 44 | editor.chain().focus().deleteRange(range).toggleTaskList().run(); 45 | }, 46 | }, 47 | { 48 | title: "Heading 1", 49 | description: "Big section heading.", 50 | searchTerms: ["title", "big", "large"], 51 | icon: , 52 | command: ({ editor, range }) => { 53 | editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); 54 | }, 55 | }, 56 | { 57 | title: "Heading 2", 58 | description: "Medium section heading.", 59 | searchTerms: ["subtitle", "medium"], 60 | icon: , 61 | command: ({ editor, range }) => { 62 | editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); 63 | }, 64 | }, 65 | { 66 | title: "Heading 3", 67 | description: "Small section heading.", 68 | searchTerms: ["subtitle", "small"], 69 | icon: , 70 | command: ({ editor, range }) => { 71 | editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); 72 | }, 73 | }, 74 | { 75 | title: "Bullet List", 76 | description: "Create a simple bullet list.", 77 | searchTerms: ["unordered", "point"], 78 | icon: , 79 | command: ({ editor, range }) => { 80 | editor.chain().focus().deleteRange(range).toggleBulletList().run(); 81 | }, 82 | }, 83 | { 84 | title: "Numbered List", 85 | description: "Create a list with numbering.", 86 | searchTerms: ["ordered"], 87 | icon: , 88 | command: ({ editor, range }) => { 89 | editor.chain().focus().deleteRange(range).toggleOrderedList().run(); 90 | }, 91 | }, 92 | { 93 | title: "Quote", 94 | description: "Capture a quote.", 95 | searchTerms: ["blockquote"], 96 | icon: , 97 | command: ({ editor, range }) => 98 | editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run(), 99 | }, 100 | { 101 | title: "Code", 102 | description: "Capture a code snippet.", 103 | searchTerms: ["codeblock"], 104 | icon: , 105 | command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), 106 | }, 107 | { 108 | title: "Image", 109 | description: "Upload an image from your computer.", 110 | searchTerms: ["photo", "picture", "media"], 111 | icon: , 112 | command: ({ editor, range }) => { 113 | editor.chain().focus().deleteRange(range).run(); 114 | // upload image 115 | const input = document.createElement("input"); 116 | input.type = "file"; 117 | input.accept = "image/*"; 118 | input.onchange = async () => { 119 | if (input.files?.length) { 120 | const file = input.files[0]; 121 | const pos = editor.view.state.selection.from; 122 | uploadFn(file, editor.view, pos); 123 | } 124 | }; 125 | input.click(); 126 | }, 127 | }, 128 | { 129 | title: "Youtube", 130 | description: "Embed a Youtube video.", 131 | searchTerms: ["video", "youtube", "embed"], 132 | icon: , 133 | command: ({ editor, range }) => { 134 | const videoLink = prompt("Please enter Youtube Video Link"); 135 | //From https://regexr.com/3dj5t 136 | const ytregex = new RegExp( 137 | /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/, 138 | ); 139 | 140 | if (ytregex.test(videoLink)) { 141 | editor 142 | .chain() 143 | .focus() 144 | .deleteRange(range) 145 | .setYoutubeVideo({ 146 | src: videoLink, 147 | }) 148 | .run(); 149 | } else { 150 | if (videoLink !== null) { 151 | alert("Please enter a correct Youtube Video Link"); 152 | } 153 | } 154 | }, 155 | }, 156 | { 157 | title: "Twitter", 158 | description: "Embed a Tweet.", 159 | searchTerms: ["twitter", "embed"], 160 | icon: , 161 | command: ({ editor, range }) => { 162 | const tweetLink = prompt("Please enter Twitter Link"); 163 | const tweetRegex = new RegExp(/^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/); 164 | 165 | if (tweetRegex.test(tweetLink)) { 166 | editor 167 | .chain() 168 | .focus() 169 | .deleteRange(range) 170 | .setTweet({ 171 | src: tweetLink, 172 | }) 173 | .run(); 174 | } else { 175 | if (tweetLink !== null) { 176 | alert("Please enter a correct Twitter Link"); 177 | } 178 | } 179 | }, 180 | }, 181 | ]); 182 | 183 | export const slashCommand = Command.configure({ 184 | suggestion: { 185 | items: () => suggestionItems, 186 | render: renderItems, 187 | }, 188 | }); 189 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", 14 | outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 15 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", 16 | ghost: "hover:bg-accent hover:text-accent-foreground", 17 | link: "text-primary underline-offset-4 hover:underline", 18 | }, 19 | size: { 20 | default: "h-10 px-4 py-2", 21 | sm: "h-9 rounded-md px-3", 22 | lg: "h-11 rounded-md px-8", 23 | icon: "h-10 w-10", 24 | }, 25 | }, 26 | defaultVariants: { 27 | variant: "default", 28 | size: "default", 29 | }, 30 | }, 31 | ); 32 | 33 | export interface ButtonProps 34 | extends React.ButtonHTMLAttributes, 35 | VariantProps { 36 | asChild?: boolean; 37 | } 38 | 39 | const Button = React.forwardRef( 40 | ({ className, variant, size, asChild = false, ...props }, ref) => { 41 | const Comp = asChild ? Slot : "button"; 42 | return ; 43 | }, 44 | ); 45 | Button.displayName = "Button"; 46 | 47 | export { Button, buttonVariants }; 48 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { DialogProps } from "@radix-ui/react-dialog"; 4 | import { Command as CommandPrimitive } from "cmdk"; 5 | import * as React from "react"; 6 | 7 | import { Dialog, DialogContent } from "@/components/tailwind/ui/dialog"; 8 | import Magic from "@/components/tailwind/ui/icons/magic"; 9 | import { cn } from "@/lib/utils"; 10 | 11 | const Command = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 23 | )); 24 | Command.displayName = CommandPrimitive.displayName; 25 | 26 | interface CommandDialogProps extends DialogProps {} 27 | 28 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => { 29 | return ( 30 | 31 | 32 | 33 | {children} 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | const CommandInput = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 |
45 | 46 | 54 |
55 | )); 56 | 57 | CommandInput.displayName = CommandPrimitive.Input.displayName; 58 | 59 | const CommandList = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, ...props }, ref) => ( 63 | 68 | )); 69 | 70 | CommandList.displayName = CommandPrimitive.List.displayName; 71 | 72 | const CommandEmpty = React.forwardRef< 73 | React.ElementRef, 74 | React.ComponentPropsWithoutRef 75 | >((props, ref) => ); 76 | 77 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName; 78 | 79 | const CommandGroup = React.forwardRef< 80 | React.ElementRef, 81 | React.ComponentPropsWithoutRef 82 | >(({ className, ...props }, ref) => ( 83 | 91 | )); 92 | 93 | CommandGroup.displayName = CommandPrimitive.Group.displayName; 94 | 95 | const CommandSeparator = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, ...props }, ref) => ( 99 | 100 | )); 101 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName; 102 | 103 | const CommandItem = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 115 | )); 116 | 117 | CommandItem.displayName = CommandPrimitive.Item.displayName; 118 | 119 | const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { 120 | return ; 121 | }; 122 | CommandShortcut.displayName = "CommandShortcut"; 123 | 124 | export { 125 | Command, 126 | CommandDialog, 127 | CommandInput, 128 | CommandList, 129 | CommandEmpty, 130 | CommandGroup, 131 | CommandItem, 132 | CommandShortcut, 133 | CommandSeparator, 134 | }; 135 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/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 = DialogPrimitive.Portal; 14 | 15 | const DialogClose = DialogPrimitive.Close; 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )); 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )); 54 | DialogContent.displayName = DialogPrimitive.Content.displayName; 55 | 56 | const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( 57 |
58 | ); 59 | DialogHeader.displayName = "DialogHeader"; 60 | 61 | const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( 62 |
63 | ); 64 | DialogFooter.displayName = "DialogFooter"; 65 | 66 | const DialogTitle = React.forwardRef< 67 | React.ElementRef, 68 | React.ComponentPropsWithoutRef 69 | >(({ className, ...props }, ref) => ( 70 | 75 | )); 76 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 77 | 78 | const DialogDescription = React.forwardRef< 79 | React.ElementRef, 80 | React.ComponentPropsWithoutRef 81 | >(({ className, ...props }, ref) => ( 82 | 83 | )); 84 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 85 | 86 | export { 87 | Dialog, 88 | DialogPortal, 89 | DialogOverlay, 90 | DialogClose, 91 | DialogTrigger, 92 | DialogContent, 93 | DialogHeader, 94 | DialogFooter, 95 | DialogTitle, 96 | DialogDescription, 97 | }; 98 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/ui/icons/crazy-spinner.tsx: -------------------------------------------------------------------------------- 1 | const CrazySpinner = () => { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 | ); 9 | }; 10 | 11 | export default CrazySpinner; 12 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/ui/icons/font-default.tsx: -------------------------------------------------------------------------------- 1 | export default function FontDefault({ className }: { className?: string }) { 2 | return ( 3 | 4 | Font Default Icon 5 | 9 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/ui/icons/font-mono.tsx: -------------------------------------------------------------------------------- 1 | export default function FontMono({ className }: { className?: string }) { 2 | return ( 3 | 11 | Font Mono Icon 12 | 16 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/ui/icons/font-serif.tsx: -------------------------------------------------------------------------------- 1 | export default function FontSerif({ className }: { className?: string }) { 2 | return ( 3 | 11 | Font Serif Icon 12 | 13 | 17 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/ui/icons/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as FontDefault } from "./font-default"; 2 | export { default as FontSerif } from "./font-serif"; 3 | export { default as FontMono } from "./font-mono"; 4 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/ui/icons/loading-circle.tsx: -------------------------------------------------------------------------------- 1 | export default function LoadingCircle({ dimensions }: { dimensions?: string }) { 2 | return ( 3 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/ui/icons/magic.tsx: -------------------------------------------------------------------------------- 1 | export default function Magic({ className }: { className: string }) { 2 | return ( 3 | 16 | Magic AI icon 17 | 18 | 22 | 26 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/ui/menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Check, Menu as MenuIcon, Monitor, Moon, SunDim } from "lucide-react"; 4 | import { useTheme } from "next-themes"; 5 | import { Button } from "./button"; 6 | import { Popover, PopoverContent, PopoverTrigger } from "./popover"; 7 | 8 | // TODO implement multiple fonts editor 9 | // const fonts = [ 10 | // { 11 | // font: "Default", 12 | // icon: , 13 | // }, 14 | // { 15 | // font: "Serif", 16 | // icon: , 17 | // }, 18 | // { 19 | // font: "Mono", 20 | // icon: , 21 | // }, 22 | // ]; 23 | const appearances = [ 24 | { 25 | theme: "System", 26 | icon: , 27 | }, 28 | { 29 | theme: "Light", 30 | icon: , 31 | }, 32 | { 33 | theme: "Dark", 34 | icon: , 35 | }, 36 | ]; 37 | export default function Menu() { 38 | // const { font: currentFont, setFont } = useContext(AppContext); 39 | const { theme: currentTheme, setTheme } = useTheme(); 40 | 41 | return ( 42 | 43 | 44 | 47 | 48 | 49 | {/*
50 |

Font

51 | {fonts.map(({ font, icon }) => ( 52 | 67 | ))} 68 |
*/} 69 |

Appearance

70 | {appearances.map(({ theme, icon }) => ( 71 | 85 | ))} 86 |
87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/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 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 13 | {children} 14 | 15 | 16 | 17 | )); 18 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; 19 | 20 | const ScrollBar = React.forwardRef< 21 | React.ElementRef, 22 | React.ComponentPropsWithoutRef 23 | >(({ className, orientation = "vertical", ...props }, ref) => ( 24 | 35 | 36 | 37 | )); 38 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; 39 | 40 | export { ScrollArea, ScrollBar }; 41 | -------------------------------------------------------------------------------- /apps/web/components/tailwind/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 | >(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( 12 | 19 | )); 20 | Separator.displayName = SeparatorPrimitive.Root.displayName; 21 | 22 | export { Separator }; 23 | -------------------------------------------------------------------------------- /apps/web/hooks/use-local-storage.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | const useLocalStorage = ( 4 | key: string, 5 | initialValue: T, 6 | // eslint-disable-next-line no-unused-vars 7 | ): [T, (value: T) => void] => { 8 | const [storedValue, setStoredValue] = useState(initialValue); 9 | 10 | useEffect(() => { 11 | // Retrieve from localStorage 12 | const item = window.localStorage.getItem(key); 13 | if (item) { 14 | setStoredValue(JSON.parse(item)); 15 | } 16 | }, [key]); 17 | 18 | const setValue = (value: T) => { 19 | // Save state 20 | setStoredValue(value); 21 | // Save to localStorage 22 | window.localStorage.setItem(key, JSON.stringify(value)); 23 | }; 24 | return [storedValue, setValue]; 25 | }; 26 | 27 | export default useLocalStorage; 28 | -------------------------------------------------------------------------------- /apps/web/lib/content.ts: -------------------------------------------------------------------------------- 1 | export const defaultEditorContent = { 2 | type: "doc", 3 | content: [ 4 | { 5 | type: "heading", 6 | attrs: { level: 2 }, 7 | content: [{ type: "text", text: "Introducing Novel" }], 8 | }, 9 | { 10 | type: "paragraph", 11 | content: [ 12 | { 13 | type: "text", 14 | marks: [ 15 | { 16 | type: "link", 17 | attrs: { 18 | href: "https://github.com/steven-tey/novel", 19 | target: "_blank", 20 | }, 21 | }, 22 | ], 23 | text: "Novel", 24 | }, 25 | { 26 | type: "text", 27 | text: " is a Notion-style WYSIWYG editor with AI-powered autocompletion. Built with ", 28 | }, 29 | { 30 | type: "text", 31 | marks: [ 32 | { 33 | type: "link", 34 | attrs: { 35 | href: "https://tiptap.dev/", 36 | target: "_blank", 37 | }, 38 | }, 39 | ], 40 | text: "Tiptap", 41 | }, 42 | { type: "text", text: " + " }, 43 | { 44 | type: "text", 45 | marks: [ 46 | { 47 | type: "link", 48 | attrs: { 49 | href: "https://sdk.vercel.ai/docs", 50 | target: "_blank", 51 | }, 52 | }, 53 | ], 54 | text: "Vercel AI SDK", 55 | }, 56 | { type: "text", text: "." }, 57 | ], 58 | }, 59 | { 60 | type: "heading", 61 | attrs: { level: 3 }, 62 | content: [{ type: "text", text: "Installation" }], 63 | }, 64 | { 65 | type: "codeBlock", 66 | attrs: { language: null }, 67 | content: [{ type: "text", text: "npm i novel" }], 68 | }, 69 | { 70 | type: "heading", 71 | attrs: { level: 3 }, 72 | content: [{ type: "text", text: "Usage" }], 73 | }, 74 | { 75 | type: "codeBlock", 76 | attrs: { language: null }, 77 | content: [ 78 | { 79 | type: "text", 80 | text: 'import { Editor } from "novel";\n\nexport default function App() {\n return (\n \n )\n}', 81 | }, 82 | ], 83 | }, 84 | { 85 | type: "heading", 86 | attrs: { level: 3 }, 87 | content: [{ type: "text", text: "Features" }], 88 | }, 89 | { 90 | type: "orderedList", 91 | attrs: { tight: true, start: 1 }, 92 | content: [ 93 | { 94 | type: "listItem", 95 | content: [ 96 | { 97 | type: "paragraph", 98 | content: [{ type: "text", text: "Slash menu & bubble menu" }], 99 | }, 100 | ], 101 | }, 102 | { 103 | type: "listItem", 104 | content: [ 105 | { 106 | type: "paragraph", 107 | content: [ 108 | { type: "text", text: "AI autocomplete (type " }, 109 | { type: "text", marks: [{ type: "code" }], text: "++" }, 110 | { 111 | type: "text", 112 | text: " to activate, or select from slash menu)", 113 | }, 114 | ], 115 | }, 116 | ], 117 | }, 118 | { 119 | type: "listItem", 120 | content: [ 121 | { 122 | type: "paragraph", 123 | content: [ 124 | { 125 | type: "text", 126 | text: "Image uploads (drag & drop / copy & paste, or select from slash menu) ", 127 | }, 128 | ], 129 | }, 130 | ], 131 | }, 132 | { 133 | type: "listItem", 134 | content: [ 135 | { 136 | type: "paragraph", 137 | content: [ 138 | { 139 | type: "text", 140 | text: "Add tweets from the command slash menu:", 141 | }, 142 | ], 143 | }, 144 | { 145 | type: "twitter", 146 | attrs: { 147 | src: "https://x.com/elonmusk/status/1800759252224729577", 148 | }, 149 | }, 150 | ], 151 | }, 152 | { 153 | type: "listItem", 154 | content: [ 155 | { 156 | type: "paragraph", 157 | content: [ 158 | { 159 | type: "text", 160 | text: "Mathematical symbols with LaTeX expression:", 161 | }, 162 | ], 163 | }, 164 | { 165 | type: "orderedList", 166 | attrs: { 167 | tight: true, 168 | start: 1, 169 | }, 170 | content: [ 171 | { 172 | type: "listItem", 173 | content: [ 174 | { 175 | type: "paragraph", 176 | content: [ 177 | { 178 | type: "math", 179 | attrs: { 180 | latex: "E = mc^2", 181 | }, 182 | }, 183 | ], 184 | }, 185 | ], 186 | }, 187 | { 188 | type: "listItem", 189 | content: [ 190 | { 191 | type: "paragraph", 192 | content: [ 193 | { 194 | type: "math", 195 | attrs: { 196 | latex: "a^2 = \\sqrt{b^2 + c^2}", 197 | }, 198 | }, 199 | ], 200 | }, 201 | ], 202 | }, 203 | { 204 | type: "listItem", 205 | content: [ 206 | { 207 | type: "paragraph", 208 | content: [ 209 | { 210 | type: "math", 211 | attrs: { 212 | latex: 213 | "\\hat{f} (\\xi)=\\int_{-\\infty}^{\\infty}f(x)e^{-2\\pi ix\\xi}dx", 214 | }, 215 | }, 216 | ], 217 | }, 218 | ], 219 | }, 220 | { 221 | type: "listItem", 222 | content: [ 223 | { 224 | type: "paragraph", 225 | content: [ 226 | { 227 | type: "math", 228 | attrs: { 229 | latex: 230 | "A=\\begin{bmatrix}a&b\\\\c&d \\end{bmatrix}", 231 | }, 232 | }, 233 | ], 234 | }, 235 | ], 236 | }, 237 | { 238 | type: "listItem", 239 | content: [ 240 | { 241 | type: "paragraph", 242 | content: [ 243 | { 244 | type: "math", 245 | attrs: { 246 | latex: "\\sum_{i=0}^n x_i", 247 | }, 248 | }, 249 | ], 250 | }, 251 | ], 252 | }, 253 | ], 254 | }, 255 | ], 256 | }, 257 | ], 258 | }, 259 | { 260 | type: "image", 261 | attrs: { 262 | src: "https://public.blob.vercel-storage.com/pJrjXbdONOnAeZAZ/banner-2wQk82qTwyVgvlhTW21GIkWgqPGD2C.png", 263 | alt: "banner.png", 264 | title: "banner.png", 265 | width: null, 266 | height: null, 267 | }, 268 | }, 269 | { type: "horizontalRule" }, 270 | { 271 | type: "heading", 272 | attrs: { level: 3 }, 273 | content: [{ type: "text", text: "Learn more" }], 274 | }, 275 | { 276 | type: "taskList", 277 | content: [ 278 | { 279 | type: "taskItem", 280 | attrs: { checked: false }, 281 | content: [ 282 | { 283 | type: "paragraph", 284 | content: [ 285 | { type: "text", text: "Star us on " }, 286 | { 287 | type: "text", 288 | marks: [ 289 | { 290 | type: "link", 291 | attrs: { 292 | href: "https://github.com/steven-tey/novel", 293 | target: "_blank", 294 | }, 295 | }, 296 | ], 297 | text: "GitHub", 298 | }, 299 | ], 300 | }, 301 | ], 302 | }, 303 | { 304 | type: "taskItem", 305 | attrs: { checked: false }, 306 | content: [ 307 | { 308 | type: "paragraph", 309 | content: [ 310 | { type: "text", text: "Install the " }, 311 | { 312 | type: "text", 313 | marks: [ 314 | { 315 | type: "link", 316 | attrs: { 317 | href: "https://www.npmjs.com/package/novel", 318 | target: "_blank", 319 | }, 320 | }, 321 | ], 322 | text: "NPM package", 323 | }, 324 | ], 325 | }, 326 | ], 327 | }, 328 | { 329 | type: "taskItem", 330 | attrs: { checked: false }, 331 | content: [ 332 | { 333 | type: "paragraph", 334 | content: [ 335 | { 336 | type: "text", 337 | marks: [ 338 | { 339 | type: "link", 340 | attrs: { 341 | href: "https://vercel.com/templates/next.js/novel", 342 | target: "_blank", 343 | }, 344 | }, 345 | ], 346 | text: "Deploy your own", 347 | }, 348 | { type: "text", text: " to Vercel" }, 349 | ], 350 | }, 351 | ], 352 | }, 353 | ], 354 | }, 355 | ], 356 | }; 357 | -------------------------------------------------------------------------------- /apps/web/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | redirects: async () => { 4 | return [ 5 | { 6 | source: "/github", 7 | destination: "https://github.com/steven-tey/novel", 8 | permanent: true, 9 | }, 10 | { 11 | source: "/sdk", 12 | destination: "https://www.npmjs.com/package/novel", 13 | permanent: true, 14 | }, 15 | { 16 | source: "/npm", 17 | destination: "https://www.npmjs.com/package/novel", 18 | permanent: true, 19 | }, 20 | { 21 | source: "/svelte", 22 | destination: "https://github.com/tglide/novel-svelte", 23 | permanent: false, 24 | }, 25 | { 26 | source: "/vue", 27 | destination: "https://github.com/naveennaidu/novel-vue", 28 | permanent: false, 29 | }, 30 | { 31 | source: "/vscode", 32 | destination: 33 | "https://marketplace.visualstudio.com/items?itemName=bennykok.novel-vscode", 34 | permanent: false, 35 | }, 36 | { 37 | source: "/feedback", 38 | destination: "https://github.com/steven-tey/novel/issues", 39 | permanent: true, 40 | }, 41 | { 42 | source: "/deploy", 43 | destination: "https://vercel.com/templates/next.js/novel", 44 | permanent: true, 45 | }, 46 | ]; 47 | }, 48 | productionBrowserSourceMaps: true, 49 | }; 50 | 51 | module.exports = nextConfig; 52 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "novel-next-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "biome lint .", 10 | "format": "biome format . ", 11 | "typecheck": "tsc --noEmit" 12 | }, 13 | "dependencies": { 14 | "@ai-sdk/openai": "^1.1.0", 15 | "@radix-ui/react-dialog": "^1.0.5", 16 | "@radix-ui/react-popover": "^1.0.7", 17 | "@radix-ui/react-scroll-area": "^1.0.5", 18 | "@radix-ui/react-select": "^2.0.0", 19 | "@radix-ui/react-separator": "^1.0.3", 20 | "@radix-ui/react-slot": "^1.0.2", 21 | "@tailwindcss/typography": "^0.5.10", 22 | "@upstash/ratelimit": "^1.0.1", 23 | "@vercel/analytics": "^1.2.2", 24 | "@vercel/blob": "^0.22.1", 25 | "@vercel/kv": "^1.0.1", 26 | "ai": "^3.0.12", 27 | "autoprefixer": "^10.4.17", 28 | "class-variance-authority": "^0.7.0", 29 | "clsx": "^2.1.0", 30 | "cmdk": "^1.0.4", 31 | "eventsource-parser": "^1.1.2", 32 | "highlight.js": "^11.9.0", 33 | "lowlight": "^3.1.0", 34 | "lucide-react": "^0.358.0", 35 | "next": "15.1.4", 36 | "next-themes": "^0.2.1", 37 | "novel": "workspace:^", 38 | "openai": "^4.28.4", 39 | "react": "18.2.0", 40 | "react-dom": "18.2.0", 41 | "react-markdown": "^9.0.1", 42 | "sonner": "^1.4.3", 43 | "tailwind-merge": "^2.2.1", 44 | "tailwindcss-animate": "^1.0.7", 45 | "tippy.js": "^6.3.7", 46 | "ts-pattern": "^5.0.8", 47 | "typescript": "^5.4.2", 48 | "use-debounce": "^10.0.0" 49 | }, 50 | "devDependencies": { 51 | "@biomejs/biome": "^1.7.2", 52 | "@types/node": "20.11.24", 53 | "@types/react": "^18.2.61", 54 | "@types/react-dom": "18.2.19", 55 | "tailwindcss": "^3.4.1", 56 | "tsconfig": "workspace:*" 57 | } 58 | } -------------------------------------------------------------------------------- /apps/web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-import": {}, 4 | "tailwindcss/nesting": {}, 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /apps/web/styles/CalSans-SemiBold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steven-tey/novel/fa95098e66476c466faebb8211baa5869c101a9c/apps/web/styles/CalSans-SemiBold.otf -------------------------------------------------------------------------------- /apps/web/styles/fonts.ts: -------------------------------------------------------------------------------- 1 | import localFont from "next/font/local"; 2 | import { Crimson_Text, Inconsolata, Inter } from "next/font/google"; 3 | 4 | export const cal = localFont({ 5 | src: "./CalSans-SemiBold.otf", 6 | variable: "--font-title", 7 | }); 8 | 9 | export const crimsonBold = Crimson_Text({ 10 | weight: "700", 11 | variable: "--font-title", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const inter = Inter({ 16 | variable: "--font-default", 17 | subsets: ["latin"], 18 | }); 19 | 20 | export const inconsolataBold = Inconsolata({ 21 | weight: "700", 22 | variable: "--font-title", 23 | subsets: ["latin"], 24 | }); 25 | 26 | export const crimson = Crimson_Text({ 27 | weight: "400", 28 | variable: "--font-default", 29 | subsets: ["latin"], 30 | }); 31 | 32 | export const inconsolata = Inconsolata({ 33 | variable: "--font-default", 34 | subsets: ["latin"], 35 | }); 36 | 37 | export const titleFontMapper = { 38 | Default: cal.variable, 39 | Serif: crimsonBold.variable, 40 | Mono: inconsolataBold.variable, 41 | }; 42 | 43 | export const defaultFontMapper = { 44 | Default: inter.variable, 45 | Serif: crimson.variable, 46 | Mono: inconsolata.variable, 47 | }; 48 | -------------------------------------------------------------------------------- /apps/web/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | 37 | --novel-highlight-default: #ffffff; 38 | --novel-highlight-purple: #f6f3f8; 39 | --novel-highlight-red: #fdebeb; 40 | --novel-highlight-yellow: #fbf4a2; 41 | --novel-highlight-blue: #c1ecf9; 42 | --novel-highlight-green: #acf79f; 43 | --novel-highlight-orange: #faebdd; 44 | --novel-highlight-pink: #faf1f5; 45 | --novel-highlight-gray: #f1f1ef; 46 | } 47 | 48 | .dark { 49 | --background: 222.2 84% 4.9%; 50 | --foreground: 210 40% 98%; 51 | 52 | --card: 222.2 84% 4.9%; 53 | --card-foreground: 210 40% 98%; 54 | 55 | --popover: 222.2 84% 4.9%; 56 | --popover-foreground: 210 40% 98%; 57 | 58 | --primary: 210 40% 98%; 59 | --primary-foreground: 222.2 47.4% 11.2%; 60 | 61 | --secondary: 217.2 32.6% 17.5%; 62 | --secondary-foreground: 210 40% 98%; 63 | 64 | --muted: 217.2 32.6% 17.5%; 65 | --muted-foreground: 215 20.2% 65.1%; 66 | 67 | --accent: 217.2 32.6% 17.5%; 68 | --accent-foreground: 210 40% 98%; 69 | 70 | --destructive: 0 62.8% 30.6%; 71 | --destructive-foreground: 210 40% 98%; 72 | 73 | --border: 217.2 32.6% 17.5%; 74 | --input: 217.2 32.6% 17.5%; 75 | --ring: 212.7 26.8% 83.9%; 76 | 77 | --novel-highlight-default: #000000; 78 | --novel-highlight-purple: #3f2c4b; 79 | --novel-highlight-red: #5c1a1a; 80 | --novel-highlight-yellow: #5c4b1a; 81 | --novel-highlight-blue: #1a3d5c; 82 | --novel-highlight-green: #1a5c20; 83 | --novel-highlight-orange: #5c3a1a; 84 | --novel-highlight-pink: #5c1a3a; 85 | --novel-highlight-gray: #3a3a3a; 86 | } 87 | } 88 | 89 | @layer base { 90 | * { 91 | @apply border-border; 92 | } 93 | body { 94 | @apply bg-background text-foreground; 95 | } 96 | } 97 | 98 | 99 | pre { 100 | background: #0d0d0d; 101 | border-radius: 0.5rem; 102 | color: #fff; 103 | font-family: "JetBrainsMono", monospace; 104 | padding: 0.75rem 1rem; 105 | 106 | code { 107 | background: none; 108 | color: inherit; 109 | font-size: 0.8rem; 110 | padding: 0; 111 | } 112 | 113 | .hljs-comment, 114 | .hljs-quote { 115 | color: #616161; 116 | } 117 | 118 | .hljs-variable, 119 | .hljs-template-variable, 120 | .hljs-attribute, 121 | .hljs-tag, 122 | .hljs-name, 123 | .hljs-regexp, 124 | .hljs-link, 125 | .hljs-name, 126 | .hljs-selector-id, 127 | .hljs-selector-class { 128 | color: #f98181; 129 | } 130 | 131 | .hljs-number, 132 | .hljs-meta, 133 | .hljs-built_in, 134 | .hljs-builtin-name, 135 | .hljs-literal, 136 | .hljs-type, 137 | .hljs-params { 138 | color: #fbbc88; 139 | } 140 | 141 | .hljs-string, 142 | .hljs-symbol, 143 | .hljs-bullet { 144 | color: #b9f18d; 145 | } 146 | 147 | .hljs-title, 148 | .hljs-section { 149 | color: #faf594; 150 | } 151 | 152 | .hljs-keyword, 153 | .hljs-selector-tag { 154 | color: #70cff8; 155 | } 156 | 157 | .hljs-emphasis { 158 | font-style: italic; 159 | } 160 | 161 | .hljs-strong { 162 | font-weight: 700; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /apps/web/styles/prosemirror.css: -------------------------------------------------------------------------------- 1 | .ProseMirror { 2 | @apply p-12 px-8 sm:px-12; 3 | } 4 | 5 | .ProseMirror .is-editor-empty:first-child::before { 6 | content: attr(data-placeholder); 7 | float: left; 8 | color: hsl(var(--muted-foreground)); 9 | pointer-events: none; 10 | height: 0; 11 | } 12 | .ProseMirror .is-empty::before { 13 | content: attr(data-placeholder); 14 | float: left; 15 | color: hsl(var(--muted-foreground)); 16 | pointer-events: none; 17 | height: 0; 18 | } 19 | 20 | /* Custom image styles */ 21 | 22 | .ProseMirror img { 23 | transition: filter 0.1s ease-in-out; 24 | 25 | &:hover { 26 | cursor: pointer; 27 | filter: brightness(90%); 28 | } 29 | 30 | &.ProseMirror-selectednode { 31 | outline: 3px solid #5abbf7; 32 | filter: brightness(90%); 33 | } 34 | } 35 | 36 | .img-placeholder { 37 | position: relative; 38 | 39 | &:before { 40 | content: ""; 41 | box-sizing: border-box; 42 | position: absolute; 43 | top: 50%; 44 | left: 50%; 45 | width: 36px; 46 | height: 36px; 47 | border-radius: 50%; 48 | border: 3px solid var(--novel-stone-200); 49 | border-top-color: var(--novel-stone-800); 50 | animation: spinning 0.6s linear infinite; 51 | } 52 | } 53 | 54 | @keyframes spinning { 55 | to { 56 | transform: rotate(360deg); 57 | } 58 | } 59 | 60 | /* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */ 61 | 62 | ul[data-type="taskList"] li > label { 63 | margin-right: 0.2rem; 64 | user-select: none; 65 | } 66 | 67 | @media screen and (max-width: 768px) { 68 | ul[data-type="taskList"] li > label { 69 | margin-right: 0.5rem; 70 | } 71 | } 72 | 73 | ul[data-type="taskList"] li > label input[type="checkbox"] { 74 | -webkit-appearance: none; 75 | appearance: none; 76 | background-color: hsl(var(--background)); 77 | margin: 0; 78 | cursor: pointer; 79 | width: 1.2em; 80 | height: 1.2em; 81 | position: relative; 82 | top: 5px; 83 | border: 2px solid hsl(var(--border)); 84 | margin-right: 0.3rem; 85 | display: grid; 86 | place-content: center; 87 | 88 | &:hover { 89 | background-color: hsl(var(--accent)); 90 | } 91 | 92 | &:active { 93 | background-color: hsl(var(--accent)); 94 | } 95 | 96 | &::before { 97 | content: ""; 98 | width: 0.65em; 99 | height: 0.65em; 100 | transform: scale(0); 101 | transition: 120ms transform ease-in-out; 102 | box-shadow: inset 1em 1em; 103 | transform-origin: center; 104 | clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); 105 | } 106 | 107 | &:checked::before { 108 | transform: scale(1); 109 | } 110 | } 111 | 112 | ul[data-type="taskList"] li[data-checked="true"] > div > p { 113 | color: var(--muted-foreground); 114 | text-decoration: line-through; 115 | text-decoration-thickness: 2px; 116 | } 117 | 118 | /* Overwrite tippy-box original max-width */ 119 | 120 | .tippy-box { 121 | max-width: 400px !important; 122 | } 123 | 124 | .ProseMirror:not(.dragging) .ProseMirror-selectednode { 125 | outline: none !important; 126 | background-color: var(--novel-highlight-blue); 127 | transition: background-color 0.2s; 128 | box-shadow: none; 129 | } 130 | 131 | .drag-handle { 132 | position: fixed; 133 | opacity: 1; 134 | transition: opacity ease-in 0.2s; 135 | border-radius: 0.25rem; 136 | 137 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(0, 0, 0, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E"); 138 | background-size: calc(0.5em + 0.375rem) calc(0.5em + 0.375rem); 139 | background-repeat: no-repeat; 140 | background-position: center; 141 | width: 1.2rem; 142 | height: 1.5rem; 143 | z-index: 50; 144 | cursor: grab; 145 | 146 | &:hover { 147 | background-color: var(--novel-stone-100); 148 | transition: background-color 0.2s; 149 | } 150 | 151 | &:active { 152 | background-color: var(--novel-stone-200); 153 | transition: background-color 0.2s; 154 | cursor: grabbing; 155 | } 156 | 157 | &.hide { 158 | opacity: 0; 159 | pointer-events: none; 160 | } 161 | 162 | @media screen and (max-width: 600px) { 163 | display: none; 164 | pointer-events: none; 165 | } 166 | } 167 | 168 | .dark .drag-handle { 169 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(255, 255, 255, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E"); 170 | } 171 | 172 | /* Custom Youtube Video CSS */ 173 | iframe { 174 | border: 8px solid #ffd00027; 175 | border-radius: 4px; 176 | min-width: 200px; 177 | min-height: 200px; 178 | display: block; 179 | outline: 0px solid transparent; 180 | } 181 | 182 | div[data-youtube-video] > iframe { 183 | cursor: move; 184 | aspect-ratio: 16 / 9; 185 | width: 100%; 186 | } 187 | 188 | .ProseMirror-selectednode iframe { 189 | transition: outline 0.15s; 190 | outline: 6px solid #fbbf24; 191 | } 192 | 193 | @media only screen and (max-width: 480px) { 194 | div[data-youtube-video] > iframe { 195 | max-height: 50px; 196 | } 197 | } 198 | 199 | @media only screen and (max-width: 720px) { 200 | div[data-youtube-video] > iframe { 201 | max-height: 100px; 202 | } 203 | } 204 | 205 | /* CSS for bold coloring and highlighting issue*/ 206 | span[style] > strong { 207 | color: inherit; 208 | } 209 | 210 | mark[style] > strong { 211 | color: inherit; 212 | } -------------------------------------------------------------------------------- /apps/web/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{ts,tsx}", 7 | "./components/**/*.{ts,tsx}", 8 | "./app/**/*.{ts,tsx}", 9 | "./src/**/*.{ts,tsx}", 10 | "./lib/**/*.{ts,tsx}", 11 | ], 12 | prefix: "", 13 | theme: { 14 | container: { 15 | center: true, 16 | padding: "2rem", 17 | screens: { 18 | "2xl": "1400px", 19 | }, 20 | }, 21 | extend: { 22 | colors: { 23 | border: "hsl(var(--border))", 24 | input: "hsl(var(--input))", 25 | ring: "hsl(var(--ring))", 26 | background: "hsl(var(--background))", 27 | foreground: "hsl(var(--foreground))", 28 | primary: { 29 | DEFAULT: "hsl(var(--primary))", 30 | foreground: "hsl(var(--primary-foreground))", 31 | }, 32 | secondary: { 33 | DEFAULT: "hsl(var(--secondary))", 34 | foreground: "hsl(var(--secondary-foreground))", 35 | }, 36 | destructive: { 37 | DEFAULT: "hsl(var(--destructive))", 38 | foreground: "hsl(var(--destructive-foreground))", 39 | }, 40 | muted: { 41 | DEFAULT: "hsl(var(--muted))", 42 | foreground: "hsl(var(--muted-foreground))", 43 | }, 44 | accent: { 45 | DEFAULT: "hsl(var(--accent))", 46 | foreground: "hsl(var(--accent-foreground))", 47 | }, 48 | popover: { 49 | DEFAULT: "hsl(var(--popover))", 50 | foreground: "hsl(var(--popover-foreground))", 51 | }, 52 | card: { 53 | DEFAULT: "hsl(var(--card))", 54 | foreground: "hsl(var(--card-foreground))", 55 | }, 56 | }, 57 | borderRadius: { 58 | lg: "var(--radius)", 59 | md: "calc(var(--radius) - 2px)", 60 | sm: "calc(var(--radius) - 4px)", 61 | }, 62 | keyframes: { 63 | "accordion-down": { 64 | from: { height: "0" }, 65 | to: { height: "var(--radix-accordion-content-height)" }, 66 | }, 67 | "accordion-up": { 68 | from: { height: "var(--radix-accordion-content-height)" }, 69 | to: { height: "0" }, 70 | }, 71 | }, 72 | animation: { 73 | "accordion-down": "accordion-down 0.2s ease-out", 74 | "accordion-up": "accordion-up 0.2s ease-out", 75 | }, 76 | }, 77 | }, 78 | plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")], 79 | } satisfies Config; 80 | 81 | export default config; 82 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/next.json", 3 | 4 | "compilerOptions": { 5 | "plugins": [ 6 | { 7 | "name": "next" 8 | } 9 | ], 10 | "paths": { 11 | "@/*": ["./*"] 12 | } 13 | }, 14 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 15 | "exclude": ["node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/docs", 5 | "destination": "https://novel.mintlify.dev/docs" 6 | }, 7 | { 8 | "source": "/docs/:match*", 9 | "destination": "https://novel.mintlify.dev/docs/:match*" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.6.4/schema.json", 3 | "files": { 4 | "ignoreUnknown": true, 5 | "ignore": [ 6 | "node_modules/*", 7 | "*.config.*", 8 | "*.json", 9 | "tsconfig.json", 10 | ".turbo", 11 | "**/dist", 12 | "**/out", 13 | ".next" 14 | ] 15 | }, 16 | "organizeImports": { 17 | "enabled": true 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true, 23 | "complexity": { 24 | "noForEach": "off", 25 | "noUselessFragments": "off" 26 | }, 27 | "correctness": { 28 | "useExhaustiveDependencies": "off", 29 | "noUnusedImports": "warn", 30 | "noUnusedVariables": "warn" 31 | }, 32 | "style": { 33 | "noParameterAssign": "off" 34 | } 35 | } 36 | }, 37 | "formatter": { 38 | "enabled": true, 39 | "formatWithErrors": false, 40 | "indentStyle": "space", 41 | "lineEnding": "lf", 42 | "lineWidth": 120 43 | } 44 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "novel", 3 | "private": true, 4 | "scripts": { 5 | "changeset": "changeset", 6 | "publish:packages": "changeset publish", 7 | "version:packages": "turbo build && changeset version", 8 | "build": "turbo build", 9 | "dev": "turbo dev", 10 | "format": "turbo format --continue --", 11 | "format:fix": "turbo format --continue -- --write", 12 | "lint": "turbo lint --continue --", 13 | "lint:fix": "turbo lint --continue -- --apply", 14 | "clean": "turbo clean", 15 | "release": "turbo run release", 16 | "prepare": "husky install", 17 | "typecheck": "turbo typecheck" 18 | }, 19 | "dependencies": { 20 | "@changesets/changelog-github": "^0.5.0", 21 | "@changesets/cli": "^2.27.11", 22 | "turbo": "^2.3.3" 23 | }, 24 | "packageManager": "pnpm@9.5.0", 25 | "devDependencies": { 26 | "@biomejs/biome": "^1.9.4", 27 | "@commitlint/cli": "^19.6.1", 28 | "@commitlint/config-conventional": "^19.6.0", 29 | "husky": "^9.1.7", 30 | "postcss": "^8.5.1" 31 | }, 32 | "commitlint": { 33 | "extends": [ 34 | "@commitlint/config-conventional" 35 | ], 36 | "rules": { 37 | "type-enum": [ 38 | 2, 39 | "always", 40 | [ 41 | "build", 42 | "chore", 43 | "ci", 44 | "clean", 45 | "doc", 46 | "feat", 47 | "fix", 48 | "perf", 49 | "ref", 50 | "revert", 51 | "style", 52 | "test" 53 | ] 54 | ], 55 | "subject-case": [ 56 | 0, 57 | "always", 58 | "sentence-case" 59 | ], 60 | "body-leading-blank": [ 61 | 2, 62 | "always", 63 | true 64 | ], 65 | "body-max-line-length": [ 66 | 0, 67 | "always", 68 | 100 69 | ] 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /packages/headless/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../biome.json"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/headless/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "novel", 3 | "version": "1.0.0", 4 | "description": "Notion-style WYSIWYG editor with AI-powered autocompletions", 5 | "license": "Apache-2.0", 6 | "type": "module", 7 | "main": "dist/index.cjs", 8 | "module": "dist/index.js", 9 | "types": "dist/index.d.ts", 10 | "files": [ 11 | "dist" 12 | ], 13 | "scripts": { 14 | "dev": "tsup --watch", 15 | "typecheck": "tsc --noEmit", 16 | "build": "tsup", 17 | "lint": "biome lint ./src", 18 | "format": "biome format ./src " 19 | }, 20 | "sideEffects": false, 21 | "peerDependencies": { 22 | "react": ">=18" 23 | }, 24 | "dependencies": { 25 | "@radix-ui/react-slot": "^1.1.1", 26 | "@tiptap/core": "^2.11.2", 27 | "@tiptap/extension-character-count": "^2.11.2", 28 | "@tiptap/extension-code-block-lowlight": "^2.11.2", 29 | "@tiptap/extension-color": "^2.11.2", 30 | "@tiptap/extension-highlight": "^2.11.2", 31 | "@tiptap/extension-horizontal-rule": "^2.11.2", 32 | "@tiptap/extension-image": "^2.11.2", 33 | "@tiptap/extension-link": "^2.11.2", 34 | "@tiptap/extension-placeholder": "^2.11.2", 35 | "@tiptap/extension-task-item": "^2.11.2", 36 | "@tiptap/extension-task-list": "^2.11.2", 37 | "@tiptap/extension-text-style": "^2.11.2", 38 | "@tiptap/extension-underline": "^2.11.2", 39 | "@tiptap/extension-youtube": "^2.11.2", 40 | "@tiptap/pm": "^2.11.2", 41 | "@tiptap/react": "^2.11.2", 42 | "@tiptap/starter-kit": "^2.11.2", 43 | "@tiptap/suggestion": "^2.11.2", 44 | "@types/node": "^22.10.6", 45 | "cmdk": "^1.0.4", 46 | "jotai": "^2.11.0", 47 | "react-markdown": "^9.0.3", 48 | "react-moveable": "^0.56.0", 49 | "react-tweet": "^3.2.1", 50 | "katex": "^0.16.20", 51 | "tippy.js": "^6.3.7", 52 | "tiptap-extension-global-drag-handle": "^0.1.16", 53 | "tiptap-markdown": "^0.8.10", 54 | "tunnel-rat": "^0.1.2" 55 | }, 56 | "devDependencies": { 57 | "@biomejs/biome": "^1.9.4", 58 | "@types/katex": "^0.16.7", 59 | "@types/react": "^18.2.55", 60 | "@types/react-dom": "18.2.19", 61 | "tsconfig": "workspace:*", 62 | "tsup": "^8.3.5", 63 | "typescript": "^5.7.3" 64 | }, 65 | "author": "Steven Tey ", 66 | "homepage": "https://novel.sh", 67 | "repository": { 68 | "type": "git", 69 | "url": "git+https://github.com/steven-tey/novel.git" 70 | }, 71 | "bugs": { 72 | "url": "https://github.com/steven-tey/novel/issues" 73 | }, 74 | "keywords": [ 75 | "ai", 76 | "novel", 77 | "editor", 78 | "markdown", 79 | "nextjs", 80 | "react" 81 | ] 82 | } -------------------------------------------------------------------------------- /packages/headless/src/components/editor-bubble-item.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { useCurrentEditor } from "@tiptap/react"; 4 | import type { Editor } from "@tiptap/react"; 5 | import type { ComponentPropsWithoutRef, ReactNode } from "react"; 6 | 7 | interface EditorBubbleItemProps { 8 | readonly children: ReactNode; 9 | readonly asChild?: boolean; 10 | readonly onSelect?: (editor: Editor) => void; 11 | } 12 | 13 | export const EditorBubbleItem = forwardRef< 14 | HTMLDivElement, 15 | EditorBubbleItemProps & Omit, "onSelect"> 16 | >(({ children, asChild, onSelect, ...rest }, ref) => { 17 | const { editor } = useCurrentEditor(); 18 | const Comp = asChild ? Slot : "div"; 19 | 20 | if (!editor) return null; 21 | 22 | return ( 23 | onSelect?.(editor)}> 24 | {children} 25 | 26 | ); 27 | }); 28 | 29 | EditorBubbleItem.displayName = "EditorBubbleItem"; 30 | 31 | export default EditorBubbleItem; 32 | -------------------------------------------------------------------------------- /packages/headless/src/components/editor-bubble.tsx: -------------------------------------------------------------------------------- 1 | import { BubbleMenu, isNodeSelection, useCurrentEditor } from "@tiptap/react"; 2 | import type { BubbleMenuProps } from "@tiptap/react"; 3 | import { forwardRef, useEffect, useMemo, useRef } from "react"; 4 | import type { ReactNode } from "react"; 5 | import type { Instance, Props } from "tippy.js"; 6 | 7 | export interface EditorBubbleProps extends Omit { 8 | readonly children: ReactNode; 9 | } 10 | 11 | export const EditorBubble = forwardRef( 12 | ({ children, tippyOptions, ...rest }, ref) => { 13 | const { editor: currentEditor } = useCurrentEditor(); 14 | const instanceRef = useRef | null>(null); 15 | 16 | useEffect(() => { 17 | if (!instanceRef.current || !tippyOptions?.placement) return; 18 | 19 | instanceRef.current.setProps({ placement: tippyOptions.placement }); 20 | instanceRef.current.popperInstance?.update(); 21 | }, [tippyOptions?.placement]); 22 | 23 | const bubbleMenuProps: Omit = useMemo(() => { 24 | const shouldShow: BubbleMenuProps["shouldShow"] = ({ editor, state }) => { 25 | const { selection } = state; 26 | const { empty } = selection; 27 | 28 | // don't show bubble menu if: 29 | // - the editor is not editable 30 | // - the selected node is an image 31 | // - the selection is empty 32 | // - the selection is a node selection (for drag handles) 33 | if (!editor.isEditable || editor.isActive("image") || empty || isNodeSelection(selection)) { 34 | return false; 35 | } 36 | return true; 37 | }; 38 | 39 | return { 40 | shouldShow, 41 | tippyOptions: { 42 | onCreate: (val) => { 43 | instanceRef.current = val; 44 | 45 | instanceRef.current.popper.firstChild?.addEventListener("blur", (event) => { 46 | event.preventDefault(); 47 | event.stopImmediatePropagation(); 48 | }); 49 | }, 50 | moveTransition: "transform 0.15s ease-out", 51 | ...tippyOptions, 52 | }, 53 | editor: currentEditor, 54 | ...rest, 55 | }; 56 | }, [rest, tippyOptions]); 57 | 58 | if (!currentEditor) return null; 59 | 60 | return ( 61 | // We need to add this because of https://github.com/ueberdosis/tiptap/issues/2658 62 |
63 | {children} 64 |
65 | ); 66 | }, 67 | ); 68 | 69 | EditorBubble.displayName = "EditorBubble"; 70 | 71 | export default EditorBubble; 72 | -------------------------------------------------------------------------------- /packages/headless/src/components/editor-command-item.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | import { CommandEmpty, CommandItem } from "cmdk"; 3 | import { useCurrentEditor } from "@tiptap/react"; 4 | import { useAtomValue } from "jotai"; 5 | import { rangeAtom } from "../utils/atoms"; 6 | import type { ComponentPropsWithoutRef } from "react"; 7 | import type { Editor, Range } from "@tiptap/core"; 8 | 9 | interface EditorCommandItemProps { 10 | readonly onCommand: ({ 11 | editor, 12 | range, 13 | }: { 14 | editor: Editor; 15 | range: Range; 16 | }) => void; 17 | } 18 | 19 | export const EditorCommandItem = forwardRef< 20 | HTMLDivElement, 21 | EditorCommandItemProps & ComponentPropsWithoutRef 22 | >(({ children, onCommand, ...rest }, ref) => { 23 | const { editor } = useCurrentEditor(); 24 | const range = useAtomValue(rangeAtom); 25 | 26 | if (!editor || !range) return null; 27 | 28 | return ( 29 | onCommand({ editor, range })}> 30 | {children} 31 | 32 | ); 33 | }); 34 | 35 | EditorCommandItem.displayName = "EditorCommandItem"; 36 | 37 | export const EditorCommandEmpty = CommandEmpty; 38 | 39 | export default EditorCommandItem; 40 | -------------------------------------------------------------------------------- /packages/headless/src/components/editor-command.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom, useSetAtom } from "jotai"; 2 | import { useEffect, forwardRef, createContext } from "react"; 3 | import { Command } from "cmdk"; 4 | import { queryAtom, rangeAtom } from "../utils/atoms"; 5 | import { novelStore } from "../utils/store"; 6 | import type tunnel from "tunnel-rat"; 7 | import type { ComponentPropsWithoutRef, FC } from "react"; 8 | import type { Range } from "@tiptap/core"; 9 | 10 | export const EditorCommandTunnelContext = createContext({} as ReturnType); 11 | 12 | interface EditorCommandOutProps { 13 | readonly query: string; 14 | readonly range: Range; 15 | } 16 | 17 | export const EditorCommandOut: FC = ({ query, range }) => { 18 | const setQuery = useSetAtom(queryAtom, { store: novelStore }); 19 | const setRange = useSetAtom(rangeAtom, { store: novelStore }); 20 | 21 | useEffect(() => { 22 | setQuery(query); 23 | }, [query, setQuery]); 24 | 25 | useEffect(() => { 26 | setRange(range); 27 | }, [range, setRange]); 28 | 29 | useEffect(() => { 30 | const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; 31 | const onKeyDown = (e: KeyboardEvent) => { 32 | if (navigationKeys.includes(e.key)) { 33 | e.preventDefault(); 34 | const commandRef = document.querySelector("#slash-command"); 35 | 36 | if (commandRef) 37 | commandRef.dispatchEvent( 38 | new KeyboardEvent("keydown", { 39 | key: e.key, 40 | cancelable: true, 41 | bubbles: true, 42 | }), 43 | ); 44 | 45 | return false; 46 | } 47 | }; 48 | document.addEventListener("keydown", onKeyDown); 49 | return () => { 50 | document.removeEventListener("keydown", onKeyDown); 51 | }; 52 | }, []); 53 | 54 | return ( 55 | 56 | {(tunnelInstance) => } 57 | 58 | ); 59 | }; 60 | 61 | export const EditorCommand = forwardRef>( 62 | ({ children, className, ...rest }, ref) => { 63 | const [query, setQuery] = useAtom(queryAtom); 64 | 65 | return ( 66 | 67 | {(tunnelInstance) => ( 68 | 69 | { 72 | e.stopPropagation(); 73 | }} 74 | id="slash-command" 75 | className={className} 76 | {...rest} 77 | > 78 | 79 | {children} 80 | 81 | 82 | )} 83 | 84 | ); 85 | }, 86 | ); 87 | export const EditorCommandList = Command.List; 88 | 89 | EditorCommand.displayName = "EditorCommand"; 90 | -------------------------------------------------------------------------------- /packages/headless/src/components/editor.tsx: -------------------------------------------------------------------------------- 1 | import { EditorProvider } from "@tiptap/react"; 2 | import type { EditorProviderProps, JSONContent } from "@tiptap/react"; 3 | import { Provider } from "jotai"; 4 | import { forwardRef, useRef } from "react"; 5 | import type { FC, ReactNode } from "react"; 6 | import tunnel from "tunnel-rat"; 7 | import { novelStore } from "../utils/store"; 8 | import { EditorCommandTunnelContext } from "./editor-command"; 9 | 10 | export interface EditorProps { 11 | readonly children: ReactNode; 12 | readonly className?: string; 13 | } 14 | 15 | interface EditorRootProps { 16 | readonly children: ReactNode; 17 | } 18 | 19 | export const EditorRoot: FC = ({ children }) => { 20 | const tunnelInstance = useRef(tunnel()).current; 21 | 22 | return ( 23 | 24 | {children} 25 | 26 | ); 27 | }; 28 | 29 | export type EditorContentProps = Omit & { 30 | readonly children?: ReactNode; 31 | readonly className?: string; 32 | readonly initialContent?: JSONContent; 33 | }; 34 | 35 | export const EditorContent = forwardRef( 36 | ({ className, children, initialContent, ...rest }, ref) => ( 37 |
38 | 39 | {children} 40 | 41 |
42 | ), 43 | ); 44 | 45 | EditorContent.displayName = "EditorContent"; 46 | -------------------------------------------------------------------------------- /packages/headless/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { useCurrentEditor as useEditor } from "@tiptap/react"; 2 | export { type Editor as EditorInstance } from "@tiptap/core"; 3 | export type { JSONContent } from "@tiptap/react"; 4 | 5 | export { EditorRoot, EditorContent, type EditorContentProps } from "./editor"; 6 | export { EditorBubble } from "./editor-bubble"; 7 | export { EditorBubbleItem } from "./editor-bubble-item"; 8 | export { EditorCommand, EditorCommandList } from "./editor-command"; 9 | export { EditorCommandItem, EditorCommandEmpty } from "./editor-command-item"; 10 | -------------------------------------------------------------------------------- /packages/headless/src/extensions/ai-highlight.ts: -------------------------------------------------------------------------------- 1 | import { type Editor, Mark, markInputRule, markPasteRule, mergeAttributes } from "@tiptap/core"; 2 | 3 | export interface AIHighlightOptions { 4 | HTMLAttributes: Record; 5 | } 6 | 7 | declare module "@tiptap/core" { 8 | interface Commands { 9 | AIHighlight: { 10 | /** 11 | * Set a AIHighlight mark 12 | */ 13 | setAIHighlight: (attributes?: { color: string }) => ReturnType; 14 | /** 15 | * Toggle a AIHighlight mark 16 | */ 17 | toggleAIHighlight: (attributes?: { color: string }) => ReturnType; 18 | /** 19 | * Unset a AIHighlight mark 20 | */ 21 | unsetAIHighlight: () => ReturnType; 22 | }; 23 | } 24 | } 25 | 26 | export const inputRegex = /(?:^|\s)((?:==)((?:[^~=]+))(?:==))$/; 27 | export const pasteRegex = /(?:^|\s)((?:==)((?:[^~=]+))(?:==))/g; 28 | 29 | export const AIHighlight = Mark.create({ 30 | name: "ai-highlight", 31 | 32 | addOptions() { 33 | return { 34 | HTMLAttributes: {}, 35 | }; 36 | }, 37 | 38 | addAttributes() { 39 | return { 40 | color: { 41 | default: null, 42 | parseHTML: (element) => element.getAttribute("data-color") || element.style.backgroundColor, 43 | renderHTML: (attributes) => { 44 | if (!attributes.color) { 45 | return {}; 46 | } 47 | 48 | return { 49 | "data-color": attributes.color, 50 | style: `background-color: ${attributes.color}; color: inherit`, 51 | }; 52 | }, 53 | }, 54 | }; 55 | }, 56 | 57 | parseHTML() { 58 | return [ 59 | { 60 | tag: "mark", 61 | }, 62 | ]; 63 | }, 64 | 65 | renderHTML({ HTMLAttributes }) { 66 | return ["mark", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; 67 | }, 68 | 69 | addCommands() { 70 | return { 71 | setAIHighlight: 72 | (attributes) => 73 | ({ commands }) => { 74 | return commands.setMark(this.name, attributes); 75 | }, 76 | toggleAIHighlight: 77 | (attributes) => 78 | ({ commands }) => { 79 | return commands.toggleMark(this.name, attributes); 80 | }, 81 | unsetAIHighlight: 82 | () => 83 | ({ commands }) => { 84 | return commands.unsetMark(this.name); 85 | }, 86 | }; 87 | }, 88 | 89 | addKeyboardShortcuts() { 90 | return { 91 | "Mod-Shift-h": () => this.editor.commands.toggleAIHighlight(), 92 | }; 93 | }, 94 | 95 | addInputRules() { 96 | return [ 97 | markInputRule({ 98 | find: inputRegex, 99 | type: this.type, 100 | }), 101 | ]; 102 | }, 103 | 104 | addPasteRules() { 105 | return [ 106 | markPasteRule({ 107 | find: pasteRegex, 108 | type: this.type, 109 | }), 110 | ]; 111 | }, 112 | }); 113 | 114 | export const removeAIHighlight = (editor: Editor) => { 115 | const tr = editor.state.tr; 116 | tr.removeMark(0, editor.state.doc.nodeSize - 2, editor.state.schema.marks["ai-highlight"]); 117 | editor.view.dispatch(tr); 118 | }; 119 | export const addAIHighlight = (editor: Editor, color?: string) => { 120 | editor 121 | .chain() 122 | .setAIHighlight({ color: color ?? "#c1ecf970" }) 123 | .run(); 124 | }; 125 | -------------------------------------------------------------------------------- /packages/headless/src/extensions/custom-keymap.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/core"; 2 | 3 | declare module "@tiptap/core" { 4 | // eslint-disable-next-line no-unused-vars 5 | interface Commands { 6 | customkeymap: { 7 | /** 8 | * Select text between node boundaries 9 | */ 10 | selectTextWithinNodeBoundaries: () => ReturnType; 11 | }; 12 | } 13 | } 14 | 15 | const CustomKeymap = Extension.create({ 16 | name: "CustomKeymap", 17 | 18 | addCommands() { 19 | return { 20 | selectTextWithinNodeBoundaries: 21 | () => 22 | ({ editor, commands }) => { 23 | const { state } = editor; 24 | const { tr } = state; 25 | const startNodePos = tr.selection.$from.start(); 26 | const endNodePos = tr.selection.$to.end(); 27 | return commands.setTextSelection({ 28 | from: startNodePos, 29 | to: endNodePos, 30 | }); 31 | }, 32 | }; 33 | }, 34 | 35 | addKeyboardShortcuts() { 36 | return { 37 | "Mod-a": ({ editor }) => { 38 | const { state } = editor; 39 | const { tr } = state; 40 | const startSelectionPos = tr.selection.from; 41 | const endSelectionPos = tr.selection.to; 42 | const startNodePos = tr.selection.$from.start(); 43 | const endNodePos = tr.selection.$to.end(); 44 | const isCurrentTextSelectionNotExtendedToNodeBoundaries = 45 | startSelectionPos > startNodePos || endSelectionPos < endNodePos; 46 | if (isCurrentTextSelectionNotExtendedToNodeBoundaries) { 47 | editor.chain().selectTextWithinNodeBoundaries().run(); 48 | return true; 49 | } 50 | return false; 51 | }, 52 | }; 53 | }, 54 | }); 55 | 56 | export default CustomKeymap; 57 | -------------------------------------------------------------------------------- /packages/headless/src/extensions/image-resizer.tsx: -------------------------------------------------------------------------------- 1 | import { useCurrentEditor } from "@tiptap/react"; 2 | import type { FC } from "react"; 3 | import Moveable from "react-moveable"; 4 | 5 | export const ImageResizer: FC = () => { 6 | const { editor } = useCurrentEditor(); 7 | 8 | if (!editor?.isActive("image")) return null; 9 | 10 | const updateMediaSize = () => { 11 | const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement; 12 | if (imageInfo) { 13 | const selection = editor.state.selection; 14 | const setImage = editor.commands.setImage as (options: { 15 | src: string; 16 | width: number; 17 | height: number; 18 | }) => boolean; 19 | 20 | setImage({ 21 | src: imageInfo.src, 22 | width: Number(imageInfo.style.width.replace("px", "")), 23 | height: Number(imageInfo.style.height.replace("px", "")), 24 | }); 25 | editor.commands.setNodeSelection(selection.from); 26 | } 27 | }; 28 | 29 | return ( 30 | { 50 | if (delta[0]) target.style.width = `${width}px`; 51 | if (delta[1]) target.style.height = `${height}px`; 52 | }} 53 | // { target, isDrag, clientX, clientY }: any 54 | onResizeEnd={() => { 55 | updateMediaSize(); 56 | }} 57 | /* scalable */ 58 | /* Only one of resizable, scalable, warpable can be used. */ 59 | scalable={true} 60 | throttleScale={0} 61 | /* Set the direction of resizable */ 62 | renderDirections={["w", "e"]} 63 | onScale={({ 64 | target, 65 | // scale, 66 | // dist, 67 | // delta, 68 | transform, 69 | }) => { 70 | target.style.transform = transform; 71 | }} 72 | /> 73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /packages/headless/src/extensions/index.ts: -------------------------------------------------------------------------------- 1 | import { InputRule } from "@tiptap/core"; 2 | import { Color } from "@tiptap/extension-color"; 3 | import Highlight from "@tiptap/extension-highlight"; 4 | import HorizontalRule from "@tiptap/extension-horizontal-rule"; 5 | import TiptapImage from "@tiptap/extension-image"; 6 | import TiptapLink from "@tiptap/extension-link"; 7 | import Placeholder from "@tiptap/extension-placeholder"; 8 | import { TaskItem } from "@tiptap/extension-task-item"; 9 | import { TaskList } from "@tiptap/extension-task-list"; 10 | import TextStyle from "@tiptap/extension-text-style"; 11 | import TiptapUnderline from "@tiptap/extension-underline"; 12 | import StarterKit from "@tiptap/starter-kit"; 13 | import { Markdown } from "tiptap-markdown"; 14 | import CustomKeymap from "./custom-keymap"; 15 | import { ImageResizer } from "./image-resizer"; 16 | import { Twitter } from "./twitter"; 17 | import { Mathematics } from "./mathematics"; 18 | import UpdatedImage from "./updated-image"; 19 | 20 | import CharacterCount from "@tiptap/extension-character-count"; 21 | import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; 22 | import Youtube from "@tiptap/extension-youtube"; 23 | import GlobalDragHandle from "tiptap-extension-global-drag-handle"; 24 | 25 | const PlaceholderExtension = Placeholder.configure({ 26 | placeholder: ({ node }) => { 27 | if (node.type.name === "heading") { 28 | return `Heading ${node.attrs.level}`; 29 | } 30 | return "Press '/' for commands"; 31 | }, 32 | includeChildren: true, 33 | }); 34 | 35 | const HighlightExtension = Highlight.configure({ 36 | multicolor: true, 37 | }); 38 | 39 | const MarkdownExtension = Markdown.configure({ 40 | html: false, 41 | transformCopiedText: true, 42 | }); 43 | 44 | const Horizontal = HorizontalRule.extend({ 45 | addInputRules() { 46 | return [ 47 | new InputRule({ 48 | find: /^(?:---|—-|___\s|\*\*\*\s)$/u, 49 | handler: ({ state, range }) => { 50 | const attributes = {}; 51 | 52 | const { tr } = state; 53 | const start = range.from; 54 | const end = range.to; 55 | 56 | tr.insert(start - 1, this.type.create(attributes)).delete(tr.mapping.map(start), tr.mapping.map(end)); 57 | }, 58 | }), 59 | ]; 60 | }, 61 | }); 62 | 63 | export * from "./ai-highlight"; 64 | export * from "./slash-command"; 65 | export { 66 | CodeBlockLowlight, 67 | Horizontal as HorizontalRule, 68 | ImageResizer, 69 | InputRule, 70 | PlaceholderExtension as Placeholder, 71 | StarterKit, 72 | TaskItem, 73 | TaskList, 74 | TiptapImage, 75 | TiptapUnderline, 76 | MarkdownExtension, 77 | TextStyle, 78 | Color, 79 | HighlightExtension, 80 | CustomKeymap, 81 | TiptapLink, 82 | UpdatedImage, 83 | Youtube, 84 | Twitter, 85 | Mathematics, 86 | CharacterCount, 87 | GlobalDragHandle, 88 | }; 89 | -------------------------------------------------------------------------------- /packages/headless/src/extensions/mathematics.ts: -------------------------------------------------------------------------------- 1 | import { Node, mergeAttributes } from "@tiptap/core"; 2 | import { EditorState } from "@tiptap/pm/state"; 3 | import katex, { type KatexOptions } from "katex"; 4 | 5 | export interface MathematicsOptions { 6 | /** 7 | * By default LaTeX decorations can render when mathematical expressions are not inside a code block. 8 | * @param state - EditorState 9 | * @param pos - number 10 | * @returns boolean 11 | */ 12 | shouldRender: (state: EditorState, pos: number) => boolean; 13 | 14 | /** 15 | * @see https://katex.org/docs/options.html 16 | */ 17 | katexOptions?: KatexOptions; 18 | 19 | HTMLAttributes: Record; 20 | } 21 | 22 | declare module "@tiptap/core" { 23 | interface Commands { 24 | LatexCommand: { 25 | 26 | /** 27 | * Set selection to a LaTex symbol 28 | */ 29 | setLatex: ({ latex }: { latex: string }) => ReturnType; 30 | 31 | /** 32 | * Unset a LaTex symbol 33 | */ 34 | unsetLatex: () => ReturnType; 35 | 36 | }; 37 | } 38 | } 39 | 40 | /** 41 | * This extension adds support for mathematical symbols with LaTex expression. 42 | * 43 | * NOTE: Don't forget to import `katex/dist/katex.min.css` CSS for KaTex styling. 44 | * 45 | * @see https://katex.org/ 46 | */ 47 | export const Mathematics = Node.create({ 48 | name: "math", 49 | inline: true, 50 | group: "inline", 51 | atom: true, 52 | selectable: true, 53 | marks: "", 54 | 55 | addAttributes() { 56 | return { 57 | latex: "", 58 | }; 59 | }, 60 | 61 | addOptions() { 62 | return { 63 | shouldRender: (state, pos) => { 64 | const $pos = state.doc.resolve(pos); 65 | 66 | if (!$pos.parent.isTextblock) { 67 | return false; 68 | } 69 | 70 | return $pos.parent.type.name !== "codeBlock"; 71 | }, 72 | katexOptions: { 73 | throwOnError: false, 74 | }, 75 | HTMLAttributes: {}, 76 | }; 77 | }, 78 | 79 | addCommands() { 80 | return { 81 | setLatex: 82 | ({ latex }) => 83 | ({ chain, state }) => { 84 | if (!latex) { 85 | return false; 86 | } 87 | const { from, to, $anchor } = state.selection; 88 | 89 | if (!this.options.shouldRender(state, $anchor.pos)) { 90 | return false; 91 | } 92 | 93 | return chain() 94 | .insertContentAt( 95 | { from: from, to: to }, 96 | { 97 | type: "math", 98 | attrs: { 99 | latex: latex, 100 | }, 101 | } 102 | ) 103 | .setTextSelection({ from: from, to: from + 1 }) 104 | .run(); 105 | }, 106 | unsetLatex: 107 | () => 108 | ({ editor, state, chain }) => { 109 | const latex = editor.getAttributes(this.name).latex; 110 | if (typeof latex !== "string") { 111 | return false; 112 | } 113 | 114 | const { from, to } = state.selection; 115 | 116 | return chain() 117 | .command(({ tr }) => { 118 | tr.insertText(latex, from, to); 119 | return true; 120 | }) 121 | .setTextSelection({ 122 | from: from, 123 | to: from + latex.length, 124 | }) 125 | .run(); 126 | }, 127 | }; 128 | }, 129 | 130 | parseHTML() { 131 | return [{ tag: `span[data-type="${this.name}"]` }]; 132 | }, 133 | 134 | renderHTML({ node, HTMLAttributes }) { 135 | const latex = node.attrs["latex"] ?? ""; 136 | return [ 137 | "span", 138 | mergeAttributes(HTMLAttributes, { 139 | "data-type": this.name, 140 | }), 141 | latex, 142 | ]; 143 | }, 144 | 145 | renderText({ node }) { 146 | return node.attrs["latex"] ?? ""; 147 | }, 148 | 149 | addNodeView() { 150 | return ({ node, HTMLAttributes, getPos, editor }) => { 151 | const dom = document.createElement("span"); 152 | const latex: string = node.attrs["latex"] ?? ""; 153 | 154 | Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => { 155 | dom.setAttribute(key, value); 156 | }); 157 | 158 | Object.entries(HTMLAttributes).forEach(([key, value]) => { 159 | dom.setAttribute(key, value); 160 | }); 161 | 162 | dom.addEventListener("click", (evt) => { 163 | if (editor.isEditable && typeof getPos === "function") { 164 | const pos = getPos(); 165 | const nodeSize = node.nodeSize; 166 | editor.commands.setTextSelection({ from: pos, to: pos + nodeSize }); 167 | } 168 | }); 169 | 170 | dom.contentEditable = "false"; 171 | 172 | dom.innerHTML = katex.renderToString(latex, this.options.katexOptions); 173 | 174 | return { 175 | dom: dom, 176 | }; 177 | }; 178 | }, 179 | }); 180 | -------------------------------------------------------------------------------- /packages/headless/src/extensions/slash-command.tsx: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/core"; 2 | import type { Editor, Range } from "@tiptap/core"; 3 | import { ReactRenderer } from "@tiptap/react"; 4 | import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion"; 5 | import type { RefObject } from "react"; 6 | import type { ReactNode } from "react"; 7 | import tippy, { type GetReferenceClientRect, type Instance, type Props } from "tippy.js"; 8 | import { EditorCommandOut } from "../components/editor-command"; 9 | 10 | const Command = Extension.create({ 11 | name: "slash-command", 12 | addOptions() { 13 | return { 14 | suggestion: { 15 | char: "/", 16 | command: ({ editor, range, props }) => { 17 | props.command({ editor, range }); 18 | }, 19 | } as SuggestionOptions, 20 | }; 21 | }, 22 | addProseMirrorPlugins() { 23 | return [ 24 | Suggestion({ 25 | editor: this.editor, 26 | ...this.options.suggestion, 27 | }), 28 | ]; 29 | }, 30 | }); 31 | 32 | const renderItems = (elementRef?: RefObject | null) => { 33 | let component: ReactRenderer | null = null; 34 | let popup: Instance[] | null = null; 35 | 36 | return { 37 | onStart: (props: { editor: Editor; clientRect: DOMRect }) => { 38 | component = new ReactRenderer(EditorCommandOut, { 39 | props, 40 | editor: props.editor, 41 | }); 42 | 43 | const { selection } = props.editor.state; 44 | 45 | const parentNode = selection.$from.node(selection.$from.depth); 46 | const blockType = parentNode.type.name; 47 | 48 | if (blockType === "codeBlock") { 49 | return false; 50 | } 51 | 52 | // @ts-ignore 53 | popup = tippy("body", { 54 | getReferenceClientRect: props.clientRect, 55 | appendTo: () => (elementRef ? elementRef.current : document.body), 56 | content: component.element, 57 | showOnCreate: true, 58 | interactive: true, 59 | trigger: "manual", 60 | placement: "bottom-start", 61 | }); 62 | }, 63 | onUpdate: (props: { editor: Editor; clientRect: GetReferenceClientRect }) => { 64 | component?.updateProps(props); 65 | 66 | popup?.[0]?.setProps({ 67 | getReferenceClientRect: props.clientRect, 68 | }); 69 | }, 70 | 71 | onKeyDown: (props: { event: KeyboardEvent }) => { 72 | if (props.event.key === "Escape") { 73 | popup?.[0]?.hide(); 74 | 75 | return true; 76 | } 77 | 78 | // @ts-ignore 79 | return component?.ref?.onKeyDown(props); 80 | }, 81 | onExit: () => { 82 | popup?.[0]?.destroy(); 83 | component?.destroy(); 84 | }, 85 | }; 86 | }; 87 | 88 | export interface SuggestionItem { 89 | title: string; 90 | description: string; 91 | icon: ReactNode; 92 | searchTerms?: string[]; 93 | command?: (props: { editor: Editor; range: Range }) => void; 94 | } 95 | 96 | export const createSuggestionItems = (items: SuggestionItem[]) => items; 97 | 98 | export const handleCommandNavigation = (event: KeyboardEvent) => { 99 | if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { 100 | const slashCommand = document.querySelector("#slash-command"); 101 | if (slashCommand) { 102 | return true; 103 | } 104 | } 105 | }; 106 | 107 | export { Command, renderItems }; 108 | -------------------------------------------------------------------------------- /packages/headless/src/extensions/twitter.tsx: -------------------------------------------------------------------------------- 1 | import { Node, mergeAttributes, nodePasteRule } from "@tiptap/core"; 2 | import { NodeViewWrapper, ReactNodeViewRenderer, type ReactNodeViewRendererOptions } from "@tiptap/react"; 3 | import { Tweet } from "react-tweet"; 4 | export const TWITTER_REGEX_GLOBAL = /(https?:\/\/)?(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?/g; 5 | export const TWITTER_REGEX = /^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/; 6 | 7 | export const isValidTwitterUrl = (url: string) => { 8 | return url.match(TWITTER_REGEX); 9 | }; 10 | 11 | const TweetComponent = ({ node }: { node: Partial }) => { 12 | const url = (node?.attrs as Record)?.src; 13 | const tweetId = url?.split("/").pop(); 14 | 15 | if (!tweetId) { 16 | return null; 17 | } 18 | 19 | return ( 20 | 21 |
22 | 23 |
24 |
25 | ); 26 | }; 27 | 28 | export interface TwitterOptions { 29 | /** 30 | * Controls if the paste handler for tweets should be added. 31 | * @default true 32 | * @example false 33 | */ 34 | addPasteHandler: boolean; 35 | 36 | // biome-ignore lint/suspicious/noExplicitAny: 37 | HTMLAttributes: Record; 38 | 39 | /** 40 | * Controls if the twitter node should be inline or not. 41 | * @default false 42 | * @example true 43 | */ 44 | inline: boolean; 45 | 46 | /** 47 | * The origin of the tweet. 48 | * @default '' 49 | * @example 'https://tiptap.dev' 50 | */ 51 | origin: string; 52 | } 53 | 54 | /** 55 | * The options for setting a tweet. 56 | */ 57 | type SetTweetOptions = { src: string }; 58 | 59 | declare module "@tiptap/core" { 60 | interface Commands { 61 | twitter: { 62 | /** 63 | * Insert a tweet 64 | * @param options The tweet attributes 65 | * @example editor.commands.setTweet({ src: 'https://x.com/seanpk/status/1800145949580517852' }) 66 | */ 67 | setTweet: (options: SetTweetOptions) => ReturnType; 68 | }; 69 | } 70 | } 71 | 72 | /** 73 | * This extension adds support for tweets. 74 | */ 75 | export const Twitter = Node.create({ 76 | name: "twitter", 77 | 78 | addOptions() { 79 | return { 80 | addPasteHandler: true, 81 | HTMLAttributes: {}, 82 | inline: false, 83 | origin: "", 84 | }; 85 | }, 86 | 87 | addNodeView() { 88 | return ReactNodeViewRenderer(TweetComponent, { attrs: this.options.HTMLAttributes }); 89 | }, 90 | 91 | inline() { 92 | return this.options.inline; 93 | }, 94 | 95 | group() { 96 | return this.options.inline ? "inline" : "block"; 97 | }, 98 | 99 | draggable: true, 100 | 101 | addAttributes() { 102 | return { 103 | src: { 104 | default: null, 105 | }, 106 | }; 107 | }, 108 | 109 | parseHTML() { 110 | return [ 111 | { 112 | tag: "div[data-twitter]", 113 | }, 114 | ]; 115 | }, 116 | 117 | addCommands() { 118 | return { 119 | setTweet: 120 | (options: SetTweetOptions) => 121 | ({ commands }) => { 122 | if (!isValidTwitterUrl(options.src)) { 123 | return false; 124 | } 125 | 126 | return commands.insertContent({ 127 | type: this.name, 128 | attrs: options, 129 | }); 130 | }, 131 | }; 132 | }, 133 | 134 | addPasteRules() { 135 | if (!this.options.addPasteHandler) { 136 | return []; 137 | } 138 | 139 | return [ 140 | nodePasteRule({ 141 | find: TWITTER_REGEX_GLOBAL, 142 | type: this.type, 143 | getAttributes: (match) => { 144 | return { src: match.input }; 145 | }, 146 | }), 147 | ]; 148 | }, 149 | 150 | renderHTML({ HTMLAttributes }) { 151 | return ["div", mergeAttributes({ "data-twitter": "" }, HTMLAttributes)]; 152 | }, 153 | }); 154 | -------------------------------------------------------------------------------- /packages/headless/src/extensions/updated-image.ts: -------------------------------------------------------------------------------- 1 | import Image from "@tiptap/extension-image"; 2 | 3 | const UpdatedImage = Image.extend({ 4 | name: "image", 5 | addAttributes() { 6 | return { 7 | ...this.parent?.(), 8 | width: { 9 | default: null, 10 | }, 11 | height: { 12 | default: null, 13 | }, 14 | }; 15 | }, 16 | }); 17 | 18 | export default UpdatedImage; 19 | -------------------------------------------------------------------------------- /packages/headless/src/index.ts: -------------------------------------------------------------------------------- 1 | // Components 2 | export { 3 | EditorRoot, 4 | EditorContent, 5 | type EditorContentProps, 6 | EditorBubble, 7 | EditorBubbleItem, 8 | EditorCommand, 9 | EditorCommandList, 10 | EditorCommandItem, 11 | EditorCommandEmpty, 12 | useEditor, 13 | type EditorInstance, 14 | type JSONContent, 15 | } from "./components"; 16 | 17 | // Extensions 18 | export { 19 | AIHighlight, 20 | removeAIHighlight, 21 | addAIHighlight, 22 | CodeBlockLowlight, 23 | HorizontalRule, 24 | ImageResizer, 25 | InputRule, 26 | Placeholder, 27 | StarterKit, 28 | TaskItem, 29 | TaskList, 30 | TiptapImage, 31 | TiptapUnderline, 32 | MarkdownExtension, 33 | TextStyle, 34 | Color, 35 | HighlightExtension, 36 | CustomKeymap, 37 | TiptapLink, 38 | UpdatedImage, 39 | Youtube, 40 | Twitter, 41 | Mathematics, 42 | CharacterCount, 43 | GlobalDragHandle, 44 | Command, 45 | renderItems, 46 | createSuggestionItems, 47 | handleCommandNavigation, 48 | type SuggestionItem, 49 | } from "./extensions"; 50 | 51 | // Plugins 52 | export { 53 | UploadImagesPlugin, 54 | type UploadFn, 55 | type ImageUploadOptions, 56 | createImageUpload, 57 | handleImageDrop, 58 | handleImagePaste, 59 | } from "./plugins"; 60 | 61 | // Utils 62 | export { 63 | isValidUrl, 64 | getUrlFromString, 65 | getPrevText, 66 | getAllContent, 67 | } from "./utils"; 68 | 69 | // Store and Atoms 70 | export { queryAtom, rangeAtom } from "./utils/atoms"; 71 | -------------------------------------------------------------------------------- /packages/headless/src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | UploadImagesPlugin, 3 | type UploadFn, 4 | type ImageUploadOptions, 5 | createImageUpload, 6 | handleImageDrop, 7 | handleImagePaste, 8 | } from "./upload-images"; 9 | -------------------------------------------------------------------------------- /packages/headless/src/plugins/upload-images.tsx: -------------------------------------------------------------------------------- 1 | import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; 2 | import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; 3 | 4 | const uploadKey = new PluginKey("upload-image"); 5 | 6 | export const UploadImagesPlugin = ({ imageClass }: { imageClass: string }) => 7 | new Plugin({ 8 | key: uploadKey, 9 | state: { 10 | init() { 11 | return DecorationSet.empty; 12 | }, 13 | apply(tr, set) { 14 | set = set.map(tr.mapping, tr.doc); 15 | // See if the transaction adds or removes any placeholders 16 | //@ts-expect-error - not yet sure what the type I need here 17 | const action = tr.getMeta(this); 18 | if (action?.add) { 19 | const { id, pos, src } = action.add; 20 | 21 | const placeholder = document.createElement("div"); 22 | placeholder.setAttribute("class", "img-placeholder"); 23 | const image = document.createElement("img"); 24 | image.setAttribute("class", imageClass); 25 | image.src = src; 26 | placeholder.appendChild(image); 27 | const deco = Decoration.widget(pos + 1, placeholder, { 28 | id, 29 | }); 30 | set = set.add(tr.doc, [deco]); 31 | } else if (action?.remove) { 32 | // biome-ignore lint/suspicious/noDoubleEquals: 33 | set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id)); 34 | } 35 | return set; 36 | }, 37 | }, 38 | props: { 39 | decorations(state) { 40 | return this.getState(state); 41 | }, 42 | }, 43 | }); 44 | 45 | // biome-ignore lint/complexity/noBannedTypes: 46 | function findPlaceholder(state: EditorState, id: {}) { 47 | const decos = uploadKey.getState(state) as DecorationSet; 48 | // biome-ignore lint/suspicious/noDoubleEquals: 49 | const found = decos.find(undefined, undefined, (spec) => spec.id == id); 50 | return found.length ? found[0]?.from : null; 51 | } 52 | 53 | export interface ImageUploadOptions { 54 | validateFn?: (file: File) => void; 55 | onUpload: (file: File) => Promise; 56 | } 57 | 58 | export const createImageUpload = 59 | ({ validateFn, onUpload }: ImageUploadOptions): UploadFn => 60 | (file, view, pos) => { 61 | // check if the file is an image 62 | const validated = validateFn?.(file); 63 | if (!validated) return; 64 | // A fresh object to act as the ID for this upload 65 | const id = {}; 66 | 67 | // Replace the selection with a placeholder 68 | const tr = view.state.tr; 69 | if (!tr.selection.empty) tr.deleteSelection(); 70 | 71 | const reader = new FileReader(); 72 | reader.readAsDataURL(file); 73 | reader.onload = () => { 74 | tr.setMeta(uploadKey, { 75 | add: { 76 | id, 77 | pos, 78 | src: reader.result, 79 | }, 80 | }); 81 | view.dispatch(tr); 82 | }; 83 | 84 | onUpload(file).then((src) => { 85 | const { schema } = view.state; 86 | 87 | const pos = findPlaceholder(view.state, id); 88 | 89 | // If the content around the placeholder has been deleted, drop 90 | // the image 91 | if (pos == null) return; 92 | 93 | // Otherwise, insert it at the placeholder's position, and remove 94 | // the placeholder 95 | 96 | // When BLOB_READ_WRITE_TOKEN is not valid or unavailable, read 97 | // the image locally 98 | const imageSrc = typeof src === "object" ? reader.result : src; 99 | 100 | const node = schema.nodes.image?.create({ src: imageSrc }); 101 | if (!node) return; 102 | 103 | const transaction = view.state.tr.replaceWith(pos, pos, node).setMeta(uploadKey, { remove: { id } }); 104 | view.dispatch(transaction); 105 | }, () => { 106 | // Deletes the image placeholder on error 107 | const transaction = view.state.tr 108 | .delete(pos, pos) 109 | .setMeta(uploadKey, { remove: { id } }); 110 | view.dispatch(transaction); 111 | }); 112 | }; 113 | 114 | export type UploadFn = (file: File, view: EditorView, pos: number) => void; 115 | 116 | export const handleImagePaste = (view: EditorView, event: ClipboardEvent, uploadFn: UploadFn) => { 117 | if (event.clipboardData?.files.length) { 118 | event.preventDefault(); 119 | const [file] = Array.from(event.clipboardData.files); 120 | const pos = view.state.selection.from; 121 | 122 | if (file) uploadFn(file, view, pos); 123 | return true; 124 | } 125 | return false; 126 | }; 127 | 128 | export const handleImageDrop = (view: EditorView, event: DragEvent, moved: boolean, uploadFn: UploadFn) => { 129 | if (!moved && event.dataTransfer?.files.length) { 130 | event.preventDefault(); 131 | const [file] = Array.from(event.dataTransfer.files); 132 | const coordinates = view.posAtCoords({ 133 | left: event.clientX, 134 | top: event.clientY, 135 | }); 136 | // here we deduct 1 from the pos or else the image will create an extra node 137 | if (file) uploadFn(file, view, coordinates?.pos ?? 0 - 1); 138 | return true; 139 | } 140 | return false; 141 | }; 142 | -------------------------------------------------------------------------------- /packages/headless/src/utils/atoms.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import type { Range } from "@tiptap/core"; 3 | 4 | export const queryAtom = atom(""); 5 | export const rangeAtom = atom(null); 6 | -------------------------------------------------------------------------------- /packages/headless/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { Fragment, type Node } from "@tiptap/pm/model"; 2 | import type { EditorInstance } from "../components"; 3 | 4 | export function isValidUrl(url: string) { 5 | try { 6 | new URL(url); 7 | return true; 8 | } catch (_e) { 9 | return false; 10 | } 11 | } 12 | 13 | export function getUrlFromString(str: string) { 14 | if (isValidUrl(str)) return str; 15 | try { 16 | if (str.includes(".") && !str.includes(" ")) { 17 | return new URL(`https://${str}`).toString(); 18 | } 19 | } catch (_e) { 20 | return null; 21 | } 22 | } 23 | 24 | // Get the text before a given position in markdown format 25 | export const getPrevText = (editor: EditorInstance, position: number) => { 26 | const nodes: Node[] = []; 27 | editor.state.doc.forEach((node, pos) => { 28 | if (pos >= position) return false; 29 | nodes.push(node); 30 | return true; 31 | }); 32 | const fragment = Fragment.fromArray(nodes); 33 | const doc = editor.state.doc.copy(fragment); 34 | 35 | return editor.storage.markdown.serializer.serialize(doc) as string; 36 | }; 37 | 38 | // Get all content from the editor in markdown format 39 | export const getAllContent = (editor: EditorInstance) => { 40 | const fragment = editor.state.doc.content; 41 | const doc = editor.state.doc.copy(fragment); 42 | 43 | return editor.storage.markdown.serializer.serialize(doc) as string; 44 | }; 45 | -------------------------------------------------------------------------------- /packages/headless/src/utils/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from "jotai"; 2 | 3 | // biome-ignore lint/suspicious/noExplicitAny: 4 | export const novelStore: any = createStore(); 5 | export * from "jotai"; 6 | -------------------------------------------------------------------------------- /packages/headless/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/react.json", 3 | "include": ["./src/**/*"], 4 | "exclude": ["dist", "build", "node_modules"], 5 | "compilerOptions": { 6 | "declarationMap": false, 7 | "outDir": "dist" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/headless/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, Options } from "tsup"; 2 | 3 | export default defineConfig((options: Options) => ({ 4 | entry: ["src/index.ts"], 5 | banner: { 6 | js: "'use client'", 7 | }, 8 | minify: true, 9 | format: ["cjs", "esm"], 10 | dts: true, 11 | clean: true, 12 | external: ["react", "react-dom"], 13 | ...options, 14 | })); 15 | -------------------------------------------------------------------------------- /packages/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "strict": true, 5 | "noUncheckedIndexedAccess": true, 6 | "alwaysStrict": false, 7 | "module": "ESNext", 8 | "moduleResolution": "Bundler", 9 | "resolveJsonModule": true, 10 | "target": "ESNext", 11 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 12 | "noEmit": true, 13 | "declaration": true, 14 | "declarationMap": true, 15 | "verbatimModuleSyntax": true, 16 | "moduleDetection": "force", 17 | 18 | "downlevelIteration": true, 19 | "allowJs": true, 20 | "isolatedModules": true, 21 | "esModuleInterop": true, 22 | "forceConsistentCasingInFileNames": true, 23 | "skipLibCheck": true, 24 | "skipDefaultLibCheck": true, 25 | "incremental": true, 26 | "tsBuildInfoFile": ".tsbuildinfo" 27 | }, 28 | "include": ["**/*.ts", "**/*.tsx"], 29 | "exclude": ["node_modules", "src/tests"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/tsconfig/next.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Next.js", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "plugins": [{ "name": "next" }], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve" 17 | }, 18 | "include": ["src", "next-env.d.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "publishConfig": { 7 | "access": "public" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/tsconfig/react.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "lib": ["DOM"], 7 | "target": "ESNext", 8 | "jsx": "react-jsx" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "packages/*" -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: true, 3 | semi: true, 4 | trailingComma: "all", 5 | printWidth: 80, 6 | tabWidth: 2, 7 | }; 8 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": [ 4 | "**/.env.*local" 5 | ], 6 | "tasks": { 7 | "topo": { 8 | "dependsOn": [ 9 | "^topo" 10 | ] 11 | }, 12 | "build": { 13 | "dependsOn": [ 14 | "^build", 15 | "typecheck" 16 | ], 17 | "outputs": [ 18 | "dist/**", 19 | ".next/**", 20 | "!.next/cache/**" 21 | ] 22 | }, 23 | "typecheck": { 24 | "dependsOn": [ 25 | "^topo" 26 | ], 27 | "outputs": [] 28 | }, 29 | "lint": { 30 | "dependsOn": [ 31 | "^topo" 32 | ] 33 | }, 34 | "format": { 35 | "dependsOn": [ 36 | "^topo" 37 | ] 38 | }, 39 | "lint:fix": { 40 | "dependsOn": [ 41 | "^topo" 42 | ] 43 | }, 44 | "format:fix": { 45 | "dependsOn": [ 46 | "^topo" 47 | ] 48 | }, 49 | "check-types": {}, 50 | "dev": { 51 | "cache": false, 52 | "persistent": true 53 | }, 54 | "clean": { 55 | "cache": false 56 | }, 57 | "release": { 58 | "cache": false 59 | } 60 | } 61 | } --------------------------------------------------------------------------------