├── .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 |
3 | Novel
4 |
5 |
6 |
7 | An open-source Notion-style WYSIWYG editor with AI-powered autocompletions.
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
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 | [](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 | 
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 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | Usage in dialog
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
Documentation
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 | {
80 | if (completion)
81 | return complete(completion, {
82 | body: { option: "zap", command: inputValue },
83 | }).then(() => setInputValue(""));
84 |
85 | const slice = editor.state.selection.content();
86 | const text = editor.storage.markdown.serializer.serialize(slice.content);
87 |
88 | complete(text, {
89 | body: { option: "zap", command: inputValue },
90 | }).then(() => setInputValue(""));
91 | }}
92 | >
93 |
94 |
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 | onOpenChange(true)}
36 | size="sm"
37 | >
38 |
39 | Ask AI
40 |
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 |
106 |
113 | A
114 |
115 |
116 |
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 |
46 | ↗
47 |
52 | Link
53 |
54 |
55 |
56 |
57 |
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 | {
17 | if (editor.isActive("math")) {
18 | editor.chain().focus().unsetLatex().run();
19 | } else {
20 | const { from, to } = editor.state.selection;
21 | const latex = editor.state.doc.textBetween(from, to);
22 |
23 | if (!latex) return;
24 |
25 | editor.chain().focus().setLatex({ latex }).run();
26 | }
27 | }}
28 | >
29 |
33 |
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 |
101 | {activeItem.name}
102 |
103 |
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 |
52 |
57 |
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 |
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 |
10 |
14 |
18 |
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 |
45 |
46 |
47 |
48 |
49 | {/*
50 |
Font
51 | {fonts.map(({ font, icon }) => (
52 |
{
56 | setFont(font);
57 | }}
58 | >
59 |
60 |
61 | {icon}
62 |
63 |
{font}
64 |
65 | {currentFont === font && }
66 |
67 | ))}
68 |
*/}
69 | Appearance
70 | {appearances.map(({ theme, icon }) => (
71 | {
76 | setTheme(theme.toLowerCase());
77 | }}
78 | >
79 |
80 |
{icon}
81 |
{theme}
82 |
83 | {currentTheme === theme.toLowerCase() && }
84 |
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 | }
--------------------------------------------------------------------------------